/** * Controller / gamepad-style navigation for Archipelago. * Supports Rii X8 (keyboard/d-pad) and standard gamepads. * - Arrow keys / d-pad: navigate between focusable elements * - Enter / A button: activate * - Escape / B button: back * - Game-like navigation sounds and visual feedback */ import { ref, onMounted, onBeforeUnmount, watch } from 'vue' import { useControllerStore } from '@/stores/controller' import { useSpotlightStore } from '@/stores/spotlight' const FOCUSABLE_SELECTOR = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', '[data-controller-focus]', ].join(', ') function getFocusableElements(container: Document | HTMLElement = document): HTMLElement[] { return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( (el) => !el.hasAttribute('disabled') && el.offsetParent !== null ) } function playNavSound(type: 'move' | 'select' | 'back' = 'move') { try { const ctx = new (window.AudioContext || (window as any).webkitAudioContext)() const osc = ctx.createOscillator() const gain = ctx.createGain() osc.connect(gain) gain.connect(ctx.destination) gain.gain.value = 0.08 osc.frequency.value = type === 'select' ? 880 : type === 'back' ? 220 : 440 osc.type = 'sine' osc.start() osc.stop(ctx.currentTime + 0.05) } catch { // Audio not supported or blocked } } export function useControllerNav(containerRef?: { value: HTMLElement | null }) { const store = useControllerStore() const isControllerActive = ref(false) const gamepadCount = ref(0) watch([isControllerActive, gamepadCount], () => { store.setActive(isControllerActive.value) store.setGamepadCount(gamepadCount.value) }, { immediate: true }) let keyNavTimeout: ReturnType | null = null let pollIntervalId: ReturnType | null = null function checkGamepads() { const gamepads = navigator.getGamepads?.() const count = gamepads ? Array.from(gamepads).filter((g) => g?.connected).length : 0 if (count !== gamepadCount.value) { gamepadCount.value = count isControllerActive.value = count > 0 } } function handleKeyDown(e: KeyboardEvent) { const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape'] if (!navKeys.includes(e.key)) return // Ignore when typing in inputs const target = e.target as HTMLElement if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { if (e.key !== 'Escape') return } const root = containerRef?.value ?? document const focusable = getFocusableElements(root) const currentIndex = focusable.indexOf(document.activeElement as HTMLElement) if (e.key === 'Escape') { if (useSpotlightStore().isOpen) { useSpotlightStore().close() e.preventDefault() e.stopPropagation() return } playNavSound('back') window.history.back() e.preventDefault() return } if (e.key === 'Enter') { if (currentIndex >= 0 && focusable[currentIndex]) { playNavSound('select') ;(focusable[currentIndex] as HTMLElement).click() } e.preventDefault() return } if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { isControllerActive.value = true if (keyNavTimeout) clearTimeout(keyNavTimeout) keyNavTimeout = setTimeout(() => { isControllerActive.value = gamepadCount.value > 0 }, 3000) let nextIndex = currentIndex const isForward = e.key === 'ArrowDown' || e.key === 'ArrowRight' if (focusable.length === 0) return if (currentIndex < 0) { nextIndex = isForward ? 0 : focusable.length - 1 } else { nextIndex = isForward ? currentIndex + 1 : currentIndex - 1 if (nextIndex < 0) nextIndex = focusable.length - 1 if (nextIndex >= focusable.length) nextIndex = 0 } const next = focusable[nextIndex] if (next) { playNavSound('move') next.focus() next.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) e.preventDefault() } } } function handleGamepadInput() { checkGamepads() } function handleGamepadConnected() { const gamepads = navigator.getGamepads?.() gamepadCount.value = gamepads ? Array.from(gamepads).filter((g) => g?.connected).length : 1 isControllerActive.value = true } function handleGamepadDisconnected() { const gamepads = navigator.getGamepads?.() gamepadCount.value = gamepads ? Array.from(gamepads).filter((g) => g?.connected).length : 0 isControllerActive.value = gamepadCount.value > 0 } onMounted(() => { checkGamepads() window.addEventListener('keydown', handleKeyDown, true) window.addEventListener('gamepadconnected', handleGamepadConnected) window.addEventListener('gamepaddisconnected', handleGamepadDisconnected) pollIntervalId = setInterval(handleGamepadInput, 500) }) onBeforeUnmount(() => { window.removeEventListener('keydown', handleKeyDown, true) window.removeEventListener('gamepadconnected', handleGamepadConnected) window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected) if (pollIntervalId) clearInterval(pollIntervalId) if (keyNavTimeout) clearTimeout(keyNavTimeout) }) return { isControllerActive, gamepadCount, } }