forked from phenom/healer-man
Release Healer Man 0.1.29
This commit is contained in:
1 parent
8c9809d3e0
commit
544c1bee91
120 files changed
+4363
-1036
No files matched your search
+102
-133
@@ -2,12 +2,13 @@ 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 {
|
||||
actionControlForControllerToken,
|
||||
actionControlForKeyboardCode,
|
||||
actionLayerForModifiers,
|
||||
type ActionBindingControl,
|
||||
type ActionBindingLayer,
|
||||
@@ -25,6 +26,10 @@ 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;
|
||||
@@ -150,21 +155,19 @@ 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")),
|
||||
};
|
||||
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 =
|
||||
@@ -231,132 +234,88 @@ function runCommand(command: GameplayInputCommand, nextActions: InputActions): v
|
||||
|
||||
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 suspended = () => isCapturingInput() || nextActions.inputSuspended?.();
|
||||
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.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 }) => {
|
||||
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;
|
||||
if (suspended()) return;
|
||||
publishActionLayer();
|
||||
if (pressed === false || repeat) return;
|
||||
nextActions.setInputMode("gamepad");
|
||||
if (repeat) return;
|
||||
if (nextActions.groundTargetActive()) {
|
||||
if (token === "Button0") {
|
||||
nextActions.confirmGroundTarget();
|
||||
return;
|
||||
if (token === CONTROLLER_SYSTEM_BACK_TOKEN) {
|
||||
if (!nextActions.gameplayActionsBlocked()) {
|
||||
if (nextActions.groundTargetActive()) nextActions.cancelGroundTarget(); else nextActions.togglePause();
|
||||
}
|
||||
if (token === "Button1" || token === CONTROLLER_SYSTEM_BACK_TOKEN) {
|
||||
nextActions.cancelGroundTarget();
|
||||
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;
|
||||
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;
|
||||
}
|
||||
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 (event.defaultPrevented || target?.closest?.("input, textarea, select, [contenteditable='true']") || suspended()) return;
|
||||
if (nextActions.handleCustomKey?.(event)) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
keys.delete(event.code);
|
||||
return;
|
||||
event.preventDefault(); event.stopImmediatePropagation(); keys.delete(event.code); 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;
|
||||
if (nextActions.groundTargetActive() && (event.code === "Escape" || event.code === "Enter")) {
|
||||
event.preventDefault();
|
||||
nextActions.setInputMode("keyboard");
|
||||
if (event.code === "Enter") nextActions.confirmGroundTarget();
|
||||
else nextActions.cancelGroundTarget();
|
||||
return;
|
||||
}
|
||||
const actionControl = nextActions.desktopActionBarsActive?.() ? null : 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 (command === "jump") event.preventDefault();
|
||||
if (event.code === "Tab") event.preventDefault();
|
||||
runCommand(command, nextActions);
|
||||
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 (nextActions.gameplayActionsBlocked() && 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 = !nextActions.gameplayActionsBlocked() && 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) => {
|
||||
keys.delete(event.code);
|
||||
if (keyboardModifiers.delete(event.code)) publishActionLayer();
|
||||
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 onMouseMove = (event: MouseEvent) => {
|
||||
if (mouseLookDragActive) {
|
||||
@@ -385,8 +344,6 @@ export function installInput(nextActions: InputActions): () => void {
|
||||
const clear = () => {
|
||||
keys.clear();
|
||||
keyboardModifiers.clear();
|
||||
controllerL1 = false;
|
||||
controllerL2 = false;
|
||||
clearJumpRequest();
|
||||
mouseLookDragActive = false;
|
||||
previousMousePosition = null;
|
||||
@@ -418,11 +375,23 @@ export function consumeMouseLook(): readonly [number, number] {
|
||||
}
|
||||
|
||||
export function readInputSnapshot(): InputSnapshot {
|
||||
const keyboard = keyboardAxesForKeys(keys);
|
||||
if (isCapturingInput() || actions?.inputSuspended?.() || 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 [stickMoveX, stickForward] = applyRadialDeadzone(controller.moveX, -controller.moveY);
|
||||
const [stickLookX, stickLookY] = applyRadialDeadzone(controller.lookX, controller.lookY, 0.16);
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user