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
+10
-1
@@ -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);
|
||||
|
||||
@@ -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) ?? []));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user