Initial I Want to Heal app
This commit is contained in:
+716
@@ -0,0 +1,716 @@
|
||||
/* 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',
|
||||
'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',
|
||||
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',
|
||||
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',
|
||||
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) {
|
||||
return element.getClientRects().length > 0
|
||||
}
|
||||
|
||||
function focusableElements() {
|
||||
const keyboard = document.querySelector<HTMLElement>('.controller-keyboard')
|
||||
const pauseMenu = document.querySelector<HTMLElement>('.pause-screen')
|
||||
const scope: ParentNode = keyboard ?? pauseMenu ?? 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 })
|
||||
next.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
|
||||
}
|
||||
|
||||
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>>({})
|
||||
|
||||
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) => {
|
||||
setLastDevice(device)
|
||||
document.documentElement.dataset.inputDevice = device
|
||||
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
||||
|
||||
if (action.startsWith('navigate')) {
|
||||
if (!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, [role="button"]')) {
|
||||
active.click()
|
||||
} else {
|
||||
focusFirstControl()
|
||||
}
|
||||
} else if (action === 'back') {
|
||||
if (keyboardInputRef.current) {
|
||||
closeKeyboard()
|
||||
} else if (!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 menuDpadActions: Partial<Record<string, InputAction>> = {
|
||||
Button12: 'navigateUp',
|
||||
Button13: 'navigateDown',
|
||||
Button14: 'navigateLeft',
|
||||
Button15: 'navigateRight',
|
||||
}
|
||||
const directTargetActions = [
|
||||
'targetParty1',
|
||||
'targetParty2',
|
||||
'targetParty3',
|
||||
'targetParty4',
|
||||
'targetParty5',
|
||||
'toggleTargetGroup',
|
||||
] satisfies InputAction[]
|
||||
const combatPriority = [
|
||||
'pause',
|
||||
'ability1',
|
||||
'ability2',
|
||||
'ability3',
|
||||
'ability4',
|
||||
'ability5',
|
||||
'ability6',
|
||||
'previousTarget',
|
||||
'nextTarget',
|
||||
'navigateUp',
|
||||
'navigateDown',
|
||||
'navigateLeft',
|
||||
'navigateRight',
|
||||
] satisfies InputAction[]
|
||||
const action = 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
|
||||
if (
|
||||
document.activeElement === document.body
|
||||
&& !keyboardInputRef.current
|
||||
&& !captureRef.current
|
||||
) {
|
||||
focusFirstControl()
|
||||
}
|
||||
}
|
||||
const observer = new MutationObserver(() => {
|
||||
window.requestAnimationFrame(ensureFocus)
|
||||
})
|
||||
observer.observe(document.getElementById('root') ?? document.body, {
|
||||
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 },
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user