diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index 8fcd50b7..537339a8 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -4,6 +4,39 @@ This file is the durable record of runtime behavior, regressions, and the evidence required before calling a visual bug fixed. Update it whenever scene, camera, input, physics, mob-rendering, or asset-loading code changes. +## 2026-09-10 Personal UI templates and desktop action bars + +- Opened Wailing Caverns through the normal offline roster, dungeon, and role + selection screens, then opened Pause > Edit UI & templates. +- Verified five bars containing 12 buttons each, including 2 x 6 and 3 x 4 + arrangements. Default stacking accounts for each bar's height. The editor + can collapse or dock on either side so drag handles remain reachable. +- Clicked Renew and pressed Shift+Q in keybind edit mode. Reassigned Shift+Q + to bar 5/button 12 and verified the previous keybind cleared. Assigned Renew + to that button through the ability picker; after saving, Shift+Q cast it and + displayed its cooldown. No spell fired during key capture. +- Dragged the minimap, hid party frames, saved/renamed/overwrote PC slot 1, + loaded it, and round-tripped all layouts through export text and import. + The imported JSON retained the minimap position, hidden party, bar geometry, + ability assignment, key chord, and template name. Restoring a hidden widget + works, and Cancel discards that visibility edit. +- At a 390 x 844 viewport, the editor remained within the viewport and the + Save UI button was visible and 36 CSS pixels high. The in-app browser's + screenshot preview scales the page unusually at this override; DOM bounds + confirmed the editor was 310 CSS pixels wide. This was responsive browser + verification, not an Android hardware playtest. +- Evidence: `playtest-artifacts/ui-layout-desktop.png` and + `playtest-artifacts/ui-layout-phone-viewport.png`. Restored the offline UI + to defaults and removed the temporary template via import after testing. +- Automated coverage includes migration to six slots, owner isolation, + cloud upload/hydration into a separate device cache with a mocked endpoint, + malformed imports, key conflicts, and input precedence. No production + account credentials were used for the cloud round-trip test. +- `npm run build` and the focused 67-test suite passed. The development server + was restarted after an existing watcher hit EBUSY on generated web-bootstrap + assets during a concurrent build; no runtime UI error was logged in the + subsequent playtest. + ## 2026-08-18 GM Manastorm administration - COMPLETE - Server-authorized `Phenom` access, revisioned SQLite configuration, safe diff --git a/package-lock.json b/package-lock.json index 9c9abb21..be1ef9b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.27", + "version": "0.1.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.27", + "version": "0.1.28", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index 0c6d20a6..ef776a80 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.27", + "version": "0.1.28", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/release-version.json b/release-version.json index d0b3d983..30c056fe 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.27", - "versionCode": 1027 + "versionName": "0.1.28", + "versionCode": 1028 } diff --git a/src/GameRuntime.tsx b/src/GameRuntime.tsx index 70e5123a..38139b80 100644 --- a/src/GameRuntime.tsx +++ b/src/GameRuntime.tsx @@ -1,4 +1,5 @@ import { useEffect } from "react"; +import "./ui/uiLayout.css"; import { DualDisplayFrame } from "./components/DualDisplayFrame"; import { installInput, releasePointerLock } from "./game/inputManager"; import { CombatBridge } from "./game/CombatBridge"; @@ -26,12 +27,27 @@ import { DungeonRunCheckpointBridge } from "./game/DungeonRunCheckpointBridge"; import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge"; import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction"; import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt"; +import { UiLayoutEditor } from "./ui/UiLayoutEditor"; +import { EditableHudElement } from "./ui/EditableHudElement"; +import { activeUiLayout, currentUiDevice } from "./game/uiLayoutStore"; +import { actionKeyForEvent, desktopSlotForKey } from "./game/uiLayout"; function InputBridge() { const overlay = useGameStore((state) => state.overlay); const companionOpen = useGameStore((state) => state.companionOpen); useEffect(() => installInput({ + desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars, + handleCustomKey: (event) => { + const game = useGameStore.getState(); + if (game.overlay === "ui-layout") return !["Tab", "Enter", "Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.code); + if (currentUiDevice() !== "pc" || game.overlay || game.companionOpen || useCombatStore.getState().pendingGroundTarget) return false; + const button = desktopSlotForKey(activeUiLayout(), actionKeyForEvent(event)); + if (!button) return false; + if (!event.repeat && button.abilityId) useCombatStore.getState().castAbility(button.abilityId, game.playerPosition, Date.now()); + game.setInputMode("keyboard"); + return true; + }, togglePause: () => useGameStore.getState().togglePause(), toggleMap: () => useGameStore.getState().toggleMap(), toggleInventory: () => useGameStore.getState().toggleOverlay("inventory"), @@ -98,10 +114,11 @@ export default function GameRuntime() { - + + diff --git a/src/app/accountRepository.ts b/src/app/accountRepository.ts index 7b64a6d9..29affbe2 100644 --- a/src/app/accountRepository.ts +++ b/src/app/accountRepository.ts @@ -1,4 +1,5 @@ import type { CharacterProfile, PlayerSession } from "./types"; +import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout"; import { contentCategoryForClass, isRomClassId, @@ -64,6 +65,7 @@ interface ProfileDatabase { accounts: LocalAccount[]; rosters: Record; presentationSettingsByOwner: Record; + uiPreferencesByOwner?: Record; } export interface AccountResult { @@ -202,6 +204,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof accounts?: unknown; rosters?: unknown; presentationSettingsByOwner?: unknown; + uiPreferencesByOwner?: unknown; }) | null; if (!parsed || ![1, 2, 3, 4, 5].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") { return emptyDatabase(); @@ -243,7 +246,9 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof return [ownerId, normalized]; }), ); - const database: ProfileDatabase = { version: 5, accounts, rosters, presentationSettingsByOwner }; + const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {}; + const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)])); + const database: ProfileDatabase = { version: 5, accounts, rosters, presentationSettingsByOwner, uiPreferencesByOwner }; if (parsed.version !== 5 || presentationSettingsMigrated) writeDatabase(database, storage); return database; } catch { @@ -254,11 +259,23 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void { storage?.setItem(DATABASE_KEY, JSON.stringify(database)); if (!storage || !rosterChangeListener) return; - for (const [ownerId, characters] of Object.entries(database.rosters)) { - rosterChangeListener(ownerId, characters); + for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.uiPreferencesByOwner ?? {})])) { + rosterChangeListener(ownerId, database.rosters[ownerId] ?? []); } } +export function loadUiPreferences(ownerId: string, storage: StorageLike | null = browserLocalStorage()): UiPreferences { + return normalizeUiPreferences(readDatabase(storage).uiPreferencesByOwner?.[ownerId]); +} + +export function saveUiPreferences(ownerId: string, value: unknown, storage: StorageLike | null = browserLocalStorage()): UiPreferences { + const database = readDatabase(storage); + const preferences = normalizeUiPreferences(value); + database.uiPreferencesByOwner = { ...database.uiPreferencesByOwner, [ownerId]: preferences }; + writeDatabase(database, storage); + return preferences; +} + function bytesToBase64(bytes: Uint8Array): string { let result = ""; for (let index = 0; index < bytes.length; index += 3) { diff --git a/src/app/onlineAccountClient.ts b/src/app/onlineAccountClient.ts index d5e7cb7a..0c68bef3 100644 --- a/src/app/onlineAccountClient.ts +++ b/src/app/onlineAccountClient.ts @@ -1,8 +1,11 @@ import { Capacitor } from "@capacitor/core"; +import { create } from "zustand"; import { listCharacters, replaceCharactersForOwner, setRosterChangeListener, + loadUiPreferences, + saveUiPreferences, type AccountResult, } from "./accountRepository"; import type { CharacterProfile, PlayerSession } from "./types"; @@ -20,7 +23,7 @@ interface AuthResponse { interface CloudSaveResponse { revision: number; updatedAt: number | null; - data: { version?: number; characters?: unknown }; + data: { version?: number; characters?: unknown; uiPreferences?: unknown }; } interface MeResponse { @@ -39,6 +42,8 @@ let suppressUpload = false; let pendingCharacters: CharacterProfile[] | null = null; let saveTimer: ReturnType | null = null; let saveQueue = Promise.resolve(); +export const useCloudSaveStatus = create<{ state: "local" | "pending" | "synced" | "error"; message: string }>(() => ({ state: "local", message: "" })); +let saveSequence = 0; export function onlineApiOrigin(): string { const configured = String(import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, ""); @@ -100,18 +105,24 @@ function flushCloudSave(): void { const characters = pendingCharacters; pendingCharacters = null; if (!session?.accessToken || !characters) return; + const uiPreferences = loadUiPreferences(session.ownerId); + const sequence = saveSequence; saveQueue = saveQueue.then(async () => { await requestOnlineJson("/api/cloud-save", { method: "PUT", - body: JSON.stringify({ data: { version: 1, characters } }), + body: JSON.stringify({ data: { version: 1, characters, uiPreferences } }), }, session.accessToken); + if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "synced", message: "Saved to your account." }); }).catch((error) => { console.error("Healer Man cloud save failed", error); + if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "error", message: "Saved on this device. Cloud upload failed; retry before switching devices." }); }); } function queueCloudSave(ownerId: string, characters: readonly CharacterProfile[]): void { if (suppressUpload || !activeSession?.accessToken || activeSession.ownerId !== ownerId) return; + saveSequence += 1; + useCloudSaveStatus.setState({ state: "pending", message: "Uploading to your account…" }); pendingCharacters = characters.map((character) => ({ ...character })); if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(() => { @@ -123,15 +134,23 @@ function queueCloudSave(ownerId: string, characters: readonly CharacterProfile[] setRosterChangeListener(queueCloudSave); export function activateOnlineSession(session: PlayerSession): void { + if (activeSession?.ownerId !== session.ownerId && saveTimer) { + clearTimeout(saveTimer); saveTimer = null; flushCloudSave(); + } activeSession = session.accessToken ? session : null; } +export function retryCloudSave(): void { + if (activeSession) queueCloudSave(activeSession.ownerId, listCharacters(activeSession.ownerId)); +} + export async function hydrateOnlineSession(session: PlayerSession): Promise { if (!session.accessToken) return listCharacters(session.ownerId); activateOnlineSession(session); const cloud = await requestOnlineJson("/api/cloud-save", {}, session.accessToken); suppressUpload = true; try { + saveUiPreferences(session.ownerId, cloud.data?.uiPreferences); return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []); } finally { suppressUpload = false; diff --git a/src/app/uiPreferencesSync.test.ts b/src/app/uiPreferencesSync.test.ts new file mode 100644 index 00000000..33efe8d6 --- /dev/null +++ b/src/app/uiPreferencesSync.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { loadUiPreferences, saveUiPreferences, setRosterChangeListener } from "./accountRepository"; +import { normalizeUiPreferences } from "../game/uiLayout"; +import type { PlayerSession } from "./types"; + +function storage() { + const values = new Map(); + return { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } }; +} +afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); setRosterChangeListener(null); }); + +describe("profile UI persistence", () => { + it("isolates owners and persists templates without requiring a character", () => { + const local = storage(); const ui = normalizeUiPreferences(null); + ui.slots.pc[1] = { name: "Healer", layout: ui.current.pc }; + saveUiPreferences("one", ui, local); + expect(loadUiPreferences("one", local).slots.pc[1]?.name).toBe("Healer"); + expect(loadUiPreferences("two", local).slots.pc[1]).toBeNull(); + ui.slots.pc[1].name = "Unsaved change"; + expect(loadUiPreferences("one", local).slots.pc[1]?.name).toBe("Healer"); + }); + it("uploads all six slots and restores them into a separate device cache", async () => { + vi.useFakeTimers(); + const first = storage(); const second = storage(); + vi.stubGlobal("window", { localStorage: first }); + let cloudData: unknown = { version: 1, characters: [] }; + vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => { + if (init.method === "PUT") cloudData = JSON.parse(String(init.body)).data; + return new Response(JSON.stringify({ revision: 1, updatedAt: 1, data: cloudData }), { status: 200, headers: { "Content-Type": "application/json" } }); + })); + const client = await import("./onlineAccountClient"); + const session: PlayerSession = { ownerId: "cloud-user", kind: "account", displayName: "Player", accessToken: "test-token" }; + client.activateOnlineSession(session); + const ui = normalizeUiPreferences(null); + ui.slots.pc[2] = { name: "Desktop raid", layout: ui.current.pc }; + ui.slots.mobile[1] = { name: "On the go", layout: ui.current.mobile }; + saveUiPreferences(session.ownerId, ui); + await vi.advanceTimersByTimeAsync(501); + expect(client.useCloudSaveStatus.getState().state).toBe("synced"); + vi.stubGlobal("window", { localStorage: second }); + await client.hydrateOnlineSession(session); + expect(loadUiPreferences(session.ownerId)).toEqual(ui); + expect(loadUiPreferences("different-account").slots.pc[2]).toBeNull(); + }); +}); diff --git a/src/components/DualDisplayFrame.tsx b/src/components/DualDisplayFrame.tsx index ea515c66..70cb2038 100644 --- a/src/components/DualDisplayFrame.tsx +++ b/src/components/DualDisplayFrame.tsx @@ -45,7 +45,7 @@ export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onAc } }); const onKeyDown = (event: KeyboardEvent) => { - if (event.repeat) return; + if (event.repeat || event.defaultPrevented) return; const target = event.target as HTMLElement | null; const isEditable = target?.matches("input, textarea, select, [contenteditable='true']") ?? false; if (event.code === "KeyC" && !isEditable) { diff --git a/src/game/inputManager.test.ts b/src/game/inputManager.test.ts index 2cd8755a..cfdd8e85 100644 --- a/src/game/inputManager.test.ts +++ b/src/game/inputManager.test.ts @@ -66,6 +66,33 @@ afterEach(() => { }); describe("gameplay input bindings", () => { + it("consumes custom action keys before movement, menus and legacy action bindings", () => { + stubInputEventTargets(); + const nextActions = inputActionSpies(); + nextActions.handleCustomKey = vi.fn(() => true); + const dispose = installInput(nextActions); + for (const code of ["KeyW", "KeyI", "Digit1"]) { + const event = new Event("keydown", { cancelable: true }); + Object.defineProperties(event, { code: { value: code }, target: { value: null } }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + } + expect(nextActions.toggleInventory).not.toHaveBeenCalled(); + expect(nextActions.castActionBinding).not.toHaveBeenCalled(); + expect(nextActions.handleCustomKey).toHaveBeenCalledTimes(3); + dispose(); + }); + it("does not fire the old numbered bindings when desktop bars are enabled", () => { + stubInputEventTargets(); + const nextActions = inputActionSpies(); + nextActions.desktopActionBarsActive = () => true; + const dispose = installInput(nextActions); + const event = new Event("keydown", { cancelable: true }); + Object.defineProperties(event, { code: { value: "Digit1" }, target: { value: null } }); + window.dispatchEvent(event); + expect(nextActions.castActionBinding).not.toHaveBeenCalled(); + dispose(); + }); it("reserves I, P, N, and E for equipment and loot interactions", () => { expect(keyboardCommandForCode("KeyI")).toBe("inventory"); expect(keyboardCommandForCode("KeyE")).toBe("loot"); diff --git a/src/game/inputManager.ts b/src/game/inputManager.ts index 05476709..41e1c8d7 100644 --- a/src/game/inputManager.ts +++ b/src/game/inputManager.ts @@ -22,6 +22,9 @@ export interface InputSnapshot { } export interface InputActions { + /** Runs before built-in shortcuts. True consumes the key, including empty custom slots. */ + handleCustomKey?: (event: KeyboardEvent) => boolean; + desktopActionBarsActive?: () => boolean; togglePause: () => void; toggleMap: () => void; toggleInventory: () => void; @@ -292,6 +295,12 @@ export function installInput(nextActions: InputActions): () => void { const onKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; if (target?.matches("input, textarea, select, [contenteditable='true']")) return; + if (nextActions.handleCustomKey?.(event)) { + event.preventDefault(); + event.stopImmediatePropagation(); + keys.delete(event.code); + return; + } if (["ShiftLeft", "ShiftRight", "AltLeft", "AltRight"].includes(event.code)) { keyboardModifiers.add(event.code); nextActions.setInputMode("keyboard"); @@ -312,7 +321,7 @@ export function installInput(nextActions: InputActions): () => void { else nextActions.cancelGroundTarget(); return; } - const actionControl = actionControlForKeyboardCode(event.code); + const actionControl = nextActions.desktopActionBarsActive?.() ? null : actionControlForKeyboardCode(event.code); if (actionControl) { if (nextActions.gameplayActionsBlocked()) return; event.preventDefault(); diff --git a/src/game/store.ts b/src/game/store.ts index 075ae5c6..e87205e3 100644 --- a/src/game/store.ts +++ b/src/game/store.ts @@ -8,7 +8,7 @@ import type { GmReviewTarget } from "./gmContentReview"; export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error"; export type InputMode = "keyboard" | "gamepad"; export type GameMode = "dungeon" | "manastorm"; -export type GameOverlay = "pause" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "gm" | null; +export type GameOverlay = "pause" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "gm" | null; interface PlayerSnapshot { position: Vector3Tuple; diff --git a/src/game/uiLayout.test.ts b/src/game/uiLayout.test.ts new file mode 100644 index 00000000..73273ca1 --- /dev/null +++ b/src/game/uiLayout.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + actionKeyForEvent, bindDesktopSlot, defaultUiLayout, desktopSlotForKey, + exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, +} from "./uiLayout"; + +describe("personal UI layouts", () => { + it("migrates old accounts to independent Mobile and PC layouts with exactly six empty slots", () => { + const ui = normalizeUiPreferences(undefined); + expect(ui.slots.mobile).toEqual([null, null, null]); + expect(ui.slots.pc).toEqual([null, null, null]); + ui.current.pc.elements.party.visible = false; + expect(ui.current.mobile.elements.party.visible).toBe(true); + expect(ui.current.pc.bars).toHaveLength(5); + expect(ui.current.pc.bars.every((bar) => bar.slots.length === 12)).toBe(true); + }); + it("round trips named slots, positions, hidden elements, geometry, assignments, and key chords", () => { + const ui = normalizeUiPreferences(null); + const layout = ui.current.pc; + layout.wowBars = true; layout.barCount = 5; + layout.elements.party = { visible: false, position: { x: .32, y: .45 }, width: 200 }; + layout.bars[4].columns = 3; + layout.bars[4].slots[11] = { abilityId: "test-heal", key: "Ctrl+Shift+KeyQ" }; + ui.slots.pc[2] = { name: "Raid night", layout }; + ui.slots.mobile[0] = { name: "Travel", layout: defaultUiLayout() }; + expect(importUiPreferences(exportUiPreferences(ui))).toEqual(ui); + }); + it("reassigns conflicts even from hidden bars, accepts modifiers and clears a binding", () => { + let layout = defaultUiLayout(); layout.wowBars = true; layout.barCount = 5; + layout = bindDesktopSlot(layout, 4, 11, "Digit1"); + expect(layout.bars[0].slots[0].key).toBeNull(); + expect(desktopSlotForKey(layout, "Digit1")).toBe(layout.bars[4].slots[11]); + layout.elements["bar-5"].visible = false; + expect(desktopSlotForKey(layout, "Digit1")).not.toBeNull(); + layout.barCount = 4; + expect(desktopSlotForKey(layout, "Digit1")).toBeNull(); + layout = bindDesktopSlot(layout, 4, 11, null); + expect(layout.bars[4].slots[11].key).toBeNull(); + expect(actionKeyForEvent({ code: "KeyQ", ctrlKey: true, shiftKey: true, altKey: false, metaKey: false })).toBe("Ctrl+Shift+KeyQ"); + expect(normalizeActionKey("Escape")).toBeNull(); + expect(normalizeActionKey("ShiftLeft")).toBeNull(); + }); + it.each([ + ["unsupported versions", (data: any) => { data.version = 99; }], + ["extra slots", (data: any) => { data.slots.mobile.push(null); }], + ["invalid coordinates", (data: any) => { data.current.pc.elements.party.position = { x: "bad", y: 0 }; }], + ["invalid bar geometry", (data: any) => { data.current.pc.bars[0].columns = 7; }], + ["invalid keys", (data: any) => { data.current.pc.bars[0].slots[0].key = "Escape"; }], + ["duplicate chords", (data: any) => { data.current.pc.bars[0].slots[1].key = "Digit1"; }], + ["missing buttons", (data: any) => { data.current.pc.bars[0].slots.pop(); }], + ])("rejects %s without mutating the original preferences", (_, mutate) => { + const ui = normalizeUiPreferences(null); + const original = exportUiPreferences(ui); + const data = JSON.parse(original); mutate(data); + expect(() => importUiPreferences(JSON.stringify(data))).toThrow(); + expect(exportUiPreferences(ui)).toBe(original); + }); + it("rejects malformed and oversized imports", () => { + expect(() => importUiPreferences("{")).toThrow("valid JSON"); + expect(() => importUiPreferences(" ".repeat(256001))).toThrow("256 KB"); + }); +}); diff --git a/src/game/uiLayout.ts b/src/game/uiLayout.ts new file mode 100644 index 00000000..f994cbfd --- /dev/null +++ b/src/game/uiLayout.ts @@ -0,0 +1,178 @@ +import { normalizeHudPosition, type NormalizedHudPosition } from "./hudLayout"; + +export type UiDevice = "mobile" | "pc"; +export const UI_DEVICES: readonly UiDevice[] = ["mobile", "pc"]; +export const BAR_COLUMNS = [1, 2, 3, 4, 6, 12] as const; +export const HUD_ELEMENTS = { + party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap", + player: "Player status", timeline: "Encounter timeline", target: "Enemy target", + friendly: "Friendly target", threat: "Threat meter", cast: "Cast bar", + hints: "Control hints", actions: "Controller action bar", experience: "Experience bar", + reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt", + manastorm: "Manastorm status", completion: "Dungeon completion", assets: "Asset notice", + objective: "Manastorm objective", messages: "Manastorm messages", cache: "Reward cache", feedback: "Action feedback", + "bar-1": "Action bar 1", "bar-2": "Action bar 2", "bar-3": "Action bar 3", + "bar-4": "Action bar 4", "bar-5": "Action bar 5", +} as const; +export type HudElementId = keyof typeof HUD_ELEMENTS; +export interface HudElementLayout { + visible: boolean; + position: NormalizedHudPosition | null; + width: number | null; +} +export interface DesktopActionSlot { abilityId: string | null; key: string | null } +export interface DesktopActionBar { columns: number; slots: DesktopActionSlot[] } +export interface UiLayout { + elements: Record; + wowBars: boolean; + barCount: number; + bars: DesktopActionBar[]; +} +export interface UiTemplate { name: string; layout: UiLayout } +export interface UiPreferences { + version: 1; + current: Record; + slots: Record; +} + +export function defaultUiLayout(): UiLayout { + return { + elements: Object.fromEntries(Object.keys(HUD_ELEMENTS).map((id) => [id, { + visible: true, position: null, width: null, + }])) as UiLayout["elements"], + wowBars: false, + barCount: 1, + bars: Array.from({ length: 5 }, (_, bar) => ({ + columns: 12, + slots: Array.from({ length: 12 }, (_, slot) => ({ + abilityId: null, + key: bar === 0 ? (slot < 9 ? `Digit${slot + 1}` : ["Digit0", "Minus", "Equal"][slot - 9]) : null, + })), + })), + }; +} + +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} +function bounded(value: unknown, fallback: number, min: number, max: number): number { + return typeof value === "number" && Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback; +} +const KEY_CODE = /^(?:Key[A-Z]|Digit[0-9]|F(?:[1-9]|1[0-2])|Numpad(?:[0-9]|Add|Subtract|Multiply|Divide|Decimal|Enter)|Arrow(?:Up|Down|Left|Right)|Space|Tab|Enter|Backquote|Minus|Equal|BracketLeft|BracketRight|Backslash|Semicolon|Quote|Comma|Period|Slash|Insert|Home|End|PageUp|PageDown)$/; +export function normalizeActionKey(value: unknown): string | null { + if (typeof value !== "string") return null; + const parts = value.split("+"); + const code = parts.pop()!; + if (!KEY_CODE.test(code) || parts.some((part) => !["Ctrl", "Alt", "Shift", "Meta"].includes(part))) return null; + return [...["Ctrl", "Alt", "Shift", "Meta"].filter((part) => parts.includes(part)), code].join("+"); +} +export function actionKeyForEvent(event: Pick): string | null { + return normalizeActionKey([ + ...(event.ctrlKey ? ["Ctrl"] : []), ...(event.altKey ? ["Alt"] : []), + ...(event.shiftKey ? ["Shift"] : []), ...(event.metaKey ? ["Meta"] : []), event.code, + ].join("+")); +} +export function actionKeyLabel(key: string | null): string { + return key?.replace(/Key|Digit/g, "").replace("Minus", "−").replace("Equal", "=").replace("Numpad", "Num ") ?? ""; +} +export function normalizeUiLayout(value: unknown): UiLayout { + const source = record(value); + const defaults = defaultUiLayout(); + const elements = record(source.elements); + for (const id of Object.keys(HUD_ELEMENTS) as HudElementId[]) { + const element = record(elements[id]); + defaults.elements[id] = { + visible: element.visible !== false, + position: element.position ? normalizeHudPosition(element.position, { x: 0.5, y: 0.5 }) : null, + width: typeof element.width === "number" ? bounded(element.width, 200, 20, 2000) : null, + }; + } + const used = new Set(); + defaults.bars = defaults.bars.map((fallback, index) => { + const bar = record(Array.isArray(source.bars) ? source.bars[index] : null); + return { + columns: BAR_COLUMNS.includes(bar.columns as 12) ? bar.columns as number : 12, + slots: fallback.slots.map((slot, slotIndex) => { + const raw = Array.isArray(bar.slots) ? record(bar.slots[slotIndex]) : slot; + const key = normalizeActionKey(raw.key); + const uniqueKey = key && !used.has(key) ? key : null; + if (uniqueKey) used.add(uniqueKey); + return { + key: uniqueKey, + abilityId: typeof raw.abilityId === "string" && raw.abilityId.length <= 200 ? raw.abilityId : null, + }; + }), + }; + }); + return { ...defaults, wowBars: source.wowBars === true, barCount: Math.floor(bounded(source.barCount, 1, 1, 5)) }; +} +export function normalizeUiPreferences(value: unknown): UiPreferences { + const source = record(value); + const current = record(source.current); + const slots = record(source.slots); + return { + version: 1, + current: { mobile: normalizeUiLayout(current.mobile), pc: normalizeUiLayout(current.pc) }, + slots: Object.fromEntries(UI_DEVICES.map((device) => [device, Array.from({ length: 3 }, (_, index) => { + const slot = record(Array.isArray(slots[device]) ? slots[device][index] : null); + return typeof slot.name === "string" && slot.layout + ? { name: slot.name.trim().slice(0, 40) || `Template ${index + 1}`, layout: normalizeUiLayout(slot.layout) } + : null; + })])) as UiPreferences["slots"], + }; +} + +/** A key has one owner, including hidden bars, so enabling a bar cannot add conflicts. */ +export function bindDesktopSlot(layout: UiLayout, barIndex: number, slotIndex: number, key: string | null): UiLayout { + return { ...layout, bars: layout.bars.map((bar, b) => ({ ...bar, slots: bar.slots.map((slot, s) => ({ + ...slot, key: b === barIndex && s === slotIndex ? key : key && slot.key === key ? null : slot.key, + })) })) }; +} +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. + return layout.bars.slice(0, layout.barCount).flatMap((bar) => bar.slots).find((slot) => slot.key === key) ?? null; +} +export function exportUiPreferences(preferences: UiPreferences): string { + return JSON.stringify({ format: "healer-man-ui", ...normalizeUiPreferences(preferences) }, null, 2); +} +export function importUiPreferences(text: string): UiPreferences { + if (text.length > 256_000) throw new Error("UI files must be smaller than 256 KB."); + let source: Record; + try { source = record(JSON.parse(text)); } catch { throw new Error("This file is not valid JSON."); } + if (source.format !== "healer-man-ui" || source.version !== 1) throw new Error("Unsupported UI file format or version."); + const validateLayout = (value: unknown) => { + const layout = record(value); + if (typeof layout.wowBars !== "boolean" || !Number.isInteger(layout.barCount) || Number(layout.barCount) < 1 || Number(layout.barCount) > 5 + || !Array.isArray(layout.bars) || layout.bars.length !== 5) throw new Error("Invalid action bar configuration."); + for (const id of Object.keys(HUD_ELEMENTS)) { + const element = record(record(layout.elements)[id]); + const position = record(element.position); + if (typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1)) + || (element.width !== null && (typeof element.width !== "number" || !Number.isFinite(element.width) || element.width < 20 || element.width > 2000))) throw new Error("Invalid HUD position or visibility."); + } + const keys = new Set(); + for (const raw of layout.bars) { + const bar = record(raw); + if (!BAR_COLUMNS.includes(bar.columns as 12) || !Array.isArray(bar.slots) || bar.slots.length !== 12) throw new Error("Each bar needs exactly 12 buttons and a valid row/column layout."); + for (const rawSlot of bar.slots) { + const slot = record(rawSlot); + if (slot.abilityId !== null && (typeof slot.abilityId !== "string" || slot.abilityId.length > 200)) throw new Error("Invalid ability assignment."); + if (slot.key !== null && (!normalizeActionKey(slot.key) || keys.has(normalizeActionKey(slot.key)!))) throw new Error("Invalid or duplicate keybind."); + if (slot.key) keys.add(normalizeActionKey(slot.key)!); + } + } + }; + for (const device of UI_DEVICES) { + validateLayout(record(source.current)[device]); + const slots = record(source.slots)[device]; + if (!Array.isArray(slots) || slots.length !== 3) throw new Error("UI files must contain three Mobile and three PC slots."); + for (const slot of slots) { + if (slot === null) continue; + const template = record(slot); + if (typeof template.name !== "string" || !template.name.trim() || template.name.length > 40) throw new Error("Template names must be 1–40 characters."); + validateLayout(template.layout); + } + } + return normalizeUiPreferences(source); +} diff --git a/src/game/uiLayoutStore.ts b/src/game/uiLayoutStore.ts new file mode 100644 index 00000000..345d50ef --- /dev/null +++ b/src/game/uiLayoutStore.ts @@ -0,0 +1,59 @@ +import { create } from "zustand"; +import { Capacitor } from "@capacitor/core"; +import { loadUiPreferences, saveUiPreferences } from "../app/accountRepository"; +import { useShellStore } from "../app/shellStore"; +import { normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreferences } from "./uiLayout"; + +export function currentUiDevice(): UiDevice { + return Capacitor.isNativePlatform() || (typeof navigator !== "undefined" && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) + || (typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches && window.innerWidth < 1100) + ? "mobile" : "pc"; +} +interface UiLayoutState { + ownerId: string | null; + preferences: UiPreferences; + draft: UiLayout | null; + device: UiDevice; + mode: "layout" | "bindings"; + selectedButton: { bar: number; slot: number } | null; + selectedElement: string | null; + begin: () => void; + updateDraft: (update: (layout: UiLayout) => UiLayout) => void; + persist: (preferences: UiPreferences) => void; + finish: (save: boolean) => void; +} +export const useUiLayoutStore = create((set, get) => ({ + ownerId: null, preferences: normalizeUiPreferences(null), draft: null, device: currentUiDevice(), + mode: "layout", selectedButton: null, selectedElement: null, + begin: () => { + const device = currentUiDevice(); + set({ draft: get().preferences.current[device], device, mode: "layout", selectedButton: null }); + }, + updateDraft: (update) => set((state) => state.draft ? { draft: update(state.draft) } : state), + persist: (preferences) => { + const ownerId = get().ownerId; + if (!ownerId) throw new Error("Sign in or choose an offline profile before saving your UI."); + // Write first: storage errors leave the editor open with its draft intact. + const saved = saveUiPreferences(ownerId, preferences); + set({ preferences: saved }); + }, + finish: (save) => { + const { draft, device, preferences } = get(); + if (save && draft) get().persist({ ...preferences, current: { ...preferences.current, [device]: draft } }); + set({ draft: null, selectedButton: null, mode: "layout" }); + }, +})); + +function activateOwner() { + const ownerId = useShellStore.getState().session?.ownerId ?? null; + useUiLayoutStore.setState({ ownerId, preferences: ownerId ? loadUiPreferences(ownerId) : normalizeUiPreferences(null), draft: null }); +} +activateOwner(); +useShellStore.subscribe((state, previous) => { + if (state.session !== previous.session) activateOwner(); +}); + +export function activeUiLayout(): UiLayout { + const state = useUiLayoutStore.getState(); + return state.draft ?? state.preferences.current[currentUiDevice()]; +} diff --git a/src/ui/ActionBar.tsx b/src/ui/ActionBar.tsx index 6207ba3b..2f0e1d14 100644 --- a/src/ui/ActionBar.tsx +++ b/src/ui/ActionBar.tsx @@ -40,7 +40,7 @@ function cooldownPercent(remaining = 0, duration = 0): number { return Math.max(0, Math.min(100, (remaining / duration) * 100)); } -function ActionIcon({ item }: { item: ActionBarItem | null }) { +export function ActionIcon({ item }: { item: ActionBarItem | null }) { const [failed, setFailed] = useState(false); useEffect(() => setFailed(false), [item?.iconUrl]); if (!item) return