Release Healer Man 0.1.29

This commit is contained in:
phenom committed 2026-09-11 14:26:15 -04:00
1 parent 8c9809d3e0
commit 544c1bee91
120 files changed
+4363 -1036

No files matched your search

+102 -133
View File
@@ -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,