Add overlay support with interactive tooltips and metadata display
This commit is contained in:
parent
549ff38334
commit
d8940a1b1d
7 changed files with 659 additions and 30 deletions
|
|
@ -5,6 +5,8 @@ import sharp from "sharp";
|
|||
|
||||
import { TILE_CACHE_ROOT, TILE_SIZE } from "../config.js";
|
||||
import {
|
||||
EGG_FAMILIES,
|
||||
FLAG_INVISIBLE,
|
||||
FLAG_FLIPPED,
|
||||
ShapeArchive,
|
||||
collectRenderItems,
|
||||
|
|
@ -16,7 +18,7 @@ import {
|
|||
summarizeRenderClasses
|
||||
} from "./formats.js";
|
||||
import { blitFrame, encodePng, rgbaBuffer } from "./png.js";
|
||||
import { prepareSortedItems } from "./sorting.js";
|
||||
import { prepareSortedItems, projectItemGeometry } from "./sorting.js";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
|
|
@ -40,6 +42,231 @@ function buildOptionSuffix(options) {
|
|||
return `editor-${options.includeEditor ? "on" : "off"}_roofs-${options.includeRoofs ? "on" : "off"}`;
|
||||
}
|
||||
|
||||
function toHex(value, width = 4) {
|
||||
return `0x${value.toString(16).padStart(width, "0")}`;
|
||||
}
|
||||
|
||||
function createEmptyProjection() {
|
||||
return {
|
||||
projected: [],
|
||||
invalidItemCount: 0,
|
||||
invalidItems: []
|
||||
};
|
||||
}
|
||||
|
||||
function computeBoundsFromNodes(nodes) {
|
||||
if (!nodes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let minLeft = Number.MAX_SAFE_INTEGER;
|
||||
let minTop = Number.MAX_SAFE_INTEGER;
|
||||
let maxRight = -Number.MAX_SAFE_INTEGER;
|
||||
let maxBottom = -Number.MAX_SAFE_INTEGER;
|
||||
|
||||
for (const node of nodes) {
|
||||
minLeft = Math.min(minLeft, node.left);
|
||||
minTop = Math.min(minTop, node.top);
|
||||
maxRight = Math.max(maxRight, node.right);
|
||||
maxBottom = Math.max(maxBottom, node.bottom);
|
||||
}
|
||||
|
||||
return {
|
||||
minLeft,
|
||||
minTop,
|
||||
maxRight,
|
||||
maxBottom,
|
||||
width: maxRight - minLeft,
|
||||
height: maxBottom - minTop
|
||||
};
|
||||
}
|
||||
|
||||
function mergeBounds(boundsList) {
|
||||
const validBounds = boundsList.filter(Boolean);
|
||||
if (!validBounds.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const minLeft = Math.min(...validBounds.map((bounds) => bounds.minLeft));
|
||||
const minTop = Math.min(...validBounds.map((bounds) => bounds.minTop));
|
||||
const maxRight = Math.max(...validBounds.map((bounds) => bounds.maxRight));
|
||||
const maxBottom = Math.max(...validBounds.map((bounds) => bounds.maxBottom));
|
||||
|
||||
return {
|
||||
minLeft,
|
||||
minTop,
|
||||
maxRight,
|
||||
maxBottom,
|
||||
width: maxRight - minLeft,
|
||||
height: maxBottom - minTop
|
||||
};
|
||||
}
|
||||
|
||||
function isOverlayItem(item, shapeInfos) {
|
||||
return item.shape < shapeInfos.length && shapeInfos[item.shape].isEditor;
|
||||
}
|
||||
|
||||
function splitRenderItems(renderItems, shapeInfos, includeEditor) {
|
||||
if (!includeEditor) {
|
||||
return {
|
||||
baseItems: renderItems,
|
||||
overlayItems: []
|
||||
};
|
||||
}
|
||||
|
||||
const baseItems = [];
|
||||
const overlayItems = [];
|
||||
for (const item of renderItems) {
|
||||
if (isOverlayItem(item, shapeInfos)) {
|
||||
overlayItems.push(item);
|
||||
} else {
|
||||
baseItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
baseItems,
|
||||
overlayItems
|
||||
};
|
||||
}
|
||||
|
||||
function classifyOverlayKind(item, info) {
|
||||
if ((item.flags & FLAG_INVISIBLE) || info.isOccl || info.isInvitem) {
|
||||
return "helper";
|
||||
}
|
||||
if (EGG_FAMILIES.has(info.family)) {
|
||||
return "egg";
|
||||
}
|
||||
if (info.isRoof) {
|
||||
return "roof";
|
||||
}
|
||||
return "editor";
|
||||
}
|
||||
|
||||
function overlayLabel(kind) {
|
||||
if (kind === "helper") {
|
||||
return "Helper Geometry";
|
||||
}
|
||||
if (kind === "egg") {
|
||||
return "Egg Trigger";
|
||||
}
|
||||
if (kind === "roof") {
|
||||
return "Roof Marker";
|
||||
}
|
||||
return "Editor Object";
|
||||
}
|
||||
|
||||
function overlayNotes(item, info) {
|
||||
const notes = [];
|
||||
if (item.flags & FLAG_INVISIBLE) {
|
||||
notes.push("invisible-flagged");
|
||||
}
|
||||
if (info.isOccl) {
|
||||
notes.push("occluding-geometry");
|
||||
}
|
||||
if (info.isInvitem) {
|
||||
notes.push("invitem-family");
|
||||
}
|
||||
if (EGG_FAMILIES.has(info.family)) {
|
||||
notes.push("egg-family");
|
||||
}
|
||||
if (info.isRoof) {
|
||||
notes.push("roof-flagged");
|
||||
}
|
||||
if (info.isTranslucent) {
|
||||
notes.push("translucent");
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
function serializeOverlayItem(node, minLeft, minTop, index) {
|
||||
const { item, info, frame } = node;
|
||||
const kind = classifyOverlayKind(item, info);
|
||||
const sceneLeft = node.left - minLeft;
|
||||
const sceneTop = node.top - minTop;
|
||||
const screenWidth = node.right - node.left;
|
||||
const screenHeight = node.bottom - node.top;
|
||||
|
||||
return {
|
||||
id: `${index}:${item.source}:${item.shape}:${item.frame}:${item.x}:${item.y}:${item.z}`,
|
||||
kind,
|
||||
label: overlayLabel(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: screenWidth,
|
||||
height: screenHeight,
|
||||
anchorX: Math.trunc(sceneLeft + screenWidth / 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 summarizeOverlayItems(items) {
|
||||
const kindCounts = {};
|
||||
const familyCounts = {};
|
||||
const sourceCounts = {};
|
||||
|
||||
for (const item of items) {
|
||||
kindCounts[item.kind] = (kindCounts[item.kind] ?? 0) + 1;
|
||||
familyCounts[item.family] = (familyCounts[item.family] ?? 0) + 1;
|
||||
sourceCounts[item.source] = (sourceCounts[item.source] ?? 0) + 1;
|
||||
}
|
||||
|
||||
const topFamilies = Object.entries(familyCounts)
|
||||
.sort((left, right) => right[1] - left[1] || Number(left[0]) - Number(right[0]))
|
||||
.slice(0, 6)
|
||||
.map(([family, count]) => ({ family: Number(family), count }));
|
||||
|
||||
return {
|
||||
itemCount: items.length,
|
||||
kindCounts,
|
||||
sourceCounts,
|
||||
topFamilies,
|
||||
helperCount: kindCounts.helper ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
function makeUsageInfo(gameId, mapId, baseItems, renderItems) {
|
||||
const itemMapNums = [...new Set(baseItems.map((item) => item.mapNum))].sort((left, right) => left - right);
|
||||
return {
|
||||
|
|
@ -63,10 +290,13 @@ function buildEmptyMetadata(gameConfig, mapId, baseItems, reason) {
|
|||
map: mapId,
|
||||
rawItemCount: baseItems.length,
|
||||
itemCount: 0,
|
||||
baseRasterItemCount: 0,
|
||||
overlayItemCount: 0,
|
||||
paintedItemCount: 0,
|
||||
occludedItemCount: 0,
|
||||
invalidItemCount: 0,
|
||||
invalidItems: [],
|
||||
overlaySummary: summarizeOverlayItems([]),
|
||||
usage: makeUsageInfo(gameConfig.id, mapId, baseItems, []),
|
||||
baseItemSummary: {
|
||||
roofItems: 0,
|
||||
|
|
@ -179,6 +409,7 @@ export class BuildManager {
|
|||
job.build = {
|
||||
assets,
|
||||
prepared: [],
|
||||
overlays: [],
|
||||
minLeft: 0,
|
||||
minTop: 0,
|
||||
width: TILE_SIZE,
|
||||
|
|
@ -195,16 +426,42 @@ export class BuildManager {
|
|||
return;
|
||||
}
|
||||
|
||||
const splitItems = splitRenderItems(renderItems, assets.shapeInfos, job.options.includeEditor);
|
||||
this.touchJob(
|
||||
job,
|
||||
`Split ${splitItems.baseItems.length} base items and ${splitItems.overlayItems.length} overlay items`
|
||||
);
|
||||
|
||||
job.phase = "sorting";
|
||||
const sorted = prepareSortedItems(renderItems, assets.shapeArchive, assets.shapeInfos, {
|
||||
checkpointEvery: 2000,
|
||||
maxInvalidDetails: 20,
|
||||
progress: (message) => this.touchJob(job, message)
|
||||
});
|
||||
if (!sorted.prepared.length) {
|
||||
const sorted = splitItems.baseItems.length
|
||||
? prepareSortedItems(splitItems.baseItems, assets.shapeArchive, assets.shapeInfos, {
|
||||
checkpointEvery: 2000,
|
||||
maxInvalidDetails: 20,
|
||||
progress: (message) => this.touchJob(job, message)
|
||||
})
|
||||
: {
|
||||
minLeft: 0,
|
||||
minTop: 0,
|
||||
maxRight: TILE_SIZE,
|
||||
maxBottom: TILE_SIZE,
|
||||
prepared: [],
|
||||
occludedCount: 0,
|
||||
invalidItemCount: 0,
|
||||
invalidItems: []
|
||||
};
|
||||
const overlayProjection = splitItems.overlayItems.length
|
||||
? projectItemGeometry(splitItems.overlayItems, assets.shapeArchive, assets.shapeInfos, {
|
||||
checkpointEvery: 2000,
|
||||
maxInvalidDetails: 20,
|
||||
progress: (message) => this.touchJob(job, message)
|
||||
})
|
||||
: createEmptyProjection();
|
||||
|
||||
if (!sorted.prepared.length && !overlayProjection.projected.length) {
|
||||
job.build = {
|
||||
assets,
|
||||
prepared: [],
|
||||
overlays: [],
|
||||
minLeft: 0,
|
||||
minTop: 0,
|
||||
width: TILE_SIZE,
|
||||
|
|
@ -226,22 +483,42 @@ export class BuildManager {
|
|||
return;
|
||||
}
|
||||
|
||||
const width = sorted.maxRight - sorted.minLeft;
|
||||
const height = sorted.maxBottom - sorted.minTop;
|
||||
if (width <= 0 || height <= 0) {
|
||||
const bounds = mergeBounds([
|
||||
sorted.prepared.length
|
||||
? {
|
||||
minLeft: sorted.minLeft,
|
||||
minTop: sorted.minTop,
|
||||
maxRight: sorted.maxRight,
|
||||
maxBottom: sorted.maxBottom,
|
||||
width: sorted.maxRight - sorted.minLeft,
|
||||
height: sorted.maxBottom - sorted.minTop
|
||||
}
|
||||
: null,
|
||||
computeBoundsFromNodes(overlayProjection.projected)
|
||||
]);
|
||||
if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
|
||||
throw new Error("Computed image bounds are invalid");
|
||||
}
|
||||
|
||||
const overlays = overlayProjection.projected.map((node, index) =>
|
||||
serializeOverlayItem(node, bounds.minLeft, bounds.minTop, index)
|
||||
);
|
||||
const invalidItemCount = sorted.invalidItemCount + overlayProjection.invalidItemCount;
|
||||
const invalidItems = [...sorted.invalidItems, ...overlayProjection.invalidItems].slice(0, 20);
|
||||
|
||||
const metadata = {
|
||||
game: gameConfig.id,
|
||||
gameLabel: gameConfig.label,
|
||||
map: job.mapId,
|
||||
rawItemCount: baseItems.length,
|
||||
itemCount: renderItems.length,
|
||||
baseRasterItemCount: splitItems.baseItems.length,
|
||||
overlayItemCount: overlays.length,
|
||||
paintedItemCount: sorted.prepared.length,
|
||||
occludedItemCount: sorted.occludedCount,
|
||||
invalidItemCount: sorted.invalidItemCount,
|
||||
invalidItems: sorted.invalidItems,
|
||||
invalidItemCount,
|
||||
invalidItems,
|
||||
overlaySummary: summarizeOverlayItems(overlays),
|
||||
usage: makeUsageInfo(gameConfig.id, job.mapId, baseItems, renderItems),
|
||||
baseItemSummary: summarizeRenderClasses(baseItems, assets.shapeInfos),
|
||||
sorter: "scummvm_dependency_graph",
|
||||
|
|
@ -252,16 +529,16 @@ export class BuildManager {
|
|||
includeRoofs: job.options.includeRoofs
|
||||
},
|
||||
bounds: {
|
||||
screenLeft: sorted.minLeft,
|
||||
screenTop: sorted.minTop,
|
||||
screenRight: sorted.maxRight,
|
||||
screenBottom: sorted.maxBottom,
|
||||
width,
|
||||
height
|
||||
screenLeft: bounds.minLeft,
|
||||
screenTop: bounds.minTop,
|
||||
screenRight: bounds.maxRight,
|
||||
screenBottom: bounds.maxBottom,
|
||||
width: bounds.width,
|
||||
height: bounds.height
|
||||
},
|
||||
tileSize: TILE_SIZE,
|
||||
tileCountX: Math.ceil(width / TILE_SIZE),
|
||||
tileCountY: Math.ceil(height / TILE_SIZE),
|
||||
tileCountX: Math.ceil(bounds.width / TILE_SIZE),
|
||||
tileCountY: Math.ceil(bounds.height / TILE_SIZE),
|
||||
zoom: {
|
||||
min: 0.01,
|
||||
max: 8,
|
||||
|
|
@ -273,10 +550,11 @@ export class BuildManager {
|
|||
job.build = {
|
||||
assets,
|
||||
prepared: sorted.prepared,
|
||||
minLeft: sorted.minLeft,
|
||||
minTop: sorted.minTop,
|
||||
width,
|
||||
height
|
||||
overlays,
|
||||
minLeft: bounds.minLeft,
|
||||
minTop: bounds.minTop,
|
||||
width: bounds.width,
|
||||
height: bounds.height
|
||||
};
|
||||
job.metadata = metadata;
|
||||
job.status = "ready";
|
||||
|
|
@ -337,6 +615,14 @@ export class BuildManager {
|
|||
return job.metadata;
|
||||
}
|
||||
|
||||
getOverlayData(jobId, gameId, mapId) {
|
||||
const job = this.requireReadyJob(jobId, gameId, mapId);
|
||||
return {
|
||||
items: job.build.overlays,
|
||||
summary: job.metadata.overlaySummary
|
||||
};
|
||||
}
|
||||
|
||||
async renderFullMap(jobId, gameId, mapId) {
|
||||
const job = this.requireReadyJob(jobId, gameId, mapId);
|
||||
const outputPath = path.join(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue