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
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ?? "");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue