import { applyRadialDeadzone, normalizePlanar } from "./inputMath"; import { CONTROLLER_SYSTEM_BACK_TOKEN, getControllerMovement, subscribeControllerToken, } from "../input/controller"; import type { PartyCommand } from "./partyStore"; import { actionControlForControllerToken, actionControlForKeyboardCode, actionLayerForModifiers, type ActionBindingControl, type ActionBindingLayer, } from "./actionBindings"; export interface InputSnapshot { moveX: number; moveForward: number; lookX: number; lookY: number; gamepadActive: boolean; } export interface InputActions { 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; gameplayActionsBlocked: () => boolean; setCameraLookActive: (active: boolean) => void; setInputMode: (mode: "keyboard" | "gamepad") => void; setActionLayer: (layer: ActionBindingLayer) => void; } const keys = new Set(); let actions: InputActions | 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 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; /** * 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 { 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; } const GAMEPLAY_AXIS_KEYS = new Set([ "KeyW", "KeyA", "KeyS", "KeyD", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", ]); export function keyboardAxesForKeys(activeKeys: ReadonlySet): Pick< InputSnapshot, "moveX" | "moveForward" | "lookX" | "lookY" > { return { moveX: Number(activeKeys.has("KeyD")) - Number(activeKeys.has("KeyA")), moveForward: Number(activeKeys.has("KeyW")) - Number(activeKeys.has("KeyS")), lookX: Number(activeKeys.has("ArrowRight")) - Number(activeKeys.has("ArrowLeft")), lookY: Number(activeKeys.has("ArrowDown")) - Number(activeKeys.has("ArrowUp")), }; } 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" | "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 "target-clear"; 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 === "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 === "target-clear") nextActions.clearTarget(); } export function installInput(nextActions: InputActions): () => void { actions = nextActions; let controllerL1 = false; let controllerL2 = false; const keyboardModifiers = new Set(); const currentActionLayer = () => actionLayerForModifiers( controllerL1 || keyboardModifiers.has("ShiftLeft") || keyboardModifiers.has("ShiftRight"), controllerL2 || keyboardModifiers.has("AltLeft") || keyboardModifiers.has("AltRight"), ); const publishActionLayer = () => nextActions.setActionLayer(currentActionLayer()); const unsubscribeController = subscribeControllerToken(({ token, repeat, pressed }) => { const isPressed = pressed !== false; if (token === "Button4" || token === "Button6") { if (token === "Button4") controllerL1 = isPressed; else controllerL2 = isPressed; nextActions.setInputMode("gamepad"); publishActionLayer(); return; } if (!isPressed) return; nextActions.setInputMode("gamepad"); if (repeat) return; if ( token === "Button0" && !nextActions.gameplayActionsBlocked() && nextActions.lootActiveBoss() ) return; const actionControl = actionControlForControllerToken(token); if (actionControl) { if (nextActions.gameplayActionsBlocked()) return; const layer = currentActionLayer(); nextActions.setActionLayer(layer); nextActions.castActionBinding(layer, actionControl); return; } const command = controllerCommandForToken(token); if (!command) return; // Android's system Back is semantic rather than the Circle face button. // Let the active menu's controller hook own it while a surface is open; // in live gameplay it opens the pause hub. if (token === CONTROLLER_SYSTEM_BACK_TOKEN && nextActions.gameplayActionsBlocked()) return; // Start must remain available in every context so a Thor player can always // reach or leave the pause hub. Menu components own the other buttons while // an overlay is visible, preventing casts from firing behind a panel. if (command === "pause") { runCommand(command, nextActions); return; } if (nextActions.gameplayActionsBlocked()) return; runCommand(command, nextActions); }); const onKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; if (target?.matches("input, textarea, select, [contenteditable='true']")) return; if (["ShiftLeft", "ShiftRight", "AltLeft", "AltRight"].includes(event.code)) { keyboardModifiers.add(event.code); nextActions.setInputMode("keyboard"); publishActionLayer(); if (!nextActions.gameplayActionsBlocked()) event.preventDefault(); return; } if (!nextActions.gameplayActionsBlocked() && GAMEPLAY_AXIS_KEYS.has(event.code)) { event.preventDefault(); keys.add(event.code); actions?.setInputMode("keyboard"); } if (event.repeat) return; const actionControl = actionControlForKeyboardCode(event.code); if (actionControl) { if (nextActions.gameplayActionsBlocked()) return; event.preventDefault(); nextActions.setInputMode("keyboard"); const layer = actionLayerForModifiers(event.shiftKey, event.altKey); nextActions.setActionLayer(layer); nextActions.castActionBinding(layer, actionControl); return; } const command = keyboardCommandForCode(event.code, event.shiftKey); if (!command) return; const partyCommand = command === "party-attack" || command === "party-defend" || command === "party-stop" || command === "party-recall"; // Function keys are browser-reserved on common PC browsers. Keep party // shortcuts inside the game even while a menu temporarily blocks orders. if (partyCommand) event.preventDefault(); const overlayCommand = command === "pause" || command === "map" || command === "inventory" || command === "spellbook" || command === "talents"; if (overlayCommand) { runCommand(command, nextActions); return; } if (nextActions.gameplayActionsBlocked()) return; if (event.code === "Tab") event.preventDefault(); runCommand(command, nextActions); }; const onKeyUp = (event: KeyboardEvent) => { keys.delete(event.code); if (keyboardModifiers.delete(event.code)) publishActionLayer(); }; const onMouseMove = (event: MouseEvent) => { if (mouseLookDragActive) { 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"); return; } 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 clear = () => { keys.clear(); keyboardModifiers.clear(); controllerL1 = false; controllerL2 = false; 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("mousemove", onMouseMove); document.addEventListener("pointerlockchange", onPointerLockChange); return () => { window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keyup", onKeyUp); window.removeEventListener("blur", clear); document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("pointerlockchange", onPointerLockChange); unsubscribeController(); clear(); actions = null; }; } export function consumeMouseLook(): readonly [number, number] { return mouseLookBuffer.consume(); } export function readInputSnapshot(): InputSnapshot { const keyboard = keyboardAxesForKeys(keys); const controller = getControllerMovement(); const [stickMoveX, stickForward] = applyRadialDeadzone(controller.moveX, -controller.moveY); const [stickLookX, stickLookY] = applyRadialDeadzone(controller.lookX, controller.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(); }