[Astro #66] ADVManager大規模リファクタリング & IndexedDB全アセットキャッシュ化

[Astro #66] ADVManager大規模リファクタリング & IndexedDB全アセットキャッシュ化

はじめに

ADV/RPGプロジェクトを開始して4日目。プロジェクトの中核を担う ADVManager.tsx が 1523行 まで膨らみ、新機能追加のたびに「どこに書けばいいか分からない」という状態になっていました。

この日は朝9時から夕方まで、ほぼ AI(Claude)とのバイブコーディングで以下を一気にやり切りました:

  • リファクタ : ADVManager 1523行 → 750行台(約50%減)
  • バグ修正 : 4日間積み残した10件以上の挙動バグを根本解決
  • IndexedDBキャッシュ化 : 全アセット(VRM/Audio/Stage/Image)をIndexedDBへ保存、起動時間が大幅短縮
  • 新シーン追加 : 街のマンホールから地下洞窟へのルート + 洞窟内のライティング

記事タイトルが「Astro #66」ですが、Astroベースのプロジェクトでこのレベルの3Dゲーム開発をしている、という意味では珍しい記録だと思います。

NOTE:

1. ADVManager.tsx の大規模リファクタ

課題

ADVManager.tsx は探索ロジック・バトル・セーブ・休憩・VR入力など複数の責務が混在し、1523行に肥大化していました。1つのファイルが大きいこと自体が悪なわけではないですが、 機能ごとに別のタイミングで触りたいのに、毎回1523行のファイルを開かないといけない のは生産性に直結します。

方針: A方式(子コンポーネントが状態を所有)

リファクタには2つの方向性があります:

  • B方式 : 親が全state所有、子はprops経由で操作する
  • A方式 : 子が機能の完結したstateを持ち、親とは最小限のprops/コールバックで連携

今回は A方式 を採用。理由は、バトルやセーブが「明確な機能単位」として完結していて、外部とのインターフェース(バトル開始時の情報、終了時の結果)が小さいから。

ステップ1: バトル機能を ADVBattle.tsx に分離

// ADVBattle.tsx の Props 設計
interface ADVBattleProps {
  // バトル開始時に確定する情報
  currentBattleStageId: string;
  activeEnemyId: string;
  initialEnemyHp: number;
  initialMaxEnemyHp: number;
  encounterRotY: number;

  // プレイヤーの恒久ステータス
  playerLevel: number;
  playerExp: number;
  playerHp: number;
  playerMp: number;

  // アバター設定・共有リソース
  avatarConfig: any;
  resolvedAnimUrls: string[];
  resolvedVRMUrls: Map<string, string>;
  resolvedStageUrls: Map<string, string>;
  chargeSystem: ADVChargeSystem | null;

  // フェード制御
  fadeState: 'none' | 'fade-out' | 'fade-in';
  setFadeState: (s: 'none' | 'fade-out' | 'fade-in') => void;

  // 完了通知
  onBattleEnd: (result: {
    outcome: 'VICTORY' | 'DEFEAT' | 'ESCAPE';
    newPlayerHp: number;
    newPlayerMp: number;
    newPlayerLevel: number;
    newPlayerExp: number;
  }) => void;
}

ADVManager 側はバトル中の状態を gameState === 'BATTLE' の判定だけで持ち、内部状態(コマンドカーソル、敵HP、battlePhase、ダメージポップアップ)はすべてADVBattleが所有します。

ステップ2: セーブ機能を SaveMenu.tsx に分離

export interface SaveData {
  playerLevel: number;
  playerExp: number;
  playerHp: number;
  playerMp: number;
  currentStageId: string;
  spawnPosition: [number, number, number];
  avatarRotY: number;
}

interface SaveMenuProps {
  activeSavePointId: string | null;  // 接近検知

  // セーブに必要なプレイヤー状態
  playerLevel: number;
  playerExp: number;
  playerHp: number;
  playerMp: number;
  currentStageId: string;
  avatarPos: THREE.Vector3;
  avatarRotY: number;

  isOpen: boolean;
  setIsOpen: (open: boolean) => void;

  // ロード時に親のStateを一括上書き
  onLoadGame: (data: SaveData) => void;
}

SaveMenu内部で:

  • LocalStorageへの読み書き
  • メニュー階層(TOP / SAVE / LOAD)
  • カーソル位置管理
  • キーボード/VRコントローラー入力処理

を全て担当。ADVManager側は接近判定(useFrame内のsavePoints距離計算)だけ残し、開閉処理は SaveMenu の useEffect が activeSavePointId を見て自動で行うように。

VR入力フック化は「不要」と判断

当初の予定では3つ目のリファクタとして「VR入力ロジックの共通フック化」を考えていましたが、バトル分離・セーブ分離が完了した時点で残るVR入力処理は ADVBattle と SaveMenu の各1か所のみ、しかも目的が違う(コマンド選択 vs スロット選択)ものでした。

「 似ているが目的が違うコードを共通化すると、汎用化のための余計な抽象が増える 」というDRY原則の罠を避け、 やらない判断 を下しました。

結果

ファイルリファクタ前リファクタ後
ADVManager.tsx1523行750行台
ADVBattle.tsxなし582行
SaveMenu.tsxなし214行

全体の行数はほぼ同じですが、 機能ごとにファイルが分かれた ことで、変更時の影響範囲が劇的に小さくなりました。

2. 4日間の積み残しバグを一気に解決

「ベッドに埋まる」問題の真の原因

ROOMでベッドに乗ろうとするとプレイヤーが埋まる問題。何度も修正を試みていたものの、毎回別の箇所が壊れるシーソーゲームになっていました。

真の原因 は ADVController の地面追従ロジックにありました。

// 修正前: プレイヤー現在Yに最も近いヒット点を選ぶ
let bestHit = intersects[0];
let minDiff = Math.abs(intersects[0].point.y - playerPosition.current.y);
// ...

この「 現在Yに最も近い 」というロジックが、フィールド01のスケール 0.1 のような縮小されたステージで誤動作。「現在のYに近い」=「地面より少し下の何か」を選んでしまっていました。

修正版: 物理的に正しい選定ロジック

const MAX_HEAD_ROOM = 1.5;
let bestIntersect: THREE.Intersection | null = null;

for (const hit of intersects) {
  // プレイヤーから2m以上上の点は無視(屋根・天井など)
  if (hit.point.y > playerPosition.current.y + MAX_HEAD_ROOM) continue;
  // 残りの中で最も高いヒット点を採用
  if (!bestIntersect || hit.point.y > bestIntersect.point.y) {
    bestIntersect = hit;
  }
}

物理的にあるべき挙動 は「上からレイを撃って、プレイヤー頭上ではない範囲で最も高い点(床面/家具上面)を選ぶ」。これに変えたら、ベッドも床もフィールドも全部一発で直りました。

副次的な4つの調整可能定数の整理

リファクタついでに、地形物理を4つの定数として明示的に整理:

const MAX_STEP_HEIGHT = 0.6;       // 登れる段差の高さ
const STEP_DEADZONE = 0.05;        // ミリ単位のメッシュずれを無視
const GROUND_LERP_SPEED = 0.15;    // 地面追従の補間速度
const MAX_HEAD_ROOM = 1.5;         // 頭上メッシュを除外する閾値

これだけで以下が連鎖的に解決:

  • 教会の床がミリ単位でずれていて発生していた振動 → STEP_DEADZONE で吸収
  • 教会の段差が登れない → MAX_STEP_HEIGHT 0.3 → 0.6 に調整
  • 急斜面で天井にワープする → MAX_HEAD_ROOM フィルタで除外
  • フィールドで足元が地面に埋まる → 最高ヒット選定ロジックで解決

エンカウント時のカメラ急変バグ

フィールドで敵に遭遇した瞬間、カメラがバンッと急変する問題。原因は「 暗転完了までの400msのあいだ、ADVController が動き続けていた 」こと。

// ADVManager.tsx
const handleEncounter = (battleStageId: string) => {
  if (fadeState !== 'none') return;

  // 🎯 追加: エンカウント中はControllerをフリーズ
  setIsEncountering(true);
  isWarpingRef.current = true;

  setFadeState('fade-out');

  setTimeout(() => {
    setGameState('BATTLE');
    setFadeState('fade-in');
    setTimeout(() => {
      setFadeState('none');
      setIsEncountering(false);  // 解除
      isWarpingRef.current = false;
    }, 600);
  }, 400);
};

// ADVController に渡す disabled に isEncountering を OR で追加
<ADVController
  // ...
  disabled={isSaveMenuOpen || isHealMenuOpen || isResting || isEncountering}
/>

これで「エンカウント発生 → 即座にプレイヤーとカメラを凍結 → 400ms暗転 → バトル切り替え」という綺麗な流れに。

バトル空間をプレイヤー視点に合わせて回転

ADVBattle の最外側 group に rotation-y={encounterRotY} を追加して、 バトル空間全体をプレイヤーが向いていた方向に回転 させる設計に変更:

<group
  key={`battle-stage-${props.currentBattleStageId}`}
  rotation-y={props.encounterRotY}
>
  {/* バトルステージ・プレイヤー・敵・UI・エフェクト */}
</group>

カメラ位置も同じ回転を適用:

const baseCamPos = new THREE.Vector3(2.0, 2.5, 5.5);
const baseTarget = new THREE.Vector3(0, 0.5, 0);
baseCamPos.applyAxisAngle(new THREE.Vector3(0, 1, 0), props.encounterRotY);
baseTarget.applyAxisAngle(new THREE.Vector3(0, 1, 0), props.encounterRotY);
state.camera.position.copy(baseCamPos);
state.camera.lookAt(baseTarget);

これにより「 振り向いた先に敵が現れる 」という、ドラクエ的に自然な遭遇演出が成立しました。

副作用: チャージエフェクト位置のズレ

バトル空間を回転させた結果、ADVChargeSystem(魔法詠唱の魔法陣エフェクト)が scene.add() でシーン直下に追加されていたため、回転前の座標に表示される問題が発生。

対応策として、ADVBattle側で applyAxisAngle で回転後のワールド座標に変換してから渡す :

const playerLocalPos = new THREE.Vector3(...);
playerLocalPos.applyAxisAngle(new THREE.Vector3(0, 1, 0), props.encounterRotY);

// プレイヤーの前方ベクトルも回転
const forward = new THREE.Vector3(0, 0, -0.4);
forward.applyAxisAngle(new THREE.Vector3(0, 1, 0), props.encounterRotY);
playerLocalPos.add(forward);

props.chargeSystem.update(delta, playerLocalPos);

3. IndexedDBによる全アセットキャッシュ化

課題

起動するたびに毎回 VRM/glb/mp3/webp を全部ダウンロードしていました。ファイル数が増えるにつれ起動が遅くなり、特にWebXRでQuestブラウザから起動する時、ネットワークが不安定だと体験が悪い。

設計方針

  • キャッシュ対象 : VRM、Stage glb、Audio (BGM + SE)、Image (webp)
  • DBスキーマ : WiredCacheDB を v4 に上げ、advStages / advAudios / advImages を新規追加
  • VRMは既存の models テーブル を共用(他のSTG/Puzzleプロジェクトと共通利用するため)
  • ロードタイミング : ADVManager 起動時に並列で全部キャッシュロード

Dexie DB スキーマ拡張

// src/lib/db.ts
export class WiredCacheDB extends Dexie {
  models!: Table<AssetData>;       // 既存(VRMを集約)
  animations!: Table<AssetData>;
  stages!: Table<AssetData>;       // 既存(他プロジェクト用)
  skyboxes!: Table<AssetData>;
  items!: Table<AssetData>;
  audios!: Table<AssetData>;       // 既存

  // 🎯 ADV専用テーブル(新規)
  advStages!: Table<AssetData>;
  advAudios!: Table<AssetData>;
  advImages!: Table<AssetData>;

  constructor() {
    super('WiredAssetCache');
    this.version(4).stores({
      models: 'id',
      animations: 'id',
      stages: 'id',
      skyboxes: 'id',
      items: 'id',
      audios: 'id',
      advStages: 'id',
      advAudios: 'id',
      advImages: 'id'
    });
  }
}

キャッシュロードの共通パターン

ADVManager 内で、各アセット種別に対して同じパターンの useEffect を書く:

const [resolvedVRMUrls, setResolvedVRMUrls] = useState<Map<string, string> | null>(null);

useEffect(() => {
  let isMounted = true;

  const resolveVRMs = async () => {
    // 1. JSON から全アセット情報を収集
    const vrmEntries: { id: string; file: string; version: string }[] = [];

    AVATARS.forEach((avatar: any) => {
      if (avatar.file?.endsWith('.vrm')) {
        vrmEntries.push({
          id: avatar.id,
          file: avatar.file,
          version: avatar.version || '1.0.0'
        });
      }
    });

    STAGES.forEach((stage: any) => {
      stage.npcs?.forEach((npc: any) => {
        if (npc.file?.endsWith('.vrm')) {
          vrmEntries.push({
            id: npc.id,
            file: npc.file,
            version: npc.version || '1.0.0'
          });
        }
      });
    });

    // 2. 重複除外
    const uniqueMap = new Map<string, typeof vrmEntries[0]>();
    vrmEntries.forEach(entry => {
      if (!uniqueMap.has(entry.file)) uniqueMap.set(entry.file, entry);
    });

    // 3. 並列キャッシュロード
    const blobUrlMap = new Map<string, string>();
    await Promise.all(
      Array.from(uniqueMap.values()).map(async (entry) => {
        const blobUrl = await getCachedAssetUrl(db.models, entry);
        blobUrlMap.set(entry.file, blobUrl);
      })
    );

    if (isMounted) {
      setResolvedVRMUrls(blobUrlMap);
    }
  };

  resolveVRMs();
  return () => { isMounted = false; };
}, []);

子コンポーネントには Map<filePath, blobUrl> を渡し、各コンポーネントは:

<ADVAvatar
  modelUrl={resolvedVRMUrls?.get(avatarConfig.file) || avatarConfig.file}
  // ...
/>

このパターンを VRM/Audio/Stage/Image 全部に適用しました。

重要な発見: useLoader と Blob URL の相性

useLoader(GLTFLoader, blobUrl) は Blob URL を渡しても何の問題もなく動きます 。useLoader はURLをキーにしてキャッシュ管理するだけなので、blob:http://... でも /models/foo.vrm でも同じように扱える。

ただし注意点として、 Blob URLは毎回作り直さず、Mapにキャッシュして使い回す ことが重要です。毎フレーム新しいBlob URLを生成すると useLoader のキャッシュが効かず、メモリリークの原因になります。

AudioController も既存のまま動く

THREE.AudioLoader.load(url, callback) は内部的に fetch でデータを取って AudioBuffer に変換するだけなので、Blob URL を渡してもそのまま動作。 AudioController.ts を1行も変更せずに 、ADVManager から渡すURLをBlob URLに置き換えるだけでキャッシュ化が完了しました。

結果

初回起動時:

🔄 VRM_PRELOAD: 7個のVRMをキャッシュ経由でロード開始...
[DOWNLOAD] AVATAR_IREINA (URL: /models/Ireina.vrm v: 1.0.0)
[DOWNLOAD] ENEMY_BUNYA (URL: /models/puzzle/Bunya.vrm v: 1.0.0)
... (省略) ...
✅ VRM_PRELOAD_COMPLETE: 7個のVRMロード完了 (517ms)

2回目以降の起動:

✅ VRM_PRELOAD_COMPLETE: 7個のVRMロード完了 (132ms)
✅ AUDIO_PRELOAD_COMPLETE: 13個のオーディオロード完了 (25ms)
✅ STAGE_PRELOAD_COMPLETE: 7個のステージロード完了 (20ms)
✅ IMAGE_PRELOAD_COMPLETE: 4個の画像ロード完了 (19ms)

31個のアセットが209msで全て準備完了 。シーン遷移時もネットワーク通信ゼロで、瞬時に切り替わるようになりました。

4. 新シーン: 街のマンホールから地下洞窟へ

Stages.json への追加

{
  "id": "STAGE_TUNNEL",
  "name": "トンネル洞窟",
  "file": "/models/adv/shing_mun_redoubt_intricate_tunnel_network_1.glb",
  "version": "1.0.0",
  "scale": [2.0, 2.0, 2.0],
  "spawnPosition": [5, 5, -5],
  "cameraRadius": 3.5,
  "bgm": "/assets/audio/異界からの風.mp3",
  "warpPoints": [
    {
      "id": "STAGE_CITY",
      "description": "マンホールから街へ",
      "position": [0.1, -1.5, -0.1],
      "radius": 1.0,
      "targetStageId": "STAGE_CITY",
      "targetSpawnPosition": [-6.38, 5.4, -8.28],
      "targetRotationY": 5.61
    }
  ]
}

JSON駆動なので、ステージを追加すると自動的に:

  • Stage glb キャッシュ対象に含まれる(STAGES.forEach で拾うため)
  • BGM もキャッシュ対象に含まれる(stage.bgm 経由)

新シーン追加にコード変更が ほぼ不要 な設計になっていることを実感しました。

暗闇の洞窟探索ライティング

洞窟内はデフォルトでは真っ暗。プレイヤーの周囲だけ照らすライトを実装したい。

最終的に行き着いた最良の実装:

{currentStageId === 'STAGE_TUNNEL' && (
  <pointLight
    position={[
      avatarPos.x + Math.sin(avatarRotY) * -3.0,
      avatarPos.y + 0.5,
      avatarPos.z + Math.cos(avatarRotY) * -3.0
    ]}
    intensity={5.0}
    distance={10.0}
    decay={2.0}
    color="#ffeecc"
  />
)}

ポイント:

  • イレイナの正面3m先に光源を浮かべる (プレイヤーから離すことでVRMの白飛びを防ぐ)
  • 距離 distance=10 で減衰を効かせて、遠くは闇のまま
  • 暖色 #ffeecc でロウソク・ランタン風

Math.sin(avatarRotY) * -3.0 の -3.0 は VRMの前方軸の符号によるもの。プロジェクトによって座標系の符号が違うため、最終的に試行錯誤で確定しました。

試行錯誤の過程

最初は spotLight でペンライト風に実装を試みましたが:

  • angle penumbra decay の調整が難しい
  • target の扱いがR3Fと相性が悪い(target が scene に追加されない問題)
  • castShadow が重い

→ シンプルな pointLight に切り替え、位置をプレイヤーから離すアプローチが最も安定しました。

Three.js のレイヤー機能による「VRMだけ光らせない」設計の試み

mesh.layers.enable(1) でステージメッシュをレイヤー1に追加、light.layers.set(1) でライトをレイヤー1専用に、という試みもしましたが、R3F + VRM環境では挙動が予測しにくく、最終的に「物理的に光源をVRMから離す」方が安定でした。

レイヤー設計のメモ:

  • light.layers.set(1): このライトはレイヤー1のオブジェクトしか照らさない
  • mesh.layers.set(1): このメッシュはレイヤー1だけに存在(カメラがレイヤー1を見ていないと描画されない)
  • mesh.layers.enable(1): このメッシュはレイヤー0と1の両方に存在
  • カメラのデフォルト: layers.mask = 1(レイヤー0のみ表示)

5. 設計上の学び

「やらない判断」の重要性

リファクタの3つ目として予定していた「VR入力フック化」は、バトル分離・セーブ分離が完了した時点で 自然と不要になった 。

このように「やる前は必要に見えたものが、別の作業の結果として不要になる」というのは設計の自然な流れで、無理に当初予定を守らない柔軟性が大事だと再認識しました。

「最小修正」のシリーズ vs 「根本修正」の判断

ベッド埋まりバグは、過去に何度も「最小修正」を入れて、毎回別の箇所が壊れていました。

最終的に効いたのは「 選定ロジックを根本から変える 」修正で、これは見方を変えれば それまでの修正全部を捨てる 判断でもありました。「これまで投資してきた修正コードを捨てる勇気」が、根本解決には必要。

JSON駆動設計の威力

Stages.json / Avatars.json / SoundEffects.json / Images.json / BattleConfigs.json と、ゲームのデータをJSONに分離してきたことが、IndexedDBキャッシュ化で大きく効きました。

「全アセットを列挙する」というのが forEach ループ1回で済むため、新規追加・削除が JSONの編集だけで完結 します。新シーン追加もJSON 1ブロック追加で完了、というのは設計の勝利。

バイブコーディングの実感

この日1日で30件以上の改修を完遂できたのは、AIとの対話によってです。1人で全部書いていたら、設計判断と実装と検証で頭がパンクして、こんなペースは絶対無理でした。

ただし、AIに任せきりではなく:

  • 設計判断のYes/No は人間が決める
  • 動作確認とフィードバック も人間が行う
  • 「ここがおかしい」「これだとダメ」 を的確に伝える

この部分は人間の役割で、ここがしっかりしているとAIは恐ろしいほど生産的に動きます。

まとめ

カテゴリ内容
リファクタADVManager 1523行 → 750行台、ADVBattle/SaveMenu に責務分離
バグ修正ベッド埋まり、フィールド埋まり、屋内天井ワープ、坂道、教会床振動、教会段差、休憩キーボード、エンカウントカメラ、バトル空間回転、チャージエフェクト
キャッシュ化DB スキーマ拡張、VRM 7個、Audio 13個、Stage 7個、Image 4個
新機能街→地下洞窟ルート、洞窟ライティング

4日目のWebベース ADV/RPGプロジェクトとして、相当濃い1日になりました。

次回(#67以降)でやりたいこと:

  • LIGHT 魔法を覚えて洞窟探索できるようになるシステム(古典RPG的なフラグ管理)
  • バトル中のスキル・アイテムシステム拡張
  • セーブ・ロードのUI整理

引き続き、開発を続けていきます。


プロジェクトURL : https://lain-lab.com 前回の記事 : Astro #65 - Three.js + WebXR + VRM: セーブ/ヒールシステムとVRAMの完全解放