/* eslint-disable react-refresh/only-export-components */ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from 'react' import { Capacitor } from '@capacitor/core' export type InputDevice = 'pc' | 'controller' export type ControllerIconStyle = 'xbox' | 'playstation' | 'nintendo' export const INPUT_ACTIONS = [ 'navigateUp', 'navigateDown', 'navigateLeft', 'navigateRight', 'confirm', 'back', 'ability1', 'ability2', 'ability3', 'ability4', 'ability5', 'ability6', 'previousTarget', 'nextTarget', 'targetParty1', 'targetParty2', 'targetParty3', 'targetParty4', 'targetParty5', 'targetParty6', 'toggleTargetGroup', 'toggleSpeed', 'toggleTouchLock', 'pause', ] as const export type InputAction = typeof INPUT_ACTIONS[number] export type InputBindings = Record export const ACTION_LABELS: Record = { navigateUp: 'Navigate Up', navigateDown: 'Navigate Down', navigateLeft: 'Navigate Left', navigateRight: 'Navigate Right', confirm: 'Confirm / Select', back: 'Back', ability1: 'Ability Slot 1', ability2: 'Ability Slot 2', ability3: 'Ability Slot 3', ability4: 'Ability Slot 4', ability5: 'Ability Slot 5', ability6: 'Ability Slot 6', previousTarget: 'Previous Party Target', nextTarget: 'Next Party Target', targetParty1: 'Target Party Member 1', targetParty2: 'Target Party Member 2', targetParty3: 'Target Party Member 3', targetParty4: 'Target Party Member 4', targetParty5: 'Target Party Member 5', targetParty6: 'Target Party Member 6', toggleTargetGroup: 'Switch Raid Target Group', toggleSpeed: 'Toggle 2x Speed', toggleTouchLock: 'Toggle Combat Touch Lock', pause: 'Pause Menu', } export const DEFAULT_BINDINGS: Record = { pc: { navigateUp: 'ArrowUp', navigateDown: 'ArrowDown', navigateLeft: 'ArrowLeft', navigateRight: 'ArrowRight', confirm: 'Enter', back: 'Escape', ability1: 'Digit1', ability2: 'Digit2', ability3: 'Digit3', ability4: 'Digit4', ability5: 'Digit5', ability6: 'Digit6', previousTarget: 'KeyQ', nextTarget: 'KeyE', targetParty1: 'F1', targetParty2: 'F2', targetParty3: 'F3', targetParty4: 'F4', targetParty5: 'F5', targetParty6: 'F6', toggleTargetGroup: 'Tab', toggleSpeed: 'Backquote', toggleTouchLock: 'F7', pause: 'Escape', }, controller: { navigateUp: 'Axis1-', navigateDown: 'Axis1+', navigateLeft: 'Axis0-', navigateRight: 'Axis0+', confirm: 'Button0', back: 'Button1', ability1: 'Button3', ability2: 'Button2', ability3: 'Button0', ability4: 'Button1', ability5: 'Button5', ability6: 'Button7', previousTarget: 'Button14', nextTarget: 'Button15', targetParty1: 'Button14', targetParty2: 'Button12', targetParty3: 'Button15', targetParty4: 'Button13', targetParty5: 'Button4', targetParty6: 'Button6', toggleTargetGroup: 'Button8', toggleSpeed: 'Button11', toggleTouchLock: 'Button10', pause: 'Button9', }, } const STORAGE_KEY = 'ashen-halls-input-bindings-v1' const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1' const GAME_ACTION_EVENT = 'ashen-halls-game-action' const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller' const FOCUSABLE_SELECTOR = 'button:not(:disabled):not([data-controller-nav="skip"]), input:not(:disabled):not([data-controller-nav="skip"]), select:not(:disabled):not([data-controller-nav="skip"]), textarea:not(:disabled):not([data-controller-nav="skip"]), [tabindex]:not([tabindex="-1"]):not([data-controller-nav="skip"])' const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display' const HEADER_CONTENT_SELECTOR = '.app-header' const GAMEPAD_COMBAT_POLL_MS = 1000 / 60 const GAMEPAD_MENU_POLL_MS = 1000 / 30 const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250 const CONTROLLER_REPEAT_INITIAL_MS = 260 const CONTROLLER_REPEAT_MS = 85 const DPAD_NAV_ACTIONS: Partial> = { Button12: 'navigateUp', Button13: 'navigateDown', Button14: 'navigateLeft', Button15: 'navigateRight', } let lastControllerFocus: HTMLElement | null = null type CaptureState = { device: InputDevice action: InputAction } | null type InputContextValue = { bindings: Record capture: CaptureState lastDevice: InputDevice controllerIconStyle: ControllerIconStyle directPartyTargeting: boolean combatTouchLocked: boolean beginCapture: (device: InputDevice, action: InputAction) => void cancelCapture: () => void resetBindings: (device: InputDevice) => void setControllerIconStyle: (style: ControllerIconStyle) => void setDirectPartyTargeting: (enabled: boolean) => void setCombatTouchLocked: (locked: boolean) => void } const InputContext = createContext(null) function loadBindings(): Record { try { const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial>> const savedController = saved.controller const controller = { ...DEFAULT_BINDINGS.controller, ...savedController } const usesLegacyAbilityDefaults = [ 'Button2', 'Button3', 'Button4', 'Button5', 'Button6', 'Button7', ].every((binding, index) => ( controller[`ability${index + 1}` as InputAction] === binding )) if (usesLegacyAbilityDefaults) { Object.assign(controller, { ability1: DEFAULT_BINDINGS.controller.ability1, ability2: DEFAULT_BINDINGS.controller.ability2, ability3: DEFAULT_BINDINGS.controller.ability3, ability4: DEFAULT_BINDINGS.controller.ability4, ability5: DEFAULT_BINDINGS.controller.ability5, ability6: DEFAULT_BINDINGS.controller.ability6, }) } if (savedController?.toggleSpeed === 'Button7') { controller.toggleSpeed = DEFAULT_BINDINGS.controller.toggleSpeed } if (savedController?.ability6 === 'Button10') { controller.ability6 = DEFAULT_BINDINGS.controller.ability6 } if (savedController?.targetParty6 === 'Button11') { controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6 } if ( savedController?.targetParty6 === undefined || savedController.targetParty6 === 'Button10' ) { controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6 } if ( savedController?.toggleTargetGroup === undefined || savedController.toggleTargetGroup === 'Button6' ) { controller.toggleTargetGroup = DEFAULT_BINDINGS.controller.toggleTargetGroup } if (savedController?.toggleTouchLock === undefined) { controller.toggleTouchLock = DEFAULT_BINDINGS.controller.toggleTouchLock } return { pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc }, controller, } } catch { return structuredClone(DEFAULT_BINDINGS) } } function loadPreferences() { try { const saved = JSON.parse(localStorage.getItem(PREFERENCES_STORAGE_KEY) ?? '{}') as { controllerIconStyle?: ControllerIconStyle directPartyTargeting?: boolean combatTouchLocked?: boolean } return { controllerIconStyle: saved.controllerIconStyle ?? 'xbox', directPartyTargeting: saved.directPartyTargeting ?? false, combatTouchLocked: saved.combatTouchLocked ?? Capacitor.isNativePlatform(), } } catch { return { controllerIconStyle: 'xbox' as ControllerIconStyle, directPartyTargeting: false, combatTouchLocked: Capacitor.isNativePlatform(), } } } function isTextInput(element: Element | null): element is HTMLInputElement | HTMLTextAreaElement { return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement } function bindingGroup(action: InputAction) { if (action.startsWith('ability')) return 'abilities' if (action.startsWith('targetParty') || action === 'toggleTargetGroup') return 'direct-targeting' if (action === 'previousTarget' || action === 'nextTarget') return 'relative-targeting' if (action === 'pause') return 'pause' if (action === 'toggleTouchLock' || action === 'toggleSpeed') return 'system' return 'navigation' } function isVisible(element: HTMLElement) { if (element.hidden || element.getAttribute('aria-hidden') === 'true') return false return element.getClientRects().length > 0 } function focusableDescendants(scope: ParentNode) { return Array.from( scope.querySelectorAll(FOCUSABLE_SELECTOR), ).filter(isVisible) } function uniqueElements(elements: HTMLElement[]) { return elements.filter((element, index) => elements.indexOf(element) === index) } function focusableElements() { const keyboard = document.querySelector('.controller-keyboard') const pauseMenu = document.querySelector('.pause-screen') const dialog = Array.from( document.querySelectorAll( '.result-screen, .binding-capture, .dual-startup-prompt', ), ).find(isVisible) const mainContent = Array.from( document.querySelectorAll(MAIN_CONTENT_SELECTOR), ).find(isVisible) const overlay = keyboard ?? pauseMenu ?? dialog if (overlay) return focusableDescendants(overlay) const visibleHeaders = Array.from( document.querySelectorAll(HEADER_CONTENT_SELECTOR), ).filter(isVisible) if (!mainContent) return visibleHeaders.length > 0 ? [] : focusableDescendants(document) const headerControls = visibleHeaders.flatMap( (header) => focusableDescendants(header), ) return uniqueElements([ ...focusableDescendants(mainContent), ...headerControls, ]) } function rememberFocusableControl(element: HTMLElement) { lastControllerFocus = element } function focusControl(element: HTMLElement) { rememberFocusableControl(element) element.focus({ preventScroll: true }) element.scrollIntoView({ block: 'nearest', inline: 'nearest' }) } function currentFocusableControl(candidates = focusableElements(), preferControllerFocus = false) { if ( preferControllerFocus && lastControllerFocus && candidates.includes(lastControllerFocus) && isVisible(lastControllerFocus) ) { return lastControllerFocus } const active = document.activeElement if (active instanceof HTMLElement && candidates.includes(active)) { return active } if (lastControllerFocus && candidates.includes(lastControllerFocus) && isVisible(lastControllerFocus)) { return lastControllerFocus } return null } function rangeDistance(startA: number, endA: number, startB: number, endB: number) { if (endA < startB) return startB - endA if (endB < startA) return startA - endB return 0 } function changeSelectOption(select: HTMLSelectElement, direction: -1 | 1) { const options = Array.from(select.options).filter((option) => !option.disabled) const currentIndex = options.findIndex((option) => option.index === select.selectedIndex) const nextOption = options[currentIndex + direction] if (!nextOption) return false select.selectedIndex = nextOption.index select.dispatchEvent(new Event('input', { bubbles: true })) select.dispatchEvent(new Event('change', { bubbles: true })) return true } export function focusFirstControl() { if (hasDedicatedGameNavigation()) return null const first = focusableElements()[0] if (first) focusControl(first) return first } function moveFocus(action: InputAction, preferControllerFocus = false) { const candidates = focusableElements() if (candidates.length === 0) return const current = currentFocusableControl(candidates, preferControllerFocus) if (!current) { focusFirstControl() return } const currentRect = current.getBoundingClientRect() const currentX = currentRect.left + currentRect.width / 2 const currentY = currentRect.top + currentRect.height / 2 const vertical = action === 'navigateUp' || action === 'navigateDown' const direction = action === 'navigateUp' || action === 'navigateLeft' ? -1 : 1 const currentIndex = candidates.indexOf(current) const adjacent = candidates[currentIndex + direction] const fallbackToAdjacent = () => { if (adjacent) focusControl(adjacent) } const ranked = candidates .filter((candidate) => candidate !== current) .map((candidate, index) => { const rect = candidate.getBoundingClientRect() const x = rect.left + rect.width / 2 const y = rect.top + rect.height / 2 const primary = vertical ? direction > 0 ? rect.top - currentRect.bottom : currentRect.top - rect.bottom : direction > 0 ? rect.left - currentRect.right : currentRect.left - rect.right const secondary = vertical ? rangeDistance(currentRect.left, currentRect.right, rect.left, rect.right) : rangeDistance(currentRect.top, currentRect.bottom, rect.top, rect.bottom) return { candidate, index, primary, secondary, score: Math.max(0, primary) + secondary * 2.5 + (vertical ? Math.abs(x - currentX) : Math.abs(y - currentY)) * 0.1, } }) .filter(({ primary }) => primary >= -4) const sameRowHorizontal = !vertical ? ranked.filter(({ secondary }) => secondary === 0) : ranked if (sameRowHorizontal.length === 0) { fallbackToAdjacent() return } sameRowHorizontal.sort((a, b) => a.score - b.score || a.secondary - b.secondary || a.index - b.index) const next = sameRowHorizontal[0]?.candidate if (!next) return focusControl(next) } function hasUiOverlay() { return Array.from( document.querySelectorAll( '.pause-screen, .result-screen, .binding-capture, .dual-startup-prompt, .controller-keyboard', ), ).some(isVisible) } function hasDedicatedGameNavigation() { return Array.from( document.querySelectorAll('[data-game-nav-active="true"]'), ).some(isVisible) } function dispatchGameAction(action: InputAction, device: InputDevice) { window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, { detail: { action, device }, })) } const BUTTON_LABELS: Record = { 0: 'A / Cross', 1: 'B / Circle', 2: 'X / Square', 3: 'Y / Triangle', 4: 'Left Bumper', 5: 'Right Bumper', 6: 'Left Trigger', 7: 'Right Trigger', 8: 'View / Select', 9: 'Menu / Start', 10: 'Left Stick', 11: 'Right Stick', 12: 'D-Pad Up', 13: 'D-Pad Down', 14: 'D-Pad Left', 15: 'D-Pad Right', 16: 'Home', } const KEY_LABELS: Record = { ArrowUp: 'Up Arrow', ArrowDown: 'Down Arrow', ArrowLeft: 'Left Arrow', ArrowRight: 'Right Arrow', Enter: 'Enter', Escape: 'Escape', Space: 'Space', } export function bindingLabel(binding: string, iconStyle: ControllerIconStyle = 'xbox') { if (binding.startsWith('Button')) { const button = Number(binding.slice(6)) const faceLabels: Record>> = { xbox: { 0: 'A', 1: 'B', 2: 'X', 3: 'Y' }, playstation: { 0: '×', 1: '○', 2: '□', 3: '△' }, nintendo: { 0: 'B', 1: 'A', 2: 'Y', 3: 'X' }, } const shoulderLabels: Record>> = { xbox: { 4: 'LB', 5: 'RB', 6: 'LT', 7: 'RT', 8: 'View', 9: 'Start' }, playstation: { 4: 'L1', 5: 'R1', 6: 'L2', 7: 'R2', 8: 'Share', 9: 'Options' }, nintendo: { 4: 'L', 5: 'R', 6: 'ZL', 7: 'ZR', 8: 'Minus', 9: 'Plus' }, } return faceLabels[iconStyle][button] ?? shoulderLabels[iconStyle][button] ?? BUTTON_LABELS[button] ?? `Button ${button}` } if (binding.startsWith('Axis')) { const axis = Number(binding.slice(4, -1)) const direction = binding.endsWith('-') ? '-' : '+' const labels: Record = { '0-': 'Left Stick Left', '0+': 'Left Stick Right', '1-': 'Left Stick Up', '1+': 'Left Stick Down', '2-': 'Right Stick Left', '2+': 'Right Stick Right', '3-': 'Right Stick Up', '3+': 'Right Stick Down', } return labels[`${axis}${direction}`] ?? `Axis ${axis} ${direction}` } if (KEY_LABELS[binding]) return KEY_LABELS[binding] if (binding.startsWith('Key') || binding.startsWith('Digit')) return binding.slice(-1) return binding } export function compactBindingLabel( binding: string, iconStyle: ControllerIconStyle = 'xbox', ) { const controllerLabels: Record = { Button14: 'D-Pad Left', Button15: 'D-Pad Right', } return controllerLabels[binding] ?? bindingLabel(binding, iconStyle) } function gamepadTokens(gamepad: Gamepad) { const tokens = new Set() gamepad.buttons.forEach((button, index) => { if (button.pressed || button.value > 0.65) tokens.add(`Button${index}`) }) gamepad.axes.forEach((value, index) => { if (value < -0.65) tokens.add(`Axis${index}-`) if (value > 0.65) tokens.add(`Axis${index}+`) }) return tokens } function isCombatActive() { return Boolean(document.querySelector('[data-combat-active="true"]')) } function firstConnectedGamepad() { return Array.from(navigator.getGamepads?.() ?? []).find(Boolean) ?? null } function setInputValue(input: HTMLInputElement | HTMLTextAreaElement, nextValue: string) { const prototype = input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set setter?.call(input, nextValue) input.dispatchEvent(new Event('input', { bubbles: true })) } export function InputProvider({ children }: { children: ReactNode }) { const [bindings, setBindings] = useState(loadBindings) const [capture, setCapture] = useState(null) const [lastDevice, setLastDevice] = useState('pc') const [preferences, setPreferences] = useState(loadPreferences) const [keyboardInput, setKeyboardInput] = useState(null) const [keyboardShift, setKeyboardShift] = useState(false) const [touchLockMessage, setTouchLockMessage] = useState('') const bindingsRef = useRef(bindings) const preferencesRef = useRef(preferences) const captureRef = useRef(capture) const keyboardInputRef = useRef(keyboardInput) const previousTokensRef = useRef(new Set()) const repeatRef = useRef>({}) const gamepadConnectedRef = useRef(Capacitor.isNativePlatform()) const touchLockMessageTimerRef = useRef(0) useEffect(() => { bindingsRef.current = bindings localStorage.setItem(STORAGE_KEY, JSON.stringify(bindings)) }, [bindings]) useEffect(() => { localStorage.setItem(PREFERENCES_STORAGE_KEY, JSON.stringify(preferences)) preferencesRef.current = preferences }, [preferences]) useEffect(() => { captureRef.current = capture }, [capture]) useEffect(() => { keyboardInputRef.current = keyboardInput }, [keyboardInput]) const assignBinding = useCallback((device: InputDevice, action: InputAction, token: string) => { setBindings((current) => { const nextDevice = { ...current[device] } const previousToken = nextDevice[action] const collision = INPUT_ACTIONS.find( (candidate) => ( candidate !== action && bindingGroup(candidate) === bindingGroup(action) && nextDevice[candidate] === token ), ) if (collision) nextDevice[collision] = previousToken nextDevice[action] = token return { ...current, [device]: nextDevice } }) setCapture(null) }, []) const closeKeyboard = useCallback(() => { const input = keyboardInputRef.current setKeyboardInput(null) window.requestAnimationFrame(() => input?.focus({ preventScroll: true })) }, []) const dispatchAction = useCallback((action: InputAction, device: InputDevice) => { const uiOverlay = hasUiOverlay() const combatActive = Boolean(document.querySelector('[data-combat-active="true"]')) const controllerUiInput = device === 'controller' && (uiOverlay || !combatActive) const dedicatedNavAction = action.startsWith('navigate') || action === 'confirm' || action === 'back' setLastDevice(device) document.documentElement.dataset.inputDevice = device if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) { if (document.activeElement instanceof HTMLElement) document.activeElement.blur() dispatchGameAction(action, device) return } if (action === 'toggleTouchLock') { setPreferences((current) => ({ ...current, combatTouchLocked: !current.combatTouchLocked, })) window.clearTimeout(touchLockMessageTimerRef.current) const nextLocked = !preferencesRef.current.combatTouchLocked setTouchLockMessage(`Combat touch ${nextLocked ? 'locked' : 'unlocked'}`) touchLockMessageTimerRef.current = window.setTimeout(() => { setTouchLockMessage('') }, 1400) } else if (action.startsWith('navigate')) { if (uiOverlay || !combatActive) { const active = currentFocusableControl(focusableElements(), controllerUiInput) if ( active instanceof HTMLSelectElement && (action === 'navigateUp' || action === 'navigateDown') && changeSelectOption(active, action === 'navigateUp' ? -1 : 1) ) { if (controllerUiInput) focusControl(active) return } moveFocus(action, controllerUiInput) } } else if (action === 'confirm') { const active = currentFocusableControl(focusableElements(), controllerUiInput) if (isTextInput(active)) { setKeyboardInput(active) window.requestAnimationFrame(() => focusFirstControl()) } else if (active instanceof HTMLSelectElement) { if (controllerUiInput) focusControl(active) const select = active as HTMLSelectElement & { showPicker?: () => void } if (select.showPicker) select.showPicker() else active.click() } else if ( active && active.matches('button:not(:disabled), [role="button"]') && isVisible(active) ) { if (controllerUiInput) focusControl(active) active.click() } else { focusFirstControl() } } else if (action === 'back') { if (keyboardInputRef.current) { closeKeyboard() } else if (uiOverlay || !combatActive) { const backButton = Array.from( document.querySelectorAll('.back-button:not(:disabled)'), ).find(isVisible) backButton?.click() } } dispatchGameAction(action, device) }, [closeKeyboard]) const dispatchControllerToken = useCallback((token: string, repeat = false) => { if (captureRef.current?.device === 'controller') { if (!repeat) assignBinding('controller', captureRef.current.action, token) return } if (captureRef.current) return const combatActive = Boolean( document.querySelector('[data-combat-active="true"]'), ) const uiOverlay = hasUiOverlay() const uiPriority = [ 'navigateUp', 'navigateDown', 'navigateLeft', 'navigateRight', 'confirm', 'back', ] satisfies InputAction[] const directTargetActions = [ 'targetParty1', 'targetParty2', 'targetParty3', 'targetParty4', 'targetParty5', 'targetParty6', 'toggleTargetGroup', 'toggleSpeed', 'toggleTouchLock', ] satisfies InputAction[] const combatPriority = [ 'toggleTouchLock', 'pause', 'toggleSpeed', 'ability1', 'ability2', 'ability3', 'ability4', 'ability5', 'ability6', 'previousTarget', 'nextTarget', 'navigateUp', 'navigateDown', 'navigateLeft', 'navigateRight', ] satisfies InputAction[] const action = DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay) ? DPAD_NAV_ACTIONS[token] : uiOverlay ? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token) : combatActive && preferencesRef.current.directPartyTargeting ? [...directTargetActions, ...combatPriority].find( (candidate) => bindingsRef.current.controller[candidate] === token, ) : combatActive && DPAD_NAV_ACTIONS[token] ? DPAD_NAV_ACTIONS[token] : !combatActive && DPAD_NAV_ACTIONS[token] ? DPAD_NAV_ACTIONS[token] : (combatActive ? combatPriority : INPUT_ACTIONS).find( (candidate) => bindingsRef.current.controller[candidate] === token, ) if (!action) return if (repeat && !action.startsWith('navigate')) return dispatchAction(action, 'controller') }, [assignBinding, dispatchAction]) useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { const active = document.activeElement if (captureRef.current?.device === 'pc') { event.preventDefault() if (event.code === 'Escape') { setCapture(null) return } assignBinding('pc', captureRef.current.action, event.code) return } if (captureRef.current || (isTextInput(active) && !keyboardInputRef.current)) return const action = INPUT_ACTIONS.find( (candidate) => bindingsRef.current.pc[candidate] === event.code, ) if (!action) return event.preventDefault() if (event.repeat && !action.startsWith('navigate')) return dispatchAction(action, 'pc') } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [assignBinding, dispatchAction]) useEffect(() => { const onFocusIn = (event: FocusEvent) => { const target = event.target if (!(target instanceof HTMLElement)) return if (!target.matches(FOCUSABLE_SELECTOR) || !isVisible(target)) return if (document.documentElement.dataset.inputDevice !== 'controller') return rememberFocusableControl(target) } const onPointerDown = (event: PointerEvent) => { if (event.pointerType !== 'touch') { document.documentElement.dataset.inputDevice = 'pc' } const target = event.target if (!(target instanceof Element)) return const control = target.closest(FOCUSABLE_SELECTOR) if (!control || !isVisible(control)) return if (event.pointerType !== 'touch') rememberFocusableControl(control) } document.addEventListener('focusin', onFocusIn) document.addEventListener('pointerdown', onPointerDown, { capture: true }) return () => { document.removeEventListener('focusin', onFocusIn) document.removeEventListener('pointerdown', onPointerDown, { capture: true }) } }, []) useEffect(() => { const listener = (event: Event) => { const detail = (event as CustomEvent<{ token: string; repeat?: boolean }>).detail dispatchControllerToken(detail.token, Boolean(detail.repeat)) } window.addEventListener(NATIVE_CONTROLLER_EVENT, listener) return () => window.removeEventListener(NATIVE_CONTROLLER_EVENT, listener) }, [dispatchControllerToken]) useEffect(() => () => { window.clearTimeout(touchLockMessageTimerRef.current) }, []) useEffect(() => { let focusFrame = 0 const ensureFocus = () => { focusFrame = 0 const combatActive = document.querySelector('[data-combat-active="true"]') if (combatActive) return if (hasDedicatedGameNavigation()) return const candidates = focusableElements() const activeControl = currentFocusableControl(candidates) if ( (!activeControl || document.activeElement === document.body) && !keyboardInputRef.current && !captureRef.current ) { if (activeControl) { focusControl(activeControl) } else { focusFirstControl() } } } const scheduleEnsureFocus = () => { if (focusFrame) return focusFrame = window.requestAnimationFrame(ensureFocus) } const observer = new MutationObserver(() => { scheduleEnsureFocus() }) observer.observe(document.getElementById('root') ?? document.body, { attributes: true, attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden'], childList: true, subtree: true, }) scheduleEnsureFocus() return () => { if (focusFrame) window.cancelAnimationFrame(focusFrame) observer.disconnect() } }, []) useEffect(() => { let timer = 0 if (Capacitor.isNativePlatform() && capture?.device !== 'controller') { previousTokensRef.current = new Set() repeatRef.current = {} return undefined } const nextDelay = () => { if (Capacitor.isNativePlatform()) { return GAMEPAD_COMBAT_POLL_MS } if (!Capacitor.isNativePlatform() && !gamepadConnectedRef.current) { return GAMEPAD_BROWSER_DISCONNECTED_POLL_MS } return isCombatActive() ? GAMEPAD_COMBAT_POLL_MS : GAMEPAD_MENU_POLL_MS } const clearControllerState = () => { previousTokensRef.current = new Set() repeatRef.current = {} } const poll = () => { const time = performance.now() const gamepad = firstConnectedGamepad() gamepadConnectedRef.current = Capacitor.isNativePlatform() || Boolean(gamepad) const currentTokens = gamepad ? gamepadTokens(gamepad) : new Set() const previousTokens = previousTokensRef.current currentTokens.forEach((token) => { const pressed = !previousTokens.has(token) if (pressed && captureRef.current?.device === 'controller') { assignBinding('controller', captureRef.current.action, token) return } if (captureRef.current) return const action = INPUT_ACTIONS.find( (candidate) => bindingsRef.current.controller[candidate] === token, ) const canRepeat = Boolean(action?.startsWith('navigate') || DPAD_NAV_ACTIONS[token]) const nextRepeat = repeatRef.current[token] ?? 0 if (pressed || (canRepeat && time >= nextRepeat)) { dispatchControllerToken(token, !pressed) repeatRef.current[token] = time + (pressed ? CONTROLLER_REPEAT_INITIAL_MS : CONTROLLER_REPEAT_MS) } }) Object.keys(repeatRef.current).forEach((token) => { if (!currentTokens.has(token)) delete repeatRef.current[token] }) previousTokensRef.current = currentTokens if (!gamepad) clearControllerState() timer = window.setTimeout(poll, nextDelay()) } const onGamepadConnected = () => { gamepadConnectedRef.current = true } const onGamepadDisconnected = () => { gamepadConnectedRef.current = Boolean(firstConnectedGamepad()) if (!gamepadConnectedRef.current) clearControllerState() } window.addEventListener('gamepadconnected', onGamepadConnected) window.addEventListener('gamepaddisconnected', onGamepadDisconnected) timer = window.setTimeout(poll, 0) return () => { window.clearTimeout(timer) window.removeEventListener('gamepadconnected', onGamepadConnected) window.removeEventListener('gamepaddisconnected', onGamepadDisconnected) } }, [assignBinding, capture, dispatchControllerToken]) useEffect(() => { const shouldBlockTouch = () => ( Capacitor.isNativePlatform() && preferencesRef.current.combatTouchLocked && isCombatActive() && !hasUiOverlay() ) const blockTouch = (event: Event) => { if ( typeof PointerEvent !== 'undefined' && event instanceof PointerEvent && event.pointerType !== 'touch' ) return if (!shouldBlockTouch()) return event.preventDefault() event.stopImmediatePropagation() } const options = { capture: true, passive: false } document.addEventListener('pointerdown', blockTouch, options) document.addEventListener('pointerup', blockTouch, options) document.addEventListener('touchstart', blockTouch, options) document.addEventListener('touchmove', blockTouch, options) document.addEventListener('touchend', blockTouch, options) return () => { document.removeEventListener('pointerdown', blockTouch, options) document.removeEventListener('pointerup', blockTouch, options) document.removeEventListener('touchstart', blockTouch, options) document.removeEventListener('touchmove', blockTouch, options) document.removeEventListener('touchend', blockTouch, options) } }, []) const contextValue = useMemo(() => ({ bindings, capture, lastDevice, controllerIconStyle: preferences.controllerIconStyle, directPartyTargeting: preferences.directPartyTargeting, combatTouchLocked: preferences.combatTouchLocked, beginCapture: (device, action) => setCapture({ device, action }), cancelCapture: () => setCapture(null), resetBindings: (device) => setBindings((current) => ({ ...current, [device]: { ...DEFAULT_BINDINGS[device] }, })), setControllerIconStyle: (controllerIconStyle) => setPreferences((current) => ({ ...current, controllerIconStyle, })), setDirectPartyTargeting: (directPartyTargeting) => setPreferences((current) => ({ ...current, directPartyTargeting, })), setCombatTouchLocked: (combatTouchLocked) => setPreferences((current) => ({ ...current, combatTouchLocked, })), }), [bindings, capture, lastDevice, preferences]) function typeKeyboardKey(key: string) { if (!keyboardInput) return const start = keyboardInput.selectionStart ?? keyboardInput.value.length const end = keyboardInput.selectionEnd ?? start if (key === 'backspace') { const from = start === end ? Math.max(0, start - 1) : start setInputValue(keyboardInput, keyboardInput.value.slice(0, from) + keyboardInput.value.slice(end)) window.requestAnimationFrame(() => keyboardInput.setSelectionRange(from, from)) return } const value = key === 'space' ? ' ' : keyboardShift ? key.toUpperCase() : key const next = keyboardInput.value.slice(0, start) + value + keyboardInput.value.slice(end) const maxLength = keyboardInput.maxLength > 0 ? keyboardInput.maxLength : Number.POSITIVE_INFINITY const limited = next.slice(0, maxLength) setInputValue(keyboardInput, limited) const cursor = Math.min(start + value.length, limited.length) window.requestAnimationFrame(() => keyboardInput.setSelectionRange(cursor, cursor)) } const keyboardKeys = [ ...'1234567890', ...'qwertyuiop', ...'asdfghjkl', ...'zxcvbnm', '_', '-', '@', '.', '!', '?', '#', '$', ] return ( {children} {keyboardInput && (

Controller Keyboard

{keyboardInput.value || 'Enter text'}
{keyboardKeys.map((key) => ( ))}
)} {touchLockMessage && (
{touchLockMessage}
)}
) } export function useInput() { const context = useContext(InputContext) if (!context) throw new Error('useInput must be used inside InputProvider') return context } export function useGameAction( handler: (action: InputAction, device: InputDevice) => void, ) { const handlerRef = useRef(handler) useEffect(() => { handlerRef.current = handler }, [handler]) useEffect(() => { const listener = (event: Event) => { const detail = (event as CustomEvent<{ action: InputAction; device: InputDevice }>).detail handlerRef.current(detail.action, detail.device) } window.addEventListener(GAME_ACTION_EVENT, listener) return () => window.removeEventListener(GAME_ACTION_EVENT, listener) }, []) } export function dispatchExternalGameAction( action: InputAction, device: InputDevice, ) { dispatchGameAction(action, device) }