From c7e8163790cea5ea5ee3cf137a7128a1e6bdab2a Mon Sep 17 00:00:00 2001 From: phenom Date: Sun, 13 Sep 2026 15:48:12 -0400 Subject: [PATCH] Release Healer Man 0.1.42 --- PLAYTEST_NOTES.md | 39 ++++++ package-lock.json | 4 +- package.json | 2 +- release-version.json | 4 +- src/game/uiLayout.test.ts | 31 ++++- src/game/uiLayout.ts | 12 ++ src/styles.css | 13 ++ src/ui/AbilityBindingsPanel.tsx | 230 ++++++++++++++++++++++++-------- 8 files changed, 271 insertions(+), 64 deletions(-) diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index bc1fadf5..83cc78d1 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -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. diff --git a/package-lock.json b/package-lock.json index e67403cf..17f49b7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d1673259..21883f07 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/release-version.json b/release-version.json index 531fb1c2..0d8caf11 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.41", - "versionCode": 1041 + "versionName": "0.1.42", + "versionCode": 1042 } diff --git a/src/game/uiLayout.test.ts b/src/game/uiLayout.test.ts index 6929c5b1..acec0c15 100644 --- a/src/game/uiLayout.test.ts +++ b/src/game/uiLayout.test.ts @@ -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 }); diff --git a/src/game/uiLayout.ts b/src/game/uiLayout.ts index c2d885ca..498ce23e 100644 --- a/src/game/uiLayout.ts +++ b/src/game/uiLayout.ts @@ -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. diff --git a/src/styles.css b/src/styles.css index 36dba7cb..f60b9fad 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } diff --git a/src/ui/AbilityBindingsPanel.tsx b/src/ui/AbilityBindingsPanel.tsx index 610c7b0b..a1860e8d 100644 --- a/src/ui/AbilityBindingsPanel.tsx +++ b/src/ui/AbilityBindingsPanel.tsx @@ -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>> = { + "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(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(() => currentUiDevice() === "pc" && pcLayout.wowBars ? "wow" : "controller"); + const [bar, setBar] = useState(0); const [layer, setLayer] = useState("primary"); - const [editingControl, setEditingControl] = useState(null); + 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 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 (
-
+
-

Controller loadout · 24 customizable slots

+

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

Ability controls

- }
-
+ {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")} @@ -330,16 +442,20 @@ function AbilityBindingsDialog() { requestRestore("layer", "binding-restore-layer")}>Restore {ACTION_LAYER_LABELS[layer]} requestRestore("all", "binding-restore-all")}>Restore all
-
+
}
+

{notice}