import { applyRadialDeadzone, normalizePlanar } from "./inputMath"; import { CONTROLLER_SYSTEM_BACK_TOKEN, getControllerMovement, getControllerHeldTokens, subscribeControllerToken, } from "../input/controller"; import { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent, defaultInputBindings, type InputBindings, type InputDevice } from "../input/keybinds"; import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime"; import type { PartyCommand } from "./partyStore"; import { actionLayerForModifiers, type ActionBindingControl, type ActionBindingLayer, } from "./actionBindings"; export interface InputSnapshot { moveX: number; moveForward: number; lookX: number; lookY: number; gamepadActive: boolean; } export interface InputActions { /** Runs before built-in shortcuts. True consumes the key, including empty custom slots. */ handleCustomKey?: (event: KeyboardEvent) => boolean; desktopActionBarsActive?: () => boolean; getBindings?: (device: InputDevice) => InputBindings; inputSuspended?: () => boolean; castDesktopSlot?: (bar: number, slot: number) => void; togglePanel?: (panel: "party" | "boss-guide" | "bindings" | "options" | "keybinds" | "ui-layout") => void; togglePause: () => void; toggleMap: () => void; toggleInventory: () => void; toggleSpellbook: () => void; toggleTalents: () => void; lootActiveBoss: () => boolean; castActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl) => void; cycleTarget: (direction: 1 | -1) => void; cyclePartyTarget: (direction: 1 | -1) => void; setPartyCommand: (command: PartyCommand) => void; recallParty: () => void; clearTarget: () => void; groundTargetActive: () => boolean; confirmGroundTarget: () => void; cancelGroundTarget: () => void; gameplayActionsBlocked: () => boolean; /** Editors may disable combat actions while retaining movement and camera controls. */ movementBlocked?: () => boolean; setCameraLookActive: (active: boolean) => void; setInputMode: (mode: "keyboard" | "gamepad") => void; setActionLayer: (layer: ActionBindingLayer) => void; } const keys = new Set(); let actions: InputActions | null = null; let jumpRequestAt: number | null = null; // Pointer-lock implementations can emit a short burst of stale, very large // movement events while the cursor is being recentered. Some Android WebViews have also been // observed to expose an undefined/non-finite movement value. Either value must // be contained here: once NaN reaches the orbit camera, every later camera // transform remains NaN and Three can render only the Canvas clear color. export const MAX_MOUSE_DELTA_PER_EVENT = 160; export const MAX_MOUSE_DELTA_PER_FRAME = 320; export const POINTER_LOCK_MOUSE_GRACE_MS = 180; export const PRIMARY_MOUSE_BUTTON = 0; export const CAMERA_LOOK_MOUSE_BUTTON = 2; /** Desktop camera look begins only from the physical mouse's right button. */ export function isDesktopCameraLookPointer(pointerType: string, button: number): boolean { return pointerType === "mouse" && button === CAMERA_LOOK_MOUSE_BUTTON; } export function sanitizeMouseDelta(value: unknown): number { if (typeof value !== "number" || !Number.isFinite(value)) return 0; return Math.max(-MAX_MOUSE_DELTA_PER_EVENT, Math.min(MAX_MOUSE_DELTA_PER_EVENT, value)); } function monotonicNow(): number { if (typeof performance !== "undefined") return performance.now(); return Date.now(); } export function queueJumpRequest(nowMs = monotonicNow()): void { if (Number.isFinite(nowMs)) jumpRequestAt = nowMs; } export function consumeJumpRequest(): number | null { const requestedAt = jumpRequestAt; jumpRequestAt = null; return requestedAt; } export function clearJumpRequest(): void { jumpRequestAt = null; } export function createMouseLookBuffer(now: () => number = monotonicNow) { let x = 0; let y = 0; let suppressUntil = Number.NEGATIVE_INFINITY; return { reset(graceMs = 0): void { x = 0; y = 0; const timestamp = now(); suppressUntil = graceMs > 0 && Number.isFinite(timestamp) ? timestamp + graceMs : Number.NEGATIVE_INFINITY; }, push(rawX: unknown, rawY: unknown): boolean { const timestamp = now(); if (Number.isFinite(timestamp) && timestamp < suppressUntil) return false; const deltaX = sanitizeMouseDelta(rawX); const deltaY = sanitizeMouseDelta(rawY); if (deltaX === 0 && deltaY === 0) return false; x = Math.max(-MAX_MOUSE_DELTA_PER_FRAME, Math.min(MAX_MOUSE_DELTA_PER_FRAME, x + deltaX)); y = Math.max(-MAX_MOUSE_DELTA_PER_FRAME, Math.min(MAX_MOUSE_DELTA_PER_FRAME, y + deltaY)); return true; }, consume(): readonly [number, number] { const movement = [x, y] as const; x = 0; y = 0; return movement; }, }; } const mouseLookBuffer = createMouseLookBuffer(); let mouseLookDragActive = false; let previousMousePosition: { x: number; y: number } | null = null; /** Clears browser text selection before gameplay takes ownership of a mouse drag. */ export function clearBrowserSelection(): void { if (typeof window === "undefined") return; window.getSelection?.()?.removeAllRanges(); } /** * Starts popup-free desktop camera look. Unlike Pointer Lock, this retains the * normal browser cursor and only consumes movement while right mouse is held. */ export function beginMouseLookDrag(clientX: number, clientY: number): void { clearBrowserSelection(); mouseLookDragActive = true; previousMousePosition = Number.isFinite(clientX) && Number.isFinite(clientY) ? { x: clientX, y: clientY } : null; mouseLookBuffer.reset(); actions?.setCameraLookActive(true); } export function endMouseLookDrag(): void { mouseLookDragActive = false; previousMousePosition = null; mouseLookBuffer.reset(); actions?.setCameraLookActive(false); } export function isMouseLookDragActive(): boolean { return mouseLookDragActive; } function inputAxes(value: (id: string) => number): Pick { return { moveX: value("move-right") - value("move-left"), moveForward: value("move-forward") - value("move-backward"), lookX: value("look-right") - value("look-left"), lookY: value("look-down") - value("look-up") }; } export function keyboardAxesForKeys(activeKeys: ReadonlySet, bindings = defaultInputBindings("pc")) { return inputAxes((id) => { const key = bindings[id]; if (!key) return 0; return Number(activeKeys.has(key)); }); } function bindingsFor(device: InputDevice): InputBindings { return actions?.getBindings?.(device) ?? getInputPreferences()[device]; } export type GameplayInputCommand = | "pause" | "map" | "inventory" | "loot" | "spellbook" | "talents" | "target-next" | "target-previous" | "party-next" | "party-previous" | "party-attack" | "party-defend" | "party-stop" | "party-recall" | "jump" | "target-clear"; export function controllerCommandForToken(token: string): GameplayInputCommand | null { if (token === "Button9" || token === CONTROLLER_SYSTEM_BACK_TOKEN) return "pause"; if (token === "Button11") return "target-next"; if (token === "Button10") return "jump"; if (token === "Button12") return "party-previous"; if (token === "Button13") return "party-next"; return null; } export function keyboardCommandForCode(code: string, shiftKey = false): GameplayInputCommand | null { if (code === "Escape") return "pause"; if (code === "KeyM") return "map"; if (code === "KeyI") return "inventory"; if (code === "KeyE") return "loot"; if (code === "KeyP") return "spellbook"; if (code === "KeyN") return "talents"; if (code === "F1") return "party-attack"; if (code === "F2") return "party-defend"; if (code === "F3") return "party-stop"; if (code === "F4") return "party-recall"; if (code === "Space") return "jump"; if (code === "Tab") return shiftKey ? "target-previous" : "target-next"; return null; } function runCommand(command: GameplayInputCommand, nextActions: InputActions): void { if (command === "pause") nextActions.togglePause(); else if (command === "map") nextActions.toggleMap(); else if (command === "inventory") nextActions.toggleInventory(); else if (command === "loot") nextActions.lootActiveBoss(); else if (command === "spellbook") nextActions.toggleSpellbook(); else if (command === "talents") nextActions.toggleTalents(); else if (command === "target-next") nextActions.cycleTarget(1); else if (command === "target-previous") nextActions.cycleTarget(-1); else if (command === "party-next") nextActions.cyclePartyTarget(1); else if (command === "party-previous") nextActions.cyclePartyTarget(-1); else if (command === "party-attack") nextActions.setPartyCommand("attack"); else if (command === "party-defend") nextActions.setPartyCommand("defend"); else if (command === "party-stop") nextActions.setPartyCommand("stop"); else if (command === "party-recall") nextActions.recallParty(); else if (command === "jump") queueJumpRequest(); else if (command === "target-clear") nextActions.clearTarget(); } export function installInput(nextActions: InputActions): () => void { actions = nextActions; const keyboardModifiers = new Set(); const suspended = () => isCapturingInput() || nextActions.inputSuspended?.(); const movementBlocked = () => nextActions.movementBlocked?.() ?? nextActions.gameplayActionsBlocked(); const publishActionLayer = () => { const held = getControllerHeldTokens(); const layerHeld = (layer: string) => Object.entries(bindingsFor("controller")).some(([id, key]) => { const parts = key?.split("&").slice(0, -1) ?? []; return id.startsWith("ability:" + layer + ":") && parts.length > 0 && parts.every((token) => held.has(token)); }); nextActions.setActionLayer(actionLayerForModifiers(layerHeld("secondary") || keyboardModifiers.has("Shift"), layerHeld("tertiary") || keyboardModifiers.has("Alt"))); }; const dispatch = (id: string) => { if (id.startsWith("ability:")) { const [, layer, control] = id.split(":"); nextActions.setActionLayer(layer as ActionBindingLayer); nextActions.castActionBinding(layer as ActionBindingLayer, control as ActionBindingControl); } else if (id.startsWith("bar:")) { const [, bar, slot] = id.split(":"); nextActions.castDesktopSlot?.(Number(bar), Number(slot)); } else if (id === "ground-confirm") nextActions.confirmGroundTarget(); else if (id === "ground-cancel") nextActions.cancelGroundTarget(); else if (["party", "boss-guide", "bindings", "options", "keybinds", "ui-layout"].includes(id)) nextActions.togglePanel?.(id as Parameters>[0]); else runCommand(id as GameplayInputCommand, nextActions); }; const candidates = (device: InputDevice) => INPUT_ACTIONS.filter((entry) => { if (entry.context === "global" || entry.context === "menu" || entry.context === "ability-editor") return false; if (entry.continuous || entry.id === "jump") return !movementBlocked(); if (entry.context === "ground") return nextActions.groundTargetActive() && !nextActions.gameplayActionsBlocked(); if (entry.id.startsWith("bar:") && (!nextActions.desktopActionBarsActive?.() || Number(entry.id.split(":")[1]) >= 5)) return false; if (device === "pc" && entry.id.startsWith("ability:") && nextActions.desktopActionBarsActive?.()) return false; return !nextActions.gameplayActionsBlocked() || (entry.group === "Panels" && (device === "pc" || entry.id === "pause")); }); const unsubscribeController = subscribeControllerToken(({ token, repeat, pressed }) => { if (suspended()) return; publishActionLayer(); if (pressed === false || repeat) return; nextActions.setInputMode("gamepad"); if (token === CONTROLLER_SYSTEM_BACK_TOKEN) { if (!nextActions.gameplayActionsBlocked()) { if (nextActions.groundTargetActive()) nextActions.cancelGroundTarget(); else nextActions.togglePause(); } return; } const bindings = bindingsFor("controller"); const held = getControllerHeldTokens(); const matches = candidates("controller").filter((entry) => controllerBindingMatches(bindings[entry.id], token, held)) .sort((a, b) => Number(b.context === "ground") - Number(a.context === "ground") || (bindings[b.id]?.split("&").length ?? 0) - (bindings[a.id]?.split("&").length ?? 0)); for (const entry of matches) { if (entry.continuous) return; if (entry.id === "loot") { if (nextActions.lootActiveBoss()) return; else continue; } dispatch(entry.id); return; } }); const onKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; if (event.defaultPrevented || target?.closest?.("input, textarea, select, [contenteditable='true']") || suspended()) return; if (nextActions.handleCustomKey?.(event)) { event.preventDefault(); event.stopImmediatePropagation(); keys.delete(event.code); return; } const modifier = { ShiftLeft: "Shift", ShiftRight: "Shift", AltLeft: "Alt", AltRight: "Alt", ControlLeft: "Ctrl", ControlRight: "Ctrl", MetaLeft: "Meta", MetaRight: "Meta" }[event.code]; if (modifier) { keyboardModifiers.add(modifier); publishActionLayer(); return; } const key = inputKeyForEvent(event); if (!key) return; const bindings = bindingsFor("pc"); if (movementBlocked() && INPUT_ACTIONS.some((entry) => entry.context === "menu" && bindings[entry.id] === key)) return; const matches = candidates("pc").filter((entry) => bindings[entry.id] === key) .sort((a, b) => Number(b.context === "ground") - Number(a.context === "ground")); // Preserve ordinary movement while a modifier is held (e.g. Shift + W). const continuous = !movementBlocked() && INPUT_ACTIONS.some((entry) => entry.continuous && (bindings[entry.id] === event.code || bindings[entry.id] === key)); if (continuous) { keys.add(event.code); keys.add(key); event.preventDefault(); nextActions.setInputMode("keyboard"); } if (!matches.length) return; const entry = matches[0]; event.preventDefault(); nextActions.setInputMode("keyboard"); if (entry.continuous || event.repeat) return; dispatch(entry.id); }; const onKeyUp = (event: KeyboardEvent) => { for (const key of keys) if (key === event.code || key.endsWith("+" + event.code)) keys.delete(key); const modifier = { ShiftLeft: "Shift", ShiftRight: "Shift", AltLeft: "Alt", AltRight: "Alt", ControlLeft: "Ctrl", ControlRight: "Ctrl", MetaLeft: "Meta", MetaRight: "Meta" }[event.code]; if (modifier) { keyboardModifiers.delete(modifier); for (const key of keys) if (key.split("+").includes(modifier)) keys.delete(key); } publishActionLayer(); }; const onPointerMove = (event: PointerEvent) => { if (!mouseLookDragActive || event.pointerType !== "mouse") return; const current = Number.isFinite(event.clientX) && Number.isFinite(event.clientY) ? { x: event.clientX, y: event.clientY } : null; if (!current) return; const previous = previousMousePosition; previousMousePosition = current; if ( previous && mouseLookBuffer.push(current.x - previous.x, current.y - previous.y) ) actions?.setInputMode("keyboard"); }; const onMouseMove = (event: MouseEvent) => { if ( document.pointerLockElement && mouseLookBuffer.push(event.movementX, event.movementY) ) actions?.setInputMode("keyboard"); }; const onPointerLockChange = () => { const locked = Boolean(document.pointerLockElement); mouseLookBuffer.reset(locked ? POINTER_LOCK_MOUSE_GRACE_MS : 0); nextActions.setCameraLookActive(locked || mouseLookDragActive); }; const onSelectStart = (event: Event) => { if (mouseLookDragActive) event.preventDefault(); }; const clear = () => { keys.clear(); keyboardModifiers.clear(); clearJumpRequest(); mouseLookDragActive = false; previousMousePosition = null; nextActions.setActionLayer("primary"); nextActions.setCameraLookActive(false); mouseLookBuffer.reset(document.pointerLockElement ? POINTER_LOCK_MOUSE_GRACE_MS : 0); }; window.addEventListener("keydown", onKeyDown); window.addEventListener("keyup", onKeyUp); window.addEventListener("blur", clear); document.addEventListener("pointermove", onPointerMove); document.addEventListener("mousemove", onMouseMove); document.addEventListener("pointerlockchange", onPointerLockChange); document.addEventListener("selectstart", onSelectStart); return () => { window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keyup", onKeyUp); window.removeEventListener("blur", clear); document.removeEventListener("pointermove", onPointerMove); document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("pointerlockchange", onPointerLockChange); document.removeEventListener("selectstart", onSelectStart); unsubscribeController(); clear(); actions = null; }; } export function consumeMouseLook(): readonly [number, number] { return mouseLookBuffer.consume(); } export function readInputSnapshot(): InputSnapshot { if (isCapturingInput() || actions?.inputSuspended?.() || (actions?.movementBlocked?.() ?? actions?.gameplayActionsBlocked())) { keys.clear(); return { moveX: 0, moveForward: 0, lookX: 0, lookY: 0, gamepadActive: false }; } const keyboard = keyboardAxesForKeys(keys, bindingsFor("pc")); const controller = getControllerMovement(); const axes = [controller.moveX, controller.moveY, controller.lookX, controller.lookY]; const held = getControllerHeldTokens(); const bindings = bindingsFor("controller"); const remapped = inputAxes((id) => { const parts = bindings[id]?.split("&") ?? []; const token = parts.pop(); if (!token || !parts.every((part) => held.has(part))) return 0; const axis = /^Axis([0-3])([+-])$/.exec(token); return axis ? Math.max(0, axes[Number(axis[1])] * (axis[2] === "+" ? 1 : -1)) : Number(held.has(token)); }); const [stickMoveX, stickForward] = applyRadialDeadzone(remapped.moveX, remapped.moveForward); const [stickLookX, stickLookY] = applyRadialDeadzone(remapped.lookX, remapped.lookY, 0.16); const [moveX, moveForward] = normalizePlanar( keyboard.moveX + stickMoveX, keyboard.moveForward + stickForward, ); const [lookX, lookY] = normalizePlanar( keyboard.lookX + stickLookX, keyboard.lookY + stickLookY, ); const gamepadActive = Math.abs(stickMoveX) + Math.abs(stickForward) + Math.abs(stickLookX) + Math.abs(stickLookY) > 0.04; if (gamepadActive) actions?.setInputMode("gamepad"); return { moveX, moveForward, lookX, lookY, gamepadActive }; } export function releasePointerLock(): void { endMouseLookDrag(); if (typeof document !== "undefined" && document.pointerLockElement) document.exitPointerLock(); }