[Astro #85] R3F WITManager リファクタリング & ハードコード地雷除去で2面完成

[Astro #85] R3F WITManager リファクタリング & ハードコード地雷除去で2面完成

はじめに

10日間続けてきた React Three Fiber 製の横スクロールシューティング “WIT” の開発が、ついに完成しました。

今日のセッションでは以下を一気に片付けました。

  • 巨大化した WITManager.tsx のリファクタリング
  • 過去のAI生成コードが埋め込んだ技術的負債の清算
  • Stage2 の朝焼け背景テーマ追加
  • 最終ステージからエンディングへの遷移
  • エンディングの自動送り実装

1. WITManager.tsx 2458行の地獄

リファクタリング前の惨状

開発開始から10日、機能追加を繰り返した結果、ゲームの心臓部である WITManager.tsx は 2458行 に膨れ上がっていました。

内訳を整理すると、こうなっていました。

ブロック行数内容
useState / useRef 宣言約200行ゲーム状態の管理変数群
useEffect(初期化・キー入力等)約100行副作用処理
useFrame(メインループ)約1346行物理演算・衝突判定・敵AI・ステージ演出
JSX(3D描画)約540行Three.jsシーンの構築
GameOverInputHandler約40行ゲームオーバー画面の入力処理

useFrame だけで1346行。これはReactコンポーネントの render に相当する部分が1300行超えているということです。1つの関数がこれだけ長いと、スクロールするだけで目的の場所にたどり着けません。

useFrame の中身の責務マップ

useFrame 内部をブロック単位で分解すると、以下の21セクションがフラットに並んでいました。

#責務行数
AVR入力読み取り&stageGroup配置約35行
B緞帳カーテン演出tick約18行
Cタイトル→VR選択でステージ突入約13行
Dゲームオーバー進行(タイマー&VR復帰)約25行
E無敵タイマー&プレイヤー点滅約10行
F-Gプレイヤー位置履歴&オプション位置算出約22行
H地形スクロール約55行
I自弾移動&地形当たり判定約22行
J敵弾移動(追尾ミサイル含む)約90行
Kタイムラインスポーン(敵生成)約180行
L敵パターン更新&射撃ロジック約370行
M自弾 vs 敵衝突約85行
Nパーティクル更新約5行
Oカプセル取得&パワースロット約40行
Pミサイル移動&地形バウンド約72行
Qミサイル vs 敵衝突約48行
Rプレイヤー被弾判定&死亡処理約140行
Svisualバッファ同期約3行
Tステージクリア演出約78行
Umixer / VRM update約2行

単独最大が L(敵パターン更新)の370行で、useFrame 全体の4分の1を占めています。

L節の中身:巨大な if-else チェーン

このL節の内部構造は、敵パターンごとの if-else がインラインで全展開されたものでした。

// useFrame の中(line 977〜1346)
if (enemiesRef.current.length > 0) {
  enemiesRef.current.forEach(enemy => {
    enemy.age += dt;

    if (enemy.patternType === 'pair_shooter') {
      // 25行:座標取得 → 位置更新 → 射撃判定 → 弾生成 → SE再生
    }
    else if (enemy.patternType === 'train') {
      // 40行:列車ボスの移動 → ミサイル発射 → SE再生
    }
    else if (enemy.patternType === 'saba_midboss') {
      // 30行:鯖中ボスの移動 → 5連弾発射
    }
    else if (enemy.patternType === 'dacker') {
      // 50行:地形の topEdge 走査 → 着地 → 自機狙い射撃
    }
    else if (enemy.patternType === 'tadpolicopter') {
      // 30行:ヘリの上昇 → 散開 → 追尾射撃
    }
    else if (enemy.enemyId === 'WIT_ENEMY_EASTER_EGGS_BLUE_COLLECTION') {
      // 30行:卵の円周配置弾幕
    }
    else if (enemy.patternType === 'ghost_boss') {
      // 45行:ワインダー4本弾幕
    }
    else if (enemy.patternType === 'plane_boss') {
      // 65行:接近フェーズ → 投下フェーズ → ミサイル生成
    }
    else if (enemy.patternType === 'plane_missile') {
      // 40行:自由落下 → ブースト追尾
    }
    else {
      // 7行:デフォルト突入パターン(top / bottom)
    }
  });
}

「敵を1体追加する = この if-else チェーンに30〜60行の分岐を追記する」という構造です。2面のボスや中ボスを追加していく中で、この部分だけが際限なく膨張していきました。

なぜこうなったのか

原因は明確です。最初の1〜2体の敵を実装するときは、useFrame 内にインラインで書くのが一番シンプルで動くコードが早く出来ます。ゲーム開発では「まず動くこと」が最優先なので、この判断自体は間違っていません。

問題は、その後も同じパターンで敵を追加し続けたことです。3体目、4体目…と増えるたびに同じ useFrame 内に else-if が積み重なり、気づいた時には370行の巨大な分岐ツリーになっていた。

本来あるべきだったのは、3体目あたりで「これはパターンだ」と気づいて、データ駆動の設計に切り替えることでした。しかし開発中は「あと1体だけ」の気持ちで追加し続けてしまう。これはゲーム開発のよくある罠です。

2. enemyBehaviors.ts への切り出し

設計方針

ファイルを14個に分割するような過剰なリファクタリングは避けました。ファイルを細かく分けすぎると、結局どこに何があるか追えなくなって元より悪くなります。WITManager に書いてあれば Ctrl+F で済んだのに、ファイル横断 grep が必要になるという本末転倒な結果になりかねません。

方針はシンプルに「1ファイルに集約」です。

  • enemyPatterns.ts(既存)= 純粋な座標計算関数(「この age のとき x,y はどこ?」)
  • enemyBehaviors.ts(新規)= 1フレームの更新処理(座標更新 + 射撃 + SE まで全部やる)

既存の enemyPatterns.ts と新設の enemyBehaviors.ts で階層が明確に分かれます。

BehaviorContext:外界との唯一の窓口

各敵の更新関数が外部にアクセスする際の共通インターフェースを定義しました。

export interface BehaviorContext {
  dt: number;                  // フレーム時間
  currentTime: number;         // 経過時間
  playerX: number;             // プレイヤー座標
  playerY: number;
  scrollSpeed: number;         // 地形スクロール速度
  monitorWidth: number;        // 画面幅
  terrainObjs: any[];          // 地形オブジェクト配列(dackerの床検索で使用)
  enemyBulletsRef: MutableRefObject<WITEnemyBullet[]>;
  enemiesRef: MutableRefObject<WITEnemy[]>;    // plane_bossがmissileをpushする
}

ディスパッチャ:1行で全パターンを呼び出す窓口

export const updateEnemyBehavior = (enemy: WITEnemy, ctx: BehaviorContext) => {
  const patternType = (enemy as any).patternType;

  switch (patternType) {
    case 'pair_shooter':  return updatePairShooter(enemy, ctx);
    case 'train':         return updateTrain(enemy, ctx);
    case 'saba_midboss':  return updateSabaMidboss(enemy, ctx);
    case 'dacker':        return updateDacker(enemy, ctx);
    case 'tadpolicopter': return updateTadpolicopter(enemy, ctx);
    case 'easter_egg':    return updateEasterEgg(enemy, ctx);
    case 'ghost_boss':    return updateGhostBoss(enemy, ctx);
    case 'plane_boss':    return updatePlaneBoss(enemy, ctx);
    case 'plane_missile': return updatePlaneMissile(enemy, ctx);
  }

  // enemyId によるフォールバック(後方互換)
  if ((enemy as any).enemyId === 'WIT_ENEMY_GIRL_RIGGED')
    return updateGirlSine(enemy, ctx);

  return updateDefaultMove(enemy, ctx);
};

WITManager 側の変化

370行の if-else チェーンがこうなりました。

// Before: 370行の if-else インライン展開
if (enemiesRef.current.length > 0) {
  enemiesRef.current.forEach(enemy => {
    enemy.age += dt;
    if (enemy.patternType === 'pair_shooter') { /* 25行 */ }
    else if (enemy.patternType === 'train') { /* 40行 */ }
    else if (enemy.patternType === 'saba_midboss') { /* 30行 */ }
    // ... 370行続く
  });
}

// After: 5行のディスパッチ
if (enemiesRef.current.length > 0) {
  const behaviorCtx: BehaviorContext = {
    dt, currentTime, playerX, playerY,
    scrollSpeed: SCROLL_SPEED, monitorWidth: MONITOR_WIDTH,
    terrainObjs: terrainObjsRef.current,
    enemyBulletsRef, enemiesRef,
  };

  enemiesRef.current.forEach(enemy => {
    enemy.age += dt;
    updateEnemyBehavior(enemy, behaviorCtx);
  });
}

結果、WITManager.tsx は 2458行 → 2128行(-330行、-13.4%) に縮小しました。

今後の敵追加ワークフロー

この設計変更により、新しい敵を追加する手順が明確になりました。

  1. enemyPatterns.ts に座標計算関数を追加(純粋関数)
  2. enemyBehaviors.tsupdateXxx 関数を追加
  3. ディスパッチャの switch に case 'xxx': を1行追加
  4. Enemies.json に敵データを追加
  5. StageN.json のタイムラインに出現イベントを追加

WITManager.tsx を触る必要はありません。

3. EASTER_EGG編隊が出現しないバグ

症状

Stage2.json のタイムラインに記載しているのに、ゲーム中に一切表示されない敵がいました。

{
  "time": 20.0,
  "enemyId": "WIT_ENEMY_EASTER_EGGS_BLUE_COLLECTION",
  "count": 1,
  "capsule": true,
  "comment": "イースターエッグの上下移動編隊"
}

最初に実装した時は綺麗に動いていたのに、2面ボスや列車の実装を繰り返すうちに出なくなっていました。

原因:3つの設計問題の合わせ技

問題1:JSON に pattern プロパティが欠落

他の全ての敵イベントには "pattern": "top""pattern": "train" が付いているのに、EASTER_EGG だけ pattern が書かれていませんでした。

問題2:画面外フィルターが右側を考慮していない

enemiesRef.current = enemiesRef.current.filter(e => {
  if (e.patternType === 'ghost_boss' || e.patternType === 'plane_boss') return true;
  if (e.patternType === 'train') return e.x > -LIMIT_X;
  return (
    e.x > -LIMIT_X && e.x < LIMIT_X &&  // ← LIMIT_X = 1.5
    e.y > -LIMIT_Y && e.y < LIMIT_Y
  );
});

EASTER_EGG の getEasterEggPatternstartX = monitorWidth / 2 + 1.0 = 2.2 から登場アニメーションで入ってくる設計です。しかし画面外フィルターは x < 1.5 でカットするため、スポーンした直後の次フレームで消滅していました。

問題3:pattern が undefined で通過した先のデフォルト処理

pattern が未定義の場合、スポーンロジックの else 節に落ちて patternType: undefined で生成されます。ディスパッチャの enemyId フォールバックで updateEasterEgg は呼ばれていたものの、画面外フィルターで即座に削除されるため、1フレームも表示されることなく消えていました。

修正

Stage2.json に pattern を追加し、画面外フィルターに専用ルールを追加しました。

{
  "time": 20.0,
  "enemyId": "WIT_ENEMY_EASTER_EGGS_BLUE_COLLECTION",
  "pattern": "easter_egg",
  "count": 1,
  "capsule": true
}
// 画面外フィルター
if (e.patternType === 'easter_egg') return e.x > -LIMIT_X;

4. 「-0.15」という魔法定数

今日のセッションで最も印象的だったバグです。

発見の経緯

EASTER_EGG を復活させた後、敵モデルの表示位置と弾の発射位置がズレている問題に気づきました。Enemies.jsonhitOffset を調整して描画を合わせると、今度は当たり判定がズレる。当たり判定を合わせると、飛行機ボスのミサイルがプレイヤーに絶対に当たらない角度で飛ぶようになる。

何を直しても別の場所が壊れる、典型的なモグラ叩きです。

犯人

// WITManager.tsx line 1916
position={useExternalPosition
  ? [enemy.x, enemy.y + hitOffset - 0.15, 0.04]  // ← この -0.15
  : [0, heightOffset, 0]
}

useExternalPosition は EASTER_EGG、飛行機ボス、飛行機ミサイルの3敵で true になるフラグです。この3敵すべての描画位置に -0.15 が強制適用されていました。

なぜ -0.15 が入ったのか

推測ですが、こういう経緯だったと思われます。

  1. EASTER_EGG を最初に実装した時、GLBモデルの原点が上寄りで表示位置がズレた
  2. -0.15 引けばちょうどいい位置に来る」とハードコードで補正
  3. 後から飛行機ボス・ミサイルが useExternalPosition に相乗り
  4. -0.15 が全員に適用されることに誰も気づかない
  5. 各敵の hitOffset で辻褄を合わせようとして、描画・判定・狙い計算の三者がバラバラに

影響範囲

この -0.15 は以下の3つの系を同時に狂わせていました。

  • 描画位置:モデルが本来の位置より 0.15 下に表示される
  • 当たり判定:hitOffset を弄ると判定中心がズレる
  • ミサイルの狙い計算:m.y を基準に狙うが、判定は m.y + hitOffset で行われるため、永遠にプレイヤーの上を通過する

修正

修正は1行です。

// Before
position={useExternalPosition
  ? [enemy.x, enemy.y + hitOffset - 0.15, 0.04]
  : [0, heightOffset, 0]}

// After(-0.15 を削除するだけ)
position={useExternalPosition
  ? [enemy.x, enemy.y + hitOffset, 0.04]
  : [0, heightOffset, 0]}

これで全 useExternalPosition 敵の描画・判定・狙い計算が正常化し、Enemies.json の hitOffset も全て 0.0 に戻せました。

教訓

「1体だけの見た目調整を、共通コードにハードコードするな」。本来やるべきだったのは Enemies.jsonheightOffset で個別に調整することでした。コード側に数値を書く必要はなかった。

同様に、HP の固定値 も見つかりました。

// 列車ボス:JSONで hp:1 にしても最低30に強制
hp: Math.max(finalHp, 30),

// 飛行機ミサイル:JSONで hp:3 にしても10固定
hp: 10,

これらも Enemies.json の値を素直に使うように修正しました。

5. プロシージャル背景のテーマ化

1面と2面で同じ夜空の背景だったので、2面を朝焼けテーマに変更しました。

データ駆動アプローチ

描画ロジックを書き換えるのではなく、テーマ定義をデータオブジェクトとして分離しました。

const THEMES = {
  night: {
    sky: [
      { stop: 0,   color: '#0a061c' },
      { stop: 0.4, color: '#120b24' },
      { stop: 1,   color: '#040208' },
    ],
    celestial: { type: 'moon', xRatio: 0.75, yRatio: 0.25, ... },
    cloud:     { color: 'rgba(40, 30, 60, 0.25)', ... },
    particles: { colors: ['#bbf', '#fbc'], count: 60 },
    forest:    { color: '#0a0812', ... },
  },
  dawn: {
    sky: [
      { stop: 0,    color: '#1a1040' },  // 残る夜空
      { stop: 0.25, color: '#4a2060' },  // 紫がかった空
      { stop: 0.5,  color: '#c45028' },  // オレンジの帯
      { stop: 0.75, color: '#e8a040' },  // 黄金の地平線
      { stop: 1,    color: '#f0c868' },  // 明るい水平線
    ],
    celestial: { type: 'sun', xRatio: 0.7, yRatio: 0.55, ... },
    cloud:     { color: 'rgba(255, 180, 120, 0.2)', ... },
    particles: { colors: ['#fde', '#fed'], count: 40 },
    forest:    { color: '#1a0818', ... },
  },
};

描画コード自体は1つだけで、テーマオブジェクトの値を参照して描画します。3面を追加するときは THEMES に新テーマを1つ足して、呼び出し側で theme={stageNum === 3 ? 'sunset' : ...} とするだけです。

WITManager 側の変更

1行の prop 追加だけです。

<ProceduralBackground
  monitorWidth={MONITOR_WIDTH + WING_WIDTH * 2}
  monitorHeight={MONITOR_HEIGHT + 0.25}
  scrollSpeed={currentTerrainData.scrollSpeed || 0.3}
  active={!showTitleScreen}
  theme={stageNum === 2 ? 'dawn' : 'night'}  // 追加
/>

6. 最終ステージ→エンディング遷移

Before:無限ループ

const nextStage = stageNum === 1 ? 2 : 1;  // 1→2→1→2→...

After:FINAL_STAGE 定数で制御

const FINAL_STAGE = 2;
const nextStage = stageNum < FINAL_STAGE ? stageNum + 1 : 'ED';
setStageNum(nextStage);

if (nextStage === 'ED') {
  // エンディングへ:BGMはWITEdSequenceが自前で管理
  console.log("🎬 最終ステージクリア!エンディングシーケンスへ移行");
} else {
  // 次のステージへ
  if (playerGroupRef.current) {
    playerGroupRef.current.position.set(-0.9, 0, 0.02);
  }
  // BGM開始...
}

3面を追加したくなったら FINAL_STAGE = 3 に変えるだけです。

エンディングの自動送り

エンディングシーケンス(WITEdSequence.tsx)はデバッグ用に ENTER キーで手動送りしていましたが、本番用に一定秒数で自動遷移するタイマーを追加しました。

case 'STAY':
  // モデル回転
  if (rotGroupRef.current && data.type === 'MODEL') {
    rotGroupRef.current.rotation.y += 0.6 * dt;
  }
  // 自動送りタイマー
  stayTimerRef.current += dt;
  if (stayTimerRef.current >= 4.0) {
    setMotionState('OUTGOING');
  }
  break;

ENTER キーの手動スキップも残してあるので、開発中のモデルサイズ調整にも引き続き使えます。

まとめ

10日間の開発で出来上がったもの

  • 3Dモデル50個以上の調達・最適化・配置
  • 11種の敵パターン(雑魚・中ボス・列車・飛行機ボス・誘導ミサイル)
  • グラディウス式パワーアップ6種(スピード・ミサイル・ダブル・レーザー・オプション・シールド)
  • VRコントローラー対応
  • 2ステージ+エンディングシーケンス
  • プロシージャル背景(夜空・朝焼け)
  • ステージ間カーテン演出・BGM切替

今日のセッションで学んだこと

過剰な共通化は早すぎる抽象化:3敵で共有する useExternalPosition フラグに特定1体用の座標補正を埋め込んだ結果、全体が狂った。共通化するなら、個別の値はデータ(JSON)で外出しにすべき。

魔法定数は時限爆弾:-0.15 というハードコードされた座標オフセットが、描画・当たり判定・狙い計算の三系統を同時に破壊した。「動いたからOK」で入れた数値が、後から別の敵に波及するパターン。

データ駆動設計は未来の自分への投資:enemyBehaviors.ts の切り出しに2日かかったが、これがなければ3面の敵追加のたびに WITManager.tsx の useFrame に else-if を追記し続ける運命だった。