From fdf984aa6106a5dd944a5e8483d38ec43300ecf4 Mon Sep 17 00:00:00 2001 From: Hubert Date: Tue, 2 Jun 2026 20:53:44 +0200 Subject: [PATCH 1/8] Mark world files versioned and separate mob markers --- public/app.js | 26 ++++++++--- scripts/build-world-atlas.mjs | 2 + scripts/extract-world.mjs | 2 + server.js | 85 ++++++++++++++++++++++++++-------- test/app-ui.test.js | 13 ++++++ test/server-user-layer.test.js | 5 ++ test/world-atlas.test.js | 8 ++++ test/world-extract.test.js | 2 + 8 files changed, 117 insertions(+), 26 deletions(-) diff --git a/public/app.js b/public/app.js index 1b4eb54..4f774be 100644 --- a/public/app.js +++ b/public/app.js @@ -523,16 +523,16 @@ async function refreshWorldSetupStatus() { function updateWorldSetupStatus(status = {}) { worldSetupStatus = status; - const cacheReady = Boolean(status.cache?.exists); - const atlasReady = Boolean(status.atlas?.exists); + const cacheReady = Boolean(status.cache?.ready); + const atlasReady = Boolean(status.atlas?.ready); const busy = Boolean(status.busy); if (els.worldCacheStatus) { - els.worldCacheStatus.textContent = cacheReady ? "gotowy" : "brak"; - els.worldCacheStatus.dataset.state = cacheReady ? "ready" : "missing"; + els.worldCacheStatus.textContent = getWorldFileStatusText(status.cache); + els.worldCacheStatus.dataset.state = getWorldFileStatusState(status.cache); } if (els.worldAtlasStatus) { - els.worldAtlasStatus.textContent = atlasReady ? "gotowy" : "brak"; - els.worldAtlasStatus.dataset.state = atlasReady ? "ready" : "missing"; + els.worldAtlasStatus.textContent = getWorldFileStatusText(status.atlas); + els.worldAtlasStatus.dataset.state = getWorldFileStatusState(status.atlas); } if (els.worldSetupWelcome) { els.worldSetupWelcome.hidden = cacheReady && atlasReady; @@ -549,6 +549,18 @@ function updateWorldSetupStatus(status = {}) { } } +function getWorldFileStatusText(fileStatus = {}) { + if (fileStatus.ready) return "gotowy"; + if (fileStatus.stale) return "nieaktualny"; + return "brak"; +} + +function getWorldFileStatusState(fileStatus = {}) { + if (fileStatus.ready) return "ready"; + if (fileStatus.stale) return "stale"; + return "missing"; +} + async function runWorldSetupStep(step) { const actionLabel = step === "extract" ? "Ekstrakcja danych gry" : "Budowa atlasu"; try { @@ -2377,7 +2389,7 @@ function drawMobMarkers(coords, worldRenderIds, cell, z) { const count = mobs.length; const group = svg("g", { class: "mob-location-marker" }); const centerX = point.x + cell - 12; - const centerY = point.y + 12; + const centerY = point.y + cell - 12; group.append(svg("circle", { cx: centerX, cy: centerY, diff --git a/scripts/build-world-atlas.mjs b/scripts/build-world-atlas.mjs index 6b8d8ea..c21d74c 100644 --- a/scripts/build-world-atlas.mjs +++ b/scripts/build-world-atlas.mjs @@ -1,5 +1,6 @@ import { readFile, writeFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; +import packageJson from "../package.json" with { type: "json" }; const DEFAULT_INPUT = "world-cache.json"; const DEFAULT_OUTPUT = "world-atlas.json"; @@ -77,6 +78,7 @@ export function buildAtlas(world, options = {}) { return { generatedAt: new Date().toISOString(), + appVersion: packageJson.version, source: options.source || DEFAULT_INPUT, rooms: atlasRooms, localMaps, diff --git a/scripts/extract-world.mjs b/scripts/extract-world.mjs index a08e4c2..258c9e5 100644 --- a/scripts/extract-world.mjs +++ b/scripts/extract-world.mjs @@ -2,6 +2,7 @@ import { readdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { TextDecoder } from "node:util"; import { pathToFileURL } from "node:url"; +import packageJson from "../package.json" with { type: "json" }; const DEFAULT_OTCHLAN_DIR = "C:\\Program Files (x86)\\Otchlan 1.3"; const DEFAULT_GAME_DIR = process.env.OTCHLAN_DIR || DEFAULT_OTCHLAN_DIR; @@ -73,6 +74,7 @@ export async function extractWorld(areaDirPath) { return { generatedAt: new Date().toISOString(), + appVersion: packageJson.version, gameDir, recordSize: RECORD_SIZE, areas: areaFiles, diff --git a/server.js b/server.js index 1fbc43f..e7a76ea 100644 --- a/server.js +++ b/server.js @@ -9,6 +9,8 @@ import { StringDecoder } from "node:string_decoder"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PUBLIC_DIR = path.join(__dirname, "public"); +const PACKAGE_JSON = JSON.parse(readFileSync(path.join(__dirname, "package.json"), "utf8")); +const APP_VERSION = String(PACKAGE_JSON.version || "0.0.0"); const DEFAULT_GAME_DIR = "C:\\Program Files (x86)\\Otchlan 1.3"; const GAME_DIR = process.env.OTCHLAN_DIR || DEFAULT_GAME_DIR; const PORT = Number(process.env.PORT || 5173); @@ -330,32 +332,39 @@ function sendJson(res, payload, statusCode = 200) { async function sendWorldCache(res) { try { - const body = await readFile(WORLD_CACHE_FILE, "utf8"); + const body = await readValidatedWorldFile(WORLD_CACHE_FILE, "world-cache.json"); res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); res.end(body); - } catch { - res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" }); - res.end(JSON.stringify({ - ok: false, - message: "world-cache.json nie istnieje. Uruchom npm.cmd run world:extract." - })); + } catch (error) { + sendJson(res, makeWorldFileError("world-cache.json", "world:extract", error), worldFileErrorStatus(error)); } } async function sendWorldAtlas(res) { try { - const body = await readFile(WORLD_ATLAS_FILE, "utf8"); + const body = await readValidatedWorldFile(WORLD_ATLAS_FILE, "world-atlas.json"); res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); res.end(body); - } catch { - res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" }); - res.end(JSON.stringify({ - ok: false, - message: "world-atlas.json nie istnieje. Uruchom npm.cmd run world:atlas." - })); + } catch (error) { + sendJson(res, makeWorldFileError("world-atlas.json", "world:atlas", error), worldFileErrorStatus(error)); } } +async function readValidatedWorldFile(file, label) { + const body = await readFile(file, "utf8"); + const payload = JSON.parse(body); + validateWorldFileVersion(payload, label); + return body; +} + +function validateWorldFileVersion(payload, label) { + if (String(payload?.appVersion || "") === APP_VERSION) return; + const error = new Error(`${label} ma niezgodna wersje aplikacji.`); + error.code = "world-file-version-mismatch"; + error.fileVersion = String(payload?.appVersion || ""); + throw error; +} + async function getWorldBuildStatus(extra = {}) { const [cache, atlas] = await Promise.all([ getLocalFileStatus(WORLD_CACHE_FILE), @@ -363,10 +372,11 @@ async function getWorldBuildStatus(extra = {}) { ]); return { ok: true, + appVersion: APP_VERSION, gameDir: GAME_DIR, cache, atlas, - ready: cache.exists && atlas.exists, + ready: cache.ready && atlas.ready, busy: Boolean(worldBuildTask), runningStep: worldBuildTask?.step || null, ...extra @@ -376,22 +386,59 @@ async function getWorldBuildStatus(extra = {}) { async function getLocalFileStatus(file) { try { const info = await stat(file); + const body = await readFile(file, "utf8"); + const payload = JSON.parse(body); + const fileVersion = String(payload?.appVersion || ""); + const ready = fileVersion === APP_VERSION; return { exists: true, + ready, + stale: !ready, file: path.relative(__dirname, file), bytes: info.size, - updatedAt: info.mtime.toISOString() + updatedAt: info.mtime.toISOString(), + appVersion: fileVersion || null, + expectedAppVersion: APP_VERSION }; - } catch { + } catch (error) { return { - exists: false, + exists: existsSync(file), + ready: false, + stale: existsSync(file), file: path.relative(__dirname, file), bytes: 0, - updatedAt: null + updatedAt: null, + appVersion: null, + expectedAppVersion: APP_VERSION, + error: existsSync(file) ? String(error?.code || error?.message || error) : null }; } } +function makeWorldFileError(file, command, error) { + if (error?.code === "world-file-version-mismatch") { + return { + ok: false, + error: "world-file-version-mismatch", + file, + appVersion: error.fileVersion || null, + expectedAppVersion: APP_VERSION, + message: `${file} jest z wersji aplikacji ${error.fileVersion || "brak"}; wymagana wersja to ${APP_VERSION}. Uruchom npm.cmd run ${command}.` + }; + } + return { + ok: false, + error: "world-file-missing", + file, + expectedAppVersion: APP_VERSION, + message: `${file} nie istnieje albo jest uszkodzony. Uruchom npm.cmd run ${command}.` + }; +} + +function worldFileErrorStatus(error) { + return error?.code === "world-file-version-mismatch" ? 409 : 404; +} + async function runWorldBuildStep(step) { if (worldBuildTask) { return { diff --git a/test/app-ui.test.js b/test/app-ui.test.js index 7773d42..8645a92 100644 --- a/test/app-ui.test.js +++ b/test/app-ui.test.js @@ -194,6 +194,10 @@ test("world atlas setup can be managed from settings and onboarding", () => { assert.match(appSource, /async function initWorldSetup\(\) \{/); assert.match(appSource, /await fetchJson\("\/api\/world\/status"\)/); assert.match(appSource, /function updateWorldSetupStatus\(status = \{\}\) \{/); + assert.match(appSource, /const cacheReady = Boolean\(status\.cache\?\.ready\);/); + assert.match(appSource, /function getWorldFileStatusText\(fileStatus = \{\}\) \{/); + assert.match(appSource, /if \(fileStatus\.stale\) return "nieaktualny";/); + assert.match(appSource, /function getWorldFileStatusState\(fileStatus = \{\}\) \{/); assert.match(appSource, /els\.worldSetupWelcome\.hidden = cacheReady && atlasReady;/); assert.match(appSource, /runWorldSetupStep\("extract"\)/); assert.match(appSource, /runWorldSetupStep\("atlas"\)/); @@ -295,11 +299,20 @@ test("map renders process-memory mobs as a separate marker layer", () => { assert.match(appSource, /function isWorldSightOpen\(worldRoom, direction\) \{/); assert.match(appSource, /return mapDebugAll \|\| visibleMobWorldKeys\.has\(mob\.worldKey\);/); assert.match(appSource, /class: "mob-location-marker"/); + assert.match(appSource, /const centerY = point\.y \+ cell - 12;/); + assert.doesNotMatch(appSource, /const centerY = point\.y \+ 12;/); assert.match(appSource, /formatMobMarkerTitle\(mobs\)/); assert.match(cssSource, /\.mob-location-marker/); assert.match(cssSource, /\.mob-location-marker-count/); }); +test("mob markers use a different room slot than vertical level badges", () => { + assert.match(appSource, /const centerX = point\.x \+ cell - 12;/); + assert.match(appSource, /const centerY = point\.y \+ cell - 12;/); + assert.match(appSource, /const y = point\.y \+ 6;/); + assert.match(appSource, /class: `map-badge map-badge-\$\{badge\.kind\}`/); +}); + test("mob layer respects darkness when player cannot look around", () => { assert.match(appSource, /position\.environment/); assert.match(appSource, /environment: position\.environment \|\| \{\}/); diff --git a/test/server-user-layer.test.js b/test/server-user-layer.test.js index 5c8e3f0..f18e55b 100644 --- a/test/server-user-layer.test.js +++ b/test/server-user-layer.test.js @@ -24,6 +24,8 @@ test("server exposes user-layer save and load endpoints", () => { }); test("server exposes world extraction and atlas build endpoints", () => { + assert.match(serverSource, /const PACKAGE_JSON = JSON\.parse\(readFileSync\(path\.join\(__dirname, "package\.json"\), "utf8"\)\);/); + assert.match(serverSource, /const APP_VERSION = String\(PACKAGE_JSON\.version \|\| "0\.0\.0"\);/); assert.match(serverSource, /const WORLD_CACHE_FILE = path\.join\(__dirname, "world-cache\.json"\);/); assert.match(serverSource, /const WORLD_ATLAS_FILE = path\.join\(__dirname, "world-atlas\.json"\);/); assert.match(serverSource, /url\.pathname === "\/api\/world\/status"/); @@ -33,6 +35,9 @@ test("server exposes world extraction and atlas build endpoints", () => { assert.match(serverSource, /async function runWorldBuildStep\(step\)/); assert.match(serverSource, /spawnProcess\(process\.execPath, \[script\]/); assert.match(serverSource, /world-build-step-finished/); + assert.match(serverSource, /validateWorldFileVersion\(payload, label\);/); + assert.match(serverSource, /error: "world-file-version-mismatch"/); + assert.match(serverSource, /ready: cache\.ready && atlas\.ready/); }); test("server prefers packaged memory reader before development build output", () => { diff --git a/test/world-atlas.test.js b/test/world-atlas.test.js index 15169df..13dbd45 100644 --- a/test/world-atlas.test.js +++ b/test/world-atlas.test.js @@ -1,7 +1,15 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; import { buildAtlas } from "../scripts/build-world-atlas.mjs"; +const atlasSource = await readFile(new URL("../scripts/build-world-atlas.mjs", import.meta.url), "utf8"); + +test("atlas output is marked with the application version", () => { + assert.match(atlasSource, /import packageJson from "\.\.\/package\.json" with \{ type: "json" \};/); + assert.match(atlasSource, /appVersion: packageJson\.version/); +}); + test("atlas preserves sparse same-axis visual distance and adds corridor cells", () => { const atlas = buildAtlas({ rooms: [ diff --git a/test/world-extract.test.js b/test/world-extract.test.js index fcd404c..6775d4e 100644 --- a/test/world-extract.test.js +++ b/test/world-extract.test.js @@ -6,8 +6,10 @@ import { linkWorldRooms, parseAreaRooms, parseSkillSymbolsFromText } from "../sc const extractorSource = await readFile(new URL("../scripts/extract-world.mjs", import.meta.url), "utf8"); test("world extraction defaults to the standard Otchlan 1.3 install directory", () => { + assert.match(extractorSource, /import packageJson from "\.\.\/package\.json" with \{ type: "json" \};/); assert.match(extractorSource, /const DEFAULT_OTCHLAN_DIR = "C:\\\\Program Files \(x86\)\\\\Otchlan 1\.3";/); assert.match(extractorSource, /const DEFAULT_GAME_DIR = process\.env\.OTCHLAN_DIR \|\| DEFAULT_OTCHLAN_DIR;/); + assert.match(extractorSource, /appVersion: packageJson\.version/); }); test("extracts skill symbols from otchlan.exe text for effect name fallback", () => { From 9c88e9a8a7c0fb69b2c156209d6f761d305c9b8e Mon Sep 17 00:00:00 2001 From: Hubert Date: Tue, 2 Jun 2026 21:11:52 +0200 Subject: [PATCH 2/8] Refine player marker frame placement --- public/app.js | 63 ++++++++++++++++++++++----------------------- public/styles.css | 28 +++++++------------- test/app-ui.test.js | 9 +++++++ 3 files changed, 49 insertions(+), 51 deletions(-) diff --git a/public/app.js b/public/app.js index 4f774be..b4e2b68 100644 --- a/public/app.js +++ b/public/app.js @@ -2507,41 +2507,39 @@ function isMapItemInRenderWindow(item, window) { } function createPlayerLocationMarker(point, cell, extraClass = "") { - const centerX = point.x + cell / 2; - const centerY = point.y + cell - 13; - const radius = Math.max(6, cell * 0.105); + const { x: centerX, y: centerY } = getPlayerMarkerCenter(point, cell); + const inset = 6; + const size = cell - inset * 2; const marker = svg("g", { class: `player-location-marker ${extraClass}`.trim(), transform: `translate(${centerX}, ${centerY})` }); - marker.append(svg("ellipse", { - cx: 0, - cy: radius * 0.62, - rx: radius * 0.95, - ry: radius * 0.34, - class: "player-location-marker-shadow" + marker.append(svg("rect", { + x: -size / 2, + y: -size / 2, + width: size, + height: size, + rx: 4, + class: "player-location-marker-wash" })); - marker.append(svg("circle", { - cx: 0, - cy: 0, - r: radius, - class: "player-location-marker-ring" - })); - marker.append(svg("circle", { - cx: 0, - cy: 0, - r: radius * 0.45, - class: "player-location-marker-core" - })); - marker.append(svg("circle", { - cx: 0, - cy: 0, - r: radius * 1.38, - class: "player-location-marker-halo" + marker.append(svg("rect", { + x: -size / 2, + y: -size / 2, + width: size, + height: size, + rx: 4, + class: "player-location-marker-frame" })); return marker; } +function getPlayerMarkerCenter(point, cell) { + return { + x: point.x + cell / 2, + y: point.y + cell / 2 + }; +} + function renderPlayerMarkerLayer(coords, cell, z) { if (!els.mapPlayerLayer) return; const point = coords.get(playerRoomId); @@ -2558,8 +2556,7 @@ function renderPlayerMarkerLayer(coords, cell, z) { els.mapPlayerLayer.replaceChildren(marker); } - const targetX = point.x + cell / 2; - const targetY = point.y + cell - 13; + const { x: targetX, y: targetY } = getPlayerMarkerCenter(point, cell); const pending = pendingPlayerTravelAnimation; if (!pending || pending.toRoomId !== playerRoomId) { activePlayerTravelAnimationId = ""; @@ -2598,10 +2595,12 @@ function renderPlayerMarkerLayer(coords, cell, z) { activePlayerTravelAnimationId = pending.id; const currentTranslate = readSvgTranslate(marker); - const fromX = currentTranslate?.x ?? fromPoint.x + cell / 2; - const fromY = currentTranslate?.y ?? fromPoint.y + cell - 13; - const toX = toPoint.x + cell / 2; - const toY = toPoint.y + cell - 13; + const fromCenter = getPlayerMarkerCenter(fromPoint, cell); + const toCenter = getPlayerMarkerCenter(toPoint, cell); + const fromX = currentTranslate?.x ?? fromCenter.x; + const fromY = currentTranslate?.y ?? fromCenter.y; + const toX = toCenter.x; + const toY = toCenter.y; const animationId = ++playerTravelAnimationId; els.mapPlayerLayer.classList.add("player-marker-animating"); animateSvgMarkerTravel(marker, fromX, fromY, toX, toY, PLAYER_TRAVEL_ANIMATION_MS, { diff --git a/public/styles.css b/public/styles.css index cc002f2..412700b 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1323,30 +1323,20 @@ body.stat-hidden-statuses.stat-hidden-clock.stat-hidden-date .active-effects { .player-location-marker { pointer-events: none; - filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.34)); + filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.24)); } -.player-location-marker-shadow { - fill: rgb(0 0 0 / 0.3); -} - -.player-location-marker-ring { - fill: color-mix(in srgb, var(--water) 72%, white); - stroke: color-mix(in srgb, var(--panel) 72%, var(--ink)); - stroke-width: 2.1; -} - -.player-location-marker-core { - fill: color-mix(in srgb, white 88%, var(--water)); - stroke: color-mix(in srgb, var(--water) 78%, black); - stroke-width: 1; +.player-location-marker-wash { + fill: color-mix(in srgb, var(--water) 8%, transparent); + stroke: none; } -.player-location-marker-halo { +.player-location-marker-frame { fill: none; - stroke: color-mix(in srgb, var(--water) 68%, transparent); - stroke-width: 1.2; - stroke-dasharray: 2.5 2.5; + stroke: color-mix(in srgb, var(--water) 78%, white); + stroke-width: 1.6; + stroke-dasharray: 5 4; + stroke-linejoin: round; } .mob-location-marker { diff --git a/test/app-ui.test.js b/test/app-ui.test.js index 8645a92..e0d364b 100644 --- a/test/app-ui.test.js +++ b/test/app-ui.test.js @@ -252,6 +252,12 @@ test("player movement animation moves the player marker instead of a room rectan assert.match(htmlSource, /id="mapSvg"[\s\S]*id="mapPlayerLayer"[\s\S]*id="mapLevelTransitionOverlay"/); assert.match(appSource, /mapPlayerLayer: document\.querySelector\("#mapPlayerLayer"\)/); assert.match(appSource, /function createPlayerLocationMarker\(point, cell, extraClass = ""\)/); + assert.match(appSource, /function getPlayerMarkerCenter\(point, cell\) \{/); + assert.match(appSource, /class: "player-location-marker-frame"/); + assert.match(appSource, /const inset = 6;/); + assert.match(appSource, /y: point\.y \+ cell \/ 2/); + assert.match(appSource, /const \{ x: targetX, y: targetY \} = getPlayerMarkerCenter\(point, cell\);/); + assert.doesNotMatch(appSource, /point\.y \+ cell - 13/); assert.match(appSource, /function renderPlayerMarkerLayer\(coords, cell, z\) \{/); assert.match(appSource, /function animateSvgMarkerTravel\(marker, fromX, fromY, toX, toY, duration, options = \{\}\)/); assert.match(appSource, /marker\.setAttribute\("transform", `translate\(\$\{x\}, \$\{y\}\)`\)/); @@ -267,6 +273,9 @@ test("player movement animation moves the player marker instead of a room rectan assert.match(appSource, /els\.mapPlayerLayer\.classList\.add\("player-marker-animating"\)/); assert.match(appSource, /els\.mapPlayerLayer\?\.setAttribute\("viewBox"/); assert.match(cssSource, /\.map-player-layer/); + assert.match(cssSource, /\.player-location-marker-frame\s*\{[\s\S]*stroke-dasharray: 5 4;/); + assert.match(cssSource, /\.player-location-marker-frame\s*\{[\s\S]*stroke-width: 1\.6;/); + assert.doesNotMatch(cssSource, /\.player-location-marker-ring/); assert.doesNotMatch(cssSource, /\.player-marker-animating \.room-node\.current \.player-location-marker/); assert.doesNotMatch(appSource, /class: "player-travel-marker player-travel-marker-moving"/); assert.doesNotMatch(cssSource, /player-travel-marker-pulse/); From efa575dac5442e0fac4cb7230cb5300f6061ae2f Mon Sep 17 00:00:00 2001 From: Hubert Date: Tue, 2 Jun 2026 21:45:33 +0200 Subject: [PATCH 3/8] Add UI perf profiler and mob-only map updates --- public/app.js | 299 +++++++++++++++++++++++++++++++-- server.js | 5 +- test/app-ui.test.js | 34 ++++ test/server-user-layer.test.js | 4 + 4 files changed, 324 insertions(+), 18 deletions(-) diff --git a/public/app.js b/public/app.js index b4e2b68..bfbfcdc 100644 --- a/public/app.js +++ b/public/app.js @@ -31,6 +31,7 @@ const TERMINAL_PARSE_WINDOW_LINES = 100; const SERVER_AUTOSAVE_MS = 120000; const SERVER_SAVE_DEBOUNCE_MS = 250; const DOCUMENTATION_DEMO_MODE = new URLSearchParams(window.location.search).get("demo") === "1"; +const UI_PERF_MODE = new URLSearchParams(window.location.search).get("perf") === "1"; const DOCUMENTATION_DEMO_FOCUS_WORLD_KEY = "miasto.are:285,338,13"; const ACTIVE_TTL_MS = 3000; const MAP_ZOOM_MIN = 0.35; @@ -107,6 +108,10 @@ let mapView = { x: 41, y: 41, z: 0, area: "" }; let mapDrag = null; let suppressNextMapClick = false; let mapHitTargets = []; +let lastRenderedMapCoords = new Map(); +let lastRenderedWorldRenderIds = new Map(); +let lastRenderedMapCell = 82; +let lastRenderedMapZ = null; let selectedRoomId = project.selectedRoomId || project.currentRoomId; let selectedRoomPreview = null; let selectedWorldPreview = null; @@ -150,8 +155,10 @@ let mapLevelTransitionTimer = null; let mapViewportRenderFrame = null; let worldRoomsByKey = new Map(); let atlasRoomsByKey = new Map(); +let uiPerf = null; ensureProjectState(); +initUiPerfProbe(); const term = new Terminal({ cols: TERMINAL_COLS, rows: TERMINAL_ROWS, @@ -732,24 +739,24 @@ function bindEvents() { }); document.querySelector("#zoomInBtn").addEventListener("click", () => { zoom = clampMapZoom(zoom * 1.2); - renderMap(); + renderMap("ui-zoom-in"); }); document.querySelector("#zoomOutBtn").addEventListener("click", () => { zoom = clampMapZoom(zoom / 1.2); - renderMap(); + renderMap("ui-zoom-out"); }); els.mapDebugBtn.addEventListener("click", () => { mapDebugAll = !mapDebugAll; if (!mapDebugAll) selectedWorldPreview = null; debugMapZ = getRenderMapZ(getPlayerRoom(), getSelectedRoom()); renderMapScopeState(); - renderMap(); + renderMap("ui-debug-toggle"); }); els.mapZDownBtn.addEventListener("click", () => shiftDebugMapZ(-1)); els.mapZUpBtn.addEventListener("click", () => shiftDebugMapZ(1)); els.centerMapBtn.addEventListener("click", () => { centerMapOnPlayer(); - renderMap(); + renderMap("ui-center-player"); }); els.followPlayerBtn.addEventListener("click", () => { followPlayer = !followPlayer; @@ -762,7 +769,7 @@ function bindEvents() { } project.followPlayer = followPlayer; saveProject(); - render(); + render("ui-follow-toggle"); }); initMapDragging(); document.querySelector("#startGameBtn").addEventListener("click", async () => { @@ -1144,7 +1151,7 @@ function setWorkspaceMode(mode, options = {}) { window.requestAnimationFrame(() => { window.requestAnimationFrame(() => { centerMapOnFocus(); - renderMap(); + renderMap("ui-workspace-change"); }); }); } @@ -1189,7 +1196,9 @@ function applyGameMemoryPosition(position = {}) { const previousRoom = getPlayerRoom(); if (playerPositionKnown && previousRoom?.worldKey === worldKey) { pendingGameMemoryPosition = null; - if (mobsChanged) renderMap(); + if (mobsChanged && !renderMobOnlyMapUpdate("memory-same-room-mobs")) { + renderMap("memory-same-room-mobs"); + } return; } @@ -1235,7 +1244,22 @@ function applyGameMemoryPosition(position = {}) { source: position.source || "process-memory" }); if (positionChanged || layerChanged) saveProject({ immediateServerSave: true, positionOnly: !layerChanged }); - render(); + const memoryRenderReason = [ + layerChanged ? "layer" : "", + mobsChanged ? "mobs" : "", + positionChanged ? "position" : "" + ].filter(Boolean).join("+") || "memory"; + if (!layerChanged && positionChanged && renderPositionOnlyMapUpdate(previousPlayerRoomId, previousSelectedRoomId, "memory-position")) { + const mobsUpdated = !mobsChanged || renderMobOnlyMapUpdate("memory-position-mobs"); + if (!mobsUpdated) { + render(`memory-${memoryRenderReason}`); + return; + } + renderFollowState(); + renderInspector(); + } else { + render(`memory-${memoryRenderReason}`); + } } } @@ -1750,7 +1774,7 @@ function setMobsVisibility(visible, options = {}) { } currentGameMobSignature = ""; currentGameMobVisibilityKey = ""; - if (options.render !== false) renderMap(); + if (options.render !== false) renderMap("ui-mobs-visibility"); } function applySavedNotesVisibility() { @@ -1882,7 +1906,7 @@ function autosaveCurrentRoom() { room.updatedAt = new Date().toISOString(); ensureArea(room.area); saveProject(); - renderMap(); + renderMap("ui-room-edit"); } function autosaveGlobalNotes() { @@ -2111,10 +2135,10 @@ function shouldShowWaitingForPlayerPosition() { return followPlayer && !playerPositionKnown && !selectedRoomPreview && !selectedWorldPreview; } -function render() { +function render(reason = "render") { renderFollowState(); renderInspector(); - renderMap(); + renderMap(reason); } function renderInspector() { @@ -2240,7 +2264,180 @@ function renderMapZControls(z) { els.mapZUpBtn.title = canGoUp ? "Pokaz ten sam obszar poziom wyzej" : "Nie ma wyzszego poziomu"; } -function renderMap() { +function initUiPerfProbe() { + if (!UI_PERF_MODE) return; + const probe = { + startedAt: performance.now(), + frames: [], + renderMapMs: [], + renderMapRecords: [], + renderMapReasons: {}, + positionOnlyReasons: {}, + mobOnlyReasons: {}, + mutations: { + mapViewBox: 0, + playerViewBox: 0, + playerTransform: 0, + mapChildList: 0, + playerChildList: 0 + }, + longTasks: [], + report() { + return buildUiPerfReport(probe); + }, + reset() { + probe.startedAt = performance.now(); + probe.frames.length = 0; + probe.renderMapMs.length = 0; + probe.renderMapRecords.length = 0; + probe.longTasks.length = 0; + probe.renderMapReasons = {}; + probe.positionOnlyReasons = {}; + probe.mobOnlyReasons = {}; + probe.mutations = { + mapViewBox: 0, + playerViewBox: 0, + playerTransform: 0, + mapChildList: 0, + playerChildList: 0 + }; + return probe.report(); + } + }; + uiPerf = probe; + window.__otchlanPerf = probe; + publishUiPerfReport(probe); + window.setInterval(() => publishUiPerfReport(probe), 1000); + startUiFrameProbe(probe); + startUiMutationProbe(probe); + startUiLongTaskProbe(probe); + console.info("[otchlan-perf] UI profiler active. Use window.__otchlanPerf.report()."); +} + +function publishUiPerfReport(probe) { + let node = document.querySelector("#uiPerfReport"); + if (!node) { + node = document.createElement("script"); + node.id = "uiPerfReport"; + node.type = "application/json"; + node.hidden = true; + document.body.append(node); + } + node.textContent = JSON.stringify(probe.report()); +} + +function startUiFrameProbe(probe) { + let last = performance.now(); + const step = (now) => { + probe.frames.push(now - last); + last = now; + if (probe.frames.length > 1200) probe.frames.splice(0, probe.frames.length - 1200); + window.requestAnimationFrame(step); + }; + window.requestAnimationFrame(step); +} + +function startUiMutationProbe(probe) { + if (!window.MutationObserver) return; + const observer = new MutationObserver((records) => { + for (const record of records) { + if (record.type === "childList") { + if (record.target === els.mapSvg || els.mapSvg?.contains(record.target)) probe.mutations.mapChildList += 1; + if (record.target === els.mapPlayerLayer || els.mapPlayerLayer?.contains(record.target)) probe.mutations.playerChildList += 1; + continue; + } + if (record.type !== "attributes") continue; + if (record.target === els.mapSvg && record.attributeName === "viewBox") probe.mutations.mapViewBox += 1; + if (record.target === els.mapPlayerLayer && record.attributeName === "viewBox") probe.mutations.playerViewBox += 1; + if (els.mapPlayerLayer?.contains(record.target) && record.attributeName === "transform") probe.mutations.playerTransform += 1; + } + }); + if (els.mapSvg) observer.observe(els.mapSvg, { attributes: true, childList: true, subtree: true, attributeFilter: ["viewBox", "transform"] }); + if (els.mapPlayerLayer) observer.observe(els.mapPlayerLayer, { attributes: true, childList: true, subtree: true, attributeFilter: ["viewBox", "transform"] }); +} + +function startUiLongTaskProbe(probe) { + if (!window.PerformanceObserver) return; + try { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) probe.longTasks.push(entry.duration); + if (probe.longTasks.length > 200) probe.longTasks.splice(0, probe.longTasks.length - 200); + }); + observer.observe({ entryTypes: ["longtask"] }); + } catch { + // Long task API is optional and missing in some embedded browsers. + } +} + +function recordUiRenderMapDuration(startedAt, reason = "unknown") { + if (!uiPerf) return; + const duration = performance.now() - startedAt; + uiPerf.renderMapMs.push(duration); + uiPerf.renderMapRecords.push({ reason, duration }); + uiPerf.renderMapReasons[reason] = (uiPerf.renderMapReasons[reason] || 0) + 1; + if (uiPerf.renderMapMs.length > 400) uiPerf.renderMapMs.splice(0, uiPerf.renderMapMs.length - 400); + if (uiPerf.renderMapRecords.length > 400) uiPerf.renderMapRecords.splice(0, uiPerf.renderMapRecords.length - 400); +} + +function recordUiPositionOnlyUpdate(reason = "unknown") { + if (!uiPerf) return; + uiPerf.positionOnlyReasons[reason] = (uiPerf.positionOnlyReasons[reason] || 0) + 1; +} + +function recordUiMobOnlyUpdate(reason = "unknown") { + if (!uiPerf) return; + uiPerf.mobOnlyReasons[reason] = (uiPerf.mobOnlyReasons[reason] || 0) + 1; +} + +function buildUiPerfReport(probe) { + const frames = probe.frames.slice(1); + return { + seconds: Number(((performance.now() - probe.startedAt) / 1000).toFixed(1)), + frames: summarizePerfValues(frames, [16.7, 33.4, 50]), + renderMap: summarizePerfValues(probe.renderMapMs, [4, 8, 16]), + renderMapReasons: { ...probe.renderMapReasons }, + renderMapByReason: summarizePerfRecordsByReason(probe.renderMapRecords), + positionOnlyReasons: { ...probe.positionOnlyReasons }, + mobOnlyReasons: { ...probe.mobOnlyReasons }, + longTasks: { + count: probe.longTasks.length, + maxMs: Number(Math.max(0, ...probe.longTasks).toFixed(2)) + }, + mutations: { ...probe.mutations }, + nodes: { + map: els.mapSvg?.querySelectorAll("*").length || 0, + player: els.mapPlayerLayer?.querySelectorAll("*").length || 0 + } + }; +} + +function summarizePerfRecordsByReason(records) { + const grouped = {}; + for (const record of records) { + if (!grouped[record.reason]) grouped[record.reason] = []; + grouped[record.reason].push(record.duration); + } + return Object.fromEntries(Object.entries(grouped) + .map(([reason, values]) => [reason, summarizePerfValues(values, [4, 8, 16])]) + .sort(([left], [right]) => left.localeCompare(right))); +} + +function summarizePerfValues(values, thresholds) { + const sorted = values.slice().sort((left, right) => left - right); + const percentile = (value) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * value))] || 0; + return { + count: sorted.length, + avgMs: Number((sorted.reduce((sum, value) => sum + value, 0) / Math.max(1, sorted.length)).toFixed(2)), + p50Ms: Number(percentile(0.5).toFixed(2)), + p95Ms: Number(percentile(0.95).toFixed(2)), + p99Ms: Number(percentile(0.99).toFixed(2)), + maxMs: Number((sorted[sorted.length - 1] || 0).toFixed(2)), + over: Object.fromEntries(thresholds.map((threshold) => [String(threshold), sorted.filter((value) => value > threshold).length])) + }; +} + +function renderMap(reason = "renderMap") { + const renderStartedAt = uiPerf ? performance.now() : 0; const playerRoom = getPlayerRoom(); const room = playerRoom || getSelectedRoom(); const atlasWorkspaceActive = false; @@ -2341,7 +2538,10 @@ function renderMap() { const point = coords.get(item.id); const selectedForPreview = selectedRoomPreview?.id === item.id; const selectedForProject = !selectedRoomPreview && item.id === selectedRoomId; - const group = svg("g", { class: `room-node ${playerPositionKnown && item.id === playerRoomId ? "current" : ""} ${selectedForPreview || selectedForProject ? "selected" : ""}` }); + const group = svg("g", { + class: `room-node ${playerPositionKnown && item.id === playerRoomId ? "current" : ""} ${selectedForPreview || selectedForProject ? "selected" : ""}`, + "data-room-id": item.id + }); group.append(drawRoomHitTarget(point, cell)); group.append(svg("rect", { x: point.x, y: point.y, width: cell, height: cell })); drawRoomLabel(group, item, point, cell); @@ -2365,10 +2565,16 @@ function renderMap() { const blocked = new Set(getRenderBlockedDirections(item)); for (const dir of blocked) drawBlockedBorder(item.id, dir, coords, cell); } + lastRenderedMapCoords = coords; + lastRenderedWorldRenderIds = worldRenderIds; + lastRenderedMapCell = cell; + lastRenderedMapZ = Number(z); renderPlayerMarkerLayer(coords, cell, z); + recordUiRenderMapDuration(renderStartedAt, reason); } function drawMobMarkers(coords, worldRenderIds, cell, z) { + const layer = getMobMarkerLayer(); const visibleMobs = getRenderableMobs(z) .map((mob) => { const roomId = worldRenderIds.get(mob.worldKey); @@ -2411,10 +2617,37 @@ function drawMobMarkers(coords, worldRenderIds, cell, z) { }, String(Math.min(count, 9)))); } group.append(svg("title", {}, formatMobMarkerTitle(mobs))); - els.mapSvg.append(group); + layer.append(group); } } +function getMobMarkerLayer() { + let layer = els.mapSvg.querySelector(".mob-marker-layer"); + if (layer) { + layer.replaceChildren(); + return layer; + } + layer = svg("g", { class: "mob-marker-layer" }); + const blockedBorder = els.mapSvg.querySelector(".blocked-border"); + if (blockedBorder) { + els.mapSvg.insertBefore(layer, blockedBorder); + } else { + els.mapSvg.append(layer); + } + return layer; +} + +function renderMobOnlyMapUpdate(reason = "mob-only") { + if (!lastRenderedMapCoords.size || !lastRenderedWorldRenderIds.size) return false; + const playerRoom = getPlayerRoom(); + const selectedRoom = getSelectedRoom(); + const z = getRenderMapZ(playerRoom, selectedRoom); + if (Number(z) !== Number(lastRenderedMapZ)) return false; + drawMobMarkers(lastRenderedMapCoords, lastRenderedWorldRenderIds, lastRenderedMapCell, z); + recordUiMobOnlyUpdate(reason); + return true; +} + function getRenderableMobs(z) { if (!canRenderGameMobs()) return []; const visibleMobWorldKeys = getPlayerVisibleMobWorldKeys(); @@ -2435,6 +2668,38 @@ function canObserveGameMobs() { return environment.canObserveMobs !== false; } +function renderPositionOnlyMapUpdate(previousPlayerRoomId, previousSelectedRoomId, reason = "position-only") { + if (mapDebugAll) return false; + const playerRoom = getPlayerRoom(); + const selectedRoom = getSelectedRoom(); + const z = getRenderMapZ(playerRoom, selectedRoom); + if (Number(z) !== Number(lastRenderedMapZ)) return false; + if (!lastRenderedMapCoords.has(playerRoomId)) return false; + updateRoomNodeState(previousPlayerRoomId); + updateRoomNodeState(playerRoomId); + updateRoomNodeState(previousSelectedRoomId); + updateRoomNodeState(selectedRoomId); + applyMapViewBox({ animate: true }); + renderPlayerMarkerLayer(lastRenderedMapCoords, lastRenderedMapCell, z); + recordUiPositionOnlyUpdate(reason); + return true; +} + +function updateRoomNodeState(roomId) { + if (!roomId) return; + const node = els.mapSvg?.querySelector(`[data-room-id="${cssEscape(roomId)}"]`); + if (!node) return; + node.classList.toggle("current", playerPositionKnown && roomId === playerRoomId); + const selectedForPreview = selectedRoomPreview?.id === roomId; + const selectedForProject = !selectedRoomPreview && roomId === selectedRoomId; + node.classList.toggle("selected", selectedForPreview || selectedForProject); +} + +function cssEscape(value) { + if (window.CSS?.escape) return window.CSS.escape(String(value)); + return String(value).replace(/["\\]/g, "\\$&"); +} + function getPlayerVisibleMobWorldKeys() { const playerRoom = getPlayerRoom(); const playerWorldKey = playerRoom?.worldKey; @@ -2484,7 +2749,7 @@ function scheduleDebugMapViewportRender() { if (mapViewportRenderFrame) return; mapViewportRenderFrame = window.requestAnimationFrame(() => { mapViewportRenderFrame = null; - renderMap(); + renderMap("ui-debug-viewport"); }); } @@ -3017,7 +3282,7 @@ function shiftDebugMapZ(delta) { z: debugMapZ, area: mapDebugAll ? "__debug_all__" : "atlas" }; - renderMap(); + renderMap("ui-z-shift"); } function getAvailableDebugZLevels() { diff --git a/server.js b/server.js index e7a76ea..3a5cad7 100644 --- a/server.js +++ b/server.js @@ -282,7 +282,10 @@ async function handleRequest(req, res) { try { const body = await readFile(filePath); const ext = path.extname(filePath).toLowerCase(); - res.writeHead(200, { "Content-Type": mimeTypes[ext] || "application/octet-stream" }); + res.writeHead(200, { + "Content-Type": mimeTypes[ext] || "application/octet-stream", + "Cache-Control": "no-store" + }); res.end(body); } catch { res.writeHead(404); diff --git a/test/app-ui.test.js b/test/app-ui.test.js index e0d364b..0170768 100644 --- a/test/app-ui.test.js +++ b/test/app-ui.test.js @@ -290,6 +290,34 @@ test("map view follows player movement with animated viewBox panning", () => { assert.match(appSource, /setSvgViewBox\(\{[\s\S]*x: from\.x \+ \(to\.x - from\.x\) \* eased/); }); +test("position-only movement updates the map without rebuilding the SVG", () => { + assert.match(appSource, /let lastRenderedMapCoords = new Map\(\);/); + assert.match(appSource, /"data-room-id": item\.id/); + assert.match(appSource, /function renderPositionOnlyMapUpdate\(previousPlayerRoomId, previousSelectedRoomId, reason = "position-only"\) \{/); + assert.match(appSource, /if \(mapDebugAll\) return false;/); + assert.match(appSource, /if \(!lastRenderedMapCoords\.has\(playerRoomId\)\) return false;/); + assert.match(appSource, /updateRoomNodeState\(previousPlayerRoomId\);/); + assert.match(appSource, /renderPlayerMarkerLayer\(lastRenderedMapCoords, lastRenderedMapCell, z\);/); + assert.match(appSource, /renderPositionOnlyMapUpdate\(previousPlayerRoomId, previousSelectedRoomId, "memory-position"\)/); +}); + +test("UI performance profiler is opt-in through the perf query flag", () => { + assert.match(appSource, /const UI_PERF_MODE = new URLSearchParams\(window\.location\.search\)\.get\("perf"\) === "1";/); + assert.match(appSource, /function initUiPerfProbe\(\) \{/); + assert.match(appSource, /if \(!UI_PERF_MODE\) return;/); + assert.match(appSource, /window\.__otchlanPerf = probe;/); + assert.match(appSource, /renderMapReasons: \{\}/); + assert.match(appSource, /positionOnlyReasons: \{\}/); + assert.match(appSource, /mobOnlyReasons: \{\}/); + assert.match(appSource, /renderMapByReason: summarizePerfRecordsByReason\(probe\.renderMapRecords\)/); + assert.match(appSource, /node\.id = "uiPerfReport";/); + assert.match(appSource, /node\.textContent = JSON\.stringify\(probe\.report\(\)\);/); + assert.match(appSource, /function recordUiRenderMapDuration\(startedAt, reason = "unknown"\) \{/); + assert.match(appSource, /function recordUiPositionOnlyUpdate\(reason = "unknown"\) \{/); + assert.match(appSource, /function recordUiMobOnlyUpdate\(reason = "unknown"\) \{/); + assert.match(appSource, /recordUiRenderMapDuration\(renderStartedAt, reason\);/); +}); + test("map renders process-memory mobs as a separate marker layer", () => { assert.match(appSource, /let currentGameMobs = \[\];/); assert.match(appSource, /let currentGameMobVisibilityKey = "";/); @@ -299,7 +327,13 @@ test("map renders process-memory mobs as a separate marker layer", () => { assert.match(appSource, /const worldKey = `\$\{areaFile\}:\$\{x\},\$\{y\},\$\{z\}`;/); assert.match(appSource, /const mobsChanged = updateGameMobs\(position\);/); assert.match(appSource, /if \(positionChanged \|\| layerChanged\) saveProject/); + assert.match(appSource, /if \(mobsChanged && !renderMobOnlyMapUpdate\("memory-same-room-mobs"\)\)/); assert.match(appSource, /drawMobMarkers\(coords, worldRenderIds, cell, z\);/); + assert.match(appSource, /function getMobMarkerLayer\(\) \{/); + assert.match(appSource, /class: "mob-marker-layer"/); + assert.match(appSource, /function renderMobOnlyMapUpdate\(reason = "mob-only"\) \{/); + assert.match(appSource, /drawMobMarkers\(lastRenderedMapCoords, lastRenderedWorldRenderIds, lastRenderedMapCell, z\);/); + assert.match(appSource, /recordUiMobOnlyUpdate\(reason\);/); assert.match(appSource, /function getRenderableMobs\(z\) \{/); assert.match(appSource, /if \(!canRenderGameMobs\(\)\) return \[\];/); assert.match(appSource, /function getPlayerVisibleMobWorldKeys\(\) \{/); diff --git a/test/server-user-layer.test.js b/test/server-user-layer.test.js index f18e55b..6886522 100644 --- a/test/server-user-layer.test.js +++ b/test/server-user-layer.test.js @@ -71,6 +71,10 @@ test("server writes root error log and returns json server errors", () => { assert.match(serverSource, /renameWithWindowsRetry\(file, first\)/); }); +test("server disables browser cache for local static files", () => { + assert.match(serverSource, /"Cache-Control": "no-store"/); +}); + test("server validates and atomically writes user-layer payloads", () => { assert.match(serverSource, /payload\?\.schema !== "otchlan-user-layer"/); assert.match(serverSource, /invalid-user-layer/); From f83be6964ff16043167d9c6d001420d2d1a748d6 Mon Sep 17 00:00:00 2001 From: Hubert Date: Thu, 4 Jun 2026 10:05:12 +0200 Subject: [PATCH 4/8] Add toggles for room tags and notes visibility --- public/app.js | 63 +++++++++++++++++++++++++++++++++++++++++++++ public/index.html | 12 +++++++-- public/styles.css | 14 ++++++++++ test/app-ui.test.js | 20 ++++++++++++++ 4 files changed, 107 insertions(+), 2 deletions(-) diff --git a/public/app.js b/public/app.js index bfbfcdc..8c228f6 100644 --- a/public/app.js +++ b/public/app.js @@ -9,6 +9,8 @@ import { Terminal } from "/vendor/@xterm/xterm/lib/xterm.mjs"; const THEME_KEY = "otchlan-automapper-theme"; const WORKSPACE_KEY = "otchlan-automapper-workspace-mode"; const DESCRIPTION_VISIBLE_KEY = "otchlan-automapper-description-visible"; +const ROOM_TAGS_VISIBLE_KEY = "otchlan-automapper-room-tags-visible"; +const ROOM_NOTES_VISIBLE_KEY = "otchlan-automapper-room-notes-visible"; const MOBS_VISIBLE_KEY = "otchlan-automapper-mobs-visible"; const NOTES_VISIBLE_KEY = "otchlan-automapper-notes-visible"; const STATS_VISIBLE_KEY = "otchlan-automapper-stats-visible"; @@ -52,6 +54,8 @@ const els = { followPlayerBtn: document.querySelector("#followPlayerBtn"), roomContext: document.querySelector("#roomContext"), roomDescriptionField: document.querySelector("#roomDescriptionField"), + roomTagsField: document.querySelector("#roomTagsField"), + roomNotesField: document.querySelector("#roomNotesField"), roomTitleInput: document.querySelector("#roomTitleInput"), roomTagsInput: document.querySelector("#roomTagsInput"), roomDescriptionInput: document.querySelector("#roomDescriptionInput"), @@ -82,6 +86,8 @@ const els = { menuPanel: document.querySelector("#appMenuPanel"), themeBtn: document.querySelector("#themeBtn"), toggleDescriptionBtn: document.querySelector("#toggleDescriptionBtn"), + toggleRoomTagsBtn: document.querySelector("#toggleRoomTagsBtn"), + toggleRoomNotesBtn: document.querySelector("#toggleRoomNotesBtn"), toggleMobsBtn: document.querySelector("#toggleMobsBtn"), toggleNotesBtn: document.querySelector("#toggleNotesBtn"), statVisibilityButtons: document.querySelectorAll("[data-stat-toggle]"), @@ -121,6 +127,8 @@ let pendingGameMemoryPosition = null; let pendingPlayerTravelAnimation = null; let followPlayer = project.followPlayer !== false; let descriptionVisible = true; +let roomTagsVisible = true; +let roomNotesVisible = true; let mobsVisible = true; let notesVisible = true; let statVisibility = { ...DEFAULT_STAT_VISIBILITY }; @@ -174,6 +182,8 @@ const parserTerm = term; bindEvents(); applySavedWorkspace(); applySavedDescriptionVisibility(); +applySavedRoomTagsVisibility(); +applySavedRoomNotesVisibility(); applySavedMobsVisibility(); applySavedNotesVisibility(); applySavedStatVisibility(); @@ -794,6 +804,14 @@ function bindEvents() { setDescriptionVisibility(!descriptionVisible); showToast(descriptionVisible ? "Opis lokacji wlaczony." : "Opis lokacji ukryty.", "success"); }); + els.toggleRoomTagsBtn?.addEventListener("click", () => { + setRoomTagsVisibility(!roomTagsVisible); + showToast(roomTagsVisible ? "Tagi pola wlaczone." : "Tagi pola ukryte.", "success"); + }); + els.toggleRoomNotesBtn?.addEventListener("click", () => { + setRoomNotesVisibility(!roomNotesVisible); + showToast(roomNotesVisible ? "Notatki pola wlaczone." : "Notatki pola ukryte.", "success"); + }); els.toggleMobsBtn?.addEventListener("click", () => { setMobsVisibility(!mobsVisible); showToast(mobsVisible ? "Moby na mapie wlaczone." : "Moby na mapie ukryte.", "success"); @@ -1755,6 +1773,51 @@ function setDescriptionVisibility(visible, options = {}) { if (options.persist !== false) { localStorage.setItem(DESCRIPTION_VISIBLE_KEY, descriptionVisible ? "true" : "false"); } + updateLocationFieldsVisibilityState(); +} + +function applySavedRoomTagsVisibility() { + const saved = localStorage.getItem(ROOM_TAGS_VISIBLE_KEY); + setRoomTagsVisibility(saved !== "false", { persist: false }); +} + +function setRoomTagsVisibility(visible, options = {}) { + roomTagsVisible = Boolean(visible); + document.body.classList.toggle("room-tags-hidden", !roomTagsVisible); + if (els.roomTagsField) els.roomTagsField.hidden = !roomTagsVisible; + if (els.toggleRoomTagsBtn) { + els.toggleRoomTagsBtn.classList.toggle("is-on", roomTagsVisible); + els.toggleRoomTagsBtn.setAttribute("aria-pressed", roomTagsVisible ? "true" : "false"); + els.toggleRoomTagsBtn.title = roomTagsVisible ? "Ukryj tagi pola w panelu UI" : "Pokaz tagi pola w panelu UI"; + } + if (options.persist !== false) { + localStorage.setItem(ROOM_TAGS_VISIBLE_KEY, roomTagsVisible ? "true" : "false"); + } + updateLocationFieldsVisibilityState(); +} + +function applySavedRoomNotesVisibility() { + const saved = localStorage.getItem(ROOM_NOTES_VISIBLE_KEY); + setRoomNotesVisibility(saved !== "false", { persist: false }); +} + +function setRoomNotesVisibility(visible, options = {}) { + roomNotesVisible = Boolean(visible); + document.body.classList.toggle("room-notes-hidden", !roomNotesVisible); + if (els.roomNotesField) els.roomNotesField.hidden = !roomNotesVisible; + if (els.toggleRoomNotesBtn) { + els.toggleRoomNotesBtn.classList.toggle("is-on", roomNotesVisible); + els.toggleRoomNotesBtn.setAttribute("aria-pressed", roomNotesVisible ? "true" : "false"); + els.toggleRoomNotesBtn.title = roomNotesVisible ? "Ukryj notatki pola w panelu UI" : "Pokaz notatki pola w panelu UI"; + } + if (options.persist !== false) { + localStorage.setItem(ROOM_NOTES_VISIBLE_KEY, roomNotesVisible ? "true" : "false"); + } + updateLocationFieldsVisibilityState(); +} + +function updateLocationFieldsVisibilityState() { + document.body.classList.toggle("location-fields-hidden", !descriptionVisible && !roomTagsVisible && !roomNotesVisible); } function applySavedMobsVisibility() { diff --git a/public/index.html b/public/index.html index 3511140..8b5c922 100644 --- a/public/index.html +++ b/public/index.html @@ -155,6 +155,14 @@

Otchlan Mapper

Opis lokacji + + +
+ Czcionka terminala +
+ + 14 + +
+
Mapa diff --git a/public/styles.css b/public/styles.css index 3fd931f..ee8d148 100644 --- a/public/styles.css +++ b/public/styles.css @@ -364,6 +364,51 @@ textarea:focus-visible, gap: 8px; } +.setting-control { + display: grid; + gap: 7px; + padding: 8px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel-soft); +} + +.setting-control > span { + color: var(--muted); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.stepper-control { + display: grid; + grid-template-columns: 32px minmax(54px, 1fr) 32px; + gap: 6px; + align-items: center; +} + +.stepper-control button { + justify-content: center; + width: 32px; + height: 30px; + min-height: 30px; + padding: 0; +} + +.stepper-control strong { + display: grid; + min-height: 30px; + place-items: center; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + color: var(--text); + font-family: var(--font-heading); + font-size: 12px; + font-weight: 400; + letter-spacing: 0; +} + .world-file-status { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -835,18 +880,34 @@ body.location-fields-hidden .layout[data-workspace="game"] .global-notes-panel { } .layout[data-workspace="game"] .terminal-stage { - flex: 0 0 auto; + flex: 0 1 auto; max-width: 100%; - max-height: none; - overflow: hidden; + max-height: clamp(360px, calc(100dvh - 230px), 760px); + overflow: auto; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--accent) 52%, transparent) transparent; } .layout[data-workspace="game"] .terminal-panel .xterm-viewport { - scrollbar-width: none; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--accent) 52%, transparent) transparent; } .layout[data-workspace="game"] .terminal-panel .xterm-viewport::-webkit-scrollbar { - display: none; + width: 8px; + height: 8px; +} + +.layout[data-workspace="game"] .terminal-stage::-webkit-scrollbar, +.layout[data-workspace="game"] .terminal-panel .xterm-viewport::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.layout[data-workspace="game"] .terminal-stage::-webkit-scrollbar-thumb, +.layout[data-workspace="game"] .terminal-panel .xterm-viewport::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--accent) 52%, transparent); + border-radius: 999px; } .terminal-panel .xterm { @@ -1795,7 +1856,8 @@ label textarea { .layout[data-workspace="game"] .terminal-stage { flex: 0 0 auto; - overflow: hidden; + max-height: clamp(340px, 52dvh, 640px); + overflow: auto; } .character-vitals { @@ -1951,7 +2013,8 @@ label textarea { } .layout[data-workspace="game"] .terminal-stage { - overflow: hidden; + max-height: clamp(300px, 50dvh, 580px); + overflow: auto; } .character-vitals { diff --git a/test/app-performance.test.js b/test/app-performance.test.js index 9a2d6c2..540ed4e 100644 --- a/test/app-performance.test.js +++ b/test/app-performance.test.js @@ -7,10 +7,20 @@ const htmlSource = await readFile(new URL("../public/index.html", import.meta.ur const cssSource = await readFile(new URL("../public/styles.css", import.meta.url), "utf8"); test("terminal keeps only a small technical scrollback", () => { - assert.match(appSource, /const TERMINAL_SCROLLBACK_LINES = 200;/); + assert.match(appSource, /const TERMINAL_SCROLLBACK_LINES = 120;/); assert.match(appSource, /scrollback: TERMINAL_SCROLLBACK_LINES/); }); +test("terminal viewport is compact without resizing the game pty", () => { + assert.match(appSource, /const TERMINAL_COLS = 120;/); + assert.match(appSource, /const TERMINAL_ROWS = 48;/); + assert.match(appSource, /rows: TERMINAL_ROWS/); + assert.match(appSource, /function fitTerminalToPanel\(\) \{[\s\S]*term\.resize\(TERMINAL_COLS, TERMINAL_ROWS\);[\s\S]*fitAtlasTerminalPreview\(\);/); + assert.doesNotMatch(appSource, /fixed terminal resize failed/); + assert.match(cssSource, /\.layout\[data-workspace="game"\] \.terminal-stage \{[\s\S]*max-height: clamp\([\s\S]*overflow: auto;/); + assert.match(cssSource, /\.layout\[data-workspace="game"\] \.terminal-panel \.xterm-viewport \{[\s\S]*scrollbar-width: thin;/); +}); + test("process memory position is the only mapper position sync", () => { assert.match(appSource, /source\.addEventListener\("game-position", \(event\) => receiveGameMemoryPosition\(JSON\.parse\(event\.data\)\)\);/); assert.match(appSource, /function applyGameMemoryPosition\(position = \{\}\) \{/); @@ -34,7 +44,7 @@ test("refresh waits for process memory before showing player position", () => { }); test("terminal output no longer drives mapper position", () => { - assert.match(appSource, /term\.write\(text\);/); + assert.match(appSource, /term\.write\(text, \(\) => \{/); assert.doesNotMatch(appSource, /processTerminalText/); assert.doesNotMatch(appSource, /processTerminalSnapshot/); assert.doesNotMatch(appSource, /readRoomObservationsFromTerminal/); diff --git a/test/app-ui.test.js b/test/app-ui.test.js index e62de1b..7585734 100644 --- a/test/app-ui.test.js +++ b/test/app-ui.test.js @@ -35,7 +35,7 @@ test("documentation demo mode provides stable UI state without live server loops assert.doesNotMatch(appSource, /getRoomByWorldKey/); assert.match(appSource, /if \(DOCUMENTATION_DEMO_MODE && mob\.visibleCardinal4\) return true;/); assert.match(appSource, /if \(DOCUMENTATION_DEMO_MODE\) return;[\s\S]*sendQueuedGameInput\(data\);/); - assert.match(appSource, /if \(!DOCUMENTATION_DEMO_MODE\) \{[\s\S]*\/api\/game\/resize/); + assert.doesNotMatch(appSource, /fixed terminal resize failed/); }); test("game control lives in the terminal header as one dynamic button", () => { @@ -182,6 +182,28 @@ test("global notes panel visibility can be toggled from settings", () => { assert.match(cssSource, /body\.notes-hidden \.layout\[data-workspace="game"\] \.location-panel\s*\{[\s\S]*grid-column: 2 \/ 4;/); }); +test("terminal font size can be changed from settings", () => { + assert.match(htmlSource, /id="terminalFontSizeDownBtn"[\s\S]*-/); + assert.match(htmlSource, /id="terminalFontSizeValue"[\s\S]*14/); + assert.match(htmlSource, /id="terminalFontSizeUpBtn"[\s\S]*\+/); + assert.match(appSource, /const TERMINAL_FONT_SIZE_KEY = "otchlan-automapper-terminal-font-size";/); + assert.match(appSource, /const TERMINAL_DEFAULT_FONT_SIZE = 14;/); + assert.match(appSource, /const TERMINAL_MIN_FONT_SIZE = 10;/); + assert.match(appSource, /const TERMINAL_MAX_FONT_SIZE = 18;/); + assert.match(appSource, /terminalFontSizeDownBtn: document\.querySelector\("#terminalFontSizeDownBtn"\)/); + assert.match(appSource, /terminalFontSizeUpBtn: document\.querySelector\("#terminalFontSizeUpBtn"\)/); + assert.match(appSource, /terminalFontSizeValue: document\.querySelector\("#terminalFontSizeValue"\)/); + assert.match(appSource, /function applySavedTerminalFontSize\(\) \{/); + assert.match(appSource, /function setTerminalFontSize\(fontSize, options = \{\}\) \{/); + assert.match(appSource, /term\.options\.fontSize = nextFontSize;/); + assert.match(appSource, /terminalFontSizeValue\.textContent = String\(nextFontSize\)/); + assert.match(appSource, /terminalFontSizeDownBtn\.disabled = nextFontSize <= TERMINAL_MIN_FONT_SIZE/); + assert.match(appSource, /terminalFontSizeUpBtn\.disabled = nextFontSize >= TERMINAL_MAX_FONT_SIZE/); + assert.match(appSource, /localStorage\.setItem\(TERMINAL_FONT_SIZE_KEY, String\(nextFontSize\)\)/); + assert.match(cssSource, /\.stepper-control/); + assert.match(cssSource, /\.stepper-control strong/); +}); + test("app menu is structured as settings with map mob visibility toggle", () => { assert.match(htmlSource, /class="settings-panel"/); assert.match(htmlSource, /class="settings-head"[\s\S]*Ustawienia/); From 3a44e68677f48370e838e50819f2de3cdeff7a8a Mon Sep 17 00:00:00 2001 From: Hubert Date: Thu, 4 Jun 2026 11:47:40 +0200 Subject: [PATCH 6/8] Expand wide layout and cull map viewport rendering --- public/app.js | 78 ++++++++++++++++++++++++---------- public/index.html | 44 ++++++++++--------- public/styles.css | 82 +++++++++++++++++++++++++++--------- test/app-performance.test.js | 13 ++++-- test/app-ui.test.js | 20 ++++++++- 5 files changed, 170 insertions(+), 67 deletions(-) diff --git a/public/app.js b/public/app.js index 7f27f03..de70514 100644 --- a/public/app.js +++ b/public/app.js @@ -1063,7 +1063,7 @@ function initMapDragging() { mapDrag.lastX = event.clientX; mapDrag.lastY = event.clientY; applyMapViewBox(); - scheduleDebugMapViewportRender(); + scheduleMapViewportRender(); }); els.mapSvg.addEventListener("pointerup", finishMapDrag); @@ -1152,7 +1152,7 @@ function zoomMapWithWheel(event) { mapView.x = mapX - pointerX * nextViewBox.width + nextViewBox.width / 2; mapView.y = mapY - pointerY * nextViewBox.height + nextViewBox.height / 2; applyMapViewBox(); - scheduleDebugMapViewportRender(); + scheduleMapViewportRender(); } function clampMapZoom(value) { @@ -1911,7 +1911,7 @@ function setTerminalFontSize(fontSize, options = {}) { if (options.persist !== false) { localStorage.setItem(TERMINAL_FONT_SIZE_KEY, String(nextFontSize)); } - scheduleTerminalFit(); + refreshTerminalLayoutAfterFontSizeChange(); return nextFontSize; } @@ -1921,6 +1921,20 @@ function normalizeTerminalFontSize(fontSize) { return Math.max(TERMINAL_MIN_FONT_SIZE, Math.min(TERMINAL_MAX_FONT_SIZE, Math.round(value))); } +function refreshTerminalLayoutAfterFontSizeChange() { + terminalStagePinnedToBottom = true; + term.refresh(0, Math.max(0, term.rows - 1)); + scheduleTerminalFit(); + window.requestAnimationFrame(() => { + term.refresh(0, Math.max(0, term.rows - 1)); + fitTerminalToPanel(); + window.requestAnimationFrame(() => { + fitTerminalToPanel(); + scrollTerminalToBottom(); + }); + }); +} + function applySavedStatVisibility() { let saved = {}; try { @@ -2569,12 +2583,27 @@ function renderMap(reason = "renderMap") { const atlasWorkspaceActive = false; const z = getRenderMapZ(playerRoom, room); const cell = 82; - const debugRenderWindow = mapDebugAll ? getMapGridRenderWindow(cell, 3) : null; + const viewArea = mapDebugAll ? "__debug_all__" : "atlas"; + const previousMapLevel = lastRenderedMapLevel; + const mapLevelChanged = Boolean(previousMapLevel && Number(previousMapLevel.z) !== Number(z)); + if (Number(mapView.z) !== Number(z)) { + centerMapOnFocus(); + } else if (mapView.area !== viewArea) { + mapView = { + ...mapView, + z, + area: viewArea + }; + } + const viewportRenderWindow = getMapGridRenderWindow(cell, 3); + const debugRenderWindow = mapDebugAll ? viewportRenderWindow : null; + const normalRenderWindow = mapDebugAll ? null : viewportRenderWindow; const canCullDebugSourceRooms = Boolean(worldAtlas); const showAtlasPreviewRooms = mapDebugAll; const normalRooms = project.rooms .filter((item) => Number(item.z) === Number(z)) - .map((item) => getProjectAtlasRoom(item)); + .map((item) => getProjectAtlasRoom(item)) + .filter((item) => !normalRenderWindow || shouldRenderNormalMapRoom(item, normalRenderWindow)); const debugWorldBaseRooms = showAtlasPreviewRooms ? getDebugWorldBaseRooms(z, canCullDebugSourceRooms ? debugRenderWindow : null) : []; @@ -2587,11 +2616,12 @@ function renderMap(reason = "renderMap") { const allMapItems = mapDebugAll ? buildDebugMapItems([...debugWorldBaseRooms, ...debugProjectRooms], { preserveCoords: Boolean(worldAtlas) }) : normalRooms.map((item) => ({ room: item, mapX: item.x, mapY: item.y, groupLabel: "" })); - const mapItems = debugRenderWindow - ? allMapItems.filter((item) => isMapItemInRenderWindow(item, debugRenderWindow)) + const activeRenderWindow = debugRenderWindow || normalRenderWindow; + const mapItems = activeRenderWindow + ? allMapItems.filter((item) => isMapItemInRenderWindow(item, activeRenderWindow) || shouldAlwaysRenderMapRoom(item.room)) : allMapItems; const rooms = mapItems.map((item) => item.room); - const corridorItems = getRenderAtlasCorridors(z, rooms, debugRenderWindow); + const corridorItems = getRenderAtlasCorridors(z, rooms, activeRenderWindow); const pad = 4; const xs = [...mapItems.map((item) => item.mapX), ...corridorItems.flatMap((item) => item.points.map((point) => point.x))]; const ys = [...mapItems.map((item) => item.mapY), ...corridorItems.flatMap((item) => item.points.map((point) => point.y))]; @@ -2599,18 +2629,6 @@ function renderMap(reason = "renderMap") { const maxX = Math.max(...xs, 2) + pad; const minY = Math.min(...ys, -2) - pad; const maxY = Math.max(...ys, 2) + pad; - const viewArea = mapDebugAll ? "__debug_all__" : "atlas"; - const previousMapLevel = lastRenderedMapLevel; - const mapLevelChanged = Boolean(previousMapLevel && Number(previousMapLevel.z) !== Number(z)); - if (Number(mapView.z) !== Number(z)) { - centerMapOnFocus(); - } else if (mapView.area !== viewArea) { - mapView = { - ...mapView, - z, - area: viewArea - }; - } els.mapTitle.textContent = "Mapa"; els.mapCount.textContent = `Poziom ${z}`; renderMapScopeState(); @@ -2807,6 +2825,7 @@ function renderPositionOnlyMapUpdate(previousPlayerRoomId, previousSelectedRoomI updateRoomNodeState(selectedRoomId); applyMapViewBox({ animate: true }); renderPlayerMarkerLayer(lastRenderedMapCoords, lastRenderedMapCell, z); + scheduleMapViewportRender(); recordUiPositionOnlyUpdate(reason); return true; } @@ -2870,12 +2889,11 @@ function formatMobMarkerTitle(mobs = []) { .join("\n"); } -function scheduleDebugMapViewportRender() { - if (!mapDebugAll) return; +function scheduleMapViewportRender() { if (mapViewportRenderFrame) return; mapViewportRenderFrame = window.requestAnimationFrame(() => { mapViewportRenderFrame = null; - renderMap("ui-debug-viewport"); + renderMap(mapDebugAll ? "ui-debug-viewport" : "ui-map-viewport"); }); } @@ -2897,6 +2915,20 @@ function isMapItemInRenderWindow(item, window) { && Number(item.mapY) <= window.maxY; } +function shouldRenderNormalMapRoom(room, window) { + if (!window) return true; + if (shouldAlwaysRenderMapRoom(room)) return true; + return isGridPointInRenderWindow({ x: room.x, y: room.y }, window); +} + +function shouldAlwaysRenderMapRoom(room) { + if (!room) return false; + return room.id === playerRoomId + || room.id === selectedRoomId + || room.id === selectedRoomPreview?.id + || room.worldKey === selectedWorldPreview?.worldKey; +} + function createPlayerLocationMarker(point, cell, extraClass = "") { const { x: centerX, y: centerY } = getPlayerMarkerCenter(point, cell); const inset = 6; diff --git a/public/index.html b/public/index.html index 2dbce3d..6772019 100644 --- a/public/index.html +++ b/public/index.html @@ -27,6 +27,7 @@

Otchlan Mapper

+
@@ -106,6 +107,28 @@

Otchlan Mapper

+
+
+
+ +
+ +
+ + + +
+
+
+
+ +
@@ -268,26 +291,6 @@

Otchlan Mapper

-
-
-
- -
- -
- - - -
-
-
-
Notes @@ -299,6 +302,7 @@

Otchlan Mapper

+
+
+ Aktualizacja +
+ Sprawdzam aktualizacje... + +
+
Mapa