Files
i-want-to-heal/src/input.tsx
T

775 lines
25 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'
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',
'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',
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',
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: 'Button11',
toggleTargetGroup: 'Button6',
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<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 controller = { ...DEFAULT_BINDINGS.controller, ...saved.controller }
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,
})
}
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>(
'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<HTMLElement>(
'.pause-screen, .result-screen, .binding-capture, .dual-startup-prompt, .controller-keyboard',
),
).some(isVisible)
}
function isCombatTargetAction(action: InputAction) {
return action.startsWith('navigate')
|| action.startsWith('targetParty')
|| action === 'previousTarget'
|| action === 'nextTarget'
|| action === 'toggleTargetGroup'
}
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 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 lastCombatNavigationRef = 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"]'))
if (combatActive && !uiOverlay && isCombatTargetAction(action)) {
const now = performance.now()
if (now - lastCombatNavigationRef.current < 125) return
lastCombatNavigationRef.current = now
}
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<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',
] satisfies InputAction[]
const combatPriority = [
'pause',
'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<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
frame = window.requestAnimationFrame(poll)
}
frame = window.requestAnimationFrame(poll)
return () => window.cancelAnimationFrame(frame)
}, [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 },
}))
}