Files
healer-man/src/game/inputManager.ts
T
2026-08-14 15:56:39 -04:00

396 lines
15 KiB
TypeScript

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<string>();
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<string>): 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<string>();
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();
}