[Astro #71] WebGL 3Dエフェクトの軌道修正、オーディオ同期、およびモバイル用バーチャルコントローラーの実装
はじめに
本稿では、React Three Fiber(R3F)を用いたWeb3Dアドベンチャーゲームの戦闘システムおよび探索システムにおいて、本日実装した2つの主要な機能について技術的な解説を行います。
- 3D星型魔法エフェクト「星屑の系譜(スターダスト・トレイル)」の軌道修正とSEのフレーム同期
- CSSメディアクエリと仮想入力を用いたレスポンシブなスマホ用バーチャルコントローラーの実装
アニメーションテスト
本番環境に移植
1. 3D星型魔法エフェクト「星屑の系譜」の軌道修正とオーディオ同期
1-1. グローバル座標系におけるステージ回転角の適用(軌道修正)
背景と座標ズレの課題
本アプリケーションのアドベンチャー戦闘システム(ADVBattle)では、背景やベースとなるアセットを包括するステージグループ(ADVStage)に対し、エンカウントが発生した際のプレイヤーの侵入角度に基づいた動的な回転角(props.encounterRotY)が適用されています。
一方で、今回新規に構築した星屑魔法エフェクトシステム(ADVStardustTrail)は、Reactの宣言的ツリーのコンポーネントとしてではなく、パフォーマンスおよびVRAMアセット管理の最適化のために、内部で生成したメッシュを最上位のグローバルな THREE.Scene に対して直接命令型で追加・削除(scene.add / scene.remove)を行うインフラ構造を採用しています。
この構造において、BATTLE_CONFIGS.json からルックアップされる初期座標(playerStartPosition および enemyStartPosition)をそのまま THREE.Vector3 に代入し、3次ベジエ曲線(THREE.CubicBezierCurve3)の制御点を生成して移動物理計算を行うと、ステージ全体の回転行列が加味されない状態となります。結果として、画面上のプレイヤーアバターのレンダリング位置とは無関係に、回転が適用されていない初期の基準軸に沿って弾道が計算され、エフェクトが敵に向かわず右下の地面(ワールド座標系の初期方向)へと吸い込まれてしまう空間的な不一致が発生していました。
解決手法と数理
このアフィン変換における座標系の不一致を解決するため、座標ベクトルに対して直接回転行列を乗算する数理プロトコルを導入しました。
具体的には、Three.jsに標準実装されている THREE.Vector3.prototype.applyAxisAngle(axis, angle) メソッドを使用します。Y軸の単位ベクトル(new THREE.Vector3(0, 1, 0))を回転軸として定義し、そこにラジアン単位のステージ回転角(props.encounterRotY)を渡すことで、静的な静止座標から現在の回転追従後のグローバル空間へと正確な座標変換を施しました。
// 補正コードの実装詳細
// 固定の初期位置からVector3を生成し、ステージ回転角を乗算してグローバル座標へ変換
const playerPos = new THREE.Vector3(
battleConfig.playerStartPosition[0],
battleConfig.playerStartPosition[1],
battleConfig.playerStartPosition[2]
).applyAxisAngle(new THREE.Vector3(0, 1, 0), props.encounterRotY);
const enemyPos = new THREE.Vector3(
battleConfig.enemyStartPosition[0],
battleConfig.enemyStartPosition[1],
battleConfig.enemyStartPosition[2]
).applyAxisAngle(new THREE.Vector3(0, 1, 0), props.encounterRotY);
この処理を経由したベクトルをベジエ曲線の始点・終点・制御点の基盤とすることで、ステージがどのサンプリング角度でエンカウントをキックしても、手前のプレイヤーアバターの杖先から、奥の敵オブジェクトの中心へと完全に一致してブチ刺さる正確な放物線軌道が保証されます。
1-2. キーボードショートカットとデバッグガードの実装
開発検証における課題
useFrame ループ内で毎フレーム計算される弾丸のベジエ移動、自転速度(spinSpeed)、および背後にドロップされるカラフルな残像用星屑粒子(spawnOrganicTrail)の密度や拡散挙動を本番環境のカメラアングルで厳密にデバッグする場合、通常のターン制コマンドメニューの選択シーケンスを経由する設計では、検証における時間的オーバーヘッドが非常に大きくなります。
さらに、既存のダメージ共通ルーチンである applyDamageToEnemy に処理が合流すると、1発目の着弾フラグを検知した時点で敵のHPが減少し、HPが0に達した瞬間に戦闘終了処理(BATTLE_END ➔ 探索マップへのリブート)がキックされ、コンポーネントがアンマウントされてしまうため、連続した弾道のブラッシュアップが物理的に不可能という制約がありました。
デバッグガードRefによるバイパス処理
このデバッグ効率のボトルネックを解消するため、副作用フック(useEffect)を用いたスタンドアロンなキーボード入力リスナーと、ステートを汚さないための「デバッグガードRef」を実装しました。
戦闘フェーズの状態や手番の有無にかかわらず、ブラウザ上で s / S キーが押されたイベントを検知した瞬間、デバッグ発射プロトコルがキックされます。この際、多段ヒットによる重複適用を防ぐ安全フラグ(hasStardustDamageAppliedRef)の初期化を行うと同時に、今回追加した isStardustDebugRef(Boolean型のRef)を true に切り替えます。
// useFrame ループ内におけるデバッグインターセプター(判定遮断)
const isTrailHit = stardustTrailSystem.update(delta, state.camera);
if (isTrailHit && !hasStardustDamageApplied.current) {
hasStardustDamageApplied.current = true; // 超高速ループ内の重複適用ロック
// デバッグRefの状態を評価
if (!isStardustDebugRef.current) {
// 通常の魔法選択時は、正規の計算式からダメージを適用してターンを進める
const magicDamage = Math.floor(currentGrowth.maxMp * 2.2);
applyDamageToEnemy(magicDamage);
} else {
// sキーによるデバッグ検証時は、ダメージ処理を完全にスルー(バイパス)
console.log("🎯外弾道DEBUG_HIT: 星が敵の中心に命中(ステート保護のためダメージ計算をスキップ)");
}
}
このRef駆動の割り込み機構により、戦闘ステートや敵の生存フラグ、プレイヤーのMP資産などのコンテキストを1ミリも破壊することなく、同一画面内で s キーを連打するだけで、何度でも即座にオブジェクトのクリーンアップ(cleanup)と再射出を実行・検証できる高効率な砂場デバッグ環境が確立されました。
1-3. 各魔星弾の生成・着弾時SEのフレーム同期
タイムラインオーディオの同期破綻問題
既存のマジックショットやマジックバースト(メテオ)システムでは、詠唱開始時に setInterval で駆動する一定周期のチャージSEを鳴らし、一定時間(例えば3.0秒後)のタイムアウト後に固定ディレイで一括のヒットSEをトリガーするという、マクロな時間管理型のオーディオ制御を採用していました。
しかし、今回構築した「星屑の系譜」は、総数5発(TOTAL_BULLETS = 5)の3D結晶星オブジェクトが、それぞれループインデックスに応じた時間差ディレイ(i * 0.38秒)を伴い、さらに3次元の乱数ベクトル(outVector)で個別にねじ曲げられたベジエ曲線上を2乗イージング(tLinear * tLinear)で加速移動する、完全なマルチオブジェクト物理演出です。そのため、従来の「一定時間後に一括でSEを鳴らす」という時間固定型の設計では、各弾丸の実際の視覚的挙動(滞空時間の長短や時間差)と音響が完全にズレてしまい、同期が破綻するという課題を抱えていました。
状態監視による粒度的(グラニュラー)オーディオ同期
この問題に対し、固定時間のタイマー依存を完全に排除し、ADVStardustTrail クラス内の移動更新処理(update)の内部から、各オブジェクトの物理的状態の変化をフレーム単位でダイレクトに監視・検知してSEをキックする「グラニュラー同期アーキテクチャ」へ変更しました。
オーディオ制御の集約インスタンスである audioController をクラス内へ直接結合し、以下のライフサイクルポイントで個別にSEを再生します:
- 射出(ポップアップ)フレームの同期
初期化時はすべて
mesh.visible = falseで非表示の状態でプールされています。stateTimerの経過によって自身のディレイ値を跨ぎ、if (!b.mesh.visible)条件を通過してメッシュが初めてアクティブ(true)に切り替わったまさにそのフレームの瞬間を検知し、audioController.playSe('SE_MAGIC_CAST')を呼び出します。これにより、数理のディレイと連射音が100%完全一致します。 - 着弾(消滅)フレームの同期
毎フレーム加算される媒介変数(
tLinear = b.age / b.duration)の進行度を監視し、値が1.0に達してオブジェクトが敵の胸元へ到達した瞬間(シーンからremoveされ、VRAMジオメトリ資産がメモリ解放される直前)に、ピンポイントでaudioController.playSe('SE_HIT_NORMAL')を再生します。
// ADVStardustTrail.ts 内でのフレーム同期SEの実装構造
this.bullets.forEach((b, idx) => {
if (b.hasHit) return;
if (this.stateTimer >= b.delay) {
if (!b.mesh.visible) {
b.mesh.visible = true;
// 🌟 発射ディレイを通過して実体化したフレームで即座にショット音を再生
audioController.playSe('SE_MAGIC_CAST', 0.4);
}
// ... (ベジエ軌道のイージング移動計算) ...
if (tLinear >= 1.0) {
b.hasHit = true;
// 🌟 媒介変数が1.0に達し、敵の中心に衝突して消滅するフレームで即座に着弾音を再生
audioController.playSe('SE_HIT_NORMAL', 0.5);
this.scene.remove(b.mesh);
// ... (ジオメトリやマテリアルのメモリ解放) ...
}
}
});
このフレーム単位の条件付きトリガーロジックにより、邪魔だった手前の冗長なチャージ音や、最後の一括ヒット音が綺麗に排除され、それぞれの星屑が描く独自の放物線スピードの余白の通りに、「シュッ、シュッ、シュッ」という小気味よい時間差の射出音と、「ポン、ポン、ポン、ポポン!」というサガシリーズ特有の鮮烈な多段着弾音が、視覚的なグラフィックの瞬きと完全にシンクロして鳴り響く極上の手応え(カタルシス)が実現しました。
2. スマホ用バーチャルコントローラー(useMobileController.ts)の実装
2-1. CSSメディアクエリによるUIコンテナの動的表示制御
背景とDOMレイヤーの分離配置
React Three Fiber(R3F)環境においてスマートフォン向けの操作インターフェース(ゲームパッドUI)を構築する場合、WebGLの3D空間内に3Dオブジェクトとしてボタンやスティックをレンダリングする手法と、WebGLキャンバスの外側のDOM層(HTML/CSS)に2D要素としてオーバーレイさせる手法の2つが存在します。
本アーキテクチャでは、アセットのロード負荷やレイアウトの柔軟性、およびデバイスごとの解像度追従(レスポンシブWebデザイン)における優位性を考慮し、DOM層にHTML要素としてバーチャルパッドを配置する設計を採用しました。これにより、3D空間のレンダリングループ(useFrame)に負荷をかけることなく、高パフォーマンスなUI駆動が可能となります。
メディアクエリを用いたスタイル注入プロトコル
PC環境(ポインティングデバイスを前提とした Pointer Lock 環境)とモバイル環境(タッチデバイス)におけるコードベースの二重化や状態管理の複雑化を回避するため、本カスタムフック(useMobileController)内部で動的にスタイルシートを生成・注入する「宣言的スタイル戦略」を導入しました。
フックがマウントされた段階で、ドキュメントの head 内に固有の識別子(id="mobile-controller-style")を持つ <style> タグが生成されます。
/* useMobileController.ts 内で注入されるCSS構造 */
/* ❌ デフォルト(大画面・PC環境)では、UIコンテナおよび内部の全パッド要素を完全に非表示化 */
.mobile-ui-container {
display: none;
}
/* ⭕ 画面横幅が 768px 以下のタッチデバイス環境でのみ要素を実体化 */
@media (max-width: 768px) {
.mobile-ui-container {
display: block;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none; /* 3Dキャンバスへのタッチイベント透過を阻害しないためのガード */
z-index: 999999; /* 最前面への配置保証 */
}
}
このメディアクエリによって、単一のコンポーネントツリーでありながら、PCブラウザでは完全に気配を消し、スマートフォンでアクセスされた時のみ固定座標(position: fixed)のゲームパッドUIを動的に召喚する、極めてクリーンなレスポンシブ制御が実現されています。
2-2. タッチイベントからの入力ベクトルの算出とPointer Lock制限の回避
モバイルブラウザにおけるPointer Lockの制約
PC版のPC向け物理コントローラー(ADVController.tsx)では、マウスを動かした際の相対移動量(e.movementX および e.movementY)を mousemove イベントから取得し、視点およびキャラクターの回転を行っています。しかし、iOSのSafariやAndroidのChrome等のモバイルブラウザ環境においては、セキュリティおよびユーザーインタラクションの仕様制限により、この「Pointer Lock API」が機能しないか、著しく制限されます。
この仕様制限を完全にバイパスするため、タッチイベント(touchstart, touchmove, touchend)をフックし、2次元のアナログスティック入力をシミュレートする数理ロジックを構築しました。
ベクトル算出の数理物理とクランプ処理
バーチャルジョイスティックの基盤要素(#v-joystick)の矩形情報(getBoundingClientRect())から、その絶対的な中心点座標()および最大可動半径()を割り出します。
ユーザーのタッチ位置()との変位ベクトル()は以下の演算によって算出されます。
スティックのノブ(#v-knob)が基盤の枠外へ無限に飛び出すのを防ぐため、中心点からの距離()を基準とした幾何学的なクランプ(制限)処理を挟みます。
// ジョイスティックの移動制限(クランプ)および正規化ベクトル計算
let dx = touch.clientX - centerX;
let dy = touch.clientY - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 0) {
// 変位が最大半径を超えた場合は、枠線上に座標をクランプ
const limit = Math.min(distance, maxRadius);
dx = (dx / distance) * limit;
dy = (dy / distance) * limit;
}
// ノブの3D変形移動(CSS Transform)への反映
knob.style.transform = `translate(${dx}px, ${dy}px)`;
// XZ平面における移動ベクトル(-1.0 〜 1.0)として正規化し、グローバルへマッピング
(window as any).mobileStickX = dx / maxRadius;
(window as any).mobileStickY = -(dy / maxRadius); // スクリーン座標系(下方向が正)をワールド座標系(奥方向が負)へ反転
グローバルバインディングと3D側でのベクトルマッピング
算出・正規化された2次元ベクトル(-1.0 〜 1.0)は、毎フレームの競合を防ぐために window.mobileStickX および window.mobileStickY へと直接格納されます。
3D物理演算を司る ADVController.tsx 側では、useFrame の内部ループにおいて、このグローバル変数をリアルタイムに参照します。
// ADVController.tsx 内におけるスマホベクトルの統合
const mobileStickX = (window as any).mobileStickX || 0;
const mobileStickY = (window as any).mobileStickY || 0;
let hasInput = keysRef.current['w'] || /* ... PCキー略 ... */
Math.abs(mobileStickX) > 0.1 || Math.abs(mobileStickY) > 0.1;
if (hasInput) {
// カメラの正面方向(camForward)と右方向(camRight)ベクトルに入力値を乗算して合成
if (Math.abs(mobileStickY) > 0.1) {
moveVector.add(camForward.clone().multiplyScalar(mobileStickY));
}
if (Math.abs(mobileStickX) > 0.1) {
moveVector.add(camRight.clone().multiplyScalar(mobileStickX));
}
}
この設計により、PC版のマウス・キーボード駆動の物理演算に対して、1ミリのラグもなく完全に並列な形でスマートフォンからの慣性移動ベクトルをドッキングさせることに成功しています。
2-3. 合成キーイベントによるメニュー操作とデッドゾーン制御
アナログ・デジタル変換におけるチャタリング課題
探索フェーズにおけるキャラクターの移動は、上記のアナログ入力(滑らかな移動ベクトル)で問題ありませんが、ゲーム内に実装されているセーブメニュー、ベッドでの休憩確認、あるいはステータス画面(Tabメニュー)といったUI操作は、完全なデジタル入力(「1回傾けたら、選択肢が1マス動く」という離散的な挙動)を要求します。
仮にアナログの傾き成分をそのままメニュー切り替えに直接マッピングしてしまうと、R3Fの1秒間に60回走るフレーム更新ループの速度によって、スティックを少し傾けただけでメニューが超高速で連打されてしまうチャタリングバグ(慣性暴走)が発生します。
仮想キーイベントの射出(dispatchEvent)と2段階しきい値制御
この慣性暴走をシャットアウトし、ゲーム機のような確実なメニュー操作感を実現するため、入力値をシュットトリガ(ヒステリシス特性)的な2段階のしきい値で判定する「デジタルエミュレーションロジック」を実装しました。
スティックの生の傾き成分(rawY)が、設定された絶対しきい値(0.7)を超えた瞬間に、一度だけブラウザの最上位に向けて合成キーボードイベント(KeyboardEvent)を直接射出(dispatchEvent)させます。
// スティックの傾きからメニュー操作へのデジタル変換ロジック
const rawY = -(dy / maxRadius);
if (!menuFlicked && Math.abs(rawY) > 0.7) {
menuFlicked = true; // 入力ロック展開(チャタリング遮断フラグ)
// ブラウザへ向けて正規のキーボードイベントを射出
window.dispatchEvent(new KeyboardEvent('keydown', {
key: rawY > 0 ? 'ArrowUp' : 'ArrowDown',
bubbles: true
}));
} else if (Math.abs(rawY) < 0.25) {
// スティックが中央付近の安全な「デッドゾーン」まで戻ってきたら、ロックを解除
menuFlicked = false;
}
ボタンA/Bの透過的バインディングによる後方互換性の維持
同様のアプローチは、画面右側に配置されたバーチャルボタン(#v-btn-a, #v-btn-b)のタッチイベント処理(touchstart / touchend)にも適用されています。
ボタンがタップされた瞬間に、PC版の決定キーである Enter や Space、キャンセルキーである Escape の合成 KeyboardEvent をシステム内部へと射出します。
これにより、ADVManager.tsx や ADVBattle.tsx 側に存在する「既存の広大なPC向けメニュー操作ロジック」に対して一切の条件分岐やコードの変更を加えることなく、完全な互換性と透過的な入力バインディングを維持したまま、モバイル対応をスマートに完遂させることが可能となりました。
3. 総括
本日の開発により、グローバル空間における3Dベクトル計算の回転補正が完了し、戦闘エフェクトの正確な位置同期と小気味よいSE同期が実現しました。また、探索フェーズにおけるモバイルデバイスへのレスポンシブ対応も同時に達成され、インフラ層としての堅牢性と操作性が向上しました。