171 lines
5.4 KiB
TypeScript
171 lines
5.4 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<HTMLElement>(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<typeof setTimeout> | null = null
|
||
|
|
let pollIntervalId: ReturnType<typeof setInterval> | 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,
|
||
|
|
}
|
||
|
|
}
|