Implement overlay sprite rendering and update API endpoints for overlays

This commit is contained in:
Marco 2026-03-27 13:12:45 +01:00
commit 06e67d8341
6 changed files with 145 additions and 82 deletions

View file

@ -26,7 +26,7 @@ Viewer behavior:
- use the scroll wheel to zoom directly at the pointer - use the scroll wheel to zoom directly at the pointer
- pinch to zoom on touch devices - pinch to zoom on touch devices
- toggle roofs and editor-only elements independently before building - toggle roofs and editor-only elements independently before building
- when editor-only elements are enabled, the base map stays server-rasterized while editor records render as interactive overlays with hover metadata - when editor-only elements are enabled, the base map excludes those records and the original editor shapes render as interactive overlay sprites with hover metadata
The app expects asset folders under the app root: The app expects asset folders under the app root:
@ -64,6 +64,7 @@ docker compose up --build
- `GET /api/builds/:id` returns build status. - `GET /api/builds/:id` returns build status.
- `GET /api/maps/:game/:mapId/metadata?buildId=...` returns map bounds and tile settings. - `GET /api/maps/:game/:mapId/metadata?buildId=...` returns map bounds and tile settings.
- `GET /api/maps/:game/:mapId/overlays?buildId=...` returns interactive overlay records for editor-only content. - `GET /api/maps/:game/:mapId/overlays?buildId=...` returns interactive overlay records for editor-only content.
- `GET /api/maps/:game/:mapId/overlays/:overlayId.webp?buildId=...` returns the rendered sprite for one overlay item.
- `GET /api/maps/:game/:mapId/tiles/:tileX/:tileY.png?buildId=...` returns rendered PNG tiles. - `GET /api/maps/:game/:mapId/tiles/:tileX/:tileY.png?buildId=...` returns rendered PNG tiles.
No raw Crusader asset files are exposed over HTTP. No raw Crusader asset files are exposed over HTTP.

View file

@ -29,6 +29,7 @@ function ensureDir(dirPath) {
} }
const DOWNLOAD_CACHE_ROOT = path.join(TILE_CACHE_ROOT, "downloads"); const DOWNLOAD_CACHE_ROOT = path.join(TILE_CACHE_ROOT, "downloads");
const RENDER_CACHE_VERSION = "v2-overlays-as-sprites";
sharp.cache(false); sharp.cache(false);
function normalizeBuildOptions(options = {}) { function normalizeBuildOptions(options = {}) {
@ -39,7 +40,7 @@ function normalizeBuildOptions(options = {}) {
} }
function buildOptionSuffix(options) { function buildOptionSuffix(options) {
return `editor-${options.includeEditor ? "on" : "off"}_roofs-${options.includeRoofs ? "on" : "off"}`; return `${RENDER_CACHE_VERSION}_editor-${options.includeEditor ? "on" : "off"}_roofs-${options.includeRoofs ? "on" : "off"}`;
} }
function toHex(value, width = 4) { function toHex(value, width = 4) {
@ -189,6 +190,7 @@ function serializeOverlayItem(node, minLeft, minTop, index) {
return { return {
id: `${index}:${item.source}:${item.shape}:${item.frame}:${item.x}:${item.y}:${item.z}`, id: `${index}:${item.source}:${item.shape}:${item.frame}:${item.x}:${item.y}:${item.z}`,
index,
kind, kind,
label: overlayLabel(kind), label: overlayLabel(kind),
family: info.family, family: info.family,
@ -238,6 +240,9 @@ function serializeOverlayItem(node, minLeft, minTop, index) {
invitem: info.isInvitem, invitem: info.isInvitem,
animType: info.animType animType: info.animType
}, },
presentation: {
opacity: kind === "helper" ? 0.5 : info.isTranslucent ? 0.7 : 1
},
notes: overlayNotes(item, info) notes: overlayNotes(item, info)
}; };
} }
@ -410,6 +415,8 @@ export class BuildManager {
assets, assets,
prepared: [], prepared: [],
overlays: [], overlays: [],
overlayNodes: [],
overlayNodeById: new Map(),
minLeft: 0, minLeft: 0,
minTop: 0, minTop: 0,
width: TILE_SIZE, width: TILE_SIZE,
@ -462,6 +469,8 @@ export class BuildManager {
assets, assets,
prepared: [], prepared: [],
overlays: [], overlays: [],
overlayNodes: [],
overlayNodeById: new Map(),
minLeft: 0, minLeft: 0,
minTop: 0, minTop: 0,
width: TILE_SIZE, width: TILE_SIZE,
@ -503,6 +512,10 @@ export class BuildManager {
const overlays = overlayProjection.projected.map((node, index) => const overlays = overlayProjection.projected.map((node, index) =>
serializeOverlayItem(node, bounds.minLeft, bounds.minTop, index) serializeOverlayItem(node, bounds.minLeft, bounds.minTop, index)
); );
const overlayNodeById = new Map();
for (let index = 0; index < overlays.length; index += 1) {
overlayNodeById.set(overlays[index].id, overlayProjection.projected[index]);
}
const invalidItemCount = sorted.invalidItemCount + overlayProjection.invalidItemCount; const invalidItemCount = sorted.invalidItemCount + overlayProjection.invalidItemCount;
const invalidItems = [...sorted.invalidItems, ...overlayProjection.invalidItems].slice(0, 20); const invalidItems = [...sorted.invalidItems, ...overlayProjection.invalidItems].slice(0, 20);
@ -551,6 +564,8 @@ export class BuildManager {
assets, assets,
prepared: sorted.prepared, prepared: sorted.prepared,
overlays, overlays,
overlayNodes: overlayProjection.projected,
overlayNodeById,
minLeft: bounds.minLeft, minLeft: bounds.minLeft,
minTop: bounds.minTop, minTop: bounds.minTop,
width: bounds.width, width: bounds.width,
@ -623,6 +638,63 @@ export class BuildManager {
}; };
} }
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);
const node = job.build.overlayNodeById.get(overlayId);
if (!overlay || !node) {
throw new Error("Unknown overlay id");
}
const extension = format === "png" ? "png" : "webp";
const spritePath = path.join(
TILE_CACHE_ROOT,
gameId,
`map-${mapId}`,
buildOptionSuffix(job.options),
"overlays",
`${overlay.index}.${extension}`
);
if (fs.existsSync(spritePath)) {
return fs.readFileSync(spritePath);
}
const spriteWidth = Math.max(1, overlay.screen.width);
const spriteHeight = Math.max(1, overlay.screen.height);
const buffer = rgbaBuffer(spriteWidth, spriteHeight, [0, 0, 0, 0]);
blitFrame(
buffer,
spriteWidth,
spriteHeight,
0,
0,
node.frame,
node.pixels,
job.build.assets.palette,
Boolean(node.item.flags & FLAG_FLIPPED)
);
let output;
if (format === "png") {
output = encodePng(spriteWidth, spriteHeight, buffer);
} else {
output = await sharp(buffer, {
raw: {
width: spriteWidth,
height: spriteHeight,
channels: 4
},
limitInputPixels: false
})
.webp({ lossless: true, effort: 4 })
.toBuffer();
}
ensureDir(path.dirname(spritePath));
fs.writeFileSync(spritePath, output);
return output;
}
async renderFullMap(jobId, gameId, mapId) { async renderFullMap(jobId, gameId, mapId) {
const job = this.requireReadyJob(jobId, gameId, mapId); const job = this.requireReadyJob(jobId, gameId, mapId);
const outputPath = path.join( const outputPath = path.join(

View file

@ -303,8 +303,8 @@ export function projectItemGeometry(items, archive, shapeInfos, options = {}) {
for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) { for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
const item = items[itemIndex]; const item = items[itemIndex];
try { try {
const frame = archive.getFrame(item.shape, item.frame); const decoded = archive.decodeFrame(item.shape, item.frame);
projected.push(buildSortNode(item, shapeInfos[item.shape], frame, null)); projected.push(buildSortNode(item, shapeInfos[item.shape], decoded.frame, decoded.pixels));
} catch (error) { } catch (error) {
invalidItemCount += 1; invalidItemCount += 1;
if (invalidItems.length < maxInvalidDetails) { if (invalidItems.length < maxInvalidDetails) {

View file

@ -288,72 +288,29 @@ select:disabled,
font: inherit; font: inherit;
background: none; background: none;
cursor: pointer; cursor: pointer;
transition: transform 140ms ease, filter 140ms ease;
} }
.overlay-marker { .overlay-item:hover,
display: grid; .overlay-item:focus-visible {
place-items: center; transform: scale(1.05);
width: 24px; filter: drop-shadow(0 10px 18px rgba(0, 0, 0, 0.34));
height: 24px;
border-radius: 999px;
color: white;
font-size: 0.68rem;
font-weight: 800;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28);
transform: translate(-50%, -100%);
transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
} }
.overlay-marker:hover, .overlay-item--helper {
.overlay-marker:focus-visible { outline: 1px solid var(--overlay-helper-stroke);
transform: translate(-50%, -100%) scale(1.08); outline-offset: 1px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.34);
filter: saturate(1.12);
}
.overlay-marker--editor {
background: linear-gradient(180deg, color-mix(in srgb, var(--overlay-editor) 86%, white 14%) 0%, var(--overlay-editor) 100%);
}
.overlay-marker--egg {
background: linear-gradient(180deg, color-mix(in srgb, var(--overlay-egg) 82%, white 18%) 0%, var(--overlay-egg) 100%);
}
.overlay-marker--roof {
background: linear-gradient(180deg, color-mix(in srgb, var(--overlay-roof) 84%, white 16%) 0%, var(--overlay-roof) 100%);
}
.overlay-helper {
display: flex;
align-items: flex-start;
justify-content: flex-start;
min-width: 14px;
min-height: 14px;
padding: 4px;
border-radius: 8px;
border: 1px solid var(--overlay-helper-stroke);
background: var(--overlay-helper-fill); background: var(--overlay-helper-fill);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
transition: transform 140ms ease, background 140ms ease;
} }
.overlay-helper:hover, .overlay-sprite {
.overlay-helper:focus-visible { display: block;
transform: scale(1.02); width: 100%;
background: color-mix(in srgb, var(--overlay-helper-fill) 70%, white 30%); height: 100%;
} image-rendering: pixelated;
image-rendering: crisp-edges;
.overlay-helper-badge { user-select: none;
display: inline-flex; pointer-events: none;
align-items: center;
justify-content: center;
min-width: 28px;
padding: 2px 6px;
border-radius: 999px;
background: rgba(7, 12, 18, 0.68);
color: rgba(255, 255, 255, 0.86);
font-size: 0.68rem;
font-weight: 800;
} }
.overlay-tooltip { .overlay-tooltip {

View file

@ -249,6 +249,11 @@ function tileUrl(buildContext, tileX, tileY) {
return `/api/maps/${selected.game}/${selected.mapId}/tiles/${tileX}/${tileY}.webp?buildId=${encodeURIComponent(jobId)}`; return `/api/maps/${selected.game}/${selected.mapId}/tiles/${tileX}/${tileY}.webp?buildId=${encodeURIComponent(jobId)}`;
} }
function overlaySpriteUrl(buildContext, overlay) {
const { selected, jobId } = buildContext;
return `/api/maps/${selected.game}/${selected.mapId}/overlays/${encodeURIComponent(overlay.id)}.webp?buildId=${encodeURIComponent(jobId)}`;
}
function positionOverlayTooltip(clientX, clientY) { function positionOverlayTooltip(clientX, clientY) {
const rect = viewport.getBoundingClientRect(); const rect = viewport.getBoundingClientRect();
const tooltipWidth = overlayTooltip.offsetWidth; const tooltipWidth = overlayTooltip.offsetWidth;
@ -307,23 +312,24 @@ function showOverlayTooltip(overlay, anchor) {
function createOverlayElement(overlay) { function createOverlayElement(overlay) {
const item = document.createElement("button"); const item = document.createElement("button");
item.type = "button"; item.type = "button";
item.className = `overlay-item ${overlay.kind === "helper" ? "overlay-helper" : `overlay-marker overlay-marker--${overlay.kind}`}`; item.className = `overlay-item overlay-item--${overlay.kind}`;
item.setAttribute("aria-label", `${overlay.label}, family ${overlay.family}, shape ${overlay.shape}, frame ${overlay.frame}`); item.setAttribute("aria-label", `${overlay.label}, family ${overlay.family}, shape ${overlay.shape}, frame ${overlay.frame}`);
item.style.left = `${overlay.screen.left}px`;
item.style.top = `${overlay.screen.top}px`;
item.style.width = `${Math.max(1, overlay.screen.width)}px`;
item.style.height = `${Math.max(1, overlay.screen.height)}px`;
item.style.opacity = String(overlay.presentation.opacity);
if (overlay.kind === "helper") { const sprite = document.createElement("img");
item.style.left = `${overlay.screen.left}px`; sprite.className = "overlay-sprite";
item.style.top = `${overlay.screen.top}px`; sprite.alt = "";
item.style.width = `${Math.max(14, overlay.screen.width)}px`; sprite.ariaHidden = "true";
item.style.height = `${Math.max(14, overlay.screen.height)}px`; sprite.draggable = false;
const badge = document.createElement("span"); sprite.loading = "eager";
badge.className = "overlay-helper-badge"; sprite.decoding = "async";
badge.textContent = `F${overlay.family}`; sprite.width = Math.max(1, overlay.screen.width);
item.append(badge); sprite.height = Math.max(1, overlay.screen.height);
} else { item.append(sprite);
item.style.left = `${overlay.screen.anchorX}px`;
item.style.top = `${overlay.screen.anchorY}px`;
item.textContent = String(overlay.family);
}
item.addEventListener("pointerenter", (event) => { item.addEventListener("pointerenter", (event) => {
showOverlayTooltip(overlay, event); showOverlayTooltip(overlay, event);
@ -339,7 +345,10 @@ function createOverlayElement(overlay) {
}); });
item.addEventListener("blur", hideOverlayTooltip); item.addEventListener("blur", hideOverlayTooltip);
return item; return {
item,
ready: waitForImage(sprite)
};
} }
function createTileElement(tileX, tileY, buildContext, metadata) { function createTileElement(tileX, tileY, buildContext, metadata) {
@ -394,14 +403,19 @@ async function buildLayer(buildContext) {
const overlayRoot = document.createElement("div"); const overlayRoot = document.createElement("div");
overlayRoot.className = "overlay-root"; overlayRoot.className = "overlay-root";
const overlayPromises = [];
for (const overlay of buildContext.overlays.items) { for (const overlay of buildContext.overlays.items) {
overlayRoot.append(createOverlayElement(overlay)); const overlayElement = createOverlayElement(overlay);
const sprite = overlayElement.item.querySelector("img");
sprite.src = overlaySpriteUrl(buildContext, overlay);
overlayRoot.append(overlayElement.item);
overlayPromises.push(overlayElement.ready);
} }
layer.append(overlayRoot); layer.append(overlayRoot);
return { return {
layer, layer,
ready: Promise.all(tilePromises) ready: Promise.all([...tilePromises, ...overlayPromises])
}; };
} }

View file

@ -72,6 +72,25 @@ app.get("/api/maps/:game/:mapId/overlays", (request, response) => {
} }
}); });
app.get("/api/maps/:game/:mapId/overlays/:overlayId.webp", async (request, response) => {
try {
const buildId = String(request.query.buildId ?? "");
const mapId = Number.parseInt(request.params.mapId, 10);
const webp = await builds.renderOverlaySprite(
buildId,
request.params.game,
mapId,
request.params.overlayId,
"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/tiles/:tileX/:tileY.png", async (request, response) => { app.get("/api/maps/:game/:mapId/tiles/:tileX/:tileY.png", async (request, response) => {
try { try {
const buildId = String(request.query.buildId ?? ""); const buildId = String(request.query.buildId ?? "");