[Astro #80] Three.jsにおける3Dアセット最適化、地上敵AIの動的地形追従、およびWebXRのAボタン拡張
開発途中バージョン・実機プレイ映像
開発5日目(実質4日)時点のアルファ版テストプレイ映像です。 フルパワーアップ時のリプル・ダブル・レーザーの撃ち分け、オプションの動的魔法陣、および新設した地上敵の挙動を確認できます。
1. 3Dアセットの結合とローポリ化によるドローコール削減(Blender 5.0.1)
バラバラのアセットが抱えるドローコールの罠
Web3D(Three.js)やWebXR環境におけるシューティングゲーム開発において、画面内に大量に出現するザコ敵の描画負荷を最小限に抑えることは、フレームレートを維持するための最優先課題です。
今回、地上敵(ダッカー型AI)の素材として採用した3Dアセット「Cute Cat in Cute Banana」は、カートゥーン調の非常に優れたルックを持っている反面、初期状態のデータ構造がそのままの量産には適さない状態でした。Blender 5.0.1にインポートして統計情報を確認したところ、目、耳、体、バナナの皮といった各パーツが22個の独立したオブジェクトに分かれて構成されていました。
3Dエンジン(WebGL)の仕様上、オブジェクトが分かれていると、そのパーツの数だけCPUからGPUに対して描画命令(ドローコール)が毎フレーム発行されます。つまり、このバナナ猫が1体画面に存在するだけで22回、編隊(例:5体)で出現させればそれだけで110回ものドローコールを消費することになり、これが描画パイプラインの重大なボトルネックとなっていました。
Armatureを維持したメッシュの統合(Join)
このドローコール問題を根本的に解決するため、まずはバラバラに分離していた22個のメッシュオブジェクトを1つの塊へガッチャンコ(結合)する処理を行いました。
通常、アニメーションが仕込まれているスキンメッシュ(スキンデータを持つモデル)を安易に結合すると、各頂点に塗られている「どの骨にどう連動するか」というウェイト情報がバグを起こし、アニメーション構造が崩壊するリスクがあります。しかし、今回はすべてのパーツが同一の骨組み(Armature)を共有していたため、以下の手順により安全に統合することが可能でした。
- 3Dビューポート上で全オブジェクトを選択(
Aキー)。 - 結合の基準(親)となる本体のメッシュ(
Object_8)を最後にアクティブ選択(Ctrl+ クリック)。 Ctrl + J(統合 / Join)を実行。
この一撃により、22個あったオブジェクトは完全に「1つのオブジェクト(Object_8)」へと集約されました。これにより、Three.js側がグラフィックボードへ送る描画命令数は、22回からわずか「1回」へと劇的に削減されました。
「ポリゴン数削減(Decimate)」モディファイアによる一括軽量化
オブジェクトの統合と同時に行わなければならないのが、ポリゴン数(頂点数・面数)の削減です。初期状態のモデルは、滑らかな曲線を表現するために 面数(Faces): 19,224 / 三角面(Triangles): 19,224 という、ザコ敵としてはかなりリッチ(ハイポリ)な数値を抱えていました。1体なら動かせても、ワラワラと量産してさらに左右2眼のレンダリングを行うVR(Enter XR)環境では、確実に処理落ちの要因になります。
そこで、結合によって全身が1つになったメッシュに対し、Blenderの「ポリゴン数削減(Decimate)」モディファイアを適用しました。
設定した比率(Ratio)は 0.1(10%) です。
全身のメッシュが一元化されていたため、このデシメート処理は全身のパーツに一網打尽に適用され、全体の三角面数は元の 19,224 から 1,922 へと、ジャスト10分の1の桁までストレートに激減しました。
WebGL/WebXRにおける最適化効果
デシメートを限界まで叩いたことで、Blenderのプレビュー上(至近距離)ではポリゴンのフチが削られ、ややトゲトゲとしたローポリ感のあるルックに変化します。
しかし、実際のSTGゲーム画面へとデプロイし、カメラを引いた視点(自機や背景との相対的なスケール感)で確認すると、人間の目の錯覚により、そのガタつきは完全に消失して見えます。画面全体の数パーセント程度のサイズでしか描画されないザコ敵としては、元の滑らかなルックの印象を100%維持したまま、データ重量と計算コストだけを極限まで削ぎ落とすことに成功しました。
「ドローコール1回、ポリゴン数1.9k」というモバイルゲーム・XRスタンドアローン向けアセットの黄金基準に生まれ変わったことで、地上のタイムラインへ何匹同時にスポーンさせても、ブラウザのJavaScriptメインスレッドおよびGPUを一切汚さない鉄壁のインフラが完成しました。
2. 常時駆動アセットのフリーズによるWebGLスキニング負荷のパージ(R3F)
背景と課題:静止している「置物」アバターが消費する隠れた計算コスト
ゲーム画面の両端(舞台の袖)には、世界観を演出するための装飾オブジェクトとして、3Dアバター(イレイナおよびもこっちの3Dモデル)が配置されています。これらはステージ上を動き回る敵キャラクターとは異なり、特定のキメポーズをとった状態でその場に佇む「背景・置物」としての役割を担っています。
しかし、GLTFやVRM形式のキャラクターモデルが内部にボーン構造(Armature)を持ち、AnimationMixer に登録されている以上、3Dエンジン(Three.js / React Three Fiber)はデフォルト状態で「毎フレーム、アニメーションの更新処理」を走らせてしまいます。たとえ見た目が1ミリも動いていない静止状態であっても、裏側では毎フレーム以下の高コストな算術演算がループ駆動します。
- アニメーションタイムラインの進捗計算(
mixer.update) - 親ボーンから子ボーンへと伝播する、すべての関節の回転・移動行列の再計算(ローカル座標からワールド座標への変換)
- 変形後のボーン位置に合わせて頂点データを再配置する、GPU(またはCPU)上でのスキンメッシュ変形計算(スキニング処理)
特にハイポリゴンなキャラクターモデルの場合、ボーンの数やウェイトの影響を受ける頂点数が多いため、この行列計算はCPUのメインスレッドを激しく圧迫します。さらに、左右の目のためにすべての描画パイプラインと計算ステップを2回ずつ実行しなければならないWebXR(VR)環境においては、この「動かない背景アバター」が消費する無駄なスキニング負荷が、フレームレート低下(スタッター)を引き起こす重大な要因となっていました。
解決策:最初の1フレームでポーズを固定する「フリーズハック」の導入
この問題を解決するために、アニメーションの駆動自体を完全に停止(ミキサーへの登録を解除)してしまうと、3Dモデルはモデリング時の初期姿勢である不自然な「Tポーズ(またはAポーズ)」に強制リセットされて表示されてしまいます。装飾アセットとしてのルック(意図したキメポーズ)を成立させるためには、最低でも1回はアニメーションクリップの特定のコマ(ポーズ情報)をメッシュへ変形適用する必要があります。
そこで、背景オブジェクトを描画する TerrainInstance コンポーネント内の useFrame ループに対して、コンポーネント固有の静的状態を管理する useRef(hasPosedRef)を用いた、最初の1フレーム目だけ計算を通して以降を完全パージするインターセプト(遮断)ロジックを実装しました。
具体的な実装コードのロジックは以下の通りです。
// 🌿 地形・背景オブジェクト描画インスタンス内のアニメーション制御層
const hasPosedRef = useRef(false); // 👑 置物ポーズ固定化用のフラグ
useFrame((_, delta) => {
if (!mixerRef.current) return;
// 👑 【置物アバター専用・不動防衛ハック】
// 画面両端の飾りアセットは、最初の1フレームだけポーズを適用して以降のボーン計算を完全パージ
if (obj.id === "fixed_elaina_mascot" || obj.id === "fixed_wizard_cat_mascot") {
if (!hasPosedRef.current) {
mixerRef.current.update(0.016); // 1コマ分(60fps換算で約16ms)だけ進めてTポーズを脱出
hasPosedRef.current = true; // フラグを反転させて防衛線を張る
}
return; // 毎フレームの重いスキニング行列計算をここで遮断して軽量化
}
// 通常のステージスクロール地形でアニメーションがある場合は通常駆動
mixerRef.current.update(delta);
});
このロジックにより、対象の装飾アバター(fixed_elaina_mascot / fixed_wizard_cat_mascot)を検知した場合、コンポーネントがマウントされた直後の第1フレーム目のみ mixer.update(0.016) を明示的に実行し、アニメーションを1コマだけ進めてポーズを変形適用します。変形が完了した瞬間に hasPosedRef.current = true へロックをかけ、第2フレーム目以降はミキサーの更新処理へ到達する前にループを手前で return; させて完全にバイパスする構造へとリファクタリングしました。
最適化効果:WebXR環境における処理マージンの最大化
このフリーズハックの導入により、静止アバターに対する毎フレームのAnimationMixer更新処理、およびそれに伴う膨大なボーン階層の行列再計算コストが100%パージされました。
頂点データの変形結果は最初の1フレーム目で固定され、以降のフレームではGPUが前フレームのバッファ(変形済みメッシュデータ)をそのまま再利用して描画命令を処理するため、CPUのメインスレッドは完全に解放されます。
結果として、ゲームのコアシステムである「大量の弾丸の移動物理」「敵エンティティの生成管理」「AABB衝突判定ループ」といった算術演算処理に回せるCPUリソースの大幅な増加につながりました。セクション1におけるドローコールおよびポリゴン数の極限削減と、このセクション2のCPUフリーズ最適化が組み合わさったことで、最もハードウェア要件の厳しいWebXRプレゼンテーションモード(Enter XR)においても、フレームドロップによる3D酔いを完全に防ぎ、ヌルヌルとした滑らかな描画性能を維持するための強固な処理マージンを構築することに成功しました。
3. 動的地形吸着レイキャストとダッカー型地上敵AIの実装
3-1. タイムライン駆動によるダッカー型AIロジックの設計(getDackerPattern)
グラディウスシリーズの地上敵「ダッカー」の挙動を再現するため、時間(age)の経過に応じて歩行と射撃の状態を厳密にループ制御するタイムライン駆動型のアルゴリズム getDackerPattern を設計しました。
本ロジックでは、個体ごとに等間隔で出現させるためのタイムオフセット(index * 0.35)を差し引いた絶対時間 t をベースに計算を行います。ダッカーの行動サイクルは1周期を 4.0秒 と定義し、内部状態を以下の3つのフェーズに分割しました。
- 歩行フェーズ(0.0秒〜2.2秒): 自力歩行速度(
walkSpeed = 0.35)で左方向へ前進します。実際のX座標は「初期位置」から「ステージ全体のスクロールによる後退距離」と「自力で前進した距離」を合算して算出されます。 - 構えフェーズ(2.2秒〜2.5秒): その場に足をピタッと止め、次フェーズの射撃に向けて自機の座標をロックオンするための静止時間を設けます。この間、コンポーネント側ではアニメーションミキサーの更新を
0にすることで足踏み処理をフリーズさせます。 - 射撃フェーズ(2.5秒〜4.0秒): 立ち止まったまま、0.5秒間隔で計3発の自機狙い弾(リプル弾)をバースト射出します。重複発射を防止するため、サイクル数と経過時間からユニークな
burstIndexを算出し、前回の発射インデックス(lastBurstIndex)と比較して1フレームのみ弾エンティティを生成する排他制御を施しています。
この4.0秒のサイクルを毎フレーム繰り返しながら、画面の左端(-MONITOR_WIDTH / 2)を超えて画面外に完全に脱出したエンティティは、配列のフィルタリングによって自動的にメモリからパージされる仕組みになっています。
3-2. リアルタイム床走査(簡易レイキャスト)と背景誤吸着防衛の配線
従来の空中を飛行する敵とは異なり、地上敵は「現在流れてきている地形ブロックの上面」に正しく接地して移動する必要があります。これを特定の固定高度(Y座標)のハードコードなしで実現するため、メインループ内でリアルタイムに足場をスキャンする簡易レイキャスト物理エンジンを構築しました。
毎フレーム、現在ステージ上にスポーンしているすべての地形オブジェクト(terrainObjsRef.current)をループで精査し、以下のステップでダッカーの足元高度(Y座標)を動的に決定します。
// ── リアルタイム・接地レイキャスト物理演算 ──
let highestGroundY = -0.675; // 何もないときの基本床(ウッドデッキ天面)の高度
for (const obj of terrainObjsRef.current) {
const box = getTerrainHitBox(obj.modelId);
if (box.width === 0 && box.height === 0) continue; // 草やキノコなどの装飾はスルー
// 🧱 【背景誤吸着防衛】床属性キーワードを持つソリッドなブロックのみを足場として認定
const modelIdUpper = obj.modelId.toUpperCase();
const isSolidFloor = modelIdUpper.includes("BLOCK") || modelIdUpper.includes("FLOOR") || modelIdUpper.includes("GROUND") || modelIdUpper.includes("STEP");
if (!isSolidFloor) continue;
const halfW = box.width / 2;
const halfH = box.height / 2;
const leftEdge = obj.x - halfW;
const rightEdge = obj.x + halfW;
const topEdge = obj.y + halfH; // 地形ブロックの上面のY座標
// 🐾 敵の現在のX座標が、この地形ブロックの幅の「真上」にあるか判定
if (enemy.x >= leftEdge - 0.02 && enemy.x <= rightEdge + 0.02) {
// 🧗 【高さの妥当性チェック】遥か上空の背景に吸い上げられないよう、
// 現在の敵の高度から自然に登れる範囲(+0.15以内)の床だけを正確に踏む
if (topEdge <= enemy.y + 0.15 && topEdge > highestGroundY) {
highestGroundY = topEdge;
}
}
}
// 地形のフチ、または基本床の天面に吸着
enemy.y = highestGroundY;
ここで重要なのは、背景誤吸着防衛と高さの妥当性チェックの2つのフィルタリングです。初期の実装では、X座標が重なっただけで遥か上空にある背景アセット(木や建物など)の上面に敵がワープしてしまうバグが発生しました。
これに対し、当たり判定(hitbox)を持たない草やキノコを免除し、かつモデルIDに特定の床属性キーワード(BLOCK / FLOOR / GROUND / STEP)が含まれるソリッドな地形オブジェクトのみを足場として選択するように制限しました。さらに、現在の高度から一瞬で登ることが不可能な高低差(+0.15 超)を無視する閾値を設けたことで、前方にソリッドな段差がある場合のみトコトコと滑らかに登り、段差が途切れたら基本床(-0.675)へ即座に着地する、正確な地形追従物理が完成しました。
3-3. 原点中心化に伴うモデル位置オフセットの接地アライメント
セクション1において、Blender側で3Dモデルの原点(Origin)をメッシュ全体の「バウンディングボックスの中心」に再設定したことにより、オブジェクトの基準点(へそ)がモデルの幾何中心へと移動しました。
この状態のままモデルをglbに出力してThree.js側の接地物理ライン(enemy.y = highestGroundY)にそのままマウントすると、モデルの幾何中心が床の天面にスナップされるため、結果としてキャラクターの「下半分(足元)」が床下に埋まってしまう現象が発生します。
この3D空間上の物理位置と「見た目の表示位置」の不一致を解決するため、外側の物理・移動・反転の中心軸となる group(物理座標)の計算は -0.675 の床ラインのまま変更せず、内側にマウントされているメッシュの実体(primitive)に対してのみ、見た目のY軸オフセット補正を適用しました。
// 🛸 EnemyInstance 内部の描画マウント層
const isBananaCat = enemy.enemyId === "WIT_ENEMY_CUTE_BANANA_CAT";
// 👑 【埋まり救済オフセット】
// Blender側で原点が幾何中心になったため、モデルの高さの半分(0.05)だけ見た目を上(+Y)にスライド
// これにより物理的な吸着ラインと、実際の足元の接地ルックを100%シンクロさせる
const modelOffset: [number, number, number] = isBananaCat ? [0, 0.05, 0] : [0, 0, 0];
return (
// 外側のgroupが自動計算された鉄壁の足場物理ラインに吸着(enemy.y = highestGroundY)
<group position={[enemy.x, enemy.y, 0.02]}>
<primitive object={clonedScene} scale={enemyScale} position={modelOffset} />
</group>
);
この見た目の分離設計(modelOffset: [0, 0.05, 0])を挟むことで、簡易レイキャストエンジンが算出する正確な地形高度を一切書き換えることなく、バナナ猫の底面がウッドデッキや段差の表面にピッタリと乗るジャストフィットな接地アライメントを達成しました。これにより、平地でも凸凹の段差の上でも、足元が一切めり込むことのないクリーンな描画ルックが保証されます。
4. 難易度調整用「グローバル敵弾速パラメータ」への一元管理化
背景と課題:マジックナンバーの散在による難易度調整の破綻
シューティングゲーム開発において、敵が放つ弾の速度(弾速)はゲームの難易度やプレイの快適性を決定づける極めて重要なファクターです。
初期の実装フェーズでは、空中敵(pair_shooter)の3方向弾には 1.2、新設した地上敵(dacker)の自機狙い弾には 1.1 というように、各敵キャラクターの射撃関数やメインループ内の処理に対して、個別の弾速が直接数値(マジックナンバー)としてハードコードされていました。
しかし、実機テスト(特に視界全体にオブジェクトが迫るWebXR環境)において「敵の弾が速すぎて回避が著しく困難である」というゲームバランス上の課題に直面した際、この分散型設計の弊害が顕在化しました。当初はVRモード時のみ弾速をマイルドにするため、個々の発射ロジック内に state.gl.xr.isPresenting による条件分岐を個別追加する方針をとっていましたが、この方法では以下の構造的欠陥を引き起こします。
- 敵キャラクターの種類や弾種が増えるたびに、コード内のあらゆる場所に条件分岐とマジックナンバーがモグラ叩きのように増殖する。
- ゲーム全体として「もう少し難易度を下げたい」「イージーモードやハードモードといった難易度選択機能を実装したい」となった場合、すべての数値を手作業で修正・検証せねばならず、修正漏れやバグの温写となる。
コード全体の保守性とゲームデザイン上の調整コストを最適化するため、個別のハードコードを完全にパージし、1箇所で全体のゲームバランスを統括できる「グローバル敵弾速インフラ」への一元管理化(リファクタリング)を実施しました。
解決策:共通定数 GLOBAL_ENEMY_BULLET_SPEED の配線
リファクタリングの手順として、まず WITManager.tsx の最上部にあるシステムグローバル定数定義セクターに対し、ゲーム内のすべての敵弾速を司るマスターボリュームノブとして GLOBAL_ENEMY_BULLET_SPEED を創設しました。
// 📄 src/components/PROJECT_LAIN/WIT/WITManager.tsx (定数定義セクター)
const MONITOR_Z = 3.0;
const MONITOR_WIDTH = 2.4;
const MONITOR_HEIGHT = 1.35;
const PLAYER_SIZE = 0.1;
const PLAYER_SPEED = 1.0;
const BULLET_SPEED = 2.8;
// 👑 【新設:ゲーム全体・敵弾幕スピード統括ハブ】
// 初期値の 1.1〜1.2 から、人間の反射神経で快適に見切れる「0.55」へリマスター
const GLOBAL_ENEMY_BULLET_SPEED = 0.55;
const RIPPLE_GLOW_COLOR = new THREE.Color("#00f2fe").multiplyScalar(2.5);
次に、メインループ(useFrame)内の敵エンティティ更新処理において、弾丸の移動ベクトル(vx, vy)を算出していた箇所を、この共通定数を参照する形へと結合・整地しました。
① pair_shooter(上下ザコ)の3方向弾への適用
// 📄 src/components/PROJECT_LAIN/WIT/WITManager.tsx (メインループ内)
if (config.shootPhase === 'FIRING' && config.burstIndex !== enemy.lastBurstIndex) {
enemy.lastBurstIndex = config.burstIndex;
const angles = [Math.PI, Math.PI - 0.38, Math.PI + 0.38];
// 📐 個別速度をパージし、グローバル共通速度を基準に3方向のベクトルを計算
angles.forEach(angle => {
enemyBulletsRef.current.push({
id: `eb_${Math.random().toString(36).substr(2, 9)}`,
x: enemy.x, y: enemy.y,
vx: Math.cos(angle) * GLOBAL_ENEMY_BULLET_SPEED,
vy: Math.sin(angle) * GLOBAL_ENEMY_BULLET_SPEED,
scale: 0.6,
});
});
audioController.playSe("SE_RIPPLE_SHOT", getSeVolume("SE_RIPPLE_SHOT", 0.4) * 0.5);
}
② dacker(地上バナナ猫)の自機狙い狙撃弾への適用
// 📄 src/components/PROJECT_LAIN/WIT/WITManager.tsx (メインループ内)
// 🎯 自機狙い弾の発射ロジック(イレイナの最新座標をリアルタイムで逆算ロック)
if (config.shootPhase === 'FIRING' && config.burstIndex !== enemy.lastBurstIndex) {
enemy.lastBurstIndex = config.burstIndex;
const playerX = playerGroupRef.current ? playerGroupRef.current.position.x : -0.9;
const playerY = playerGroupRef.current ? playerGroupRef.current.position.y : 0;
// 自機への正確な射出ベクトル角を算出
const angle = Math.atan2(playerY - enemy.y, playerX - enemy.x);
// 📐 狙撃弾の移動ベクトルも、完全に共通のグローバルパラメータへ統合
enemyBulletsRef.current.push({
id: `eb_${Math.random().toString(36).substr(2, 9)}`,
x: enemy.x, y: enemy.y + 0.05,
vx: Math.cos(angle) * GLOBAL_ENEMY_BULLET_SPEED,
vy: Math.sin(angle) * GLOBAL_ENEMY_BULLET_SPEED,
scale: 0.6,
});
audioController.playSe("SE_RIPPLE_SHOT", getSeVolume("SE_RIPPLE_SHOT", 0.4) * 0.5);
}
リファクタリングの効果とアーキテクチャの柔軟性
このパラメータ化リファクタリングにより、個別のアルゴリズム層からマジックナンバーが完全に一掃され、メインループ内の算術処理が劇的にクリーン化されました。
全体の弾速基準を 0.55 に設定したことで、右端から直線的に飛来する3方向弾も、地べたの死角からイレイナを正確にスナイプしてくる地上狙撃弾も、すべてが同期した適正速度へとスローダウンしました。これにより、2Dモニターでの通常プレイ時はもちろん、情報認知コストの極めて高いWebXR環境においても「視覚的に弾道を認識し、引きつけてから自機の移動やシールドで正確に対処する」という、クラシックSTG本来の戦術的かつ爽快なゲームプレイが確立されました。
また、この共通パラメータ化は将来の拡張性に対しても強力なアドバンテージを持ちます。今後「Easy / Normal / Hard」といった難易度選択システムを配線する際、定数から useState などの動的ステート(例:enemyBulletSpeed)へ差し替えるだけで、個々の敵の攻撃ロジックに1ミリも手を触れることなく、ゲーム全体のゲームバランスを一瞬で切り替えられる堅牢なシステムアーキテクチャが整いました。
5. WebXRコントローラーのAボタンによるパワーアップ発動とチャタリング防衛
背景と課題:VRスタンドアローン環境における独立した操作系の必要性
デスクトップ環境における『Witchadius』のパワーアップシステムは、パワーゲージが目的のスロット(SPEED UP、MISSILE、DOUBLE、LASER、OPTION、?)に到達した段階で、キーボードの Shift キーを入力することによって任意に発動する設計となっています。
しかし、WebXR(Enter XR)モードを起動してヘッドセットを装着したVRスタンドアローン環境においては、プレイヤーは両手に持ったVRコントローラー(Meta QuestのTouchコントローラーなど)のみで全ての操作を行う必要があります。従来の入力配線では、ショット(トリガー:buttons[0])および移動(アナログスティック:axes)しかマウントされておらず、キーボードの Shift キーに相当する「パワーアップ発動ボタン」がVR側へマッピングされていませんでした。
これにより、VR環境下ではパワーカプセルをどれだけ回収してもゲージを消費して兵装を強化することができず、ゲームプレイの進行が完全に制限されるという問題が生じていました。VR空間での完全なスタンドアローンプレイを実現するためには、WebXR Gamepad APIを利用してコントローラー上の特定の物理ボタンを検出し、既存のパワーアップシステムへダイレクトに配線する必要がありました。
WebXR Gamepad APIにおけるAボタン(第4ボタン)のリアルタイム監視
一般的なVRコントローラーにおける標準的なプロファイル(xr-standard)では、トリガーやグリップ、スティック以外の汎用アクションボタンも特定のインデックスに割り当てられています。右コントローラーの「Aボタン」(および左コントローラーの「Xボタン」)は、Gamepad.buttons 配列のインデックス 4(buttons[4])にマッピングされています。
この入力を監視するため、メインループ(useFrame)内のXRセッションスキャン層において、以下の通りリアルタイムスキャンを構築しました。
// 📄 src/components/PROJECT_LAIN/WIT/WITManager.tsx (useFrame 内部・XR入力を走査するセクター)
let liveVrAction = false; // 👑 VRのアクションボタン入力をキャッチするローカルフラグ
const xrSession = state.gl.xr.getSession();
if (xrSession?.inputSources) {
for (const source of xrSession.inputSources) {
const gp = source.gamepad;
if (gp) {
if (gp.buttons[0]?.pressed) isVrSelect = true; // トリガー弾
// 👑 🎮 【Aボタン(第4ボタン)のリアルタイムスキャン】
// xr-standardプロファイルにおける Aボタン(またはXボタン)の押下状態を常時監視
if (gp.buttons[4]?.pressed) liveVrAction = true;
const sx = gp.axes[0] || gp.axes[2] || 0;
const sy = gp.axes[1] || gp.axes[3] || 0;
if (Math.abs(sx) > 0.1) liveVrStickX = sx;
if (Math.abs(sy) > 0.1) liveVrStickY = sy;
}
}
}
毎フレーム実行される useFrame ループ内でコントローラーの状態を直接ポーリングし、gp.buttons[4]?.pressed が真であれば liveVrAction フラグを瞬時に反転(true)させます。
チャタリングおよび連続暴発を防ぐ「ボタン立ち上がり(ポジティブエッジ)検知ロジック」
単にボタンが押されているかどうか(liveVrAction === true)の条件だけでパワーアップ発動関数を呼び出すと、重大なバグが発生します。VRコントローラーのボタン入力はフレームレート(90Hz〜120Hz等)の速度で毎フレームポーリングされるため、人間がボタンを「一瞬だけ短く押した」つもりでも、プログラム側では数十フレームにわたって連続でボタンが押され続けていると判定されます。
その結果、ボタンを押した瞬間にパワーゲージが猛烈な勢いで連続消費され、意図しない兵装(例:SPEED UPを一段階だけ上げるつもりが、一瞬でゲージがループしてシールドまで強制解放される等)へと暴発してしまう「チャタリング(連続消費)現象」が引き起こされます。
この暴発を防ぎ、ボタンを「新しく押し下げた瞬間(ポジティブエッジ)」の1フレームのみを正確に捉えるため、コンポーネントの再レンダリングに影響されない静的メモリバッファとして lastVrActionPressedRef を新設しました。これを用いた防衛線ロジックは以下の通りです。
// 📄 src/components/PROJECT_LAIN/WIT/WITManager.tsx (発動・防衛判定層)
// ── 👑 🎰 【VRコントローラー・Aボタンによるパワーアップ発動物理】 ──
// 「タイトル画面ではなく」かつ「今ボタンが押されていて」「前回フレームでは押されていなかった」瞬間に一撃発動!
if (!showTitleScreen && liveVrAction && !lastVrActionPressedRef.current) {
const slot = currentPowerSlotRef.current;
if (slot !== 0) {
switch (slot) {
case 1: // SPEED UP
if (speedLevel < 3) {
setSpeedLevel(prev => prev + 1);
audioController.playSe("SE_POWER_UP", getSeVolume("SE_POWER_UP", 1.0) * 0.7);
setCurrentPowerSlot(0); // 成功時のみゲージを消費
}
break;
case 2: // MISSILE
if (missileLevel < 2) {
setMissileLevel(prev => prev + 1);
audioController.playSe("SE_POWER_UP", getSeVolume("SE_POWER_UP", 1.0) * 0.7);
setCurrentPowerSlot(0);
}
break;
case 3: // DOUBLE
if (!hasDouble) {
setHasDouble(true);
audioController.playSe("SE_POWER_UP", getSeVolume("SE_POWER_UP", 1.0) * 0.7);
setCurrentPowerSlot(0);
}
break;
case 4: // LASER
if (!hasLaser) {
setHasLaser(true);
setHasDouble(false); // 排他制御
audioController.playSe("SE_POWER_UP", getSeVolume("SE_POWER_UP", 1.0) * 0.7);
setCurrentPowerSlot(0);
}
break;
case 5: // OPTION
if (optionCount < 4) {
setOptionCount(prev => prev + 1);
audioController.playSe("SE_POWER_UP", getSeVolume("SE_POWER_UP", 1.0) * 0.7);
setCurrentPowerSlot(0);
}
break;
case 6: // ? (SHIELD)
if (shieldHp === 0) {
setShieldHp(3);
audioController.playSe("SE_POWER_UP", getSeVolume("SE_POWER_UP", 1.0) * 0.7);
setCurrentPowerSlot(0);
}
break;
}
}
}
// 👑 今回のフレームのボタン状態をRefに焼き付け、次回フレームの防衛線とする
lastVrActionPressedRef.current = liveVrAction;
liveVrAction && !lastVrActionPressedRef.current という評価式を張ることで、「現在のフレームでボタンが押されており(true)、かつ直前のフレームではボタンが押されていなかった(false)」という、ボタンが押し下げられた最初の1フレームのみを完全にピンポイントで検知します。
そして処理の最後尾で、現在の状態を lastVrActionPressedRef.current = liveVrAction; としてRefへ退避・上書き更新します。これにより、プレイヤーがボタンを何秒間押し続けていても、一度ボタンを完全に離して再度押し下げない限り、2回目以降のスイッチ(switch (slot))は絶対に作動しない鉄壁のチャタリング防衛インフラが完成しました。
VRフルスタンドアローン環境の確立とゲージ運用の最適化
この入力インフラのリファクタリングにより、キーボード環境(Shift 入力による発動)とVR環境(Aボタン入力による発動)の内部ステートおよびパワーアップロジックが100%同一のルートへ統合されました。
VRヘッドセットを装着した状態でも、パワーカプセルを回収してゲージを選択スロットへと誘導し、右手のAボタンをカチッと小気味よく叩くだけで、意図した通りのタイミングで正確にスピードアップやオプション追加、レーザー換装などをコントロールできる高品位なゲージ運用環境が確立されました。
ハードウェアの入力差異をポーリング層およびRefによる状態防衛線で完全に吸収したことで、プラットフォームに依存しない、極めてアーケードライクで操作性の高い完全体シューティングシステムがデプロイされました。