forked from phenom/healer-man
432 lines
19 KiB
TypeScript
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();
|
|
}
|