Release Healer Man 0.1.28
This commit is contained in:
1 parent
ed8b9e3719
commit
8c9809d3e0
23 files changed
+975
-55
No files matched your search
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.27",
|
||||
"versionCode": 1027
|
||||
"versionName": "0.1.28",
|
||||
"versionCode": 1028
|
||||
}
|
||||
+18
-1
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()];
|
||||
}
|
||||
@@ -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" />;
|
||||
|
||||
@@ -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>; })}</>;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user