Files
i-want-to-heal/src/input.tsx
T
2026-07-07 15:07:14 -04:00

1196 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 type MovementVector = {
x: number
y: number
}
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<InputAction, string>
export const ACTION_LABELS: Record<InputAction, string> = {
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<InputDevice, InputBindings> = {
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 NATIVE_CONTROLLER_MOTION_EVENT = 'ashen-halls-native-controller-motion'
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, .iwt2-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 NATIVE_STICK_TIMEOUT_MS = 140
const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = {
Button12: 'navigateUp',
Button13: 'navigateDown',
Button14: 'navigateLeft',
Button15: 'navigateRight',
}
let lastControllerFocus: HTMLElement | null = null
type CaptureState = {
device: InputDevice
action: InputAction
} | null
type InputContextValue = {
bindings: Record<InputDevice, InputBindings>
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<InputContextValue | null>(null)
function loadBindings(): Record<InputDevice, InputBindings> {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial<Record<InputDevice, Partial<InputBindings>>>
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 ?? 'playstation',
directPartyTargeting: saved.directPartyTargeting ?? false,
combatTouchLocked: saved.combatTouchLocked ?? Capacitor.isNativePlatform(),
}
} catch {
return {
controllerIconStyle: 'playstation' 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<HTMLElement>(FOCUSABLE_SELECTOR),
).filter(isVisible)
}
function uniqueElements(elements: HTMLElement[]) {
return elements.filter((element, index) => elements.indexOf(element) === index)
}
function focusableElements() {
const keyboard = document.querySelector<HTMLElement>('.controller-keyboard')
const pauseMenu = document.querySelector<HTMLElement>('.pause-screen')
const dialog = Array.from(
document.querySelectorAll<HTMLElement>(
'.result-screen, .binding-capture, .dual-startup-prompt',
),
).find(isVisible)
const mainContent = Array.from(
document.querySelectorAll<HTMLElement>(MAIN_CONTENT_SELECTOR),
).find(isVisible)
const overlay = keyboard ?? pauseMenu ?? dialog
if (overlay) return focusableDescendants(overlay)
const visibleHeaders = Array.from(
document.querySelectorAll<HTMLElement>(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<HTMLElement>(
'.pause-screen, .result-screen, .binding-capture, .dual-startup-prompt, .controller-keyboard',
),
).some(isVisible)
}
function hasDedicatedGameNavigation() {
return Array.from(
document.querySelectorAll<HTMLElement>('[data-game-nav-active="true"]'),
).some(isVisible)
}
function isDedicatedNavigationAction(action: InputAction) {
return action.startsWith('navigate') || action === 'confirm' || action === 'back'
}
function dispatchGameAction(action: InputAction, device: InputDevice) {
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
detail: { action, device },
}))
}
const BUTTON_LABELS: Record<number, string> = {
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<string, string> = {
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<ControllerIconStyle, Partial<Record<number, string>>> = {
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<ControllerIconStyle, Partial<Record<number, string>>> = {
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<string, string> = {
'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<string, string> = {
Button14: 'D-Pad Left',
Button15: 'D-Pad Right',
}
return controllerLabels[binding] ?? bindingLabel(binding, iconStyle)
}
function gamepadTokens(gamepad: Gamepad) {
const tokens = new Set<string>()
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 hasContinuousMovementActive() {
return Boolean(document.querySelector('[data-continuous-movement-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<CaptureState>(null)
const [lastDevice, setLastDevice] = useState<InputDevice>('pc')
const [preferences, setPreferences] = useState(loadPreferences)
const [keyboardInput, setKeyboardInput] = useState<HTMLInputElement | HTMLTextAreaElement | null>(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<string>())
const repeatRef = useRef<Record<string, number>>({})
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 (
device === 'controller'
&& dedicatedNavAction
&& !keyboardInputRef.current
&& 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<HTMLButtonElement>('.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()
if (
combatActive
&& !uiOverlay
&& hasContinuousMovementActive()
&& (token.startsWith('Axis0') || token.startsWith('Axis1'))
) return
const uiPriority = [
'navigateUp',
'navigateDown',
'navigateLeft',
'navigateRight',
'confirm',
'back',
'pause',
] 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 dedicatedNavigationActive = hasDedicatedGameNavigation() && (!combatActive || uiOverlay)
const action = dedicatedNavigationActive
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
?? (DPAD_NAV_ACTIONS[token] && isDedicatedNavigationAction(DPAD_NAV_ACTIONS[token])
? DPAD_NAV_ACTIONS[token]
: undefined)
: 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<HTMLElement>(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<string>()
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<InputContextValue>(() => ({
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 (
<InputContext.Provider value={contextValue}>
{children}
{keyboardInput && (
<div className="controller-keyboard-backdrop" role="presentation">
<section className="controller-keyboard" aria-label="On-screen keyboard">
<div className="controller-keyboard-heading">
<div>
<p className="eyebrow">Controller Keyboard</p>
<strong>{keyboardInput.value || 'Enter text'}</strong>
</div>
<button onClick={closeKeyboard} type="button">Done</button>
</div>
<div className="controller-keyboard-grid">
{keyboardKeys.map((key) => (
<button key={key} onClick={() => typeKeyboardKey(key)} type="button">
{keyboardShift ? key.toUpperCase() : key}
</button>
))}
</div>
<div className="controller-keyboard-actions">
<button
className={keyboardShift ? 'active' : ''}
onClick={() => setKeyboardShift((value) => !value)}
type="button"
>
Shift
</button>
<button onClick={() => typeKeyboardKey('space')} type="button">Space</button>
<button onClick={() => typeKeyboardKey('backspace')} type="button">Backspace</button>
<button onClick={closeKeyboard} type="button">Done</button>
</div>
</section>
</div>
)}
{touchLockMessage && (
<div className="combat-touch-lock-status" aria-live="polite">
{touchLockMessage}
</div>
)}
</InputContext.Provider>
)
}
function normalizedVector(x: number, y: number): MovementVector {
const magnitude = Math.hypot(x, y)
if (magnitude <= 1) return { x, y }
return { x: x / magnitude, y: y / magnitude }
}
function applyStickDeadzone(x: number, y: number, deadzone = 0.18): MovementVector {
const magnitude = Math.hypot(x, y)
if (magnitude < deadzone) return { x: 0, y: 0 }
const scaled = Math.min(1, (magnitude - deadzone) / (1 - deadzone))
return {
x: (x / magnitude) * scaled,
y: (y / magnitude) * scaled,
}
}
export function useMovementVectorRef(enabled = true) {
const movementRef = useRef<MovementVector>({ x: 0, y: 0 })
useEffect(() => {
if (!enabled) {
movementRef.current = { x: 0, y: 0 }
return undefined
}
let frame = 0
const pressedKeys = new Set<string>()
let nativeStick: MovementVector = { x: 0, y: 0 }
let nativeStickUpdatedAt = 0
const keyboardVector = () => normalizedVector(
(pressedKeys.has('KeyD') ? 1 : 0) - (pressedKeys.has('KeyA') ? 1 : 0),
(pressedKeys.has('KeyS') ? 1 : 0) - (pressedKeys.has('KeyW') ? 1 : 0),
)
const updateMovement = () => {
const gamepad = firstConnectedGamepad()
const stick = gamepad ? applyStickDeadzone(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0) : { x: 0, y: 0 }
const native = performance.now() - nativeStickUpdatedAt <= NATIVE_STICK_TIMEOUT_MS
? applyStickDeadzone(nativeStick.x, nativeStick.y)
: { x: 0, y: 0 }
const keyboard = keyboardVector()
movementRef.current = Math.hypot(native.x, native.y) > 0
? native
: Math.hypot(stick.x, stick.y) > 0
? stick
: keyboard
frame = window.requestAnimationFrame(updateMovement)
}
const onNativeMotion = (event: Event) => {
const detail = (event as CustomEvent<Partial<MovementVector>>).detail
nativeStick = {
x: Number(detail.x) || 0,
y: Number(detail.y) || 0,
}
nativeStickUpdatedAt = performance.now()
document.documentElement.dataset.inputDevice = 'controller'
}
const onKeyDown = (event: KeyboardEvent) => {
if (isTextInput(document.activeElement)) return
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
pressedKeys.add(event.code)
document.documentElement.dataset.inputDevice = 'pc'
event.preventDefault()
}
const onKeyUp = (event: KeyboardEvent) => {
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
pressedKeys.delete(event.code)
event.preventDefault()
}
window.addEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
frame = window.requestAnimationFrame(updateMovement)
return () => {
window.cancelAnimationFrame(frame)
window.removeEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
movementRef.current = { x: 0, y: 0 }
}
}, [enabled])
return movementRef
}
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)
}