/* eslint-disable react-refresh/only-export-components */ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from 'react' 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', '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', 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', 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: 'Button10', toggleTargetGroup: 'Button6', toggleSpeed: 'Button11', 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' type CaptureState = { device: InputDevice action: InputAction } | null type InputContextValue = { bindings: Record capture: CaptureState lastDevice: InputDevice controllerIconStyle: ControllerIconStyle directPartyTargeting: boolean beginCapture: (device: InputDevice, action: InputAction) => void cancelCapture: () => void resetBindings: (device: InputDevice) => void setControllerIconStyle: (style: ControllerIconStyle) => void setDirectPartyTargeting: (enabled: 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 } 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 } return { controllerIconStyle: saved.controllerIconStyle ?? 'xbox', directPartyTargeting: saved.directPartyTargeting ?? false, } } catch { return { controllerIconStyle: 'xbox' as ControllerIconStyle, directPartyTargeting: false, } } } 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' return 'navigation' } function isVisible(element: HTMLElement) { if (element.hidden || element.getAttribute('aria-hidden') === 'true') return false return element.getClientRects().length > 0 } 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 scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document return Array.from( scope.querySelectorAll( 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])', ), ).filter(isVisible) } export function focusFirstControl() { const first = focusableElements()[0] first?.focus({ preventScroll: true }) return first } function moveFocus(action: InputAction) { const candidates = focusableElements() if (candidates.length === 0) return const current = document.activeElement instanceof HTMLElement && candidates.includes(document.activeElement) ? document.activeElement : null 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 ranked = candidates .filter((candidate) => candidate !== current) .map((candidate) => { const rect = candidate.getBoundingClientRect() const x = rect.left + rect.width / 2 const y = rect.top + rect.height / 2 const primary = vertical ? y - currentY : x - currentX const secondary = vertical ? Math.abs(x - currentX) : Math.abs(y - currentY) return { candidate, primary, score: Math.abs(primary) + secondary * 2.5 } }) .filter(({ primary }) => Math.sign(primary) === direction) .sort((a, b) => a.score - b.score) const next = ranked[0]?.candidate if (!next) return next.focus({ preventScroll: true }) } function hasUiOverlay() { return Array.from( document.querySelectorAll( '.pause-screen, .result-screen, .binding-capture, .dual-startup-prompt, .controller-keyboard', ), ).some(isVisible) } 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 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 bindingsRef = useRef(bindings) const preferencesRef = useRef(preferences) const captureRef = useRef(capture) const keyboardInputRef = useRef(keyboardInput) const previousTokensRef = useRef(new Set()) const repeatRef = useRef>({}) 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"]')) setLastDevice(device) document.documentElement.dataset.inputDevice = device if (action.startsWith('navigate')) { if (uiOverlay || !combatActive) moveFocus(action) } else if (action === 'confirm') { const active = document.activeElement if (isTextInput(active)) { setKeyboardInput(active) window.requestAnimationFrame(() => focusFirstControl()) } else if ( active instanceof HTMLElement && active.matches('button:not(:disabled), [role="button"]') && isVisible(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() } } window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, { detail: { 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 menuDpadActions: Partial> = { Button12: 'navigateUp', Button13: 'navigateDown', Button14: 'navigateLeft', Button15: 'navigateRight', } const uiPriority = [ 'navigateUp', 'navigateDown', 'navigateLeft', 'navigateRight', 'confirm', 'back', ] satisfies InputAction[] const directTargetActions = [ 'targetParty1', 'targetParty2', 'targetParty3', 'targetParty4', 'targetParty5', 'targetParty6', 'toggleTargetGroup', 'toggleSpeed', ] satisfies InputAction[] const combatPriority = [ 'pause', 'toggleSpeed', 'ability1', 'ability2', 'ability3', 'ability4', 'ability5', 'ability6', 'previousTarget', 'nextTarget', 'navigateUp', 'navigateDown', 'navigateLeft', 'navigateRight', ] satisfies InputAction[] const action = menuDpadActions[token] && (!combatActive || uiOverlay) ? menuDpadActions[token] : uiOverlay ? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token) : combatActive && preferencesRef.current.directPartyTargeting ? [...directTargetActions, ...combatPriority].find( (candidate) => bindingsRef.current.controller[candidate] === token, ) : combatActive && menuDpadActions[token] ? menuDpadActions[token] : !combatActive && menuDpadActions[token] ? menuDpadActions[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 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(() => { const ensureFocus = () => { const combatActive = document.querySelector('[data-combat-active="true"]') if (combatActive) return const candidates = focusableElements() const active = document.activeElement const activeIsUsable = active instanceof HTMLElement && candidates.includes(active) && isVisible(active) if ( (!activeIsUsable || document.activeElement === document.body) && !keyboardInputRef.current && !captureRef.current ) { focusFirstControl() } } const observer = new MutationObserver(() => { window.requestAnimationFrame(ensureFocus) }) observer.observe(document.getElementById('root') ?? document.body, { attributes: true, attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden', 'style'], childList: true, subtree: true, }) window.requestAnimationFrame(ensureFocus) return () => observer.disconnect() }, []) useEffect(() => { let frame = 0 const poll = (time: number) => { const gamepad = Array.from(navigator.getGamepads?.() ?? []).find(Boolean) 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 = action?.startsWith('navigate') ?? false const nextRepeat = repeatRef.current[token] ?? 0 if (pressed || (canRepeat && time >= nextRepeat)) { dispatchControllerToken(token, !pressed) repeatRef.current[token] = time + (pressed ? 360 : 125) } }) Object.keys(repeatRef.current).forEach((token) => { if (!currentTokens.has(token)) delete repeatRef.current[token] }) previousTokensRef.current = currentTokens frame = window.requestAnimationFrame(poll) } frame = window.requestAnimationFrame(poll) return () => window.cancelAnimationFrame(frame) }, [assignBinding, dispatchControllerToken]) const contextValue = useMemo(() => ({ bindings, capture, lastDevice, controllerIconStyle: preferences.controllerIconStyle, directPartyTargeting: preferences.directPartyTargeting, 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, })), }), [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) => ( ))}
)}
) } 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, ) { window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, { detail: { action, device }, })) }