forked from phenom/healer-man
398 lines
20 KiB
TypeScript
398 lines
20 KiB
TypeScript
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;
|
||
}
|