Files
healer-man/src/ui/AbilityBindingsPanel.tsx
T
2026-09-11 14:26:15 -04:00

398 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { classById } from "../app/characterCatalog";
import {
ACTION_CONTROLS,
ACTION_CONTROL_DEFINITIONS,
ACTION_LAYERS,
ACTION_LAYER_LABELS,
actionBindingId,
type ActionBindingControl,
type ActionBindingLayer,
} from "../game/actionBindings";
import { useCombatStore } from "../game/combatStore";
import { useGameStore } from "../game/store";
import { getControllerHeldTokens, subscribeControllerToken } from "../input/controller";
import { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent } from "../input/keybinds";
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
function AbilityBindingIcon({ icon, sigil, color }: { icon?: string; sigil: string; color: string }) {
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [icon]);
return (
<span className="ability-bindings__icon" style={{ "--ability-color": color } as CSSProperties} aria-hidden="true">
{icon && !failed ? <img src={icon} alt="" onError={() => setFailed(true)} /> : sigil}
</span>
);
}
type RestoreScope = "layer" | "all";
interface RestoreRequest {
scope: RestoreScope;
layer: ActionBindingLayer;
cancelFocusId: string;
completeFocusId: string;
}
const layerControlId = (layer: ActionBindingLayer) => `binding-layer-${layer}`;
const slotControlId = (control: ActionBindingControl) => `binding-slot-${control}`;
const choiceControlId = (abilityId: string) => `binding-choice-${abilityId}`;
const MAIN_ACTION_NEIGHBORS: Readonly<Record<string, NonNullable<MenuAction["neighbors"]>>> = {
"binding-layer-primary": {
up: "binding-restore-layer", down: "binding-slot-dpad-left", left: "binding-layer-tertiary", right: "binding-layer-secondary",
},
"binding-layer-secondary": {
up: "binding-restore-layer", down: "binding-slot-r1", left: "binding-layer-primary", right: "binding-layer-tertiary",
},
"binding-layer-tertiary": {
up: "binding-restore-all", down: "binding-slot-r2", left: "binding-layer-secondary", right: "binding-layer-primary",
},
"binding-slot-r1": {
up: "binding-layer-secondary", down: "binding-slot-face-top", left: "binding-slot-dpad-right", right: "binding-slot-r2",
},
"binding-slot-r2": {
up: "binding-layer-tertiary", down: "binding-slot-face-right", left: "binding-slot-r1", right: "binding-slot-dpad-left",
},
"binding-slot-dpad-left": {
up: "binding-layer-primary", down: "binding-restore-layer", left: "binding-slot-face-right", right: "binding-slot-dpad-right",
},
"binding-slot-dpad-right": {
up: "binding-slot-r1", down: "binding-restore-layer", left: "binding-slot-dpad-left", right: "binding-slot-face-left",
},
"binding-slot-face-top": {
up: "binding-slot-r1", down: "binding-slot-face-bottom", left: "binding-slot-face-left", right: "binding-slot-face-right",
},
"binding-slot-face-right": {
up: "binding-slot-face-top", down: "binding-slot-face-bottom", left: "binding-slot-face-left", right: "binding-slot-dpad-left",
},
"binding-slot-face-bottom": {
up: "binding-slot-face-top", down: "binding-restore-all", left: "binding-slot-face-left", right: "binding-slot-face-right",
},
"binding-slot-face-left": {
up: "binding-slot-face-top", down: "binding-slot-face-bottom", left: "binding-slot-dpad-right", right: "binding-slot-face-right",
},
"binding-restore-layer": {
up: "binding-slot-dpad-left", down: "binding-layer-primary", left: "binding-restore-all", right: "binding-restore-all",
},
"binding-restore-all": {
up: "binding-slot-face-bottom", down: "binding-layer-tertiary", left: "binding-restore-layer", right: "binding-restore-layer",
},
};
function AbilityBindingsDialog() {
const keyLabel = useInputBindingLabels();
const bindingChord = (layer: ActionBindingLayer, control: ActionBindingControl) => `${keyLabel("controller", `ability:${layer}:${control}`)} / ${keyLabel("pc", `ability:${layer}:${control}`)}`;
const openOverlay = useGameStore((state) => state.openOverlay);
const classId = useCombatStore((state) => state.classId);
const level = useCombatStore((state) => state.level);
const abilities = useCombatStore((state) => state.abilities);
const bindings = useCombatStore((state) => state.actionBindings);
const [layer, setLayer] = useState<ActionBindingLayer>("primary");
const [editingControl, setEditingControl] = useState<ActionBindingControl | null>(null);
const [restoreRequest, setRestoreRequest] = useState<RestoreRequest | null>(null);
const [notice, setNotice] = useState("Choose a button, then choose the ability it should use.");
const classColor = classById(classId).color;
const editingAbilityId = editingControl ? bindings[actionBindingId(layer, editingControl)] : null;
const pickerAbilities = useMemo(() => {
const ordered = abilities
.filter((ability) => !ability.passive)
.sort((left, right) => left.unlockLevel - right.unlockLevel);
return editingAbilityId
? [
...ordered.filter((ability) => ability.id === editingAbilityId),
...ordered.filter((ability) => ability.id !== editingAbilityId),
]
: ordered;
}, [abilities, editingAbilityId]);
const close = () => openOverlay("pause");
const chooseLayer = (next: ActionBindingLayer) => {
setLayer(next);
setEditingControl(null);
setRestoreRequest(null);
setNotice(`${ACTION_LAYER_LABELS[next]} layer selected.`);
controller.select(layerControlId(next));
};
const beginEditing = (control: ActionBindingControl) => {
const abilityId = bindings[actionBindingId(layer, control)];
setEditingControl(control);
setRestoreRequest(null);
controller.select(abilityId ? choiceControlId(abilityId) : "binding-choice-empty");
};
const cancelEditing = () => {
if (!editingControl) return;
const control = editingControl;
setEditingControl(null);
controller.select(slotControlId(control));
};
const assign = (abilityId: string | null) => {
if (!editingControl) return;
const control = editingControl;
const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
useCombatStore.getState().setActionBinding(layer, control, abilityId);
setNotice(`${bindingChord(layer, control)}: ${ability?.name ?? "Empty"}.`);
setEditingControl(null);
controller.select(slotControlId(control));
};
const clearSelected = (control: ActionBindingControl | null = editingControl) => {
if (!control) return;
useCombatStore.getState().setActionBinding(layer, control, null);
setNotice(`${bindingChord(layer, control)} cleared.`);
setEditingControl(null);
controller.select(slotControlId(control));
};
const requestRestore = (scope: RestoreScope, focusId: string) => {
setRestoreRequest({
scope,
layer,
cancelFocusId: focusId,
completeFocusId: editingControl ? slotControlId(editingControl) : focusId,
});
controller.select("binding-confirm-cancel");
};
const cancelRestore = () => {
if (!restoreRequest) return;
const focusId = restoreRequest.cancelFocusId;
setRestoreRequest(null);
controller.select(focusId);
};
const confirmRestore = () => {
if (!restoreRequest) return;
const request = restoreRequest;
if (request.scope === "layer") {
useCombatStore.getState().resetActionBindingLayer(request.layer);
setNotice(`${ACTION_LAYER_LABELS[request.layer]} layer restored.`);
} else {
useCombatStore.getState().resetActionBindings();
setNotice("All controller ability bindings restored.");
}
setEditingControl(null);
setRestoreRequest(null);
controller.select(request.completeFocusId);
};
const actions = useMemo<MenuAction[]>(() => {
if (restoreRequest) {
return [
{
id: "binding-confirm-cancel",
run: cancelRestore,
neighbors: { left: "binding-confirm-restore", right: "binding-confirm-restore" },
},
{
id: "binding-confirm-restore",
run: confirmRestore,
neighbors: { left: "binding-confirm-cancel", right: "binding-confirm-cancel" },
},
];
}
if (editingControl) {
return [
...pickerAbilities.map((ability) => ({ id: choiceControlId(ability.id), run: () => assign(ability.id) })),
{ id: "binding-choice-empty", run: () => assign(null) },
];
}
return [
...ACTION_LAYERS.map((nextLayer) => {
const id = layerControlId(nextLayer);
return { id, run: () => chooseLayer(nextLayer), neighbors: MAIN_ACTION_NEIGHBORS[id] };
}),
...ACTION_CONTROLS.map((control) => {
const id = slotControlId(control);
return { id, run: () => beginEditing(control), neighbors: MAIN_ACTION_NEIGHBORS[id] };
}),
{
id: "binding-restore-layer",
run: () => requestRestore("layer", "binding-restore-layer"),
neighbors: MAIN_ACTION_NEIGHBORS["binding-restore-layer"],
},
{
id: "binding-restore-all",
run: () => requestRestore("all", "binding-restore-all"),
neighbors: MAIN_ACTION_NEIGHBORS["binding-restore-all"],
},
];
}, [abilities, bindings, editingControl, layer, pickerAbilities, restoreRequest]);
const controller = useMenuController(actions, {
columns: restoreRequest ? 2 : editingControl ? 1 : 3,
initialId: restoreRequest
? "binding-confirm-cancel"
: editingControl
? editingAbilityId ? choiceControlId(editingAbilityId) : "binding-choice-empty"
: layerControlId(layer),
onBack: restoreRequest ? cancelRestore : editingControl ? cancelEditing : close,
});
useEffect(() => {
const run = (id: string | undefined) => {
if (restoreRequest) return;
if (id === "editor-layer-2") chooseLayer("secondary");
else if (id === "editor-layer-3") chooseLayer("tertiary");
else if (id === "editor-clear") {
if (editingControl) clearSelected(editingControl);
else {
const prefix = "binding-slot-";
if (controller.selectedId.startsWith(prefix)) clearSelected(controller.selectedId.slice(prefix.length) as ActionBindingControl);
}
} else if (id === "editor-reset") requestRestore("layer", controller.selectedId);
};
const unsubscribe = subscribeControllerToken(({ token, repeat, pressed }) => {
if (pressed === false || repeat || isCapturingInput()) return;
run(INPUT_ACTIONS.find((entry) => entry.context === "ability-editor" && controllerBindingMatches(getInputPreferences().controller[entry.id], token, getControllerHeldTokens()))?.id);
});
const onKey = (event: KeyboardEvent) => {
if (event.repeat || event.defaultPrevented || isCapturingInput() || (event.target instanceof Element && event.target.closest("input, textarea, select, [contenteditable='true']"))) return;
const key = inputKeyForEvent(event);
const id = key && INPUT_ACTIONS.find((entry) => entry.context === "ability-editor" && getInputPreferences().pc[entry.id] === key)?.id;
if (id) { event.preventDefault(); run(id); }
};
window.addEventListener("keydown", onKey);
return () => { unsubscribe(); window.removeEventListener("keydown", onKey); };
}, [controller.selectedId, editingControl, layer, restoreRequest]);
const bindingSlot = (control: ActionBindingControl) => {
const definition = ACTION_CONTROL_DEFINITIONS[control];
const abilityId = bindings[actionBindingId(layer, control)];
const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
const locked = Boolean(ability && ability.unlockLevel > level);
return (
<ControllerButton
key={control}
controlId={slotControlId(control)}
selectedId={controller.selectedId}
select={controller.select}
type="button"
className={`ability-binding-slot ability-binding-slot--${control} ${editingControl === control ? "is-editing" : ""} ${locked ? "is-locked" : ""}`}
aria-label={`${bindingChord(layer, control)}: ${ability?.name ?? "Empty"}${locked ? `, unlocks at level ${ability!.unlockLevel}` : ""}`}
aria-pressed={editingControl === control}
onClick={() => beginEditing(control)}
>
<kbd>{keyLabel("controller", `ability:${layer}:${control}`)}</kbd>
{ability
? <AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} />
: <span className="ability-bindings__empty" aria-hidden="true">+</span>}
<span><strong>{definition.label}</strong><small>{locked ? `${ability!.name} · Level ${ability!.unlockLevel}` : ability?.name ?? "Empty"}</small></span>
</ControllerButton>
);
};
return (
<div className="modal-backdrop game-panel-backdrop" role="presentation">
<section className="game-panel ability-bindings-panel" role="dialog" aria-modal="true" aria-labelledby="ability-bindings-title">
<header className="game-panel__header">
<div>
<p className="eyebrow">Controller loadout · 24 customizable slots</p>
<h2 id="ability-bindings-title">Ability controls</h2>
</div>
<button type="button" className="icon-button" onClick={close} aria-label="Return to pause menu">×</button>
</header>
<nav className="ability-bindings__layers" aria-label="Ability binding layers">
{ACTION_LAYERS.map((nextLayer) => (
<ControllerButton
key={nextLayer}
controlId={layerControlId(nextLayer)}
selectedId={controller.selectedId}
select={controller.select}
type="button"
className={layer === nextLayer ? "is-active" : ""}
aria-pressed={layer === nextLayer}
onClick={() => chooseLayer(nextLayer)}
>
<kbd>{nextLayer === "primary" ? "—" : ACTION_LAYER_LABELS[nextLayer]}</kbd>
<span><strong>{ACTION_LAYER_LABELS[nextLayer]}</strong><small>{nextLayer === "primary" ? "No modifier" : `Hold ${ACTION_LAYER_LABELS[nextLayer]}`}</small></span>
</ControllerButton>
))}
</nav>
<div className="ability-bindings__layout">
<section className="ability-bindings__diagram" aria-label={`${ACTION_LAYER_LABELS[layer]} controller layout`}>
<div className="ability-bindings__shoulders">
{bindingSlot("r1")}
{bindingSlot("r2")}
</div>
<div className="ability-bindings__dpad" aria-label="D-pad abilities">
{bindingSlot("dpad-left")}
<span aria-hidden="true">+</span>
{bindingSlot("dpad-right")}
</div>
<div className="ability-bindings__face" aria-label="Face button abilities">
{bindingSlot("face-top")}
{bindingSlot("face-right")}
{bindingSlot("face-bottom")}
{bindingSlot("face-left")}
</div>
<div className="ability-bindings__restore">
<ControllerButton controlId="binding-restore-layer" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => requestRestore("layer", "binding-restore-layer")}>Restore {ACTION_LAYER_LABELS[layer]}</ControllerButton>
<ControllerButton controlId="binding-restore-all" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => requestRestore("all", "binding-restore-all")}>Restore all</ControllerButton>
</div>
</section>
<aside className="ability-bindings__picker" aria-live="polite">
{restoreRequest ? (
<>
<p className="eyebrow">Confirm controller reset</p>
<h3>{restoreRequest.scope === "all"
? "Restore all ability controls?"
: `Restore the ${ACTION_LAYER_LABELS[restoreRequest.layer]} layer?`}</h3>
<p className="ability-bindings__confirm-warning">{restoreRequest.scope === "all"
? "This replaces every customized controller slot with the class defaults."
: `This replaces all eight ${ACTION_LAYER_LABELS[restoreRequest.layer]} slots with their class defaults.`}</p>
<div className="ability-bindings__choices ability-bindings__choices--confirm">
<ControllerButton controlId="binding-confirm-cancel" selectedId={controller.selectedId} select={controller.select} type="button" onClick={cancelRestore}>
<span className="ability-bindings__empty" aria-hidden="true">○</span>
<span><strong>Cancel</strong><small>Keep current bindings</small></span>
</ControllerButton>
<ControllerButton controlId="binding-confirm-restore" selectedId={controller.selectedId} select={controller.select} type="button" className="is-danger" onClick={confirmRestore}>
<span className="ability-bindings__empty" aria-hidden="true">△</span>
<span><strong>Restore defaults</strong><small>This cannot be undone</small></span>
</ControllerButton>
</div>
</>
) : editingControl ? (
<>
<p className="eyebrow">Assign {bindingChord(layer, editingControl)}</p>
<h3>Choose an ability</h3>
<div className="ability-bindings__choices">
{pickerAbilities.map((ability) => (
<ControllerButton key={ability.id} controlId={choiceControlId(ability.id)} selectedId={controller.selectedId} select={controller.select} type="button" className={`${ability.id === editingAbilityId ? "is-current" : ""} ${ability.unlockLevel > level ? "is-locked" : ""}`} onClick={() => assign(ability.id)}>
<AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} />
<span><strong>{ability.name}</strong><small>{ability.unlockLevel > level ? `Unlocks at level ${ability.unlockLevel}` : ability.id === editingAbilityId ? "Current assignment" : ability.target === "friendly" ? "Friendly / self" : ability.target}</small></span>
</ControllerButton>
))}
<ControllerButton controlId="binding-choice-empty" selectedId={controller.selectedId} select={controller.select} type="button" className={editingAbilityId === null ? "is-current" : ""} onClick={() => assign(null)}>
<span className="ability-bindings__empty" aria-hidden="true">×</span>
<span><strong>Leave empty</strong><small>{editingAbilityId === null ? "Current assignment" : "Remove the current assignment"}</small></span>
</ControllerButton>
</div>
</>
) : (
<>
<p className="eyebrow">{ACTION_LAYER_LABELS[layer]} layer</p>
<h3>Select a controller button</h3>
<p>Each layer has the four face buttons, R1, R2, and both horizontal D-pad directions. Abilities may be reused on more than one button.</p>
<p className="ability-bindings__notice" role="status">{notice}</p>
</>
)}
</aside>
</div>
<footer className="game-panel__footer">
{restoreRequest
? <><span><kbd>{keyLabel("controller", "menu-confirm")}</kbd> Choose</span><span><kbd>{keyLabel("controller", "menu-back")}</kbd> Cancel</span></>
: <><span><kbd>{keyLabel("controller", "menu-confirm")}</kbd> Assign</span><span><kbd>{keyLabel("controller", "editor-clear")}</kbd> Clear</span><span><kbd>{keyLabel("controller", "menu-back")}</kbd> Back</span><span><kbd>{keyLabel("controller", "editor-reset")}</kbd> Restore layer</span></>}
</footer>
</section>
</div>
);
}
export function AbilityBindingsPanel() {
const open = useGameStore((state) => state.overlay === "bindings");
return open ? <AbilityBindingsDialog /> : null;
}