diff --git a/map_renderer/phase-plan.md b/map_renderer/phase-plan.md index c90024f..576ed3e 100644 --- a/map_renderer/phase-plan.md +++ b/map_renderer/phase-plan.md @@ -19,18 +19,33 @@ Phase 1 implementation choice: 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 +- completed: keep the base map rendered as a flat server-generated tile surface +- completed: extract editor-only objects into a standalone overlay data stream +- completed: render editor-only overlay items in the client as positioned sprite overlays above the base map +- completed: remove editor-only records from the base raster so overlay shapes are not duplicated in the map tiles +- completed: fix overlay transparency so the sprite background stays transparent instead of fading black +- completed: add an inspection mode checkbox that shows metadata for any rendered shape under the cursor, not just editor overlays - 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 +Current phase 2 status: + +- the server now builds three outputs from one map build: base raster tiles, editor-only overlay sprites, and a shared inspectable-shape metadata stream +- editor-only shapes are interactive overlay sprites rendered from their original decoded frames rather than synthetic markers +- when inspect mode is active, the cursor reports metadata for whichever rendered shape is currently under it, including base-map shapes + +Next steps: + +- use inspect mode on representative maps to identify which visible structures are true roofs versus normal geometry so roof filtering can be tightened with evidence +- decide which helper/occluder families should stay semitransparent overlays and which should eventually be hidden or toggled separately +- inspect broken pipe shapes and compare their metadata against ScummVM handling to determine why they currently render incorrectly +- inspect force-field shapes and compare palette or translucency traits against expected in-game appearance + 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 +- which helper/editor families should stay as overlay sprites versus gain their own visibility toggles +- what exact metadata fields are reliable enough to expose in the tooltip long-term - 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 f0392bf..50a99a3 100644 --- a/map_renderer/src/lib/build-manager.js +++ b/map_renderer/src/lib/build-manager.js @@ -180,6 +180,98 @@ function overlayNotes(item, info) { return notes; } +function classifyBaseKind(info) { + if (info.isRoof) { + return "roof"; + } + if (info.isOccl || info.isInvitem) { + return "helper"; + } + if (info.isLand) { + return "terrain"; + } + return "base"; +} + +function inspectLabel(layer, kind) { + if (layer === "overlay") { + return overlayLabel(kind); + } + if (kind === "roof") { + return "Roof Shape"; + } + if (kind === "helper") { + return "Occluding Helper"; + } + if (kind === "terrain") { + return "Terrain Shape"; + } + return "Map Shape"; +} + +function serializeInspectableItem(node, minLeft, minTop, id, layer, stackOrder) { + const { item, info, frame } = node; + const kind = layer === "overlay" ? classifyOverlayKind(item, info) : classifyBaseKind(info); + const sceneLeft = node.left - minLeft; + const sceneTop = node.top - minTop; + + return { + id, + layer, + stackOrder, + kind, + label: inspectLabel(layer, kind), + family: info.family, + source: item.source, + world: { + x: item.x, + y: item.y, + z: item.z + }, + mapNum: item.mapNum, + npcNum: item.npcNum, + nextItem: item.nextItem, + quality: item.quality, + shape: item.shape, + frame: item.frame, + frameSize: { + width: frame.width, + height: frame.height, + xoff: frame.xoff, + yoff: frame.yoff + }, + screen: { + left: sceneLeft, + top: sceneTop, + right: node.right - minLeft, + bottom: node.bottom - minTop, + width: node.right - node.left, + height: node.bottom - node.top, + anchorX: Math.trunc(sceneLeft + (node.right - node.left) / 2), + anchorY: node.bottom - minTop + }, + flags: { + raw: item.flags, + hex: toHex(item.flags), + invisible: Boolean(item.flags & FLAG_INVISIBLE), + flipped: Boolean(item.flags & FLAG_FLIPPED) + }, + traits: { + editor: info.isEditor, + roof: info.isRoof, + occluding: info.isOccl, + translucent: info.isTranslucent, + solid: info.isSolid, + fixed: info.isFixed, + land: info.isLand, + draw: info.isDraw, + invitem: info.isInvitem, + animType: info.animType + }, + notes: overlayNotes(item, info) + }; +} + function serializeOverlayItem(node, minLeft, minTop, index) { const { item, info, frame } = node; const kind = classifyOverlayKind(item, info); @@ -417,6 +509,7 @@ export class BuildManager { overlays: [], overlayNodes: [], overlayNodeById: new Map(), + inspectables: [], minLeft: 0, minTop: 0, width: TILE_SIZE, @@ -471,6 +564,7 @@ export class BuildManager { overlays: [], overlayNodes: [], overlayNodeById: new Map(), + inspectables: [], minLeft: 0, minTop: 0, width: TILE_SIZE, @@ -516,6 +610,34 @@ export class BuildManager { for (let index = 0; index < overlays.length; index += 1) { overlayNodeById.set(overlays[index].id, overlayProjection.projected[index]); } + const inspectables = []; + for (let index = 0; index < sorted.prepared.length; index += 1) { + const node = sorted.prepared[index]; + const stackOrder = node.order >= 0 ? node.order : index; + inspectables.push( + serializeInspectableItem( + node, + bounds.minLeft, + bounds.minTop, + `base:${stackOrder}:${node.item.source}:${node.item.shape}:${node.item.frame}:${node.item.x}:${node.item.y}:${node.item.z}`, + "base", + stackOrder + ) + ); + } + for (let index = 0; index < overlays.length; index += 1) { + inspectables.push( + serializeInspectableItem( + overlayProjection.projected[index], + bounds.minLeft, + bounds.minTop, + `overlay:${overlays[index].id}`, + "overlay", + sorted.prepared.length + index + ) + ); + } + inspectables.sort((left, right) => left.stackOrder - right.stackOrder); const invalidItemCount = sorted.invalidItemCount + overlayProjection.invalidItemCount; const invalidItems = [...sorted.invalidItems, ...overlayProjection.invalidItems].slice(0, 20); @@ -566,6 +688,7 @@ export class BuildManager { overlays, overlayNodes: overlayProjection.projected, overlayNodeById, + inspectables, minLeft: bounds.minLeft, minTop: bounds.minTop, width: bounds.width, @@ -638,6 +761,13 @@ export class BuildManager { }; } + getInspectData(jobId, gameId, mapId) { + const job = this.requireReadyJob(jobId, gameId, mapId); + return { + items: job.build.inspectables + }; + } + async renderOverlaySprite(jobId, gameId, mapId, overlayId, format = "webp") { const job = this.requireReadyJob(jobId, gameId, mapId); const overlay = job.build.overlays.find((item) => item.id === overlayId); diff --git a/map_renderer/src/public/app.css b/map_renderer/src/public/app.css index 79d4094..094ab44 100644 --- a/map_renderer/src/public/app.css +++ b/map_renderer/src/public/app.css @@ -291,16 +291,15 @@ select:disabled, transition: transform 140ms ease, filter 140ms ease; } -.overlay-item:hover, -.overlay-item:focus-visible { +.viewport:not(.inspect-active) .overlay-item:hover, +.viewport:not(.inspect-active) .overlay-item:focus-visible { transform: scale(1.05); filter: drop-shadow(0 10px 18px rgba(0, 0, 0, 0.34)); } -.overlay-item--helper { - outline: 1px solid var(--overlay-helper-stroke); - outline-offset: 1px; - background: var(--overlay-helper-fill); +.viewport.inspect-active .overlay-item { + pointer-events: none; + cursor: crosshair; } .overlay-sprite { @@ -365,6 +364,15 @@ select:disabled, font-size: 0.8rem; } +.inspect-highlight { + position: absolute; + z-index: 4; + border: 2px solid rgba(255, 229, 107, 0.95); + background: rgba(255, 229, 107, 0.08); + box-shadow: 0 0 0 1px rgba(7, 12, 18, 0.82), 0 0 18px rgba(255, 229, 107, 0.28); + pointer-events: none; +} + @keyframes spin { from { transform: rotate(0deg); @@ -379,6 +387,14 @@ select:disabled, cursor: grabbing; } +.viewport.inspect-active { + cursor: crosshair; +} + +.viewport.inspect-active.is-dragging { + cursor: grabbing; +} + .viewport-hint { position: absolute; top: 16px; diff --git a/map_renderer/src/public/app.js b/map_renderer/src/public/app.js index 894968d..3b2c132 100644 --- a/map_renderer/src/public/app.js +++ b/map_renderer/src/public/app.js @@ -2,6 +2,7 @@ const mapForm = document.querySelector("#map-form"); const mapSelect = document.querySelector("#map-select"); const includeEditorCheckbox = document.querySelector("#include-editor"); const includeRoofsCheckbox = document.querySelector("#include-roofs"); +const inspectShapesCheckbox = document.querySelector("#inspect-shapes"); const downloadButton = document.querySelector("#download-button"); const spinner = document.querySelector("#spinner"); const progressWrap = document.querySelector("#progress-wrap"); @@ -9,7 +10,9 @@ const progressFill = document.querySelector("#progress-fill"); const statusBox = document.querySelector("#status"); const metaBox = document.querySelector("#meta"); const viewport = document.querySelector("#viewport"); +const viewportHint = document.querySelector("#viewport-hint"); const scene = document.querySelector("#scene"); +const inspectHighlight = document.querySelector("#inspect-highlight"); const overlayTooltip = document.querySelector("#overlay-tooltip"); const emptyState = document.querySelector("#empty-state"); const zoomLabel = document.querySelector("#zoom-label"); @@ -31,7 +34,8 @@ const state = { buildToken: 0, drag: null, pointers: new Map(), - pinch: null + pinch: null, + inspectTargetId: null }; const ZOOM_FACTOR = 1.2; @@ -63,6 +67,18 @@ function setStatus(message) { statusBox.textContent = message; } +function setInspectMode(active) { + viewport.classList.toggle("inspect-active", active); + viewportHint.textContent = active + ? "Inspect mode: move the cursor to identify shapes. Drag still pans." + : "Drag to pan. Scroll or pinch to zoom."; + if (!active) { + state.inspectTargetId = null; + hideInspectHighlight(); + hideOverlayTooltip(); + } +} + function escapeHtml(value) { return String(value) .replaceAll("&", "&") @@ -273,19 +289,20 @@ function positionOverlayTooltip(clientX, clientY) { overlayTooltip.style.top = `${top}px`; } -function renderOverlayTooltip(overlay) { - const notes = overlay.notes.length ? overlay.notes.map((note) => `