Implement overlay sprite rendering and update API endpoints for overlays
This commit is contained in:
parent
d8940a1b1d
commit
06e67d8341
6 changed files with 145 additions and 82 deletions
|
|
@ -26,7 +26,7 @@ Viewer behavior:
|
|||
- use the scroll wheel to zoom directly at the pointer
|
||||
- pinch to zoom on touch devices
|
||||
- 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:
|
||||
|
||||
|
|
@ -64,6 +64,7 @@ docker compose up --build
|
|||
- `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/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.
|
||||
|
||||
No raw Crusader asset files are exposed over HTTP.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ function ensureDir(dirPath) {
|
|||
}
|
||||
|
||||
const DOWNLOAD_CACHE_ROOT = path.join(TILE_CACHE_ROOT, "downloads");
|
||||
const RENDER_CACHE_VERSION = "v2-overlays-as-sprites";
|
||||
sharp.cache(false);
|
||||
|
||||
function normalizeBuildOptions(options = {}) {
|
||||
|
|
@ -39,7 +40,7 @@ function normalizeBuildOptions(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) {
|
||||
|
|
@ -189,6 +190,7 @@ function serializeOverlayItem(node, minLeft, minTop, index) {
|
|||
|
||||
return {
|
||||
id: `${index}:${item.source}:${item.shape}:${item.frame}:${item.x}:${item.y}:${item.z}`,
|
||||
index,
|
||||
kind,
|
||||
label: overlayLabel(kind),
|
||||
family: info.family,
|
||||
|
|
@ -238,6 +240,9 @@ function serializeOverlayItem(node, minLeft, minTop, index) {
|
|||
invitem: info.isInvitem,
|
||||
animType: info.animType
|
||||
},
|
||||
presentation: {
|
||||
opacity: kind === "helper" ? 0.5 : info.isTranslucent ? 0.7 : 1
|
||||
},
|
||||
notes: overlayNotes(item, info)
|
||||
};
|
||||
}
|
||||
|
|
@ -410,6 +415,8 @@ export class BuildManager {
|
|||
assets,
|
||||
prepared: [],
|
||||
overlays: [],
|
||||
overlayNodes: [],
|
||||
overlayNodeById: new Map(),
|
||||
minLeft: 0,
|
||||
minTop: 0,
|
||||
width: TILE_SIZE,
|
||||
|
|
@ -462,6 +469,8 @@ export class BuildManager {
|
|||
assets,
|
||||
prepared: [],
|
||||
overlays: [],
|
||||
overlayNodes: [],
|
||||
overlayNodeById: new Map(),
|
||||
minLeft: 0,
|
||||
minTop: 0,
|
||||
width: TILE_SIZE,
|
||||
|
|
@ -503,6 +512,10 @@ export class BuildManager {
|
|||
const overlays = overlayProjection.projected.map((node, 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 invalidItems = [...sorted.invalidItems, ...overlayProjection.invalidItems].slice(0, 20);
|
||||
|
||||
|
|
@ -551,6 +564,8 @@ export class BuildManager {
|
|||
assets,
|
||||
prepared: sorted.prepared,
|
||||
overlays,
|
||||
overlayNodes: overlayProjection.projected,
|
||||
overlayNodeById,
|
||||
minLeft: bounds.minLeft,
|
||||
minTop: bounds.minTop,
|
||||
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) {
|
||||
const job = this.requireReadyJob(jobId, gameId, mapId);
|
||||
const outputPath = path.join(
|
||||
|
|
|
|||
|
|
@ -303,8 +303,8 @@ export function projectItemGeometry(items, archive, shapeInfos, options = {}) {
|
|||
for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
|
||||
const item = items[itemIndex];
|
||||
try {
|
||||
const frame = archive.getFrame(item.shape, item.frame);
|
||||
projected.push(buildSortNode(item, shapeInfos[item.shape], frame, null));
|
||||
const decoded = archive.decodeFrame(item.shape, item.frame);
|
||||
projected.push(buildSortNode(item, shapeInfos[item.shape], decoded.frame, decoded.pixels));
|
||||
} catch (error) {
|
||||
invalidItemCount += 1;
|
||||
if (invalidItems.length < maxInvalidDetails) {
|
||||
|
|
|
|||
|
|
@ -288,72 +288,29 @@ select:disabled,
|
|||
font: inherit;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: transform 140ms ease, filter 140ms ease;
|
||||
}
|
||||
|
||||
.overlay-marker {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
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-item:hover,
|
||||
.overlay-item:focus-visible {
|
||||
transform: scale(1.05);
|
||||
filter: drop-shadow(0 10px 18px rgba(0, 0, 0, 0.34));
|
||||
}
|
||||
|
||||
.overlay-marker:hover,
|
||||
.overlay-marker:focus-visible {
|
||||
transform: translate(-50%, -100%) scale(1.08);
|
||||
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);
|
||||
.overlay-item--helper {
|
||||
outline: 1px solid var(--overlay-helper-stroke);
|
||||
outline-offset: 1px;
|
||||
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-helper:focus-visible {
|
||||
transform: scale(1.02);
|
||||
background: color-mix(in srgb, var(--overlay-helper-fill) 70%, white 30%);
|
||||
}
|
||||
|
||||
.overlay-helper-badge {
|
||||
display: inline-flex;
|
||||
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-sprite {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.overlay-tooltip {
|
||||
|
|
|
|||
|
|
@ -249,6 +249,11 @@ function tileUrl(buildContext, tileX, tileY) {
|
|||
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) {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
const tooltipWidth = overlayTooltip.offsetWidth;
|
||||
|
|
@ -307,23 +312,24 @@ function showOverlayTooltip(overlay, anchor) {
|
|||
function createOverlayElement(overlay) {
|
||||
const item = document.createElement("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.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") {
|
||||
item.style.left = `${overlay.screen.left}px`;
|
||||
item.style.top = `${overlay.screen.top}px`;
|
||||
item.style.width = `${Math.max(14, overlay.screen.width)}px`;
|
||||
item.style.height = `${Math.max(14, overlay.screen.height)}px`;
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "overlay-helper-badge";
|
||||
badge.textContent = `F${overlay.family}`;
|
||||
item.append(badge);
|
||||
} else {
|
||||
item.style.left = `${overlay.screen.anchorX}px`;
|
||||
item.style.top = `${overlay.screen.anchorY}px`;
|
||||
item.textContent = String(overlay.family);
|
||||
}
|
||||
const sprite = document.createElement("img");
|
||||
sprite.className = "overlay-sprite";
|
||||
sprite.alt = "";
|
||||
sprite.ariaHidden = "true";
|
||||
sprite.draggable = false;
|
||||
sprite.loading = "eager";
|
||||
sprite.decoding = "async";
|
||||
sprite.width = Math.max(1, overlay.screen.width);
|
||||
sprite.height = Math.max(1, overlay.screen.height);
|
||||
item.append(sprite);
|
||||
|
||||
item.addEventListener("pointerenter", (event) => {
|
||||
showOverlayTooltip(overlay, event);
|
||||
|
|
@ -339,7 +345,10 @@ function createOverlayElement(overlay) {
|
|||
});
|
||||
item.addEventListener("blur", hideOverlayTooltip);
|
||||
|
||||
return item;
|
||||
return {
|
||||
item,
|
||||
ready: waitForImage(sprite)
|
||||
};
|
||||
}
|
||||
|
||||
function createTileElement(tileX, tileY, buildContext, metadata) {
|
||||
|
|
@ -394,14 +403,19 @@ async function buildLayer(buildContext) {
|
|||
|
||||
const overlayRoot = document.createElement("div");
|
||||
overlayRoot.className = "overlay-root";
|
||||
const overlayPromises = [];
|
||||
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);
|
||||
|
||||
return {
|
||||
layer,
|
||||
ready: Promise.all(tilePromises)
|
||||
ready: Promise.all([...tilePromises, ...overlayPromises])
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
try {
|
||||
const buildId = String(request.query.buildId ?? "");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue