[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.CROSS と PAD.CIRCLE の対応を入れ替えるだけで済む。本編(useFTGGameLoop.ts)では ×=パンチ(主アクション)なので、現状の方が統一感がある。