Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c9809d3e0 | ||
|
|
ed8b9e3719 | ||
|
|
147c2804d4 |
No files matched your search
@@ -4,6 +4,39 @@ This file is the durable record of runtime behavior, regressions, and the
|
||||
evidence required before calling a visual bug fixed. Update it whenever scene,
|
||||
camera, input, physics, mob-rendering, or asset-loading code changes.
|
||||
|
||||
## 2026-09-10 Personal UI templates and desktop action bars
|
||||
|
||||
- Opened Wailing Caverns through the normal offline roster, dungeon, and role
|
||||
selection screens, then opened Pause > Edit UI & templates.
|
||||
- Verified five bars containing 12 buttons each, including 2 x 6 and 3 x 4
|
||||
arrangements. Default stacking accounts for each bar's height. The editor
|
||||
can collapse or dock on either side so drag handles remain reachable.
|
||||
- Clicked Renew and pressed Shift+Q in keybind edit mode. Reassigned Shift+Q
|
||||
to bar 5/button 12 and verified the previous keybind cleared. Assigned Renew
|
||||
to that button through the ability picker; after saving, Shift+Q cast it and
|
||||
displayed its cooldown. No spell fired during key capture.
|
||||
- Dragged the minimap, hid party frames, saved/renamed/overwrote PC slot 1,
|
||||
loaded it, and round-tripped all layouts through export text and import.
|
||||
The imported JSON retained the minimap position, hidden party, bar geometry,
|
||||
ability assignment, key chord, and template name. Restoring a hidden widget
|
||||
works, and Cancel discards that visibility edit.
|
||||
- At a 390 x 844 viewport, the editor remained within the viewport and the
|
||||
Save UI button was visible and 36 CSS pixels high. The in-app browser's
|
||||
screenshot preview scales the page unusually at this override; DOM bounds
|
||||
confirmed the editor was 310 CSS pixels wide. This was responsive browser
|
||||
verification, not an Android hardware playtest.
|
||||
- Evidence: `playtest-artifacts/ui-layout-desktop.png` and
|
||||
`playtest-artifacts/ui-layout-phone-viewport.png`. Restored the offline UI
|
||||
to defaults and removed the temporary template via import after testing.
|
||||
- Automated coverage includes migration to six slots, owner isolation,
|
||||
cloud upload/hydration into a separate device cache with a mocked endpoint,
|
||||
malformed imports, key conflicts, and input precedence. No production
|
||||
account credentials were used for the cloud round-trip test.
|
||||
- `npm run build` and the focused 67-test suite passed. The development server
|
||||
was restarted after an existing watcher hit EBUSY on generated web-bootstrap
|
||||
assets during a concurrent build; no runtime UI error was logged in the
|
||||
subsequent playtest.
|
||||
|
||||
## 2026-08-18 GM Manastorm administration - COMPLETE
|
||||
|
||||
- Server-authorized `Phenom` access, revisioned SQLite configuration, safe
|
||||
@@ -1854,3 +1887,164 @@ Vite's existing large-chunk advisory remains informational.
|
||||
- Verification passed all 1,133 Vitest cases across 208 files. The production
|
||||
build completed with 974 modules transformed; arena, KTX2, and environment-
|
||||
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.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.25",
|
||||
"version": "0.1.28",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.25",
|
||||
"version": "0.1.28",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.25",
|
||||
"version": "0.1.28",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -12,6 +12,7 @@ const BUNDLED_ASSET_PREFIXES = [
|
||||
"/assets/characters/wow335a-v1/manifest.json",
|
||||
"/assets/characters/wow335a-v1/human/",
|
||||
"/assets/creatures/wailing-caverns/",
|
||||
"/assets/game/arenas/",
|
||||
"/assets/game/dungeons/wailing-caverns/",
|
||||
];
|
||||
const BUNDLED_ASSET_PATHS = new Set([
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.25",
|
||||
"versionCode": 1025
|
||||
"versionName": "0.1.28",
|
||||
"versionCode": 1028
|
||||
}
|
||||
+18
-1
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import "./ui/uiLayout.css";
|
||||
import { DualDisplayFrame } from "./components/DualDisplayFrame";
|
||||
import { installInput, releasePointerLock } from "./game/inputManager";
|
||||
import { CombatBridge } from "./game/CombatBridge";
|
||||
@@ -26,12 +27,27 @@ import { DungeonRunCheckpointBridge } from "./game/DungeonRunCheckpointBridge";
|
||||
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
|
||||
import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction";
|
||||
import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt";
|
||||
import { UiLayoutEditor } from "./ui/UiLayoutEditor";
|
||||
import { EditableHudElement } from "./ui/EditableHudElement";
|
||||
import { activeUiLayout, currentUiDevice } from "./game/uiLayoutStore";
|
||||
import { actionKeyForEvent, desktopSlotForKey } from "./game/uiLayout";
|
||||
|
||||
function InputBridge() {
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
const companionOpen = useGameStore((state) => state.companionOpen);
|
||||
|
||||
useEffect(() => installInput({
|
||||
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
|
||||
handleCustomKey: (event) => {
|
||||
const game = useGameStore.getState();
|
||||
if (game.overlay === "ui-layout") return !["Tab", "Enter", "Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.code);
|
||||
if (currentUiDevice() !== "pc" || game.overlay || game.companionOpen || useCombatStore.getState().pendingGroundTarget) return false;
|
||||
const button = desktopSlotForKey(activeUiLayout(), actionKeyForEvent(event));
|
||||
if (!button) return false;
|
||||
if (!event.repeat && button.abilityId) useCombatStore.getState().castAbility(button.abilityId, game.playerPosition, Date.now());
|
||||
game.setInputMode("keyboard");
|
||||
return true;
|
||||
},
|
||||
togglePause: () => useGameStore.getState().togglePause(),
|
||||
toggleMap: () => useGameStore.getState().toggleMap(),
|
||||
toggleInventory: () => useGameStore.getState().toggleOverlay("inventory"),
|
||||
@@ -98,10 +114,11 @@ export default function GameRuntime() {
|
||||
<ManastormBridge />
|
||||
<SceneErrorBoundary><GameScene /></SceneErrorBoundary>
|
||||
<Hud />
|
||||
<DungeonInteractionPrompt />
|
||||
<EditableHudElement id="interaction"><DungeonInteractionPrompt /></EditableHudElement>
|
||||
<MapPanel />
|
||||
<GameplayPanels />
|
||||
<PauseMenu />
|
||||
<UiLayoutEditor />
|
||||
<GmReviewCard />
|
||||
<GmWorldEditor />
|
||||
</main>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CharacterProfile, PlayerSession } from "./types";
|
||||
import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout";
|
||||
import {
|
||||
contentCategoryForClass,
|
||||
isRomClassId,
|
||||
@@ -64,6 +65,7 @@ interface ProfileDatabase {
|
||||
accounts: LocalAccount[];
|
||||
rosters: Record<string, CharacterProfile[]>;
|
||||
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
|
||||
uiPreferencesByOwner?: Record<string, UiPreferences>;
|
||||
}
|
||||
|
||||
export interface AccountResult {
|
||||
@@ -202,6 +204,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
accounts?: unknown;
|
||||
rosters?: unknown;
|
||||
presentationSettingsByOwner?: unknown;
|
||||
uiPreferencesByOwner?: unknown;
|
||||
}) | null;
|
||||
if (!parsed || ![1, 2, 3, 4, 5].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||
return emptyDatabase();
|
||||
@@ -243,7 +246,9 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
return [ownerId, normalized];
|
||||
}),
|
||||
);
|
||||
const database: ProfileDatabase = { version: 5, accounts, rosters, presentationSettingsByOwner };
|
||||
const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {};
|
||||
const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)]));
|
||||
const database: ProfileDatabase = { version: 5, accounts, rosters, presentationSettingsByOwner, uiPreferencesByOwner };
|
||||
if (parsed.version !== 5 || presentationSettingsMigrated) writeDatabase(database, storage);
|
||||
return database;
|
||||
} catch {
|
||||
@@ -254,11 +259,23 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void {
|
||||
storage?.setItem(DATABASE_KEY, JSON.stringify(database));
|
||||
if (!storage || !rosterChangeListener) return;
|
||||
for (const [ownerId, characters] of Object.entries(database.rosters)) {
|
||||
rosterChangeListener(ownerId, characters);
|
||||
for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.uiPreferencesByOwner ?? {})])) {
|
||||
rosterChangeListener(ownerId, database.rosters[ownerId] ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadUiPreferences(ownerId: string, storage: StorageLike | null = browserLocalStorage()): UiPreferences {
|
||||
return normalizeUiPreferences(readDatabase(storage).uiPreferencesByOwner?.[ownerId]);
|
||||
}
|
||||
|
||||
export function saveUiPreferences(ownerId: string, value: unknown, storage: StorageLike | null = browserLocalStorage()): UiPreferences {
|
||||
const database = readDatabase(storage);
|
||||
const preferences = normalizeUiPreferences(value);
|
||||
database.uiPreferencesByOwner = { ...database.uiPreferencesByOwner, [ownerId]: preferences };
|
||||
writeDatabase(database, storage);
|
||||
return preferences;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let result = "";
|
||||
for (let index = 0; index < bytes.length; index += 3) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
listCharacters,
|
||||
replaceCharactersForOwner,
|
||||
setRosterChangeListener,
|
||||
loadUiPreferences,
|
||||
saveUiPreferences,
|
||||
type AccountResult,
|
||||
} from "./accountRepository";
|
||||
import type { CharacterProfile, PlayerSession } from "./types";
|
||||
@@ -20,7 +23,7 @@ interface AuthResponse {
|
||||
interface CloudSaveResponse {
|
||||
revision: number;
|
||||
updatedAt: number | null;
|
||||
data: { version?: number; characters?: unknown };
|
||||
data: { version?: number; characters?: unknown; uiPreferences?: unknown };
|
||||
}
|
||||
|
||||
interface MeResponse {
|
||||
@@ -39,6 +42,8 @@ let suppressUpload = false;
|
||||
let pendingCharacters: CharacterProfile[] | null = null;
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let saveQueue = Promise.resolve();
|
||||
export const useCloudSaveStatus = create<{ state: "local" | "pending" | "synced" | "error"; message: string }>(() => ({ state: "local", message: "" }));
|
||||
let saveSequence = 0;
|
||||
|
||||
export function onlineApiOrigin(): string {
|
||||
const configured = String(import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, "");
|
||||
@@ -100,18 +105,24 @@ function flushCloudSave(): void {
|
||||
const characters = pendingCharacters;
|
||||
pendingCharacters = null;
|
||||
if (!session?.accessToken || !characters) return;
|
||||
const uiPreferences = loadUiPreferences(session.ownerId);
|
||||
const sequence = saveSequence;
|
||||
saveQueue = saveQueue.then(async () => {
|
||||
await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ data: { version: 1, characters } }),
|
||||
body: JSON.stringify({ data: { version: 1, characters, uiPreferences } }),
|
||||
}, session.accessToken);
|
||||
if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "synced", message: "Saved to your account." });
|
||||
}).catch((error) => {
|
||||
console.error("Healer Man cloud save failed", error);
|
||||
if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "error", message: "Saved on this device. Cloud upload failed; retry before switching devices." });
|
||||
});
|
||||
}
|
||||
|
||||
function queueCloudSave(ownerId: string, characters: readonly CharacterProfile[]): void {
|
||||
if (suppressUpload || !activeSession?.accessToken || activeSession.ownerId !== ownerId) return;
|
||||
saveSequence += 1;
|
||||
useCloudSaveStatus.setState({ state: "pending", message: "Uploading to your account…" });
|
||||
pendingCharacters = characters.map((character) => ({ ...character }));
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
@@ -123,15 +134,23 @@ function queueCloudSave(ownerId: string, characters: readonly CharacterProfile[]
|
||||
setRosterChangeListener(queueCloudSave);
|
||||
|
||||
export function activateOnlineSession(session: PlayerSession): void {
|
||||
if (activeSession?.ownerId !== session.ownerId && saveTimer) {
|
||||
clearTimeout(saveTimer); saveTimer = null; flushCloudSave();
|
||||
}
|
||||
activeSession = session.accessToken ? session : null;
|
||||
}
|
||||
|
||||
export function retryCloudSave(): void {
|
||||
if (activeSession) queueCloudSave(activeSession.ownerId, listCharacters(activeSession.ownerId));
|
||||
}
|
||||
|
||||
export async function hydrateOnlineSession(session: PlayerSession): Promise<CharacterProfile[]> {
|
||||
if (!session.accessToken) return listCharacters(session.ownerId);
|
||||
activateOnlineSession(session);
|
||||
const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
|
||||
suppressUpload = true;
|
||||
try {
|
||||
saveUiPreferences(session.ownerId, cloud.data?.uiPreferences);
|
||||
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []);
|
||||
} finally {
|
||||
suppressUpload = false;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadUiPreferences, saveUiPreferences, setRosterChangeListener } from "./accountRepository";
|
||||
import { normalizeUiPreferences } from "../game/uiLayout";
|
||||
import type { PlayerSession } from "./types";
|
||||
|
||||
function storage() {
|
||||
const values = new Map<string, string>();
|
||||
return { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } };
|
||||
}
|
||||
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); setRosterChangeListener(null); });
|
||||
|
||||
describe("profile UI persistence", () => {
|
||||
it("isolates owners and persists templates without requiring a character", () => {
|
||||
const local = storage(); const ui = normalizeUiPreferences(null);
|
||||
ui.slots.pc[1] = { name: "Healer", layout: ui.current.pc };
|
||||
saveUiPreferences("one", ui, local);
|
||||
expect(loadUiPreferences("one", local).slots.pc[1]?.name).toBe("Healer");
|
||||
expect(loadUiPreferences("two", local).slots.pc[1]).toBeNull();
|
||||
ui.slots.pc[1].name = "Unsaved change";
|
||||
expect(loadUiPreferences("one", local).slots.pc[1]?.name).toBe("Healer");
|
||||
});
|
||||
it("uploads all six slots and restores them into a separate device cache", async () => {
|
||||
vi.useFakeTimers();
|
||||
const first = storage(); const second = storage();
|
||||
vi.stubGlobal("window", { localStorage: first });
|
||||
let cloudData: unknown = { version: 1, characters: [] };
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
if (init.method === "PUT") cloudData = JSON.parse(String(init.body)).data;
|
||||
return new Response(JSON.stringify({ revision: 1, updatedAt: 1, data: cloudData }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}));
|
||||
const client = await import("./onlineAccountClient");
|
||||
const session: PlayerSession = { ownerId: "cloud-user", kind: "account", displayName: "Player", accessToken: "test-token" };
|
||||
client.activateOnlineSession(session);
|
||||
const ui = normalizeUiPreferences(null);
|
||||
ui.slots.pc[2] = { name: "Desktop raid", layout: ui.current.pc };
|
||||
ui.slots.mobile[1] = { name: "On the go", layout: ui.current.mobile };
|
||||
saveUiPreferences(session.ownerId, ui);
|
||||
await vi.advanceTimersByTimeAsync(501);
|
||||
expect(client.useCloudSaveStatus.getState().state).toBe("synced");
|
||||
vi.stubGlobal("window", { localStorage: second });
|
||||
await client.hydrateOnlineSession(session);
|
||||
expect(loadUiPreferences(session.ownerId)).toEqual(ui);
|
||||
expect(loadUiPreferences("different-account").slots.pc[2]).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -25,9 +25,12 @@ import {
|
||||
type Material,
|
||||
type Mesh,
|
||||
type Object3D,
|
||||
type Skeleton,
|
||||
type SkinnedMesh,
|
||||
type Texture,
|
||||
} from "three";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import { shareClonedSkeletons } from "./sharedSkeletons";
|
||||
import type {
|
||||
CharacterAppearance,
|
||||
ClassId,
|
||||
@@ -123,6 +126,7 @@ function configureMaterial(material: Material): void {
|
||||
|
||||
function cloneAvatarScene(root: Object3D): Object3D {
|
||||
const scene = cloneSkeleton(root);
|
||||
shareClonedSkeletons(scene);
|
||||
scene.traverse((node) => {
|
||||
const mesh = node as Mesh;
|
||||
if (!mesh.isMesh) return;
|
||||
@@ -267,13 +271,17 @@ function LoadedCharacterModel({
|
||||
|
||||
useEffect(() => () => {
|
||||
const ownedMaterials = new Set<Material>();
|
||||
const ownedSkeletons = new Set<Skeleton>();
|
||||
scene.traverse((node) => {
|
||||
const skinned = node as SkinnedMesh;
|
||||
if (skinned.isSkinnedMesh) ownedSkeletons.add(skinned.skeleton);
|
||||
const mesh = node as Mesh;
|
||||
if (!mesh.isMesh) return;
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||||
materials.forEach((material) => ownedMaterials.add(material));
|
||||
});
|
||||
ownedMaterials.forEach((material) => material.dispose());
|
||||
ownedSkeletons.forEach((skeleton) => skeleton.dispose());
|
||||
}, [scene]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -359,22 +367,33 @@ function LoadedCharacterModel({
|
||||
?? gltf.animations.find((candidate) => candidate.name.toLowerCase().includes(normalized));
|
||||
};
|
||||
|
||||
const selectClip = (motion: CharacterBaseMotion): AnimationClip | undefined => (
|
||||
selectCharacterBaseClip(gltf.animations, motion, weaponStance)
|
||||
?? namedClip(entry.animations[motion])
|
||||
?? namedClip(entry.animations.idle)
|
||||
?? gltf.animations[0]
|
||||
);
|
||||
// The GLTF clip library and weapon stance change far less often than frames.
|
||||
// Resolve locomotion once so five party members do not repeatedly parse and
|
||||
// sort the same hundreds of animation names while standing or running.
|
||||
const baseClips = useMemo(() => Object.fromEntries(
|
||||
(["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) => {
|
||||
if (overrideAction.current) return;
|
||||
const verticalSelection = motion === "rising" || motion === "falling" || motion === "landing"
|
||||
? selectCharacterVerticalClip(gltf.animations, motion, moving)
|
||||
? verticalClips[motion][moving ? 1 : 0]
|
||||
: null;
|
||||
const fallbackMotion: CharacterBaseMotion = moving ? "run" : "idle";
|
||||
const clip = verticalSelection?.clip ?? selectClip(
|
||||
motion === "idle" || motion === "walk" || motion === "run" ? motion : fallbackMotion,
|
||||
);
|
||||
const clip = verticalSelection?.clip ?? baseClips[
|
||||
motion === "idle" || motion === "walk" || motion === "run" ? motion : fallbackMotion
|
||||
];
|
||||
if (!clip) return;
|
||||
if (currentMotion.current === motion && currentAction.current?.getClip() === clip) return;
|
||||
const loop = verticalSelection?.loop ?? true;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onAc
|
||||
}
|
||||
});
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.repeat) return;
|
||||
if (event.repeat || event.defaultPrevented) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
const isEditable = target?.matches("input, textarea, select, [contenteditable='true']") ?? false;
|
||||
if (event.code === "KeyC" && !isEditable) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ARENA_STAGES } from "../game/arenaStages";
|
||||
import { BROWSER_CONTENT_CACHE_NAME } from "./contentManager";
|
||||
|
||||
const workerSource = readFileSync(
|
||||
@@ -7,7 +9,61 @@ const workerSource = readFileSync(
|
||||
"utf8",
|
||||
);
|
||||
|
||||
function contentWorker(cached?: Response) {
|
||||
const handlers = new Map<string, (event: unknown) => void>();
|
||||
const fetch = vi.fn(async () => new Response("bundled arena", { status: 200 }));
|
||||
const match = vi.fn(async () => cached);
|
||||
runInNewContext(workerSource, {
|
||||
self: {
|
||||
location: { origin: "https://healer.test" },
|
||||
addEventListener: (type: string, handler: (event: unknown) => void) => handlers.set(type, handler),
|
||||
},
|
||||
caches: { open: async () => ({ match }) },
|
||||
fetch,
|
||||
URL,
|
||||
Response,
|
||||
});
|
||||
return {
|
||||
fetch,
|
||||
match,
|
||||
request: (path: string) => {
|
||||
let response: Promise<Response> | undefined;
|
||||
handlers.get("fetch")!({
|
||||
request: new Request(new URL(path, "https://healer.test")),
|
||||
respondWith: (value: Promise<Response>) => { response = value; },
|
||||
});
|
||||
return response;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("browser content cache worker", () => {
|
||||
it.each(ARENA_STAGES)("loads bundled $name assets without an optional content install", async (stage) => {
|
||||
const worker = contentWorker();
|
||||
for (const asset of stage.assets) {
|
||||
const response = await worker.request(`${asset.url}?arena-runtime=1`);
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.text()).toBe("bundled arena");
|
||||
}
|
||||
expect(worker.fetch).toHaveBeenCalledTimes(stage.assets.length);
|
||||
});
|
||||
|
||||
it("prefers installed arena updates over bundled files, ignoring the runtime query", async () => {
|
||||
const worker = contentWorker(new Response("updated arena"));
|
||||
const response = await worker.request(`${ARENA_STAGES[0].assets[0].url}?arena-runtime=1`);
|
||||
expect(await response?.text()).toBe("updated arena");
|
||||
expect(worker.match).toHaveBeenCalledWith(expect.any(Request), { ignoreSearch: true });
|
||||
expect(worker.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still blocks optional dungeon assets that have not been installed", async () => {
|
||||
const worker = contentWorker();
|
||||
const response = await worker.request("/assets/game/manastorm/optional/visual.glb");
|
||||
expect(response?.status).toBe(404);
|
||||
expect(await response?.text()).toBe("Optional content is not installed.");
|
||||
expect(worker.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves only cached same-origin asset requests from the active content cache", () => {
|
||||
expect(workerSource).toContain(`const CONTENT_CACHE_NAME = "${BROWSER_CONTENT_CACHE_NAME}";`);
|
||||
expect(workerSource).toContain('request.method !== "GET"');
|
||||
|
||||
@@ -66,6 +66,33 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("gameplay input bindings", () => {
|
||||
it("consumes custom action keys before movement, menus and legacy action bindings", () => {
|
||||
stubInputEventTargets();
|
||||
const nextActions = inputActionSpies();
|
||||
nextActions.handleCustomKey = vi.fn(() => true);
|
||||
const dispose = installInput(nextActions);
|
||||
for (const code of ["KeyW", "KeyI", "Digit1"]) {
|
||||
const event = new Event("keydown", { cancelable: true });
|
||||
Object.defineProperties(event, { code: { value: code }, target: { value: null } });
|
||||
window.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
}
|
||||
expect(nextActions.toggleInventory).not.toHaveBeenCalled();
|
||||
expect(nextActions.castActionBinding).not.toHaveBeenCalled();
|
||||
expect(nextActions.handleCustomKey).toHaveBeenCalledTimes(3);
|
||||
dispose();
|
||||
});
|
||||
it("does not fire the old numbered bindings when desktop bars are enabled", () => {
|
||||
stubInputEventTargets();
|
||||
const nextActions = inputActionSpies();
|
||||
nextActions.desktopActionBarsActive = () => true;
|
||||
const dispose = installInput(nextActions);
|
||||
const event = new Event("keydown", { cancelable: true });
|
||||
Object.defineProperties(event, { code: { value: "Digit1" }, target: { value: null } });
|
||||
window.dispatchEvent(event);
|
||||
expect(nextActions.castActionBinding).not.toHaveBeenCalled();
|
||||
dispose();
|
||||
});
|
||||
it("reserves I, P, N, and E for equipment and loot interactions", () => {
|
||||
expect(keyboardCommandForCode("KeyI")).toBe("inventory");
|
||||
expect(keyboardCommandForCode("KeyE")).toBe("loot");
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface InputSnapshot {
|
||||
}
|
||||
|
||||
export interface InputActions {
|
||||
/** Runs before built-in shortcuts. True consumes the key, including empty custom slots. */
|
||||
handleCustomKey?: (event: KeyboardEvent) => boolean;
|
||||
desktopActionBarsActive?: () => boolean;
|
||||
togglePause: () => void;
|
||||
toggleMap: () => void;
|
||||
toggleInventory: () => void;
|
||||
@@ -292,6 +295,12 @@ export function installInput(nextActions: InputActions): () => void {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.matches("input, textarea, select, [contenteditable='true']")) return;
|
||||
if (nextActions.handleCustomKey?.(event)) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
keys.delete(event.code);
|
||||
return;
|
||||
}
|
||||
if (["ShiftLeft", "ShiftRight", "AltLeft", "AltRight"].includes(event.code)) {
|
||||
keyboardModifiers.add(event.code);
|
||||
nextActions.setInputMode("keyboard");
|
||||
@@ -312,7 +321,7 @@ export function installInput(nextActions: InputActions): () => void {
|
||||
else nextActions.cancelGroundTarget();
|
||||
return;
|
||||
}
|
||||
const actionControl = actionControlForKeyboardCode(event.code);
|
||||
const actionControl = nextActions.desktopActionBarsActive?.() ? null : actionControlForKeyboardCode(event.code);
|
||||
if (actionControl) {
|
||||
if (nextActions.gameplayActionsBlocked()) return;
|
||||
event.preventDefault();
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import type { GmReviewTarget } from "./gmContentReview";
|
||||
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
|
||||
export type InputMode = "keyboard" | "gamepad";
|
||||
export type GameMode = "dungeon" | "manastorm";
|
||||
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "gm" | null;
|
||||
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "gm" | null;
|
||||
|
||||
interface PlayerSnapshot {
|
||||
position: Vector3Tuple;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
actionKeyForEvent, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
|
||||
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences,
|
||||
} from "./uiLayout";
|
||||
|
||||
describe("personal UI layouts", () => {
|
||||
it("migrates old accounts to independent Mobile and PC layouts with exactly six empty slots", () => {
|
||||
const ui = normalizeUiPreferences(undefined);
|
||||
expect(ui.slots.mobile).toEqual([null, null, null]);
|
||||
expect(ui.slots.pc).toEqual([null, null, null]);
|
||||
ui.current.pc.elements.party.visible = false;
|
||||
expect(ui.current.mobile.elements.party.visible).toBe(true);
|
||||
expect(ui.current.pc.bars).toHaveLength(5);
|
||||
expect(ui.current.pc.bars.every((bar) => bar.slots.length === 12)).toBe(true);
|
||||
});
|
||||
it("round trips named slots, positions, hidden elements, geometry, assignments, and key chords", () => {
|
||||
const ui = normalizeUiPreferences(null);
|
||||
const layout = ui.current.pc;
|
||||
layout.wowBars = true; layout.barCount = 5;
|
||||
layout.elements.party = { visible: false, position: { x: .32, y: .45 }, width: 200 };
|
||||
layout.bars[4].columns = 3;
|
||||
layout.bars[4].slots[11] = { abilityId: "test-heal", key: "Ctrl+Shift+KeyQ" };
|
||||
ui.slots.pc[2] = { name: "Raid night", layout };
|
||||
ui.slots.mobile[0] = { name: "Travel", layout: defaultUiLayout() };
|
||||
expect(importUiPreferences(exportUiPreferences(ui))).toEqual(ui);
|
||||
});
|
||||
it("reassigns conflicts even from hidden bars, accepts modifiers and clears a binding", () => {
|
||||
let layout = defaultUiLayout(); layout.wowBars = true; layout.barCount = 5;
|
||||
layout = bindDesktopSlot(layout, 4, 11, "Digit1");
|
||||
expect(layout.bars[0].slots[0].key).toBeNull();
|
||||
expect(desktopSlotForKey(layout, "Digit1")).toBe(layout.bars[4].slots[11]);
|
||||
layout.elements["bar-5"].visible = false;
|
||||
expect(desktopSlotForKey(layout, "Digit1")).not.toBeNull();
|
||||
layout.barCount = 4;
|
||||
expect(desktopSlotForKey(layout, "Digit1")).toBeNull();
|
||||
layout = bindDesktopSlot(layout, 4, 11, null);
|
||||
expect(layout.bars[4].slots[11].key).toBeNull();
|
||||
expect(actionKeyForEvent({ code: "KeyQ", ctrlKey: true, shiftKey: true, altKey: false, metaKey: false })).toBe("Ctrl+Shift+KeyQ");
|
||||
expect(normalizeActionKey("Escape")).toBeNull();
|
||||
expect(normalizeActionKey("ShiftLeft")).toBeNull();
|
||||
});
|
||||
it.each([
|
||||
["unsupported versions", (data: any) => { data.version = 99; }],
|
||||
["extra slots", (data: any) => { data.slots.mobile.push(null); }],
|
||||
["invalid coordinates", (data: any) => { data.current.pc.elements.party.position = { x: "bad", y: 0 }; }],
|
||||
["invalid bar geometry", (data: any) => { data.current.pc.bars[0].columns = 7; }],
|
||||
["invalid keys", (data: any) => { data.current.pc.bars[0].slots[0].key = "Escape"; }],
|
||||
["duplicate chords", (data: any) => { data.current.pc.bars[0].slots[1].key = "Digit1"; }],
|
||||
["missing buttons", (data: any) => { data.current.pc.bars[0].slots.pop(); }],
|
||||
])("rejects %s without mutating the original preferences", (_, mutate) => {
|
||||
const ui = normalizeUiPreferences(null);
|
||||
const original = exportUiPreferences(ui);
|
||||
const data = JSON.parse(original); mutate(data);
|
||||
expect(() => importUiPreferences(JSON.stringify(data))).toThrow();
|
||||
expect(exportUiPreferences(ui)).toBe(original);
|
||||
});
|
||||
it("rejects malformed and oversized imports", () => {
|
||||
expect(() => importUiPreferences("{")).toThrow("valid JSON");
|
||||
expect(() => importUiPreferences(" ".repeat(256001))).toThrow("256 KB");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { normalizeHudPosition, type NormalizedHudPosition } from "./hudLayout";
|
||||
|
||||
export type UiDevice = "mobile" | "pc";
|
||||
export const UI_DEVICES: readonly UiDevice[] = ["mobile", "pc"];
|
||||
export const BAR_COLUMNS = [1, 2, 3, 4, 6, 12] as const;
|
||||
export const HUD_ELEMENTS = {
|
||||
party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap",
|
||||
player: "Player status", timeline: "Encounter timeline", target: "Enemy target",
|
||||
friendly: "Friendly target", threat: "Threat meter", cast: "Cast bar",
|
||||
hints: "Control hints", actions: "Controller action bar", experience: "Experience bar",
|
||||
reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt",
|
||||
manastorm: "Manastorm status", completion: "Dungeon completion", assets: "Asset notice",
|
||||
objective: "Manastorm objective", messages: "Manastorm messages", cache: "Reward cache", feedback: "Action feedback",
|
||||
"bar-1": "Action bar 1", "bar-2": "Action bar 2", "bar-3": "Action bar 3",
|
||||
"bar-4": "Action bar 4", "bar-5": "Action bar 5",
|
||||
} as const;
|
||||
export type HudElementId = keyof typeof HUD_ELEMENTS;
|
||||
export interface HudElementLayout {
|
||||
visible: boolean;
|
||||
position: NormalizedHudPosition | null;
|
||||
width: number | null;
|
||||
}
|
||||
export interface DesktopActionSlot { abilityId: string | null; key: string | null }
|
||||
export interface DesktopActionBar { columns: number; slots: DesktopActionSlot[] }
|
||||
export interface UiLayout {
|
||||
elements: Record<HudElementId, HudElementLayout>;
|
||||
wowBars: boolean;
|
||||
barCount: number;
|
||||
bars: DesktopActionBar[];
|
||||
}
|
||||
export interface UiTemplate { name: string; layout: UiLayout }
|
||||
export interface UiPreferences {
|
||||
version: 1;
|
||||
current: Record<UiDevice, UiLayout>;
|
||||
slots: Record<UiDevice, (UiTemplate | null)[]>;
|
||||
}
|
||||
|
||||
export function defaultUiLayout(): UiLayout {
|
||||
return {
|
||||
elements: Object.fromEntries(Object.keys(HUD_ELEMENTS).map((id) => [id, {
|
||||
visible: true, position: null, width: null,
|
||||
}])) as UiLayout["elements"],
|
||||
wowBars: false,
|
||||
barCount: 1,
|
||||
bars: Array.from({ length: 5 }, (_, bar) => ({
|
||||
columns: 12,
|
||||
slots: Array.from({ length: 12 }, (_, slot) => ({
|
||||
abilityId: null,
|
||||
key: bar === 0 ? (slot < 9 ? `Digit${slot + 1}` : ["Digit0", "Minus", "Equal"][slot - 9]) : null,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
function bounded(value: unknown, fallback: number, min: number, max: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
|
||||
}
|
||||
const KEY_CODE = /^(?:Key[A-Z]|Digit[0-9]|F(?:[1-9]|1[0-2])|Numpad(?:[0-9]|Add|Subtract|Multiply|Divide|Decimal|Enter)|Arrow(?:Up|Down|Left|Right)|Space|Tab|Enter|Backquote|Minus|Equal|BracketLeft|BracketRight|Backslash|Semicolon|Quote|Comma|Period|Slash|Insert|Home|End|PageUp|PageDown)$/;
|
||||
export function normalizeActionKey(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const parts = value.split("+");
|
||||
const code = parts.pop()!;
|
||||
if (!KEY_CODE.test(code) || parts.some((part) => !["Ctrl", "Alt", "Shift", "Meta"].includes(part))) return null;
|
||||
return [...["Ctrl", "Alt", "Shift", "Meta"].filter((part) => parts.includes(part)), code].join("+");
|
||||
}
|
||||
export function actionKeyForEvent(event: Pick<KeyboardEvent, "code" | "ctrlKey" | "altKey" | "shiftKey" | "metaKey">): string | null {
|
||||
return normalizeActionKey([
|
||||
...(event.ctrlKey ? ["Ctrl"] : []), ...(event.altKey ? ["Alt"] : []),
|
||||
...(event.shiftKey ? ["Shift"] : []), ...(event.metaKey ? ["Meta"] : []), event.code,
|
||||
].join("+"));
|
||||
}
|
||||
export function actionKeyLabel(key: string | null): string {
|
||||
return key?.replace(/Key|Digit/g, "").replace("Minus", "−").replace("Equal", "=").replace("Numpad", "Num ") ?? "";
|
||||
}
|
||||
export function normalizeUiLayout(value: unknown): UiLayout {
|
||||
const source = record(value);
|
||||
const defaults = defaultUiLayout();
|
||||
const elements = record(source.elements);
|
||||
for (const id of Object.keys(HUD_ELEMENTS) as HudElementId[]) {
|
||||
const element = record(elements[id]);
|
||||
defaults.elements[id] = {
|
||||
visible: element.visible !== false,
|
||||
position: element.position ? normalizeHudPosition(element.position, { x: 0.5, y: 0.5 }) : null,
|
||||
width: typeof element.width === "number" ? bounded(element.width, 200, 20, 2000) : null,
|
||||
};
|
||||
}
|
||||
const used = new Set<string>();
|
||||
defaults.bars = defaults.bars.map((fallback, index) => {
|
||||
const bar = record(Array.isArray(source.bars) ? source.bars[index] : null);
|
||||
return {
|
||||
columns: BAR_COLUMNS.includes(bar.columns as 12) ? bar.columns as number : 12,
|
||||
slots: fallback.slots.map((slot, slotIndex) => {
|
||||
const raw = Array.isArray(bar.slots) ? record(bar.slots[slotIndex]) : slot;
|
||||
const key = normalizeActionKey(raw.key);
|
||||
const uniqueKey = key && !used.has(key) ? key : null;
|
||||
if (uniqueKey) used.add(uniqueKey);
|
||||
return {
|
||||
key: uniqueKey,
|
||||
abilityId: typeof raw.abilityId === "string" && raw.abilityId.length <= 200 ? raw.abilityId : null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
return { ...defaults, wowBars: source.wowBars === true, barCount: Math.floor(bounded(source.barCount, 1, 1, 5)) };
|
||||
}
|
||||
export function normalizeUiPreferences(value: unknown): UiPreferences {
|
||||
const source = record(value);
|
||||
const current = record(source.current);
|
||||
const slots = record(source.slots);
|
||||
return {
|
||||
version: 1,
|
||||
current: { mobile: normalizeUiLayout(current.mobile), pc: normalizeUiLayout(current.pc) },
|
||||
slots: Object.fromEntries(UI_DEVICES.map((device) => [device, Array.from({ length: 3 }, (_, index) => {
|
||||
const slot = record(Array.isArray(slots[device]) ? slots[device][index] : null);
|
||||
return typeof slot.name === "string" && slot.layout
|
||||
? { name: slot.name.trim().slice(0, 40) || `Template ${index + 1}`, layout: normalizeUiLayout(slot.layout) }
|
||||
: null;
|
||||
})])) as UiPreferences["slots"],
|
||||
};
|
||||
}
|
||||
|
||||
/** A key has one owner, including hidden bars, so enabling a bar cannot add conflicts. */
|
||||
export function bindDesktopSlot(layout: UiLayout, barIndex: number, slotIndex: number, key: string | null): UiLayout {
|
||||
return { ...layout, bars: layout.bars.map((bar, b) => ({ ...bar, slots: bar.slots.map((slot, s) => ({
|
||||
...slot, key: b === barIndex && s === slotIndex ? key : key && slot.key === key ? null : slot.key,
|
||||
})) })) };
|
||||
}
|
||||
export function desktopSlotForKey(layout: UiLayout, key: string | null): DesktopActionSlot | null {
|
||||
if (!layout.wowBars || !key) return null;
|
||||
// Hidden bars retain their bindings, like WoW; reducing the bar count deactivates them.
|
||||
return layout.bars.slice(0, layout.barCount).flatMap((bar) => bar.slots).find((slot) => slot.key === key) ?? null;
|
||||
}
|
||||
export function exportUiPreferences(preferences: UiPreferences): string {
|
||||
return JSON.stringify({ format: "healer-man-ui", ...normalizeUiPreferences(preferences) }, null, 2);
|
||||
}
|
||||
export function importUiPreferences(text: string): UiPreferences {
|
||||
if (text.length > 256_000) throw new Error("UI files must be smaller than 256 KB.");
|
||||
let source: Record<string, unknown>;
|
||||
try { source = record(JSON.parse(text)); } catch { throw new Error("This file is not valid JSON."); }
|
||||
if (source.format !== "healer-man-ui" || source.version !== 1) throw new Error("Unsupported UI file format or version.");
|
||||
const validateLayout = (value: unknown) => {
|
||||
const layout = record(value);
|
||||
if (typeof layout.wowBars !== "boolean" || !Number.isInteger(layout.barCount) || Number(layout.barCount) < 1 || Number(layout.barCount) > 5
|
||||
|| !Array.isArray(layout.bars) || layout.bars.length !== 5) throw new Error("Invalid action bar configuration.");
|
||||
for (const id of Object.keys(HUD_ELEMENTS)) {
|
||||
const element = record(record(layout.elements)[id]);
|
||||
const position = record(element.position);
|
||||
if (typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1))
|
||||
|| (element.width !== null && (typeof element.width !== "number" || !Number.isFinite(element.width) || element.width < 20 || element.width > 2000))) throw new Error("Invalid HUD position or visibility.");
|
||||
}
|
||||
const keys = new Set<string>();
|
||||
for (const raw of layout.bars) {
|
||||
const bar = record(raw);
|
||||
if (!BAR_COLUMNS.includes(bar.columns as 12) || !Array.isArray(bar.slots) || bar.slots.length !== 12) throw new Error("Each bar needs exactly 12 buttons and a valid row/column layout.");
|
||||
for (const rawSlot of bar.slots) {
|
||||
const slot = record(rawSlot);
|
||||
if (slot.abilityId !== null && (typeof slot.abilityId !== "string" || slot.abilityId.length > 200)) throw new Error("Invalid ability assignment.");
|
||||
if (slot.key !== null && (!normalizeActionKey(slot.key) || keys.has(normalizeActionKey(slot.key)!))) throw new Error("Invalid or duplicate keybind.");
|
||||
if (slot.key) keys.add(normalizeActionKey(slot.key)!);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const device of UI_DEVICES) {
|
||||
validateLayout(record(source.current)[device]);
|
||||
const slots = record(source.slots)[device];
|
||||
if (!Array.isArray(slots) || slots.length !== 3) throw new Error("UI files must contain three Mobile and three PC slots.");
|
||||
for (const slot of slots) {
|
||||
if (slot === null) continue;
|
||||
const template = record(slot);
|
||||
if (typeof template.name !== "string" || !template.name.trim() || template.name.length > 40) throw new Error("Template names must be 1–40 characters.");
|
||||
validateLayout(template.layout);
|
||||
}
|
||||
}
|
||||
return normalizeUiPreferences(source);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { create } from "zustand";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { loadUiPreferences, saveUiPreferences } from "../app/accountRepository";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreferences } from "./uiLayout";
|
||||
|
||||
export function currentUiDevice(): UiDevice {
|
||||
return Capacitor.isNativePlatform() || (typeof navigator !== "undefined" && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent))
|
||||
|| (typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches && window.innerWidth < 1100)
|
||||
? "mobile" : "pc";
|
||||
}
|
||||
interface UiLayoutState {
|
||||
ownerId: string | null;
|
||||
preferences: UiPreferences;
|
||||
draft: UiLayout | null;
|
||||
device: UiDevice;
|
||||
mode: "layout" | "bindings";
|
||||
selectedButton: { bar: number; slot: number } | null;
|
||||
selectedElement: string | null;
|
||||
begin: () => void;
|
||||
updateDraft: (update: (layout: UiLayout) => UiLayout) => void;
|
||||
persist: (preferences: UiPreferences) => void;
|
||||
finish: (save: boolean) => void;
|
||||
}
|
||||
export const useUiLayoutStore = create<UiLayoutState>((set, get) => ({
|
||||
ownerId: null, preferences: normalizeUiPreferences(null), draft: null, device: currentUiDevice(),
|
||||
mode: "layout", selectedButton: null, selectedElement: null,
|
||||
begin: () => {
|
||||
const device = currentUiDevice();
|
||||
set({ draft: get().preferences.current[device], device, mode: "layout", selectedButton: null });
|
||||
},
|
||||
updateDraft: (update) => set((state) => state.draft ? { draft: update(state.draft) } : state),
|
||||
persist: (preferences) => {
|
||||
const ownerId = get().ownerId;
|
||||
if (!ownerId) throw new Error("Sign in or choose an offline profile before saving your UI.");
|
||||
// Write first: storage errors leave the editor open with its draft intact.
|
||||
const saved = saveUiPreferences(ownerId, preferences);
|
||||
set({ preferences: saved });
|
||||
},
|
||||
finish: (save) => {
|
||||
const { draft, device, preferences } = get();
|
||||
if (save && draft) get().persist({ ...preferences, current: { ...preferences.current, [device]: draft } });
|
||||
set({ draft: null, selectedButton: null, mode: "layout" });
|
||||
},
|
||||
}));
|
||||
|
||||
function activateOwner() {
|
||||
const ownerId = useShellStore.getState().session?.ownerId ?? null;
|
||||
useUiLayoutStore.setState({ ownerId, preferences: ownerId ? loadUiPreferences(ownerId) : normalizeUiPreferences(null), draft: null });
|
||||
}
|
||||
activateOwner();
|
||||
useShellStore.subscribe((state, previous) => {
|
||||
if (state.session !== previous.session) activateOwner();
|
||||
});
|
||||
|
||||
export function activeUiLayout(): UiLayout {
|
||||
const state = useUiLayoutStore.getState();
|
||||
return state.draft ?? state.preferences.current[currentUiDevice()];
|
||||
}
|
||||
@@ -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
@@ -1,13 +1,14 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { Canvas, useThree } from "@react-three/fiber";
|
||||
import { Physics } from "@react-three/rapier";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { spawnMatchesDifficulty } from "../game/mobPopulation";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
} from "../game/inputManager";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { DungeonEnvironment } from "./DungeonEnvironment";
|
||||
import { dungeonRenderDistance } from "./dungeonRenderDistance";
|
||||
import { MobPopulation } from "./MobPopulation";
|
||||
import { PartyPopulation } from "./PartyPopulation";
|
||||
import { GroundEffectTelegraphs } from "./GroundEffectTelegraphs";
|
||||
@@ -69,6 +71,17 @@ import {
|
||||
suppressCompletedDungeonPopulation,
|
||||
} 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() {
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
@@ -85,16 +98,7 @@ export function GameScene() {
|
||||
const completedObjectiveIds = gameMode === "dungeon"
|
||||
? activeDungeonCompletedObjectiveIds(activeDungeonId)
|
||||
: [];
|
||||
const cameraFar = 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 cameraFar = dungeonRenderDistance(dungeon);
|
||||
const stagePresentation = resolveStagePresentation(gameMode, manastormEncounter, {
|
||||
title: dungeon.title,
|
||||
mapId: dungeon.mapId,
|
||||
@@ -332,6 +336,7 @@ export function GameScene() {
|
||||
powerPreference: safeGraphics ? "default" : "high-performance",
|
||||
}}
|
||||
>
|
||||
<DungeonCameraRange far={cameraFar} />
|
||||
{frameloop === "never" && targetFps !== null && (
|
||||
<CappedFrameLoop
|
||||
active={documentVisible || sharedOnline}
|
||||
@@ -355,7 +360,7 @@ export function GameScene() {
|
||||
/>
|
||||
)}
|
||||
{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]} />
|
||||
<ambientLight intensity={dungeon.presentation.ambientLight.intensity} color={dungeon.presentation.ambientLight.color} />
|
||||
<hemisphereLight args={[
|
||||
|
||||
@@ -5,9 +5,10 @@ const source = readFileSync(new URL("./MobPopulation.tsx", import.meta.url), "ut
|
||||
|
||||
describe("mob nameplate presentation", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
import { useGameGLTF } from "./useGameGLTF";
|
||||
import { onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime";
|
||||
import { isMobAggroRangeVisible } from "./mobAggroRangeVisibility";
|
||||
import { DungeonOccludedHtml } from "./DungeonOccludedHtml";
|
||||
|
||||
const MOVEMENT_LOCKING_MOB_STATUSES: ReadonlySet<string> = new Set([
|
||||
"stun", "root", "sleep", "knockdown", "freeze", "charm", "confuse",
|
||||
@@ -1037,9 +1038,8 @@ function ProxyMob({
|
||||
<MobHealthPlate instanceId={instanceId} height={labelHeight - (showLabel ? 0.38 : 0)} boss={boss} />
|
||||
|
||||
{showLabel && !dead ? (
|
||||
<Html
|
||||
<DungeonOccludedHtml
|
||||
center
|
||||
occlude="raycast"
|
||||
position={[0, labelHeight, 0]}
|
||||
distanceFactor={13}
|
||||
style={boss ? bossLabelStyle : mobLabelStyle}
|
||||
@@ -1048,7 +1048,7 @@ function ProxyMob({
|
||||
<div>{definition.name}</div>
|
||||
{boss ? <div style={{ fontSize: "0.78em", fontWeight: 600, opacity: 0.85 }}>{definition.title}</div> : null}
|
||||
</div>
|
||||
</Html>
|
||||
</DungeonOccludedHtml>
|
||||
) : null}
|
||||
{lootable ? (
|
||||
<Html
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
AdditiveBlending,
|
||||
BufferAttribute,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
prepareCollision,
|
||||
} from "./productionDungeonCollision";
|
||||
import { prepareDungeonMaterial } from "./productionDungeonMaterials";
|
||||
import { WAILING_CAVERNS_DEFINITION } from "../game/dungeons/wailingCaverns";
|
||||
|
||||
const EMPTY_MATERIAL_ROLES = {
|
||||
cutoutPatterns: [],
|
||||
@@ -110,10 +112,45 @@ describe("streamed dungeon collision 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", () => {
|
||||
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.transparent).toBe(true);
|
||||
@@ -126,7 +163,7 @@ describe("streamed dungeon material preparation", () => {
|
||||
it("preserves alpha masking declared by the source GLB", () => {
|
||||
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.opacity).toBe(1);
|
||||
@@ -141,7 +178,7 @@ describe("streamed dungeon material preparation", () => {
|
||||
additiveNames: ["LIGHT-VOLUME"],
|
||||
};
|
||||
|
||||
const prepared = prepareDungeonMaterial(source, roles);
|
||||
const prepared = prepareDungeonMaterial(source, roles, "runewaker-ros");
|
||||
|
||||
expect(prepared.transparent).toBe(true);
|
||||
expect(prepared.depthWrite).toBe(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { RigidBody, TrimeshCollider } from "@react-three/rapier";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import {
|
||||
Mesh,
|
||||
type InstancedMesh,
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
type Object3D,
|
||||
} from "three";
|
||||
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 {
|
||||
collisionLineOfSightTrimeshes,
|
||||
@@ -19,7 +20,8 @@ import {
|
||||
DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS,
|
||||
} from "./dungeonLineOfSight";
|
||||
import { disposeObject3DResources } from "./threeResourceDisposal";
|
||||
import { instanceRepeatedStaticMeshes } from "./staticSceneInstancing";
|
||||
import { cacheStaticMatrices, instanceRepeatedStaticMeshes } from "./staticSceneInstancing";
|
||||
import { createStaticVisibilityCells, registerStaticVisibility } from "./staticSceneVisibility";
|
||||
import { useGameGLTF } from "./useGameGLTF";
|
||||
import { prepareDungeonMaterial } from "./productionDungeonMaterials";
|
||||
import {
|
||||
@@ -28,7 +30,7 @@ import {
|
||||
advanceDungeonAssetCount,
|
||||
} from "./dungeonLoadSequencing";
|
||||
|
||||
function prepareVisual(root: Object3D, roles: DungeonMaterialRoles): Object3D {
|
||||
function prepareVisual(root: Object3D, definition: DungeonDefinition) {
|
||||
const clone = root.clone(true);
|
||||
const clonedMaterials = new Map<string, Material>();
|
||||
clone.traverse((node) => {
|
||||
@@ -38,7 +40,7 @@ function prepareVisual(root: Object3D, roles: DungeonMaterialRoles): Object3D {
|
||||
const prepared = sources.map((source) => {
|
||||
const cached = clonedMaterials.get(source.uuid);
|
||||
if (cached) return cached;
|
||||
const next = prepareDungeonMaterial(source, roles);
|
||||
const next = prepareDungeonMaterial(source, definition.presentation.materials, definition.environmentMode);
|
||||
clonedMaterials.set(source.uuid, next);
|
||||
return next;
|
||||
});
|
||||
@@ -52,15 +54,19 @@ function prepareVisual(root: Object3D, roles: DungeonMaterialRoles): Object3D {
|
||||
}
|
||||
});
|
||||
instanceRepeatedStaticMeshes(clone);
|
||||
return clone;
|
||||
const cells = createStaticVisibilityCells(clone);
|
||||
cacheStaticMatrices(clone);
|
||||
return { visual: clone, cells };
|
||||
}
|
||||
|
||||
function VisualChunk({ url, definition }: { readonly url: string; readonly definition: DungeonDefinition }) {
|
||||
const gltf = useGameGLTF(url);
|
||||
const visual = useMemo(
|
||||
() => prepareVisual(gltf.scene, definition.presentation.materials),
|
||||
[definition.presentation.materials, gltf.scene],
|
||||
const scene = useThree((state) => state.scene);
|
||||
const { visual, cells } = useMemo(
|
||||
() => prepareVisual(gltf.scene, definition),
|
||||
[definition, gltf.scene],
|
||||
);
|
||||
useEffect(() => registerStaticVisibility(scene, cells), [scene, cells]);
|
||||
useEffect(() => () => { disposeObject3DResources(visual); }, [visual]);
|
||||
const { position, rotation, scale } = definition.transform;
|
||||
return <primitive object={visual} position={position} rotation={rotation} scale={scale} />;
|
||||
|
||||
@@ -15,7 +15,8 @@ describe("world NPC prompts", () => {
|
||||
const promptSource = readFileSync(new URL("./WorldNpcPrompt.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).not.toContain("<Html");
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Html } from "@react-three/drei";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { Group, Vector3 } from "three";
|
||||
import type { MobVector3 } from "../game/mobPopulation";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { DungeonOccludedHtml } from "./DungeonOccludedHtml";
|
||||
|
||||
export const WORLD_NPC_PROMPT_MAX_DISTANCE = 22;
|
||||
|
||||
@@ -61,9 +61,9 @@ export function WorldNpcPrompt({
|
||||
return (
|
||||
<group ref={anchorRef} position={[position[0], position[1], position[2]]}>
|
||||
{visible ? (
|
||||
<Html center occlude="raycast" zIndexRange={[zIndexRange[0], zIndexRange[1]]}>
|
||||
<DungeonOccludedHtml center zIndexRange={[zIndexRange[0], zIndexRange[1]]}>
|
||||
{children}
|
||||
</Html>
|
||||
</DungeonOccludedHtml>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -17,6 +17,22 @@ function renderCount(targetFps: 30 | 60, sourceFps: number, durationMs = 1_000):
|
||||
}
|
||||
|
||||
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", () => {
|
||||
expect(renderCount(30, 144)).toBeGreaterThanOrEqual(30);
|
||||
expect(renderCount(30, 144)).toBeLessThanOrEqual(31);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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 {
|
||||
readonly mode: FrameRateGovernorMode;
|
||||
readonly lastTimestampMs: number | null;
|
||||
@@ -66,7 +71,7 @@ export function stepFrameRateGovernor(
|
||||
}
|
||||
|
||||
const deadlineMs = state.nextDeadlineMs ?? timestampMs;
|
||||
if (timestampMs < deadlineMs) {
|
||||
if (timestampMs + DEADLINE_TOLERANCE_MS < deadlineMs) {
|
||||
const nextState: FrameRateGovernorState = {
|
||||
...state,
|
||||
lastTimestampMs: timestampMs,
|
||||
@@ -75,7 +80,9 @@ export function stepFrameRateGovernor(
|
||||
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 = {
|
||||
...state,
|
||||
lastTimestampMs: timestampMs,
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
NormalBlending,
|
||||
type Material,
|
||||
} 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 {
|
||||
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));
|
||||
}
|
||||
|
||||
export function prepareDungeonMaterial(source: Material, roles: DungeonMaterialRoles): Material {
|
||||
export function prepareDungeonMaterial(
|
||||
source: Material,
|
||||
roles: DungeonMaterialRoles,
|
||||
environmentMode: DungeonEnvironmentMode,
|
||||
): Material {
|
||||
const material = source.clone();
|
||||
const name = material.name;
|
||||
material.side = DoubleSide;
|
||||
@@ -31,17 +35,19 @@ export function prepareDungeonMaterial(source: Material, roles: DungeonMaterialR
|
||||
material.transparent = true;
|
||||
material.depthWrite = false;
|
||||
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.
|
||||
material.transparent = false;
|
||||
material.opacity = 1;
|
||||
material.depthWrite = true;
|
||||
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.
|
||||
material.depthWrite = false;
|
||||
material.blending = NormalBlending;
|
||||
} 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.opacity = 1;
|
||||
material.alphaTest = 0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
BoxGeometry,
|
||||
Group,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
MeshBasicMaterial,
|
||||
Vector3,
|
||||
} from "three";
|
||||
import { instanceRepeatedStaticMeshes } from "./staticSceneInstancing";
|
||||
import { cacheStaticMatrices, instanceRepeatedStaticMeshes } from "./staticSceneInstancing";
|
||||
|
||||
function batchIn(root: Group): InstancedMesh {
|
||||
let result: InstancedMesh | null = null;
|
||||
@@ -28,6 +28,33 @@ function batchesIn(root: Group): readonly InstancedMesh[] {
|
||||
}
|
||||
|
||||
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", () => {
|
||||
const root = new Group();
|
||||
root.position.set(10, 0, 0);
|
||||
@@ -72,6 +99,26 @@ describe("static scene instancing", () => {
|
||||
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", () => {
|
||||
const root = new Group();
|
||||
const geometry = new BoxGeometry();
|
||||
|
||||
@@ -9,6 +9,32 @@ import {
|
||||
|
||||
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 {
|
||||
readonly batches: number;
|
||||
readonly instances: number;
|
||||
@@ -86,6 +112,7 @@ export function instanceRepeatedStaticMeshes(
|
||||
let batches = 0;
|
||||
let instances = 0;
|
||||
let removedMeshes = 0;
|
||||
const emptiedParents = new Set<Object3D>();
|
||||
|
||||
for (const meshes of groups.values()) {
|
||||
if (meshes.length < minimumInstances) continue;
|
||||
@@ -101,6 +128,7 @@ export function instanceRepeatedStaticMeshes(
|
||||
for (let index = 0; index < meshes.length; index += 1) {
|
||||
const mesh = meshes[index];
|
||||
batch.setMatrixAt(index, new Matrix4().multiplyMatrices(inverseRootWorld, mesh.matrixWorld));
|
||||
if (mesh.parent) emptiedParents.add(mesh.parent);
|
||||
mesh.removeFromParent();
|
||||
}
|
||||
batch.instanceMatrix.needsUpdate = true;
|
||||
@@ -112,5 +140,17 @@ export function instanceRepeatedStaticMeshes(
|
||||
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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -40,7 +40,7 @@ function cooldownPercent(remaining = 0, duration = 0): number {
|
||||
return Math.max(0, Math.min(100, (remaining / duration) * 100));
|
||||
}
|
||||
|
||||
function ActionIcon({ item }: { item: ActionBarItem | null }) {
|
||||
export function ActionIcon({ item }: { item: ActionBarItem | null }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => setFailed(false), [item?.iconUrl]);
|
||||
if (!item) return <span className="action-slot__icon" aria-hidden="true" />;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { actionKeyLabel, type HudElementId } from "../game/uiLayout";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { ActionIcon, type ActionBarItem } from "./ActionBar";
|
||||
|
||||
export function DesktopActionBars({ items }: { items: Readonly<Record<string, ActionBarItem>> }) {
|
||||
const ui = useUiLayoutStore();
|
||||
const editing = useGameStore((state) => state.overlay === "ui-layout");
|
||||
const blocked = useGameStore((state) => state.overlay !== null || state.companionOpen);
|
||||
const layout = ui.draft ?? ui.preferences.current[currentUiDevice()];
|
||||
if (currentUiDevice() !== "pc" || !layout.wowBars) return null;
|
||||
return <>{layout.bars.slice(0, layout.barCount).map((bar, b) => {
|
||||
const offset = 30 + layout.bars.slice(0, b).reduce((total, previous) => total + (12 / previous.columns) * 46 + 10 + (editing ? 24 : 0), 0);
|
||||
return <EditableHudElement id={`bar-${b + 1}` as HudElementId} key={b} intrinsicWidth={bar.columns * 46 + 8} bottomAnchor={offset + (12 / bar.columns) * 23 + (editing ? 16 : 4)}>
|
||||
<section className="desktop-action-bar" aria-label={`Action bar ${b + 1}`} style={{ "--bar-columns": bar.columns, "--bar-offset": `${offset}px` } as CSSProperties}>
|
||||
{bar.slots.map((button, s) => {
|
||||
const item = button.abilityId ? items[button.abilityId] : null;
|
||||
const selected = editing && ui.selectedButton?.bar === b && ui.selectedButton.slot === s;
|
||||
return <button type="button" key={s} className={`desktop-action-button ${item?.disabled ? "is-unavailable" : ""} ${selected ? "is-selected" : ""}`}
|
||||
aria-label={`Bar ${b + 1} button ${s + 1}: ${item?.name ?? "Empty"}${button.key ? `, ${actionKeyLabel(button.key)}` : ""}`}
|
||||
aria-disabled={!editing && (!item || item.disabled || blocked)} aria-pressed={editing ? selected : undefined}
|
||||
title={item ? `${item.name}\n${item.description ?? ""}\n${actionKeyLabel(button.key)}${item.lockedUntilLevel ? ` · Level ${item.lockedUntilLevel}` : ""}` : `Empty · ${actionKeyLabel(button.key)}`}
|
||||
onClick={() => {
|
||||
if (editing) { useUiLayoutStore.setState({ selectedButton: { bar: b, slot: s } }); return; }
|
||||
if (!blocked && item && !item.disabled) useCombatStore.getState().castAbility(item.id, useGameStore.getState().playerPosition, Date.now());
|
||||
}}>
|
||||
<ActionIcon item={item ?? null} /><kbd>{actionKeyLabel(button.key)}</kbd>
|
||||
{item && item.cooldownRemaining! > .05 && <span className="desktop-action-button__cooldown">{item.cooldownRemaining! < 10 ? item.cooldownRemaining!.toFixed(1) : Math.ceil(item.cooldownRemaining!)}</span>}
|
||||
{editing && !item && <small>{s + 1}</small>}
|
||||
</button>;
|
||||
})}
|
||||
</section>
|
||||
</EditableHudElement>; })}</>;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { HUD_ELEMENTS, type HudElementId } from "../game/uiLayout";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
const FALLBACKS: Partial<Record<HudElementId, [number, number, number]>> = {
|
||||
party: [.1, .3, 210], summons: [.27, .25, 150], minimap: [.9, .12, 160], player: [.9, .34, 160],
|
||||
timeline: [.9, .52, 160], target: [.5, .15, 230], friendly: [.3, .15, 230], threat: [.78, .25, 160],
|
||||
cast: [.5, .68, 330], actions: [.5, .86, 420], hints: [.5, .65, 420], experience: [.5, .99, 900],
|
||||
reticle: [.5, .5, 20], loot: [.5, .3, 320], interaction: [.5, .6, 320], manastorm: [.5, .05, 400],
|
||||
completion: [.5, .39, 360], assets: [.78, .6, 200], feedback: [.5, .71, 400],
|
||||
objective: [.5, .1, 400], messages: [.5, .22, 350], cache: [.78, .42, 200],
|
||||
};
|
||||
|
||||
/** Defaults remain in their original CSS flow until the player moves the widget. */
|
||||
export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor }: {
|
||||
id: HudElementId; children?: ReactNode; intrinsicWidth?: number; bottomAnchor?: number;
|
||||
}) {
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
const editing = overlay === "ui-layout";
|
||||
const state = useUiLayoutStore();
|
||||
const layout = state.draft ?? state.preferences.current[currentUiDevice()];
|
||||
const element = layout.elements[id];
|
||||
const [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [host, setHost] = useState<Element | null>(null);
|
||||
const drag = useRef<{ pointerId: number; dx: number; dy: number } | null>(null);
|
||||
const [bounds, setBounds] = useState({ width: 0, height: 0, panelWidth: 0, panelHeight: 0 });
|
||||
const positioned = editing || element.position !== null;
|
||||
const fallback = FALLBACKS[id] ?? [.5, .35, 250];
|
||||
const position = element.position ?? (bottomAnchor !== undefined
|
||||
? { x: .5, y: 1 - bottomAnchor / Math.max(1, bounds.height) }
|
||||
: origin ?? { x: fallback[0], y: fallback[1] });
|
||||
const width = intrinsicWidth ?? element.width ?? origin?.width ?? fallback[2];
|
||||
|
||||
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(".game-shell")); }, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (positioned || !ref.current) return;
|
||||
const node = ref.current;
|
||||
const measure = () => {
|
||||
const child = node.firstElementChild?.getBoundingClientRect();
|
||||
const host = node.closest(".game-shell")?.getBoundingClientRect();
|
||||
if (child && host && child.width > 0 && child.height > 0) {
|
||||
setOrigin({ x: (child.left - host.left + child.width / 2) / host.width,
|
||||
y: (child.top - host.top + child.height / 2) / host.height, width: child.width });
|
||||
}
|
||||
};
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
if (node.firstElementChild) observer.observe(node.firstElementChild);
|
||||
window.addEventListener("resize", measure);
|
||||
return () => { observer.disconnect(); window.removeEventListener("resize", measure); };
|
||||
}, [positioned, children]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current;
|
||||
const host = node?.closest(".game-shell");
|
||||
if (!positioned || !node || !host) return;
|
||||
const measure = () => setBounds({ width: host.clientWidth, height: host.clientHeight, panelWidth: node.offsetWidth, panelHeight: node.offsetHeight });
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(node); observer.observe(host); measure();
|
||||
return () => observer.disconnect();
|
||||
}, [positioned, host]);
|
||||
|
||||
const updatePosition = (x: number, y: number) => state.updateDraft((draft) => ({ ...draft, elements: {
|
||||
...draft.elements, [id]: { ...draft.elements[id], position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width },
|
||||
} }));
|
||||
const left = Math.max(6, Math.min(Math.max(6, bounds.width - bounds.panelWidth - 6), position.x * bounds.width - bounds.panelWidth / 2));
|
||||
const top = Math.max(6, Math.min(Math.max(6, bounds.height - bounds.panelHeight - 6), position.y * bounds.height - bounds.panelHeight / 2));
|
||||
const style = positioned ? { left, top, width: `min(${width}px, calc(100% - 12px))` } as CSSProperties : undefined;
|
||||
const content = <div ref={ref} data-hud-element={id} style={style}
|
||||
className={`editable-hud ${positioned ? "editable-hud--positioned" : "editable-hud--default"} ${editing ? "editable-hud--editing" : ""} ${!element.visible ? editing ? "editable-hud--ghost" : "editable-hud--hidden" : ""}`}
|
||||
onClickCapture={(event) => { if (editing && !id.startsWith("bar-")) { event.preventDefault(); event.stopPropagation(); useUiLayoutStore.setState({ selectedElement: id }); } }}>
|
||||
{children}
|
||||
{editing && <button type="button" className="editable-hud__handle" aria-label={`Move ${HUD_ELEMENTS[id]}`} title="Drag to move; arrow keys nudge"
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const rect = ref.current!.getBoundingClientRect();
|
||||
drag.current = { pointerId: event.pointerId, dx: event.clientX - rect.left - rect.width / 2, dy: event.clientY - rect.top - rect.height / 2 };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
useUiLayoutStore.setState({ selectedElement: id });
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (drag.current?.pointerId !== event.pointerId) return;
|
||||
const host = ref.current!.closest(".game-shell")!.getBoundingClientRect();
|
||||
updatePosition((event.clientX - host.left - drag.current.dx) / host.width, (event.clientY - host.top - drag.current.dy) / host.height);
|
||||
}}
|
||||
onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} onLostPointerCapture={() => { drag.current = null; }}
|
||||
onKeyDown={(event) => {
|
||||
const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key];
|
||||
if (!delta) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
updatePosition(position.x + delta[0] * .01, position.y + delta[1] * .01);
|
||||
}}>{HUD_ELEMENTS[id]}{!element.visible ? " · Hidden" : ""}</button>}
|
||||
</div>;
|
||||
return positioned && host ? createPortal(content, host) : content;
|
||||
}
|
||||
+45
-32
@@ -32,6 +32,9 @@ import { AuraStrip } from "./AuraStrip";
|
||||
import { ManastormStatus } from "./ManastormStatus";
|
||||
import { ThreatMeterWidget } from "./ThreatMeterWidget";
|
||||
import { BossLootWindow } from "./BossLootWindow";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { DesktopActionBars } from "./DesktopActionBars";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { SummonFrames } from "./SummonFrames";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { onlineAccountIdFromActor, onlinePlayerActorId } from "../game/onlineSessionRuntime";
|
||||
@@ -139,6 +142,8 @@ export function DungeonCompletePrompt({
|
||||
}
|
||||
|
||||
export function Hud() {
|
||||
const ui = useUiLayoutStore();
|
||||
const desktopBars = currentUiDevice() === "pc" && (ui.draft ?? ui.preferences.current.pc).wowBars;
|
||||
const character = useShellStore((state) => state.activeCharacter);
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const areaName = useGameStore((state) => state.areaName);
|
||||
@@ -216,10 +221,12 @@ export function Hud() {
|
||||
|
||||
const classColor = classById(classId).color;
|
||||
const resourceTone = resourceProfileForClass(classId).type;
|
||||
const actionItems = useMemo<ActionBarItems>(() => Object.fromEntries(ACTION_CONTROLS.map((control) => {
|
||||
const abilityId = actionBindings[actionBindingId(actionLayer, control)];
|
||||
const catalogAbility = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
|
||||
if (!catalogAbility) return [control, null];
|
||||
const assignedAbilities = useMemo(() => {
|
||||
const used = new Set(Object.values(actionBindings));
|
||||
if (desktopBars) for (const bar of (ui.draft ?? ui.preferences.current.pc).bars) for (const slot of bar.slots) used.add(slot.abilityId);
|
||||
return abilities.filter((ability) => used.has(ability.id));
|
||||
}, [abilities, actionBindings, desktopBars, ui.draft, ui.preferences]);
|
||||
const abilityItems = useMemo<Record<string, ActionBarItem>>(() => Object.fromEntries(assignedAbilities.map((catalogAbility) => {
|
||||
const learnedTalentRank = catalogAbility.talentEntryId === undefined
|
||||
? undefined
|
||||
: talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
|
||||
@@ -254,8 +261,11 @@ export function Hud() {
|
||||
lockedUntilLevel: level < ability.unlockLevel ? ability.unlockLevel : undefined,
|
||||
target: ability.target,
|
||||
};
|
||||
return [control, item];
|
||||
})) as ActionBarItems, [abilities, actionBindings, actionLayer, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]);
|
||||
return [catalogAbility.id, item];
|
||||
})), [assignedAbilities, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]);
|
||||
const actionItems = Object.fromEntries(ACTION_CONTROLS.map((control) => [control,
|
||||
abilityItems[actionBindings[actionBindingId(actionLayer, control)] ?? ""] ?? null,
|
||||
])) as ActionBarItems;
|
||||
|
||||
const castBinding = (control: (typeof ACTION_CONTROLS)[number]) => useCombatStore.getState().castActionBinding(
|
||||
useGameStore.getState().actionLayer,
|
||||
@@ -362,17 +372,17 @@ export function Hud() {
|
||||
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
|
||||
<ManastormStatus />
|
||||
<BossLootWindow />
|
||||
{dungeonComplete && health > 0 ? (
|
||||
<EditableHudElement id="completion">{dungeonComplete && health > 0 ? (
|
||||
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} />
|
||||
) : null}
|
||||
{feedback?.kind === "loot" ? (
|
||||
) : null}</EditableHudElement>
|
||||
<EditableHudElement id="loot">{feedback?.kind === "loot" ? (
|
||||
<section className="loot-reward" role="status" aria-live="polite">
|
||||
<span aria-hidden="true">◆</span>
|
||||
<p><strong>Loot acquired</strong><small>{feedback.message}</small><em>Press I to inspect and equip</em></p>
|
||||
</section>
|
||||
) : null}
|
||||
<PartyFrames />
|
||||
<SummonFrames now={clock} />
|
||||
) : null}</EditableHudElement>
|
||||
<EditableHudElement id="party"><PartyFrames /></EditableHudElement>
|
||||
<EditableHudElement id="summons"><SummonFrames now={clock} /></EditableHudElement>
|
||||
|
||||
{pendingGroundTarget ? (
|
||||
<section className="ground-target-reticle" role="status" aria-live="polite">
|
||||
@@ -391,19 +401,19 @@ export function Hud() {
|
||||
) : null}
|
||||
|
||||
<div className="hud-top-right">
|
||||
<Minimap
|
||||
<EditableHudElement id="minimap"><Minimap
|
||||
position={playerPosition}
|
||||
yaw={cameraYaw}
|
||||
areaName={stagePresentation.areaName}
|
||||
distanceTravelled={distance}
|
||||
rotationMode={settings.minimapRotation}
|
||||
onOpenMap={toggleMap}
|
||||
/>
|
||||
<PlayerStatusPanel now={clock} />
|
||||
<EncounterTimeline now={clock} />
|
||||
/></EditableHudElement>
|
||||
<EditableHudElement id="player"><PlayerStatusPanel now={clock} /></EditableHudElement>
|
||||
<EditableHudElement id="timeline"><EncounterTimeline now={clock} /></EditableHudElement>
|
||||
</div>
|
||||
|
||||
{target && settings.showTargetFrame && (
|
||||
<EditableHudElement id="target">{target && settings.showTargetFrame && (
|
||||
<section className={`target-frame ${target.boss ? "target-frame--boss" : ""}`} aria-label={`Target ${target.name}`}>
|
||||
<header><strong>{target.name}</strong><span>{target.boss ? "Boss" : `Level ${target.level}`}</span></header>
|
||||
<div className="target-frame__track"><span style={{ width: `${targetPercent}%` }} /></div>
|
||||
@@ -420,18 +430,18 @@ export function Hud() {
|
||||
<AuraStrip targetId={target.id} className="aura-strip--target" />
|
||||
<small>{Math.ceil(target.health)} / {target.maxHealth}</small>
|
||||
</section>
|
||||
)}
|
||||
)}</EditableHudElement>
|
||||
|
||||
{target && settings.showThreatMeter && threatRows.length > 0 && (
|
||||
<EditableHudElement id="threat">{target && settings.showThreatMeter && threatRows.length > 0 && (
|
||||
<ThreatMeterWidget
|
||||
targetName={target.name}
|
||||
rows={threatRows}
|
||||
position={settings.threatMeterPosition}
|
||||
onPositionChange={updateThreatMeterPosition}
|
||||
/>
|
||||
)}
|
||||
)}</EditableHudElement>
|
||||
|
||||
{selectedFriendly && settings.showTargetFrame && (
|
||||
<EditableHudElement id="friendly">{selectedFriendly && settings.showTargetFrame && (
|
||||
<section className="target-frame target-frame--friendly" aria-label={`Friendly target ${selectedFriendly.name}${selectedFriendly.self ? ", self" : ""}`}>
|
||||
<header>
|
||||
<strong>{selectedFriendly.name}</strong>
|
||||
@@ -448,10 +458,10 @@ export function Hud() {
|
||||
/>
|
||||
<small>{Math.ceil(selectedFriendly.health)} / {selectedFriendly.maxHealth}</small>
|
||||
</section>
|
||||
)}
|
||||
)}</EditableHudElement>
|
||||
|
||||
<div className="hud-bottom-stack">
|
||||
{activeCast && castingAbility && (
|
||||
<EditableHudElement id="cast">{activeCast && castingAbility && (
|
||||
<section className={`cast-bar cast-bar--${activeCast.mode}`} aria-label={`${activeCast.mode === "channel" ? "Channeling" : "Casting"} ${castingAbility.name}`}>
|
||||
<header><strong>{castingAbility.name}</strong><span>{castRemaining.toFixed(1)}s</span></header>
|
||||
<div className="cast-bar__track">
|
||||
@@ -459,9 +469,9 @@ export function Hud() {
|
||||
</div>
|
||||
<small>{activeCast.mode === "channel" ? "Channeling" : "Casting"}</small>
|
||||
</section>
|
||||
)}
|
||||
)}</EditableHudElement>
|
||||
|
||||
{!activeCast && (
|
||||
<EditableHudElement id="hints">{!activeCast && (
|
||||
<div className={`control-hint ${hasMoved && cameraLookActive ? "control-hint--settled" : ""}`}>
|
||||
{inputMode === "gamepad"
|
||||
? "Left stick move | L1 / L2 layers | Face / R1 / R2 / D-pad abilities | R3 target | Start pause"
|
||||
@@ -469,21 +479,24 @@ export function Hud() {
|
||||
? "Release right mouse for cursor | WASD move | Arrow keys look | 1-8 abilities | Shift / Alt layers"
|
||||
: "Right mouse / Arrow keys look | Click party frames to target | WASD move | Tab enemy"}
|
||||
</div>
|
||||
)}
|
||||
)}</EditableHudElement>
|
||||
|
||||
<ActionBar
|
||||
{!desktopBars && <EditableHudElement id="actions"><ActionBar
|
||||
items={actionItems}
|
||||
activeLayer={actionLayer}
|
||||
onActivate={castBinding}
|
||||
inputMode={inputMode}
|
||||
feedback={settings.showFloatingCombatText ? feedback?.message ?? null : null}
|
||||
/>
|
||||
/></EditableHudElement>}
|
||||
</div>
|
||||
<ExperienceBar level={level} current={xp} required={xpToNext} />
|
||||
<DesktopActionBars items={abilityItems} />
|
||||
<EditableHudElement id="feedback">{desktopBars && settings.showFloatingCombatText && feedback?.message
|
||||
? <div className="desktop-action-feedback" role="status">{feedback.message}</div> : null}</EditableHudElement>
|
||||
<EditableHudElement id="experience"><ExperienceBar level={level} current={xp} required={xpToNext} /></EditableHudElement>
|
||||
|
||||
{cameraLookActive && <span className="reticle" aria-hidden="true" />}
|
||||
<EditableHudElement id="reticle">{cameraLookActive && <span className="reticle" aria-hidden="true" />}</EditableHudElement>
|
||||
|
||||
{assetStatus === "proxy" && (
|
||||
<EditableHudElement id="assets">{assetStatus === "proxy" && (
|
||||
<div className="asset-notice" role="status">
|
||||
<strong>Cave proxy active</strong>
|
||||
<span>{assetMessage ?? `Add ${[
|
||||
@@ -491,7 +504,7 @@ export function Hud() {
|
||||
...dungeon.assets.collision,
|
||||
].map((asset) => asset.fileName).join(" and ")}.`}</span>
|
||||
</div>
|
||||
)}
|
||||
)}</EditableHudElement>
|
||||
<LoadingOverlay />
|
||||
{health <= 0 ? (
|
||||
<DefeatedPrompt
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { ManastormClientOverlay } from "./ManastormClientOverlay";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
|
||||
export function ManastormStatus() {
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
@@ -56,7 +57,9 @@ export function ManastormStatus() {
|
||||
return () => window.clearInterval(interval);
|
||||
}, [encounter?.id, level, waveActive]);
|
||||
|
||||
if (gameMode !== "manastorm" || !encounter || status === "idle" || status === "failed") return null;
|
||||
if (gameMode !== "manastorm" || !encounter || status === "idle" || status === "failed") return <>
|
||||
<EditableHudElement id="manastorm" /><EditableHudElement id="objective" /><EditableHudElement id="messages" /><EditableHudElement id="cache" />
|
||||
</>;
|
||||
|
||||
const dungeon = requireDungeonDefinition(activeDungeonId);
|
||||
const stageLocation = encounter.location ?? dungeon.title;
|
||||
@@ -85,7 +88,7 @@ export function ManastormStatus() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="manastorm-status" aria-label={`Manastorm level ${level}`}>
|
||||
<EditableHudElement id="manastorm"><section className="manastorm-status" aria-label={`Manastorm level ${level}`}>
|
||||
<div>
|
||||
<span>{milestone ? "Milestone" : modeLabel}</span>
|
||||
<strong>{level}</strong>
|
||||
@@ -120,9 +123,9 @@ export function ManastormStatus() {
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</section>
|
||||
<ManastormClientOverlay />
|
||||
{phase === "loot" ? (
|
||||
</section></EditableHudElement>
|
||||
<EditableHudElement id="messages"><ManastormClientOverlay /></EditableHudElement>
|
||||
<EditableHudElement id="objective">{phase === "loot" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`manastorm-objective manastorm-objective--${status}`}
|
||||
@@ -137,8 +140,8 @@ export function ManastormStatus() {
|
||||
<span aria-hidden="true">{phase === "portal" ? "✦" : "⚔"}</span>
|
||||
<strong>{objective}</strong>
|
||||
</div>
|
||||
)}
|
||||
{rewardCache[0] ? (
|
||||
)}</EditableHudElement>
|
||||
<EditableHudElement id="cache">{rewardCache[0] ? (
|
||||
<button
|
||||
type="button"
|
||||
className="manastorm-cache-open"
|
||||
@@ -147,7 +150,7 @@ export function ManastormStatus() {
|
||||
>
|
||||
Open {rewardCache[0].cacheId.replace(/-/g, " ")}
|
||||
</button>
|
||||
) : null}
|
||||
) : null}</EditableHudElement>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ function PauseDialog() {
|
||||
{ id: "pause-bindings", run: () => openOverlay("bindings") },
|
||||
{ id: "pause-talents", run: () => openOverlay("talents") },
|
||||
{ id: "pause-options", run: () => openOverlay("options") },
|
||||
{ id: "pause-ui-layout", run: () => openOverlay("ui-layout") },
|
||||
...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
|
||||
...(gameMode === "manastorm"
|
||||
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
|
||||
@@ -77,6 +78,7 @@ function PauseDialog() {
|
||||
{menuButton("pause-bindings", "Ability controls", () => openOverlay("bindings"))}
|
||||
{menuButton("pause-talents", skillMenuLabel, () => openOverlay("talents"))}
|
||||
{menuButton("pause-options", "Options", () => openOverlay("options"))}
|
||||
{menuButton("pause-ui-layout", "Edit UI & templates", () => openOverlay("ui-layout"))}
|
||||
{isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
|
||||
{gameMode === "manastorm"
|
||||
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm)
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { retryCloudSave, useCloudSaveStatus } from "../app/onlineAccountClient";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { defaultActionBarForCharacter } from "../game/abilityCatalog";
|
||||
import { useGameStore } from "../game/store";
|
||||
import {
|
||||
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout,
|
||||
exportUiPreferences, importUiPreferences, type HudElementId, type UiDevice,
|
||||
} from "../game/uiLayout";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
|
||||
export function UiLayoutEditor() {
|
||||
const open = useGameStore((state) => state.overlay === "ui-layout");
|
||||
const ui = useUiLayoutStore();
|
||||
const session = useShellStore((state) => state.session);
|
||||
const cloudSave = useCloudSaveStatus();
|
||||
const abilities = useCombatStore((state) => state.abilities);
|
||||
const [tab, setTab] = useState<UiDevice>(ui.device);
|
||||
const [name, setName] = useState("My UI");
|
||||
const [slot, setSlot] = useState(0);
|
||||
const [message, setMessage] = useState("");
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [dockRight, setDockRight] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [pendingImport, setPendingImport] = useState<ReturnType<typeof importUiPreferences> | null>(null);
|
||||
const [transferText, setTransferText] = useState<string | null>(null);
|
||||
const file = useRef<HTMLInputElement>(null);
|
||||
const wasOpen = useRef(false);
|
||||
useEffect(() => {
|
||||
if (open && !wasOpen.current) {
|
||||
useUiLayoutStore.getState().begin();
|
||||
setTab(useUiLayoutStore.getState().device); setMessage("");
|
||||
}
|
||||
if (!open && wasOpen.current) useUiLayoutStore.getState().finish(false);
|
||||
wasOpen.current = open;
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
const state = useUiLayoutStore.getState();
|
||||
if (event.target instanceof Element && event.target.closest("input, textarea, select, [contenteditable='true']")) return;
|
||||
if (event.code === "Escape") {
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
if (state.selectedButton) useUiLayoutStore.setState({ selectedButton: null });
|
||||
else { state.finish(false); useGameStore.getState().openOverlay("pause"); }
|
||||
return;
|
||||
}
|
||||
if (state.mode !== "bindings" || !state.selectedButton) return;
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
if (event.repeat) return;
|
||||
const key = event.code === "Backspace" || event.code === "Delete" ? null : actionKeyForEvent(event);
|
||||
if (!key && event.code !== "Backspace" && event.code !== "Delete") return;
|
||||
const { bar, slot: button } = state.selectedButton;
|
||||
state.updateDraft((draft) => bindDesktopSlot(draft, bar, button, key));
|
||||
setMessage(key ? `Bound bar ${bar + 1}, button ${button + 1}. Any previous assignment was cleared.` : "Keybind cleared.");
|
||||
useUiLayoutStore.setState({ selectedButton: null });
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, [open]);
|
||||
if (!open || !ui.draft) return null;
|
||||
const draft = ui.draft;
|
||||
const attempt = (run: () => void) => { try { run(); } catch (error) { setMessage(error instanceof Error ? error.message : "Could not save this UI."); } };
|
||||
const saveAndClose = () => attempt(() => { ui.finish(true); useGameStore.getState().closeOverlay(); });
|
||||
const validName = name.trim().length > 0;
|
||||
const chooseSlot = (device: UiDevice, index: number) => {
|
||||
setTab(device); setSlot(index); setName(ui.preferences.slots[device][index]?.name ?? "My UI");
|
||||
};
|
||||
const selected = ui.selectedButton;
|
||||
const setAbility = (abilityId: string | null) => {
|
||||
if (!selected) return;
|
||||
ui.updateDraft((layout) => ({ ...layout, bars: layout.bars.map((bar, index) => index !== selected.bar ? bar : {
|
||||
...bar, slots: bar.slots.map((button, index) => index === selected.slot ? { ...button, abilityId } : button),
|
||||
}) }));
|
||||
};
|
||||
return <aside className={`ui-layout-editor ${collapsed ? "ui-layout-editor--collapsed" : ""} ${dockRight ? "ui-layout-editor--right" : ""}`} aria-label="UI layout editor">
|
||||
<header><div><small>PERSONAL INTERFACE · {ui.device === "pc" ? "PC" : "MOBILE"}</small><h2>Edit your UI</h2></div>
|
||||
<button type="button" onClick={() => setDockRight(!dockRight)} aria-label={dockRight ? "Dock editor left" : "Dock editor right"}>{dockRight ? "←" : "→"}</button>
|
||||
<button type="button" onClick={() => setCollapsed(!collapsed)} aria-label={collapsed ? "Expand editor" : "Collapse editor"}>{collapsed ? "+" : "−"}</button></header>
|
||||
{!collapsed && <div className="ui-layout-editor__body">
|
||||
<p>Drag a widget’s label to move it. Hidden and inactive widgets appear as placeholders. Use the list to show them again.</p>
|
||||
<details open><summary>UI templates · 6 slots</summary>
|
||||
<div className="ui-layout-editor__tabs">{(["mobile", "pc"] as const).map((device) => <button key={device} type="button" aria-pressed={tab === device} onClick={() => chooseSlot(device, 0)}>{device === "pc" ? "PC" : "Mobile"} · 3 slots</button>)}</div>
|
||||
<div className="ui-layout-editor__slots">{ui.preferences.slots[tab].map((template, index) => <button type="button" key={index} aria-pressed={slot === index} onClick={() => chooseSlot(tab, index)}><small>Slot {index + 1}</small><strong>{template?.name ?? "Empty"}</strong></button>)}</div>
|
||||
<label>Template name<input value={name} maxLength={40} onChange={(event) => setName(event.target.value)} /></label>
|
||||
<div className="ui-layout-editor__row">
|
||||
<button type="button" disabled={!validName || tab !== ui.device} onClick={() => attempt(() => {
|
||||
ui.persist({ ...ui.preferences, slots: { ...ui.preferences.slots, [tab]: ui.preferences.slots[tab].map((template, index) => index === slot ? { name: name.trim(), layout: draft } : template) } });
|
||||
setMessage(`Saved ${name.trim()} to ${tab === "pc" ? "PC" : "Mobile"} slot ${slot + 1}.`);
|
||||
})}>{ui.preferences.slots[tab][slot] ? "Overwrite slot" : "Save to slot"}</button>
|
||||
<button type="button" disabled={!validName || !ui.preferences.slots[tab][slot]} onClick={() => attempt(() => {
|
||||
ui.persist({ ...ui.preferences, slots: { ...ui.preferences.slots, [tab]: ui.preferences.slots[tab].map((template, index) => template && index === slot ? { ...template, name: name.trim() } : template) } });
|
||||
setMessage("Template renamed.");
|
||||
})}>Rename</button>
|
||||
<button type="button" disabled={!ui.preferences.slots[tab][slot] || tab !== ui.device} onClick={() => {
|
||||
useUiLayoutStore.setState({ draft: ui.preferences.slots[tab][slot]!.layout, selectedButton: null }); setMessage("Template loaded into the editor. Save UI to apply.");
|
||||
}}>Load</button>
|
||||
</div>
|
||||
{tab !== ui.device && <small>Load and save {tab === "pc" ? "PC" : "Mobile"} layouts on that device type. You can rename or export either set here.</small>}
|
||||
<div className="ui-layout-editor__row">
|
||||
<button type="button" onClick={() => {
|
||||
const blob = new Blob([exportUiPreferences({ ...ui.preferences, current: { ...ui.preferences.current, [ui.device]: draft } })], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "healer-man-ui.json"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}}>Export all</button>
|
||||
<button type="button" onClick={() => file.current?.click()}>Import file</button>
|
||||
<button type="button" onClick={() => setTransferText(exportUiPreferences({ ...ui.preferences, current: { ...ui.preferences.current, [ui.device]: draft } }))}>Export text</button>
|
||||
<button type="button" onClick={() => setTransferText("")}>Import text</button>
|
||||
<input ref={file} type="file" accept=".json,application/json" hidden onChange={(event) => {
|
||||
const chosen = event.target.files?.[0]; event.target.value = ""; if (!chosen) return;
|
||||
if (chosen.size > 256_000) { setMessage("UI files must be smaller than 256 KB."); return; }
|
||||
void chosen.text().then((text) => setPendingImport(importUiPreferences(text))).catch((error: Error) => setMessage(error.message));
|
||||
}} />
|
||||
</div>
|
||||
{transferText !== null && <div>
|
||||
<label>UI configuration JSON<textarea aria-label="UI configuration JSON" value={transferText} maxLength={256000} onChange={(event) => setTransferText(event.target.value)} /></label>
|
||||
<small>Copy this text to transfer your UI, or paste an exported configuration to import it.</small>
|
||||
<button type="button" onClick={() => attempt(() => { setPendingImport(importUiPreferences(transferText)); setTransferText(null); })}>Review import</button>
|
||||
<button type="button" onClick={() => setTransferText(null)}>Close text</button>
|
||||
</div>}
|
||||
{pendingImport && <div className="ui-layout-editor__import"><p>Replace all six slots and both saved layouts with this file?</p><button type="button" onClick={() => attempt(() => {
|
||||
ui.persist(pendingImport); useUiLayoutStore.setState({ draft: pendingImport.current[ui.device], selectedButton: null }); setPendingImport(null); setMessage("Imported all six slots.");
|
||||
})}>Replace with imported UI</button><button type="button" onClick={() => setPendingImport(null)}>Keep existing UI</button></div>}
|
||||
<small>{session?.accessToken ? "Templates save to your account and upload with cloud saves. Sign in on another device to load them." : "Offline profile: saved on this device. Use export/import to transfer your UI."}</small>
|
||||
</details>
|
||||
<details><summary>Elements · move / hide</summary>
|
||||
{(Object.entries(HUD_ELEMENTS) as [HudElementId, string][]).filter(([id]) => !id.startsWith("bar-") || ui.device === "pc").map(([id, label]) => <div key={id} className={`ui-layout-editor__element ${ui.selectedElement === id ? "is-selected" : ""}`}>
|
||||
<label><input type="checkbox" checked={draft.elements[id].visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [id]: { ...layout.elements[id], visible: event.target.checked } } }))} />{label}</label>
|
||||
<button type="button" aria-label={`Reset ${label} position`} onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [id]: { ...layout.elements[id], position: null, width: null } } }))}>Reset</button>
|
||||
</div>)}
|
||||
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements }))}>Reset all positions and visibility</button>
|
||||
</details>
|
||||
{ui.device === "pc" && <details open><summary>PC action bars</summary>
|
||||
<label className="ui-layout-editor__check"><input type="checkbox" checked={draft.wowBars} onChange={(event) => ui.updateDraft((layout) => {
|
||||
const combat = useCombatStore.getState();
|
||||
const defaults = defaultActionBarForCharacter(combat.classId, combat.secondaryClassId, combat.level);
|
||||
const empty = layout.bars.every((bar) => bar.slots.every((button) => !button.abilityId));
|
||||
return { ...layout, wowBars: event.target.checked, bars: empty && event.target.checked ? layout.bars.map((bar, b) => ({ ...bar, slots: bar.slots.map((button, s) => ({ ...button, abilityId: defaults[b * 12 + s] ?? null })) })) : layout.bars };
|
||||
})} />Use WoW action bars</label>
|
||||
{draft.wowBars && <>
|
||||
<label>Visible bar count<select value={draft.barCount} onChange={(event) => { ui.updateDraft((layout) => ({ ...layout, barCount: Number(event.target.value) })); useUiLayoutStore.setState({ selectedButton: null }); }}>{[1, 2, 3, 4, 5].map((count) => <option key={count}>{count}</option>)}</select></label>
|
||||
{draft.bars.slice(0, draft.barCount).map((bar, index) => <div className="ui-layout-editor__row" key={index}><span>Bar {index + 1}</span>
|
||||
<label>Rows<select aria-label={`Bar ${index + 1} rows`} value={12 / bar.columns} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, bars: layout.bars.map((bar, b) => b === index ? { ...bar, columns: 12 / Number(event.target.value) } : bar) }))}>{BAR_COLUMNS.map((rows) => <option key={rows}>{rows}</option>)}</select></label>
|
||||
<label>Columns<select aria-label={`Bar ${index + 1} columns`} value={bar.columns} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, bars: layout.bars.map((bar, b) => b === index ? { ...bar, columns: Number(event.target.value) } : bar) }))}>{BAR_COLUMNS.map((cols) => <option key={cols}>{cols}</option>)}</select></label>
|
||||
</div>)}
|
||||
<button type="button" aria-pressed={ui.mode === "bindings"} onClick={() => useUiLayoutStore.setState({ mode: ui.mode === "bindings" ? "layout" : "bindings", selectedButton: null })}>{ui.mode === "bindings" ? "Finish keybind editing" : "Keybind edit mode"}</button>
|
||||
<p>{ui.mode === "bindings" ? "Click a bar button, then press a key or modifier + key. Backspace/Delete clears it; Escape cancels selection. Bound keys take priority over game shortcuts." : "Click a bar button to assign an ability."}</p>
|
||||
{selected && <div className="ui-layout-editor__assignment"><strong>Bar {selected.bar + 1} · Button {selected.slot + 1}{ui.mode === "bindings" ? " · Press a key…" : ""}</strong>
|
||||
<button type="button" onClick={() => setAbility(null)}>Clear ability</button>
|
||||
<label>Find ability<input value={search} onChange={(event) => setSearch(event.target.value)} /></label>
|
||||
<div className="ui-layout-editor__abilities">{abilities.filter((ability) => !ability.passive && ability.name.toLowerCase().includes(search.toLowerCase())).map((ability) => <button key={ability.id} type="button" onClick={() => setAbility(ability.id)} aria-pressed={draft.bars[selected.bar].slots[selected.slot].abilityId === ability.id}>{ability.name}</button>)}</div>
|
||||
</div>}
|
||||
</>}
|
||||
</details>}
|
||||
<p role="status">{message}</p>
|
||||
{session?.accessToken && <p role="status">{cloudSave.message}{cloudSave.state === "error" && <button type="button" onClick={retryCloudSave}>Retry cloud save</button>}</p>}
|
||||
</div>}
|
||||
<footer><button type="button" onClick={() => { ui.finish(false); useGameStore.getState().closeOverlay(); }}>Cancel edits</button><button type="button" className="button--primary" onClick={saveAndClose}>Save UI</button></footer>
|
||||
</aside>;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
.editable-hud--default { display: contents; }
|
||||
.editable-hud--hidden { display: none !important; }
|
||||
.editable-hud--positioned {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
pointer-events: none;
|
||||
max-height: calc(100% - 12px);
|
||||
}
|
||||
.editable-hud--positioned > :not(.editable-hud__handle) {
|
||||
position: relative !important;
|
||||
inset: auto !important;
|
||||
transform: none !important;
|
||||
scale: none !important;
|
||||
margin: 0 !important;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.editable-hud--positioned:empty { display: none; }
|
||||
.editable-hud--editing {
|
||||
min-height: 38px;
|
||||
outline: 1px dashed var(--gold);
|
||||
border-radius: 5px;
|
||||
background: rgba(9, 25, 18, .6);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.editable-hud--editing > :not(.editable-hud__handle) { pointer-events: none; }
|
||||
.editable-hud--editing > .desktop-action-bar { pointer-events: auto; }
|
||||
.editable-hud--ghost > :not(.editable-hud__handle) { opacity: .22; }
|
||||
.editable-hud__handle {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--gold);
|
||||
border-radius: 4px;
|
||||
background: #16251c;
|
||||
color: #ffe3a0;
|
||||
font-size: 11px;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
.editable-hud__handle:active { cursor: grabbing; }
|
||||
.editable-hud--editing:has(.desktop-action-bar) { padding-top: 24px; }
|
||||
.hud-bottom-stack > .editable-hud--default > .action-bar {
|
||||
position: relative; bottom: auto; left: auto; width: min(420px, calc(100vw - 24px)); margin-top: 20px; transform: none; scale: none;
|
||||
}
|
||||
.hud-bottom-stack > .editable-hud--default > :is(.cast-bar, .control-hint) {
|
||||
position: relative; inset: auto; max-width: min(560px, calc(100vw - 24px)); transform: none; scale: none;
|
||||
}
|
||||
.desktop-action-bar {
|
||||
position: absolute;
|
||||
bottom: var(--bar-offset, 30px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--bar-columns), minmax(0, 1fr));
|
||||
width: min(calc(var(--bar-columns) * 46px + 8px), calc(100vw - 16px));
|
||||
gap: 3px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: 5px;
|
||||
background: rgba(4, 12, 8, .82);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.desktop-action-button {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 1px solid #807044;
|
||||
border-radius: 3px;
|
||||
color: var(--ink);
|
||||
background: linear-gradient(145deg, #293c30, #070e0a);
|
||||
box-shadow: inset 0 0 5px #000;
|
||||
cursor: pointer;
|
||||
}
|
||||
.desktop-action-button .action-slot__icon { position: absolute; inset: 0; width: 100%; height: 100%; display: grid; place-items: center; font-size: 16px; }
|
||||
.desktop-action-button .action-slot__icon img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.desktop-action-button > kbd { position: absolute; z-index: 2; top: 1px; right: 2px; max-width: 100%; font: bold 10px "Segoe UI", sans-serif; text-shadow: 0 1px 2px #000, 1px 0 2px #000; }
|
||||
.desktop-action-button > small { position: absolute; bottom: 2px; left: 3px; color: var(--muted); }
|
||||
.desktop-action-button.is-unavailable .action-slot__icon { opacity: .4; }
|
||||
.desktop-action-button:is(:hover, :focus-visible, .is-selected) { border-color: #ffe3a0; outline: 2px solid var(--gold); outline-offset: -2px; }
|
||||
.desktop-action-button__cooldown { position: absolute; inset: 0; display: grid; place-items: center; background: #0006; font-size: 15px; font-weight: bold; text-shadow: 0 1px 2px #000; }
|
||||
.desktop-action-feedback { position: absolute; top: 65%; left: 50%; transform: translateX(-50%); max-width: 80%; color: #f3dfaa; text-align: center; text-shadow: 0 1px 3px #000; font-size: 12px; }
|
||||
.ui-layout-editor {
|
||||
position: absolute;
|
||||
z-index: 80;
|
||||
top: max(10px, env(safe-area-inset-top));
|
||||
left: max(10px, env(safe-area-inset-left));
|
||||
width: min(360px, calc(100% - 20px));
|
||||
max-height: calc(100% - 20px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--gold);
|
||||
border-radius: 10px;
|
||||
background: #0b1a14f5;
|
||||
color: var(--ink);
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 12px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.ui-layout-editor > header { display: flex; align-items: center; justify-content: space-between; padding: 12px; border-bottom: 1px solid var(--edge); }
|
||||
.ui-layout-editor--right { left: auto; right: max(10px, env(safe-area-inset-right)); }
|
||||
.single-display-frame:has(.ui-layout-editor) > .single-context-toggle { display: none; }
|
||||
.ui-layout-editor h2 { margin: 3px 0 0; color: #f3dfaa; font: 22px Georgia, serif; }
|
||||
.ui-layout-editor small { color: var(--muted); }
|
||||
.ui-layout-editor p { line-height: 1.5; margin: 8px 0; }
|
||||
.ui-layout-editor__body { padding: 0 12px 12px; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.ui-layout-editor details { padding: 10px 0; border-bottom: 1px solid var(--edge); }
|
||||
.ui-layout-editor summary { margin-bottom: 10px; color: #eed7a0; font-weight: bold; cursor: pointer; }
|
||||
.ui-layout-editor button, .ui-layout-editor select, .ui-layout-editor input:not([type="checkbox"]) { border: 1px solid var(--edge); border-radius: 4px; padding: 7px; background: #172b20; color: var(--ink); font: inherit; }
|
||||
.ui-layout-editor button { cursor: pointer; }
|
||||
.ui-layout-editor button:disabled { opacity: .4; cursor: default; }
|
||||
.ui-layout-editor button[aria-pressed="true"] { border-color: var(--gold); background: #3d3a22; }
|
||||
.ui-layout-editor button:focus-visible, .ui-layout-editor input:focus-visible, .ui-layout-editor select:focus-visible { outline: 2px solid var(--gold); }
|
||||
.ui-layout-editor label { display: flex; gap: 6px; align-items: center; margin: 8px 0; }
|
||||
.ui-layout-editor input:not([type="checkbox"]) { min-width: 0; flex: 1; width: 100%; }
|
||||
.ui-layout-editor textarea { width: 100%; min-width: 0; min-height: 100px; resize: vertical; color: var(--ink); background: var(--cave); border: 1px solid var(--edge); }
|
||||
.ui-layout-editor__tabs, .ui-layout-editor__row, .ui-layout-editor > footer { display: flex; gap: 5px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
|
||||
.ui-layout-editor__slots { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; }
|
||||
.ui-layout-editor__slots button { min-width: 0; }
|
||||
.ui-layout-editor__slots :is(strong, small) { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ui-layout-editor__element { display: flex; justify-content: space-between; align-items: center; gap: 5px; }
|
||||
.ui-layout-editor__element.is-selected { background: #a2853426; }
|
||||
.ui-layout-editor__element button { padding: 3px 6px; }
|
||||
.ui-layout-editor__abilities { display: grid; max-height: 160px; overflow-y: auto; gap: 3px; }
|
||||
.ui-layout-editor__assignment { margin-top: 10px; padding: 8px; border: 1px solid var(--edge); }
|
||||
.ui-layout-editor > footer { margin: 0; padding: 10px 12px; justify-content: flex-end; border-top: 1px solid var(--edge); }
|
||||
.ui-layout-editor > footer .button--primary { color: #0d170e; background: #c3d890; }
|
||||
@media (max-width: 650px), (max-height: 500px) {
|
||||
.ui-layout-editor { width: min(310px, calc(100% - 16px)); top: 8px; left: 8px; max-height: calc(100% - 16px); }
|
||||
.ui-layout-editor--right { left: auto; right: 8px; }
|
||||
.ui-layout-editor button { min-height: 36px; }
|
||||
.editable-hud__handle { min-height: 30px; font-size: 10px; }
|
||||
}
|
||||
Reference in new issue
Block a user