[Astro #92] LAYER FIGHTER // VR完全対応・サウンドテスト3D化・確認ダイアログ・ゲームループ修正

[Astro #92] LAYER FIGHTER // VR完全対応・サウンドテスト3D化・確認ダイアログ・ゲームループ修正

はじめに

前回 [Astro #91] でCanvas統合・VR対応土台・バトルUI刷新を行った。

今回はその土台の上にVR実機での動作を目標に一気に詰めた回だ。

Quest2・Quest3で実際にプレイできるレベルまで持っていくことを目標にした。

動画:

1. VRタイトルメニュー完成

前回実装途中だったVRタイトルメニューを完成させた。

Xボタンでメニューパネルをトグル表示し、右スティックで選択・右トリガーで決定できるようにした。

// useFrame内でXボタンを検出
const xPressed = gp.buttons[4]?.pressed;
if (xPressed && !xMenuPressedRef.current) {
  const nextVisible = !vrMenuRef.current.visible;
  vrMenuRef.current.visible = nextVisible;
  if (nextVisible) {
    vrMenuRef.current.position.set(0, 1.0, 1.9); // 固定座標
  }
}

メニューの位置はカメラ追従ではなく座標固定にした。Billboardで追従させるとパネルが暴れて酔いやすいため、表示時の一発スナップで固定する方式が正解だった。


2. サウンドテスト 3D UI化

<Html> でDOMとして実装していた FTGSoundTest をPC/VR共通の3D UIパネルに全面刷新した。

背景:部屋のGLBをシーンに追加

サウンドテスト専用のROOMモデル(Pink Bedroom / MiSide)をシーンに配置し、その前に浮かせる形でUIパネルを表示する構成にした。

// Assets.json
"SoundTestObjects": [
  {
    "id": "FTG_SOUND_TEST_ROOM",
    "modelUrl": "/models/FTG/pink_bedroom_miside.glb",
    "scale": [3.4, 3.4, 3.4],
    "position": [0, 0.5, 0],
    "rotation": [0, 1.5708, 0]
  }
]

3D UIパネルの実装

@react-three/dreiText + mesh で全UIを3D空間に描画した。サイズ・座標は定数で管理して微調整できるようにした。

const PANEL_W = 2.2;
const PANEL_H = 1.4;
const PANEL_POS: [number, number, number] = [0, 1.35, 0.3];

// BGMリスト、タブ、詳細パネル、サムネイル、VOLバーすべてTextとmeshで描画

サムネイル画像はアスペクト比をテクスチャから取得して補正した。

const aspect = texture.image
  ? texture.image.width / texture.image.height
  : 1;
const w = THUMB_SIZE * aspect;

VRコントローラー操作

右スティック上下 → リスト選択
右スティック左右 → タブ切替(BGM/SE/VOICE)
右トリガー       → 再生/停止
左Bボタン        → 戻る

3. 確認ダイアログ実装

ESCキーやVRのXボタンで即タイトルに戻っていた問題を解消するため、全モード共通の確認ダイアログを実装した。

アーキテクチャ

各シーンからは CustomEvent を発火するだけ、受け取りは FTGManager で一元管理する方式にした。

// 各シーン(TrainingScene、SoundTest、CreditSequence)
if (e.key === 'Escape') {
  window.dispatchEvent(new CustomEvent('ftg:requestExit'));
}

// FTGManager
useEffect(() => {
  if (gameMode === 'TITLE') return;
  const handler = () => setShowExitConfirm(true);
  window.addEventListener('ftg:requestExit', handler);
  return () => window.removeEventListener('ftg:requestExit', handler);
}, [gameMode]);

ダイアログ本体は3D空間に Text + mesh で描画した。モード別にカメラ位置が異なるため、座標もモードで切り替えた。

<group position={
  gameMode === 'SOUND_TEST'      ? [0, 1.45, 0.0] :
  gameMode === 'CREDIT_SEQUENCE' ? [0, 0.0, 0.5]  :
  [0, 1.5, 1.0]  // TRAINING
}>

VRではレーザーポインターで YES/NO を直接クリックできる。TextonClick がR3FのRaycasterで機能するため、追加実装不要だった。


4. HUDフラグ管理

バトルモードを途中で抜けた際に KO / YOU WIN などのDOM表示が残留する問題を修正した。

FTGHudStore.tsisTraining / isBattling フラグを追加し、各表示をフラグで制御するようにした。

// FTGHudStore.ts
export const ftgHudRef = {
  current: {
    // ...既存フィールド
    isTraining: false,
    isBattling: false,
  }
};
// FTGBattleHUD.tsx
{hud.isBattling && (
  <>
    {hud.roundPhase === 'KO' && <div>K.O.</div>}
    {/* ... */}
  </>
)}

{hud.isTraining && (
  <div>{/* テンキー・ボタン・FRAMEカウンター・入力ログ */}</div>
)}

アンマウント時にフラグをリセットする処理も追加した。

// FTGTrainingScene.tsx
return () => {
  ftgHudRef.current.isBattling = false;
  ftgHudRef.current.isTraining = false;
  ftgHudRef.current.roundPhase = 'FIGHTING';
};

5. VRゲームループ修正(最重要)

今回の最大の問題だった。

Quest単体(スタンドアロン)でトレーニングモードに入ると、キャラクターがアイドルアニメーションのまま固まる。VRモードを解除してブラウザに戻ると、溜まったフレームが一気に処理されて即死する。

原因

useFTGGameLoop.ts のゲームループが requestAnimationFrame で実装されていた。

// 問題のコード
const tick = (now: number) => {
  // ...全ゲームロジック
  animId = requestAnimationFrame(tick); // ← これが問題
};
animId = requestAnimationFrame(tick);

Quest単体でVRモードに入ると、ブラウザのタブがバックグラウンド扱いになり、通常の requestAnimationFrame が停止する。VRから戻った瞬間に溜まったフレームが一気に処理されて異常動作が起きる。

タイトル画面のアニメーションが止まらなかったのは useFrame(R3F)を使っていたため。R3FはWebXRセッション中に自動的に XRSession.requestAnimationFrame に切り替わるため、VR中でも正常に動作する。

解決

useEffect + requestAnimationFrameuseFrame に移行した。変更は3箇所だけで、ロジック本体は一切触らなかった。

// 変更前
useEffect(() => {
  let animId: number;
  let acc = 0;
  const FT = 1000 / 60;

  const tick = (now: number) => {
    acc += now - lastTime;
    while (acc >= FT) {
      acc -= FT;
      setFrameCount(prev => { /* 全ロジック */ });
    }
    animId = requestAnimationFrame(tick);
  };
  animId = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(animId);
}, [transitionState, pushLog, physicsRef]);
// 変更後
const accRef = useRef(0);
const FT = 1000 / 60;

useFrame((_, delta) => {
  accRef.current += Math.min(delta * 1000, 100); // 最大100msクランプ

  while (accRef.current >= FT) {
    accRef.current -= FT;
    setFrameCount(prev => { /* 全ロジック(そのまま)*/ });
  }
});

accRefuseRef で管理することで、useEffect の依存配列から解放された。

この修正でQuest2・Quest3単体での動作が確認できた。


6. VRラウンド表示

FTGBattleHUD はDOMなのでVR中は見えない。トレーニングシーンに3D Textで表示を追加した。

<Billboard position={[0, 1.8, 0]} follow={true}>
  {roundPhase === 'KO' && (
    <Text fontSize={0.5} color="#ff0000" toneMapped={false}>K.O.</Text>
  )}
  {roundPhase === 'FIGHT_CALL' && (
    <Text fontSize={0.45} color="#ff4444" toneMapped={false}>FIGHT!</Text>
  )}
  {/* YOU WIN / YOU LOSE / ROUND N */}
</Billboard>

今後の課題

  • domOverlay でVRにDOM表示を試す(FTGBattleHUDをVRでそのまま使えるか検証)
  • ボタンコンフィグ設定画面(VRコントローラーの割り当てを変更できるUI)
  • ステージ選択(トレーニング開始時にモーダルで選択)
  • VRカメラキャリブレーション(身長に合わせたXROrigin調整)