775 lines
25 KiB
TypeScript
775 lines
25 KiB
TypeScript
/* 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 },
|
||
}))
|
||
}
|