From f93cfc31c896d17832571306469a951952fd822a Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 27 Mar 2026 16:28:45 +0100 Subject: [PATCH] Refactor map renderer and server API - Updated index.html to enhance UI with new elements for hidden shapes and catalog CSVs. - Changed download button to a button element for better accessibility. - Modified server.js to improve API endpoints: - Renamed overlays endpoint to scene for clarity. - Updated tile rendering endpoints to use atlas instead of tile coordinates. - Added new endpoint for downloading shape catalog CSV files. - Removed unused options in build creation. --- map_renderer/.dockerignore | 2 - .../Catalogs/usecode_shape_catalog_regret.csv | 185 +++ .../usecode_shape_catalog_remorse.csv | 218 +++ map_renderer/Dockerfile | 27 +- map_renderer/README.md | 56 +- map_renderer/compose.yaml | 1 + map_renderer/package.json | 3 +- map_renderer/phase-plan.md | 80 ++ map_renderer/src/build-cache.js | 67 + map_renderer/src/config.js | 3 + map_renderer/src/lib/atlas-packer.js | 105 ++ map_renderer/src/lib/build-manager.js | 1167 +++++++---------- map_renderer/src/lib/catalog.js | 144 +- map_renderer/src/public/app.css | 217 ++- map_renderer/src/public/app.js | 1166 +++++++++------- map_renderer/src/public/index.html | 30 +- map_renderer/src/server.js | 82 +- 17 files changed, 2291 insertions(+), 1262 deletions(-) create mode 100644 map_renderer/Catalogs/usecode_shape_catalog_regret.csv create mode 100644 map_renderer/Catalogs/usecode_shape_catalog_remorse.csv create mode 100644 map_renderer/src/build-cache.js create mode 100644 map_renderer/src/lib/atlas-packer.js diff --git a/map_renderer/.dockerignore b/map_renderer/.dockerignore index 409d710..703094a 100644 --- a/map_renderer/.dockerignore +++ b/map_renderer/.dockerignore @@ -4,5 +4,3 @@ coverage/ dist/ .env .env.* -STATIC/ -STATIC_REGRET/ diff --git a/map_renderer/Catalogs/usecode_shape_catalog_regret.csv b/map_renderer/Catalogs/usecode_shape_catalog_regret.csv new file mode 100644 index 0000000..f966664 --- /dev/null +++ b/map_renderer/Catalogs/usecode_shape_catalog_regret.csv @@ -0,0 +1,185 @@ +shape_code,human_readable_id,description,roof,semitransparency +0x0001,, +0x000B,, +0x0011,, +0x0028,, +0x0030,, +0x0033,door_shape_0033,Auto-derived from DOOR self-shape comparison in USECODE +0x0034,, +0x004B,door_shape_004b,Auto-derived from DOOR self-shape comparison in USECODE +0x005F,, +0x0064,door_shape_0064,Auto-derived from DOOR self-shape comparison in USECODE +0x006C,door_shape_006c,Auto-derived from DOOR self-shape comparison in USECODE +0x0070,yelrail_shape_0070,Auto-derived from YELRAIL self-shape comparison in USECODE +0x0085,, +0x0088,yelrail_shape_0088,Auto-derived from YELRAIL self-shape comparison in USECODE +0x008A,yelrail_shape_008a,Auto-derived from YELRAIL self-shape comparison in USECODE +0x008B,yelrail_shape_008b,Auto-derived from YELRAIL self-shape comparison in USECODE +0x008C,yelrail_shape_008c,Auto-derived from YELRAIL self-shape comparison in USECODE +0x008D,yelrail_shape_008d,Auto-derived from YELRAIL self-shape comparison in USECODE +0x0091,yelrail_shape_0091,Auto-derived from YELRAIL self-shape comparison in USECODE +0x0092,yelrail_shape_0092,Auto-derived from YELRAIL self-shape comparison in USECODE +0x0093,yelrail_shape_0093,Auto-derived from YELRAIL self-shape comparison in USECODE +0x0095,, +0x00AA,barrel_shape_00aa,Auto-derived from BARREL self-shape comparison in USECODE +0x00AD,, +0x00C0,, +0x00D1,, +0x00D4,, +0x00D9,, +0x00DB,, +0x0108,, +0x0111,, +0x0113,, +0x0127,, +0x0135,, +0x0151,barrel_shape_0151,Auto-derived from BARREL self-shape comparison in USECODE +0x0152,barrel_shape_0152,Auto-derived from BARREL self-shape comparison in USECODE +0x0153,barrel_shape_0153,Auto-derived from BARREL self-shape comparison in USECODE +0x0154,barrel_shape_0154,Auto-derived from BARREL self-shape comparison in USECODE +0x0155,barrel_shape_0155,Auto-derived from BARREL self-shape comparison in USECODE +0x0156,, +0x018D,, +0x018E,, +0x0190,, +0x0193,, +0x01AB,, +0x01B4,booty_shape_01b4,Auto-derived from BOOTY self-shape comparison in USECODE +0x01B9,, +0x01BA,, +0x01C1,, +0x01C8,, +0x01CD,, +0x01D9,, +0x01DA,, +0x01DB,, +0x01E4,, +0x01EE,, +0x01F5,, +0x022D,, +0x0251,, +0x025F,, +0x0260,, +0x0277,, +0x0278,, +0x0287,, +0x0289,, +0x028D,, +0x02C9,bbetty_shape_02c9,Auto-derived from BBETTY self-shape comparison in USECODE +0x02CB,, +0x02D8,door_shape_02d8,Auto-derived from DOOR self-shape comparison in USECODE +0x02DC,, +0x02DE,, +0x02DF,booty_shape_02df,Auto-derived from BOOTY self-shape comparison in USECODE +0x02EF,, +0x02F0,, +0x02F5,, +0x02F6,, +0x02F7,, +0x0301,, +0x0308,booty_shape_0308,Auto-derived from BOOTY self-shape comparison in USECODE +0x030C,, +0x0319,, +0x0337,, +0x0338,, +0x033A,, +0x0344,, +0x034B,, +0x0361,, +0x0363,door_shape_0363,Auto-derived from DOOR self-shape comparison in USECODE +0x0371,booty_shape_0371,Auto-derived from BOOTY self-shape comparison in USECODE +0x0373,, +0x037A,door_shape_037a,Auto-derived from DOOR self-shape comparison in USECODE +0x0383,, +0x0384,, +0x0385,, +0x0399,, +0x039A,, +0x039C,, +0x03A1,, +0x03A9,, +0x03AC,, +0x03AD,, +0x03B9,door_shape_03b9,Auto-derived from DOOR self-shape comparison in USECODE +0x03BA,door_shape_03ba,Auto-derived from DOOR self-shape comparison in USECODE +0x0401,, +0x0403,, +0x041C,, +0x0438,, +0x0439,, +0x043A,, +0x043B,, +0x043D,, +0x0443,, +0x044A,, +0x044D,, +0x044E,, +0x0452,, +0x0456,, +0x0457,, +0x0459,, +0x045A,, +0x045D,, +0x046A,, +0x046C,, +0x0476,, +0x04B1,, +0x04B8,, +0x04C8,, +0x04C9,, +0x04D0,, +0x04D1,booty_shape_04d1,Auto-derived from BOOTY self-shape comparison in USECODE +0x04D9,, +0x04E0,, +0x04E6,, +0x04E7,, +0x04F8,, +0x04F9,, +0x04FA,, +0x04FD,, +0x04FE,, +0x0500,, +0x050A,, +0x0510,, +0x0511,, +0x0518,, +0x051A,, +0x0524,, +0x0528,booty_shape_0528,Auto-derived from BOOTY self-shape comparison in USECODE +0x053A,, +0x053B,door_shape_053b,Auto-derived from DOOR self-shape comparison in USECODE +0x054E,, +0x055F,, +0x0561,, +0x056F,door_shape_056f,Auto-derived from DOOR self-shape comparison in USECODE +0x0571,door_shape_0571,Auto-derived from DOOR self-shape comparison in USECODE +0x0573,door_shape_0573,Auto-derived from DOOR self-shape comparison in USECODE +0x0574,door_shape_0574,Auto-derived from DOOR self-shape comparison in USECODE +0x0576,, +0x057A,booty_shape_057a,Auto-derived from BOOTY self-shape comparison in USECODE +0x058F,, +0x0595,, +0x0596,, +0x0597,, +0x05A4,, +0x05A5,, +0x05B1,, +0x05BA,, +0x05D5,booty_shape_05d5,Auto-derived from BOOTY self-shape comparison in USECODE +0x05D6,, +0x05D8,, +0x05D9,, +0x05DA,, +0x05DD,, +0x05DE,, +0x05DF,, +0x05E0,, +0x05E2,, +0x05E6,, +0x05EF,, +0x0606,booty_shape_0606,Auto-derived from BOOTY self-shape comparison in USECODE +0x0616,, +0x062D,, +0x062E,booty_shape_062e,Auto-derived from BOOTY self-shape comparison in USECODE +0x0631,booty_shape_0631,Auto-derived from BOOTY self-shape comparison in USECODE +0x0656,, diff --git a/map_renderer/Catalogs/usecode_shape_catalog_remorse.csv b/map_renderer/Catalogs/usecode_shape_catalog_remorse.csv new file mode 100644 index 0000000..2757e01 --- /dev/null +++ b/map_renderer/Catalogs/usecode_shape_catalog_remorse.csv @@ -0,0 +1,218 @@ +shape_code,human_readable_id,description,roof,semitransparency +0x0001,, +0x0003,, +0x0004,, +0x0005,door_shape_0005,Auto-derived from DOOR self-shape comparison in USECODE +0x0007,, +0x0009,, +0x000A,, +0x000B,, +0x000D,, +0x000F,, +0x0011,, +0x0013,, +0x0015,, +0x0017,, +0x0019,, +0x001B,, +0x001D,, +0x001E,, +0x0028,, +0x0029,, +0x0030,, +0x0033,, +0x0046,door_shape_0046,Auto-derived from DOOR self-shape comparison in USECODE +0x007B,door_shape_007b,Auto-derived from DOOR self-shape comparison in USECODE +0x0095,door_shape_0095,Auto-derived from DOOR self-shape comparison in USECODE +0x00A1,, +0x00A5,, +0x00A9,door_shape_00a9,Auto-derived from DOOR self-shape comparison in USECODE +0x00AD,, +0x00C2,, +0x0100,, +0x0135,, +0x0136,, +0x0137,, +0x0138,, +0x0139,, +0x0158,, +0x0159,, +0x015A,, +0x015B,, +0x0167,REACTOR,Level 1 Reactor +0x0168,REACTOR_CELL,Level 1 +0x0187,WALL_EDGE,Not sure +0x0189,ELEVATOR_DOOR_SEGMENT, +0x018D,ELEVATOR_DOOR_LEFT, +0x018E,TELEPAD_RED, +0x0193,ELEVATOR_DOOR_RIGHT, +0x01A2,RADAR_DISH, +0x01A6,COMM_CONSOLE, +0x01BC,LIGHT_TUBE,A tube with a light inside +0x01BF,ELECTRODE,The zappy things that flash the whole screen when destroyed +0x01C1,TUBE_PLATFORM,A platform for square tubes +0x01C6,ELEVATOR_DOOR_LEFT_2, +0x01C7,ELEVATOR_DOOR_RIGHT_2, +0x01C8,WHITE_GRID,It's a white grid no idea +0x01D5,IR_SENSOR, +0x01DA,NARROW_COLUMN_BASE, +0x01DB,TELEPORTER_LIGHTS, +0x01E4,SMALL_BOX_PROP, +0x01EE,PRISON_BARS_DOOR, +0x021D,MINE_LAYER_TRAP, +0x021E,GRATE_FLOOR_GRAY, +0x0251,PLACEHOLDER_KEY_CUBE,Placeholder UI Element +0x0289,, +0x028E,, +0x02DC,FLOOR_PEDESTAL_SLICE, +0x02DE,ELEVATOR_BASE,It's the concave shape under elevators +0x02E0,DOOR_CORNER_LOWER_RIGHT, +0x02E1,, +0x02E2,HEALTH_STATION_TOP, +0x02E3,HEALTH_STATION_BACK, +0x02E5,PRISONER_1, +0x02E7,PRISONER_2, +0x02E8,PRISONER_3, +0x02EF,TURRET_BASE, +0x02F6,NPC_TECH, +0x02FD,npcdeath_shape_02fd,Auto-derived from NPCDEATH self-shape comparison in USECODE +0x030D,, +0x030E,, +0x0315,BAR_STOOL, +0x0318,PLACEHOLDER_CUBE,Placeholder UI element +0x0329,FORCEFIELD_NW, +0x032A,FORCEFIELD_NE, +0x0337,PLACEHOLDER_CUBE_BIG, +0x0338,MECH_1, +0x033A,NUMBERS, +0x034D,BAR_PATRONS_1, +0x0361,PLACEHOLDER_CUBE_RED_BLACK, +0x0368,, +0x0369,, +0x036A,, +0x036B,, +0x037A,, +0x037D,, +0x03A9,, +0x03AA,, +0x03AC,npcdeath_shape_03ac,Auto-derived from NPCDEATH self-shape comparison in USECODE +0x03B0,, +0x03BF,, +0x03C1,, +0x0401,, +0x043D,, +0x0442,, +0x0443,, +0x044F,, +0x0452,, +0x0457,, +0x0476,, +0x0493,, +0x04B1,, +0x04B8,, +0x04C6,, +0x04C8,wallgun_shape_04c8,Auto-derived from WALLGUN self-shape comparison in USECODE +0x04C9,, +0x04D0,, +0x04D5,, +0x04D9,, +0x04DC,, +0x04E0,, +0x04E7,, +0x04EE,, +0x04F8,, +0x0500,, +0x0524,, +0x053A,, +0x054F,, +0x0561,, +0x005F,, +0x0070,yelrail_shape_0070,Auto-derived from YELRAIL self-shape comparison in USECODE +0x0085,, +0x0088,yelrail_shape_0088,Auto-derived from YELRAIL self-shape comparison in USECODE +0x008A,yelrail_shape_008a,Auto-derived from YELRAIL self-shape comparison in USECODE +0x008B,yelrail_shape_008b,Auto-derived from YELRAIL self-shape comparison in USECODE +0x008C,yelrail_shape_008c,Auto-derived from YELRAIL self-shape comparison in USECODE +0x008D,yelrail_shape_008d,Auto-derived from YELRAIL self-shape comparison in USECODE +0x0091,yelrail_shape_0091,Auto-derived from YELRAIL self-shape comparison in USECODE +0x0092,yelrail_shape_0092,Auto-derived from YELRAIL self-shape comparison in USECODE +0x0093,yelrail_shape_0093,Auto-derived from YELRAIL self-shape comparison in USECODE +0x0099,door_shape_0099,Auto-derived from DOOR self-shape comparison in USECODE +0x00AA,barrel_shape_00aa,Auto-derived from BARREL self-shape comparison in USECODE +0x00D1,, +0x0108,wallgun_shape_0108,Auto-derived from WALLGUN self-shape comparison in USECODE +0x0111,, +0x0113,wallgun_shape_0113,Auto-derived from WALLGUN self-shape comparison in USECODE +0x0141,, +0x0151,barrel_shape_0151,Auto-derived from BARREL self-shape comparison in USECODE +0x0152,BARREL_YELLOW_SIDEWAYS_0152,Auto-derived from BARREL self-shape comparison in USECODE +0x0153,BARREL_YELLOW_SIDEWAYS_0153,Auto-derived from BARREL self-shape comparison in USECODE +0x0154,barrel_shape_0154,Auto-derived from BARREL self-shape comparison in USECODE +0x0155,barrel_shape_0155,Auto-derived from BARREL self-shape comparison in USECODE +0x017F,, +0x018F,, +0x0196,, +0x01B4,npcdeath_shape_01b4,Auto-derived from NPCDEATH self-shape comparison in USECODE +0x01B9,wallgun_shape_01b9,Auto-derived from WALLGUN self-shape comparison in USECODE +0x01BA,wallgun_shape_01ba,Auto-derived from WALLGUN self-shape comparison in USECODE +0x01CD,wallgun_shape_01cd,Auto-derived from WALLGUN self-shape comparison in USECODE +0x01D9,, +0x025F,wallgun_shape_025f,Auto-derived from WALLGUN self-shape comparison in USECODE +0x0260,wallgun_shape_0260,Auto-derived from WALLGUN self-shape comparison in USECODE +0x02C3,, +0x02C4,, +0x02C9,bbetty_shape_02c9,Auto-derived from BBETTY self-shape comparison in USECODE +0x02CB,, +0x02DF,npcdeath_shape_02df,Auto-derived from NPCDEATH self-shape comparison in USECODE +0x02F0,wallgun_shape_02f0,Auto-derived from WALLGUN self-shape comparison in USECODE +0x02F5,, +0x02F7,, +0x030A,door_shape_030a,Auto-derived from DOOR self-shape comparison in USECODE +0x030B,door_shape_030b,Auto-derived from DOOR self-shape comparison in USECODE +0x0319,npcdeath_shape_0319,Auto-derived from NPCDEATH self-shape comparison in USECODE +0x033C,npc_shape_033c,Auto-derived from NPC self-shape comparison in USECODE +0x0344,, +0x0371,npcdeath_shape_0371,Auto-derived from NPCDEATH self-shape comparison in USECODE +0x0373,wallgun_shape_0373,Auto-derived from WALLGUN self-shape comparison in USECODE +0x0383,, +0x0384,npcdeath_shape_0384,Auto-derived from NPCDEATH self-shape comparison in USECODE +0x0385,, +0x0399,wallgun_shape_0399,Auto-derived from WALLGUN self-shape comparison in USECODE +0x03A1,wallgun_shape_03a1,Auto-derived from WALLGUN self-shape comparison in USECODE +0x03F8,, +0x03FF,, +0x0438,, +0x0439,, +0x043A,, +0x043B,, +0x043F,door2_shape_043f,Auto-derived from DOOR2 self-shape comparison in USECODE +0x0440,door2_shape_0440,Auto-derived from DOOR2 self-shape comparison in USECODE +0x044A,, +0x0456,, +0x0459,, +0x045A,, +0x045D,, +0x045E,, +0x045F,, +0x0460,, +0x0461,, +0x0470,, +0x0471,, +0x04D1,npcdeath_shape_04d1,Auto-derived from NPCDEATH self-shape comparison in USECODE +0x04E6,, +0x04F9,, +0x04FA,, +0x04FD,, +0x04FE,, +0x050A,, +0x0511,, +0x0518,, +0x0528,npcdeath_shape_0528,Auto-derived from NPCDEATH self-shape comparison in USECODE +0x052C,, +0x0576,, +0x057A,, +0x057F,, +0x0580,, +0x058F,, +0x0596,, +0x059C,, diff --git a/map_renderer/Dockerfile b/map_renderer/Dockerfile index b06d8b2..d2f7f17 100644 --- a/map_renderer/Dockerfile +++ b/map_renderer/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine +FROM node:20-alpine AS base WORKDIR /app @@ -6,6 +6,31 @@ 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 diff --git a/map_renderer/README.md b/map_renderer/README.md index f436878..aa74dc9 100644 --- a/map_renderer/README.md +++ b/map_renderer/README.md @@ -1,13 +1,13 @@ # Crusader Map Renderer -Node web app that renders Crusader maps on the server and streams only finished PNG tiles to the browser. +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 render state on demand after the user selects a map. -- Serve large maps as draggable and zoomable image tiles. +- 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 @@ -25,8 +25,27 @@ Viewer behavior: - drag with the mouse or one finger to pan - use the scroll wheel to zoom directly at the pointer - pinch to zoom on touch devices -- toggle roofs and editor-only elements independently before building -- when editor-only elements are enabled, the base map excludes those records and the original editor shapes render as interactive overlay sprites with hover metadata +- 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 +- catalog CSV rows support `roof` and `semitransparency` boolean overrides; leave them blank to use decoded defaults, or set `true`/`false` per shape + +## 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: @@ -35,22 +54,32 @@ The app expects asset folders under the app root: ## Docker Run -The Docker image excludes the Crusader assets on purpose. Mount them at runtime so they stay outside the image and are never served directly to clients. +The `dev` image stays light and expects Crusader assets to be mounted at runtime. ```powershell cd map_renderer -docker build -t crusader-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 + 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 mounts `STATIC` and `STATIC_REGRET` from the host filesystem into the container as read-only volumes. They are excluded from the image build by `.dockerignore`, so the assets are never copied into the image. +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 @@ -62,9 +91,10 @@ docker compose up --build - `GET /api/maps` returns the detected catalog. - `POST /api/builds` starts or reuses a build. - `GET /api/builds/:id` returns build status. -- `GET /api/maps/:game/:mapId/metadata?buildId=...` returns map bounds and tile settings. -- `GET /api/maps/:game/:mapId/overlays?buildId=...` returns interactive overlay records for editor-only content. -- `GET /api/maps/:game/:mapId/overlays/:overlayId.webp?buildId=...` returns the rendered sprite for one overlay item. -- `GET /api/maps/:game/:mapId/tiles/:tileX/:tileY.png?buildId=...` returns rendered PNG tiles. +- `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. diff --git a/map_renderer/compose.yaml b/map_renderer/compose.yaml index 614534d..8011181 100644 --- a/map_renderer/compose.yaml +++ b/map_renderer/compose.yaml @@ -2,6 +2,7 @@ services: map-renderer: build: context: . + target: dev ports: - "3000:3000" environment: diff --git a/map_renderer/package.json b/map_renderer/package.json index 51fb4c2..51ed68b 100644 --- a/map_renderer/package.json +++ b/map_renderer/package.json @@ -6,7 +6,8 @@ "description": "Server-side tiled Crusader map renderer for browser viewing.", "scripts": { "start": "node src/server.js", - "dev": "node --watch src/server.js" + "dev": "node --watch src/server.js", + "build-cache": "node src/build-cache.js" }, "engines": { "node": ">=20" diff --git a/map_renderer/phase-plan.md b/map_renderer/phase-plan.md index 576ed3e..5597fed 100644 --- a/map_renderer/phase-plan.md +++ b/map_renderer/phase-plan.md @@ -49,3 +49,83 @@ 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 diff --git a/map_renderer/src/build-cache.js b/map_renderer/src/build-cache.js new file mode 100644 index 0000000..38df36c --- /dev/null +++ b/map_renderer/src/build-cache.js @@ -0,0 +1,67 @@ +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; +}); \ No newline at end of file diff --git a/map_renderer/src/config.js b/map_renderer/src/config.js index 31803cf..7dad398 100644 --- a/map_renderer/src/config.js +++ b/map_renderer/src/config.js @@ -7,9 +7,12 @@ 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", diff --git a/map_renderer/src/lib/atlas-packer.js b/map_renderer/src/lib/atlas-packer.js new file mode 100644 index 0000000..d9f15df --- /dev/null +++ b/map_renderer/src/lib/atlas-packer.js @@ -0,0 +1,105 @@ +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 + }; +} \ No newline at end of file diff --git a/map_renderer/src/lib/build-manager.js b/map_renderer/src/lib/build-manager.js index 50a99a3..76ab896 100644 --- a/map_renderer/src/lib/build-manager.js +++ b/map_renderer/src/lib/build-manager.js @@ -1,13 +1,14 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import sharp from "sharp"; -import { TILE_CACHE_ROOT, TILE_SIZE } from "../config.js"; +import { SCENE_CACHE_ROOT, TILE_SIZE } from "../config.js"; +import { packSprites } from "./atlas-packer.js"; +import { getShapeCatalog } from "./catalog.js"; import { EGG_FAMILIES, - FLAG_INVISIBLE, FLAG_FLIPPED, + FLAG_INVISIBLE, ShapeArchive, collectRenderItems, loadGlobs, @@ -18,7 +19,9 @@ import { summarizeRenderClasses } from "./formats.js"; import { blitFrame, encodePng, rgbaBuffer } from "./png.js"; -import { prepareSortedItems, projectItemGeometry } from "./sorting.js"; +import { prepareSortedItems } from "./sorting.js"; + +const SCENE_CACHE_VERSION = "v3-atlas-scene"; function nowIso() { return new Date().toISOString(); @@ -28,9 +31,9 @@ function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } -const DOWNLOAD_CACHE_ROOT = path.join(TILE_CACHE_ROOT, "downloads"); -const RENDER_CACHE_VERSION = "v2-overlays-as-sprites"; -sharp.cache(false); +function sha1(value) { + return crypto.createHash("sha1").update(value).digest("hex"); +} function normalizeBuildOptions(options = {}) { return { @@ -39,99 +42,28 @@ function normalizeBuildOptions(options = {}) { }; } -function buildOptionSuffix(options) { - return `${RENDER_CACHE_VERSION}_editor-${options.includeEditor ? "on" : "off"}_roofs-${options.includeRoofs ? "on" : "off"}`; -} - function toHex(value, width = 4) { return `0x${value.toString(16).padStart(width, "0")}`; } -function createEmptyProjection() { - return { - projected: [], - invalidItemCount: 0, - invalidItems: [] - }; +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 computeBoundsFromNodes(nodes) { - if (!nodes.length) { - return null; - } - - let minLeft = Number.MAX_SAFE_INTEGER; - let minTop = Number.MAX_SAFE_INTEGER; - let maxRight = -Number.MAX_SAFE_INTEGER; - let maxBottom = -Number.MAX_SAFE_INTEGER; - - for (const node of nodes) { - minLeft = Math.min(minLeft, node.left); - minTop = Math.min(minTop, node.top); - maxRight = Math.max(maxRight, node.right); - maxBottom = Math.max(maxBottom, node.bottom); - } - - return { - minLeft, - minTop, - maxRight, - maxBottom, - width: maxRight - minLeft, - height: maxBottom - minTop - }; +function fileStamp(filePath) { + const stat = fs.statSync(filePath); + return `${path.basename(filePath)}:${stat.size}:${Math.trunc(stat.mtimeMs)}`; } -function mergeBounds(boundsList) { - const validBounds = boundsList.filter(Boolean); - if (!validBounds.length) { - return null; - } - - const minLeft = Math.min(...validBounds.map((bounds) => bounds.minLeft)); - const minTop = Math.min(...validBounds.map((bounds) => bounds.minTop)); - const maxRight = Math.max(...validBounds.map((bounds) => bounds.maxRight)); - const maxBottom = Math.max(...validBounds.map((bounds) => bounds.maxBottom)); - - return { - minLeft, - minTop, - maxRight, - maxBottom, - width: maxRight - minLeft, - height: maxBottom - minTop - }; -} - -function isOverlayItem(item, shapeInfos) { - return item.shape < shapeInfos.length && shapeInfos[item.shape].isEditor; -} - -function splitRenderItems(renderItems, shapeInfos, includeEditor) { - if (!includeEditor) { - return { - baseItems: renderItems, - overlayItems: [] - }; - } - - const baseItems = []; - const overlayItems = []; - for (const item of renderItems) { - if (isOverlayItem(item, shapeInfos)) { - overlayItems.push(item); - } else { - baseItems.push(item); - } - } - - return { - baseItems, - overlayItems - }; -} - -function classifyOverlayKind(item, info) { +function classifySceneKind(item, info) { if ((item.flags & FLAG_INVISIBLE) || info.isOccl || info.isInvitem) { return "helper"; } @@ -141,23 +73,33 @@ function classifyOverlayKind(item, info) { if (info.isRoof) { return "roof"; } - return "editor"; + if (info.isLand) { + return "terrain"; + } + if (info.isEditor) { + return "editor"; + } + return "base"; } -function overlayLabel(kind) { - if (kind === "helper") { - return "Helper Geometry"; +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"; } - if (kind === "egg") { - return "Egg Trigger"; - } - if (kind === "roof") { - return "Roof Marker"; - } - return "Editor Object"; } -function overlayNotes(item, info) { +function sceneNotes(item, info) { const notes = []; if (item.flags & FLAG_INVISIBLE) { notes.push("invisible-flagged"); @@ -177,169 +119,34 @@ function overlayNotes(item, info) { if (info.isTranslucent) { notes.push("translucent"); } + if (info.isEditor) { + notes.push("editor-record"); + } return notes; } -function classifyBaseKind(info) { - if (info.isRoof) { - return "roof"; - } - if (info.isOccl || info.isInvitem) { - return "helper"; - } - if (info.isLand) { - return "terrain"; - } - return "base"; -} - -function inspectLabel(layer, kind) { - if (layer === "overlay") { - return overlayLabel(kind); - } - if (kind === "roof") { - return "Roof Shape"; - } +function presentationOpacity(kind, info) { if (kind === "helper") { - return "Occluding Helper"; + return 0.5; } - if (kind === "terrain") { - return "Terrain Shape"; + if (info.isTranslucent) { + return 0.7; } - return "Map Shape"; + return 1; } -function serializeInspectableItem(node, minLeft, minTop, id, layer, stackOrder) { - const { item, info, frame } = node; - const kind = layer === "overlay" ? classifyOverlayKind(item, info) : classifyBaseKind(info); - const sceneLeft = node.left - minLeft; - const sceneTop = node.top - minTop; - +function applyCatalogOverrides(info, catalogEntry) { + if (!catalogEntry) { + return info; + } return { - id, - layer, - stackOrder, - kind, - label: inspectLabel(layer, kind), - family: info.family, - 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, - shape: item.shape, - frame: item.frame, - frameSize: { - width: frame.width, - height: frame.height, - xoff: frame.xoff, - yoff: frame.yoff - }, - screen: { - left: sceneLeft, - top: sceneTop, - right: node.right - minLeft, - bottom: node.bottom - minTop, - width: node.right - node.left, - height: node.bottom - node.top, - anchorX: Math.trunc(sceneLeft + (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) - }, - traits: { - editor: info.isEditor, - roof: info.isRoof, - occluding: info.isOccl, - translucent: info.isTranslucent, - solid: info.isSolid, - fixed: info.isFixed, - land: info.isLand, - draw: info.isDraw, - invitem: info.isInvitem, - animType: info.animType - }, - notes: overlayNotes(item, info) + ...info, + isRoof: catalogEntry.roof ?? info.isRoof, + isTranslucent: catalogEntry.semitransparency ?? info.isTranslucent }; } -function serializeOverlayItem(node, minLeft, minTop, index) { - const { item, info, frame } = node; - const kind = classifyOverlayKind(item, info); - const sceneLeft = node.left - minLeft; - const sceneTop = node.top - minTop; - const screenWidth = node.right - node.left; - const screenHeight = node.bottom - node.top; - - return { - id: `${index}:${item.source}:${item.shape}:${item.frame}:${item.x}:${item.y}:${item.z}`, - index, - kind, - label: overlayLabel(kind), - family: info.family, - 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, - shape: item.shape, - frame: item.frame, - frameSize: { - width: frame.width, - height: frame.height, - xoff: frame.xoff, - yoff: frame.yoff - }, - screen: { - left: sceneLeft, - top: sceneTop, - right: node.right - minLeft, - bottom: node.bottom - minTop, - width: screenWidth, - height: screenHeight, - anchorX: Math.trunc(sceneLeft + screenWidth / 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) - }, - traits: { - editor: info.isEditor, - roof: info.isRoof, - occluding: info.isOccl, - translucent: info.isTranslucent, - solid: info.isSolid, - fixed: info.isFixed, - land: info.isLand, - draw: info.isDraw, - invitem: info.isInvitem, - animType: info.animType - }, - presentation: { - opacity: kind === "helper" ? 0.5 : info.isTranslucent ? 0.7 : 1 - }, - notes: overlayNotes(item, info) - }; -} - -function summarizeOverlayItems(items) { +function summarizeSceneItems(items) { const kindCounts = {}; const familyCounts = {}; const sourceCounts = {}; @@ -352,7 +159,7 @@ function summarizeOverlayItems(items) { const topFamilies = Object.entries(familyCounts) .sort((left, right) => right[1] - left[1] || Number(left[0]) - Number(right[0])) - .slice(0, 6) + .slice(0, 8) .map(([family, count]) => ({ family: Number(family), count })); return { @@ -380,21 +187,26 @@ function makeUsageInfo(gameId, mapId, baseItems, renderItems) { }; } -function buildEmptyMetadata(gameConfig, mapId, baseItems, reason) { - return { +function createEmptyScene(gameConfig, mapId, fingerprint, reason) { + const metadata = { game: gameConfig.id, gameLabel: gameConfig.label, map: mapId, - rawItemCount: baseItems.length, + rawItemCount: 0, itemCount: 0, - baseRasterItemCount: 0, - overlayItemCount: 0, paintedItemCount: 0, occludedItemCount: 0, invalidItemCount: 0, invalidItems: [], - overlaySummary: summarizeOverlayItems([]), - usage: makeUsageInfo(gameConfig.id, mapId, baseItems, []), + sceneSummary: { + atlasCount: 0, + spriteCount: 0, + helperCount: 0, + kindCounts: {}, + sourceCounts: {}, + topFamilies: [] + }, + usage: makeUsageInfo(gameConfig.id, mapId, [], []), baseItemSummary: { roofItems: 0, editorItems: 0, @@ -405,10 +217,6 @@ function buildEmptyMetadata(gameConfig, mapId, baseItems, reason) { sorter: "scummvm_dependency_graph", isEmpty: true, emptyReason: reason, - filters: { - includeEditor: true, - includeRoofs: false - }, bounds: { screenLeft: 0, screenTop: 0, @@ -417,26 +225,141 @@ function buildEmptyMetadata(gameConfig, mapId, baseItems, reason) { width: TILE_SIZE, height: TILE_SIZE }, - tileSize: TILE_SIZE, - tileCountX: 1, - tileCountY: 1, 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 + }; +} + export class BuildManager { constructor(catalog) { this.catalog = catalog; this.assetCache = new Map(); this.jobs = new Map(); this.jobsByKey = new Map(); - this.tileCache = new Map(); - ensureDir(TILE_CACHE_ROOT); + ensureDir(SCENE_CACHE_ROOT); } listCatalog() { @@ -447,9 +370,31 @@ export class BuildManager { 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); + } + async createOrReuseBuild(gameConfig, mapId, rawOptions = {}) { const options = normalizeBuildOptions(rawOptions); - const key = `${gameConfig.id}:${mapId}:${buildOptionSuffix(options)}`; + 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") { @@ -461,6 +406,7 @@ export class BuildManager { const job = { id: crypto.randomUUID(), key, + fingerprint, game: gameConfig.id, mapId, options, @@ -471,233 +417,36 @@ export class BuildManager { progress: [], error: null, metadata: null, - build: null + build: null, + promise: null }; this.jobs.set(job.id, job); this.jobsByKey.set(key, job); - void this.runBuild(job, gameConfig); + job.promise = this.runBuild(job, gameConfig, catalogInfo); return job; } - async runBuild(job, gameConfig) { + async runBuild(job, gameConfig, catalogInfo) { try { job.status = "building"; job.phase = "loading-assets"; - this.touchJob(job, `Loading ${gameConfig.label} assets`); - const assets = this.getAssets(gameConfig); + this.touchJob(job, `Preparing ${gameConfig.label} assets`); - job.phase = "loading-map"; - this.touchJob(job, `Loading map ${job.mapId}`); - const fixedDatPath = resolveStaticFile(gameConfig.staticDir, "FIXED.DAT"); - const baseItems = loadMapItems(fixedDatPath, job.mapId); - this.touchJob(job, `Loaded ${baseItems.length} fixed records`); - - job.phase = "collecting-items"; - const renderItems = collectRenderItems(baseItems, assets.shapeInfos, assets.globs, { - includeEditor: job.options.includeEditor, - expandGlobs: true, - worldRect: null, - includeRoofs: job.options.includeRoofs, - includeHiddenMarkers: true, - checkpointEvery: 2000, - progress: (message) => this.touchJob(job, message) - }); - if (!renderItems.length) { - job.build = { - assets, - prepared: [], - overlays: [], - overlayNodes: [], - overlayNodeById: new Map(), - inspectables: [], - minLeft: 0, - minTop: 0, - width: TILE_SIZE, - height: TILE_SIZE - }; - job.metadata = buildEmptyMetadata(gameConfig, job.mapId, baseItems, "This map has no renderable items in FIXED.DAT."); - job.metadata.filters = { - includeEditor: job.options.includeEditor, - includeRoofs: job.options.includeRoofs - }; - job.status = "ready"; - job.phase = "ready"; - this.touchJob(job, "Build ready: map is empty, serving a blank placeholder tile"); - return; - } - - const splitItems = splitRenderItems(renderItems, assets.shapeInfos, job.options.includeEditor); - this.touchJob( - job, - `Split ${splitItems.baseItems.length} base items and ${splitItems.overlayItems.length} overlay items` - ); - - job.phase = "sorting"; - const sorted = splitItems.baseItems.length - ? prepareSortedItems(splitItems.baseItems, assets.shapeArchive, assets.shapeInfos, { - checkpointEvery: 2000, - maxInvalidDetails: 20, - progress: (message) => this.touchJob(job, message) - }) - : { - minLeft: 0, - minTop: 0, - maxRight: TILE_SIZE, - maxBottom: TILE_SIZE, - prepared: [], - occludedCount: 0, - invalidItemCount: 0, - invalidItems: [] - }; - const overlayProjection = splitItems.overlayItems.length - ? projectItemGeometry(splitItems.overlayItems, assets.shapeArchive, assets.shapeInfos, { - checkpointEvery: 2000, - maxInvalidDetails: 20, - progress: (message) => this.touchJob(job, message) - }) - : createEmptyProjection(); - - if (!sorted.prepared.length && !overlayProjection.projected.length) { - job.build = { - assets, - prepared: [], - overlays: [], - overlayNodes: [], - overlayNodeById: new Map(), - inspectables: [], - minLeft: 0, - minTop: 0, - width: TILE_SIZE, - height: TILE_SIZE - }; - job.metadata = buildEmptyMetadata( - gameConfig, - job.mapId, - baseItems, - "This map resolved to no valid shape or frame pairs after decoding." - ); - job.metadata.filters = { - includeEditor: job.options.includeEditor, - includeRoofs: job.options.includeRoofs - }; - job.status = "ready"; - job.phase = "ready"; - this.touchJob(job, "Build ready: no valid frames were renderable, serving a blank placeholder tile"); - return; - } - - const bounds = mergeBounds([ - sorted.prepared.length - ? { - minLeft: sorted.minLeft, - minTop: sorted.minTop, - maxRight: sorted.maxRight, - maxBottom: sorted.maxBottom, - width: sorted.maxRight - sorted.minLeft, - height: sorted.maxBottom - sorted.minTop - } - : null, - computeBoundsFromNodes(overlayProjection.projected) - ]); - if (!bounds || bounds.width <= 0 || bounds.height <= 0) { - throw new Error("Computed image bounds are invalid"); - } - - const overlays = overlayProjection.projected.map((node, index) => - serializeOverlayItem(node, bounds.minLeft, bounds.minTop, index) - ); - const overlayNodeById = new Map(); - for (let index = 0; index < overlays.length; index += 1) { - overlayNodeById.set(overlays[index].id, overlayProjection.projected[index]); - } - const inspectables = []; - for (let index = 0; index < sorted.prepared.length; index += 1) { - const node = sorted.prepared[index]; - const stackOrder = node.order >= 0 ? node.order : index; - inspectables.push( - serializeInspectableItem( - node, - bounds.minLeft, - bounds.minTop, - `base:${stackOrder}:${node.item.source}:${node.item.shape}:${node.item.frame}:${node.item.x}:${node.item.y}:${node.item.z}`, - "base", - stackOrder - ) - ); - } - for (let index = 0; index < overlays.length; index += 1) { - inspectables.push( - serializeInspectableItem( - overlayProjection.projected[index], - bounds.minLeft, - bounds.minTop, - `overlay:${overlays[index].id}`, - "overlay", - sorted.prepared.length + index - ) - ); - } - inspectables.sort((left, right) => left.stackOrder - right.stackOrder); - const invalidItemCount = sorted.invalidItemCount + overlayProjection.invalidItemCount; - const invalidItems = [...sorted.invalidItems, ...overlayProjection.invalidItems].slice(0, 20); - - const metadata = { - game: gameConfig.id, - gameLabel: gameConfig.label, - map: job.mapId, - rawItemCount: baseItems.length, - itemCount: renderItems.length, - baseRasterItemCount: splitItems.baseItems.length, - overlayItemCount: overlays.length, - paintedItemCount: sorted.prepared.length, - occludedItemCount: sorted.occludedCount, - invalidItemCount, - invalidItems, - overlaySummary: summarizeOverlayItems(overlays), - usage: makeUsageInfo(gameConfig.id, job.mapId, baseItems, renderItems), - baseItemSummary: summarizeRenderClasses(baseItems, assets.shapeInfos), - sorter: "scummvm_dependency_graph", - isEmpty: false, - emptyReason: null, - filters: { - includeEditor: job.options.includeEditor, - includeRoofs: job.options.includeRoofs - }, - bounds: { - screenLeft: bounds.minLeft, - screenTop: bounds.minTop, - screenRight: bounds.maxRight, - screenBottom: bounds.maxBottom, - width: bounds.width, - height: bounds.height - }, - tileSize: TILE_SIZE, - tileCountX: Math.ceil(bounds.width / TILE_SIZE), - tileCountY: Math.ceil(bounds.height / TILE_SIZE), - zoom: { - min: 0.01, - max: 8, - step: 0.1, - initial: 1 + 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 = { - assets, - prepared: sorted.prepared, - overlays, - overlayNodes: overlayProjection.projected, - overlayNodeById, - inspectables, - minLeft: bounds.minLeft, - minTop: bounds.minTop, - width: bounds.width, - height: bounds.height - }; - job.metadata = metadata; + job.build = scene; + job.metadata = scene.metadata; job.status = "ready"; job.phase = "ready"; - this.touchJob(job, `Build ready with ${metadata.tileCountX}x${metadata.tileCountY} tiles`); + 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"; @@ -707,19 +456,244 @@ export class BuildManager { } getAssets(gameConfig) { - if (this.assetCache.has(gameConfig.id)) { - return this.assetCache.get(gameConfig.id); + 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(resolveStaticFile(gameConfig.staticDir, "GAMEPAL.PAL")), - shapeInfos: loadTypeflags(resolveStaticFile(gameConfig.staticDir, "TYPEFLAG.DAT")), - globs: loadGlobs(resolveStaticFile(gameConfig.staticDir, "GLOB.FLX")), - shapeArchive: new ShapeArchive(resolveStaticFile(gameConfig.staticDir, "SHAPES.FLX")) + palette: loadPalette(palettePath), + shapeInfos: loadTypeflags(typeflagPath), + globs: loadGlobs(globPath), + shapeArchive: new ShapeArchive(shapesPath) }; - this.assetCache.set(gameConfig.id, assets); + 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({ @@ -727,8 +701,8 @@ export class BuildManager { phase: job.phase, message }); - if (job.progress.length > 100) { - job.progress.splice(0, job.progress.length - 100); + if (job.progress.length > 120) { + job.progress.splice(0, job.progress.length - 120); } } @@ -737,7 +711,12 @@ export class BuildManager { id: job.id, game: job.game, mapId: job.mapId, - options: job.options, + options: { + includeEditor: true, + includeRoofs: true, + cacheMode: "single-scene" + }, + fingerprint: job.fingerprint, status: job.status, phase: job.phase, createdAt: job.createdAt, @@ -749,194 +728,50 @@ export class BuildManager { } getMetadata(jobId, gameId, mapId) { - const job = this.requireReadyJob(jobId, gameId, mapId); - return job.metadata; + return this.requireReadyJob(jobId, gameId, mapId).metadata; } - getOverlayData(jobId, gameId, mapId) { + getSceneData(jobId, gameId, mapId) { const job = this.requireReadyJob(jobId, gameId, mapId); return { - items: job.build.overlays, - summary: job.metadata.overlaySummary + 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 { - items: job.build.inspectables + shapeDefinitions: job.build.shapeDefinitions, + items: job.build.items }; } - async renderOverlaySprite(jobId, gameId, mapId, overlayId, format = "webp") { + getOverlayData(jobId, gameId, mapId) { const job = this.requireReadyJob(jobId, gameId, mapId); - const overlay = job.build.overlays.find((item) => item.id === overlayId); - const node = job.build.overlayNodeById.get(overlayId); - if (!overlay || !node) { - throw new Error("Unknown overlay id"); - } - - const extension = format === "png" ? "png" : "webp"; - const spritePath = path.join( - TILE_CACHE_ROOT, - gameId, - `map-${mapId}`, - buildOptionSuffix(job.options), - "overlays", - `${overlay.index}.${extension}` - ); - if (fs.existsSync(spritePath)) { - return fs.readFileSync(spritePath); - } - - const spriteWidth = Math.max(1, overlay.screen.width); - const spriteHeight = Math.max(1, overlay.screen.height); - const buffer = rgbaBuffer(spriteWidth, spriteHeight, [0, 0, 0, 0]); - blitFrame( - buffer, - spriteWidth, - spriteHeight, - 0, - 0, - node.frame, - node.pixels, - job.build.assets.palette, - Boolean(node.item.flags & FLAG_FLIPPED) - ); - - let output; - if (format === "png") { - output = encodePng(spriteWidth, spriteHeight, buffer); - } else { - output = await sharp(buffer, { - raw: { - width: spriteWidth, - height: spriteHeight, - channels: 4 - }, - limitInputPixels: false - }) - .webp({ lossless: true, effort: 4 }) - .toBuffer(); - } - - ensureDir(path.dirname(spritePath)); - fs.writeFileSync(spritePath, output); - return output; + 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) + }; } - async renderFullMap(jobId, gameId, mapId) { + getAtlas(jobId, gameId, mapId, atlasId) { const job = this.requireReadyJob(jobId, gameId, mapId); - const outputPath = path.join( - DOWNLOAD_CACHE_ROOT, - gameId, - `map-${mapId}`, - `${gameId}-map-${mapId}-${buildOptionSuffix(job.options)}.png` - ); - if (fs.existsSync(outputPath)) { - return outputPath; + const atlas = job.build.atlasFiles.find((entry) => entry.id === atlasId); + if (!atlas || !fs.existsSync(atlas.filePath)) { + throw new Error("Unknown atlas id"); } - - ensureDir(path.dirname(outputPath)); - const composites = []; - for (let tileY = 0; tileY < job.metadata.tileCountY; tileY += 1) { - for (let tileX = 0; tileX < job.metadata.tileCountX; tileX += 1) { - composites.push({ - input: await this.renderTile(jobId, gameId, mapId, tileX, tileY, "png"), - left: tileX * job.metadata.tileSize, - top: tileY * job.metadata.tileSize - }); - } - } - - await sharp({ - create: { - width: job.metadata.bounds.width, - height: job.metadata.bounds.height, - channels: 4, - background: { r: 10, g: 12, b: 18, alpha: 1 } - }, - limitInputPixels: false - }) - .composite(composites) - .png() - .toFile(outputPath); - - return outputPath; - } - - async renderTile(jobId, gameId, mapId, tileX, tileY, format = "webp") { - const job = this.requireReadyJob(jobId, gameId, mapId); - const tileKey = `${job.id}:${format}:${tileX}:${tileY}`; - if (this.tileCache.has(tileKey)) { - return this.tileCache.get(tileKey); - } - - const extension = format === "png" ? "png" : "webp"; - - const tilePath = path.join( - TILE_CACHE_ROOT, - gameId, - `map-${mapId}`, - buildOptionSuffix(job.options), - `${tileX}-${tileY}.${extension}` - ); - if (fs.existsSync(tilePath)) { - const cached = fs.readFileSync(tilePath); - this.tileCache.set(tileKey, cached); - return cached; - } - - const tileLeft = tileX * TILE_SIZE; - const tileTop = tileY * TILE_SIZE; - const tileWidth = Math.max(0, Math.min(TILE_SIZE, job.build.width - tileLeft)); - const tileHeight = Math.max(0, Math.min(TILE_SIZE, job.build.height - tileTop)); - if (tileWidth <= 0 || tileHeight <= 0) { - throw new Error("Requested tile is outside the rendered map bounds"); - } - - const buffer = rgbaBuffer(tileWidth, tileHeight); - const screenLeft = job.build.minLeft + tileLeft; - const screenTop = job.build.minTop + tileTop; - const screenRight = screenLeft + tileWidth; - const screenBottom = screenTop + tileHeight; - - for (const node of job.build.prepared) { - if (node.right <= screenLeft || node.left >= screenRight || node.bottom <= screenTop || node.top >= screenBottom) { - continue; - } - blitFrame( - buffer, - tileWidth, - tileHeight, - node.left - screenLeft, - node.top - screenTop, - node.frame, - node.pixels, - job.build.assets.palette, - Boolean(node.item.flags & FLAG_FLIPPED) - ); - } - - let output; - if (format === "png") { - output = encodePng(tileWidth, tileHeight, buffer); - } else { - output = await sharp(buffer, { - raw: { - width: tileWidth, - height: tileHeight, - channels: 4 - }, - limitInputPixels: false - }) - .webp({ lossless: true, effort: 4 }) - .toBuffer(); - } - ensureDir(path.dirname(tilePath)); - fs.writeFileSync(tilePath, output); - this.tileCache.set(tileKey, output); - return output; + return fs.readFileSync(atlas.filePath); } requireReadyJob(jobId, gameId, mapId) { diff --git a/map_renderer/src/lib/catalog.js b/map_renderer/src/lib/catalog.js index a58eb64..110ff47 100644 --- a/map_renderer/src/lib/catalog.js +++ b/map_renderer/src/lib/catalog.js @@ -1,8 +1,150 @@ +import crypto from "node:crypto"; import fs from "node:fs"; +import path from "node:path"; -import { GAMES } from "../config.js"; +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(); + +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")) + }; +} + +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 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 detectCatalog() { const games = []; for (const game of GAMES) { diff --git a/map_renderer/src/public/app.css b/map_renderer/src/public/app.css index 094ab44..b73532b 100644 --- a/map_renderer/src/public/app.css +++ b/map_renderer/src/public/app.css @@ -1,5 +1,6 @@ :root { color-scheme: light dark; + --panel-width: 360px; --bg: #f1ead6; --panel: rgba(255, 248, 232, 0.92); --card: rgba(255, 255, 255, 0.58); @@ -8,13 +9,7 @@ --muted: #6e5a37; --accent: #0d6c7d; --accent-strong: #114f59; - --overlay-editor: #18849a; - --overlay-egg: #c67129; - --overlay-roof: #627894; - --overlay-helper-fill: rgba(77, 169, 196, 0.16); - --overlay-helper-stroke: rgba(108, 201, 228, 0.72); --viewport: #0e1218; - --tile-border: rgba(255, 255, 255, 0.04); --shadow: 0 18px 45px rgba(59, 40, 8, 0.16); --font-ui: "Segoe UI Variable Text", "Aptos", "Trebuchet MS", sans-serif; } @@ -30,7 +25,6 @@ --accent: #46a7bc; --accent-strong: #2a7b8d; --viewport: #06080d; - --tile-border: rgba(255, 255, 255, 0.03); --shadow: 0 18px 45px rgba(0, 0, 0, 0.35); } } @@ -39,6 +33,11 @@ box-sizing: border-box; } +html, +body { + min-height: 100%; +} + body { margin: 0; min-height: 100vh; @@ -51,8 +50,8 @@ body { .shell { display: grid; - grid-template-columns: 340px minmax(0, 1fr); - min-height: 100vh; + grid-template-columns: minmax(280px, var(--panel-width)) 12px minmax(0, 1fr); + height: 100vh; } .panel { @@ -61,6 +60,22 @@ body { 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 { @@ -86,7 +101,10 @@ label { } select, -.action-link { +.action-link, +.button-row button, +.hidden-item-button, +.tooltip-action { width: 100%; border-radius: 12px; border: 1px solid rgba(65, 48, 21, 0.18); @@ -98,7 +116,6 @@ select, cursor: pointer; color: white; background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%); - text-decoration: none; text-align: center; } @@ -114,6 +131,26 @@ select:disabled, 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 { @@ -150,6 +187,10 @@ select:disabled, font-weight: 700; } +.action-link + .action-link { + margin-top: 2px; +} + .status { min-height: 92px; } @@ -235,9 +276,33 @@ select:disabled, 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 { @@ -246,84 +311,78 @@ select:disabled, 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); + 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 { - position: absolute; - left: 0; - top: 0; - transform-origin: top left; - will-change: transform; -} - -.layer { - position: absolute; - inset: 0 auto auto 0; -} - -.tile { - position: absolute; - image-rendering: pixelated; - image-rendering: crisp-edges; - pointer-events: none; -} - -.overlay-root { +.scene-canvas { position: absolute; inset: 0; - pointer-events: none; -} - -.overlay-item { - position: absolute; - pointer-events: auto; - border: 0; - padding: 0; - color: inherit; - font: inherit; - background: none; - cursor: pointer; - transition: transform 140ms ease, filter 140ms ease; -} - -.viewport:not(.inspect-active) .overlay-item:hover, -.viewport:not(.inspect-active) .overlay-item:focus-visible { - transform: scale(1.05); - filter: drop-shadow(0 10px 18px rgba(0, 0, 0, 0.34)); -} - -.viewport.inspect-active .overlay-item { - pointer-events: none; - cursor: crosshair; -} - -.overlay-sprite { - display: block; width: 100%; height: 100%; image-rendering: pixelated; image-rendering: crisp-edges; - user-select: none; - pointer-events: none; } .overlay-tooltip { position: absolute; z-index: 6; - max-width: 290px; + 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: none; + 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 { @@ -414,25 +473,43 @@ select:disabled, display: grid; place-items: center; padding: 24px; - color: rgba(255,255,255,0.72); + color: rgba(255, 255, 255, 0.72); text-align: center; } .empty-state.is-hidden { - display: none; + opacity: 0; + pointer-events: none; } -@media (max-width: 900px) { +@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; } -} +} \ No newline at end of file diff --git a/map_renderer/src/public/app.js b/map_renderer/src/public/app.js index 3b2c132..d02ebd6 100644 --- a/map_renderer/src/public/app.js +++ b/map_renderer/src/public/app.js @@ -4,14 +4,18 @@ const includeEditorCheckbox = document.querySelector("#include-editor"); const includeRoofsCheckbox = document.querySelector("#include-roofs"); const inspectShapesCheckbox = document.querySelector("#inspect-shapes"); const downloadButton = document.querySelector("#download-button"); +const hiddenExportButton = document.querySelector("#hidden-export-button"); +const catalogExportButtons = document.querySelector("#catalog-export-buttons"); const spinner = document.querySelector("#spinner"); const progressWrap = document.querySelector("#progress-wrap"); const progressFill = document.querySelector("#progress-fill"); const statusBox = document.querySelector("#status"); const metaBox = document.querySelector("#meta"); +const hiddenList = document.querySelector("#hidden-list"); +const hiddenEmpty = document.querySelector("#hidden-empty"); const viewport = document.querySelector("#viewport"); const viewportHint = document.querySelector("#viewport-hint"); -const scene = document.querySelector("#scene"); +const canvas = document.querySelector("#scene-canvas"); const inspectHighlight = document.querySelector("#inspect-highlight"); const overlayTooltip = document.querySelector("#overlay-tooltip"); const emptyState = document.querySelector("#empty-state"); @@ -20,9 +24,12 @@ const zoomInButton = document.querySelector("#zoom-in"); const zoomOutButton = document.querySelector("#zoom-out"); const zoomResetButton = document.querySelector("#zoom-reset"); const zoomFitButton = document.querySelector("#zoom-fit"); +const panelResizer = document.querySelector("#panel-resizer"); + +const context = canvas.getContext("2d", { alpha: true }); -let activeLayer = document.querySelector("#active-layer"); let autoBuildTimer = null; +let renderFrame = 0; const state = { catalog: null, @@ -33,13 +40,18 @@ const state = { buildPollTimer: null, buildToken: 0, drag: null, + suppressNextClick: false, pointers: new Map(), pinch: null, - inspectTargetId: null + hoverItemId: null, + pinnedItemId: null, + panelResize: null }; const ZOOM_FACTOR = 1.2; const FIT_PADDING = 24; +const DEVICE_PIXEL_RATIO = Math.max(1, window.devicePixelRatio || 1); +const EXPORT_BACKGROUND = "#0a0c12"; function setEmptyStateVisible(visible) { emptyState.hidden = !visible; @@ -67,33 +79,17 @@ function setStatus(message) { statusBox.textContent = message; } -function setInspectMode(active) { - viewport.classList.toggle("inspect-active", active); - viewportHint.textContent = active - ? "Inspect mode: move the cursor to identify shapes. Drag still pans." - : "Drag to pan. Scroll or pinch to zoom."; - if (!active) { - state.inspectTargetId = null; - hideInspectHighlight(); - hideOverlayTooltip(); - } -} - -function escapeHtml(value) { - return String(value) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """); -} - function phaseProgress(build) { const phaseToValue = { queued: 5, - "loading-assets": 18, - "loading-map": 32, - "collecting-items": 58, - sorting: 84, + "loading-assets": 14, + "cache-check": 26, + "cache-hit": 100, + "loading-map": 36, + "collecting-items": 52, + sorting: 70, + "packing-atlases": 82, + "writing-atlases": 94, ready: 100, failed: 100 }; @@ -103,11 +99,7 @@ function phaseProgress(build) { function setLoadingState(active, build = null) { spinner.hidden = !active; progressWrap.hidden = !active; - if (active) { - progressFill.style.width = `${phaseProgress(build)}%`; - } else { - progressFill.style.width = "0%"; - } + progressFill.style.width = active ? `${phaseProgress(build)}%` : "0%"; } function setMeta(metadata) { @@ -116,11 +108,11 @@ function setMeta(metadata) { return; } - const overlayKinds = Object.entries(metadata.overlaySummary?.kindCounts ?? {}) + const kindSummary = Object.entries(metadata.sceneSummary?.kindCounts ?? {}) .sort((left, right) => left[0].localeCompare(right[0])) .map(([kind, count]) => `${kind}: ${count}`) .join(", "); - const overlayFamilies = (metadata.overlaySummary?.topFamilies ?? []) + const topFamilies = (metadata.sceneSummary?.topFamilies ?? []) .map((entry) => `family ${entry.family} (${entry.count})`) .join(", "); @@ -131,34 +123,35 @@ function setMeta(metadata) {
Game
${metadata.gameLabel}
Map
${metadata.map}
Bounds
${metadata.bounds.width} x ${metadata.bounds.height}
-
Tiles
${metadata.tileCountX} x ${metadata.tileCountY}
+
Fingerprint
${metadata.buildFingerprint}
-

Render

+

Scene

Raw items
${metadata.rawItemCount}
-
Total renderables
${metadata.itemCount}
-
Base raster
${metadata.baseRasterItemCount}
-
Overlay items
${metadata.overlayItemCount}
-
Painted base
${metadata.paintedItemCount}
+
Renderables
${metadata.itemCount}
+
Painted items
${metadata.paintedItemCount}
+
Atlases
${metadata.sceneSummary?.atlasCount ?? 0}
+
Unique sprites
${metadata.sceneSummary?.spriteCount ?? 0}
Occluded
${metadata.occludedItemCount}
Invalid
${metadata.invalidItemCount}
-

Overlay

+

Families

-
Helper geometry
${metadata.overlaySummary?.helperCount ?? 0}
-
Kinds
${overlayKinds || "None"}
-
Top families
${overlayFamilies || "None"}
+
Helper geometry
${metadata.sceneSummary?.helperCount ?? 0}
+
Shape definitions
${state.current?.shapeDefinitions.size ?? 0}
+
Kinds
${kindSummary || "None"}
+
Top families
${topFamilies || "None"}
-

Filters

+

View

-
Editor-only
${metadata.filters.includeEditor ? "Shown" : "Hidden"}
-
Roofs
${metadata.filters.includeRoofs ? "Shown" : "Hidden"}
+
Editor-only
${includeEditorCheckbox.checked ? "Shown" : "Hidden"}
+
Roofs
${includeRoofsCheckbox.checked ? "Shown" : "Hidden"}
Empty map
${metadata.isEmpty ? "Yes" : "No"}
${metadata.emptyReason ? `

${metadata.emptyReason}

` : ""} @@ -173,27 +166,31 @@ function enableZoomControls(enabled) { zoomFitButton.disabled = !enabled; } -function setDownloadState(enabled, href = "#") { - downloadButton.href = href; +function setDownloadState(enabled) { downloadButton.classList.toggle("is-disabled", !enabled); downloadButton.setAttribute("aria-disabled", String(!enabled)); + downloadButton.disabled = !enabled; +} + +function setHiddenExportState(enabled) { + hiddenExportButton.classList.toggle("is-disabled", !enabled); + hiddenExportButton.setAttribute("aria-disabled", String(!enabled)); + hiddenExportButton.disabled = !enabled; } function updateZoomLabel() { zoomLabel.textContent = `Zoom: ${Math.round(state.zoom * 100)}%`; } -function updateSceneLayout() { - if (!state.current) { - scene.style.width = "0px"; - scene.style.height = "0px"; - scene.style.transform = "translate(0px, 0px) scale(1)"; - return; - } - const { metadata } = state.current; - scene.style.width = `${metadata.bounds.width}px`; - scene.style.height = `${metadata.bounds.height}px`; - scene.style.transform = `translate(${state.offsetX}px, ${state.offsetY}px) scale(${state.zoom})`; +function resizeCanvas() { + const width = Math.max(1, Math.floor(viewport.clientWidth)); + const height = Math.max(1, Math.floor(viewport.clientHeight)); + canvas.width = Math.floor(width * DEVICE_PIXEL_RATIO); + canvas.height = Math.floor(height * DEVICE_PIXEL_RATIO); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + context.setTransform(DEVICE_PIXEL_RATIO, 0, 0, DEVICE_PIXEL_RATIO, 0, 0); + context.imageSmoothingEnabled = false; } function clampZoom(nextZoom) { @@ -243,8 +240,8 @@ function setZoom(nextZoom, anchor = null) { state.offsetX = focus.x - worldX * state.zoom; state.offsetY = focus.y - worldY * state.zoom; clampOffsets(); - updateSceneLayout(); updateZoomLabel(); + scheduleRender(); } function fitMap() { @@ -256,281 +253,16 @@ function fitMap() { const scaleY = Math.max(0.01, (viewport.clientHeight - FIT_PADDING * 2) / height); state.zoom = clampZoom(Math.min(scaleX, scaleY)); clampOffsets(); - updateSceneLayout(); updateZoomLabel(); -} - -function tileUrl(buildContext, tileX, tileY) { - const { selected, jobId } = buildContext; - return `/api/maps/${selected.game}/${selected.mapId}/tiles/${tileX}/${tileY}.webp?buildId=${encodeURIComponent(jobId)}`; -} - -function overlaySpriteUrl(buildContext, overlay) { - const { selected, jobId } = buildContext; - return `/api/maps/${selected.game}/${selected.mapId}/overlays/${encodeURIComponent(overlay.id)}.webp?buildId=${encodeURIComponent(jobId)}`; -} - -function positionOverlayTooltip(clientX, clientY) { - const rect = viewport.getBoundingClientRect(); - const tooltipWidth = overlayTooltip.offsetWidth; - const tooltipHeight = overlayTooltip.offsetHeight; - const padding = 18; - let left = clientX - rect.left + 16; - let top = clientY - rect.top + 16; - - if (left + tooltipWidth + padding > rect.width) { - left = Math.max(padding, rect.width - tooltipWidth - padding); - } - if (top + tooltipHeight + padding > rect.height) { - top = Math.max(padding, rect.height - tooltipHeight - padding); - } - - overlayTooltip.style.left = `${left}px`; - overlayTooltip.style.top = `${top}px`; -} - -function renderShapeTooltip(shape) { - const notes = shape.notes.length ? shape.notes.map((note) => `
  • ${escapeHtml(note)}
  • `).join("") : ""; - return ` -
    ${escapeHtml(shape.label)}
    -
    Shape ${shape.shape} frame ${shape.frame}
    -
    -
    Layer
    ${escapeHtml(shape.layer)}
    -
    Family
    ${shape.family}
    -
    World
    ${shape.world.x}, ${shape.world.y}, ${shape.world.z}
    -
    Source
    ${escapeHtml(shape.source)}
    -
    Flags
    ${escapeHtml(shape.flags.hex)}
    -
    NPC
    ${shape.npcNum || "-"}
    -
    Map
    ${shape.mapNum || "-"}
    -
    Quality
    ${shape.quality || "-"}
    -
    - ${notes ? `` : ""} - `; -} - -function hideOverlayTooltip() { - overlayTooltip.hidden = true; - overlayTooltip.innerHTML = ""; -} - -function hideInspectHighlight() { - inspectHighlight.hidden = true; -} - -function showInspectHighlight(shape) { - inspectHighlight.hidden = false; - inspectHighlight.style.left = `${shape.screen.left}px`; - inspectHighlight.style.top = `${shape.screen.top}px`; - inspectHighlight.style.width = `${Math.max(1, shape.screen.width)}px`; - inspectHighlight.style.height = `${Math.max(1, shape.screen.height)}px`; -} - -function showShapeTooltip(shape, anchor) { - overlayTooltip.innerHTML = renderShapeTooltip(shape); - overlayTooltip.hidden = false; - - if (anchor instanceof Event) { - positionOverlayTooltip(anchor.clientX, anchor.clientY); - return; - } - - const rect = anchor.getBoundingClientRect(); - positionOverlayTooltip(rect.right, rect.top + rect.height / 2); -} - -function createOverlayElement(overlay) { - const item = document.createElement("button"); - item.type = "button"; - item.className = `overlay-item overlay-item--${overlay.kind}`; - item.setAttribute("aria-label", `${overlay.label}, family ${overlay.family}, shape ${overlay.shape}, frame ${overlay.frame}`); - item.style.left = `${overlay.screen.left}px`; - item.style.top = `${overlay.screen.top}px`; - item.style.width = `${Math.max(1, overlay.screen.width)}px`; - item.style.height = `${Math.max(1, overlay.screen.height)}px`; - item.style.opacity = String(overlay.presentation.opacity); - - const sprite = document.createElement("img"); - sprite.className = "overlay-sprite"; - sprite.alt = ""; - sprite.ariaHidden = "true"; - sprite.draggable = false; - sprite.loading = "eager"; - sprite.decoding = "async"; - sprite.width = Math.max(1, overlay.screen.width); - sprite.height = Math.max(1, overlay.screen.height); - item.append(sprite); - - item.addEventListener("pointerenter", (event) => { - if (inspectShapesCheckbox.checked) { - return; - } - showShapeTooltip(overlay, event); - }); - item.addEventListener("pointermove", (event) => { - if (inspectShapesCheckbox.checked || overlayTooltip.hidden) { - return; - } - positionOverlayTooltip(event.clientX, event.clientY); - }); - item.addEventListener("pointerleave", () => { - if (inspectShapesCheckbox.checked) { - return; - } - hideOverlayTooltip(); - }); - item.addEventListener("focus", () => { - if (inspectShapesCheckbox.checked) { - return; - } - showShapeTooltip(overlay, item); - }); - item.addEventListener("blur", () => { - if (inspectShapesCheckbox.checked) { - return; - } - hideOverlayTooltip(); - }); - - return { - item, - ready: waitForImage(sprite) - }; -} - -function clientToScenePoint(clientX, clientY) { - const rect = viewport.getBoundingClientRect(); - return { - x: (clientX - rect.left - state.offsetX) / state.zoom, - y: (clientY - rect.top - state.offsetY) / state.zoom - }; -} - -function findInspectableAtPoint(point) { - const items = state.current?.inspect?.items ?? []; - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index]; - if ( - point.x >= item.screen.left && - point.x < item.screen.right && - point.y >= item.screen.top && - point.y < item.screen.bottom - ) { - return item; - } - } - return null; -} - -function updateInspectHover(event) { - if (!inspectShapesCheckbox.checked || !state.current) { - return; - } - const target = findInspectableAtPoint(clientToScenePoint(event.clientX, event.clientY)); - if (!target) { - state.inspectTargetId = null; - hideInspectHighlight(); - hideOverlayTooltip(); - return; - } - if (state.inspectTargetId !== target.id) { - state.inspectTargetId = target.id; - showInspectHighlight(target); - showShapeTooltip(target, event); - return; - } - showInspectHighlight(target); - positionOverlayTooltip(event.clientX, event.clientY); -} - -function createTileElement(tileX, tileY, buildContext, metadata) { - const tileSize = metadata.tileSize; - const tile = document.createElement("img"); - tile.className = "tile"; - tile.alt = `Tile ${tileX},${tileY}`; - tile.loading = "eager"; - tile.decoding = "async"; - tile.draggable = false; - tile.src = tileUrl(buildContext, tileX, tileY); - tile.style.left = `${tileX * tileSize}px`; - tile.style.top = `${tileY * tileSize}px`; - tile.style.width = `${Math.min(tileSize, metadata.bounds.width - tileX * tileSize)}px`; - tile.style.height = `${Math.min(tileSize, metadata.bounds.height - tileY * tileSize)}px`; - return tile; -} - -function waitForImage(tile) { - if (tile.complete && tile.naturalWidth > 0) { - return tile.decode?.().catch(() => undefined) ?? Promise.resolve(); - } - return new Promise((resolve, reject) => { - tile.addEventListener( - "load", - () => { - const decodePromise = tile.decode?.().catch(() => undefined); - Promise.resolve(decodePromise).then(resolve); - }, - { once: true } - ); - tile.addEventListener("error", () => reject(new Error(`Failed to load ${tile.alt}`)), { once: true }); - }); -} - -async function buildLayer(buildContext) { - const layer = document.createElement("div"); - layer.className = "layer"; - layer.id = "active-layer"; - const { metadata } = buildContext; - layer.style.width = `${metadata.bounds.width}px`; - layer.style.height = `${metadata.bounds.height}px`; - - const tilePromises = []; - for (let tileY = 0; tileY < metadata.tileCountY; tileY += 1) { - for (let tileX = 0; tileX < metadata.tileCountX; tileX += 1) { - const tile = createTileElement(tileX, tileY, buildContext, metadata); - layer.append(tile); - tilePromises.push(waitForImage(tile)); - } - } - - const overlayRoot = document.createElement("div"); - overlayRoot.className = "overlay-root"; - const overlayPromises = []; - for (const overlay of buildContext.overlays.items) { - const overlayElement = createOverlayElement(overlay); - const sprite = overlayElement.item.querySelector("img"); - sprite.src = overlaySpriteUrl(buildContext, overlay); - overlayRoot.append(overlayElement.item); - overlayPromises.push(overlayElement.ready); - } - layer.append(overlayRoot); - - return { - layer, - ready: Promise.all([...tilePromises, ...overlayPromises]) - }; + scheduleRender(); } function getSelectedMap() { - if (!mapSelect.value) { - return null; - } - return JSON.parse(mapSelect.value); + return mapSelect.value ? JSON.parse(mapSelect.value) : null; } function currentSelectionMatches(selected) { - return Boolean( - state.current && - state.current.selected.game === selected.game && - state.current.selected.mapId === selected.mapId - ); -} - -function currentFiltersMatch() { - return Boolean( - state.current && - state.current.metadata.filters.includeEditor === includeEditorCheckbox.checked && - state.current.metadata.filters.includeRoofs === includeRoofsCheckbox.checked - ); + return Boolean(state.current && state.current.selected.game === selected.game && state.current.selected.mapId === selected.mapId); } function scheduleAutoBuild() { @@ -542,7 +274,7 @@ function scheduleAutoBuild() { setEmptyStateVisible(true); return; } - if (currentSelectionMatches(selected) && currentFiltersMatch()) { + if (currentSelectionMatches(selected)) { return; } startBuild(selected).catch((error) => { @@ -573,43 +305,528 @@ function populateCatalog(catalog) { mapSelect.disabled = catalog.games.length === 0; setDownloadState(false); + renderCatalogExportButtons(catalog.games); if (catalog.games.length === 0) { setStatus("No usable STATIC folders were detected under the app root."); } else { - setStatus("Select a map to build it immediately."); + setStatus("Select a map to build its cached scene immediately."); } } +function renderCatalogExportButtons(games) { + catalogExportButtons.innerHTML = ""; + for (const game of games) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "action-link"; + button.textContent = `Download ${game.label} CSV`; + button.addEventListener("click", async () => { + try { + setStatus(`Downloading ${game.label} shape catalog CSV...`); + await downloadByUrl(`/api/catalogs/${game.id}.csv`, `${game.id}-shape-catalog.csv`); + setStatus(`Ready. ${game.label} shape catalog CSV downloaded.`); + } catch (error) { + setStatus(error instanceof Error ? error.message : String(error)); + } + }); + catalogExportButtons.append(button); + } +} + +function atlasUrl(selected, jobId, atlasId) { + return `/api/maps/${selected.game}/${selected.mapId}/atlases/${atlasId}.png?buildId=${encodeURIComponent(jobId)}`; +} + +function loadImage(src) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.decoding = "async"; + image.onload = () => resolve(image); + image.onerror = () => reject(new Error(`Failed to load ${src}`)); + image.src = src; + }); +} + +async function loadSceneAssets(scene, selected, jobId) { + const atlasImages = new Map(); + await Promise.all( + scene.atlases.map(async (atlas) => { + atlasImages.set(atlas.id, await loadImage(atlasUrl(selected, jobId, atlas.id))); + }) + ); + return atlasImages; +} + +function sceneToViewportRect(item) { + return { + left: item.screen.left * state.zoom + state.offsetX, + top: item.screen.top * state.zoom + state.offsetY, + width: item.screen.width * state.zoom, + height: item.screen.height * state.zoom + }; +} + +function hideInspectHighlight() { + inspectHighlight.hidden = true; +} + +function showInspectHighlight(item) { + const rect = sceneToViewportRect(item); + inspectHighlight.hidden = false; + inspectHighlight.style.left = `${rect.left}px`; + inspectHighlight.style.top = `${rect.top}px`; + inspectHighlight.style.width = `${Math.max(1, rect.width)}px`; + inspectHighlight.style.height = `${Math.max(1, rect.height)}px`; +} + +function getItemById(itemId) { + return state.current?.itemIndex.get(itemId) ?? null; +} + +function getShapeDefinition(shapeDefId) { + return state.current?.shapeDefinitions.get(shapeDefId) ?? null; +} + +function getItemDisplay(item) { + const definition = getShapeDefinition(item.shapeDefId); + return { + definition, + displayName: definition?.displayName || item.shapeDefId, + description: definition?.description || "", + shapeHex: definition?.shapeHex || "-", + family: definition?.family ?? "-", + kind: definition?.kind || item.kind + }; +} + +function isItemVisible(item) { + if (!state.current || state.current.hiddenIds.has(item.id)) { + return false; + } + const definition = getShapeDefinition(item.shapeDefId); + if (!definition) { + return true; + } + if (!includeEditorCheckbox.checked && definition.traits?.editor) { + return false; + } + if (!includeRoofsCheckbox.checked && definition.traits?.roof) { + return false; + } + return true; +} + +function isEditorSelectableItem(item) { + if (!item || !includeEditorCheckbox.checked) { + return false; + } + const definition = getShapeDefinition(item.shapeDefId); + return Boolean(definition?.traits?.editor); +} + +function canPinItem(item) { + return Boolean(item) && (inspectShapesCheckbox.checked || isEditorSelectableItem(item)); +} + +function canKeepPinnedItemVisible() { + return isEditorSelectableItem(getItemById(state.pinnedItemId)); +} + +function canKeepHoverItemVisible() { + return isEditorSelectableItem(getItemById(state.hoverItemId)); +} + +function getFocusedItem() { + if (!state.current) { + return null; + } + if (state.pinnedItemId) { + return getItemById(state.pinnedItemId); + } + if (state.hoverItemId) { + return getItemById(state.hoverItemId); + } + return null; +} + +function eyeIconSvg(hidden) { + if (hidden) { + return ''; + } + return ''; +} + +function renderTooltip(item) { + const hidden = state.current?.hiddenIds.has(item.id) ?? false; + const display = getItemDisplay(item); + const notes = item.notes.length ? `` : ""; + overlayTooltip.innerHTML = ` +
    +
    +
    ${item.label}
    +
    ${display.displayName}
    +
    +
    + +
    +
    + ${hidden ? '
    Hidden
    ' : ""} +
    +
    Shape
    ${display.shapeHex} frame ${item.frame}
    +
    Kind
    ${display.kind}
    +
    Family
    ${display.family}
    +
    World
    ${item.world.x}, ${item.world.y}, ${item.world.z}
    +
    Source
    ${item.source}
    +
    Flags
    ${item.flags.hex}
    +
    NPC
    ${item.npcNum || "-"}
    +
    Map
    ${item.mapNum || "-"}
    +
    Quality
    ${item.quality || "-"}
    +
    + ${display.description ? `

    ${display.description}

    ` : ""} + ${notes} + `; + overlayTooltip.querySelector('[data-action="toggle-hidden"]')?.addEventListener("click", (event) => { + event.stopPropagation(); + toggleHidden(item.id); + }); + overlayTooltip.hidden = false; +} + +function positionTooltipForItem(item) { + const rect = sceneToViewportRect(item); + const tooltipWidth = overlayTooltip.offsetWidth; + const tooltipHeight = overlayTooltip.offsetHeight; + const padding = 18; + let left = rect.left + rect.width + 16; + let top = rect.top + Math.min(rect.height / 2, 48); + + if (left + tooltipWidth + padding > viewport.clientWidth) { + left = Math.max(padding, rect.left - tooltipWidth - 16); + } + if (top + tooltipHeight + padding > viewport.clientHeight) { + top = Math.max(padding, viewport.clientHeight - tooltipHeight - padding); + } + if (top < padding) { + top = padding; + } + + overlayTooltip.style.left = `${left}px`; + overlayTooltip.style.top = `${top}px`; +} + +function hideOverlayTooltip() { + overlayTooltip.hidden = true; + overlayTooltip.innerHTML = ""; +} + +function updateHiddenList() { + if (!state.current) { + hiddenList.innerHTML = ""; + hiddenEmpty.hidden = false; + return; + } + + const hiddenItems = [...state.current.hiddenIds].map((itemId) => getItemById(itemId)).filter(Boolean); + hiddenEmpty.hidden = hiddenItems.length > 0; + hiddenList.innerHTML = ""; + + for (const item of hiddenItems) { + const display = getItemDisplay(item); + const row = document.createElement("div"); + row.className = "hidden-item"; + row.innerHTML = ` +
    ${display.displayName}
    +
    ${display.shapeHex} ยท ${item.id}
    + + `; + row.querySelector("button").addEventListener("click", () => toggleHidden(item.id, false)); + hiddenList.append(row); + } + + setHiddenExportState(hiddenItems.length > 0); +} + +function syncOverlayState() { + if (!inspectShapesCheckbox.checked && !canKeepPinnedItemVisible() && !canKeepHoverItemVisible()) { + hideInspectHighlight(); + hideOverlayTooltip(); + return; + } + const item = getFocusedItem(); + if (!item) { + hideInspectHighlight(); + hideOverlayTooltip(); + return; + } + showInspectHighlight(item); + renderTooltip(item); + positionTooltipForItem(item); +} + +function scheduleRender() { + if (renderFrame) { + return; + } + renderFrame = window.requestAnimationFrame(() => { + renderFrame = 0; + renderScene(); + }); +} + +function drawSceneToContext(targetContext, canvasWidth, canvasHeight, scale, offsetX, offsetY, hiddenIds = new Set()) { + targetContext.save(); + targetContext.fillStyle = EXPORT_BACKGROUND; + targetContext.fillRect(0, 0, canvasWidth, canvasHeight); + targetContext.imageSmoothingEnabled = false; + + if (!state.current) { + targetContext.restore(); + return; + } + + for (const item of state.current.scene.items) { + if (hiddenIds.has(item.id) || !isItemVisible(item)) { + continue; + } + const sprite = state.current.spriteIndex.get(item.spriteId); + const atlas = sprite ? state.current.atlasImages.get(sprite.atlasId) : null; + if (!sprite || !atlas) { + continue; + } + + const left = item.screen.left * scale + offsetX; + const top = item.screen.top * scale + offsetY; + const width = item.screen.width * scale; + const height = item.screen.height * scale; + + if (left + width < 0 || top + height < 0 || left > canvasWidth || top > canvasHeight) { + continue; + } + + targetContext.globalAlpha = item.presentation.opacity ?? 1; + if (item.flags.flipped) { + targetContext.save(); + targetContext.translate(left + width, top); + targetContext.scale(-1, 1); + targetContext.drawImage(atlas, sprite.x, sprite.y, sprite.width, sprite.height, 0, 0, width, height); + targetContext.restore(); + } else { + targetContext.drawImage(atlas, sprite.x, sprite.y, sprite.width, sprite.height, left, top, width, height); + } + } + + targetContext.globalAlpha = 1; + targetContext.restore(); +} + +function renderScene() { + resizeCanvas(); + context.clearRect(0, 0, viewport.clientWidth, viewport.clientHeight); + drawSceneToContext(context, viewport.clientWidth, viewport.clientHeight, state.zoom, state.offsetX, state.offsetY, state.current?.hiddenIds ?? new Set()); + syncOverlayState(); +} + +function clientToScenePoint(clientX, clientY) { + const rect = viewport.getBoundingClientRect(); + return { + x: (clientX - rect.left - state.offsetX) / state.zoom, + y: (clientY - rect.top - state.offsetY) / state.zoom + }; +} + +function findItemAtPoint(point) { + if (!state.current) { + return null; + } + const items = state.current.scene.items; + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (!isItemVisible(item)) { + continue; + } + if ( + point.x >= item.screen.left && + point.x < item.screen.right && + point.y >= item.screen.top && + point.y < item.screen.bottom + ) { + return item; + } + } + return null; +} + +function updateInspectHover(event) { + if (!state.current || state.pinnedItemId) { + return; + } + const item = findItemAtPoint(clientToScenePoint(event.clientX, event.clientY)); + if (!inspectShapesCheckbox.checked && !isEditorSelectableItem(item)) { + state.hoverItemId = null; + syncOverlayState(); + return; + } + state.hoverItemId = item?.id ?? null; + syncOverlayState(); +} + +function toggleHidden(itemId, nextHidden = null) { + if (!state.current) { + return; + } + const shouldHide = nextHidden ?? !state.current.hiddenIds.has(itemId); + if (shouldHide) { + state.current.hiddenIds.add(itemId); + } else { + state.current.hiddenIds.delete(itemId); + } + updateHiddenList(); + if (state.current) { + setMeta(state.current.metadata); + } + scheduleRender(); +} + +function setInspectMode(active) { + viewport.classList.toggle("inspect-active", active); + viewportHint.textContent = active + ? "Inspect mode: click a shape to pin its tooltip. Drag still pans." + : "Drag to pan. Scroll or pinch to zoom. Editor objects remain clickable when visible."; + if (!active) { + state.hoverItemId = null; + if (!canKeepPinnedItemVisible()) { + state.pinnedItemId = null; + hideInspectHighlight(); + hideOverlayTooltip(); + } else { + syncOverlayState(); + } + } else { + syncOverlayState(); + } +} + +async function downloadCurrentScene() { + if (!state.current) { + return; + } + const { width, height } = state.current.metadata.bounds; + const exportCanvas = document.createElement("canvas"); + exportCanvas.width = Math.max(1, width); + exportCanvas.height = Math.max(1, height); + const exportContext = exportCanvas.getContext("2d", { alpha: true }); + exportContext.imageSmoothingEnabled = false; + drawSceneToContext(exportContext, width, height, 1, 0, 0, state.current.hiddenIds); + + const blob = await new Promise((resolve, reject) => { + exportCanvas.toBlob((value) => { + if (value) { + resolve(value); + } else { + reject(new Error("Failed to encode PNG export")); + } + }, "image/png"); + }); + + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${state.current.selected.game}-map-${state.current.selected.mapId}.png`; + link.click(); + URL.revokeObjectURL(url); +} + +function downloadBlob(blob, fileName) { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + link.click(); + URL.revokeObjectURL(url); +} + +async function downloadByUrl(url, fileName) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const blob = await response.blob(); + downloadBlob(blob, fileName); +} + +function buildHiddenExportPayload() { + if (!state.current) { + return null; + } + + const grouped = new Map(); + for (const itemId of state.current.hiddenIds) { + const item = getItemById(itemId); + if (!item) { + continue; + } + const display = getItemDisplay(item); + if (!grouped.has(item.shapeDefId)) { + grouped.set(item.shapeDefId, { + shapeDefId: item.shapeDefId, + shape: display.definition?.shape ?? null, + shapeHex: display.shapeHex, + displayName: display.displayName, + kind: display.kind, + hiddenItemIds: [] + }); + } + grouped.get(item.shapeDefId).hiddenItemIds.push(item.id); + } + + return { + game: state.current.selected.game, + mapId: state.current.selected.mapId, + includeEditor: includeEditorCheckbox.checked, + includeRoofs: includeRoofsCheckbox.checked, + exportedAt: new Date().toISOString(), + hiddenShapes: [...grouped.values()].sort((left, right) => left.shapeHex.localeCompare(right.shapeHex)) + }; +} + +async function exportHiddenShapes() { + const payload = buildHiddenExportPayload(); + if (!payload) { + return; + } + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); + downloadBlob(blob, `${payload.game}-map-${payload.mapId}-hidden-shapes.json`); +} + async function startBuild(selected) { clearTimeout(state.buildPollTimer); const token = ++state.buildToken; const preserveView = currentSelectionMatches(selected); hideOverlayTooltip(); + hideInspectHighlight(); setEmptyStateVisible(false); if (!state.current) { enableZoomControls(false); setMeta(null); setDownloadState(false); + setHiddenExportState(false); } setLoadingState(true, { phase: "queued" }); - setStatus( preserveView - ? `Rebuilding ${selected.game} map ${selected.mapId}. The current view stays visible until the new tiles are ready.` + ? `Rebuilding ${selected.game} map ${selected.mapId}. The current camera stays in place until the new scene is ready.` : `Building ${selected.game} map ${selected.mapId}...` ); const build = await fetchJson("/api/builds", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ...selected, - includeEditor: includeEditorCheckbox.checked, - includeRoofs: includeRoofsCheckbox.checked - }) + body: JSON.stringify(selected) }); await pollBuild(build.id, selected, token, preserveView); @@ -619,7 +836,6 @@ async function pollBuild(jobId, selected, token, preserveView) { if (token !== state.buildToken) { return; } - const build = await fetchJson(`/api/builds/${encodeURIComponent(jobId)}`); if (token !== state.buildToken) { return; @@ -641,77 +857,116 @@ async function pollBuild(jobId, selected, token, preserveView) { return; } - const [metadata, overlays, inspect] = await Promise.all([ - fetchJson(`/api/maps/${selected.game}/${selected.mapId}/metadata?buildId=${encodeURIComponent(jobId)}`), - fetchJson(`/api/maps/${selected.game}/${selected.mapId}/overlays?buildId=${encodeURIComponent(jobId)}`), - fetchJson(`/api/maps/${selected.game}/${selected.mapId}/inspect?buildId=${encodeURIComponent(jobId)}`) - ]); + const scene = await fetchJson(`/api/maps/${selected.game}/${selected.mapId}/scene?buildId=${encodeURIComponent(jobId)}`); if (token !== state.buildToken) { return; } - const nextContext = { selected, jobId, metadata, overlays, inspect }; - const nextLayerBuild = await buildLayer(nextContext); + setStatus(`Loading ${scene.atlases.length} atlas image${scene.atlases.length === 1 ? "" : "s"} for ${selected.game} map ${selected.mapId}...`); + const atlasImages = await loadSceneAssets(scene, selected, jobId); if (token !== state.buildToken) { return; } - state.current = nextContext; - setMeta(metadata); - setDownloadState( - true, - `/api/maps/${selected.game}/${selected.mapId}/download.png?buildId=${encodeURIComponent(jobId)}` - ); + state.current = { + selected, + jobId, + metadata: scene.metadata, + scene, + atlasImages, + spriteIndex: new Map(scene.sprites.map((sprite) => [sprite.id, sprite])), + shapeDefinitions: new Map(scene.shapeDefinitions.map((definition) => [definition.id, definition])), + itemIndex: new Map(scene.items.map((item) => [item.id, item])), + hiddenIds: new Set() + }; + state.hoverItemId = null; + state.pinnedItemId = null; + setMeta(scene.metadata); + updateHiddenList(); + setDownloadState(scene.items.length > 0); + setHiddenExportState(false); setEmptyStateVisible(false); enableZoomControls(true); if (!preserveView) { - activeLayer.replaceWith(nextLayerBuild.layer); - activeLayer = nextLayerBuild.layer; fitMap(); - setStatus(`Rendering complete. Streaming tiles for ${selected.game} map ${selected.mapId}...`); - nextLayerBuild.ready.then(() => { - if (token !== state.buildToken) { - return; - } - setLoadingState(false); - setStatus(`Ready. ${selected.game} map ${selected.mapId} is fully loaded.`); - }).catch((error) => { - if (token !== state.buildToken) { - return; - } - setLoadingState(false); - setStatus(error instanceof Error ? error.message : String(error)); - }); - return; + } else { + clampOffsets(); + scheduleRender(); } - setStatus(`Rendering complete. Streaming updated tiles for ${selected.game} map ${selected.mapId}...`); - try { - await nextLayerBuild.ready; - } catch (error) { - if (token !== state.buildToken) { - return; - } - setLoadingState(false); - throw error; - } - if (token !== state.buildToken) { - return; - } - - activeLayer.replaceWith(nextLayerBuild.layer); - activeLayer = nextLayerBuild.layer; - clampOffsets(); - updateSceneLayout(); - updateZoomLabel(); setLoadingState(false); - setStatus(`Ready. ${selected.game} map ${selected.mapId} is fully loaded.`); + setStatus(`Ready. ${selected.game} map ${selected.mapId} is atlas-backed and fully loaded.`); } async function loadCatalog() { - const catalog = await fetchJson("/api/maps"); - populateCatalog(catalog); + populateCatalog(await fetchJson("/api/maps")); +} + +function handleViewportClick(event) { + if (event.target.closest("#overlay-tooltip") || !state.current) { + return; + } + if (state.suppressNextClick) { + state.suppressNextClick = false; + return; + } + + const item = findItemAtPoint(clientToScenePoint(event.clientX, event.clientY)); + if (!inspectShapesCheckbox.checked && !state.pinnedItemId && !isEditorSelectableItem(item)) { + return; + } + + if (!canPinItem(item)) { + state.pinnedItemId = null; + state.hoverItemId = null; + } else { + state.pinnedItemId = state.pinnedItemId === item.id ? null : item.id; + state.hoverItemId = state.pinnedItemId ? null : item.id; + } + syncOverlayState(); +} + +function releasePointer(event) { + state.pointers.delete(event.pointerId); + if (viewport.hasPointerCapture(event.pointerId)) { + viewport.releasePointerCapture(event.pointerId); + } + if (state.pointers.size < 2) { + state.pinch = null; + } + if (state.drag?.pointerId === event.pointerId) { + state.suppressNextClick = state.drag.moved; + state.drag = null; + } + if (state.pointers.size === 0) { + viewport.classList.remove("is-dragging"); + } +} + +function beginPanelResize(event) { + event.preventDefault(); + state.panelResize = { + startX: event.clientX, + startWidth: Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--panel-width")) || 360 + }; + panelResizer.classList.add("is-dragging"); +} + +function updatePanelResize(event) { + if (!state.panelResize) { + return; + } + const nextWidth = Math.max(280, Math.min(window.innerWidth * 0.65, state.panelResize.startWidth + (event.clientX - state.panelResize.startX))); + document.documentElement.style.setProperty("--panel-width", `${nextWidth}px`); +} + +function endPanelResize() { + if (!state.panelResize) { + return; + } + state.panelResize = null; + panelResizer.classList.remove("is-dragging"); } mapForm.addEventListener("submit", async (event) => { @@ -721,7 +976,6 @@ mapForm.addEventListener("submit", async (event) => { setStatus("Choose a map first."); return; } - try { await startBuild(selected); } catch (error) { @@ -730,42 +984,68 @@ mapForm.addEventListener("submit", async (event) => { }); mapSelect.addEventListener("change", scheduleAutoBuild); -includeEditorCheckbox.addEventListener("change", scheduleAutoBuild); -includeRoofsCheckbox.addEventListener("change", scheduleAutoBuild); +includeEditorCheckbox.addEventListener("change", () => { + if (state.current) { + setMeta(state.current.metadata); + scheduleRender(); + } +}); +includeRoofsCheckbox.addEventListener("change", () => { + if (state.current) { + setMeta(state.current.metadata); + scheduleRender(); + } +}); inspectShapesCheckbox.addEventListener("change", () => { setInspectMode(inspectShapesCheckbox.checked); - if (!inspectShapesCheckbox.checked) { - hideOverlayTooltip(); + scheduleRender(); +}); + +downloadButton.addEventListener("click", async () => { + if (downloadButton.classList.contains("is-disabled")) { + return; + } + try { + setStatus("Encoding PNG export in the browser..."); + await downloadCurrentScene(); + setStatus(`Ready. ${state.current.selected.game} map ${state.current.selected.mapId} export created.`); + } catch (error) { + setStatus(error instanceof Error ? error.message : String(error)); } }); -downloadButton.addEventListener("click", (event) => { - if (downloadButton.classList.contains("is-disabled")) { - event.preventDefault(); +hiddenExportButton.addEventListener("click", async () => { + if (hiddenExportButton.classList.contains("is-disabled")) { + return; + } + try { + await exportHiddenShapes(); + setStatus(`Ready. ${state.current.selected.game} map ${state.current.selected.mapId} hidden shape export created.`); + } catch (error) { + setStatus(error instanceof Error ? error.message : String(error)); } }); +overlayTooltip.addEventListener("pointerdown", (event) => { + event.stopPropagation(); +}); + +overlayTooltip.addEventListener("pointerup", (event) => { + event.stopPropagation(); +}); + +overlayTooltip.addEventListener("click", (event) => { + event.stopPropagation(); +}); + +zoomInButton.addEventListener("click", () => setZoom(state.zoom * ZOOM_FACTOR)); +zoomOutButton.addEventListener("click", () => setZoom(state.zoom / ZOOM_FACTOR)); +zoomResetButton.addEventListener("click", () => setZoom(1)); +zoomFitButton.addEventListener("click", () => fitMap()); + window.addEventListener("resize", () => { - if (state.current) { - clampOffsets(); - updateSceneLayout(); - } -}); - -zoomInButton.addEventListener("click", () => { - setZoom(state.zoom * ZOOM_FACTOR); -}); - -zoomOutButton.addEventListener("click", () => { - setZoom(state.zoom / ZOOM_FACTOR); -}); - -zoomResetButton.addEventListener("click", () => { - setZoom(1); -}); - -zoomFitButton.addEventListener("click", () => { - fitMap(); + clampOffsets(); + scheduleRender(); }); viewport.addEventListener( @@ -790,19 +1070,24 @@ viewport.addEventListener("pointermove", (event) => { }); viewport.addEventListener("pointerleave", () => { - if (!inspectShapesCheckbox.checked) { + if (!inspectShapesCheckbox.checked || state.pinnedItemId) { return; } - state.inspectTargetId = null; - hideInspectHighlight(); - hideOverlayTooltip(); + state.hoverItemId = null; + syncOverlayState(); }); +viewport.addEventListener("click", handleViewportClick); + viewport.addEventListener("pointerdown", (event) => { if (!state.current) { return; } + if (event.target.closest("#overlay-tooltip")) { + return; + } event.preventDefault(); + state.suppressNextClick = false; viewport.setPointerCapture(event.pointerId); state.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); @@ -812,7 +1097,8 @@ viewport.addEventListener("pointerdown", (event) => { startX: event.clientX, startY: event.clientY, originX: state.offsetX, - originY: state.offsetY + originY: state.offsetY, + moved: false }; viewport.classList.add("is-dragging"); } @@ -851,41 +1137,35 @@ viewport.addEventListener("pointermove", (event) => { return; } + if (Math.abs(event.clientX - state.drag.startX) > 4 || Math.abs(event.clientY - state.drag.startY) > 4) { + state.drag.moved = true; + } state.offsetX = state.drag.originX + (event.clientX - state.drag.startX); state.offsetY = state.drag.originY + (event.clientY - state.drag.startY); clampOffsets(); - updateSceneLayout(); + scheduleRender(); }); -function releasePointer(event) { - state.pointers.delete(event.pointerId); - if (viewport.hasPointerCapture(event.pointerId)) { - viewport.releasePointerCapture(event.pointerId); - } - if (state.pointers.size < 2) { - state.pinch = null; - } - if (state.drag?.pointerId === event.pointerId) { - state.drag = null; - } - if (state.pointers.size === 0) { - viewport.classList.remove("is-dragging"); - } -} - viewport.addEventListener("pointerup", releasePointer); viewport.addEventListener("pointercancel", releasePointer); viewport.addEventListener("lostpointercapture", releasePointer); +panelResizer.addEventListener("pointerdown", beginPanelResize); +window.addEventListener("pointermove", updatePanelResize); +window.addEventListener("pointerup", endPanelResize); +window.addEventListener("pointercancel", endPanelResize); + enableZoomControls(false); updateZoomLabel(); setMeta(null); setDownloadState(false); +setHiddenExportState(false); setLoadingState(false); setEmptyStateVisible(true); hideOverlayTooltip(); -setInspectMode(false); hideInspectHighlight(); +resizeCanvas(); +setInspectMode(false); loadCatalog().catch((error) => { setStatus(error instanceof Error ? error.message : String(error)); -}); +}); \ No newline at end of file diff --git a/map_renderer/src/public/index.html b/map_renderer/src/public/index.html index 52202fd..2c83645 100644 --- a/map_renderer/src/public/index.html +++ b/map_renderer/src/public/index.html @@ -8,9 +8,9 @@
    -
    Zoom: --
    - Download PNG + + + + +
    + +
    @@ -51,6 +57,14 @@
    +
    + +
    +

    Hidden shapes will appear here and can be restored individually.

    +
    +
    +
    +
    @@ -59,13 +73,13 @@
    + +
    Drag to pan. Scroll or pinch to zoom.
    -
    -
    - -
    + +
    Choose a detected map to build and view it.
    @@ -74,4 +88,4 @@ - + \ No newline at end of file diff --git a/map_renderer/src/server.js b/map_renderer/src/server.js index fcd478d..5f884b2 100644 --- a/map_renderer/src/server.js +++ b/map_renderer/src/server.js @@ -3,7 +3,7 @@ import path from "node:path"; import { PORT, PUBLIC_ROOT } from "./config.js"; import { BuildManager } from "./lib/build-manager.js"; -import { detectCatalog, getGameConfig } from "./lib/catalog.js"; +import { detectCatalog, getGameConfig, getShapeCatalogFile } from "./lib/catalog.js"; const app = express(); const catalog = detectCatalog(); @@ -21,10 +21,6 @@ app.post("/api/builds", async (request, response) => { try { const game = String(request.body?.game ?? ""); const mapId = Number.parseInt(String(request.body?.mapId ?? ""), 10); - const options = { - includeEditor: request.body?.includeEditor !== false, - includeRoofs: request.body?.includeRoofs === true - }; const gameConfig = getGameConfig(game); if (!gameConfig) { response.status(400).json({ error: "Unknown game id" }); @@ -34,7 +30,7 @@ app.post("/api/builds", async (request, response) => { response.status(400).json({ error: "Invalid map id" }); return; } - const job = await builds.createOrReuseBuild(gameConfig, mapId, options); + 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) }); @@ -61,12 +57,12 @@ app.get("/api/maps/:game/:mapId/metadata", (request, response) => { } }); -app.get("/api/maps/:game/:mapId/overlays", (request, response) => { +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 overlays = builds.getOverlayData(buildId, request.params.game, mapId); - response.json(overlays); + 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) }); } @@ -83,74 +79,46 @@ app.get("/api/maps/:game/:mapId/inspect", (request, response) => { } }); -app.get("/api/maps/:game/:mapId/overlays/:overlayId.webp", async (request, response) => { +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 webp = await builds.renderOverlaySprite( - buildId, - request.params.game, - mapId, - request.params.overlayId, - "webp" - ); - response.setHeader("Content-Type", "image/webp"); - response.setHeader("Cache-Control", "public, max-age=31536000, immutable"); - response.end(webp); + 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/tiles/:tileX/:tileY.png", async (request, response) => { +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 tileX = Number.parseInt(request.params.tileX, 10); - const tileY = Number.parseInt(request.params.tileY, 10); - if (!Number.isInteger(tileX) || !Number.isInteger(tileY) || tileX < 0 || tileY < 0) { - response.status(400).json({ error: "Invalid tile coordinates" }); - return; - } - const png = await builds.renderTile(buildId, request.params.game, mapId, tileX, tileY, "png"); + 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(png); + response.end(atlas); } catch (error) { response.status(400).json({ error: error instanceof Error ? error.message : String(error) }); } }); -app.get("/api/maps/:game/:mapId/tiles/:tileX/:tileY.webp", async (request, response) => { - try { - const buildId = String(request.query.buildId ?? ""); - const mapId = Number.parseInt(request.params.mapId, 10); - const tileX = Number.parseInt(request.params.tileX, 10); - const tileY = Number.parseInt(request.params.tileY, 10); - if (!Number.isInteger(tileX) || !Number.isInteger(tileY) || tileX < 0 || tileY < 0) { - response.status(400).json({ error: "Invalid tile coordinates" }); +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; } - const webp = await builds.renderTile(buildId, request.params.game, mapId, tileX, tileY, "webp"); - response.setHeader("Content-Type", "image/webp"); - response.setHeader("Cache-Control", "public, max-age=31536000, immutable"); - response.end(webp); - } catch (error) { - response.status(400).json({ error: error instanceof Error ? error.message : String(error) }); - } -}); - -app.get("/api/maps/:game/:mapId/download.png", async (request, response) => { - try { - const buildId = String(request.query.buildId ?? ""); - const mapId = Number.parseInt(request.params.mapId, 10); - const filePath = await builds.renderFullMap(buildId, request.params.game, mapId); - response.setHeader("Content-Type", "image/png"); - response.setHeader("Content-Disposition", `attachment; filename="${path.basename(filePath)}"`); - response.sendFile(path.resolve(filePath), { dotfiles: "allow" }); - } catch (error) { - response.status(400).json({ error: error instanceof Error ? error.message : String(error) }); - } + if (!response.headersSent) { + response.status(404).json({ error: "Catalog CSV not found" }); + } + }); }); app.get("/api/health", (_request, response) => {