[Astro #98] LAYER FIGHTER: PS4コントローラー対応を全メニュー画面に実装

[Astro #98] LAYER FIGHTER: PS4コントローラー対応を全メニュー画面に実装

問題

LAYER FIGHTERは3つの入力系統を持っている。

  • キーボード(矢印キー + Enter/Esc)
  • Quest 3 XRコントローラー(左スティック + 右トリガー)
  • PS4コントローラー(Gamepad API)

しかし、PS4コントローラーが動くのは 本編のトレーニングモードだけ だった。

タイトル画面、ステージセレクト、サウンドテスト、クレジットシーケンス——すべてキーボードイベントとXRコントローラーのポーリングしか見ておらず、標準の Gamepad API を呼んでいなかった。

原因

useFTGGameLoop.ts(本編のゲームループ)では、毎フレーム navigator.getGamepads() をポーリングして、D-pad(buttons[12]〜[15])と左スティック(axes[0]/axes[1])を読み取っている。

// useFTGGameLoop.ts 内の既存パターン
const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
const pad = gamepads[0];

const padLeft  = pad ? (pad.buttons[PAD.DPAD_LEFT]?.pressed  || pad.axes[0] < -0.5) : false;
const padRight = pad ? (pad.buttons[PAD.DPAD_RIGHT]?.pressed || pad.axes[0] >  0.5) : false;
const padUp    = pad ? (pad.buttons[PAD.DPAD_UP]?.pressed    || pad.axes[1] < -0.5) : false;
const padDown  = pad ? (pad.buttons[PAD.DPAD_DOWN]?.pressed  || pad.axes[1] >  0.5) : false;

メニュー画面にはこのポーリングが一切無かった。

解決: useFTGMenuPad 共通フック

メニュー画面共通の React hook を1つ作成した。

useFTGMenuPad.ts

import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';

const PAD = {
  CROSS:      0,   // × → 決定
  CIRCLE:     1,   // ○ → キャンセル
  DPAD_UP:    12,
  DPAD_DOWN:  13,
  DPAD_LEFT:  14,
  DPAD_RIGHT: 15,
};

interface MenuPadCallbacks {
  onUp:      () => void;
  onDown:    () => void;
  onLeft?:   () => void;
  onRight?:  () => void;
  onConfirm: () => void;
  onCancel?: () => void;
  enabled:   boolean;
}

export const useFTGMenuPad = (callbacks: MenuPadCallbacks) => {
  const cooldownRef = useRef(false);
  const confirmRef  = useRef(false);
  const cancelRef   = useRef(false);

  useFrame(() => {
    if (!callbacks.enabled) return;

    const gamepads = navigator.getGamepads?.() ?? [];
    const pad = gamepads[0];
    if (!pad) return;

    // 方向入力: D-pad or 左スティック(クールダウン付き)
    const up    = pad.buttons[PAD.DPAD_UP]?.pressed    || pad.axes[1] < -0.5;
    const down  = pad.buttons[PAD.DPAD_DOWN]?.pressed  || pad.axes[1] >  0.5;
    const left  = pad.buttons[PAD.DPAD_LEFT]?.pressed  || pad.axes[0] < -0.5;
    const right = pad.buttons[PAD.DPAD_RIGHT]?.pressed || pad.axes[0] >  0.5;

    if (!cooldownRef.current) {
      if (up)         { callbacks.onUp();    cooldownRef.current = true; setTimeout(() => { cooldownRef.current = false; }, 250); }
      else if (down)  { callbacks.onDown();  cooldownRef.current = true; setTimeout(() => { cooldownRef.current = false; }, 250); }
      else if (left  && callbacks.onLeft)  { callbacks.onLeft();  cooldownRef.current = true; setTimeout(() => { cooldownRef.current = false; }, 250); }
      else if (right && callbacks.onRight) { callbacks.onRight(); cooldownRef.current = true; setTimeout(() => { cooldownRef.current = false; }, 250); }
    }

    // ×ボタン → 決定(エッジ検出)
    const cross = !!pad.buttons[PAD.CROSS]?.pressed;
    if (cross && !confirmRef.current) { callbacks.onConfirm(); }
    confirmRef.current = cross;

    // ○ボタン → キャンセル(エッジ検出)
    const circle = !!pad.buttons[PAD.CIRCLE]?.pressed;
    if (circle && !cancelRef.current && callbacks.onCancel) { callbacks.onCancel(); }
    cancelRef.current = circle;
  });
};

設計のポイント

enabled フラグ: React の hook は条件付きで呼べないため、enabled: false の時は useFrame 内で early return する。これにより、1つのコンポーネント内で複数の useFTGMenuPad を共存させられる(例: タイトルメニュー用 + 終了確認ダイアログ用)。

クールダウン(250ms): 方向入力は押しっぱなしで連続発火するので、setTimeout で制御。ボタン入力はエッジ検出(前フレームで押されてなかった場合のみ発火)。

D-pad + スティック両対応: pad.buttons[12]?.pressed || pad.axes[1] < -0.5 で、物理D-padとアナログスティックの両方を拾う。本編の useFTGGameLoop.ts と同じパターン。

各画面への適用

タイトル画面(FTGManager.tsx)

const menuOrder: MenuOption[] = ['TRAINING', 'SOUND_TEST', 'CREDIT', 'EXIT'];

useFTGMenuPad({
  enabled: gameMode === 'TITLE',
  onUp:      () => { setSelectedMenu(prev => /* 前へ */); },
  onDown:    () => { setSelectedMenu(prev => /* 次へ */); },
  onConfirm: () => { /* Enter と同じ分岐 */ },
  onCancel:  () => onClose(),
});

ステージセレクト(FTGStageSelect.tsx)

useFTGMenuPad({
  enabled: true,
  onUp:      () => { setSelectedIndex(prev => /* 前のステージ */); },
  onDown:    () => { setSelectedIndex(prev => /* 次のステージ */); },
  onConfirm: () => { if (!readyRef.current) return; onSelect(stageList[selectedIndex].id); },
  onCancel:  () => onBack(),
});

readyRef ガード: マウント直後200msは決定を無視する既存の仕組みをそのまま踏襲。

サウンドテスト(FTGSoundTest.tsx)

useFTGMenuPad({
  enabled: true,
  onUp:      () => { setSelectedIdx(prev => Math.max(0, prev - 1)); },
  onDown:    () => { setSelectedIdx(prev => Math.min(filtered.length - 1, prev + 1)); },
  onLeft:    () => { /* 前のタブ(BGM/SE/VOICE) */ },
  onRight:   () => { /* 次のタブ */ },
  onConfirm: () => { if (current) togglePlay(current); },
  onCancel:  () => { window.dispatchEvent(new CustomEvent('ftg:requestExit')); },
});

サウンドテストだけ onLeft / onRight を使用(タブ切り替え)。

クレジットシーケンス(FTGCreditSequence.tsx)

useFTGMenuPad({
  enabled: true,
  onUp: () => {},
  onDown: () => {},
  onConfirm: () => {
    window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
  },
  onCancel: () => {
    window.dispatchEvent(new CustomEvent('ftg:requestExit'));
  },
});

CreditSliderRow 内の既存 Enter ハンドラーに KeyboardEvent を dispatch して委譲。

終了確認ダイアログ(FTGManager.tsx)

useFTGMenuPad({
  enabled: showExitConfirm,
  onUp: () => {},
  onDown: () => {},
  onLeft:    () => setDialogSelected(prev => prev === 'YES' ? 'NO' : 'YES'),
  onRight:   () => setDialogSelected(prev => prev === 'YES' ? 'NO' : 'YES'),
  onConfirm: () => { dialogSelected === 'YES' ? handleExitYes() : handleExitNo(); },
  onCancel:  () => handleExitNo(),
});

enabled: showExitConfirm で、ダイアログ表示中のみ有効化。タイトル用の hook と共存。

結果

  • 変更ファイル: 4ファイル(FTGManager, FTGStageSelect, FTGSoundTest, FTGCreditSequence)
  • 新規ファイル: 1ファイル(useFTGMenuPad.ts)
  • 各ファイルの変更量: import 1行 + hook 呼び出し数行
  • 全画面一発で動作確認完了

キーボード、Quest 3 XRコントローラー、PS4コントローラーの 3入力系統が全メニュー画面で統一 された。

備考

×ボタンが決定、○ボタンがキャンセルの海外仕様になっている。日本仕様にする場合は hook 内の PAD.CROSSPAD.CIRCLE の対応を入れ替えるだけで済む。本編(useFTGGameLoop.ts)では ×=パンチ(主アクション)なので、現状の方が統一感がある。