[Astro #72] 3D RPG開発:データ駆動型シナリオ・ワープインフラ構築と3D数学回転バグの完全打破
はじめに
初めて自作している3D RPG開発ですが、今日、このゲームは単なる「3Dオブジェクトの集まり」から、世界のタイムラインをデータで完全支配する「本物のゲームエンジン」へと進化を遂げました。
当初は力押しでJSONを膨らませるしかないかと思っていた領域を、Reactの宣言的UIとデータ駆動(Data-Driven)の思想で美しくリファクタリングし、今朝ブラウザ単体で実験していた極上のネオン誘導弾幕を本番環境へ完璧にマージすることに成功しました。
新しい魔法とエフェクトを追加:
🗺️ 1. シナリオの誕生と、トビラをデータで支配するインフラ構築
初めての3D RPG開発において、箱庭の世界に命を吹き込むためのイベントインフラとシナリオの統治システムを構築しました。策定した短編プロットは『硝子の心と自動人形の国』です。住人全員が「悲しみ」を恐れて心を地底に封印し、感情を持たないドールとして淡々と暮らす静かな街を舞台に、魔女イレイナの旅が始まります。
大聖堂に佇むレイチェルちゃん(IDの重複バグから救出した物語のキーパーソン)から森のボスの討伐クエストを引き受けることで、世界の進行度を司るフラグ(storyStep)が動き出し、世界全体の前提条件がゴロッと切り替わる仕組みを実装しました。
📝 セリフデータの分離(StoryTexts.ts)
当初はすべてのNPCのセリフを3D配置データである Stages.json の中に直接書き込むスタイルを予定していました。しかし、開発規模が少しでも大きくなると、長いテキストを編集するたびにカンマの有無によるJSONの構文エラー(Syntax Error)で画面が真っ白になったり、テキストの修正中に誤ってNPCのXYZ座標データを消してしまうといった事故のリスクが高まります。
そこで、テキストデータだけを独立したTypeScriptファイル(StoryTexts.ts)へ完全に分離しました。TypeScript化することで、改行コード(\n)の挿入やコードコメントの記述も自由に行えるようになり、シナリオの肉付けに100%集中できる環境が整いました。
ロジック側では、Reactの強力な「宣言的UI」の性質を最大限に活かしています。ADVManager.tsx に以下のコードを1行滑り込ませるだけで、NPCをループ配置する際に現在の進行度に応じた適切なテキストが安全にルックアップされます。
// StoryTexts.ts から現在の storyStep に応じたセリフを動的に抽出します
const activeTalkText = STORY_NPC_DIALOGUES[npc.id]?.[storyStep] || npc.talkText;
裏側で手続き的に「セリフを書き換える関数」を呼び出す必要は一切ありません。大聖堂でイベントが発生し、storyStep が 0 から 1 へと更新された瞬間、街中や各ショップに配置されている全NPCのセリフが一斉に、かつリアクティブに最新の状態へと切り替わります。
🕳️ ワープポイントの通行規制(見えないトビラのバグ根絶)
今回のシナリオでは、森の中ボスを倒す前(STEP 0〜1の段階)に、地上にあるマンホールから地下世界のラストダンジョン(STAGE_TUNNEL)へ侵入できてしまうと、ストーリーのタイムラインが崩壊してしまいます。そのため、ストーリーの進行状況に応じてワープポイントの有効・無効を制御する通行規制インフラが必要でした。
これを実現するために、Stages.json のワープポイント設定の中に "visibleSteps" という条件配列のプロパティを拡張しました。例えば、地下へ続くマンホールの項目には "visibleSteps": [2, 3, 4] と記述し、中ボスを撃破した段階(STEP 2)以降のみ世界に実体化するように定義します。
{
"id": "STAGE_TUNNEL",
"description": "マンホールから地下へ",
"position": [-6.38, 5.4, -10.28],
"radius": 1.0,
"targetStageId": "STAGE_TUNNEL",
"targetSpawnPosition": [0.0, 0.0, 2.0],
"targetRotationY": 5.61,
"visibleSteps": [2, 3, 4]
}
ここで、3Dゲーム開発における重大な罠に直面します。それは、「見た目のエフェクト(金色の重層リング)だけを非表示にしても、プレイヤーの移動や衝突判定(ADVController)に生のJSONデータをそのまま渡していると、目に見えない透明なマンホールが空間に残ってしまい、そこを踏んだ瞬間にプレイヤーがワープに吸い込まれてしまう」というバグです。
この「透明ワープバグ」を完全に根絶するため、Reactの useMemo を使用して、現在の storyStep に適合するワープポイントだけをリアルタイムに抽出した純粋な配列(activeWarpPoints)を動的に生成するロジックを構築しました。
// 現在の storyStep に応じて、有効なワープポイントだけをリアルタイム抽出します
const activeWarpPoints = useMemo(() => {
return stageConfig.warpPoints?.filter((wp: any) => {
if (wp.visibleSteps && Array.isArray(wp.visibleSteps)) {
return wp.visibleSteps.includes(storyStep);
}
return false; // 設定(数字)がない場合は安全のため出現させません
}) || [];
}, [stageConfig.warpPoints, storyStep]);
このクリーンに抽出された activeWarpPoints を、空間の見た目を描画する WavyRing のループ処理と、プレイヤーの衝突判定を司る ADVController の双方に一元分配して同期させました。
これにより、見た目の消滅と同時に当たり判定も一撃で完全消滅する、非常に堅牢なイベントシステムが完成しました。プログラム側に特定のステージ名や条件式のハードコードを一切残すことなく、「すべてをデータ(JSON)の数字だけで支配・制御できる美しさ」を持ったゲームエンジンの土台がここに確立されました。
まずは短編プロット『硝子の心と自動人形の国』を策定しました。住人全員が心を地底に封印し、感情を持たないドールとして暮らす静かな街です。魔女イレイナが、大聖堂のレイチェルちゃん(ID重複バグから救出したキーパーソン)からクエストを引き受けることで世界のフラグ(storyStep)が動き出します。
セリフデータの分離(StoryTexts.ts)
最初は Stages.json にすべてを書き込む予定でしたが、JSON構文エラーの悪夢や座標データ誤消去のリスクを避けるため、シナリオテキストをTypeScriptファイルへと完全分離しました。
Reactのリアクティブな性質を活かし、プログラム側は以下の1行を仕込むだけで、storyStep の前進と同時に世界中のNPCのセリフが一斉に変貌します。
const activeTalkText = STORY_NPC_DIALOGUES[npc.id]?.[storyStep] || npc.talkText;
ワープポイントの通行規制(見えないトビラのバグ根絶)
森のボスを倒す前(STEP 0〜1)に、地下のラストダンジョンへ侵入できてしまう順序の崩壊を防ぐため、JSON側に "visibleSteps": [2, 3, 4] という条件配列を拡張しました。
{
"id": "STAGE_TUNNEL",
"description": "マンホールから地下へ",
"targetStageId": "STAGE_TUNNEL",
"visibleSteps": [2, 3, 4]
}
単に見た目(金色の波紋リング)を消すだけでなく、useMemo で条件を満たしたアクティブなワープデータだけを抽出して当たり判定(ADVController)にも分配しています。
これにより、「見た目は消えているのに、透明なマンホールを踏んだら地下へワープしてしまう」というゲーム開発特有の怪奇現象バグを鉄壁のガードで根絶しました。ボスを倒した瞬間、足元にマンホールがリアクティブに湧き出す快感は最高です。
🎨 2. UIハック:テキストの行長に吸い付く「ジャストフィット吹き出し」
3D空間上に配置された住人(NPC)たちの頭上にテキストを表示するスピーチバブル(ADVSpeechBubble.tsx)において、視認性とゲームとしての美しさを劇的に向上させるUIハックを行いました。
🧐 従来の課題:一律計算による「横長ウインドウ」と「虚無の余白バグ」
初期の実装では、表示する文字列全体の文字数(text.length)に一律の係数を掛け合わせるだけで、吹き出しウインドウの横幅(dynamicWidth)を計算していました。この単純なアプローチには、3Dアドベンチャーゲームを構築する上で致命的な2つの問題がありました。
- 画面の端から端まで広がる横長ウインドウ 長文を喋らせると吹き出しが画面の横幅いっぱいにまで伸びてしまい、プレイヤーの視線移動が激しくなり、せっかくの美しい3D箱庭の背景を大きく遮ってしまっていました。
- 改行を入れてもハコが縮まない「虚無の余白」
シナリオ側で読みやすさを考慮して手動で改行コード(
\n)を挿入しても、文字列全体の合計文字数(text.length)自体は変わらないため、「文字は全く存在しないのに、右側に不格好なウインドウ幅だけが広く残ってしまう」というレイアウトの不具合が発生していました。
システム側が固定の文字数で強制的に自動折り返しを行う手法も考えられますが、それでは「ここで一呼吸置くために改行を入れ、次の行はあえて短い言葉にする」といった、シナリオライターとしての絶妙なセリフのテンポや「間」の演出がシステムによって破壊されてしまいます。
🛠️ 解決策:行依存型の動的ジャストフィット構造
そこで、データに含まれる改行位置の意図を100%尊重しつつ、最も長い行にハコ全体の幅がピタッと吸い付くように自動伸縮する「マルチライン可変フィット構造」へとロジックを刷新しました。
Reactの useMemo を活用し、テキストが書き換わったタイミングで「最も文字数が多い行」の純粋な長さを割り出し、そこに「+2文字分」の安全なマージン(遊び)を上乗せして縦横のサイズをマトリクス計算する数理ロジックを組み込みました。
// シナリオデータの改行コード(\n)だけで純粋に分解し、ジャストフィットするウインドウサイズを計算します
const layout = useMemo(() => {
if (!text) return null;
// 1. 改行コードでテキストを行ごとに配列化
const lines = text.split('\n');
// 2. すべての行の中で「最も文字数が多い行」の長さを抽出
const maxLineLength = Math.max(...lines.map(l => l.length));
// 3. 一番長い行の文字数に「+2文字分」のマージンを追加して基準値を決定
const safeMaxChars = maxLineLength + 2;
// 各種レンダリング用のフォント・ピクセル係数
const fontSize = 0.045;
const charWidthFactor = 0.042; // ドットゴシックのフォント幅に最適化
const paddingX = 0.16;
const paddingY = 0.10;
// 4. 横幅は最長行の文字数(safeMaxChars)を基準に、縦幅は行数(lines.length)に比例して伸縮
const dynamicWidth = (safeMaxChars * charWidthFactor) + paddingX;
const dynamicHeight = (lines.length * fontSize * 1.4) + paddingY;
return {
processedText: text, // 強制的な分割はせず、元のテキストをそのまま流し込みます
dynamicWidth,
dynamicHeight,
fontSize
};
}, [text]);
文字列の配列をぐるぐる回して文字数をカウントしながら再結合していたような不格好で重い while ループを一掃し、Math.max で一撃で行長をフックする極めてクリーンで軽量なエンジニアリングへと昇華させています。
✨ ハックが生み出した圧倒的な製品クオリティ
この動的ジャストフィットハックにより、3D空間の見た目とシナリオ演出のクオリティが跳ね上がりました。
- 完璧な余白の維持(虚無のスペースの完全根絶)
どれだけ短い行と長い行が混在するセリフであっても、最長行の幅に合わせてウインドウが綺麗にフィットするため、右側の不自然な空間残りが完全に消滅しました。縦幅(
dynamicHeight)も行数に応じてビヨーンと自動で縦に広がるため、文字がハコからはみ出す心配もありません。 - ライターの意図した「間」の完全再現 プログラム側の制約を一切気にすることなく、テキストファイル側での改行位置の調整だけで、プレイヤーに対するメッセージの伝わり方をミリ単位で美しくセルフ演出できるようになりました。
- 往年の名作JRPGのようなソリッドデザイン
ウインドウがキャラクターの頭上にコンパクトに収まるようになったことで、サイバー感のあるネオンブルー(
#00f2fe)のフレームがテキストを綺麗に引き締め、一気に製品クオリティの洗練されたゲーム画面へと変貌を遂げました。
新しく追加したパブの前の猫耳ちゃん(NPC_CAT_EAR)のアンドロイドらしいセリフも、このスマートな可変型ダイアログによって、その哀愁とサイバー感が何倍も引き立つ美しい仕上がりとなっています。
⚔️ 3. 3D数学の死闘:左右に広がるファンアウト弾幕の完全データ駆動化
今朝、単体のWeb環境(SpreadReflectionArrow.html)で作成した必殺技の演出(立体ネオンのパーツ別矢・残像トレイル・着弾時のサイバー火花バースト、そして1.7倍速のスクリュー風切り音ハック)を、本番環境のバトルシステム(EnemyReflectionArrow.ts)へと移植しました。その過程で直面した3D空間の「ねじれ」との戦い、そして真のデータ駆動型(Data-Driven)設計への昇華についての記録です。
🌀 座標とワールド回転のねじれバグを撃破
実験用の単体HTML環境では完璧に美しい放物線を描いていた弾幕ですが、本番環境のバトルフィールドへと組み込んだ瞬間、2つの大きな3D数学的バグに行く手を阻まれました。
最初の問題は、「技を発動した瞬間、エフェクトが敵側ではなく味方(プレイヤー)のすぐ後ろに出現し、自分自身に突き刺さってしまう自爆現象」です。原因は、3D空間上から敵のメッシュを名前で直接探索しようとしたロジックが空振りし、フォールバック用の固定座標が適用されてしまっていたことでした。本番のバトル空間(ADVBattle.tsx)はエンカウントの方角に合わせて全体が旋回しているため、回転の影響で座標がズレ、たまたまプレイヤーの目の前に出現してしまっていたのです。
そこで、既存のインフラに倣い、回転角の計算を完全に済ませた正確な敵の足元座標(enemyGroundPos)を親のバトルエンジンから直接引き渡す安全なコンテキスト設計に修正しました。
しかし、これで敵の胸元から正しく射出されるようになったものの、次に「7本の矢が左右に扇形に広がらず、なぜかカメラから見て『縦一列』に並んで噴き上がってしまう」という奇妙な現象に直面しました。
この原因こそが、3D空間におけるワールド固定軸とローカル旋回軸のすれ違いでした。単体テスト環境とは異なり、本番のバトルフィールドはステージの方角に合わせて encounterRotY の角度で丸ごと旋回しています。位置座標(XYZ)は敵の場所に合わせられたものの、矢が進む方向ベクトル(spreadDirs)だけが元のワールド固定軸(真横)を向いたままになっていたため、旋回後のカメラの視線方向と完全に重なり、縦一列に並んでいるように見えていたのです。
この「向きのねじれ」を解決するため、進路ベクトルを複製し、ステージ全体の回転角(Y軸)に合わせて全く同じだけ旋回させる3D数学ハックを適用しました。
// 生の方向ベクトルを複製し、ステージの回転角(Y軸)に合わせて旋回させます
const rotatedDir = spreadDirs[i].clone();
rotatedDir.applyAxisAngle(new THREE.Vector3(0, 1, 0), this.encounterRotY);
この補正により、世界がどの方向を向いてエンカウントしようとも、常に敵アバターから見て綺麗な左右の扇形(ファンアウト)を描いて弾幕が拡散し、空中でピタッとロックオンしてイレイナに急加速で突撃する、極上のシークエンスが本番環境でも完全再現されました。
⚙️ ストラテジーパターンによる完全自動結合とアセット一元化
プログラム側に if (activeEnemyId === 'ENEMY_BUNYA') といった特定のキャラクターに依存する泥臭い条件分岐を書き散らすことは、拡張性の観点から絶対に避けたかった部分です。
幸いなことに、すでにバトルエンジン側には、敵の特殊攻撃を司るストラテジーレジストリ(specialAttackRegistry)と、JSONの "type" に応じて自動でシステムを切り替える executeEnemySpecialAttack 関数が最初から実装されていました。そのため、今回作った新システムをそのレジストリマップにパーツをハメるように1行登録するだけで、驚くほどスッキリとした汎用コードへと収束させることができました(途中で発生した変数名の些細なミスマッチバグもここで綺麗にクリーンアップしています)。
// ── 📊 【データ駆動型】敵の特殊攻撃を司るストラテジーレジストリ・マップ ──
const specialAttackRegistry = useMemo(() => {
return {
'APPLE_DROP': enemyAppleDropSystem, // 🍎 既存のリンゴ雨システム
'REFLECTION_ARROW': enemyReflectionArrow, // 🎯 ❤️ 今回移植した立体ネオン反射矢システム!
};
}, [enemyAppleDropSystem, enemyReflectionArrow]); // 依存配列に新システムをしっかり追加
さらに、直パスでコード内にベタ書きされていた効果音のファイルパスも完全に撤廃しました。一斉射撃音や着弾音(SE_MAGIC_REFLECTION_ARROW_02、SE_MAGIC_DAMAGE)のIDをすべて Avatars.json の中に一元掌握させることで、美しくデータ駆動型の接続を完了しています。
一番の課題となった「1.7倍速でループ再生ハックをかけている風切り音(SE_MAGIC_REFLECTION_ARROW_01)」についても、AudioController がプリロードした安全なキャッシュ(Blob URL)を破壊しないよう、内部のプールからソースを安全に引っこ抜いて複製する鉄壁のフォールバック処理を組み込みました。
// プリロード済みのBlob URLから安全に音源を引っこ抜いて複製し、1.7倍速ハックを適用します
const baseAudio = (audioController as any).seMap?.get('SE_MAGIC_REFLECTION_ARROW_01');
this.seSpinInstance = baseAudio ? new Audio(baseAudio.src) : new Audio('/assets/se/鞭を振り回す1.mp3');
これにより、ゲームシステムとしての必殺技インフラは完全なるデータ駆動へと進化を遂げました。プログラム側のソースコードには、特定のファイルパスやアセット固有のノイズが1文字も残りません。
今後、新ボス(hacklordやakuma)を追加した際も、JSONデータに "type": "REFLECTION_ARROW" と書き加えるだけで、プログラムを一切書き換えることなく、彼らも自動であの格好いい立体誘導弾幕を操れるようになります。アーキテクチャのクリーンさと美しさが極限まで研ぎ澄まされた、最高の開発セッションになりました。