[Astro #76] Three.jsで作るサイバー魔女STG:データ駆動型タイムラインと音響インフラの完全移行
1. はじめに
本記事は、自作のAstro環境上に構築している3D Webコンポーネント空間において、本格的なサイドビュー・シューティングゲーム(STG)『Witchadius』のエッセンスをゼロから組み上げていく開発プロセスのテクニカルレポートです。
自機の滑らかな移動および姿勢変化の処理をベースとし、本日1日で「弾幕描画のクリーン化」「軽量GLBアセットへの移行とクレジット管理」「Z字クランク移動の外部数式化」「JSONによるステージタイムライン制御」「相対タイマーを伴うデバッグリセット機能」「WebAudioシングルトンによる遅延ゼロ音響システム」の全6レイヤーを一気に実装・統合しました。
コードの責務を綺麗に分離し、ゲームとしての「手触りの良さ」と「破綻のない設計」をいかに両立させたか、ロジカルに解説します。
動画:
スクショ:
制作途中のスクショ、まだ当たり判定まで実装しておらず、基本設計とインフラ周りの整備のみ。設計が一番大事。
2. 弾幕描画の最適化:不要な拡大の排除と常時点灯(ソリッド)化
当初、グラディウスシリーズの「リプルレーザー」の仕様解釈において、弾丸が右に進むにつれて巨大化する処理を仮配線していましたが、画面端においてビジュアルバランスを著しく損なう問題が発生しました。また、液晶の描画レートと干渉を起こす「間引き点滅エフェクト」が原因で、光の輪がガタついて視認できる致命的なチラつきが生じていました。
対策
- 等倍固定生成への修正:
WITManager.tsx内の射撃ジェネレーターおよび直進ループから拡大係数の乗算を完全に削除し、初期サイズ(scale: 0.8)を維持した等速直進へとロジックをクリーン化しました。 - ソリッド化(常時点灯): 高速リフレッシュレート環境での同期ズレを防ぐため、
performance.now()によるフレーム間引き処理を廃止。2.5倍の高輝度乗算カラー(#00f2fe)を維持したまま常時点灯させることで、ブレのない一本の流麗な光のラインとして弾幕を表現しました。
2.1. 初期実装における視覚的課題と干渉バグ
初期プロトタイプにおいては、レトロ2D STGのクラシックな仕様に準拠し、弾丸の進行距離に応じて拡大係数を乗算するロジックを組んでいました。しかし、3D空間上のパースペクティブ表示において、画面右端(液晶の境界付近)に到達したリプルレーザーの輪が想定以上に巨大化し、自機や背景のオブジェクトを覆い隠してしまうというビジュアルバランスの破綻を招きました。
さらに深刻だったのは、弾丸の存在感を演出するために仕込んでいた「1/28秒周期のソフトウェア間引き明滅処理」です。
// 変更前の問題コード:1/28秒ごとのフラッシュ判定
const isFlashFrame = Math.floor(performance.now() / 28) % 2 === 0;
if (isFlashFrame) return null;
この実装は、ブラウザの requestAnimationFrame(React Three Fiberの useFrame 内で駆動)の実行周期、および現代の高リフレッシュレートディスプレイ(60Hz、120Hz、144Hz等)の垂直同期(V-Sync)のタイミングと深刻な干渉(うなり現象)を引き起こしました。結果として、綺麗な等間隔の明滅にはならず、描画フレームの同期ズレによる不規則なガタつき(ジャダー現象)や、弾丸が一瞬消えかかっているように見える視覚的バグとして知覚されるに至りました。
2.2. コードの構造改革(Before / After)
これらの課題を根本から解決するため、スケーリング処理の単純化とレンダリングループのクリーン化を実行しました。
射撃ジェネレーターおよび直進ループの修正
弾丸のデータ構造(WITBullet)はそのままに、生成時の初期スケールを 0.8 で完全に固定しました。また、毎フレームのデルタ時間を元に計算していたスケール加算コード(bullet.scale += RIPPLE_GROWTH * dt)を完全に削除し、等速・等サイズの直進運動へと変更しました。
レンダリングパスからの分岐撤去
最下部のJSX描画レイヤーにおいて、前述の performance.now() を用いた条件分岐(isFlashFrame による早期 return null)を丸ごと抹殺しました。これにより、毎フレーム確実にポリゴンが描画される常時点灯(ソリッド)状態へと移行しました。
2.3. 静的メモリ配置と高輝度発光(Glow)の維持
グラフィックスの品位を高めるため、点滅を廃止する代わりにWebGLの特性を活かしたアプローチへとシフトしています。
// ガベージコレクション(GC)を発生させない静的メモリ配置
const RIPPLE_GLOW_COLOR = new THREE.Color("#00f2fe").multiplyScalar(2.5);
- GC(ガベージコレクション)の徹底的な排除:
毎フレームの描画ループ内で
new THREE.Color()を実行して色成分を演算すると、ミリ秒単位で微細なメモリの確保と解放が繰り返され、将来的な処理落ち(スパイク)の原因となります。そのため、通常の2.5倍の輝度を持つネオンシアン(#00f2fe)のカラーインスタンスを、ファイル上部のグローバルスコープに定数RIPPLE_GLOW_COLORとして一度だけ静的にメモリ配置する設計をとりました。 - トーンマップの無効化:
メッシュマテリアル側で
toneMapped={false}を明示的に指定しています。これにより、Three.js全体のレンダラーによる一律のトーンマッピング(色調補正)による減衰を受けず、ディスプレイに対してRGBの限界値を超えた高輝度なネオンシアンを直接出力することが可能となりました。
このリファクタリングの結果、チラつきやブレが100%消失し、洗練されたサイバーガラスUIの背景空間に対して、一本の滑らかで美しい光のラインがシュバババッと連なって画面を埋め尽くす、極めて現代的かつクリーンなシューティング画面の手触りを実現しました。
3. 敵アセットの軽量化と管理JSON(Enemies.json)の新設
大量に並列出現する雑魚敵に対してVRMモデル(ボーンおよびウェイト計算のオーバーヘッドを持つ)を適用することは、パフォーマンスの観点から完全に不適切です。そのため、雑魚敵を軽量なGLB形式へ全面移行する設計判断を下しました。
アセットの増大に伴い、原作者のクレジット表記(CC BY 4.0)やURL、スケール、初期HPなどのゲームパラメータをコード内にハードコードせず、一括管理するためのJSONインフラを新設しました。
データ構造 (data/Enemies.json)
```text
Successfully generated file at: src/posts/astro-76-threejs-cyber-witch-stg-data-driven-timeline-and-audio-infrastructure.md
```json
{
"enemies": [
{
"id": "WIT_ENEMY_GHOST_01",
"name": "eggswc11",
"file": "/models/wit/uwu_-_eggswc11.glb",
"version": "1.0.0",
"scale": [0.05, 0.05, 0.05],
"creator": "felixyadomi",
"credit": "[https://sketchfab.com/felixyadomi/](https://sketchfab.com/felixyadomi/)",
"license": "CC BY 4.0",
"comment": "1体目の雑魚敵。5体編隊行動用"
}
]
}
このアセットメタデータの一本化により、ゲーム内のスポーンシステム、画面隅のリアルタイムクレジット、およびエンディングにおけるスタッフロールへの配列流用が完全自動化される基盤が整いました。
3.1. 雑魚敵におけるVRM運用の非効率性とGLBへの全面移行
シューティングゲーム(STG)のフレームレート(60FPS、あるいは120FPS以上)をWebブラウザ上で安定して維持するためには、画面内に同時出現するオブジェクトの計算コストをシビアに削減する必要があります。
自機アバターとして運用しているVRM形式は、人型モデルの高度な表現(リップシンク、視線制御、表情モーフ、多数のボーン構造、および揺れもの用の springBones 計算)に特化した、非常にリッチなファイル規格です。しかし、このVRM規格を画面内に並列で数十体出現する「雑魚敵(エネミー)」に対して一律に適用することは、CPU側でのボーンアニメーション評価およびウェイト計算のオーバーヘッド、GPU側でのドローコールの劇的な増大を招くため、パフォーマンスの観点から完全に不適切であると判断しました。
そこで、雑魚敵アセットに関しては、ジオメトリとマテリアルが単一かつ静的に最適化された「超軽量なGLB形式」へと全面移行する設計方針をとりました。人型かつ主要な演出を担う自機(イレイナ)やボス級のキャラクターにのみVRMを限定配置し、大量にスクロールされるエネミーには軽量GLBを採用するという明確なメリハリをつけることで、Web環境におけるリソース制限をクリアする最高の最適化基盤を確立しています。
3.2. 管理JSON(Enemies.json)の新設とパラメータの分離
アセットの増大に伴い、敵アバターのファイルパスやゲーム用パラメータをメインのコンポーネントコード(WITManager.tsx)に直接ハードコードすることは、コードの汚染や保守性の低下に直結します。これを回避するため、敵に関する静的メタデータを完全分離して一括管理するインフラとして、専用のJSONファイルを新設しました。
データ構造 (src/components/PROJECT_LAIN/WIT/data/Enemies.json)
{
"enemies": [
{
"id": "WIT_ENEMY_GHOST_01",
"name": "eggswc11",
"file": "/models/wit/uwu_-_eggswc11.glb",
"version": "1.0.0",
"scale": [0.05, 0.05, 0.05],
"creator": "felixyadomi",
"credit": "https://sketchfab.com/felixyadomi/",
"license": "CC BY 4.0",
"comment": "1体目の雑魚敵。5体編隊行動用"
}
]
}
このJSON設計の主要な目的は、コンポーネント側のロジックをデータ駆動(Data-Driven)に変更することにあります。
id/file/version: IndexedDBを用いたクライアントキャッシュシステム(getCachedAssetUrl)と直接連動するためのアセット解決キー。scale: 3Dアセットごとにバラバラである初期サイズを、ゲーム画面(液晶フレーム)のスケール感にジャストフィットさせるための3次元アライメント係数。creator/credit/license: 知的財産権および利用規約を遵守するためのライセンス・識別用データ。
今後の開発において、新しい雑魚敵をバリエーションとして追加、あるいはパラメータの修正(HPやスケールの微調整など)を行う際も、WITManager.tsx のプログラムコードを1文字も汚すことなく、このJSONシートを1行追記・編集するだけで開発が完結する柔軟性を確保しました。
3.3. リアルタイムクレジットとエンドロールの完全自動化
Sketchfab等の外部プラットフォームから高品質なオープンソース3Dアセットの恩恵を受けるにあたり、クリエイティブ・コモンズ(CC BY 4.0:Attribution)規約が定める「原作者の名前(クレジット)とURLの明記」は不可避の義務です。しかし、ゲームが完成した後にアセットの出所を一つずつ手動で探してReadMeやスタッフロールを書き起こす作業は、極めて不合理で手戻りの多いプロセスとなります。
本システムでは、Enemies.json にあらかじめライセンスメタデータ(creator, credit)を焼き付けておくことで、このライセンス処理をプログラム側で完全自動化するインフラを構築しました。
- 画面隅のリアルタイムクレジット連射:
ステージ中に特定の敵アバターが解決・スポーンされた瞬間、そのIDに紐づく
creator文字列をコード側で自動ルックアップし、現在画面の右下に稼働しているクレジット表示インフラへと動的に結合・流し込みを行う配線が可能となりました。 - スタッフロール(エンディング画面)へのダイレクトストリーム:
このJSONファイルはピュアな配列データとしてインポートできるため、将来的に実装するゲームクリア時のエンディング(スタッフロール)のループ処理において、後述する楽曲クレジット(
Sounds.json)と合わせてそのまま配列として一発流用できます。
開発の初期段階(1体目の軽量GLB敵アセットを迎えるタイミング)でこのデータ構造を一本化させたことは、ゲームエンジンの「データ整合性」と「規約遵守の安全性」を100%保証する、非常に堅牢な設計判断となりました。
4. 液晶枠内クリッピング(マスク)の実装
STG全体の描画キャンバスをサイバーな半透明液晶(黒い3D平面)として部屋の背景に浮かび上がらせるデザインを採っているため、画面外(右側)で待機しているスポーン直後の敵や、画面左端を通り抜けた敵のモデルが、お部屋のドレッサー空間にはみ出して丸見えになってしまう視覚的破綻が生じていました。
対策
Three.jsの複雑なステンシルバッファやクリッピングプレーンの処理を追加することなく、JSXのレンダリングループの最先頭に「ネオン枠の限界境界線を越えた座標の個体をスキップする」2行のカットオフ処理を配線しました。
{visualEnemies.map((enemy) => {
const screenEdge = MONITOR_WIDTH / 2;
// 枠外の座標にいる場合はレンダリングを完全にスキップ
if (enemy.x > screenEdge || enemy.x < -screenEdge) return null;
return (
<group key={enemy.id} position={[enemy.x, enemy.y, 0.02]}>
<primitive object={enemyScene.clone()} scale={enemyScale} />
</group>
);
})}
これにより、内部的な編隊スクロールロジックを一切汚すことなく、見た目だけをゲーム画面内に100%完全に閉じ込めることに成功しました。
4.1. 3D空間内の仮想ディスプレイにおける視覚的境界破綻
本作のグラフィックス構造は、3Dで構築された自室空間の背景(ドレッサーや家具類)の前面に、ゲームキャンバスとなる半透明な仮想液晶モニター(黒い3D平面平面オブジェクト)を浮かび上がらせるという、二重の空間レイヤー設計を採用しています。
通常の2Dゲームや全画面描画のSTGであれば、ゲーム画面の外側はウィンドウの枠や暗黒の非表示領域となるため、画面外の座標に存在するオブジェクトがプレイヤーに視認されることはありません。しかし、本作の特殊な空間構造においては、液晶フレームの外側はそのまま背後のドレッサー空間へと透過しています。
そのため、後述するタイムライン駆動によって画面外の右側(x > MONITOR_WIDTH / 2)に一列縦隊でスポーンし、順次滑り込んでくる待機状態の敵個体群や、自機を通り抜けて画面左端(x < -MONITOR_WIDTH / 2)からフレームアウトしたはずの敵個体が、お部屋の空中空間にそのまま露出して浮いてしまうという、深刻な視覚的整合性の破綻(バグ)が発生していました。
4.2. ステンシルバッファ回避と境界カットオフによる軽量実装
3Dグラフィックスにおいて特定の領域内だけの描画を制限する場合、一般的には以下の手法が用いられます。
- ステンシルバッファ(Stencil Buffer)の利用: レンダラーにマスク領域を描画し、バッファを比較して枠内のみをピクセルレンダリングする。
- クリッピングプレーン(
clippingPlanes)の利用:THREE.Planeを定義し、描画する全てのメッシュのマテリアルにクリッピング用配列を割り当て、バーテックス・フラグメントシェーダー側でピクセルを破棄(discard)する。
しかし、これらの手法はWebGLのステート切り替えオーバーヘッドを増大させ、マテリアルの管理やシェーダーのコンパイルを複雑化させるデメリットがあります。特に並列クローンを行う primitive メッシュ群に対して、個別にクリッピングマテリアルをインジェクション・同期し続ける処理は、ランタイムのパフォーマンス低下を招くリスクを孕んでいました。
そこで本作では、WebGLのハードウェア機能に頼るのではなく、React(JSX)の仮想DOM構造を活かした「座標論理比較による早期カットオフ(レンダリングスキップ)」という、極めて軽量かつ直接的なアプローチを配線しました。
{visualEnemies.map((enemy) => {
const screenEdge = MONITOR_WIDTH / 2;
// 仮想液晶の物理的な境界線を基準に、枠外にいる個体はJSXツリーへのマウント自体をスキップ
if (enemy.x > screenEdge || enemy.x < -screenEdge) return null;
return (
<group key={enemy.id} position={[enemy.x, enemy.y, 0.02]}>
<primitive object={enemyScene.clone()} scale={enemyScale} />
</group>
);
})}
仮想液晶モニターの総横幅(MONITOR_WIDTH)の半分を境界限界値(screenEdge)として定義し、その絶対値を超えている敵個体に対しては、即座に return null を実行します。これにより、Three.js のシーンツリーへの追加(オブジェクトのインスタンス生成およびドローコール)自体がV-Sync単位で完全にシャットアウトされます。
4.3. 処理責威の完全分離:データ進行と描画マスクの独立
この実装のアーキテクチャ上の最大のメリットは、「データの物理演算(Model)」と「描画の可視性(View)」の責務が100%完全に分離されている点にあります。
useFrame 内で駆動している敵の編隊フライトアルゴリズムや、画面外の左端へ到達した際の配列からの完全消滅(ガベージコレクション)を担うクリーンアップロジックは、このマスク処理による影響を一切受けません。敵の座標データ(enemy.x)は、画面外の隠れた領域であっても、タイムラインに従ってミリ秒単位で正確に計算・直進し続けます。
データ側の進行システム(裏側のロジック)を1ミリも書き換えることなく、画面のフチから1体ずつスッと滑らかに実体化して出現し、左端のネオンフレームに触れた瞬間にスッと消滅するという、STGとして極めてクリーンでゲームらしい「画面内完結型」のフライト表現を、最小限のCPU/GPUコストで成立させることに成功しました。
5. Z字型(クランク)編隊飛行パターンの数学的外部モジュール化
敵の動きにゲーム的なキレ味を持たせるため、「画面右上から侵入 → 水平前進 → 右後ろ(画面後方)にのけぞりながら斜め下に降下 → 下部レーンで再び左へ一気に加速脱出」という3段階のZ字クランク移動パターンを実装しました。
この複雑なタイムライン制御が WITManager.tsx を圧迫することを防ぐため、計算の全責任を負う数学ヘルパーファイルを完全分離しました。
軌道数式ファイル (utils/enemyPatterns.ts)
export interface PatternConfig {
x: number;
y: number;
}
export const getTopEnteringPattern = (age: number, index: number, monitorWidth: number): PatternConfig => {
const timeOffset = index * 0.22; // 5体が等間隔に追従するディレイ
const t = age - timeOffset;
const startX = monitorWidth / 2;
const startY = 0.52;
// 各フェーズの純粋な時間(秒)を定義
const P1_TIME = 1.5;
const P1_DIST = 0.85; // 左へ深く突入する距離
const P2_TIME = 0.8;
const P2_BACK = 0.25; // 右後ろへ引き戻されるタメの距離
const P2_DROP = 0.25; // 下に降りる距離
const P3_SPEED = 0.75;
const t1End = P1_TIME;
const t2End = t1End + P2_TIME;
if (t < 0) return { x: startX, y: startY };
// フェーズ1:上部を水平に左前進
if (t < t1End) {
const progress = t / P1_TIME;
return { x: startX - progress * P1_DIST, y: startY };
}
// フェーズ2:右後ろに引き下がりながら斜め下へ降下(タメの演出)
else if (t < t2End) {
const t2 = (t - t1End) / P2_TIME;
return { x: (startX - P1_DIST) + t2 * P2_BACK, y: startY - t2 * P2_DROP };
}
// フェーズ3:下部レーンで左へ一気にダッシュ脱出
else {
const t3 = t - t2End;
return { x: (startX - P1_DIST + P2_BACK) - t3 * P3_SPEED, y: startY - P2_DROP };
}
};
単にフェーズの秒数を変更するだけでは移動速度が低下して「スローモーション」になるバグが発生するため、「秒数の延長と同時に、進む総距離(P1_DIST)も比例して拡大させる」完全連動構造へとリファクタリングを施しました。なお、この対となる下段からの反転上昇パターン(getBottomEnteringPattern)も同ファイルに実装しています。
5.1. レトロSTGにおける「タメ」の美学とZ字クランク軌道の設計
シューティングゲームにおける雑魚敵の移動アルゴリズムは、ゲーム全体の緊張感と手触りを決定づける重要な要素です。単に画面右端から左端へ向かって一定速度で直進するだけのスクロール移動では、敵個体が単なる「動く障害物(背景の一部)」と化し、プレイヤーに対する戦術的な脅威やゲームとしてのフックが薄れてしまいます。
そこで、プレイヤーの予測線(水平な直進軌道)を意図的に裏切るキレ味のある動きとして、「画面右上から水平に侵入 → 画面中央付近で一度右後ろ(画面後方)にのけぞるように『タメ』を作りながら急降下 → 下部レーンに到達した瞬間に再び左へ一気に加速脱出する」という、3段階のステップを踏むZ字型(クランク)の編隊飛行パターンを設計しました。 特にフェーズ2における「前進の慣性に逆らって一瞬後方へ引き下がりながら降下する」というタメの演出は、敵個体が意思を持って自機を追い詰めるようなインテリジェンスを感じさせ、古典的なアーケードSTGが持つ独特のプレイフィールを再現する上で極めて高い効果を発揮します。
しかし、このような「経過時間(秒数)に応じた条件分岐」「各フェーズ内の進捗率(0.0〜1.0)の算出」「イージングやオフセット座標の加減算」といった複雑な幾何学・数理ロジックを、ゲームの主軸である WITManager.tsx の中に直接書き連ねることは、コンポーネントコードの可読性を著しく低下させ、バグの温床となります。
この課題を解決するため、移動演算の全責任を負う独立モジュールとして utils/enemyPatterns.ts を新設。メインループ側からは敵個体の年齢(age)と編隊内のインデックス(index)を引数として渡すだけで、一意の2次元座標(x, y)を瞬時に返す純粋関数(Pure Function)として、数学的責務の完全な分離を達成しました。
5.2. 単純な時間延長が引き起こす「スローモーションバグ」の数理的分析
開発の過程において、敵個体のフライトタイムラインをより細かく調整するため、フェーズ1(上部水平移動)の滞在時間を延長するリファクタリングを試みました。当初、フェーズの切り替わりポイントは if (t < 0.7) や else if (t < 1.5) のように秒数が直接ハードコードされていたため、これらを変数化して制御の抽象度を高める必要がありました。
しかし、単にフェーズ1の長さ(P1_TIME)を 0.7秒 から 1.2秒 へと書き換えた際、敵の移動速度が急激に低下し、全体の動きがスローモーションのようになる重篤なビジュアルバグが発生しました。
数理的な原因は、移動の「総距離(距離係数 0.45)」が固定されたまま、時間(割る数である分母)だけが増大したことにあります。速度の算出式()において分母だけが大きくなった結果、フレーム単位の移動量が意図せず減速し、結果としてプレイヤーの鼻先(画面の左前方)まで攻め込んでくる手前の領域(画面右側)で不自然に折り返してしまうという現象を引き起こしました。
この分析から、STGにおけるトリッキーな機動のキレ味(スピード感)を一定に維持したまま特定のフェーズを長く持たせるためには、「時間の延長(TIME)」と「進出する物理的な距離(DIST)」を比例関係として完全に同期させ、セットでスケーリングさせなければならないという明確な数理的相関を突き止めました。
5.3. パラメータの完全連動リファクタリングと下段反転パターンの対称性設計
数理的相関の判明に伴い、getTopEnteringPattern 関数の内部構造を全面的にリファクタリングしました。
関数の最先頭に「直感カスタマイズエリア」としての定数群(P1_TIME, P1_DIST, P2_TIME, P2_BACK, P2_DROP)を集中定義。そして、フェーズ2やフェーズ3の開始トリガーとなる境界時間を、これらの定数から自動的に累積計算(t1End = P1_TIME / t2End = t1End + P2_TIME)する数式へと書き換えました。
// 時間(TIME)を延ばしたら、進む量(DIST)も一緒に大きくする完全連動構造
const P1_TIME = 1.5; // ⏳ 滞在時間を1.5秒に延長
const P1_DIST = 0.85; // 🚀 進出距離を0.85に拡大(画面中央を越えて前線へ深く侵入)
const P2_TIME = 0.8;
const P2_BACK = 0.25; // 右後ろへ引き戻されるタメの距離
const P2_DROP = 0.25; // 縦方向への降下量
この変更により、進捗率(progress = t / P1_TIME)の分母分子が常に正しい比率で維持され、スピード感を一切殺すことなく、「画面中央の深くまで高速で突っ込み、そこからシャープにのけぞって急降下する」という意図通りのダイナミックなクランク軌道が実現しました。
さらに、グラディウス等の伝統的なSTGにおける空中戦の基本セオリーである「上下からの挟撃の布陣」を完成させるため、この上段パターンの幾何学構造をY軸方向に対して完全対称に反転させた下段逆パターン関数(getBottomEnteringPattern)を同ファイルに追記・実装しました。
横方向(X軸)の前進・のけぞりタメ・加速脱出のタイムラインおよび距離係数は100%共通の資産として活かしつつ、初期位置を startY = -0.52(画面下端)へとマイナス化。フェーズ2におけるY軸の移動ベクトルを「マイナス(降下)」から「プラス(上昇:startY + t2 * P2_RISE)」へと符号反転させるだけで、上段パターンと180度完全に対称な軌道を描くクローン数式を最小限のコード量で導出しました。
この外部モジュール化の恩恵により、メインのゲーム駆動側(WITManager.tsx)は、ステージデータから渡される識別子に応じて呼び出す関数をカチッと切り替えるだけの最小限の配線で済み、コードの堅牢性とステージデザインの自由度が最高水準で確保されました。
6. データ駆動型ステージタイムライン(Stage1.json)の構築
「何秒目に、どの敵が、どの編隊パターンで出現するか」というゲーム進行の設計図をコードから完全に剥離し、独立したJSONファイルで定義するシステムを構築しました。
構造 (data/Stage1.json)
{
"stageId": "WIT_STAGE_1",
"stageName": "Existential Wired - Phase 1",
"BGM": "BGM_STAGE_01",
"timeline": [
{ "time": 2.0, "enemyId": "WIT_ENEMY_GHOST_01", "pattern": "top", "count": 5 },
{ "time": 3.0, "enemyId": "WIT_ENEMY_GHOST_01", "pattern": "bottom", "count": 5 },
{ "time": 6.0, "enemyId": "WIT_ENEMY_GHOST_01", "pattern": "top", "count": 5 }
]
}
コード側の配線
WITManager.tsx 側には、配列全体を毎フレーム走査するような無駄な高負荷処理を避け、消化したイベントをカウントアップしていく「直列インデックス・トリガー(timelineIndexRef)」を採用しました。
これによって、コード自体は極めてクリーンな状態を保ちながら、JSONを編集するだけで難易度やステージ構成を自在にデザインできる本格的なゲームエンジンの設計を達成しました。
6.1. ハードコードからの脱却とデータ駆動(Data-Driven)設計への思想転換
初期のプロトタイプ開発においては、「8秒ごとに画面右側に5体の編隊を自動生成する」といった暫定的なスポーン処理を、コンポーネントのループ(useFrame)内に直接記述していました。しかし、ゲーム全体の進行(タイムライン)や敵の出現パターン、ステージの長さ、演出用BGMの指定といった「ゲームデザインの領分」のコードがプログラムのメインロジック(エンジン部分)と混在することは、プロジェクトの規模拡大に伴い致命的な足枷となります。
ステージの構成を少し変更する(例:敵の出現を1秒早める、下段パターンを連続で出すなど)たびに、TypeScriptのロジックそのものを書き換えて再コンパイルを走らせる方式は、開発のイテレーション速度を著しく低下させます。
この課題を根本から解決するため、「ゲームの進行シナリオ」をプログラムから完全に隔離し、独立したアセットとして外部ファイル化するデータ駆動型(Data-Driven)アーキテクチャへの設計転換を行いました。ゲームエンジン側は「与えられた仕様書(JSON)通りに敵を配置・フライトさせる」という純粋な実行機能のみに特化させ、ステージデザインの全責任を Stage1.json に移譲する形へと統合しています。
6.2. Stage1.json のスキーマ設計と拡張性
新設したステージデータの構成は、人間が直感的に編集しやすく、かつプログラム側でのパース(解析)負荷が最小限になるよう、時系列の配列(タイムライン)構造を採用しています。
データ構造 (src/components/PROJECT_LAIN/WIT/data/Stage1.json)
{
"stageId": "WIT_STAGE_1",
"stageName": "Existential Wired - Phase 1",
"BGM": "BGM_STAGE_01",
"timeline": [
{ "time": 2.0, "enemyId": "WIT_ENEMY_GHOST_01", "pattern": "top", "count": 5 },
{ "time": 3.0, "enemyId": "WIT_ENEMY_GHOST_01", "pattern": "bottom", "count": 5 },
{ "time": 6.0, "enemyId": "WIT_ENEMY_GHOST_01", "pattern": "top", "count": 5 }
]
}
各パラメータの役割とデータ仕様
BGM: ステージ開幕と同時にロードされるアセット識別用ID。後述するサウンドマスターシート(Sounds.json)のIDと完全同期しており、ステージごとに再生する楽曲の紐付けをコードを一切触らずに変更可能です。timeline内の各イベント要素:time: ゲーム(ステージ)が実際に開始された瞬間からの経過秒数(相対時間基準)。enemyId: スポーンさせる敵の種類を指定するキー。前述のEnemies.jsonと連動し、ロードする3Dモデルの切り替えを行います。pattern: 外部モジュール化した移動関数の識別子(topまたはbottom)。これにより、敵個体ごとに割り当てる数学的な移動軌道(クランク軌道など)の分岐を決定します。count: 編隊を構成する敵の同時出現数。ループ回数として動的に処理されます。
6.3. 直列インデックス・トリガーによる O(1) スポーン制御の最適化
タイムライン駆動を実装する際、素朴なアプローチ(ナイーブな実装)としては、毎フレーム useFrame ループの中でタイムライン配列全体を最初から走査(forEach や filter 等を実行)し、「現在の経過時間がイベントの指定秒数を超えているか」を常時監視する手法が挙げられます。しかし、この実装はタイムラインのイベント総数 に対して、毎フレーム の検索コストを支払い続けることになり、長大なステージや大量の敵データを扱う際にCPUの処理能力を圧迫する要因となります。
本作ではこの無駄な走査コストを完全に排除するため、配列が時系列(time の昇順)でソートされている特性を活かし、「直列インデックス・トリガー(ポインタ制御)」アルゴリズムを採用しました。
// 消化したイベント数を保持するポインタRef(毎フレームの全件走査を回避)
const nextEvent = WIT_STAGE1.timeline[timelineIndexRef.current];
// 現在監視すべき「次の1件」だけをピンポイントで判定 (O(1) の超軽量処理)
if (nextEvent && stageAge >= nextEvent.time) {
const count = nextEvent.count || 5;
// 指定された数だけ敵配列(メモリ)に個体をプッシュ
for (let i = 0; i < count; i++) {
enemiesRef.current.push({
id: `e_${Math.random().toString(36).substr(2, 9)}`,
x: MONITOR_WIDTH / 2,
y: nextEvent.pattern === 'top' ? 0.52 : -0.52,
hp: 1,
age: 0,
index: i,
patternType: nextEvent.pattern
});
}
// イベントの処理が完了したため、監視対象を次の要素(インデックス)へ1つ進める
timelineIndexRef.current += 1;
}
コンポーネント上部に保持した timelineIndexRef が、現在出現待ち状態にある「次の1件のイベント」の配列添字のみを指し示し続けます。毎フレーム実行されるチェックロジックは、このポインタが指す要素の time と現在のステージ時間(stageAge)を1回比較するだけの 最適化状態へとチューニングされました。
条件を満たして編隊がスポーンされると、ポインタ(timelineIndexRef.current)が自動的に +1 加算されて次のイベントの監視へと進むため、一度出現した敵データへの重複アクセスや逆流が発生することもありません。
プログラムコードを完全に隠蔽・クリーン化したまま、この Stage1.json のテキストデータを編集するだけで、弾幕STGの神髄である「精密なタイムライン構築」と「自由自在なゲームバランスのチューニング」をノーコストで行える、極めて実践的で美しいデータ駆動型の基盤が完成しました。
7. デバッグ効率化:完全初期化リセット(Rキー)と相対タイマーの罠
7.1. 開発イテレーションを加速する即時リセットの必要性
STGにおける敵の出現タイミングや移動速度、弾の連射レスポンスといった「手触り」の調整は、ミリ秒単位の微修正と実機検証を何百回と繰り返す過酷なプロセスとなります。この際、修正のたびにブラウザ自体をリロード(F5)してしまうと、部屋の3Dアセット(two_bedroom.glb)や重厚な自機VRMモデル、アニメーションデータの再ロードおよび IndexedDB へのキャッシュ解決走査が毎回走り、数秒の待機時間が生じます。この累積ロスはデバッグ効率を著しく低下させるため、アセットをメモリに保持したまま、ゲームの状態のみを瞬時に初期化してタイトル画面へ引き戻す R キーによる「即時リセットプロトコル」の配線が必須となりました。
7.2. Three.jsにおける累積時間の罠と同時スポーンバグ
当初、R キーが押された際に、自機座標の初期化、弾丸・敵配列のワイプ、タイムラインインデックス(timelineIndexRef)を 0 に巻き戻す処理を愚直に実装しました。しかし、この状態でリセットを行い再スタートした瞬間、本来2秒、3秒、6秒と順番に出現するはずの敵編隊が、1フレーム目で一斉に同時スポーンして画面が埋め尽くされる重篤な同期バグに直面しました。
この原因は、Three.js の基盤タイマーである state.clock.getElapsedTime() の仕様にあります。この関数が返す時間は、コンポーネントが起動してからの「絶対的な累積秒数」であり、ゲーム内でどれだけ配列をクリアしようとも巻き戻ることはありません。そのため、リセット時点で累積時間が例えば「30秒」に達していた場合、タイムラインポインタを 0 に戻した瞬間に、2秒・3秒・6秒という出現条件を一瞬で同時に満たしてしまい、スポーンエンジンが全ての編隊を同一フレームで実体化させてしまうという数理的な罠でした。
7.3. ステージ相対時間(stageAge)の導入による同期解決
この累積時間の罠を打破するため、絶対時間による判定を完全に廃止し、基準点からの差分を計算する「ステージ相対時間システム」へとアーキテクチャをアップデートしました。
コンポーネント上部にステージ開始時刻を記録するための stageStartTimeRef を新設。タイトル画面でスペースキーが叩かれ、イレイナが出撃した「その瞬間」の絶対時刻(clock.getElapsedTime())をこのRefに焼き付けます。
TypeScript // useFrame内での相対時間(経過秒数)の算出 const stageAge = currentTime - stageStartTimeRef.current; 毎フレームの累積時間(currentTime)からステージ開始時の時間を差し引くことで、ゲーム全体の稼働時間に関わらず、出撃した瞬間を「常にクリーンな0.0秒」としてカウントする相対軸(stageAge)の抽出に成功しました。R キーによるリセット時は、この stageStartTimeRef を 0 にワイプするだけでよくなり、再開後のタイムラインが設計図(Stage1.json)通り正確に1ウェーブずつ時間差で襲来する盤石な同期機構へと昇華されました。
8. 堅牢な音響シングルトン(AudioController.ts)への完全移行
8.1. WebAudio APIとThree.js AudioListenerのドッキング
ブラウザ標準の new Audio() を用いた生生成によるサウンド再生は、ロードの遅延や、特にSTGにおいて重要となる「同一効果音の超高速連射(重複再生)」を行った際に、前の音がブツ切りになる、あるいは発音数の限界で再生自体がブロックされるといった致命的な制約を抱えています。そこで、別コンポーネントで実績のあったWebGL/WebAudio APIネイティブの音響管理インフラ AudioController.ts(シングルトン設計)を本エンジンへと移植・統合しました。
まず、コンポーネント起動時の useEffect パスにおいて、現在のアクティブな3Dカメラ(camera)に対してコントローラーが内包する THREE.AudioListener をガチッとドッキング(アタッチ)させます。これにより、3D空間内における音響の受聴点(耳の役割)が確定し、遅延のないピュアなオーディオコンテキストの稼働が可能となりました。
8.2. BGMオート遷移と表記揺れ(タイポ)を吸収するフォールバック処理
AudioController の playBgm() メソッドは、新しいアセットURLが渡された際、現在鳴っているBGMのバッファを安全にフラッシュ(停止)してから次の楽曲を再生する自動排他制御を備えています。これにより、スペースキーを押した瞬間に、ハードコードなしでタイトル曲からステージ本編曲(French Pop)へとクリーンに楽曲を遷移させることができます。
デバッグ検証中、R キーでタイトルに戻した際にBGMが切り替わらない(ステージ曲が鳴り続ける)バグが発生しました。ソースコードを精査した結果、初期ロード時(144行目付近)は “BGM_OPENING”(Eあり)を指定していたのに対し、R キーの戻り処理内では “BGM_OPNING”(Eなし・タイポ)でデータ検索を行っていたため、JSON側から対象アセットのファイルパスを解決できず、再生処理がサイレントに虚無へ落ちていることが判明しました。
このデータ表現の不一致を完全に防ぐため、検索ロジックに論理和(OR)を用いたフォールバック処理を実装。
TypeScript const opConfig = WIT_SOUNDS.bgm.find(b => b.id === “BGM_OPENING” || b.id === “BGM_OPNING”); どのようなスペル差分であっても確実にマスターデータからオープニング曲をキャッチし、同時に audioController.stopBgm() を先頭で明示的にコールする二重の安全策を配線したことで、リセット時の音響リカバリを100%確実なものとしました。
8.3. バッファキャッシュによる遅延ゼロの無制限重複SE発火
シューティングにおける最大の快感である「リプルレーザーの連射音」は、ゲーム起動時に Sounds.json から解決した「ショット.mp3」を audioController.preloadSe() によって非同期で読み込み、生のバイナリデータ(AudioBuffer)として内部のメモリ(seCache)に完全にキャッシュ(先読み)させる手法をとりました。
発射トリガー(スペースキー長押し、0.16秒間隔)が検知されるたびに、コントローラー内部ではキャッシュ済みのデコード済みバッファをワンショットの THREE.Audio インスタンスへ直接インジェクションしてトリガーします。 ファイルを毎回ネットワークやディスクから読み直すオーバーヘッドがゼロになるため、リプルレーザーをどれだけ高速で連射しても、音が途切れたりプチプチとクリップしたりすることなく、すべての発射音が美しく、重なり合いながら超高レスポンスで撃ち鳴らされる極上のオーディオフィールが完成しました。
9. まとめおよび今後の展望
本日の開発フェーズにより、プロトタイプ段階だったSTGシステムは、データ(JSON)とロジック(プログラム)が完全に分離された、極めて抽象度の高い本格的なゲームエンジンへと進化を遂げました。
複雑な幾何学移動は enemyPatterns.ts に隠蔽され、ステージの進行配置は Stage1.json に委ねられ、音響インフラは AudioController.ts によって完全に掌握されています。これにより、コンポーネントコードである WITManager.tsx は、ゲームオブジェクトのライフサイクル管理とキーボード入力のハンドリングという、本来のクリーンな責務のみに集中できるようになりました。
基盤インフラが100%の堅牢さで締まったため、次の開発ステップである「放ったリプルレーザーのコライダと、Z字クランクで迫り来る雑魚敵のAABB(軸平行境界ボックス)による高精度な当たり判定(コリジョン・エンジン)」、および「敵消滅時の爆発エミッターエフェクト」の実装へ、何一つ設計の負債がない理想的な状態で突入することができます。