[Astro #73] ストーリー進行・ボス戦実装・ハイポリモデルのレイキャスター問題解決
🎯 今日の実装サマリ
| 項目 | 内容 |
|---|---|
| ボス戦・ダイアログUI | 会話イベント → ボス戦 → ストーリー進行フラグ |
| 敵の特殊攻撃(JSON化) | Avatars.json の specialAttack で各キャラの固有攻撃を定義 |
| 敵バリエーション追加 | SPHERE_CAT / GOSUT2 / BOSS_HACKLOAD / BOSS_AKUMA |
| 森ステージ エンカウント追加 | ボスと雑魚エンカウントを共存させる設計 |
| レイキャスター最適化 | 40万ポリゴンモデルで詰まる問題をメッシュフィルタで解決 |
| バトルバグ修正 | 魔法エフェクト誤発動・二重ダメージ・パネルはみ出し・敵ワープ |
| 箒移動改善 | スロープフィルタで坂道での速度低下を解消 |
🗣️ ボス戦・NPCダイアログシステム
storyStep(0〜4)でストーリー進行を管理する仕組みを構築した。
ボスNPCに一定距離(2.2m)まで接近すると会話イベントが自動発火し、ダイアログ終了後にボス戦へ移行する。
// 接近検知 → 会話 → handleEncounter で強制ボス戦
handleEncounter(battleStageId, 'BOSS_HACKLOAD'); // forcedEnemyId を渡す
重要な設計ポイント: handleEncounter は第2引数 forcedEnemyId を持っていて、ここにボスIDを渡すと encounterConfig.enemyIds のランダム抽選を無視してボス固定になる。これにより**「森を歩いていて出る雑魚敵」と「ボスNPCに接近して発生するボス戦」を同じステージで共存**させられる。
// Stages.json
"encounterConfig": {
"enabled": true,
"rate": 0.25,
"enemyIds": ["ENEMY_SPHERE_CAT", "ENEMY_GOSUT2"] // ランダム抽選はこの2体
}
// ボスは forcedEnemyId で別途ロック
NPCダイアログ(StoryTexts.ts)
各NPCのセリフをストーリーステップ別に Record<string, Record<number, string>> 形式で一元管理:
"NPC_CAT_EAR": {
0: "パブから陽気な音楽が聞こえなくなって、もう何サイクル経ったかしら。",
2: "あ、あれ……? 急に耳の奥のセンサーが熱くなって……これが『楽しい』っていう感情回路なの!?",
4: "おかえりなさい!今夜はパブでみんなで大お祝いパーティーよ!"
}
ストーリーの軸は「感情を失ったロボットたちの街で、ボスに封印された心を取り戻す」という内容。NPCが storyStep に応じてセリフを変えることで、世界観の変化を演出する。
⚔️ 敵の特殊攻撃をJSON化
Avatars.json の specialAttack フィールドに攻撃定義を追加することで、各キャラに固有攻撃を持たせられるようにした。
{
"id": "ENEMY_CHEN_QIANYU",
"status": {
"specialAttack": {
"name": "気功弾",
"damage": 25,
"chance": 0.35,
"type": "PROJECTILE",
"color": "#ff4400",
"animId": "ADV_ENEMY_ATTACKS"
}
}
}
{
"id": "BOSS_HACKLOAD",
"status": {
"specialAttack": {
"name": "拡散反射誘導弾",
"damage": 45,
"chance": 0.45,
"type": "REFLECTION_ARROW",
"castSe": "SE_SYSTEM_MENU_OPEN",
"strikeSe": "SE_MAGIC_REFLECTION_ARROW_02"
}
}
}
specialAttack を持たない敵(Bunya など)は通常の突撃攻撃のみ。chance で発動確率を調整できる。将来のボスに HEAL(自己回復)や AREA(範囲攻撃)を追加する際も、この JSON に type を増やすだけで対応できる設計。
敵の攻撃アニメーションのタイミングは3段階の setTimeout で制御:
setEnemyActiveAnim('ADV_ENEMY_ATTACKS'); // 即時:攻撃モーション開始
setTimeout(() => {
playSe('SE_MAGIC_CAST'); // 500ms:SE(モーションに合わせる)
fireProjectile(); // 500ms:弾丸発射
}, 500);
setTimeout(() => {
setEnemyActiveAnim('IDLE_BATTLE_02'); // 1500ms:アイドルに戻す
}, 1500);
🌲 ハイポリモデルのレイキャスター問題
今日の最大の難関。Katydid氏の森モデル(sketchfab)で特定の場所を歩くとプレイヤーが動けなくなり、カメラだけ先に進んでしまうバグが発生。ログは一切出ないため原因特定に相当な時間がかかった。
モデルのスペック
- 頂点数:785,408
- 面数:390,792
- オブジェクト数:203
- スケール:7倍で使用
調査過程
物理バイパステスト(全レイキャスト処理を return でスキップ)で自由に動けることを確認 → レイキャスターが原因と確定。
しかし WALL_BLOCK・FLOOR_STUCK・NO_GROUND のすべてのログが出ない。全状態ダンプ(Pキーで1フレームの情報を出力)を追加して確認すると:
velocity: (3.79, 0.00, -1.28) len=4.0000 ← 正常
finalPos ≠ position ← 移動は発生している
プレイヤーは動いているのにフリーズして見える — つまりレイキャスト計算がフレームに間に合わず、処理が詰まっていた。
原因
Material2 という名前のメッシュ群が40万ポリゴンの木・葉を構成しており、intersectObjects(..., true) で毎フレーム再帰的に全メッシュをレイキャスト対象にしていた。これが 60fps で連続処理できる限界を超えていた。
解決策(Geminiが提案)
// 毎フレーム:物理判定用のメッシュリストを構築(Material2を除外)
const validPhysicsMeshes: THREE.Object3D[] = [];
stageGroupRef.current.traverse((node) => {
if ((node as THREE.Mesh).isMesh) {
if (node.name.includes('Material2')) return; // 木・葉を除外
validPhysicsMeshes.push(node);
}
});
// intersectObjects の第2引数を false(再帰なし)に変更
frontRaycaster.current.intersectObjects(validPhysicsMeshes, false);
2つの最適化が効いた:
Material2除外 → 40万面のメッシュをレイキャスト対象から完全排除recursive: false→ フラット配列での直接判定(再帰走査を排除)
これは今後の汎用知識として重要: 高ポリゴンモデルには「物理判定専用の軽量メッシュ」を分けるか、メッシュ名でフィルタするのがThree.jsでの定石。
🐛 バトル関連バグ修正
① MAGIC_SHOT がバーストエフェクトを使っていた
executeAttackMagic が magic.id を区別せず全攻撃魔法で magicBurstSystem.startBurst() を呼んでいた。executeMagicShot と executeMagicBurst に分離して解決。
② ボス戦後にMPが回復する
executeMagicBurst 内の setTimeout から handleBattleVictory を呼ぶ際、MPの setState が反映される前の stale な値を使っていた。
// ❌ クロージャで古い値をキャプチャ
let finalMp = workingPlayerMp;
// ✅ Ref で最新値を参照
const workingPlayerMpRef = useRef(props.playerMp);
workingPlayerMpRef.current = workingPlayerMp;
let finalMp = workingPlayerMpRef.current;
③ 魔法選択UIがパネルからはみ出す
コマンド数に応じてパネル高さを動的計算:
const itemSpacing = commands.length > 3 ? 0.30 : 0.38;
const panelHeight = Math.max(1.5, commands.length * itemSpacing + 0.5);
④ 敵が突撃前に右側へワープ
triggerEnemyTurn で enemyStartPosition[0] + 1.0 していたオフセットを削除。
🧹 箒モード移動改善
坂道で箒モードの移動速度が歩行より遅くなる問題。壁判定のレイキャスターが地面の斜面を「壁」と誤認し、速度成分を削っていた。
法線のY成分でスロープを判定するフィルタを追加:
const slopeThreshold = isBroomModeRef.current ? 0.3 : 0.5;
if (Math.abs(faceNormal.y) > slopeThreshold) continue; // 斜面をスキップ
📝 振り返り
ストーリーが形になってきた。感情を失ったロボットたちの街、森のボス、地下の感情炉 — まだストーリーの全容は決まっていないが、プレイヤーが「次に何が起きるか」を感じられる骨格ができた。
今日一番の学びはレイキャスター問題。「ログが出ない」「原因が分からない」という状況で、全物理バイパスで切り分け → 全状態ダンプで値確認 → メッシュ名フィルタで解決 という流れは、今後の似たような問題でも使えるパターン。パフォーマンス問題はツールではなく論理的な切り分けで解くしかない。