はじめに
この MOD を作ったきっかけは、子どもの一言でした。
Youtubeを観ていた子供の「Minecraft で乗り物に乗りたい」という要望に応えようと既存の MOD を探したのですが、子どもでも直感的に操作できて、導入も手軽なものが見当たりませんでした。それなら自分で作ろう、というのが SkyHopper の出発点です。
Minecraft のゲームエンジンは、実は Java 製の本格的なソフトウェアです。Forge MOD は「Minecraft の上で動くアプリ開発」であり、Java の知識がそのまま活きる場所です。
この記事では、筆者が趣味で開発した「SkyHopper」MOD(子ども向けの乗り物 MOD)を題材に、Forge MOD 開発で避けて通れない3つの設計課題を解説します。
- エンティティの継承設計:複数の乗り物の挙動をどう整理するか
- クライアント/サーバー間の通信:プレイヤーの入力をどうサーバーに届けるか
- 入力の横取り:ゲームパッドとキーボードを同時に扱い、バニラ操作と衝突させないか
Java は書けるけど Forge は未経験、という方を対象にしています。
Forge の基礎:普通の Java アプリと何が違うか
イベント駆動モデル
Forge での処理の起点はほぼすべて「イベント」です。@SubscribeEvent アノテーションを付けたメソッドを Forge のイベントバスに登録すると、対応するタイミングで自動的に呼ばれます。
@SubscribeEvent
public static void onClientTick(TickEvent.ClientTickEvent event) {
// 毎 tick(1秒=20回)呼ばれる
}
GUI ライブラリのリスナーに近い感覚で理解できます。
クライアントとサーバーの二重世界
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() だけをオーバーライドすれば済む、という拡張性が生まれています。
ネットワーク設計:クライアント→サーバーのパケット通信
なぜパケットが必要か
入力はクライアントにしか存在しません。エンティティの移動計算はサーバーで行います。この gap を埋めるのがカスタムパケットです。
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 で直接ゲームパッド状態を読んでいます。
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]
}
アナログトリガーは生値が [-1.0, 1.0] で返ってくるため、0.0?1.0 に正規化してからしきい値(0.55f)と比較しています。
エッジ検出で「変化時のみ」送信する
上昇・下降・攻撃のような継続入力は、状態が変わった時だけパケットを送信します。
// 前回送信した値を保持
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;
}
毎 tick 無条件に送ると 20 パケット/秒が流れ続けます。状態変化時のみ送ることで、移動していない間は送信ゼロになります。
【補足】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 で送信)+エッジ検出で帯域最適化 |
Java の継承・イベントリスナー・シリアライズといった知識がそのまま使えると実感できる領域です。「ゲームを題材にしたソフトウェア設計の練習」として、Forge MOD 開発はかなり面白い選択肢だと思います。
開発環境:Minecraft 1.20.1 / Forge 47.2.0 / Java 17