From e34d2471c3348ee97083e2c130d61e3a0c606741 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 4 May 2026 00:51:52 +0000 Subject: [PATCH] Add gamepad navigation --- .../src/composables/useGamepadNavigation.ts | 123 ++++++++++++++++++ frontend/src/main.ts | 2 + 2 files changed, 125 insertions(+) create mode 100644 frontend/src/composables/useGamepadNavigation.ts diff --git a/frontend/src/composables/useGamepadNavigation.ts b/frontend/src/composables/useGamepadNavigation.ts new file mode 100644 index 0000000..5cba3cf --- /dev/null +++ b/frontend/src/composables/useGamepadNavigation.ts @@ -0,0 +1,123 @@ +type GamepadAction = 'up' | 'down' | 'left' | 'right' | 'select' | 'back'; + +const GAMEPAD_AXIS_THRESHOLD = 0.55; +const GAMEPAD_REPEAT_MS = 180; + +const KEY_BY_ACTION: Record = { + up: 'ArrowUp', + down: 'ArrowDown', + left: 'ArrowLeft', + right: 'ArrowRight', + select: 'Enter', + back: 'Escape', +}; + +const gamepadPressedState: Record = { + up: false, + down: false, + left: false, + right: false, + select: false, + back: false, +}; + +const gamepadLastTriggerAt: Record = { + up: 0, + down: 0, + left: 0, + right: 0, + select: 0, + back: 0, +}; + +let gamepadFrameId: number | null = null; +let gamepadInstalled = false; + +function dispatchKey(key: string) { + const active = document.activeElement; + const target = active instanceof HTMLElement ? active : document; + target.dispatchEvent(new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + })); +} + +function applyGamepadAction(action: GamepadAction, isPressed: boolean, now: number) { + const wasPressed = gamepadPressedState[action]; + gamepadPressedState[action] = isPressed; + + if (!isPressed) return; + + const shouldRepeat = action === 'up' || action === 'down' || action === 'left' || action === 'right'; + const canTrigger = !wasPressed || (shouldRepeat && now - gamepadLastTriggerAt[action] >= GAMEPAD_REPEAT_MS); + if (!canTrigger) return; + + gamepadLastTriggerAt[action] = now; + dispatchKey(KEY_BY_ACTION[action]); +} + +function resetPressedState() { + gamepadPressedState.up = false; + gamepadPressedState.down = false; + gamepadPressedState.left = false; + gamepadPressedState.right = false; + gamepadPressedState.select = false; + gamepadPressedState.back = false; +} + +function pollGamepad() { + const gamepads = navigator.getGamepads?.() ?? []; + const now = performance.now(); + const connectedGamepads = gamepads.filter((gp): gp is Gamepad => Boolean(gp && gp.connected)); + + if (connectedGamepads.length > 0) { + let up = false; + let down = false; + let left = false; + let right = false; + let select = false; + let back = false; + + for (const gamepad of connectedGamepads) { + const axisX = gamepad.axes[0] ?? 0; + const axisY = gamepad.axes[1] ?? 0; + + up = up || Boolean(gamepad.buttons[12]?.pressed) || axisY <= -GAMEPAD_AXIS_THRESHOLD; + down = down || Boolean(gamepad.buttons[13]?.pressed) || axisY >= GAMEPAD_AXIS_THRESHOLD; + left = left || Boolean(gamepad.buttons[14]?.pressed) || axisX <= -GAMEPAD_AXIS_THRESHOLD; + right = right || Boolean(gamepad.buttons[15]?.pressed) || axisX >= GAMEPAD_AXIS_THRESHOLD; + + // Xbox mapping on standard gamepads: A=0, B=1 + select = select || Boolean(gamepad.buttons[0]?.pressed); + back = back || Boolean(gamepad.buttons[1]?.pressed); + } + + applyGamepadAction('up', up, now); + applyGamepadAction('down', down, now); + applyGamepadAction('left', left, now); + applyGamepadAction('right', right, now); + applyGamepadAction('select', select, now); + applyGamepadAction('back', back, now); + } else { + resetPressedState(); + } + + gamepadFrameId = window.requestAnimationFrame(pollGamepad); +} + +export function installGamepadNavigation() { + if (gamepadInstalled) return; + gamepadInstalled = true; + gamepadFrameId = window.requestAnimationFrame(pollGamepad); +} + +export function uninstallGamepadNavigation() { + if (!gamepadInstalled) return; + gamepadInstalled = false; + if (gamepadFrameId !== null) { + window.cancelAnimationFrame(gamepadFrameId); + gamepadFrameId = null; + } + resetPressedState(); +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 4b3d0a4..8e5cde2 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -3,8 +3,10 @@ import App from './App.vue' import router from './router' import './styles/main.css' import { installKeyboardNavigation } from './composables/useKeyboardNavigation' +import { installGamepadNavigation } from './composables/useGamepadNavigation' // Install global keyboard navigation handlers immediately installKeyboardNavigation() +installGamepadNavigation() createApp(App).use(router).mount('#app')