forked from phenom/healer-man
Release Healer Man 0.1.42
This commit is contained in:
1 parent
c3f8a69b51
commit
c7e8163790
8 files changed
+271
-64
No files matched your search
@@ -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.
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.41",
|
||||
"versionCode": 1041
|
||||
"versionName": "0.1.42",
|
||||
"versionCode": 1042
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user