import { useEffect, useMemo, useRef, 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 { abilityAtLevel, abilityTalentNodeId, defaultActionBarForCharacter, learnedAbilityRank, type AbilityDefinition, } from "../game/abilityCatalog"; import { useGameStore } from "../game/store"; import { actionKeyLabel, assignedActionBarAbilityIds, assignDesktopAbility, resetDesktopAbilities, type UiLayout } from "../game/uiLayout"; import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore"; 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"; import { AbilityTooltipTarget, type AbilityTooltipData } from "./AbilityTooltip"; function AbilityBindingIcon({ icon, sigil, color }: { icon?: string; sigil: string; color: string }) { const [failed, setFailed] = useState(false); useEffect(() => setFailed(false), [icon]); return ( ); } type RestoreScope = "layer" | "all"; type BindingLayout = "controller" | "wow"; type BindingControl = ActionBindingControl | number; interface RestoreRequest { scope: RestoreScope; layout: BindingLayout; layer: ActionBindingLayer; bar: number; cancelFocusId: string; completeFocusId: string; } const layerControlId = (layer: ActionBindingLayer) => `binding-layer-${layer}`; const barControlId = (bar: number) => `binding-bar-${bar}`; const slotControlId = (control: BindingControl) => typeof control === "number" ? `binding-wow-slot-${control}` : `binding-slot-${control}`; const choiceControlId = (abilityId: string) => `binding-choice-${abilityId}`; const MAIN_ACTION_NEIGHBORS: Readonly>> = { "binding-layout-controller": { up: "binding-restore-layer", down: "binding-layer-primary", left: "binding-layout-wow", right: "binding-layout-wow", }, "binding-layout-wow": { up: "binding-restore-all", down: "binding-layer-tertiary", left: "binding-layout-controller", right: "binding-layout-controller", }, "binding-layer-primary": { up: "binding-layout-controller", down: "binding-slot-dpad-left", left: "binding-layer-tertiary", right: "binding-layer-secondary", }, "binding-layer-secondary": { up: "binding-layout-controller", down: "binding-slot-r1", left: "binding-layer-primary", right: "binding-layer-tertiary", }, "binding-layer-tertiary": { up: "binding-layout-wow", 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({ onClose }: { onClose?: () => void }) { const panel = useRef(null); 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 secondaryClassId = useCombatStore((state) => state.secondaryClassId); const level = useCombatStore((state) => state.level); const abilities = useCombatStore((state) => state.abilities); const talentRanks = useCombatStore((state) => state.talentRanks); const tooltipData = (definition: AbilityDefinition | null | undefined): AbilityTooltipData | null => { if (!definition) return null; const talentRank = learnedAbilityRank(definition, talentRanks); const ability = abilityAtLevel(definition, level, talentRank); return { name: ability.name, description: ability.description, target: ability.target, resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource.replace(/-/g, " "), cooldownSeconds: ability.cooldownMs / 1_000, castSeconds: ability.castTimeMs / 1_000, range: ability.range.max, lockedUntilLevel: ability.unlockLevel > level ? ability.unlockLevel : undefined, talentLocked: Boolean(abilityTalentNodeId(ability)) && !talentRank, }; }; const bindings = useCombatStore((state) => state.actionBindings); const pcLayout = useUiLayoutStore((state) => state.preferences.current.pc); const [layout, setLayout] = useState(() => currentUiDevice() === "pc" && pcLayout.wowBars ? "wow" : "controller"); const [bar, setBar] = useState(0); const [layer, setLayer] = useState("primary"); const [editingControl, setEditingControl] = useState(null); const [restoreRequest, setRestoreRequest] = useState(null); const [notice, setNotice] = useState("Choose a button, then choose the ability it should use."); const classColor = classById(classId).color; const isWow = layout === "wow"; const assignedIds = assignedActionBarAbilityIds(pcLayout, bindings, isWow); const isMissing = (ability: AbilityDefinition) => ability.unlockLevel <= level && (!abilityTalentNodeId(ability) || Boolean(learnedAbilityRank(ability, talentRanks))) && !assignedIds.has(ability.id); const assignment = (control: BindingControl) => typeof control === "number" ? pcLayout.bars[bar].slots[control].abilityId : bindings[actionBindingId(layer, control)]; const controlLabel = (control: BindingControl) => typeof control === "number" ? `Bar ${bar + 1} · Button ${control + 1}${pcLayout.bars[bar].slots[control].key ? ` · ${actionKeyLabel(pcLayout.bars[bar].slots[control].key)}` : ""}` : bindingChord(layer, control); const editingAbilityId = editingControl !== null ? assignment(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 = onClose ?? (() => openOverlay("pause")); const saveWowLayout = (update: (current: UiLayout) => UiLayout) => { try { const ui = useUiLayoutStore.getState(); ui.persist({ ...ui.preferences, current: { ...ui.preferences.current, pc: update(ui.preferences.current.pc) } }); return true; } catch (error) { setNotice(error instanceof Error ? error.message : "Could not save WoW ability bindings."); return false; } }; const setBinding = (control: BindingControl, abilityId: string | null) => { if (typeof control === "number") return saveWowLayout((current) => assignDesktopAbility(current, bar, control, abilityId)); useCombatStore.getState().setActionBinding(layer, control, abilityId); return true; }; const chooseLayout = (next: BindingLayout) => { setLayout(next); setEditingControl(null); setRestoreRequest(null); setNotice(`Choose a ${next === "wow" ? "WoW bar" : "controller"} button, then choose its ability.`); controller.select(`binding-layout-${next}`); }; const chooseBar = (next: number) => { setBar(next); setEditingControl(null); setRestoreRequest(null); setNotice(`Bar ${next + 1} selected.`); controller.select(barControlId(next)); }; const chooseLayer = (next: ActionBindingLayer) => { setLayer(next); setEditingControl(null); setRestoreRequest(null); setNotice(`${ACTION_LAYER_LABELS[next]} layer selected.`); controller.select(layerControlId(next)); }; const beginEditing = (control: BindingControl) => { const abilityId = assignment(control); setEditingControl(control); setRestoreRequest(null); controller.select(abilityId ? choiceControlId(abilityId) : "binding-choice-empty"); }; const cancelEditing = () => { if (editingControl === null) return; const control = editingControl; setEditingControl(null); controller.select(slotControlId(control)); }; const assign = (abilityId: string | null) => { if (editingControl === null) return; const control = editingControl; const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null; if (!setBinding(control, abilityId)) return; setNotice(`${controlLabel(control)}: ${ability?.name ?? "Empty"}. Saved.`); setEditingControl(null); controller.select(slotControlId(control)); }; const clearSelected = (control: BindingControl | null = editingControl) => { if (control === null) return; if (!setBinding(control, null)) return; setNotice(`${controlLabel(control)} cleared. Saved.`); setEditingControl(null); controller.select(slotControlId(control)); }; const requestRestore = (scope: RestoreScope, focusId: string) => { setRestoreRequest({ scope, layout, layer, bar, cancelFocusId: focusId, completeFocusId: editingControl !== null ? 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.layout === "wow") { const defaults = defaultActionBarForCharacter(classId, secondaryClassId, level); if (!saveWowLayout((current) => resetDesktopAbilities(current, defaults, request.scope === "layer" ? request.bar : undefined))) return; setNotice(request.scope === "layer" ? `Bar ${request.bar + 1} abilities restored. Saved.` : "All WoW bar abilities restored. Saved."); } else 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(() => { 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 !== null) { return [ ...pickerAbilities.map((ability) => ({ id: choiceControlId(ability.id), run: () => assign(ability.id) })), { id: "binding-choice-empty", run: () => assign(null) }, ]; } return [ ...(["controller", "wow"] as const).map((next) => ({ id: `binding-layout-${next}`, run: () => chooseLayout(next), neighbors: isWow ? { up: "binding-restore-all", down: barControlId(bar), left: `binding-layout-${next === "wow" ? "controller" : "wow"}`, right: `binding-layout-${next === "wow" ? "controller" : "wow"}` } : MAIN_ACTION_NEIGHBORS[`binding-layout-${next}`], })), ...(isWow ? [ ...pcLayout.bars.map((_, index) => ({ id: barControlId(index), run: () => chooseBar(index), neighbors: { up: "binding-layout-wow", down: slotControlId(index), left: barControlId((index + 4) % 5), right: barControlId((index + 1) % 5), } })), ...pcLayout.bars[bar].slots.map((_, slot) => ({ id: slotControlId(slot), run: () => beginEditing(slot), neighbors: { up: slot < 4 ? barControlId(bar) : slotControlId(slot - 4), down: slot >= 8 ? "binding-restore-layer" : slotControlId(slot + 4), left: slotControlId((slot + 11) % 12), right: slotControlId((slot + 1) % 12), } })), ] : [ ...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: isWow ? { up: slotControlId(8), down: "binding-layout-controller", left: "binding-restore-all", right: "binding-restore-all" } : MAIN_ACTION_NEIGHBORS["binding-restore-layer"], }, { id: "binding-restore-all", run: () => requestRestore("all", "binding-restore-all"), neighbors: isWow ? { up: slotControlId(11), down: "binding-layout-wow", left: "binding-restore-layer", right: "binding-restore-layer" } : MAIN_ACTION_NEIGHBORS["binding-restore-all"], }, ]; }, [abilities, bindings, classId, secondaryClassId, level, editingControl, layer, layout, bar, pcLayout, pickerAbilities, restoreRequest]); const controller = useMenuController(actions, { columns: restoreRequest ? 2 : editingControl !== null ? 1 : isWow ? 4 : 3, initialId: restoreRequest ? "binding-confirm-cancel" : editingControl !== null ? editingAbilityId ? choiceControlId(editingAbilityId) : "binding-choice-empty" : isWow ? barControlId(bar) : layerControlId(layer), onBack: restoreRequest ? cancelRestore : editingControl !== null ? cancelEditing : close, }); useEffect(() => { panel.current?.querySelector(".is-controller-selected")?.scrollIntoView({ block: "nearest" }); }, [controller.selectedId]); useEffect(() => { const run = (id: string | undefined) => { if (restoreRequest) return; if (id === "editor-layer-2") { if (isWow) chooseBar(1); else chooseLayer("secondary"); } else if (id === "editor-layer-3") { if (isWow) chooseBar(2); else chooseLayer("tertiary"); } else if (id === "editor-clear") { if (editingControl !== null) clearSelected(editingControl); else { const prefix = isWow ? "binding-wow-slot-" : "binding-slot-"; if (controller.selectedId.startsWith(prefix)) clearSelected(isWow ? Number(controller.selectedId.slice(prefix.length)) : 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, layout, bar, pcLayout, 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 ( beginEditing(control)} > {keyLabel("controller", `ability:${layer}:${control}`)} {ability ? : } {definition.label}{locked ? `${ability!.name} · Level ${ability!.unlockLevel}` : ability?.name ?? "Empty"} ); }; return (

{isWow ? "WoW loadout · 5 bars · 60 customizable slots" : "Controller loadout · 24 customizable slots"}

Ability controls

{isWow ? : }
{isWow ?
{pcLayout.bars[bar].slots.map((slot, index) => { const ability = abilities.find((candidate) => candidate.id === slot.abilityId); const locked = Boolean(ability && ability.unlockLevel > level); const name = ability?.name ?? (slot.abilityId ? "Unavailable for this character" : "Empty"); return beginEditing(index)}> {actionKeyLabel(slot.key) || "—"} {ability ? : } Button {index + 1}{locked ? `${name} · Level ${ability!.unlockLevel}` : name} ; })}

{!pcLayout.wowBars ? "Enable WoW action bars in UI layout to use these assignments on PC." : bar >= pcLayout.barCount ? "This bar is inactive. Increase the visible bar count in UI layout to use it." : "Choose a button to change its ability. Changes save immediately."}

requestRestore("layer", "binding-restore-layer")}>Restore Bar {bar + 1} requestRestore("all", "binding-restore-all")}>Restore all WoW bars
:
{bindingSlot("r1")} {bindingSlot("r2")}
{bindingSlot("dpad-left")} {bindingSlot("dpad-right")}
{bindingSlot("face-top")} {bindingSlot("face-right")} {bindingSlot("face-bottom")} {bindingSlot("face-left")}
requestRestore("layer", "binding-restore-layer")}>Restore {ACTION_LAYER_LABELS[layer]} requestRestore("all", "binding-restore-all")}>Restore all
}

{notice}

{restoreRequest ? <>{keyLabel("controller", "menu-confirm")} Choose{keyLabel("controller", "menu-back")} Cancel : <>{keyLabel("controller", "menu-confirm")} Assign{keyLabel("controller", "editor-clear")} Clear{keyLabel("controller", "menu-back")} Back{keyLabel("controller", "editor-reset")} Restore {isWow ? "bar" : "layer"}}
); } export function AbilityBindingsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) { const storeOpen = useGameStore((state) => state.overlay === "bindings"); return (open ?? storeOpen) ? : null; }