Added node based web map renderer

This commit is contained in:
Marco 2026-03-27 10:04:44 +01:00
commit 24a4d90a3e
19 changed files with 3970 additions and 0 deletions

View file

@ -0,0 +1,8 @@
node_modules/
.cache/
coverage/
dist/
.env
.env.*
STATIC/
STATIC_REGRET/

12
map_renderer/.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
node_modules/
.cache/
coverage/
dist/
.env
.env.*
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
STATIC/
STATIC_REGRET/

13
map_renderer/Dockerfile Normal file
View file

@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev --no-audit --no-fund
COPY src ./src
ENV PORT=3000
EXPOSE 3000
CMD ["npm", "start"]

67
map_renderer/README.md Normal file
View file

@ -0,0 +1,67 @@
# Crusader Map Renderer
Node web app that renders Crusader maps on the server and streams only finished PNG tiles to the browser.
## Goals
- Keep Crusader source assets server-side.
- Detect maps from `STATIC` and `STATIC_REGRET` automatically.
- Build map render state on demand after the user selects a map.
- Serve large maps as draggable and zoomable image tiles.
- Run locally with Node or inside Docker.
## Local Run
```powershell
cd map_renderer
npm install
npm start
```
Open `http://localhost:3000`.
Viewer behavior:
- drag with the mouse or one finger to pan
- 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
The app expects asset folders under the app root:
- `map_renderer/STATIC`
- `map_renderer/STATIC_REGRET`
## Docker Run
The Docker image excludes the Crusader assets on purpose. Mount them at runtime so they stay outside the image and are never served directly to clients.
```powershell
cd map_renderer
docker build -t crusader-map-renderer .
docker run --rm -p 3000:3000 `
-v ${PWD}/STATIC:/app/STATIC:ro `
-v ${PWD}/STATIC_REGRET:/app/STATIC_REGRET:ro `
crusader-map-renderer
```
If only one game is available, mount only that folder.
## Docker Compose
The compose file mounts `STATIC` and `STATIC_REGRET` from the host filesystem into the container as read-only volumes. They are excluded from the image build by `.dockerignore`, so the assets are never copied into the image.
```powershell
cd map_renderer
docker compose up --build
```
## HTTP Surface
- `GET /api/maps` returns the detected catalog.
- `POST /api/builds` starts or reuses a 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/tiles/:tileX/:tileY.png?buildId=...` returns rendered PNG tiles.
No raw Crusader asset files are exposed over HTTP.

13
map_renderer/compose.yaml Normal file
View file

@ -0,0 +1,13 @@
services:
map-renderer:
build:
context: .
ports:
- "3000:3000"
environment:
PORT: "3000"
REMORSE_STATIC_DIR: /app/STATIC
REGRET_STATIC_DIR: /app/STATIC_REGRET
volumes:
- ./STATIC:/app/STATIC:ro
- ./STATIC_REGRET:/app/STATIC_REGRET:ro

1387
map_renderer/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

19
map_renderer/package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "crusader-map-renderer",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Server-side tiled Crusader map renderer for browser viewing.",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"express": "^5.1.0",
"pngjs": "^7.0.0",
"sharp": "^0.34.2"
}
}

View file

@ -0,0 +1,24 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export const APP_ROOT = path.resolve(__dirname, "..");
export const PUBLIC_ROOT = path.join(APP_ROOT, "src", "public");
export const TILE_SIZE = Number.parseInt(process.env.TILE_SIZE ?? "1024", 10);
export const PORT = Number.parseInt(process.env.PORT ?? "3000", 10);
export const CACHE_ROOT = path.join(APP_ROOT, ".cache");
export const TILE_CACHE_ROOT = path.join(CACHE_ROOT, "tiles");
export const GAMES = [
{
id: "remorse",
label: "No Remorse",
staticDir: process.env.REMORSE_STATIC_DIR || path.join(APP_ROOT, "STATIC")
},
{
id: "regret",
label: "No Regret",
staticDir: process.env.REGRET_STATIC_DIR || path.join(APP_ROOT, "STATIC_REGRET")
}
];

View file

@ -0,0 +1,15 @@
export function readU16LE(buffer, offset) {
return buffer.readUInt16LE(offset);
}
export function readU24LE(buffer, offset) {
return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16);
}
export function readU32LE(buffer, offset) {
return buffer.readUInt32LE(offset);
}
export function readI32LE(buffer, offset) {
return buffer.readInt32LE(offset);
}

View 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;
}
}

View file

@ -0,0 +1,34 @@
import fs from "node:fs";
import { GAMES } from "../config.js";
import { getMapSummaries, resolveStaticFile } from "./formats.js";
export function detectCatalog() {
const games = [];
for (const game of GAMES) {
const fixedDat = resolveStaticFile(game.staticDir, "FIXED.DAT");
if (!fs.existsSync(fixedDat)) {
continue;
}
const maps = getMapSummaries(fixedDat)
.filter((map) => map.isValid && map.rawItemCount > 0)
.map((map) => ({
id: map.id,
label: `${game.label} Map ${map.id}`,
rawItemCount: map.rawItemCount
}));
if (maps.length > 0) {
games.push({
id: game.id,
label: game.label,
mapCount: maps.length,
maps
});
}
}
return { games };
}
export function getGameConfig(gameId) {
return GAMES.find((game) => game.id === gameId) ?? null;
}

View file

@ -0,0 +1,475 @@
import fs from "node:fs";
import path from "node:path";
import { readI32LE, readU16LE, readU24LE, readU32LE } from "./binary.js";
export const FLEX_TABLE_OFFSET = 0x80;
export const FLEX_COUNT_OFFSET = 0x54;
export const FIXED_MAP_COUNT_OFFSET = 0x54;
export const FIXED_MAP_TABLE_OFFSET = 0x80;
export const CRUSADER_COORD_SCALE = 2;
export const GLOB_COORD_MASK = ~0x3ff;
export const GLOB_COORD_SHIFT = 2;
export const GLOB_COORD_OFFSET = 2;
export const FLAG_INVISIBLE = 0x0010;
export const FLAG_FLIPPED = 0x0020;
export const EGG_FAMILIES = new Set([3, 4, 7, 8]);
export const SI_FIXED = 0x0001;
export const SI_SOLID = 0x0002;
export const SI_LAND = 0x0008;
export const SI_OCCL = 0x0010;
export const SI_NOISY = 0x0080;
export const SI_DRAW = 0x0100;
export const SI_ROOF = 0x0400;
export const SI_TRANSL = 0x0800;
export function getMapCount(fixedDatPath) {
const data = fs.readFileSync(fixedDatPath);
return readU16LE(data, FIXED_MAP_COUNT_OFFSET);
}
export function getMapSummaries(fixedDatPath) {
const data = fs.readFileSync(fixedDatPath);
const mapCount = readU16LE(data, FIXED_MAP_COUNT_OFFSET);
const maps = [];
for (let mapId = 0; mapId < mapCount; mapId += 1) {
const tableOffset = FIXED_MAP_TABLE_OFFSET + mapId * 8;
const mapOffset = readU32LE(data, tableOffset);
const mapSize = readU32LE(data, tableOffset + 4);
maps.push({
id: mapId,
offset: mapOffset,
byteSize: mapSize,
rawItemCount: Math.floor(mapSize / 16),
isValid: mapSize >= 16
});
}
return maps;
}
export class FlexArchive {
constructor(filePath) {
this.path = filePath;
this.data = fs.readFileSync(filePath);
this.entries = FlexArchive.readEntries(this.data);
}
static readEntries(data) {
const count = readU32LE(data, FLEX_COUNT_OFFSET);
const entries = [];
for (let index = 0; index < count; index += 1) {
const base = FLEX_TABLE_OFFSET + index * 8;
entries.push({
offset: readU32LE(data, base),
size: readU32LE(data, base + 4)
});
}
return entries;
}
get(index) {
const entry = this.entries[index];
if (!entry || entry.size === 0) {
return Buffer.alloc(0);
}
return this.data.subarray(entry.offset, entry.offset + entry.size);
}
get length() {
return this.entries.length;
}
}
export class ShapeArchive {
constructor(filePath) {
this.archive = new FlexArchive(filePath);
this.shapeCache = new Map();
this.decodedFrameCache = new Map();
}
getFrame(shapeIndex, frameIndex) {
const frames = this.getShape(shapeIndex);
if (frameIndex < 0 || frameIndex >= frames.length) {
throw new RangeError(`shape ${shapeIndex} frame ${frameIndex} out of range`);
}
return frames[frameIndex];
}
decodeFrame(shapeIndex, frameIndex) {
const cacheKey = `${shapeIndex}:${frameIndex}`;
let decoded = this.decodedFrameCache.get(cacheKey);
const frame = this.getFrame(shapeIndex, frameIndex);
if (!decoded) {
decoded = decodePixels(frame);
this.decodedFrameCache.set(cacheKey, decoded);
}
return { frame, pixels: decoded };
}
getShape(shapeIndex) {
if (this.shapeCache.has(shapeIndex)) {
return this.shapeCache.get(shapeIndex);
}
const raw = this.archive.get(shapeIndex);
if (!raw.length) {
throw new Error(`shape ${shapeIndex} has no data`);
}
const frames = parseShape(raw);
this.shapeCache.set(shapeIndex, frames);
return frames;
}
}
function parseShape(data) {
const frameCount = readU16LE(data, 4);
const frames = [];
for (let index = 0; index < frameCount; index += 1) {
const headerOffset = 6 + index * 8;
const frameOffset = readU24LE(data, headerOffset);
const frameSize = readU32LE(data, headerOffset + 4);
const frameData = data.subarray(frameOffset, frameOffset + frameSize);
if (frameData.length < 28) {
throw new Error(`frame ${index} too small: ${frameData.length}`);
}
const compressed = Boolean(readU32LE(frameData, 8));
const width = readU32LE(frameData, 12);
const height = readU32LE(frameData, 16);
const xoff = readI32LE(frameData, 20);
const yoff = readI32LE(frameData, 24);
const lineOffsets = [];
for (let row = 0; row < height; row += 1) {
lineOffsets.push(readU32LE(frameData, 28 + row * 4) - ((height - row) * 4));
}
const rleOffset = 28 + height * 4;
frames.push({
compressed,
width,
height,
xoff,
yoff,
lineOffsets,
rleData: frameData.subarray(rleOffset)
});
}
return frames;
}
function decodePixels(frame) {
const pixels = new Int16Array(frame.width * frame.height);
pixels.fill(-1);
const rle = frame.rleData;
for (let row = 0; row < frame.height; row += 1) {
let pos = frame.lineOffsets[row];
let xpos = 0;
while (xpos < frame.width) {
if (pos >= rle.length) {
throw new Error(`row ${row} overran RLE data`);
}
xpos += rle[pos];
pos += 1;
if (xpos === frame.width) {
break;
}
if (pos >= rle.length) {
throw new Error(`row ${row} missing run header`);
}
let dlen = rle[pos];
pos += 1;
let runType = 0;
if (frame.compressed) {
runType = dlen & 1;
dlen >>= 1;
}
if (dlen <= 0 || xpos + dlen > frame.width) {
throw new Error(`invalid run length ${dlen} at row ${row}`);
}
const rowBase = row * frame.width + xpos;
if (runType === 0) {
const end = pos + dlen;
if (end > rle.length) {
throw new Error(`row ${row} literal run overruns RLE data`);
}
for (let index = 0; index < dlen; index += 1) {
pixels[rowBase + index] = rle[pos + index];
}
pos = end;
} else {
if (pos >= rle.length) {
throw new Error(`row ${row} repeated-color run missing color byte`);
}
const color = rle[pos];
pos += 1;
for (let index = 0; index < dlen; index += 1) {
pixels[rowBase + index] = color;
}
}
xpos += dlen;
}
}
return pixels;
}
export function loadPalette(filePath) {
const data = fs.readFileSync(filePath);
if (data.length < 768) {
throw new Error(`palette too small: ${filePath}`);
}
const palette = [];
for (let index = 0; index < 256; index += 1) {
const r = Math.floor((data[index * 3] * 255) / 63);
const g = Math.floor((data[index * 3 + 1] * 255) / 63);
const b = Math.floor((data[index * 3 + 2] * 255) / 63);
palette.push([r, g, b]);
}
return palette;
}
export function loadTypeflags(filePath) {
const data = fs.readFileSync(filePath);
const infos = [];
for (let base = 0; base + 9 <= data.length; base += 9) {
const block = data.subarray(base, base + 9);
let flags = 0;
if (block[0] & 0x01) flags |= 0x0001;
if (block[0] & 0x02) flags |= 0x0002;
if (block[0] & 0x04) flags |= 0x0004;
if (block[0] & 0x08) flags |= 0x0008;
if (block[0] & 0x10) flags |= 0x0010;
if (block[0] & 0x20) flags |= 0x0020;
if (block[0] & 0x40) flags |= 0x0040;
if (block[0] & 0x80) flags |= 0x0080;
if (block[1] & 0x01) flags |= 0x0100;
if (block[1] & 0x02) flags |= 0x0200;
if (block[1] & 0x04) flags |= 0x0400;
if (block[1] & 0x08) flags |= 0x0800;
if (block[6] & 0x01) flags |= 0x1000;
if (block[6] & 0x02) flags |= 0x2000;
if (block[6] & 0x04) flags |= 0x4000;
if (block[6] & 0x08) flags |= 0x8000;
if (block[6] & 0x10) flags |= 0x10000;
if (block[6] & 0x20) flags |= 0x20000;
if (block[6] & 0x40) flags |= 0x40000;
if (block[6] & 0x80) flags |= 0x80000;
const family = (block[1] >> 4) + ((block[2] & 1) << 4);
const x = ((block[3] << 3) | (block[2] >> 5)) & 0x1f;
const y = (block[3] >> 2) & 0x1f;
const z = ((block[4] << 1) | (block[3] >> 7)) & 0x1f;
const animType = block[4] >> 4;
infos.push({
family,
flags,
x,
y,
z,
animType,
isEditor: Boolean(flags & 0x1000),
isFixed: Boolean(flags & SI_FIXED),
isSolid: Boolean(flags & SI_SOLID),
isLand: Boolean(flags & SI_LAND),
isOccl: Boolean(flags & SI_OCCL),
isNoisy: Boolean(flags & SI_NOISY),
isDraw: Boolean(flags & SI_DRAW),
isRoof: Boolean(flags & SI_ROOF),
isTranslucent: Boolean(flags & SI_TRANSL),
isInvitem: family === 13
});
}
return infos;
}
export function loadGlobs(filePath) {
const archive = new FlexArchive(filePath);
const globs = [];
for (let index = 0; index < archive.length; index += 1) {
const raw = archive.get(index);
if (!raw.length) {
globs.push([]);
continue;
}
const count = readU16LE(raw, 0);
const items = [];
for (let itemIndex = 0; itemIndex < count; itemIndex += 1) {
const base = 2 + itemIndex * 6;
items.push({
x: raw[base],
y: raw[base + 1],
z: raw[base + 2],
shape: readU16LE(raw, base + 3),
frame: raw[base + 5]
});
}
globs.push(items);
}
return globs;
}
export function loadMapItems(filePath, mapIndex) {
if (!fs.existsSync(filePath)) {
throw new Error(`Missing file: ${filePath}`);
}
const data = fs.readFileSync(filePath);
const mapCount = readU16LE(data, FIXED_MAP_COUNT_OFFSET);
if (mapIndex < 0 || mapIndex >= mapCount) {
throw new Error(`map index ${mapIndex} out of range 0..${mapCount - 1}`);
}
const tableOffset = FIXED_MAP_TABLE_OFFSET + mapIndex * 8;
const mapOffset = readU32LE(data, tableOffset);
const mapSize = readU32LE(data, tableOffset + 4);
const payload = data.subarray(mapOffset, mapOffset + mapSize);
if (payload.length !== mapSize) {
throw new Error(`map ${mapIndex} payload truncated`);
}
const items = [];
for (let base = 0; base + 16 <= payload.length; base += 16) {
const record = payload.subarray(base, base + 16);
items.push({
x: readU16LE(record, 0) * CRUSADER_COORD_SCALE,
y: readU16LE(record, 2) * CRUSADER_COORD_SCALE,
z: record[4],
shape: readU16LE(record, 5),
frame: record[7],
flags: readU16LE(record, 8),
quality: readU16LE(record, 10),
npcNum: record[12],
mapNum: record[13],
nextItem: readU16LE(record, 14),
source: "fixed"
});
}
return items;
}
export function expandGlobItem(item, globs) {
if (item.quality < 0 || item.quality >= globs.length) {
return [];
}
return globs[item.quality].map((globItem) => ({
x: (item.x & GLOB_COORD_MASK) + (globItem.x << GLOB_COORD_SHIFT) + GLOB_COORD_OFFSET,
y: (item.y & GLOB_COORD_MASK) + (globItem.y << GLOB_COORD_SHIFT) + GLOB_COORD_OFFSET,
z: item.z + globItem.z,
shape: globItem.shape,
frame: globItem.frame,
flags: 0,
quality: 0,
npcNum: 0,
mapNum: item.mapNum,
nextItem: 0,
source: "glob"
}));
}
export function collectRenderItems(baseItems, shapeInfos, globs, options) {
const {
includeEditor,
expandGlobs,
worldRect,
includeRoofs,
includeHiddenMarkers,
progress,
checkpointEvery = 0
} = options;
const renderItems = [];
const pending = [...baseItems];
let index = 0;
let skippedInvisible = 0;
let skippedWorldRect = 0;
let skippedInvalidShape = 0;
let skippedEditor = 0;
let skippedEgg = 0;
let skippedRoof = 0;
let skippedHidden = 0;
let expandedGlobs = 0;
while (index < pending.length) {
const item = pending[index];
index += 1;
if (item.flags & FLAG_INVISIBLE) {
if (!includeHiddenMarkers) {
skippedHidden += 1;
continue;
}
skippedInvisible += 1;
}
if (worldRect) {
const [minX, minY, maxX, maxY] = worldRect;
if (item.x < minX || item.y < minY || item.x > maxX || item.y > maxY) {
skippedWorldRect += 1;
continue;
}
}
if (item.shape >= shapeInfos.length) {
skippedInvalidShape += 1;
continue;
}
const info = shapeInfos[item.shape];
if (info.isEditor && !includeEditor) {
skippedEditor += 1;
continue;
}
if (info.isRoof && !includeRoofs) {
skippedRoof += 1;
continue;
}
if (expandGlobs && info.family === 3 && item.source === "fixed") {
pending.push(...expandGlobItem(item, globs));
expandedGlobs += 1;
if (!includeHiddenMarkers) {
continue;
}
}
if (EGG_FAMILIES.has(info.family) && !includeHiddenMarkers) {
skippedEgg += 1;
continue;
}
renderItems.push(item);
if (progress && checkpointEvery > 0 && index % checkpointEvery === 0) {
progress(
`collect processed=${index} pending=${pending.length} rendered=${renderItems.length} expanded_globs=${expandedGlobs} ` +
`skipped=(invisible:${skippedInvisible}, world:${skippedWorldRect}, shape:${skippedInvalidShape}, editor:${skippedEditor}, ` +
`roof:${skippedRoof}, hidden:${skippedHidden}, egg:${skippedEgg})`
);
}
}
if (progress) {
progress(
`collect complete processed=${index} pending=${pending.length} rendered=${renderItems.length} expanded_globs=${expandedGlobs} ` +
`skipped=(invisible:${skippedInvisible}, world:${skippedWorldRect}, shape:${skippedInvalidShape}, editor:${skippedEditor}, ` +
`roof:${skippedRoof}, hidden:${skippedHidden}, egg:${skippedEgg})`
);
}
return renderItems;
}
export function summarizeRenderClasses(baseItems, shapeInfos) {
const summary = {
roofItems: 0,
editorItems: 0,
eggFamilyItems: 0,
invisibleFlaggedItems: 0,
npcLinkedItems: 0
};
for (const item of baseItems) {
if (item.flags & FLAG_INVISIBLE) {
summary.invisibleFlaggedItems += 1;
}
if (item.npcNum !== 0) {
summary.npcLinkedItems += 1;
}
if (item.shape >= shapeInfos.length) {
continue;
}
const info = shapeInfos[item.shape];
if (info.isRoof) summary.roofItems += 1;
if (info.isEditor) summary.editorItems += 1;
if (EGG_FAMILIES.has(info.family)) summary.eggFamilyItems += 1;
}
return summary;
}
export function resolveStaticFile(staticDir, name) {
return path.join(staticDir, name);
}

View file

@ -0,0 +1,48 @@
import { PNG } from "pngjs";
export const DEFAULT_BACKGROUND = [10, 12, 18, 255];
export function rgbaBuffer(width, height, color = DEFAULT_BACKGROUND) {
const [r, g, b, a] = color;
const pixels = Buffer.alloc(width * height * 4);
for (let offset = 0; offset < pixels.length; offset += 4) {
pixels[offset] = r;
pixels[offset + 1] = g;
pixels[offset + 2] = b;
pixels[offset + 3] = a;
}
return pixels;
}
export function blitFrame(buffer, canvasWidth, canvasHeight, left, top, frame, pixels, palette, flipped) {
for (let srcY = 0; srcY < frame.height; srcY += 1) {
const dstY = top + srcY;
if (dstY < 0 || dstY >= canvasHeight) {
continue;
}
const rowBase = srcY * frame.width;
for (let srcX = 0; srcX < frame.width; srcX += 1) {
const pixelIndex = rowBase + (flipped ? frame.width - 1 - srcX : srcX);
const colorIndex = pixels[pixelIndex];
if (colorIndex < 0) {
continue;
}
const dstX = left + srcX;
if (dstX < 0 || dstX >= canvasWidth) {
continue;
}
const pixelBase = (dstY * canvasWidth + dstX) * 4;
const [r, g, b] = palette[colorIndex];
buffer[pixelBase] = r;
buffer[pixelBase + 1] = g;
buffer[pixelBase + 2] = b;
buffer[pixelBase + 3] = 255;
}
}
}
export function encodePng(width, height, data) {
const png = new PNG({ width, height });
data.copy(png.data);
return PNG.sync.write(png, { colorType: 6, inputColorType: 6 });
}

View file

@ -0,0 +1,394 @@
import { FLAG_FLIPPED } from "./formats.js";
function rectIntersects(left, right) {
return left.left < right.right && left.right > right.left && left.top < right.bottom && left.bottom > right.top;
}
function rectContains(outer, inner) {
return outer.left <= inner.left && outer.top <= inner.top && outer.right >= inner.right && outer.bottom >= inner.bottom;
}
function listLessThan(left, right) {
if (left.sprite !== right.sprite) {
return left.sprite < right.sprite;
}
if (left.z !== right.z) {
return left.z < right.z;
}
return left.flat > right.flat;
}
function overlap(left, right) {
if (!rectIntersects(left, right)) {
return false;
}
const pointTopDiff = [left.sx_top - right.sx_bot, left.sy_top - right.sy_bot];
const pointBotDiff = [left.sx_bot - right.sx_top, left.sy_bot - right.sy_top];
const dotTopLeft = pointTopDiff[0] + pointTopDiff[1] * 2;
const dotTopRight = -pointTopDiff[0] + pointTopDiff[1] * 2;
const dotBotLeft = pointBotDiff[0] - pointBotDiff[1] * 2;
const dotBotRight = -pointBotDiff[0] - pointBotDiff[1] * 2;
const rightClear = left.sx_right <= right.sx_left;
const leftClear = left.sx_left >= right.sx_right;
const topLeftClear = dotTopLeft >= 0;
const topRightClear = dotTopRight >= 0;
const botLeftClear = dotBotLeft >= 0;
const botRightClear = dotBotRight >= 0;
const clear = rightClear || leftClear || botRightClear || botLeftClear || topRightClear || topLeftClear;
return !clear;
}
function occludes(left, right) {
if (!rectContains(left, right)) {
return false;
}
const pointTopDiff = [left.sx_top - right.sx_top, left.sy_top - right.sy_top];
const pointBotDiff = [left.sx_bot - right.sx_bot, left.sy_bot - right.sy_bot];
const dotTopLeft = pointTopDiff[0] + pointTopDiff[1] * 2;
const dotTopRight = -pointTopDiff[0] + pointTopDiff[1] * 2;
const dotBotLeft = pointBotDiff[0] - pointBotDiff[1] * 2;
const dotBotRight = -pointBotDiff[0] - pointBotDiff[1] * 2;
const rightRes = left.sx_right >= right.sx_right;
const leftRes = left.sx_left <= right.sx_left;
const topLeftRes = dotTopLeft <= 0;
const topRightRes = dotTopRight <= 0;
const botLeftRes = dotBotLeft <= 0;
const botRightRes = dotBotRight <= 0;
return rightRes && leftRes && botRightRes && botLeftRes && topRightRes && topLeftRes;
}
function below(left, right) {
if (left.sprite !== right.sprite) {
return left.sprite < right.sprite;
}
if (left.flat && right.flat) {
if (left.z !== right.z) {
return left.z < right.z;
}
} else if (left.invitem === right.invitem) {
if (left.z_top <= right.z) {
return true;
}
if (left.z >= right.z_top) {
return false;
}
}
const yFlatLeft = left.y_far === left.y;
const yFlatRight = right.y_far === right.y;
if (yFlatLeft && yFlatRight) {
if (Math.floor(left.y / 32) !== Math.floor(right.y / 32)) {
return left.y < right.y;
}
} else {
if (left.y <= right.y_far) {
return true;
}
if (left.y_far >= right.y) {
return false;
}
}
const xFlatLeft = left.x_left === left.x;
const xFlatRight = right.x_left === right.x;
if (xFlatLeft && xFlatRight) {
if (Math.floor(left.x / 32) !== Math.floor(right.x / 32)) {
return left.x < right.x;
}
} else {
if (left.x <= right.x_left) {
return true;
}
if (left.x_left >= right.x) {
return false;
}
}
if (left.z_top - 8 <= right.z && left.z < right.z_top - 8) {
return true;
}
if (left.z >= right.z_top - 8 && left.z_top - 8 > right.z) {
return false;
}
if (yFlatLeft !== yFlatRight) {
if (Math.floor(left.y / 32) <= Math.floor(right.y_far / 32)) {
return true;
}
if (Math.floor(left.y_far / 32) >= Math.floor(right.y / 32)) {
return false;
}
const yCenterLeft = Math.floor((Math.floor(left.y_far / 32) + Math.floor(left.y / 32)) / 2);
const yCenterRight = Math.floor((Math.floor(right.y_far / 32) + Math.floor(right.y / 32)) / 2);
if (yCenterLeft !== yCenterRight) {
return yCenterLeft < yCenterRight;
}
}
if (xFlatLeft !== xFlatRight) {
if (Math.floor(left.x / 32) <= Math.floor(right.x_left / 32)) {
return true;
}
if (Math.floor(left.x_left / 32) >= Math.floor(right.x / 32)) {
return false;
}
const xCenterLeft = Math.floor((Math.floor(left.x_left / 32) + Math.floor(left.x / 32)) / 2);
const xCenterRight = Math.floor((Math.floor(right.x_left / 32) + Math.floor(right.x / 32)) / 2);
if (xCenterLeft !== xCenterRight) {
return xCenterLeft < xCenterRight;
}
}
if (left.flat || right.flat) {
if (left.z !== right.z) return left.z < right.z;
if (left.invitem !== right.invitem) return left.invitem < right.invitem;
if (left.flat !== right.flat) return left.flat > right.flat;
if (left.trans !== right.trans) return left.trans < right.trans;
if (left.anim !== right.anim) return left.anim < right.anim;
if (left.draw !== right.draw) return left.draw > right.draw;
if (left.solid !== right.solid) return left.solid > right.solid;
if (left.occl !== right.occl) return left.occl > right.occl;
if (left.fbigsq !== right.fbigsq) return left.fbigsq > right.fbigsq;
}
if (left.x === right.x && left.y === right.y && left.trans !== right.trans) {
return left.trans < right.trans;
}
if (left.land && right.land && left.roof !== right.roof) {
return left.roof < right.roof;
}
if (left.roof !== right.roof) {
return left.roof > right.roof;
}
if (left.z !== right.z) {
return left.z < right.z;
}
if (xFlatLeft || xFlatRight || yFlatLeft || yFlatRight) {
if (left.sx_left !== right.sx_left) {
return left.sx_left > right.sx_left;
}
if (left.sy_bot !== right.sy_bot) {
return left.sy_bot < right.sy_bot;
}
}
if (left.x + left.y !== right.x + right.y) return left.x + left.y < right.x + right.y;
if (left.x_left + left.y_far !== right.x_left + right.y_far) return left.x_left + left.y_far < right.x_left + right.y_far;
if (left.y !== right.y) return left.y < right.y;
if (left.x !== right.x) return left.x < right.x;
if (left.item.shape !== right.item.shape) return left.item.shape < right.item.shape;
return left.item.frame < right.item.frame;
}
function buildSortNode(item, info, frame, pixels) {
const flipped = Boolean(item.flags & FLAG_FLIPPED);
const xdim = (flipped ? info.y : info.x) * 32;
const ydim = (flipped ? info.x : info.y) * 32;
const zdim = info.z * 8;
const x = item.x;
const y = item.y;
const z = item.z;
const xLeft = x - xdim;
const yFar = y - ydim;
const zTop = z + zdim;
const sxLeft = Math.trunc(xLeft / 4 - y / 4);
const sxRight = Math.trunc(x / 4 - yFar / 4);
const sxTop = Math.trunc(xLeft / 4 - yFar / 4);
const syTop = Math.trunc(xLeft / 8 + yFar / 8 - zTop);
const sxBot = Math.trunc(x / 4 - y / 4);
const syBot = Math.trunc(x / 8 + y / 8 - z);
const left = flipped ? sxBot + frame.xoff - frame.width : sxBot - frame.xoff;
const top = syBot - frame.yoff;
const right = left + frame.width;
const bottom = top + frame.height;
return {
item,
info,
frame,
pixels,
left,
top,
right,
bottom,
x,
x_left: xLeft,
y,
y_far: yFar,
z,
z_top: zTop,
sx_left: sxLeft,
sx_right: sxRight,
sx_top: sxTop,
sy_top: syTop,
sx_bot: sxBot,
sy_bot: syBot,
fbigsq: xdim === ydim && xdim >= 128,
flat: zdim === 0,
occl: info.isOccl && !info.isTranslucent,
solid: info.isSolid,
draw: info.isDraw,
roof: info.isRoof,
noisy: info.isNoisy,
anim: info.animType !== 0,
trans: info.isTranslucent,
fixed: info.isFixed,
land: info.isLand,
sprite: false,
invitem: info.isInvitem,
occluded: false,
order: -1,
depends: []
};
}
function insertDependencySorted(depends, node) {
for (let index = 0; index < depends.length; index += 1) {
const current = depends[index];
if (current === node) {
return false;
}
if (listLessThan(node, current)) {
depends.splice(index, 0, node);
return true;
}
}
depends.push(node);
return true;
}
function resolvePaintOrder(ordered, progress, checkpointEvery = 0) {
const painted = [];
function visit(node) {
if (node.occluded || node.order >= 0) {
return;
}
node.order = -2;
for (const dependency of node.depends) {
if (dependency.order === -2) {
break;
}
if (dependency.order === -1) {
visit(dependency);
}
}
node.order = painted.length ? painted[painted.length - 1].order + 1 : 0;
painted.push(node);
if (progress && checkpointEvery > 0 && painted.length % checkpointEvery === 0) {
progress(`paint resolved=${painted.length} of ${ordered.length}`);
}
}
for (const node of ordered) {
if (node.order === -1) {
visit(node);
}
}
if (progress) {
progress(`paint complete resolved=${painted.length} of ${ordered.length}`);
}
return painted;
}
export function prepareSortedItems(items, archive, shapeInfos, options = {}) {
const { progress, checkpointEvery = 0, maxInvalidDetails = 20 } = options;
const ordered = [];
let minLeft = Number.MAX_SAFE_INTEGER;
let minTop = Number.MAX_SAFE_INTEGER;
let maxRight = -Number.MAX_SAFE_INTEGER;
let maxBottom = -Number.MAX_SAFE_INTEGER;
let occludedCount = 0;
let invalidItemCount = 0;
const invalidItems = [];
let dependencyCount = 0;
for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
const item = items[itemIndex];
let frame;
let pixels;
try {
const decoded = archive.decodeFrame(item.shape, item.frame);
frame = decoded.frame;
pixels = decoded.pixels;
} catch (error) {
invalidItemCount += 1;
if (invalidItems.length < maxInvalidDetails) {
invalidItems.push({
shape: item.shape,
frame: item.frame,
x: item.x,
y: item.y,
z: item.z,
source: item.source,
reason: error instanceof Error ? error.message : String(error)
});
}
continue;
}
const node = buildSortNode(item, shapeInfos[item.shape], frame, pixels);
minLeft = Math.min(minLeft, node.left);
minTop = Math.min(minTop, node.top);
maxRight = Math.max(maxRight, node.right);
maxBottom = Math.max(maxBottom, node.bottom);
let insertAt = ordered.length;
for (let index = 0; index < ordered.length; index += 1) {
const other = ordered[index];
if (insertAt === ordered.length && listLessThan(node, other)) {
insertAt = index;
}
if (other.occluded) {
continue;
}
if (!overlap(node, other)) {
continue;
}
if (below(node, other)) {
if (other.occl && occludes(other, node)) {
node.occluded = true;
occludedCount += 1;
break;
}
if (insertDependencySorted(other.depends, node)) {
dependencyCount += 1;
}
} else if (node.occl && occludes(node, other)) {
if (!other.occluded) {
other.occluded = true;
occludedCount += 1;
}
} else if (insertDependencySorted(node.depends, other)) {
dependencyCount += 1;
}
}
ordered.splice(insertAt, 0, node);
if (progress && checkpointEvery > 0 && (itemIndex + 1) % checkpointEvery === 0) {
progress(
`sort processed=${itemIndex + 1} valid=${ordered.length} occluded=${occludedCount} invalid=${invalidItemCount} dependencies=${dependencyCount}`
);
}
}
if (progress) {
progress(
`sort complete processed=${items.length} valid=${ordered.length} occluded=${occludedCount} invalid=${invalidItemCount} dependencies=${dependencyCount}`
);
}
return {
minLeft,
minTop,
maxRight,
maxBottom,
prepared: resolvePaintOrder(ordered, progress, checkpointEvery),
occludedCount,
invalidItemCount,
invalidItems
};
}

View file

@ -0,0 +1,278 @@
:root {
color-scheme: light dark;
--bg: #f1ead6;
--panel: rgba(255, 248, 232, 0.92);
--panel-border: rgba(94, 73, 37, 0.25);
--ink: #2d2212;
--muted: #6e5a37;
--accent: #0d6c7d;
--accent-strong: #114f59;
--viewport: #0e1218;
--tile-border: rgba(255, 255, 255, 0.04);
--shadow: 0 18px 45px rgba(59, 40, 8, 0.16);
--font-ui: "Segoe UI Variable Text", "Aptos", "Trebuchet MS", sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #12161d;
--panel: rgba(22, 28, 38, 0.92);
--panel-border: rgba(166, 187, 211, 0.16);
--ink: #edf2fa;
--muted: #aab8cc;
--accent: #46a7bc;
--accent-strong: #2a7b8d;
--viewport: #06080d;
--tile-border: rgba(255, 255, 255, 0.03);
--shadow: 0 18px 45px rgba(0, 0, 0, 0.35);
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: var(--font-ui);
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.14), transparent 32%),
linear-gradient(135deg, var(--bg) 0%, color-mix(in srgb, var(--bg) 75%, #6e8aa3 25%) 100%);
}
.shell {
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
min-height: 100vh;
}
.panel {
padding: 24px;
background: var(--panel);
backdrop-filter: blur(16px);
border-right: 1px solid var(--panel-border);
box-shadow: var(--shadow);
}
.panel h1 {
margin: 0;
font-size: 1.6rem;
line-height: 1.1;
}
.lede,
.muted {
color: var(--muted);
}
.stack {
display: grid;
gap: 10px;
margin-top: 20px;
}
label {
font-weight: 700;
font-size: 0.95rem;
}
select,
button,
.action-link {
width: 100%;
border-radius: 12px;
border: 1px solid rgba(65, 48, 21, 0.18);
padding: 12px 14px;
font: inherit;
}
button,
.action-link {
cursor: pointer;
color: white;
background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%);
text-decoration: none;
text-align: center;
}
button:disabled,
select:disabled,
.action-link.is-disabled {
cursor: not-allowed;
opacity: 0.55;
}
.button-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.toggle-grid,
.status,
.meta-panel {
padding: 12px 14px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.58);
border: 1px solid rgba(65, 48, 21, 0.08);
overflow-wrap: anywhere;
}
.toggle-grid {
display: grid;
gap: 10px;
}
.toggle {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 0.92rem;
}
.toggle input {
width: 18px;
height: 18px;
margin: 0;
}
.action-link {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.status {
min-height: 72px;
}
.meta-panel {
min-height: 240px;
}
.meta-empty {
margin: 0;
color: var(--muted);
}
.meta-section + .meta-section {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid color-mix(in srgb, var(--panel-border) 70%, transparent 30%);
}
.meta-title {
margin: 0 0 8px;
font-size: 0.84rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.meta-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px 12px;
font-size: 0.92rem;
}
.meta-grid dt {
color: var(--muted);
}
.meta-grid dd {
margin: 0;
text-align: right;
font-weight: 600;
}
.workspace {
min-width: 0;
padding: 18px;
}
.viewport {
position: relative;
width: 100%;
height: calc(100vh - 36px);
overflow: hidden;
border-radius: 24px;
background:
linear-gradient(0deg, rgba(255,255,255,0.03), rgba(255,255,255,0.03)),
linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px),
linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px),
var(--viewport);
background-size: auto, 32px 32px, 32px 32px, auto;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.05), var(--shadow);
touch-action: none;
cursor: grab;
user-select: none;
}
.scene {
position: absolute;
left: 0;
top: 0;
transform-origin: top left;
will-change: transform;
}
.layer {
position: absolute;
inset: 0 auto auto 0;
}
.tile {
position: absolute;
image-rendering: pixelated;
image-rendering: crisp-edges;
border: 1px solid var(--tile-border);
pointer-events: none;
}
.viewport.is-dragging {
cursor: grabbing;
}
.viewport-hint {
position: absolute;
top: 16px;
left: 16px;
z-index: 2;
padding: 8px 12px;
border-radius: 999px;
background: rgba(6, 8, 12, 0.66);
color: rgba(255, 255, 255, 0.82);
font-size: 0.86rem;
backdrop-filter: blur(10px);
}
.empty-state {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
color: rgba(255,255,255,0.72);
text-align: center;
}
@media (max-width: 900px) {
.shell {
grid-template-columns: 1fr;
}
.panel {
border-right: 0;
border-bottom: 1px solid var(--panel-border);
}
.viewport {
height: 70vh;
}
}

View file

@ -0,0 +1,559 @@
const mapForm = document.querySelector("#map-form");
const mapSelect = document.querySelector("#map-select");
const buildButton = document.querySelector("#build-button");
const includeEditorCheckbox = document.querySelector("#include-editor");
const includeRoofsCheckbox = document.querySelector("#include-roofs");
const downloadButton = document.querySelector("#download-button");
const statusBox = document.querySelector("#status");
const metaBox = document.querySelector("#meta");
const viewport = document.querySelector("#viewport");
const scene = document.querySelector("#scene");
const emptyState = document.querySelector("#empty-state");
const zoomLabel = document.querySelector("#zoom-label");
const zoomInButton = document.querySelector("#zoom-in");
const zoomOutButton = document.querySelector("#zoom-out");
const zoomResetButton = document.querySelector("#zoom-reset");
const zoomFitButton = document.querySelector("#zoom-fit");
let activeLayer = document.querySelector("#active-layer");
let autoBuildTimer = null;
const state = {
catalog: null,
current: null,
zoom: 1,
offsetX: 0,
offsetY: 0,
buildPollTimer: null,
buildToken: 0,
drag: null,
pointers: new Map(),
pinch: null
};
const ZOOM_FACTOR = 1.2;
const FIT_PADDING = 24;
async function fetchJson(url, init) {
const response = await fetch(url, init);
if (!response.ok) {
let message = `HTTP ${response.status}`;
try {
const body = await response.json();
if (body.error) {
message = body.error;
}
} catch {
// Ignore parse failures.
}
throw new Error(message);
}
return response.json();
}
function setStatus(message) {
statusBox.textContent = message;
}
function setMeta(metadata) {
if (!metadata) {
metaBox.innerHTML = '<p class="meta-empty">Select a map to see render metadata.</p>';
return;
}
metaBox.innerHTML = `
<section class="meta-section">
<h2 class="meta-title">Overview</h2>
<dl class="meta-grid">
<dt>Game</dt><dd>${metadata.gameLabel}</dd>
<dt>Map</dt><dd>${metadata.map}</dd>
<dt>Bounds</dt><dd>${metadata.bounds.width} x ${metadata.bounds.height}</dd>
<dt>Tiles</dt><dd>${metadata.tileCountX} x ${metadata.tileCountY}</dd>
</dl>
</section>
<section class="meta-section">
<h2 class="meta-title">Render</h2>
<dl class="meta-grid">
<dt>Raw items</dt><dd>${metadata.rawItemCount}</dd>
<dt>Render items</dt><dd>${metadata.itemCount}</dd>
<dt>Painted items</dt><dd>${metadata.paintedItemCount}</dd>
<dt>Occluded</dt><dd>${metadata.occludedItemCount}</dd>
<dt>Invalid</dt><dd>${metadata.invalidItemCount}</dd>
</dl>
</section>
<section class="meta-section">
<h2 class="meta-title">Filters</h2>
<dl class="meta-grid">
<dt>Editor-only</dt><dd>${metadata.filters.includeEditor ? "Shown" : "Hidden"}</dd>
<dt>Roofs</dt><dd>${metadata.filters.includeRoofs ? "Shown" : "Hidden"}</dd>
<dt>Empty map</dt><dd>${metadata.isEmpty ? "Yes" : "No"}</dd>
</dl>
${metadata.emptyReason ? `<p class="muted">${metadata.emptyReason}</p>` : ""}
</section>
`;
}
function enableZoomControls(enabled) {
zoomInButton.disabled = !enabled;
zoomOutButton.disabled = !enabled;
zoomResetButton.disabled = !enabled;
zoomFitButton.disabled = !enabled;
}
function setDownloadState(enabled, href = "#") {
downloadButton.href = href;
downloadButton.classList.toggle("is-disabled", !enabled);
downloadButton.setAttribute("aria-disabled", String(!enabled));
}
function updateZoomLabel() {
zoomLabel.textContent = `Zoom: ${Math.round(state.zoom * 100)}%`;
}
function updateSceneLayout() {
if (!state.current) {
scene.style.width = "0px";
scene.style.height = "0px";
scene.style.transform = "translate(0px, 0px) scale(1)";
return;
}
const { metadata } = state.current;
scene.style.width = `${metadata.bounds.width}px`;
scene.style.height = `${metadata.bounds.height}px`;
scene.style.transform = `translate(${state.offsetX}px, ${state.offsetY}px) scale(${state.zoom})`;
}
function clampZoom(nextZoom) {
if (!state.current) {
return 1;
}
const { min, max } = state.current.metadata.zoom;
return Math.min(max, Math.max(min, nextZoom));
}
function clampOffsets() {
if (!state.current) {
return;
}
const { width, height } = state.current.metadata.bounds;
const scaledWidth = width * state.zoom;
const scaledHeight = height * state.zoom;
if (scaledWidth <= viewport.clientWidth) {
state.offsetX = (viewport.clientWidth - scaledWidth) / 2;
} else {
state.offsetX = Math.min(0, Math.max(viewport.clientWidth - scaledWidth, state.offsetX));
}
if (scaledHeight <= viewport.clientHeight) {
state.offsetY = (viewport.clientHeight - scaledHeight) / 2;
} else {
state.offsetY = Math.min(0, Math.max(viewport.clientHeight - scaledHeight, state.offsetY));
}
}
function setZoom(nextZoom, anchor = null) {
if (!state.current) {
return;
}
const clamped = clampZoom(nextZoom);
if (clamped === state.zoom) {
updateZoomLabel();
return;
}
const focus = anchor ?? { x: viewport.clientWidth / 2, y: viewport.clientHeight / 2 };
const worldX = (focus.x - state.offsetX) / state.zoom;
const worldY = (focus.y - state.offsetY) / state.zoom;
state.zoom = clamped;
state.offsetX = focus.x - worldX * state.zoom;
state.offsetY = focus.y - worldY * state.zoom;
clampOffsets();
updateSceneLayout();
updateZoomLabel();
}
function fitMap() {
if (!state.current) {
return;
}
const { width, height } = state.current.metadata.bounds;
const scaleX = Math.max(0.01, (viewport.clientWidth - FIT_PADDING * 2) / width);
const scaleY = Math.max(0.01, (viewport.clientHeight - FIT_PADDING * 2) / height);
state.zoom = clampZoom(Math.min(scaleX, scaleY));
clampOffsets();
updateSceneLayout();
updateZoomLabel();
}
function tileUrl(buildContext, tileX, tileY) {
const { selected, jobId } = buildContext;
return `/api/maps/${selected.game}/${selected.mapId}/tiles/${tileX}/${tileY}.png?buildId=${encodeURIComponent(jobId)}`;
}
function createTileElement(tileX, tileY, buildContext, metadata) {
const tileSize = metadata.tileSize;
const tile = document.createElement("img");
tile.className = "tile";
tile.alt = `Tile ${tileX},${tileY}`;
tile.loading = "eager";
tile.decoding = "async";
tile.draggable = false;
tile.src = tileUrl(buildContext, tileX, tileY);
tile.style.left = `${tileX * tileSize}px`;
tile.style.top = `${tileY * tileSize}px`;
tile.style.width = `${Math.min(tileSize, metadata.bounds.width - tileX * tileSize)}px`;
tile.style.height = `${Math.min(tileSize, metadata.bounds.height - tileY * tileSize)}px`;
return tile;
}
function waitForImage(tile) {
if (tile.complete && tile.naturalWidth > 0) {
return tile.decode?.().catch(() => undefined) ?? Promise.resolve();
}
return new Promise((resolve, reject) => {
tile.addEventListener(
"load",
() => {
const decodePromise = tile.decode?.().catch(() => undefined);
Promise.resolve(decodePromise).then(resolve);
},
{ once: true }
);
tile.addEventListener("error", () => reject(new Error(`Failed to load ${tile.alt}`)), { once: true });
});
}
async function buildLayer(buildContext) {
const layer = document.createElement("div");
layer.className = "layer";
const { metadata } = buildContext;
layer.style.width = `${metadata.bounds.width}px`;
layer.style.height = `${metadata.bounds.height}px`;
const tilePromises = [];
for (let tileY = 0; tileY < metadata.tileCountY; tileY += 1) {
for (let tileX = 0; tileX < metadata.tileCountX; tileX += 1) {
const tile = createTileElement(tileX, tileY, buildContext, metadata);
layer.append(tile);
tilePromises.push(waitForImage(tile));
}
}
await Promise.all(tilePromises);
return layer;
}
function getSelectedMap() {
if (!mapSelect.value) {
return null;
}
return JSON.parse(mapSelect.value);
}
function currentSelectionMatches(selected) {
return Boolean(
state.current &&
state.current.selected.game === selected.game &&
state.current.selected.mapId === selected.mapId
);
}
function currentFiltersMatch() {
return Boolean(
state.current &&
state.current.metadata.filters.includeEditor === includeEditorCheckbox.checked &&
state.current.metadata.filters.includeRoofs === includeRoofsCheckbox.checked
);
}
function scheduleAutoBuild() {
clearTimeout(autoBuildTimer);
autoBuildTimer = window.setTimeout(() => {
const selected = getSelectedMap();
if (!selected) {
return;
}
if (currentSelectionMatches(selected) && currentFiltersMatch()) {
return;
}
startBuild(selected).catch((error) => {
setStatus(error instanceof Error ? error.message : String(error));
});
}, 100);
}
function populateCatalog(catalog) {
state.catalog = catalog;
mapSelect.innerHTML = "";
const placeholder = document.createElement("option");
placeholder.textContent = "Select a map";
placeholder.value = "";
mapSelect.append(placeholder);
for (const game of catalog.games) {
const group = document.createElement("optgroup");
group.label = `${game.label} (${game.mapCount} maps)`;
for (const map of game.maps) {
const option = document.createElement("option");
option.value = JSON.stringify({ game: game.id, mapId: map.id });
option.textContent = `${map.label} (${map.rawItemCount} items)`;
group.append(option);
}
mapSelect.append(group);
}
mapSelect.disabled = catalog.games.length === 0;
buildButton.disabled = catalog.games.length === 0;
setDownloadState(false);
if (catalog.games.length === 0) {
setStatus("No usable STATIC folders were detected under the app root.");
} else {
setStatus("Select a map to build it immediately.");
}
}
async function startBuild(selected) {
clearTimeout(state.buildPollTimer);
const token = ++state.buildToken;
const preserveView = currentSelectionMatches(selected);
if (!state.current) {
emptyState.hidden = false;
enableZoomControls(false);
setMeta(null);
setDownloadState(false);
}
setStatus(
preserveView
? `Rebuilding ${selected.game} map ${selected.mapId}. The current view stays visible until the new tiles are ready.`
: `Building ${selected.game} map ${selected.mapId}...`
);
const build = await fetchJson("/api/builds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...selected,
includeEditor: includeEditorCheckbox.checked,
includeRoofs: includeRoofsCheckbox.checked
})
});
await pollBuild(build.id, selected, token, preserveView);
}
async function pollBuild(jobId, selected, token, preserveView) {
if (token !== state.buildToken) {
return;
}
const build = await fetchJson(`/api/builds/${encodeURIComponent(jobId)}`);
if (token !== state.buildToken) {
return;
}
const latest = build.progress.at(-1);
setStatus(latest ? `${build.phase}: ${latest.message}` : `${build.phase}...`);
if (build.status === "failed") {
throw new Error(build.error || "Build failed");
}
if (build.status !== "ready") {
state.buildPollTimer = window.setTimeout(() => {
pollBuild(jobId, selected, token, preserveView).catch((error) => {
setStatus(error.message);
});
}, 1000);
return;
}
const metadata = await fetchJson(
`/api/maps/${selected.game}/${selected.mapId}/metadata?buildId=${encodeURIComponent(jobId)}`
);
if (token !== state.buildToken) {
return;
}
const nextContext = { selected, jobId, metadata };
const nextLayer = await buildLayer(nextContext);
if (token !== state.buildToken) {
return;
}
state.current = nextContext;
activeLayer.replaceWith(nextLayer);
activeLayer = nextLayer;
if (!preserveView) {
fitMap();
} else {
clampOffsets();
updateSceneLayout();
updateZoomLabel();
}
setMeta(metadata);
setDownloadState(
true,
`/api/maps/${selected.game}/${selected.mapId}/download.png?buildId=${encodeURIComponent(jobId)}`
);
setStatus(`Ready. ${selected.game} map ${selected.mapId} is fully loaded.`);
emptyState.hidden = true;
enableZoomControls(true);
}
async function loadCatalog() {
const catalog = await fetchJson("/api/maps");
populateCatalog(catalog);
}
mapForm.addEventListener("submit", async (event) => {
event.preventDefault();
const selected = getSelectedMap();
if (!selected) {
setStatus("Choose a map first.");
return;
}
try {
await startBuild(selected);
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error));
}
});
mapSelect.addEventListener("change", scheduleAutoBuild);
includeEditorCheckbox.addEventListener("change", scheduleAutoBuild);
includeRoofsCheckbox.addEventListener("change", scheduleAutoBuild);
downloadButton.addEventListener("click", (event) => {
if (downloadButton.classList.contains("is-disabled")) {
event.preventDefault();
}
});
window.addEventListener("resize", () => {
if (state.current) {
clampOffsets();
updateSceneLayout();
}
});
zoomInButton.addEventListener("click", () => {
setZoom(state.zoom * ZOOM_FACTOR);
});
zoomOutButton.addEventListener("click", () => {
setZoom(state.zoom / ZOOM_FACTOR);
});
zoomResetButton.addEventListener("click", () => {
setZoom(1);
});
zoomFitButton.addEventListener("click", () => {
fitMap();
});
viewport.addEventListener(
"wheel",
(event) => {
if (!state.current) {
return;
}
event.preventDefault();
const rect = viewport.getBoundingClientRect();
const nextZoom = event.deltaY < 0 ? state.zoom * ZOOM_FACTOR : state.zoom / ZOOM_FACTOR;
setZoom(nextZoom, { x: event.clientX - rect.left, y: event.clientY - rect.top });
},
{ passive: false }
);
viewport.addEventListener("pointerdown", (event) => {
if (!state.current) {
return;
}
event.preventDefault();
viewport.setPointerCapture(event.pointerId);
state.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (state.pointers.size === 1) {
state.drag = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: state.offsetX,
originY: state.offsetY
};
viewport.classList.add("is-dragging");
}
if (state.pointers.size === 2) {
const [first, second] = [...state.pointers.values()];
state.pinch = {
distance: Math.hypot(second.x - first.x, second.y - first.y),
zoom: state.zoom
};
state.drag = null;
}
});
viewport.addEventListener("pointermove", (event) => {
if (!state.current || !state.pointers.has(event.pointerId)) {
return;
}
state.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (state.pointers.size === 2 && state.pinch) {
const [first, second] = [...state.pointers.values()];
const distance = Math.hypot(second.x - first.x, second.y - first.y);
if (distance > 0) {
const rect = viewport.getBoundingClientRect();
const center = {
x: (first.x + second.x) / 2 - rect.left,
y: (first.y + second.y) / 2 - rect.top
};
setZoom(state.pinch.zoom * (distance / state.pinch.distance), center);
}
return;
}
if (!state.drag || state.drag.pointerId !== event.pointerId) {
return;
}
state.offsetX = state.drag.originX + (event.clientX - state.drag.startX);
state.offsetY = state.drag.originY + (event.clientY - state.drag.startY);
clampOffsets();
updateSceneLayout();
});
function releasePointer(event) {
state.pointers.delete(event.pointerId);
if (viewport.hasPointerCapture(event.pointerId)) {
viewport.releasePointerCapture(event.pointerId);
}
if (state.pointers.size < 2) {
state.pinch = null;
}
if (state.drag?.pointerId === event.pointerId) {
state.drag = null;
}
if (state.pointers.size === 0) {
viewport.classList.remove("is-dragging");
}
}
viewport.addEventListener("pointerup", releasePointer);
viewport.addEventListener("pointercancel", releasePointer);
viewport.addEventListener("lostpointercapture", releasePointer);
enableZoomControls(false);
updateZoomLabel();
setMeta(null);
setDownloadState(false);
loadCatalog().catch((error) => {
setStatus(error instanceof Error ? error.message : String(error));
});

View file

@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Crusader Map Renderer</title>
<link rel="stylesheet" href="/app.css">
</head>
<body>
<div class="shell">
<aside class="panel">
<h1>Crusader Map Renderer</h1>
<p class="lede">Server-rendered tiles only. Source assets stay on the server.</p>
<form id="map-form" class="stack">
<label for="map-select">Detected maps</label>
<select id="map-select" name="map" disabled>
<option>Loading map catalog...</option>
</select>
<div class="toggle-grid">
<label class="toggle"><input id="include-editor" type="checkbox" checked> Show editor-only elements</label>
<label class="toggle"><input id="include-roofs" type="checkbox"> Show roofs</label>
</div>
<button id="build-button" type="submit" disabled>Rebuild now</button>
</form>
<div class="stack controls">
<label>View</label>
<div class="button-row">
<button id="zoom-out" type="button" disabled>-</button>
<button id="zoom-reset" type="button" disabled>100%</button>
<button id="zoom-in" type="button" disabled>+</button>
<button id="zoom-fit" type="button" disabled>Fit</button>
</div>
<div id="zoom-label" class="muted">Zoom: --</div>
<a id="download-button" class="action-link is-disabled" aria-disabled="true" href="#">Download PNG</a>
</div>
<div class="stack">
<label>Status</label>
<div id="status" class="status">Idle.</div>
</div>
<div class="stack">
<label>Map Metadata</label>
<div id="meta" class="meta-panel">
<p class="meta-empty">Select a map to see render metadata.</p>
</div>
</div>
</aside>
<main class="workspace">
<div id="viewport" class="viewport">
<div id="viewport-hint" class="viewport-hint">Drag to pan. Scroll or pinch to zoom.</div>
<div id="scene" class="scene">
<div id="active-layer" class="layer"></div>
</div>
<div id="empty-state" class="empty-state">Choose a detected map to build and view it.</div>
</div>
</main>
</div>
<script type="module" src="/app.js"></script>
</body>
</html>

102
map_renderer/src/server.js Normal file
View file

@ -0,0 +1,102 @@
import express from "express";
import path from "node:path";
import { PORT, PUBLIC_ROOT } from "./config.js";
import { BuildManager } from "./lib/build-manager.js";
import { detectCatalog, getGameConfig } from "./lib/catalog.js";
const app = express();
const catalog = detectCatalog();
const builds = new BuildManager(catalog);
app.disable("x-powered-by");
app.use(express.json({ limit: "64kb" }));
app.use(express.static(PUBLIC_ROOT, { extensions: ["html"] }));
app.get("/api/maps", (_request, response) => {
response.json(builds.listCatalog());
});
app.post("/api/builds", async (request, response) => {
try {
const game = String(request.body?.game ?? "");
const mapId = Number.parseInt(String(request.body?.mapId ?? ""), 10);
const options = {
includeEditor: request.body?.includeEditor !== false,
includeRoofs: request.body?.includeRoofs === true
};
const gameConfig = getGameConfig(game);
if (!gameConfig) {
response.status(400).json({ error: "Unknown game id" });
return;
}
if (!Number.isInteger(mapId) || mapId < 0) {
response.status(400).json({ error: "Invalid map id" });
return;
}
const job = await builds.createOrReuseBuild(gameConfig, mapId, options);
response.status(202).json(builds.getPublicJob(job));
} catch (error) {
response.status(500).json({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get("/api/builds/:id", (request, response) => {
const job = builds.getJob(request.params.id);
if (!job) {
response.status(404).json({ error: "Unknown build id" });
return;
}
response.json(builds.getPublicJob(job));
});
app.get("/api/maps/:game/:mapId/metadata", (request, response) => {
try {
const buildId = String(request.query.buildId ?? "");
const mapId = Number.parseInt(request.params.mapId, 10);
const metadata = builds.getMetadata(buildId, request.params.game, mapId);
response.json(metadata);
} catch (error) {
response.status(400).json({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get("/api/maps/:game/:mapId/tiles/:tileX/:tileY.png", (request, response) => {
try {
const buildId = String(request.query.buildId ?? "");
const mapId = Number.parseInt(request.params.mapId, 10);
const tileX = Number.parseInt(request.params.tileX, 10);
const tileY = Number.parseInt(request.params.tileY, 10);
if (!Number.isInteger(tileX) || !Number.isInteger(tileY) || tileX < 0 || tileY < 0) {
response.status(400).json({ error: "Invalid tile coordinates" });
return;
}
const png = builds.renderTile(buildId, request.params.game, mapId, tileX, tileY);
response.setHeader("Content-Type", "image/png");
response.setHeader("Cache-Control", "public, max-age=31536000, immutable");
response.end(png);
} catch (error) {
response.status(400).json({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get("/api/maps/:game/:mapId/download.png", async (request, response) => {
try {
const buildId = String(request.query.buildId ?? "");
const mapId = Number.parseInt(request.params.mapId, 10);
const filePath = await builds.renderFullMap(buildId, request.params.game, mapId);
response.setHeader("Content-Type", "image/png");
response.setHeader("Content-Disposition", `attachment; filename="${path.basename(filePath)}"`);
response.sendFile(path.resolve(filePath), { dotfiles: "allow" });
} catch (error) {
response.status(400).json({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get("/api/health", (_request, response) => {
response.json({ ok: true, games: catalog.games.length });
});
app.listen(PORT, () => {
console.log(`Crusader map renderer listening on http://localhost:${PORT}`);
});

6
package-lock.json generated Normal file
View file

@ -0,0 +1,6 @@
{
"name": "Crusader",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}