[Astro #67] ステータス画面・Magic.json化・箒モード・ヒールエフェクト一気に実装
前回 Astro #66 で ADVManager のリファクタと IndexedDB キャッシュを実装したが、今日はその上に乗せる UI 機能 と 魔法システムの拡張 を一気に進めた。一日の作業ログを残しておく。
🎯 今日の実装サマリ
| 項目 | 内容 |
|---|---|
| ステータス画面 | Tab / T キーで開閉、HEAL魔法で HP 回復可能 |
| CameraHUD.tsx | UI追従の共通部品化(VR対応) |
| Magic.json | 魔法定義のJSON化(effect: HEAL/ATTACK_ONLY/PASSIVE/TOGGLE_MODE) |
| PlayerGrowth.json | Lv5まで拡張 |
| MAGIC_BROOM | Bキーで箒搭乗、速度2倍以上、体45°+頭逆回転 |
| バトルUI階層化 | ATTACK/MAGIC/ESCAPE→魔法選択サブメニュー |
| ヒールエフェクト | ADVHealSystem.ts 新規、300個の星パーティクル |
📊 ステータス画面 (Tab / T キー)
ADVStatusMenu.tsx を新規作成。LV / HP / MP / EXP に加えて、習得済みの魔法一覧を表示する。HEAL を選択すると HP が回復し MP を消費する仕組み。
メニュー内で扱える魔法は effect フィールドで挙動分岐:
HEAL→ 探索中に使用可能、HPを回復ATTACK_ONLY→ 戦闘専用としてグレーアウト表示PASSIVE→ 常時発動(説明文のみ表示)TOGGLE_MODE→ モード切替系
3D空間ホログラム形式の見た目で、HP/MP/EXP には残量プログレスバー付き。
📺 CameraHUD.tsx の共通化
セーブ・ベッド休憩・ステータス画面など、複数の UI で「カメラを動かすとパネルがズレる」問題が散発していた。原因は rotation.y だけでカメラ追従していたこと(ピッチ・ロールに追従しない)。
毎フレーム、カメラのワールド位置とクォータニオンを取得して子要素を完全追従させる純粋なHUDアンカーとして CameraHUD.tsx を作成:
useFrame(() => {
camera.getWorldPosition(worldPos);
camera.getWorldQuaternion(worldQuat);
forward.set(0, 0, -1).applyQuaternion(worldQuat);
groupRef.current.position
.copy(worldPos)
.addScaledVector(forward, distance);
groupRef.current.quaternion.copy(worldQuat);
});
getWorldPosition/Quaternion を使っているので VR の cameraRig 配下でも問題なく動く。SaveMenu / HealMenu / StatusMenu すべてに横展開して、UI 安定問題を一括解決した。
🪄 Magic.json で魔法データを一元化
これまで MAGIC_DEFINITIONS としてコード内ベタ書きだった魔法情報を JSON 化。Magic.json 一箇所変更すれば全箇所に反映されるようになった。
[
{
"id": "HEAL",
"displayName": "ヒール",
"mpCost": 5,
"effect": "HEAL",
"hpRestore": 50,
"description": "HPを50回復する。MPを5消費。"
}
]
バトル中の MP コストもこれまでベタ書きで const MAGIC_COST = 10 だったが、magic.mpCost から動的取得するように変更。
📈 PlayerGrowth.json を Lv5 まで拡張
レベルアップで覚える魔法を各レベルに紐付け。
| Lv | 習得魔法 |
|---|---|
| 1 | MAGIC_SHOT |
| 2 | + HEAL |
| 3 | + MAGIC_LIGHT |
| 4 | + MAGIC_BURST |
| 5 | + MAGIC_BROOM |
🧹 箒モード (MAGIC_BROOM)
レベル5で習得する魔法。B キーを押すと箒に騎乗、速度2倍以上で移動できる。
実装は STG ゲームで作成済みの WiredAccessory(アクセサリ装着機構)と SYS_BROOM_STILL アニメーション資産をそのまま流用。ADVAvatar.tsx を改修して、isBroomMode prop を受け取って:
- 内側グループで体を 45° 回転(ネスト構造で yaw と分離)
- VRM の Head ボーンを逆回転 → 顔だけ正面を維持
- WiredAccessory で箒モデルを腰に装着
- ADVController に
speedMultiplierを渡して速度ブースト
ステージ遷移・戦闘突入時には自動 OFF。useEffect([currentStageId, gameState]) で監視すればこれが1行で済む。
useEffect(() => {
setIsBroomMode(false);
}, [currentStageId, gameState]);
⚔️ バトルUIの階層化
これまで「まほう」を選ぶと自動的に MAGIC_SHOT が発動する固定実装だったが、ドラクエ風のサブメニュー化に変更:
[MAIN] ATTACK / MAGIC / ESCAPE
↓ MAGIC を選択
[MAGIC] マジックショット(MP10) / ヒール(MP5) / ← もどる
battleMenuMode: 'MAIN' | 'MAGIC' で切り替え、currentCommands を動的に組み立てる。ESC / Backspace で MAIN に戻れる。
また、アニメーション中はコマンドメニュー部分だけ非表示にした。HP/MP ゲージは常時表示、コマンドだけ隠れるので「操作不能状態」が直感的に分かる。
💚 ADVHealSystem.ts (ヒールエフェクト)
今日一番苦労した部分。300個の星型パーティクルがプレイヤー周囲を等速螺旋で上昇し、足元には魔法陣が二重反転で回転する演出を実装。
設計のポイント:
- 5角形星型テクスチャを Canvas で動的生成(外部画像依存ゼロ)
- HSL色相シフトで粒子が虹色にグラデーション(時間 × インデックス位置)
vertexColors: true+ colorArray 直接書き換えでシェーダー不要- 高さは
(loopTime / 1.5) * 1.6mでループ → 「継続的に湧き上がる」感じを演出 - 1.5秒で頭上に達して 0 にリセット
タイミング設計も重要で、executeHealMagic 内で:
// 800ms タメ → 1500ms 演出
setTimeout(() => healSystemRef.current?.startHeal(playerPos), 800);
setTimeout(() => {
healSystemRef.current?.releaseHeal();
setWorkingPlayerHp(prev => Math.min(prev + (magic.hpRestore || 0), maxHp));
triggerEnemyTurn();
}, 2300);
SE と祈りアニメが先に始まり、800ms 後に星屑が爆誕する。詠唱と同時にエフェクトが出ると違和感しかないので、SE・アニメ・エフェクトの3つを聴覚と視覚で計測しながらこの値に詰めた。こういう「数字を選ぶ」作業は AI には絶対できない、人間の試行回数だけがゴールに辿り着ける部分だと思う。
🐛 今日嵌ったバグ
① SE が39回連打される問題
ステータス画面を Tab で開くと、SE_SYSTEM_MENU_OPEN が爆音で鳴る現象。console.log を仕込むとイベントハンドラ自体は1回しか発火していない。
原因は React の setState(updater) の中で副作用を呼んでいたこと:
// ❌ updater の中で SE 再生
setIsStatusMenuOpen((prev) => {
audioController.playSe('SE_SYSTEM_MENU_OPEN', 0.5); // 39回呼ばれる
return !prev;
});
React は updater 関数を検証・並行処理のために予告なく複数回呼ぶ権利を持っている。(prev) => ... の中に書いていいのは「次のstateを計算する純粋なロジック」だけ。
修正は Ref で最新値を取得し、副作用は updater の外へ:
const wasOpen = isStatusMenuOpenRef.current;
setIsStatusMenuOpen(!wasOpen);
audioController.playSe('SE_SYSTEM_MENU_OPEN', 0.5); // 1回だけ
これに気付くまでデバッグに30分以上かかった。「2回目以降は鳴らない」「ログは1回だけ」という症状が原因の特定を難しくした。
② TypeScript の verbatimModuleSyntax
新ファイルから interface を import { ADVStatusMenu, ADVStatusSkillEntry } で取り込んだら Vite がエラー。原因は verbatimModuleSyntax: true 設定下では型を通常 import できないこと。
修正は1文字追加:
import { ADVStatusMenu, type ADVStatusSkillEntry } from './ADVStatusMenu';
type キーワード付きは「コンパイル後に削除される印」で、循環参照や副作用問題も同時に防げる仕様。
③ 戦闘リセット漏れ
isBroomMode を useEffect([currentStageId]) で監視していたが、戦闘突入は currentStageId を変えずに gameState だけ変える設計だったので、戦闘中も箒モードが残る問題。
修正:
useEffect(() => {
setIsBroomMode(false);
}, [currentStageId, gameState]); // gameState を追加
④ 「最初に Tab を押した時だけ SE が爆音」のような症状
これは①の SE 39回連打が原因だった。2回目以降は React がレンダリングを最適化していて updater 呼び出しが1回に収束していた模様。
🎮 動作確認
すべて完成したものを通しでプレイ:
- ステージ移動中に B → 箒に乗って爽快移動
- Tab → ステータス画面でHP/MP/EXP/魔法一覧確認
- HEAL を選択 → 探索中も HP 回復
- 敵と遭遇 → バトル画面
- MAGIC → ヒールを選択 → 星屑エフェクトとともに HP 全回復
通しでスムーズに動いた。
📝 残タスク
- STG 側にも頭の逆回転パッチを当てる(同じ
getNormalizedBoneNode('head')パターン) MagicDefinition型を ADVBattle.tsx と ADVManager.tsx で重複定義しているのをtypes/Magic.tsに集約- 箒のモデル差し替え(今のは仮置きなので、もっと魔女らしいものに)
- バランス調整(MP コスト、HP回復量、敵の強さ)
🧠 振り返り
今回の AI 活用は Claude が中心。GUI/JSX レイアウトや状態管理の設計、TypeScript エラーの解読には強い。一方で、「ちょうど良い数字を選ぶ」「数学的に難しい軌道計算」は今回も AI 一発出しでは厳しく、手で詰める必要があった。
特にヒールエフェクトの螺旋軌道と色相シフトは、Gemini と一緒に試行錯誤して詰めた部分。AI に骨組みを書かせ、自分で命を吹き込む、という構図は変わらない。
ノイズ表現を作っていた頃と本質は同じで、コードを読む力 + バグを見つける力を AI が補ってくれることはない。AI 使用上限のリセットを待つ間にこの記事を書いている。