[Astro #65] セーブ・回復システムの完全隔離とVRMアセットのVRAM解放プロトコル

[Astro #65] セーブ・回復システムの完全隔離とVRMアセットのVRAM解放プロトコル

1. ローカルストレージ連動型セーブシステム(日記帳)の構築

3D探索ゲームにおいて、プレイヤーの進行状況を保存するセーブシステムは基盤インフラの一つである。本開発では、localStorage と連動したデータ駆動型の「日記帳システム」を構築した。実装にあたり、3D空間におけるステート(状態)の厳密な統制、ブラウザDOMに依存しないReactネイティブな関数制御、そして戦闘(BATTLE)フェーズと探索(EXPLORE)フェーズ間における演算の完全な隔離に焦点を当ててシステムを最適化した。


1-1. 状態管理の統合と探索足止めプロトコル

セーブポイントの検知とUIの動的展開をシームレスに行うため、フィールド全体の統括を担う ADVManager コンポーネント内にセーブシステム専用のステート群を新設・統合した。

  • activeSavePointId: 現在プレイヤーが接近しているセーブポイントのIDを保持する(未接近時は null)。
  • isSaveMenuOpen: 日記帳メニューの開閉状態を司るフラグ。
  • saveMenuMode: メニュー内の階層('TOP' / 'SAVE' / 'LOAD')を管理する。
  • selectedSaveSlot: 選択中のセーブスロット(0〜2)を格納する。

これらの状態変数を追加した上で、毎フレームの描画ループである useFrame 内でステージ設定(stageConfig.savePoints)からセーブポイントの座標を抽出し、プレイヤーアバターの現在地(avatarPos)との距離を算出した。設定された有効半径(radius)以内にイレイナが進入した際、接近トリガーが起動する。

// 💾 探索中のセーブポイント接近判定(useFrame内)
if (gameState === 'EXPLORE' && stageConfig.savePoints) {
  let nearestSavePointId: string | null = null;

  for (const sp of stageConfig.savePoints) {
    const spPos = new THREE.Vector3(sp.position[0], sp.position[1], sp.position[2]);
    if (avatarPos.distanceTo(spPos) < sp.radius) {
      nearestSavePointId = sp.id;
      break;
    }
  }

  if (nearestSavePointId !== activeSavePointId) {
    setActiveSavePointId(nearestSavePointId);
    if (nearestSavePointId && !isSaveMenuOpen) {
      setIsSaveMenuOpen(true);
        setSaveMenuMode('TOP');
        setSelectedSaveSlot(0);

        // 🎯 【移動アニメーション継続バグの根絶】
        // メニューを開いた瞬間に、歩行フラグを強制的に false へクリア
        setIsMoving(false);

        audioController.playSe('SE_SYSTEM_DECIDE', 0.5);
    } else if (!nearestSavePointId) {
      setIsSaveMenuOpen(false);
    }
  }
}

このアプローチにおける技術的要衝は、nearestSavePointId を検知して setIsSaveMenuOpen(true) をキックするのと同一フレームで、強制的に setIsMoving(false) をインジェクションしている点にある。

これにより、プレイヤーが移動キーを入力したままセーブポイントに突入した場合でも、内部的な移動判定とアニメーションフラグが即座に遮断される。アバターが「足を滑らせて部屋の中を走り続ける」というレイアウト破綻現象を構造的にシャットアウトし、美しい直立アイドルポーズで日記帳を開く挙動を完全に保証した。


1-2. DOMハックの排除とRef直結型関数制御への移行

初期設計のキーボード操作プロトコル(handleSaveMenuKeyDown)およびVR入力制御では、3D空間上の仮想メニューである ADVMenu の決定処理を仲介するために、HTMLのDOM要素を取得して擬似的にクリックを発生させる「DOMハック」を採用していた。

// ❌ 従来の不整合コード例(Three.js空間のコンポーネントをDOMから叩こうとして破綻)
else if (e.key === 'Enter' || e.key === ' ') {
  const saveMenuConfirmBtn = document.getElementById('wired-save-confirm-proxy');
  if (saveMenuConfirmBtn) saveMenuConfirmBtn.click();
}

しかし、React Three Fiber(R3F)のコンポーネントツリーから出力される <object3D><mesh> はThree.jsの仮想オブジェクトであり、ブラウザのHTMLソース上(リアルDOM)には対応する実体が存在しない。また、Three.jsオブジェクトが持つ id プロパティはシステム固有の読み取り専用(Read-only)属性であるため、文字列の代入操作そのものがR3Fコア(applyProps)で Cannot assign to read only property 'id' のTypeErrorを引き起こし、アプリケーションがクラッシュする原因となっていた。

この不整合を根本から解決するため、DOM依存の設計を完全に撤廃し、Reactの関数駆動の原則に則った Ref直結型実行プロトコル へと移行した。

  1. ADVManager 内に、決定処理のロジックを安全に仲介・退避させるための saveConfirmProxyRef = useRef<(() => void) | null>(null) を定義。
  2. JSX側の ADVMenuonExecute に渡すクロージャ(セーブ・ロードの内部ロジック)を、評価時に直接 saveConfirmProxyRef.current へ代入して関数インスタンスをバインド。
  3. キーボードの Enter / Space 入力時、およびVRコントローラーのトリガー発火時は、DOMを介さずRefに保持された関数オブジェクトをダイレクトにキックする構造に刷新。
// 📄 1. キーボード入力監視 useEffect 内での関数直叩き
const handleSaveMenuKeyDown = (e: KeyboardEvent) => {
  if (!isSaveMenuOpen) return;
  // ...(上下カーソル選択処理)
  else if (e.key === 'Enter' || e.key === ' ') {
    // 🟢 修正:DOMハックを廃止し、Refから直接ロジックを実行する
    if (saveConfirmProxyRef.current) {
      saveConfirmProxyRef.current();
    }
  }
};
// 📄 2. JSX側:ADVMenu コンポーネントマウント時の関数プロキシバインド
<ADVMenu
  title={
    saveMenuMode === 'TOP' ? '日記帳 (SYSTEM MENU)' :
    saveMenuMode === 'SAVE' ? '記録するスロットを選択' : '再開するスロットを選択'
  }
  selectedIndex={selectedSaveSlot}
  commands={/* スロットデータの文字列表現 */}
  onMenuChange={(idx) => {
    setSelectedSaveSlot(idx);
    audioController.playSe('SE_CURSOR_MOVE', 0.4);
  }}
  onExecute={(() => {
    const executeLogic = () => {
      audioController.playSe('SE_SYSTEM_DECIDE', 0.5);

      if (saveMenuMode === 'TOP') {
        if (selectedSaveSlot === 0) { setSaveMenuMode('SAVE'); setSelectedSaveSlot(0); }
        else if (selectedSaveSlot === 1) { setSaveMenuMode('LOAD'); setSelectedSaveSlot(0); }
        else if (selectedSaveSlot === 2) { setIsSaveMenuOpen(false); }
      }
      else if (saveMenuMode === 'SAVE') {
        const slotId = selectedSaveSlot + 1;
        const saveData = {
          playerLevel, playerExp, playerHp, playerMp, currentStageId,
          spawnPosition: [avatarPos.x, avatarPos.y, avatarPos.z],
          avatarRotY
        };
        localStorage.setItem(`WIRED_SAVE_SLOT_${slotId}`, JSON.stringify(saveData));
        setSaveMenuMode('TOP'); setSelectedSaveSlot(0);
      }
      else if (saveMenuMode === 'LOAD') {
        const slotId = selectedSaveSlot + 1;
        const raw = localStorage.getItem(`WIRED_SAVE_SLOT_${slotId}`);
        if (raw) {
          const data = JSON.parse(raw);
          setPlayerLevel(data.playerLevel);
          setPlayerExp(data.playerExp);
          setPlayerHp(data.playerHp);
          setPlayerMp(data.playerMp);
          setCurrentStageId(data.currentStageId);
          spawnRef.current.set(data.spawnPosition[0], data.spawnPosition[1], data.spawnPosition[2]);
          setAvatarPos(spawnRef.current.clone());
          setAvatarRotY(data.avatarRotY);
          setBattleReturnTrigger(prev => prev + 1);
        }
        setSaveMenuMode('TOP'); setSelectedSaveSlot(0);
      }
    };

    // 💡 決定打:実行ロジックの実体をRefに直結、DOM未検出による無反応バグを完全駆逐
    saveConfirmProxyRef.current = executeLogic;
    return executeLogic;
  })()}
/>

このリファクタリングにより、document.getElementById が常に null を返しメニューが完全にフリーズしていたバグが霧散し、localStorage に対するスロットごとのバイタル(HP/MP)、進行レベル、詳細座標のシリアライズ(保存)とデシリアライズ(復元・ロード)が100%安全に連動するようになった。


1-3. 物理演算・カメラの安全隔離

セーブ画面の展開時に、三人称視点のカメラ座標が天井付近の遥か彼方にすっ飛んで虚空を見上げる、あるいは特定座標でカメラフリーズが発生するという致命的な不整合が存在していた。

このバグの真の原因は、useFrame 描画ループにおける条件分岐のスコープ境界線の破綻にあった。

// ❌ 従来の破綻していた境界条件(バグのトリガー)
useFrame((state, delta) => {
  if (gameState === 'BATTLE' || isSaveMenuOpen) {
    // 💡 探索中のセーブメニュー展開時であっても、この巨大なブロックの内部に引きずり込まれる
    if (gameState === 'BATTLE') {
      // バトル専用のカメラ固定処理(探索中は当然ここを素通りする)
    }

    // 💀 問題の領域:敵の突撃物理演算、 lerp 接近、タイマーディレイなどが
    // 探索フェーズ(gameState === 'EXPLORE')かつセーブ開帳中の状態に対して毎フレーム実行される。
    // 結果、内部行列やカメラスタックのクォータニオンが不正値(NaNなど)に汚染され、カメラが天井へ吹き飛ぶ。
  }
});

探索中(マイルームなど)に日記帳を開くと、isSaveMenuOpen === true であるためにバトル用の演算ブロックの内部へ処理が突入してしまっていた。その結果、本来はエンカウント時のみ有効化されるべきエネミーの移動補間(lerp)やカメラ上書きのバッファ、ターン制御用のタイムラインが意図しないタイミングで牙を剥き、カメラのワールド行列を異常な値へ書き換えていたのである。

この競合を完全に断つため、useFrame 内におけるフェーズの進入条件を gameState === 'BATTLE' 単体へと厳格化(完全隔離) した。

// 🟢 修正:useFrame内の各フェーズ制御の厳密なカプセル化
useFrame((state, delta) => {

  // 1. バトルモード中の固定カメラプロトコル & 敵の突撃物理演算群
  if (gameState === 'BATTLE') {
    const isXR = state.gl.xr.isPresenting;

    // PC環境の固定カメラマウント
    if (!isXR && fadeState !== 'fade-out') {
      const battleCameraPosition = new THREE.Vector3(2.0, 2.5, 5.5);
      const battleTarget = new THREE.Vector3(0, 0.5, 0);
      state.camera.position.copy(battleCameraPosition);
      state.camera.lookAt(battleTarget);
    }
    // ...(VRカメラリグのイレイナ背後へのパイルバンカーおよび敵の突撃 lerp 演算)
  }

  // 2. 探索中かつセーブメニュー開帳時の独立入力プロトコル
  if (gameState === 'EXPLORE' && isSaveMenuOpen) {
    // 💡 バトル側のカメラや物理空間を1ミリも汚さずに、VRコントローラーや
    // 各種フラグの安全な状態監視だけを単独で実行する
  }

  // ...(以下、不透明度の線形補間やフェードシールドのカメラ吸着ハック)
});

この境界線の再構築により、マイルームで日記帳を展開した際には、ADVController が維持している適正なカメラアングル(ベッドや水槽が見える正常な視野)が完全に保護される。

さらに、日記帳からの脱出経路としてキーボードの Escape キー、および Backspace キーの入力を検知した際、安全に setIsSaveMenuOpen(false) をインジェクションして探索の動的状態へ何事もなかったかのように復帰するシーケンスを確立した。バトル側の制御コードが探索側のコンテキストを汚染する危険性は構造上100%消失した。

2. バイタル完全復元型・回復(休憩)システムの追加

RPGやアドベンチャーゲームにおいて、フィールド上の特定のオブジェクト(ベッドなど)とインタラクトしてリソースを回復させる仕組みは、ゲームの進行テンポを制御する上で極めて重要である。本開発では、ステージ設定に組み込まれた healPoints をトリガーとし、画面の暗転(フェードアウト・フェードイン)演出とプレイヤーの動的ステータス回復を完全に同期させた「バイタル完全復元型・回復(休憩)システム」を実装した。


2-1. 2D平面距離計算に基づくベッド近接トリガーの確立

回復ポイントへの接近判定は、高低差(Y軸)のノイズによる誤判定を防ぎ、プレイヤーの直感的な操作感と一致させるため、THREE.Vector2 を用いたXZ平面(2D平面)上での距離計算プロトコルを構築した。

毎フレームの描画ループ(useFrame)内で、ステージJSON(stageConfig.healPoints)から定義された座標群を走査し、イレイナの現在地(avatarPos)との平面距離をリアルタイムに測定する。

// 🛏️ 探索中の回復(ベッド)ポイント接近判定(useFrame内)
if (gameState === 'EXPLORE' && (stageConfig as any).healPoints) {
  let nearestHealPointId: string | null = null;

  for (const hp of (stageConfig as any).healPoints) {
    // XZ平面上での近接判定を正確に行うため、Vector2にマッピング
    const npcXZ = new THREE.Vector2(hp.position[0], hp.position[2]);
    const playerXZ = new THREE.Vector2(avatarPos.x, avatarPos.z);

    if (npcXZ.distanceTo(playerXZ) < hp.radius) {
      nearestHealPointId = hp.id;
      break;
    }
  }

  if (nearestHealPointId !== activeHealPointId) {
    setActiveHealPointId(nearestHealPointId);
    if (nearestHealPointId && !isHealMenuOpen && !isResting) {
      // ベッドに近づいたら探索の足を止め、フワッとUIを展開
      setIsHealMenuOpen(true);
      setSelectedHealIndex(0); // 初期カーソルは YES (はい)
      setIsMoving(false);      // イレイナの歩行アニメを直直立待機にロック
      audioController.playSe('SE_SYSTEM_DECIDE', 0.5);
    } else if (!nearestHealPointId) {
      setIsHealMenuOpen(false); // 判定圏内から離れたら自動で閉じる
    }
  }
}

技術的な要衝として、有効半径(radius)である2メートル以内に進入した瞬間、メニューの開帳(setIsHealMenuOpen(true))と同時に setIsMoving(false) を強制注入してプレイヤーの足を完全に止めさせている点が挙げられる。これにより、メニューが開いている最中にキャラクターが慣性や入力の残効で勝手に部屋を歩き回るバグを構造的に排除した。

さらに、展開される回復確認メニュー(ADVMenu)は、セーブメニューで確立した「カメラ対面型ビルボードハック」をそのまま継承している。rotation.ycamera.rotation.y を直撃させることで、プレイヤーが部屋のどの角度からベッドにアプローチした場合でも、常に視界の真正面(視線方向の1.1m先)に「休憩しますか?」のダイアログが美しく正対してマウントされる仕組みを構築した。


2-2. クロスフェード演出シーケンスとステータス復元プロトコルの同期

回復処理の実行時(メニューで「YES」を選択、あるいはVRコントローラーのトリガーが引かれた瞬間)は、単に数値を書き換えるのではなく、非同期のタイムライン制御(setTimeout)を用いた重厚なクロスフェード演出シーケンスへと移行する。

// 🛏️ 休憩実行シーケンス・プロトコル
const handleExecuteHeal = () => {
  if (selectedHealIndex === 1) {
    // 「NO (いいえ)」を選んだ場合はUIを閉じる
    setIsHealMenuOpen(false);
    audioController.playSe('SE_SYSTEM_DECIDE', 0.5);
    return;
  }

  // 「YES (はい)」の場合、演出開始!
  setIsResting(true);
  setIsHealMenuOpen(false); // メニュー文字を即座に消去
  setFadeState('fade-out');  // 黒幕を閉じる(暗転開始)

  setTimeout(() => {
    // 1. 🎵 画面が完全に真っ暗になった絶妙なタイミングで回復SEを発火
    audioController.playSe('SE_SYSTEM_DECIDE', 0.6);

    // 2. 🧬 バイタルの完全復元ハック
    // レベルに応じた成長テーブルから最大値を引き出し、HPとMPを完全全回復させる
    setPlayerHp(currentGrowth.maxHp);
    setPlayerMp(currentGrowth.maxMp);
    console.log("🧬 VITAL_RESTORATION: イレイナのHPおよびMPが全回復しました。");

    // 3. ⏱️ 2秒間しっかり眠る「余白の美学」をキープしてから、朝日と共に明るく戻す
    setTimeout(() => {
      setFadeState('fade-in'); // 明るくフェードイン

      setTimeout(() => {
        setFadeState('none');
        setIsResting(false); // 休憩フェーズ完全終了
        console.log("☀️ REBOOT_INN: イレイナはすっきりと目を覚ましました。");
      }, 600);
    }, 2000); // 完全な睡眠時間(2秒間の非同期ディレイ)

  }, 400); // フェードアウト(マテリアルのlerp)にかかる暗転時間
};

この演出における設計思想の核心は、「画面が完全に暗転しきった瞬間(400ms後)」を狙ってステータスの最大復元を実行している点にある。画面が視覚的に遮断された状態(余白)の裏でシステムデータを書き換えることにより、プレイヤーの没入感を一切削ぐことなく、安全にバイタルの書き換えを完了できる。

データ書き換え後は、2秒間の「睡眠の余白時間」を維持したのち、フェードインによってマイルームの描画を滑らかに復帰させる。この一連のタイムライン制御により、暗転からバイタル回復、そして探索フェーズへの復帰までが1ミリのラグもなくシームレスに結合された、極めて完成度の高い休憩シーケンスが完成した。

3. 重層波紋リング & 光粒子(パーティクル)エフェクトの実装

探索フィールド(EXPLOREフェーズ)における没入感を高める視覚演出として、データ駆動でカラーリングが変化する「重層波紋リング」および、GPUの描画特性を最大限に活かした「舞い上がる光粒子(パーティクル)システム」を統合した高性能エフェクトコンポーネント WavyRing を実装した。Three.jsの低レイヤAPIを組み合わせることで、リッチなSF・サイバー演出と圧倒的な軽量動作を両立させている。


3-1. 進捗Refと加算合成によるサイバー波紋リングの動的表現

フィールド内の重要インフラである各ポイント(ワープポイント=金、セーブポイント=紫、回復ポイント=エメラルドミント)の足元には、波紋が広がり続ける3Dリング演出を配置した。

この動的演出をReactのState(状態)で管理すると、毎フレームの座標・スケール更新によってコンポーネント全体に超高速な再レンダリング(Re-render)が発生し、VR環境での致命的なフレームレート低下を招く。これを回避するため、時間経過に伴う波紋の広がり(進捗度)はすべて Reactのレンダリング機構を介さない固定メモリ領域(useRef) で管理・計算する構造を採用した。

// 🌊 重層波紋の初期進捗オフセット定義
const ringProgressRefs = useRef([0.0, 0.33, 0.66]);

useFrame((state, delta) => {
  // ① レンダリングを完全にバイパスし、Refの数値だけを毎フレーム超爆速更新
  ringProgressRefs.current = ringProgressRefs.current.map((progress) => {
    let nextProgress = progress + delta * 0.45; // 拡張速度の補間
    if (nextProgress > 1.0) nextProgress = 0.0; // 限界値に達したら中央リスタート
    return nextProgress;
  });
});

JSXの描画層では、この 3 つの位相がずれた進捗数値を元に、THREE.RingGeometry のスケールと不透明度をリアルタイムに反比例計算してマッピングしている。

  • スケール計算: progress * radius によって、中央の原点から外側へ向かって線形に滑らかに拡張する。
  • 不透明度(Opacity)計算: (1.0 - progress) * 0.7 によって、外周に向かって広がるにつれてフフワッと光が淡く消えていく減衰ロジックを構成。

マテリアルのブレンドモードには blending={THREE.AdditiveBlending}(加算合成) を指定。波紋が重なり合う中心部や色の高密度な領域が物理的に「発光」しているかのような美しいサイバー質感を表現した。同時に depthWrite={false} をインジェクションすることで、半透明オブジェクト固有の描画順のねじれやチラつき(Zファイティング)を構造的にシャットアウトしている。


3-2. BufferAttribute直写による超爆速 Points パーティクルシステム

リングの中心から空間へ向かってフワフワと舞い上がる光の粒子演出には、個別のメッシュ(<mesh>)を多数並べる重い設計を捨て、1つの描画コールで全粒子を一括レンダリングする THREE.Points(ポイント描画システム) を構築した。

15粒のパーティクルごとに「生の現在座標(THREE.Vector3)」「上昇速度」「寿命(age)」「最大寿命(maxAge)」を保持するオブジェクト配列を useMemo から particlesRef.current に格納。

useFrame ループ内では、仮想的な物理演算を行うと同時に、GPUが直接読み込む生の頂点バッファ配列(Float32Array)へ、計算結果のXYZ座標をダイレクトに直写(インデックス上書き)するプロトコルを確立した。

// ✨ 舞い上がる光粒子の物理演算 & バッファ直写プロトコル
if (pointsRef.current) {
  const geo = pointsRef.current.geometry;
  const posAttr = geo.attributes.position; // GPU直結の頂点バッファ
  const FloatArray = posAttr.array as Float32Array;

  particlesRef.current.forEach((p, idx) => {
    p.age += delta;
    p.pos.y += p.speedY * delta; // 上高度へ向かってフワフワと浮上

    // ── 🔄 自動循環ロジック ──
    // 個別の最大寿命を迎えるか、あるいは高度が1.5mを超えたら床面からリスタート
    if (p.age > p.maxAge || p.pos.y > 1.5) {
      p.pos.set(
        (Math.random() - 0.5) * radius * 1.2, // リングの内周にランダム飛散
        0.0,                                  // 地面(床)から再度湧き出す
        (Math.random() - 0.5) * radius * 1.2
      );
      p.age = 0;
    }

    // 💡 決定打:仮想オブジェクトを生成せず、生配列の対応インデックスへ直接座標をインジェクション
    // 配列の並び:[x0, y0, z0, x1, y1, z1, ...]
    const stride = idx * 3;
    FloatArray[stride]     = p.pos.x;
    FloatArray[stride + 1] = p.pos.y;
    FloatArray[stride + 2] = p.pos.z;
  });

  // ⚡ GPUへ「生データが書き換わったので、次フレームで即座に再描画せよ」と一瞬で通知
  posAttr.needsUpdate = true;
}

この実装が持つ最大のエンジニアリング的利点は、Reactの仮想DOM(Virtual DOM)の差分検知やCPUのオブジェクト生成コストを完全にバイパスしている点にある。

15粒の光粒子がそれぞれ独立した速度と個別の寿命サイクル(2〜4秒)を持って床面から湧き出し、上空へとフワフワ消えていく複雑なパーティクル演出を動かすにあたり、React側にかかる負荷は「ゼロ」である。

マテリアルには軽量な pointsMaterial を採用し、size={0.04}(4cm四方)の淡い光の粒子として定義。重層波紋リングと同様に加算合成(AdditiveBlending)と depthWrite={false} を適用することで、背景の3Dステージモデルの描画を一切阻害せず、最前面でSFチックに発光して舞い上がる、極めて軽量かつ美しい空間エフェクト基盤が完成した。

4. バトルモードにおけるカメラ・入力プロトコルのブラッシュアップ

探索フェーズから戦闘(BATTLE)フェーズへ切り替わった際、3D空間内での視覚的な整合性を保つためのカメラ制御、およびVR空間におけるメニュー操作の安定性を極限まで高めるため、デバイス環境(PC/VR)を横断する「ハイブリッドカメラシステム」と「ワンショット限定入力ガード」を実装した。


4-1. デバイス環境を自動判別するPC・VRハイブリッドカメラマウント

戦闘が開始された瞬間、ゲームエンジンは現在のクライアントがWebXR(VR)提示中であるか、通常のPCブラウザ環境であるかを state.gl.xr.isPresenting から自動判別し、カメラの配置・マウントプロトコルを完全に切り替える。

  • PCブラウザ環境(非XR環境): 戦場の全景とキャラクター・エネミーの対峙位置を最も美しく俯瞰できる、固定のカメラ座標 [2.0, 2.5, 5.5] へ強制同期。同時に、視線(ターゲット)を空間の中心である [0, 0.5, 0] にカチッと完全固定(lookAt)させることで、シネマティックでブレのないターン制バトルの視界を確立した。また、ステージ遷移や暗転(fade-out)の開始を検知した瞬間、この上書きを即座に停止させて探索側のコントローラーへ安全にバトンを渡すガードを仕込み、画面外へのすっ飛びを100%防いでいる。
  • WebXR(VR)環境: VR空間におけるカメラの生座標(state.camera.position)は、常にヘッドマウントディスプレイ(HMD)の物理的な位置トラッキングに支配されている。そのため、生座標を直接書き換えると視界の強烈なねじれやVR酔いを引き起こす。これを防ぐため、VR環境時はカメラそのものではなく、プレイヤーのトラッキング空間の基準点である親リグ(camera.parent)の座標行列を直接制御するのが絶対鉄則となる。
// 🕶️ VR環境限定・カメラリグ(親)のイレイナ背後へのパイルバンカー(useFrame内)
} else if (isXR && camera && camera.parent) {
  // イレイナの初期位置(playerStartPosition)の真後ろへ、親リグごと移動
  camera.parent.position.set(
    battleConfig.playerStartPosition[0] + 0.5, // イレイナと同じX軸(わずかに右オフセット)
    0.0,                                       // 高度(Y)はリアル床基準(0)にするのがVRの鉄則
    battleConfig.playerStartPosition[2] + 1.0  // イレイナの後ろに下がった位置(Z)
  );

  // 視線(向き)は戦場の中心(0, 0.5, 0)へ完全に固定
  camera.parent.lookAt(new THREE.Vector3(0, 0.5, 0));
}

このマウント処理により、VRプレイヤーは「イレイナの斜め後方(背後1.0m)」の等身大の視点へ原点ごと強制グラップ(追従)される。高度をリアル床(Y: 0.0)にロックした上で戦場の中心を見つめる構造としたことで、VRにおける空間のスケール感を完璧に維持した臨場感溢れるバトル視界を実現した。


4-2. VRコントローラー入力統合と「ワンショット限定ガード」による連打バグの絶滅

VR環境でのメニュー(コマンド選択や日記帳のスロット選択)操作において、アナログスティックの極めて高速なスキャンループが原因で、1回の入力でカーソルが何項目も爆速ですっ飛んでしまう現象や、画面遷移直後に背後でトリガーの残りカス判定を拾って多重誤決定(多重発火)してしまうStateラグバグが課題となっていた。

このバグを構造的に絶滅させるため、左右のVRコントローラーから送られる生の入力データを単一の純粋な数値へ集約(統合)し、物理的なロック機構を介して制御する「ワンショット限定入力ガード」プロトコルを確立した。

// ── 🎯 🕶️ 【バグ完全絶滅:VRコントローラー入力統合プロトコル】 ──
const xrSession = state.gl.xr.getSession();
if (xrSession && xrSession.inputSources) {
  let maxStickY = 0; // 左右で「一番深く傾いている手の入力」を抽出する器
  let triggerFired = false;

  // ① まずループを回して、左右のコントローラーの入力を1つの純粋な数値に集約する
  for (let i = 0; i < xrSession.inputSources.length; i++) {
    const source = xrSession.inputSources[i];
    const gamepad = source.gamepad;
    if (gamepad) {
      const sY = gamepad.axes[1] || gamepad.axes[3] || 0;
      // 絶対値が大きい方を採用(一番深く倒している手の入力を信じる)
      if (Math.abs(sY) > Math.abs(maxStickY)) {
        maxStickY = sY;
      }
      // トリガーや各種決定ボタンの押し込みを厳密にスキャン
      if (gamepad.buttons[0]?.pressed || gamepad.buttons[4]?.pressed) {
        triggerFired = true;
      }
    }
  }

  // ② 集約した1つの入力をベースに、ロック状態を厳密に監視・制御
  if (battlePhase === 'PLAYER_TURN' || isSaveMenuOpen) {
    if (!vrInputLockedRef.current) {
      // まだ物理ロックがかかっていない時だけ、深い傾き(0.6以上)を検知してカーソル移動
      if (maxStickY < -0.6) {
        // スティックを上へ:引数に関数形式 (prev) を使うことで同フレーム内のStateラグを100%回避
        setSelectedMenuIndex((prev) => (prev === 0 ? COMMANDS.length - 1 : prev - 1));
        audioController.playSe('SE_CURSOR_MOVE', 0.4);
        vrInputLockedRef.current = true; // ガチッと物理ロック!
      }
      else if (maxStickY > 0.6) {
        // スティックを下へ
        setSelectedMenuIndex((prev) => (prev === COMMANDS.length - 1 ? 0 : prev + 1));
        audioController.playSe('SE_CURSOR_MOVE', 0.4);
        vrInputLockedRef.current = true; // ガチッと物理ロック!
      }
    } else {
      // 🎯 【デッドゾーン解放ガード】
      // 左右どちらのスティックも中央(0.15未満のデッドゾーン)へ戻り、かつトリガーも完全に離された時だけロックを釈放
      if (Math.abs(maxStickY) < 0.15 && !triggerFired) {
        vrInputLockedRef.current = false;
      }
    }

    // ── ⚡ トリガーによるワンショットコマンド決定 ──
    if (triggerFired && !vrInputLockedRef.current) {
      vrInputLockedRef.current = true; // 決定した瞬間に即座にロック
      handleExecuteCommand();
    }
  }
}

この入力システムのエンジニアリング的ブレイクスルーは、スティックが閾値(0.6)を超えた瞬間に vrInputLockedRef.current = true で即座に処理の進入路を物理的にロックし、「プレイヤーが一度指を中央のデッドゾーン(0.15 未満)へ戻すまで、次のカーソル入力を100%受け付けない」という完全なディスクリート(離散)制御を達成した点にある。

さらに、決定トリガーに対しても同様のニュートラルチェックを義務付けたことで、メニュー選択が確定して次のフェーズ(アニメーションや画面遷移)に切り替わった瞬間に、指を離すまでのわずかな残りカス入力をシステムが誤検知して発生していた「多重決定バグ」が完全に消滅。VR空間内におけるUI操作の挙動が、ゲーム専用機と同等レベルのクリーンさと堅牢さで確定されるに至った。

5. 各種バグの根本的修正

アプリケーションが複数のフェーズや多数の3Dアセット(VRM)を跨いで駆動するにつれ、グラフィックメモリ(VRAM)の飽和によるハードウェア起因のフリーズ、およびVR(WebXR)空間特有の視点(カメラ空間)の不整合が顕在化した。本セクションでは、VRMアセットのライフサイクルに連動した完全なメモリ解放プロトコルと、HMDの移動・傾きに完全追従するY軸固定型の3Dビルボード(吹き出し)の方向補正ロジックについて解説する。


5-1. キャッシュクリアとディープディスポーズによるVRM複数同時展開時のフリーズ解消

アバターやNPCとしてVRMモデルをシーン切り替え操作のたびに何体もロードすると、4体目付近に達した瞬間にメインスレッドが完全にロックされ、WebXRセッションごとブラウザがクラッシュ(ハングアップ)する致命的な現象が発生していた。

このフリーズを引き起こしていた構造的原因は、「React Three Fiber(R3F)内部のローダーキャッシュの永久肥大」と、「Three.jsのVRAM資産の残留」の2重のメモリリークにあった。

  1. R3Fローダーキャッシュの飽和: useLoader(GLTFLoader, url) は、一度読み込んだ3Dモデルの構成データをグローバルなMapオブジェクトに永久キャッシュする。そのため、ステージ遷移によって画面上からNPCコンポーネントが消滅(アンマウント)しても、元のデータはメモリから1ミリも解放されず居座り続ける。
  2. GPUメモリ(VRAM)の居座り: Three.jsの仕様上、JavaScript側のオブジェクト参照を外してメッシュをシーンから削除(scene.remove)しても、そのメッシュが参照している頂点データ(Geometry)や肌・衣類の画像データ(Texture)はGPU側のVRAMに確保されたまま解放されない。これが累積し、HMDのハードウェア限界を超えた瞬間に確定フリーズを誘発していた。

このリソースバーストを根本から根絶するため、ステージ遷移のライフサイクル(useEffect)に連動した 「ローダーキャッシュの強制エバキュエーション(追い出し)」 と、コンポーネント消滅時の 「VRAMの明示的ディープディスポーズ」 のハイブリッドクリーンアップ機構をインフラ層に整備した。

// 📄 ADVManager.tsx : ステージ遷移時のローダーキャッシュ強制解放プロトコル
useEffect(() => {
  const prevStageId = prevStageIdRef.current;

  // 初回マウント時をスキップし、ステージIDが真に変更されたタイミングをフック
  if (prevStageId && prevStageId !== currentStageId) {
    const prevStage = STAGES.find(s => s.id === prevStageId);
    if (prevStage?.npcs) {
      prevStage.npcs.forEach((npc: any) => {
        // 💡 決定打1:R3Fのグローバルキャッシュから該当のNPCモデルURLを完全に追放
        useLoader.clear(GLTFLoader, npc.file);
        console.log(`♻️ STAGE_TRANSITION: 旧ステージNPC [${npc.file}] のキャッシュを解放しました`);
      });
    }
  }
  prevStageIdRef.current = currentStageId;
}, [currentStageId]);
// 📄 ADVNPC.tsx : コンポーネントアンマウント時のVRAMディープディスポーズプロトコル
useEffect(() => {
  if (!gltf || !gltf.userData.vrm) return;
  vrmRef.current = gltf.userData.vrm;
  const mixer = new THREE.AnimationMixer(gltf.scene);
  mixerRef.current = mixer;

  // 💡 決定打2:NPCが消滅する瞬間に、Pixiv @pixiv/three-vrm 公式のディープクリーンアップを実行
  return () => {
    mixer.stopAllAction();
    const vrm = vrmRef.current;
    if (vrm) {
      // ジオメトリ、マテリアル、テクスチャ、SpringBoneの物理バッファをGPUから100%完全剥離
      VRMUtils.deepDispose(vrm.scene);
      console.log(`♻️ NPC_DISPOSE: VRMのGPU資産を解放 [${modelUrl}]`);
    }
    vrmRef.current = null;
    mixerRef.current = null;
  };
}, [gltf, modelUrl]);

このクリーンアップインフラの確立により、ステージを切り替える、あるいはNPCの有効判定圏外へ脱出してアバターがアンマウントされた瞬間に、数万ポリゴンに及ぶ頂点バッファと高解像度なテクスチャアトラスがVRAMから確実に消去されるようになった。どれだけ多くのキャラクターを動的に切り替えて展開しても、メモリ消費量が一定のクリーンな定常状態に維持され、フリーズバグは構造から完全に消滅した。


5-2. WebXRカメラアトラスに基づく吹き出し(SpeechBubble)の方向・直立補正

NPCが2メートル以内に接近した際、その頭上にセリフ(talkText)をフロート展開する3D吹き出しシステム(ADVSpeechBubble)において、VR(WebXR)モードを起動した瞬間に、吹き出しのウインドウが明後日の方向を向いてしまったり、プレイヤーの首の傾き(ロール回転)に同期して文字板が斜めに傾いてしまいテキストが読めなくなる視覚的レイアウト破綻が発生していた。

この問題の根本原因は、PCブラウザ環境(非XR環境)を想定した従来の三人称ビルボード処理(lookAt(camera.position))が、VR特有のマルチカメラ構造(左右の眼球用ArrayCamera)とHMDのワールド行列を正しく捕捉できていなかった点にある。VR空間において state.camera を直接参照すると、トラッキングの原点座標しか取得できず、プレイヤーの実際のHMD(頭部)の位置および首のクォータニオンと完全に乖離してしまう。

このVR空間における表示のねじれを完全にデトックスするため、WebGLRendererのXRサブシステム(gl.xr)から生のXRカメラアトラスを抽出し、首の傾きを打ち消すY軸限定の直立対面ビルボードアルゴリズムを組み込んだ。

// 📄 ADVSpeechBubble.tsx : 首の傾き完全デトックス・VR対応ビルボード制御(useFrame内)
useFrame(({ camera, gl }) => {
  if (!groupRef.current) return;

  // 🎯 【VR環境とPC環境のハイブリッドカメラ自動識別】
  // gl.xr.isPresenting が true(VRゴーグル提示中)なら、
  // ArrayCameraの中点座標を保持している本物のXRカメラオブジェクト(gl.xr.getCamera())を強制抽出
  const renderCamera = gl.xr.isPresenting ? gl.xr.getCamera() : camera;

  // 1. プレイヤーの頭部の正確なワールド空間の絶対座標を算出
  const headPos = new THREE.Vector3();
  renderCamera.getWorldPosition(headPos);

  // 2. 3D吹き出しウインドウの現在のワールド空間の絶対座標を算出
  const bubblePos = new THREE.Vector3();
  groupRef.current.getWorldPosition(bubblePos);

  // 🎯 【首の傾き(ロール・ピッチ)の完全相殺ハック】
  // ターゲットである頭部座標の高度(Y座標)を、吹き出し自身の高度(Y座標)と完全に同一化
  headPos.y = bubblePos.y; // Z軸・X軸の傾き成分を物理的に消去
  groupRef.current.lookAt(headPos);

  // 3. 念のためクォータニオンからYXZ順のオイラー角を再抽出し、X(ピッチ)とZ(ロール)をゼロに固定
  const euler = new THREE.Euler().setFromQuaternion(groupRef.current.quaternion, 'YXZ');
  groupRef.current.rotation.set(0, euler.y, 0); // Y軸(垂直方向の旋回)のみを適用し、直立を強制保証
});

この補正ロジックの要衝は、gl.xr.getCamera() から取得したVRアトラス空間上の絶対座標を抽出し、headPos.y = bubblePos.y の等価代入によって 「カメラと同じ水平面上にターゲットを強制配置」 している点にある。

これにより、プレイヤーがVR空間内でどれだけ首を左右に傾けようが(ロール回転)、あるいは上空を見上げたりベッドに寝そべるように高度を変えようが(ピッチ回転)、3D空間上の吹き出し板は常に地面に対して正確に垂直(90度)を維持する。

サイバーネイビー(#001122)の背景とシアン(#00f2fe)の枠線で発光するエッジ(Edges)、およびフォント(DotGothic16)でレンダリングされたセリフテキストが、プレイヤーの視線のド真ん中に向かって常に美しく正対(ビルボード対面)する、VRに最適化された完璧な3D会話UIレイアウトが完成した。