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