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

+217
View File
@@ -0,0 +1,217 @@
import { useEffect, useRef, useState } from "react";
import { Capacitor } from "@capacitor/core";
import { useShellStore } from "../app/shellStore";
import { retryCloudSave, useCloudSaveStatus } from "../app/onlineAccountClient";
import { useCombatStore } from "../game/combatStore";
import { useGameStore } from "../game/store";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { defaultUiLayout } from "../game/uiLayout";
import { profileInputBindings, restoreMovementBindings, withProfileInputBindings } from "../game/keybindPreferences";
import {
CONTROLLER_BINDING_CHOICES, INPUT_ACTIONS, INPUT_ACTION_BY_ID, conflictingInputActions,
defaultInputDevice, defaultInputBindings, inputBindingLabel, inputKeyForEvent, normalizeInputBinding, replaceInputBinding, type InputDevice,
} from "../input/keybinds";
import { getControllerHeldTokens, interceptControllerInput, subscribeControllerMovement } from "../input/controller";
import { setCapturingInput } from "../input/keybindRuntime";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import "./keybinds.css";
const PC_KEYS = [
...Array.from({ length: 26 }, (_, i) => `Key${String.fromCharCode(65 + i)}`),
...Array.from({ length: 10 }, (_, i) => `Digit${i}`),
...Array.from({ length: 12 }, (_, i) => `F${i + 1}`),
"Space", "Tab", "Enter", "Escape", "Backspace", "Delete", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
"Backquote", "Minus", "Equal", "BracketLeft", "BracketRight", "Backslash", "Semicolon", "Quote", "Comma", "Period", "Slash",
"Insert", "Home", "End", "PageUp", "PageDown", ...Array.from({ length: 10 }, (_, i) => `Numpad${i}`),
"NumpadAdd", "NumpadSubtract", "NumpadMultiply", "NumpadDivide", "NumpadDecimal", "NumpadEnter",
];
function KeybindsDialog() {
const [device, setDevice] = useState<InputDevice>(() => defaultInputDevice(Capacitor.isNativePlatform(), currentUiDevice() === "mobile"));
const [draft, setDraft] = useState(() => useUiLayoutStore.getState().preferences);
const [search, setSearch] = useState("");
const [group, setGroup] = useState("All actions");
const [conflictsOnly, setConflictsOnly] = useState(false);
const [editing, setEditing] = useState<string | null>(null);
const [listening, setListening] = useState(false);
const [pending, setPending] = useState<{ id: string; key: string; conflicts: string[] } | null>(null);
const [resetPending, setResetPending] = useState(false);
const [message, setMessage] = useState("");
const [manualKey, setManualKey] = useState("KeyW");
const [modifiers, setModifiers] = useState<string[]>([]);
const [controllerModifier, setControllerModifier] = useState("");
const dialog = useRef<HTMLElement>(null);
const cloud = useCloudSaveStatus();
const online = useShellStore((state) => Boolean(state.session?.accessToken));
const abilities = useCombatStore((state) => state.abilities);
const assignments = useCombatStore((state) => state.actionBindings);
const bindings = profileInputBindings(draft, device);
const conflicts = INPUT_ACTIONS.filter((entry) => conflictingInputActions(bindings, device, entry.id, bindings[entry.id]).length > 0);
const detail = (id: string): string => {
const abilityId = id.startsWith("ability:") ? assignments[id.slice(8) as keyof typeof assignments]
: id.startsWith("bar:") ? draft.current.pc.bars[Number(id.split(":")[1])]?.slots[Number(id.split(":")[2])]?.abilityId : null;
if (id.startsWith("ability:") || id.startsWith("bar:")) return abilities.find((ability) => ability.id === abilityId)?.name ?? "Empty ability slot";
if (id === "loot" && device === "controller") return "When interaction is available, it takes priority over the base Cross ability.";
return "";
};
const shown = INPUT_ACTIONS.filter((entry) => (group === "All actions" || entry.group === group)
&& (!conflictsOnly || conflicts.some((conflict) => conflict.id === entry.id))
&& `${entry.label} ${detail(entry.id)} ${inputBindingLabel(device, bindings[entry.id])}`.toLowerCase().includes(search.toLowerCase()));
const close = () => useGameStore.getState().openOverlay("options");
const endCapture = () => { setListening(false); setCapturingInput(false); };
const cancelEdit = () => { endCapture(); setEditing(null); setPending(null); setResetPending(false); };
const apply = (id: string, key: string | null) => {
setDraft((current) => withProfileInputBindings(current, device, replaceInputBinding(profileInputBindings(current, device), device, id, key)));
setMessage(`${INPUT_ACTION_BY_ID[id].label}: ${inputBindingLabel(device, key)}. Save changes to keep it.`);
cancelEdit();
};
const offer = (key: string) => {
if (!editing) return;
endCapture();
const collisions = conflictingInputActions(bindings, device, editing, key);
if (collisions.length) setPending({ id: editing, key, conflicts: collisions });
else apply(editing, key);
};
const startCapture = () => { setCapturingInput(true); setListening(true); };
const editBinding = (id: string) => {
setEditing(id); setManualKey(device === "pc" ? "KeyW" : "Button0"); setModifiers([]); setControllerModifier("");
};
const save = () => {
try { useUiLayoutStore.getState().persist(draft); setMessage("Keybinds saved to your profile."); }
catch (error) { setMessage(error instanceof Error ? error.message : "Could not save keybinds."); }
};
const restoreMovement = () => {
setDraft((current) => restoreMovementBindings(current, device));
setMessage("Movement and jump restored. Conflicting bindings were cleared. Save changes to keep this repair.");
};
const restoreDefaults = () => {
let defaults = defaultInputBindings(device);
if (device === "pc") defaultUiLayout().bars.forEach((bar, b) => bar.slots.forEach((slot, s) => { defaults[`bar:${b}:${s}`] = slot.key; }));
setDraft((current) => withProfileInputBindings(current, device, defaults));
cancelEdit(); setMessage(`${device === "pc" ? "PC" : "Controller"} defaults restored. Save changes to keep them.`);
};
const actions: MenuAction[] = pending ? [
{ id: "keep", run: cancelEdit }, { id: "replace", run: () => apply(pending.id, pending.key) },
] : resetPending ? [{ id: "keep", run: cancelEdit }, { id: "reset-confirm", run: restoreDefaults }]
: editing ? [{ id: "capture", run: startCapture }, { id: "clear", run: () => apply(editing, null) }, { id: "keep", run: cancelEdit }]
: [
{ id: "tab-pc", run: () => setDevice("pc") }, { id: "tab-controller", run: () => setDevice("controller") },
{ id: "movement", run: restoreMovement }, { id: "conflicts", run: () => setConflictsOnly(!conflictsOnly) },
...shown.map((entry) => ({ id: `edit-${entry.id}`, run: () => editBinding(entry.id) })),
{ id: "reset", run: () => setResetPending(true) }, { id: "close", run: close }, { id: "save", run: save },
];
const controller = useMenuController(actions, { onBack: () => editing || resetPending ? cancelEdit() : close() });
const button = (id: string, label: string, run: () => void, className = "") => <ControllerButton
key={id} controlId={id} selectedId={controller.selectedId} select={controller.select} data-control-id={id}
type="button" aria-label={label} className={`button ${className}`} onClick={run}>{className.includes("keybinds-change") ? "Change" : label}</ControllerButton>;
useEffect(() => {
const selected = [...(dialog.current?.querySelectorAll<HTMLElement>("[data-control-id]") ?? [])].find((element) => element.dataset.controlId === controller.selectedId);
selected?.scrollIntoView({ block: "nearest" });
}, [controller.selectedId]);
useEffect(() => () => setCapturingInput(false), []);
useEffect(() => {
if (!listening || !editing) return;
let captured = false;
const ignored = new Set(getControllerHeldTokens());
const accept = (key: string | null) => { if (!key || captured) return; captured = true; offer(key); };
const onKey = (event: KeyboardEvent) => {
event.preventDefault(); event.stopImmediatePropagation();
if (event.repeat) return;
if (event.code === "Escape") { endCapture(); return; }
if (device === "pc") accept(inputKeyForEvent(event));
};
window.addEventListener("keydown", onKey, true);
const releaseController = interceptControllerInput(({ token, pressed, repeat }) => {
if (pressed === false) {
const wasIgnored = ignored.delete(token);
if (!wasIgnored && device === "controller" && !captured && ["Button4", "Button6"].includes(token)) accept(token);
return true;
}
if (repeat || ignored.has(token)) return true;
if (token === "SystemBack") { endCapture(); return true; }
if (device !== "controller" || ["Button4", "Button6"].includes(token)) return true;
const held = [...getControllerHeldTokens()].filter((part) => part !== token && !ignored.has(part) && part.startsWith("Button"));
accept(normalizeInputBinding("controller", [...held, token].join("&")));
return true;
});
const releaseMotion = subscribeControllerMovement((motion) => {
if (device !== "controller") return;
const axes = [motion.moveX, motion.moveY, motion.lookX, motion.lookY];
const index = axes.findIndex((axis) => Math.abs(axis) > .65);
if (index >= 0) accept(`Axis${index}${axes[index] > 0 ? "+" : "-"}`);
});
return () => { window.removeEventListener("keydown", onKey, true); releaseController(); releaseMotion(); };
}, [listening, editing, device]);
useEffect(() => {
if (listening) return;
const onKey = (event: KeyboardEvent) => {
if (event.code !== "Escape") return;
event.preventDefault(); event.stopImmediatePropagation();
if (editing || resetPending) cancelEdit(); else close();
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [listening, editing, resetPending]);
const manualBinding = device === "pc" ? normalizeInputBinding(device, [...modifiers, manualKey].join("+"))
: normalizeInputBinding(device, [controllerModifier, manualKey].filter(Boolean).join("&"));
return <div className="modal-backdrop game-panel-backdrop keybinds-backdrop">
<section ref={dialog} className="game-panel keybinds-panel" role="dialog" aria-modal="true" aria-labelledby="keybinds-title">
<header className="game-panel__header"><div><p className="eyebrow">Your controls</p><h2 id="keybinds-title">Keybinds</h2></div>
<button type="button" className="icon-button" onClick={close} aria-label="Close keybinds">×</button></header>
<div className="keybinds-toolbar" inert={editing !== null || resetPending}>
<div className="keybinds-tabs" role="tablist" aria-label="Input type">{(["pc", "controller"] as const).map((tab) => <ControllerButton
key={tab} controlId={`tab-${tab}`} selectedId={controller.selectedId} select={controller.select} type="button" role="tab"
aria-selected={device === tab} onClick={() => setDevice(tab)}>{tab === "pc" ? "PC" : "Controller"}</ControllerButton>)}</div>
<p>Both input types work on every device. Changes apply when saved.</p>
<div className="keybinds-filters"><label>Search actions or keys<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Try W, jump, or a skill name" /></label>
<label>Category<select value={group} onChange={(event) => setGroup(event.target.value)}>{["All actions", ...new Set(INPUT_ACTIONS.map((entry) => entry.group))].map((value) => <option key={value}>{value}</option>)}</select></label></div>
<div className="keybinds-tools">{button("movement", "Restore movement", restoreMovement)}
{button("conflicts", `${conflictsOnly ? "Show all actions" : "Show conflicts"} (${conflicts.length})`, () => setConflictsOnly(!conflictsOnly))}
<small>{shown.length} actions shown</small></div>
</div>
<div className="keybinds-list" inert={editing !== null || resetPending}>
{conflicts.length > 0 && <p className="keybinds-warning">Conflicting controls need attention. Movement takes priority over old conflicting ability bindings.</p>}
{shown.map((entry, index) => <div key={entry.id}>
{(index === 0 || shown[index - 1].group !== entry.group) && <h3>{entry.group}</h3>}
<div className={`keybinds-row ${conflicts.some((conflict) => conflict.id === entry.id) ? "has-conflict" : ""}`}>
<span><strong>{entry.label}</strong>{detail(entry.id) && <small>{detail(entry.id)}</small>}
{entry.group === "WoW action bars" && <small>{draft.current.pc.wowBars && Number(entry.id.split(":")[1]) < draft.current.pc.barCount ? "PC WoW layout" : "Inactive until this PC bar is enabled"}</small>}
{conflicts.some((conflict) => conflict.id === entry.id) && <small className="keybinds-warning">Also used by {conflictingInputActions(bindings, device, entry.id, bindings[entry.id]).map((id) => INPUT_ACTION_BY_ID[id].label).join(", ")}</small>}</span>
<kbd>{inputBindingLabel(device, bindings[entry.id])}</kbd>
{button(`edit-${entry.id}`, `Change ${entry.label}`, () => editBinding(entry.id), "keybinds-change")}
</div>
</div>)}
{!shown.length && <p>No matching actions.</p>}
<p className="keybinds-note">Point and click / tap selects a target. Hold right mouse and drag to turn the camera. Android system Back remains available for navigation. Ability slots cast the skills assigned in Ability controls; WoW bar keys follow the loaded PC UI template.</p>
</div>
{(editing || resetPending) && <div className="keybinds-capture" role="group" aria-label="Change binding">
{pending ? <><h3>Replace conflicting bindings?</h3><p><kbd>{inputBindingLabel(device, pending.key)}</kbd> is already assigned to {pending.conflicts.map((id) => INPUT_ACTION_BY_ID[id].label).join(", ")}.</p>
<p>Replacing will unbind those actions and assign it to {INPUT_ACTION_BY_ID[pending.id].label}.</p>
<div>{button("keep", "Keep existing bindings", cancelEdit)}{button("replace", "Replace bindings", () => apply(pending.id, pending.key))}</div></>
: resetPending ? <><h3>Restore {device === "pc" ? "PC" : "Controller"} defaults?</h3><p>This resets this input type, including its action-bar keys. Assigned skills stay in place.</p>
{button("keep", "Keep my bindings", cancelEdit)}{button("reset-confirm", "Restore defaults", restoreDefaults)}</>
: editing && <><h3>{INPUT_ACTION_BY_ID[editing].label}</h3><p>Current: {inputBindingLabel(device, bindings[editing])}</p>
{listening ? <><p role="status">{device === "pc" ? "Press a key or modifier + key…" : "Press a controller button, hold L1/L2 and press a button, or move a stick…"}</p><p>Escape cancels capture. Use the selectors below to bind Escape or any controller button.</p><button type="button" onClick={endCapture}>Stop listening</button></>
: button("capture", device === "pc" ? "Listen for key" : "Listen for controller", startCapture)}
<div className="keybinds-manual"><p>Or choose a binding using touch or mouse:</p>
{device === "pc" ? <div className="keybinds-modifiers">{["Ctrl", "Alt", "Shift", "Meta"].map((modifier) => <label key={modifier}><input type="checkbox" checked={modifiers.includes(modifier)} onChange={(event) => setModifiers((current) => event.target.checked ? [...current, modifier] : current.filter((part) => part !== modifier))} />{modifier}</label>)}</div>
: <label>Hold modifier<select value={controllerModifier} onChange={(event) => setControllerModifier(event.target.value)}><option value="">None</option>{CONTROLLER_BINDING_CHOICES.filter((key) => key.startsWith("Button")).map((key) => <option key={key} value={key}>{inputBindingLabel(device, key)}</option>)}</select></label>}
<label>{device === "pc" ? "Key" : "Button or stick direction"}<select value={manualKey} onChange={(event) => setManualKey(event.target.value)}>{(device === "pc" ? PC_KEYS : CONTROLLER_BINDING_CHOICES).map((key) => <option key={key} value={key}>{inputBindingLabel(device, key)}</option>)}</select></label>
<button type="button" disabled={!manualBinding} onClick={() => manualBinding && offer(manualBinding)}>Use selected binding</button>
</div><div>{button("clear", "Clear binding", () => apply(editing, null))}{button("keep", "Cancel binding change", cancelEdit)}</div></>}
</div>}
<footer className="keybinds-footer" inert={editing !== null || resetPending}>
<p role="status">{message || (online ? "Saved controls sync with your account and are included in UI exports." : "Offline controls save to this profile. UI export/import transfers them to another device.")}</p>
{online && <p role="status">{cloud.message}{cloud.state === "error" && <button type="button" onClick={retryCloudSave}>Retry cloud save</button>}</p>}
<div>{button("reset", `Reset ${device === "pc" ? "PC" : "Controller"} defaults`, () => setResetPending(true))}
{button("close", "Done", close)}{button("save", "Save changes", save, "button--primary")}</div>
</footer>
</section>
</div>;
}
export function KeybindsPanel() {
const open = useGameStore((state) => state.overlay === "keybinds");
return open ? <KeybindsDialog /> : null;
}