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