[Astro #89] LAYER FIGHTER // タイトル・バトル・クレジット実装

[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 で全停止
ENTEREDfadeAfterSec 秒後にフェードアウト → リセットしてループ

ゴールハウスの設計

最初は「家を固定してチャンクだけ動かす」方式を試みたが、衝突判定の問題でうまくいかなかった。最終的に家もチャンクと同速で動かし、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秒)+フェード時間を引いた位置に設定している。

ループ処理

フェードアウト完了後、phaseRefspeedRef をリセットして最初から再生する。OpeningControllerelapsedRef も 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.jsonstages[] から 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 を実装した。useFrameenemyPhysicsRef を直接読むため 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.jsonanimSpeed で制御
{ "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.tsxGLBステージ・敵AI攻撃判定・ラウンド推移・プレイヤーHP
FTGCreditSequence.tsxAssets.json駆動のクレジットスライダー
Assets.jsontitleObjectsanimSpeedenemyHitboxes 追加

次の課題は被弾のけぞりアニメ、敵の技バリエーション追加、2本先取の勝利条件あたり。格ゲーより演出を作る方が楽しいと気づいてしまったのは内緒。