[Astro #55] WebXR対応フルダイブSTGの実装:VRコントローラーとJSONタイムライン管理

[Astro #55] WebXR対応フルダイブSTGの実装:VRコントローラーとJSONタイムライン管理

はじめに

本日は、開発中のWebXR対応シューティングゲーム(STG)システムにおいて、PCとVR両方の環境でシームレスかつ没入感のあるプレイができるよう、大規模なシステムの統合とブラッシュアップを行いました。

午前中はゲームの基盤となるステージ管理や演出の強化、午後はVRコントローラーの完全対応とモード遷移のバグ修正という、非常に密度の濃い実装となりました。今回はその内容をまとめて紹介します。

YouTube:

1. 地上判定と歩行モーションの実装

実装の背景と課題

これまでのSTGモードでは、魔女のアバターは常に「箒に乗って空中に浮いている姿勢(SYS_BROOM_STILL)」に固定されていました。しかし、これではせっかく自機を上下左右に自由に動かせるにもかかわらず、地面スレスレの低空を移動している際の見栄えやリアリティに欠けるという課題がありました。

さらに、以前の実装では移動範囲の下限(Y軸の限界値)が画面の描画解像度やビューポートの高さに依存して動的に計算されていたため、3D空間上の床面のグラフィックと自機の足元との間に微妙な隙間や埋まり込みといった「座標のズレ」が発生していました。

これらの課題を解決するため、「完全に床面に接地した」と判定される高度を固定化し、接地状態に応じてアニメーション・自機の向き・アタッチメント(箒)を完全に動的連動させるシステムへと刷新しました。

技術的なアプローチと実装詳細

この機能は、自機の移動入力を制御する STGController.tsx と、アバターの描画・アニメーションを司る WiredAvatar.tsx の密な連携によって実現しています。具体的には以下の4つのステップで処理を行っています。

① ビューポート依存からの脱却と床面座標の固定

まず、画面サイズによって変動していた移動下限値を廃止し、3D空間上の床面グラフィックの高さに合わせて、自機の最小Y座標(minY)を -1.7 という絶対的な固定値として定義しました。これにより、自機がどれだけ下に入力を入れ続けても、床面に対して常に一定の高さでピタッと止まるようになり、座標のズレを根本から排除しました。

② 遊び(Threshold)を持たせた接地判定の共有

単純に y === minY だけで判定すると、浮動小数点の計算誤差によって接地フラグが毎フレーム激しくブレてしまう原因になります。そこで、ごくわずかな遊び(groundThreshold = 0.05)を定義し、現在の自機高度がこの範囲内にあるかどうかを判定するロジックを組み込みました。

const groundThreshold = 0.05;
(window as any).wiredPlayerGrounded = targetPos.current.y <= minY + groundThreshold;

この結果をグローバルなウインドウオブジェクト(wiredPlayerGrounded)に毎フレーム退避させることで、他のコンポーネントからいつでも接地状態を参照できるようにしています。

③ 接地状態に応じたY軸回転の滑らかな補間(Lerp)

滞空時と接地時で、機体(アバター)のベースとなる向き(Y軸の回転角度)を動的に切り替える処理を追加しました。

  • 滞空時(飛行中):少し斜めに構える 45度(Math.PI / 4
  • 接地時(地上走行中):進行方向である真っ直ぐ奥(0度)

着地した瞬間にパッとキャラクターの向きが瞬間移動のように変わってしまうのを防ぐため、THREE.MathUtils.lerp を用いて、毎フレーム数パーセントずつ滑らかに目標の角度へと振り向かせる補間処理を実装しました。これにより、離陸時や着地時にキャラクターがスッと綺麗に向きを変える美しいビジュアルが完成しました。

④ アニメーションとアタッチメント(箒)の動的切り替え

共有された接地フラグを WiredAvatar.tsx 側で監視し、状態が切り替わった瞬間(RUNFLY)にだけ、適切なアニメーションの再生と装備アイテムの出し分けを実行しています。

  • 地上を走っている時(RUN): アバターに走行モーション(SYS_RUNNING)を再生させ、同時にそれまで乗っていた箒のアタッチメント(Broom_01)のIDを null に書き換えて非表示にします。
  • 空中を飛んでいる時(FLY): アバターを箒飛行ポーズ(SYS_BROOM_STILL)に戻し、再び足元に箒のアタッチメントを表示させて搭乗状態をシームレスに再現します。

これら一連の処理が useFrame の描画ループ内でミリ秒単位で計算されることで、プレイヤーがアナログスティックやキーボードを上下に動かすだけで、空中戦の「飛行」と地上戦の「ダッシュ」が破綻なく滑らかに繋がる、極めて自然で気持ちの良いゲームフィールへと進化を遂げました。

2. JSONによるタイムライン管理の導入(スパゲティコード対策)

実装の背景と課題

STG(シューティングゲーム)の開発において、敵や障害物の出現(スポーン)管理は大きな悩みの種です。これまでの実装では Math.random() を用いたランダムスポーンを中心としていたため、「特定のタイミングで特定の形のゲートを潜らせる」「隕石と敵を組み合わせた複合的な攻撃を仕掛ける」といった、意図的で起伏のあるレベルデザイン(ステージ構築)が困難でした。

また、出現ロジックを STGManager.tsx の中に直接 if (time > 10 && time < 12) { ... } のように書き連ねていくと、コードが瞬く間に肥大化し、後から「この敵の出現を0.5秒遅らせたい」と思っただけでも、膨大なソースコードの中から該当箇所を探し出す必要があるスパゲティ状態に陥ってしまいます。

技術的なアプローチ:データ駆動(Data-Driven)への移行

この問題を解決するため、ゲームのロジックとステージ構成のデータを完全に分離し、外部のJSONファイル(stage1_timeline.json)からイベントを読み込んで進行する「タイムラインシステム」を構築しました。

[
  { "time": 1.0, "type": "pillar", "x": -15.0, "y": 3.0 },
  { "time": 2.0, "type": "gate", "shape": "triangle", "x": 0.0, "y": -1.1, "color": "#449999" },
  { "time": 5.0, "type": "enemy", "x": 4.0, "y": -1.0 },
  { "time": 6.0, "type": "meteor", "x": 1.0, "y": 1.0 }
  // ...
]

実装の詳細とパフォーマンスへの配慮

STGManager.tsx の描画ループ(useFrame)内では、ゲームの経過時間(stageTime.current)とタイムライン上の次のイベントの time を毎フレーム比較し、時間が到達したイベントから順次処理を実行していきます。

この処理において、VRゲームとして特にこだわったのが「オブジェクトプーリング」との連携です。 3D空間でオブジェクトを都度 new THREE.Mesh() して生成・破棄を繰り返すと、メモリの確保・解放(ガベージコレクション)による一瞬の処理落ちが発生します。VR環境において、この「カクつき」は深刻なVR酔いを引き起こす致命的な原因となります。

そのため、出現イベントが発火した際は、新たにオブジェクトを作るのではなく、あらかじめ非表示にしてプール(待機)させてある配列の中から未使用のものを探し出し(obstacles.current.find(o => !o.active) など)、それにJSONの座標データを流し込んでアクティブ化(active = true)するという設計を徹底しています。

イベントの type ごとに以下のような柔軟な初期化を行っています。

  • 敵(enemy) / ピラー(pillar):JSONで指定された xy 座標に配置し、プレイヤーに向かって進行させます(座標指定がない場合はランダムにフォールバックします)。
  • サイバーゲート(gate):通過しなければならない穴の形状(shape: 'circle' | 'triangle' | 'star')を切り替え、さらに color プロパティでゲートの発光色も個別に設定できるようにしました。色が未指定の場合はデフォルトのシアン(#00ffcc)が適用されます。
  • 隕石(meteor):指定された座標に配置後、ランダムに計算された速度(velocity)を与えてプレイヤーに向かって直進させ、破壊可能なHP(hp: 2)を設定します。

もたらされた効果

この仕組みの導入により、コードのメンテナンス性が劇的に向上しました。プログラムを一切書き換えることなく、テキストエディタでJSONを編集するだけで、敵の配置や障害物のタイミングをミリ秒単位で自由自在に調整できるようになりました。これにより、ゲームバランスのチューニングや新しいステージの作成にかかるコストが飛躍的に削減されています。

3. VR空間に浮かぶ「3D UIパネル」の構築

実装の背景と課題

PCやスマホなどの一般的なブラウザ環境では、ゲーム画面(WebGLのCanvas)の上に通常のHTML要素(<div><h2> など)を position: absolute で重ねるだけで、簡単にスコアやタイトル画面といったUI(ユーザーインターフェース)を構築できます。

しかし、WebXRを用いたVR(バーチャルリアリティ)モードに突入した瞬間、この手法は完全に通用しなくなります。VRヘッドセットの内部には、WebGLコンテキスト(Three.jsの3D空間)の中に実在するオブジェクトしか投影されないという絶対的な仕様があるためです。そのため、従来のHTMLで構築していたサイバーパンクなタイトル画面は、PCのモニター上には映っていても、ヘッドセットを被ったプレイヤーの視界からは跡形もなく消え去ってしまい、ゲームを開始することすらできないという致命的な問題が発生していました。

この問題を解決するため、UI自体を3D空間内のポリゴンとして再構築し、プレイヤーの目の前に浮かび上がるホログラムのような3D UIパネル(STGTitleVR.tsx)を実装しました。

技術的なアプローチと実装詳細

VR空間内でのUI構築には、@react-three/drei パッケージが提供する強力なコンポーネントである <Text> を全面的に採用しました。これにより、テクスチャ画像を作ることなく、3D空間内に直接シャープで美麗なテキストを描画できます。

① 視界に合わせた空間配置とレイアウト

プレイヤーがヘッドセットを被った際のデフォルトの視点位置(原点)を基準に、パネルを「正面に4要素分進み、少し見上げる高さ(position: [0, 2.3, -4])」に配置しました。 背景には半透明の紺色の板(PlaneGeometry)を置き、その外周にビビッドなワイヤーフレームの枠線を重ねることで、SF映画に出てくるライトベースのディスプレイ(ホログラムシールド)のような質感を表現しています。

② 状態(State)に伴う動的なデザイン切り替え

ゲームのメインループ(useFrame)内でグローバルなゲーム状態(window.wiredSTGState)を毎フレーム監視し、タイトル・ゲームオーバー・クリアの各フェーズに応じて、パネルのカラーやテキスト、誘導文言をリアルタイムに書き換える仕組みを導入しました。

  • TITLE(ゲーム開始前): テーマカラーをマゼンタ(#ff0055)に設定し、自機の操作方法や脅威分析データをハッカーの端末風に表示します。パネルの下部には独立した「RETURN TO ROOM」ボタンと、開発元の世界観を演出するクレジット表記(ARCHITECT: lain-dev // TACHIBANA_LAB.)が浮遊します。
  • GAMEOVER(撃沈時): ヘッダーが「SYSTEM_FAILURE」という警告表示に切り替わり、中央にはカーネルパニックを模したエラーログと、そのセッションで記録した最終スコア(wiredPlayerScore)がリアルタイムに反映されます。
  • CLEAR(ステージ制覇時): 全体のテーマカラーがセーフ状態を示すグリーン(#00ff88)へと反転し、ミッションの正常終了を告げるログと最終スコアが表示されます。

③ VRコントローラー(レイキャスター)によるインタラクション

3D空間内のメッシュに対して、React Three Fiberネイティブの onClickonPointerOveronPointerOut イベントをバインドしました。 VRコントローラーから照射されるポインター(光線)がパネルに重なると、ホバーを検知して背景色が明るくなり、枠線の色がシアン(#00ffff)へとインタラクティブに変化します。その状態で人差し指のトリガーを引く(クリックする)ことでイベントが発火し、どの状態からでもシームレスにゲームの開始・タイトルへの差し戻しが行える、VRネイティブな操作環境が整いました。

4. VRコントローラー入力の完全統合とバグ修正

実装の背景と課題

PCブラウザ向けのプロトタイプでは、キーボード(WASDキーでの移動、Spaceキーでの射撃、Shiftキーでのシールド)を用いて快適にプレイできるシステムが完成していました。しかし、WebXR APIを通じてVRモード(Wired)にダイブした際、これらのキーボード入力は事実上使えなくなります。

VR環境で真の没入感(フルダイブ体験)を提供するためには、プレイヤーが両手に持つVRコントローラーの入力を、ゲーム内の自機(アバター)の挙動へ完璧に同期させる必要がありました。しかし、WebXRのコントローラー入力処理には、ハードウェアごとの仕様の違いや、両手入力の非同期な上書きといった、Web標準APIならではの厄介な罠がいくつも潜んでいました。

技術的なアプローチと実装詳細

これらの課題を克服するため、STGManager.tsx 内で gl.xr.getSession() を用いてコントローラーのハードウェア入力を直接監視し、ゲーム内アクションへと変換する統合レイヤーを構築しました。

① 直感的な操作マッピングとアナログスティック制御の確立

VRコントローラーのボタンを、STGの基本操作へ直感的にマッピングしました。

  • トリガー(人差し指):引いた瞬間に「通常弾」を発射し、そのままホールド(0.2秒以上)でチャージ開始。離した瞬間に「貫通魔弾」を発射。
  • グリップ(中指):握り込んでいる間、魔力ゲージを消費して「電脳盾(サイバーシールド)」を展開。
  • アナログスティック(親指):自機の全方位移動。

特に苦労したのがアナログスティックの制御です。WebXRの標準的な仕様では、ジョイスティックの入力は gamepad.axes[0][1] に入ると思われがちですが、Meta Quest系のコントローラーでは、物理的に存在しない「タッチパッド」が [0][1] を占有し、実際のアナログスティックは [2][3] に格納されるというハードウェア特有の仕様が存在します。 この問題を吸収するため、以下のようなフォールバック処理を実装し、どんなVRヘッドセットでもスティック入力(-1.0 〜 1.0 の傾き具合)を正確に拾い上げ、グローバルな変数(wiredVrStickX, wiredVrStickY)としてエクスポートする仕組みを作りました。

if (axes.length >= 4) {
  stickX = axes[2]; stickY = axes[3]; // Quest等
} else if (axes.length >= 2) {
  stickX = axes[0]; stickY = axes[1]; // その他の標準コントローラー
}

この入力値は、自機の移動制御を担う STGController.tsx で受け取られ、キーボード移動のロジックと合流します。単に動くだけでなく、「スティックの倒し具合(入力の強さ)に応じて、機体が左右に滑らかにロール回転(傾く)」という、戦闘機のようなダイナミックな機体制御をVR空間でも完全再現することに成功しました。

② 両手コントローラーの競合バグの解消

WebXRでは、左手と右手の入力ソース(session.inputSources)を for ループで順番に処理するのが一般的です。 しかし、初期の実装では「左手でトリガーを引いている(発射)」にもかかわらず、直後に処理される右手のトリガーが「引かれていない」状態だと、変数が false で上書きされてしまい、射撃がキャンセルされてしまうという致命的な「入力上書きバグ」が発生していました。

これを防ぐため、ループ内では状態を一時変数に合算(論理和を取る)する手法へとリファクタリングしました。

// 左右どちらかのコントローラーで入力があれば true にする
if (buttons.length > 0 && buttons[0].pressed) anyTrigger = true;

これにより、左手・右手・あるいは両手を同時に使った激しい操作でも、一切の取りこぼしなく入力を捌き切れる堅牢な入力システムが完成しました。

③ VR特有の描画・状態バグのクリーンアップ

入力を同期させた後、実際にVR上でテストプレイを行う中で発覚した特有のバグを徹底的に修正しました。

  • 射撃の高度(Y座標)補正: VR環境において、自機(VRMアバター)の基準座標は「足の裏」にあります。そのまま弾を発射すると足元からレーザーが出る状態になっていたため、射撃イベントの発生時に playerPos.y + 1.0 と補正をかけ、胸の高さ(魔法陣の位置)から正確に弾が発射されるよう修正しました。
  • チャージエフェクトの孤児化バグ解消: トリガーを激しく連打した際、前回のチャージ中のエネルギー弾(光球)の参照が上書きされて迷子になり、空間に永遠に残り続けるバグが発生していました。これを防ぐため、STGChargeSystem.ts 側に「新しいエネルギー弾を生成する前に、古いものが残っていれば強制的にメモリを解放(dispose)して消去する」という強力なセーフティガードを実装しました。同時に、コントローラー側でも「トリガーを離した瞬間は、チャージ時間に関わらず必ずエフェクト消去イベントを飛ばす」ようロジックを修正し、どれだけ乱暴に操作しても画面が破綻しないクリーンな状態を維持できるようになりました。

5. STG ⇄ ROOM を繋ぐ「シームレスなモード遷移」

実装の背景と課題

本システムは、マイルーム(ROOMモード)からゲーム(STGモード)へシームレスにダイブし、終了後は元の空間へスムーズに戻れることを目指しています。しかし、STGモード中は STGController が毎フレーム自機の座標や姿勢(ロール回転)を強力に書き換えており、ゲーム終了時にこの制御を適切に解除しないと、アバターがSTGモード最終フレームの姿勢(傾いたまま、あるいは手足を曲げたまま)でフリーズするという深刻な問題が発生していました。

特にVRMアバターのボーン制御において、動的に変形させた関節角度がそのまま残ってしまうと、ROOMに戻った後の自律AIによるアイドリングモーションと競合し、キャラクターが不自然なポーズで固まるという「ポーズのフリーズ現象」が課題でした。

技術的なアプローチ:リセットプロトコル

この問題を解決するため、モード遷移時に確実にクリーンアップを行うための多重的な「リセットプロトコル」を実装しました。

① トランスフォーム制御の遮断と座標リセット

STGController.tsx において、appMode'PLAYING' ではない瞬間に、毎フレーム行われている座標更新やクォータニオン(回転)の上書き処理を即座に停止させるセーフティを実装しました。 さらに、wired-toggle-stg-mode イベントをトリガーとして、目標座標(targetPos)をアイドリング位置である (0, -1.2, 0) に戻し、VRMモデル自身の座標・回転を明示的にゼロリセットする処理を組み込みました。

② ボーンとモーションの強制初期化

WiredAvatar.tsx にて、appMode の変化を監視する副作用(useEffect)を追加しました。STGモードからROOMモードへ切り替わったことを検知すると、以下の二段構えでアバターをリセットします。

  1. ポーズの強制リセット: vrmRef.current.humanoid.resetPose() を呼び出し、STGモーションによって変形させられていたすべての関節(ボーン)を一度デフォルトのTポーズ状態に戻します。これにより、関節が変な方向に曲がったまま固まる現象を根絶しました。
  2. アイドリングへの復帰: 直後に revertToIdle() を実行し、ROOM用の待機モーションへ滑らかに遷移させます。これにより、STG終了後は何事もなかったかのように、ROOM内の自律モーション(呼吸や視線追従)が再開される仕組みです。

実装の効果

この設計により、STGモードという「激しいアクションが支配する空間」から、ROOMモードという「日常の空間」への帰還が、まるでアーケードゲームの筐体からログアウトするような滑らかさで実現されました。

プレイヤーは、どれだけ激しい戦闘を繰り広げた後であっても、ボタン一つで瞬時に、かつバグや違和感なく元の生活空間へと戻ることができ、電脳空間 PROTOCOL.LAIN 全体の没入感を維持することに成功しました。この遷移システムは、STGという「非日常」をより引き立てるための、不可欠なUI/UXの一部となっています。