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

+10 -1
View File
@@ -1,4 +1,5 @@
import { Capacitor } from "@capacitor/core";
import { controllerModifierTokens } from "./keybindRuntime";
export interface ControllerTokenEvent {
token: string;
@@ -29,6 +30,7 @@ const GAMEPAD_DISCOVERY_INTERVAL_MS = 500;
const REPEATABLE_BUTTON_TOKENS = new Set(["Button12", "Button13", "Button14", "Button15"]);
const listeners = new Set<TokenListener>();
const interceptors = new Set<(event: ControllerTokenEvent) => boolean>();
const movementListeners = new Set<MovementListener>();
const repeatAt = new Map<string, number>();
const lastNativeTokenAt = new Map<string, number>();
@@ -74,7 +76,7 @@ function tokenSetsMatch(left: ReadonlySet<string>, right: ReadonlySet<string>):
}
function isLayerModifier(token: string): boolean {
return token === "Button4" || token === "Button6";
return controllerModifierTokens().has(token);
}
/**
@@ -106,9 +108,16 @@ export function emitControllerToken(event: ControllerTokenEvent): void {
};
if (normalized.pressed) heldTokens.add(normalized.token);
else heldTokens.delete(normalized.token);
for (const intercept of interceptors) if (intercept(normalized)) return;
for (const listener of listeners) listener(normalized);
}
export function getControllerHeldTokens(): ReadonlySet<string> { return heldTokens; }
export function interceptControllerInput(intercept: (event: ControllerTokenEvent) => boolean): () => void {
interceptors.add(intercept);
return () => { interceptors.delete(intercept); };
}
export function subscribeControllerToken(listener: TokenListener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
+21
View File
@@ -0,0 +1,21 @@
import { normalizeInputPreferences, type InputPreferences } from "./keybinds";
// Kept independent of React, account storage and gameplay stores. The profile store
// publishes changes here so shell menus, native input and the simulation agree.
let preferences = normalizeInputPreferences(null);
let capturing = false;
const listeners = new Set<() => void>();
export function getInputPreferences(): InputPreferences { return preferences; }
export function publishInputPreferences(next: InputPreferences): void {
if (preferences === next) return;
preferences = next;
for (const listener of listeners) listener();
}
export function subscribeInputPreferences(listener: () => void): () => void {
listeners.add(listener); return () => { listeners.delete(listener); };
}
export function isCapturingInput(): boolean { return capturing; }
export function setCapturingInput(value: boolean): void { capturing = value; }
export function controllerModifierTokens(): Set<string> {
return new Set(Object.values(preferences.controller).flatMap((key) => key?.split("&").slice(0, -1) ?? []));
}
+165
View File
@@ -0,0 +1,165 @@
import { ACTION_CONTROLS, ACTION_CONTROL_DEFINITIONS, ACTION_LAYERS } from "../game/actionBindings";
export type InputDevice = "pc" | "controller";
export function defaultInputDevice(native: boolean, mobile: boolean): InputDevice { return native || mobile ? "controller" : "pc"; }
export type InputContext = "game" | "ground" | "menu" | "global" | "ability-editor";
export interface InputActionDefinition {
id: string;
label: string;
group: string;
context: InputContext;
pc: string | null;
controller: string | null;
continuous?: boolean;
}
const action = (id: string, label: string, group: string, pc: string | null, controller: string | null,
context: InputContext = "game", continuous = false): InputActionDefinition => ({ id, label, group, pc, controller, context, continuous });
export const INPUT_ACTIONS: readonly InputActionDefinition[] = [
action("move-forward", "Move forward", "Movement & camera", "KeyW", "Axis1-", "game", true),
action("move-backward", "Move backward", "Movement & camera", "KeyS", "Axis1+", "game", true),
action("move-left", "Strafe left", "Movement & camera", "KeyA", "Axis0-", "game", true),
action("move-right", "Strafe right", "Movement & camera", "KeyD", "Axis0+", "game", true),
action("jump", "Jump", "Movement & camera", "Space", "Button10"),
action("look-up", "Look up", "Movement & camera", "ArrowUp", "Axis3-", "game", true),
action("look-down", "Look down", "Movement & camera", "ArrowDown", "Axis3+", "game", true),
action("look-left", "Look left", "Movement & camera", "ArrowLeft", "Axis2-", "game", true),
action("look-right", "Look right", "Movement & camera", "ArrowRight", "Axis2+", "game", true),
action("target-next", "Next enemy", "Targeting & interaction", "Tab", "Button11"),
action("target-previous", "Previous enemy", "Targeting & interaction", "Shift+Tab", null),
action("party-next", "Next party member / self", "Targeting & interaction", null, "Button13"),
action("party-previous", "Previous party member / self", "Targeting & interaction", null, "Button12"),
action("target-clear", "Clear target", "Targeting & interaction", null, null),
// Contextual interaction uses A before its ability only when there is something to use/loot.
action("loot", "Interact / loot", "Targeting & interaction", "KeyE", "Button0"),
action("ground-confirm", "Confirm ground spell", "Ground targeting", "Enter", "Button0", "ground"),
action("ground-cancel", "Cancel ground spell", "Ground targeting", "Escape", "Button1", "ground"),
action("party-attack", "Party: attack", "Party commands", "F1", null),
action("party-defend", "Party: defend", "Party commands", "F2", null),
action("party-stop", "Party: stop", "Party commands", "F3", null),
action("party-recall", "Party: recall", "Party commands", "F4", null),
action("pause", "Pause / resume", "Panels", "Escape", "Button9"),
action("map", "Dungeon map", "Panels", "KeyM", null),
action("inventory", "Inventory & equipment", "Panels", "KeyI", null),
action("spellbook", "Spellbook", "Panels", "KeyP", null),
action("talents", "Talents / skills", "Panels", "KeyN", null),
action("party", "Party management", "Panels", null, null),
action("boss-guide", "Boss guide", "Panels", null, null),
action("bindings", "Ability assignments", "Panels", null, null),
action("options", "Options", "Panels", null, null),
action("keybinds", "Keybinds", "Panels", null, null),
action("ui-layout", "Edit UI & templates", "Panels", null, null),
action("companion", "Companion display", "Panels", "KeyC", "Button8", "global"),
action("menu-up", "Menu: up", "Menu navigation", "ArrowUp", "Button12", "menu"),
action("menu-down", "Menu: down", "Menu navigation", "ArrowDown", "Button13", "menu"),
action("menu-left", "Menu: left", "Menu navigation", "ArrowLeft", "Button14", "menu"),
action("menu-right", "Menu: right", "Menu navigation", "ArrowRight", "Button15", "menu"),
action("menu-confirm", "Menu: confirm", "Menu navigation", "Enter", "Button0", "menu"),
action("menu-back", "Menu: back", "Menu navigation", "Escape", "Button1", "menu"),
action("menu-up-alt", "Menu: up (alternate)", "Menu navigation", "KeyW", "Axis1-", "menu"),
action("menu-down-alt", "Menu: down (alternate)", "Menu navigation", "KeyS", "Axis1+", "menu"),
action("menu-left-alt", "Menu: left (alternate)", "Menu navigation", "KeyA", "Axis0-", "menu"),
action("menu-right-alt", "Menu: right (alternate)", "Menu navigation", "KeyD", "Axis0+", "menu"),
action("menu-confirm-alt", "Menu: confirm (alternate)", "Menu navigation", "Space", null, "menu"),
action("menu-back-alt", "Menu: back (alternate)", "Menu navigation", "Backspace", null, "menu"),
action("editor-layer-2", "Ability editor: preview layer 2", "Ability editor", null, "Button4", "ability-editor"),
action("editor-layer-3", "Ability editor: preview layer 3", "Ability editor", null, "Button6", "ability-editor"),
action("editor-clear", "Ability editor: clear slot", "Ability editor", null, "Button2", "ability-editor"),
action("editor-reset", "Ability editor: restore layer", "Ability editor", null, "Button3", "ability-editor"),
...ACTION_LAYERS.flatMap((layer, index) => ACTION_CONTROLS.map((control) => {
const definition = ACTION_CONTROL_DEFINITIONS[control];
return action(`ability:${layer}:${control}`, `${["Base", "Layer 2", "Layer 3"][index]} · ${definition.label}`,
"Ability slots", `${["", "Shift+", "Alt+"][index]}${definition.keyboardCode}`,
`${["", "Button4&", "Button6&"][index]}${definition.gamepadToken}`);
})),
...Array.from({ length: 60 }, (_, index) => action(`bar:${Math.floor(index / 12)}:${index % 12}`,
`Bar ${Math.floor(index / 12) + 1} · Button ${index % 12 + 1}`, "WoW action bars", null, null)),
];
export const INPUT_ACTION_BY_ID = Object.fromEntries(INPUT_ACTIONS.map((entry) => [entry.id, entry]));
export type InputBindings = Record<string, string | null>;
export interface InputPreferences { pc: InputBindings; controller: InputBindings }
const PC_CODE = /^(?:Key[A-Z]|Digit[0-9]|F(?:[1-9]|1[0-2])|Numpad(?:[0-9]|Add|Subtract|Multiply|Divide|Decimal|Enter)|Arrow(?:Up|Down|Left|Right)|Space|Tab|Enter|Escape|Backspace|Delete|Backquote|Minus|Equal|BracketLeft|BracketRight|Backslash|Semicolon|Quote|Comma|Period|Slash|Insert|Home|End|PageUp|PageDown)$/;
const CONTROLLER_TOKEN = /^(?:Button(?:[0-9]|[12][0-9]|3[01])|Axis[0-3][+-])$/;
export function normalizeInputBinding(device: InputDevice, value: unknown): string | null {
if (typeof value !== "string" || value.length > 100) return null;
if (device === "pc") {
const parts = value.split("+"); const code = parts.pop()!;
if (!PC_CODE.test(code) || parts.some((part) => !["Ctrl", "Alt", "Shift", "Meta"].includes(part))) return null;
return [...["Ctrl", "Alt", "Shift", "Meta"].filter((part) => parts.includes(part)), code].join("+");
}
const parts = value.split("&"); const token = parts.pop()!;
if (!CONTROLLER_TOKEN.test(token) || parts.length > 3 || parts.some((part) => !/^Button(?:[0-9]|[12][0-9]|3[01])$/.test(part) || part === token)) return null;
return [...new Set(parts)].sort().concat(token).join("&");
}
export function inputKeyForEvent(event: Pick<KeyboardEvent, "code" | "ctrlKey" | "altKey" | "shiftKey" | "metaKey">): string | null {
return normalizeInputBinding("pc", [event.ctrlKey && "Ctrl", event.altKey && "Alt", event.shiftKey && "Shift", event.metaKey && "Meta", event.code].filter(Boolean).join("+"));
}
export function defaultInputBindings(device: InputDevice): InputBindings {
return Object.fromEntries(INPUT_ACTIONS.map((entry) => [entry.id, entry[device]]));
}
export function normalizeInputPreferences(value: unknown): InputPreferences {
const source = value && typeof value === "object" ? value as Partial<InputPreferences> : {};
const result = { pc: defaultInputBindings("pc"), controller: defaultInputBindings("controller") };
for (const device of ["pc", "controller"] as const) {
const bindings = source[device];
if (!bindings || typeof bindings !== "object" || Array.isArray(bindings)) continue;
for (const entry of INPUT_ACTIONS) {
if (!Object.hasOwn(bindings, entry.id)) continue;
result[device][entry.id] = bindings[entry.id] === null ? null
: normalizeInputBinding(device, bindings[entry.id]) ?? result[device][entry.id];
}
}
return result;
}
const PAD_LABELS: Record<string, string> = {
Button0: "A / Cross", Button1: "B / Circle", Button2: "X / Square", Button3: "Y / Triangle",
Button4: "LB / L1", Button5: "RB / R1", Button6: "LT / L2", Button7: "RT / R2",
Button8: "Select / View", Button9: "Start / Menu", Button10: "L3", Button11: "R3",
Button12: "D-pad up", Button13: "D-pad down", Button14: "D-pad left", Button15: "D-pad right",
"Axis0-": "Left stick left", "Axis0+": "Left stick right", "Axis1-": "Left stick up", "Axis1+": "Left stick down",
"Axis2-": "Right stick left", "Axis2+": "Right stick right", "Axis3-": "Right stick up", "Axis3+": "Right stick down",
};
export function inputBindingLabel(device: InputDevice, binding: string | null | undefined): string {
if (!binding) return "Unbound";
if (device === "controller") return binding.split("&").map((part) => PAD_LABELS[part] ?? part).join(" + ");
return binding.replace(/Key|Digit/g, "").replace("Minus", "−").replace("Equal", "=").replace("Numpad", "Num ");
}
export const CONTROLLER_BINDING_CHOICES = Object.keys(PAD_LABELS);
/** Ground targeting and menus intentionally reuse gameplay buttons in separate contexts. */
export function bindingContextsOverlap(a: string, b: string, device: InputDevice): boolean {
// Contextual A interaction falls through to its base ability when nothing can be used.
if (device === "controller" && [a, b].includes("loot") && [a, b].includes("ability:primary:face-bottom")) return false;
const left = INPUT_ACTION_BY_ID[a]; const right = INPUT_ACTION_BY_ID[b];
if (!left || !right) return false;
// The PC's legacy ability slots and WoW bars are alternative layouts.
if (device === "pc" && ((a.startsWith("bar:") && b.startsWith("ability:")) || (b.startsWith("bar:") && a.startsWith("ability:")))) return false;
return left.context === right.context || left.context === "global" || right.context === "global"
|| [left.context, right.context].every((context) => context === "menu" || context === "ability-editor");
}
export function conflictingInputActions(bindings: InputBindings, device: InputDevice, id: string, key: string | null): string[] {
if (!key) return [];
const collides = (other: string | null | undefined) => {
if (!other) return false;
if (key === other) return true;
if (device !== "controller") return false;
const left = key.split("&"); const right = other.split("&");
// A standalone command on a chord's modifier would fire before its final
// button (e.g. View would open the companion before View + A could jump).
// Sharing the final button is intentional: the more specific chord wins.
return left.every((token) => right.slice(0, -1).includes(token))
|| right.every((token) => left.slice(0, -1).includes(token));
};
return INPUT_ACTIONS.filter((entry) => entry.id !== id && collides(bindings[entry.id]) && bindingContextsOverlap(id, entry.id, device)).map((entry) => entry.id);
}
export function replaceInputBinding(bindings: InputBindings, device: InputDevice, id: string, key: string | null): InputBindings {
const next = { ...bindings, [id]: key };
for (const conflict of conflictingInputActions(bindings, device, id, key)) next[conflict] = null;
return next;
}
export function controllerBindingMatches(binding: string | null | undefined, token: string, held: ReadonlySet<string>): boolean {
if (!binding) return false;
const parts = binding.split("&");
return parts.pop() === token && parts.every((part) => held.has(part));
}
+8
View File
@@ -0,0 +1,8 @@
import { useSyncExternalStore } from "react";
import { getInputPreferences, subscribeInputPreferences } from "./keybindRuntime";
import { inputBindingLabel, type InputDevice } from "./keybinds";
export function useInputBindingLabels() {
const preferences = useSyncExternalStore(subscribeInputPreferences, getInputPreferences, getInputPreferences);
return (device: InputDevice, id: string) => inputBindingLabel(device, preferences[device][id]);
}
+20 -17
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { CONTROLLER_SYSTEM_BACK_TOKEN, subscribeControllerToken } from "./controller";
import { CONTROLLER_SYSTEM_BACK_TOKEN, getControllerHeldTokens, subscribeControllerToken } from "./controller";
import { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent } from "./keybinds";
import { getInputPreferences, isCapturingInput } from "./keybindRuntime";
export type MenuDirection = "up" | "down" | "left" | "right";
@@ -17,7 +19,8 @@ interface MenuControllerOptions {
}
export function isControllerMenuBackToken(token: string): boolean {
return token === "Button1" || token === CONTROLLER_SYSTEM_BACK_TOKEN;
const bindings = getInputPreferences().controller;
return token === CONTROLLER_SYSTEM_BACK_TOKEN || ["menu-back", "menu-back-alt"].some((id) => controllerBindingMatches(bindings[id], token, getControllerHeldTokens()));
}
function nextIndex(index: number, direction: MenuDirection, count: number, columns: number): number {
@@ -63,14 +66,15 @@ export function useMenuController(actions: MenuAction[], options: MenuController
const interactiveTarget = event.target instanceof Element
? event.target.closest("button, input, select, textarea, a[href], [contenteditable='true'], [role='button'], [role='link']")
: null;
if (event.repeat || interactiveTarget) return;
const key = event.key.toLowerCase();
if (["arrowup", "w"].includes(key)) move("up");
else if (["arrowdown", "s"].includes(key)) move("down");
else if (["arrowleft", "a"].includes(key)) move("left");
else if (["arrowright", "d"].includes(key)) move("right");
else if (key === "enter" || key === " ") confirm();
else if (key === "escape" || key === "backspace") backRef.current?.();
const textTarget = event.target instanceof Element ? event.target.closest("input, select, textarea, [contenteditable='true']") : null;
if (event.repeat || event.defaultPrevented || textTarget || isCapturingInput()) return;
// Keep native activation/focus navigation on focused controls.
if (interactiveTarget && ["Enter", "Space", "Tab", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.code)) return;
const key = inputKeyForEvent(event);
const command = key && INPUT_ACTIONS.find((entry) => entry.context === "menu" && getInputPreferences().pc[entry.id] === key)?.id.replace(/-alt$/, "");
if (["menu-up", "menu-down", "menu-left", "menu-right"].includes(command || "")) move(command!.slice(5) as MenuDirection);
else if (command === "menu-confirm") confirm();
else if (command === "menu-back") backRef.current?.();
else return;
event.preventDefault();
};
@@ -79,14 +83,13 @@ export function useMenuController(actions: MenuAction[], options: MenuController
}, [confirm, move]);
useEffect(() => subscribeControllerToken(({ token, repeat, pressed }) => {
if (pressed === false) return;
const direction: MenuDirection | null = token === "Button12" || token === "Axis1-" ? "up"
: token === "Button13" || token === "Axis1+" ? "down"
: token === "Button14" || token === "Axis0-" ? "left"
: token === "Button15" || token === "Axis0+" ? "right"
: null;
if (pressed === false || isCapturingInput()) return;
const bindings = getInputPreferences().controller;
const command = INPUT_ACTIONS.filter((entry) => entry.context === "menu" && controllerBindingMatches(bindings[entry.id], token, getControllerHeldTokens()))
.sort((a, b) => (bindings[b.id]?.length ?? 0) - (bindings[a.id]?.length ?? 0))[0]?.id.replace(/-alt$/, "");
const direction = ["menu-up", "menu-down", "menu-left", "menu-right"].includes(command) ? command.slice(5) as MenuDirection : null;
if (direction) move(direction);
else if (!repeat && token === "Button0") confirm();
else if (!repeat && command === "menu-confirm") confirm();
else if (!repeat && isControllerMenuBackToken(token)) backRef.current?.();
}), [confirm, move]);