Added node based web map renderer
This commit is contained in:
parent
82ae89865a
commit
24a4d90a3e
19 changed files with 3970 additions and 0 deletions
451
map_renderer/src/lib/build-manager.js
Normal file
451
map_renderer/src/lib/build-manager.js
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
|
||||
import { TILE_CACHE_ROOT, TILE_SIZE } from "../config.js";
|
||||
import {
|
||||
FLAG_FLIPPED,
|
||||
ShapeArchive,
|
||||
collectRenderItems,
|
||||
loadGlobs,
|
||||
loadMapItems,
|
||||
loadPalette,
|
||||
loadTypeflags,
|
||||
resolveStaticFile,
|
||||
summarizeRenderClasses
|
||||
} from "./formats.js";
|
||||
import { blitFrame, encodePng, rgbaBuffer } from "./png.js";
|
||||
import { prepareSortedItems } from "./sorting.js";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
const DOWNLOAD_CACHE_ROOT = path.join(TILE_CACHE_ROOT, "downloads");
|
||||
sharp.cache(false);
|
||||
|
||||
function normalizeBuildOptions(options = {}) {
|
||||
return {
|
||||
includeEditor: options.includeEditor !== false,
|
||||
includeRoofs: options.includeRoofs === true
|
||||
};
|
||||
}
|
||||
|
||||
function buildOptionSuffix(options) {
|
||||
return `editor-${options.includeEditor ? "on" : "off"}_roofs-${options.includeRoofs ? "on" : "off"}`;
|
||||
}
|
||||
|
||||
function makeUsageInfo(gameId, mapId, baseItems, renderItems) {
|
||||
const itemMapNums = [...new Set(baseItems.map((item) => item.mapNum))].sort((left, right) => left - right);
|
||||
return {
|
||||
status: "unknown",
|
||||
confidence: "unknown",
|
||||
knownHints: [],
|
||||
itemMapNums,
|
||||
nonzeroItemMapNums: itemMapNums.filter((value) => value !== 0),
|
||||
npcLinkedItemCount: baseItems.filter((item) => item.npcNum !== 0).length,
|
||||
note: "No authoritative mission-to-map ownership table has been extracted yet. Unknown does not imply orphaned.",
|
||||
hasRenderableContent: renderItems.length > 0,
|
||||
game: gameId,
|
||||
map: mapId
|
||||
};
|
||||
}
|
||||
|
||||
function buildEmptyMetadata(gameConfig, mapId, baseItems, reason) {
|
||||
return {
|
||||
game: gameConfig.id,
|
||||
gameLabel: gameConfig.label,
|
||||
map: mapId,
|
||||
rawItemCount: baseItems.length,
|
||||
itemCount: 0,
|
||||
paintedItemCount: 0,
|
||||
occludedItemCount: 0,
|
||||
invalidItemCount: 0,
|
||||
invalidItems: [],
|
||||
usage: makeUsageInfo(gameConfig.id, mapId, baseItems, []),
|
||||
baseItemSummary: {
|
||||
roofItems: 0,
|
||||
editorItems: 0,
|
||||
eggFamilyItems: 0,
|
||||
invisibleFlaggedItems: 0,
|
||||
npcLinkedItems: 0
|
||||
},
|
||||
sorter: "scummvm_dependency_graph",
|
||||
isEmpty: true,
|
||||
emptyReason: reason,
|
||||
filters: {
|
||||
includeEditor: true,
|
||||
includeRoofs: false
|
||||
},
|
||||
bounds: {
|
||||
screenLeft: 0,
|
||||
screenTop: 0,
|
||||
screenRight: TILE_SIZE,
|
||||
screenBottom: TILE_SIZE,
|
||||
width: TILE_SIZE,
|
||||
height: TILE_SIZE
|
||||
},
|
||||
tileSize: TILE_SIZE,
|
||||
tileCountX: 1,
|
||||
tileCountY: 1,
|
||||
zoom: {
|
||||
min: 0.01,
|
||||
max: 8,
|
||||
step: 0.1,
|
||||
initial: 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export class BuildManager {
|
||||
constructor(catalog) {
|
||||
this.catalog = catalog;
|
||||
this.assetCache = new Map();
|
||||
this.jobs = new Map();
|
||||
this.jobsByKey = new Map();
|
||||
this.tileCache = new Map();
|
||||
ensureDir(TILE_CACHE_ROOT);
|
||||
}
|
||||
|
||||
listCatalog() {
|
||||
return this.catalog;
|
||||
}
|
||||
|
||||
getJob(jobId) {
|
||||
return this.jobs.get(jobId) ?? null;
|
||||
}
|
||||
|
||||
async createOrReuseBuild(gameConfig, mapId, rawOptions = {}) {
|
||||
const options = normalizeBuildOptions(rawOptions);
|
||||
const key = `${gameConfig.id}:${mapId}:${buildOptionSuffix(options)}`;
|
||||
const existing = this.jobsByKey.get(key);
|
||||
if (existing) {
|
||||
if (existing.status === "ready" || existing.status === "building") {
|
||||
return existing;
|
||||
}
|
||||
this.jobsByKey.delete(key);
|
||||
}
|
||||
|
||||
const job = {
|
||||
id: crypto.randomUUID(),
|
||||
key,
|
||||
game: gameConfig.id,
|
||||
mapId,
|
||||
options,
|
||||
status: "queued",
|
||||
phase: "queued",
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
progress: [],
|
||||
error: null,
|
||||
metadata: null,
|
||||
build: null
|
||||
};
|
||||
this.jobs.set(job.id, job);
|
||||
this.jobsByKey.set(key, job);
|
||||
void this.runBuild(job, gameConfig);
|
||||
return job;
|
||||
}
|
||||
|
||||
async runBuild(job, gameConfig) {
|
||||
try {
|
||||
job.status = "building";
|
||||
job.phase = "loading-assets";
|
||||
this.touchJob(job, `Loading ${gameConfig.label} assets`);
|
||||
const assets = this.getAssets(gameConfig);
|
||||
|
||||
job.phase = "loading-map";
|
||||
this.touchJob(job, `Loading map ${job.mapId}`);
|
||||
const fixedDatPath = resolveStaticFile(gameConfig.staticDir, "FIXED.DAT");
|
||||
const baseItems = loadMapItems(fixedDatPath, job.mapId);
|
||||
this.touchJob(job, `Loaded ${baseItems.length} fixed records`);
|
||||
|
||||
job.phase = "collecting-items";
|
||||
const renderItems = collectRenderItems(baseItems, assets.shapeInfos, assets.globs, {
|
||||
includeEditor: job.options.includeEditor,
|
||||
expandGlobs: true,
|
||||
worldRect: null,
|
||||
includeRoofs: job.options.includeRoofs,
|
||||
includeHiddenMarkers: true,
|
||||
checkpointEvery: 2000,
|
||||
progress: (message) => this.touchJob(job, message)
|
||||
});
|
||||
if (!renderItems.length) {
|
||||
job.build = {
|
||||
assets,
|
||||
prepared: [],
|
||||
minLeft: 0,
|
||||
minTop: 0,
|
||||
width: TILE_SIZE,
|
||||
height: TILE_SIZE
|
||||
};
|
||||
job.metadata = buildEmptyMetadata(gameConfig, job.mapId, baseItems, "This map has no renderable items in FIXED.DAT.");
|
||||
job.metadata.filters = {
|
||||
includeEditor: job.options.includeEditor,
|
||||
includeRoofs: job.options.includeRoofs
|
||||
};
|
||||
job.status = "ready";
|
||||
job.phase = "ready";
|
||||
this.touchJob(job, "Build ready: map is empty, serving a blank placeholder tile");
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
job.build = {
|
||||
assets,
|
||||
prepared: [],
|
||||
minLeft: 0,
|
||||
minTop: 0,
|
||||
width: TILE_SIZE,
|
||||
height: TILE_SIZE
|
||||
};
|
||||
job.metadata = buildEmptyMetadata(
|
||||
gameConfig,
|
||||
job.mapId,
|
||||
baseItems,
|
||||
"This map resolved to no valid shape or frame pairs after decoding."
|
||||
);
|
||||
job.metadata.filters = {
|
||||
includeEditor: job.options.includeEditor,
|
||||
includeRoofs: job.options.includeRoofs
|
||||
};
|
||||
job.status = "ready";
|
||||
job.phase = "ready";
|
||||
this.touchJob(job, "Build ready: no valid frames were renderable, serving a blank placeholder tile");
|
||||
return;
|
||||
}
|
||||
|
||||
const width = sorted.maxRight - sorted.minLeft;
|
||||
const height = sorted.maxBottom - sorted.minTop;
|
||||
if (width <= 0 || height <= 0) {
|
||||
throw new Error("Computed image bounds are invalid");
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
game: gameConfig.id,
|
||||
gameLabel: gameConfig.label,
|
||||
map: job.mapId,
|
||||
rawItemCount: baseItems.length,
|
||||
itemCount: renderItems.length,
|
||||
paintedItemCount: sorted.prepared.length,
|
||||
occludedItemCount: sorted.occludedCount,
|
||||
invalidItemCount: sorted.invalidItemCount,
|
||||
invalidItems: sorted.invalidItems,
|
||||
usage: makeUsageInfo(gameConfig.id, job.mapId, baseItems, renderItems),
|
||||
baseItemSummary: summarizeRenderClasses(baseItems, assets.shapeInfos),
|
||||
sorter: "scummvm_dependency_graph",
|
||||
isEmpty: false,
|
||||
emptyReason: null,
|
||||
filters: {
|
||||
includeEditor: job.options.includeEditor,
|
||||
includeRoofs: job.options.includeRoofs
|
||||
},
|
||||
bounds: {
|
||||
screenLeft: sorted.minLeft,
|
||||
screenTop: sorted.minTop,
|
||||
screenRight: sorted.maxRight,
|
||||
screenBottom: sorted.maxBottom,
|
||||
width,
|
||||
height
|
||||
},
|
||||
tileSize: TILE_SIZE,
|
||||
tileCountX: Math.ceil(width / TILE_SIZE),
|
||||
tileCountY: Math.ceil(height / TILE_SIZE),
|
||||
zoom: {
|
||||
min: 0.01,
|
||||
max: 8,
|
||||
step: 0.1,
|
||||
initial: 1
|
||||
}
|
||||
};
|
||||
|
||||
job.build = {
|
||||
assets,
|
||||
prepared: sorted.prepared,
|
||||
minLeft: sorted.minLeft,
|
||||
minTop: sorted.minTop,
|
||||
width,
|
||||
height
|
||||
};
|
||||
job.metadata = metadata;
|
||||
job.status = "ready";
|
||||
job.phase = "ready";
|
||||
this.touchJob(job, `Build ready with ${metadata.tileCountX}x${metadata.tileCountY} tiles`);
|
||||
} catch (error) {
|
||||
job.status = "failed";
|
||||
job.phase = "failed";
|
||||
job.error = error instanceof Error ? error.message : String(error);
|
||||
this.touchJob(job, `Build failed: ${job.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
getAssets(gameConfig) {
|
||||
if (this.assetCache.has(gameConfig.id)) {
|
||||
return this.assetCache.get(gameConfig.id);
|
||||
}
|
||||
const assets = {
|
||||
palette: loadPalette(resolveStaticFile(gameConfig.staticDir, "GAMEPAL.PAL")),
|
||||
shapeInfos: loadTypeflags(resolveStaticFile(gameConfig.staticDir, "TYPEFLAG.DAT")),
|
||||
globs: loadGlobs(resolveStaticFile(gameConfig.staticDir, "GLOB.FLX")),
|
||||
shapeArchive: new ShapeArchive(resolveStaticFile(gameConfig.staticDir, "SHAPES.FLX"))
|
||||
};
|
||||
this.assetCache.set(gameConfig.id, assets);
|
||||
return assets;
|
||||
}
|
||||
|
||||
touchJob(job, message) {
|
||||
job.updatedAt = nowIso();
|
||||
job.progress.push({
|
||||
at: job.updatedAt,
|
||||
phase: job.phase,
|
||||
message
|
||||
});
|
||||
if (job.progress.length > 100) {
|
||||
job.progress.splice(0, job.progress.length - 100);
|
||||
}
|
||||
}
|
||||
|
||||
getPublicJob(job) {
|
||||
return {
|
||||
id: job.id,
|
||||
game: job.game,
|
||||
mapId: job.mapId,
|
||||
options: job.options,
|
||||
status: job.status,
|
||||
phase: job.phase,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: job.updatedAt,
|
||||
error: job.error,
|
||||
metadata: job.status === "ready" ? job.metadata : null,
|
||||
progress: job.progress
|
||||
};
|
||||
}
|
||||
|
||||
getMetadata(jobId, gameId, mapId) {
|
||||
const job = this.requireReadyJob(jobId, gameId, mapId);
|
||||
return job.metadata;
|
||||
}
|
||||
|
||||
async renderFullMap(jobId, gameId, mapId) {
|
||||
const job = this.requireReadyJob(jobId, gameId, mapId);
|
||||
const outputPath = path.join(
|
||||
DOWNLOAD_CACHE_ROOT,
|
||||
gameId,
|
||||
`map-${mapId}`,
|
||||
`${buildOptionSuffix(job.options)}.png`
|
||||
);
|
||||
if (fs.existsSync(outputPath)) {
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
ensureDir(path.dirname(outputPath));
|
||||
const composites = [];
|
||||
for (let tileY = 0; tileY < job.metadata.tileCountY; tileY += 1) {
|
||||
for (let tileX = 0; tileX < job.metadata.tileCountX; tileX += 1) {
|
||||
composites.push({
|
||||
input: this.renderTile(jobId, gameId, mapId, tileX, tileY),
|
||||
left: tileX * job.metadata.tileSize,
|
||||
top: tileY * job.metadata.tileSize
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await sharp({
|
||||
create: {
|
||||
width: job.metadata.bounds.width,
|
||||
height: job.metadata.bounds.height,
|
||||
channels: 4,
|
||||
background: { r: 10, g: 12, b: 18, alpha: 1 }
|
||||
},
|
||||
limitInputPixels: false
|
||||
})
|
||||
.composite(composites)
|
||||
.png()
|
||||
.toFile(outputPath);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
renderTile(jobId, gameId, mapId, tileX, tileY) {
|
||||
const job = this.requireReadyJob(jobId, gameId, mapId);
|
||||
const tileKey = `${job.id}:${tileX}:${tileY}`;
|
||||
if (this.tileCache.has(tileKey)) {
|
||||
return this.tileCache.get(tileKey);
|
||||
}
|
||||
|
||||
const tilePath = path.join(
|
||||
TILE_CACHE_ROOT,
|
||||
gameId,
|
||||
`map-${mapId}`,
|
||||
buildOptionSuffix(job.options),
|
||||
`${tileX}-${tileY}.png`
|
||||
);
|
||||
if (fs.existsSync(tilePath)) {
|
||||
const cached = fs.readFileSync(tilePath);
|
||||
this.tileCache.set(tileKey, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const tileLeft = tileX * TILE_SIZE;
|
||||
const tileTop = tileY * TILE_SIZE;
|
||||
const tileWidth = Math.max(0, Math.min(TILE_SIZE, job.build.width - tileLeft));
|
||||
const tileHeight = Math.max(0, Math.min(TILE_SIZE, job.build.height - tileTop));
|
||||
if (tileWidth <= 0 || tileHeight <= 0) {
|
||||
throw new Error("Requested tile is outside the rendered map bounds");
|
||||
}
|
||||
|
||||
const buffer = rgbaBuffer(tileWidth, tileHeight);
|
||||
const screenLeft = job.build.minLeft + tileLeft;
|
||||
const screenTop = job.build.minTop + tileTop;
|
||||
const screenRight = screenLeft + tileWidth;
|
||||
const screenBottom = screenTop + tileHeight;
|
||||
|
||||
for (const node of job.build.prepared) {
|
||||
if (node.right <= screenLeft || node.left >= screenRight || node.bottom <= screenTop || node.top >= screenBottom) {
|
||||
continue;
|
||||
}
|
||||
blitFrame(
|
||||
buffer,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
node.left - screenLeft,
|
||||
node.top - screenTop,
|
||||
node.frame,
|
||||
node.pixels,
|
||||
job.build.assets.palette,
|
||||
Boolean(node.item.flags & FLAG_FLIPPED)
|
||||
);
|
||||
}
|
||||
|
||||
const png = encodePng(tileWidth, tileHeight, buffer);
|
||||
ensureDir(path.dirname(tilePath));
|
||||
fs.writeFileSync(tilePath, png);
|
||||
this.tileCache.set(tileKey, png);
|
||||
return png;
|
||||
}
|
||||
|
||||
requireReadyJob(jobId, gameId, mapId) {
|
||||
const job = this.getJob(jobId);
|
||||
if (!job) {
|
||||
throw new Error("Unknown build id");
|
||||
}
|
||||
if (job.game !== gameId || job.mapId !== mapId) {
|
||||
throw new Error("Build id does not match the requested map");
|
||||
}
|
||||
if (job.status !== "ready") {
|
||||
throw new Error("Build is not ready yet");
|
||||
}
|
||||
return job;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue