Moved map renderer to its own repo
This commit is contained in:
parent
90954fbf37
commit
d78808d6b5
23 changed files with 0 additions and 8447 deletions
|
|
@ -1,6 +0,0 @@
|
|||
node_modules/
|
||||
.cache/
|
||||
coverage/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
12
map_renderer/.gitignore
vendored
12
map_renderer/.gitignore
vendored
|
|
@ -1,12 +0,0 @@
|
|||
node_modules/
|
||||
.cache/
|
||||
coverage/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
STATIC/
|
||||
STATIC_REGRET/
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,38 +0,0 @@
|
|||
FROM node:20-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --omit=dev --no-audit --no-fund
|
||||
|
||||
COPY src ./src
|
||||
COPY Catalogs ./Catalogs
|
||||
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
|
||||
FROM base AS dev
|
||||
|
||||
CMD ["npm", "start"]
|
||||
|
||||
FROM base AS precache
|
||||
|
||||
COPY STATIC ./STATIC
|
||||
COPY STATIC_REGRET ./STATIC_REGRET
|
||||
RUN npm run build-cache
|
||||
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=precache /app/package.json ./package.json
|
||||
COPY --from=precache /app/package-lock.json ./package-lock.json
|
||||
COPY --from=precache /app/node_modules ./node_modules
|
||||
COPY --from=precache /app/src ./src
|
||||
COPY --from=precache /app/Catalogs ./Catalogs
|
||||
COPY --from=precache /app/.cache ./.cache
|
||||
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
# Crusader Map Renderer
|
||||
|
||||
Node web app that decodes Crusader maps into cached sprite atlases plus scene JSON, then renders the scene directly in the browser.
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep Crusader source assets server-side.
|
||||
- Detect maps from `STATIC` and `STATIC_REGRET` automatically.
|
||||
- Build map scene caches on demand after the user selects a map.
|
||||
- Serve cached atlas images and scene JSON so the browser reconstructs the view client-side.
|
||||
- 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 without rebuilding; the client filters one full cached scene payload
|
||||
- inspect mode lets you pin a shape tooltip, hide a single instance, and restore hidden instances from the left panel
|
||||
- PNG export is generated in the browser from the cached scene instead of being rasterized server-side
|
||||
- hidden instances can be exported as JSON and each catalog CSV can be downloaded from the viewer
|
||||
- the left panel includes a `Reload Current Map` button that forces a fresh rebuild/load of the currently selected map after catalog edits
|
||||
- catalog CSV rows support `roof` and `semitransparency` boolean overrides; the catalog is authoritative for those properties, so blank means `false` and only explicit `true` turns them on
|
||||
- cache builds automatically add any newly observed shapes into the matching game catalog CSV without overwriting existing rows, then rewrite the file sorted by `shape_code`
|
||||
- catalog CSV rows also support non-authoritative `categorization` and `qualities` columns; cache builds auto-fill them when blank from the existing derived categorization and observed per-shape quality values
|
||||
|
||||
## Cache Warming
|
||||
|
||||
Build atlas and scene cache artifacts outside the request path:
|
||||
|
||||
```powershell
|
||||
cd map_renderer
|
||||
npm run build-cache
|
||||
```
|
||||
|
||||
Optional focused warmup:
|
||||
|
||||
```powershell
|
||||
cd map_renderer
|
||||
npm run build-cache -- remorse 1
|
||||
```
|
||||
|
||||
The app expects asset folders under the app root:
|
||||
|
||||
- `map_renderer/STATIC`
|
||||
- `map_renderer/STATIC_REGRET`
|
||||
|
||||
## Docker Run
|
||||
|
||||
The `dev` image stays light and expects Crusader assets to be mounted at runtime.
|
||||
|
||||
```powershell
|
||||
cd map_renderer
|
||||
docker build --target dev -t crusader-map-renderer:dev .
|
||||
docker run --rm -p 3000:3000 `
|
||||
-v ${PWD}/STATIC:/app/STATIC:ro `
|
||||
-v ${PWD}/STATIC_REGRET:/app/STATIC_REGRET:ro `
|
||||
crusader-map-renderer:dev
|
||||
```
|
||||
|
||||
If only one game is available, mount only that folder.
|
||||
|
||||
Production image with prebuilt cache artifacts and no raw `STATIC` assets in the final layer:
|
||||
|
||||
```powershell
|
||||
cd map_renderer
|
||||
docker build --target production -t crusader-map-renderer:prod .
|
||||
docker run --rm -p 3000:3000 crusader-map-renderer:prod
|
||||
```
|
||||
|
||||
The production target copies `STATIC` and `STATIC_REGRET` only into the intermediate precache stage, runs `npm run build-cache`, then ships just `src`, `Catalogs`, `node_modules`, and `.cache` in the final image.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
The compose file targets the lightweight `dev` image and mounts `STATIC` and `STATIC_REGRET` from the host filesystem as read-only volumes.
|
||||
|
||||
```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 scene metadata.
|
||||
- `GET /api/maps/:game/:mapId/scene?buildId=...` returns the cached atlas-backed scene payload.
|
||||
- `GET /api/maps/:game/:mapId/atlases/:atlasId.png?buildId=...` returns a cached packed sprite atlas.
|
||||
- `GET /api/maps/:game/:mapId/inspect?buildId=...` returns the same per-instance shape metadata used for inspection.
|
||||
- `GET /api/catalogs/:game.csv` returns the source catalog CSV for that game.
|
||||
|
||||
No raw Crusader asset files are exposed over HTTP.
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
services:
|
||||
map-renderer:
|
||||
build:
|
||||
context: .
|
||||
target: dev
|
||||
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
1387
map_renderer/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"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",
|
||||
"build-cache": "node src/build-cache.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"sharp": "^0.34.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
# Map Renderer Phase Plan
|
||||
|
||||
## Phase 1
|
||||
|
||||
Goal: tighten the current viewer flow without changing the map semantics.
|
||||
|
||||
- remove the explicit manual build button because map selection and filter changes already trigger builds automatically
|
||||
- hide the viewport's "choose a detected map" message as soon as a map is selected and a build starts
|
||||
- add build feedback in the side panel with a spinner and a simple phase-based loading bar
|
||||
- remove visible tile seam lines and the synthetic background grid so the map reads as one surface
|
||||
- use a more efficient streamed visualization format for interactive tiles while keeping PNG as the final export format
|
||||
|
||||
Phase 1 implementation choice:
|
||||
|
||||
- interactive tiles switch from PNG to lossless WebP because it is broadly supported in current browsers and is more bandwidth-efficient for repeated server-rendered tile delivery
|
||||
- full-map download remains PNG so export quality and compatibility stay unchanged
|
||||
|
||||
## Phase 2
|
||||
|
||||
Goal: promote editor-only content from "baked into the raster" to interactive overlay objects.
|
||||
|
||||
- completed: keep the base map rendered as a flat server-generated tile surface
|
||||
- completed: extract editor-only objects into a standalone overlay data stream
|
||||
- completed: render editor-only overlay items in the client as positioned sprite overlays above the base map
|
||||
- completed: remove editor-only records from the base raster so overlay shapes are not duplicated in the map tiles
|
||||
- completed: fix overlay transparency so the sprite background stays transparent instead of fading black
|
||||
- completed: add an inspection mode checkbox that shows metadata for any rendered shape under the cursor, not just editor overlays
|
||||
- improve roof detection before or during the overlay split because the current roof filtering still lets some roofs render when they should not
|
||||
- identify occluding helper geometry such as invisible walls and render those semitransparently so they remain legible without hiding too much of the map beneath them
|
||||
- fix pipe rendering because pipes currently are not showing up correctly
|
||||
- investigate force-field rendering because they appear yellow instead of the expected blue semitransparent look; this may be a debug-shape choice issue or a palette/color-rendering issue
|
||||
- likely revisit ScummVM Crusader handling in `D:\source\scummvm` to confirm what editor/debug records carry and how best to decode them for display
|
||||
|
||||
Current phase 2 status:
|
||||
|
||||
- the server now builds three outputs from one map build: base raster tiles, editor-only overlay sprites, and a shared inspectable-shape metadata stream
|
||||
- editor-only shapes are interactive overlay sprites rendered from their original decoded frames rather than synthetic markers
|
||||
- when inspect mode is active, the cursor reports metadata for whichever rendered shape is currently under it, including base-map shapes
|
||||
|
||||
Next steps:
|
||||
|
||||
- use inspect mode on representative maps to identify which visible structures are true roofs versus normal geometry so roof filtering can be tightened with evidence
|
||||
- decide which helper/occluder families should stay semitransparent overlays and which should eventually be hidden or toggled separately
|
||||
- inspect broken pipe shapes and compare their metadata against ScummVM handling to determine why they currently render incorrectly
|
||||
- inspect force-field shapes and compare palette or translucency traits against expected in-game appearance
|
||||
|
||||
Open questions for phase 2:
|
||||
|
||||
- which helper/editor families should stay as overlay sprites versus gain their own visibility toggles
|
||||
- what exact metadata fields are reliable enough to expose in the tooltip long-term
|
||||
- whether some editor-only entries should be clustered, filtered, or toggled by family to keep dense maps usable
|
||||
|
||||
## Phase 3
|
||||
|
||||
Goal: replace server-side full-map raster composition with a cached atlas-plus-scene-data pipeline that the client renders directly.
|
||||
|
||||
- stop baking the playable map and editor-only items into one server-rendered visual surface
|
||||
- have the server decode every shape needed for a map build, including editor/debug/usecode shapes, and pack them into one or more cached atlas images
|
||||
- emit cached JSON scene data that tells the client which atlas sprite to draw, where to place it, what metadata it exposes, and how it should be identified in the UI
|
||||
- reuse the existing usecode shape catalog CSV files in `map_renderer/Catalogs` as part of the build pipeline so shape names and other catalog metadata flow into the exported scene data
|
||||
- keep the catalog CSV files inside the Docker image build context rather than mounting them separately in `compose.yaml`; they are local source assets and should be burned into the image
|
||||
- add an explicit npm cache-generation script that prebuilds atlas images and scene JSON for every map outside the web request path; this can be run manually or during Docker/container initialization
|
||||
- keep the live viewer on cached artifacts by default and regenerate only missing or stale atlas/map data on demand when a request needs them
|
||||
|
||||
Phase 3 implementation choice:
|
||||
|
||||
- the primary server artifact becomes cached render data per map build rather than cached raster tiles
|
||||
- cache artifacts are per-map for now; do not generate separate atlas/scene folders for roof/editor visibility modes because those filters will be applied entirely in the client from one full scene payload
|
||||
- each cached map build should include:
|
||||
- atlas image data containing all decoded shape frames required for that map, including editor-only items
|
||||
- scene JSON listing every shape instance with atlas coordinates, map placement, draw order or layer hints, ID, and enough metadata for the client to decide whether the instance is a roof, editor/debug object, helper geometry, or normal map geometry
|
||||
- build metadata sufficient to validate cache freshness against source assets, decoding rules, and the catalog CSV inputs
|
||||
- atlas packing and scene serialization should deduplicate repeated shapes so the client draws many instances from a small packed sprite set instead of receiving repeated rendered pixels
|
||||
- cache invalidation should key off map inputs plus a content/version fingerprint that includes the relevant catalog CSV data so name edits and decoding changes invalidate stale cached outputs cleanly
|
||||
|
||||
Phase 3 metadata proposal:
|
||||
|
||||
- keep per-instance records compact and focused on placement/runtime state: instance ID, sprite ID, shape-definition ID, draw order, source, world coordinates, flags, map/NPC linkage, and screen rect
|
||||
- move repeated descriptive data into shared per-shape definitions in the same scene JSON: shape code, display name, description, family, roof/editor/helper traits, and visibility tags
|
||||
- keep sprite packing data separate from shape definitions so multiple frames can share one shape definition while still pointing at distinct packed atlas entries
|
||||
- this reduces JSON duplication while keeping the client fully self-sufficient for filtering, inspection, and export operations
|
||||
|
||||
Phase 3 client/UI work:
|
||||
|
||||
- replace the current base-map tile surface plus overlay composition with one client-side scene renderer driven entirely by cached atlas plus scene JSON
|
||||
- preserve inspect mode, but change click behavior so when inspect mode is enabled a clicked shape pins its tooltip in place until the same shape is clicked again or a different shape is selected
|
||||
- ensure the pinned tooltip text remains selectable and copyable
|
||||
- add an eye icon to the tooltip that hides the currently selected shape instance from the scene without deleting its metadata
|
||||
- add a left-panel section that lists hidden shapes by name and ID and allows restoring each hidden shape to visibility
|
||||
- add a button that exports the current hidden-shape instance list as JSON
|
||||
- add one export button for each shape database CSV so the current catalog sources can be downloaded directly from the viewer workflow
|
||||
- make the left column scroll independently from the map viewer
|
||||
- make the left column horizontally resizable, with the renderer always filling the remaining viewport width and height
|
||||
|
||||
Phase 3 server/runtime work:
|
||||
|
||||
- separate cache warming from the web server process with a dedicated npm script such as `npm run build-cache` or similar
|
||||
- optionally call that script during Docker initialization so containers can start warm without forcing atlas generation into the request-serving process
|
||||
- on normal requests, serve cached atlas and scene artifacts when present; if an artifact is absent or invalid, regenerate just the required map data and then serve it
|
||||
- keep the runtime response machine-friendly so the client can reconstruct scene state without server-rendered presentation assumptions
|
||||
|
||||
-- add a production Docker build step that bakes the fully precached atlas images and scene JSON into the production image so the container can serve maps without the original `STATIC` source files present
|
||||
- ensure the Docker build step excludes raw `STATIC` input sources from the final image layers: only the compiled/packed atlas outputs and scene JSON should be included in the production image
|
||||
- keep the development image light and mount `STATIC` locally (or read from the workspace) so developers can iterate on source assets without rebuilding the image; the dev image should not precache by default
|
||||
|
||||
Open questions for phase 3:
|
||||
|
||||
- atlas artifacts stay strictly per-map for now
|
||||
- prefer compact per-instance records plus shared shape-definition metadata in the JSON payload
|
||||
- whether hidden-shape state should stay purely client-side for a session or also become part of URL/share state later
|
||||
- keep the scene renderer DOM/canvas based for now
|
||||
|
||||
## Phase 4
|
||||
|
||||
Goal: add guarded catalog-editing tools for shape naming once the atlas-plus-scene-data pipeline is stable.
|
||||
|
||||
- add a shape-name editor UI that can update the usecode shape catalog CSV files from inside the map viewer workflow
|
||||
- keep catalog editing disabled in the default server mode so externally exposed viewers remain read-only
|
||||
- expose catalog editing only through a special server mode with a separate npm target if practical, for example a dedicated dev/admin run mode rather than the default `start` or `dev` targets
|
||||
- make Phase 4 build directly on the Phase 3 scene data so edits operate on stable shape IDs and catalog-backed names instead of ad hoc tooltip text
|
||||
|
||||
Phase 4 implementation choice:
|
||||
|
||||
- prefer explicit opt-in server modes such as a dedicated admin/edit target over runtime query flags so editing capability cannot be enabled accidentally
|
||||
- any catalog write path should validate CSV targets, preserve formatting conventions, and trigger cache invalidation for affected maps so renamed shapes show up in freshly generated atlas data
|
||||
|
||||
Open questions for phase 4:
|
||||
|
||||
- whether catalog edits should write directly to the CSV files or stage edits through a review queue first
|
||||
- whether editing should be limited to names only or eventually extend to richer catalog metadata
|
||||
- how much authentication or local-only binding is needed beyond the separate npm target if the editor is ever exposed outside a purely local workflow
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
import { BuildManager } from "./lib/build-manager.js";
|
||||
import { detectCatalog, getGameConfig } from "./lib/catalog.js";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {
|
||||
game: null,
|
||||
mapId: null
|
||||
};
|
||||
|
||||
for (const arg of argv) {
|
||||
if (arg.startsWith("--game=")) {
|
||||
parsed.game = arg.slice("--game=".length);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--map=")) {
|
||||
parsed.mapId = Number.parseInt(arg.slice("--map=".length), 10);
|
||||
continue;
|
||||
}
|
||||
if (!parsed.game && Number.isNaN(Number(arg))) {
|
||||
parsed.game = arg;
|
||||
continue;
|
||||
}
|
||||
if (!Number.isNaN(Number(arg))) {
|
||||
parsed.mapId = Number.parseInt(arg, 10);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const catalog = detectCatalog();
|
||||
const builds = new BuildManager(catalog);
|
||||
const games = args.game ? catalog.games.filter((game) => game.id === args.game) : catalog.games;
|
||||
|
||||
if (!games.length) {
|
||||
throw new Error(args.game ? `No detected catalog entry for game ${args.game}` : "No detected maps to cache");
|
||||
}
|
||||
|
||||
for (const game of games) {
|
||||
const gameConfig = getGameConfig(game.id);
|
||||
if (!gameConfig) {
|
||||
throw new Error(`Missing game config for ${game.id}`);
|
||||
}
|
||||
const maps = Number.isInteger(args.mapId) ? game.maps.filter((map) => map.id === args.mapId) : game.maps;
|
||||
if (!maps.length) {
|
||||
throw new Error(`No detected map ${args.mapId} for game ${game.id}`);
|
||||
}
|
||||
|
||||
for (const map of maps) {
|
||||
const label = `${game.id} map ${map.id}`;
|
||||
console.log(`warming ${label}`);
|
||||
const job = await builds.createOrReuseBuild(gameConfig, map.id);
|
||||
await job.promise;
|
||||
if (job.status !== "ready") {
|
||||
throw new Error(`Cache build failed for ${label}: ${job.error ?? "unknown error"}`);
|
||||
}
|
||||
console.log(`ready ${label} fingerprint=${job.fingerprint} atlases=${job.metadata.sceneSummary.atlasCount}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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 ATLAS_MAX_SIZE = Number.parseInt(process.env.ATLAS_MAX_SIZE ?? "4096", 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 SCENE_CACHE_ROOT = path.join(CACHE_ROOT, "scene-cache");
|
||||
export const CATALOG_ROOT = path.join(APP_ROOT, "Catalogs");
|
||||
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")
|
||||
}
|
||||
];
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import { ATLAS_MAX_SIZE } from "../config.js";
|
||||
|
||||
function createAtlas(index, maxSize, padding) {
|
||||
return {
|
||||
id: `atlas-${index}`,
|
||||
maxSize,
|
||||
padding,
|
||||
width: 0,
|
||||
height: 0,
|
||||
cursorX: padding,
|
||||
cursorY: padding,
|
||||
shelfHeight: 0,
|
||||
sprites: []
|
||||
};
|
||||
}
|
||||
|
||||
function finalizeAtlas(atlas) {
|
||||
return {
|
||||
id: atlas.id,
|
||||
width: Math.max(1, atlas.width + atlas.padding),
|
||||
height: Math.max(1, atlas.height + atlas.padding),
|
||||
sprites: atlas.sprites
|
||||
};
|
||||
}
|
||||
|
||||
function tryPlaceSprite(atlas, sprite) {
|
||||
const paddedWidth = sprite.width + atlas.padding;
|
||||
const paddedHeight = sprite.height + atlas.padding;
|
||||
|
||||
if (paddedWidth + atlas.padding > atlas.maxSize || paddedHeight + atlas.padding > atlas.maxSize) {
|
||||
throw new Error(`Sprite ${sprite.id} exceeds atlas limit ${atlas.maxSize}`);
|
||||
}
|
||||
|
||||
if (atlas.cursorX + sprite.width > atlas.maxSize - atlas.padding) {
|
||||
atlas.cursorX = atlas.padding;
|
||||
atlas.cursorY += atlas.shelfHeight + atlas.padding;
|
||||
atlas.shelfHeight = 0;
|
||||
}
|
||||
|
||||
if (atlas.cursorY + sprite.height > atlas.maxSize - atlas.padding) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const placed = {
|
||||
id: sprite.id,
|
||||
x: atlas.cursorX,
|
||||
y: atlas.cursorY,
|
||||
width: sprite.width,
|
||||
height: sprite.height
|
||||
};
|
||||
|
||||
atlas.sprites.push(placed);
|
||||
atlas.width = Math.max(atlas.width, atlas.cursorX + sprite.width);
|
||||
atlas.height = Math.max(atlas.height, atlas.cursorY + sprite.height);
|
||||
atlas.cursorX += paddedWidth;
|
||||
atlas.shelfHeight = Math.max(atlas.shelfHeight, paddedHeight);
|
||||
return placed;
|
||||
}
|
||||
|
||||
export function packSprites(rawSprites, options = {}) {
|
||||
const maxAtlasSize = options.maxAtlasSize ?? ATLAS_MAX_SIZE;
|
||||
const padding = options.padding ?? 1;
|
||||
const sprites = [...rawSprites].sort((left, right) => {
|
||||
const leftMax = Math.max(left.width, left.height);
|
||||
const rightMax = Math.max(right.width, right.height);
|
||||
if (leftMax !== rightMax) {
|
||||
return rightMax - leftMax;
|
||||
}
|
||||
const leftArea = left.width * left.height;
|
||||
const rightArea = right.width * right.height;
|
||||
if (leftArea !== rightArea) {
|
||||
return rightArea - leftArea;
|
||||
}
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
|
||||
const atlases = [];
|
||||
const placements = new Map();
|
||||
let atlas = createAtlas(0, maxAtlasSize, padding);
|
||||
|
||||
for (const sprite of sprites) {
|
||||
let placed = tryPlaceSprite(atlas, sprite);
|
||||
if (!placed) {
|
||||
atlases.push(finalizeAtlas(atlas));
|
||||
atlas = createAtlas(atlases.length, maxAtlasSize, padding);
|
||||
placed = tryPlaceSprite(atlas, sprite);
|
||||
}
|
||||
placements.set(sprite.id, {
|
||||
atlasId: atlas.id,
|
||||
x: placed.x,
|
||||
y: placed.y,
|
||||
width: placed.width,
|
||||
height: placed.height
|
||||
});
|
||||
}
|
||||
|
||||
if (atlas.sprites.length || atlases.length === 0) {
|
||||
atlases.push(finalizeAtlas(atlas));
|
||||
}
|
||||
|
||||
return {
|
||||
atlases,
|
||||
placements
|
||||
};
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,830 +0,0 @@
|
|||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { SCENE_CACHE_ROOT, TILE_SIZE } from "../config.js";
|
||||
import { packSprites } from "./atlas-packer.js";
|
||||
import { ensureShapeCatalogCoverage, getShapeCatalog } from "./catalog.js";
|
||||
import {
|
||||
EGG_FAMILIES,
|
||||
FLAG_FLIPPED,
|
||||
FLAG_INVISIBLE,
|
||||
ShapeArchive,
|
||||
collectRenderItems,
|
||||
loadGlobs,
|
||||
loadMapItems,
|
||||
loadPalette,
|
||||
loadTypeflags,
|
||||
resolveStaticFile,
|
||||
summarizeRenderClasses
|
||||
} from "./formats.js";
|
||||
import { blitFrame, encodePng, rgbaBuffer } from "./png.js";
|
||||
import { prepareSortedItems } from "./sorting.js";
|
||||
|
||||
const SCENE_CACHE_VERSION = "v3-atlas-scene-catalog-sync";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function sha1(value) {
|
||||
return crypto.createHash("sha1").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function normalizeBuildOptions(options = {}) {
|
||||
return {
|
||||
includeEditor: options.includeEditor !== false,
|
||||
includeRoofs: options.includeRoofs === true
|
||||
};
|
||||
}
|
||||
|
||||
function toHex(value, width = 4) {
|
||||
return `0x${value.toString(16).padStart(width, "0")}`;
|
||||
}
|
||||
|
||||
function removeLegacyOptionCacheDirs(mapCacheRoot) {
|
||||
const legacyDirs = [
|
||||
"editor-off_roofs-off",
|
||||
"editor-off_roofs-on",
|
||||
"editor-on_roofs-off",
|
||||
"editor-on_roofs-on"
|
||||
];
|
||||
for (const dirName of legacyDirs) {
|
||||
fs.rmSync(path.join(mapCacheRoot, dirName), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function fileStamp(filePath) {
|
||||
const stat = fs.statSync(filePath);
|
||||
return `${path.basename(filePath)}:${stat.size}:${Math.trunc(stat.mtimeMs)}`;
|
||||
}
|
||||
|
||||
function classifySceneKind(item, info) {
|
||||
if ((item.flags & FLAG_INVISIBLE) || info.isOccl || info.isInvitem) {
|
||||
return "helper";
|
||||
}
|
||||
if (EGG_FAMILIES.has(info.family)) {
|
||||
return "egg";
|
||||
}
|
||||
if (info.isRoof) {
|
||||
return "roof";
|
||||
}
|
||||
if (info.isLand) {
|
||||
return "terrain";
|
||||
}
|
||||
if (info.isEditor) {
|
||||
return "editor";
|
||||
}
|
||||
return "base";
|
||||
}
|
||||
|
||||
function sceneLabel(kind) {
|
||||
switch (kind) {
|
||||
case "helper":
|
||||
return "Helper Geometry";
|
||||
case "egg":
|
||||
return "Egg Trigger";
|
||||
case "roof":
|
||||
return "Roof Shape";
|
||||
case "terrain":
|
||||
return "Terrain Shape";
|
||||
case "editor":
|
||||
return "Editor Object";
|
||||
default:
|
||||
return "Map Shape";
|
||||
}
|
||||
}
|
||||
|
||||
function sceneNotes(item, info) {
|
||||
const notes = [];
|
||||
if (item.flags & FLAG_INVISIBLE) {
|
||||
notes.push("invisible-flagged");
|
||||
}
|
||||
if (info.isOccl) {
|
||||
notes.push("occluding-geometry");
|
||||
}
|
||||
if (info.isInvitem) {
|
||||
notes.push("invitem-family");
|
||||
}
|
||||
if (EGG_FAMILIES.has(info.family)) {
|
||||
notes.push("egg-family");
|
||||
}
|
||||
if (info.isRoof) {
|
||||
notes.push("roof-flagged");
|
||||
}
|
||||
if (info.isTranslucent) {
|
||||
notes.push("translucent");
|
||||
}
|
||||
if (info.isEditor) {
|
||||
notes.push("editor-record");
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
function presentationOpacity(kind, info) {
|
||||
if (kind === "helper") {
|
||||
return 0.5;
|
||||
}
|
||||
if (info.isTranslucent) {
|
||||
return 0.7;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function applyCatalogOverrides(info, catalogEntry) {
|
||||
if (!catalogEntry) {
|
||||
return info;
|
||||
}
|
||||
return {
|
||||
...info,
|
||||
isRoof: catalogEntry.roof === true,
|
||||
isTranslucent: catalogEntry.semitransparency === true
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeSceneItems(items) {
|
||||
const kindCounts = {};
|
||||
const familyCounts = {};
|
||||
const sourceCounts = {};
|
||||
|
||||
for (const item of items) {
|
||||
kindCounts[item.kind] = (kindCounts[item.kind] ?? 0) + 1;
|
||||
familyCounts[item.family] = (familyCounts[item.family] ?? 0) + 1;
|
||||
sourceCounts[item.source] = (sourceCounts[item.source] ?? 0) + 1;
|
||||
}
|
||||
|
||||
const topFamilies = Object.entries(familyCounts)
|
||||
.sort((left, right) => right[1] - left[1] || Number(left[0]) - Number(right[0]))
|
||||
.slice(0, 8)
|
||||
.map(([family, count]) => ({ family: Number(family), count }));
|
||||
|
||||
return {
|
||||
itemCount: items.length,
|
||||
kindCounts,
|
||||
sourceCounts,
|
||||
topFamilies,
|
||||
helperCount: kindCounts.helper ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
function makeUsageInfo(gameId, mapId, baseItems, renderItems) {
|
||||
const itemMapNums = [...new Set(baseItems.map((item) => item.mapNum))].sort((left, right) => left - right);
|
||||
return {
|
||||
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 createEmptyScene(gameConfig, mapId, fingerprint, reason) {
|
||||
const metadata = {
|
||||
game: gameConfig.id,
|
||||
gameLabel: gameConfig.label,
|
||||
map: mapId,
|
||||
rawItemCount: 0,
|
||||
itemCount: 0,
|
||||
paintedItemCount: 0,
|
||||
occludedItemCount: 0,
|
||||
invalidItemCount: 0,
|
||||
invalidItems: [],
|
||||
sceneSummary: {
|
||||
atlasCount: 0,
|
||||
spriteCount: 0,
|
||||
helperCount: 0,
|
||||
kindCounts: {},
|
||||
sourceCounts: {},
|
||||
topFamilies: []
|
||||
},
|
||||
usage: makeUsageInfo(gameConfig.id, mapId, [], []),
|
||||
baseItemSummary: {
|
||||
roofItems: 0,
|
||||
editorItems: 0,
|
||||
eggFamilyItems: 0,
|
||||
invisibleFlaggedItems: 0,
|
||||
npcLinkedItems: 0
|
||||
},
|
||||
sorter: "scummvm_dependency_graph",
|
||||
isEmpty: true,
|
||||
emptyReason: reason,
|
||||
bounds: {
|
||||
screenLeft: 0,
|
||||
screenTop: 0,
|
||||
screenRight: TILE_SIZE,
|
||||
screenBottom: TILE_SIZE,
|
||||
width: TILE_SIZE,
|
||||
height: TILE_SIZE
|
||||
},
|
||||
zoom: {
|
||||
min: 0.01,
|
||||
max: 8,
|
||||
step: 0.1,
|
||||
initial: 1
|
||||
},
|
||||
buildFingerprint: fingerprint,
|
||||
generatedAt: nowIso()
|
||||
};
|
||||
|
||||
return {
|
||||
build: {
|
||||
version: SCENE_CACHE_VERSION,
|
||||
fingerprint,
|
||||
generatedAt: metadata.generatedAt,
|
||||
cacheMode: "single-scene"
|
||||
},
|
||||
metadata,
|
||||
atlases: [],
|
||||
sprites: [],
|
||||
shapeDefinitions: [],
|
||||
items: []
|
||||
};
|
||||
}
|
||||
|
||||
function buildShapeDefinition(info, shape, catalogEntry) {
|
||||
const effectiveInfo = applyCatalogOverrides(info, catalogEntry);
|
||||
const kind = classifySceneKind({ flags: 0 }, effectiveInfo);
|
||||
return {
|
||||
id: `shape:${shape}`,
|
||||
shape,
|
||||
shapeHex: toHex(shape),
|
||||
family: info.family,
|
||||
label: sceneLabel(kind),
|
||||
kind,
|
||||
displayName: catalogEntry?.humanReadableId || `shape_${shape.toString(16).padStart(4, "0")}`,
|
||||
description: catalogEntry?.description || "",
|
||||
visibilityTags: [
|
||||
...(effectiveInfo.isRoof ? ["roof"] : []),
|
||||
...(effectiveInfo.isEditor ? ["editor"] : []),
|
||||
...(effectiveInfo.isOccl || effectiveInfo.isInvitem ? ["helper"] : []),
|
||||
...(EGG_FAMILIES.has(effectiveInfo.family) ? ["egg"] : [])
|
||||
],
|
||||
traits: {
|
||||
editor: effectiveInfo.isEditor,
|
||||
roof: effectiveInfo.isRoof,
|
||||
occluding: effectiveInfo.isOccl,
|
||||
translucent: effectiveInfo.isTranslucent,
|
||||
solid: effectiveInfo.isSolid,
|
||||
fixed: effectiveInfo.isFixed,
|
||||
land: effectiveInfo.isLand,
|
||||
draw: effectiveInfo.isDraw,
|
||||
invitem: effectiveInfo.isInvitem,
|
||||
animType: effectiveInfo.animType
|
||||
},
|
||||
catalogOverrides: {
|
||||
roof: catalogEntry?.roof ?? null,
|
||||
semitransparency: catalogEntry?.semitransparency ?? null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function serializeSceneItem(node, minLeft, minTop, index, catalogEntry) {
|
||||
const { item, info, frame } = node;
|
||||
const effectiveInfo = applyCatalogOverrides(info, catalogEntry);
|
||||
const kind = classifySceneKind(item, effectiveInfo);
|
||||
|
||||
return {
|
||||
id: `item:${index}:${item.source}:${item.shape}:${item.frame}:${item.x}:${item.y}:${item.z}`,
|
||||
drawOrder: index,
|
||||
kind,
|
||||
label: sceneLabel(kind),
|
||||
source: item.source,
|
||||
world: {
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
z: item.z
|
||||
},
|
||||
mapNum: item.mapNum,
|
||||
npcNum: item.npcNum,
|
||||
nextItem: item.nextItem,
|
||||
quality: item.quality,
|
||||
frame: item.frame,
|
||||
screen: {
|
||||
left: node.left - minLeft,
|
||||
top: node.top - minTop,
|
||||
right: node.right - minLeft,
|
||||
bottom: node.bottom - minTop,
|
||||
width: node.right - node.left,
|
||||
height: node.bottom - node.top,
|
||||
anchorX: Math.trunc(node.left - minLeft + (node.right - node.left) / 2),
|
||||
anchorY: node.bottom - minTop
|
||||
},
|
||||
flags: {
|
||||
raw: item.flags,
|
||||
hex: toHex(item.flags),
|
||||
invisible: Boolean(item.flags & FLAG_INVISIBLE),
|
||||
flipped: Boolean(item.flags & FLAG_FLIPPED)
|
||||
},
|
||||
presentation: {
|
||||
opacity: presentationOpacity(kind, effectiveInfo),
|
||||
visibilityDefault: true
|
||||
},
|
||||
notes: sceneNotes(item, effectiveInfo),
|
||||
frameSize: {
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
xoff: frame.xoff,
|
||||
yoff: frame.yoff
|
||||
},
|
||||
shapeDefId: `shape:${item.shape}`,
|
||||
spriteId: `sprite:${item.shape}:${item.frame}`
|
||||
};
|
||||
}
|
||||
|
||||
function serializeSprite(sprite, placement) {
|
||||
return {
|
||||
id: sprite.id,
|
||||
atlasId: placement.atlasId,
|
||||
shape: sprite.shape,
|
||||
frame: sprite.frame,
|
||||
x: placement.x,
|
||||
y: placement.y,
|
||||
width: placement.width,
|
||||
height: placement.height
|
||||
};
|
||||
}
|
||||
|
||||
function collectObservedShapes(renderItems, shapeInfos) {
|
||||
const observed = new Map();
|
||||
for (const item of renderItems) {
|
||||
const info = shapeInfos[item.shape] ?? {};
|
||||
if (!observed.has(item.shape)) {
|
||||
const kind = classifySceneKind({ flags: 0 }, info);
|
||||
observed.set(item.shape, {
|
||||
shapeCode: item.shape,
|
||||
isEditor: Boolean(info.isEditor),
|
||||
categorization: kind,
|
||||
qualitySet: new Set()
|
||||
});
|
||||
}
|
||||
observed.get(item.shape).qualitySet.add(item.quality);
|
||||
}
|
||||
return [...observed.values()]
|
||||
.map((entry) => ({
|
||||
shapeCode: entry.shapeCode,
|
||||
isEditor: entry.isEditor,
|
||||
categorization: entry.categorization,
|
||||
qualities: [...entry.qualitySet].sort((left, right) => left - right).join(";")
|
||||
}))
|
||||
.sort((left, right) => left.shapeCode - right.shapeCode);
|
||||
}
|
||||
|
||||
export class BuildManager {
|
||||
constructor(catalog) {
|
||||
this.catalog = catalog;
|
||||
this.assetCache = new Map();
|
||||
this.jobs = new Map();
|
||||
this.jobsByKey = new Map();
|
||||
ensureDir(SCENE_CACHE_ROOT);
|
||||
}
|
||||
|
||||
listCatalog() {
|
||||
return this.catalog;
|
||||
}
|
||||
|
||||
getJob(jobId) {
|
||||
return this.jobs.get(jobId) ?? null;
|
||||
}
|
||||
|
||||
computeBuildFingerprint(gameConfig, mapId, options, catalogInfo) {
|
||||
const relevantFiles = [
|
||||
resolveStaticFile(gameConfig.staticDir, "FIXED.DAT"),
|
||||
resolveStaticFile(gameConfig.staticDir, "GAMEPAL.PAL"),
|
||||
resolveStaticFile(gameConfig.staticDir, "TYPEFLAG.DAT"),
|
||||
resolveStaticFile(gameConfig.staticDir, "GLOB.FLX"),
|
||||
resolveStaticFile(gameConfig.staticDir, "SHAPES.FLX")
|
||||
];
|
||||
|
||||
return sha1(
|
||||
JSON.stringify({
|
||||
version: SCENE_CACHE_VERSION,
|
||||
game: gameConfig.id,
|
||||
mapId,
|
||||
files: relevantFiles.map((filePath) => fileStamp(filePath)),
|
||||
catalogDigest: catalogInfo.digest
|
||||
})
|
||||
).slice(0, 16);
|
||||
}
|
||||
|
||||
ensureCatalogCoverage(gameConfig, mapId) {
|
||||
const assets = this.getAssets(gameConfig);
|
||||
const fixedDatPath = resolveStaticFile(gameConfig.staticDir, "FIXED.DAT");
|
||||
const baseItems = loadMapItems(fixedDatPath, mapId);
|
||||
const renderItems = collectRenderItems(baseItems, assets.shapeInfos, assets.globs, {
|
||||
includeEditor: true,
|
||||
expandGlobs: true,
|
||||
worldRect: null,
|
||||
includeRoofs: true,
|
||||
includeHiddenMarkers: true
|
||||
});
|
||||
return ensureShapeCatalogCoverage(gameConfig.id, collectObservedShapes(renderItems, assets.shapeInfos));
|
||||
}
|
||||
|
||||
async createOrReuseBuild(gameConfig, mapId, rawOptions = {}) {
|
||||
const options = normalizeBuildOptions(rawOptions);
|
||||
this.ensureCatalogCoverage(gameConfig, mapId);
|
||||
const catalogInfo = getShapeCatalog(gameConfig.id);
|
||||
const fingerprint = this.computeBuildFingerprint(gameConfig, mapId, options, catalogInfo);
|
||||
const key = `${gameConfig.id}:${mapId}:${fingerprint}`;
|
||||
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,
|
||||
fingerprint,
|
||||
game: gameConfig.id,
|
||||
mapId,
|
||||
options,
|
||||
status: "queued",
|
||||
phase: "queued",
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
progress: [],
|
||||
error: null,
|
||||
metadata: null,
|
||||
build: null,
|
||||
promise: null
|
||||
};
|
||||
this.jobs.set(job.id, job);
|
||||
this.jobsByKey.set(key, job);
|
||||
job.promise = this.runBuild(job, gameConfig, catalogInfo);
|
||||
return job;
|
||||
}
|
||||
|
||||
async runBuild(job, gameConfig, catalogInfo) {
|
||||
try {
|
||||
job.status = "building";
|
||||
job.phase = "loading-assets";
|
||||
this.touchJob(job, `Preparing ${gameConfig.label} assets`);
|
||||
|
||||
const scene = await this.ensureSceneArtifacts(gameConfig, job.mapId, job.options, job.fingerprint, catalogInfo, {
|
||||
progress: (phase, message) => {
|
||||
job.phase = phase;
|
||||
this.touchJob(job, message);
|
||||
}
|
||||
});
|
||||
|
||||
job.build = scene;
|
||||
job.metadata = scene.metadata;
|
||||
job.status = "ready";
|
||||
job.phase = "ready";
|
||||
this.touchJob(
|
||||
job,
|
||||
`Scene ready with ${scene.metadata.sceneSummary.spriteCount} sprites across ${scene.metadata.sceneSummary.atlasCount} atlases`
|
||||
);
|
||||
} 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) {
|
||||
const palettePath = resolveStaticFile(gameConfig.staticDir, "GAMEPAL.PAL");
|
||||
const typeflagPath = resolveStaticFile(gameConfig.staticDir, "TYPEFLAG.DAT");
|
||||
const globPath = resolveStaticFile(gameConfig.staticDir, "GLOB.FLX");
|
||||
const shapesPath = resolveStaticFile(gameConfig.staticDir, "SHAPES.FLX");
|
||||
const stamp = [palettePath, typeflagPath, globPath, shapesPath].map((filePath) => fileStamp(filePath)).join("|");
|
||||
const cached = this.assetCache.get(gameConfig.id);
|
||||
if (cached?.stamp === stamp) {
|
||||
return cached.assets;
|
||||
}
|
||||
|
||||
const assets = {
|
||||
palette: loadPalette(palettePath),
|
||||
shapeInfos: loadTypeflags(typeflagPath),
|
||||
globs: loadGlobs(globPath),
|
||||
shapeArchive: new ShapeArchive(shapesPath)
|
||||
};
|
||||
this.assetCache.set(gameConfig.id, { stamp, assets });
|
||||
return assets;
|
||||
}
|
||||
|
||||
async ensureSceneArtifacts(gameConfig, mapId, options, fingerprint, catalogInfo, hooks = {}) {
|
||||
const mapCacheRoot = path.join(SCENE_CACHE_ROOT, gameConfig.id, `map-${mapId}`);
|
||||
removeLegacyOptionCacheDirs(mapCacheRoot);
|
||||
const cacheDir = path.join(mapCacheRoot, fingerprint);
|
||||
const sceneFilePath = path.join(cacheDir, "scene.json");
|
||||
|
||||
hooks.progress?.("cache-check", `Checking cached scene artifacts for ${gameConfig.id} map ${mapId}`);
|
||||
if (fs.existsSync(sceneFilePath)) {
|
||||
const cachedScene = JSON.parse(fs.readFileSync(sceneFilePath, "utf8"));
|
||||
const allAtlasesPresent = cachedScene.atlases.every((atlas) => fs.existsSync(path.join(cacheDir, atlas.fileName)));
|
||||
if (allAtlasesPresent) {
|
||||
hooks.progress?.("cache-hit", `Using cached atlas and scene data for ${gameConfig.id} map ${mapId}`);
|
||||
return {
|
||||
...cachedScene,
|
||||
cacheDir,
|
||||
sceneFilePath,
|
||||
atlasFiles: cachedScene.atlases.map((atlas) => ({
|
||||
...atlas,
|
||||
filePath: path.join(cacheDir, atlas.fileName)
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const assets = this.getAssets(gameConfig);
|
||||
const fixedDatPath = resolveStaticFile(gameConfig.staticDir, "FIXED.DAT");
|
||||
hooks.progress?.("loading-map", `Loading FIXED.DAT map ${mapId}`);
|
||||
const baseItems = loadMapItems(fixedDatPath, mapId);
|
||||
|
||||
hooks.progress?.("collecting-items", `Collecting renderable items for map ${mapId}`);
|
||||
const renderItems = collectRenderItems(baseItems, assets.shapeInfos, assets.globs, {
|
||||
includeEditor: true,
|
||||
expandGlobs: true,
|
||||
worldRect: null,
|
||||
includeRoofs: true,
|
||||
includeHiddenMarkers: true,
|
||||
checkpointEvery: 2000,
|
||||
progress: (message) => hooks.progress?.("collecting-items", message)
|
||||
});
|
||||
|
||||
if (!renderItems.length) {
|
||||
ensureDir(cacheDir);
|
||||
const emptyScene = createEmptyScene(
|
||||
gameConfig,
|
||||
mapId,
|
||||
fingerprint,
|
||||
"This map has no renderable items in FIXED.DAT."
|
||||
);
|
||||
emptyScene.metadata.rawItemCount = baseItems.length;
|
||||
emptyScene.metadata.usage = makeUsageInfo(gameConfig.id, mapId, baseItems, []);
|
||||
emptyScene.metadata.baseItemSummary = summarizeRenderClasses(baseItems, assets.shapeInfos);
|
||||
fs.writeFileSync(sceneFilePath, JSON.stringify(emptyScene, null, 2));
|
||||
return {
|
||||
...emptyScene,
|
||||
cacheDir,
|
||||
sceneFilePath,
|
||||
atlasFiles: []
|
||||
};
|
||||
}
|
||||
|
||||
hooks.progress?.("sorting", `Sorting ${renderItems.length} decoded items`);
|
||||
const sorted = prepareSortedItems(renderItems, assets.shapeArchive, assets.shapeInfos, {
|
||||
checkpointEvery: 2000,
|
||||
maxInvalidDetails: 20,
|
||||
progress: (message) => hooks.progress?.("sorting", message)
|
||||
});
|
||||
|
||||
if (!sorted.prepared.length) {
|
||||
ensureDir(cacheDir);
|
||||
const emptyScene = createEmptyScene(
|
||||
gameConfig,
|
||||
mapId,
|
||||
fingerprint,
|
||||
"This map resolved to no valid shape or frame pairs after decoding."
|
||||
);
|
||||
emptyScene.metadata.rawItemCount = baseItems.length;
|
||||
emptyScene.metadata.usage = makeUsageInfo(gameConfig.id, mapId, baseItems, renderItems);
|
||||
emptyScene.metadata.baseItemSummary = summarizeRenderClasses(baseItems, assets.shapeInfos);
|
||||
emptyScene.metadata.invalidItemCount = sorted.invalidItemCount;
|
||||
emptyScene.metadata.invalidItems = sorted.invalidItems;
|
||||
fs.writeFileSync(sceneFilePath, JSON.stringify(emptyScene, null, 2));
|
||||
return {
|
||||
...emptyScene,
|
||||
cacheDir,
|
||||
sceneFilePath,
|
||||
atlasFiles: []
|
||||
};
|
||||
}
|
||||
|
||||
const spriteMap = new Map();
|
||||
for (const node of sorted.prepared) {
|
||||
const spriteId = `sprite:${node.item.shape}:${node.item.frame}`;
|
||||
if (!spriteMap.has(spriteId)) {
|
||||
spriteMap.set(spriteId, {
|
||||
id: spriteId,
|
||||
shape: node.item.shape,
|
||||
frame: node.item.frame,
|
||||
width: node.frame.width,
|
||||
height: node.frame.height,
|
||||
frameData: node.frame,
|
||||
pixels: node.pixels
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hooks.progress?.("packing-atlases", `Packing ${spriteMap.size} unique sprites into atlases`);
|
||||
const packed = packSprites(
|
||||
[...spriteMap.values()].map((sprite) => ({
|
||||
id: sprite.id,
|
||||
width: sprite.width,
|
||||
height: sprite.height
|
||||
}))
|
||||
);
|
||||
|
||||
ensureDir(cacheDir);
|
||||
const atlasFiles = [];
|
||||
for (const atlas of packed.atlases) {
|
||||
hooks.progress?.("writing-atlases", `Encoding ${atlas.id} (${atlas.width}x${atlas.height})`);
|
||||
const buffer = rgbaBuffer(atlas.width, atlas.height, [0, 0, 0, 0]);
|
||||
for (const placed of atlas.sprites) {
|
||||
const sprite = spriteMap.get(placed.id);
|
||||
blitFrame(buffer, atlas.width, atlas.height, placed.x, placed.y, sprite.frameData, sprite.pixels, assets.palette, false);
|
||||
}
|
||||
const fileName = `${atlas.id}.png`;
|
||||
const filePath = path.join(cacheDir, fileName);
|
||||
fs.writeFileSync(filePath, encodePng(atlas.width, atlas.height, buffer));
|
||||
atlasFiles.push({
|
||||
id: atlas.id,
|
||||
fileName,
|
||||
filePath,
|
||||
width: atlas.width,
|
||||
height: atlas.height
|
||||
});
|
||||
}
|
||||
|
||||
const shapeDefinitionMap = new Map();
|
||||
for (const node of sorted.prepared) {
|
||||
const shapeDefId = `shape:${node.item.shape}`;
|
||||
if (!shapeDefinitionMap.has(shapeDefId)) {
|
||||
const catalogEntry = catalogInfo.entries.get(node.item.shape) ?? null;
|
||||
shapeDefinitionMap.set(shapeDefId, buildShapeDefinition(node.info, node.item.shape, catalogEntry));
|
||||
}
|
||||
}
|
||||
|
||||
const items = sorted.prepared.map((node, index) =>
|
||||
serializeSceneItem(node, sorted.minLeft, sorted.minTop, index, catalogInfo.entries.get(node.item.shape) ?? null)
|
||||
);
|
||||
const sprites = [...spriteMap.values()].map((sprite) => serializeSprite(sprite, packed.placements.get(sprite.id)));
|
||||
const shapeDefinitions = [...shapeDefinitionMap.values()].sort((left, right) => left.shape - right.shape);
|
||||
const sceneSummary = summarizeSceneItems(items);
|
||||
|
||||
const scene = {
|
||||
build: {
|
||||
version: SCENE_CACHE_VERSION,
|
||||
fingerprint,
|
||||
generatedAt: nowIso(),
|
||||
cacheMode: "single-scene"
|
||||
},
|
||||
metadata: {
|
||||
game: gameConfig.id,
|
||||
gameLabel: gameConfig.label,
|
||||
map: mapId,
|
||||
rawItemCount: baseItems.length,
|
||||
itemCount: renderItems.length,
|
||||
paintedItemCount: items.length,
|
||||
occludedItemCount: sorted.occludedCount,
|
||||
invalidItemCount: sorted.invalidItemCount,
|
||||
invalidItems: sorted.invalidItems,
|
||||
sceneSummary: {
|
||||
atlasCount: atlasFiles.length,
|
||||
spriteCount: sprites.length,
|
||||
helperCount: sceneSummary.helperCount,
|
||||
kindCounts: sceneSummary.kindCounts,
|
||||
sourceCounts: sceneSummary.sourceCounts,
|
||||
topFamilies: sceneSummary.topFamilies
|
||||
},
|
||||
usage: makeUsageInfo(gameConfig.id, mapId, baseItems, renderItems),
|
||||
baseItemSummary: summarizeRenderClasses(baseItems, assets.shapeInfos),
|
||||
sorter: "scummvm_dependency_graph",
|
||||
isEmpty: false,
|
||||
emptyReason: null,
|
||||
bounds: {
|
||||
screenLeft: sorted.minLeft,
|
||||
screenTop: sorted.minTop,
|
||||
screenRight: sorted.maxRight,
|
||||
screenBottom: sorted.maxBottom,
|
||||
width: sorted.maxRight - sorted.minLeft,
|
||||
height: sorted.maxBottom - sorted.minTop
|
||||
},
|
||||
zoom: {
|
||||
min: 0.01,
|
||||
max: 8,
|
||||
step: 0.1,
|
||||
initial: 1
|
||||
},
|
||||
buildFingerprint: fingerprint,
|
||||
generatedAt: nowIso()
|
||||
},
|
||||
atlases: atlasFiles.map((atlas) => ({
|
||||
id: atlas.id,
|
||||
fileName: atlas.fileName,
|
||||
width: atlas.width,
|
||||
height: atlas.height
|
||||
})),
|
||||
sprites,
|
||||
shapeDefinitions,
|
||||
items
|
||||
};
|
||||
|
||||
fs.writeFileSync(sceneFilePath, JSON.stringify(scene, null, 2));
|
||||
return {
|
||||
...scene,
|
||||
cacheDir,
|
||||
sceneFilePath,
|
||||
atlasFiles
|
||||
};
|
||||
}
|
||||
|
||||
touchJob(job, message) {
|
||||
job.updatedAt = nowIso();
|
||||
job.progress.push({
|
||||
at: job.updatedAt,
|
||||
phase: job.phase,
|
||||
message
|
||||
});
|
||||
if (job.progress.length > 120) {
|
||||
job.progress.splice(0, job.progress.length - 120);
|
||||
}
|
||||
}
|
||||
|
||||
getPublicJob(job) {
|
||||
return {
|
||||
id: job.id,
|
||||
game: job.game,
|
||||
mapId: job.mapId,
|
||||
options: {
|
||||
includeEditor: true,
|
||||
includeRoofs: true,
|
||||
cacheMode: "single-scene"
|
||||
},
|
||||
fingerprint: job.fingerprint,
|
||||
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) {
|
||||
return this.requireReadyJob(jobId, gameId, mapId).metadata;
|
||||
}
|
||||
|
||||
getSceneData(jobId, gameId, mapId) {
|
||||
const job = this.requireReadyJob(jobId, gameId, mapId);
|
||||
return {
|
||||
build: job.build.build,
|
||||
metadata: job.build.metadata,
|
||||
atlases: job.build.atlases,
|
||||
sprites: job.build.sprites,
|
||||
shapeDefinitions: job.build.shapeDefinitions,
|
||||
items: job.build.items
|
||||
};
|
||||
}
|
||||
|
||||
getInspectData(jobId, gameId, mapId) {
|
||||
const job = this.requireReadyJob(jobId, gameId, mapId);
|
||||
return {
|
||||
shapeDefinitions: job.build.shapeDefinitions,
|
||||
items: job.build.items
|
||||
};
|
||||
}
|
||||
|
||||
getOverlayData(jobId, gameId, mapId) {
|
||||
const job = this.requireReadyJob(jobId, gameId, mapId);
|
||||
const shapeDefinitions = new Map(job.build.shapeDefinitions.map((definition) => [definition.id, definition]));
|
||||
const overlayItems = job.build.items.filter((item) => {
|
||||
const definition = shapeDefinitions.get(item.shapeDefId);
|
||||
return definition?.traits.editor || definition?.kind === "helper" || definition?.kind === "egg";
|
||||
});
|
||||
return {
|
||||
shapeDefinitions: job.build.shapeDefinitions,
|
||||
items: overlayItems,
|
||||
summary: summarizeSceneItems(overlayItems)
|
||||
};
|
||||
}
|
||||
|
||||
getAtlas(jobId, gameId, mapId, atlasId) {
|
||||
const job = this.requireReadyJob(jobId, gameId, mapId);
|
||||
const atlas = job.build.atlasFiles.find((entry) => entry.id === atlasId);
|
||||
if (!atlas || !fs.existsSync(atlas.filePath)) {
|
||||
throw new Error("Unknown atlas id");
|
||||
}
|
||||
return fs.readFileSync(atlas.filePath);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { CATALOG_ROOT, GAMES } from "../config.js";
|
||||
import { getMapSummaries, resolveStaticFile } from "./formats.js";
|
||||
|
||||
const CATALOG_FILE_BY_GAME = {
|
||||
remorse: "usecode_shape_catalog_remorse.csv",
|
||||
regret: "usecode_shape_catalog_regret.csv"
|
||||
};
|
||||
|
||||
const shapeCatalogCache = new Map();
|
||||
const CATALOG_HEADERS = ["shape_code", "human_readable_id", "description", "roof", "semitransparency", "categorization", "qualities"];
|
||||
|
||||
function sha1(value) {
|
||||
return crypto.createHash("sha1").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function parseCsvLine(line) {
|
||||
const values = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
if (char === '"') {
|
||||
if (inQuotes && line[index + 1] === '"') {
|
||||
current += '"';
|
||||
index += 1;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === "," && !inQuotes) {
|
||||
values.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
values.push(current);
|
||||
return values;
|
||||
}
|
||||
|
||||
function parseOptionalBoolean(value) {
|
||||
const normalized = String(value ?? "").trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (["true", "1", "yes", "y"].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (["false", "0", "no", "n"].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRowValue(row, ...keys) {
|
||||
for (const key of keys) {
|
||||
if (Object.hasOwn(row, key)) {
|
||||
return row[key];
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeCatalogEntry(row) {
|
||||
const shapeCode = Number.parseInt(String(getRowValue(row, "shape_code", "shapeCode", "ShapeCode")).trim(), 16);
|
||||
if (!Number.isInteger(shapeCode)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
shapeCode,
|
||||
shapeCodeHex: `0x${shapeCode.toString(16).padStart(4, "0")}`,
|
||||
humanReadableId: String(getRowValue(row, "human_readable_id", "humanReadableId", "HumanReadableId")).trim(),
|
||||
description: String(getRowValue(row, "description", "Description")).trim(),
|
||||
roof: parseOptionalBoolean(getRowValue(row, "roof", "Roof")),
|
||||
semitransparency: parseOptionalBoolean(getRowValue(row, "semitransparency", "semi_transparency", "Semitransparency", "SemiTransparency")),
|
||||
categorization: String(getRowValue(row, "categorization", "category", "Categorization", "Category")).trim(),
|
||||
qualities: String(getRowValue(row, "qualities", "quality_values", "Qualities", "QualityValues")).trim()
|
||||
};
|
||||
}
|
||||
|
||||
function parseCatalogCsv(text) {
|
||||
const lines = text
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.length > 0);
|
||||
if (!lines.length) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const headers = parseCsvLine(lines[0]).map((value) => value.trim());
|
||||
const entries = new Map();
|
||||
for (let lineIndex = 1; lineIndex < lines.length; lineIndex += 1) {
|
||||
const values = parseCsvLine(lines[lineIndex]);
|
||||
const row = {};
|
||||
for (let headerIndex = 0; headerIndex < headers.length; headerIndex += 1) {
|
||||
row[headers[headerIndex]] = values[headerIndex] ?? "";
|
||||
}
|
||||
const entry = normalizeCatalogEntry(row);
|
||||
if (entry) {
|
||||
entries.set(entry.shapeCode, entry);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function formatOptionalBoolean(value) {
|
||||
if (value === true) {
|
||||
return "true";
|
||||
}
|
||||
if (value === false) {
|
||||
return "false";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function escapeCsvValue(value) {
|
||||
const text = String(value ?? "");
|
||||
if (!/[",\r\n]/u.test(text)) {
|
||||
return text;
|
||||
}
|
||||
return `"${text.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function serializeCatalog(entries) {
|
||||
const lines = [CATALOG_HEADERS.join(",")];
|
||||
const sortedEntries = [...entries.values()].sort((left, right) => left.shapeCode - right.shapeCode);
|
||||
for (const entry of sortedEntries) {
|
||||
lines.push(
|
||||
[
|
||||
entry.shapeCodeHex,
|
||||
entry.humanReadableId,
|
||||
entry.description,
|
||||
formatOptionalBoolean(entry.roof),
|
||||
formatOptionalBoolean(entry.semitransparency),
|
||||
entry.categorization,
|
||||
entry.qualities
|
||||
]
|
||||
.map(escapeCsvValue)
|
||||
.join(",")
|
||||
);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function getCatalogPath(gameId) {
|
||||
const fileName = CATALOG_FILE_BY_GAME[gameId];
|
||||
if (!fileName) {
|
||||
return null;
|
||||
}
|
||||
return path.join(CATALOG_ROOT, fileName);
|
||||
}
|
||||
|
||||
export function getShapeCatalogFile(gameId) {
|
||||
return getCatalogPath(gameId);
|
||||
}
|
||||
|
||||
export function getShapeCatalog(gameId) {
|
||||
const filePath = getCatalogPath(gameId);
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
return {
|
||||
filePath,
|
||||
digest: "missing",
|
||||
entries: new Map()
|
||||
};
|
||||
}
|
||||
|
||||
const stat = fs.statSync(filePath);
|
||||
const stamp = `${stat.size}:${Math.trunc(stat.mtimeMs)}`;
|
||||
const cached = shapeCatalogCache.get(gameId);
|
||||
if (cached?.stamp === stamp) {
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
const text = fs.readFileSync(filePath, "utf8");
|
||||
const value = {
|
||||
filePath,
|
||||
digest: sha1(text),
|
||||
entries: parseCatalogCsv(text)
|
||||
};
|
||||
shapeCatalogCache.set(gameId, { stamp, value });
|
||||
return value;
|
||||
}
|
||||
|
||||
export function ensureShapeCatalogCoverage(gameId, observedShapes) {
|
||||
const filePath = getCatalogPath(gameId);
|
||||
if (!filePath) {
|
||||
return {
|
||||
changed: false,
|
||||
added: 0,
|
||||
filePath: null
|
||||
};
|
||||
}
|
||||
|
||||
const existing = getShapeCatalog(gameId);
|
||||
const entries = new Map(existing.entries);
|
||||
let added = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const observed of observedShapes) {
|
||||
if (entries.has(observed.shapeCode)) {
|
||||
const entry = entries.get(observed.shapeCode);
|
||||
let changed = false;
|
||||
if (!entry.categorization && observed.categorization) {
|
||||
entry.categorization = observed.categorization;
|
||||
changed = true;
|
||||
}
|
||||
if (!entry.qualities && observed.qualities) {
|
||||
entry.qualities = observed.qualities;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
updated += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
entries.set(observed.shapeCode, {
|
||||
shapeCode: observed.shapeCode,
|
||||
shapeCodeHex: `0x${observed.shapeCode.toString(16).padStart(4, "0")}`,
|
||||
humanReadableId: "",
|
||||
description: observed.isEditor ? "Editor Object" : "",
|
||||
roof: null,
|
||||
semitransparency: null,
|
||||
categorization: observed.categorization,
|
||||
qualities: observed.qualities
|
||||
});
|
||||
added += 1;
|
||||
}
|
||||
|
||||
if (!added && !updated) {
|
||||
return {
|
||||
changed: false,
|
||||
added: 0,
|
||||
updated: 0,
|
||||
filePath
|
||||
};
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const serialized = serializeCatalog(entries);
|
||||
fs.writeFileSync(filePath, serialized, "utf8");
|
||||
shapeCatalogCache.delete(gameId);
|
||||
return {
|
||||
changed: true,
|
||||
added,
|
||||
updated,
|
||||
filePath
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -1,475 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -1,449 +0,0 @@
|
|||
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 projectItemGeometry(items, archive, shapeInfos, options = {}) {
|
||||
const { progress, checkpointEvery = 0, maxInvalidDetails = 20 } = options;
|
||||
const projected = [];
|
||||
let invalidItemCount = 0;
|
||||
const invalidItems = [];
|
||||
|
||||
for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
|
||||
const item = items[itemIndex];
|
||||
try {
|
||||
const decoded = archive.decodeFrame(item.shape, item.frame);
|
||||
projected.push(buildSortNode(item, shapeInfos[item.shape], decoded.frame, 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)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (progress && checkpointEvery > 0 && (itemIndex + 1) % checkpointEvery === 0) {
|
||||
progress(`project processed=${itemIndex + 1} valid=${projected.length} invalid=${invalidItemCount}`);
|
||||
}
|
||||
}
|
||||
|
||||
projected.sort((left, right) => {
|
||||
if (left.sy_bot !== right.sy_bot) {
|
||||
return left.sy_bot - right.sy_bot;
|
||||
}
|
||||
if (left.sx_bot !== right.sx_bot) {
|
||||
return left.sx_bot - right.sx_bot;
|
||||
}
|
||||
if (left.item.shape !== right.item.shape) {
|
||||
return left.item.shape - right.item.shape;
|
||||
}
|
||||
return left.item.frame - right.item.frame;
|
||||
});
|
||||
|
||||
if (progress) {
|
||||
progress(`project complete processed=${items.length} valid=${projected.length} invalid=${invalidItemCount}`);
|
||||
}
|
||||
|
||||
return {
|
||||
projected,
|
||||
invalidItemCount,
|
||||
invalidItems
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
|
@ -1,515 +0,0 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
--panel-width: 360px;
|
||||
--bg: #f1ead6;
|
||||
--panel: rgba(255, 248, 232, 0.92);
|
||||
--card: rgba(255, 255, 255, 0.58);
|
||||
--panel-border: rgba(94, 73, 37, 0.25);
|
||||
--ink: #2d2212;
|
||||
--muted: #6e5a37;
|
||||
--accent: #0d6c7d;
|
||||
--accent-strong: #114f59;
|
||||
--viewport: #0e1218;
|
||||
--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);
|
||||
--card: rgba(28, 35, 46, 0.96);
|
||||
--panel-border: rgba(166, 187, 211, 0.16);
|
||||
--ink: #edf2fa;
|
||||
--muted: #aab8cc;
|
||||
--accent: #46a7bc;
|
||||
--accent-strong: #2a7b8d;
|
||||
--viewport: #06080d;
|
||||
--shadow: 0 18px 45px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
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: minmax(280px, var(--panel-width)) 12px minmax(0, 1fr);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 24px;
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(16px);
|
||||
border-right: 1px solid var(--panel-border);
|
||||
box-shadow: var(--shadow);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.panel-resizer {
|
||||
cursor: col-resize;
|
||||
background:
|
||||
linear-gradient(180deg, transparent 0%, rgba(17, 79, 89, 0.3) 15%, rgba(17, 79, 89, 0.3) 85%, transparent 100%),
|
||||
linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.22) 50%, transparent 100%);
|
||||
}
|
||||
|
||||
.panel-resizer:hover,
|
||||
.panel-resizer.is-dragging {
|
||||
background:
|
||||
linear-gradient(180deg, transparent 0%, rgba(13, 108, 125, 0.56) 15%, rgba(13, 108, 125, 0.56) 85%, transparent 100%),
|
||||
linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.38) 50%, transparent 100%);
|
||||
}
|
||||
|
||||
.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,
|
||||
.action-link,
|
||||
.button-row button,
|
||||
.hidden-item-button,
|
||||
.tooltip-action {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(65, 48, 21, 0.18);
|
||||
padding: 12px 14px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.action-link {
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
select:disabled,
|
||||
.action-link.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.catalog-export-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.button-row button,
|
||||
.hidden-item-button,
|
||||
.tooltip-action {
|
||||
cursor: pointer;
|
||||
background: color-mix(in srgb, var(--card) 80%, white 20%);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.button-row button:disabled,
|
||||
.hidden-item-button:disabled,
|
||||
.tooltip-action:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.toggle-grid,
|
||||
.status,
|
||||
.meta-panel {
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: var(--card);
|
||||
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;
|
||||
}
|
||||
|
||||
.action-link + .action-link {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.status {
|
||||
min-height: 92px;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-top-color: var(--accent);
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.progress-wrap {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, var(--accent) 0%, var(--accent-strong) 100%);
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.hidden-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hidden-item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(65, 48, 21, 0.08);
|
||||
background: color-mix(in srgb, var(--card) 76%, transparent 24%);
|
||||
}
|
||||
|
||||
.hidden-item-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hidden-item-meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
padding: 18px;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: calc(100vh - 36px);
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
background: radial-gradient(circle at top left, rgba(255, 255, 255, 0.04), transparent 26%), var(--viewport);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05), var(--shadow);
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.scene-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
.overlay-tooltip {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
max-width: 340px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(8, 12, 18, 0.9);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(124, 182, 214, 0.28);
|
||||
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.34);
|
||||
pointer-events: auto;
|
||||
backdrop-filter: blur(14px);
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.overlay-tooltip[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tooltip-header {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tooltip-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tooltip-action {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.tooltip-action svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tooltip-state {
|
||||
display: inline-flex;
|
||||
margin-top: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(229, 192, 76, 0.18);
|
||||
color: rgba(255, 225, 145, 0.96);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tooltip-eyebrow {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(138, 202, 221, 0.88);
|
||||
}
|
||||
|
||||
.tooltip-title {
|
||||
margin-top: 4px;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tooltip-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 10px;
|
||||
margin: 10px 0 0;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.tooltip-grid dt {
|
||||
color: rgba(176, 197, 212, 0.76);
|
||||
}
|
||||
|
||||
.tooltip-grid dd {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tooltip-notes {
|
||||
margin: 10px 0 0;
|
||||
padding-left: 18px;
|
||||
color: rgba(214, 227, 237, 0.82);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.inspect-highlight {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
border: 2px solid rgba(255, 229, 107, 0.95);
|
||||
background: rgba(255, 229, 107, 0.08);
|
||||
box-shadow: 0 0 0 1px rgba(7, 12, 18, 0.82), 0 0 18px rgba(255, 229, 107, 0.28);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.viewport.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.viewport.inspect-active {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.viewport.inspect-active.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;
|
||||
}
|
||||
|
||||
.empty-state.is-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.shell {
|
||||
grid-template-columns: minmax(260px, 42vw) 10px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.panel-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
padding-top: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
height: 70vh;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,92 +0,0 @@
|
|||
<!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" id="side-panel">
|
||||
<h1>Crusader Map Renderer</h1>
|
||||
<p class="lede">Cache-backed atlas scene renderer. Source assets stay server-side while the browser reconstructs each map from packed sprite atlases.</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>
|
||||
<label class="toggle"><input id="inspect-shapes" type="checkbox"> Inspect shapes under cursor</label>
|
||||
</div>
|
||||
</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>
|
||||
<button id="reload-map-button" class="action-link is-disabled" type="button" aria-disabled="true">Reload Current Map</button>
|
||||
<button id="download-button" class="action-link is-disabled" type="button" aria-disabled="true">Download PNG</button>
|
||||
<button id="hidden-export-button" class="action-link is-disabled" type="button" aria-disabled="true">Export Hidden JSON</button>
|
||||
</div>
|
||||
|
||||
<div class="stack controls">
|
||||
<label>Catalog CSVs</label>
|
||||
<div id="catalog-export-buttons" class="catalog-export-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<label>Status</label>
|
||||
<div class="status">
|
||||
<div class="status-row">
|
||||
<span id="spinner" class="spinner" hidden></span>
|
||||
<div id="status" class="status-text">Idle.</div>
|
||||
</div>
|
||||
<div id="progress-wrap" class="progress-wrap" hidden>
|
||||
<div class="progress-track">
|
||||
<div id="progress-fill" class="progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<label>Hidden Shapes</label>
|
||||
<div id="hidden-panel" class="meta-panel">
|
||||
<p id="hidden-empty" class="meta-empty">Hidden shapes will appear here and can be restored individually.</p>
|
||||
<div id="hidden-list" class="hidden-list"></div>
|
||||
</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>
|
||||
|
||||
<div id="panel-resizer" class="panel-resizer" role="separator" aria-orientation="vertical" aria-label="Resize side panel"></div>
|
||||
|
||||
<main class="workspace">
|
||||
<div id="viewport" class="viewport">
|
||||
<div id="viewport-hint" class="viewport-hint">Drag to pan. Scroll or pinch to zoom.</div>
|
||||
<canvas id="scene-canvas" class="scene-canvas"></canvas>
|
||||
<div id="inspect-highlight" class="inspect-highlight" hidden></div>
|
||||
<div id="overlay-tooltip" class="overlay-tooltip" hidden></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>
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
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, getShapeCatalogFile } 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 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);
|
||||
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/scene", (request, response) => {
|
||||
try {
|
||||
const buildId = String(request.query.buildId ?? "");
|
||||
const mapId = Number.parseInt(request.params.mapId, 10);
|
||||
const scene = builds.getSceneData(buildId, request.params.game, mapId);
|
||||
response.json(scene);
|
||||
} catch (error) {
|
||||
response.status(400).json({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/maps/:game/:mapId/inspect", (request, response) => {
|
||||
try {
|
||||
const buildId = String(request.query.buildId ?? "");
|
||||
const mapId = Number.parseInt(request.params.mapId, 10);
|
||||
const inspect = builds.getInspectData(buildId, request.params.game, mapId);
|
||||
response.json(inspect);
|
||||
} catch (error) {
|
||||
response.status(400).json({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/maps/:game/:mapId/overlays", (request, response) => {
|
||||
try {
|
||||
const buildId = String(request.query.buildId ?? "");
|
||||
const mapId = Number.parseInt(request.params.mapId, 10);
|
||||
const overlays = builds.getOverlayData(buildId, request.params.game, mapId);
|
||||
response.json(overlays);
|
||||
} catch (error) {
|
||||
response.status(400).json({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/maps/:game/:mapId/atlases/:atlasId.png", (request, response) => {
|
||||
try {
|
||||
const buildId = String(request.query.buildId ?? "");
|
||||
const mapId = Number.parseInt(request.params.mapId, 10);
|
||||
const atlas = builds.getAtlas(buildId, request.params.game, mapId, request.params.atlasId);
|
||||
response.setHeader("Content-Type", "image/png");
|
||||
response.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
response.end(atlas);
|
||||
} catch (error) {
|
||||
response.status(400).json({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/catalogs/:game.csv", (request, response) => {
|
||||
const filePath = getShapeCatalogFile(request.params.game);
|
||||
if (!filePath) {
|
||||
response.status(404).json({ error: "Unknown game id" });
|
||||
return;
|
||||
}
|
||||
response.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||
response.setHeader("Content-Disposition", `attachment; filename="${path.basename(filePath)}"`);
|
||||
response.sendFile(path.resolve(filePath), { dotfiles: "allow" }, (error) => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
if (!response.headersSent) {
|
||||
response.status(404).json({ error: "Catalog CSV not found" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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}`);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue