Release Healer Man 0.1.42

This commit is contained in:
phenom committed 2026-09-13 15:48:12 -04:00
1 parent c3f8a69b51
commit c7e8163790
8 files changed
+271 -64

No files matched your search

+39
View File
@@ -3116,3 +3116,42 @@ Vite's existing large-chunk advisory remains informational.
`artifacts/boss-difficulty/wow-coa-balance.json`, and
`artifacts/boss-difficulty/coa-healer-review.md`. Survival at the 60-second
cap does not establish a boss clear or indefinite resource sustainability.
## WoW action bars in Ability controls — September 13, 2026
- Added Controller bars / WoW bars selection to Ability controls. On PC, the
dialog opens on WoW bars when that UI layout is enabled. The existing three
controller layers remain available through the same screen.
- The WoW editor exposes all five bars and twelve buttons per bar, including
inactive bars. Slots show their saved keyboard shortcuts and assigned
abilities. Assigning or leaving a slot empty saves immediately through the
existing profile UI persistence path and updates the gameplay action bar.
- Restore Bar resets that bar's abilities to the current character's class
defaults; Restore all WoW bars resets all sixty ability slots. Both use a
confirmation with cancellation. Keyboard shortcuts, bar geometry, PC/mobile
UI templates, and controller bindings remain independent of these resets.
- Long WoW ability lists scroll inside the picker. Controller navigation keeps
the selected control visible. Future abilities retain their unlock-level
labels and can be assigned ahead of time; passive abilities are excluded.
- Live 1280x720 production-preview QA used an isolated offline Human Priest
profile at port 5192. Verified first-slot assignment, inactive fifth-bar
button-twelve assignment and clearing, canceling a reset, restoring one bar
while retaining the fifth-bar assignment, and restoring all WoW bars.
Controller Cross retained Smite through the WoW edits and resets.
- Enabled the WoW layout through Edit UI & templates, confirmed Ability controls
opened directly on WoW bars, and changed the first button to Lesser Heal.
The gameplay bar updated immediately. After a full page reload and dungeon
reentry, both the gameplay bar and editor retained Lesser Heal and key 1.
The final picker capture shows access to the late-level abilities and Leave
empty while all bar controls stay visible. No browser UI errors were logged.
- Validation: all 55 UI-layout, action-binding, keybind, profile-sync, input,
HUD-layering, and panel-integration tests passed. The production build,
including TypeScript with the project's configured 8 GB heap, prebuild asset
audits, and postbuild release-bundle validation, passed. A separate manual
TypeScript attempt using Node's default heap exhausted memory; the standard
build completed successfully. Final release hygiene covers all changed paths.
- Evidence: `artifacts/ability-controls/wow-bars.png`,
`artifacts/ability-controls/controller-bars.png`,
`artifacts/ability-controls/wow-picker.png`, and
`artifacts/ability-controls/wow-picker-end.png`. Cloud upload and physical
gamepad input were not exercised by this offline browser pass.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.41",
"version": "0.1.42",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.41",
"version": "0.1.42",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.41",
"version": "0.1.42",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.41",
"versionCode": 1041
"versionName": "0.1.42",
"versionCode": 1042
}
+29 -2
View File
@@ -1,10 +1,37 @@
import { describe, expect, it } from "vitest";
import {
actionKeyForEvent, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resolveDpsMeterDisplay,
actionKeyForEvent, assignDesktopAbility, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resetDesktopAbilities, resolveDpsMeterDisplay,
} from "./uiLayout";
describe("personal UI layouts", () => {
it("saves and clears abilities on inactive WoW bars without changing keys, templates, or mobile controls", () => {
const ui = normalizeUiPreferences(null);
ui.slots.pc[0] = { name: "Original", layout: ui.current.pc };
const original = exportUiPreferences(ui);
ui.current.pc = assignDesktopAbility(ui.current.pc, 4, 11, "test-heal");
const saved = importUiPreferences(exportUiPreferences(ui));
expect(saved.current.pc.bars[4].slots[11]).toEqual({ abilityId: "test-heal", key: null });
expect(saved.current.pc.bars[0]).toEqual(JSON.parse(original).current.pc.bars[0]);
expect(saved.current.mobile).toEqual(ui.current.mobile);
expect(saved.slots.pc[0]?.layout.bars[4].slots[11].abilityId).toBeNull();
expect(assignDesktopAbility(saved.current.pc, 4, 11, null).bars[4].slots[11].abilityId).toBeNull();
});
it("restores one or all WoW ability bars with the correct offset and retains custom keys and geometry", () => {
let layout = defaultUiLayout();
layout.wowBars = true; layout.barCount = 2; layout.bars[1].columns = 3;
layout = bindDesktopSlot(layout, 1, 0, "Ctrl+KeyQ");
layout = assignDesktopAbility(assignDesktopAbility(layout, 0, 0, "custom-one"), 1, 0, "custom-two");
const defaults = Array.from({ length: 14 }, (_, i) => `spell-${i}`);
const bar = resetDesktopAbilities(layout, defaults, 1);
expect(bar.bars[0]).toEqual(layout.bars[0]);
expect(bar.bars[1].slots[0]).toEqual({ abilityId: "spell-12", key: "Ctrl+KeyQ" });
expect(bar.bars[1].slots[2].abilityId).toBeNull();
const all = resetDesktopAbilities(bar, defaults);
expect(all.bars[0].slots[0]).toEqual({ abilityId: "spell-0", key: "Digit1" });
expect(all.bars[1].columns).toBe(3);
expect(all.wowBars).toBe(true); expect(all.barCount).toBe(2);
});
it("defaults DPS to PC lower-left flow and the Thor companion, with saved overrides", () => {
const ui = normalizeUiPreferences(null);
expect(ui.current.pc.elements.dps).toEqual({ visible: true, position: null, width: null });
+12
View File
@@ -139,6 +139,18 @@ export function bindDesktopSlot(layout: UiLayout, barIndex: number, slotIndex: n
...slot, key: b === barIndex && s === slotIndex ? key : key && slot.key === key ? null : slot.key,
})) })) };
}
export function assignDesktopAbility(layout: UiLayout, barIndex: number, slotIndex: number, abilityId: string | null): UiLayout {
return { ...layout, bars: layout.bars.map((bar, b) => b !== barIndex ? bar : {
...bar, slots: bar.slots.map((slot, s) => s === slotIndex ? { ...slot, abilityId } : slot),
}) };
}
/** Restore abilities only; key chords and the arrangement belong to the UI template. */
export function resetDesktopAbilities(layout: UiLayout, abilityIds: readonly string[], barIndex?: number): UiLayout {
return { ...layout, bars: layout.bars.map((bar, b) => barIndex !== undefined && b !== barIndex ? bar : {
...bar, slots: bar.slots.map((slot, s) => ({ ...slot, abilityId: abilityIds[b * 12 + s] ?? null })),
}) };
}
export function desktopSlotForKey(layout: UiLayout, key: string | null): DesktopActionSlot | null {
if (!layout.wowBars || !key) return null;
// Hidden bars retain their bindings, like WoW; reducing the bar count deactivates them.
+13
View File
@@ -1408,7 +1408,19 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.option-audio-controls { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
.ability-bindings-panel { width: min(980px, 100%); max-height: calc(100vh - 28px); }
.ability-bindings__layouts { display: flex; gap: 6px; margin-top: 10px; }
.ability-bindings__layouts button { flex: 1; min-height: 34px; padding: 6px 10px; border: 1px solid rgba(193,211,160,.18); border-radius: 8px; background: rgba(255,255,255,.025); color: var(--ink); cursor: pointer; }
.ability-bindings__layouts button.is-active { border-color: rgba(196,221,144,.4); background: rgba(103,148,78,.25); }
.ability-bindings__layers { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin: 10px 0; }
.ability-bindings__bars { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.ability-bindings__wow { display: flex; min-width: 0; flex-direction: column; gap: 12px; padding: 12px; border: 1px solid rgba(190,210,157,.14); border-radius: 12px; background: rgba(4,12,8,.58); }
.ability-bindings__wow-slots { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.ability-bindings__wow-slots .ability-binding-slot { min-height: 76px; }
.ability-bindings__wow-slots .ability-binding-slot strong { font-size: 10px; }
.ability-bindings__wow-slots .ability-binding-slot small { font-size: 8px; }
.ability-bindings__wow-note { margin: 0; color: var(--muted); font-size: 10px; line-height: 1.5; }
.ability-bindings__wow .ability-bindings__restore { margin-top: auto; }
.ability-bindings__wow + .ability-bindings__picker .ability-bindings__choices { max-height: min(280px, 36vh); overflow: auto; padding: 2px; }
.ability-bindings__layers button { display: grid; min-width: 0; min-height: 42px; grid-template-columns: 31px minmax(0, 1fr); align-items: center; gap: 7px; padding: 5px 8px; border: 1px solid rgba(193,211,160,.13); border-radius: 8px; background: rgba(255,255,255,.025); color: var(--ink); cursor: pointer; text-align: left; }
.ability-bindings__layers button.is-active { border-color: rgba(196,221,144,.4); background: rgba(103,148,78,.25); }
.ability-bindings__layers kbd { display: grid; width: 29px; height: 27px; place-items: center; border: 1px solid rgba(229,210,138,.24); border-radius: 7px; background: rgba(0,0,0,.28); color: #ecd98c; }
@@ -1604,6 +1616,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.ability-bindings__layers button { grid-template-columns: 1fr; justify-items: center; padding: 4px; text-align: center; }
.ability-bindings__layers kbd, .ability-bindings__layers small { display: none; }
.ability-bindings__diagram { display: block; min-height: 0; padding: 8px; border-radius: 16px; }
.ability-bindings__wow-slots { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ability-bindings__shoulders { display: grid; grid-template-columns: repeat(2, 1fr); margin-bottom: 7px; }
.ability-bindings__dpad { display: grid; max-width: 230px; margin: 0 auto 7px; }
.ability-bindings__face { transform: scale(.9); margin-block: -10px; }
+173 -57
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { classById } from "../app/characterCatalog";
import {
ACTION_CONTROLS,
@@ -10,7 +10,10 @@ import {
type ActionBindingLayer,
} from "../game/actionBindings";
import { useCombatStore } from "../game/combatStore";
import { defaultActionBarForCharacter } from "../game/abilityCatalog";
import { useGameStore } from "../game/store";
import { actionKeyLabel, 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";
@@ -29,27 +32,38 @@ function AbilityBindingIcon({ icon, sigil, color }: { icon?: string; sigil: stri
}
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 slotControlId = (control: ActionBindingControl) => `binding-slot-${control}`;
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<Record<string, NonNullable<MenuAction["neighbors"]>>> = {
"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-restore-layer", down: "binding-slot-dpad-left", left: "binding-layer-tertiary", right: "binding-layer-secondary",
up: "binding-layout-controller", 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",
up: "binding-layout-controller", 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",
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",
@@ -84,19 +98,30 @@ const MAIN_ACTION_NEIGHBORS: Readonly<Record<string, NonNullable<MenuAction["nei
};
function AbilityBindingsDialog() {
const panel = useRef<HTMLElement>(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 bindings = useCombatStore((state) => state.actionBindings);
const pcLayout = useUiLayoutStore((state) => state.preferences.current.pc);
const [layout, setLayout] = useState<BindingLayout>(() => currentUiDevice() === "pc" && pcLayout.wowBars ? "wow" : "controller");
const [bar, setBar] = useState(0);
const [layer, setLayer] = useState<ActionBindingLayer>("primary");
const [editingControl, setEditingControl] = useState<ActionBindingControl | null>(null);
const [editingControl, setEditingControl] = useState<BindingControl | 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 isWow = layout === "wow";
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)
@@ -109,6 +134,31 @@ function AbilityBindingsDialog() {
: ordered;
}, [abilities, editingAbilityId]);
const close = () => 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);
@@ -117,40 +167,42 @@ function AbilityBindingsDialog() {
setNotice(`${ACTION_LAYER_LABELS[next]} layer selected.`);
controller.select(layerControlId(next));
};
const beginEditing = (control: ActionBindingControl) => {
const abilityId = bindings[actionBindingId(layer, control)];
const beginEditing = (control: BindingControl) => {
const abilityId = assignment(control);
setEditingControl(control);
setRestoreRequest(null);
controller.select(abilityId ? choiceControlId(abilityId) : "binding-choice-empty");
};
const cancelEditing = () => {
if (!editingControl) return;
if (editingControl === null) return;
const control = editingControl;
setEditingControl(null);
controller.select(slotControlId(control));
};
const assign = (abilityId: string | null) => {
if (!editingControl) return;
if (editingControl === null) 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"}.`);
if (!setBinding(control, abilityId)) return;
setNotice(`${controlLabel(control)}: ${ability?.name ?? "Empty"}. Saved.`);
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.`);
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 ? slotControlId(editingControl) : focusId,
completeFocusId: editingControl !== null ? slotControlId(editingControl) : focusId,
});
controller.select("binding-confirm-cancel");
};
@@ -163,7 +215,11 @@ function AbilityBindingsDialog() {
const confirmRestore = () => {
if (!restoreRequest) return;
const request = restoreRequest;
if (request.scope === "layer") {
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 {
@@ -190,53 +246,72 @@ function AbilityBindingsDialog() {
},
];
}
if (editingControl) {
if (editingControl !== null) {
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] };
}),
...(["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: MAIN_ACTION_NEIGHBORS["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: MAIN_ACTION_NEIGHBORS["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, editingControl, layer, pickerAbilities, restoreRequest]);
}, [abilities, bindings, classId, secondaryClassId, level, editingControl, layer, layout, bar, pcLayout, pickerAbilities, restoreRequest]);
const controller = useMenuController(actions, {
columns: restoreRequest ? 2 : editingControl ? 1 : 3,
columns: restoreRequest ? 2 : editingControl !== null ? 1 : isWow ? 4 : 3,
initialId: restoreRequest
? "binding-confirm-cancel"
: editingControl
: editingControl !== null
? editingAbilityId ? choiceControlId(editingAbilityId) : "binding-choice-empty"
: layerControlId(layer),
onBack: restoreRequest ? cancelRestore : editingControl ? cancelEditing : close,
: 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") chooseLayer("secondary");
else if (id === "editor-layer-3") chooseLayer("tertiary");
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) clearSelected(editingControl);
if (editingControl !== null) clearSelected(editingControl);
else {
const prefix = "binding-slot-";
if (controller.selectedId.startsWith(prefix)) clearSelected(controller.selectedId.slice(prefix.length) as ActionBindingControl);
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);
};
@@ -252,7 +327,7 @@ function AbilityBindingsDialog() {
};
window.addEventListener("keydown", onKey);
return () => { unsubscribe(); window.removeEventListener("keydown", onKey); };
}, [controller.selectedId, editingControl, layer, restoreRequest]);
}, [controller.selectedId, editingControl, layer, layout, bar, pcLayout, restoreRequest]);
const bindingSlot = (control: ActionBindingControl) => {
const definition = ACTION_CONTROL_DEFINITIONS[control];
@@ -282,16 +357,32 @@ function AbilityBindingsDialog() {
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">
<section ref={panel} 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>
<p className="eyebrow">{isWow ? "WoW loadout · 5 bars · 60 customizable slots" : "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">
<nav className="ability-bindings__layouts" aria-label="Action bar style">
{(["controller", "wow"] as const).map((next) => (
<ControllerButton key={next} controlId={`binding-layout-${next}`} selectedId={controller.selectedId} select={controller.select}
type="button" aria-pressed={layout === next} className={layout === next ? "is-active" : ""} onClick={() => chooseLayout(next)}>
{next === "wow" ? "WoW bars" : "Controller bars"}
</ControllerButton>
))}
</nav>
{isWow ? <nav className="ability-bindings__layers ability-bindings__bars" aria-label="WoW action bars">
{pcLayout.bars.map((_, index) => (
<ControllerButton key={index} controlId={barControlId(index)} selectedId={controller.selectedId} select={controller.select}
type="button" aria-pressed={bar === index} className={bar === index ? "is-active" : ""} onClick={() => chooseBar(index)}>
<kbd>{index + 1}</kbd><span><strong>Bar {index + 1}</strong><small>{pcLayout.wowBars && index < pcLayout.barCount ? "Enabled" : "Inactive"}</small></span>
</ControllerButton>
))}
</nav> : <nav className="ability-bindings__layers" aria-label="Ability binding layers">
{ACTION_LAYERS.map((nextLayer) => (
<ControllerButton
key={nextLayer}
@@ -307,10 +398,31 @@ function AbilityBindingsDialog() {
<span><strong>{ACTION_LAYER_LABELS[nextLayer]}</strong><small>{nextLayer === "primary" ? "No modifier" : `Hold ${ACTION_LAYER_LABELS[nextLayer]}`}</small></span>
</ControllerButton>
))}
</nav>
</nav>}
<div className="ability-bindings__layout">
<section className="ability-bindings__diagram" aria-label={`${ACTION_LAYER_LABELS[layer]} controller layout`}>
{isWow ? <section className="ability-bindings__wow" aria-label={`Bar ${bar + 1} ability assignments`}>
<div className="ability-bindings__wow-slots">
{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 <ControllerButton key={index} controlId={slotControlId(index)} selectedId={controller.selectedId} select={controller.select}
type="button" className={`ability-binding-slot ${locked ? "is-locked" : ""} ${editingControl === index ? "is-editing" : ""}`}
aria-label={`${controlLabel(index)}: ${name}${locked ? `, unlocks at level ${ability!.unlockLevel}` : ""}`}
aria-pressed={editingControl === index} title={`${controlLabel(index)}: ${name}`} onClick={() => beginEditing(index)}>
<kbd>{actionKeyLabel(slot.key) || "—"}</kbd>
{ability ? <AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} /> : <span className="ability-bindings__empty" aria-hidden="true">+</span>}
<span><strong>Button {index + 1}</strong><small>{locked ? `${name} · Level ${ability!.unlockLevel}` : name}</small></span>
</ControllerButton>;
})}
</div>
<p className="ability-bindings__wow-note">{!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."}</p>
<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 Bar {bar + 1}</ControllerButton>
<ControllerButton controlId="binding-restore-all" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => requestRestore("all", "binding-restore-all")}>Restore all WoW bars</ControllerButton>
</div>
</section> : <section className="ability-bindings__diagram" aria-label={`${ACTION_LAYER_LABELS[layer]} controller layout`}>
<div className="ability-bindings__shoulders">
{bindingSlot("r1")}
{bindingSlot("r2")}
@@ -330,16 +442,20 @@ function AbilityBindingsDialog() {
<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>
</section>}
<aside className="ability-bindings__picker" aria-live="polite">
{restoreRequest ? (
<>
<p className="eyebrow">Confirm controller reset</p>
<h3>{restoreRequest.scope === "all"
<p className="eyebrow">Confirm {restoreRequest.layout === "wow" ? "WoW bar" : "controller"} reset</p>
<h3>{restoreRequest.layout === "wow"
? restoreRequest.scope === "all" ? "Restore all WoW bar abilities?" : `Restore Bar ${restoreRequest.bar + 1} abilities?`
: 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"
<p className="ability-bindings__confirm-warning">{restoreRequest.layout === "wow"
? `This replaces ${restoreRequest.scope === "all" ? "all 60 WoW slots" : "this bar's 12 slots"} with the class defaults. Keyboard shortcuts and bar layout are kept.`
: 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">
@@ -353,9 +469,9 @@ function AbilityBindingsDialog() {
</ControllerButton>
</div>
</>
) : editingControl ? (
) : editingControl !== null ? (
<>
<p className="eyebrow">Assign {bindingChord(layer, editingControl)}</p>
<p className="eyebrow">Assign {controlLabel(editingControl)}</p>
<h3>Choose an ability</h3>
<div className="ability-bindings__choices">
{pickerAbilities.map((ability) => (
@@ -372,19 +488,19 @@ function AbilityBindingsDialog() {
</>
) : (
<>
<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>
<p className="eyebrow">{isWow ? `Bar ${bar + 1}` : `${ACTION_LAYER_LABELS[layer]} layer`}</p>
<h3>Select a {isWow ? "WoW bar" : "controller"} button</h3>
<p>{isWow ? "Each WoW bar has 12 buttons. Assign abilities on any of the five bars, including inactive bars. Change keyboard shortcuts in Options → Keybinds." : "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>
</>
)}
</aside>
</div>
<p className="ability-bindings__notice" role="status">{notice}</p>
<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></>}
: <><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 {isWow ? "bar" : "layer"}</span></>}
</footer>
</section>
</div>