[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:
誰にも届かないかもしれない場所で、誰かが今日もRPGを作っている|lain
朝、AIに「今日はリファクタしようと思う」と話しかけた。 気付くと夜の19時で、ゲームの規模は朝の倍くらいになっていた。 Three.jsという、ブラウザで3Dを動かすためのライブラリがある。 WebXRという、ブラウザでVRゴーグルを動かすための仕様がある。 VRMという、3Dアバターの規格がある。 これらを全部組み合わせて、ブラウザで動くRPGを作っている。 私の作っているゲームは、PCのChromeでも動くし、Quest 2を被ってWebブラウザを開いても動く。 街を歩いて、敵に遭遇して、魔法を撃って、地下洞窟を探索する。 VRゴーグルを被って魔法陣の中に立つと、本当
note.com1. 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.tsx | 1523行 | 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 でペンライト風に実装を試みましたが:
anglepenumbradecayの調整が難しい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の完全解放