Added node based web map renderer
This commit is contained in:
parent
82ae89865a
commit
24a4d90a3e
19 changed files with 3970 additions and 0 deletions
8
map_renderer/.dockerignore
Normal file
8
map_renderer/.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
node_modules/
|
||||||
|
.cache/
|
||||||
|
coverage/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
STATIC/
|
||||||
|
STATIC_REGRET/
|
||||||
12
map_renderer/.gitignore
vendored
Normal file
12
map_renderer/.gitignore
vendored
Normal 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
13
map_renderer/Dockerfile
Normal 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
67
map_renderer/README.md
Normal 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
13
map_renderer/compose.yaml
Normal 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
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
19
map_renderer/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
map_renderer/src/config.js
Normal file
24
map_renderer/src/config.js
Normal 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")
|
||||||
|
}
|
||||||
|
];
|
||||||
15
map_renderer/src/lib/binary.js
Normal file
15
map_renderer/src/lib/binary.js
Normal 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);
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
map_renderer/src/lib/catalog.js
Normal file
34
map_renderer/src/lib/catalog.js
Normal 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;
|
||||||
|
}
|
||||||
475
map_renderer/src/lib/formats.js
Normal file
475
map_renderer/src/lib/formats.js
Normal 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);
|
||||||
|
}
|
||||||
48
map_renderer/src/lib/png.js
Normal file
48
map_renderer/src/lib/png.js
Normal 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 });
|
||||||
|
}
|
||||||
394
map_renderer/src/lib/sorting.js
Normal file
394
map_renderer/src/lib/sorting.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
278
map_renderer/src/public/app.css
Normal file
278
map_renderer/src/public/app.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
559
map_renderer/src/public/app.js
Normal file
559
map_renderer/src/public/app.js
Normal 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));
|
||||||
|
});
|
||||||
65
map_renderer/src/public/index.html
Normal file
65
map_renderer/src/public/index.html
Normal 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
102
map_renderer/src/server.js
Normal 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
6
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "Crusader",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue