Insights

技術情報

Minecraft Forge MOD で学ぶ:乗り物システムの C/S アーキテクチャと入力同期設計

はじめに

この MOD(SkyHopper)を作ったきっかけは、子どもの一言でした。YouTube を観ていた子どもの「Minecraft で乗り物に乗りたい」という要望に応えようと既存の MOD を探したのですが、子どもでも直感的に操作できて、導入も手軽なものが見当たりませんでした。それなら自分で作ろうと考えたのが出発点です。

Minecraft のゲームエンジンは Java 製のソフトウェアです。Forge MOD は Minecraft の上で動くアプリケーション開発であり、Java の知識がそのまま活きます。

この記事では、趣味で開発した子ども向けの乗り物 MOD「SkyHopper」を題材に、Forge MOD 開発で避けて通れない3つの設計課題を解説します。Java は書けるものの Forge は未経験という方を対象としています。

  • エンティティの継承設計:複数の乗り物の挙動をどう整理するか
  • クライアント/サーバー間の通信:プレイヤーの入力をどうサーバーに届けるか
  • 入力の横取り:ゲームパッドとキーボードを同時に扱い、バニラ操作とどう衝突させないか

Forge の基礎:普通の Java アプリと何が違うか

イベント駆動モデル

Forge での処理の起点は、ほぼすべてイベントです。@SubscribeEvent アノテーションを付けたメソッドを Forge のイベントバスに登録すると、対応するタイミングで自動的に呼ばれます。GUI ライブラリのリスナーに近い感覚で理解できます。

@SubscribeEvent
public static void onClientTick(TickEvent.ClientTickEvent event) {
    // 毎 tick(1秒=20回)呼ばれる
}

クライアントとサーバーの二重世界

Minecraft は同一プロセス内に「クライアント(描画・入力)」と「サーバー(ゲームロジック)」が共存しています。シングルプレイでも構造は同じです。

入力検知はクライアント側でしか行えません。 プレイヤーがキーを押した事実は、そのままではサーバーに届きません。キーの状態をサーバーに伝えるには、ネットワークパケットを自分で設計・送信する必要があります。

[クライアント]           [サーバー]
 キー押下を検知    →   パケット送信   →   エンティティの状態更新
 (ClientInputHandler)  (VehicleInputPacket)  (SkyHopperEntity#tick)

tick(ゲームループ)

Minecraft のゲームループは 1 秒間に 20 回(20 tick)動作します。移動計算、当たり判定、パケット送受信はすべてこの tick の中で処理されます。


エンティティ設計:テンプレートメソッドパターンで乗り物を拡張する

基底クラスで共通処理を持つ

SkyHopper MOD には複数の乗り物があります。

乗り物移動方式
SkyHopper(スピーダー)WASD 手動操作、平面移動
XWing常時自動前進、視点方向に3D移動
Dropship自動前進、2人乗り
Cube手動前進のみ、横移動あり

それぞれ挙動は異なりますが、搭乗・降機、重力無効、パケット受信、当たり判定は共通です。これらの共通処理を基底クラスの SkyHopperEntity に実装し、速度計算だけをサブクラスに委譲するテンプレートメソッドパターンで整理しました。

computeVelocityDelta のオーバーライド

基底クラスの applyMovement() 内で、computeVelocityDelta() を呼び出します。

SkyHopperEntity(基底):WASD 平面移動

// entity/SkyHopperEntity.java
protected double[] computeVelocityDelta(Player player) {
    float forward = player.zza;  // 前進: 正, 後退: 負
    float strafe  = player.xxa;  // 左: 正, 右: 負

    float yawRad = (float) Math.toRadians(this.getYRot());
    double dvx = (-Math.sin(yawRad) * forward + Math.cos(yawRad) * strafe) * MOVE_SPEED;
    double dvz = ( Math.cos(yawRad) * forward + Math.sin(yawRad) * strafe) * MOVE_SPEED;
    return new double[]{dvx, 0.0, dvz};  // Y方向の増分は 0(平面移動)
}

XWingEntity(サブクラス):視点方向への3D移動

// entity/XWingEntity.java
@Override
protected double[] computeVelocityDelta(Player player) {
    float yawRad   = (float) Math.toRadians(this.getYRot());
    float pitchRad = (float) Math.toRadians(player.getXRot());

    float speed = PATTERN2_BASE_SPEED;  // 常時巡航速度で自動前進

    double dvx = -Math.sin(yawRad) * Math.cos(pitchRad) * speed;
    double dvy = -Math.sin(pitchRad) * speed;  // 視点の上下で高度が変わる
    double dvz =  Math.cos(yawRad)  * Math.cos(pitchRad) * speed;
    return new double[]{dvx, dvy, dvz};
}

戻り値の型は単純な double[] ですが、[dvx, dvy, dvz] の形式で返す規約にすることで、基底クラス側の物理計算(ホバー減衰、着地補助、衝突補正)をすべて共通化できています。新しい乗り物を追加するときも、computeVelocityDelta() だけをオーバーライドすれば済みます。


ネットワーク設計:クライアント→サーバーのパケット通信

なぜパケットが必要か

入力はクライアントにしか存在しない一方、エンティティの移動計算はサーバーで行います。この隔たりを埋めるのがカスタムパケットです。

encode/decode の対称設計

VehicleInputPacket は、現時点での入力状態を 10 個の boolean としてシリアライズします。

// network/VehicleInputPacket.java

public static void encode(VehicleInputPacket pkt, FriendlyByteBuf buf) {
    buf.writeBoolean(pkt.ascending);          // 上昇(X ボタン)
    buf.writeBoolean(pkt.descending);         // 下降(A ボタン)
    buf.writeBoolean(pkt.attacking);          // 攻撃(RT)
    buf.writeBoolean(pkt.decelerating);       // 減速/前進補助(LB)
    buf.writeBoolean(pkt.accelerating);       // 加速/後退補助(RB)
    buf.writeBoolean(pkt.lightToggle);        // ライト切替(B・ワンショット)
    buf.writeBoolean(pkt.fireMissile);        // ミサイル発射(ワンショット)
    buf.writeBoolean(pkt.drillAction);        // 掘削(LT・ワンショット)
    buf.writeBoolean(pkt.drillModeToggle);
    buf.writeBoolean(pkt.continuousDrillToggle);
}

public static VehicleInputPacket decode(FriendlyByteBuf buf) {
    return new VehicleInputPacket(
        buf.readBoolean(), buf.readBoolean(), buf.readBoolean(),
        buf.readBoolean(), buf.readBoolean(), buf.readBoolean(),
        buf.readBoolean(), buf.readBoolean(), buf.readBoolean(),
        buf.readBoolean()
    );
}

encode と decode では、書き込み順と読み出し順を必ず一致させます。順序が崩れると全フィールドがずれるため、設計上とくに注意すべき箇所です。

継続入力とワンショット入力の分離

サーバー側ハンドラでは、入力の性質に応じて処理を分岐させています。同じパケットに両方の入力を同梱することで、パケット種別を最小化しました。

// handle() の抜粋
public static void handle(VehicleInputPacket pkt, Supplier<NetworkEvent.Context> ctx) {
    ctx.get().enqueueWork(() -> {
        // ワンショット(1回だけ実行)
        if (pkt.lightToggle) {
            hopper.handleLightToggle(player);
            return;  // 以降の継続入力処理をスキップ
        }
        if (pkt.fireMissile) {
            hopper.handleMissileFire(player);
            return;
        }

        // 継続入力(毎 tick の移動状態に反映)
        hopper.setInputState(player,
            pkt.ascending, pkt.descending, pkt.attacking, ...);
    });
}

ライト切替のようなワンショットアクションは、処理後に return して以降の継続入力処理をスキップします。上昇中かどうかといった継続状態は setInputState() でエンティティのフィールドに保持し、毎 tick の applyMovement() から参照します。


入力ハンドリング:tick フェーズ分割とゲームパッド対応

なぜ tick を2フェーズに分けるか

Forge の ClientTickEvent には Phase.START と Phase.END があります。バニラ Minecraft の内部キー処理(handleKeybinds())は、Phase.START と Phase.END のあいだ(tick 本体の処理中)で動作します。そのため、それぞれのフェーズを次のように使い分けます。

  • Phase.START:バニラがキーを処理する前に割り込む場所
  • Phase.END:バニラ処理が終わった後に安全に送信する場所
// client/ClientInputHandler.java

@SubscribeEvent
public static void onClientTick(TickEvent.ClientTickEvent event) {

    // --- Phase.START: バニラより先に不要キーを食いつぶす ---
    if (event.phase == TickEvent.Phase.START) {
        if (isRidingSkyHopper && !isGuiOpen) {
            consumeAndDisable(mc.options.keyDrop);        // Q キーを無効化
            consumeAndDisable(mc.options.keySwapOffhand); // F キーを無効化
            for (var slot : mc.options.keyHotbarSlots) {
                consumeAndDisable(slot);                  // 1〜9 を無効化
            }
        }
    }

    // --- Phase.END: 入力状態を確定してパケット送信 ---
    if (event.phase == TickEvent.Phase.END) {
        boolean ascending  = SkyHopperKeyBindings.KEY_ASCEND.isDown() || gamepad.x;
        boolean descending = SkyHopperKeyBindings.KEY_DESCEND.isDown() || gamepad.a;
        // ...
    }
}

ゲームパッドは GLFW から直接読む

ゲームパッドの状態は、Controllable のような外部 MOD に依存せず、LWJGL の GLFW API から直接読み取っています。アナログトリガーの生値は [-1.0, 1.0] で返ってくるため、[0.0, 1.0] に正規化してからしきい値(0.55f)と比較しています。

private static GamepadStateSnapshot readGamepadState() {
    GLFWGamepadState state = GLFWGamepadState.create();
    for (int jid = GLFW.GLFW_JOYSTICK_1; jid <= GLFW.GLFW_JOYSTICK_LAST; jid++) {
        if (!GLFW.glfwJoystickIsGamepad(jid)) continue;
        if (!GLFW.glfwGetGamepadState(jid, state)) continue;

        // トリガーは -1.0〜1.0 を 0.0〜1.0 に正規化する
        lt = normalizeTriggerAxis(state.axes(GLFW.GLFW_GAMEPAD_AXIS_LEFT_TRIGGER));
    }
}

private static float normalizeTriggerAxis(float axis) {
    return (axis + 1.0f) * 0.5f; // [-1, 1] → [0, 1]
}

エッジ検出で「変化時のみ」送信する

上昇、下降、攻撃のような継続入力は、状態が変わった時だけパケットを送信します。毎 tick 無条件に送ると 20 パケット/秒が流れ続けますが、状態変化時のみ送ることで、移動していない間の送信をゼロに抑えられます。

// 前回送信した値を保持
private static boolean prevAscending  = false;
private static boolean prevDescending = false;
private static boolean prevAttacking  = false;

// Phase.END 内
if (ascending != prevAscending || descending != prevDescending || attacking != prevAttacking) {
    ModNetwork.CHANNEL.sendToServer(new VehicleInputPacket(ascending, descending, attacking, ...));
    prevAscending  = ascending;
    prevDescending = descending;
    prevAttacking  = attacking;
}

【補足】HUD カスタマイズ:イベントでオーバーレイを制御する

搭乗中は、コントローラーの LB/RB がバニラのホットバー操作と誤判定され、ホットバーに触れていないのにアイテム名ポップアップが出ることがあります。この表示は RenderGuiOverlayEvent で抑止しています。

// client/ClientInputHandler.java

@SubscribeEvent
public static void onRenderGuiOverlay(RenderGuiOverlayEvent.Pre event) {
    Minecraft mc = Minecraft.getInstance();
    Player player = mc.player;
    if (player != null && player.getVehicle() instanceof SkyHopperEntity) {
        if (event.getOverlay() == VanillaGuiOverlay.ITEM_NAME.type()) {
            event.setCanceled(true);  // アイテム名ポップアップを非表示
        }
    }
}

Forge の RenderGuiOverlayEvent では、HUD の各要素(ホットバー、クロスヘア、体力、アイテム名など)を VanillaGuiOverlay 定数で個別に識別でき、event.setCanceled(true) の1行で描画をキャンセルできます。

同様に、搭乗中の一人称視点における手や持ち物の描画も RenderHandEvent でキャンセルし、視界がブロックに埋まったときの暗転も RenderBlockScreenEffectEvent で抑止しています。描画の差し込みやキャンセルをコード1行で制御できるのが、Forge のイベントシステムの利点です。


まとめ

SkyHopper MOD の開発で実践した3つの設計パターンを整理します。

課題解決策
複数の乗り物の挙動の違いを管理したいテンプレートメソッドパターン(computeVelocityDelta のオーバーライド)
クライアントの入力をサーバーに届けたいカスタムパケット(encode/decode 対称設計)+継続入力とワンショットの分離
バニラ操作と衝突させたくない(入力の横取り)tick フェーズ分割(START で横取り、END で送信)+エッジ検出で帯域最適化

Forge MOD 開発では、Java の継承、イベントリスナー、シリアライズといった知識がそのまま使えます。ゲームを題材にソフトウェア設計を実践する場としても適しています。


開発環境:Minecraft 1.20.1 / Forge 47.2.0 / Java 17

お問い合わせ・お見積もりはお気軽に

内容に応じたお見積もりも無料で行います。