diff --git a/.gitignore b/.gitignore index bf8a53f..9dcede2 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ vite.config.d.ts !.env.example __pycache__/ *.py[cod] +/public/basis/ diff --git a/package.json b/package.json index 7a60a84..7f76f9a 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,15 @@ { "name": "i-want-to-heal", "private": true, - "version": "0.1.8", + "version": "0.1.9", "type": "module", "scripts": { + "predev": "pnpm assets:sync-basis-transcoder", "dev": "vite --host 0.0.0.0", "dev:api": "HOST=127.0.0.1 PORT=4174 node server/production.mjs", "db:backup": "node scripts/backup-db.mjs", "db:init": "node scripts/init-db.mjs", + "prebuild": "pnpm assets:sync-basis-transcoder", "build": "tsc -b && vite build", "android:sync": "pnpm run build && cap sync android", "android:sync:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com pnpm run android:sync", @@ -18,8 +20,12 @@ "publish:gitea": "python3 scripts/publish_gitea.py", "test": "vitest run && node --test server/game-api.test.mjs", "test:watch": "vitest", + "assets:build-dungeon-kit": "node scripts/build_dungeon_kit.mjs", + "assets:build-gravehorn": "node scripts/build_gravehorn_triceratops.mjs", + "assets:build-ktx2": "node scripts/build_ktx2_game_assets.mjs", "assets:prune-party-animations": "node scripts/prune_party_animations.mjs --write", - "assets:import": "node scripts/import-game-asset.mjs" + "assets:import": "node scripts/import-game-asset.mjs", + "assets:sync-basis-transcoder": "node scripts/sync_basis_transcoder.mjs" }, "dependencies": { "@capacitor/android": "8.4.1", @@ -29,17 +35,20 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "three": "^0.179.1", + "three-stdlib": "2.36.1", "zustand": "^5.0.8" }, "devDependencies": { "@capacitor/cli": "8.4.1", "@gltf-transform/core": "^4.4.1", "@gltf-transform/extensions": "^4.4.1", + "@gltf-transform/functions": "4.4.1", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@types/three": "^0.179.0", "@vitejs/plugin-react": "^5.0.2", "meshoptimizer": "^1.2.0", + "sharp": "0.34.5", "typescript": "~5.8.3", "vite": "^7.1.3", "vitest": "^3.2.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f430e5..906b13d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: three: specifier: ^0.179.1 version: 0.179.1 + three-stdlib: + specifier: 2.36.1 + version: 2.36.1(three@0.179.1) zustand: specifier: ^5.0.8 version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) @@ -42,6 +45,9 @@ importers: '@gltf-transform/extensions': specifier: ^4.4.1 version: 4.4.1 + '@gltf-transform/functions': + specifier: 4.4.1 + version: 4.4.1 '@types/react': specifier: ^19.1.10 version: 19.2.17 @@ -57,6 +63,9 @@ importers: meshoptimizer: specifier: ^1.2.0 version: 1.2.0 + sharp: + specifier: 0.34.5 + version: 0.34.5 typescript: specifier: ~5.8.3 version: 5.8.3 @@ -172,6 +181,9 @@ packages: '@dimforge/rapier3d-compat@0.12.0': resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -334,6 +346,162 @@ packages: '@gltf-transform/extensions@4.4.1': resolution: {integrity: sha512-dZZ9D/NdpNeJUmQKExtISYtd3W6OxU4njk8UI3IKm6j97uVskYQ24BNi2YP40uUpdWPiREsS/DhjNhWAbGU/1A==} + '@gltf-transform/functions@4.4.1': + resolution: {integrity: sha512-CAU6hczuRz7NIEtpn7BARuwVEwRawwIcGqmoMCaEwA4XC+CSUi3xptXuf2zDG/5AftFO7IeQxXl/56rDsck0GQ==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@ionic/cli-framework-output@2.2.8': resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==} engines: {node: '>=16.0.0'} @@ -601,6 +769,9 @@ packages: '@types/fs-extra@8.1.5': resolution: {integrity: sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==} + '@types/ndarray@1.0.14': + resolution: {integrity: sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg==} + '@types/node@26.1.1': resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} @@ -791,6 +962,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cwise-compiler@1.1.3: + resolution: {integrity: sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -811,6 +985,10 @@ packages: detect-gpu@5.0.70: resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + draco3d@1.5.7: resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==} @@ -908,6 +1086,12 @@ packages: resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + iota-array@1.0.0: + resolution: {integrity: sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==} + + is-buffer@1.1.6: + resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} + is-docker@2.2.1: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} @@ -1020,6 +1204,18 @@ packages: engines: {node: '>=16.0.0'} hasBin: true + ndarray-lanczos@0.3.0: + resolution: {integrity: sha512-5kBmmG3Zvyj77qxIAC4QFLKuYdDIBJwCG+DukT6jQHNa1Ft74/hPH1z5mbQXeHBt8yvGPBGVrr3wEOdJPYYZYg==} + + ndarray-ops@1.2.2: + resolution: {integrity: sha512-BppWAFRjMYF7N/r6Ie51q6D4fs0iiGmeXIACKY66fLpnwIui3Wc3CXiD/30mgLbDjPpSLrsqcp3Z62+IcHZsDw==} + + ndarray-pixels@5.0.1: + resolution: {integrity: sha512-IBtrpefpqlI8SPDCGjXk4v5NV5z7r3JSuCbfuEEXaM0vrOJtNGgYUa4C3Lt5H+qWdYF4BCPVFsnXhNC7QvZwkw==} + + ndarray@1.0.19: + resolution: {integrity: sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==} + node-releases@2.0.50: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} @@ -1139,6 +1335,10 @@ packages: engines: {node: '>=10'} hasBin: true + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1275,6 +1475,9 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + uniq@1.0.1: + resolution: {integrity: sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -1597,6 +1800,11 @@ snapshots: '@dimforge/rapier3d-compat@0.12.0': {} + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true @@ -1684,6 +1892,111 @@ snapshots: '@gltf-transform/core': 4.4.1 ktx-parse: 1.1.0 + '@gltf-transform/functions@4.4.1': + dependencies: + '@gltf-transform/core': 4.4.1 + '@gltf-transform/extensions': 4.4.1 + ktx-parse: 1.1.0 + ndarray: 1.0.19 + ndarray-lanczos: 0.3.0 + ndarray-pixels: 5.0.1 + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.2 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@ionic/cli-framework-output@2.2.8': dependencies: '@ionic/utils-terminal': 2.3.5 @@ -1958,6 +2271,8 @@ snapshots: dependencies: '@types/node': 26.1.1 + '@types/ndarray@1.0.14': {} + '@types/node@26.1.1': dependencies: undici-types: 8.3.0 @@ -2146,6 +2461,10 @@ snapshots: csstype@3.2.3: {} + cwise-compiler@1.1.3: + dependencies: + uniq: 1.0.1 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -2158,6 +2477,8 @@ snapshots: dependencies: webgl-constants: 1.1.1 + detect-libc@2.1.2: {} + draco3d@1.5.7: {} electron-to-chromium@1.5.389: {} @@ -2259,6 +2580,10 @@ snapshots: ini@4.1.3: {} + iota-array@1.0.0: {} + + is-buffer@1.1.6: {} + is-docker@2.2.1: {} is-fullwidth-code-point@3.0.0: {} @@ -2357,6 +2682,27 @@ snapshots: transitivePeerDependencies: - supports-color + ndarray-lanczos@0.3.0: + dependencies: + '@types/ndarray': 1.0.14 + ndarray: 1.0.19 + + ndarray-ops@1.2.2: + dependencies: + cwise-compiler: 1.1.3 + + ndarray-pixels@5.0.1: + dependencies: + '@types/ndarray': 1.0.14 + ndarray: 1.0.19 + ndarray-ops: 1.2.2 + sharp: 0.34.5 + + ndarray@1.0.19: + dependencies: + iota-array: 1.0.0 + is-buffer: 1.1.6 + node-releases@2.0.50: {} open@8.4.2: @@ -2481,6 +2827,37 @@ snapshots: semver@7.8.5: {} + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + 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 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2609,6 +2986,8 @@ snapshots: undici-types@8.3.0: {} + uniq@1.0.1: {} + universalify@2.0.1: {} untildify@4.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ed0b5a..dbb26c8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ allowBuilds: esbuild: true + sharp: true diff --git a/scripts/build_dungeon_kit.mjs b/scripts/build_dungeon_kit.mjs new file mode 100644 index 0000000..96e9e63 --- /dev/null +++ b/scripts/build_dungeon_kit.mjs @@ -0,0 +1,80 @@ +import { access } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + EXTMeshoptCompression, +} from "@gltf-transform/extensions"; +import { dedup, mergeDocuments, prune, unpartition } from "@gltf-transform/functions"; +import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceDirectory = path.join( + repositoryRoot, + "game_assets", + "models", + "original", + "environment", + "kaykit-dungeon", +); +const outputPath = path.join(sourceDirectory, "dungeon-kit-uastc.glb"); +const sources = ["pillar-decorated.glb", "wall-pillar.glb", "torch-lit.glb"]; +const expectedMeshNames = ["pillar_decorated", "wall_pillar", "torch_lit"]; +await Promise.all(sources.map((source) => access(path.join(sourceDirectory, source)))); +const io = await createGameAssetIO(); + +const documents = await Promise.all(sources.map((source) => io.read(path.join(sourceDirectory, source)))); +const document = documents[0]; +const destinationScene = document.getRoot().listScenes()[0].setName("dungeon_kit"); + +for (const sourceDocument of documents.slice(1)) { + const sourceScene = sourceDocument.getRoot().listScenes()[0]; + const propertyMap = mergeDocuments(document, sourceDocument); + const copiedScene = propertyMap.get(sourceScene); + if (!copiedScene) throw new Error(`Could not merge scene from ${sourceScene.getName() || "unnamed source"}.`); + for (const child of [...copiedScene.listChildren()]) destinationScene.addChild(child); + copiedScene.dispose(); +} + +await document.transform( + dedup({ keepUniqueNames: false }), + unpartition(), + prune({ keepSolidTextures: true }), +); + +const root = document.getRoot(); +const meshNames = root.listMeshes().map((mesh) => mesh.getName()).sort(); +const expectedSorted = [...expectedMeshNames].sort(); +if (JSON.stringify(meshNames) !== JSON.stringify(expectedSorted)) { + throw new Error(`Dungeon kit mesh names changed: expected ${expectedSorted.join(", ")}; received ${meshNames.join(", ")}.`); +} +if (root.listTextures().length !== 1) { + throw new Error(`Dungeon kit must contain exactly one shared texture; received ${root.listTextures().length}.`); +} +if (root.listMaterials().length !== 1) { + throw new Error(`Dungeon kit must contain exactly one shared material; received ${root.listMaterials().length}.`); +} + +await convertDocumentTexturesToKtx2(document, "iwt-dungeon-kit-"); +const meshoptExtension = root.listExtensionsUsed() + .find((extension) => extension.extensionName === EXTMeshoptCompression.EXTENSION_NAME) + ?? document.createExtension(EXTMeshoptCompression); +meshoptExtension + .setRequired(true) + .setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.QUANTIZE }); + +await io.write(outputPath, document); + +const outputDocument = await io.read(outputPath); +const outputRoot = outputDocument.getRoot(); +const outputTexture = outputRoot.listTextures()[0]; +if ( + outputRoot.listMeshes().length !== 3 + || outputRoot.listMaterials().length !== 1 + || outputRoot.listTextures().length !== 1 + || outputTexture.getMimeType() !== "image/ktx2" +) { + throw new Error("Generated dungeon kit failed structural validation."); +} + +console.log(`Built ${path.relative(repositoryRoot, outputPath)}`); +console.log("Meshes: pillar_decorated, wall_pillar, torch_lit; shared textures: 1; encoding: KTX2/UASTC."); diff --git a/scripts/build_gravehorn_triceratops.mjs b/scripts/build_gravehorn_triceratops.mjs new file mode 100644 index 0000000..cb009b9 --- /dev/null +++ b/scripts/build_gravehorn_triceratops.mjs @@ -0,0 +1,170 @@ +import { access, readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Box3, Vector3 } from "three"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer"; +import { dedup, meshopt, prune, resample } from "@gltf-transform/functions"; +import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const assetDirectory = path.join(repositoryRoot, "game_assets", "models", "sketchfab-opensource"); +const sourcePath = path.join(assetDirectory, "animated_triceratops_skeleton.glb"); +const legacyPath = path.join(assetDirectory, "gravehorn-triceratops.glb"); +const optimizedPath = path.join(assetDirectory, "gravehorn-triceratops-uastc.glb"); +const requiredClips = [ + "Armature|RiseUp", + "Armature|Roar", + "Armature|Walk", + "Armature|Fall", + "Gravehorn|Idle", +]; +const unusedClips = new Set(["Armature|IdleGround", "Armature|RoarToWalk"]); + +function sortedNames(properties) { + return properties.map((property) => property.getName()).sort(); +} + +function addUprightIdle(document) { + const walk = document.getRoot().listAnimations().find((animation) => animation.getName() === "Armature|Walk"); + const buffer = document.getRoot().listBuffers()[0]; + if (!walk || !buffer) throw new Error("Gravehorn source is missing its Walk animation or binary buffer."); + + const idle = document.createAnimation("Gravehorn|Idle"); + for (const [index, sourceChannel] of walk.listChannels().entries()) { + const sourceSampler = sourceChannel.getSampler(); + const sourceOutput = sourceSampler?.getOutput(); + const sourceValues = sourceOutput?.getArray(); + const targetNode = sourceChannel.getTargetNode(); + const targetPath = sourceChannel.getTargetPath(); + if (!sourceSampler || !sourceOutput || !sourceValues || !targetNode || !targetPath) { + throw new Error(`Gravehorn Walk channel ${index} is incomplete.`); + } + if (sourceSampler.getInterpolation() === "CUBICSPLINE") { + throw new Error("Gravehorn upright idle builder does not support cubic animation tracks."); + } + + const elementSize = sourceOutput.getElementSize(); + const values = new sourceValues.constructor(elementSize * 2); + values.set(sourceValues.subarray(0, elementSize), 0); + values.set(sourceValues.subarray(0, elementSize), elementSize); + const input = document.createAccessor(`gravehorn_idle_time_${index}`) + .setType("SCALAR") + .setArray(new Float32Array([0, 1])) + .setBuffer(buffer); + const output = document.createAccessor(`gravehorn_idle_value_${index}`) + .setType(sourceOutput.getType()) + .setNormalized(sourceOutput.getNormalized()) + .setArray(values) + .setBuffer(buffer); + const sampler = document.createAnimationSampler(`gravehorn_idle_sampler_${index}`) + .setInput(input) + .setOutput(output) + .setInterpolation("LINEAR"); + const channel = document.createAnimationChannel(`gravehorn_idle_channel_${index}`) + .setSampler(sampler) + .setTargetNode(targetNode) + .setTargetPath(targetPath); + idle.addSampler(sampler).addChannel(channel); + } +} + +async function runtimeBounds(assetPath) { + const data = await readFile(assetPath); + const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); + globalThis.self ??= globalThis; + globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} }); + const gltf = await new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).parseAsync(arrayBuffer, ""); + gltf.scene.updateMatrixWorld(true); + const bounds = new Box3().setFromObject(gltf.scene); + return { + min: bounds.min.toArray(), + max: bounds.max.toArray(), + center: bounds.getCenter(new Vector3()).toArray(), + }; +} + +function validateStructure(document, { optimized }) { + const root = document.getRoot(); + const material = root.listMaterials()[0]; + const clips = sortedNames(root.listAnimations()); + const textureMimeTypes = root.listTextures().map((texture) => texture.getMimeType()); + const extensionNames = new Set(root.listExtensionsUsed().map((extension) => extension.extensionName)); + + if (JSON.stringify(clips) !== JSON.stringify([...requiredClips].sort())) { + throw new Error(`Gravehorn animation clips changed: ${clips.join(", ")}.`); + } + if (root.listMeshes().length !== 1 || root.listMaterials().length !== 1 || root.listTextures().length !== 3 || root.listSkins().length !== 1) { + throw new Error("Gravehorn must contain one mesh, one material, three textures, and one skin."); + } + if (root.listSkins()[0].listJoints().length !== 140) { + throw new Error(`Gravehorn joint count changed: ${root.listSkins()[0].listJoints().length}.`); + } + if (material.getDoubleSided()) { + throw new Error("Gravehorn material must keep backface culling enabled."); + } + if (!extensionNames.has("EXT_meshopt_compression")) { + throw new Error("Gravehorn is missing Meshopt compression."); + } + if (optimized && (textureMimeTypes.some((mimeType) => mimeType !== "image/ktx2") || !extensionNames.has("KHR_texture_basisu"))) { + throw new Error("Optimized Gravehorn asset must contain only KTX2 textures."); + } + if (!optimized && textureMimeTypes.some((mimeType) => mimeType === "image/ktx2")) { + throw new Error("Legacy Gravehorn fallback must retain standard textures."); + } +} + +await access(sourcePath); +await MeshoptDecoder.ready; +await MeshoptEncoder.ready; +const io = await createGameAssetIO(); +const document = await io.read(sourcePath); +const root = document.getRoot(); +const scene = root.listScenes()[0].setName("gravehorn_triceratops"); +const sceneRoot = scene.listChildren()[0]; +const sourceBounds = await runtimeBounds(sourcePath); +const sourceTranslation = sceneRoot.getTranslation(); + +sceneRoot.setTranslation([ + sourceTranslation[0] - sourceBounds.center[0], + sourceTranslation[1] - sourceBounds.min[1], + sourceTranslation[2] - sourceBounds.center[2], +]); +root.listMeshes()[0].setName("gravehorn_triceratops"); +root.listMaterials()[0] + .setName("gravehorn_bone") + .setDoubleSided(false) + .setEmissiveFactor([0.1, 0.06, 0.02]); +addUprightIdle(document); +for (const animation of root.listAnimations()) { + if (!unusedClips.has(animation.getName())) continue; + for (const channel of animation.listChannels()) channel.dispose(); + for (const sampler of animation.listSamplers()) sampler.dispose(); + animation.dispose(); +} + +await document.transform( + resample({ tolerance: 1e-4 }), + dedup({ keepUniqueNames: true }), + prune({ keepLeaves: true, keepSolidTextures: true }), + meshopt({ encoder: MeshoptEncoder, level: "high" }), +); + +await io.write(legacyPath, document); +const legacyDocument = await io.read(legacyPath); +validateStructure(legacyDocument, { optimized: false }); +const legacyBounds = await runtimeBounds(legacyPath); +if (Math.abs(legacyBounds.min[1]) > 0.015 || Math.abs(legacyBounds.center[0]) > 0.015 || Math.abs(legacyBounds.center[2]) > 0.015) { + throw new Error(`Gravehorn pivot is not grounded and centered: minY=${legacyBounds.min[1]}, centerX=${legacyBounds.center[0]}, centerZ=${legacyBounds.center[2]}.`); +} + +const optimizedDocument = await io.read(legacyPath); +await convertDocumentTexturesToKtx2(optimizedDocument, "iwt-gravehorn-"); +await io.write(optimizedPath, optimizedDocument); +validateStructure(await io.read(optimizedPath), { optimized: true }); + +const sourceSize = (await stat(sourcePath)).size; +const legacySize = (await stat(legacyPath)).size; +const optimizedSize = (await stat(optimizedPath)).size; +console.log(`Built ${path.relative(repositoryRoot, legacyPath)} (${sourceSize} -> ${legacySize} bytes).`); +console.log(`Built ${path.relative(repositoryRoot, optimizedPath)} (${optimizedSize} bytes, KTX2/UASTC).`); diff --git a/scripts/build_ktx2_game_assets.mjs b/scripts/build_ktx2_game_assets.mjs new file mode 100644 index 0000000..85c59a2 --- /dev/null +++ b/scripts/build_ktx2_game_assets.mjs @@ -0,0 +1,70 @@ +import { access } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceRoot = path.join(repositoryRoot, "game_assets"); +const sourceAssets = [ + "models/claudecraft/chars/players/druid.glb", + "models/claudecraft/chars/players/knight.glb", + "models/claudecraft/chars/players/mage.glb", + "models/claudecraft/chars/players/ranger.glb", + "models/claudecraft/chars/players/rogue.glb", + "models/claudecraft/weapons/adv_dagger.glb", + "models/claudecraft/weapons/adv_druid_staff.glb", + "models/claudecraft/weapons/adv_sword_1handed.glb", + "models/claudecraft/weapons/adv_wand.glb", + "models/claudecraft/weapons/crossbow_2handed.glb", + "models/claudecraft/weapons/shield_badge.glb", + "models/claudecraft/weapons/spellbook_open.glb", +]; + +function optimizedPath(sourcePath) { + return sourcePath.replace(/\.glb$/, "-uastc.glb"); +} + +function sortedNames(properties) { + return properties.map((property) => property.getName()).sort(); +} + +const io = await createGameAssetIO(); +for (const relativePath of sourceAssets) { + const inputPath = path.join(sourceRoot, relativePath); + const outputRelativePath = optimizedPath(relativePath); + const outputPath = path.join(sourceRoot, outputRelativePath); + await access(inputPath); + + const document = await io.read(inputPath); + const sourceRootProperties = document.getRoot(); + const expected = { + animations: sortedNames(sourceRootProperties.listAnimations()), + materials: sortedNames(sourceRootProperties.listMaterials()), + meshes: sortedNames(sourceRootProperties.listMeshes()), + nodes: sortedNames(sourceRootProperties.listNodes()), + scenes: sortedNames(sourceRootProperties.listScenes()), + textureCount: sourceRootProperties.listTextures().length, + }; + + await convertDocumentTexturesToKtx2(document, "iwt-ktx2-"); + await io.write(outputPath, document); + + const outputDocument = await io.read(outputPath); + const outputRoot = outputDocument.getRoot(); + const actual = { + animations: sortedNames(outputRoot.listAnimations()), + materials: sortedNames(outputRoot.listMaterials()), + meshes: sortedNames(outputRoot.listMeshes()), + nodes: sortedNames(outputRoot.listNodes()), + scenes: sortedNames(outputRoot.listScenes()), + textureCount: outputRoot.listTextures().length, + }; + if ( + JSON.stringify(actual) !== JSON.stringify(expected) + || outputRoot.listTextures().some((texture) => texture.getMimeType() !== "image/ktx2") + || outputRoot.listExtensionsUsed().some((extension) => extension.extensionName === "EXT_texture_webp") + ) { + throw new Error(`Generated asset failed round-trip validation: ${outputRelativePath}`); + } + console.log(`Built game_assets/${outputRelativePath}`); +} diff --git a/scripts/lib/ktx2.mjs b/scripts/lib/ktx2.mjs new file mode 100644 index 0000000..c3d7c90 --- /dev/null +++ b/scripts/lib/ktx2.mjs @@ -0,0 +1,72 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { NodeIO } from "@gltf-transform/core"; +import { ALL_EXTENSIONS, EXTTextureWebP, KHRTextureBasisu } from "@gltf-transform/extensions"; +import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer"; +import sharp from "sharp"; + +export const TOKTX_COMMAND = process.env.TOKTX ?? "toktx"; + +export async function createGameAssetIO() { + await MeshoptDecoder.ready; + await MeshoptEncoder.ready; + return new NodeIO() + .registerExtensions(ALL_EXTENSIONS) + .registerDependencies({ + "meshopt.decoder": MeshoptDecoder, + "meshopt.encoder": MeshoptEncoder, + }); +} + +function run(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: "inherit" }); + child.once("error", reject); + child.once("exit", (code) => { + if (code === 0) resolve(); + else reject(new Error(`${command} exited with code ${code ?? "unknown"}.`)); + }); + }); +} + +export async function convertDocumentTexturesToKtx2(document, temporaryPrefix) { + const textures = document.getRoot().listTextures(); + if (textures.length === 0) throw new Error("Asset contains no textures to convert."); + + const temporaryDirectory = await mkdtemp(path.join(tmpdir(), temporaryPrefix)); + try { + for (const [index, texture] of textures.entries()) { + const sourceImage = texture.getImage(); + if (!sourceImage) throw new Error(`Texture ${texture.getName() || index} contains no image data.`); + + const pngPath = path.join(temporaryDirectory, `texture-${index}.png`); + const ktx2Path = path.join(temporaryDirectory, `texture-${index}.ktx2`); + await writeFile(pngPath, await sharp(sourceImage).png().toBuffer()); + await run(TOKTX_COMMAND, [ + "--t2", + "--encode", "uastc", + "--uastc_quality", "4", + "--zcmp", "18", + "--threads", process.env.TOKTX_THREADS ?? "4", + "--genmipmap", + "--assign_oetf", "srgb", + "--assign_primaries", "bt709", + ktx2Path, + pngPath, + ]); + + texture + .setName(`${texture.getName() || `texture_${index}`}_uastc`) + .setMimeType("image/ktx2") + .setImage(new Uint8Array(await readFile(ktx2Path))); + } + document.getRoot().listExtensionsUsed() + .find((extension) => extension.extensionName === EXTTextureWebP.EXTENSION_NAME) + ?.dispose(); + document.createExtension(KHRTextureBasisu).setRequired(true); + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } +} diff --git a/scripts/sync_basis_transcoder.mjs b/scripts/sync_basis_transcoder.mjs new file mode 100644 index 0000000..d4ded02 --- /dev/null +++ b/scripts/sync_basis_transcoder.mjs @@ -0,0 +1,15 @@ +import { copyFile, mkdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceDirectory = path.join(repositoryRoot, "node_modules", "three", "examples", "jsm", "libs", "basis"); +const outputDirectory = path.join(repositoryRoot, "public", "basis"); +const files = ["basis_transcoder.js", "basis_transcoder.wasm"]; + +await mkdir(outputDirectory, { recursive: true }); +for (const file of files) { + await copyFile(path.join(sourceDirectory, file), path.join(outputDirectory, file)); +} + +console.log(`Synced Three.js Basis transcoder ${files.join(", ")} -> public/basis`); diff --git a/src/assets/game/README.md b/src/assets/game/README.md index d8cd8bf..d90c600 100644 --- a/src/assets/game/README.md +++ b/src/assets/game/README.md @@ -3,3 +3,19 @@ Only assets referenced by the game ship from this folder. They are copied from the ignored `game_assets` source library, retain their source-relative path, and are imported with Vite `new URL(...)` calls. Use `pnpm assets:import ` to add a source asset. Add `--replace` only when deliberately updating an existing runtime copy. + +## KTX2/UASTC pilot + +The tracked `*-uastc.glb` files are parallel optimized copies. Their original GLBs remain the rollback path and must not be replaced or deleted during this pilot. + +KTX-Software `toktx` is an authoring dependency. Rebuild source copies with: + +```sh +TOKTX=/path/to/toktx pnpm assets:build-ktx2 +TOKTX=/path/to/toktx pnpm assets:build-dungeon-kit +TOKTX=/path/to/toktx pnpm assets:build-gravehorn +``` + +Import each generated path from `game_assets/` with `pnpm assets:import --replace`. Builds and dev startup copy Three.js's matching Basis transcoder into the ignored `public/basis/` generated directory. + +Force all original GLBs at runtime with `?legacyGameAssets=1`. Force them in an Android/browser build with `VITE_LEGACY_GAME_ASSETS=1 pnpm build`. `legacyDungeonAssets` and `VITE_LEGACY_DUNGEON_ASSETS=1` remain supported aliases. diff --git a/src/assets/game/models/claudecraft/chars/players/druid-uastc.glb b/src/assets/game/models/claudecraft/chars/players/druid-uastc.glb new file mode 100644 index 0000000..cab71a5 Binary files /dev/null and b/src/assets/game/models/claudecraft/chars/players/druid-uastc.glb differ diff --git a/src/assets/game/models/claudecraft/chars/players/knight-uastc.glb b/src/assets/game/models/claudecraft/chars/players/knight-uastc.glb new file mode 100644 index 0000000..b9857ef Binary files /dev/null and b/src/assets/game/models/claudecraft/chars/players/knight-uastc.glb differ diff --git a/src/assets/game/models/claudecraft/chars/players/mage-uastc.glb b/src/assets/game/models/claudecraft/chars/players/mage-uastc.glb new file mode 100644 index 0000000..7d000ba Binary files /dev/null and b/src/assets/game/models/claudecraft/chars/players/mage-uastc.glb differ diff --git a/src/assets/game/models/claudecraft/chars/players/ranger-uastc.glb b/src/assets/game/models/claudecraft/chars/players/ranger-uastc.glb new file mode 100644 index 0000000..6042381 Binary files /dev/null and b/src/assets/game/models/claudecraft/chars/players/ranger-uastc.glb differ diff --git a/src/assets/game/models/claudecraft/chars/players/rogue-uastc.glb b/src/assets/game/models/claudecraft/chars/players/rogue-uastc.glb new file mode 100644 index 0000000..bf878cd Binary files /dev/null and b/src/assets/game/models/claudecraft/chars/players/rogue-uastc.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/adv_dagger-uastc.glb b/src/assets/game/models/claudecraft/weapons/adv_dagger-uastc.glb new file mode 100644 index 0000000..6578659 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/adv_dagger-uastc.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/adv_druid_staff-uastc.glb b/src/assets/game/models/claudecraft/weapons/adv_druid_staff-uastc.glb new file mode 100644 index 0000000..960446f Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/adv_druid_staff-uastc.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/adv_sword_1handed-uastc.glb b/src/assets/game/models/claudecraft/weapons/adv_sword_1handed-uastc.glb new file mode 100644 index 0000000..5e090ed Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/adv_sword_1handed-uastc.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/adv_wand-uastc.glb b/src/assets/game/models/claudecraft/weapons/adv_wand-uastc.glb new file mode 100644 index 0000000..a3ed081 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/adv_wand-uastc.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/crossbow_2handed-uastc.glb b/src/assets/game/models/claudecraft/weapons/crossbow_2handed-uastc.glb new file mode 100644 index 0000000..af305f0 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/crossbow_2handed-uastc.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/shield_badge-uastc.glb b/src/assets/game/models/claudecraft/weapons/shield_badge-uastc.glb new file mode 100644 index 0000000..15b8ba1 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/shield_badge-uastc.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/spellbook_open-uastc.glb b/src/assets/game/models/claudecraft/weapons/spellbook_open-uastc.glb new file mode 100644 index 0000000..2a6c99d Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/spellbook_open-uastc.glb differ diff --git a/src/assets/game/models/original/environment/kaykit-dungeon/dungeon-kit-uastc.glb b/src/assets/game/models/original/environment/kaykit-dungeon/dungeon-kit-uastc.glb new file mode 100644 index 0000000..a4e9d73 Binary files /dev/null and b/src/assets/game/models/original/environment/kaykit-dungeon/dungeon-kit-uastc.glb differ diff --git a/src/assets/game/models/sketchfab-opensource/gravehorn-triceratops-uastc.glb b/src/assets/game/models/sketchfab-opensource/gravehorn-triceratops-uastc.glb new file mode 100644 index 0000000..2e18c0a Binary files /dev/null and b/src/assets/game/models/sketchfab-opensource/gravehorn-triceratops-uastc.glb differ diff --git a/src/assets/game/models/sketchfab-opensource/gravehorn-triceratops.LICENSE.md b/src/assets/game/models/sketchfab-opensource/gravehorn-triceratops.LICENSE.md new file mode 100644 index 0000000..0ae9839 --- /dev/null +++ b/src/assets/game/models/sketchfab-opensource/gravehorn-triceratops.LICENSE.md @@ -0,0 +1,7 @@ +# Animated Triceratops Skeleton + +- Creator: Zacxophone — https://sketchfab.com/Zacxophone +- Source: https://sketchfab.com/3d-models/animated-triceratops-skeleton-06cb55f941d94dc8b95ac46f92d89e7c +- License: CC0 1.0 Universal — https://creativecommons.org/publicdomain/zero/1.0/ + +Runtime files named `gravehorn-triceratops*.glb` are optimized derivatives of this model. diff --git a/src/assets/game/models/sketchfab-opensource/gravehorn-triceratops.glb b/src/assets/game/models/sketchfab-opensource/gravehorn-triceratops.glb new file mode 100644 index 0000000..d84244d Binary files /dev/null and b/src/assets/game/models/sketchfab-opensource/gravehorn-triceratops.glb differ diff --git a/src/components/BossRoom.tsx b/src/components/BossRoom.tsx index 5ae96ad..55a5cfa 100644 --- a/src/components/BossRoom.tsx +++ b/src/components/BossRoom.tsx @@ -1,15 +1,22 @@ import { useFrame } from "@react-three/fiber"; import { useGLTF } from "@react-three/drei"; -import { Suspense, useEffect, useLayoutEffect, useMemo, useRef } from "react"; +import { Component, Suspense, useEffect, useLayoutEffect, useMemo, useRef, type ReactNode } from "react"; import * as THREE from "three"; import { ARENA_CENTER, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "../game/arena"; import { bossRoomFor, type BossRoomDefinition, type BossRoomFloor } from "../game/bossRooms"; import { useGameStore } from "../game/store"; +import { LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider"; const ROOM_CENTER_Z = ARENA_CENTER[1]; const KAYKIT_DUNGEON_PILLAR_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/pillar-decorated.glb", import.meta.url).href; const KAYKIT_DUNGEON_WALL_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/wall-pillar.glb", import.meta.url).href; const KAYKIT_DUNGEON_TORCH_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/torch-lit.glb", import.meta.url).href; +const KAYKIT_DUNGEON_KIT_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/dungeon-kit-uastc.glb", import.meta.url).href; +const DUNGEON_MESH_NAMES = { + pillar: "pillar_decorated", + wall: "wall_pillar", + torch: "torch_lit", +} as const; type ArenaFixture = { position: readonly [number, number, number]; @@ -106,21 +113,19 @@ function firstMesh(scene: THREE.Object3D) { return mesh as THREE.Mesh; } -function DungeonAssetInstances({ - url, +function DungeonMeshInstances({ + mesh, fixtures, tint, opacity = 1, colors, }: { - url: string; + mesh: THREE.Mesh; fixtures: readonly ArenaFixture[]; tint: string; opacity?: number; colors?: readonly THREE.Color[]; }) { - const gltf = useGLTF(url, false, true); - const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]); const sourceMaterial = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material; const material = useMemo(() => { const next = sourceMaterial.clone(); @@ -156,20 +161,77 @@ function DungeonAssetInstances({ return ; } -function ArenaArchitecture({ room }: { room: BossRoomDefinition }) { - const walls = useMemo(() => ARENA_WALL_SEGMENTS.map((fixture) => ({ +function LegacyDungeonAssetInstances({ + url, + ...props +}: Omit[0], "mesh"> & { url: string }) { + const gltf = useGLTF(url, false, true); + const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]); + return ; +} + +function arenaWalls(room: BossRoomDefinition) { + return ARENA_WALL_SEGMENTS.map((fixture) => ({ ...fixture, scaleY: room.wallHeight / 4, - })), [room.wallHeight]); + })); +} + +function LegacyArenaArchitecture({ room }: { room: BossRoomDefinition }) { + const walls = useMemo(() => arenaWalls(room), [room]); return ( - - - + + + ); } +function namedMesh(scene: THREE.Object3D, name: string) { + const object = scene.getObjectByName(name); + if (object instanceof THREE.Mesh) { + return object as THREE.Mesh; + } + throw new Error(`Dungeon kit is missing mesh ${name}.`); +} + +function DungeonKitArchitecture({ room }: { room: BossRoomDefinition }) { + const gltf = useGameGLTF(KAYKIT_DUNGEON_KIT_URL); + const walls = useMemo(() => arenaWalls(room), [room]); + const meshes = useMemo(() => ({ + pillar: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.pillar), + wall: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.wall), + torch: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.torch), + }), [gltf.scene]); + return ( + + + + + + ); +} + +class DungeonAssetErrorBoundary extends Component<{ + children: ReactNode; + fallback: ReactNode; +}, { failed: boolean }> { + state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + componentDidCatch(error: unknown) { + console.warn("Optimized dungeon asset failed; using legacy GLBs.", error); + } + + render() { + return this.state.failed ? this.props.fallback : this.props.children; + } +} + function RoomWallFallback({ room }: { room: BossRoomDefinition }) { const walls = useRef(null); const previousCameraPosition = useRef(null); @@ -205,14 +267,30 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) { ); } -function RoomWalls({ room }: { room: BossRoomDefinition }) { +function LegacyRoomWalls({ room }: { room: BossRoomDefinition }) { return ( }> - + ); } +function OptimizedRoomWalls({ room }: { room: BossRoomDefinition }) { + return ( + }> + }> + + + + ); +} + +function RoomWalls({ room }: { room: BossRoomDefinition }) { + return LEGACY_GAME_ASSETS_FORCED + ? + : ; +} + function RoomMarks({ room }: { room: BossRoomDefinition }) { const rays = useRef(null); const pattern = ROOM_PATTERNS[room.floor]; @@ -345,6 +423,8 @@ export function BossRoom() { ); } -useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true); -useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true); -useGLTF.preload(KAYKIT_DUNGEON_TORCH_URL, false, true); +if (LEGACY_GAME_ASSETS_FORCED) { + useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true); + useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true); + useGLTF.preload(KAYKIT_DUNGEON_TORCH_URL, false, true); +} diff --git a/src/components/BossTrophyPortrait.tsx b/src/components/BossTrophyPortrait.tsx index ea41e71..f30e144 100644 --- a/src/components/BossTrophyPortrait.tsx +++ b/src/components/BossTrophyPortrait.tsx @@ -1,14 +1,14 @@ import { Canvas } from "@react-three/fiber"; -import { useGLTF } from "@react-three/drei"; import { Suspense, useMemo } from "react"; import * as THREE from "three"; import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; import { BOSS_DEFINITIONS } from "../game/bossCatalog"; import { ALTERNATE_BOSS_CONFIG, bossVisualUrl } from "../game/bossVisuals"; import type { BossId } from "../game/types"; +import { GameAssetProvider, LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider"; function PortraitModel({ bossId }: { bossId: BossId }) { - const gltf = useGLTF(bossVisualUrl(bossId), false, true); + const gltf = useGameGLTF(bossVisualUrl(bossId, !LEGACY_GAME_ASSETS_FORCED)); const model = useMemo(() => { const clone = cloneSkeleton(gltf.scene); clone.updateMatrixWorld(true); @@ -49,7 +49,9 @@ export function BossTrophyPortrait({ bossId }: { bossId: BossId }) { - + + + diff --git a/src/components/GameAssetProvider.tsx b/src/components/GameAssetProvider.tsx new file mode 100644 index 0000000..ad352d1 --- /dev/null +++ b/src/components/GameAssetProvider.tsx @@ -0,0 +1,43 @@ +import { useGLTF } from "@react-three/drei"; +import { useThree } from "@react-three/fiber"; +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; +import { KTX2Loader } from "three-stdlib"; + +const BASIS_TRANSCODER_PATH = `${import.meta.env.BASE_URL}basis/`; +const query = typeof window === "undefined" ? null : new URLSearchParams(window.location.search); + +export const LEGACY_GAME_ASSETS_FORCED = import.meta.env.VITE_LEGACY_GAME_ASSETS === "1" + || import.meta.env.VITE_LEGACY_DUNGEON_ASSETS === "1" + || query?.has("legacyGameAssets") === true + || query?.has("legacyDungeonAssets") === true; + +const Ktx2LoaderContext = createContext(null); + +export function selectedGameAssetUrl(legacyUrl: string, optimizedUrl: string) { + return LEGACY_GAME_ASSETS_FORCED ? legacyUrl : optimizedUrl; +} + +export function GameAssetProvider({ children }: { children: ReactNode }) { + const gl = useThree((state) => state.gl); + const [ktx2Loader] = useState(() => LEGACY_GAME_ASSETS_FORCED + ? null + : new KTX2Loader().setTranscoderPath(BASIS_TRANSCODER_PATH).detectSupport(gl)); + + useEffect(() => () => { + ktx2Loader?.dispose(); + }, [ktx2Loader]); + + return {children}; +} + +export function useGameGLTF(url: string) { + const ktx2Loader = useContext(Ktx2LoaderContext); + return useGLTF( + url, + false, + true, + (loader) => { + if (ktx2Loader) loader.setKTX2Loader(ktx2Loader); + }, + ); +} diff --git a/src/components/GameScene.tsx b/src/components/GameScene.tsx index 3502fd8..be71b9e 100644 --- a/src/components/GameScene.tsx +++ b/src/components/GameScene.tsx @@ -30,15 +30,30 @@ import type { BossId, MemberId, PulseKind } from "../game/types"; import { BossRoom } from "./BossRoom"; import { BossMechanicIndicators } from "./boss/BossMechanicIndicators"; import { bossCanTrackTarget, bossDeathOpacity } from "./boss/bossDeathVisuals"; +import { GameAssetProvider, LEGACY_GAME_ASSETS_FORCED, selectedGameAssetUrl, useGameGLTF } from "./GameAssetProvider"; -const PARTY_MODEL_URLS: Record = { +const PARTY_MODEL_LEGACY_URLS: Record = { aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href, brann: new URL("../assets/game/models/claudecraft/chars/players/knight.glb", import.meta.url).href, nia: new URL("../assets/game/models/claudecraft/chars/players/ranger.glb", import.meta.url).href, orin: new URL("../assets/game/models/claudecraft/chars/players/mage.glb", import.meta.url).href, vale: new URL("../assets/game/models/claudecraft/chars/players/rogue.glb", import.meta.url).href, }; -const PARTY_WEAPON_URLS: Record = { +const PARTY_MODEL_OPTIMIZED_URLS: Record = { + aelia: new URL("../assets/game/models/claudecraft/chars/players/druid-uastc.glb", import.meta.url).href, + brann: new URL("../assets/game/models/claudecraft/chars/players/knight-uastc.glb", import.meta.url).href, + nia: new URL("../assets/game/models/claudecraft/chars/players/ranger-uastc.glb", import.meta.url).href, + orin: new URL("../assets/game/models/claudecraft/chars/players/mage-uastc.glb", import.meta.url).href, + vale: new URL("../assets/game/models/claudecraft/chars/players/rogue-uastc.glb", import.meta.url).href, +}; +const PARTY_MODEL_URLS = Object.fromEntries(Object.keys(PARTY_MODEL_LEGACY_URLS).map((memberId) => [ + memberId, + selectedGameAssetUrl( + PARTY_MODEL_LEGACY_URLS[memberId as MemberId], + PARTY_MODEL_OPTIMIZED_URLS[memberId as MemberId], + ), +])) as Record; +const PARTY_WEAPON_LEGACY_URLS: Record = { aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff.glb", import.meta.url).href }, brann: { right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed.glb", import.meta.url).href, @@ -54,6 +69,30 @@ const PARTY_WEAPON_URLS: Record = { left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href, }, }; +const PARTY_WEAPON_OPTIMIZED_URLS: Record = { + aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff-uastc.glb", import.meta.url).href }, + brann: { + right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed-uastc.glb", import.meta.url).href, + left: new URL("../assets/game/models/claudecraft/weapons/shield_badge-uastc.glb", import.meta.url).href, + }, + nia: { right: new URL("../assets/game/models/claudecraft/weapons/crossbow_2handed-uastc.glb", import.meta.url).href }, + orin: { + right: new URL("../assets/game/models/claudecraft/weapons/adv_wand-uastc.glb", import.meta.url).href, + left: new URL("../assets/game/models/claudecraft/weapons/spellbook_open-uastc.glb", import.meta.url).href, + }, + vale: { + right: new URL("../assets/game/models/claudecraft/weapons/adv_dagger-uastc.glb", import.meta.url).href, + left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger-uastc.glb", import.meta.url).href, + }, +}; +const PARTY_WEAPON_URLS = Object.fromEntries(Object.keys(PARTY_WEAPON_LEGACY_URLS).map((memberId) => { + const legacy = PARTY_WEAPON_LEGACY_URLS[memberId as MemberId]; + const optimized = PARTY_WEAPON_OPTIMIZED_URLS[memberId as MemberId]; + return [memberId, { + right: selectedGameAssetUrl(legacy.right, optimized.right), + left: legacy.left && optimized.left ? selectedGameAssetUrl(legacy.left, optimized.left) : undefined, + }]; +})) as Record; const PARTY_MODEL_SCALES: Record = { aelia: 0.62, brann: 0.68, nia: 0.7, orin: 0.64, vale: 0.72 }; const PARTY_ATTACK_CLIPS: Record = { aelia: "2H_Melee_Attack_Chop", @@ -227,12 +266,12 @@ function PartyCharacterModel({ animationState: MutableRefObject; animationTrigger: MutableRefObject; }) { - const gltf = useGLTF(PARTY_MODEL_URLS[memberId], false, true); + const gltf = useGameGLTF(PARTY_MODEL_URLS[memberId]); const actorScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]); const loadout = PARTY_WEAPON_URLS[memberId]; const grips = PARTY_WEAPON_GRIPS[memberId]; - const rightWeapon = useGLTF(loadout.right, false, true); - const leftWeapon = useGLTF(loadout.left ?? loadout.right, false, true); + const rightWeapon = useGameGLTF(loadout.right); + const leftWeapon = useGameGLTF(loadout.left ?? loadout.right); const rightHandSlot = resolveRigNode(actorScene, "handslot.r"); const leftHandSlot = resolveRigNode(actorScene, "handslot.l"); const rightWeaponScene = useMemo( @@ -793,7 +832,8 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex const defeated = bossHp <= 0; const group = useRef(null); const light = useRef(null); - const gltf = useGLTF(config.url, false, true); + const assetUrl = selectedGameAssetUrl(config.url, config.optimizedUrl ?? config.url); + const gltf = useGameGLTF(assetUrl); const { model, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]); const { actions } = useAnimations(gltf.animations, model); const targetPosition = useMemo(() => new THREE.Vector3(), []); @@ -1184,23 +1224,27 @@ export function GameScene() { camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }} gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }} > - - - - - - - - - - {PERFORMANCE_PROBE_ENABLED && } + + + + + + + + + + + {PERFORMANCE_PROBE_ENABLED && } + ); } -for (const memberId of CRITICAL_PARTY_MEMBER_IDS) { - useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true); - const loadout = PARTY_WEAPON_URLS[memberId]; - useGLTF.preload(loadout.right, false, true); - if (loadout.left) useGLTF.preload(loadout.left, false, true); +if (LEGACY_GAME_ASSETS_FORCED) { + for (const memberId of CRITICAL_PARTY_MEMBER_IDS) { + useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true); + const loadout = PARTY_WEAPON_URLS[memberId]; + useGLTF.preload(loadout.right, false, true); + if (loadout.left) useGLTF.preload(loadout.left, false, true); + } } diff --git a/src/game/bossCatalog.test.ts b/src/game/bossCatalog.test.ts index 78b8c20..6a5f124 100644 --- a/src/game/bossCatalog.test.ts +++ b/src/game/bossCatalog.test.ts @@ -7,7 +7,7 @@ import { ALTERNATE_BOSS_CONFIG } from "./bossVisuals"; describe("boss catalog", () => { it("derives the available roster from every catalog definition", () => { expect(AVAILABLE_BOSS_IDS).toEqual(Object.keys(BOSS_DEFINITIONS)); - expect(new Set(AVAILABLE_BOSS_IDS)).toHaveLength(27); + expect(new Set(AVAILABLE_BOSS_IDS)).toHaveLength(28); }); it("assigns every boss to one compatible mechanic group", () => { @@ -66,6 +66,18 @@ describe("boss catalog", () => { expect(ALTERNATE_BOSS_CONFIG["bristlequake-boar"].death).toBe("Dying"); }); + it("assigns Gravehorn to the underfilled burrow group with its optimized animation set", () => { + expect(BOSS_GROUPS.find((group) => group.id === "burrow-eruption")?.bossIds).toContain("gravehorn-triceratops"); + expect(ALTERNATE_BOSS_CONFIG["gravehorn-triceratops"]).toMatchObject({ + idle: "Gravehorn|Idle", + move: "Armature|Walk", + attack: "Armature|Roar", + special: "Armature|RiseUp", + death: "Armature|Fall", + }); + expect(ALTERNATE_BOSS_CONFIG["gravehorn-triceratops"].optimizedUrl).toContain("gravehorn-triceratops-uastc"); + }); + it("uses original animated creatures instead of the retired chicken and frog visuals", () => { expect(ALTERNATE_BOSS_CONFIG["cluckhorn-colossus"]).toMatchObject({ idle: "Idle", move: "Scuttle", attack: "BeakRend", special: "FurnaceBurst", death: "Death", diff --git a/src/game/bossCatalog.ts b/src/game/bossCatalog.ts index bd6e361..92a17ba 100644 --- a/src/game/bossCatalog.ts +++ b/src/game/bossCatalog.ts @@ -191,6 +191,10 @@ const BOSS_SEEDS: Record = { name: "Rimeclaw", title: "The Frozen Duel", icon: "✥", accent: "#75bfe8", summary: "Sidesteps between frost strikes before forming a lethal ice cross.", mechanicIds: ["elemental-beam", "guardian-cross", "memory-sequence"], maxHp: 500, archetype: "duelist", }, + "gravehorn-triceratops": { + name: "Gravehorn", title: "The Fossil Wake", icon: "☠", accent: "#d8c79b", + summary: "Burrows through fossil trails, erupts beneath the party, and releases a radial grave pulse.", mechanicIds: ["burrow-rush", "hourglass-eruption", "destruction-pulse"], maxHp: 540, archetype: "burrower", + }, }; /** @@ -220,7 +224,7 @@ export const BOSS_GROUPS: readonly BossGroupDefinition[] = [ }, { id: "burrow-eruption", letter: "F", name: "Burrow / Eruption", coreMechanic: "Burrow and Eruption", - bossIds: ["sandglass-scorpion", "mirelord-frog"], archetypes: ["burrower"], + bossIds: ["sandglass-scorpion", "mirelord-frog", "gravehorn-triceratops"], archetypes: ["burrower"], }, { id: "scuttle-burst", letter: "G", name: "Scuttle / Burst", coreMechanic: "Scuttle and Burst", diff --git a/src/game/bossRooms.ts b/src/game/bossRooms.ts index 7014754..4702986 100644 --- a/src/game/bossRooms.ts +++ b/src/game/bossRooms.ts @@ -133,6 +133,9 @@ export const BOSS_ROOMS = { "rimeclaw-yeti": room("frozen-duel", "The Frozen Duel", "Frozen mirror palace", "frost", { background: "#07162a", fog: "#285a83", sky: "#8fd8ff", ground: "#030c19", floorColor: "#17476d", wallColor: "#2d6691", accent: "#74c8f4", accentSecondary: "#c1f6ff", wallHeight: 4.9, }), + "gravehorn-triceratops": room("fossil-wake", "The Fossil Wake", "Buried ossuary", "reliquary", { + background: "#100d08", fog: "#40382a", sky: "#d8c79b", ground: "#090704", floorColor: "#332b20", wallColor: "#4c4030", accent: "#d8c79b", accentSecondary: "#8eb9a6", wallHeight: 4.5, + }), } as const satisfies Record; export function bossRoomFor(bossId: BossId): BossRoomDefinition { diff --git a/src/game/bossVisuals.ts b/src/game/bossVisuals.ts index bdf7d83..c5bc5a8 100644 --- a/src/game/bossVisuals.ts +++ b/src/game/bossVisuals.ts @@ -4,6 +4,7 @@ export type AlternateBossKind = Exclude; export interface AlternateBossConfig { url: string; + optimizedUrl?: string; scale: number; idle: string; move: string; @@ -21,6 +22,8 @@ const SANDGLASS_URL = new URL("../assets/game/models/original/bosses/sandglass-s const CRYSTAL_BAT_MATRIARCH_URL = new URL("../assets/game/models/original/bosses/crystal-bat-matriarch/crystal-bat-matriarch.glb", import.meta.url).href; const BRASSBEAK_BASILISK_URL = new URL("../assets/game/models/original/bosses/brassbeak-basilisk/brassbeak-basilisk.glb", import.meta.url).href; const BOGBELL_MYCONID_URL = new URL("../assets/game/models/original/bosses/bogbell-myconid/bogbell-myconid.glb", import.meta.url).href; +const GRAVEHORN_URL = new URL("../assets/game/models/sketchfab-opensource/gravehorn-triceratops.glb", import.meta.url).href; +const GRAVEHORN_OPTIMIZED_URL = new URL("../assets/game/models/sketchfab-opensource/gravehorn-triceratops-uastc.glb", import.meta.url).href; const CRAGCLAW_URL = new URL("../assets/game/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href; const MOURNVEIL_URL = new URL("../assets/game/models/claudecraft/creatures/ghost.glb", import.meta.url).href; const CROWNSHARD_URL = new URL("../assets/game/models/claudecraft/creatures/golelingevolved.glb", import.meta.url).href; @@ -34,6 +37,7 @@ const CLAUDE_BOSS_URLS: Record, string> = { "stormwool-alpaca": new URL("../assets/game/models/claudecraft/creatures/alpaca.glb", import.meta.url).href, "ashwing-demon": new URL("../assets/game/models/claudecraft/creatures/demon.glb", import.meta.url).href, @@ -84,8 +88,11 @@ export const ALTERNATE_BOSS_CONFIG: Record