[Astro #89] LAYER FIGHTER // タイトル・バトル・クレジット実装
前回(Astro #88)でヒットエフェクトとコマンド技を実装した。今回はタイトル画面・バトル強化・クレジット画面と、一気に3つのエリアを作り込んだ。
ファイル構成
src/components/PROJECT_LAIN/FTG/
├── FTGManager.tsx # モード管理(TITLE / TRAINING / CREDIT)
├── FTGTrainingScene.tsx # バトルシーン(敵AI・ラウンド・ステージ)
├── FTGTitleBackground.tsx # タイトル背景(3Dスクロール・VRM・ゴールハウス)
├── FTGCreditSequence.tsx # クレジットスライダー
└── data/
├── Assets.json # VRM・アニメ・BGM・SE・ステージ・タイトルオブジェクト定義
├── Moves.json # 技パラメーター(enemyHitboxes追加)
└── FTGTitleMap.json # タイトル地形チャンクマップ
1. タイトル画面(FTGTitleBackground)
タイトル画面は、VRMアバターが無限スクロールする地形の上を走り続けるオープニング演出になっている。
コンポーネント設計
FTGTitleBackground
├── OpeningController # 時間計測 → HOUSE_IN を発火するだけ
├── ScrollChunk × 5 # 地形チャンクをループスクロール
├── GoalHouse # 出現 → 停止 → フェード → ループをすべて自己管理
├── TitlePlayer # VRMアバター走りアニメ(TitleCatFixed を子に持つ)
│ └── TitleCatFixed # 猫(プレイヤーグループの子として位置固定)
└── Billboard + Text # LAYER FIGHTERタイトル(VR対応)
フェーズ管理
フェーズと速度は useRef で管理し、Canvas 内外で共有する。React state にすると Canvas 内の useFrame から読めないため。
type OpeningPhase = 'SCROLLING' | 'HOUSE_IN' | 'ARRIVING' | 'ENTERED';
const phaseRef = useRef<OpeningPhase>('SCROLLING');
const speedRef = useRef<number>(SCROLL_SPEED);
| フェーズ | 内容 |
|---|---|
| SCROLLING | 通常スクロール。appearAfterSec 秒後に HOUSE_IN へ |
| HOUSE_IN | 家がチャンクと同速でスクロールイン |
| ARRIVING | 家が titleStopX に到達。speedRef=0 で全停止 |
| ENTERED | fadeAfterSec 秒後にフェードアウト → リセットしてループ |
ゴールハウスの設計
最初は「家を固定してチャンクだけ動かす」方式を試みたが、衝突判定の問題でうまくいかなかった。最終的に家もチャンクと同速で動かし、titleStopX に到達したら speedRef=0 で両方止める方式に落ち着いた。
if (phase === 'HOUSE_IN' && !arrivedRef.current) {
groupRef.current.position.x -= speedRef.current * dt;
if (groupRef.current.position.x <= STOP_X) {
groupRef.current.position.x = STOP_X;
speedRef.current = 0; // チャンクも止める
phaseRef.current = 'ARRIVING';
arrivedRef.current = true;
}
}
Assets.json でタイミングを制御
"titleObjects": [
{
"id": "FTG_TITLE_GOAL_HOUSE",
"modelUrl": "/models/FTG/low_poly_stylized_home_olg.glb",
"titleScale": [3.5, 3.5, 3.5],
"titlePosition": [0, 0.5, 0],
"titleRotation": [0, 1.5708, 0],
"appearAfterSec": 84.0,
"titleStartX": 20.0,
"titleStopX": 2.5,
"fadeAfterSec": 2.0
},
{
"id": "FTG_TITLE_CAT",
"modelUrl": "/models/FTG/trotting_cat.glb",
"titleScale": [0.5, 0.5, 0.5],
"titleRotation": [0, -1.5708, 0],
"titleOffsetZ": 2.5,
"titleAnimSpeed": 1.8
}
]
appearAfterSec: 84.0 はBGMの長さ(1分37秒)から逆算した値。家のスクロールイン時間(約8.75秒)+フェード時間を引いた位置に設定している。
ループ処理
フェードアウト完了後、phaseRef と speedRef をリセットして最初から再生する。OpeningController の elapsedRef も SCROLLING に戻った瞬間にリセットする必要がある。
// OpeningController の useFrame 内
if (phaseRef.current === 'SCROLLING' && prevPhaseRef.current !== 'SCROLLING') {
elapsedRef.current = 0;
}
prevPhaseRef.current = phaseRef.current;
また、TitlePlayer の走りアニメ timeScale も ARRIVING で 0 に落としているため、SCROLLING に戻ったときに 1.0 にリセットする。
if (phase === 'SCROLLING' && actionRef.current && actionRef.current.timeScale < 1.0) {
actionRef.current.timeScale = 1.0;
}
VR対応タイトルテキスト
DOM の <h1> はWebXRに映らないため、drei の Billboard + Text に移行した。Billboard はカメラの向きを常に追いかけるので、どの角度からでも正面に見える。
<Billboard position={[0, 2.0, 0]}>
<Text
fontSize={0.6}
color="#ffffff"
letterSpacing={0.15}
toneMapped={false}
outlineWidth={0.02}
outlineColor="#00ffcc"
>
LAYER FIGHTER
</Text>
</Billboard>
メニュー(TRAINING / CREDIT / EXIT)はVR専用UIを後回しにしてDOMのまま残し、selectedMenu を props で渡してハイライト表示している。
2. バトル強化(FTGTrainingScene)
GLBステージ読み込み
Assets.json の stages[] から id でステージを引いてGLBを読み込む FTGStageModel コンポーネントを追加した。
const FTGStageModelInner: React.FC<{ stageId: string }> = ({ stageId }) => {
const stageDef = assets.stages?.find(s => s.id === stageId);
const gltf = useLoader(GLTFLoader, stageDef.modelUrl, (loader) => {
loader.setMeshoptDecoder(MeshoptDecoder);
});
return (
<primitive
object={gltf.scene}
scale={stageDef.edScale}
position={stageDef.edPosition}
rotation={stageDef.edRotation}
/>
);
};
呼び出し側で stageId="FTG_STAGE_CYBER_RING" を指定するだけで切り替えられる。
敵AI攻撃・ダメージ判定
EnemyPhysics に攻撃フレーム管理フィールドを追加した。
interface EnemyPhysics {
// ...既存フィールド
attackMoveId: string | null; // 発動中の技ID(Moves.jsonのid)
attackFrame: number; // 経過フレーム
attackDuration: number; // 全体フレーム数
hasHitPlayer: boolean; // 1技1ヒット制御
}
AI が攻撃を選択した瞬間にセットし、毎フレーム attackFrame を進める。startup ≤ frame < startup + active の間だけプレイヤーとの X 軸重なりを判定する。
if (ep.attackMoveId && !ep.hasHitPlayer) {
const enemyMove = moves.moves.find(m => m.id === ep.attackMoveId);
const af = ep.attackFrame;
if (af >= enemyMove.startup && af < enemyMove.startup + enemyMove.active) {
// X軸の重なりチェック → ヒット処理
}
}
敵専用ヒットボックス(EnemyHitBox)
プレイヤー側の HitBox と対称に EnemyHitBox を実装した。useFrame で enemyPhysicsRef を直接読むため React state のラウンドトリップがなく、1フレームのズレが起きない。
攻撃ボックスの色はオレンジ(#ff8800)でプレイヤー(青)・敵の食らい判定(緑)と区別している。
敵専用ヒットボックスのパラメーター(Moves.json)
プレイヤーと敵ではVRMモデルの向きが逆なため、enemyHitboxes フィールドを追加して敵専用のオフセットを設定できるようにした。未定義なら hitboxes にフォールバックする。
{
"id": "PUNCH_L",
"hitboxes": [
{ "offsetX": 0.45, "offsetY": 1.1, "width": 0.5, "height": 0.2, "depth": 0.3 }
],
"enemyHitboxes": [
{ "offsetX": 0.45, "offsetY": 1.1, "width": 0.5, "height": 0.2, "depth": 0.3 }
]
}
ラウンド推移
FIGHTING → KO(2秒) → VICTORY → WAIT_INPUT → ROUND_CALL → FIGHT_CALL → FIGHTING
各フェーズは roundPhaseRef(ref)で管理し、表示用に setRoundPhase(state)で同期する。
敵AI改善
- 後退時は
SYS_ENEMY_WALKING_BACKWARDSアニメに切り替え - 攻撃モーション中は AI の移動・アニメ更新をスキップ(
attackMoveIdが null のときだけ AI ブロックを実行) - アニメ速度を
Assets.jsonのanimSpeedで制御
{ "id": "SYS_ENEMY_WALKING", "animSpeed": 1.4 },
{ "id": "SYS_ENEMY_WALKING_BACKWARDS", "animSpeed": 2.1 }
3. クレジット画面(FTGCreditSequence)
Assets.json に定義されたすべてのアセット(VRM・ステージ・BGM・SE)を自動収集して 3D スライダーで順番に表示する。
const creditPipeline = useMemo(() => {
const list: CreditItem[] = [];
assets.vrm?.forEach(v => list.push({ type: 'MODEL', modelUrl: v.file, ... }));
assets.stages?.forEach(s => list.push({ type: 'MODEL', modelUrl: s.modelUrl, ... }));
assets.bgm?.forEach(b => list.push({ type: 'IMAGE', logoUrl: b.logo, ... }));
assets.se?.forEach(s => { /* 重複クリエイターを除外 */ });
return list;
}, []);
各スライドは INCOMING → STAY → OUTGOING の3ステートで動く。Enter キーで強制スキップ可能。
まとめ
今回追加・変更したファイルは4つ。
| ファイル | 主な変更 |
|---|---|
FTGTitleBackground.tsx | タイトル背景全体。VRMアバター・猫・ゴールハウス演出・ループ |
FTGTrainingScene.tsx | GLBステージ・敵AI攻撃判定・ラウンド推移・プレイヤーHP |
FTGCreditSequence.tsx | Assets.json駆動のクレジットスライダー |
Assets.json | titleObjects・animSpeed・enemyHitboxes 追加 |
次の課題は被弾のけぞりアニメ、敵の技バリエーション追加、2本先取の勝利条件あたり。格ゲーより演出を作る方が楽しいと気づいてしまったのは内緒。