Compare commits

...
2 Commits
Author SHA1 Message Date
phenom 8c9809d3e0 Release Healer Man 0.1.28 2026-09-10 14:14:02 -04:00
phenom ed8b9e3719 Release Healer Man 0.1.27 2026-09-10 09:54:12 -04:00
25 changed files with 1056 additions and 56 deletions

No files matched your search

+56
View File
@@ -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
@@ -1992,3 +2025,26 @@ Vite's existing large-chunk advisory remains informational.
eight-heading pixel comparisons, profiles, before/after timing JSON, and
screenshots. Measurements use the same desktop Edge/RX 9070 XT setup as the
preceding audit. These CPU improvements do not certify Thor performance.
# 2026-09-09 — bundled arena assets blocked by the browser content worker
- Reproduced the reported collision-matched fallback in the production build
with the content worker active. Warnorken's `visual.glb?arena-runtime=1`
returned 404 even though the file was bundled and directly served with 200.
The worker omitted `/assets/game/arenas/` from its bundled asset prefixes and
incorrectly rejected these files as uninstalled optional content.
- Added the arena prefix to the worker. Installed updates still take priority;
missing optional dungeon content still returns 404. Behavioral worker tests
failed for all eight arenas before the fix and now cover visual, collision,
and navigation requests with the runtime query parameter.
- The production build passed. Reloading the same browser session activated
the fixed worker without clearing storage or downloading content. Warnorken
rendered textured stone geometry and Nagrand rendered its authored arena
geometry; neither showed the loading/fallback banner after loading, and no
new console errors were recorded. Checks used offline practice in the Codex
browser at `http://127.0.0.1:4174/`, backed by a local test server on port 4173.
- All 22 content tests and the eight-arena asset validator passed. Screenshots,
before/after error logs, asset validation, and build output are saved under
`playtest-artifacts/2026-09-09-arena-loading/`. The existing large-bundle
advisory remains. This verifies the local browser build, not a deployment
or an Android package.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.26",
"version": "0.1.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.26",
"version": "0.1.28",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.26",
"version": "0.1.28",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
+1
View File
@@ -12,6 +12,7 @@ const BUNDLED_ASSET_PREFIXES = [
"/assets/characters/wow335a-v1/manifest.json",
"/assets/characters/wow335a-v1/human/",
"/assets/creatures/wailing-caverns/",
"/assets/game/arenas/",
"/assets/game/dungeons/wailing-caverns/",
];
const BUNDLED_ASSET_PATHS = new Set([
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.26",
"versionCode": 1026
"versionName": "0.1.28",
"versionCode": 1028
}
+18 -1
View File
@@ -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() {
<ManastormBridge />
<SceneErrorBoundary><GameScene /></SceneErrorBoundary>
<Hud />
<DungeonInteractionPrompt />
<EditableHudElement id="interaction"><DungeonInteractionPrompt /></EditableHudElement>
<MapPanel />
<GameplayPanels />
<PauseMenu />
<UiLayoutEditor />
<GmReviewCard />
<GmWorldEditor />
</main>
+20 -3
View File
@@ -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<string, CharacterProfile[]>;
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
uiPreferencesByOwner?: Record<string, UiPreferences>;
}
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) {
+21 -2
View File
@@ -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<typeof setTimeout> | 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<CloudSaveResponse>("/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<CharacterProfile[]> {
if (!session.accessToken) return listCharacters(session.ownerId);
activateOnlineSession(session);
const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
suppressUpload = true;
try {
saveUiPreferences(session.ownerId, cloud.data?.uiPreferences);
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []);
} finally {
suppressUpload = false;
+45
View File
@@ -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<string, string>();
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();
});
});
+1 -1
View File
@@ -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) {
+57 -1
View File
@@ -1,5 +1,7 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { runInNewContext } from "node:vm";
import { describe, expect, it, vi } from "vitest";
import { ARENA_STAGES } from "../game/arenaStages";
import { BROWSER_CONTENT_CACHE_NAME } from "./contentManager";
const workerSource = readFileSync(
@@ -7,7 +9,61 @@ const workerSource = readFileSync(
"utf8",
);
function contentWorker(cached?: Response) {
const handlers = new Map<string, (event: unknown) => void>();
const fetch = vi.fn(async () => new Response("bundled arena", { status: 200 }));
const match = vi.fn(async () => cached);
runInNewContext(workerSource, {
self: {
location: { origin: "https://healer.test" },
addEventListener: (type: string, handler: (event: unknown) => void) => handlers.set(type, handler),
},
caches: { open: async () => ({ match }) },
fetch,
URL,
Response,
});
return {
fetch,
match,
request: (path: string) => {
let response: Promise<Response> | undefined;
handlers.get("fetch")!({
request: new Request(new URL(path, "https://healer.test")),
respondWith: (value: Promise<Response>) => { response = value; },
});
return response;
},
};
}
describe("browser content cache worker", () => {
it.each(ARENA_STAGES)("loads bundled $name assets without an optional content install", async (stage) => {
const worker = contentWorker();
for (const asset of stage.assets) {
const response = await worker.request(`${asset.url}?arena-runtime=1`);
expect(response?.status).toBe(200);
expect(await response?.text()).toBe("bundled arena");
}
expect(worker.fetch).toHaveBeenCalledTimes(stage.assets.length);
});
it("prefers installed arena updates over bundled files, ignoring the runtime query", async () => {
const worker = contentWorker(new Response("updated arena"));
const response = await worker.request(`${ARENA_STAGES[0].assets[0].url}?arena-runtime=1`);
expect(await response?.text()).toBe("updated arena");
expect(worker.match).toHaveBeenCalledWith(expect.any(Request), { ignoreSearch: true });
expect(worker.fetch).not.toHaveBeenCalled();
});
it("still blocks optional dungeon assets that have not been installed", async () => {
const worker = contentWorker();
const response = await worker.request("/assets/game/manastorm/optional/visual.glb");
expect(response?.status).toBe(404);
expect(await response?.text()).toBe("Optional content is not installed.");
expect(worker.fetch).not.toHaveBeenCalled();
});
it("serves only cached same-origin asset requests from the active content cache", () => {
expect(workerSource).toContain(`const CONTENT_CACHE_NAME = "${BROWSER_CONTENT_CACHE_NAME}";`);
expect(workerSource).toContain('request.method !== "GET"');
+27
View File
@@ -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");
+10 -1
View File
@@ -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();
+1 -1
View File
@@ -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;
+62
View File
@@ -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");
});
});
+178
View File
@@ -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<HudElementId, HudElementLayout>;
wowBars: boolean;
barCount: number;
bars: DesktopActionBar[];
}
export interface UiTemplate { name: string; layout: UiLayout }
export interface UiPreferences {
version: 1;
current: Record<UiDevice, UiLayout>;
slots: Record<UiDevice, (UiTemplate | null)[]>;
}
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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
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<KeyboardEvent, "code" | "ctrlKey" | "altKey" | "shiftKey" | "metaKey">): 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<string>();
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<string, unknown>;
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<string>();
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);
}
+59
View File
@@ -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<UiLayoutState>((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()];
}
+1 -1
View File
@@ -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 <span className="action-slot__icon" aria-hidden="true" />;
+37
View File
@@ -0,0 +1,37 @@
import type { CSSProperties } from "react";
import { useGameStore } from "../game/store";
import { useCombatStore } from "../game/combatStore";
import { actionKeyLabel, type HudElementId } from "../game/uiLayout";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { EditableHudElement } from "./EditableHudElement";
import { ActionIcon, type ActionBarItem } from "./ActionBar";
export function DesktopActionBars({ items }: { items: Readonly<Record<string, ActionBarItem>> }) {
const ui = useUiLayoutStore();
const editing = useGameStore((state) => state.overlay === "ui-layout");
const blocked = useGameStore((state) => state.overlay !== null || state.companionOpen);
const layout = ui.draft ?? ui.preferences.current[currentUiDevice()];
if (currentUiDevice() !== "pc" || !layout.wowBars) return null;
return <>{layout.bars.slice(0, layout.barCount).map((bar, b) => {
const offset = 30 + layout.bars.slice(0, b).reduce((total, previous) => total + (12 / previous.columns) * 46 + 10 + (editing ? 24 : 0), 0);
return <EditableHudElement id={`bar-${b + 1}` as HudElementId} key={b} intrinsicWidth={bar.columns * 46 + 8} bottomAnchor={offset + (12 / bar.columns) * 23 + (editing ? 16 : 4)}>
<section className="desktop-action-bar" aria-label={`Action bar ${b + 1}`} style={{ "--bar-columns": bar.columns, "--bar-offset": `${offset}px` } as CSSProperties}>
{bar.slots.map((button, s) => {
const item = button.abilityId ? items[button.abilityId] : null;
const selected = editing && ui.selectedButton?.bar === b && ui.selectedButton.slot === s;
return <button type="button" key={s} className={`desktop-action-button ${item?.disabled ? "is-unavailable" : ""} ${selected ? "is-selected" : ""}`}
aria-label={`Bar ${b + 1} button ${s + 1}: ${item?.name ?? "Empty"}${button.key ? `, ${actionKeyLabel(button.key)}` : ""}`}
aria-disabled={!editing && (!item || item.disabled || blocked)} aria-pressed={editing ? selected : undefined}
title={item ? `${item.name}\n${item.description ?? ""}\n${actionKeyLabel(button.key)}${item.lockedUntilLevel ? ` · Level ${item.lockedUntilLevel}` : ""}` : `Empty · ${actionKeyLabel(button.key)}`}
onClick={() => {
if (editing) { useUiLayoutStore.setState({ selectedButton: { bar: b, slot: s } }); return; }
if (!blocked && item && !item.disabled) useCombatStore.getState().castAbility(item.id, useGameStore.getState().playerPosition, Date.now());
}}>
<ActionIcon item={item ?? null} /><kbd>{actionKeyLabel(button.key)}</kbd>
{item && item.cooldownRemaining! > .05 && <span className="desktop-action-button__cooldown">{item.cooldownRemaining! < 10 ? item.cooldownRemaining!.toFixed(1) : Math.ceil(item.cooldownRemaining!)}</span>}
{editing && !item && <small>{s + 1}</small>}
</button>;
})}
</section>
</EditableHudElement>; })}</>;
}
+100
View File
@@ -0,0 +1,100 @@
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { HUD_ELEMENTS, type HudElementId } from "../game/uiLayout";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { useGameStore } from "../game/store";
const FALLBACKS: Partial<Record<HudElementId, [number, number, number]>> = {
party: [.1, .3, 210], summons: [.27, .25, 150], minimap: [.9, .12, 160], player: [.9, .34, 160],
timeline: [.9, .52, 160], target: [.5, .15, 230], friendly: [.3, .15, 230], threat: [.78, .25, 160],
cast: [.5, .68, 330], actions: [.5, .86, 420], hints: [.5, .65, 420], experience: [.5, .99, 900],
reticle: [.5, .5, 20], loot: [.5, .3, 320], interaction: [.5, .6, 320], manastorm: [.5, .05, 400],
completion: [.5, .39, 360], assets: [.78, .6, 200], feedback: [.5, .71, 400],
objective: [.5, .1, 400], messages: [.5, .22, 350], cache: [.78, .42, 200],
};
/** Defaults remain in their original CSS flow until the player moves the widget. */
export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor }: {
id: HudElementId; children?: ReactNode; intrinsicWidth?: number; bottomAnchor?: number;
}) {
const overlay = useGameStore((state) => state.overlay);
const editing = overlay === "ui-layout";
const state = useUiLayoutStore();
const layout = state.draft ?? state.preferences.current[currentUiDevice()];
const element = layout.elements[id];
const [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null);
const ref = useRef<HTMLDivElement>(null);
const [host, setHost] = useState<Element | null>(null);
const drag = useRef<{ pointerId: number; dx: number; dy: number } | null>(null);
const [bounds, setBounds] = useState({ width: 0, height: 0, panelWidth: 0, panelHeight: 0 });
const positioned = editing || element.position !== null;
const fallback = FALLBACKS[id] ?? [.5, .35, 250];
const position = element.position ?? (bottomAnchor !== undefined
? { x: .5, y: 1 - bottomAnchor / Math.max(1, bounds.height) }
: origin ?? { x: fallback[0], y: fallback[1] });
const width = intrinsicWidth ?? element.width ?? origin?.width ?? fallback[2];
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(".game-shell")); }, []);
useLayoutEffect(() => {
if (positioned || !ref.current) return;
const node = ref.current;
const measure = () => {
const child = node.firstElementChild?.getBoundingClientRect();
const host = node.closest(".game-shell")?.getBoundingClientRect();
if (child && host && child.width > 0 && child.height > 0) {
setOrigin({ x: (child.left - host.left + child.width / 2) / host.width,
y: (child.top - host.top + child.height / 2) / host.height, width: child.width });
}
};
measure();
const observer = new ResizeObserver(measure);
if (node.firstElementChild) observer.observe(node.firstElementChild);
window.addEventListener("resize", measure);
return () => { observer.disconnect(); window.removeEventListener("resize", measure); };
}, [positioned, children]);
useLayoutEffect(() => {
const node = ref.current;
const host = node?.closest(".game-shell");
if (!positioned || !node || !host) return;
const measure = () => setBounds({ width: host.clientWidth, height: host.clientHeight, panelWidth: node.offsetWidth, panelHeight: node.offsetHeight });
const observer = new ResizeObserver(measure);
observer.observe(node); observer.observe(host); measure();
return () => observer.disconnect();
}, [positioned, host]);
const updatePosition = (x: number, y: number) => state.updateDraft((draft) => ({ ...draft, elements: {
...draft.elements, [id]: { ...draft.elements[id], position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width },
} }));
const left = Math.max(6, Math.min(Math.max(6, bounds.width - bounds.panelWidth - 6), position.x * bounds.width - bounds.panelWidth / 2));
const top = Math.max(6, Math.min(Math.max(6, bounds.height - bounds.panelHeight - 6), position.y * bounds.height - bounds.panelHeight / 2));
const style = positioned ? { left, top, width: `min(${width}px, calc(100% - 12px))` } as CSSProperties : undefined;
const content = <div ref={ref} data-hud-element={id} style={style}
className={`editable-hud ${positioned ? "editable-hud--positioned" : "editable-hud--default"} ${editing ? "editable-hud--editing" : ""} ${!element.visible ? editing ? "editable-hud--ghost" : "editable-hud--hidden" : ""}`}
onClickCapture={(event) => { if (editing && !id.startsWith("bar-")) { event.preventDefault(); event.stopPropagation(); useUiLayoutStore.setState({ selectedElement: id }); } }}>
{children}
{editing && <button type="button" className="editable-hud__handle" aria-label={`Move ${HUD_ELEMENTS[id]}`} title="Drag to move; arrow keys nudge"
onPointerDown={(event) => {
if (event.button !== 0) return;
event.preventDefault(); event.stopPropagation();
const rect = ref.current!.getBoundingClientRect();
drag.current = { pointerId: event.pointerId, dx: event.clientX - rect.left - rect.width / 2, dy: event.clientY - rect.top - rect.height / 2 };
event.currentTarget.setPointerCapture(event.pointerId);
useUiLayoutStore.setState({ selectedElement: id });
}}
onPointerMove={(event) => {
if (drag.current?.pointerId !== event.pointerId) return;
const host = ref.current!.closest(".game-shell")!.getBoundingClientRect();
updatePosition((event.clientX - host.left - drag.current.dx) / host.width, (event.clientY - host.top - drag.current.dy) / host.height);
}}
onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} onLostPointerCapture={() => { drag.current = null; }}
onKeyDown={(event) => {
const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key];
if (!delta) return;
event.preventDefault(); event.stopPropagation();
updatePosition(position.x + delta[0] * .01, position.y + delta[1] * .01);
}}>{HUD_ELEMENTS[id]}{!element.visible ? " · Hidden" : ""}</button>}
</div>;
return positioned && host ? createPortal(content, host) : content;
}
+45 -32
View File
@@ -32,6 +32,9 @@ import { AuraStrip } from "./AuraStrip";
import { ManastormStatus } from "./ManastormStatus";
import { ThreatMeterWidget } from "./ThreatMeterWidget";
import { BossLootWindow } from "./BossLootWindow";
import { EditableHudElement } from "./EditableHudElement";
import { DesktopActionBars } from "./DesktopActionBars";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { SummonFrames } from "./SummonFrames";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { onlineAccountIdFromActor, onlinePlayerActorId } from "../game/onlineSessionRuntime";
@@ -139,6 +142,8 @@ export function DungeonCompletePrompt({
}
export function Hud() {
const ui = useUiLayoutStore();
const desktopBars = currentUiDevice() === "pc" && (ui.draft ?? ui.preferences.current.pc).wowBars;
const character = useShellStore((state) => state.activeCharacter);
const gameMode = useGameStore((state) => state.gameMode);
const areaName = useGameStore((state) => state.areaName);
@@ -216,10 +221,12 @@ export function Hud() {
const classColor = classById(classId).color;
const resourceTone = resourceProfileForClass(classId).type;
const actionItems = useMemo<ActionBarItems>(() => Object.fromEntries(ACTION_CONTROLS.map((control) => {
const abilityId = actionBindings[actionBindingId(actionLayer, control)];
const catalogAbility = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
if (!catalogAbility) return [control, null];
const assignedAbilities = useMemo(() => {
const used = new Set(Object.values(actionBindings));
if (desktopBars) for (const bar of (ui.draft ?? ui.preferences.current.pc).bars) for (const slot of bar.slots) used.add(slot.abilityId);
return abilities.filter((ability) => used.has(ability.id));
}, [abilities, actionBindings, desktopBars, ui.draft, ui.preferences]);
const abilityItems = useMemo<Record<string, ActionBarItem>>(() => Object.fromEntries(assignedAbilities.map((catalogAbility) => {
const learnedTalentRank = catalogAbility.talentEntryId === undefined
? undefined
: talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
@@ -254,8 +261,11 @@ export function Hud() {
lockedUntilLevel: level < ability.unlockLevel ? ability.unlockLevel : undefined,
target: ability.target,
};
return [control, item];
})) as ActionBarItems, [abilities, actionBindings, actionLayer, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]);
return [catalogAbility.id, item];
})), [assignedAbilities, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]);
const actionItems = Object.fromEntries(ACTION_CONTROLS.map((control) => [control,
abilityItems[actionBindings[actionBindingId(actionLayer, control)] ?? ""] ?? null,
])) as ActionBarItems;
const castBinding = (control: (typeof ACTION_CONTROLS)[number]) => useCombatStore.getState().castActionBinding(
useGameStore.getState().actionLayer,
@@ -362,17 +372,17 @@ export function Hud() {
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
<ManastormStatus />
<BossLootWindow />
{dungeonComplete && health > 0 ? (
<EditableHudElement id="completion">{dungeonComplete && health > 0 ? (
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} />
) : null}
{feedback?.kind === "loot" ? (
) : null}</EditableHudElement>
<EditableHudElement id="loot">{feedback?.kind === "loot" ? (
<section className="loot-reward" role="status" aria-live="polite">
<span aria-hidden="true">◆</span>
<p><strong>Loot acquired</strong><small>{feedback.message}</small><em>Press I to inspect and equip</em></p>
</section>
) : null}
<PartyFrames />
<SummonFrames now={clock} />
) : null}</EditableHudElement>
<EditableHudElement id="party"><PartyFrames /></EditableHudElement>
<EditableHudElement id="summons"><SummonFrames now={clock} /></EditableHudElement>
{pendingGroundTarget ? (
<section className="ground-target-reticle" role="status" aria-live="polite">
@@ -391,19 +401,19 @@ export function Hud() {
) : null}
<div className="hud-top-right">
<Minimap
<EditableHudElement id="minimap"><Minimap
position={playerPosition}
yaw={cameraYaw}
areaName={stagePresentation.areaName}
distanceTravelled={distance}
rotationMode={settings.minimapRotation}
onOpenMap={toggleMap}
/>
<PlayerStatusPanel now={clock} />
<EncounterTimeline now={clock} />
/></EditableHudElement>
<EditableHudElement id="player"><PlayerStatusPanel now={clock} /></EditableHudElement>
<EditableHudElement id="timeline"><EncounterTimeline now={clock} /></EditableHudElement>
</div>
{target && settings.showTargetFrame && (
<EditableHudElement id="target">{target && settings.showTargetFrame && (
<section className={`target-frame ${target.boss ? "target-frame--boss" : ""}`} aria-label={`Target ${target.name}`}>
<header><strong>{target.name}</strong><span>{target.boss ? "Boss" : `Level ${target.level}`}</span></header>
<div className="target-frame__track"><span style={{ width: `${targetPercent}%` }} /></div>
@@ -420,18 +430,18 @@ export function Hud() {
<AuraStrip targetId={target.id} className="aura-strip--target" />
<small>{Math.ceil(target.health)} / {target.maxHealth}</small>
</section>
)}
)}</EditableHudElement>
{target && settings.showThreatMeter && threatRows.length > 0 && (
<EditableHudElement id="threat">{target && settings.showThreatMeter && threatRows.length > 0 && (
<ThreatMeterWidget
targetName={target.name}
rows={threatRows}
position={settings.threatMeterPosition}
onPositionChange={updateThreatMeterPosition}
/>
)}
)}</EditableHudElement>
{selectedFriendly && settings.showTargetFrame && (
<EditableHudElement id="friendly">{selectedFriendly && settings.showTargetFrame && (
<section className="target-frame target-frame--friendly" aria-label={`Friendly target ${selectedFriendly.name}${selectedFriendly.self ? ", self" : ""}`}>
<header>
<strong>{selectedFriendly.name}</strong>
@@ -448,10 +458,10 @@ export function Hud() {
/>
<small>{Math.ceil(selectedFriendly.health)} / {selectedFriendly.maxHealth}</small>
</section>
)}
)}</EditableHudElement>
<div className="hud-bottom-stack">
{activeCast && castingAbility && (
<EditableHudElement id="cast">{activeCast && castingAbility && (
<section className={`cast-bar cast-bar--${activeCast.mode}`} aria-label={`${activeCast.mode === "channel" ? "Channeling" : "Casting"} ${castingAbility.name}`}>
<header><strong>{castingAbility.name}</strong><span>{castRemaining.toFixed(1)}s</span></header>
<div className="cast-bar__track">
@@ -459,9 +469,9 @@ export function Hud() {
</div>
<small>{activeCast.mode === "channel" ? "Channeling" : "Casting"}</small>
</section>
)}
)}</EditableHudElement>
{!activeCast && (
<EditableHudElement id="hints">{!activeCast && (
<div className={`control-hint ${hasMoved && cameraLookActive ? "control-hint--settled" : ""}`}>
{inputMode === "gamepad"
? "Left stick move | L1 / L2 layers | Face / R1 / R2 / D-pad abilities | R3 target | Start pause"
@@ -469,21 +479,24 @@ export function Hud() {
? "Release right mouse for cursor | WASD move | Arrow keys look | 1-8 abilities | Shift / Alt layers"
: "Right mouse / Arrow keys look | Click party frames to target | WASD move | Tab enemy"}
</div>
)}
)}</EditableHudElement>
<ActionBar
{!desktopBars && <EditableHudElement id="actions"><ActionBar
items={actionItems}
activeLayer={actionLayer}
onActivate={castBinding}
inputMode={inputMode}
feedback={settings.showFloatingCombatText ? feedback?.message ?? null : null}
/>
/></EditableHudElement>}
</div>
<ExperienceBar level={level} current={xp} required={xpToNext} />
<DesktopActionBars items={abilityItems} />
<EditableHudElement id="feedback">{desktopBars && settings.showFloatingCombatText && feedback?.message
? <div className="desktop-action-feedback" role="status">{feedback.message}</div> : null}</EditableHudElement>
<EditableHudElement id="experience"><ExperienceBar level={level} current={xp} required={xpToNext} /></EditableHudElement>
{cameraLookActive && <span className="reticle" aria-hidden="true" />}
<EditableHudElement id="reticle">{cameraLookActive && <span className="reticle" aria-hidden="true" />}</EditableHudElement>
{assetStatus === "proxy" && (
<EditableHudElement id="assets">{assetStatus === "proxy" && (
<div className="asset-notice" role="status">
<strong>Cave proxy active</strong>
<span>{assetMessage ?? `Add ${[
@@ -491,7 +504,7 @@ export function Hud() {
...dungeon.assets.collision,
].map((asset) => asset.fileName).join(" and ")}.`}</span>
</div>
)}
)}</EditableHudElement>
<LoadingOverlay />
{health <= 0 ? (
<DefeatedPrompt
+11 -8
View File
@@ -13,6 +13,7 @@ import {
import { useManastormStore } from "../game/manastormStore";
import { useGameStore } from "../game/store";
import { ManastormClientOverlay } from "./ManastormClientOverlay";
import { EditableHudElement } from "./EditableHudElement";
export function ManastormStatus() {
const gameMode = useGameStore((state) => state.gameMode);
@@ -56,7 +57,9 @@ export function ManastormStatus() {
return () => window.clearInterval(interval);
}, [encounter?.id, level, waveActive]);
if (gameMode !== "manastorm" || !encounter || status === "idle" || status === "failed") return null;
if (gameMode !== "manastorm" || !encounter || status === "idle" || status === "failed") return <>
<EditableHudElement id="manastorm" /><EditableHudElement id="objective" /><EditableHudElement id="messages" /><EditableHudElement id="cache" />
</>;
const dungeon = requireDungeonDefinition(activeDungeonId);
const stageLocation = encounter.location ?? dungeon.title;
@@ -85,7 +88,7 @@ export function ManastormStatus() {
return (
<>
<section className="manastorm-status" aria-label={`Manastorm level ${level}`}>
<EditableHudElement id="manastorm"><section className="manastorm-status" aria-label={`Manastorm level ${level}`}>
<div>
<span>{milestone ? "Milestone" : modeLabel}</span>
<strong>{level}</strong>
@@ -120,9 +123,9 @@ export function ManastormStatus() {
</span>
) : null}
</p>
</section>
<ManastormClientOverlay />
{phase === "loot" ? (
</section></EditableHudElement>
<EditableHudElement id="messages"><ManastormClientOverlay /></EditableHudElement>
<EditableHudElement id="objective">{phase === "loot" ? (
<button
type="button"
className={`manastorm-objective manastorm-objective--${status}`}
@@ -137,8 +140,8 @@ export function ManastormStatus() {
<span aria-hidden="true">{phase === "portal" ? "✦" : "⚔"}</span>
<strong>{objective}</strong>
</div>
)}
{rewardCache[0] ? (
)}</EditableHudElement>
<EditableHudElement id="cache">{rewardCache[0] ? (
<button
type="button"
className="manastorm-cache-open"
@@ -147,7 +150,7 @@ export function ManastormStatus() {
>
Open {rewardCache[0].cacheId.replace(/-/g, " ")}
</button>
) : null}
) : null}</EditableHudElement>
</>
);
}
+2
View File
@@ -42,6 +42,7 @@ function PauseDialog() {
{ id: "pause-bindings", run: () => openOverlay("bindings") },
{ id: "pause-talents", run: () => openOverlay("talents") },
{ id: "pause-options", run: () => openOverlay("options") },
{ id: "pause-ui-layout", run: () => openOverlay("ui-layout") },
...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
...(gameMode === "manastorm"
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
@@ -77,6 +78,7 @@ function PauseDialog() {
{menuButton("pause-bindings", "Ability controls", () => openOverlay("bindings"))}
{menuButton("pause-talents", skillMenuLabel, () => openOverlay("talents"))}
{menuButton("pause-options", "Options", () => openOverlay("options"))}
{menuButton("pause-ui-layout", "Edit UI & templates", () => openOverlay("ui-layout"))}
{isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
{gameMode === "manastorm"
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm)
+160
View File
@@ -0,0 +1,160 @@
import { useEffect, useRef, useState } from "react";
import { useShellStore } from "../app/shellStore";
import { retryCloudSave, useCloudSaveStatus } from "../app/onlineAccountClient";
import { useCombatStore } from "../game/combatStore";
import { defaultActionBarForCharacter } from "../game/abilityCatalog";
import { useGameStore } from "../game/store";
import {
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout,
exportUiPreferences, importUiPreferences, type HudElementId, type UiDevice,
} from "../game/uiLayout";
import { useUiLayoutStore } from "../game/uiLayoutStore";
export function UiLayoutEditor() {
const open = useGameStore((state) => state.overlay === "ui-layout");
const ui = useUiLayoutStore();
const session = useShellStore((state) => state.session);
const cloudSave = useCloudSaveStatus();
const abilities = useCombatStore((state) => state.abilities);
const [tab, setTab] = useState<UiDevice>(ui.device);
const [name, setName] = useState("My UI");
const [slot, setSlot] = useState(0);
const [message, setMessage] = useState("");
const [collapsed, setCollapsed] = useState(false);
const [dockRight, setDockRight] = useState(false);
const [search, setSearch] = useState("");
const [pendingImport, setPendingImport] = useState<ReturnType<typeof importUiPreferences> | null>(null);
const [transferText, setTransferText] = useState<string | null>(null);
const file = useRef<HTMLInputElement>(null);
const wasOpen = useRef(false);
useEffect(() => {
if (open && !wasOpen.current) {
useUiLayoutStore.getState().begin();
setTab(useUiLayoutStore.getState().device); setMessage("");
}
if (!open && wasOpen.current) useUiLayoutStore.getState().finish(false);
wasOpen.current = open;
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
const state = useUiLayoutStore.getState();
if (event.target instanceof Element && event.target.closest("input, textarea, select, [contenteditable='true']")) return;
if (event.code === "Escape") {
event.preventDefault(); event.stopImmediatePropagation();
if (state.selectedButton) useUiLayoutStore.setState({ selectedButton: null });
else { state.finish(false); useGameStore.getState().openOverlay("pause"); }
return;
}
if (state.mode !== "bindings" || !state.selectedButton) return;
event.preventDefault(); event.stopImmediatePropagation();
if (event.repeat) return;
const key = event.code === "Backspace" || event.code === "Delete" ? null : actionKeyForEvent(event);
if (!key && event.code !== "Backspace" && event.code !== "Delete") return;
const { bar, slot: button } = state.selectedButton;
state.updateDraft((draft) => bindDesktopSlot(draft, bar, button, key));
setMessage(key ? `Bound bar ${bar + 1}, button ${button + 1}. Any previous assignment was cleared.` : "Keybind cleared.");
useUiLayoutStore.setState({ selectedButton: null });
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [open]);
if (!open || !ui.draft) return null;
const draft = ui.draft;
const attempt = (run: () => void) => { try { run(); } catch (error) { setMessage(error instanceof Error ? error.message : "Could not save this UI."); } };
const saveAndClose = () => attempt(() => { ui.finish(true); useGameStore.getState().closeOverlay(); });
const validName = name.trim().length > 0;
const chooseSlot = (device: UiDevice, index: number) => {
setTab(device); setSlot(index); setName(ui.preferences.slots[device][index]?.name ?? "My UI");
};
const selected = ui.selectedButton;
const setAbility = (abilityId: string | null) => {
if (!selected) return;
ui.updateDraft((layout) => ({ ...layout, bars: layout.bars.map((bar, index) => index !== selected.bar ? bar : {
...bar, slots: bar.slots.map((button, index) => index === selected.slot ? { ...button, abilityId } : button),
}) }));
};
return <aside className={`ui-layout-editor ${collapsed ? "ui-layout-editor--collapsed" : ""} ${dockRight ? "ui-layout-editor--right" : ""}`} aria-label="UI layout editor">
<header><div><small>PERSONAL INTERFACE · {ui.device === "pc" ? "PC" : "MOBILE"}</small><h2>Edit your UI</h2></div>
<button type="button" onClick={() => setDockRight(!dockRight)} aria-label={dockRight ? "Dock editor left" : "Dock editor right"}>{dockRight ? "←" : "→"}</button>
<button type="button" onClick={() => setCollapsed(!collapsed)} aria-label={collapsed ? "Expand editor" : "Collapse editor"}>{collapsed ? "+" : "−"}</button></header>
{!collapsed && <div className="ui-layout-editor__body">
<p>Drag a widget’s label to move it. Hidden and inactive widgets appear as placeholders. Use the list to show them again.</p>
<details open><summary>UI templates · 6 slots</summary>
<div className="ui-layout-editor__tabs">{(["mobile", "pc"] as const).map((device) => <button key={device} type="button" aria-pressed={tab === device} onClick={() => chooseSlot(device, 0)}>{device === "pc" ? "PC" : "Mobile"} · 3 slots</button>)}</div>
<div className="ui-layout-editor__slots">{ui.preferences.slots[tab].map((template, index) => <button type="button" key={index} aria-pressed={slot === index} onClick={() => chooseSlot(tab, index)}><small>Slot {index + 1}</small><strong>{template?.name ?? "Empty"}</strong></button>)}</div>
<label>Template name<input value={name} maxLength={40} onChange={(event) => setName(event.target.value)} /></label>
<div className="ui-layout-editor__row">
<button type="button" disabled={!validName || tab !== ui.device} onClick={() => attempt(() => {
ui.persist({ ...ui.preferences, slots: { ...ui.preferences.slots, [tab]: ui.preferences.slots[tab].map((template, index) => index === slot ? { name: name.trim(), layout: draft } : template) } });
setMessage(`Saved ${name.trim()} to ${tab === "pc" ? "PC" : "Mobile"} slot ${slot + 1}.`);
})}>{ui.preferences.slots[tab][slot] ? "Overwrite slot" : "Save to slot"}</button>
<button type="button" disabled={!validName || !ui.preferences.slots[tab][slot]} onClick={() => attempt(() => {
ui.persist({ ...ui.preferences, slots: { ...ui.preferences.slots, [tab]: ui.preferences.slots[tab].map((template, index) => template && index === slot ? { ...template, name: name.trim() } : template) } });
setMessage("Template renamed.");
})}>Rename</button>
<button type="button" disabled={!ui.preferences.slots[tab][slot] || tab !== ui.device} onClick={() => {
useUiLayoutStore.setState({ draft: ui.preferences.slots[tab][slot]!.layout, selectedButton: null }); setMessage("Template loaded into the editor. Save UI to apply.");
}}>Load</button>
</div>
{tab !== ui.device && <small>Load and save {tab === "pc" ? "PC" : "Mobile"} layouts on that device type. You can rename or export either set here.</small>}
<div className="ui-layout-editor__row">
<button type="button" onClick={() => {
const blob = new Blob([exportUiPreferences({ ...ui.preferences, current: { ...ui.preferences.current, [ui.device]: draft } })], { type: "application/json" });
const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "healer-man-ui.json"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
}}>Export all</button>
<button type="button" onClick={() => file.current?.click()}>Import file</button>
<button type="button" onClick={() => setTransferText(exportUiPreferences({ ...ui.preferences, current: { ...ui.preferences.current, [ui.device]: draft } }))}>Export text</button>
<button type="button" onClick={() => setTransferText("")}>Import text</button>
<input ref={file} type="file" accept=".json,application/json" hidden onChange={(event) => {
const chosen = event.target.files?.[0]; event.target.value = ""; if (!chosen) return;
if (chosen.size > 256_000) { setMessage("UI files must be smaller than 256 KB."); return; }
void chosen.text().then((text) => setPendingImport(importUiPreferences(text))).catch((error: Error) => setMessage(error.message));
}} />
</div>
{transferText !== null && <div>
<label>UI configuration JSON<textarea aria-label="UI configuration JSON" value={transferText} maxLength={256000} onChange={(event) => setTransferText(event.target.value)} /></label>
<small>Copy this text to transfer your UI, or paste an exported configuration to import it.</small>
<button type="button" onClick={() => attempt(() => { setPendingImport(importUiPreferences(transferText)); setTransferText(null); })}>Review import</button>
<button type="button" onClick={() => setTransferText(null)}>Close text</button>
</div>}
{pendingImport && <div className="ui-layout-editor__import"><p>Replace all six slots and both saved layouts with this file?</p><button type="button" onClick={() => attempt(() => {
ui.persist(pendingImport); useUiLayoutStore.setState({ draft: pendingImport.current[ui.device], selectedButton: null }); setPendingImport(null); setMessage("Imported all six slots.");
})}>Replace with imported UI</button><button type="button" onClick={() => setPendingImport(null)}>Keep existing UI</button></div>}
<small>{session?.accessToken ? "Templates save to your account and upload with cloud saves. Sign in on another device to load them." : "Offline profile: saved on this device. Use export/import to transfer your UI."}</small>
</details>
<details><summary>Elements · move / hide</summary>
{(Object.entries(HUD_ELEMENTS) as [HudElementId, string][]).filter(([id]) => !id.startsWith("bar-") || ui.device === "pc").map(([id, label]) => <div key={id} className={`ui-layout-editor__element ${ui.selectedElement === id ? "is-selected" : ""}`}>
<label><input type="checkbox" checked={draft.elements[id].visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [id]: { ...layout.elements[id], visible: event.target.checked } } }))} />{label}</label>
<button type="button" aria-label={`Reset ${label} position`} onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [id]: { ...layout.elements[id], position: null, width: null } } }))}>Reset</button>
</div>)}
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements }))}>Reset all positions and visibility</button>
</details>
{ui.device === "pc" && <details open><summary>PC action bars</summary>
<label className="ui-layout-editor__check"><input type="checkbox" checked={draft.wowBars} onChange={(event) => ui.updateDraft((layout) => {
const combat = useCombatStore.getState();
const defaults = defaultActionBarForCharacter(combat.classId, combat.secondaryClassId, combat.level);
const empty = layout.bars.every((bar) => bar.slots.every((button) => !button.abilityId));
return { ...layout, wowBars: event.target.checked, bars: empty && event.target.checked ? layout.bars.map((bar, b) => ({ ...bar, slots: bar.slots.map((button, s) => ({ ...button, abilityId: defaults[b * 12 + s] ?? null })) })) : layout.bars };
})} />Use WoW action bars</label>
{draft.wowBars && <>
<label>Visible bar count<select value={draft.barCount} onChange={(event) => { ui.updateDraft((layout) => ({ ...layout, barCount: Number(event.target.value) })); useUiLayoutStore.setState({ selectedButton: null }); }}>{[1, 2, 3, 4, 5].map((count) => <option key={count}>{count}</option>)}</select></label>
{draft.bars.slice(0, draft.barCount).map((bar, index) => <div className="ui-layout-editor__row" key={index}><span>Bar {index + 1}</span>
<label>Rows<select aria-label={`Bar ${index + 1} rows`} value={12 / bar.columns} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, bars: layout.bars.map((bar, b) => b === index ? { ...bar, columns: 12 / Number(event.target.value) } : bar) }))}>{BAR_COLUMNS.map((rows) => <option key={rows}>{rows}</option>)}</select></label>
<label>Columns<select aria-label={`Bar ${index + 1} columns`} value={bar.columns} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, bars: layout.bars.map((bar, b) => b === index ? { ...bar, columns: Number(event.target.value) } : bar) }))}>{BAR_COLUMNS.map((cols) => <option key={cols}>{cols}</option>)}</select></label>
</div>)}
<button type="button" aria-pressed={ui.mode === "bindings"} onClick={() => useUiLayoutStore.setState({ mode: ui.mode === "bindings" ? "layout" : "bindings", selectedButton: null })}>{ui.mode === "bindings" ? "Finish keybind editing" : "Keybind edit mode"}</button>
<p>{ui.mode === "bindings" ? "Click a bar button, then press a key or modifier + key. Backspace/Delete clears it; Escape cancels selection. Bound keys take priority over game shortcuts." : "Click a bar button to assign an ability."}</p>
{selected && <div className="ui-layout-editor__assignment"><strong>Bar {selected.bar + 1} · Button {selected.slot + 1}{ui.mode === "bindings" ? " · Press a key…" : ""}</strong>
<button type="button" onClick={() => setAbility(null)}>Clear ability</button>
<label>Find ability<input value={search} onChange={(event) => setSearch(event.target.value)} /></label>
<div className="ui-layout-editor__abilities">{abilities.filter((ability) => !ability.passive && ability.name.toLowerCase().includes(search.toLowerCase())).map((ability) => <button key={ability.id} type="button" onClick={() => setAbility(ability.id)} aria-pressed={draft.bars[selected.bar].slots[selected.slot].abilityId === ability.id}>{ability.name}</button>)}</div>
</div>}
</>}
</details>}
<p role="status">{message}</p>
{session?.accessToken && <p role="status">{cloudSave.message}{cloudSave.state === "error" && <button type="button" onClick={retryCloudSave}>Retry cloud save</button>}</p>}
</div>}
<footer><button type="button" onClick={() => { ui.finish(false); useGameStore.getState().closeOverlay(); }}>Cancel edits</button><button type="button" className="button--primary" onClick={saveAndClose}>Save UI</button></footer>
</aside>;
}
+139
View File
@@ -0,0 +1,139 @@
.editable-hud--default { display: contents; }
.editable-hud--hidden { display: none !important; }
.editable-hud--positioned {
position: absolute;
z-index: 12;
pointer-events: none;
max-height: calc(100% - 12px);
}
.editable-hud--positioned > :not(.editable-hud__handle) {
position: relative !important;
inset: auto !important;
transform: none !important;
scale: none !important;
margin: 0 !important;
width: 100%;
min-width: 0;
max-width: 100%;
}
.editable-hud--positioned:empty { display: none; }
.editable-hud--editing {
min-height: 38px;
outline: 1px dashed var(--gold);
border-radius: 5px;
background: rgba(9, 25, 18, .6);
pointer-events: auto;
}
.editable-hud--editing > :not(.editable-hud__handle) { pointer-events: none; }
.editable-hud--editing > .desktop-action-bar { pointer-events: auto; }
.editable-hud--ghost > :not(.editable-hud__handle) { opacity: .22; }
.editable-hud__handle {
position: absolute;
z-index: 3;
top: 0;
left: 0;
padding: 3px 7px;
border: 1px solid var(--gold);
border-radius: 4px;
background: #16251c;
color: #ffe3a0;
font-size: 11px;
cursor: grab;
touch-action: none;
}
.editable-hud__handle:active { cursor: grabbing; }
.editable-hud--editing:has(.desktop-action-bar) { padding-top: 24px; }
.hud-bottom-stack > .editable-hud--default > .action-bar {
position: relative; bottom: auto; left: auto; width: min(420px, calc(100vw - 24px)); margin-top: 20px; transform: none; scale: none;
}
.hud-bottom-stack > .editable-hud--default > :is(.cast-bar, .control-hint) {
position: relative; inset: auto; max-width: min(560px, calc(100vw - 24px)); transform: none; scale: none;
}
.desktop-action-bar {
position: absolute;
bottom: var(--bar-offset, 30px);
left: 50%;
transform: translateX(-50%);
display: grid;
grid-template-columns: repeat(var(--bar-columns), minmax(0, 1fr));
width: min(calc(var(--bar-columns) * 46px + 8px), calc(100vw - 16px));
gap: 3px;
padding: 4px;
border: 1px solid var(--edge);
border-radius: 5px;
background: rgba(4, 12, 8, .82);
pointer-events: auto;
}
.desktop-action-button {
position: relative;
aspect-ratio: 1;
min-width: 0;
overflow: hidden;
padding: 0;
border: 1px solid #807044;
border-radius: 3px;
color: var(--ink);
background: linear-gradient(145deg, #293c30, #070e0a);
box-shadow: inset 0 0 5px #000;
cursor: pointer;
}
.desktop-action-button .action-slot__icon { position: absolute; inset: 0; width: 100%; height: 100%; display: grid; place-items: center; font-size: 16px; }
.desktop-action-button .action-slot__icon img { width: 100%; height: 100%; object-fit: cover; }
.desktop-action-button > kbd { position: absolute; z-index: 2; top: 1px; right: 2px; max-width: 100%; font: bold 10px "Segoe UI", sans-serif; text-shadow: 0 1px 2px #000, 1px 0 2px #000; }
.desktop-action-button > small { position: absolute; bottom: 2px; left: 3px; color: var(--muted); }
.desktop-action-button.is-unavailable .action-slot__icon { opacity: .4; }
.desktop-action-button:is(:hover, :focus-visible, .is-selected) { border-color: #ffe3a0; outline: 2px solid var(--gold); outline-offset: -2px; }
.desktop-action-button__cooldown { position: absolute; inset: 0; display: grid; place-items: center; background: #0006; font-size: 15px; font-weight: bold; text-shadow: 0 1px 2px #000; }
.desktop-action-feedback { position: absolute; top: 65%; left: 50%; transform: translateX(-50%); max-width: 80%; color: #f3dfaa; text-align: center; text-shadow: 0 1px 3px #000; font-size: 12px; }
.ui-layout-editor {
position: absolute;
z-index: 80;
top: max(10px, env(safe-area-inset-top));
left: max(10px, env(safe-area-inset-left));
width: min(360px, calc(100% - 20px));
max-height: calc(100% - 20px);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--gold);
border-radius: 10px;
background: #0b1a14f5;
color: var(--ink);
box-shadow: var(--shadow);
font-size: 12px;
pointer-events: auto;
}
.ui-layout-editor > header { display: flex; align-items: center; justify-content: space-between; padding: 12px; border-bottom: 1px solid var(--edge); }
.ui-layout-editor--right { left: auto; right: max(10px, env(safe-area-inset-right)); }
.single-display-frame:has(.ui-layout-editor) > .single-context-toggle { display: none; }
.ui-layout-editor h2 { margin: 3px 0 0; color: #f3dfaa; font: 22px Georgia, serif; }
.ui-layout-editor small { color: var(--muted); }
.ui-layout-editor p { line-height: 1.5; margin: 8px 0; }
.ui-layout-editor__body { padding: 0 12px 12px; overflow-y: auto; overscroll-behavior: contain; }
.ui-layout-editor details { padding: 10px 0; border-bottom: 1px solid var(--edge); }
.ui-layout-editor summary { margin-bottom: 10px; color: #eed7a0; font-weight: bold; cursor: pointer; }
.ui-layout-editor button, .ui-layout-editor select, .ui-layout-editor input:not([type="checkbox"]) { border: 1px solid var(--edge); border-radius: 4px; padding: 7px; background: #172b20; color: var(--ink); font: inherit; }
.ui-layout-editor button { cursor: pointer; }
.ui-layout-editor button:disabled { opacity: .4; cursor: default; }
.ui-layout-editor button[aria-pressed="true"] { border-color: var(--gold); background: #3d3a22; }
.ui-layout-editor button:focus-visible, .ui-layout-editor input:focus-visible, .ui-layout-editor select:focus-visible { outline: 2px solid var(--gold); }
.ui-layout-editor label { display: flex; gap: 6px; align-items: center; margin: 8px 0; }
.ui-layout-editor input:not([type="checkbox"]) { min-width: 0; flex: 1; width: 100%; }
.ui-layout-editor textarea { width: 100%; min-width: 0; min-height: 100px; resize: vertical; color: var(--ink); background: var(--cave); border: 1px solid var(--edge); }
.ui-layout-editor__tabs, .ui-layout-editor__row, .ui-layout-editor > footer { display: flex; gap: 5px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
.ui-layout-editor__slots { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; }
.ui-layout-editor__slots button { min-width: 0; }
.ui-layout-editor__slots :is(strong, small) { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ui-layout-editor__element { display: flex; justify-content: space-between; align-items: center; gap: 5px; }
.ui-layout-editor__element.is-selected { background: #a2853426; }
.ui-layout-editor__element button { padding: 3px 6px; }
.ui-layout-editor__abilities { display: grid; max-height: 160px; overflow-y: auto; gap: 3px; }
.ui-layout-editor__assignment { margin-top: 10px; padding: 8px; border: 1px solid var(--edge); }
.ui-layout-editor > footer { margin: 0; padding: 10px 12px; justify-content: flex-end; border-top: 1px solid var(--edge); }
.ui-layout-editor > footer .button--primary { color: #0d170e; background: #c3d890; }
@media (max-width: 650px), (max-height: 500px) {
.ui-layout-editor { width: min(310px, calc(100% - 16px)); top: 8px; left: 8px; max-height: calc(100% - 16px); }
.ui-layout-editor--right { left: auto; right: 8px; }
.ui-layout-editor button { min-height: 36px; }
.editable-hud__handle { min-height: 30px; font-size: 10px; }
}