[Astro #67] ステータス画面・Magic.json化・箒モード・ヒールエフェクト一気に実装

[Astro #67] ステータス画面・Magic.json化・箒モード・ヒールエフェクト一気に実装

前回 Astro #66 で ADVManager のリファクタと IndexedDB キャッシュを実装したが、今日はその上に乗せる UI 機能 と 魔法システムの拡張 を一気に進めた。一日の作業ログを残しておく。

🎯 今日の実装サマリ

項目内容
ステータス画面Tab / T キーで開閉、HEAL魔法で HP 回復可能
CameraHUD.tsxUI追従の共通部品化(VR対応)
Magic.json魔法定義のJSON化(effect: HEAL/ATTACK_ONLY/PASSIVE/TOGGLE_MODE)
PlayerGrowth.jsonLv5まで拡張
MAGIC_BROOMBキーで箒搭乗、速度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習得魔法
1MAGIC_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 を受け取って:

  1. 内側グループで体を 45° 回転(ネスト構造で yaw と分離)
  2. VRM の Head ボーンを逆回転 → 顔だけ正面を維持
  3. WiredAccessory で箒モデルを腰に装着
  4. 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 キーワード付きは「コンパイル後に削除される印」で、循環参照や副作用問題も同時に防げる仕様。

③ 戦闘リセット漏れ

isBroomModeuseEffect([currentStageId]) で監視していたが、戦闘突入は currentStageId を変えずに gameState だけ変える設計だったので、戦闘中も箒モードが残る問題。

修正:

useEffect(() => {
  setIsBroomMode(false);
}, [currentStageId, gameState]);  // gameState を追加

④ 「最初に Tab を押した時だけ SE が爆音」のような症状

これは①の SE 39回連打が原因だった。2回目以降は React がレンダリングを最適化していて updater 呼び出しが1回に収束していた模様。


🎮 動作確認

すべて完成したものを通しでプレイ:

  1. ステージ移動中に B → 箒に乗って爽快移動
  2. Tab → ステータス画面でHP/MP/EXP/魔法一覧確認
  3. HEAL を選択 → 探索中も HP 回復
  4. 敵と遭遇 → バトル画面
  5. 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 使用上限のリセットを待つ間にこの記事を書いている。


関連投稿