From 549ff383347cae91a0326d582c54d6136c596787 Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 27 Mar 2026 11:02:30 +0100 Subject: [PATCH] Enhance map rendering with WebP support and loading feedback - Update tile rendering to support both PNG and WebP formats. - Introduce a loading spinner and progress bar for build feedback. - Remove the manual build button; builds now trigger automatically on map selection. - Add a phase plan document outlining future improvements and goals. --- map_renderer/phase-plan.md | 36 +++++++++ map_renderer/src/lib/build-manager.js | 34 +++++--- map_renderer/src/public/app.css | 71 +++++++++++++--- map_renderer/src/public/app.js | 111 +++++++++++++++++++++----- map_renderer/src/public/index.html | 13 ++- map_renderer/src/server.js | 23 +++++- 6 files changed, 243 insertions(+), 45 deletions(-) create mode 100644 map_renderer/phase-plan.md diff --git a/map_renderer/phase-plan.md b/map_renderer/phase-plan.md new file mode 100644 index 0000000..c90024f --- /dev/null +++ b/map_renderer/phase-plan.md @@ -0,0 +1,36 @@ +# Map Renderer Phase Plan + +## Phase 1 + +Goal: tighten the current viewer flow without changing the map semantics. + +- remove the explicit manual build button because map selection and filter changes already trigger builds automatically +- hide the viewport's "choose a detected map" message as soon as a map is selected and a build starts +- add build feedback in the side panel with a spinner and a simple phase-based loading bar +- remove visible tile seam lines and the synthetic background grid so the map reads as one surface +- use a more efficient streamed visualization format for interactive tiles while keeping PNG as the final export format + +Phase 1 implementation choice: + +- interactive tiles switch from PNG to lossless WebP because it is broadly supported in current browsers and is more bandwidth-efficient for repeated server-rendered tile delivery +- full-map download remains PNG so export quality and compatibility stay unchanged + +## Phase 2 + +Goal: promote editor-only content from "baked into the raster" to interactive overlay objects. + +- keep the base map rendered as a flat server-generated tile surface +- extract editor-only objects into a standalone overlay data stream +- render those overlay items in the client as positioned interactive markers or sprites above the base map +- on hover, slightly scale the hovered item and show a tooltip with its decoded metadata payload +- improve roof detection before or during the overlay split because the current roof filtering still lets some roofs render when they should not +- identify occluding helper geometry such as invisible walls and render those semitransparently so they remain legible without hiding too much of the map beneath them +- fix pipe rendering because pipes currently are not showing up correctly +- investigate force-field rendering because they appear yellow instead of the expected blue semitransparent look; this may be a debug-shape choice issue or a palette/color-rendering issue +- likely revisit ScummVM Crusader handling in `D:\source\scummvm` to confirm what editor/debug records carry and how best to decode them for display + +Open questions for phase 2: + +- which editor-only families should become interactive overlays versus remain baked into the base render +- what exact metadata fields are reliable enough to expose in the tooltip +- whether some editor-only entries should be clustered, filtered, or toggled by family to keep dense maps usable diff --git a/map_renderer/src/lib/build-manager.js b/map_renderer/src/lib/build-manager.js index c14d50f..de7794b 100644 --- a/map_renderer/src/lib/build-manager.js +++ b/map_renderer/src/lib/build-manager.js @@ -343,7 +343,7 @@ export class BuildManager { DOWNLOAD_CACHE_ROOT, gameId, `map-${mapId}`, - `${buildOptionSuffix(job.options)}.png` + `${gameId}-map-${mapId}-${buildOptionSuffix(job.options)}.png` ); if (fs.existsSync(outputPath)) { return outputPath; @@ -354,7 +354,7 @@ export class BuildManager { for (let tileY = 0; tileY < job.metadata.tileCountY; tileY += 1) { for (let tileX = 0; tileX < job.metadata.tileCountX; tileX += 1) { composites.push({ - input: this.renderTile(jobId, gameId, mapId, tileX, tileY), + input: await this.renderTile(jobId, gameId, mapId, tileX, tileY, "png"), left: tileX * job.metadata.tileSize, top: tileY * job.metadata.tileSize }); @@ -377,19 +377,21 @@ export class BuildManager { return outputPath; } - renderTile(jobId, gameId, mapId, tileX, tileY) { + async renderTile(jobId, gameId, mapId, tileX, tileY, format = "webp") { const job = this.requireReadyJob(jobId, gameId, mapId); - const tileKey = `${job.id}:${tileX}:${tileY}`; + const tileKey = `${job.id}:${format}:${tileX}:${tileY}`; if (this.tileCache.has(tileKey)) { return this.tileCache.get(tileKey); } + const extension = format === "png" ? "png" : "webp"; + const tilePath = path.join( TILE_CACHE_ROOT, gameId, `map-${mapId}`, buildOptionSuffix(job.options), - `${tileX}-${tileY}.png` + `${tileX}-${tileY}.${extension}` ); if (fs.existsSync(tilePath)) { const cached = fs.readFileSync(tilePath); @@ -428,11 +430,25 @@ export class BuildManager { ); } - const png = encodePng(tileWidth, tileHeight, buffer); + let output; + if (format === "png") { + output = encodePng(tileWidth, tileHeight, buffer); + } else { + output = await sharp(buffer, { + raw: { + width: tileWidth, + height: tileHeight, + channels: 4 + }, + limitInputPixels: false + }) + .webp({ lossless: true, effort: 4 }) + .toBuffer(); + } ensureDir(path.dirname(tilePath)); - fs.writeFileSync(tilePath, png); - this.tileCache.set(tileKey, png); - return png; + fs.writeFileSync(tilePath, output); + this.tileCache.set(tileKey, output); + return output; } requireReadyJob(jobId, gameId, mapId) { diff --git a/map_renderer/src/public/app.css b/map_renderer/src/public/app.css index 8297b6e..46514d3 100644 --- a/map_renderer/src/public/app.css +++ b/map_renderer/src/public/app.css @@ -2,6 +2,7 @@ color-scheme: light dark; --bg: #f1ead6; --panel: rgba(255, 248, 232, 0.92); + --card: rgba(255, 255, 255, 0.58); --panel-border: rgba(94, 73, 37, 0.25); --ink: #2d2212; --muted: #6e5a37; @@ -17,6 +18,7 @@ :root { --bg: #12161d; --panel: rgba(22, 28, 38, 0.92); + --card: rgba(28, 35, 46, 0.96); --panel-border: rgba(166, 187, 211, 0.16); --ink: #edf2fa; --muted: #aab8cc; @@ -79,7 +81,6 @@ label { } select, -button, .action-link { width: 100%; border-radius: 12px; @@ -88,7 +89,6 @@ button, font: inherit; } -button, .action-link { cursor: pointer; color: white; @@ -97,7 +97,6 @@ button, text-align: center; } -button:disabled, select:disabled, .action-link.is-disabled { cursor: not-allowed; @@ -115,7 +114,7 @@ select:disabled, .meta-panel { padding: 12px 14px; border-radius: 14px; - background: rgba(255, 255, 255, 0.58); + background: var(--card); border: 1px solid rgba(65, 48, 21, 0.08); overflow-wrap: anywhere; } @@ -147,7 +146,47 @@ select:disabled, } .status { - min-height: 72px; + min-height: 92px; +} + +.status-row { + display: flex; + align-items: center; + gap: 10px; +} + +.status-text { + min-height: 24px; +} + +.spinner { + width: 18px; + height: 18px; + border-radius: 999px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: var(--accent); + animation: spin 0.8s linear infinite; + flex: 0 0 auto; +} + +.progress-wrap { + margin-top: 12px; +} + +.progress-track { + width: 100%; + height: 8px; + border-radius: 999px; + overflow: hidden; + background: rgba(0, 0, 0, 0.08); +} + +.progress-fill { + height: 100%; + width: 0%; + border-radius: inherit; + background: linear-gradient(90deg, var(--accent) 0%, var(--accent-strong) 100%); + transition: width 180ms ease; } .meta-panel { @@ -202,12 +241,7 @@ select:disabled, height: calc(100vh - 36px); overflow: hidden; border-radius: 24px; - background: - linear-gradient(0deg, rgba(255,255,255,0.03), rgba(255,255,255,0.03)), - linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px), - linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px), - var(--viewport); - background-size: auto, 32px 32px, 32px 32px, auto; + background: radial-gradient(circle at top left, rgba(255,255,255,0.04), transparent 26%), var(--viewport); box-shadow: inset 0 0 0 1px rgba(255,255,255,0.05), var(--shadow); touch-action: none; cursor: grab; @@ -231,10 +265,19 @@ select:disabled, position: absolute; image-rendering: pixelated; image-rendering: crisp-edges; - border: 1px solid var(--tile-border); pointer-events: none; } +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + .viewport.is-dragging { cursor: grabbing; } @@ -262,6 +305,10 @@ select:disabled, text-align: center; } +.empty-state.is-hidden { + display: none; +} + @media (max-width: 900px) { .shell { grid-template-columns: 1fr; diff --git a/map_renderer/src/public/app.js b/map_renderer/src/public/app.js index 97d6caf..4af8465 100644 --- a/map_renderer/src/public/app.js +++ b/map_renderer/src/public/app.js @@ -1,9 +1,11 @@ const mapForm = document.querySelector("#map-form"); const mapSelect = document.querySelector("#map-select"); -const buildButton = document.querySelector("#build-button"); const includeEditorCheckbox = document.querySelector("#include-editor"); const includeRoofsCheckbox = document.querySelector("#include-roofs"); const downloadButton = document.querySelector("#download-button"); +const spinner = document.querySelector("#spinner"); +const progressWrap = document.querySelector("#progress-wrap"); +const progressFill = document.querySelector("#progress-fill"); const statusBox = document.querySelector("#status"); const metaBox = document.querySelector("#meta"); const viewport = document.querySelector("#viewport"); @@ -34,6 +36,11 @@ const state = { const ZOOM_FACTOR = 1.2; const FIT_PADDING = 24; +function setEmptyStateVisible(visible) { + emptyState.hidden = !visible; + emptyState.classList.toggle("is-hidden", !visible); +} + async function fetchJson(url, init) { const response = await fetch(url, init); if (!response.ok) { @@ -55,6 +62,29 @@ function setStatus(message) { statusBox.textContent = message; } +function phaseProgress(build) { + const phaseToValue = { + queued: 5, + "loading-assets": 18, + "loading-map": 32, + "collecting-items": 58, + sorting: 84, + ready: 100, + failed: 100 + }; + return phaseToValue[build?.phase] ?? 8; +} + +function setLoadingState(active, build = null) { + spinner.hidden = !active; + progressWrap.hidden = !active; + if (active) { + progressFill.style.width = `${phaseProgress(build)}%`; + } else { + progressFill.style.width = "0%"; + } +} + function setMeta(metadata) { if (!metadata) { metaBox.innerHTML = '

Select a map to see render metadata.

'; @@ -189,7 +219,7 @@ function fitMap() { function tileUrl(buildContext, tileX, tileY) { const { selected, jobId } = buildContext; - return `/api/maps/${selected.game}/${selected.mapId}/tiles/${tileX}/${tileY}.png?buildId=${encodeURIComponent(jobId)}`; + return `/api/maps/${selected.game}/${selected.mapId}/tiles/${tileX}/${tileY}.webp?buildId=${encodeURIComponent(jobId)}`; } function createTileElement(tileX, tileY, buildContext, metadata) { @@ -241,8 +271,10 @@ async function buildLayer(buildContext) { } } - await Promise.all(tilePromises); - return layer; + return { + layer, + ready: Promise.all(tilePromises) + }; } function getSelectedMap() { @@ -270,9 +302,11 @@ function currentFiltersMatch() { function scheduleAutoBuild() { clearTimeout(autoBuildTimer); + setEmptyStateVisible(false); autoBuildTimer = window.setTimeout(() => { const selected = getSelectedMap(); if (!selected) { + setEmptyStateVisible(true); return; } if (currentSelectionMatches(selected) && currentFiltersMatch()) { @@ -305,7 +339,6 @@ function populateCatalog(catalog) { } mapSelect.disabled = catalog.games.length === 0; - buildButton.disabled = catalog.games.length === 0; setDownloadState(false); if (catalog.games.length === 0) { setStatus("No usable STATIC folders were detected under the app root."); @@ -319,13 +352,16 @@ async function startBuild(selected) { const token = ++state.buildToken; const preserveView = currentSelectionMatches(selected); + setEmptyStateVisible(false); + if (!state.current) { - emptyState.hidden = false; enableZoomControls(false); setMeta(null); setDownloadState(false); } + setLoadingState(true, { phase: "queued" }); + setStatus( preserveView ? `Rebuilding ${selected.game} map ${selected.mapId}. The current view stays visible until the new tiles are ready.` @@ -356,8 +392,10 @@ async function pollBuild(jobId, selected, token, preserveView) { } const latest = build.progress.at(-1); + setLoadingState(build.status !== "ready" && build.status !== "failed", build); setStatus(latest ? `${build.phase}: ${latest.message}` : `${build.phase}...`); if (build.status === "failed") { + setLoadingState(false); throw new Error(build.error || "Build failed"); } if (build.status !== "ready") { @@ -377,31 +415,62 @@ async function pollBuild(jobId, selected, token, preserveView) { } const nextContext = { selected, jobId, metadata }; - const nextLayer = await buildLayer(nextContext); + const nextLayerBuild = await buildLayer(nextContext); if (token !== state.buildToken) { return; } state.current = nextContext; - activeLayer.replaceWith(nextLayer); - activeLayer = nextLayer; - - if (!preserveView) { - fitMap(); - } else { - clampOffsets(); - updateSceneLayout(); - updateZoomLabel(); - } - setMeta(metadata); setDownloadState( true, `/api/maps/${selected.game}/${selected.mapId}/download.png?buildId=${encodeURIComponent(jobId)}` ); - setStatus(`Ready. ${selected.game} map ${selected.mapId} is fully loaded.`); - emptyState.hidden = true; + setEmptyStateVisible(false); enableZoomControls(true); + + if (!preserveView) { + activeLayer.replaceWith(nextLayerBuild.layer); + activeLayer = nextLayerBuild.layer; + fitMap(); + setStatus(`Rendering complete. Streaming tiles for ${selected.game} map ${selected.mapId}...`); + nextLayerBuild.ready.then(() => { + if (token !== state.buildToken) { + return; + } + setLoadingState(false); + setStatus(`Ready. ${selected.game} map ${selected.mapId} is fully loaded.`); + }).catch((error) => { + if (token !== state.buildToken) { + return; + } + setLoadingState(false); + setStatus(error instanceof Error ? error.message : String(error)); + }); + return; + } + + setStatus(`Rendering complete. Streaming updated tiles for ${selected.game} map ${selected.mapId}...`); + try { + await nextLayerBuild.ready; + } catch (error) { + if (token !== state.buildToken) { + return; + } + setLoadingState(false); + throw error; + } + if (token !== state.buildToken) { + return; + } + + activeLayer.replaceWith(nextLayerBuild.layer); + activeLayer = nextLayerBuild.layer; + clampOffsets(); + updateSceneLayout(); + updateZoomLabel(); + setLoadingState(false); + setStatus(`Ready. ${selected.game} map ${selected.mapId} is fully loaded.`); } async function loadCatalog() { @@ -554,6 +623,8 @@ enableZoomControls(false); updateZoomLabel(); setMeta(null); setDownloadState(false); +setLoadingState(false); +setEmptyStateVisible(true); loadCatalog().catch((error) => { setStatus(error instanceof Error ? error.message : String(error)); }); diff --git a/map_renderer/src/public/index.html b/map_renderer/src/public/index.html index bb68972..70a2da9 100644 --- a/map_renderer/src/public/index.html +++ b/map_renderer/src/public/index.html @@ -21,7 +21,6 @@ -
@@ -38,7 +37,17 @@
-
Idle.
+
+
+ +
Idle.
+
+ +
diff --git a/map_renderer/src/server.js b/map_renderer/src/server.js index 38b30c6..2395724 100644 --- a/map_renderer/src/server.js +++ b/map_renderer/src/server.js @@ -61,7 +61,7 @@ app.get("/api/maps/:game/:mapId/metadata", (request, response) => { } }); -app.get("/api/maps/:game/:mapId/tiles/:tileX/:tileY.png", (request, response) => { +app.get("/api/maps/:game/:mapId/tiles/:tileX/:tileY.png", async (request, response) => { try { const buildId = String(request.query.buildId ?? ""); const mapId = Number.parseInt(request.params.mapId, 10); @@ -71,7 +71,7 @@ app.get("/api/maps/:game/:mapId/tiles/:tileX/:tileY.png", (request, response) => response.status(400).json({ error: "Invalid tile coordinates" }); return; } - const png = builds.renderTile(buildId, request.params.game, mapId, tileX, tileY); + const png = await builds.renderTile(buildId, request.params.game, mapId, tileX, tileY, "png"); response.setHeader("Content-Type", "image/png"); response.setHeader("Cache-Control", "public, max-age=31536000, immutable"); response.end(png); @@ -80,6 +80,25 @@ app.get("/api/maps/:game/:mapId/tiles/:tileX/:tileY.png", (request, response) => } }); +app.get("/api/maps/:game/:mapId/tiles/:tileX/:tileY.webp", async (request, response) => { + try { + const buildId = String(request.query.buildId ?? ""); + const mapId = Number.parseInt(request.params.mapId, 10); + const tileX = Number.parseInt(request.params.tileX, 10); + const tileY = Number.parseInt(request.params.tileY, 10); + if (!Number.isInteger(tileX) || !Number.isInteger(tileY) || tileX < 0 || tileY < 0) { + response.status(400).json({ error: "Invalid tile coordinates" }); + return; + } + const webp = await builds.renderTile(buildId, request.params.game, mapId, tileX, tileY, "webp"); + response.setHeader("Content-Type", "image/webp"); + response.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + response.end(webp); + } catch (error) { + response.status(400).json({ error: error instanceof Error ? error.message : String(error) }); + } +}); + app.get("/api/maps/:game/:mapId/download.png", async (request, response) => { try { const buildId = String(request.query.buildId ?? "");