+
+ Drag to pan. Scroll or pinch to zoom.
+
+
+
+ Choose a detected map to build and view it.
+ diff --git a/map_renderer/.dockerignore b/map_renderer/.dockerignore new file mode 100644 index 0000000..409d710 --- /dev/null +++ b/map_renderer/.dockerignore @@ -0,0 +1,8 @@ +node_modules/ +.cache/ +coverage/ +dist/ +.env +.env.* +STATIC/ +STATIC_REGRET/ diff --git a/map_renderer/.gitignore b/map_renderer/.gitignore new file mode 100644 index 0000000..72376ee --- /dev/null +++ b/map_renderer/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +.cache/ +coverage/ +dist/ +.env +.env.* +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +STATIC/ +STATIC_REGRET/ diff --git a/map_renderer/Dockerfile b/map_renderer/Dockerfile new file mode 100644 index 0000000..b06d8b2 --- /dev/null +++ b/map_renderer/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev --no-audit --no-fund + +COPY src ./src + +ENV PORT=3000 +EXPOSE 3000 + +CMD ["npm", "start"] diff --git a/map_renderer/README.md b/map_renderer/README.md new file mode 100644 index 0000000..d87773c --- /dev/null +++ b/map_renderer/README.md @@ -0,0 +1,67 @@ +# Crusader Map Renderer + +Node web app that renders Crusader maps on the server and streams only finished PNG tiles to the browser. + +## Goals + +- Keep Crusader source assets server-side. +- Detect maps from `STATIC` and `STATIC_REGRET` automatically. +- Build map render state on demand after the user selects a map. +- Serve large maps as draggable and zoomable image tiles. +- Run locally with Node or inside Docker. + +## Local Run + +```powershell +cd map_renderer +npm install +npm start +``` + +Open `http://localhost:3000`. + +Viewer behavior: + +- drag with the mouse or one finger to pan +- use the scroll wheel to zoom directly at the pointer +- pinch to zoom on touch devices +- toggle roofs and editor-only elements independently before building + +The app expects asset folders under the app root: + +- `map_renderer/STATIC` +- `map_renderer/STATIC_REGRET` + +## Docker Run + +The Docker image excludes the Crusader assets on purpose. Mount them at runtime so they stay outside the image and are never served directly to clients. + +```powershell +cd map_renderer +docker build -t crusader-map-renderer . +docker run --rm -p 3000:3000 ` + -v ${PWD}/STATIC:/app/STATIC:ro ` + -v ${PWD}/STATIC_REGRET:/app/STATIC_REGRET:ro ` + crusader-map-renderer +``` + +If only one game is available, mount only that folder. + +## Docker Compose + +The compose file mounts `STATIC` and `STATIC_REGRET` from the host filesystem into the container as read-only volumes. They are excluded from the image build by `.dockerignore`, so the assets are never copied into the image. + +```powershell +cd map_renderer +docker compose up --build +``` + +## HTTP Surface + +- `GET /api/maps` returns the detected catalog. +- `POST /api/builds` starts or reuses a build. +- `GET /api/builds/:id` returns build status. +- `GET /api/maps/:game/:mapId/metadata?buildId=...` returns map bounds and tile settings. +- `GET /api/maps/:game/:mapId/tiles/:tileX/:tileY.png?buildId=...` returns rendered PNG tiles. + +No raw Crusader asset files are exposed over HTTP. diff --git a/map_renderer/compose.yaml b/map_renderer/compose.yaml new file mode 100644 index 0000000..614534d --- /dev/null +++ b/map_renderer/compose.yaml @@ -0,0 +1,13 @@ +services: + map-renderer: + build: + context: . + ports: + - "3000:3000" + environment: + PORT: "3000" + REMORSE_STATIC_DIR: /app/STATIC + REGRET_STATIC_DIR: /app/STATIC_REGRET + volumes: + - ./STATIC:/app/STATIC:ro + - ./STATIC_REGRET:/app/STATIC_REGRET:ro \ No newline at end of file diff --git a/map_renderer/package-lock.json b/map_renderer/package-lock.json new file mode 100644 index 0000000..ef8c63d --- /dev/null +++ b/map_renderer/package-lock.json @@ -0,0 +1,1387 @@ +{ + "name": "crusader-map-renderer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "crusader-map-renderer", + "version": "0.1.0", + "dependencies": { + "express": "^5.1.0", + "pngjs": "^7.0.0", + "sharp": "^0.34.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", + "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/map_renderer/package.json b/map_renderer/package.json new file mode 100644 index 0000000..51fb4c2 --- /dev/null +++ b/map_renderer/package.json @@ -0,0 +1,19 @@ +{ + "name": "crusader-map-renderer", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Server-side tiled Crusader map renderer for browser viewing.", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "express": "^5.1.0", + "pngjs": "^7.0.0", + "sharp": "^0.34.2" + } +} diff --git a/map_renderer/src/config.js b/map_renderer/src/config.js new file mode 100644 index 0000000..31803cf --- /dev/null +++ b/map_renderer/src/config.js @@ -0,0 +1,24 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const APP_ROOT = path.resolve(__dirname, ".."); +export const PUBLIC_ROOT = path.join(APP_ROOT, "src", "public"); +export const TILE_SIZE = Number.parseInt(process.env.TILE_SIZE ?? "1024", 10); +export const PORT = Number.parseInt(process.env.PORT ?? "3000", 10); +export const CACHE_ROOT = path.join(APP_ROOT, ".cache"); +export const TILE_CACHE_ROOT = path.join(CACHE_ROOT, "tiles"); +export const GAMES = [ + { + id: "remorse", + label: "No Remorse", + staticDir: process.env.REMORSE_STATIC_DIR || path.join(APP_ROOT, "STATIC") + }, + { + id: "regret", + label: "No Regret", + staticDir: process.env.REGRET_STATIC_DIR || path.join(APP_ROOT, "STATIC_REGRET") + } +]; diff --git a/map_renderer/src/lib/binary.js b/map_renderer/src/lib/binary.js new file mode 100644 index 0000000..04ea581 --- /dev/null +++ b/map_renderer/src/lib/binary.js @@ -0,0 +1,15 @@ +export function readU16LE(buffer, offset) { + return buffer.readUInt16LE(offset); +} + +export function readU24LE(buffer, offset) { + return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16); +} + +export function readU32LE(buffer, offset) { + return buffer.readUInt32LE(offset); +} + +export function readI32LE(buffer, offset) { + return buffer.readInt32LE(offset); +} diff --git a/map_renderer/src/lib/build-manager.js b/map_renderer/src/lib/build-manager.js new file mode 100644 index 0000000..c14d50f --- /dev/null +++ b/map_renderer/src/lib/build-manager.js @@ -0,0 +1,451 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import sharp from "sharp"; + +import { TILE_CACHE_ROOT, TILE_SIZE } from "../config.js"; +import { + FLAG_FLIPPED, + ShapeArchive, + collectRenderItems, + loadGlobs, + loadMapItems, + loadPalette, + loadTypeflags, + resolveStaticFile, + summarizeRenderClasses +} from "./formats.js"; +import { blitFrame, encodePng, rgbaBuffer } from "./png.js"; +import { prepareSortedItems } from "./sorting.js"; + +function nowIso() { + return new Date().toISOString(); +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +const DOWNLOAD_CACHE_ROOT = path.join(TILE_CACHE_ROOT, "downloads"); +sharp.cache(false); + +function normalizeBuildOptions(options = {}) { + return { + includeEditor: options.includeEditor !== false, + includeRoofs: options.includeRoofs === true + }; +} + +function buildOptionSuffix(options) { + return `editor-${options.includeEditor ? "on" : "off"}_roofs-${options.includeRoofs ? "on" : "off"}`; +} + +function makeUsageInfo(gameId, mapId, baseItems, renderItems) { + const itemMapNums = [...new Set(baseItems.map((item) => item.mapNum))].sort((left, right) => left - right); + return { + status: "unknown", + confidence: "unknown", + knownHints: [], + itemMapNums, + nonzeroItemMapNums: itemMapNums.filter((value) => value !== 0), + npcLinkedItemCount: baseItems.filter((item) => item.npcNum !== 0).length, + note: "No authoritative mission-to-map ownership table has been extracted yet. Unknown does not imply orphaned.", + hasRenderableContent: renderItems.length > 0, + game: gameId, + map: mapId + }; +} + +function buildEmptyMetadata(gameConfig, mapId, baseItems, reason) { + return { + game: gameConfig.id, + gameLabel: gameConfig.label, + map: mapId, + rawItemCount: baseItems.length, + itemCount: 0, + paintedItemCount: 0, + occludedItemCount: 0, + invalidItemCount: 0, + invalidItems: [], + usage: makeUsageInfo(gameConfig.id, mapId, baseItems, []), + baseItemSummary: { + roofItems: 0, + editorItems: 0, + eggFamilyItems: 0, + invisibleFlaggedItems: 0, + npcLinkedItems: 0 + }, + sorter: "scummvm_dependency_graph", + isEmpty: true, + emptyReason: reason, + filters: { + includeEditor: true, + includeRoofs: false + }, + bounds: { + screenLeft: 0, + screenTop: 0, + screenRight: TILE_SIZE, + screenBottom: TILE_SIZE, + width: TILE_SIZE, + height: TILE_SIZE + }, + tileSize: TILE_SIZE, + tileCountX: 1, + tileCountY: 1, + zoom: { + min: 0.01, + max: 8, + step: 0.1, + initial: 1 + } + }; +} + +export class BuildManager { + constructor(catalog) { + this.catalog = catalog; + this.assetCache = new Map(); + this.jobs = new Map(); + this.jobsByKey = new Map(); + this.tileCache = new Map(); + ensureDir(TILE_CACHE_ROOT); + } + + listCatalog() { + return this.catalog; + } + + getJob(jobId) { + return this.jobs.get(jobId) ?? null; + } + + async createOrReuseBuild(gameConfig, mapId, rawOptions = {}) { + const options = normalizeBuildOptions(rawOptions); + const key = `${gameConfig.id}:${mapId}:${buildOptionSuffix(options)}`; + const existing = this.jobsByKey.get(key); + if (existing) { + if (existing.status === "ready" || existing.status === "building") { + return existing; + } + this.jobsByKey.delete(key); + } + + const job = { + id: crypto.randomUUID(), + key, + game: gameConfig.id, + mapId, + options, + status: "queued", + phase: "queued", + createdAt: nowIso(), + updatedAt: nowIso(), + progress: [], + error: null, + metadata: null, + build: null + }; + this.jobs.set(job.id, job); + this.jobsByKey.set(key, job); + void this.runBuild(job, gameConfig); + return job; + } + + async runBuild(job, gameConfig) { + try { + job.status = "building"; + job.phase = "loading-assets"; + this.touchJob(job, `Loading ${gameConfig.label} assets`); + const assets = this.getAssets(gameConfig); + + job.phase = "loading-map"; + this.touchJob(job, `Loading map ${job.mapId}`); + const fixedDatPath = resolveStaticFile(gameConfig.staticDir, "FIXED.DAT"); + const baseItems = loadMapItems(fixedDatPath, job.mapId); + this.touchJob(job, `Loaded ${baseItems.length} fixed records`); + + job.phase = "collecting-items"; + const renderItems = collectRenderItems(baseItems, assets.shapeInfos, assets.globs, { + includeEditor: job.options.includeEditor, + expandGlobs: true, + worldRect: null, + includeRoofs: job.options.includeRoofs, + includeHiddenMarkers: true, + checkpointEvery: 2000, + progress: (message) => this.touchJob(job, message) + }); + if (!renderItems.length) { + job.build = { + assets, + prepared: [], + minLeft: 0, + minTop: 0, + width: TILE_SIZE, + height: TILE_SIZE + }; + job.metadata = buildEmptyMetadata(gameConfig, job.mapId, baseItems, "This map has no renderable items in FIXED.DAT."); + job.metadata.filters = { + includeEditor: job.options.includeEditor, + includeRoofs: job.options.includeRoofs + }; + job.status = "ready"; + job.phase = "ready"; + this.touchJob(job, "Build ready: map is empty, serving a blank placeholder tile"); + return; + } + + job.phase = "sorting"; + const sorted = prepareSortedItems(renderItems, assets.shapeArchive, assets.shapeInfos, { + checkpointEvery: 2000, + maxInvalidDetails: 20, + progress: (message) => this.touchJob(job, message) + }); + if (!sorted.prepared.length) { + job.build = { + assets, + prepared: [], + minLeft: 0, + minTop: 0, + width: TILE_SIZE, + height: TILE_SIZE + }; + job.metadata = buildEmptyMetadata( + gameConfig, + job.mapId, + baseItems, + "This map resolved to no valid shape or frame pairs after decoding." + ); + job.metadata.filters = { + includeEditor: job.options.includeEditor, + includeRoofs: job.options.includeRoofs + }; + job.status = "ready"; + job.phase = "ready"; + this.touchJob(job, "Build ready: no valid frames were renderable, serving a blank placeholder tile"); + return; + } + + const width = sorted.maxRight - sorted.minLeft; + const height = sorted.maxBottom - sorted.minTop; + if (width <= 0 || height <= 0) { + throw new Error("Computed image bounds are invalid"); + } + + const metadata = { + game: gameConfig.id, + gameLabel: gameConfig.label, + map: job.mapId, + rawItemCount: baseItems.length, + itemCount: renderItems.length, + paintedItemCount: sorted.prepared.length, + occludedItemCount: sorted.occludedCount, + invalidItemCount: sorted.invalidItemCount, + invalidItems: sorted.invalidItems, + usage: makeUsageInfo(gameConfig.id, job.mapId, baseItems, renderItems), + baseItemSummary: summarizeRenderClasses(baseItems, assets.shapeInfos), + sorter: "scummvm_dependency_graph", + isEmpty: false, + emptyReason: null, + filters: { + includeEditor: job.options.includeEditor, + includeRoofs: job.options.includeRoofs + }, + bounds: { + screenLeft: sorted.minLeft, + screenTop: sorted.minTop, + screenRight: sorted.maxRight, + screenBottom: sorted.maxBottom, + width, + height + }, + tileSize: TILE_SIZE, + tileCountX: Math.ceil(width / TILE_SIZE), + tileCountY: Math.ceil(height / TILE_SIZE), + zoom: { + min: 0.01, + max: 8, + step: 0.1, + initial: 1 + } + }; + + job.build = { + assets, + prepared: sorted.prepared, + minLeft: sorted.minLeft, + minTop: sorted.minTop, + width, + height + }; + job.metadata = metadata; + job.status = "ready"; + job.phase = "ready"; + this.touchJob(job, `Build ready with ${metadata.tileCountX}x${metadata.tileCountY} tiles`); + } catch (error) { + job.status = "failed"; + job.phase = "failed"; + job.error = error instanceof Error ? error.message : String(error); + this.touchJob(job, `Build failed: ${job.error}`); + } + } + + getAssets(gameConfig) { + if (this.assetCache.has(gameConfig.id)) { + return this.assetCache.get(gameConfig.id); + } + const assets = { + palette: loadPalette(resolveStaticFile(gameConfig.staticDir, "GAMEPAL.PAL")), + shapeInfos: loadTypeflags(resolveStaticFile(gameConfig.staticDir, "TYPEFLAG.DAT")), + globs: loadGlobs(resolveStaticFile(gameConfig.staticDir, "GLOB.FLX")), + shapeArchive: new ShapeArchive(resolveStaticFile(gameConfig.staticDir, "SHAPES.FLX")) + }; + this.assetCache.set(gameConfig.id, assets); + return assets; + } + + touchJob(job, message) { + job.updatedAt = nowIso(); + job.progress.push({ + at: job.updatedAt, + phase: job.phase, + message + }); + if (job.progress.length > 100) { + job.progress.splice(0, job.progress.length - 100); + } + } + + getPublicJob(job) { + return { + id: job.id, + game: job.game, + mapId: job.mapId, + options: job.options, + status: job.status, + phase: job.phase, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + error: job.error, + metadata: job.status === "ready" ? job.metadata : null, + progress: job.progress + }; + } + + getMetadata(jobId, gameId, mapId) { + const job = this.requireReadyJob(jobId, gameId, mapId); + return job.metadata; + } + + async renderFullMap(jobId, gameId, mapId) { + const job = this.requireReadyJob(jobId, gameId, mapId); + const outputPath = path.join( + DOWNLOAD_CACHE_ROOT, + gameId, + `map-${mapId}`, + `${buildOptionSuffix(job.options)}.png` + ); + if (fs.existsSync(outputPath)) { + return outputPath; + } + + ensureDir(path.dirname(outputPath)); + const composites = []; + for (let tileY = 0; tileY < job.metadata.tileCountY; tileY += 1) { + for (let tileX = 0; tileX < job.metadata.tileCountX; tileX += 1) { + composites.push({ + input: this.renderTile(jobId, gameId, mapId, tileX, tileY), + left: tileX * job.metadata.tileSize, + top: tileY * job.metadata.tileSize + }); + } + } + + await sharp({ + create: { + width: job.metadata.bounds.width, + height: job.metadata.bounds.height, + channels: 4, + background: { r: 10, g: 12, b: 18, alpha: 1 } + }, + limitInputPixels: false + }) + .composite(composites) + .png() + .toFile(outputPath); + + return outputPath; + } + + renderTile(jobId, gameId, mapId, tileX, tileY) { + const job = this.requireReadyJob(jobId, gameId, mapId); + const tileKey = `${job.id}:${tileX}:${tileY}`; + if (this.tileCache.has(tileKey)) { + return this.tileCache.get(tileKey); + } + + const tilePath = path.join( + TILE_CACHE_ROOT, + gameId, + `map-${mapId}`, + buildOptionSuffix(job.options), + `${tileX}-${tileY}.png` + ); + if (fs.existsSync(tilePath)) { + const cached = fs.readFileSync(tilePath); + this.tileCache.set(tileKey, cached); + return cached; + } + + const tileLeft = tileX * TILE_SIZE; + const tileTop = tileY * TILE_SIZE; + const tileWidth = Math.max(0, Math.min(TILE_SIZE, job.build.width - tileLeft)); + const tileHeight = Math.max(0, Math.min(TILE_SIZE, job.build.height - tileTop)); + if (tileWidth <= 0 || tileHeight <= 0) { + throw new Error("Requested tile is outside the rendered map bounds"); + } + + const buffer = rgbaBuffer(tileWidth, tileHeight); + const screenLeft = job.build.minLeft + tileLeft; + const screenTop = job.build.minTop + tileTop; + const screenRight = screenLeft + tileWidth; + const screenBottom = screenTop + tileHeight; + + for (const node of job.build.prepared) { + if (node.right <= screenLeft || node.left >= screenRight || node.bottom <= screenTop || node.top >= screenBottom) { + continue; + } + blitFrame( + buffer, + tileWidth, + tileHeight, + node.left - screenLeft, + node.top - screenTop, + node.frame, + node.pixels, + job.build.assets.palette, + Boolean(node.item.flags & FLAG_FLIPPED) + ); + } + + const png = encodePng(tileWidth, tileHeight, buffer); + ensureDir(path.dirname(tilePath)); + fs.writeFileSync(tilePath, png); + this.tileCache.set(tileKey, png); + return png; + } + + requireReadyJob(jobId, gameId, mapId) { + const job = this.getJob(jobId); + if (!job) { + throw new Error("Unknown build id"); + } + if (job.game !== gameId || job.mapId !== mapId) { + throw new Error("Build id does not match the requested map"); + } + if (job.status !== "ready") { + throw new Error("Build is not ready yet"); + } + return job; + } +} diff --git a/map_renderer/src/lib/catalog.js b/map_renderer/src/lib/catalog.js new file mode 100644 index 0000000..a58eb64 --- /dev/null +++ b/map_renderer/src/lib/catalog.js @@ -0,0 +1,34 @@ +import fs from "node:fs"; + +import { GAMES } from "../config.js"; +import { getMapSummaries, resolveStaticFile } from "./formats.js"; + +export function detectCatalog() { + const games = []; + for (const game of GAMES) { + const fixedDat = resolveStaticFile(game.staticDir, "FIXED.DAT"); + if (!fs.existsSync(fixedDat)) { + continue; + } + const maps = getMapSummaries(fixedDat) + .filter((map) => map.isValid && map.rawItemCount > 0) + .map((map) => ({ + id: map.id, + label: `${game.label} Map ${map.id}`, + rawItemCount: map.rawItemCount + })); + if (maps.length > 0) { + games.push({ + id: game.id, + label: game.label, + mapCount: maps.length, + maps + }); + } + } + return { games }; +} + +export function getGameConfig(gameId) { + return GAMES.find((game) => game.id === gameId) ?? null; +} diff --git a/map_renderer/src/lib/formats.js b/map_renderer/src/lib/formats.js new file mode 100644 index 0000000..830a28e --- /dev/null +++ b/map_renderer/src/lib/formats.js @@ -0,0 +1,475 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { readI32LE, readU16LE, readU24LE, readU32LE } from "./binary.js"; + +export const FLEX_TABLE_OFFSET = 0x80; +export const FLEX_COUNT_OFFSET = 0x54; +export const FIXED_MAP_COUNT_OFFSET = 0x54; +export const FIXED_MAP_TABLE_OFFSET = 0x80; +export const CRUSADER_COORD_SCALE = 2; +export const GLOB_COORD_MASK = ~0x3ff; +export const GLOB_COORD_SHIFT = 2; +export const GLOB_COORD_OFFSET = 2; +export const FLAG_INVISIBLE = 0x0010; +export const FLAG_FLIPPED = 0x0020; +export const EGG_FAMILIES = new Set([3, 4, 7, 8]); + +export const SI_FIXED = 0x0001; +export const SI_SOLID = 0x0002; +export const SI_LAND = 0x0008; +export const SI_OCCL = 0x0010; +export const SI_NOISY = 0x0080; +export const SI_DRAW = 0x0100; +export const SI_ROOF = 0x0400; +export const SI_TRANSL = 0x0800; + +export function getMapCount(fixedDatPath) { + const data = fs.readFileSync(fixedDatPath); + return readU16LE(data, FIXED_MAP_COUNT_OFFSET); +} + +export function getMapSummaries(fixedDatPath) { + const data = fs.readFileSync(fixedDatPath); + const mapCount = readU16LE(data, FIXED_MAP_COUNT_OFFSET); + const maps = []; + for (let mapId = 0; mapId < mapCount; mapId += 1) { + const tableOffset = FIXED_MAP_TABLE_OFFSET + mapId * 8; + const mapOffset = readU32LE(data, tableOffset); + const mapSize = readU32LE(data, tableOffset + 4); + maps.push({ + id: mapId, + offset: mapOffset, + byteSize: mapSize, + rawItemCount: Math.floor(mapSize / 16), + isValid: mapSize >= 16 + }); + } + return maps; +} + +export class FlexArchive { + constructor(filePath) { + this.path = filePath; + this.data = fs.readFileSync(filePath); + this.entries = FlexArchive.readEntries(this.data); + } + + static readEntries(data) { + const count = readU32LE(data, FLEX_COUNT_OFFSET); + const entries = []; + for (let index = 0; index < count; index += 1) { + const base = FLEX_TABLE_OFFSET + index * 8; + entries.push({ + offset: readU32LE(data, base), + size: readU32LE(data, base + 4) + }); + } + return entries; + } + + get(index) { + const entry = this.entries[index]; + if (!entry || entry.size === 0) { + return Buffer.alloc(0); + } + return this.data.subarray(entry.offset, entry.offset + entry.size); + } + + get length() { + return this.entries.length; + } +} + +export class ShapeArchive { + constructor(filePath) { + this.archive = new FlexArchive(filePath); + this.shapeCache = new Map(); + this.decodedFrameCache = new Map(); + } + + getFrame(shapeIndex, frameIndex) { + const frames = this.getShape(shapeIndex); + if (frameIndex < 0 || frameIndex >= frames.length) { + throw new RangeError(`shape ${shapeIndex} frame ${frameIndex} out of range`); + } + return frames[frameIndex]; + } + + decodeFrame(shapeIndex, frameIndex) { + const cacheKey = `${shapeIndex}:${frameIndex}`; + let decoded = this.decodedFrameCache.get(cacheKey); + const frame = this.getFrame(shapeIndex, frameIndex); + if (!decoded) { + decoded = decodePixels(frame); + this.decodedFrameCache.set(cacheKey, decoded); + } + return { frame, pixels: decoded }; + } + + getShape(shapeIndex) { + if (this.shapeCache.has(shapeIndex)) { + return this.shapeCache.get(shapeIndex); + } + const raw = this.archive.get(shapeIndex); + if (!raw.length) { + throw new Error(`shape ${shapeIndex} has no data`); + } + const frames = parseShape(raw); + this.shapeCache.set(shapeIndex, frames); + return frames; + } +} + +function parseShape(data) { + const frameCount = readU16LE(data, 4); + const frames = []; + for (let index = 0; index < frameCount; index += 1) { + const headerOffset = 6 + index * 8; + const frameOffset = readU24LE(data, headerOffset); + const frameSize = readU32LE(data, headerOffset + 4); + const frameData = data.subarray(frameOffset, frameOffset + frameSize); + if (frameData.length < 28) { + throw new Error(`frame ${index} too small: ${frameData.length}`); + } + const compressed = Boolean(readU32LE(frameData, 8)); + const width = readU32LE(frameData, 12); + const height = readU32LE(frameData, 16); + const xoff = readI32LE(frameData, 20); + const yoff = readI32LE(frameData, 24); + const lineOffsets = []; + for (let row = 0; row < height; row += 1) { + lineOffsets.push(readU32LE(frameData, 28 + row * 4) - ((height - row) * 4)); + } + const rleOffset = 28 + height * 4; + frames.push({ + compressed, + width, + height, + xoff, + yoff, + lineOffsets, + rleData: frameData.subarray(rleOffset) + }); + } + return frames; +} + +function decodePixels(frame) { + const pixels = new Int16Array(frame.width * frame.height); + pixels.fill(-1); + const rle = frame.rleData; + for (let row = 0; row < frame.height; row += 1) { + let pos = frame.lineOffsets[row]; + let xpos = 0; + while (xpos < frame.width) { + if (pos >= rle.length) { + throw new Error(`row ${row} overran RLE data`); + } + xpos += rle[pos]; + pos += 1; + if (xpos === frame.width) { + break; + } + if (pos >= rle.length) { + throw new Error(`row ${row} missing run header`); + } + let dlen = rle[pos]; + pos += 1; + let runType = 0; + if (frame.compressed) { + runType = dlen & 1; + dlen >>= 1; + } + if (dlen <= 0 || xpos + dlen > frame.width) { + throw new Error(`invalid run length ${dlen} at row ${row}`); + } + const rowBase = row * frame.width + xpos; + if (runType === 0) { + const end = pos + dlen; + if (end > rle.length) { + throw new Error(`row ${row} literal run overruns RLE data`); + } + for (let index = 0; index < dlen; index += 1) { + pixels[rowBase + index] = rle[pos + index]; + } + pos = end; + } else { + if (pos >= rle.length) { + throw new Error(`row ${row} repeated-color run missing color byte`); + } + const color = rle[pos]; + pos += 1; + for (let index = 0; index < dlen; index += 1) { + pixels[rowBase + index] = color; + } + } + xpos += dlen; + } + } + return pixels; +} + +export function loadPalette(filePath) { + const data = fs.readFileSync(filePath); + if (data.length < 768) { + throw new Error(`palette too small: ${filePath}`); + } + const palette = []; + for (let index = 0; index < 256; index += 1) { + const r = Math.floor((data[index * 3] * 255) / 63); + const g = Math.floor((data[index * 3 + 1] * 255) / 63); + const b = Math.floor((data[index * 3 + 2] * 255) / 63); + palette.push([r, g, b]); + } + return palette; +} + +export function loadTypeflags(filePath) { + const data = fs.readFileSync(filePath); + const infos = []; + for (let base = 0; base + 9 <= data.length; base += 9) { + const block = data.subarray(base, base + 9); + let flags = 0; + if (block[0] & 0x01) flags |= 0x0001; + if (block[0] & 0x02) flags |= 0x0002; + if (block[0] & 0x04) flags |= 0x0004; + if (block[0] & 0x08) flags |= 0x0008; + if (block[0] & 0x10) flags |= 0x0010; + if (block[0] & 0x20) flags |= 0x0020; + if (block[0] & 0x40) flags |= 0x0040; + if (block[0] & 0x80) flags |= 0x0080; + if (block[1] & 0x01) flags |= 0x0100; + if (block[1] & 0x02) flags |= 0x0200; + if (block[1] & 0x04) flags |= 0x0400; + if (block[1] & 0x08) flags |= 0x0800; + if (block[6] & 0x01) flags |= 0x1000; + if (block[6] & 0x02) flags |= 0x2000; + if (block[6] & 0x04) flags |= 0x4000; + if (block[6] & 0x08) flags |= 0x8000; + if (block[6] & 0x10) flags |= 0x10000; + if (block[6] & 0x20) flags |= 0x20000; + if (block[6] & 0x40) flags |= 0x40000; + if (block[6] & 0x80) flags |= 0x80000; + const family = (block[1] >> 4) + ((block[2] & 1) << 4); + const x = ((block[3] << 3) | (block[2] >> 5)) & 0x1f; + const y = (block[3] >> 2) & 0x1f; + const z = ((block[4] << 1) | (block[3] >> 7)) & 0x1f; + const animType = block[4] >> 4; + infos.push({ + family, + flags, + x, + y, + z, + animType, + isEditor: Boolean(flags & 0x1000), + isFixed: Boolean(flags & SI_FIXED), + isSolid: Boolean(flags & SI_SOLID), + isLand: Boolean(flags & SI_LAND), + isOccl: Boolean(flags & SI_OCCL), + isNoisy: Boolean(flags & SI_NOISY), + isDraw: Boolean(flags & SI_DRAW), + isRoof: Boolean(flags & SI_ROOF), + isTranslucent: Boolean(flags & SI_TRANSL), + isInvitem: family === 13 + }); + } + return infos; +} + +export function loadGlobs(filePath) { + const archive = new FlexArchive(filePath); + const globs = []; + for (let index = 0; index < archive.length; index += 1) { + const raw = archive.get(index); + if (!raw.length) { + globs.push([]); + continue; + } + const count = readU16LE(raw, 0); + const items = []; + for (let itemIndex = 0; itemIndex < count; itemIndex += 1) { + const base = 2 + itemIndex * 6; + items.push({ + x: raw[base], + y: raw[base + 1], + z: raw[base + 2], + shape: readU16LE(raw, base + 3), + frame: raw[base + 5] + }); + } + globs.push(items); + } + return globs; +} + +export function loadMapItems(filePath, mapIndex) { + if (!fs.existsSync(filePath)) { + throw new Error(`Missing file: ${filePath}`); + } + const data = fs.readFileSync(filePath); + const mapCount = readU16LE(data, FIXED_MAP_COUNT_OFFSET); + if (mapIndex < 0 || mapIndex >= mapCount) { + throw new Error(`map index ${mapIndex} out of range 0..${mapCount - 1}`); + } + const tableOffset = FIXED_MAP_TABLE_OFFSET + mapIndex * 8; + const mapOffset = readU32LE(data, tableOffset); + const mapSize = readU32LE(data, tableOffset + 4); + const payload = data.subarray(mapOffset, mapOffset + mapSize); + if (payload.length !== mapSize) { + throw new Error(`map ${mapIndex} payload truncated`); + } + const items = []; + for (let base = 0; base + 16 <= payload.length; base += 16) { + const record = payload.subarray(base, base + 16); + items.push({ + x: readU16LE(record, 0) * CRUSADER_COORD_SCALE, + y: readU16LE(record, 2) * CRUSADER_COORD_SCALE, + z: record[4], + shape: readU16LE(record, 5), + frame: record[7], + flags: readU16LE(record, 8), + quality: readU16LE(record, 10), + npcNum: record[12], + mapNum: record[13], + nextItem: readU16LE(record, 14), + source: "fixed" + }); + } + return items; +} + +export function expandGlobItem(item, globs) { + if (item.quality < 0 || item.quality >= globs.length) { + return []; + } + return globs[item.quality].map((globItem) => ({ + x: (item.x & GLOB_COORD_MASK) + (globItem.x << GLOB_COORD_SHIFT) + GLOB_COORD_OFFSET, + y: (item.y & GLOB_COORD_MASK) + (globItem.y << GLOB_COORD_SHIFT) + GLOB_COORD_OFFSET, + z: item.z + globItem.z, + shape: globItem.shape, + frame: globItem.frame, + flags: 0, + quality: 0, + npcNum: 0, + mapNum: item.mapNum, + nextItem: 0, + source: "glob" + })); +} + +export function collectRenderItems(baseItems, shapeInfos, globs, options) { + const { + includeEditor, + expandGlobs, + worldRect, + includeRoofs, + includeHiddenMarkers, + progress, + checkpointEvery = 0 + } = options; + + const renderItems = []; + const pending = [...baseItems]; + let index = 0; + let skippedInvisible = 0; + let skippedWorldRect = 0; + let skippedInvalidShape = 0; + let skippedEditor = 0; + let skippedEgg = 0; + let skippedRoof = 0; + let skippedHidden = 0; + let expandedGlobs = 0; + + while (index < pending.length) { + const item = pending[index]; + index += 1; + if (item.flags & FLAG_INVISIBLE) { + if (!includeHiddenMarkers) { + skippedHidden += 1; + continue; + } + skippedInvisible += 1; + } + if (worldRect) { + const [minX, minY, maxX, maxY] = worldRect; + if (item.x < minX || item.y < minY || item.x > maxX || item.y > maxY) { + skippedWorldRect += 1; + continue; + } + } + if (item.shape >= shapeInfos.length) { + skippedInvalidShape += 1; + continue; + } + const info = shapeInfos[item.shape]; + if (info.isEditor && !includeEditor) { + skippedEditor += 1; + continue; + } + if (info.isRoof && !includeRoofs) { + skippedRoof += 1; + continue; + } + if (expandGlobs && info.family === 3 && item.source === "fixed") { + pending.push(...expandGlobItem(item, globs)); + expandedGlobs += 1; + if (!includeHiddenMarkers) { + continue; + } + } + if (EGG_FAMILIES.has(info.family) && !includeHiddenMarkers) { + skippedEgg += 1; + continue; + } + renderItems.push(item); + if (progress && checkpointEvery > 0 && index % checkpointEvery === 0) { + progress( + `collect processed=${index} pending=${pending.length} rendered=${renderItems.length} expanded_globs=${expandedGlobs} ` + + `skipped=(invisible:${skippedInvisible}, world:${skippedWorldRect}, shape:${skippedInvalidShape}, editor:${skippedEditor}, ` + + `roof:${skippedRoof}, hidden:${skippedHidden}, egg:${skippedEgg})` + ); + } + } + + if (progress) { + progress( + `collect complete processed=${index} pending=${pending.length} rendered=${renderItems.length} expanded_globs=${expandedGlobs} ` + + `skipped=(invisible:${skippedInvisible}, world:${skippedWorldRect}, shape:${skippedInvalidShape}, editor:${skippedEditor}, ` + + `roof:${skippedRoof}, hidden:${skippedHidden}, egg:${skippedEgg})` + ); + } + + return renderItems; +} + +export function summarizeRenderClasses(baseItems, shapeInfos) { + const summary = { + roofItems: 0, + editorItems: 0, + eggFamilyItems: 0, + invisibleFlaggedItems: 0, + npcLinkedItems: 0 + }; + for (const item of baseItems) { + if (item.flags & FLAG_INVISIBLE) { + summary.invisibleFlaggedItems += 1; + } + if (item.npcNum !== 0) { + summary.npcLinkedItems += 1; + } + if (item.shape >= shapeInfos.length) { + continue; + } + const info = shapeInfos[item.shape]; + if (info.isRoof) summary.roofItems += 1; + if (info.isEditor) summary.editorItems += 1; + if (EGG_FAMILIES.has(info.family)) summary.eggFamilyItems += 1; + } + return summary; +} + +export function resolveStaticFile(staticDir, name) { + return path.join(staticDir, name); +} diff --git a/map_renderer/src/lib/png.js b/map_renderer/src/lib/png.js new file mode 100644 index 0000000..bd38b87 --- /dev/null +++ b/map_renderer/src/lib/png.js @@ -0,0 +1,48 @@ +import { PNG } from "pngjs"; + +export const DEFAULT_BACKGROUND = [10, 12, 18, 255]; + +export function rgbaBuffer(width, height, color = DEFAULT_BACKGROUND) { + const [r, g, b, a] = color; + const pixels = Buffer.alloc(width * height * 4); + for (let offset = 0; offset < pixels.length; offset += 4) { + pixels[offset] = r; + pixels[offset + 1] = g; + pixels[offset + 2] = b; + pixels[offset + 3] = a; + } + return pixels; +} + +export function blitFrame(buffer, canvasWidth, canvasHeight, left, top, frame, pixels, palette, flipped) { + for (let srcY = 0; srcY < frame.height; srcY += 1) { + const dstY = top + srcY; + if (dstY < 0 || dstY >= canvasHeight) { + continue; + } + const rowBase = srcY * frame.width; + for (let srcX = 0; srcX < frame.width; srcX += 1) { + const pixelIndex = rowBase + (flipped ? frame.width - 1 - srcX : srcX); + const colorIndex = pixels[pixelIndex]; + if (colorIndex < 0) { + continue; + } + const dstX = left + srcX; + if (dstX < 0 || dstX >= canvasWidth) { + continue; + } + const pixelBase = (dstY * canvasWidth + dstX) * 4; + const [r, g, b] = palette[colorIndex]; + buffer[pixelBase] = r; + buffer[pixelBase + 1] = g; + buffer[pixelBase + 2] = b; + buffer[pixelBase + 3] = 255; + } + } +} + +export function encodePng(width, height, data) { + const png = new PNG({ width, height }); + data.copy(png.data); + return PNG.sync.write(png, { colorType: 6, inputColorType: 6 }); +} diff --git a/map_renderer/src/lib/sorting.js b/map_renderer/src/lib/sorting.js new file mode 100644 index 0000000..f8d5f17 --- /dev/null +++ b/map_renderer/src/lib/sorting.js @@ -0,0 +1,394 @@ +import { FLAG_FLIPPED } from "./formats.js"; + +function rectIntersects(left, right) { + return left.left < right.right && left.right > right.left && left.top < right.bottom && left.bottom > right.top; +} + +function rectContains(outer, inner) { + return outer.left <= inner.left && outer.top <= inner.top && outer.right >= inner.right && outer.bottom >= inner.bottom; +} + +function listLessThan(left, right) { + if (left.sprite !== right.sprite) { + return left.sprite < right.sprite; + } + if (left.z !== right.z) { + return left.z < right.z; + } + return left.flat > right.flat; +} + +function overlap(left, right) { + if (!rectIntersects(left, right)) { + return false; + } + const pointTopDiff = [left.sx_top - right.sx_bot, left.sy_top - right.sy_bot]; + const pointBotDiff = [left.sx_bot - right.sx_top, left.sy_bot - right.sy_top]; + const dotTopLeft = pointTopDiff[0] + pointTopDiff[1] * 2; + const dotTopRight = -pointTopDiff[0] + pointTopDiff[1] * 2; + const dotBotLeft = pointBotDiff[0] - pointBotDiff[1] * 2; + const dotBotRight = -pointBotDiff[0] - pointBotDiff[1] * 2; + const rightClear = left.sx_right <= right.sx_left; + const leftClear = left.sx_left >= right.sx_right; + const topLeftClear = dotTopLeft >= 0; + const topRightClear = dotTopRight >= 0; + const botLeftClear = dotBotLeft >= 0; + const botRightClear = dotBotRight >= 0; + const clear = rightClear || leftClear || botRightClear || botLeftClear || topRightClear || topLeftClear; + return !clear; +} + +function occludes(left, right) { + if (!rectContains(left, right)) { + return false; + } + const pointTopDiff = [left.sx_top - right.sx_top, left.sy_top - right.sy_top]; + const pointBotDiff = [left.sx_bot - right.sx_bot, left.sy_bot - right.sy_bot]; + const dotTopLeft = pointTopDiff[0] + pointTopDiff[1] * 2; + const dotTopRight = -pointTopDiff[0] + pointTopDiff[1] * 2; + const dotBotLeft = pointBotDiff[0] - pointBotDiff[1] * 2; + const dotBotRight = -pointBotDiff[0] - pointBotDiff[1] * 2; + const rightRes = left.sx_right >= right.sx_right; + const leftRes = left.sx_left <= right.sx_left; + const topLeftRes = dotTopLeft <= 0; + const topRightRes = dotTopRight <= 0; + const botLeftRes = dotBotLeft <= 0; + const botRightRes = dotBotRight <= 0; + return rightRes && leftRes && botRightRes && botLeftRes && topRightRes && topLeftRes; +} + +function below(left, right) { + if (left.sprite !== right.sprite) { + return left.sprite < right.sprite; + } + + if (left.flat && right.flat) { + if (left.z !== right.z) { + return left.z < right.z; + } + } else if (left.invitem === right.invitem) { + if (left.z_top <= right.z) { + return true; + } + if (left.z >= right.z_top) { + return false; + } + } + + const yFlatLeft = left.y_far === left.y; + const yFlatRight = right.y_far === right.y; + if (yFlatLeft && yFlatRight) { + if (Math.floor(left.y / 32) !== Math.floor(right.y / 32)) { + return left.y < right.y; + } + } else { + if (left.y <= right.y_far) { + return true; + } + if (left.y_far >= right.y) { + return false; + } + } + + const xFlatLeft = left.x_left === left.x; + const xFlatRight = right.x_left === right.x; + if (xFlatLeft && xFlatRight) { + if (Math.floor(left.x / 32) !== Math.floor(right.x / 32)) { + return left.x < right.x; + } + } else { + if (left.x <= right.x_left) { + return true; + } + if (left.x_left >= right.x) { + return false; + } + } + + if (left.z_top - 8 <= right.z && left.z < right.z_top - 8) { + return true; + } + if (left.z >= right.z_top - 8 && left.z_top - 8 > right.z) { + return false; + } + + if (yFlatLeft !== yFlatRight) { + if (Math.floor(left.y / 32) <= Math.floor(right.y_far / 32)) { + return true; + } + if (Math.floor(left.y_far / 32) >= Math.floor(right.y / 32)) { + return false; + } + const yCenterLeft = Math.floor((Math.floor(left.y_far / 32) + Math.floor(left.y / 32)) / 2); + const yCenterRight = Math.floor((Math.floor(right.y_far / 32) + Math.floor(right.y / 32)) / 2); + if (yCenterLeft !== yCenterRight) { + return yCenterLeft < yCenterRight; + } + } + + if (xFlatLeft !== xFlatRight) { + if (Math.floor(left.x / 32) <= Math.floor(right.x_left / 32)) { + return true; + } + if (Math.floor(left.x_left / 32) >= Math.floor(right.x / 32)) { + return false; + } + const xCenterLeft = Math.floor((Math.floor(left.x_left / 32) + Math.floor(left.x / 32)) / 2); + const xCenterRight = Math.floor((Math.floor(right.x_left / 32) + Math.floor(right.x / 32)) / 2); + if (xCenterLeft !== xCenterRight) { + return xCenterLeft < xCenterRight; + } + } + + if (left.flat || right.flat) { + if (left.z !== right.z) return left.z < right.z; + if (left.invitem !== right.invitem) return left.invitem < right.invitem; + if (left.flat !== right.flat) return left.flat > right.flat; + if (left.trans !== right.trans) return left.trans < right.trans; + if (left.anim !== right.anim) return left.anim < right.anim; + if (left.draw !== right.draw) return left.draw > right.draw; + if (left.solid !== right.solid) return left.solid > right.solid; + if (left.occl !== right.occl) return left.occl > right.occl; + if (left.fbigsq !== right.fbigsq) return left.fbigsq > right.fbigsq; + } + + if (left.x === right.x && left.y === right.y && left.trans !== right.trans) { + return left.trans < right.trans; + } + + if (left.land && right.land && left.roof !== right.roof) { + return left.roof < right.roof; + } + if (left.roof !== right.roof) { + return left.roof > right.roof; + } + if (left.z !== right.z) { + return left.z < right.z; + } + + if (xFlatLeft || xFlatRight || yFlatLeft || yFlatRight) { + if (left.sx_left !== right.sx_left) { + return left.sx_left > right.sx_left; + } + if (left.sy_bot !== right.sy_bot) { + return left.sy_bot < right.sy_bot; + } + } + + if (left.x + left.y !== right.x + right.y) return left.x + left.y < right.x + right.y; + if (left.x_left + left.y_far !== right.x_left + right.y_far) return left.x_left + left.y_far < right.x_left + right.y_far; + if (left.y !== right.y) return left.y < right.y; + if (left.x !== right.x) return left.x < right.x; + if (left.item.shape !== right.item.shape) return left.item.shape < right.item.shape; + return left.item.frame < right.item.frame; +} + +function buildSortNode(item, info, frame, pixels) { + const flipped = Boolean(item.flags & FLAG_FLIPPED); + const xdim = (flipped ? info.y : info.x) * 32; + const ydim = (flipped ? info.x : info.y) * 32; + const zdim = info.z * 8; + const x = item.x; + const y = item.y; + const z = item.z; + const xLeft = x - xdim; + const yFar = y - ydim; + const zTop = z + zdim; + const sxLeft = Math.trunc(xLeft / 4 - y / 4); + const sxRight = Math.trunc(x / 4 - yFar / 4); + const sxTop = Math.trunc(xLeft / 4 - yFar / 4); + const syTop = Math.trunc(xLeft / 8 + yFar / 8 - zTop); + const sxBot = Math.trunc(x / 4 - y / 4); + const syBot = Math.trunc(x / 8 + y / 8 - z); + const left = flipped ? sxBot + frame.xoff - frame.width : sxBot - frame.xoff; + const top = syBot - frame.yoff; + const right = left + frame.width; + const bottom = top + frame.height; + + return { + item, + info, + frame, + pixels, + left, + top, + right, + bottom, + x, + x_left: xLeft, + y, + y_far: yFar, + z, + z_top: zTop, + sx_left: sxLeft, + sx_right: sxRight, + sx_top: sxTop, + sy_top: syTop, + sx_bot: sxBot, + sy_bot: syBot, + fbigsq: xdim === ydim && xdim >= 128, + flat: zdim === 0, + occl: info.isOccl && !info.isTranslucent, + solid: info.isSolid, + draw: info.isDraw, + roof: info.isRoof, + noisy: info.isNoisy, + anim: info.animType !== 0, + trans: info.isTranslucent, + fixed: info.isFixed, + land: info.isLand, + sprite: false, + invitem: info.isInvitem, + occluded: false, + order: -1, + depends: [] + }; +} + +function insertDependencySorted(depends, node) { + for (let index = 0; index < depends.length; index += 1) { + const current = depends[index]; + if (current === node) { + return false; + } + if (listLessThan(node, current)) { + depends.splice(index, 0, node); + return true; + } + } + depends.push(node); + return true; +} + +function resolvePaintOrder(ordered, progress, checkpointEvery = 0) { + const painted = []; + + function visit(node) { + if (node.occluded || node.order >= 0) { + return; + } + node.order = -2; + for (const dependency of node.depends) { + if (dependency.order === -2) { + break; + } + if (dependency.order === -1) { + visit(dependency); + } + } + node.order = painted.length ? painted[painted.length - 1].order + 1 : 0; + painted.push(node); + if (progress && checkpointEvery > 0 && painted.length % checkpointEvery === 0) { + progress(`paint resolved=${painted.length} of ${ordered.length}`); + } + } + + for (const node of ordered) { + if (node.order === -1) { + visit(node); + } + } + if (progress) { + progress(`paint complete resolved=${painted.length} of ${ordered.length}`); + } + return painted; +} + +export function prepareSortedItems(items, archive, shapeInfos, options = {}) { + const { progress, checkpointEvery = 0, maxInvalidDetails = 20 } = options; + const ordered = []; + let minLeft = Number.MAX_SAFE_INTEGER; + let minTop = Number.MAX_SAFE_INTEGER; + let maxRight = -Number.MAX_SAFE_INTEGER; + let maxBottom = -Number.MAX_SAFE_INTEGER; + let occludedCount = 0; + let invalidItemCount = 0; + const invalidItems = []; + let dependencyCount = 0; + + for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) { + const item = items[itemIndex]; + let frame; + let pixels; + try { + const decoded = archive.decodeFrame(item.shape, item.frame); + frame = decoded.frame; + pixels = decoded.pixels; + } catch (error) { + invalidItemCount += 1; + if (invalidItems.length < maxInvalidDetails) { + invalidItems.push({ + shape: item.shape, + frame: item.frame, + x: item.x, + y: item.y, + z: item.z, + source: item.source, + reason: error instanceof Error ? error.message : String(error) + }); + } + continue; + } + + const node = buildSortNode(item, shapeInfos[item.shape], frame, pixels); + minLeft = Math.min(minLeft, node.left); + minTop = Math.min(minTop, node.top); + maxRight = Math.max(maxRight, node.right); + maxBottom = Math.max(maxBottom, node.bottom); + + let insertAt = ordered.length; + for (let index = 0; index < ordered.length; index += 1) { + const other = ordered[index]; + if (insertAt === ordered.length && listLessThan(node, other)) { + insertAt = index; + } + if (other.occluded) { + continue; + } + if (!overlap(node, other)) { + continue; + } + if (below(node, other)) { + if (other.occl && occludes(other, node)) { + node.occluded = true; + occludedCount += 1; + break; + } + if (insertDependencySorted(other.depends, node)) { + dependencyCount += 1; + } + } else if (node.occl && occludes(node, other)) { + if (!other.occluded) { + other.occluded = true; + occludedCount += 1; + } + } else if (insertDependencySorted(node.depends, other)) { + dependencyCount += 1; + } + } + ordered.splice(insertAt, 0, node); + + if (progress && checkpointEvery > 0 && (itemIndex + 1) % checkpointEvery === 0) { + progress( + `sort processed=${itemIndex + 1} valid=${ordered.length} occluded=${occludedCount} invalid=${invalidItemCount} dependencies=${dependencyCount}` + ); + } + } + + if (progress) { + progress( + `sort complete processed=${items.length} valid=${ordered.length} occluded=${occludedCount} invalid=${invalidItemCount} dependencies=${dependencyCount}` + ); + } + + return { + minLeft, + minTop, + maxRight, + maxBottom, + prepared: resolvePaintOrder(ordered, progress, checkpointEvery), + occludedCount, + invalidItemCount, + invalidItems + }; +} diff --git a/map_renderer/src/public/app.css b/map_renderer/src/public/app.css new file mode 100644 index 0000000..8297b6e --- /dev/null +++ b/map_renderer/src/public/app.css @@ -0,0 +1,278 @@ +:root { + color-scheme: light dark; + --bg: #f1ead6; + --panel: rgba(255, 248, 232, 0.92); + --panel-border: rgba(94, 73, 37, 0.25); + --ink: #2d2212; + --muted: #6e5a37; + --accent: #0d6c7d; + --accent-strong: #114f59; + --viewport: #0e1218; + --tile-border: rgba(255, 255, 255, 0.04); + --shadow: 0 18px 45px rgba(59, 40, 8, 0.16); + --font-ui: "Segoe UI Variable Text", "Aptos", "Trebuchet MS", sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #12161d; + --panel: rgba(22, 28, 38, 0.92); + --panel-border: rgba(166, 187, 211, 0.16); + --ink: #edf2fa; + --muted: #aab8cc; + --accent: #46a7bc; + --accent-strong: #2a7b8d; + --viewport: #06080d; + --tile-border: rgba(255, 255, 255, 0.03); + --shadow: 0 18px 45px rgba(0, 0, 0, 0.35); + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: var(--font-ui); + color: var(--ink); + background: + radial-gradient(circle at top left, rgba(255, 255, 255, 0.14), transparent 32%), + linear-gradient(135deg, var(--bg) 0%, color-mix(in srgb, var(--bg) 75%, #6e8aa3 25%) 100%); +} + +.shell { + display: grid; + grid-template-columns: 340px minmax(0, 1fr); + min-height: 100vh; +} + +.panel { + padding: 24px; + background: var(--panel); + backdrop-filter: blur(16px); + border-right: 1px solid var(--panel-border); + box-shadow: var(--shadow); +} + +.panel h1 { + margin: 0; + font-size: 1.6rem; + line-height: 1.1; +} + +.lede, +.muted { + color: var(--muted); +} + +.stack { + display: grid; + gap: 10px; + margin-top: 20px; +} + +label { + font-weight: 700; + font-size: 0.95rem; +} + +select, +button, +.action-link { + width: 100%; + border-radius: 12px; + border: 1px solid rgba(65, 48, 21, 0.18); + padding: 12px 14px; + font: inherit; +} + +button, +.action-link { + cursor: pointer; + color: white; + background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%); + text-decoration: none; + text-align: center; +} + +button:disabled, +select:disabled, +.action-link.is-disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.button-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} + +.toggle-grid, +.status, +.meta-panel { + padding: 12px 14px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.58); + border: 1px solid rgba(65, 48, 21, 0.08); + overflow-wrap: anywhere; +} + +.toggle-grid { + display: grid; + gap: 10px; +} + +.toggle { + display: flex; + align-items: center; + gap: 10px; + font-weight: 600; + font-size: 0.92rem; +} + +.toggle input { + width: 18px; + height: 18px; + margin: 0; +} + +.action-link { + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; +} + +.status { + min-height: 72px; +} + +.meta-panel { + min-height: 240px; +} + +.meta-empty { + margin: 0; + color: var(--muted); +} + +.meta-section + .meta-section { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid color-mix(in srgb, var(--panel-border) 70%, transparent 30%); +} + +.meta-title { + margin: 0 0 8px; + font-size: 0.84rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.meta-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px 12px; + font-size: 0.92rem; +} + +.meta-grid dt { + color: var(--muted); +} + +.meta-grid dd { + margin: 0; + text-align: right; + font-weight: 600; +} + +.workspace { + min-width: 0; + padding: 18px; +} + +.viewport { + position: relative; + width: 100%; + height: calc(100vh - 36px); + overflow: hidden; + border-radius: 24px; + background: + linear-gradient(0deg, rgba(255,255,255,0.03), rgba(255,255,255,0.03)), + linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px), + linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px), + var(--viewport); + background-size: auto, 32px 32px, 32px 32px, auto; + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.05), var(--shadow); + touch-action: none; + cursor: grab; + user-select: none; +} + +.scene { + position: absolute; + left: 0; + top: 0; + transform-origin: top left; + will-change: transform; +} + +.layer { + position: absolute; + inset: 0 auto auto 0; +} + +.tile { + position: absolute; + image-rendering: pixelated; + image-rendering: crisp-edges; + border: 1px solid var(--tile-border); + pointer-events: none; +} + +.viewport.is-dragging { + cursor: grabbing; +} + +.viewport-hint { + position: absolute; + top: 16px; + left: 16px; + z-index: 2; + padding: 8px 12px; + border-radius: 999px; + background: rgba(6, 8, 12, 0.66); + color: rgba(255, 255, 255, 0.82); + font-size: 0.86rem; + backdrop-filter: blur(10px); +} + +.empty-state { + position: absolute; + inset: 0; + display: grid; + place-items: center; + padding: 24px; + color: rgba(255,255,255,0.72); + text-align: center; +} + +@media (max-width: 900px) { + .shell { + grid-template-columns: 1fr; + } + + .panel { + border-right: 0; + border-bottom: 1px solid var(--panel-border); + } + + .viewport { + height: 70vh; + } +} diff --git a/map_renderer/src/public/app.js b/map_renderer/src/public/app.js new file mode 100644 index 0000000..97d6caf --- /dev/null +++ b/map_renderer/src/public/app.js @@ -0,0 +1,559 @@ +const mapForm = document.querySelector("#map-form"); +const mapSelect = document.querySelector("#map-select"); +const buildButton = document.querySelector("#build-button"); +const includeEditorCheckbox = document.querySelector("#include-editor"); +const includeRoofsCheckbox = document.querySelector("#include-roofs"); +const downloadButton = document.querySelector("#download-button"); +const statusBox = document.querySelector("#status"); +const metaBox = document.querySelector("#meta"); +const viewport = document.querySelector("#viewport"); +const scene = document.querySelector("#scene"); +const emptyState = document.querySelector("#empty-state"); +const zoomLabel = document.querySelector("#zoom-label"); +const zoomInButton = document.querySelector("#zoom-in"); +const zoomOutButton = document.querySelector("#zoom-out"); +const zoomResetButton = document.querySelector("#zoom-reset"); +const zoomFitButton = document.querySelector("#zoom-fit"); + +let activeLayer = document.querySelector("#active-layer"); +let autoBuildTimer = null; + +const state = { + catalog: null, + current: null, + zoom: 1, + offsetX: 0, + offsetY: 0, + buildPollTimer: null, + buildToken: 0, + drag: null, + pointers: new Map(), + pinch: null +}; + +const ZOOM_FACTOR = 1.2; +const FIT_PADDING = 24; + +async function fetchJson(url, init) { + const response = await fetch(url, init); + if (!response.ok) { + let message = `HTTP ${response.status}`; + try { + const body = await response.json(); + if (body.error) { + message = body.error; + } + } catch { + // Ignore parse failures. + } + throw new Error(message); + } + return response.json(); +} + +function setStatus(message) { + statusBox.textContent = message; +} + +function setMeta(metadata) { + if (!metadata) { + metaBox.innerHTML = '
'; + return; + } + + metaBox.innerHTML = ` + + + + `; +} + +function enableZoomControls(enabled) { + zoomInButton.disabled = !enabled; + zoomOutButton.disabled = !enabled; + zoomResetButton.disabled = !enabled; + zoomFitButton.disabled = !enabled; +} + +function setDownloadState(enabled, href = "#") { + downloadButton.href = href; + downloadButton.classList.toggle("is-disabled", !enabled); + downloadButton.setAttribute("aria-disabled", String(!enabled)); +} + +function updateZoomLabel() { + zoomLabel.textContent = `Zoom: ${Math.round(state.zoom * 100)}%`; +} + +function updateSceneLayout() { + if (!state.current) { + scene.style.width = "0px"; + scene.style.height = "0px"; + scene.style.transform = "translate(0px, 0px) scale(1)"; + return; + } + const { metadata } = state.current; + scene.style.width = `${metadata.bounds.width}px`; + scene.style.height = `${metadata.bounds.height}px`; + scene.style.transform = `translate(${state.offsetX}px, ${state.offsetY}px) scale(${state.zoom})`; +} + +function clampZoom(nextZoom) { + if (!state.current) { + return 1; + } + const { min, max } = state.current.metadata.zoom; + return Math.min(max, Math.max(min, nextZoom)); +} + +function clampOffsets() { + if (!state.current) { + return; + } + const { width, height } = state.current.metadata.bounds; + const scaledWidth = width * state.zoom; + const scaledHeight = height * state.zoom; + + if (scaledWidth <= viewport.clientWidth) { + state.offsetX = (viewport.clientWidth - scaledWidth) / 2; + } else { + state.offsetX = Math.min(0, Math.max(viewport.clientWidth - scaledWidth, state.offsetX)); + } + + if (scaledHeight <= viewport.clientHeight) { + state.offsetY = (viewport.clientHeight - scaledHeight) / 2; + } else { + state.offsetY = Math.min(0, Math.max(viewport.clientHeight - scaledHeight, state.offsetY)); + } +} + +function setZoom(nextZoom, anchor = null) { + if (!state.current) { + return; + } + const clamped = clampZoom(nextZoom); + if (clamped === state.zoom) { + updateZoomLabel(); + return; + } + + const focus = anchor ?? { x: viewport.clientWidth / 2, y: viewport.clientHeight / 2 }; + const worldX = (focus.x - state.offsetX) / state.zoom; + const worldY = (focus.y - state.offsetY) / state.zoom; + + state.zoom = clamped; + state.offsetX = focus.x - worldX * state.zoom; + state.offsetY = focus.y - worldY * state.zoom; + clampOffsets(); + updateSceneLayout(); + updateZoomLabel(); +} + +function fitMap() { + if (!state.current) { + return; + } + const { width, height } = state.current.metadata.bounds; + const scaleX = Math.max(0.01, (viewport.clientWidth - FIT_PADDING * 2) / width); + const scaleY = Math.max(0.01, (viewport.clientHeight - FIT_PADDING * 2) / height); + state.zoom = clampZoom(Math.min(scaleX, scaleY)); + clampOffsets(); + updateSceneLayout(); + updateZoomLabel(); +} + +function tileUrl(buildContext, tileX, tileY) { + const { selected, jobId } = buildContext; + return `/api/maps/${selected.game}/${selected.mapId}/tiles/${tileX}/${tileY}.png?buildId=${encodeURIComponent(jobId)}`; +} + +function createTileElement(tileX, tileY, buildContext, metadata) { + const tileSize = metadata.tileSize; + const tile = document.createElement("img"); + tile.className = "tile"; + tile.alt = `Tile ${tileX},${tileY}`; + tile.loading = "eager"; + tile.decoding = "async"; + tile.draggable = false; + tile.src = tileUrl(buildContext, tileX, tileY); + tile.style.left = `${tileX * tileSize}px`; + tile.style.top = `${tileY * tileSize}px`; + tile.style.width = `${Math.min(tileSize, metadata.bounds.width - tileX * tileSize)}px`; + tile.style.height = `${Math.min(tileSize, metadata.bounds.height - tileY * tileSize)}px`; + return tile; +} + +function waitForImage(tile) { + if (tile.complete && tile.naturalWidth > 0) { + return tile.decode?.().catch(() => undefined) ?? Promise.resolve(); + } + return new Promise((resolve, reject) => { + tile.addEventListener( + "load", + () => { + const decodePromise = tile.decode?.().catch(() => undefined); + Promise.resolve(decodePromise).then(resolve); + }, + { once: true } + ); + tile.addEventListener("error", () => reject(new Error(`Failed to load ${tile.alt}`)), { once: true }); + }); +} + +async function buildLayer(buildContext) { + const layer = document.createElement("div"); + layer.className = "layer"; + const { metadata } = buildContext; + layer.style.width = `${metadata.bounds.width}px`; + layer.style.height = `${metadata.bounds.height}px`; + + const tilePromises = []; + for (let tileY = 0; tileY < metadata.tileCountY; tileY += 1) { + for (let tileX = 0; tileX < metadata.tileCountX; tileX += 1) { + const tile = createTileElement(tileX, tileY, buildContext, metadata); + layer.append(tile); + tilePromises.push(waitForImage(tile)); + } + } + + await Promise.all(tilePromises); + return layer; +} + +function getSelectedMap() { + if (!mapSelect.value) { + return null; + } + return JSON.parse(mapSelect.value); +} + +function currentSelectionMatches(selected) { + return Boolean( + state.current && + state.current.selected.game === selected.game && + state.current.selected.mapId === selected.mapId + ); +} + +function currentFiltersMatch() { + return Boolean( + state.current && + state.current.metadata.filters.includeEditor === includeEditorCheckbox.checked && + state.current.metadata.filters.includeRoofs === includeRoofsCheckbox.checked + ); +} + +function scheduleAutoBuild() { + clearTimeout(autoBuildTimer); + autoBuildTimer = window.setTimeout(() => { + const selected = getSelectedMap(); + if (!selected) { + return; + } + if (currentSelectionMatches(selected) && currentFiltersMatch()) { + return; + } + startBuild(selected).catch((error) => { + setStatus(error instanceof Error ? error.message : String(error)); + }); + }, 100); +} + +function populateCatalog(catalog) { + state.catalog = catalog; + mapSelect.innerHTML = ""; + const placeholder = document.createElement("option"); + placeholder.textContent = "Select a map"; + placeholder.value = ""; + mapSelect.append(placeholder); + + for (const game of catalog.games) { + const group = document.createElement("optgroup"); + group.label = `${game.label} (${game.mapCount} maps)`; + for (const map of game.maps) { + const option = document.createElement("option"); + option.value = JSON.stringify({ game: game.id, mapId: map.id }); + option.textContent = `${map.label} (${map.rawItemCount} items)`; + group.append(option); + } + mapSelect.append(group); + } + + mapSelect.disabled = catalog.games.length === 0; + buildButton.disabled = catalog.games.length === 0; + setDownloadState(false); + if (catalog.games.length === 0) { + setStatus("No usable STATIC folders were detected under the app root."); + } else { + setStatus("Select a map to build it immediately."); + } +} + +async function startBuild(selected) { + clearTimeout(state.buildPollTimer); + const token = ++state.buildToken; + const preserveView = currentSelectionMatches(selected); + + if (!state.current) { + emptyState.hidden = false; + enableZoomControls(false); + setMeta(null); + setDownloadState(false); + } + + setStatus( + preserveView + ? `Rebuilding ${selected.game} map ${selected.mapId}. The current view stays visible until the new tiles are ready.` + : `Building ${selected.game} map ${selected.mapId}...` + ); + + const build = await fetchJson("/api/builds", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...selected, + includeEditor: includeEditorCheckbox.checked, + includeRoofs: includeRoofsCheckbox.checked + }) + }); + + await pollBuild(build.id, selected, token, preserveView); +} + +async function pollBuild(jobId, selected, token, preserveView) { + if (token !== state.buildToken) { + return; + } + + const build = await fetchJson(`/api/builds/${encodeURIComponent(jobId)}`); + if (token !== state.buildToken) { + return; + } + + const latest = build.progress.at(-1); + setStatus(latest ? `${build.phase}: ${latest.message}` : `${build.phase}...`); + if (build.status === "failed") { + throw new Error(build.error || "Build failed"); + } + if (build.status !== "ready") { + state.buildPollTimer = window.setTimeout(() => { + pollBuild(jobId, selected, token, preserveView).catch((error) => { + setStatus(error.message); + }); + }, 1000); + return; + } + + const metadata = await fetchJson( + `/api/maps/${selected.game}/${selected.mapId}/metadata?buildId=${encodeURIComponent(jobId)}` + ); + if (token !== state.buildToken) { + return; + } + + const nextContext = { selected, jobId, metadata }; + const nextLayer = await buildLayer(nextContext); + if (token !== state.buildToken) { + return; + } + + state.current = nextContext; + activeLayer.replaceWith(nextLayer); + activeLayer = nextLayer; + + if (!preserveView) { + fitMap(); + } else { + clampOffsets(); + updateSceneLayout(); + updateZoomLabel(); + } + + setMeta(metadata); + setDownloadState( + true, + `/api/maps/${selected.game}/${selected.mapId}/download.png?buildId=${encodeURIComponent(jobId)}` + ); + setStatus(`Ready. ${selected.game} map ${selected.mapId} is fully loaded.`); + emptyState.hidden = true; + enableZoomControls(true); +} + +async function loadCatalog() { + const catalog = await fetchJson("/api/maps"); + populateCatalog(catalog); +} + +mapForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const selected = getSelectedMap(); + if (!selected) { + setStatus("Choose a map first."); + return; + } + + try { + await startBuild(selected); + } catch (error) { + setStatus(error instanceof Error ? error.message : String(error)); + } +}); + +mapSelect.addEventListener("change", scheduleAutoBuild); +includeEditorCheckbox.addEventListener("change", scheduleAutoBuild); +includeRoofsCheckbox.addEventListener("change", scheduleAutoBuild); + +downloadButton.addEventListener("click", (event) => { + if (downloadButton.classList.contains("is-disabled")) { + event.preventDefault(); + } +}); + +window.addEventListener("resize", () => { + if (state.current) { + clampOffsets(); + updateSceneLayout(); + } +}); + +zoomInButton.addEventListener("click", () => { + setZoom(state.zoom * ZOOM_FACTOR); +}); + +zoomOutButton.addEventListener("click", () => { + setZoom(state.zoom / ZOOM_FACTOR); +}); + +zoomResetButton.addEventListener("click", () => { + setZoom(1); +}); + +zoomFitButton.addEventListener("click", () => { + fitMap(); +}); + +viewport.addEventListener( + "wheel", + (event) => { + if (!state.current) { + return; + } + event.preventDefault(); + const rect = viewport.getBoundingClientRect(); + const nextZoom = event.deltaY < 0 ? state.zoom * ZOOM_FACTOR : state.zoom / ZOOM_FACTOR; + setZoom(nextZoom, { x: event.clientX - rect.left, y: event.clientY - rect.top }); + }, + { passive: false } +); + +viewport.addEventListener("pointerdown", (event) => { + if (!state.current) { + return; + } + event.preventDefault(); + viewport.setPointerCapture(event.pointerId); + state.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); + + if (state.pointers.size === 1) { + state.drag = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: state.offsetX, + originY: state.offsetY + }; + viewport.classList.add("is-dragging"); + } + + if (state.pointers.size === 2) { + const [first, second] = [...state.pointers.values()]; + state.pinch = { + distance: Math.hypot(second.x - first.x, second.y - first.y), + zoom: state.zoom + }; + state.drag = null; + } +}); + +viewport.addEventListener("pointermove", (event) => { + if (!state.current || !state.pointers.has(event.pointerId)) { + return; + } + state.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); + + if (state.pointers.size === 2 && state.pinch) { + const [first, second] = [...state.pointers.values()]; + const distance = Math.hypot(second.x - first.x, second.y - first.y); + if (distance > 0) { + const rect = viewport.getBoundingClientRect(); + const center = { + x: (first.x + second.x) / 2 - rect.left, + y: (first.y + second.y) / 2 - rect.top + }; + setZoom(state.pinch.zoom * (distance / state.pinch.distance), center); + } + return; + } + + if (!state.drag || state.drag.pointerId !== event.pointerId) { + return; + } + + state.offsetX = state.drag.originX + (event.clientX - state.drag.startX); + state.offsetY = state.drag.originY + (event.clientY - state.drag.startY); + clampOffsets(); + updateSceneLayout(); +}); + +function releasePointer(event) { + state.pointers.delete(event.pointerId); + if (viewport.hasPointerCapture(event.pointerId)) { + viewport.releasePointerCapture(event.pointerId); + } + if (state.pointers.size < 2) { + state.pinch = null; + } + if (state.drag?.pointerId === event.pointerId) { + state.drag = null; + } + if (state.pointers.size === 0) { + viewport.classList.remove("is-dragging"); + } +} + +viewport.addEventListener("pointerup", releasePointer); +viewport.addEventListener("pointercancel", releasePointer); +viewport.addEventListener("lostpointercapture", releasePointer); + +enableZoomControls(false); +updateZoomLabel(); +setMeta(null); +setDownloadState(false); +loadCatalog().catch((error) => { + setStatus(error instanceof Error ? error.message : String(error)); +}); diff --git a/map_renderer/src/public/index.html b/map_renderer/src/public/index.html new file mode 100644 index 0000000..bb68972 --- /dev/null +++ b/map_renderer/src/public/index.html @@ -0,0 +1,65 @@ + + + + + +