Files
i-want-to-heal/src/input.tsx
T
2026-06-29 22:35:49 -04:00

859 lines
28 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 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<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',
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',
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'
const FOCUSABLE_SELECTOR = 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
const GAMEPAD_COMBAT_POLL_MS = 1000 / 60
const GAMEPAD_MENU_POLL_MS = 1000 / 30
const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250
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
beginCapture: (device: InputDevice, action: InputAction) => void
cancelCapture: () => void
resetBindings: (device: InputDevice) => void
setControllerIconStyle: (style: ControllerIconStyle) => void
setDirectPartyTargeting: (enabled: 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
}
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<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 scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document
return Array.from(
scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
).filter(isVisible)
}
function rememberFocusableControl(element: HTMLElement) {
lastControllerFocus = element
}
function focusControl(element: HTMLElement) {
rememberFocusableControl(element)
element.focus({ preventScroll: true })
}
function currentFocusableControl(candidates = focusableElements()) {
const active = document.activeElement
if (active instanceof HTMLElement && candidates.includes(active)) {
rememberFocusableControl(active)
return active
}
if (lastControllerFocus && candidates.includes(lastControllerFocus) && isVisible(lastControllerFocus)) {
return lastControllerFocus
}
return null
}
export function focusFirstControl() {
const first = focusableElements()[0]
if (first) focusControl(first)
return first
}
function moveFocus(action: InputAction) {
const candidates = focusableElements()
if (candidates.length === 0) return
const current = currentFocusableControl(candidates)
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
focusControl(next)
}
function hasUiOverlay() {
return Array.from(
document.querySelectorAll<HTMLElement>(
'.pause-screen, .result-screen, .binding-capture, .dual-startup-prompt, .controller-keyboard',
),
).some(isVisible)
}
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 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 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())
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 = currentFocusableControl()
if (isTextInput(active)) {
setKeyboardInput(active)
window.requestAnimationFrame(() => focusFirstControl())
} else if (
active
&& 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<HTMLButtonElement>('.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<Record<string, InputAction>> = {
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 onFocusIn = (event: FocusEvent) => {
const target = event.target
if (!(target instanceof HTMLElement)) return
if (!target.matches(FOCUSABLE_SELECTOR) || !isVisible(target)) return
rememberFocusableControl(target)
}
const onPointerDown = (event: PointerEvent) => {
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
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(() => {
const ensureFocus = () => {
const combatActive = document.querySelector('[data-combat-active="true"]')
if (combatActive) 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 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 timer = 0
const nextDelay = () => {
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 = 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
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, dispatchControllerToken])
const contextValue = useMemo<InputContextValue>(() => ({
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 (
<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>
)}
</InputContext.Provider>
)
}
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 },
}))
}