[Astro #68] VRコントローラー全ボタン実装・連打防止・マジックバーストエフェクト
前回 Astro #67 でステータス画面・魔法システム・箒モードを一気に実装したが、VR 実機でのテストが追いついていなかった。今日はほぼ丸一日 VR 対応に費やした。
🎯 今日の実装サマリ
| 項目 | 内容 |
|---|---|
| VR 全ボタン実装 | Trigger / Grip / A / B / スティック の役割割り当て |
| Rising Edge 検出 | ボタン長押しで連打しない仕組みを全箇所に導入 |
?? vs || の罠 | スティック入力が完全に死んでいた原因を特定・修正 |
| 吹き出し VR 安定化 | lookAt → atan2 方式でY軸ビルボードを安定化 |
| バトル敗北カメラ修正 | DEFEAT 時のワールド傾きバグを解消 |
| GPU 暖機試行 | renderer.compile() によるカクツキ低減(効果限定的) |
| マジックバースト | 4層構造の究極魔法エフェクトを実装 |
🎮 VRコントローラーのボタンマッピング
キーボード用に実装済みだった全機能を、VR コントローラーにもマッピングした。
| ボタン | WebXR Index | 役割 |
|---|---|---|
| Trigger | buttons[0] | UI 決定 / ジャンプ(状況自動切替) |
| Grip | buttons[1] | 箒モード切替 |
| A/X | buttons[4] | ステータス画面 開閉 |
| B/Y | buttons[5] | キャンセル(UI を閉じる) |
| スティック Y | axes[1]/[3] | メニューカーソル上下 |
トリガーは「UI が開いている時は決定、閉じている時はジャンプ」と状況で自動分岐する。ベッド休憩メニュー、ステータス画面、バトル UI のすべてに対応。
🔫 Rising Edge 検出(連打防止の本質)
VR ではボタンの pressed 状態が毎フレーム true を返すため、何の対策もしないとフレームレート(72fps〜90fps)の速度でコマンドが連打される。
既存の vrInputLockedRef 方式(ロックフラグ)では不十分で、前フレームの状態と比較する Rising Edge 検出を導入した。
const vrPrevButtonRef = useRef({
trigger: false, grip: false, buttonA: false, buttonB: false,
stickLocked: false,
});
// useFrame 内で毎フレーム
const triggerJust = triggerDown && !prev.trigger; // 押した瞬間だけ true
prev.trigger = triggerDown; // 今の状態を記録
「前フレームで未押下、今フレームで押下」の瞬間だけ true になるので、ボタンを押し続けても1回しか発火しない。バトル UI の VR 入力にも同じパターンを適用し、魔法選択直後に MAGIC_SHOT が暴発する問題も解消した。
🕹️ ?? vs || — JavaScript の地雷
ステータス画面でスティック入力が「完全に死ぬ」バグに1時間近く費やした。原因は ??(null 合体演算子)と ||(論理 OR)の挙動の違い。
Quest コントローラーの axes レイアウトでは、axes[1] が常に 0(ニュートラル)を返し、実際のスティック Y 値は axes[3] に入る場合がある。
// ❌ axes[1] が 0 なら「値がある」と判定して axes[3] に進まない
const sy = gp.axes[1] ?? gp.axes[3] ?? 0;
// ✅ axes[1] が 0(falsy)なら axes[3] にフォールバック
const sy = gp.axes[1] || gp.axes[3] || 0;
?? は null / undefined のみスキップ、0 は「有効な値」として返す。|| は 0 を falsy として扱い次の候補に進む。ゲームパッド API では 0(ニュートラル)を「値なし」として扱いたいので || が正解。
同じプロジェクト内の SaveMenu.tsx は最初から || を使っていて問題なく動いていたが、新規実装時に ?? を使ってしまったのが原因。たった1文字でスティックが完全に死ぬ。
💬 吹き出しの VR 安定化
NPC の頭上に表示するスピーチバブルが VR で 90 度回転する問題。原因は lookAt() でワールド回転を計算した後、ローカルクォータニオンから Euler 角を抽出する方式が、親(NPC グループ)の rotationY と干渉していたこと。
atan2 で直接ワールド空間の Y 角度を計算し、親のワールド Y 回転を引いてローカル回転に変換する方式に変更。
const dx = camPos.x - bubblePos.x;
const dz = camPos.z - bubblePos.z;
const worldAngleY = Math.atan2(dx, dz);
// 親のワールド Y 回転を引いてローカルに変換
parent.getWorldQuaternion(parentQuat);
parentEuler.setFromQuaternion(parentQuat, 'YXZ');
groupRef.current.rotation.set(0, worldAngleY - parentEuler.y, 0);
lookAt を完全に排除したことで、NPC がどの方向を向いていても吹き出しが安定してカメラを向くようになった。
💀 バトル敗北時のカメラ傾き
戦闘で HP が 0 になり ROOM に強制送還された際、ワールドが斜めに傾くバグ。VICTORY / ESCAPE では修正済みだったが DEFEAT では再発していた。
カメラリグのリセットコード自体は DEFEAT ブロックにも入っていたが、ADVBattle の useFrame が BATTLE_END フェーズ中もカメラを制御し続けていたため、リセット直後に上書きされていた。
修正は ADVBattle の useFrame 冒頭に1行追加:
useFrame((state, delta) => {
if (!battleConfig) return;
if (battlePhase === 'BATTLE_END') return; // 終了後はカメラ制御を停止
// ...
});
🔥 GPU 暖機の試行
VRM の初回表示時のカクツキを軽減するため、renderer.compile() と renderer.initTexture() による事前 GPU 暖機を実装した。ADVNPC と ADVAvatar の両方に、VRM ロード直後にシェーダコンパイルとテクスチャアップロードを走らせる useEffect を追加。
結果は効果限定的。MToon シェーダのコンパイルとテクスチャアップロードは事前処理できたが、VRM 特有のスキニング初期化や SpringBone の初回計算コストは renderer.compile() では触れない領域。VRM が重いという本質的な制約は残るが、致命的なフリーズ(複数戦闘後の街遷移)は先日の VRMUtils.deepDispose() で解決済みなので、カクツキは許容範囲として次に進む判断をした。
💥 マジックバースト(ADVMagicBurstSystem.ts)
Gemini と一緒に作った究極魔法のエフェクト。4層構造のステートマシンで演出を制御する。
| フェーズ | 時間 | 演出 |
|---|---|---|
| GATHER | 2.0秒 | 200個のワイヤフレーム球が360度から敵に収束 |
| CRITICAL | 1.2秒 | 中央コア球が二乗カーブで青→深紅に変色、回転加速 |
| BURST | 1.5秒 | コア消滅 → 800粒子が爆散 + 足元に衝撃波リング |
ADVHealSystem と同じ imperative API パターン(startBurst() / update() / cleanup())で統一されているので、ADVBattle 側からの呼び出しコードも共通の形式で書ける。
CRITICAL フェーズで progress * progress(二乗カーブ)を使っているのがポイントで、色変化にタメが生まれて爆発の瞬間が際立つ。
🐛 今日嵌ったバグ
| バグ | 原因 | 修正時間 |
|---|---|---|
| スティックが完全に死ぬ | ?? vs || の1文字違い | ~1時間 |
| バトル魔法選択で連打 | Rising Edge 未実装 | ~30分 |
| DEFEAT 後ワールド傾く | useFrame が BATTLE_END 中もカメラ制御 | ~20分 |
| 吹き出し VR で90度回転 | lookAt + Euler 抽出が親回転と干渉 | ~40分 |
gamepad 変数名ミス | SaveMenu のコードを変数名変えずにコピー | 即時 |
📝 残タスク
- VR セーブメニューの操作対応(SaveMenu.tsx 内に既に VR 入力コードあり、要確認)
- NPC 初回表示カクツキの更なる低減(距離ベースの先読み暖機案を検討)
- 箒モデルの差し替え(魔女らしいデザインに)
- Magic.json にダメージ倍率フィールド追加
- MagicDefinition 型の共通化(ADVBattle / ADVManager で重複定義)
🧠 振り返り
今日は VR 特有のバグとの戦い が中心だった。?? vs || は JavaScript の仕様を正確に理解していないと踏む地雷で、AI も最初は ?? を提案してきた。結局、既に動いている SaveMenu のコードを参照して「なぜこちらは || なのか」を考えたのがゴールへの近道だった。
Rising Edge 検出は、ゲーム開発では基本中の基本だが、Web フロントエンド出身だと馴染みがない概念。pressed が毎フレーム true を返すことに気付くまでに時間がかかった。今後の VR 入力は全てこのパターンで統一する。
マジックバーストのエフェクトは Gemini と一緒に作った。ヒールエフェクトに続き、パーティクルの軌道計算と色変化は AI と人間の共同作業で詰めるのが一番効率が良い。骨組みを AI が書き、数字(タイミング・速度・色相)を人間が手で合わせる — このパターンは今後も変わらなさそう。