Files
healer-man/src/game/inputManager.ts
T
2026-09-28 22:46:45 -04:00

432 lines
19 KiB
TypeScript

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<string>();
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<InputSnapshot, "moveX" | "moveForward" | "lookX" | "lookY"> {
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<string>, 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<string>();
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<NonNullable<InputActions["togglePanel"]>>[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();
}