[Astro #82] React Three Fiberによるデータ駆動型マルチステージ構築とシステム堅牢化ハック

[Astro #82] React Three Fiberによるデータ駆動型マルチステージ構築とシステム堅牢化ハック

はじめに

本稿では、React Three Fiber(R3F)をベースとした横スクロールシューティングゲーム(WITプロジェクト)において、新規エネミーAIの追加、アセット流用によるボス戦の構築、動的データ駆動によるマルチステージ化、およびバグ根絶のための状態制御の実装内容について記述します。

1. 旋回公転フォーメーションエネミー(Tadpolicopter)の実装詳細

本セクションでは、おたまじゃくし型ヘリコプター(WIT_ENEMY_TADPOLICOPTER)が、単なる直線スクロールから有機的な編隊狙撃エネミーへと進化した背景にある3つのシステムロジックを解説します。

① 平たい楕円軌道による公転運動の数理

3機の敵個体が互いに衝突せず、かつ画面外へバラバラに散らばらないよう、移動の基準点となる「共通の中心点(仮想コア座標)」を軸とした相対的な媒介変数表示(三角関数)アルゴリズムを採用しています。

  • 正三角形を維持する位相差の定義: 編隊の美しさを保つため、個体インデックス(enemy.index:0, 1, 2)を基準に、円周を3等分する一様位相差を算出します。

θi=angle+(i×2π3)\theta_i = \text{angle} + \left( i \times \frac{2\pi}{3} \right)

  • 平たい楕円軌道の座標変換: シューティングゲームの画面比率(横長)および敵弾の回避スペースを縦方向に確保するため、円運動ではなく縦軸を潰した「扁平楕円」の補正係数を適用します。中心点を (Xcore,Ycore)(X_{\text{core}}, Y_{\text{core}}) としたとき、各個体の相対座標は以下の数式でリアルタイムに計算されます。

xi=Xcore+Rx×cos(θi)x_i = X_{\text{core}} + R_x \times \cos(\theta_i)

yi=Ycore+Ry×sin(θi)y_i = Y_{\text{core}} + R_y \times \sin(\theta_i)

ここで横半径 RxR_x に対し、縦半径 RyR_y を約半分(例: Rx=0.2,Ry=0.1R_x = 0.2, R_y = 0.1)にタイト化することで、画面内をコンパクトに旋回し続ける高密度なフォーメーションが維持されます。

② 首振り(ピッチ)演出のブレンドロジック

3Dグラフィックとしての実在感を高めるため、公転運動の物理的な移動方向(速度ベクトル)を、自動的に3Dモデルの自転角度(rotationY)へフィードバックさせる変調処理を噛ませています。

  • 速度ベクトルと角度の同期: 公転の数式における yy 軸の変位(縦移動)は sin(θi)\sin(\theta_i) の微分、すなわち cos(θi)\cos(\theta_i) 成分に比例します。このコサイン成分を、Blender側からインポートした基本回転オフセットに動的にブレンドします。
  • リアルタイムなピッチング表現: 個体が楕円の「上死点」から降下に移るフェーズ(速度ベクトルが下向き)では、コサインの正負によって rotationY が前傾姿勢(機首をやや下に向ける)に傾きます。逆に、楕円の下側から上昇に反転するフェーズでは自動的に機首が上を向くため、静的な3Dモデルでありながら、まるでローターの揚力で高度を変えているかのようなインゲーム演出が完全同期されます。

③ 自機狙い(索敵)およびインターバル射出ロジック

ホバリング(BURST フェーズ)突入時、イレイナ(自機)の現在地を正確に逆探知して狙撃する「自機狙い弾(エイミング弾)」の計算エンジンが、WITManager.tsx 内で毎フレーム駆動します。

  • ラジアン角の逆算 (Math.atan2): 3D空間上のプレイヤーグループ座標(playerGroupRef.current.position)から、敵個体の現在位置を差し引き、相対ベクトル (Δx,Δy)(\Delta x, \Delta y) を算出します。

Δx=XplayerXenemy\Delta x = X_{\text{player}} - X_{\text{enemy}}

Δy=YplayerYenemy\Delta y = Y_{\text{player}} - Y_{\text{enemy}}

この値を Math.atan2(dy, dx) に放り込むことで、正負の境界(象限)を跨いだ全方位への正確なターゲティングラジアン角(ϕ\phi)が一発で逆算されます。

  • バーストインデックスによる多段ヒット防止と射出処理:
if (config.shootPhase === 'BURST' && config.burstIndex !== enemy.lastBurstIndex) {
  enemy.lastBurstIndex = config.burstIndex; // 💡多段トリガー防止ガード
  ...
  enemyBulletsRef.current.push({
    id: `eb_tad_bullet_${Math.random().toString(36).substr(2, 9)}`,
    x: enemy.x - 0.05, // 口元から発射される位置補正
    y: enemy.y,
    vx: Math.cos(angle) * GLOBAL_ENEMY_BULLET_SPEED, // 算出された角度へ初速を分解
    vy: Math.sin(angle) * GLOBAL_ENEMY_BULLET_SPEED,
    scale: 0.55, // おたまじゃくし専用の小粒弾
  });
}

useFrame(秒間60回)のループ内で弾が意図せず重なって連射されるのを防ぐため、パターン側から送られてくる整数型の burstIndex が切り替わった「その1フレーム」だけ射出判定が貫通するガードロック(lastBurstIndex)を配線しています。 射出された弾は、三角関数(cosϕ,sinϕ\cos\phi, \sin\phi)によって瞬時に直交座標系の速度成分(vx, vy)へと分解され、動的配列バッファ(enemyBulletsRef)へ安全にプッシュされます。

2. 巨大アセット流用によるエリアボスと速度位相変調弾幕のハック詳細

本セクションでは、雑魚敵(WIT_ENEMY_GHOST_01)の3Dモデルをそのまま10倍にスケールアップ(scale: [0.5, 0.5, 0.5]、物理判定半径 hitRadius: 0.28)してエリアボス(ghost_boss)へと流用するにあたり、ゲームのプレイフィールを商業クオリティへと引き上げるために実装した3つのハックについて解説します。

① 速度位相変調(Velocity-Phase Modulation)ワインダーの数理的アプローチ

本ゲームの弾道計算エンジンは、実行負荷を極限まで下げるため、個々の敵弾が射出時の初速ベクトル (Δvx,Δvy)(\Delta v_x, \Delta v_y) をそのまま維持して等速直線運動を行うアーキテクチャを採用しています。この「直進しかできない単純な弾」のインフラを1ミリも書き換えることなく、画面上で生き物のようにうねり、かつ自動的に安地が広がる複雑な弾幕を表現するため、速度位相変調(Velocity-Phase Modulation)という数学ハックを実装しました。

  • 銃口スイング角度の動的算出: ボスの生存時間(enemy.age)を基準波としたサイン関数から、4条の銃口の共通スイング角度 θswing\theta_{\text{swing}} を毎フレーム算出します。

θswing=sin(enemy.age×ω)×A\theta_{\text{swing}} = \sin(\text{enemy.age} \times \omega) \times A

ここで、あなたがうねりの激しさとして調整した角周波数 ω=6.4\omega = 6.4、および画面全域をダイナミックに薙ぎ払う最大振幅 A=1.5A = 1.5 を適用しています。

  • ラインごとの速度差(位相差)のマウント: 発射される4本のライン(l=0,1,2,3l = 0, 1, 2, 3)に対し、微小な射出角度オフセット Δθl\Delta \theta_l と、意図的にずらした「弾速倍率(speedMod)」をペアでマウントします。

vxl=cos(π+θswing+Δθl)×(vbase×speedModl)vx_l = \cos(\pi + \theta_{\text{swing}} + \Delta \theta_l) \times (v_{\text{base}} \times \text{speedMod}_l)

vyl=sin(π+θswing+Δθl)×(vbase×speedModl)vy_l = \sin(\pi + \theta_{\text{swing}} + \Delta \theta_l) \times (v_{\text{base}} \times \text{speedMod}_l)

  • 安全地帯(通り道)の動的生成メカニズム: 射出位置の縦間隔を極限まで収束(ΔY=(l1.5)×0.015\Delta Y = (l - 1.5) \times 0.015)させているため、発射された直後のボス口元では、4発の弾が1本の極太なエネルギービームのように重なって見えます。しかし、弾がプレイヤー側(左方向)へ飛行し、時間(tt)が経過するにつれて、弾速倍率の差から空間上の位置的な位相差(vxl×tvx_l \times t)が徐々に開いていきます。 これにより、左側に到達した段階ではサイン波の波形が綺麗にズレて「4本の独立した美しいサイン波の川」へと変化し、バリアを持たないプレイヤーでも弾の合間に飛び込んで精密に避けきれる、極上の「攻防の余白」が自動生成される仕組みとなっています。

② 戦闘サイクル(State Machine)と時間軸の間引き処理

ボスの耐久値を雑魚敵の数百倍にあたる「600」という肉厚な一一打ち仕様へと引き上げたため、単調な垂れ流し弾幕では画面全域が弾のインフレによって埋め尽くされ、確実にハメ殺される安地潰れバグが発生します。これを根本から根絶するため、時間軸ベースの状態制御を導入しました。

  • 5秒周期の戦闘サイクル(剰余演算ハック): ボスの生存時間から5秒周期のローカルタイムライン(enemy.age % 5.0)を切り出し、即席の状態マシン(State Machine)を構築しました。

  • 0.0tcycle3.20.0 \le t_{\text{cycle}} \le 3.2 秒(FIRING ステート): 4条ワインダーによる怒涛の猛攻を展開。

  • 3.2<tcycle<5.03.2 < t_{\text{cycle}} < 5.0 秒(COOLING_DOWN ステート): ボスが息切れを起こしたかのように、射出処理を完全にシャットアウト。 この「1.8秒間の明確な静寂(息切れ時間)」を作ったことにより、プレイヤーがボスの正面に躍り出てレーザーやオプションの火力を一気に叩き込むという、STGにおいて最も脳汁が出る反撃チャンスの演出に成功しました。

  • フレーム単位の間引き制御: さらに、弾の前後(横スクロール方向)の密度を適正化するため、射出インターバルを従来の9フレームから15フレーム(1秒間に4回)へとがっつり拡張しました。これにより、ステップ操作で弾の合間に綺麗に潜り込めるクリアな隙間が確保され、運ゲー要素が完全に排除されます。

③ 描画レイヤー(WITInstances.tsx)における自転速度の減速補正

3Dグラフィックの演出において、雑魚敵のモデルを単純に10倍に拡大した際、元の雑魚敵と同じ高速な自転速度(Y軸回転)をそのまま適用してしまうと、巨大な質量を持つオブジェクトとしての説得力が失われ、視覚的に非常にチープ(おもちゃっぽく)見えてしまうというグラフィック特性上の課題がありました。

  • コンポーネント内でのボス個別識別: 描画を担当する EnemyInstance コンポーネント内の useFrame ループにおいて、インスタンス化された敵の enemy.patternType === 'ghost_boss' であるかを識別する条件分岐の割り込みを挿入しました。
  • 重厚感の演出: 対象がボス個体である場合のみ、時間の経過(state.clock.getElapsedTime())に対する回転角速度の乗数を、雑魚敵の高速スピンから「2.0」などの低い値へとダイナミックにデチューン(減速)させました。 この減速補正により、アセット(GLTFモデルデータ)を完全に流用しながらも、ゆったりと巨体を回しながら威厳をもってスライドインしてくる、大物ボスキャラクターとしての重厚な存在感とクオリティを保証しました。

3. データ駆動型(Data-Driven)マルチステージ・インフラへの拡張詳細

本セクションでは、ステージごとにプログラムソース(WITManager.tsx)を複製・ベタ書きする力技の設計から脱却し、タイムラインおよび地形データをJSONファイル側から完全に切り離して制御するデータ駆動型(Data-Driven)マルチステージ・アーキテクチャの実装詳細について解説します。


useMemo による動的データソース変調とReact非同期ステートのハック

単一のゲームエンジンコンポーネントで複数のステージをシームレスに処理するため、コンポーネントのトップレベルにステージ番号の状態(stageNum)を定義し、それに応じてアクティブなデータ参照を動的にスイッチするインフラを構築しました。

  • 依存配列によるデータの一括切り替え(データバインド):
const [stageNum, setStageNum] = useState(1); // 1: ステージ1, 2: ステージ2

const currentStageData = useMemo(() => stageNum === 1 ? WIT_STAGE1 : WIT_STAGE2, [stageNum]);
const currentTerrainData = useMemo(() => stageNum === 1 ? WIT_TERRAIN1 : WIT_TERRAIN2, [stageNum]);

ゲーム内のすべてのタイムラインイベント処理(currentStageData.timeline)や、スクロール物理演算(currentTerrainData.scrollSpeed)がこのMemo化オブジェクトを直接参照するようにコード全体を置換しました。これにより、stageNum が変更された瞬間に、エンジン側のロジックを1ミリも汚すことなく、敵の出現スケジュールや3D地形データが一斉に変調(スワップ)されます。

  • React非同期更新への防衛対策(先読み処理): Reactの useState は非同期にバッチ処理されるため、暗転中に setStageNum(2) をキックしても、同じフレーム内(1/60秒未満)の直後のコード行では currentStageData の中身はまだステージ1のデータを指したままになります。 このタイムラグによる次ステージBGMのロード失敗バグを防ぐため、以下の通り、次フレームのレンダリングを待たずに同期的に生のJSON配列から直接ターゲットデータを先読みするガードロジックを配線しました。
const nextStage = stageNum === 1 ? 2 : 1;
setStageNum(nextStage);

// 💡非同期ステートを出し抜き、次のステージデータを同期的に直接参照してBGMを索敵・トリガー
const targetStageJson = nextStage === 1 ? WIT_STAGE1 : WIT_STAGE2;
const routeBgmConfig = WIT_SOUNDS.bgm.find(b => b.id === targetStageJson.BGM);

② インゲーム・クリアカットシーン演出のシーケンス制御ロジック

ボス(ghost_boss)の死亡判定が検知された瞬間、ゲーム状態は通常のプレイモードから、useFrame(デルタタイム dt)ベースのタイマーで精密に駆動する「4フェーズ・シネマティックカットシーンエンジン」へと安全に移行します。

[ボス撃破] ➔ 【フェーズ1: 弾消し/無敵】 ➔ 【フェーズ2: 高速右袖退場】 ➔ 【フェーズ3: 幕閉鎖/BGM停止】 ➔ 【フェーズ4: 暗転/データ若返りリスタート】

1. フェーズ1(時間駆動待機 & 画面内一括クリーンアップ)

ボスにトドメを刺した同フレーム内で、画面上のピンクの敵弾配列バッファ(enemyBulletsRef.current)を即座に空の配列([])へと上書きし、同期的に描画バッファもクリアします。これにより、いわゆるアーケードSTG王道の「ボス撃破時の弾消し演出」を再現し、撃破のカタルシスを高めています。 同時に、isStageClear フラグを true に倒すことで、プレイヤーの衝突判定スレッドへの侵入を完全にシャットアウトし、イレイナをシステム的な「鉄壁の完全無敵レイヤー」で保護します。このフェーズは 1.2 秒間維持され、リザルトUIを画面中央に美しく投影させます。

2. フェーズ2(入力を遮断した座標移動・傾きベクトルの自動減衰補正)

タイマーが1.2秒を超えるとフェーズ2へ移行し、WITController へのキーボードおよびコントローラー操作入力信号(onUpdate)の配線が物理的に切断されます。自機はプレイヤーの手を離れ、以下の自律スクロールロジックによって画面右外(右袖)へとハケていきます。

playerGroupRef.current.position.x += 4.2 * dt; // 通常プレイヤー操作を凌駕する高速自動前進速度
if (playerTiltRef.current) playerTiltRef.current.rotation.x *= 0.8; // 箒のピッチ回転角速度をなめらかに0(水平)へと収束

自機の XX 座標が画面の右エッジを超えた瞬間(position.x >= MONITOR_WIDTH / 2 + 0.35)、完全に右のカーテンの裏に隠れきったと判定され、即座にフェーズ3へと遷移します。

3. フェーズ3(緞帳StatusRefと音響制御の密結合)

画面内にイレイナの姿がなくなった瞬間に、既存のカーテン制御インフラを起動します。

if (curtainStatusRef.current === "idle") {
  audioController.stopBgm(); // 💡カーテンが降り始める完全に同フレームでクリアBGMを即座にシャットアウト
  curtainStatusRef.current = "closing";
}

視覚的に大きな赤い緞帳(幕)が上空から「ガラガラ……」と降りてくる動きと完全に同期させて、裏でループ再生されようとするクリアBGMをピタッと止め、「完全な無音の静寂」を作ります。この引き算の音響ハックを入れることで、次のステージ2が始まった瞬間の道中BGMのドロップによるテンポのブースト効果(メリハリ)が極限まで高まります。

4. フェーズ4(暗転裏の完全初期化 & 相対時間軸の若返り処理)

カーテンが下に降りきって画面が完全に暗転した瞬間、登録されていたコールバック関数(curtainCallbackRef)が発火し、フェーズ4のデータ組み換えが走ります。

  • 3D空間上の完全パージ: 前ステージの残骸やパーティクルの生存データを完全にリセットします。
enemiesRef.current = []; bulletsRef.current = []; enemyBulletsRef.current = [];
particlesRef.current = []; missilesRef.current = [];
  • 相対時間軸の若返り(リセット演算): ゲーム内のすべてのタイムラインイベントの基準となっている stageAge(現在のクロック時間から突入時間を引いた相対時間)をリセットするため、現在の絶対経過時間をそのままRefへと再書き込みします。

stageStartTimeRef=state.clock.getElapsedTime()\text{stageStartTimeRef} = \text{state.clock.getElapsedTime()}

これによって、内部の時間軸はバグを起こすことなく綺麗に「0秒」へと若返り、タイムラインの走査インデックス(timelineIndexRef)および地形配置インデックス(terrainIndexRef)も 0 に安全に巻き戻されます。

すべてのデータとイレイナの初期配置([-0.9, 0, 0.02])の書き換えが完了した1.2秒後、ステージ2の道中曲が鳴り響き、再び緞帳が上へとパァッと開いて、新世界(ステージ2)での戦いがシームレスに幕を開けます。

4. パワーアップインフラの排他制御と制限ロジックの堅牢化詳細

本セクションでは、プレイ中の操作やパワーアップアイテムの連続取得に伴って発生する、内部フラグの競合(ゲージ点灯不良バグ)や、長期ボス戦時における3D世界の崩壊(床データの枯渇バグ)を完全に根絶するために実装した、防衛ガードロジックおよびリファクタリングの詳細を解説します。


① 双方向排他インターセプターによる状態競合の解決

本ゲームのパワーアップゲージシステムでは、パワーカプセル取得時に特定の装備が有効化されていると、その該当ゲージスロットを自動でスキップ(スルー)して次のスロットへホバーを移すロジックが組み込まれています。しかし、従来のコードではレーザー(hasLaser)とダブル(hasDouble)の切り替え時において、双方の内部フラグが同時に true になってしまう排他漏れのリスクが存在していました。

  • ゲージフリーズバグの発生メカニズム: hasDoublehasLaser が同時に true になると、ゲージの点灯ホバー計算時に「ダブル装備中だからスロット3をスキップ」「レーザー装備中だからスロット4をスキップ」という処理が同期的かつ連続的に発動します。これにより、カプセルを取ってもスロット3と4の間で無限にスキップ判定がループし、ゲージが二度と点灯しなくなる致命的な「ゲージフリーズ(詰まり)」が発生していました。
  • 高階関数(インターセプター)による強制パージの配線: 外部のコントロールフック(useGameControls.ts)側の結合を汚すことなく、状態変化をフックして強制的な上書きを行うため、WITManager.tsx 内部に関数の変更要求を中継・フィルタリングする「排他インターセプター(ラッパー関数)」を新設しました。
const setHasDoubleInterceptor = React.useCallback((value: boolean | ((prev: boolean) => boolean)) => {
  if (typeof value === 'function') {
    setHasDouble(prev => {
      const next = value(prev);
      if (next) setHasLaser(false); // 💡ダブル有効化の同フレームでレーザーを強制完全消去
      return next;
    });
  } else {
    setHasDouble(value);
    if (value) setHasLaser(false);
  }
}, []);

setHasLaserInterceptor にも同様に setHasDouble(false) を行う対のロジックを実装し、フック側の関数参照へ繋ぎ変えました。これにより、状態更新のバッチ処理の隙を突いた同時有効化を物理的に防ぎ、100%双方向の排他制御が保証されます。


② 数値一括置換によるオプション最大2機制限とスロットスキップの最適化

火力の過剰インフレによるボス弾幕の瞬殺や相殺ハメを防止し、HP 600の肉厚なラリーを維持するために、オプション(optionCount)の最大取得上限を「4」から「2」へと引き下げる最適化を行いました。 新規に複雑なバリデーション関数を増設するのではなく、既存の「4」という数値的なマジックナンバーを「2」へと論理的にリファクタリングすることで、スマートな整合性を達成しています。

  • useGameControls.ts 側の条件引き下げ: カプセルゲージスロット5(OPTION)確定時における最大数チェックを、p.optionCount < 4 から p.optionCount < 2 へと書き換え、3機目以降の生成をロジックレベルでシャットアウトしました。
  • WITManager.tsx のカプセルゲージ自動スキップ連動: オプションがすでに最大数(2機)まで装備されている状態でカプセルを取得した際、無駄にOPTIONスロット(スロット5)にホバーが留まるのを防ぐため、スキップ境界条件を書き換えました。
if (next === 5 && optionCount >= 2) next = 6; // 💡 === 4 から >= 2 へリファクタリング

これにより、2機装備状態ではカプセル取得時に自動でOPTIONゲージがスルーされ、即座に次のスロット6(? / シールドバリア)へとホバーが移る精密な連動が1行で完成しました。

  • PowerUpGauge.tsx へのUI非表示同調: 2機装備が完了している際、画面下のHUDゲージの「OPTION」文字を消灯(非表示化)させ、これ以上パワーアップできないことをプレイヤーに直感的に伝えるUI連動条件も、同様に optionCount >= 2 へ引き下げを行っています。

③ 地形データの動的無限自動生成(リピートハック)

本ゲームのスクロールシステムは、ステージ全体の進行距離(stageAge * SCROLL_SPEED)が、外部JSONファイル(Stage1_terrain.json 等)に記述された地形オブジェクトの XX 座標を超えた段階で、配列から実体を順次3D空間へ push するデータ駆動設計をとっています。しかし、ボスの耐久値を「600」に引き上げた長期戦においては、プレイヤーの撃破時間によっては用意されたJSONデータの行数をすべて消費し尽くし、背景の床がパッと消え去る深刻な「空中戦(虚無スクロール)バグ」が発生するリスクがありました。

  • 数式による未来座標の動的シミュレーション: JSONファイルのテキストを力技で何百行もコピペして肥大化させる対応を回避するため、タイムラインインデックス(terrainIndexRef.current)がJSONの配列長を超過した(尽きた)瞬間を検知するガードロジックをコード側にインジェクションしました。
// 配列を配り終えた場合、最後の要素のX座標を取得
const lastTerrain = currentTerrainData.terrain[currentTerrainData.terrain.length - 1];

// 配列の末尾から、現在どれだけインデックスが溢れているか(超過数)を算出
const overflowCount = terrainIndexRef.current - (currentTerrainData.terrain.length - 1);

// 最後の地形Xを起点に、0.3ピッチで「未来の仮想X座標」をその場で動的無限計算
const virtualX = lastTerrain.x + overflowCount * 0.3;
  • 既存のメモリ最適化インフラとの融合: このハックにより生成された「未来の仮想オブジェクトデータ」は、既存のインフラに対して本物のタイムラインデータとしてシームレスに引き渡されます。画面外にハケた床を自動で配列から間引いてメモリ解放するフィルター処理(filter)とそのまま噛み合うため、どれだけボス戦が長引き床が無限生成され続けようとも、グラフィックメモリ(VRAM)およびCPUにかかる負荷は「画面内の一握りのタイル数分」の一定値に完全に固定されます。 また、ボス戦中の視覚的なハメ(背景の木オブジェクト等に弾幕が紛れて見えなくなる現象)を根絶するため、自動延長時のモデルIDは平らな更地(WIT_TERRAIN_TILE)へと固定される設計を組み込んでいます。

5. シングルページアプリケーション(SPA)におけるゾンビオーディオの根絶詳細

本セクションでは、ゲームプレイ中にヘッダーのナビゲーションリンク等を介して他のページへ画面遷移(コンポーネントの切り替え)した際、3D描画画面がブラウザ上から完全に消滅したにもかかわらず、BGMだけがバックグラウンドで再生され続けてしまう「ゾンビオーディオ現象」の発生メカニズムと、Reactのライフサイクル機構を用いた完全なリソース回収(クリーンアップ)の実装詳細について解説します。


① SPAにおけるコンポーネントツリーと外部音声スレッドのライフサイクル乖離のメカニズム

AstroによるSPA(シングルページアプリケーション)環境下において、Reactコンポーネント内の状態や3D描画(React Three FiberのCanvas)は、Reactの仮想DOM管理システムの下でライフサイクルを制御されています。しかし、音声再生を司る audioController の実体(AudioContextHTMLAudioElement を用いたネイティブなブラウザ音声スレッド)は、Reactの管理外であるグローバルなメモリ空間、あるいはシングルトンなインスタンスとして駆動しています。

  • ゾンビオーディオの発生原因: ユーザーがページ上部の別メニューをクリックしてページが切り替わると、Reactのレンダラーは WITManager.tsx を仮想DOMツリーから削除(アンマウント)し、付随する3Dオブジェクトのメモリを破棄(ガベージコレクション)します。 しかし、音声スレッドに対して「明示的な停止命令」を同期させない限り、ブラウザ側はコンポーネントの消滅を検知できません。結果として、視覚的なゲーム画面が完全に消失しているにもかかわらず、裏のメモリ上で音声オブジェクトだけが永久に周回再生(ループ)を維持し続けるという挙動の欠陥が発生していました。

② 空の依存配列を持つ useEffect クリーンアップ(デストラクタ)の論理的挙動

このライフサイクルの不一致を100%解決するため、コンポーネントの消滅フェーズに直接フックし、ネイティブな音声リソースを同期的かつ強制的にパージする「ライフサイクル・バスター」を WITManager.tsx のトップレベルに配線しました。

  • クリーンアップ関数の登録(デストラクタのシミュレート):
useEffect(() => {
  // 💡 空の依存配列 [] により、コンポーネントのマウント(誕生)時にこのブロックが1発だけ駆動

  return () => {
    // 🎯 クリーンアップ関数(アンマウントフェーズ)
    audioController.stopBgm();
    console.log("🧼 【WIT CLEANUP】ゲーム画面の退場を検知。裏のゾンビBGMを安全に破棄しました。");
  };
}, []);
  • Reactスケジューラーによる確実な実行保証: useEffect に空の依存配列([])を渡すことで、第一引数の戻り値として返した関数(return () => { ... })は、Reactのスケジューラーによって「コンポーネントが画面から完全にアンマウント(破棄)される最後の1フレーム」に、確実かつ排他的に1回だけ実行されることが保証されます。

ユーザーが別ページに切り替えた瞬間(ブログ記事のロードやツールへの遷移時)、このデストラクタ関数がインターセプトし、グローバルで鳴っている audioController.stopBgm() をキックしてブラウザのオーディオバッファを強制的にクリーンアップします。 これにより、SPA特有のリソース残存バグが完全に根絶され、ページを跨いでも音響と画面のライフサイクルが常にミリ秒単位で完全に同期する、製品レベルのスマートなシステム挙動へと最適化されました。