Compare commits

..
3 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
phenom 147c2804d4 Release Healer Man 0.1.26 2026-09-09 22:18:15 -04:00
48 changed files with 2061 additions and 106 deletions

No files matched your search

+194
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, evidence required before calling a visual bug fixed. Update it whenever scene,
camera, input, physics, mob-rendering, or asset-loading code changes. 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 ## 2026-08-18 GM Manastorm administration - COMPLETE
- Server-authorized `Phenom` access, revisioned SQLite configuration, safe - Server-authorized `Phenom` access, revisioned SQLite configuration, safe
@@ -1854,3 +1887,164 @@ Vite's existing large-chunk advisory remains informational.
- Verification passed all 1,133 Vitest cases across 208 files. The production - Verification passed all 1,133 Vitest cases across 208 files. The production
build completed with 974 modules transformed; arena, KTX2, and environment- build completed with 974 modules transformed; arena, KTX2, and environment-
performance audits remained green, and the release-candidate check passed. performance audits remained green, and the release-candidate check passed.
# 2026-09-09 — dungeon wall and nameplate performance regressions
- The 0.1.24 material fallback honored BLEND tags on legacy WoW WMO walls,
floors, and ceilings, disabling their depth writes. WoW environments now use
their authored material roles again; RuneWaker environments retain their
source MASK/BLEND semantics. Shipped Wailing Caverns wall materials, vines,
and water are covered by regression tests.
- The 0.1.25 boss-name occlusion scanned the complete rendered scene with
Three.js raycasts. A live Kalin's Shrine CPU profile was dominated by triangle
intersection and vertex decoding. Boss names and nearby NPC prompts now use
bounded camera-to-label queries against Rapier's existing physical spatial
index, at most ten times per second, skipping offscreen labels. Walls, floors,
and ceilings occlude labels; actors and trigger sensors do not.
- In an isolated offline Edge playtest at 1440 x 900 on an AMD RX 9070 XT,
Kalin's Shrine improved from about 10 FPS (98.5 ms/frame, 167 draw calls,
DPR 1.125) to 55.5-56.1 FPS (17.8-18.0 ms/frame, 168-170 draw calls,
DPR 1.5). This is a representative entrance/combat comparison, not an audit
of all thirty RoM dungeons. The environment alone took about 0.8 ms CPU and
0.5 ms GPU with 14 draw calls.
- Wailing Caverns screenshots show continuous opaque cavern walls and ceilings.
A controlled toggle of the old material flags reproduced the missing wall
sections at the same camera position; restoring depth-writing materials fixed
them. Its full scene ran at about 57 FPS. No uncaught client errors appeared.
- Evidence and repeatable local benchmark helpers are under
`playtest-artifacts/2026-09-09-dungeon-rendering/`: `kalin-before.cpuprofile`,
`kalin-after.cpuprofile`, `kalin-after-metrics.json`, `kalin-before.png`,
`kalin-after.png`, `wailing-regression.png`, and `wailing-restored.png`.
- All 26 focused material, label occlusion, combat sight, aggro visibility,
instancing, and NPC-prompt tests passed. Physical-occlusion tests exercise
actual Rapier worlds rather than just source-code assertions.
# 2026-09-09 — campaign rendering audit and Thor performance preparation
- Audited the actual visual GLB headers for all 54 WoW campaign dungeons:
9,402 materials, including 3,586 legacy BLEND materials restored to opaque
rendering by the shared policy. Every WoW package contained affected
materials. A permanent shipped-asset test covers all 54 definitions and
retains explicit cutout, blend, and additive roles. This is material-policy
coverage, not a claim that every room has been visually traversed.
- Live screenshots checked Wailing Caverns, Shadowfang Keep, Utgarde Pinnacle,
the Nexus, and Tinkertech Showdown. The sampled walls, floors, stairs, and
ceilings remain solid; authored lighting effects remain transparent. Kalin's
Shrine and Forsaken Abbey provided RoM rendering checks. Roster and in-dungeon
screenshots show character bodies and equipped weapon silhouettes after the
shared character animation change.
- A controlled Utgarde Pinnacle entrance test found about 1,739 draw calls,
1.95 million triangles, and 30.5 FPS on the desktop GPU. Clipping at the
existing full-fog distance reduced this to about 209 calls, 567,000 triangles,
and 60 FPS at the same viewpoint. The camera now follows each dungeon's fog
distance, including transitions that retain the existing Canvas/camera.
The background uses the fog color to avoid a clipping seam at the horizon.
All 84 dungeon definitions have render-distance coverage.
- Static dungeon visual transforms are cached until the root or its parent's
world transform changes. Tests verify imported matrices, repeated unchanged
frames, root movement, and parent movement. Characters, moving world objects,
and physics remain outside this immutable visual cache.
The final 63-second Utgarde sample held 60 FPS with 213 draw calls:
main-thread work averaged 9.2 ms/frame, renderer CPU averaged 7.5 ms
(p95 8.3 ms), and GPU averaged 2.7 ms (p95 4.1 ms). Movement and jump input
continued to animate the player while the environment remained stationary.
- Character locomotion and jump clip selections are cached by animation
library and weapon stance. The final Kalin CPU profile contains no sampled
per-frame base-clip selection/name parsing. Frame-cap deadlines now tolerate
sub-millisecond timestamp rounding without drifting or emitting catch-up
frames; tests cover 60 FPS on rounded 60 Hz and 120 Hz callbacks.
- A 31.5-second Kalin sample with a connected browser companion display held
60 FPS: main-thread work averaged 8.7 ms/frame, renderer CPU averaged 5.4 ms
(p95 5.9 ms), and GPU averaged 2.4 ms (p95 3.4 ms). The companion used about
5.1 ms of main-thread time per second. Recent ability-cost snapshot work was
not a dominant hotspot in this sample. Startup warmup is bounded to loading;
companion snapshots are already limited to ten per second during gameplay.
- These are isolated offline Edge desktop samples on an AMD RX 9070 XT at
1920 x 1080 CSS pixels, DPR 1.5 (2880 x 1620 renderbuffer). Party composition
can change between dungeon entries. They do not certify Android performance
or sustained busy-combat headroom. ADB listed no connected Android device.
- Thor acceptance should use the actual model, both native displays, target
resolution, and a sustained 20-minute traversal/combat run after warmup.
Provisional headroom target: p95 main-thread and GPU work each at or below
12 ms, with p99 presented frame intervals within the 16.7 ms 60-FPS budget.
Test large pulls, boss effects, menus, and repeated dungeon transitions;
record thermal behavior and frame-time spikes, not only average FPS.
The environment asset audit still has 60 grandfathered budget exceptions;
its green result means no new asset regression, not a Thor FPS guarantee.
- Evidence is in `playtest-artifacts/2026-09-09-thor-audit/`, including roster,
dungeon, and companion screenshots, CPU profiles, and CPU/GPU JSON samples.
The full material report is
`playtest-artifacts/2026-09-09-dungeon-rendering/wow-material-audit.json`.
- Validation passed 227 focused tests covering avatar rendering and animation,
camera distances, material policies, frame pacing, static transforms, label
visibility, and companion snapshots. The final static-cache change also
passed the 105-test scene subset. The production build and release-candidate
check passed; the existing large-bundle advisory remains.
# 2026-09-09 — additional CPU headroom from culling and shared skeletons
- Prepared dungeon visuals now group static siblings into conservative spatial
bounds. One scene hook checks these bounds after the current camera and world
matrices update, before Three.js traverses individual meshes. Bounds include
complete wall geometry and all instances; lights, animated meshes, and meshes
explicitly disabling frustum culling remain outside these groups. The hook
supports streamed chunks and restores its predecessor after cleanup.
- At the same Utgarde Pinnacle entrance, only 16 of 1,383 spatial groups were
visible. The renderer examined 108 static meshes instead of 7,002, retaining
the same 206 draw calls and 566,913 rendered triangles. Controlled culling
toggles produced byte-identical canvas PNGs across eight camera headings.
- In the same running scene, spatial grouping reduced average main-thread work
from 9.45 to 6.83 ms/frame, and renderer CPU from 7.81 to 5.31 ms/frame.
Removing containers emptied by static instancing reduced the final fresh-run
averages further to 6.49 ms main-thread and 4.85 ms renderer CPU (p95 5.2 ms).
The final sample lasted 87.8 seconds and held 60 FPS. Authored empty scene
nodes are preserved; only containers emptied by our batching are removed.
- The shared character renderer still uses SkeletonUtils.clone, then reuses
equivalent skeleton palettes within that owned clone. Bone identities, order,
and inverse bind matrices must all match; per-mesh bind transforms remain
unchanged. Different characters retain independent bones and animations.
Owned bone textures are now explicitly disposed during character teardown.
- A live character audit found 66 skeleton objects for one player palette and
256 objects for four party palettes. A Wailing Caverns toggle removed 309
duplicate skeletons and produced an identical rendered image. Tests verify
posed vertices, differing bind transforms, incompatible palettes, independent
cloned characters, and redundant texture disposal.
In the same Wailing scene, main-thread work fell from 7.40 to 6.70 ms/frame;
renderer CPU fell from 5.51 to 4.86 ms/frame, and allocated GPU textures fell
from 292 to 217. This measures palette sharing after spatial culling was
already enabled. Both samples held 60 FPS.
- The final built runtime showed a complete roster body and staff with one
skeleton for 66 skinned meshes. Kalin's Shrine showed one player palette and
four independent party palettes; walking retained the player body and weapon
silhouette. Wailing Caverns and Utgarde screenshots retained solid dungeon
surfaces. There were no uncaught client errors during these playtests.
- All 210 targeted avatar, visibility, instancing, material, camera-distance,
and label tests passed. The production build and release-candidate check
passed. The existing large-bundle advisory remains.
- Evidence is under `playtest-artifacts/2026-09-09-static-culling/`: paired PNGs,
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", "name": "healer-man",
"version": "0.1.25", "version": "0.1.28",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.25", "version": "0.1.28",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.25", "version": "0.1.28",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "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/manifest.json",
"/assets/characters/wow335a-v1/human/", "/assets/characters/wow335a-v1/human/",
"/assets/creatures/wailing-caverns/", "/assets/creatures/wailing-caverns/",
"/assets/game/arenas/",
"/assets/game/dungeons/wailing-caverns/", "/assets/game/dungeons/wailing-caverns/",
]; ];
const BUNDLED_ASSET_PATHS = new Set([ const BUNDLED_ASSET_PATHS = new Set([
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.25", "versionName": "0.1.28",
"versionCode": 1025 "versionCode": 1028
} }
+18 -1
View File
@@ -1,4 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import "./ui/uiLayout.css";
import { DualDisplayFrame } from "./components/DualDisplayFrame"; import { DualDisplayFrame } from "./components/DualDisplayFrame";
import { installInput, releasePointerLock } from "./game/inputManager"; import { installInput, releasePointerLock } from "./game/inputManager";
import { CombatBridge } from "./game/CombatBridge"; import { CombatBridge } from "./game/CombatBridge";
@@ -26,12 +27,27 @@ import { DungeonRunCheckpointBridge } from "./game/DungeonRunCheckpointBridge";
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge"; import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction"; import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction";
import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt"; 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() { function InputBridge() {
const overlay = useGameStore((state) => state.overlay); const overlay = useGameStore((state) => state.overlay);
const companionOpen = useGameStore((state) => state.companionOpen); const companionOpen = useGameStore((state) => state.companionOpen);
useEffect(() => installInput({ 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(), togglePause: () => useGameStore.getState().togglePause(),
toggleMap: () => useGameStore.getState().toggleMap(), toggleMap: () => useGameStore.getState().toggleMap(),
toggleInventory: () => useGameStore.getState().toggleOverlay("inventory"), toggleInventory: () => useGameStore.getState().toggleOverlay("inventory"),
@@ -98,10 +114,11 @@ export default function GameRuntime() {
<ManastormBridge /> <ManastormBridge />
<SceneErrorBoundary><GameScene /></SceneErrorBoundary> <SceneErrorBoundary><GameScene /></SceneErrorBoundary>
<Hud /> <Hud />
<DungeonInteractionPrompt /> <EditableHudElement id="interaction"><DungeonInteractionPrompt /></EditableHudElement>
<MapPanel /> <MapPanel />
<GameplayPanels /> <GameplayPanels />
<PauseMenu /> <PauseMenu />
<UiLayoutEditor />
<GmReviewCard /> <GmReviewCard />
<GmWorldEditor /> <GmWorldEditor />
</main> </main>
+20 -3
View File
@@ -1,4 +1,5 @@
import type { CharacterProfile, PlayerSession } from "./types"; import type { CharacterProfile, PlayerSession } from "./types";
import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout";
import { import {
contentCategoryForClass, contentCategoryForClass,
isRomClassId, isRomClassId,
@@ -64,6 +65,7 @@ interface ProfileDatabase {
accounts: LocalAccount[]; accounts: LocalAccount[];
rosters: Record<string, CharacterProfile[]>; rosters: Record<string, CharacterProfile[]>;
presentationSettingsByOwner: Record<string, CombatPresentationSettings>; presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
uiPreferencesByOwner?: Record<string, UiPreferences>;
} }
export interface AccountResult { export interface AccountResult {
@@ -202,6 +204,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
accounts?: unknown; accounts?: unknown;
rosters?: unknown; rosters?: unknown;
presentationSettingsByOwner?: unknown; presentationSettingsByOwner?: unknown;
uiPreferencesByOwner?: unknown;
}) | null; }) | null;
if (!parsed || ![1, 2, 3, 4, 5].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") { if (!parsed || ![1, 2, 3, 4, 5].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
return emptyDatabase(); return emptyDatabase();
@@ -243,7 +246,9 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
return [ownerId, normalized]; 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); if (parsed.version !== 5 || presentationSettingsMigrated) writeDatabase(database, storage);
return database; return database;
} catch { } catch {
@@ -254,11 +259,23 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void { function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void {
storage?.setItem(DATABASE_KEY, JSON.stringify(database)); storage?.setItem(DATABASE_KEY, JSON.stringify(database));
if (!storage || !rosterChangeListener) return; if (!storage || !rosterChangeListener) return;
for (const [ownerId, characters] of Object.entries(database.rosters)) { for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.uiPreferencesByOwner ?? {})])) {
rosterChangeListener(ownerId, characters); 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 { function bytesToBase64(bytes: Uint8Array): string {
let result = ""; let result = "";
for (let index = 0; index < bytes.length; index += 3) { for (let index = 0; index < bytes.length; index += 3) {
+21 -2
View File
@@ -1,8 +1,11 @@
import { Capacitor } from "@capacitor/core"; import { Capacitor } from "@capacitor/core";
import { create } from "zustand";
import { import {
listCharacters, listCharacters,
replaceCharactersForOwner, replaceCharactersForOwner,
setRosterChangeListener, setRosterChangeListener,
loadUiPreferences,
saveUiPreferences,
type AccountResult, type AccountResult,
} from "./accountRepository"; } from "./accountRepository";
import type { CharacterProfile, PlayerSession } from "./types"; import type { CharacterProfile, PlayerSession } from "./types";
@@ -20,7 +23,7 @@ interface AuthResponse {
interface CloudSaveResponse { interface CloudSaveResponse {
revision: number; revision: number;
updatedAt: number | null; updatedAt: number | null;
data: { version?: number; characters?: unknown }; data: { version?: number; characters?: unknown; uiPreferences?: unknown };
} }
interface MeResponse { interface MeResponse {
@@ -39,6 +42,8 @@ let suppressUpload = false;
let pendingCharacters: CharacterProfile[] | null = null; let pendingCharacters: CharacterProfile[] | null = null;
let saveTimer: ReturnType<typeof setTimeout> | null = null; let saveTimer: ReturnType<typeof setTimeout> | null = null;
let saveQueue = Promise.resolve(); 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 { export function onlineApiOrigin(): string {
const configured = String(import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, ""); const configured = String(import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, "");
@@ -100,18 +105,24 @@ function flushCloudSave(): void {
const characters = pendingCharacters; const characters = pendingCharacters;
pendingCharacters = null; pendingCharacters = null;
if (!session?.accessToken || !characters) return; if (!session?.accessToken || !characters) return;
const uiPreferences = loadUiPreferences(session.ownerId);
const sequence = saveSequence;
saveQueue = saveQueue.then(async () => { saveQueue = saveQueue.then(async () => {
await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", { await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {
method: "PUT", method: "PUT",
body: JSON.stringify({ data: { version: 1, characters } }), body: JSON.stringify({ data: { version: 1, characters, uiPreferences } }),
}, session.accessToken); }, session.accessToken);
if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "synced", message: "Saved to your account." });
}).catch((error) => { }).catch((error) => {
console.error("Healer Man cloud save failed", 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 { function queueCloudSave(ownerId: string, characters: readonly CharacterProfile[]): void {
if (suppressUpload || !activeSession?.accessToken || activeSession.ownerId !== ownerId) return; if (suppressUpload || !activeSession?.accessToken || activeSession.ownerId !== ownerId) return;
saveSequence += 1;
useCloudSaveStatus.setState({ state: "pending", message: "Uploading to your account…" });
pendingCharacters = characters.map((character) => ({ ...character })); pendingCharacters = characters.map((character) => ({ ...character }));
if (saveTimer) clearTimeout(saveTimer); if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => { saveTimer = setTimeout(() => {
@@ -123,15 +134,23 @@ function queueCloudSave(ownerId: string, characters: readonly CharacterProfile[]
setRosterChangeListener(queueCloudSave); setRosterChangeListener(queueCloudSave);
export function activateOnlineSession(session: PlayerSession): void { export function activateOnlineSession(session: PlayerSession): void {
if (activeSession?.ownerId !== session.ownerId && saveTimer) {
clearTimeout(saveTimer); saveTimer = null; flushCloudSave();
}
activeSession = session.accessToken ? session : null; 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[]> { export async function hydrateOnlineSession(session: PlayerSession): Promise<CharacterProfile[]> {
if (!session.accessToken) return listCharacters(session.ownerId); if (!session.accessToken) return listCharacters(session.ownerId);
activateOnlineSession(session); activateOnlineSession(session);
const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken); const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
suppressUpload = true; suppressUpload = true;
try { try {
saveUiPreferences(session.ownerId, cloud.data?.uiPreferences);
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []); return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []);
} finally { } finally {
suppressUpload = false; 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();
});
});
+29 -10
View File
@@ -25,9 +25,12 @@ import {
type Material, type Material,
type Mesh, type Mesh,
type Object3D, type Object3D,
type Skeleton,
type SkinnedMesh,
type Texture, type Texture,
} from "three"; } from "three";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { shareClonedSkeletons } from "./sharedSkeletons";
import type { import type {
CharacterAppearance, CharacterAppearance,
ClassId, ClassId,
@@ -123,6 +126,7 @@ function configureMaterial(material: Material): void {
function cloneAvatarScene(root: Object3D): Object3D { function cloneAvatarScene(root: Object3D): Object3D {
const scene = cloneSkeleton(root); const scene = cloneSkeleton(root);
shareClonedSkeletons(scene);
scene.traverse((node) => { scene.traverse((node) => {
const mesh = node as Mesh; const mesh = node as Mesh;
if (!mesh.isMesh) return; if (!mesh.isMesh) return;
@@ -267,13 +271,17 @@ function LoadedCharacterModel({
useEffect(() => () => { useEffect(() => () => {
const ownedMaterials = new Set<Material>(); const ownedMaterials = new Set<Material>();
const ownedSkeletons = new Set<Skeleton>();
scene.traverse((node) => { scene.traverse((node) => {
const skinned = node as SkinnedMesh;
if (skinned.isSkinnedMesh) ownedSkeletons.add(skinned.skeleton);
const mesh = node as Mesh; const mesh = node as Mesh;
if (!mesh.isMesh) return; if (!mesh.isMesh) return;
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
materials.forEach((material) => ownedMaterials.add(material)); materials.forEach((material) => ownedMaterials.add(material));
}); });
ownedMaterials.forEach((material) => material.dispose()); ownedMaterials.forEach((material) => material.dispose());
ownedSkeletons.forEach((skeleton) => skeleton.dispose());
}, [scene]); }, [scene]);
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -359,22 +367,33 @@ function LoadedCharacterModel({
?? gltf.animations.find((candidate) => candidate.name.toLowerCase().includes(normalized)); ?? gltf.animations.find((candidate) => candidate.name.toLowerCase().includes(normalized));
}; };
const selectClip = (motion: CharacterBaseMotion): AnimationClip | undefined => ( // The GLTF clip library and weapon stance change far less often than frames.
selectCharacterBaseClip(gltf.animations, motion, weaponStance) // Resolve locomotion once so five party members do not repeatedly parse and
?? namedClip(entry.animations[motion]) // sort the same hundreds of animation names while standing or running.
?? namedClip(entry.animations.idle) const baseClips = useMemo(() => Object.fromEntries(
?? gltf.animations[0] (["idle", "walk", "run"] as const).map((motion) => [
); motion,
selectCharacterBaseClip(gltf.animations, motion, weaponStance)
?? namedClip(entry.animations[motion])
?? namedClip(entry.animations.idle)
?? gltf.animations[0],
]),
) as Record<CharacterBaseMotion, AnimationClip | undefined>, [gltf.animations, entry.animations, weaponStance]);
const verticalClips = useMemo(() => ({
rising: [selectCharacterVerticalClip(gltf.animations, "rising", false), selectCharacterVerticalClip(gltf.animations, "rising", true)],
falling: [selectCharacterVerticalClip(gltf.animations, "falling", false), selectCharacterVerticalClip(gltf.animations, "falling", true)],
landing: [selectCharacterVerticalClip(gltf.animations, "landing", false), selectCharacterVerticalClip(gltf.animations, "landing", true)],
}), [gltf.animations]);
const switchMotion = (motion: CharacterLocomotionMotion, moving: boolean) => { const switchMotion = (motion: CharacterLocomotionMotion, moving: boolean) => {
if (overrideAction.current) return; if (overrideAction.current) return;
const verticalSelection = motion === "rising" || motion === "falling" || motion === "landing" const verticalSelection = motion === "rising" || motion === "falling" || motion === "landing"
? selectCharacterVerticalClip(gltf.animations, motion, moving) ? verticalClips[motion][moving ? 1 : 0]
: null; : null;
const fallbackMotion: CharacterBaseMotion = moving ? "run" : "idle"; const fallbackMotion: CharacterBaseMotion = moving ? "run" : "idle";
const clip = verticalSelection?.clip ?? selectClip( const clip = verticalSelection?.clip ?? baseClips[
motion === "idle" || motion === "walk" || motion === "run" ? motion : fallbackMotion, motion === "idle" || motion === "walk" || motion === "run" ? motion : fallbackMotion
); ];
if (!clip) return; if (!clip) return;
if (currentMotion.current === motion && currentAction.current?.getClip() === clip) return; if (currentMotion.current === motion && currentAction.current?.getClip() === clip) return;
const loop = verticalSelection?.loop ?? true; const loop = verticalSelection?.loop ?? true;
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from "vitest";
import { Bone, BufferGeometry, Float32BufferAttribute, Group, Matrix4, MeshBasicMaterial, Skeleton, SkinnedMesh, Vector3 } from "three";
import { clone } from "three/examples/jsm/utils/SkeletonUtils.js";
import { shareClonedSkeletons } from "./sharedSkeletons";
function skinnedMesh(bone: Bone, inverse = new Matrix4()): SkinnedMesh {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute([1, 0, 0], 3));
geometry.setAttribute("skinIndex", new Float32BufferAttribute([0, 0, 0, 0], 4));
geometry.setAttribute("skinWeight", new Float32BufferAttribute([1, 0, 0, 0], 4));
const mesh = new SkinnedMesh(geometry, new MeshBasicMaterial());
mesh.bind(new Skeleton([bone], [inverse]), new Matrix4());
return mesh;
}
function character(): Group {
const root = new Group();
const bone = new Bone();
bone.name = "spine";
root.add(bone, skinnedMesh(bone), skinnedMesh(bone));
return root;
}
describe("shared skeleton palettes in character clones", () => {
it("preserves posed vertices and each mesh's bind transform while sharing uploads", () => {
const root = character();
const first = root.children[1] as SkinnedMesh;
const second = root.children[2] as SkinnedMesh;
second.bindMatrix.makeTranslation(0, 2, 0);
root.children[0].rotation.z = 0.5;
root.updateMatrixWorld(true);
const poseBefore = [first, second].map((mesh) => mesh.applyBoneTransform(0, new Vector3(1, 0, 0)));
const bindBefore = second.bindMatrix.clone();
expect(shareClonedSkeletons(root)).toBe(1);
expect(first.skeleton).toBe(second.skeleton);
expect(second.bindMatrix.equals(bindBefore)).toBe(true);
[first, second].forEach((mesh, index) => {
expect(mesh.applyBoneTransform(0, new Vector3(1, 0, 0)).distanceTo(poseBefore[index])).toBeLessThan(1e-9);
});
});
it("keeps separate cloned characters and the loader source independently animated", () => {
const source = character();
const first = clone(source);
const second = clone(source);
shareClonedSkeletons(first);
shareClonedSkeletons(second);
const firstMesh = first.children[1] as SkinnedMesh;
const secondMesh = second.children[1] as SkinnedMesh;
expect(firstMesh.skeleton).not.toBe(secondMesh.skeleton);
expect(firstMesh.skeleton.bones[0]).not.toBe(secondMesh.skeleton.bones[0]);
first.children[0].position.y = 3;
first.updateMatrixWorld(true);
second.updateMatrixWorld(true);
expect(firstMesh.applyBoneTransform(0, new Vector3(1, 0, 0)).y).toBe(3);
expect(secondMesh.applyBoneTransform(0, new Vector3(1, 0, 0)).y).toBe(0);
expect(source.children[0].position.y).toBe(0);
});
it("retains different inverse bind matrices and different bone objects", () => {
const root = character();
const otherBone = new Bone();
otherBone.name = "spine";
root.add(skinnedMesh(root.children[0] as Bone, new Matrix4().makeTranslation(1, 0, 0)));
root.add(otherBone, skinnedMesh(otherBone));
expect(shareClonedSkeletons(root)).toBe(1);
const meshes = root.children.filter((node) => (node as SkinnedMesh).isSkinnedMesh) as SkinnedMesh[];
expect(new Set(meshes.map((mesh) => mesh.skeleton)).size).toBe(3);
});
it("disposes redundant bone textures once and tolerates already shared skeletons", () => {
const root = character();
const first = root.children[1] as SkinnedMesh;
const second = root.children[2] as SkinnedMesh;
second.skeleton.computeBoneTexture();
const dispose = vi.spyOn(second.skeleton.boneTexture!, "dispose");
expect(shareClonedSkeletons(root)).toBe(1);
expect(dispose).toHaveBeenCalledTimes(1);
expect(shareClonedSkeletons(root)).toBe(0);
expect(first.skeleton).toBe(second.skeleton);
expect(dispose).toHaveBeenCalledTimes(1);
});
});
+33
View File
@@ -0,0 +1,33 @@
import type { Object3D, Skeleton, SkinnedMesh } from "three";
function samePalette(left: Skeleton, right: Skeleton): boolean {
return left.bones.length === right.bones.length
&& left.boneInverses.length === right.boneInverses.length
&& left.bones.every((bone, index) => bone === right.bones[index])
&& left.boneInverses.every((inverse, index) => inverse.equals(right.boneInverses[index]));
}
/** Share identical palettes within an owned clone, never across characters. */
export function shareClonedSkeletons(root: Object3D): number {
const palettes = new Map<Skeleton["bones"][number] | undefined, Skeleton[]>();
const redundant = new Set<Skeleton>();
root.traverse((node) => {
const mesh = node as SkinnedMesh;
if (!mesh.isSkinnedMesh) return;
const skeleton = mesh.skeleton;
const firstBone = skeleton.bones[0];
const candidates = palettes.get(firstBone) ?? [];
const shared = candidates.find((candidate) => samePalette(candidate, skeleton));
if (shared) {
if (shared !== skeleton) {
mesh.skeleton = shared;
redundant.add(skeleton);
}
} else {
candidates.push(skeleton);
palettes.set(firstBone, candidates);
}
});
for (const skeleton of redundant) skeleton.dispose();
return redundant.size;
}
+1 -1
View File
@@ -45,7 +45,7 @@ export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onAc
} }
}); });
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.repeat) return; if (event.repeat || event.defaultPrevented) return;
const target = event.target as HTMLElement | null; const target = event.target as HTMLElement | null;
const isEditable = target?.matches("input, textarea, select, [contenteditable='true']") ?? false; const isEditable = target?.matches("input, textarea, select, [contenteditable='true']") ?? false;
if (event.code === "KeyC" && !isEditable) { if (event.code === "KeyC" && !isEditable) {
+57 -1
View File
@@ -1,5 +1,7 @@
import { readFileSync } from "node:fs"; 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"; import { BROWSER_CONTENT_CACHE_NAME } from "./contentManager";
const workerSource = readFileSync( const workerSource = readFileSync(
@@ -7,7 +9,61 @@ const workerSource = readFileSync(
"utf8", "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", () => { 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", () => { 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(`const CONTENT_CACHE_NAME = "${BROWSER_CONTENT_CACHE_NAME}";`);
expect(workerSource).toContain('request.method !== "GET"'); expect(workerSource).toContain('request.method !== "GET"');
+27
View File
@@ -66,6 +66,33 @@ afterEach(() => {
}); });
describe("gameplay input bindings", () => { 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", () => { it("reserves I, P, N, and E for equipment and loot interactions", () => {
expect(keyboardCommandForCode("KeyI")).toBe("inventory"); expect(keyboardCommandForCode("KeyI")).toBe("inventory");
expect(keyboardCommandForCode("KeyE")).toBe("loot"); expect(keyboardCommandForCode("KeyE")).toBe("loot");
+10 -1
View File
@@ -22,6 +22,9 @@ export interface InputSnapshot {
} }
export interface InputActions { export interface InputActions {
/** Runs before built-in shortcuts. True consumes the key, including empty custom slots. */
handleCustomKey?: (event: KeyboardEvent) => boolean;
desktopActionBarsActive?: () => boolean;
togglePause: () => void; togglePause: () => void;
toggleMap: () => void; toggleMap: () => void;
toggleInventory: () => void; toggleInventory: () => void;
@@ -292,6 +295,12 @@ export function installInput(nextActions: InputActions): () => void {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null; const target = event.target as HTMLElement | null;
if (target?.matches("input, textarea, select, [contenteditable='true']")) return; 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)) { if (["ShiftLeft", "ShiftRight", "AltLeft", "AltRight"].includes(event.code)) {
keyboardModifiers.add(event.code); keyboardModifiers.add(event.code);
nextActions.setInputMode("keyboard"); nextActions.setInputMode("keyboard");
@@ -312,7 +321,7 @@ export function installInput(nextActions: InputActions): () => void {
else nextActions.cancelGroundTarget(); else nextActions.cancelGroundTarget();
return; return;
} }
const actionControl = actionControlForKeyboardCode(event.code); const actionControl = nextActions.desktopActionBarsActive?.() ? null : actionControlForKeyboardCode(event.code);
if (actionControl) { if (actionControl) {
if (nextActions.gameplayActionsBlocked()) return; if (nextActions.gameplayActionsBlocked()) return;
event.preventDefault(); 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 AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
export type InputMode = "keyboard" | "gamepad"; export type InputMode = "keyboard" | "gamepad";
export type GameMode = "dungeon" | "manastorm"; 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 { interface PlayerSnapshot {
position: Vector3Tuple; 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()];
}
+47
View File
@@ -0,0 +1,47 @@
import { Html } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { useRapier } from "@react-three/rapier";
import { useRef, useState, type ComponentPropsWithoutRef } from "react";
import { Group, Vector3 } from "three";
import { hasDungeonLabelLineOfSight } from "./dungeonLabelVisibility";
const VISIBILITY_INTERVAL = 0.1;
/** Keeps world labels behind solid geometry without raycasting visual GLBs. */
export function DungeonOccludedHtml({
position = [0, 0, 0],
style,
...props
}: Omit<ComponentPropsWithoutRef<typeof Html>, "position" | "occlude" | "onOcclude"> & {
readonly position?: readonly [number, number, number];
}) {
const { rapier, world } = useRapier();
const anchor = useRef<Group>(null);
const cameraPosition = useRef(new Vector3());
const labelPosition = useRef(new Vector3());
const projected = useRef(new Vector3());
const elapsed = useRef(VISIBILITY_INTERVAL);
const visibleRef = useRef(false);
const [visible, setVisible] = useState(false);
useFrame(({ camera }, delta) => {
elapsed.current += delta;
if (elapsed.current < VISIBILITY_INTERVAL || !anchor.current) return;
elapsed.current = 0;
anchor.current.getWorldPosition(labelPosition.current);
projected.current.copy(labelPosition.current).project(camera);
const point = projected.current;
const onScreen = Math.abs(point.x) <= 1 && Math.abs(point.y) <= 1 && Math.abs(point.z) <= 1;
camera.getWorldPosition(cameraPosition.current);
const next = onScreen && hasDungeonLabelLineOfSight(rapier, world, cameraPosition.current, labelPosition.current);
if (next === visibleRef.current) return;
visibleRef.current = next;
setVisible(next);
});
return (
<group ref={anchor} position={[...position]}>
<Html {...props} style={{ ...style, visibility: visible ? "visible" : "hidden" }} />
</group>
);
}
+17 -12
View File
@@ -1,13 +1,14 @@
import { import {
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
useSyncExternalStore, useSyncExternalStore,
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
} from "react"; } from "react";
import { Canvas } from "@react-three/fiber"; import { Canvas, useThree } from "@react-three/fiber";
import { Physics } from "@react-three/rapier"; import { Physics } from "@react-three/rapier";
import { requireDungeonDefinition } from "../game/dungeonRegistry"; import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { spawnMatchesDifficulty } from "../game/mobPopulation"; import { spawnMatchesDifficulty } from "../game/mobPopulation";
@@ -19,6 +20,7 @@ import {
} from "../game/inputManager"; } from "../game/inputManager";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { DungeonEnvironment } from "./DungeonEnvironment"; import { DungeonEnvironment } from "./DungeonEnvironment";
import { dungeonRenderDistance } from "./dungeonRenderDistance";
import { MobPopulation } from "./MobPopulation"; import { MobPopulation } from "./MobPopulation";
import { PartyPopulation } from "./PartyPopulation"; import { PartyPopulation } from "./PartyPopulation";
import { GroundEffectTelegraphs } from "./GroundEffectTelegraphs"; import { GroundEffectTelegraphs } from "./GroundEffectTelegraphs";
@@ -69,6 +71,17 @@ import {
suppressCompletedDungeonPopulation, suppressCompletedDungeonPopulation,
} from "../game/dungeonEncounterSpawns"; } from "../game/dungeonEncounterSpawns";
function DungeonCameraRange({ far }: { readonly far: number }) {
const camera = useThree((state) => state.camera);
useLayoutEffect(() => {
// Canvas preserves its camera across encounters; initial camera props alone
// do not update the clipping distance when switching dungeon packages.
camera.far = far;
camera.updateProjectionMatrix();
}, [camera, far]);
return null;
}
export function GameScene() { export function GameScene() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId); const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const gameMode = useGameStore((state) => state.gameMode); const gameMode = useGameStore((state) => state.gameMode);
@@ -85,16 +98,7 @@ export function GameScene() {
const completedObjectiveIds = gameMode === "dungeon" const completedObjectiveIds = gameMode === "dungeon"
? activeDungeonCompletedObjectiveIds(activeDungeonId) ? activeDungeonCompletedObjectiveIds(activeDungeonId)
: []; : [];
const cameraFar = Math.max( const cameraFar = dungeonRenderDistance(dungeon);
950,
Math.min(
10_000,
Math.hypot(
dungeon.bounds.max[0] - dungeon.bounds.min[0],
dungeon.bounds.max[2] - dungeon.bounds.min[2],
) * 1.35,
),
);
const stagePresentation = resolveStagePresentation(gameMode, manastormEncounter, { const stagePresentation = resolveStagePresentation(gameMode, manastormEncounter, {
title: dungeon.title, title: dungeon.title,
mapId: dungeon.mapId, mapId: dungeon.mapId,
@@ -332,6 +336,7 @@ export function GameScene() {
powerPreference: safeGraphics ? "default" : "high-performance", powerPreference: safeGraphics ? "default" : "high-performance",
}} }}
> >
<DungeonCameraRange far={cameraFar} />
{frameloop === "never" && targetFps !== null && ( {frameloop === "never" && targetFps !== null && (
<CappedFrameLoop <CappedFrameLoop
active={documentVisible || sharedOnline} active={documentVisible || sharedOnline}
@@ -355,7 +360,7 @@ export function GameScene() {
/> />
)} )}
{import.meta.env.DEV && <DevelopmentPerformanceProbe />} {import.meta.env.DEV && <DevelopmentPerformanceProbe />}
<color attach="background" args={[dungeon.presentation.background]} /> <color attach="background" args={[dungeon.presentation.fog.color]} />
<fog attach="fog" args={[dungeon.presentation.fog.color, dungeon.presentation.fog.near, dungeon.presentation.fog.far]} /> <fog attach="fog" args={[dungeon.presentation.fog.color, dungeon.presentation.fog.near, dungeon.presentation.fog.far]} />
<ambientLight intensity={dungeon.presentation.ambientLight.intensity} color={dungeon.presentation.ambientLight.color} /> <ambientLight intensity={dungeon.presentation.ambientLight.intensity} color={dungeon.presentation.ambientLight.color} />
<hemisphereLight args={[ <hemisphereLight args={[
+3 -2
View File
@@ -5,9 +5,10 @@ const source = readFileSync(new URL("./MobPopulation.tsx", import.meta.url), "ut
describe("mob nameplate presentation", () => { describe("mob nameplate presentation", () => {
it("occludes boss names against dungeon geometry", () => { it("occludes boss names against dungeon geometry", () => {
const nameplate = source.match(/\{showLabel && !dead \? \([\s\S]*?<\/Html>\s*\) : null\}/)?.[0] ?? ""; const nameplate = source.match(/\{showLabel && !dead \? \([\s\S]*?<\/DungeonOccludedHtml>\s*\) : null\}/)?.[0] ?? "";
expect(nameplate).toContain('occlude="raycast"'); expect(nameplate).toContain("<DungeonOccludedHtml");
expect(source).not.toContain('occlude="raycast"');
}); });
it("keeps boss names larger and differently colored", () => { it("keeps boss names larger and differently colored", () => {
+3 -3
View File
@@ -78,6 +78,7 @@ import {
import { useGameGLTF } from "./useGameGLTF"; import { useGameGLTF } from "./useGameGLTF";
import { onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime"; import { onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime";
import { isMobAggroRangeVisible } from "./mobAggroRangeVisibility"; import { isMobAggroRangeVisible } from "./mobAggroRangeVisibility";
import { DungeonOccludedHtml } from "./DungeonOccludedHtml";
const MOVEMENT_LOCKING_MOB_STATUSES: ReadonlySet<string> = new Set([ const MOVEMENT_LOCKING_MOB_STATUSES: ReadonlySet<string> = new Set([
"stun", "root", "sleep", "knockdown", "freeze", "charm", "confuse", "stun", "root", "sleep", "knockdown", "freeze", "charm", "confuse",
@@ -1037,9 +1038,8 @@ function ProxyMob({
<MobHealthPlate instanceId={instanceId} height={labelHeight - (showLabel ? 0.38 : 0)} boss={boss} /> <MobHealthPlate instanceId={instanceId} height={labelHeight - (showLabel ? 0.38 : 0)} boss={boss} />
{showLabel && !dead ? ( {showLabel && !dead ? (
<Html <DungeonOccludedHtml
center center
occlude="raycast"
position={[0, labelHeight, 0]} position={[0, labelHeight, 0]}
distanceFactor={13} distanceFactor={13}
style={boss ? bossLabelStyle : mobLabelStyle} style={boss ? bossLabelStyle : mobLabelStyle}
@@ -1048,7 +1048,7 @@ function ProxyMob({
<div>{definition.name}</div> <div>{definition.name}</div>
{boss ? <div style={{ fontSize: "0.78em", fontWeight: 600, opacity: 0.85 }}>{definition.title}</div> : null} {boss ? <div style={{ fontSize: "0.78em", fontWeight: 600, opacity: 0.85 }}>{definition.title}</div> : null}
</div> </div>
</Html> </DungeonOccludedHtml>
) : null} ) : null}
{lootable ? ( {lootable ? (
<Html <Html
+40 -3
View File
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { import {
AdditiveBlending, AdditiveBlending,
BufferAttribute, BufferAttribute,
@@ -17,6 +18,7 @@ import {
prepareCollision, prepareCollision,
} from "./productionDungeonCollision"; } from "./productionDungeonCollision";
import { prepareDungeonMaterial } from "./productionDungeonMaterials"; import { prepareDungeonMaterial } from "./productionDungeonMaterials";
import { WAILING_CAVERNS_DEFINITION } from "../game/dungeons/wailingCaverns";
const EMPTY_MATERIAL_ROLES = { const EMPTY_MATERIAL_ROLES = {
cutoutPatterns: [], cutoutPatterns: [],
@@ -110,10 +112,45 @@ describe("streamed dungeon collision preparation", () => {
}); });
describe("streamed dungeon material preparation", () => { describe("streamed dungeon material preparation", () => {
it("renders the shipped Wailing Caverns walls as opaque depth-writing surfaces", () => {
const buffer = readFileSync(new URL(
"../assets/game/dungeons/wailing-caverns/wailing-caverns-visual.glb",
import.meta.url,
));
const gltf = JSON.parse(buffer.toString("utf8", 20, 20 + buffer.readUInt32LE(12))) as {
materials: { name: string; alphaMode?: string }[];
};
const walls = gltf.materials.filter((material) => /cavernwall|cavernfloor|cavernceiling/.test(material.name));
expect(walls.length).toBeGreaterThan(0);
for (const wall of walls) {
const source = new MeshStandardMaterial({ name: wall.name, transparent: wall.alphaMode === "BLEND" });
const prepared = prepareDungeonMaterial(
source,
WAILING_CAVERNS_DEFINITION.presentation.materials,
WAILING_CAVERNS_DEFINITION.environmentMode,
);
expect(prepared.transparent, wall.name).toBe(false);
expect(prepared.depthWrite, wall.name).toBe(true);
expect(prepared.opacity, wall.name).toBe(1);
}
});
it.each(["global-wmo", "adt-hybrid"] as const)("keeps authored WoW transparency in %s", (mode) => {
const roles = WAILING_CAVERNS_DEFINITION.presentation.materials;
const vine = prepareDungeonMaterial(new MeshStandardMaterial({ name: "mat_snktmpvine", transparent: true }), roles, mode);
const water = prepareDungeonMaterial(new MeshStandardMaterial({ name: "WailingWater", opacity: 0.37 }), roles, mode);
expect(vine.transparent).toBe(false);
expect(vine.alphaTest).toBe(0.5);
expect(vine.depthWrite).toBe(true);
expect(water.transparent).toBe(true);
expect(water.opacity).toBe(0.37);
expect(water.depthWrite).toBe(false);
});
it("preserves alpha blending declared by the source GLB", () => { it("preserves alpha blending declared by the source GLB", () => {
const source = new MeshStandardMaterial({ transparent: true, opacity: 0.2 }); const source = new MeshStandardMaterial({ transparent: true, opacity: 0.2 });
const prepared = prepareDungeonMaterial(source, EMPTY_MATERIAL_ROLES); const prepared = prepareDungeonMaterial(source, EMPTY_MATERIAL_ROLES, "runewaker-ros");
expect(prepared).not.toBe(source); expect(prepared).not.toBe(source);
expect(prepared.transparent).toBe(true); expect(prepared.transparent).toBe(true);
@@ -126,7 +163,7 @@ describe("streamed dungeon material preparation", () => {
it("preserves alpha masking declared by the source GLB", () => { it("preserves alpha masking declared by the source GLB", () => {
const source = new MeshStandardMaterial({ alphaTest: 0.37, transparent: false }); const source = new MeshStandardMaterial({ alphaTest: 0.37, transparent: false });
const prepared = prepareDungeonMaterial(source, EMPTY_MATERIAL_ROLES); const prepared = prepareDungeonMaterial(source, EMPTY_MATERIAL_ROLES, "runewaker-ros");
expect(prepared.transparent).toBe(false); expect(prepared.transparent).toBe(false);
expect(prepared.opacity).toBe(1); expect(prepared.opacity).toBe(1);
@@ -141,7 +178,7 @@ describe("streamed dungeon material preparation", () => {
additiveNames: ["LIGHT-VOLUME"], additiveNames: ["LIGHT-VOLUME"],
}; };
const prepared = prepareDungeonMaterial(source, roles); const prepared = prepareDungeonMaterial(source, roles, "runewaker-ros");
expect(prepared.transparent).toBe(true); expect(prepared.transparent).toBe(true);
expect(prepared.depthWrite).toBe(false); expect(prepared.depthWrite).toBe(false);
+14 -8
View File
@@ -1,5 +1,6 @@
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { RigidBody, TrimeshCollider } from "@react-three/rapier"; import { RigidBody, TrimeshCollider } from "@react-three/rapier";
import { useThree } from "@react-three/fiber";
import { import {
Mesh, Mesh,
type InstancedMesh, type InstancedMesh,
@@ -7,7 +8,7 @@ import {
type Object3D, type Object3D,
} from "three"; } from "three";
import type { ResolvedDungeonAssets } from "../game/dungeonAssets"; import type { ResolvedDungeonAssets } from "../game/dungeonAssets";
import type { DungeonDefinition, DungeonMaterialRoles } from "../game/dungeonTypes"; import type { DungeonDefinition } from "../game/dungeonTypes";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { import {
collisionLineOfSightTrimeshes, collisionLineOfSightTrimeshes,
@@ -19,7 +20,8 @@ import {
DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS, DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS,
} from "./dungeonLineOfSight"; } from "./dungeonLineOfSight";
import { disposeObject3DResources } from "./threeResourceDisposal"; import { disposeObject3DResources } from "./threeResourceDisposal";
import { instanceRepeatedStaticMeshes } from "./staticSceneInstancing"; import { cacheStaticMatrices, instanceRepeatedStaticMeshes } from "./staticSceneInstancing";
import { createStaticVisibilityCells, registerStaticVisibility } from "./staticSceneVisibility";
import { useGameGLTF } from "./useGameGLTF"; import { useGameGLTF } from "./useGameGLTF";
import { prepareDungeonMaterial } from "./productionDungeonMaterials"; import { prepareDungeonMaterial } from "./productionDungeonMaterials";
import { import {
@@ -28,7 +30,7 @@ import {
advanceDungeonAssetCount, advanceDungeonAssetCount,
} from "./dungeonLoadSequencing"; } from "./dungeonLoadSequencing";
function prepareVisual(root: Object3D, roles: DungeonMaterialRoles): Object3D { function prepareVisual(root: Object3D, definition: DungeonDefinition) {
const clone = root.clone(true); const clone = root.clone(true);
const clonedMaterials = new Map<string, Material>(); const clonedMaterials = new Map<string, Material>();
clone.traverse((node) => { clone.traverse((node) => {
@@ -38,7 +40,7 @@ function prepareVisual(root: Object3D, roles: DungeonMaterialRoles): Object3D {
const prepared = sources.map((source) => { const prepared = sources.map((source) => {
const cached = clonedMaterials.get(source.uuid); const cached = clonedMaterials.get(source.uuid);
if (cached) return cached; if (cached) return cached;
const next = prepareDungeonMaterial(source, roles); const next = prepareDungeonMaterial(source, definition.presentation.materials, definition.environmentMode);
clonedMaterials.set(source.uuid, next); clonedMaterials.set(source.uuid, next);
return next; return next;
}); });
@@ -52,15 +54,19 @@ function prepareVisual(root: Object3D, roles: DungeonMaterialRoles): Object3D {
} }
}); });
instanceRepeatedStaticMeshes(clone); instanceRepeatedStaticMeshes(clone);
return clone; const cells = createStaticVisibilityCells(clone);
cacheStaticMatrices(clone);
return { visual: clone, cells };
} }
function VisualChunk({ url, definition }: { readonly url: string; readonly definition: DungeonDefinition }) { function VisualChunk({ url, definition }: { readonly url: string; readonly definition: DungeonDefinition }) {
const gltf = useGameGLTF(url); const gltf = useGameGLTF(url);
const visual = useMemo( const scene = useThree((state) => state.scene);
() => prepareVisual(gltf.scene, definition.presentation.materials), const { visual, cells } = useMemo(
[definition.presentation.materials, gltf.scene], () => prepareVisual(gltf.scene, definition),
[definition, gltf.scene],
); );
useEffect(() => registerStaticVisibility(scene, cells), [scene, cells]);
useEffect(() => () => { disposeObject3DResources(visual); }, [visual]); useEffect(() => () => { disposeObject3DResources(visual); }, [visual]);
const { position, rotation, scale } = definition.transform; const { position, rotation, scale } = definition.transform;
return <primitive object={visual} position={position} rotation={rotation} scale={scale} />; return <primitive object={visual} position={position} rotation={rotation} scale={scale} />;
+2 -1
View File
@@ -15,7 +15,8 @@ describe("world NPC prompts", () => {
const promptSource = readFileSync(new URL("./WorldNpcPrompt.tsx", import.meta.url), "utf8"); const promptSource = readFileSync(new URL("./WorldNpcPrompt.tsx", import.meta.url), "utf8");
const naralexSource = readFileSync(new URL("./WailingNaralexEvent.tsx", import.meta.url), "utf8"); const naralexSource = readFileSync(new URL("./WailingNaralexEvent.tsx", import.meta.url), "utf8");
expect(promptSource).toContain('occlude="raycast"'); expect(promptSource).toContain("<DungeonOccludedHtml");
expect(promptSource).not.toContain('occlude="raycast"');
expect(naralexSource).toContain("<WorldNpcPrompt"); expect(naralexSource).toContain("<WorldNpcPrompt");
expect(naralexSource).not.toContain("<Html"); expect(naralexSource).not.toContain("<Html");
}); });
+3 -3
View File
@@ -1,9 +1,9 @@
import { Html } from "@react-three/drei";
import { useFrame } from "@react-three/fiber"; import { useFrame } from "@react-three/fiber";
import { useRef, useState, type ReactNode } from "react"; import { useRef, useState, type ReactNode } from "react";
import { Group, Vector3 } from "three"; import { Group, Vector3 } from "three";
import type { MobVector3 } from "../game/mobPopulation"; import type { MobVector3 } from "../game/mobPopulation";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { DungeonOccludedHtml } from "./DungeonOccludedHtml";
export const WORLD_NPC_PROMPT_MAX_DISTANCE = 22; export const WORLD_NPC_PROMPT_MAX_DISTANCE = 22;
@@ -61,9 +61,9 @@ export function WorldNpcPrompt({
return ( return (
<group ref={anchorRef} position={[position[0], position[1], position[2]]}> <group ref={anchorRef} position={[position[0], position[1], position[2]]}>
{visible ? ( {visible ? (
<Html center occlude="raycast" zIndexRange={[zIndexRange[0], zIndexRange[1]]}> <DungeonOccludedHtml center zIndexRange={[zIndexRange[0], zIndexRange[1]]}>
{children} {children}
</Html> </DungeonOccludedHtml>
) : null} ) : null}
</group> </group>
); );
+58
View File
@@ -0,0 +1,58 @@
import RAPIER from "@dimforge/rapier3d-compat";
import { beforeAll, describe, expect, it } from "vitest";
import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS } from "./dungeonLineOfSight";
import { hasDungeonLabelLineOfSight } from "./dungeonLabelVisibility";
beforeAll(async () => { await RAPIER.init(); });
const CAMERA = { x: 0, y: 2, z: 0 };
const LABEL = { x: 4, y: 2, z: 0 };
describe("dungeon label occlusion", () => {
it("hides labels behind solid dungeon walls using the physical spatial index", () => {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
try {
world.createCollider(RAPIER.ColliderDesc.cuboid(0.1, 3, 3)
.setTranslation(2, 2, 0).setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
world.step();
expect(hasDungeonLabelLineOfSight(RAPIER, world, CAMERA, LABEL)).toBe(false);
expect(hasDungeonLabelLineOfSight(RAPIER, world, CAMERA, { x: 1, y: 2, z: 0 })).toBe(true);
} finally { world.free(); }
});
it("occludes across floors and ceilings at the exact camera and label heights", () => {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
try {
world.createCollider(RAPIER.ColliderDesc.cuboid(5, 0.1, 5)
.setTranslation(0, 1, 0).setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
world.step();
expect(hasDungeonLabelLineOfSight(RAPIER, world, CAMERA, { x: 0, y: 0, z: 0 })).toBe(false);
expect(hasDungeonLabelLineOfSight(RAPIER, world, CAMERA, LABEL)).toBe(true);
} finally { world.free(); }
});
it("ignores actors, trigger volumes, and the duplicate combat sight layer", () => {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
try {
const actor = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased());
world.createCollider(RAPIER.ColliderDesc.cuboid(0.1, 3, 3).setTranslation(1, 2, 0), actor);
world.createCollider(RAPIER.ColliderDesc.cuboid(0.1, 3, 3).setTranslation(2, 2, 0)
.setSensor(true).setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
world.createCollider(RAPIER.ColliderDesc.cuboid(0.1, 3, 3).setTranslation(3, 2, 0)
.setSensor(true).setCollisionGroups(DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS));
world.step();
expect(hasDungeonLabelLineOfSight(RAPIER, world, CAMERA, LABEL)).toBe(true);
} finally { world.free(); }
});
it("does not query past a label or hide one at the camera position", () => {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
try {
world.createCollider(RAPIER.ColliderDesc.cuboid(0.1, 3, 3).setTranslation(5, 2, 0));
world.step();
expect(hasDungeonLabelLineOfSight(RAPIER, world, CAMERA, LABEL)).toBe(true);
expect(hasDungeonLabelLineOfSight(RAPIER, world, CAMERA, CAMERA)).toBe(true);
expect(hasDungeonLabelLineOfSight(RAPIER, world, CAMERA, { x: NaN, y: 2, z: 0 })).toBe(false);
} finally { world.free(); }
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { Ray, Vector, World } from "@dimforge/rapier3d-compat";
import { interactionGroups } from "@react-three/rapier";
interface LabelRapierApi {
readonly Ray: new (origin: Vector, direction: Vector) => Ray;
readonly QueryFilterFlags: {
readonly ONLY_FIXED: number;
readonly EXCLUDE_SENSORS: number;
};
}
const LABEL_OCCLUSION_GROUPS = interactionGroups(0, 0);
const LABEL_SURFACE_MARGIN = 0.08;
/** Uses Rapier's spatial index, bounded to the camera-to-label segment. */
export function hasDungeonLabelLineOfSight(
rapier: LabelRapierApi,
world: World,
camera: Vector,
label: Vector,
): boolean {
const dx = label.x - camera.x;
const dy = label.y - camera.y;
const dz = label.z - camera.z;
const distance = Math.hypot(dx, dy, dz);
if (!Number.isFinite(distance)) return false;
if (distance <= LABEL_SURFACE_MARGIN) return true;
const ray = new rapier.Ray(camera, { x: dx / distance, y: dy / distance, z: dz / distance });
return !world.castRay(
ray,
distance - LABEL_SURFACE_MARGIN,
true,
rapier.QueryFilterFlags.ONLY_FIXED | rapier.QueryFilterFlags.EXCLUDE_SENSORS,
LABEL_OCCLUSION_GROUPS,
);
}
+72
View File
@@ -0,0 +1,72 @@
import { closeSync, openSync, readSync } from "node:fs";
import path from "node:path";
import { MeshStandardMaterial } from "three";
import { describe, expect, it } from "vitest";
import { DUNGEON_DEFINITIONS } from "../game/dungeonRegistry";
import { prepareDungeonMaterial } from "./productionDungeonMaterials";
interface GltfMaterial {
name?: string;
alphaMode?: string;
alphaCutoff?: number;
pbrMetallicRoughness?: { baseColorFactor?: number[] };
}
function shippedMaterials(file: string): GltfMaterial[] {
const descriptor = openSync(file, "r");
try {
const header = Buffer.alloc(20);
expect(readSync(descriptor, header, 0, 20, 0)).toBe(20);
expect(header.toString("utf8", 0, 4)).toBe("glTF");
const json = Buffer.alloc(header.readUInt32LE(12));
expect(readSync(descriptor, json, 0, json.length, 20)).toBe(json.length);
return (JSON.parse(json.toString("utf8")) as { materials: GltfMaterial[] }).materials;
} finally {
closeSync(descriptor);
}
}
const wowDungeons = DUNGEON_DEFINITIONS.filter((definition) => definition.expansion !== "runewaker");
describe("shipped WoW dungeon material policies", () => {
it("covers all 54 campaign dungeons", () => {
expect(wowDungeons).toHaveLength(54);
});
it.each(wowDungeons)("retains authored opacity and effect roles in $title", (definition) => {
let opaqueCount = 0;
const roles = definition.presentation.materials;
const matches = (name: string, patterns: readonly string[], names: readonly string[]) => (
names.some((candidate) => candidate.toLowerCase() === name.toLowerCase())
|| patterns.some((pattern) => new RegExp(pattern, "i").test(name))
);
for (const chunk of definition.assets.visual) {
const file = chunk.url
? path.join(process.cwd(), "public", chunk.url)
: path.join(process.cwd(), "src/assets/game/dungeons", definition.id, chunk.fileName);
for (const raw of shippedMaterials(file)) {
const name = raw.name ?? "";
const source = new MeshStandardMaterial({
name,
transparent: raw.alphaMode === "BLEND",
alphaTest: raw.alphaMode === "MASK" ? raw.alphaCutoff ?? 0.5 : 0,
opacity: raw.pbrMetallicRoughness?.baseColorFactor?.[3] ?? 1,
});
const prepared = prepareDungeonMaterial(source, roles, definition.environmentMode);
const cutout = matches(name, roles.cutoutPatterns, roles.cutoutNames);
const blended = !cutout && (matches(name, roles.blendPatterns, roles.blendNames)
|| matches(name, roles.additivePatterns, roles.additiveNames));
expect(prepared.transparent, name).toBe(blended);
expect(prepared.depthWrite, name).toBe(!blended);
if (!cutout && !blended) {
opaqueCount += 1;
expect(prepared.opacity, name).toBe(1);
expect(prepared.alphaTest, name).toBe(0);
}
prepared.dispose();
source.dispose();
}
}
expect(opaqueCount).toBeGreaterThan(0);
});
});
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { DUNGEON_DEFINITIONS } from "../game/dungeonRegistry";
import { dungeonRenderDistance } from "./dungeonRenderDistance";
describe("dungeon render distance", () => {
it.each(DUNGEON_DEFINITIONS)("clips only beyond complete fog in $title", (dungeon) => {
const far = dungeonRenderDistance(dungeon);
expect(far).toBeGreaterThan(dungeon.presentation.fog.near);
expect(far).toBe(dungeon.presentation.fog.far);
});
});
+14
View File
@@ -0,0 +1,14 @@
import type { DungeonDefinition } from "../game/dungeonTypes";
export function dungeonRenderDistance(dungeon: DungeonDefinition): number {
const boundsDistance = Math.max(950, Math.min(10_000, Math.hypot(
dungeon.bounds.max[0] - dungeon.bounds.min[0],
dungeon.bounds.max[2] - dungeon.bounds.min[2],
) * 1.35));
const fogFar = dungeon.presentation.fog.far;
// Linear fog has completely obscured surfaces at this camera-space depth.
// The background must use the fog color so clipping cannot reveal a horizon.
return Number.isFinite(fogFar) && fogFar > 1
? Math.min(boundsDistance, fogFar)
: boundsDistance;
}
+16
View File
@@ -17,6 +17,22 @@ function renderCount(targetFps: 30 | 60, sourceFps: number, durationMs = 1_000):
} }
describe("frame-rate governor", () => { describe("frame-rate governor", () => {
it.each([60, 120])("keeps 60 FPS evenly spaced with rounded %i Hz callbacks", (sourceFps) => {
let state = createFrameRateGovernor(60);
const rendered: number[] = [];
for (let frame = 0; frame <= sourceFps * 5; frame += 1) {
const timestamp = Math.round(frame * 1_000 / sourceFps * 10) / 10;
const step = stepFrameRateGovernor(state, timestamp);
state = step.state;
if (step.shouldRender) rendered.push(timestamp);
}
expect(rendered).toHaveLength(301);
for (let index = 1; index < rendered.length; index += 1) {
expect(rendered[index] - rendered[index - 1]).toBeGreaterThan(16.5);
expect(rendered[index] - rendered[index - 1]).toBeLessThan(16.8);
}
});
it("holds capped modes near their target on high-refresh displays", () => { it("holds capped modes near their target on high-refresh displays", () => {
expect(renderCount(30, 144)).toBeGreaterThanOrEqual(30); expect(renderCount(30, 144)).toBeGreaterThanOrEqual(30);
expect(renderCount(30, 144)).toBeLessThanOrEqual(31); expect(renderCount(30, 144)).toBeLessThanOrEqual(31);
+9 -2
View File
@@ -1,5 +1,10 @@
export type FrameRateGovernorMode = number | "display"; export type FrameRateGovernorMode = number | "display";
// rAF timestamps can fall just before a deadline due to rounding/jitter.
// Keep the fixed deadline sequence, but avoid skipping a whole refresh for a
// sub-millisecond difference (especially 60 FPS on a 120 Hz display).
const DEADLINE_TOLERANCE_MS = 0.5;
export interface FrameRateGovernorState { export interface FrameRateGovernorState {
readonly mode: FrameRateGovernorMode; readonly mode: FrameRateGovernorMode;
readonly lastTimestampMs: number | null; readonly lastTimestampMs: number | null;
@@ -66,7 +71,7 @@ export function stepFrameRateGovernor(
} }
const deadlineMs = state.nextDeadlineMs ?? timestampMs; const deadlineMs = state.nextDeadlineMs ?? timestampMs;
if (timestampMs < deadlineMs) { if (timestampMs + DEADLINE_TOLERANCE_MS < deadlineMs) {
const nextState: FrameRateGovernorState = { const nextState: FrameRateGovernorState = {
...state, ...state,
lastTimestampMs: timestampMs, lastTimestampMs: timestampMs,
@@ -75,7 +80,9 @@ export function stepFrameRateGovernor(
return { state: nextState, shouldRender: false, elapsedSeconds }; return { state: nextState, shouldRender: false, elapsedSeconds };
} }
const missedIntervals = Math.floor(Math.max(0, timestampMs - deadlineMs) / intervalMs); const missedIntervals = Math.floor(
Math.max(0, timestampMs + DEADLINE_TOLERANCE_MS - deadlineMs) / intervalMs,
);
const nextState: FrameRateGovernorState = { const nextState: FrameRateGovernorState = {
...state, ...state,
lastTimestampMs: timestampMs, lastTimestampMs: timestampMs,
+10 -4
View File
@@ -4,7 +4,7 @@ import {
NormalBlending, NormalBlending,
type Material, type Material,
} from "three"; } from "three";
import type { DungeonMaterialRoles } from "../game/dungeonTypes"; import type { DungeonEnvironmentMode, DungeonMaterialRoles } from "../game/dungeonTypes";
function matchesRole(name: string, patterns: readonly string[], names: readonly string[]): boolean { function matchesRole(name: string, patterns: readonly string[], names: readonly string[]): boolean {
const lower = name.toLowerCase(); const lower = name.toLowerCase();
@@ -12,7 +12,11 @@ function matchesRole(name: string, patterns: readonly string[], names: readonly
|| patterns.some((pattern) => new RegExp(pattern, "i").test(name)); || patterns.some((pattern) => new RegExp(pattern, "i").test(name));
} }
export function prepareDungeonMaterial(source: Material, roles: DungeonMaterialRoles): Material { export function prepareDungeonMaterial(
source: Material,
roles: DungeonMaterialRoles,
environmentMode: DungeonEnvironmentMode,
): Material {
const material = source.clone(); const material = source.clone();
const name = material.name; const name = material.name;
material.side = DoubleSide; material.side = DoubleSide;
@@ -31,17 +35,19 @@ export function prepareDungeonMaterial(source: Material, roles: DungeonMaterialR
material.transparent = true; material.transparent = true;
material.depthWrite = false; material.depthWrite = false;
material.blending = NormalBlending; material.blending = NormalBlending;
} else if (material.alphaTest > 0) { } else if (environmentMode === "runewaker-ros" && material.alphaTest > 0) {
// Preserve a source GLB's MASK semantics when no presentation override exists. // Preserve a source GLB's MASK semantics when no presentation override exists.
material.transparent = false; material.transparent = false;
material.opacity = 1; material.opacity = 1;
material.depthWrite = true; material.depthWrite = true;
material.blending = NormalBlending; material.blending = NormalBlending;
} else if (material.transparent) { } else if (environmentMode === "runewaker-ros" && material.transparent) {
// Preserve a source GLB's BLEND semantics when no presentation override exists. // Preserve a source GLB's BLEND semantics when no presentation override exists.
material.depthWrite = false; material.depthWrite = false;
material.blending = NormalBlending; material.blending = NormalBlending;
} else { } else {
// Legacy WoW WMO exports label solid walls/floors BLEND. Their authored
// presentation roles, rather than the GLB alpha mode, define transparency.
material.transparent = false; material.transparent = false;
material.opacity = 1; material.opacity = 1;
material.alphaTest = 0; material.alphaTest = 0;
+49 -2
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
BoxGeometry, BoxGeometry,
Group, Group,
@@ -8,7 +8,7 @@ import {
MeshBasicMaterial, MeshBasicMaterial,
Vector3, Vector3,
} from "three"; } from "three";
import { instanceRepeatedStaticMeshes } from "./staticSceneInstancing"; import { cacheStaticMatrices, instanceRepeatedStaticMeshes } from "./staticSceneInstancing";
function batchIn(root: Group): InstancedMesh { function batchIn(root: Group): InstancedMesh {
let result: InstancedMesh | null = null; let result: InstancedMesh | null = null;
@@ -28,6 +28,33 @@ function batchesIn(root: Group): readonly InstancedMesh[] {
} }
describe("static scene instancing", () => { describe("static scene instancing", () => {
it("caches local transforms without freezing dungeon placement or imported matrices", () => {
const parent = new Group();
const root = new Group();
const mesh = new Mesh(new BoxGeometry(), new MeshBasicMaterial());
mesh.matrixAutoUpdate = false;
mesh.matrix.makeTranslation(3, 0, 0);
parent.add(root);
root.add(mesh);
const updateChild = vi.spyOn(mesh, "updateMatrixWorld");
cacheStaticMatrices(root);
root.position.x = 10;
parent.position.x = 20;
parent.updateMatrixWorld(true);
expect(new Vector3().setFromMatrixPosition(mesh.matrixWorld).x).toBe(33);
parent.updateMatrixWorld(true);
expect(updateChild).toHaveBeenCalledTimes(1);
root.position.x = 15;
parent.updateMatrixWorld(true);
expect(new Vector3().setFromMatrixPosition(mesh.matrixWorld).x).toBe(38);
parent.position.x = 30;
parent.updateMatrixWorld(true);
expect(new Vector3().setFromMatrixPosition(mesh.matrixWorld).x).toBe(48);
expect(updateChild).toHaveBeenCalledTimes(3);
expect(root.matrixAutoUpdate).toBe(true);
expect(mesh.matrixWorldAutoUpdate).toBe(true);
});
it("batches repeated meshes while retaining their world transforms", () => { it("batches repeated meshes while retaining their world transforms", () => {
const root = new Group(); const root = new Group();
root.position.set(10, 0, 0); root.position.set(10, 0, 0);
@@ -72,6 +99,26 @@ describe("static scene instancing", () => {
expect(root.children).toContain(existing); expect(root.children).toContain(existing);
}); });
it("removes containers emptied by batching while preserving authored empty nodes", () => {
const root = new Group();
const geometry = new BoxGeometry();
const material = new MeshBasicMaterial();
const authoredMarker = new Group();
authoredMarker.name = "authored-marker";
root.add(authoredMarker);
for (let index = 0; index < 3; index += 1) {
const outer = new Group();
const inner = new Group();
outer.add(inner);
inner.add(new Mesh(geometry, material));
root.add(outer);
}
instanceRepeatedStaticMeshes(root);
expect(root.children).toHaveLength(2);
expect(root.children).toContain(authoredMarker);
expect(batchIn(root).count).toBe(3);
});
it("does not combine meshes whose render state differs", () => { it("does not combine meshes whose render state differs", () => {
const root = new Group(); const root = new Group();
const geometry = new BoxGeometry(); const geometry = new BoxGeometry();
+40
View File
@@ -9,6 +9,32 @@ import {
export const STATIC_INSTANCE_CELL_SIZE = 192; export const STATIC_INSTANCE_CELL_SIZE = 192;
/** Cache an immutable visual subtree while still following its root/parent. */
export function cacheStaticMatrices(root: Object3D): void {
root.traverse((node) => {
if (node === root) return;
if (node.matrixAutoUpdate) node.updateMatrix();
node.matrixAutoUpdate = false;
});
const updateMatrixWorld = root.updateMatrixWorld;
const previousLocal = new Matrix4();
const previousParent = new Matrix4();
const identity = new Matrix4();
let initialized = false;
root.updateMatrixWorld = () => {
if (root.matrixAutoUpdate) root.updateMatrix();
const parentWorld = root.parent?.matrixWorld ?? identity;
if (initialized && previousLocal.equals(root.matrix) && previousParent.equals(parentWorld)) {
root.matrixWorldNeedsUpdate = false;
return;
}
updateMatrixWorld.call(root, true);
previousLocal.copy(root.matrix);
previousParent.copy(parentWorld);
initialized = true;
};
}
export interface StaticSceneInstancingReport { export interface StaticSceneInstancingReport {
readonly batches: number; readonly batches: number;
readonly instances: number; readonly instances: number;
@@ -86,6 +112,7 @@ export function instanceRepeatedStaticMeshes(
let batches = 0; let batches = 0;
let instances = 0; let instances = 0;
let removedMeshes = 0; let removedMeshes = 0;
const emptiedParents = new Set<Object3D>();
for (const meshes of groups.values()) { for (const meshes of groups.values()) {
if (meshes.length < minimumInstances) continue; if (meshes.length < minimumInstances) continue;
@@ -101,6 +128,7 @@ export function instanceRepeatedStaticMeshes(
for (let index = 0; index < meshes.length; index += 1) { for (let index = 0; index < meshes.length; index += 1) {
const mesh = meshes[index]; const mesh = meshes[index];
batch.setMatrixAt(index, new Matrix4().multiplyMatrices(inverseRootWorld, mesh.matrixWorld)); batch.setMatrixAt(index, new Matrix4().multiplyMatrices(inverseRootWorld, mesh.matrixWorld));
if (mesh.parent) emptiedParents.add(mesh.parent);
mesh.removeFromParent(); mesh.removeFromParent();
} }
batch.instanceMatrix.needsUpdate = true; batch.instanceMatrix.needsUpdate = true;
@@ -112,5 +140,17 @@ export function instanceRepeatedStaticMeshes(
removedMeshes += meshes.length; removedMeshes += meshes.length;
} }
// Imported prop containers can outnumber their meshes after batching. Remove
// only containers emptied by this operation, preserving authored scene nodes.
for (const candidate of emptiedParents) {
let empty: Object3D | null = candidate;
while (empty && empty !== root && empty.children.length === 0
&& (empty.type === "Group" || empty.type === "Object3D")) {
const parent: Object3D | null = empty.parent;
empty.removeFromParent();
empty = parent;
}
}
return { batches, instances, removedMeshes }; return { batches, instances, removedMeshes };
} }
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, it, vi } from "vitest";
import {
BoxGeometry,
BufferGeometry,
Frustum,
Group,
InstancedMesh,
Matrix4,
Mesh,
MeshBasicMaterial,
PerspectiveCamera,
PointLight,
Scene,
SkinnedMesh,
Vector3,
type WebGLRenderer,
} from "three";
import { cacheStaticMatrices } from "./staticSceneInstancing";
import { createStaticVisibilityCells, registerStaticVisibility, updateStaticVisibility } from "./staticSceneVisibility";
function cube(x: number, z: number, width = 1): Mesh {
const mesh = new Mesh(new BoxGeometry(width, 1, width), new MeshBasicMaterial());
mesh.position.set(x, 0, z);
return mesh;
}
function cameraFrustum(camera: PerspectiveCamera): Frustum {
camera.updateMatrixWorld();
return new Frustum().setFromProjectionMatrix(
new Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse),
);
}
describe("static dungeon visibility cells", () => {
it("preserves hierarchy transforms, render ordering, and authored visibility", () => {
const root = new Group();
root.position.set(7, 3, -2);
root.rotation.y = 0.4;
root.scale.set(2, 1, -3);
root.renderOrder = 4;
const nested = new Group();
nested.position.set(2, 0, 1);
nested.add(cube(1, -8));
const hidden = cube(0, -5);
hidden.visible = false;
root.add(nested, hidden);
root.updateMatrixWorld(true);
const mesh = nested.children[0];
const before = mesh.matrixWorld.clone();
const cells = createStaticVisibilityCells(root, 64, 1);
cacheStaticMatrices(root);
root.updateMatrixWorld(true);
expect(mesh.matrixWorld.equals(before)).toBe(true);
expect(hidden.visible).toBe(false);
expect(cells.every((cell) => cell.group.renderOrder === 4)).toBe(true);
expect(root.getObjectsByProperty("isMesh", true)).toHaveLength(2);
});
it("keeps walls crossing the view and distant instances visible regardless of their origin", () => {
const root = new Group();
const front = cube(0, -10);
const behind = cube(0, 20);
const crossingWall = cube(30, -10, 60);
const instances = new InstancedMesh(new BoxGeometry(), new MeshBasicMaterial(), 1);
instances.position.z = 150;
instances.setMatrixAt(0, new Matrix4().makeTranslation(0, 0, -160));
root.add(front, behind, crossingWall, instances);
const cells = createStaticVisibilityCells(root, 4, 1);
root.updateMatrixWorld(true);
const camera = new PerspectiveCamera(60, 1, 0.1, 100);
updateStaticVisibility(cells, cameraFrustum(camera));
expect(front.parent?.visible).toBe(true);
expect(behind.parent?.visible).toBe(false);
expect(crossingWall.parent?.visible).toBe(true);
expect(instances.parent?.visible).toBe(true);
camera.rotation.y = Math.PI;
updateStaticVisibility(cells, cameraFrustum(camera));
expect(behind.parent?.visible).toBe(true);
});
it("refreshes bounds after mirrored, scaled parent placement changes", () => {
const parent = new Group();
const root = new Group();
const mesh = cube(0, -10);
root.add(mesh);
parent.add(root);
const cells = createStaticVisibilityCells(root, 64, 1);
cacheStaticMatrices(root);
parent.updateMatrixWorld(true);
const frustum = cameraFrustum(new PerspectiveCamera(60, 1, 0.1, 100));
updateStaticVisibility(cells, frustum);
expect(mesh.parent?.visible).toBe(true);
parent.scale.set(2, 1, -1);
parent.updateMatrixWorld(true);
updateStaticVisibility(cells, frustum);
expect(mesh.parent?.visible).toBe(false);
parent.position.z = -30;
parent.updateMatrixWorld(true);
updateStaticVisibility(cells, frustum);
expect(mesh.parent?.visible).toBe(true);
const position = new Vector3().setFromMatrixPosition(mesh.matrixWorld);
expect(cells[0].worldBounds.containsPoint(position)).toBe(true);
});
it("leaves lights, animated meshes, and meshes that disable culling alone", () => {
const root = new Group();
const light = new PointLight();
const skin = new SkinnedMesh(new BoxGeometry(), new MeshBasicMaterial());
const uncullable = cube(0, -10);
uncullable.frustumCulled = false;
const plain = cube(0, -10);
root.add(light, skin, uncullable, plain);
expect(createStaticVisibilityCells(root, 64, 1)).toHaveLength(1);
expect(light.parent).toBe(root);
expect(skin.parent).toBe(root);
expect(uncullable.parent).toBe(root);
expect(plain.parent).not.toBe(root);
});
it("shares a render hook across streamed chunks and restores it after cleanup", () => {
const scene = new Scene();
const previous = vi.fn();
scene.onBeforeRender = previous;
const roots = [new Group(), new Group()];
const sets = roots.map((root) => {
root.add(cube(0, 20));
scene.add(root);
return createStaticVisibilityCells(root, 64, 1);
});
scene.updateMatrixWorld(true);
const first = registerStaticVisibility(scene, sets[0]);
const callback = scene.onBeforeRender;
const second = registerStaticVisibility(scene, sets[1]);
expect(scene.onBeforeRender).toBe(callback);
const camera = new PerspectiveCamera(60, 1, 0.1, 100);
camera.updateMatrixWorld();
scene.onBeforeRender({} as WebGLRenderer, scene, camera, new BufferGeometry(), new MeshBasicMaterial(), new Group());
expect(previous).toHaveBeenCalledTimes(1);
expect(sets.every((cells) => !cells[0].group.visible)).toBe(true);
first();
expect(sets[0][0].group.visible).toBe(true);
expect(scene.onBeforeRender).toBe(callback);
second();
expect(scene.onBeforeRender).toBe(previous);
});
});
+132
View File
@@ -0,0 +1,132 @@
import {
Box3,
Frustum,
Group,
Matrix4,
Sphere,
Vector3,
type Mesh,
type Object3D,
type Scene,
} from "three";
export interface StaticVisibilityCell {
readonly group: Group;
readonly bounds: Box3;
readonly worldBounds: Box3;
readonly worldSphere: Sphere;
readonly previousWorld: Matrix4;
initialized: boolean;
}
function canCullSubtree(root: Object3D): boolean {
let safe = true;
root.traverse((node) => {
const mesh = node as Mesh & { isSkinnedMesh?: boolean };
if (mesh.isMesh) {
if (!mesh.frustumCulled || mesh.isSkinnedMesh || Object.keys(mesh.geometry.morphAttributes).length) safe = false;
} else if (node.type !== "Group" && node.type !== "Object3D") {
// Do not hide lights, cameras, sprites, or other renderable object types.
safe = false;
}
});
return safe;
}
/** Group static siblings under conservative bounds without merging geometry. */
export function createStaticVisibilityCells(
root: Object3D,
cellSize = 64,
minimumChildren = 24,
): readonly StaticVisibilityCell[] {
let container = root;
while (container.children.length === 1 && container.children[0].type === "Group") {
container = container.children[0];
}
if (container.children.length < minimumChildren) return [];
container.updateWorldMatrix(true, true);
const inverseWorld = container.matrixWorld.clone().invert();
const safeCellSize = Number.isFinite(cellSize) && cellSize > 0 ? cellSize : 64;
const center = new Vector3();
const cells = new Map<string, StaticVisibilityCell>();
for (const child of [...container.children]) {
if (!canCullSubtree(child)) continue;
const bounds = new Box3().setFromObject(child).applyMatrix4(inverseWorld);
if (bounds.isEmpty() || ![...bounds.min.toArray(), ...bounds.max.toArray()].every(Number.isFinite)) continue;
bounds.getCenter(center);
const key = [center.x, center.y, center.z].map((value) => Math.floor(value / safeCellSize)).join(":");
let cell = cells.get(key);
if (!cell) {
const group = new Group();
group.name = `static-visibility:${key}`;
group.renderOrder = container.renderOrder;
cell = {
group,
bounds: new Box3(),
worldBounds: new Box3(),
worldSphere: new Sphere(),
previousWorld: new Matrix4(),
initialized: false,
};
cells.set(key, cell);
container.add(group);
}
// The new group has an identity local transform under the same parent.
cell.group.add(child);
cell.bounds.union(bounds);
}
return [...cells.values()];
}
export function updateStaticVisibility(cells: readonly StaticVisibilityCell[], frustum: Frustum): void {
for (const cell of cells) {
if (!cell.initialized || !cell.previousWorld.equals(cell.group.matrixWorld)) {
// Transform the entire box before making a sphere, retaining conservative
// bounds with rotated, mirrored, or nonuniformly scaled dungeon roots.
cell.worldBounds.copy(cell.bounds).applyMatrix4(cell.group.matrixWorld);
cell.worldBounds.getBoundingSphere(cell.worldSphere);
cell.previousWorld.copy(cell.group.matrixWorld);
cell.initialized = true;
}
cell.group.visible = frustum.intersectsSphere(cell.worldSphere);
}
}
interface SceneVisibilityRegistration {
readonly sets: Set<readonly StaticVisibilityCell[]>;
readonly previous: Scene["onBeforeRender"];
readonly callback: Scene["onBeforeRender"];
}
const registrations = new WeakMap<Scene, SceneVisibilityRegistration>();
/** Run after current camera/world matrices are updated, before mesh traversal. */
export function registerStaticVisibility(scene: Scene, cells: readonly StaticVisibilityCell[]): () => void {
if (!cells.length) return () => undefined;
let registration = registrations.get(scene);
if (!registration) {
const sets = new Set<readonly StaticVisibilityCell[]>();
const previous = scene.onBeforeRender;
const frustum = new Frustum();
const projectionView = new Matrix4();
const callback: Scene["onBeforeRender"] = (...args) => {
previous.apply(scene, args);
const camera = args[2];
projectionView.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
frustum.setFromProjectionMatrix(projectionView, camera.coordinateSystem, camera.reversedDepth);
for (const set of sets) updateStaticVisibility(set, frustum);
};
registration = { sets, previous, callback };
registrations.set(scene, registration);
scene.onBeforeRender = callback;
}
const active = registration;
active.sets.add(cells);
return () => {
active.sets.delete(cells);
for (const cell of cells) cell.group.visible = true;
if (active.sets.size) return;
if (scene.onBeforeRender === active.callback) scene.onBeforeRender = active.previous;
registrations.delete(scene);
};
}
+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)); 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); const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [item?.iconUrl]); useEffect(() => setFailed(false), [item?.iconUrl]);
if (!item) return <span className="action-slot__icon" aria-hidden="true" />; 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 { ManastormStatus } from "./ManastormStatus";
import { ThreatMeterWidget } from "./ThreatMeterWidget"; import { ThreatMeterWidget } from "./ThreatMeterWidget";
import { BossLootWindow } from "./BossLootWindow"; import { BossLootWindow } from "./BossLootWindow";
import { EditableHudElement } from "./EditableHudElement";
import { DesktopActionBars } from "./DesktopActionBars";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { SummonFrames } from "./SummonFrames"; import { SummonFrames } from "./SummonFrames";
import { useOnlineSessionStore } from "../app/onlineSessionStore"; import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { onlineAccountIdFromActor, onlinePlayerActorId } from "../game/onlineSessionRuntime"; import { onlineAccountIdFromActor, onlinePlayerActorId } from "../game/onlineSessionRuntime";
@@ -139,6 +142,8 @@ export function DungeonCompletePrompt({
} }
export function Hud() { export function Hud() {
const ui = useUiLayoutStore();
const desktopBars = currentUiDevice() === "pc" && (ui.draft ?? ui.preferences.current.pc).wowBars;
const character = useShellStore((state) => state.activeCharacter); const character = useShellStore((state) => state.activeCharacter);
const gameMode = useGameStore((state) => state.gameMode); const gameMode = useGameStore((state) => state.gameMode);
const areaName = useGameStore((state) => state.areaName); const areaName = useGameStore((state) => state.areaName);
@@ -216,10 +221,12 @@ export function Hud() {
const classColor = classById(classId).color; const classColor = classById(classId).color;
const resourceTone = resourceProfileForClass(classId).type; const resourceTone = resourceProfileForClass(classId).type;
const actionItems = useMemo<ActionBarItems>(() => Object.fromEntries(ACTION_CONTROLS.map((control) => { const assignedAbilities = useMemo(() => {
const abilityId = actionBindings[actionBindingId(actionLayer, control)]; const used = new Set(Object.values(actionBindings));
const catalogAbility = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null; if (desktopBars) for (const bar of (ui.draft ?? ui.preferences.current.pc).bars) for (const slot of bar.slots) used.add(slot.abilityId);
if (!catalogAbility) return [control, null]; 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 const learnedTalentRank = catalogAbility.talentEntryId === undefined
? undefined ? undefined
: talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0; : talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
@@ -254,8 +261,11 @@ export function Hud() {
lockedUntilLevel: level < ability.unlockLevel ? ability.unlockLevel : undefined, lockedUntilLevel: level < ability.unlockLevel ? ability.unlockLevel : undefined,
target: ability.target, target: ability.target,
}; };
return [control, item]; return [catalogAbility.id, item];
})) as ActionBarItems, [abilities, actionBindings, actionLayer, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]); })), [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( const castBinding = (control: (typeof ACTION_CONTROLS)[number]) => useCombatStore.getState().castActionBinding(
useGameStore.getState().actionLayer, useGameStore.getState().actionLayer,
@@ -362,17 +372,17 @@ export function Hud() {
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}> <div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
<ManastormStatus /> <ManastormStatus />
<BossLootWindow /> <BossLootWindow />
{dungeonComplete && health > 0 ? ( <EditableHudElement id="completion">{dungeonComplete && health > 0 ? (
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} /> <DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} />
) : null} ) : null}</EditableHudElement>
{feedback?.kind === "loot" ? ( <EditableHudElement id="loot">{feedback?.kind === "loot" ? (
<section className="loot-reward" role="status" aria-live="polite"> <section className="loot-reward" role="status" aria-live="polite">
<span aria-hidden="true">◆</span> <span aria-hidden="true">◆</span>
<p><strong>Loot acquired</strong><small>{feedback.message}</small><em>Press I to inspect and equip</em></p> <p><strong>Loot acquired</strong><small>{feedback.message}</small><em>Press I to inspect and equip</em></p>
</section> </section>
) : null} ) : null}</EditableHudElement>
<PartyFrames /> <EditableHudElement id="party"><PartyFrames /></EditableHudElement>
<SummonFrames now={clock} /> <EditableHudElement id="summons"><SummonFrames now={clock} /></EditableHudElement>
{pendingGroundTarget ? ( {pendingGroundTarget ? (
<section className="ground-target-reticle" role="status" aria-live="polite"> <section className="ground-target-reticle" role="status" aria-live="polite">
@@ -391,19 +401,19 @@ export function Hud() {
) : null} ) : null}
<div className="hud-top-right"> <div className="hud-top-right">
<Minimap <EditableHudElement id="minimap"><Minimap
position={playerPosition} position={playerPosition}
yaw={cameraYaw} yaw={cameraYaw}
areaName={stagePresentation.areaName} areaName={stagePresentation.areaName}
distanceTravelled={distance} distanceTravelled={distance}
rotationMode={settings.minimapRotation} rotationMode={settings.minimapRotation}
onOpenMap={toggleMap} onOpenMap={toggleMap}
/> /></EditableHudElement>
<PlayerStatusPanel now={clock} /> <EditableHudElement id="player"><PlayerStatusPanel now={clock} /></EditableHudElement>
<EncounterTimeline now={clock} /> <EditableHudElement id="timeline"><EncounterTimeline now={clock} /></EditableHudElement>
</div> </div>
{target && settings.showTargetFrame && ( <EditableHudElement id="target">{target && settings.showTargetFrame && (
<section className={`target-frame ${target.boss ? "target-frame--boss" : ""}`} aria-label={`Target ${target.name}`}> <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> <header><strong>{target.name}</strong><span>{target.boss ? "Boss" : `Level ${target.level}`}</span></header>
<div className="target-frame__track"><span style={{ width: `${targetPercent}%` }} /></div> <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" /> <AuraStrip targetId={target.id} className="aura-strip--target" />
<small>{Math.ceil(target.health)} / {target.maxHealth}</small> <small>{Math.ceil(target.health)} / {target.maxHealth}</small>
</section> </section>
)} )}</EditableHudElement>
{target && settings.showThreatMeter && threatRows.length > 0 && ( <EditableHudElement id="threat">{target && settings.showThreatMeter && threatRows.length > 0 && (
<ThreatMeterWidget <ThreatMeterWidget
targetName={target.name} targetName={target.name}
rows={threatRows} rows={threatRows}
position={settings.threatMeterPosition} position={settings.threatMeterPosition}
onPositionChange={updateThreatMeterPosition} 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" : ""}`}> <section className="target-frame target-frame--friendly" aria-label={`Friendly target ${selectedFriendly.name}${selectedFriendly.self ? ", self" : ""}`}>
<header> <header>
<strong>{selectedFriendly.name}</strong> <strong>{selectedFriendly.name}</strong>
@@ -448,10 +458,10 @@ export function Hud() {
/> />
<small>{Math.ceil(selectedFriendly.health)} / {selectedFriendly.maxHealth}</small> <small>{Math.ceil(selectedFriendly.health)} / {selectedFriendly.maxHealth}</small>
</section> </section>
)} )}</EditableHudElement>
<div className="hud-bottom-stack"> <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}`}> <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> <header><strong>{castingAbility.name}</strong><span>{castRemaining.toFixed(1)}s</span></header>
<div className="cast-bar__track"> <div className="cast-bar__track">
@@ -459,9 +469,9 @@ export function Hud() {
</div> </div>
<small>{activeCast.mode === "channel" ? "Channeling" : "Casting"}</small> <small>{activeCast.mode === "channel" ? "Channeling" : "Casting"}</small>
</section> </section>
)} )}</EditableHudElement>
{!activeCast && ( <EditableHudElement id="hints">{!activeCast && (
<div className={`control-hint ${hasMoved && cameraLookActive ? "control-hint--settled" : ""}`}> <div className={`control-hint ${hasMoved && cameraLookActive ? "control-hint--settled" : ""}`}>
{inputMode === "gamepad" {inputMode === "gamepad"
? "Left stick move | L1 / L2 layers | Face / R1 / R2 / D-pad abilities | R3 target | Start pause" ? "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" ? "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"} : "Right mouse / Arrow keys look | Click party frames to target | WASD move | Tab enemy"}
</div> </div>
)} )}</EditableHudElement>
<ActionBar {!desktopBars && <EditableHudElement id="actions"><ActionBar
items={actionItems} items={actionItems}
activeLayer={actionLayer} activeLayer={actionLayer}
onActivate={castBinding} onActivate={castBinding}
inputMode={inputMode} inputMode={inputMode}
feedback={settings.showFloatingCombatText ? feedback?.message ?? null : null} feedback={settings.showFloatingCombatText ? feedback?.message ?? null : null}
/> /></EditableHudElement>}
</div> </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"> <div className="asset-notice" role="status">
<strong>Cave proxy active</strong> <strong>Cave proxy active</strong>
<span>{assetMessage ?? `Add ${[ <span>{assetMessage ?? `Add ${[
@@ -491,7 +504,7 @@ export function Hud() {
...dungeon.assets.collision, ...dungeon.assets.collision,
].map((asset) => asset.fileName).join(" and ")}.`}</span> ].map((asset) => asset.fileName).join(" and ")}.`}</span>
</div> </div>
)} )}</EditableHudElement>
<LoadingOverlay /> <LoadingOverlay />
{health <= 0 ? ( {health <= 0 ? (
<DefeatedPrompt <DefeatedPrompt
+11 -8
View File
@@ -13,6 +13,7 @@ import {
import { useManastormStore } from "../game/manastormStore"; import { useManastormStore } from "../game/manastormStore";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { ManastormClientOverlay } from "./ManastormClientOverlay"; import { ManastormClientOverlay } from "./ManastormClientOverlay";
import { EditableHudElement } from "./EditableHudElement";
export function ManastormStatus() { export function ManastormStatus() {
const gameMode = useGameStore((state) => state.gameMode); const gameMode = useGameStore((state) => state.gameMode);
@@ -56,7 +57,9 @@ export function ManastormStatus() {
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
}, [encounter?.id, level, waveActive]); }, [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 dungeon = requireDungeonDefinition(activeDungeonId);
const stageLocation = encounter.location ?? dungeon.title; const stageLocation = encounter.location ?? dungeon.title;
@@ -85,7 +88,7 @@ export function ManastormStatus() {
return ( return (
<> <>
<section className="manastorm-status" aria-label={`Manastorm level ${level}`}> <EditableHudElement id="manastorm"><section className="manastorm-status" aria-label={`Manastorm level ${level}`}>
<div> <div>
<span>{milestone ? "Milestone" : modeLabel}</span> <span>{milestone ? "Milestone" : modeLabel}</span>
<strong>{level}</strong> <strong>{level}</strong>
@@ -120,9 +123,9 @@ export function ManastormStatus() {
</span> </span>
) : null} ) : null}
</p> </p>
</section> </section></EditableHudElement>
<ManastormClientOverlay /> <EditableHudElement id="messages"><ManastormClientOverlay /></EditableHudElement>
{phase === "loot" ? ( <EditableHudElement id="objective">{phase === "loot" ? (
<button <button
type="button" type="button"
className={`manastorm-objective manastorm-objective--${status}`} className={`manastorm-objective manastorm-objective--${status}`}
@@ -137,8 +140,8 @@ export function ManastormStatus() {
<span aria-hidden="true">{phase === "portal" ? "✦" : "⚔"}</span> <span aria-hidden="true">{phase === "portal" ? "✦" : "⚔"}</span>
<strong>{objective}</strong> <strong>{objective}</strong>
</div> </div>
)} )}</EditableHudElement>
{rewardCache[0] ? ( <EditableHudElement id="cache">{rewardCache[0] ? (
<button <button
type="button" type="button"
className="manastorm-cache-open" className="manastorm-cache-open"
@@ -147,7 +150,7 @@ export function ManastormStatus() {
> >
Open {rewardCache[0].cacheId.replace(/-/g, " ")} Open {rewardCache[0].cacheId.replace(/-/g, " ")}
</button> </button>
) : null} ) : null}</EditableHudElement>
</> </>
); );
} }
+2
View File
@@ -42,6 +42,7 @@ function PauseDialog() {
{ id: "pause-bindings", run: () => openOverlay("bindings") }, { id: "pause-bindings", run: () => openOverlay("bindings") },
{ id: "pause-talents", run: () => openOverlay("talents") }, { id: "pause-talents", run: () => openOverlay("talents") },
{ id: "pause-options", run: () => openOverlay("options") }, { id: "pause-options", run: () => openOverlay("options") },
{ id: "pause-ui-layout", run: () => openOverlay("ui-layout") },
...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []), ...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
...(gameMode === "manastorm" ...(gameMode === "manastorm"
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }] ? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
@@ -77,6 +78,7 @@ function PauseDialog() {
{menuButton("pause-bindings", "Ability controls", () => openOverlay("bindings"))} {menuButton("pause-bindings", "Ability controls", () => openOverlay("bindings"))}
{menuButton("pause-talents", skillMenuLabel, () => openOverlay("talents"))} {menuButton("pause-talents", skillMenuLabel, () => openOverlay("talents"))}
{menuButton("pause-options", "Options", () => openOverlay("options"))} {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} {isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
{gameMode === "manastorm" {gameMode === "manastorm"
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm) ? 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; }
}