/** * Xbox-style controller / gamepad navigation for Archipelago. * - Left: Go to side menu only when on leftmost main content * - Right: Go to main content (from side menu) * - Main: spatial/grid navigation (up/down/left/right like a game) * - Enter enters container's inner actions; actions get celebratory sound */ import { ref, onMounted, onBeforeUnmount, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useControllerStore } from '@/stores/controller' import { useSpotlightStore } from '@/stores/spotlight' import { playNavSound } from '@/composables/useNavSounds' const FOCUSABLE_SELECTOR = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', '[data-controller-focus]', '[data-controller-container]', ].join(', ') function getFocusableElements(container: Document | HTMLElement = document): HTMLElement[] { return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( (el) => !el.hasAttribute('disabled') && el.offsetParent !== null ) } function getElementsInZone(zone: 'sidebar' | 'main'): HTMLElement[] { const container = document.querySelector(`[data-controller-zone="${zone}"]`) as HTMLElement | null if (!container) return [] return getFocusableElements(container) } function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean { if (!el) return false return !!el.closest(`[data-controller-zone="${zone}"]`) } function getInnerFocusables(container: HTMLElement): HTMLElement[] { return getFocusableElements(container).filter((el) => el !== container && !el.hasAttribute('data-controller-container')) } function isInsideContainer(el: HTMLElement | null): boolean { if (!el) return false const container = el.closest('[data-controller-container]') return !!container && container !== el } /** Spatial navigation: find nearest focusable in direction (game-style grid) */ function findNearestInDirection( from: HTMLElement, candidates: HTMLElement[], direction: 'up' | 'down' | 'left' | 'right' ): HTMLElement | null { const fromRect = from.getBoundingClientRect() const fromCenterX = fromRect.left + fromRect.width / 2 const fromCenterY = fromRect.top + fromRect.height / 2 const threshold = 50 // px overlap allowed const filtered = candidates.filter((el) => { if (el === from) return false const r = el.getBoundingClientRect() switch (direction) { case 'left': return r.right <= fromRect.left + threshold case 'right': return r.left >= fromRect.right - threshold case 'up': return r.bottom <= fromRect.top + threshold case 'down': return r.top >= fromRect.bottom - threshold default: return false } }) if (filtered.length === 0) return null // Pick best: most overlap on perpendicular axis, then closest const scored = filtered.map((el) => { const r = el.getBoundingClientRect() const centerX = r.left + r.width / 2 const centerY = r.top + r.height / 2 let overlap: number let dist: number switch (direction) { case 'left': case 'right': overlap = Math.max(0, Math.min(fromRect.bottom, r.bottom) - Math.max(fromRect.top, r.top)) dist = Math.abs(centerX - fromCenterX) break case 'up': case 'down': overlap = Math.max(0, Math.min(fromRect.right, r.right) - Math.max(fromRect.left, r.left)) dist = Math.abs(centerY - fromCenterY) break default: overlap = 0 dist = Infinity } return { el, overlap, dist } }) scored.sort((a, b) => { if (b.overlap !== a.overlap) return b.overlap - a.overlap return a.dist - b.dist }) return scored[0]?.el ?? null } export function useControllerNav(containerRef?: { value: HTMLElement | null }) { const route = useRoute() const router = useRouter() 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 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) const activeEl = document.activeElement as HTMLElement // --- ESCAPE --- if (e.key === 'Escape') { if (useSpotlightStore().isOpen) { useSpotlightStore().close() e.preventDefault() e.stopPropagation() return } if (isInsideContainer(activeEl)) { const container = activeEl.closest('[data-controller-container]') as HTMLElement | null if (container && container.tabIndex >= 0) { playNavSound('back') container.focus() container.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) e.preventDefault() return } } const isDetailPage = /\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/.test(route.path) if (isDetailPage) { playNavSound('back') window.history.back() e.preventDefault() return } const sidebarEls = getElementsInZone('sidebar') const firstSidebar = sidebarEls[0] if (firstSidebar && isInZone(activeEl, 'main')) { playNavSound('back') firstSidebar.focus() firstSidebar.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) e.preventDefault() return } playNavSound('back') window.history.back() e.preventDefault() return } // --- ENTER --- if (e.key === 'Enter') { if (currentIndex >= 0 && focusable[currentIndex]) { const el = focusable[currentIndex] as HTMLElement if (el.hasAttribute('data-controller-container')) { const inner = getInnerFocusables(el) const firstInner = inner[0] if (firstInner) { playNavSound('action') firstInner.focus() firstInner.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) e.preventDefault() return } } playNavSound('action') el.click() } e.preventDefault() return } // --- ARROWS --- if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { isControllerActive.value = true if (keyNavTimeout) clearTimeout(keyNavTimeout) keyNavTimeout = setTimeout(() => { isControllerActive.value = gamepadCount.value > 0 }, 3000) const sidebarEls = getElementsInZone('sidebar') const mainEls = getElementsInZone('main') const hasZones = sidebarEls.length > 0 && mainEls.length > 0 // Right: from sidebar → main const firstMain = mainEls[0] if (e.key === 'ArrowRight' && hasZones && isInZone(activeEl, 'sidebar') && firstMain) { playNavSound('move') firstMain.focus() firstMain.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) e.preventDefault() return } // Main zone: spatial navigation (game-style grid) if (hasZones && isInZone(activeEl, 'main')) { const dir = e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowUp' ? 'up' : 'down' const next = findNearestInDirection(activeEl, mainEls, dir) if (next) { playNavSound('move') next.focus() next.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) e.preventDefault() return } // No element in that direction: Left from leftmost → sidebar if (e.key === 'ArrowLeft' && dir === 'left') { const lastSidebar = sidebarEls[sidebarEls.length - 1] if (lastSidebar) { playNavSound('move') lastSidebar.focus() lastSidebar.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) e.preventDefault() return } } } // Inside container: spatial nav among inner elements if (isInsideContainer(activeEl)) { const container = activeEl.closest('[data-controller-container]') as HTMLElement if (container) { const inner = getInnerFocusables(container) const dir = e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowUp' ? 'up' : 'down' const next = findNearestInDirection(activeEl, inner, dir) if (next) { playNavSound('move') next.focus() next.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) e.preventDefault() return } } } // Sidebar: linear up/down if (isInZone(activeEl, 'sidebar')) { const idx = sidebarEls.indexOf(activeEl) if (idx >= 0) { const isDown = e.key === 'ArrowDown' const nextIdx = isDown ? Math.min(idx + 1, sidebarEls.length - 1) : Math.max(idx - 1, 0) const next = sidebarEls[nextIdx] if (next && next !== activeEl) { playNavSound('move') next.focus() next.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) if (next.tagName === 'A') { const href = (next as HTMLAnchorElement).getAttribute?.('href') if (href && href.startsWith('/')) router.push(href).catch(() => {}) } e.preventDefault() return } } } // Fallback: linear navigation 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' }) if (isInZone(next, 'sidebar') && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { const href = (next as HTMLAnchorElement).getAttribute?.('href') if (href && href.startsWith('/') && next.tagName === 'A') { router.push(href).catch(() => {}) } } 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, } }