Compare commits

..
5 Commits
Author SHA1 Message Date
Warren H ed34c2503c Release v0.1.12 2026-07-13 2026-07-13 22:41:35 -04:00
Warren H 8e48bb4fb6 Release v0.1.11 2026-07-13 2026-07-13 21:31:36 -04:00
Warren H fb41147197 Release v0.1.10 2026-07-13 2026-07-13 20:47:56 -04:00
Warren H aa9f5eff20 Release v0.1.9 2026-07-13 2026-07-13 19:37:30 -04:00
Warren H 18c416d4d2 Release v0.1.8 2026-07-13 2026-07-13 17:21:10 -04:00
66 changed files with 3339 additions and 314 deletions
+1
View File
@@ -12,3 +12,4 @@ vite.config.d.ts
!.env.example
__pycache__/
*.py[cod]
/public/basis/
+11 -2
View File
@@ -1,13 +1,15 @@
{
"name": "i-want-to-heal",
"private": true,
"version": "0.1.7",
"version": "0.1.12",
"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"
+379
View File
@@ -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: {}
+1
View File
@@ -1,2 +1,3 @@
allowBuilds:
esbuild: true
sharp: true
@@ -0,0 +1,352 @@
"""Build two original low-poly creature bosses as animated runtime GLBs.
Replaces weak chicken and frog visuals while keeping stable boss IDs in game data.
Run with:
/Applications/Blender.app/Contents/MacOS/Blender --background --factory-startup \
--python scripts/blender/build_replacement_creature_bosses.py
"""
from __future__ import annotations
import json
import math
import sys
from pathlib import Path
import bpy
sys.path.insert(0, str(Path(__file__).parent))
from build_iwt2_boss_trio import ( # noqa: E402
OUT_ROOT,
actions,
armature,
cone,
ellipsoid,
export_asset,
finish,
join_parts,
plate,
prepare_materials,
reset_scene,
)
def add_metadata(asset_id: str, concept: str) -> None:
metadata_path = OUT_ROOT / asset_id / f"{asset_id}.asset.json"
metadata = json.loads(metadata_path.read_text())
metadata["sourceConcept"] = concept
metadata["license"] = "Original project-owned asset"
metadata["runtime"]["forward"] = "-Y"
metadata["runtime"]["unit"] = "meters"
metadata_path.write_text(json.dumps(metadata, indent=2) + "\n")
def build_brassbeak_basilisk() -> None:
"""Six-legged forge basilisk replacing Cluckhorn's chicken-cow model."""
asset_id = "brassbeak-basilisk"
reset_scene()
mats = prepare_materials({
"Scale": {"color": (0.035, 0.105, 0.12, 1), "metallic": 0.14, "roughness": 0.62},
"Underbelly": {"color": (0.12, 0.19, 0.18, 1), "metallic": 0.05, "roughness": 0.72},
"Copper": {"color": (0.45, 0.16, 0.055, 1), "metallic": 0.48, "roughness": 0.33},
"Brass": {"color": (0.78, 0.48, 0.09, 1), "metallic": 0.62, "roughness": 0.25},
"Blade": {"color": (0.50, 0.58, 0.55, 1), "metallic": 0.72, "roughness": 0.2},
"Furnace": {"color": (0.02, 0.82, 0.72, 1), "roughness": 0.18, "emission": (0.01, 0.72, 0.64, 1), "strength": 5.5},
})
specs = [
("Root", (0, 0, 0), (0, 0, 0.45), None),
("Body", (0, 0.06, 1.18), (0, 0.05, 2.05), "Root"),
("Head", (0, -1.0, 1.48), (0, -1.82, 1.38), "Body"),
("Jaw", (0, -1.42, 1.28), (0, -2.05, 1.12), "Head"),
("Wing.L", (-0.62, -0.08, 1.72), (-1.55, -0.28, 1.5), "Body"),
("Wing.R", (0.62, -0.08, 1.72), (1.55, -0.28, 1.5), "Body"),
("Leg.FL", (-0.62, -0.72, 1.12), (-0.88, -0.84, 0.24), "Body"),
("Leg.FR", (0.62, -0.72, 1.12), (0.88, -0.84, 0.24), "Body"),
("Leg.ML", (-0.78, 0.03, 1.06), (-1.0, 0.02, 0.22), "Body"),
("Leg.MR", (0.78, 0.03, 1.06), (1.0, 0.02, 0.22), "Body"),
("Leg.BL", (-0.66, 0.76, 1.12), (-0.9, 0.88, 0.24), "Body"),
("Leg.BR", (0.66, 0.76, 1.12), (0.9, 0.88, 0.24), "Body"),
("Tail.1", (0, 1.0, 1.3), (0, 1.85, 1.12), "Body"),
("Tail.2", (0, 1.8, 1.12), (0, 2.7, 0.92), "Tail.1"),
]
rig = armature("BrassbeakBasilisk", specs)
# Broad armored silhouette with glowing furnace seams.
ellipsoid("BasiliskBody", (0, 0.08, 1.36), (1.08, 1.43, 0.72), mats["Scale"], "Body", 2)
ellipsoid("FurnaceBelly", (0, -0.15, 1.08), (0.78, 1.08, 0.46), mats["Underbelly"], "Body", 2)
for index, y in enumerate((-0.7, -0.22, 0.28, 0.76)):
width = 0.82 + (0.12 if index in (1, 2) else 0)
plate(
f"BackPlate{index}", (0, y, 1.92 + 0.08 * math.sin(index)),
(width, 0.55, 0.27), (math.radians(84), 0, 0),
mats["Copper"] if index % 2 == 0 else mats["Brass"], "Body",
)
cone(
f"ChimneySpine{index}", (0, y, 2.02), (0, y + 0.03, 2.52 - index * 0.04),
0.13, 0, mats["Blade"], "Body", 5,
)
for side in (-1, 1):
cone(
f"FurnaceSeam{side:+d}", (side * 0.58, -0.76, 1.34), (side * 0.72, 0.72, 1.34),
0.035, 0.022, mats["Furnace"], "Body", 5,
)
# Hammerhead, brass beak, split jaw, and crown blades.
ellipsoid("HammerHead", (0, -1.28, 1.5), (0.78, 0.74, 0.56), mats["Copper"], "Head", 2)
ellipsoid("FaceMask", (0, -1.72, 1.5), (0.58, 0.34, 0.42), mats["Brass"], "Head", 1)
cone("UpperBeak", (0, -1.68, 1.51), (0, -2.58, 1.34), 0.42, 0.035, mats["Brass"], "Head", 6)
cone("LowerBeak", (0, -1.64, 1.3), (0, -2.32, 1.18), 0.3, 0.025, mats["Blade"], "Jaw", 6)
for side, suffix in ((-1, "L"), (1, "R")):
ellipsoid(f"Eye{suffix}", (side * 0.43, -1.72, 1.66), (0.09, 0.055, 0.09), mats["Furnace"], "Head", 1)
cone(
f"BrowHorn{suffix}", (side * 0.42, -1.38, 1.78), (side * 0.88, -1.7, 2.03),
0.13, 0, mats["Blade"], "Head", 5,
)
for index, (x, z) in enumerate(((-0.34, 2.0), (0, 2.14), (0.34, 2.0))):
cone(f"CrownBlade{index}", (x, -1.2, 1.8), (x * 1.35, -1.15, z + 0.54), 0.12, 0, mats["Brass"], "Head", 5)
# Blade-like vestigial wings make lateral cleaves readable from camera.
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Wing.{suffix}"
plate(
f"WingShield{suffix}", (side * 1.05, -0.08, 1.62), (0.7, 0.56, 0.13),
(math.radians(78), math.radians(side * 18), math.radians(side * 8)), mats["Copper"], bone,
)
cone(
f"WingBlade{suffix}", (side * 0.72, -0.24, 1.7), (side * 1.95, -0.58, 1.42),
0.19, 0.015, mats["Blade"], bone, 6,
)
cone(
f"WingGlow{suffix}", (side * 0.88, -0.3, 1.69), (side * 1.7, -0.52, 1.5),
0.05, 0.008, mats["Furnace"], bone, 5,
)
# Six short piston legs: stable, strange, easy to read while scuttling.
leg_rows = (("F", -0.72), ("M", 0.03), ("B", 0.76))
for row_index, (row, y) in enumerate(leg_rows):
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Leg.{row}{suffix}"
hip_x = side * (0.64 if row != "M" else 0.78)
foot_x = side * (0.98 if row != "M" else 1.1)
ellipsoid(f"Hip{row}{suffix}", (hip_x, y, 1.05), (0.3, 0.34, 0.3), mats["Copper"], bone, 1)
cone(f"Shin{row}{suffix}", (hip_x, y, 1.0), (foot_x, y - 0.04, 0.28), 0.22, 0.14, mats["Scale"], bone, 6)
ellipsoid(f"Foot{row}{suffix}", (foot_x, y - 0.22, 0.2), (0.31, 0.48, 0.19), mats["Brass"], bone, 1)
for toe_index, toe_x in enumerate((-0.13, 0.13)):
cone(
f"Toe{row}{suffix}{toe_index}", (foot_x + toe_x, y - 0.42, 0.2),
(foot_x + toe_x * 1.4, y - 0.75, 0.1), 0.055, 0.004, mats["Blade"], bone, 5,
)
cone("TailCore1", (0, 0.95, 1.3), (0, 1.85, 1.1), 0.48, 0.3, mats["Scale"], "Tail.1", 7)
cone("TailCore2", (0, 1.78, 1.1), (0, 2.72, 0.88), 0.31, 0.07, mats["Copper"], "Tail.2", 7)
cone("TailBladeTop", (0, 2.45, 0.9), (0, 3.18, 1.45), 0.2, 0.015, mats["Blade"], "Tail.2", 5)
cone("TailBladeBottom", (0, 2.45, 0.9), (0, 3.15, 0.48), 0.18, 0.015, mats["Brass"], "Tail.2", 5)
ellipsoid("TailCoreGlow", (0, 2.54, 0.91), (0.14, 0.17, 0.14), mats["Furnace"], "Tail.2", 1)
body = join_parts("BrassbeakBasilisk", rig)
clips = actions(rig, brassbeak_actions())
export_asset(
asset_id, "Brassbeak Basilisk", rig, body, clips,
[
("Body", (0, 0, 1.25), (1.25, 1.55, 0.9)),
("Head", (0, -1.65, 1.42), (0.95, 1.05, 0.75)),
("Tail", (0, 2.08, 1.0), (0.55, 1.25, 0.78)),
],
(0, 0, 1.25), 8.8, "FurnaceBurst", 20,
)
add_metadata(asset_id, "Original six-legged forge basilisk designed for I Want to Heal")
def brassbeak_actions():
return [
("Idle", 60, True, [
{"frame": 1},
{"frame": 15, "locations": {"Root": (0, 0, 0.04)}, "rotations": {"Head": (3, 0, -3), "Jaw": (7, 0, 0), "Tail.2": (0, 0, 7), "Wing.L": (0, 0, -4), "Wing.R": (0, 0, 4)}},
{"frame": 30, "rotations": {"Head": (0, 0, 3), "Jaw": (0, 0, 0), "Tail.2": (0, 0, -7)}},
{"frame": 45, "locations": {"Root": (0, 0, 0.04)}, "rotations": {"Head": (3, 0, -3), "Jaw": (7, 0, 0), "Tail.2": (0, 0, 7), "Wing.L": (0, 0, -4), "Wing.R": (0, 0, 4)}},
{"frame": 60},
]),
("Scuttle", 30, True, [
{"frame": 1, "rotations": {"Leg.FL": (-18, 0, -5), "Leg.MR": (-18, 0, 4), "Leg.BL": (-18, 0, -4), "Leg.FR": (18, 0, 5), "Leg.ML": (18, 0, -4), "Leg.BR": (18, 0, 4), "Tail.2": (0, 0, -9)}},
{"frame": 8, "locations": {"Root": (0, 0, 0.08)}, "rotations": {"Body": (-3, 0, 0)}},
{"frame": 16, "rotations": {"Leg.FL": (18, 0, 5), "Leg.MR": (18, 0, -4), "Leg.BL": (18, 0, 4), "Leg.FR": (-18, 0, -5), "Leg.ML": (-18, 0, 4), "Leg.BR": (-18, 0, -4), "Tail.2": (0, 0, 9)}},
{"frame": 23, "locations": {"Root": (0, 0, 0.08)}, "rotations": {"Body": (3, 0, 0)}},
{"frame": 30, "rotations": {"Leg.FL": (-18, 0, -5), "Leg.MR": (-18, 0, 4), "Leg.BL": (-18, 0, -4), "Leg.FR": (18, 0, 5), "Leg.ML": (18, 0, -4), "Leg.BR": (18, 0, 4), "Tail.2": (0, 0, -9)}},
]),
("BeakRend", 34, False, [
{"frame": 1},
{"frame": 9, "locations": {"Root": (0, 0.12, -0.05)}, "rotations": {"Body": (-9, 0, 0), "Head": (-24, 0, 0), "Jaw": (24, 0, 0), "Wing.L": (0, -16, -10), "Wing.R": (0, 16, 10)}},
{"frame": 15, "locations": {"Root": (0, -0.2, 0.05)}, "rotations": {"Body": (15, 0, 0), "Head": (28, 0, 0), "Jaw": (-6, 0, 0)}},
{"frame": 23, "rotations": {"Head": (-8, 0, 0), "Jaw": (12, 0, 0)}},
{"frame": 34},
]),
("FurnaceBurst", 46, False, [
{"frame": 1},
{"frame": 12, "locations": {"Root": (0, 0, 0.1)}, "scales": {"Body": (0.94, 0.94, 0.94)}, "rotations": {"Wing.L": (-18, 18, -22), "Wing.R": (-18, -18, 22), "Head": (-12, 0, 0), "Jaw": (18, 0, 0), "Tail.1": (-12, 0, 0)}},
{"frame": 20, "locations": {"Root": (0, -0.08, 0.22)}, "scales": {"Body": (1.1, 1.1, 1.1)}, "rotations": {"Wing.L": (18, -62, -64), "Wing.R": (18, 62, 64), "Head": (20, 0, 0), "Jaw": (30, 0, 0), "Tail.1": (18, 0, 0), "Tail.2": (-22, 0, 0)}},
{"frame": 30, "scales": {"Body": (0.97, 0.97, 0.97)}, "rotations": {"Wing.L": (4, -18, -20), "Wing.R": (4, 18, 20), "Jaw": (4, 0, 0), "Tail.2": (8, 0, 0)}},
{"frame": 46},
]),
("Stagger", 28, False, [
{"frame": 1},
{"frame": 6, "locations": {"Root": (0.12, 0.12, -0.09)}, "rotations": {"Body": (-14, 0, 13), "Head": (22, 0, -12), "Wing.L": (24, 0, -18), "Wing.R": (-8, 0, 12)}},
{"frame": 15, "rotations": {"Body": (7, 0, -6), "Head": (-8, 0, 5)}},
{"frame": 28},
]),
("Death", 72, False, [
{"frame": 1},
{"frame": 20, "locations": {"Root": (0.15, 0.08, -0.25)}, "rotations": {"Root": (0, 25, 32), "Body": (18, 0, 12), "Head": (24, 0, -10), "Jaw": (20, 0, 0), "Wing.L": (32, 0, -26), "Wing.R": (16, 0, 20)}},
{"frame": 46, "locations": {"Root": (0.28, 0.08, -0.78)}, "rotations": {"Root": (0, 52, 82), "Body": (30, 0, 20), "Head": (42, 0, -20), "Leg.FL": (30, 0, 0), "Leg.ML": (-24, 0, 0), "Leg.BL": (20, 0, 0), "Tail.1": (-32, 0, 0), "Tail.2": (-25, 0, 0)}},
{"frame": 72, "locations": {"Root": (0.28, 0.08, -0.82)}, "rotations": {"Root": (0, 52, 82), "Body": (30, 0, 20), "Head": (44, 0, -20), "Leg.FL": (30, 0, 0), "Leg.ML": (-24, 0, 0), "Leg.BL": (20, 0, 0), "Tail.1": (-32, 0, 0), "Tail.2": (-25, 0, 0)}},
]),
]
def build_bogbell_myconid() -> None:
"""Bell-capped fungal brute replacing Mirelord's frog model."""
asset_id = "bogbell-myconid"
reset_scene()
mats = prepare_materials({
"Bark": {"color": (0.12, 0.18, 0.095, 1), "roughness": 0.88},
"Root": {"color": (0.25, 0.31, 0.16, 1), "roughness": 0.8},
"Cap": {"color": (0.29, 0.055, 0.31, 1), "roughness": 0.6},
"CapEdge": {"color": (0.52, 0.15, 0.42, 1), "roughness": 0.52},
"Gill": {"color": (0.62, 0.55, 0.31, 1), "roughness": 0.7},
"Spore": {"color": (0.48, 1.0, 0.32, 1), "roughness": 0.16, "emission": (0.22, 0.92, 0.16, 1), "strength": 4.8},
})
specs = [
("Root", (0, 0, 0), (0, 0, 0.5), None),
("Body", (0, 0, 1.15), (0, 0, 2.25), "Root"),
("Cap", (0, -0.05, 2.18), (0, -0.05, 3.18), "Body"),
("Arm.L", (-0.58, -0.15, 1.72), (-1.28, -0.62, 0.7), "Body"),
("Arm.R", (0.58, -0.15, 1.72), (1.28, -0.62, 0.7), "Body"),
("Leg.L", (-0.38, 0.08, 1.08), (-0.58, -0.1, 0.2), "Body"),
("Leg.R", (0.38, 0.08, 1.08), (0.58, -0.1, 0.2), "Body"),
("Tendril.L", (-0.42, 0.58, 1.45), (-1.05, 1.45, 0.82), "Body"),
("Tendril.R", (0.42, 0.58, 1.45), (1.05, 1.45, 0.82), "Body"),
]
rig = armature("BogbellMyconid", specs)
# Gnarled trunk and hanging bell cap.
ellipsoid("Trunk", (0, 0.05, 1.48), (0.82, 0.68, 1.05), mats["Bark"], "Body", 2)
ellipsoid("ChestKnot", (0, -0.5, 1.62), (0.58, 0.28, 0.62), mats["Root"], "Body", 1)
cone("NeckStalk", (0, -0.02, 1.95), (0, -0.04, 2.65), 0.48, 0.36, mats["Gill"], "Cap", 8)
plate("BellCap", (0, -0.04, 2.78), (1.55, 1.38, 0.55), (0, 0, 0), mats["Cap"], "Cap", 9)
ellipsoid("CapCrown", (0, 0.02, 3.04), (1.25, 1.08, 0.42), mats["CapEdge"], "Cap", 2)
plate("GillBell", (0, -0.03, 2.58), (1.28, 1.12, 0.28), (0, 0, math.radians(180)), mats["Gill"], "Cap", 9)
for index, angle in enumerate(range(0, 360, 45)):
radians = math.radians(angle)
x, y = math.cos(radians) * 1.02, math.sin(radians) * 0.87
cone(
f"CapHorn{index}", (x * 0.9, y * 0.9, 3.12), (x * 1.38, y * 1.35, 3.34 + 0.08 * (index % 2)),
0.11, 0, mats["CapEdge"], "Cap", 5,
)
for side, suffix in ((-1, "L"), (1, "R")):
ellipsoid(f"Eye{suffix}", (side * 0.29, -0.65, 2.18), (0.095, 0.055, 0.11), mats["Spore"], "Cap", 1)
cone(
f"FaceRoot{suffix}", (side * 0.25, -0.52, 2.03), (side * 0.42, -0.78, 1.72),
0.07, 0.015, mats["Root"], "Cap", 5,
)
ellipsoid("MouthHollow", (0, -0.68, 1.94), (0.22, 0.055, 0.13), mats["Cap"], "Cap", 1)
# Root arms end in broad knuckles for readable pummel animation.
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Arm.{suffix}"
cone(f"UpperArm{suffix}", (side * 0.55, -0.12, 1.78), (side * 1.04, -0.45, 1.05), 0.3, 0.22, mats["Bark"], bone, 7)
cone(f"Forearm{suffix}", (side * 1.02, -0.44, 1.06), (side * 1.34, -0.84, 0.58), 0.24, 0.18, mats["Root"], bone, 7)
ellipsoid(f"Knuckle{suffix}", (side * 1.38, -0.91, 0.48), (0.43, 0.38, 0.32), mats["Bark"], bone, 1)
for finger in (-0.16, 0, 0.16):
cone(
f"Finger{suffix}{finger}", (side * 1.36 + finger, -1.02, 0.43),
(side * 1.46 + finger, -1.35, 0.22), 0.065, 0.012, mats["Root"], bone, 5,
)
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Leg.{suffix}"
ellipsoid(f"Hip{suffix}", (side * 0.4, 0.08, 1.0), (0.42, 0.46, 0.5), mats["Bark"], bone, 1)
cone(f"RootLeg{suffix}", (side * 0.4, 0.06, 0.95), (side * 0.62, -0.12, 0.25), 0.34, 0.22, mats["Root"], bone, 7)
for toe_index, toe_x in enumerate((-0.22, 0, 0.22)):
cone(
f"RootToe{suffix}{toe_index}", (side * 0.62 + toe_x, -0.2, 0.25),
(side * 0.72 + toe_x * 1.25, -0.78 - abs(toe_x), 0.08), 0.095, 0.015, mats["Bark"], bone, 6,
)
# Rear tendrils drag through mire. Spore sacs pulse during eruption.
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Tendril.{suffix}"
cone(f"TendrilBase{suffix}", (side * 0.4, 0.5, 1.38), (side * 0.78, 1.22, 0.82), 0.22, 0.12, mats["Root"], bone, 7)
cone(f"TendrilTip{suffix}", (side * 0.76, 1.18, 0.84), (side * 1.28, 1.85, 0.3), 0.13, 0.018, mats["Bark"], bone, 6)
ellipsoid(f"SporeSac{suffix}", (side * 0.82, 0.72, 1.25), (0.24, 0.31, 0.3), mats["Spore"], bone, 1)
for index, (x, y, z, size) in enumerate(((-0.5, 0.45, 1.88, 0.16), (0.48, 0.5, 1.72, 0.2), (-0.28, 0.62, 1.35, 0.13))):
ellipsoid(f"BodySpore{index}", (x, y, z), (size, size * 0.82, size * 1.1), mats["Spore"], "Body", 1)
body = join_parts("BogbellMyconid", rig)
clips = actions(rig, bogbell_actions())
export_asset(
asset_id, "Bogbell Myconid", rig, body, clips,
[
("Body", (0, 0, 1.45), (1.05, 0.95, 1.35)),
("Cap", (0, 0, 2.82), (1.68, 1.48, 0.72)),
("Roots", (0, 0.38, 0.62), (1.58, 1.75, 0.72)),
],
(0, 0, 1.65), 8.5, "SporeEruption", 21,
)
add_metadata(asset_id, "Original bell-capped fungal mire creature designed for I Want to Heal")
def bogbell_actions():
return [
("Idle", 60, True, [
{"frame": 1},
{"frame": 15, "locations": {"Root": (0, 0, 0.04)}, "scales": {"Cap": (1.03, 1.03, 0.98)}, "rotations": {"Cap": (2, 0, -3), "Arm.L": (0, 0, -3), "Arm.R": (0, 0, 3), "Tendril.L": (0, 0, 7), "Tendril.R": (0, 0, -7)}},
{"frame": 30, "scales": {"Cap": (0.98, 0.98, 1.03)}, "rotations": {"Cap": (-1, 0, 3), "Tendril.L": (0, 0, -7), "Tendril.R": (0, 0, 7)}},
{"frame": 45, "locations": {"Root": (0, 0, 0.04)}, "scales": {"Cap": (1.03, 1.03, 0.98)}, "rotations": {"Cap": (2, 0, -3), "Arm.L": (0, 0, -3), "Arm.R": (0, 0, 3), "Tendril.L": (0, 0, 7), "Tendril.R": (0, 0, -7)}},
{"frame": 60},
]),
("BurrowRush", 32, True, [
{"frame": 1, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (10, 0, 0), "Arm.L": (26, 0, -12), "Arm.R": (26, 0, 12), "Leg.L": (-16, 0, 0), "Leg.R": (16, 0, 0), "Tendril.L": (-18, 0, -12), "Tendril.R": (-18, 0, 12)}},
{"frame": 9, "locations": {"Root": (0, 0, -0.26)}, "rotations": {"Cap": (-9, 0, 0), "Leg.L": (16, 0, 0), "Leg.R": (-16, 0, 0), "Tendril.L": (14, 0, 10), "Tendril.R": (14, 0, -10)}},
{"frame": 17, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (10, 0, 0), "Arm.L": (26, 0, -12), "Arm.R": (26, 0, 12), "Leg.L": (-16, 0, 0), "Leg.R": (16, 0, 0), "Tendril.L": (-18, 0, -12), "Tendril.R": (-18, 0, 12)}},
{"frame": 25, "locations": {"Root": (0, 0, -0.26)}, "rotations": {"Cap": (-9, 0, 0), "Leg.L": (16, 0, 0), "Leg.R": (-16, 0, 0), "Tendril.L": (14, 0, 10), "Tendril.R": (14, 0, -10)}},
{"frame": 32, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (10, 0, 0), "Arm.L": (26, 0, -12), "Arm.R": (26, 0, 12), "Leg.L": (-16, 0, 0), "Leg.R": (16, 0, 0), "Tendril.L": (-18, 0, -12), "Tendril.R": (-18, 0, 12)}},
]),
("RootPummel", 36, False, [
{"frame": 1},
{"frame": 10, "locations": {"Root": (0, 0.1, 0.06)}, "rotations": {"Body": (-12, 0, 0), "Cap": (-8, 0, 0), "Arm.L": (-42, 0, -30), "Arm.R": (-42, 0, 30)}},
{"frame": 17, "locations": {"Root": (0, -0.12, -0.14)}, "rotations": {"Body": (22, 0, 0), "Cap": (18, 0, 0), "Arm.L": (58, 0, 16), "Arm.R": (58, 0, -16)}},
{"frame": 25, "rotations": {"Body": (-5, 0, 0), "Arm.L": (12, 0, -6), "Arm.R": (12, 0, 6)}},
{"frame": 36},
]),
("SporeEruption", 48, False, [
{"frame": 1},
{"frame": 13, "locations": {"Root": (0, 0, -0.12)}, "scales": {"Cap": (0.88, 0.88, 1.16)}, "rotations": {"Body": (-13, 0, 0), "Cap": (-15, 0, 0), "Arm.L": (-26, 0, -28), "Arm.R": (-26, 0, 28), "Tendril.L": (-28, 0, -24), "Tendril.R": (-28, 0, 24)}},
{"frame": 21, "locations": {"Root": (0, -0.04, 0.24)}, "scales": {"Cap": (1.18, 1.18, 0.9), "Body": (1.08, 1.08, 1.08)}, "rotations": {"Body": (18, 0, 0), "Cap": (19, 0, 0), "Arm.L": (18, 0, 62), "Arm.R": (18, 0, -62), "Tendril.L": (32, 0, 48), "Tendril.R": (32, 0, -48)}},
{"frame": 32, "scales": {"Cap": (0.97, 0.97, 1.04), "Body": (0.97, 0.97, 0.97)}, "rotations": {"Cap": (-5, 0, 0), "Arm.L": (4, 0, 12), "Arm.R": (4, 0, -12)}},
{"frame": 48},
]),
("Stagger", 28, False, [
{"frame": 1},
{"frame": 6, "locations": {"Root": (0.14, 0.1, -0.08)}, "rotations": {"Body": (-15, 0, 13), "Cap": (24, 0, -18), "Arm.L": (20, 0, -18), "Arm.R": (-8, 0, 12)}},
{"frame": 15, "rotations": {"Body": (7, 0, -6), "Cap": (-8, 0, 7)}},
{"frame": 28},
]),
("Death", 74, False, [
{"frame": 1},
{"frame": 20, "locations": {"Root": (0.14, 0.1, -0.3)}, "rotations": {"Root": (0, 24, 30), "Body": (20, 0, 12), "Cap": (28, 0, -18), "Arm.L": (30, 0, -26), "Arm.R": (16, 0, 20), "Tendril.L": (-24, 0, -14), "Tendril.R": (-16, 0, 18)}},
{"frame": 48, "locations": {"Root": (0.28, 0.1, -0.86)}, "rotations": {"Root": (0, 54, 84), "Body": (34, 0, 24), "Cap": (48, 0, -30), "Arm.L": (52, 0, -42), "Arm.R": (28, 0, 34), "Leg.L": (24, 0, 0), "Leg.R": (-18, 0, 0), "Tendril.L": (-40, 0, -24), "Tendril.R": (-34, 0, 26)}},
{"frame": 74, "locations": {"Root": (0.28, 0.1, -0.9)}, "rotations": {"Root": (0, 54, 84), "Body": (34, 0, 24), "Cap": (50, 0, -30), "Arm.L": (52, 0, -42), "Arm.R": (28, 0, 34), "Leg.L": (24, 0, 0), "Leg.R": (-18, 0, 0), "Tendril.L": (-40, 0, -24), "Tendril.R": (-34, 0, 26)}},
]),
]
def main() -> None:
bpy.context.preferences.filepaths.save_version = 0
OUT_ROOT.mkdir(parents=True, exist_ok=True)
build_brassbeak_basilisk()
build_bogbell_myconid()
if __name__ == "__main__":
main()
+80
View File
@@ -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.");
+170
View File
@@ -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).`);
+70
View File
@@ -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}`);
}
+72
View File
@@ -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 });
}
}
+15
View File
@@ -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`);
+16
View File
@@ -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 <path-within-game_assets>` 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 <path> --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.
@@ -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.
+97 -17
View File
@@ -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<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
}
function DungeonAssetInstances({
url,
function DungeonMeshInstances({
mesh,
fixtures,
tint,
opacity = 1,
colors,
}: {
url: string;
mesh: THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
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 <instancedMesh ref={instances} args={[mesh.geometry, material, fixtures.length]} castShadow receiveShadow />;
}
function ArenaArchitecture({ room }: { room: BossRoomDefinition }) {
const walls = useMemo(() => ARENA_WALL_SEGMENTS.map((fixture) => ({
function LegacyDungeonAssetInstances({
url,
...props
}: Omit<Parameters<typeof DungeonMeshInstances>[0], "mesh"> & { url: string }) {
const gltf = useGLTF(url, false, true);
const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]);
return <DungeonMeshInstances mesh={mesh} {...props} />;
}
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 (
<group>
<DungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
<DungeonAssetInstances url={KAYKIT_DUNGEON_PILLAR_URL} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
<DungeonAssetInstances url={KAYKIT_DUNGEON_TORCH_URL} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_PILLAR_URL} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_TORCH_URL} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
</group>
);
}
function namedMesh(scene: THREE.Object3D, name: string) {
const object = scene.getObjectByName(name);
if (object instanceof THREE.Mesh) {
return object as THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
}
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 (
<group>
<DungeonMeshInstances mesh={meshes.wall} fixtures={walls} tint={room.wallColor} opacity={0.54} />
<DungeonMeshInstances mesh={meshes.pillar} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
<DungeonMeshInstances mesh={meshes.torch} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
</group>
);
}
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<THREE.Group>(null);
const previousCameraPosition = useRef<THREE.Vector3 | null>(null);
@@ -205,14 +267,30 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
);
}
function RoomWalls({ room }: { room: BossRoomDefinition }) {
function LegacyRoomWalls({ room }: { room: BossRoomDefinition }) {
return (
<Suspense fallback={<RoomWallFallback room={room} />}>
<ArenaArchitecture room={room} />
<LegacyArenaArchitecture room={room} />
</Suspense>
);
}
function OptimizedRoomWalls({ room }: { room: BossRoomDefinition }) {
return (
<DungeonAssetErrorBoundary fallback={<LegacyRoomWalls room={room} />}>
<Suspense fallback={<RoomWallFallback room={room} />}>
<DungeonKitArchitecture room={room} />
</Suspense>
</DungeonAssetErrorBoundary>
);
}
function RoomWalls({ room }: { room: BossRoomDefinition }) {
return LEGACY_GAME_ASSETS_FORCED
? <LegacyRoomWalls room={room} />
: <OptimizedRoomWalls room={room} />;
}
function RoomMarks({ room }: { room: BossRoomDefinition }) {
const rays = useRef<THREE.InstancedMesh>(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);
}
+5 -3
View File
@@ -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 }) {
<ambientLight intensity={1.9} />
<directionalLight color="#fff3cf" intensity={3.2} position={[3, 5, 4]} />
<directionalLight color={boss.accent} intensity={2.1} position={[-4, 2, -2]} />
<Suspense fallback={null}><PortraitModel bossId={bossId} /></Suspense>
<GameAssetProvider>
<Suspense fallback={null}><PortraitModel bossId={bossId} /></Suspense>
</GameAssetProvider>
</Canvas>
<span aria-hidden="true">{boss.icon}</span>
</div>
+2 -1
View File
@@ -84,6 +84,7 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
const mana = useGameStore((state) => state.mana);
const phase = useGameStore((state) => state.phase);
const healerAlive = useGameStore((state) => state.party.some((member) => member.id === "aelia" && member.hp > 0));
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const activeCast = useGameStore((state) => state.activeCast);
const castAbility = useGameStore((state) => state.castAbility);
@@ -95,7 +96,7 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
const globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = abilityId === "purify" && selected.debuffs.length === 0;
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
const disabled = phase !== "combat" || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const resourceCopy = `${manaCost ? `${manaCost} mana` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
return (
+167 -53
View File
@@ -1,6 +1,7 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { buildCollections, MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName } from "../frontend/data";
import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
import { resolveSaveContinuation, saveVersionsMatch } from "../frontend/saveContinuation";
import { useActiveHunter, useFrontendStore } from "../frontend/store";
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
import { useMenuController, type MenuAction } from "../input/useMenuController";
@@ -80,11 +81,46 @@ function ControllerLegend({ back = false }: { back?: boolean }) {
return <div className="controller-legend"><span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Select</span>{back && <span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back</span>}<span><b></b> Navigate</span></div>;
}
function SaveLibraryContext({ slots, accountId }: { slots: readonly SaveSlotState[]; accountId: string | null }) {
return (
<FrontSurface className="login-save-context" bottom ariaLabel="Save slot information">
<header className="context-header"><span>Device saves</span><b>{accountId ? "SERVER LINKED" : "OFFLINE READY"}</b></header>
<div className="login-save-list">
{slots.map((slot) => {
const save = slot.local ?? slot.online;
const healer = save ? HEALER_CLASSES[save.activeClassId] : null;
return (
<article key={slot.id} className={save ? "has-save" : "is-empty"}>
<b>{String(slot.id).padStart(2, "0")}</b>
{save ? (
<>
<div className="login-save-avatar">{save.hunterName[0]}</div>
<span>
<small>{slot.local ? "On this Thor" : "Online copy"}</small>
<strong>{save.hunterName}</strong>
<em>Level {save.healers[save.activeClassId].level} {healer?.name} · {save.location}</em>
</span>
<time><strong>{formatPlayTime(save.playSeconds)}</strong><small>{formatSaveTimestamp(save.updatedAt)}</small></time>
</>
) : (
<span className="login-empty-copy"><small>Available slot</small><strong>New hunter</strong><em>Continue offline to create</em></span>
)}
</article>
);
})}
</div>
<footer className="login-save-footer"><span>Save details update from upper-screen selection</span><b>LOWER DISPLAY · INFORMATION ONLY</b></footer>
</FrontSurface>
);
}
function LoginScreen() {
const restoreSession = useFrontendStore((state) => state.restoreSession);
const signIn = useFrontendStore((state) => state.signIn);
const createAccount = useFrontendStore((state) => state.createAccount);
const continueOffline = useFrontendStore((state) => state.continueOffline);
const slots = useFrontendStore((state) => state.slots);
const accountId = useFrontendStore((state) => state.accountId);
const notice = useFrontendStore((state) => state.notice);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
@@ -101,7 +137,7 @@ function LoginScreen() {
{ id: "password", run: () => passwordRef.current?.focus() },
{ id: "sign-in", run: () => { void signIn(username, password); } },
{ id: "create-account", run: () => { void createAccount(username, password); } },
{ id: "offline", run: continueOffline },
{ id: "continue", run: continueOffline },
], [continueOffline, createAccount, password, signIn, username]);
const controller = useMenuController(actions);
@@ -116,7 +152,7 @@ function LoginScreen() {
<div className="login-copy">
<span>Offline-first hunter records</span>
<h1>Keep everyone standing.</h1>
<p>Your save always lives on this device. Sign in only when you want a second copy for PC AYN Thor handoff.</p>
<p>Continue to your saved hunters. Sign in when you want online copies for PC AYN Thor handoff.</p>
</div>
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); submitSignIn(); }}>
<label htmlFor="account-username">Username</label>
@@ -153,38 +189,27 @@ function LoginScreen() {
<FocusButton id="create-account" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={() => { void createAccount(username, password); }}>
<span>Create account</span><small>Required for first sync</small>
</FocusButton>
<FocusButton id="offline" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}>
<span>Continue with offline save</span><small>No account required</small>
<FocusButton id="continue" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}>
<span>Continue</span><small>Choose saved hunter</small>
</FocusButton>
</form>
{notice && <div className="front-notice" role="status" aria-live="polite">{notice}</div>}
<ControllerLegend />
</FrontSurface>
}
bottom={
<FrontSurface className="login-context" bottom ariaLabel="Offline save explanation">
<BrandMark compact />
<div className="offline-promise">
<span className="context-kicker">How saving works</span>
<ol>
<li><b>01</b><span><strong>Play offline</strong><small>Every change writes to device storage first.</small></span></li>
<li><b>02</b><span><strong>Create or sign in</strong><small>Account is secured by the TrueNAS game server.</small></span></li>
<li><b>03</b><span><strong>Move devices</strong><small>Upload or download any of your three server save slots.</small></span></li>
</ol>
</div>
<div className="device-route"><span>PC</span><i></i><b>ONLINE COPY</b><i></i><span>THOR</span></div>
</FrontSurface>
}
bottom={<SaveLibraryContext slots={slots} accountId={accountId} />}
/>
);
}
function SlotCard({ slot, selected, focused, onSelect, onFocus }: { slot: SaveSlotState; selected: boolean; focused: boolean; onSelect: () => void; onFocus: () => void }) {
const save = slot.local;
const continuation = resolveSaveContinuation(slot);
const save = slot.local ?? slot.online;
const healer = save ? HEALER_CLASSES[save.activeClassId] : null;
const copyStatus = continuation === "choose" ? "Newer online" : continuation === "online" ? "Online only" : null;
return (
<button className={`save-slot ${selected ? "is-selected" : ""} ${focused ? "is-controller-focused" : ""}`} onClick={onSelect} onFocus={onFocus} onPointerEnter={onFocus}>
<span className="slot-number">Slot {String(slot.id).padStart(2, "0")}</span>
<span className="slot-number">Slot {String(slot.id).padStart(2, "0")}{copyStatus && <b>{copyStatus}</b>}</span>
{save ? (
<>
<div className="slot-portrait">{save.hunterName[0]}<i></i></div>
@@ -213,11 +238,15 @@ function SaveScreen() {
const copySlot = useFrontendStore((state) => state.copySlot);
const deleteSlot = useFrontendStore((state) => state.deleteSlot);
const navigate = useFrontendStore((state) => state.navigate);
const [dialog, setDialog] = useState<"create" | "copy" | "delete" | null>(null);
const [dialog, setDialog] = useState<"create" | "copy" | "delete" | "version" | null>(null);
const [hunterName, setHunterName] = useState("");
const [resolvingOnline, setResolvingOnline] = useState(false);
const [versionError, setVersionError] = useState("");
const selected = slots.find((slot) => slot.id === selectedSlotId)!;
const hasLocal = Boolean(selected.local);
const hasOnline = Boolean(selected.online);
const continuation = resolveSaveContinuation(selected);
const primaryActionId = continuation === "create" ? "create" : "play";
const finishCreation = () => {
if (createSlot(selectedSlotId, hunterName)) {
@@ -235,7 +264,41 @@ function SaveScreen() {
requestDisplaySurface("top");
};
const actions = useMemo<MenuAction[]>(() => dialog === "create"
const continueWithOnline = async () => {
if (resolvingOnline) return;
setResolvingOnline(true);
setVersionError("");
await downloadSlot(selectedSlotId);
const refreshed = useFrontendStore.getState().slots.find((slot) => slot.id === selectedSlotId);
if (refreshed && saveVersionsMatch(refreshed.local, refreshed.online)) {
setDialog(null);
setResolvingOnline(false);
playSlot(selectedSlotId);
return;
}
setVersionError(useFrontendStore.getState().notice || "Online save could not be loaded.");
setResolvingOnline(false);
};
const continueSelected = () => {
if (continuation === "create") return openCreation();
if (continuation === "local") return playSlot(selectedSlotId);
if (continuation === "online") {
void continueWithOnline();
return;
}
setVersionError("");
setDialog("version");
requestDisplaySurface("top");
};
const actions = useMemo<MenuAction[]>(() => dialog === "version"
? [
{ id: "version-online", run: () => { void continueWithOnline(); }, enabled: !resolvingOnline },
{ id: "version-local", run: () => { setDialog(null); playSlot(selectedSlotId); }, enabled: !resolvingOnline },
{ id: "cancel-version", run: () => setDialog(null), enabled: !resolvingOnline },
]
: dialog === "create"
? [
{ id: "confirm-create", run: finishCreation },
{ id: "cancel-create", run: () => setDialog(null) },
@@ -248,17 +311,25 @@ function SaveScreen() {
{ id: "cancel-delete", run: () => setDialog(null) },
]
: [
...slots.map((slot) => ({ id: `slot-${slot.id}`, run: () => selectSlot(slot.id) })),
{ id: hasLocal ? "play" : "create", run: () => hasLocal ? playSlot(selectedSlotId) : openCreation() },
{ id: "upload", run: () => uploadSlot(selectedSlotId), enabled: hasLocal && Boolean(accountId) },
{ id: "download", run: () => downloadSlot(selectedSlotId), enabled: hasOnline && Boolean(accountId) },
{ id: "copy", run: () => openSaveDialog("copy"), enabled: hasLocal },
{ id: "delete", run: () => openSaveDialog("delete"), enabled: hasLocal },
{ id: "back", run: () => navigate("login") },
], [accountId, copySlot, createSlot, deleteSlot, dialog, downloadSlot, hasLocal, hasOnline, hunterName, navigate, playSlot, selectSlot, selectedSlotId, slots, uploadSlot]);
const controller = useMenuController(actions, { onBack: () => dialog ? setDialog(null) : navigate("login") });
...slots.map((slot, index) => ({
id: `slot-${slot.id}`,
run: () => selectSlot(slot.id),
neighbors: {
left: `slot-${slots[Math.max(0, index - 1)].id}`,
right: `slot-${slots[Math.min(slots.length - 1, index + 1)].id}`,
down: primaryActionId,
},
})),
{ id: primaryActionId, run: continueSelected, enabled: !resolvingOnline, neighbors: { up: `slot-${selectedSlotId}`, right: "upload" } },
{ id: "upload", run: () => uploadSlot(selectedSlotId), enabled: hasLocal && Boolean(accountId), neighbors: { left: primaryActionId, right: "download", up: "slot-1" } },
{ id: "download", run: () => downloadSlot(selectedSlotId), enabled: hasOnline && Boolean(accountId), neighbors: { left: "upload", right: "copy", up: "slot-2" } },
{ id: "copy", run: () => openSaveDialog("copy"), enabled: hasLocal, neighbors: { left: "download", right: "delete", up: "slot-2" } },
{ id: "delete", run: () => openSaveDialog("delete"), enabled: hasLocal, neighbors: { left: "copy", right: "back", up: "slot-3" } },
{ id: "back", run: () => navigate("login"), neighbors: { left: "delete", up: "slot-3" } },
], [accountId, continuation, copySlot, createSlot, deleteSlot, dialog, downloadSlot, hasLocal, hasOnline, hunterName, navigate, playSlot, primaryActionId, resolvingOnline, selectSlot, selectedSlotId, slots, uploadSlot]);
const controller = useMenuController(actions, { onBack: () => dialog ? resolvingOnline ? undefined : setDialog(null) : navigate("login") });
const cloudStatus = !accountId ? "Offline mode" : selected.online ? "Online version available" : "No online version";
const cloudStatus = continuation === "choose" ? "Newer online save" : !accountId ? "Offline mode" : selected.online ? "Online version available" : "No online version";
return (
<DualDisplayFrame
top={
@@ -276,10 +347,47 @@ function SaveScreen() {
/>
))}
</div>
<div className="save-footer"><span>Autosave <b>OFFLINE FIRST</b></span><ControllerLegend back /></div>
<div className="save-top-actions">
<FocusButton id={primaryActionId} focusedId={controller.focusedId} focus={controller.focus} className="front-primary" disabled={resolvingOnline} onClick={continueSelected}>
<span>{continuation === "create" ? "Create hunter" : resolvingOnline ? "Loading online save…" : "Continue"}</span>
<small>{continuation === "create" ? `Use slot ${selectedSlotId}` : continuation === "online" ? "Download online copy" : continuation === "choose" ? "Choose online or device copy" : `Slot ${selectedSlotId} · ${selected.local?.hunterName}`}</small>
</FocusButton>
<FocusButton id="upload" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}><strong>Upload</strong><small>Device server</small></FocusButton>
<FocusButton id="download" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasOnline || !accountId} onClick={() => downloadSlot(selectedSlotId)}><strong>Download</strong><small>Server device</small></FocusButton>
<FocusButton id="copy" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} onClick={() => openSaveDialog("copy")}><strong>Copy</strong><small>Duplicate save</small></FocusButton>
<FocusButton id="delete" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} className="danger-link" onClick={() => openSaveDialog("delete")}><strong>Delete</strong><small>Erase device copy</small></FocusButton>
<FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("login")}><strong>Back</strong><small>Login screen</small></FocusButton>
</div>
<div className="save-footer"><span>Autosave <b>OFFLINE FIRST</b></span><div className="save-top-notice" role="status" aria-live="polite">{notice || "Lower display shows selected save details."}</div><ControllerLegend back /></div>
{dialog && (
<div className="front-dialog" role="dialog" aria-modal="true" aria-label={dialog === "create" ? "Name new hunter" : dialog === "copy" ? "Copy save" : "Delete save"}>
{dialog === "create" ? (
<div className={`front-dialog ${dialog === "version" ? "version-dialog" : ""}`} role="dialog" aria-modal="true" aria-label={dialog === "version" ? "Choose save version" : dialog === "create" ? "Name new hunter" : dialog === "copy" ? "Copy save" : "Delete save"}>
{dialog === "version" && selected.local && selected.online ? (
<div className="version-choice-dialog">
<span>Newer online save found</span>
<h2>Which save do you want?</h2>
<p>Online choice replaces this device copy. Device choice keeps online copy unchanged.</p>
<div className="version-comparison">
<article className="is-newer">
<header><span>Online copy</span><b>NEWER</b></header>
<strong>{selected.online.hunterName}</strong>
<time>{formatSaveTimestamp(selected.online.updatedAt)}</time>
<small>{formatPlayTime(selected.online.playSeconds)} · Level {selected.online.healers[selected.online.activeClassId].level}</small>
</article>
<article>
<header><span>Device copy</span><b>OFFLINE</b></header>
<strong>{selected.local.hunterName}</strong>
<time>{formatSaveTimestamp(selected.local.updatedAt)}</time>
<small>{formatPlayTime(selected.local.playSeconds)} · Level {selected.local.healers[selected.local.activeClassId].level}</small>
</article>
</div>
<div className="dialog-actions version-actions">
<FocusButton id="version-online" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" disabled={resolvingOnline} onClick={() => { void continueWithOnline(); }}><span>{resolvingOnline ? "Loading…" : "Continue online copy"}</span><small>{formatSaveTimestamp(selected.online.updatedAt)}</small></FocusButton>
<FocusButton id="version-local" focusedId={controller.focusedId} focus={controller.focus} disabled={resolvingOnline} onClick={() => { setDialog(null); playSlot(selectedSlotId); }}><span>Continue device copy</span><small>{formatSaveTimestamp(selected.local.updatedAt)}</small></FocusButton>
<FocusButton id="cancel-version" focusedId={controller.focusedId} focus={controller.focus} disabled={resolvingOnline} onClick={() => setDialog(null)}>Cancel</FocusButton>
</div>
{versionError && <div className="version-choice-error" role="alert">{versionError}</div>}
</div>
) : dialog === "create" ? (
<form onSubmit={(event) => { event.preventDefault(); finishCreation(); }}>
<span>New offline save</span><h2>Name your hunter</h2><p>This name identifies the character in local and online save lists.</p>
<label htmlFor="new-hunter-name">Hunter name</label>
@@ -324,31 +432,37 @@ function SaveScreen() {
</FrontSurface>
}
bottom={
<FrontSurface className="save-context" bottom ariaLabel="Selected save management">
<FrontSurface className="save-context" bottom ariaLabel="Selected save information">
<header className="context-header"><span>Slot {selectedSlotId}</span><b>{cloudStatus}</b></header>
<div className="selected-save-summary">
{selected.local ? (
<><div className="summary-avatar">{selected.local.hunterName[0]}</div><span><small>Local record</small><h2>{selected.local.hunterName}</h2><p>{selected.local.location} · {formatPlayTime(selected.local.playSeconds)}</p><time>{formatSaveTimestamp(selected.local.updatedAt)}</time></span></>
{selected.local ?? selected.online ? (
<><div className="summary-avatar">{(selected.local ?? selected.online)!.hunterName[0]}</div><span><small>{selected.local ? "Device save" : "Online copy only"}</small><h2>{(selected.local ?? selected.online)!.hunterName}</h2><p>{(selected.local ?? selected.online)!.location}</p><time>{formatSaveTimestamp((selected.local ?? selected.online)!.updatedAt)}</time></span></>
) : (
<><div className="summary-avatar is-empty"></div><span><small>Local record</small><h2>Empty slot</h2><p>Create a hunter or download an online version.</p></span></>
)}
</div>
{selected.online && <div className="online-record"><span><b>ONLINE</b>{selected.online.hunterName}</span><time>{formatSaveTimestamp(selected.online.updatedAt)}</time></div>}
<div className="save-actions">
<FocusButton id={hasLocal ? "play" : "create"} focusedId={controller.focusedId} focus={controller.focus} className="front-primary" onClick={() => hasLocal ? playSlot(selectedSlotId) : openCreation()}>
{hasLocal ? "Continue offline save" : "Create new hunter"}<small>{DEFAULT_CONTROLLER_GLYPHS.confirm}</small>
</FocusButton>
<div className="sync-actions">
<FocusButton id="upload" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}> Sync offline to server</FocusButton>
<FocusButton id="download" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasOnline || !accountId} onClick={() => downloadSlot(selectedSlotId)}> Overwrite with online</FocusButton>
</div>
<div className="record-actions">
<FocusButton id="copy" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} onClick={() => openSaveDialog("copy")}>Copy save</FocusButton>
<FocusButton id="delete" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} className="danger-link" onClick={() => openSaveDialog("delete")}>Delete save</FocusButton>
<FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("login")}>Back</FocusButton>
</div>
{(selected.local ?? selected.online) && (() => {
const save = (selected.local ?? selected.online)!;
const healer = HEALER_CLASSES[save.activeClassId];
return (
<>
<div className="save-dossier-stats">
<span><small>Active healer</small><strong>Lv {save.healers[save.activeClassId].level}</strong><em>{healer.name}</em></span>
<span><small>Play time</small><strong>{formatPlayTime(save.playSeconds)}</strong><em>Local activity</em></span>
<span><small>Boss kills</small><strong>{save.stats.totalBossKills}</strong><em>{save.stats.flawlessClears} flawless</em></span>
</div>
<div className="save-dossier-records">
<span><small>Roguelike best</small><b>Round {save.stats.highestRoguelikeRound}</b></span>
<span><small>Endless best</small><b>{save.stats.highestRogueTrialsEndlessKills} kills</b></span>
</div>
</>
);
})()}
<div className="save-copy-state">
<span><i className={selected.local ? "is-present" : ""} />Device copy<b>{selected.local ? formatSaveTimestamp(selected.local.updatedAt) : "Not present"}</b></span>
<span><i className={selected.online ? "is-present" : ""} />Online copy<b>{selected.online ? formatSaveTimestamp(selected.online.updatedAt) : accountId ? "Not uploaded" : "Sign-in required"}</b></span>
</div>
<div className="front-notice is-lower">{notice || "All gameplay changes save to local storage automatically."}</div>
<div className="front-notice is-lower">{notice || "Use upper display for every save action. Details here follow selected slot."}</div>
</FrontSurface>
}
/>
+43
View File
@@ -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<KTX2Loader | null>(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 <Ktx2LoaderContext.Provider value={ktx2Loader}>{children}</Ktx2LoaderContext.Provider>;
}
export function useGameGLTF(url: string) {
const ktx2Loader = useContext(Ktx2LoaderContext);
return useGLTF(
url,
false,
true,
(loader) => {
if (ktx2Loader) loader.setKTX2Loader(ktx2Loader);
},
);
}
+478 -93
View File
@@ -1,6 +1,6 @@
import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
import { useAnimations, useGLTF } from "@react-three/drei";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type RefObject } from "react";
import * as THREE from "three";
import { getControllerMovement } from "../input/controller";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
@@ -25,20 +25,38 @@ import {
type ActorAnimationState,
} from "../game/actorAnimation";
import { PERFORMANCE_PROBE_ENABLED, recordSimulationTick, simulationTickSnapshot } from "../game/performance";
import type { PartyAbilityId } from "../game/partyCombat";
import { partyAttackVfxProfile } from "../game/partyAttackVisuals";
import { useGameStore } from "../game/store";
import type { BossId, MemberId, PulseKind } from "../game/types";
import { BossRoom } from "./BossRoom";
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
import { bossCanTrackTarget } from "./boss/bossDeathVisuals";
import { bossBurrowPositionY, bossIsBurrowing } from "./boss/bossBurrowVisuals";
import { bossCanTrackTarget, bossDeathOpacity } from "./boss/bossDeathVisuals";
import { GameAssetProvider, LEGACY_GAME_ASSETS_FORCED, selectedGameAssetUrl, useGameGLTF } from "./GameAssetProvider";
const PARTY_MODEL_URLS: Record<MemberId, string> = {
const PARTY_MODEL_LEGACY_URLS: Record<MemberId, string> = {
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<MemberId, { right: string; left?: string }> = {
const PARTY_MODEL_OPTIMIZED_URLS: Record<MemberId, string> = {
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<MemberId, string>;
const PARTY_WEAPON_LEGACY_URLS: Record<MemberId, { right: string; left?: string }> = {
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 +72,30 @@ const PARTY_WEAPON_URLS: Record<MemberId, { right: string; left?: string }> = {
left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
},
};
const PARTY_WEAPON_OPTIMIZED_URLS: Record<MemberId, { right: string; left?: string }> = {
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<MemberId, { right: string; left?: string }>;
const PARTY_MODEL_SCALES: Record<MemberId, number> = { aelia: 0.62, brann: 0.68, nia: 0.7, orin: 0.64, vale: 0.72 };
const PARTY_ATTACK_CLIPS: Record<MemberId, string> = {
aelia: "2H_Melee_Attack_Chop",
@@ -66,6 +108,88 @@ const CRITICAL_PARTY_MEMBER_IDS: readonly MemberId[] = ["aelia", "brann"];
const SUPPORT_PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia" | "brann">[] = ["nia", "orin", "vale"];
type GameStoreState = ReturnType<typeof useGameStore.getState>;
interface BossFadeMaterial {
material: THREE.Material;
baseOpacity: number;
baseTransparent: boolean;
baseDepthWrite: boolean;
}
function createBossRenderModel(source: THREE.Object3D) {
const model = cloneSkeleton(source);
const materialClones = new Map<THREE.Material, THREE.Material>();
model.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
object.castShadow = true;
object.receiveShadow = true;
const cloneMaterial = (material: THREE.Material) => {
const existing = materialClones.get(material);
if (existing) return existing;
const clone = material.clone();
materialClones.set(material, clone);
return clone;
};
object.material = Array.isArray(object.material)
? object.material.map(cloneMaterial)
: cloneMaterial(object.material);
});
model.updateMatrixWorld(true);
const modelTopY = new THREE.Box3().setFromObject(model).max.y;
return {
model,
modelTopY,
fadeMaterials: [...materialClones.values()].map((material): BossFadeMaterial => ({
material,
baseOpacity: material.opacity,
baseTransparent: material.transparent,
baseDepthWrite: material.depthWrite,
})),
};
}
function applyBossOpacity(materials: readonly BossFadeMaterial[], opacity: number) {
const fading = opacity < 0.999;
for (const entry of materials) {
const transparent = entry.baseTransparent || fading;
if (entry.material.transparent !== transparent) {
entry.material.transparent = transparent;
entry.material.needsUpdate = true;
}
entry.material.opacity = entry.baseOpacity * opacity;
entry.material.depthWrite = fading ? false : entry.baseDepthWrite;
}
}
function useBossDeathFade(
group: RefObject<THREE.Group | null>,
light: RefObject<THREE.PointLight | null>,
materials: readonly BossFadeMaterial[],
defeated: boolean,
baseLightIntensity: number,
) {
const elapsed = useRef(0);
const lastOpacity = useRef(1);
useFrame((_, delta) => {
if (!defeated) {
elapsed.current = 0;
if (lastOpacity.current !== 1) {
lastOpacity.current = 1;
if (group.current) group.current.visible = true;
if (light.current) light.current.intensity = baseLightIntensity;
applyBossOpacity(materials, 1);
}
return;
}
elapsed.current += delta;
const opacity = bossDeathOpacity(elapsed.current);
if (opacity === lastOpacity.current) return;
lastOpacity.current = opacity;
if (group.current) group.current.visible = opacity > 0;
if (light.current) light.current.intensity = baseLightIntensity * opacity;
applyBossOpacity(materials, opacity);
});
}
function encounterBossAt(state: GameStoreState, bossIndex: number) {
return bossIndex === 0
? { boss: state.boss, motion: state.bossMotion }
@@ -148,12 +272,12 @@ function PartyCharacterModel({
animationState: MutableRefObject<ActorAnimationState>;
animationTrigger: MutableRefObject<number>;
}) {
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(
@@ -587,14 +711,34 @@ function PartyFallback({ memberIds }: { memberIds: readonly MemberId[] }) {
function BossFallback({ bossIndex }: { bossIndex: number }) {
const boss = useGameStore((state) => bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss);
const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion);
const group = useRef<THREE.Group>(null);
const material = useRef<THREE.MeshStandardMaterial>(null);
const deathElapsed = useRef(0);
useFrame((_, delta) => {
const defeated = (boss?.hp ?? 1) <= 0;
deathElapsed.current = defeated ? deathElapsed.current + delta : 0;
const opacity = bossDeathOpacity(deathElapsed.current);
if (group.current) group.current.visible = opacity > 0;
if (material.current) {
const transparent = opacity < 0.999;
if (material.current.transparent !== transparent) {
material.current.transparent = transparent;
material.current.needsUpdate = true;
}
material.current.opacity = opacity;
material.current.depthWrite = opacity >= 0.999;
}
});
if (!boss || !motion) return null;
const position = motion.position;
const bossId = boss.id;
return (
<mesh castShadow position={[position[0], 1.1, position[1]]}>
<dodecahedronGeometry args={[1.1, 0]} />
<meshStandardMaterial color={BOSS_ARCHETYPE_BY_ID[bossId] === "web-caster" ? "#56306f" : BOSS_ARCHETYPE_BY_ID[bossId] === "sky-sweeper" ? "#9d4c24" : BOSS_ARCHETYPE_BY_ID[bossId] === "burrower" ? "#b78b32" : BOSS_ARCHETYPE_BY_ID[bossId] === "duelist" || BOSS_ARCHETYPE_BY_ID[bossId] === "ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
</mesh>
<group ref={group} position={[position[0], 1.1, position[1]]}>
<mesh castShadow>
<dodecahedronGeometry args={[1.1, 0]} />
<meshStandardMaterial ref={material} color={BOSS_ARCHETYPE_BY_ID[bossId] === "web-caster" ? "#56306f" : BOSS_ARCHETYPE_BY_ID[bossId] === "sky-sweeper" ? "#9d4c24" : BOSS_ARCHETYPE_BY_ID[bossId] === "burrower" ? "#b78b32" : BOSS_ARCHETYPE_BY_ID[bossId] === "duelist" || BOSS_ARCHETYPE_BY_ID[bossId] === "ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
</mesh>
</group>
);
}
@@ -606,19 +750,17 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
const defeated = bossHp <= 0;
const group = useRef<THREE.Group>(null);
const light = useRef<THREE.PointLight>(null);
const gltf = useGLTF(BULL_URL, false, true);
const bullScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
const { model: bullScene, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]);
const { actions } = useAnimations(gltf.animations, bullScene);
const targetPosition = useMemo(() => new THREE.Vector3(), []);
useEffect(() => {
bullScene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.castShadow = true;
object.receiveShadow = true;
}
});
}, [bullScene]);
return () => { for (const entry of fadeMaterials) entry.material.dispose(); };
}, [fadeMaterials]);
useBossDeathFade(group, light, fadeMaterials, defeated, 2.8);
const clipName = phase === "victory" || defeated
? "Death"
@@ -675,7 +817,7 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
return (
<group ref={group}>
<primitive object={bullScene} scale={0.81} />
<pointLight color="#ff9b5c" intensity={2.8} distance={7} position={[0, 2.3, 0.8]} />
<pointLight ref={light} color="#ff9b5c" intensity={2.8} distance={7} position={[0, 2.3, 0.8]} />
</group>
);
}
@@ -695,19 +837,19 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
const defeated = bossHp <= 0;
const group = useRef<THREE.Group>(null);
const gltf = useGLTF(config.url, false, true);
const model = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
const light = useRef<THREE.PointLight>(null);
const assetUrl = selectedGameAssetUrl(config.url, config.optimizedUrl ?? config.url);
const gltf = useGameGLTF(assetUrl);
const { model, modelTopY, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]);
const { actions } = useAnimations(gltf.animations, model);
const targetPosition = useMemo(() => new THREE.Vector3(), []);
const burrowPositionY = bossBurrowPositionY(modelTopY, config.scale);
useEffect(() => {
model.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.castShadow = true;
object.receiveShadow = true;
}
});
}, [kind, model]);
return () => { for (const entry of fadeMaterials) entry.material.dispose(); };
}, [fadeMaterials]);
useBossDeathFade(group, light, fadeMaterials, defeated, 2.5);
const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motion ?? useGameStore.getState().bossMotion);
@@ -738,9 +880,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
if (!current) return;
const motion = current.motion;
const airborne = archetype === "sky-sweeper" && motion.mode === "skyfall";
const burrowed = archetype === "burrower" && motion.activeMechanicId === "burrow-rush" && motion.mode === "charging";
const burrowing = archetype === "burrower" && bossIsBurrowing(motion.activeMechanicId, motion.mode);
const floatingHeight = config.floating ? 0.2 : 0.03;
targetPosition.set(motion.position[0], airborne ? 3.2 : burrowed ? -0.58 : floatingHeight, motion.position[1]);
targetPosition.set(motion.position[0], airborne ? 3.2 : burrowing ? burrowPositionY : floatingHeight, motion.position[1]);
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
if (!bossCanTrackTarget(current.boss.hp)) return;
@@ -770,7 +912,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
return (
<group ref={group}>
<primitive object={model} scale={config.scale} rotation={[0, config.rotationOffset, 0]} />
<pointLight color={config.light} intensity={2.5} distance={7} position={[0, 2.2, 0.5]} />
<pointLight ref={light} color={config.light} intensity={2.5} distance={7} position={[0, 2.2, 0.5]} />
</group>
);
}
@@ -859,7 +1001,15 @@ function TankAuraField() {
}
function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
const group = useRef<THREE.Group>(null);
const projectile = useRef<THREE.Group>(null);
const impact = useRef<THREE.Group>(null);
const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
const accentMaterial = useRef<THREE.MeshBasicMaterial>(null);
const trailMaterial = useRef<THREE.MeshBasicMaterial>(null);
const trail = useRef<THREE.Mesh>(null);
const impactMaterial = useRef<THREE.MeshBasicMaterial>(null);
const impactCoreMaterial = useRef<THREE.MeshBasicMaterial>(null);
const lastAbilityId = useRef<PartyAbilityId | null>(null);
const start = useMemo(() => new THREE.Vector3(), []);
const end = useMemo(() => new THREE.Vector3(), []);
const current = useMemo(() => new THREE.Vector3(), []);
@@ -867,7 +1017,7 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
const up = useMemo(() => new THREE.Vector3(0, 1, 0), []);
useFrame(({ clock }) => {
if (!group.current) return;
if (!projectile.current || !impact.current) return;
const state = useGameStore.getState();
const action = state.partyCombat.combatants[memberId].visualAction;
const member = state.party.find((entry) => entry.id === memberId)!;
@@ -878,8 +1028,19 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
&& action.abilityId !== "overcharge"
&& state.time >= action.startedAt
&& (rapid ? state.time <= action.endsAt : state.time <= action.impactAt);
group.current.visible = active;
if (!active || !action) return;
projectile.current.visible = active;
impact.current.visible = false;
if (!action || state.phase !== "combat" || member.hp <= 0 || action.abilityId === "overcharge") return;
const profile = partyAttackVfxProfile(action.abilityId);
if (lastAbilityId.current !== action.abilityId) {
lastAbilityId.current = action.abilityId;
coreMaterial.current?.color.set(profile.primary);
accentMaterial.current?.color.set(profile.accent);
trailMaterial.current?.color.set(profile.primary);
impactMaterial.current?.color.set(profile.accent);
impactCoreMaterial.current?.color.set(profile.primary);
}
const targetMotion = targetBossMotionByInstance(state, action.targetInstanceId);
const projectileDuration = Math.max(0.12, action.impactAt - action.startedAt);
@@ -890,44 +1051,81 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
const target = targetMotion.position;
start.set(source[0], 1.18, source[1]);
end.set(target[0], 1.12, target[1]);
current.copy(start).lerp(end, progress);
current.y += Math.sin(progress * Math.PI) * (memberId === "orin" ? 0.95 : 0.34);
group.current.position.copy(current);
direction.subVectors(end, start).normalize();
group.current.quaternion.setFromUnitVectors(up, direction);
if (memberId === "orin") group.current.scale.setScalar(0.9 + Math.sin(clock.elapsedTime * 14) * 0.12);
if (active) {
current.copy(start).lerp(end, progress);
current.y += Math.sin(progress * Math.PI) * (memberId === "orin" ? 0.95 : 0.34);
projectile.current.position.copy(current);
direction.subVectors(end, start).normalize();
projectile.current.quaternion.setFromUnitVectors(up, direction);
const pulseScale = memberId === "orin" ? 1 + Math.sin(clock.elapsedTime * 14) * 0.12 : 1;
projectile.current.scale.setScalar(profile.scale * pulseScale);
trail.current?.scale.set(1, profile.trail, 1);
if (trailMaterial.current) trailMaterial.current.opacity = 0.34 + Math.sin(clock.elapsedTime * 10) * 0.08;
}
const impactProgress = rapid
? progress > 0.72 ? (progress - 0.72) / 0.28 : -1
: (state.time - action.impactAt) / 0.3;
const impactVisible = impactProgress >= 0 && impactProgress <= 1 && state.time <= action.endsAt + 0.3;
impact.current.visible = impactVisible;
if (impactVisible) {
impact.current.position.copy(end);
impact.current.scale.setScalar(profile.scale * (0.45 + impactProgress * 2.15));
if (impactMaterial.current) impactMaterial.current.opacity = (1 - impactProgress) * 0.9;
if (impactCoreMaterial.current) impactCoreMaterial.current.opacity = (1 - impactProgress) * 0.72;
}
});
return (
<group ref={group} visible={false}>
{memberId === "nia" ? (
<>
<mesh>
<cylinderGeometry args={[0.026, 0.026, 0.82, 6]} />
<meshBasicMaterial color="#d7b477" />
</mesh>
<mesh position={[0, 0.5, 0]}>
<coneGeometry args={[0.085, 0.2, 6]} />
<meshBasicMaterial color="#f1ddaa" />
</mesh>
<mesh position={[0, -0.4, 0]}>
<coneGeometry args={[0.1, 0.18, 4]} />
<meshBasicMaterial color="#70cf8e" />
</mesh>
</>
) : (
<>
<mesh>
<sphereGeometry args={[0.19, 12, 10]} />
<meshBasicMaterial color="#bc8cff" />
</mesh>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.25, 0.025, 6, 20]} />
<meshBasicMaterial color="#ead8ff" transparent opacity={0.8} />
</mesh>
</>
)}
</group>
<>
<group ref={projectile} visible={false}>
{memberId === "nia" ? (
<>
<mesh>
<cylinderGeometry args={[0.026, 0.026, 0.82, 6]} />
<meshBasicMaterial ref={coreMaterial} color="#77d596" />
</mesh>
<mesh position={[0, 0.5, 0]}>
<coneGeometry args={[0.085, 0.2, 6]} />
<meshBasicMaterial ref={accentMaterial} color="#e5ffb8" />
</mesh>
<mesh position={[0, -0.4, 0]}>
<coneGeometry args={[0.1, 0.18, 4]} />
<meshBasicMaterial color="#d7b477" />
</mesh>
<mesh ref={trail} position={[0, -0.62, 0]}>
<coneGeometry args={[0.12, 0.72, 6, 1, true]} />
<meshBasicMaterial ref={trailMaterial} color="#77d596" transparent opacity={0.34} depthWrite={false} blending={THREE.AdditiveBlending} />
</mesh>
</>
) : (
<>
<mesh>
<sphereGeometry args={[0.19, 12, 10]} />
<meshBasicMaterial ref={coreMaterial} color="#a87cff" toneMapped={false} />
</mesh>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.25, 0.025, 6, 20]} />
<meshBasicMaterial ref={accentMaterial} color="#ead9ff" transparent opacity={0.8} depthWrite={false} />
</mesh>
<mesh ref={trail} position={[0, -0.48, 0]}>
<coneGeometry args={[0.18, 0.95, 8, 1, true]} />
<meshBasicMaterial ref={trailMaterial} color="#a87cff" transparent opacity={0.34} depthWrite={false} blending={THREE.AdditiveBlending} />
</mesh>
</>
)}
</group>
<group ref={impact} visible={false}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.28, 0.45, 24]} />
<meshBasicMaterial ref={impactMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<mesh>
<octahedronGeometry args={[0.24, 0]} />
<meshBasicMaterial ref={impactCoreMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
</group>
</>
);
}
@@ -940,6 +1138,144 @@ function RangedProjectiles() {
);
}
function CloseAttackVfx({ memberId }: { memberId: "brann" | "orin" | "vale" }) {
const group = useRef<THREE.Group>(null);
const firstArc = useRef<THREE.Mesh>(null);
const secondArc = useRef<THREE.Mesh>(null);
const groundRing = useRef<THREE.Mesh>(null);
const core = useRef<THREE.Mesh>(null);
const primaryMaterial = useRef<THREE.MeshBasicMaterial>(null);
const secondaryMaterial = useRef<THREE.MeshBasicMaterial>(null);
const ringMaterial = useRef<THREE.MeshBasicMaterial>(null);
const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
const lastAbilityId = useRef<PartyAbilityId | null>(null);
useFrame(({ clock }) => {
if (!group.current || !firstArc.current || !secondArc.current || !groundRing.current || !core.current) return;
const state = useGameStore.getState();
const actor = state.partyCombat.combatants[memberId];
const action = actor.visualAction;
const member = state.party.find((entry) => entry.id === memberId)!;
const visible = action !== null
&& state.phase === "combat"
&& member.hp > 0
&& state.time >= action.startedAt
&& state.time <= action.endsAt + 0.18
&& partyAttackVfxProfile(action.abilityId).style !== "projectile";
group.current.visible = visible;
if (!visible || !action) return;
const profile = partyAttackVfxProfile(action.abilityId);
if (lastAbilityId.current !== action.abilityId) {
lastAbilityId.current = action.abilityId;
primaryMaterial.current?.color.set(profile.primary);
secondaryMaterial.current?.color.set(profile.accent);
ringMaterial.current?.color.set(profile.primary);
coreMaterial.current?.color.set(profile.accent);
}
const duration = Math.max(0.2, action.endsAt - action.startedAt);
const progress = THREE.MathUtils.clamp((state.time - action.startedAt) / duration, 0, 1);
const impactProgress = THREE.MathUtils.clamp((state.time - action.impactAt + 0.08) / 0.3, 0, 1);
const source = state.partyPositions[memberId];
const targetMotion = targetBossMotionByInstance(state, action.targetInstanceId);
const target = targetMotion.position;
const sourceStyle = profile.style === "buff" || profile.style === "spin";
const effectHeight = sourceStyle ? 0.22 : profile.style === "slam" ? 0.18 : 1.05;
group.current.position.set(sourceStyle ? source[0] : target[0], effectHeight, sourceStyle ? source[1] : target[1]);
group.current.rotation.y = sourceStyle
? clock.elapsedTime * 0.8
: Math.atan2(target[0] - source[0], target[1] - source[1]);
const slashStyle = profile.style === "slash" || profile.style === "double-slash";
firstArc.current.visible = slashStyle;
secondArc.current.visible = profile.style === "double-slash";
groundRing.current.visible = profile.style === "slam" || profile.style === "spin" || profile.style === "buff";
core.current.visible = profile.style === "slam" || profile.style === "buff";
const actionScale = profile.scale * (0.65 + Math.sin(progress * Math.PI) * 0.75);
firstArc.current.scale.setScalar(actionScale);
firstArc.current.rotation.z = -Math.PI * (0.82 - progress * 0.34);
secondArc.current.scale.setScalar(actionScale * 0.92);
secondArc.current.rotation.z = -Math.PI * (0.15 + progress * 0.36);
const burstScale = profile.scale * (0.5 + impactProgress * 2.2);
groundRing.current.scale.setScalar(burstScale);
groundRing.current.rotation.z = clock.elapsedTime * (memberId === "vale" ? -1.6 : 0.85);
core.current.scale.setScalar(profile.scale * (0.6 + Math.sin(progress * Math.PI) * 1.1));
core.current.rotation.set(clock.elapsedTime, clock.elapsedTime * 1.4, 0);
if (primaryMaterial.current) primaryMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.9;
if (secondaryMaterial.current) secondaryMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.84;
if (ringMaterial.current) ringMaterial.current.opacity = (1 - impactProgress * 0.7) * 0.76;
if (coreMaterial.current) coreMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.72;
});
return (
<group ref={group} visible={false}>
<mesh ref={firstArc} rotation={[0, 0, -Math.PI * 0.72]}>
<torusGeometry args={[0.72, 0.055, 6, 28, Math.PI * 1.28]} />
<meshBasicMaterial ref={primaryMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<mesh ref={secondArc} position={[0.16, 0.02, 0.05]} rotation={[0, 0, -Math.PI * 0.2]}>
<torusGeometry args={[0.64, 0.045, 6, 26, Math.PI * 1.18]} />
<meshBasicMaterial ref={secondaryMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<mesh ref={groundRing} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.38, 0.54, 28]} />
<meshBasicMaterial ref={ringMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<mesh ref={core} position={[0, 0.3, 0]}>
<octahedronGeometry args={[0.26, 0]} />
<meshBasicMaterial ref={coreMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
</group>
);
}
function PartyPowerAuraVfx({ memberId }: { memberId: "orin" | "vale" }) {
const group = useRef<THREE.Group>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
if (!group.current) return;
const state = useGameStore.getState();
const actor = state.partyCombat.combatants[memberId];
const member = state.party.find((entry) => entry.id === memberId)!;
const active = state.phase === "combat" && member.hp > 0 && (memberId === "orin" ? actor.overchargeStacks > 0 : actor.bladeFlurryUntil > state.time);
group.current.visible = active;
if (!active) return;
const position = state.partyPositions[memberId];
group.current.position.set(position[0], 0.16, position[1]);
group.current.rotation.y = clock.elapsedTime * (memberId === "orin" ? 1.4 : -1.8);
const pulse = 0.92 + Math.sin(clock.elapsedTime * 5.5) * 0.12;
group.current.scale.setScalar(pulse);
if (material.current) material.current.opacity = 0.38 + Math.sin(clock.elapsedTime * 4.2) * 0.1;
});
const color = memberId === "orin" ? "#bc72ff" : "#9a86ff";
return (
<group ref={group} visible={false}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.7, 0.035, 6, 28]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.4} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<mesh position={[0, 0.72, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.46, 0.025, 6, 24]} />
<meshBasicMaterial color={color} transparent opacity={0.5} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
</group>
);
}
function PartyCombatVfx() {
return (
<>
<RangedProjectiles />
<CloseAttackVfx memberId="brann" />
<CloseAttackVfx memberId="orin" />
<CloseAttackVfx memberId="vale" />
<PartyPowerAuraVfx memberId="orin" />
<PartyPowerAuraVfx memberId="vale" />
</>
);
}
function BossActor() {
const phase = useGameStore((state) => state.phase);
const primaryBossId = useGameStore((state) => state.boss.id);
@@ -1045,27 +1381,72 @@ function PerformanceProbe() {
return null;
}
const FX_BURST_PARTICLE_COUNT = 8;
function scenePulseColor(kind: PulseKind) {
if (kind === "shield") return "#62bdff";
if (kind === "purify") return "#c39bff";
if (kind === "renew") return "#72e0a1";
if (kind === "breath") return "#66dcff";
if (kind === "venom") return "#8fdb4f";
if (kind === "tether") return "#d482ff";
if (kind === "skyfall") return "#ffd36b";
if (kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" || kind === "slash") return "#ff643c";
return "#ffe087";
}
function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
const group = useRef<THREE.Group>(null);
const ring = useRef<THREE.Mesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
const particles = useRef<THREE.InstancedMesh>(null);
const particleMaterial = useRef<THREE.MeshBasicMaterial>(null);
const core = useRef<THREE.Mesh>(null);
const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
const age = useRef(0);
const isBossFx = kind === "boss";
const state = useGameStore.getState();
const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0];
const position: [number, number, number] = [worldPosition[0], 0.15, worldPosition[1]];
const color = kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" || kind === "slash" ? "#ff643c" : kind === "shield" ? "#62bdff" : kind === "purify" ? "#c39bff" : "#ffe087";
useFrame((_, delta) => {
const color = scenePulseColor(kind);
useFrame(({ clock }, delta) => {
age.current += delta;
if (!ring.current || !material.current) return;
if (!group.current || !ring.current || !material.current || !particles.current || !core.current) return;
const progress = Math.min(1, age.current / 0.7);
ring.current.scale.setScalar(0.5 + progress * 3.6);
material.current.opacity = (1 - progress) * 0.85;
core.current.scale.setScalar(0.45 + Math.sin(progress * Math.PI) * 1.8);
core.current.rotation.set(clock.elapsedTime * 1.5, clock.elapsedTime * 2, 0);
if (coreMaterial.current) coreMaterial.current.opacity = (1 - progress) * 0.72;
for (let index = 0; index < FX_BURST_PARTICLE_COUNT; index += 1) {
const angle = index / FX_BURST_PARTICLE_COUNT * Math.PI * 2 + clock.elapsedTime * 0.6;
const radial = progress * (kind === "boss" ? 3.2 : 1.65);
const scale = (1 - progress) * (kind === "boss" ? 1.4 : 0.9);
transform.position.set(Math.sin(angle) * radial, 0.18 + Math.sin(progress * Math.PI) * (0.7 + (index % 2) * 0.45), Math.cos(angle) * radial);
transform.rotation.set(angle, progress * Math.PI * 2 + index, clock.elapsedTime);
transform.scale.setScalar(scale);
transform.updateMatrix();
particles.current.setMatrixAt(index, transform.matrix);
}
particles.current.instanceMatrix.needsUpdate = true;
if (particleMaterial.current) particleMaterial.current.opacity = (1 - progress) * 0.82;
});
return (
<mesh ref={ring} position={position} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.38, 0.5, 32]} />
<meshBasicMaterial ref={material} color={color} transparent depthWrite={false} />
</mesh>
<group ref={group} position={position}>
<mesh ref={ring} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.38, 0.5, 32]} />
<meshBasicMaterial ref={material} color={color} transparent depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<mesh ref={core} position={[0, 0.42, 0]}>
<octahedronGeometry args={[0.24, 0]} />
<meshBasicMaterial ref={coreMaterial} color={color} transparent opacity={0.72} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<instancedMesh ref={particles} args={[undefined, undefined, FX_BURST_PARTICLE_COUNT]} frustumCulled={false}>
<tetrahedronGeometry args={[0.18, 0]} />
<meshBasicMaterial ref={particleMaterial} color={color} transparent opacity={0.82} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</instancedMesh>
</group>
);
}
@@ -1089,23 +1470,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" }}
>
<SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} />
<BossRoom />
<BossMechanicIndicators />
<BarrierField />
<TankAuraField />
<Party />
<BossActor />
<RangedProjectiles />
<CombatFx />
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
<GameAssetProvider>
<SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} />
<BossRoom />
<BossMechanicIndicators />
<BarrierField />
<TankAuraField />
<Party />
<BossActor />
<PartyCombatVfx />
<CombatFx />
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
</GameAssetProvider>
</Canvas>
);
}
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);
}
}
+215
View File
@@ -0,0 +1,215 @@
import { useFrame } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import * as THREE from "three";
import type { PoolTelegraphKind, WorldPosition } from "../../game/types";
const TAU = Math.PI * 2;
const LANE_PARTICLE_COUNT = 7;
const AREA_PARTICLE_COUNT = 8;
const BREATH_PARTICLE_COUNT = 12;
function useReducedMotion() {
return useMemo(() => (
document.documentElement.classList.contains("force-reduced-motion")
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches
), []);
}
export type LaneVfxVariant = "beam" | "charge" | "slash" | "web";
function LaneParticleGeometry({ variant }: { variant: LaneVfxVariant }) {
if (variant === "beam" || variant === "web") return <sphereGeometry args={[0.11, 7, 5]} />;
if (variant === "slash") return <octahedronGeometry args={[0.15, 0]} />;
return <tetrahedronGeometry args={[0.13, 0]} />;
}
/** One instanced draw for motion/readability layered over an existing lane telegraph. */
export function LaneEnergyVfx({
start,
end,
width,
color,
active,
variant,
height = 0.13,
}: {
start: WorldPosition;
end: WorldPosition;
width: number;
color: string;
active: boolean;
variant: LaneVfxVariant;
height?: number;
}) {
const particles = useRef<THREE.InstancedMesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
const reducedMotion = useReducedMotion();
const dx = end[0] - start[0];
const dz = end[1] - start[1];
const length = Math.hypot(dx, dz);
const angle = Math.atan2(dx, dz);
const midpoint: [number, number, number] = [(start[0] + end[0]) * 0.5, height, (start[1] + end[1]) * 0.5];
useFrame(({ clock }) => {
if (!particles.current) return;
const time = reducedMotion ? 0.35 : clock.elapsedTime;
const speed = active ? 2.6 : variant === "web" ? 0.5 : 0.85;
for (let index = 0; index < LANE_PARTICLE_COUNT; index += 1) {
const offset = index / LANE_PARTICLE_COUNT;
const cycle = (time * speed + offset) % 1;
const cross = Math.sin(time * 2.4 + index * 1.7) * width * (variant === "web" ? 0.08 : 0.28);
const rise = variant === "slash"
? Math.sin(cycle * Math.PI) * (active ? 0.65 : 0.28)
: variant === "web" ? Math.sin(time * 3 + index) * 0.07 : Math.sin(cycle * Math.PI) * 0.18;
transform.position.set(cross, rise, -length * 0.5 + cycle * length);
transform.rotation.set(time * 0.8 + index, index * 0.63, active ? time * 1.5 : 0);
const scale = (active ? 1.25 : 0.78) * (0.75 + Math.sin(cycle * Math.PI) * 0.4);
transform.scale.set(
variant === "slash" ? scale * 0.7 : scale,
variant === "charge" ? scale * 0.55 : scale,
variant === "charge" ? scale * 2.1 : scale,
);
transform.updateMatrix();
particles.current.setMatrixAt(index, transform.matrix);
}
particles.current.instanceMatrix.needsUpdate = true;
if (material.current) {
const pulse = reducedMotion ? 0.65 : 0.58 + Math.sin(time * 6.2) * 0.14;
material.current.opacity = active ? pulse + 0.22 : pulse * 0.62;
}
});
return (
<group position={midpoint} rotation={[0, angle, 0]}>
<instancedMesh ref={particles} args={[undefined, undefined, LANE_PARTICLE_COUNT]} frustumCulled={false}>
<LaneParticleGeometry variant={variant} />
<meshBasicMaterial
ref={material}
color={color}
transparent
opacity={active ? 0.82 : 0.38}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</instancedMesh>
</group>
);
}
export function BreathParticleVfx({
position,
angle,
range,
halfAngle,
active,
}: {
position: WorldPosition;
angle: number;
range: number;
halfAngle: number;
active: boolean;
}) {
const particles = useRef<THREE.InstancedMesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
const reducedMotion = useReducedMotion();
useFrame(({ clock }) => {
if (!particles.current) return;
const time = reducedMotion ? 0.4 : clock.elapsedTime;
for (let index = 0; index < BREATH_PARTICLE_COUNT; index += 1) {
const row = index % 4;
const lane = Math.floor(index / 4);
const offsetAngle = -halfAngle + (lane / 2) * halfAngle * 2;
const cycle = (time * (active ? 1.7 : 0.52) + row * 0.24 + lane * 0.08) % 1;
const distance = range * (0.08 + cycle * 0.9);
const scale = (active ? 1 : 0.55) * (1.15 - cycle * 0.55);
transform.position.set(
Math.sin(offsetAngle) * distance,
0.18 + Math.sin(cycle * Math.PI) * (active ? 0.72 : 0.32),
Math.cos(offsetAngle) * distance,
);
transform.rotation.set(time * 1.2 + index, -offsetAngle, cycle * TAU);
transform.scale.set(scale * 0.72, scale * 1.45, scale * 0.72);
transform.updateMatrix();
particles.current.setMatrixAt(index, transform.matrix);
}
particles.current.instanceMatrix.needsUpdate = true;
if (material.current) material.current.opacity = active ? 0.74 : 0.26;
});
return (
<group position={[position[0], 0.1, position[1]]} rotation={[0, angle, 0]}>
<instancedMesh ref={particles} args={[undefined, undefined, BREATH_PARTICLE_COUNT]} frustumCulled={false}>
<tetrahedronGeometry args={[0.2, 0]} />
<meshBasicMaterial
ref={material}
color={active ? "#7ceaff" : "#b8f4ff"}
transparent
opacity={active ? 0.74 : 0.26}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</instancedMesh>
</group>
);
}
export function CircleMechanicVfx({
radius,
innerRadius = 0,
kind,
color,
active,
}: {
radius: number;
innerRadius?: number;
kind: Extract<PoolTelegraphKind, "donut" | "soak" | "spread"> | "pounce";
color: string;
active: boolean;
}) {
const particles = useRef<THREE.InstancedMesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
const reducedMotion = useReducedMotion();
useFrame(({ clock }) => {
if (!particles.current) return;
const time = reducedMotion ? 0.3 : clock.elapsedTime;
for (let index = 0; index < AREA_PARTICLE_COUNT; index += 1) {
const offset = index / AREA_PARTICLE_COUNT;
const cycle = (time * (active ? 1.2 : 0.55) + offset) % 1;
const orbit = offset * TAU + time * (kind === "soak" ? -0.55 : 0.7);
let radial = radius * 0.82;
if (kind === "spread") radial = radius * (0.18 + cycle * 0.75);
if (kind === "soak" || kind === "pounce") radial = radius * (0.92 - cycle * 0.68);
if (kind === "donut") radial = index % 2 === 0 ? Math.max(0.2, innerRadius + 0.18) : radius - 0.18;
const scale = (active ? 1 : 0.64) * (0.72 + Math.sin(cycle * Math.PI) * 0.48);
transform.position.set(Math.sin(orbit) * radial, 0.12 + Math.sin(cycle * Math.PI) * 0.52, Math.cos(orbit) * radial);
transform.rotation.set(time + index, orbit, cycle * TAU);
transform.scale.setScalar(scale);
transform.updateMatrix();
particles.current.setMatrixAt(index, transform.matrix);
}
particles.current.instanceMatrix.needsUpdate = true;
if (material.current) material.current.opacity = active ? 0.84 : 0.42;
});
return (
<instancedMesh ref={particles} args={[undefined, undefined, AREA_PARTICLE_COUNT]} frustumCulled={false}>
<octahedronGeometry args={[0.16, 0]} />
<meshBasicMaterial
ref={material}
color={color}
transparent
opacity={active ? 0.84 : 0.42}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</instancedMesh>
);
}
+151 -60
View File
@@ -3,10 +3,13 @@ import { Html } from "@react-three/drei";
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentType } from "react";
import * as THREE from "three";
import { BULL_CHARGE, BULL_POUNCE, MEMORY_SEQUENCE, MEMORY_SYMBOLS, SKY_SWEEPER_BREATH } from "../../game/bosses/mechanicPool";
import { bossHazardVfxProfile } from "../../game/bossHazardVisuals";
import { angleTo } from "../../game/geometry";
import { useGameStore } from "../../game/store";
import type { BossMotionMode, BossMotionState, MemorySymbolId, MemoryTile, PoolTelegraph, SoulSiphonState, WorldPosition } from "../../game/types";
import { BreathParticleVfx, CircleMechanicVfx, LaneEnergyVfx } from "./BossAttackVfx";
import { BOSS_INDICATOR_DEATH_FADE_MS, advanceBossIndicatorOpacity } from "./bossDeathVisuals";
import { CircleHazardVfx } from "./CircleHazardVfx";
const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
@@ -41,6 +44,10 @@ function bossHpAt(state: GameStoreState, bossIndex: number) {
return bossIndex === 0 ? state.boss.hp : state.additionalBosses[bossIndex - 1]?.boss.hp ?? 0;
}
function bossAt(state: GameStoreState, bossIndex: number) {
return bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss;
}
function earliestSoulSiphonInMotion(motion: BossMotionState | undefined, current?: SoulSiphonState) {
if (!motion) return current;
let earliest = current;
@@ -192,26 +199,37 @@ function SlashLaneIndicator({ laneId, bossIndex }: { laneId: string; bossIndex:
(lane.start[1] + lane.end[1]) * 0.5,
];
const active = ACTIVE_LANE_MODES.has(motion.mode);
const chargeEffect = motion.mode === "telegraph" || motion.mode === "charging";
const color = active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
return (
<group position={midpoint} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[lane.width, length]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.22} depthWrite={false} />
</mesh>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * lane.width * 0.5, 0.018, 0]}>
<boxGeometry args={[0.07, 0.03, length]} />
<meshBasicMaterial ref={side < 0 ? edgeMaterial : undefined} color={color} transparent opacity={0.8} depthWrite={false} />
<>
<group position={midpoint} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[lane.width, length]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.22} depthWrite={false} />
</mesh>
))}
{active && (
<mesh position={[0, 0.055, 0]}>
<boxGeometry args={[0.16, 0.08, length]} />
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.92} depthWrite={false} />
</mesh>
)}
</group>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * lane.width * 0.5, 0.018, 0]}>
<boxGeometry args={[0.07, 0.03, length]} />
<meshBasicMaterial ref={side < 0 ? edgeMaterial : undefined} color={color} transparent opacity={0.8} depthWrite={false} />
</mesh>
))}
{active && (
<mesh position={[0, 0.055, 0]}>
<boxGeometry args={[0.16, 0.08, length]} />
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.92} depthWrite={false} />
</mesh>
)}
</group>
<LaneEnergyVfx
start={lane.start}
end={lane.end}
width={lane.width}
color={active ? DANGER_HIGHLIGHT_COLOR : color}
active={active}
variant={chargeEffect ? "charge" : "slash"}
/>
</>
);
}
@@ -232,9 +250,10 @@ export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number })
if (!warningMaterial.current) return;
warningMaterial.current.opacity = 0.14 + (Math.sin(clock.elapsedTime * 8) + 1) * 0.08;
});
if (phase !== "combat" || motionMode !== "stacking") return null;
if (phase !== "combat" || (motionMode !== "stacking" && motionMode !== "pouncing")) return null;
const radius = BULL_POUNCE.stackRadius;
const active = motionMode === "pouncing";
return (
<group ref={group}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
@@ -261,6 +280,7 @@ export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number })
</mesh>
</group>
))}
<CircleMechanicVfx radius={radius} kind="pounce" color={active ? "#ffe0a8" : "#ff7278"} active={active} />
</group>
);
}
@@ -291,6 +311,7 @@ export function BindingWebIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
<meshBasicMaterial color={color} transparent opacity={0.92} depthWrite={false} />
</mesh>
))}
<LaneEnergyVfx start={first} end={second} width={0.34} color={color} active={stretched} variant="web" height={0.2} />
</group>
);
}
@@ -305,16 +326,25 @@ export function BreathConeIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
if (!motion || phase !== "combat" || (motion.mode !== "breath_telegraph" && motion.mode !== "breath_sweeping")) return null;
const color = motion.mode === "breath_sweeping" ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
return (
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - SKY_SWEEPER_BREATH.halfAngle]}>
<circleGeometry args={[SKY_SWEEPER_BREATH.range, 64, 0, SKY_SWEEPER_BREATH.halfAngle * 2]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.25} depthWrite={false} />
</mesh>
<mesh position={[0, 0.02, SKY_SWEEPER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[SKY_SWEEPER_BREATH.range * 0.47, SKY_SWEEPER_BREATH.range * 0.49, 48, 1, -SKY_SWEEPER_BREATH.halfAngle, SKY_SWEEPER_BREATH.halfAngle * 2]} />
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
</mesh>
</group>
<>
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - SKY_SWEEPER_BREATH.halfAngle]}>
<circleGeometry args={[SKY_SWEEPER_BREATH.range, 64, 0, SKY_SWEEPER_BREATH.halfAngle * 2]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.25} depthWrite={false} />
</mesh>
<mesh position={[0, 0.02, SKY_SWEEPER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[SKY_SWEEPER_BREATH.range * 0.47, SKY_SWEEPER_BREATH.range * 0.49, 48, 1, -SKY_SWEEPER_BREATH.halfAngle, SKY_SWEEPER_BREATH.halfAngle * 2]} />
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
</mesh>
</group>
<BreathParticleVfx
position={motion.position}
angle={motion.breathAngle}
range={SKY_SWEEPER_BREATH.range}
halfAngle={SKY_SWEEPER_BREATH.halfAngle}
active={motion.mode === "breath_sweeping"}
/>
</>
);
}
@@ -328,19 +358,8 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss
});
if (!hazard) return null;
const active = time >= hazard.activatesAt;
const colors = {
venom_pool: "#a94ee6",
skyfall: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
quake: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
lava_pool: "#ff5a24",
stinger_eruption: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
hourglass: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
tidal_burst: active ? DANGER_ACTIVE_COLOR : "#39c9df",
soul_rift: active ? "#7446d8" : "#aa78ff",
crownfall: active ? DANGER_ACTIVE_COLOR : "#e4c548",
royal_shockwave: active ? DANGER_ACTIVE_COLOR : "#f0ca4d",
} as const;
const color = colors[hazard.kind];
const visual = bossHazardVfxProfile(hazard.kind);
const color = active ? visual.activeColor : visual.warningColor;
return (
<group position={[hazard.center[0], 0.065, hazard.center[1]]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
@@ -358,9 +377,10 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss
{!active && (
<mesh position={[0, 0.03, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.22, 0.34, 24]} />
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.95} depthWrite={false} />
<meshBasicMaterial color={visual.highlightColor} transparent opacity={0.95} depthWrite={false} />
</mesh>
)}
<CircleHazardVfx hazard={hazard} active={active} />
</group>
);
}
@@ -639,30 +659,41 @@ function PooledTelegraphIndicator({ telegraphId, bossIndex }: { telegraphId: str
: telegraph.kind === "spread" ? "#e869ff"
: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
if (telegraph.kind === "beam" && telegraph.start && telegraph.end) {
if (telegraph.kind === "beam") {
if (!telegraph.start || !telegraph.end) return null;
const dx = telegraph.end[0] - telegraph.start[0];
const dz = telegraph.end[1] - telegraph.start[1];
const length = Math.hypot(dx, dz);
const angle = Math.atan2(dx, dz);
return (
<group position={[(telegraph.start[0] + telegraph.end[0]) * 0.5, 0.07, (telegraph.start[1] + telegraph.end[1]) * 0.5]} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[telegraph.width ?? 1, length]} />
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
</mesh>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * (telegraph.width ?? 1) * 0.5, 0.022, 0]}>
<boxGeometry args={[0.06, 0.035, length]} />
<meshBasicMaterial color={color} transparent opacity={0.95} depthWrite={false} />
<>
<group position={[(telegraph.start[0] + telegraph.end[0]) * 0.5, 0.07, (telegraph.start[1] + telegraph.end[1]) * 0.5]} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[telegraph.width ?? 1, length]} />
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
</mesh>
))}
{CHARGE_MARKERS.map((index) => (
<mesh key={index} position={[0, 0.034, -length * 0.44 + (index / 6) * length * 0.88]}>
<boxGeometry args={[(telegraph.width ?? 1) * 0.56, 0.035, 0.13]} />
<meshBasicMaterial color="#ffe1b3" transparent opacity={active ? 1 : 0.7} />
</mesh>
))}
</group>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * (telegraph.width ?? 1) * 0.5, 0.022, 0]}>
<boxGeometry args={[0.06, 0.035, length]} />
<meshBasicMaterial color={color} transparent opacity={0.95} depthWrite={false} />
</mesh>
))}
{CHARGE_MARKERS.map((index) => (
<mesh key={index} position={[0, 0.034, -length * 0.44 + (index / 6) * length * 0.88]}>
<boxGeometry args={[(telegraph.width ?? 1) * 0.56, 0.035, 0.13]} />
<meshBasicMaterial color="#ffe1b3" transparent opacity={active ? 1 : 0.7} />
</mesh>
))}
</group>
<LaneEnergyVfx
start={telegraph.start}
end={telegraph.end}
width={telegraph.width ?? 1}
color={active ? "#ffe1b3" : color}
active={active}
variant="beam"
/>
</>
);
}
@@ -705,6 +736,13 @@ function PooledTelegraphIndicator({ telegraphId, bossIndex }: { telegraphId: str
</mesh>
</group>
))}
<CircleMechanicVfx
radius={telegraph.radius}
innerRadius={innerRadius}
kind={telegraph.kind}
color={color}
active={active}
/>
</group>
);
}
@@ -714,7 +752,60 @@ export function PooledMechanicIndicators({ bossIndex = 0 }: { bossIndex?: number
return <>{telegraphs.map((telegraph) => <PooledTelegraphIndicator key={telegraph.id} telegraphId={telegraph.id} bossIndex={bossIndex} />)}</>;
}
export function BasicMeleeVfx({ bossIndex = 0 }: { bossIndex?: number }) {
const group = useRef<THREE.Group>(null);
const slashMaterial = useRef<THREE.MeshBasicMaterial>(null);
const impactMaterial = useRef<THREE.MeshBasicMaterial>(null);
const initialBoss = bossAt(useGameStore.getState(), bossIndex);
const previousNextMeleeAt = useRef(initialBoss?.nextMeleeAt ?? 0);
const age = useRef(Number.POSITIVE_INFINITY);
useFrame((_, delta) => {
const state = useGameStore.getState();
const boss = bossAt(state, bossIndex);
const motion = motionAt(state, bossIndex);
if (!group.current || !boss || !motion) return;
if (boss.nextMeleeAt !== previousNextMeleeAt.current) {
if (state.phase === "combat" && motion.mode === "holding") age.current = 0;
previousNextMeleeAt.current = boss.nextMeleeAt;
}
age.current += delta;
const visible = state.phase === "combat" && age.current < 0.42 && state.party[1].hp > 0;
group.current.visible = visible;
if (!visible) return;
const target = state.partyPositions.brann;
const progress = THREE.MathUtils.clamp(age.current / 0.42, 0, 1);
group.current.position.set(target[0], 1.02, target[1]);
group.current.rotation.y = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
group.current.scale.setScalar(0.68 + progress * 0.72);
if (slashMaterial.current) slashMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.92;
if (impactMaterial.current) impactMaterial.current.opacity = (1 - progress) * 0.68;
});
return (
<group ref={group} visible={false}>
<mesh rotation={[0, 0, -Math.PI * 0.72]}>
<torusGeometry args={[0.7, 0.055, 6, 28, Math.PI * 1.35]} />
<meshBasicMaterial
ref={slashMaterial}
color="#ffd58a"
transparent
opacity={0}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</mesh>
<mesh position={[0, -0.94, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.32, 0.5, 24]} />
<meshBasicMaterial ref={impactMaterial} color="#ff8d5c" transparent opacity={0} depthWrite={false} />
</mesh>
</group>
);
}
const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
BasicMeleeVfx,
ChargeLaneIndicator,
SlashLaneIndicators,
PounceStackIndicator,
+198
View File
@@ -0,0 +1,198 @@
import { useFrame } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import * as THREE from "three";
import { bossHazardVfxProfile, bossHazardVfxSeed, type BossHazardVfxShape } from "../../game/bossHazardVisuals";
import { useGameStore } from "../../game/store";
import type { CircleHazard } from "../../game/types";
const TAU = Math.PI * 2;
function ParticleGeometry({ shape }: { shape: BossHazardVfxShape }) {
if (shape === "orb") return <sphereGeometry args={[0.16, 8, 6]} />;
if (shape === "spike") return <coneGeometry args={[0.16, 0.9, 5]} />;
return <octahedronGeometry args={[0.19, 0]} />;
}
/**
* One instanced particle draw plus one transient accent and an optional column.
* Effects mutate preallocated transforms and never touch combat state.
*/
export function CircleHazardVfx({ hazard, active }: { hazard: CircleHazard; active: boolean }) {
const profile = bossHazardVfxProfile(hazard.kind);
const particles = useRef<THREE.InstancedMesh>(null);
const particleMaterial = useRef<THREE.MeshBasicMaterial>(null);
const warningRing = useRef<THREE.Mesh>(null);
const warningMaterial = useRef<THREE.MeshBasicMaterial>(null);
const impactRing = useRef<THREE.Mesh>(null);
const impactMaterial = useRef<THREE.MeshBasicMaterial>(null);
const column = useRef<THREE.Mesh>(null);
const columnMaterial = useRef<THREE.MeshBasicMaterial>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
const seed = useMemo(() => bossHazardVfxSeed(hazard.id), [hazard.id]);
const reducedMotion = useMemo(() => (
document.documentElement.classList.contains("force-reduced-motion")
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches
), []);
useFrame(({ clock }) => {
const time = useGameStore.getState().time;
const activeAge = Math.max(0, time - hazard.activatesAt);
const warningRemaining = Math.max(0, hazard.activatesAt - time);
const effectTime = reducedMotion ? seed * 2 : clock.elapsedTime + seed * 7.3;
const activeDuration = Math.max(0.22, hazard.expiresAt - hazard.activatesAt);
const impactDuration = Math.min(0.48, activeDuration);
const impactProgress = THREE.MathUtils.clamp(activeAge / impactDuration, 0, 1);
if (particles.current) {
for (let index = 0; index < profile.particleCount; index += 1) {
const offset = (index + 0.5) / profile.particleCount;
const baseAngle = offset * TAU + seed * TAU;
const angle = baseAngle + effectTime * profile.spinSpeed;
const stagger = (offset + seed * 0.37) % 1;
let radial = hazard.radius * (0.28 + (index % 3) * 0.2);
let y = 0.12;
let width = 0.72;
let height = 0.72;
if (profile.motion === "bubble") {
const cycle = (effectTime * 0.48 + stagger) % 1;
radial = hazard.radius * (0.22 + (index % 3) * 0.22);
y = 0.08 + cycle * profile.height;
width = 0.55 + Math.sin(cycle * Math.PI) * 0.65;
height = width;
} else if (profile.motion === "fall") {
const cycle = 1 - ((effectTime * 0.72 + stagger) % 1);
radial = hazard.radius * (0.18 + (index % 3) * 0.2);
y = 0.22 + cycle * profile.height;
width = active ? 0.82 : 0.55;
height = active ? 2.9 : 2.15;
} else if (profile.motion === "flame") {
const cycle = (effectTime * 0.95 + stagger) % 1;
radial = hazard.radius * (0.2 + (index % 3) * 0.21);
y = 0.12 + cycle * profile.height * 0.62;
width = 0.5 + Math.sin(cycle * Math.PI) * 0.85;
height = 0.75 + Math.sin(cycle * Math.PI) * 1.6;
} else if (profile.motion === "orbit") {
radial = hazard.radius * (0.3 + (index % 2) * 0.24);
y = 0.28 + ((index % 4) / 3) * profile.height + Math.sin(effectTime * 2.2 + index) * 0.16;
width = 0.65 + (index % 2) * 0.35;
height = profile.shape === "crystal" ? 1.55 : width;
} else if (profile.motion === "shockwave") {
const wave = active ? impactProgress : 0.22 + (1 - Math.min(1, warningRemaining / 1.6)) * 0.18;
radial = hazard.radius * wave;
y = 0.13 + Math.sin(offset * Math.PI) * profile.height * 0.12;
width = active ? 0.72 + impactProgress * 0.75 : 0.58;
height = active ? 1.25 : 0.75;
} else if (profile.motion === "spike") {
const rise = active ? THREE.MathUtils.clamp(activeAge / 0.16, 0, 1) : 0.12;
radial = hazard.radius * (0.2 + (index % 3) * 0.23);
width = 0.75 + (index % 2) * 0.35;
height = Math.max(0.18, rise * (profile.height + (index % 3) * 0.34));
y = height * 0.45;
} else {
const cycle = (effectTime * 0.72 + stagger) % 1;
radial = hazard.radius * (0.18 + cycle * 0.68);
y = 0.12 + Math.sin(cycle * Math.PI) * profile.height;
width = 0.6 + Math.sin(cycle * Math.PI) * 0.72;
height = width * 1.4;
}
const warningScale = active ? 1 : 0.56;
transform.position.set(Math.sin(angle) * radial, y, Math.cos(angle) * radial);
transform.rotation.set(index * 0.73, -angle, profile.motion === "fall" ? 0 : effectTime * 0.45 + index);
transform.scale.set(width * warningScale, height * warningScale, width * warningScale);
transform.updateMatrix();
particles.current.setMatrixAt(index, transform.matrix);
}
particles.current.instanceMatrix.needsUpdate = true;
}
if (particleMaterial.current) {
const pulse = reducedMotion ? 0.72 : 0.64 + Math.sin(effectTime * 4.6) * 0.12;
particleMaterial.current.opacity = active ? pulse + 0.16 : pulse * 0.48;
}
if (warningRing.current && warningMaterial.current) {
const countdown = THREE.MathUtils.clamp(warningRemaining / 1.8, 0, 1);
const scale = hazard.radius * (0.18 + countdown * 0.82);
warningRing.current.visible = !active;
warningRing.current.scale.setScalar(scale);
warningRing.current.rotation.z = reducedMotion ? seed * TAU : effectTime * profile.spinSpeed * 0.42;
warningMaterial.current.opacity = 0.48 + (reducedMotion ? 0 : Math.sin(effectTime * 7) * 0.16);
}
if (impactRing.current && impactMaterial.current) {
impactRing.current.visible = active && impactProgress < 0.999;
impactRing.current.scale.setScalar(hazard.radius * (0.16 + impactProgress * 1.02));
impactMaterial.current.opacity = (1 - impactProgress) * 0.92;
}
if (column.current && columnMaterial.current) {
const columnPulse = reducedMotion ? 0.78 : 0.7 + Math.sin(effectTime * 3.8) * 0.18;
column.current.rotation.y = effectTime * profile.spinSpeed * 0.3;
column.current.scale.set(
hazard.radius * (active ? 0.9 : 0.62),
profile.height * (active ? 1 : 0.72),
hazard.radius * (active ? 0.9 : 0.62),
);
columnMaterial.current.opacity = profile.columnOpacity * columnPulse * (active ? 1 : 0.52);
}
});
const color = active ? profile.activeColor : profile.warningColor;
return (
<group>
<instancedMesh ref={particles} args={[undefined, undefined, profile.particleCount]} frustumCulled={false}>
<ParticleGeometry shape={profile.shape} />
<meshBasicMaterial
ref={particleMaterial}
color={color}
transparent
opacity={active ? 0.8 : 0.35}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</instancedMesh>
<mesh ref={warningRing} position={[0, 0.085, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<torusGeometry args={[1, 0.026, 5, 36]} />
<meshBasicMaterial
ref={warningMaterial}
color={profile.highlightColor}
transparent
opacity={0.58}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</mesh>
<mesh ref={impactRing} position={[0, 0.12, 0]} rotation={[-Math.PI / 2, 0, 0]} visible={false}>
<ringGeometry args={[0.84, 1, 40]} />
<meshBasicMaterial
ref={impactMaterial}
color={profile.highlightColor}
transparent
opacity={0}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</mesh>
{profile.columnOpacity > 0 && (
<mesh ref={column} position={[0, profile.height * 0.5, 0]}>
<cylinderGeometry args={[0.48, 0.16, 1, 18, 1, true]} />
<meshBasicMaterial
ref={columnMaterial}
color={color}
transparent
opacity={profile.columnOpacity}
depthWrite={false}
side={THREE.DoubleSide}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</mesh>
)}
</group>
);
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { BOSS_BURROW_CLEARANCE, bossBurrowPositionY, bossIsBurrowing } from "./bossBurrowVisuals";
describe("boss burrow visuals", () => {
it("places the scaled top of the model below the arena floor", () => {
const modelTopY = 2.8371768724243784;
const modelScale = 0.82;
const positionY = bossBurrowPositionY(modelTopY, modelScale);
expect(positionY + modelTopY * modelScale).toBeCloseTo(-BOSS_BURROW_CLEARANCE, 6);
});
it("starts sinking during the warning and remains submerged through the charge", () => {
expect(bossIsBurrowing("burrow-rush", "telegraph")).toBe(true);
expect(bossIsBurrowing("burrow-rush", "charging")).toBe(true);
expect(bossIsBurrowing("burrow-rush", "holding")).toBe(false);
expect(bossIsBurrowing("bull-charge", "charging")).toBe(false);
});
});
+18
View File
@@ -0,0 +1,18 @@
import type { BossMechanicId, BossMotionMode } from "../../game/types";
export const BOSS_BURROW_CLEARANCE = 0.58;
export function bossBurrowPositionY(
modelTopY: number,
modelScale: number,
clearance = BOSS_BURROW_CLEARANCE,
): number {
return -(Math.max(0, modelTopY) * Math.max(0, modelScale) + Math.max(0, clearance));
}
export function bossIsBurrowing(
activeMechanicId: BossMechanicId | null,
mode: BossMotionMode,
): boolean {
return activeMechanicId === "burrow-rush" && (mode === "telegraph" || mode === "charging");
}
@@ -1,8 +1,12 @@
import { describe, expect, it } from "vitest";
import {
BOSS_INDICATOR_DEATH_FADE_MS,
BOSS_DEATH_DESPAWN_SECONDS,
BOSS_DEATH_FADE_SECONDS,
BOSS_DEATH_HOLD_SECONDS,
advanceBossIndicatorOpacity,
bossCanTrackTarget,
bossDeathOpacity,
} from "./bossDeathVisuals";
describe("boss death visuals", () => {
@@ -18,4 +22,10 @@ describe("boss death visuals", () => {
expect(advanceBossIndicatorOpacity(halfway, true, BOSS_INDICATOR_DEATH_FADE_MS / 2_000)).toBe(0);
expect(advanceBossIndicatorOpacity(0.4, false, 1 / 60)).toBe(1);
});
it("holds the death pose for a few seconds, then fades the model", () => {
expect(bossDeathOpacity(BOSS_DEATH_HOLD_SECONDS)).toBe(1);
expect(bossDeathOpacity(BOSS_DEATH_HOLD_SECONDS + BOSS_DEATH_FADE_SECONDS / 2)).toBeCloseTo(0.5);
expect(bossDeathOpacity(BOSS_DEATH_DESPAWN_SECONDS)).toBe(0);
});
});
+7
View File
@@ -1,3 +1,10 @@
export {
BOSS_DEATH_DESPAWN_SECONDS,
BOSS_DEATH_FADE_SECONDS,
BOSS_DEATH_HOLD_SECONDS,
bossDeathOpacity,
} from "../../game/bossDeath";
export const BOSS_INDICATOR_DEATH_FADE_MS = 250;
export function bossCanTrackTarget(hp: number) {
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { createHunterSave } from "./data";
import { resolveSaveContinuation, saveVersionsMatch } from "./saveContinuation";
function save(updatedAt: string) {
return createHunterSave(1, updatedAt, "Test Hunter");
}
describe("save continuation", () => {
it("creates only when neither copy exists", () => {
expect(resolveSaveContinuation({ local: null, online: null })).toBe("create");
});
it("uses whichever single copy exists", () => {
const local = save("2026-07-13T12:00:00.000Z");
const online = save("2026-07-13T13:00:00.000Z");
expect(resolveSaveContinuation({ local, online: null })).toBe("local");
expect(resolveSaveContinuation({ local: null, online })).toBe("online");
});
it("asks only when online copy is newer", () => {
const local = save("2026-07-13T12:00:00.000Z");
expect(resolveSaveContinuation({ local, online: save("2026-07-13T13:00:00.000Z") })).toBe("choose");
expect(resolveSaveContinuation({ local, online: save("2026-07-13T11:00:00.000Z") })).toBe("local");
expect(resolveSaveContinuation({ local, online: save(local.updatedAt) })).toBe("local");
});
it("matches downloaded copies by slot and timestamp", () => {
const local = save("2026-07-13T12:00:00.000Z");
expect(saveVersionsMatch(local, save(local.updatedAt))).toBe(true);
expect(saveVersionsMatch(local, save("2026-07-13T13:00:00.000Z"))).toBe(false);
expect(saveVersionsMatch(local, null)).toBe(false);
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { HunterSave, SaveSlotState } from "./types";
export type SaveContinuation = "create" | "local" | "online" | "choose";
function saveTimestamp(save: HunterSave): number | null {
const timestamp = Date.parse(save.updatedAt);
return Number.isFinite(timestamp) ? timestamp : null;
}
export function resolveSaveContinuation(slot: Pick<SaveSlotState, "local" | "online">): SaveContinuation {
if (!slot.local) return slot.online ? "online" : "create";
if (!slot.online) return "local";
const localTimestamp = saveTimestamp(slot.local);
const onlineTimestamp = saveTimestamp(slot.online);
if (localTimestamp !== null && onlineTimestamp !== null && onlineTimestamp > localTimestamp) return "choose";
return "local";
}
export function saveVersionsMatch(local: HunterSave | null, online: HunterSave | null): boolean {
return Boolean(local && online && local.slotId === online.slotId && local.updatedAt === online.updatedAt);
}
+4 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { createClassInventory } from "./healers";
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
import { canAddBossToEncounter } from "./bossSelection";
import { useGameStore } from "./store";
import type { BossId } from "./types";
@@ -27,7 +28,9 @@ function simulateControlledBattle(bossIds: readonly [BossId, BossId], maxSeconds
describe("full-mechanics dual-boss battle simulations", () => {
const combinations: readonly (readonly [BossId, BossId])[] = AVAILABLE_BOSS_IDS.flatMap((first, index) =>
AVAILABLE_BOSS_IDS.slice(index + 1).map((second) => [first, second] as const),
AVAILABLE_BOSS_IDS.slice(index + 1)
.filter((second) => canAddBossToEncounter([first], second))
.map((second) => [first, second] as const),
);
it.each(combinations)("party rotations defeat %s + %s", (first, second) => {
+29 -1
View File
@@ -2,11 +2,12 @@ import { describe, expect, it } from "vitest";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "./bossCatalog";
import { createBossMotionState, createBossState } from "./bossMechanics";
import { BOSS_MECHANIC_POOL, BOSS_MECHANIC_REGISTRY, bossMechanicName } from "./bosses/mechanicPool";
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", () => {
@@ -60,4 +61,31 @@ describe("boss catalog", () => {
const kits = Object.values(BOSS_DEFINITIONS).map((boss) => boss.mechanicIds.join(","));
expect(new Set(kits)).toHaveLength(AVAILABLE_BOSS_IDS.length);
});
it("uses the boar model's full authored death performance", () => {
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",
});
expect(ALTERNATE_BOSS_CONFIG["cluckhorn-colossus"].url).toContain("brassbeak-basilisk");
expect(ALTERNATE_BOSS_CONFIG["mirelord-frog"]).toMatchObject({
idle: "Idle", move: "BurrowRush", attack: "RootPummel", special: "SporeEruption", death: "Death",
});
expect(ALTERNATE_BOSS_CONFIG["mirelord-frog"].url).toContain("bogbell-myconid");
});
});
+9 -5
View File
@@ -112,8 +112,8 @@ const BOSS_SEEDS: Record<BossId, BossSeed> = {
summary: "Gallops through charged lanes before crashing onto the marked healer.", mechanicIds: ["bull-charge", "crushing-pounce", "stormfall"], maxHp: 480, archetype: "bull",
},
"cluckhorn-colossus": {
name: "Cluckhorn Colossus", title: "The Roostbreaker", icon: "✹", accent: "#f0b85d",
summary: "Stampedes sideways and drops cracking shell bursts on spread targets.", mechanicIds: ["sidewinder-rush", "crushing-tide", "meteor-spread"], maxHp: 475, archetype: "crab",
name: "Brassbeak Basilisk", title: "The Furnace Nest", icon: "✹", accent: "#dba33e",
summary: "Scuttles through lateral lanes and vents furnace bursts on spread targets.", mechanicIds: ["sidewinder-rush", "crushing-tide", "meteor-spread"], maxHp: 475, archetype: "crab",
},
"ashwing-demon": {
name: "Ashwing", title: "The Cinder Choir", icon: "♠", accent: "#df665d",
@@ -132,8 +132,8 @@ const BOSS_SEEDS: Record<BossId, BossSeed> = {
summary: "Ricochets across the arena and leaves fire at every landing.", mechanicIds: ["ricochet-rush", "meteor-slam", "ember-brand"], maxHp: 465, archetype: "ricochet",
},
"mirelord-frog": {
name: "Mirelord", title: "The Drowned Bell", icon: "●", accent: "#73c96b",
summary: "Dives below the mire before erupting through timed bog zones.", mechanicIds: ["burrow-rush", "hourglass-eruption", "hollow-collapse"], maxHp: 490, archetype: "burrower",
name: "Bogbell Myconid", title: "The Drowned Bell", icon: "●", accent: "#8fdc69",
summary: "Roots below the mire before erupting through timed spore blooms.", mechanicIds: ["burrow-rush", "hourglass-eruption", "hollow-collapse"], maxHp: 490, archetype: "burrower",
},
"stonebreaker-giant": {
name: "Stonebreaker", title: "The Walking Crag", icon: "▰", accent: "#c89563",
@@ -191,6 +191,10 @@ const BOSS_SEEDS: Record<BossId, BossSeed> = {
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",
+8
View File
@@ -0,0 +1,8 @@
export const BOSS_DEATH_HOLD_SECONDS = 2.5;
export const BOSS_DEATH_FADE_SECONDS = 0.75;
export const BOSS_DEATH_DESPAWN_SECONDS = BOSS_DEATH_HOLD_SECONDS + BOSS_DEATH_FADE_SECONDS;
export function bossDeathOpacity(elapsedSeconds: number) {
if (elapsedSeconds <= BOSS_DEATH_HOLD_SECONDS) return 1;
return Math.max(0, 1 - (elapsedSeconds - BOSS_DEATH_HOLD_SECONDS) / BOSS_DEATH_FADE_SECONDS);
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { BOSS_HAZARD_VFX, bossHazardVfxProfile, bossHazardVfxSeed } from "./bossHazardVisuals";
import type { CircleHazardKind } from "./types";
const HAZARD_KINDS: readonly CircleHazardKind[] = [
"venom_pool",
"skyfall",
"quake",
"lava_pool",
"stinger_eruption",
"hourglass",
"tidal_burst",
"soul_rift",
"crownfall",
"royal_shockwave",
];
describe("boss hazard visuals", () => {
it("defines a bounded effect profile for every circular hazard kind", () => {
expect(Object.keys(BOSS_HAZARD_VFX).sort()).toEqual([...HAZARD_KINDS].sort());
for (const kind of HAZARD_KINDS) {
const profile = bossHazardVfxProfile(kind);
expect(profile.particleCount).toBeGreaterThanOrEqual(6);
expect(profile.particleCount).toBeLessThanOrEqual(9);
expect(profile.height).toBeGreaterThan(0);
expect(profile.warningColor).not.toBe(profile.highlightColor);
}
});
it("uses shape and motion, not color alone, to distinguish common attacks", () => {
expect(bossHazardVfxProfile("venom_pool")).toMatchObject({ motion: "bubble", shape: "orb" });
expect(bossHazardVfxProfile("stinger_eruption")).toMatchObject({ motion: "spike", shape: "spike" });
expect(bossHazardVfxProfile("royal_shockwave")).toMatchObject({ motion: "shockwave", shape: "crystal" });
expect(bossHazardVfxProfile("soul_rift")).toMatchObject({ motion: "orbit", shape: "orb" });
});
it("creates stable normalized seeds without runtime randomness", () => {
expect(bossHazardVfxSeed("meteor-12")).toBe(bossHazardVfxSeed("meteor-12"));
expect(bossHazardVfxSeed("meteor-12")).not.toBe(bossHazardVfxSeed("meteor-13"));
expect(bossHazardVfxSeed("meteor-12")).toBeGreaterThanOrEqual(0);
expect(bossHazardVfxSeed("meteor-12")).toBeLessThanOrEqual(1);
});
});
+146
View File
@@ -0,0 +1,146 @@
import type { CircleHazardKind } from "./types";
export type BossHazardVfxMotion = "bubble" | "fall" | "flame" | "orbit" | "shockwave" | "spike" | "surge";
export type BossHazardVfxShape = "crystal" | "orb" | "spike";
export interface BossHazardVfxProfile {
readonly warningColor: string;
readonly activeColor: string;
readonly highlightColor: string;
readonly motion: BossHazardVfxMotion;
readonly shape: BossHazardVfxShape;
readonly particleCount: number;
readonly height: number;
readonly spinSpeed: number;
readonly columnOpacity: number;
}
/**
* Rendering-only identity for circular hazards. Combat rules remain in boss
* mechanics; renderers can project this profile without branching on boss id.
*/
export const BOSS_HAZARD_VFX: Record<CircleHazardKind, BossHazardVfxProfile> = {
venom_pool: {
warningColor: "#9a54d6",
activeColor: "#6fca45",
highlightColor: "#d6ff7d",
motion: "bubble",
shape: "orb",
particleCount: 7,
height: 0.8,
spinSpeed: 0.35,
columnOpacity: 0,
},
skyfall: {
warningColor: "#ff4d42",
activeColor: "#ff792e",
highlightColor: "#ffe0a1",
motion: "fall",
shape: "crystal",
particleCount: 6,
height: 5.2,
spinSpeed: 0.55,
columnOpacity: 0.13,
},
quake: {
warningColor: "#ff5549",
activeColor: "#d64226",
highlightColor: "#ffd08a",
motion: "shockwave",
shape: "crystal",
particleCount: 8,
height: 0.75,
spinSpeed: 0.18,
columnOpacity: 0,
},
lava_pool: {
warningColor: "#ff6a31",
activeColor: "#ff3b18",
highlightColor: "#ffd05a",
motion: "flame",
shape: "spike",
particleCount: 7,
height: 1.25,
spinSpeed: 0.28,
columnOpacity: 0.04,
},
stinger_eruption: {
warningColor: "#ff4b42",
activeColor: "#cf2442",
highlightColor: "#ffd2b3",
motion: "spike",
shape: "spike",
particleCount: 7,
height: 1.75,
spinSpeed: 0.12,
columnOpacity: 0,
},
hourglass: {
warningColor: "#ed8b3a",
activeColor: "#c16a28",
highlightColor: "#ffe3a3",
motion: "orbit",
shape: "crystal",
particleCount: 8,
height: 2.2,
spinSpeed: 1.35,
columnOpacity: 0.1,
},
tidal_burst: {
warningColor: "#36cae2",
activeColor: "#178fc6",
highlightColor: "#c9fbff",
motion: "surge",
shape: "orb",
particleCount: 8,
height: 1.65,
spinSpeed: 0.75,
columnOpacity: 0.08,
},
soul_rift: {
warningColor: "#aa78ff",
activeColor: "#6f42cf",
highlightColor: "#ebd8ff",
motion: "orbit",
shape: "orb",
particleCount: 8,
height: 2.45,
spinSpeed: 1.65,
columnOpacity: 0.14,
},
crownfall: {
warningColor: "#e7c94d",
activeColor: "#d95331",
highlightColor: "#fff1a8",
motion: "fall",
shape: "crystal",
particleCount: 7,
height: 5.6,
spinSpeed: -0.65,
columnOpacity: 0.15,
},
royal_shockwave: {
warningColor: "#edcb55",
activeColor: "#c94631",
highlightColor: "#fff0a6",
motion: "shockwave",
shape: "crystal",
particleCount: 9,
height: 0.85,
spinSpeed: -0.22,
columnOpacity: 0,
},
};
export function bossHazardVfxProfile(kind: CircleHazardKind) {
return BOSS_HAZARD_VFX[kind];
}
export function bossHazardVfxSeed(id: string) {
let hash = 2166136261;
for (let index = 0; index < id.length; index += 1) {
hash ^= id.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) / 0xffffffff;
}
+7 -4
View File
@@ -9,7 +9,7 @@ import {
} from "./bosses/mechanicPool";
import { createBaseMotion } from "./bosses/shared";
import type { BossMechanicContext, BossMechanicResult } from "./bosses/types";
import type { BossId, BossMotionState, BossState, MemberId, WorldPosition } from "./types";
import type { BossId, BossMotionState, BossState, Debuff, MemberId, WorldPosition } from "./types";
export { BOSS_MECHANIC_REGISTRY, BULL_CHARGE, BULL_POUNCE };
@@ -38,14 +38,17 @@ export function advanceBossMechanics(context: BossMechanicContext): BossMechanic
}
export function handleBossDispel(
_bossId: BossId,
bossId: BossId,
motion: BossMotionState,
memberId: MemberId,
position: WorldPosition,
time: number,
debuffNames: readonly string[],
debuffs: readonly Debuff[],
) {
return handleMechanicDispel(motion, memberId, position, time, debuffNames);
if (!BOSS_DEFINITIONS[bossId].mechanicIds.includes("venom-purge")) {
return { motion, message: "Harmful magic removed." };
}
return handleMechanicDispel(motion, memberId, position, time, debuffs);
}
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
+5 -2
View File
@@ -73,8 +73,8 @@ export const BOSS_ROOMS = {
"stormwool-alpaca": room("thunder-fleece", "The Thunder Fleece", "Wind-scoured highland", "storm", {
background: "#071321", fog: "#274d71", sky: "#a4d5ff", ground: "#071019", floorColor: "#233f5d", wallColor: "#365676", accent: "#9bd3ff", accentSecondary: "#eef9ff", wallHeight: 2.8,
}),
"cluckhorn-colossus": room("roostbreaker-yard", "The Roostbreaker Yard", "Ruinous farmstead", "wilds", {
background: "#171308", fog: "#49542a", sky: "#d8c675", ground: "#0b1207", floorColor: "#37421e", wallColor: "#554626", accent: "#f1b95c", accentSecondary: "#b8d065", wallHeight: 2.6,
"cluckhorn-colossus": room("furnace-nest", "The Furnace Nest", "Overgrown brass hatchery", "junkyard", {
background: "#081615", fog: "#284c45", sky: "#6edccb", ground: "#07100d", floorColor: "#263b32", wallColor: "#5a4123", accent: "#dfaa43", accentSecondary: "#62e3d1", wallHeight: 3.2,
}),
"ashwing-demon": room("cinder-choir", "The Cinder Choir", "Ashen cathedral", "cinder", {
background: "#1b0608", fog: "#511721", sky: "#ee7566", ground: "#120407", floorColor: "#4b1720", wallColor: "#592029", accent: "#ef625c", accentSecondary: "#ffb16e", wallHeight: 5.2,
@@ -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<BossId, BossRoomDefinition>;
export function bossRoomFor(bossId: BossId): BossRoomDefinition {
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { canAddBossToEncounter, normalizeEncounterBossIds } from "./bossSelection";
describe("boss encounter selection", () => {
it("allows only one Memory Sequence boss in an encounter", () => {
expect(canAddBossToEncounter(["sandglass-scorpion"], "crystal-bat-matriarch")).toBe(false);
expect(canAddBossToEncounter(["sandglass-scorpion"], "rimeclaw-yeti")).toBe(false);
expect(canAddBossToEncounter(["sandglass-scorpion"], "bulldrome")).toBe(true);
const normalized = normalizeEncounterBossIds([
"sandglass-scorpion",
"crystal-bat-matriarch",
"bulldrome",
"rimeclaw-yeti",
]);
expect(normalized).toEqual(["sandglass-scorpion", "bulldrome"]);
expect(normalized.filter((bossId) => BOSS_DEFINITIONS[bossId].mechanicIds.includes("memory-sequence"))).toHaveLength(1);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { BOSS_DEFINITIONS } from "./bossCatalog";
import type { BossId, BossMechanicId } from "./types";
/** Mechanics that cannot be resolved safely when two bosses own them at once. */
export const ENCOUNTER_EXCLUSIVE_MECHANICS: ReadonlySet<BossMechanicId> = new Set(["memory-sequence"]);
export function canAddBossToEncounter(selectedBossIds: readonly BossId[], candidateBossId: BossId) {
if (selectedBossIds.includes(candidateBossId)) return false;
const candidateMechanics = BOSS_DEFINITIONS[candidateBossId].mechanicIds;
for (const mechanicId of candidateMechanics) {
if (!ENCOUNTER_EXCLUSIVE_MECHANICS.has(mechanicId)) continue;
if (selectedBossIds.some((bossId) => BOSS_DEFINITIONS[bossId].mechanicIds.includes(mechanicId))) return false;
}
return true;
}
export function normalizeEncounterBossIds(requestedBossIds: readonly BossId[], limit = 3): BossId[] {
const selected: BossId[] = [];
for (const bossId of requestedBossIds) {
if (selected.length >= limit) break;
if (canAddBossToEncounter(selected, bossId)) selected.push(bossId);
}
return selected.length ? selected : ["bulldrome"];
}
+17 -7
View File
@@ -4,6 +4,7 @@ export type AlternateBossKind = Exclude<BossId, "bulldrome">;
export interface AlternateBossConfig {
url: string;
optimizedUrl?: string;
scale: number;
idle: string;
move: string;
@@ -19,6 +20,10 @@ export const BULL_URL = new URL("../assets/game/models/claudecraft/creatures/bul
const SANDGLASS_URL = new URL("../assets/game/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href;
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;
@@ -30,14 +35,15 @@ const CLAUDE_BOSS_URLS: Record<Exclude<BossId,
| "mournveil-ghost"
| "crownshard-golem"
| "crystal-bat-matriarch"
| "cluckhorn-colossus"
| "mirelord-frog"
| "gravehorn-triceratops"
>, string> = {
"stormwool-alpaca": new URL("../assets/game/models/claudecraft/creatures/alpaca.glb", import.meta.url).href,
"cluckhorn-colossus": new URL("../assets/game/models/claudecraft/creatures/chicken_cow.glb", import.meta.url).href,
"ashwing-demon": new URL("../assets/game/models/claudecraft/creatures/demon.glb", import.meta.url).href,
"riftclaw-demon": new URL("../assets/game/models/claudecraft/creatures/demonalt.glb", import.meta.url).href,
"tempestscale-dragon": new URL("../assets/game/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href,
emberfox: new URL("../assets/game/models/claudecraft/creatures/fox.glb", import.meta.url).href,
"mirelord-frog": new URL("../assets/game/models/claudecraft/creatures/frog.glb", import.meta.url).href,
"stonebreaker-giant": new URL("../assets/game/models/claudecraft/creatures/giant.glb", import.meta.url).href,
"glub-sovereign": new URL("../assets/game/models/claudecraft/creatures/glubevolved.glb", import.meta.url).href,
"scrapking-goblin": new URL("../assets/game/models/claudecraft/creatures/goblin.glb", import.meta.url).href,
@@ -61,12 +67,13 @@ export const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfi
"crownshard-golem": { url: CROWNSHARD_URL, scale: 1.15, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#e0bd45", rotationOffset: 0, floating: true },
"crystal-bat-matriarch": { url: CRYSTAL_BAT_MATRIARCH_URL, scale: 0.828, idle: "Idle", move: "Swoop", attack: "SonicPulse", special: "MirrorShatter", death: "Death", light: "#8eeaff", rotationOffset: 0, floating: true },
"stormwool-alpaca": { url: CLAUDE_BOSS_URLS["stormwool-alpaca"], scale: 0.72, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#8fc7ff", rotationOffset: 0 },
"cluckhorn-colossus": { url: CLAUDE_BOSS_URLS["cluckhorn-colossus"], scale: 2.2, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#f0b85d", rotationOffset: 0 },
// IDs remain stable so existing saves and trophies keep working after visual replacement.
"cluckhorn-colossus": { url: BRASSBEAK_BASILISK_URL, scale: 0.75, idle: "Idle", move: "Scuttle", attack: "BeakRend", special: "FurnaceBurst", death: "Death", light: "#5cebd7", rotationOffset: 0 },
"ashwing-demon": { url: CLAUDE_BOSS_URLS["ashwing-demon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#df665d", rotationOffset: 0, floating: true },
"riftclaw-demon": { url: CLAUDE_BOSS_URLS["riftclaw-demon"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#d45cff", rotationOffset: 0 },
"tempestscale-dragon": { url: CLAUDE_BOSS_URLS["tempestscale-dragon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#5fc8e8", rotationOffset: 0, floating: true },
emberfox: { url: CLAUDE_BOSS_URLS.emberfox, scale: 1, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#ff7b45", rotationOffset: 0 },
"mirelord-frog": { url: CLAUDE_BOSS_URLS["mirelord-frog"], scale: 1.4, idle: "Idle", move: "Run", attack: "Punch", special: "Jump", death: "Death", light: "#73c96b", rotationOffset: 0 },
"mirelord-frog": { url: BOGBELL_MYCONID_URL, scale: 0.78, idle: "Idle", move: "BurrowRush", attack: "RootPummel", special: "SporeEruption", death: "Death", light: "#8df06b", rotationOffset: 0 },
"stonebreaker-giant": { url: CLAUDE_BOSS_URLS["stonebreaker-giant"], scale: 1, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#c89563", rotationOffset: 0 },
"glub-sovereign": { url: CLAUDE_BOSS_URLS["glub-sovereign"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#6ce0b8", rotationOffset: 0, floating: true },
"scrapking-goblin": { url: CLAUDE_BOSS_URLS["scrapking-goblin"], scale: 1.5, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#d7a34b", rotationOffset: 0 },
@@ -77,12 +84,15 @@ export const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfi
"thorncrown-stag": { url: CLAUDE_BOSS_URLS["thorncrown-stag"], scale: 0.85, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#7fc46b", rotationOffset: 0 },
"sky-totem": { url: CLAUDE_BOSS_URLS["sky-totem"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#69d4d1", rotationOffset: 0, floating: true },
"razorcrest-raptor": { url: CLAUDE_BOSS_URLS["razorcrest-raptor"], scale: 1.1, idle: "Velociraptor_Idle", move: "Velociraptor_Run", attack: "Velociraptor_Attack", special: "Velociraptor_Jump", death: "Velociraptor_Death", light: "#d9c45a", rotationOffset: 0 },
"bristlequake-boar": { url: CLAUDE_BOSS_URLS["bristlequake-boar"], scale: 0.475, idle: "Idle_AnimalArmature", move: "Gallop_AnimalArmature", attack: "Attack_Headbutt_AnimalArmature", special: "Attack_Kick_AnimalArmature", death: "Death_AnimalArmature", light: "#d47b45", rotationOffset: 0 },
"bristlequake-boar": { url: CLAUDE_BOSS_URLS["bristlequake-boar"], scale: 0.475, idle: "Idle_AnimalArmature", move: "Gallop_AnimalArmature", attack: "Attack_Headbutt_AnimalArmature", special: "Attack_Kick_AnimalArmature", death: "Dying", light: "#d47b45", rotationOffset: 0 },
"moonfang-wolf": { url: CLAUDE_BOSS_URLS["moonfang-wolf"], scale: 1.05, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#9db9e5", rotationOffset: 0 },
"frostmaw-yeti": { url: CLAUDE_BOSS_URLS["frostmaw-yeti"], scale: 1.35, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#8ed8ef", rotationOffset: 0 },
"rimeclaw-yeti": { url: CLAUDE_BOSS_URLS["rimeclaw-yeti"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#75bfe8", rotationOffset: 0 },
"gravehorn-triceratops": { url: GRAVEHORN_URL, optimizedUrl: GRAVEHORN_OPTIMIZED_URL, scale: 0.82, idle: "Gravehorn|Idle", move: "Armature|Walk", attack: "Armature|Roar", special: "Armature|RiseUp", death: "Armature|Fall", light: "#ffd9a0", rotationOffset: 0 },
};
export function bossVisualUrl(bossId: BossId): string {
return bossId === "bulldrome" ? BULL_URL : ALTERNATE_BOSS_CONFIG[bossId].url;
export function bossVisualUrl(bossId: BossId, optimized = false): string {
if (bossId === "bulldrome") return BULL_URL;
const config = ALTERNATE_BOSS_CONFIG[bossId];
return optimized ? config.optimizedUrl ?? config.url : config.url;
}
+18
View File
@@ -141,6 +141,24 @@ describe("shared boss mechanic pool", () => {
expect(bossAnimationCue(started.motion)).toBe("attack");
});
it("moves a web caster sideways during Binding Web and animates its return home", () => {
const source = context(0);
source.motion.nextMechanicAt = 0;
const started = advanceMechanicLoadout(source, ["binding-web", "venom-purge"]);
expect(bossAnimationCue(started.motion)).toBe("move");
const next = context(0.1);
next.boss = started.boss;
next.motion = started.motion;
next.party = started.party;
const advanced = advanceMechanicLoadout(next, ["binding-web", "venom-purge"]);
expect(advanced.motion.position).not.toEqual(started.motion.position);
advanced.motion.activeMechanicId = null;
advanced.motion.mode = "holding";
expect(bossAnimationCue(advanced.motion)).toBe("move");
});
it("splits soak damage across allies inside its indicator", () => {
const source = context(0);
const activatesAt = 1.5;
+29 -12
View File
@@ -1,6 +1,6 @@
import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossAnimationCue, BossMechanicId, BossMotionState, CircleHazard, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, SlashLane, WorldPosition } from "../types";
import type { BossAnimationCue, BossMechanicId, BossMotionState, CircleHazard, Debuff, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createCircleHazard, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
@@ -11,7 +11,7 @@ export const BOSS_MECHANIC_POOL = [
{ id: "cinder-nova", name: "Cinder Nova", instruction: "Heal the party through raidwide damage." },
{ id: "ember-brand", name: "Ember Brand", instruction: "Purify the marked ally." },
{ id: "binding-web", name: "Binding Web", instruction: "Separate the linked allies." },
{ id: "venom-purge", name: "Venom Purge", instruction: "Move away before cleansing Widow Venom." },
{ id: "venom-purge", name: "Venom Purge", instruction: "Move apart, then cleanse Widow Venom before it expires." },
{ id: "storm-breath", name: "Storm Breath", instruction: "Rotate behind the sweeping cone." },
{ id: "stormfall", name: "Stormfall", instruction: "Spread before the marked impacts." },
{ id: "elemental-beam", name: "Elemental Beam", instruction: "Clear the glowing lane." },
@@ -110,10 +110,13 @@ export const VENOM_PURGE = {
tickDamage: 5,
castDuration: 2.5,
poolRadius: 2,
poolDuration: 7,
poolDamage: 14,
poolArmDelay: 1.25,
poolDuration: 6,
poolDamage: 8,
} as const;
const BINDING_WEB_SCUTTLE_SPEED = 3.2;
export const MEMORY_SEQUENCE = {
sequenceLength: 4,
flashDuration: 0.9,
@@ -942,11 +945,22 @@ const bindingWeb: BossMechanicDefinition = {
runtime.motion.mode = "tethering";
runtime.motion.tetherIds = [first, second];
runtime.motion.tetherBreakDistance = 6.8;
runtime.motion.chargeStart = [...runtime.motion.position];
const scuttleDirection = runtime.motion.mechanicCount % 2 === 0 ? -1 : 1;
runtime.motion.chargeEnd = clampToArena([
ARENA_CENTER[0] + runtime.motion.formationOffsetX + scuttleDirection * 4.2,
ARENA_CENTER[1] - 1.25,
]);
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + 4.5;
runtime.events.push({ at: runtime.context.time, message: `Binding Web links ${memberName(runtime.party, first)} and ${memberName(runtime.party, second)}. Spread apart.`, tone: "danger", pulseKind: "tether", targetId: first });
},
advance(runtime) {
runtime.motion.position = moveToward(
runtime.motion.position,
runtime.motion.chargeEnd,
BINDING_WEB_SCUTTLE_SPEED * runtime.context.delta,
);
const [first, second] = runtime.motion.tetherIds;
if (!first || !second || distance(runtime.context.partyPositions[first], runtime.context.partyPositions[second]) >= runtime.motion.tetherBreakDistance) {
runtime.events.push({ at: runtime.context.time, message: "Binding Web snaps. Formation is free.", pulseKind: "tether" });
@@ -960,19 +974,19 @@ const bindingWeb: BossMechanicDefinition = {
runtime.events.push({ at: runtime.context.time, message: "Binding Web constricts and roots its targets.", tone: "danger", pulseKind: "tether" });
finishMechanic(runtime, 4);
},
animationCue: () => "attack",
animationCue: (motion) => distance(motion.position, motion.chargeEnd) > 0.08 ? "move" : "attack",
};
bindingWeb.upcoming = (motion, time) => defaultUpcoming(bindingWeb, motion, time);
const venomPurge = instantTimedDefinition("venom-purge", 4, (runtime) => {
const targets = [0, 1].map((offset) => chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount + offset));
runtime.motion.mode = "venom_cast";
runtime.motion.phaseEndsAt = runtime.context.time + VENOM_PURGE.castDuration;
runtime.motion.phaseEndsAt = runtime.context.time + VENOM_PURGE.castDuration;
runtime.party = runtime.party.map((member) => targets.includes(member.id) ? {
...member,
debuffs: [...member.debuffs, { id: `widow-venom-${runtime.motion.mechanicCount}-${member.id}`, name: "Widow Venom", expiresAt: runtime.context.time + VENOM_PURGE.duration, nextTickAt: runtime.context.time + 1, tickDamage: VENOM_PURGE.tickDamage }],
debuffs: [...member.debuffs, { id: `widow-venom-${runtime.motion.bossId}-${runtime.motion.mechanicCount}-${member.id}`, name: "Widow Venom", expiresAt: runtime.context.time + VENOM_PURGE.duration, nextTickAt: runtime.context.time + 1, tickDamage: VENOM_PURGE.tickDamage, sourceBossId: runtime.motion.bossId }],
} : member);
runtime.events.push({ at: runtime.context.time, message: "Venom Purge applies Widow Venom. Move away before cleansing.", tone: "danger", pulseKind: "venom", targetId: targets[0] });
runtime.events.push({ at: runtime.context.time, message: "Venom Purge applies Widow Venom. Move apart, then cleanse.", tone: "danger", pulseKind: "venom", targetId: targets[0] });
});
const stormBreath: BossMechanicDefinition = {
@@ -1264,12 +1278,15 @@ export function upcomingLoadoutMechanic(
}
export function bossAnimationCue(motion: BossMotionState): BossAnimationCue {
return motion.activeMechanicId ? BOSS_MECHANIC_REGISTRY[motion.activeMechanicId].animationCue(motion) : "idle";
if (motion.activeMechanicId) return BOSS_MECHANIC_REGISTRY[motion.activeMechanicId].animationCue(motion);
const homeDx = motion.position[0] - (ARENA_CENTER[0] + motion.formationOffsetX);
const homeDz = motion.position[1] - ARENA_CENTER[1];
return Math.hypot(homeDx, homeDz) > 0.08 ? "move" : "idle";
}
export function dropVenomPool(motion: BossMotionState, memberId: MemberId, center: WorldPosition, time: number) {
const next = cloneMotion(motion);
next.hazards.push(createCircleHazard({ id: `venom-pool-${memberId}-${time.toFixed(2)}`, kind: "venom_pool", center, radius: VENOM_PURGE.poolRadius, activatesAt: time + 0.25, duration: VENOM_PURGE.poolDuration, damage: VENOM_PURGE.poolDamage, tickInterval: 1 }));
next.hazards.push(createCircleHazard({ id: `venom-pool-${memberId}-${time.toFixed(2)}`, kind: "venom_pool", center, radius: VENOM_PURGE.poolRadius, activatesAt: time + VENOM_PURGE.poolArmDelay, duration: VENOM_PURGE.poolDuration, damage: VENOM_PURGE.poolDamage, tickInterval: 1 }));
return next;
}
@@ -1278,9 +1295,9 @@ export function handleMechanicDispel(
memberId: MemberId,
position: WorldPosition,
time: number,
debuffNames: readonly string[],
debuffs: readonly Debuff[],
) {
if (debuffNames.includes("Widow Venom")) {
if (debuffs.some((debuff) => debuff.name === "Widow Venom" && debuff.sourceBossId === motion.bossId)) {
return {
motion: dropVenomPool(motion, memberId, position, time),
message: "Widow Venom purged. A venom pool forms where the target stood.",
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { PARTY_ABILITY_LOADOUTS, PARTY_ABILITY_NAMES } from "./partyCombat";
import { PARTY_ATTACK_VFX, partyAttackVfxProfile } from "./partyAttackVisuals";
describe("party attack visuals", () => {
it("covers every party combat ability", () => {
expect(Object.keys(PARTY_ATTACK_VFX).sort()).toEqual(Object.keys(PARTY_ABILITY_NAMES).sort());
});
it("keeps every profile attached to its loadout owner", () => {
for (const [ownerId, abilityIds] of Object.entries(PARTY_ABILITY_LOADOUTS)) {
for (const abilityId of abilityIds) {
expect(partyAttackVfxProfile(abilityId).ownerId).toBe(ownerId);
}
}
});
it("gives every ranged attack a visible trail and every melee loadout a non-projectile style", () => {
for (const abilityId of [...PARTY_ABILITY_LOADOUTS.nia, ...PARTY_ABILITY_LOADOUTS.orin]) {
const profile = partyAttackVfxProfile(abilityId);
if (profile.style === "buff") continue;
expect(profile.style).toBe("projectile");
expect(profile.trail).toBeGreaterThan(0);
}
for (const abilityId of [...PARTY_ABILITY_LOADOUTS.brann, ...PARTY_ABILITY_LOADOUTS.vale]) {
expect(partyAttackVfxProfile(abilityId).style).not.toBe("projectile");
}
});
});
+43
View File
@@ -0,0 +1,43 @@
import type { AiCombatantId, PartyAbilityId } from "./partyCombat";
export type PartyAttackVfxStyle = "buff" | "double-slash" | "projectile" | "slam" | "slash" | "spin";
export interface PartyAttackVfxProfile {
readonly ownerId: AiCombatantId;
readonly style: PartyAttackVfxStyle;
readonly primary: string;
readonly accent: string;
readonly scale: number;
readonly trail: number;
}
/** Visual identity only. Damage, timing, targeting, and resources stay in partyCombat. */
export const PARTY_ATTACK_VFX: Record<PartyAbilityId, PartyAttackVfxProfile> = {
sword_slash: { ownerId: "brann", style: "slash", primary: "#f0c56b", accent: "#fff0b0", scale: 0.9, trail: 0 },
shield_slam: { ownerId: "brann", style: "slam", primary: "#62b9ff", accent: "#d9f2ff", scale: 1.05, trail: 0 },
revenge: { ownerId: "brann", style: "double-slash", primary: "#ef6c4d", accent: "#ffd0a5", scale: 1.05, trail: 0 },
sweeping_guard: { ownerId: "brann", style: "spin", primary: "#77d7ff", accent: "#e5f8ff", scale: 1.15, trail: 0 },
bulwark_march: { ownerId: "brann", style: "buff", primary: "#4da8ff", accent: "#d5efff", scale: 1.25, trail: 0 },
quick_shot: { ownerId: "nia", style: "projectile", primary: "#77d596", accent: "#e5ffb8", scale: 0.78, trail: 0.45 },
aimed_shot: { ownerId: "nia", style: "projectile", primary: "#f0c961", accent: "#fff4be", scale: 1, trail: 0.7 },
rapid_fire: { ownerId: "nia", style: "projectile", primary: "#65dcba", accent: "#d8fff1", scale: 0.72, trail: 0.38 },
kill_shot: { ownerId: "nia", style: "projectile", primary: "#f05d57", accent: "#ffd3a3", scale: 1.18, trail: 0.9 },
deadeye: { ownerId: "nia", style: "projectile", primary: "#fff0a8", accent: "#ffffff", scale: 1.35, trail: 1.15 },
arcane_bolt: { ownerId: "orin", style: "projectile", primary: "#a87cff", accent: "#ead9ff", scale: 0.9, trail: 0.75 },
ember_lance: { ownerId: "orin", style: "projectile", primary: "#ff713d", accent: "#ffd080", scale: 0.92, trail: 0.9 },
arcane_burst: { ownerId: "orin", style: "projectile", primary: "#5d9fff", accent: "#dcf3ff", scale: 1.25, trail: 1.05 },
comet: { ownerId: "orin", style: "projectile", primary: "#7ce5ff", accent: "#f0fdff", scale: 1.55, trail: 1.35 },
overcharge: { ownerId: "orin", style: "buff", primary: "#b15cff", accent: "#f0d6ff", scale: 1.15, trail: 0 },
quick_cut: { ownerId: "vale", style: "slash", primary: "#c68aff", accent: "#f1ddff", scale: 0.78, trail: 0 },
twin_fang: { ownerId: "vale", style: "double-slash", primary: "#e36cff", accent: "#ffd5ff", scale: 0.92, trail: 0 },
backstab: { ownerId: "vale", style: "slash", primary: "#f15b7a", accent: "#ffd4df", scale: 1.12, trail: 0 },
fan_of_blades: { ownerId: "vale", style: "spin", primary: "#c9d5df", accent: "#ffffff", scale: 1.2, trail: 0 },
blade_flurry: { ownerId: "vale", style: "buff", primary: "#8a7cff", accent: "#e8e3ff", scale: 1.15, trail: 0 },
};
export function partyAttackVfxProfile(abilityId: PartyAbilityId) {
return PARTY_ATTACK_VFX[abilityId];
}
+5
View File
@@ -0,0 +1,5 @@
import type { PartyMember } from "./types";
export function isPartyWiped(party: readonly PartyMember[]) {
return party.length > 0 && party.every((member) => member.hp <= 0);
}
+12 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS } from "./bossCatalog";
import {
RUN_BUFF_ORDER,
RUN_BUFFS,
@@ -97,6 +97,17 @@ describe("roguelike progression", () => {
expect(trio.every((bossId) => !seen.includes(bossId as typeof seen[number]))).toBe(true);
});
it("never selects two Memory Sequence bosses in one pair or trio", () => {
const pair = selectRandomBossPair([], () => 0.04);
const trio = selectUnseenBosses(3, [], () => 0);
const memoryBossCount = (bossIds: readonly (typeof AVAILABLE_BOSS_IDS)[number][]) => bossIds
.filter((bossId) => BOSS_DEFINITIONS[bossId].mechanicIds.includes("memory-sequence"))
.length;
expect(memoryBossCount(pair)).toBeLessThanOrEqual(1);
expect(memoryBossCount(trio)).toBeLessThanOrEqual(1);
});
it("fails instead of silently reusing seen bosses when unseen pool is too small", () => {
expect(() => selectUnseenBosses(3, AVAILABLE_BOSS_IDS.slice(0, -2))).toThrow(/Cannot select 3 unseen bosses/);
});
+14 -5
View File
@@ -1,4 +1,5 @@
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
import { canAddBossToEncounter } from "./bossSelection";
import type { AbilityId, BossId, RunBuffId, RunBuffRanks } from "./types";
export type RunBuffEffectKind =
@@ -210,6 +211,7 @@ export function selectUnseenBosses(
count: number,
seenBossIds: readonly BossId[] = [],
random: () => number = Math.random,
selectedBossIds: readonly BossId[] = [],
): BossId[] {
const seen = new Set(seenBossIds);
const pool = AVAILABLE_BOSS_IDS.filter((bossId) => !seen.has(bossId));
@@ -219,11 +221,16 @@ export function selectUnseenBosses(
}
const bosses: BossId[] = [];
while (bosses.length < requestedCount) {
const compatiblePool = pool.filter((bossId) => canAddBossToEncounter([...selectedBossIds, ...bosses], bossId));
if (!compatiblePool.length) {
throw new Error(`Cannot select ${requestedCount} unseen bosses without duplicating an encounter-exclusive mechanic.`);
}
const sample = random();
const randomValue = Number.isFinite(sample) ? Math.max(0, Math.min(0.999999999, sample)) : 0;
const index = Math.floor(randomValue * pool.length);
bosses.push(pool[index]);
pool.splice(index, 1);
const index = Math.floor(randomValue * compatiblePool.length);
const selected = compatiblePool[index];
bosses.push(selected);
pool.splice(pool.indexOf(selected), 1);
}
return bosses;
}
@@ -245,6 +252,8 @@ export function selectRandomBossPair(
const eligibleBosses = AVAILABLE_BOSS_IDS.filter((bossId) => !excluded.has(bossId));
const pool = eligibleBosses.length >= 2 ? eligibleBosses : AVAILABLE_BOSS_IDS;
const firstIndex = Math.floor(random() * pool.length) % pool.length;
const secondOffset = 1 + (Math.floor(random() * (pool.length - 1)) % (pool.length - 1));
return [pool[firstIndex], pool[(firstIndex + secondOffset) % pool.length]];
const first = pool[firstIndex];
const compatiblePool = pool.filter((bossId) => canAddBossToEncounter([first], bossId));
const secondIndex = Math.floor(random() * compatiblePool.length) % compatiblePool.length;
return [first, compatiblePool[secondIndex]];
}
+114 -3
View File
@@ -6,6 +6,7 @@ import { createClassInventory, HEALER_CLASSES } from "./healers";
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
import { ARENA_CENTER, isInsideArena } from "./arena";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { BOSS_DEATH_DESPAWN_SECONDS } from "./bossDeath";
import { RUN_BUFF_ORDER, RUN_BUFFS, compileRunModifiers } from "./roguelike";
import type { RunBuffRanks } from "./types";
@@ -192,6 +193,26 @@ describe("Disc Priest combat simulation", () => {
expect(paused.castAbility("renew")).toBe(false);
});
it("cancels an active cast and blocks new abilities when the healer falls", () => {
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
party: state.party.map((member) => member.id === "nia" ? { ...member, hp: 40 } : member),
}));
useGameStore.getState().selectMember("nia");
expect(useGameStore.getState().castAbility("mend")).toBe(true);
useGameStore.setState((state) => ({
party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 0 } : member),
}));
useGameStore.getState().tick(0.6);
const state = useGameStore.getState();
expect(state.phase).toBe("combat");
expect(state.activeCast).toBeNull();
expect(state.party.find((member) => member.id === "nia")?.hp).toBe(40);
expect(state.castAbility("renew")).toBe(false);
});
it("does not publish unchanged player positions while idle", () => {
let updates = 0;
const unsubscribe = useGameStore.subscribe(() => { updates += 1; });
@@ -311,6 +332,64 @@ describe("Disc Priest combat simulation", () => {
});
});
describe("shared party wipe rules", () => {
it.each(["encounter", "roguelike", "rogue-trials"] as const)(
"keeps %s combat running after individual party deaths",
(runMode) => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
["bulldrome", "broodfang-spider"],
runMode,
);
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
boss: { ...state.boss, hp: 1_000_000, maxHp: 1_000_000, nextMeleeAt: 999 },
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
additionalBosses: state.additionalBosses.map((entry) => ({
...entry,
boss: { ...entry.boss, hp: 1_000_000, maxHp: 1_000_000, nextMeleeAt: 999 },
motion: { ...entry.motion, nextMechanicAt: 999 },
})),
party: state.party.map((member) => member.id === "aelia" || member.id === "brann" ? { ...member, hp: 0 } : member),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe("combat");
useGameStore.setState((state) => ({
party: state.party.map((member) => ({ ...member, hp: 0 })),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe("defeat");
},
);
it.each([
["encounter", "victory"],
["roguelike", "intermission"],
["rogue-trials", "intermission"],
] as const)("awards %s boss completion when both sides fall on the same tick", (runMode, expectedPhase) => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
["bulldrome", "broodfang-spider"],
runMode,
);
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
boss: { ...state.boss, hp: 0 },
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
party: state.party.map((member) => ({ ...member, hp: 0 })),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe(expectedPhase);
});
});
describe("Broodfang encounter", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "broodfang-spider");
@@ -351,7 +430,7 @@ describe("Broodfang encounter", () => {
expect(state.bossMotion.hazards[0]).toMatchObject({ kind: "venom_pool", center: state.partyPositions[poisoned.id] });
});
it("repeatedly damages the player while they remain in a venom pool", () => {
it("arms a lower-damage venom pool after giving the party time to move", () => {
useGameStore.getState().setPlayerPosition([0, 0]);
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
@@ -362,7 +441,10 @@ describe("Broodfang encounter", () => {
}));
const startingHp = useGameStore.getState().party[0].hp;
useGameStore.getState().tick(0.3);
useGameStore.getState().tick(VENOM_PURGE.poolArmDelay - 0.05);
expect(useGameStore.getState().party[0].hp).toBe(startingHp);
useGameStore.getState().tick(0.1);
const firstTickHp = useGameStore.getState().party[0].hp;
useGameStore.getState().tick(1);
const secondTickHp = useGameStore.getState().party[0].hp;
@@ -427,6 +509,23 @@ describe("PVE dual-boss encounter", () => {
useGameStore.getState().tick(1);
expect(useGameStore.getState().phase).toBe("victory");
});
it("drops a dispelled venom pool only for the spider that applied the debuff", () => {
useGameStore.setState((state) => ({
bossMotion: { ...state.bossMotion, nextMechanicAt: state.time, mechanicCount: 1 },
additionalBosses: state.additionalBosses.map((entry) => ({
...entry,
motion: { ...entry.motion, nextMechanicAt: 999 },
})),
}));
useGameStore.getState().tick(0.05);
const poisoned = useGameStore.getState().party.find((member) => member.debuffs.some((debuff) => debuff.name === "Widow Venom"))!;
useGameStore.getState().selectMember(poisoned.id);
expect(useGameStore.getState().castAbility("purify")).toBe(true);
expect(useGameStore.getState().bossMotion.hazards.filter((hazard) => hazard.kind === "venom_pool")).toHaveLength(1);
expect(useGameStore.getState().additionalBosses[0].motion.hazards.filter((hazard) => hazard.kind === "venom_pool")).toHaveLength(0);
});
});
describe("Roguelike ability buffs", () => {
@@ -742,6 +841,11 @@ describe("Rogue Trials", () => {
const defeatedInstanceId = defeated.bossInstanceId;
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().bossInstanceId).toBe(defeatedInstanceId);
const despawnAt = useGameStore.getState().boss.defeatedAt! + BOSS_DEATH_DESPAWN_SECONDS;
while (useGameStore.getState().time + 0.11 < despawnAt) useGameStore.getState().tick(0.1);
expect(useGameStore.getState().bossInstanceId).toBe(defeatedInstanceId);
useGameStore.getState().tick(0.12);
const replaced = useGameStore.getState();
expect(replaced.phase).toBe("combat");
expect(replaced.boss.hp).toBe(replaced.boss.maxHp);
@@ -750,7 +854,7 @@ describe("Rogue Trials", () => {
expect(new Set([replaced.boss.id, ...replaced.additionalBosses.map((entry) => entry.boss.id)])).toHaveLength(3);
});
it("ends endless mode when the party falls without losing its kill count", () => {
it("keeps endless mode running until the whole party falls", () => {
useGameStore.setState((state) => ({
round: 5,
phase: "victory",
@@ -763,6 +867,13 @@ describe("Rogue Trials", () => {
party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 0 } : member),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe("combat");
expect(useGameStore.getState().endlessBossKills).toBe(7);
useGameStore.setState((state) => ({
party: state.party.map((member) => ({ ...member, hp: 0 })),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe("defeat");
expect(useGameStore.getState().endlessBossKills).toBe(7);
+24 -11
View File
@@ -7,6 +7,8 @@ import {
upcomingMechanic,
} from "./bossMechanics";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { BOSS_DEATH_DESPAWN_SECONDS } from "./bossDeath";
import { normalizeEncounterBossIds } from "./bossSelection";
import { clampToArena, constrainBossMotion } from "./arena";
import { cloneMotion } from "./bosses/shared";
import { freshParty } from "./data";
@@ -14,6 +16,7 @@ import { distance } from "./geometry";
import { createClassInventory, HEALER_CLASSES } from "./healers";
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
import { isPartyWiped } from "./partyState";
import {
RUN_BUFFS,
bossHealthMultiplier,
@@ -150,8 +153,7 @@ export const BARRIER_DAMAGE_REDUCTION = 0.3;
const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): BossId[] => {
const requested = typeof bossIds === "string" ? [bossIds] : [...bossIds];
const unique = requested.filter((bossId, index) => requested.indexOf(bossId) === index).slice(0, 3);
return unique.length ? unique : ["bulldrome"];
return normalizeEncounterBossIds(requested);
};
function createEncounterMotion(bossId: BossId, index: number, count: number): BossMotionState {
@@ -515,6 +517,8 @@ export const useGameStore = create<GameState>((set, get) => ({
if (state.phase !== "combat") return false;
if (state.paused) return false;
if (state.activeCast) return false;
const healer = state.party.find((member) => member.id === "aelia");
if (!healer || healer.hp <= 0) return false;
const ability = HEALER_CLASSES[state.healerClassId].abilities[abilityId];
const manaCost = runAbilityManaCost(abilityId, ability.mana, state.runModifiers);
@@ -578,11 +582,11 @@ export const useGameStore = create<GameState>((set, get) => ({
const primaryNames = party[selectedIndex].debuffs.map((debuff) => debuff.name);
for (const index of cleanseIndexes) {
const target = party[index];
const dispelledNames = target.debuffs.map((debuff) => debuff.name);
const primaryDispel = handleBossDispel(state.boss.id, bossMotion, target.id, state.partyPositions[target.id], state.time, dispelledNames);
const dispelledDebuffs = target.debuffs;
const primaryDispel = handleBossDispel(state.boss.id, bossMotion, target.id, state.partyPositions[target.id], state.time, dispelledDebuffs);
bossMotion = primaryDispel.motion;
additionalBosses = additionalBosses.map((entry) => {
const dispel = handleBossDispel(entry.boss.id, entry.motion, target.id, state.partyPositions[target.id], state.time, dispelledNames);
const dispel = handleBossDispel(entry.boss.id, entry.motion, target.id, state.partyPositions[target.id], state.time, dispelledDebuffs);
return { ...entry, motion: dispel.motion };
});
party[index] = { ...party[index], debuffs: [] };
@@ -671,6 +675,8 @@ export const useGameStore = create<GameState>((set, get) => ({
let endlessBossKills = state.endlessBossKills;
let endlessSpawnSequence = state.endlessSpawnSequence;
if (!party.some((member) => member.id === "aelia" && member.hp > 0)) activeCast = null;
if (state.endlessMode) {
const slots: AdditionalBossState[] = [
{ instanceId: bossInstanceId, boss, motion: bossMotion },
@@ -678,10 +684,13 @@ export const useGameStore = create<GameState>((set, get) => ({
];
for (let index = 0; index < slots.length; index += 1) {
if (slots[index].boss.hp > 0) continue;
const defeatedAt = slots[index].boss.defeatedAt ?? oldTime;
slots[index].boss.defeatedAt = defeatedAt;
if (time < defeatedAt + BOSS_DEATH_DESPAWN_SECONDS) continue;
const activeBossIds = slots
.filter((entry, slotIndex) => slotIndex !== index && entry.boss.hp > 0)
.map((entry) => entry.boss.id);
const replacementId = selectUnseenBosses(1, [slots[index].boss.id, ...activeBossIds])[0];
const replacementId = selectUnseenBosses(1, [slots[index].boss.id, ...activeBossIds], Math.random, activeBossIds)[0];
endlessSpawnSequence += 1;
const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
const replacement = createEncounterBoss(
@@ -816,12 +825,16 @@ export const useGameStore = create<GameState>((set, get) => ({
const target = encounterBosses.find((entry) => entry.instanceId === event.targetInstanceId);
if (target) target.boss.hp = Math.max(0, target.boss.hp - event.amount);
}
for (const entry of encounterBosses) {
if (entry.boss.hp <= 0 && entry.boss.defeatedAt === undefined) entry.boss.defeatedAt = time;
}
boss = encounterBosses[0].boss;
bossInstanceId = encounterBosses[0].instanceId;
bossMotion = encounterBosses[0].motion;
additionalBosses = encounterBosses.slice(1);
const tank = party.find((member) => member.id === "brann")!;
const healer = party.find((member) => member.id === "aelia")!;
if (healer.hp <= 0) activeCast = null;
const partyWiped = isPartyWiped(party);
let phase: GamePhase = state.phase;
let runBuffInputUnlockAt = state.runBuffInputUnlockAt;
let newlyDefeatedBossCount = 0;
@@ -837,9 +850,9 @@ export const useGameStore = create<GameState>((set, get) => ({
}
}
endlessBossKills += newlyDefeatedBossCount;
if (state.endlessMode && (tank.hp <= 0 || healer.hp <= 0)) {
if (state.endlessMode && partyWiped) {
phase = "defeat";
combatLog = addLog(combatLog, time, `${endlessBossKills} endless bosses defeated before the formation fell.`, "danger");
combatLog = addLog(combatLog, time, `${endlessBossKills} endless bosses defeated before the party fell.`, "danger");
} else if (state.endlessMode) {
phase = "combat";
} else if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
@@ -847,9 +860,9 @@ export const useGameStore = create<GameState>((set, get) => ({
phase = state.runMode !== "encounter" && !rogueTrialsComplete ? "intermission" : "victory";
if (phase === "intermission") runBuffInputUnlockAt = Date.now() + RUN_BUFF_INPUT_LOCK_MS;
combatLog = addLog(combatLog, time, `${encounterBosses.map((entry) => entry.boss.name).join(" and ")} fall. Party survives.`, "good");
} else if (tank.hp <= 0 || healer.hp <= 0) {
} else if (partyWiped) {
phase = "defeat";
combatLog = addLog(combatLog, time, tank.hp <= 0 ? `Brann falls. ${boss.name} breaks formation.` : `${healer.name} falls. Healing ends.`, "danger");
combatLog = addLog(combatLog, time, `The party falls. ${boss.name} claims the vault.`, "danger");
}
set({
+5 -1
View File
@@ -27,7 +27,8 @@ export type BossId =
| "bristlequake-boar"
| "moonfang-wolf"
| "frostmaw-yeti"
| "rimeclaw-yeti";
| "rimeclaw-yeti"
| "gravehorn-triceratops";
export type BossMechanicId =
| "basic-melee"
| "bull-charge"
@@ -198,6 +199,7 @@ export interface Debuff {
expiresAt: number;
nextTickAt: number;
tickDamage: number;
sourceBossId?: BossId;
}
export interface PartyMember {
@@ -221,6 +223,8 @@ export interface BossState {
maxHp: number;
hp: number;
nextMeleeAt: number;
/** Simulation time when health first reached zero. Used for delayed endless replacement. */
defeatedAt?: number;
}
export interface BossMotionState {
+94 -32
View File
@@ -20,6 +20,7 @@
/* Android lays out at logical CSS size, not AMOLED framebuffer resolution. */
--thor-main-css-width: 960px;
--thor-secondary-width-ratio: 64.583333%;
--thor-top-bottom-overscan: 8px;
}
* {
@@ -1362,6 +1363,13 @@ button:focus-visible {
background: #030706;
}
/* Thor top panel hides a thin lower edge in immersive mode. Keep content and
render surface above that measured strip, plus any Android-reported inset. */
.native-platform .surface-slot.top-slot,
.native-platform[data-display-surface="top"] .dedicated-display-surface {
padding-bottom: max(var(--thor-top-bottom-overscan), env(safe-area-inset-bottom, 0px));
}
.native-platform .surface-slot.is-active {
display: grid;
}
@@ -1668,32 +1676,36 @@ button:focus-visible {
.login-panel .front-secondary { min-height: 40px; padding-top: 6px; padding-bottom: 6px; }
.login-surface > .front-notice { position: absolute; right: 44px; bottom: 83px; width: 300px; }
.login-surface > .controller-legend { position: absolute; right: 44px; bottom: 47px; }
.login-context { padding: 0; }
.login-context > .front-brand { margin: 22px 5.5% 0; }
.offline-promise { margin: 32px 7% 0; }
.context-kicker { color: var(--gold); font-size: 9px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; }
.offline-promise ol { display: grid; gap: 13px; margin: 14px 0 0; padding: 0; list-style: none; }
.offline-promise li { display: grid; grid-template-columns: 35px 1fr; align-items: center; gap: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
.offline-promise li > b { color: #536a61; font-family: "Cinzel", serif; font-size: 17px; }
.offline-promise li > span { display: grid; }
.offline-promise li strong { font-size: clamp(11px, 2.35cqw, 14px); letter-spacing: 0.03em; }
.offline-promise li small { color: #748a81; font-size: clamp(8px, 1.7cqw, 10px); }
.device-route { position: absolute; right: 7%; bottom: 25px; left: 7%; display: flex; align-items: center; justify-content: center; gap: 13px; color: #70857c; font-size: 9px; font-weight: 700; letter-spacing: 0.1em; }
.device-route i { color: var(--gold); font-style: normal; }
.device-route b { padding: 7px 10px; border: 1px solid #486159; color: #b8c9c2; background: #0b1b17; font-size: 8px; }
.login-save-context { padding: 0 5.5% 18px; }
.login-save-context .context-header { margin: 0 -5.8%; }
.login-save-list { display: grid; gap: 9px; margin-top: 16px; }
.login-save-list article { min-height: 105px; display: grid; grid-template-columns: 30px 52px minmax(0, 1fr) auto; align-items: center; gap: 11px; padding: 11px 13px; border: 1px solid rgba(150,190,175,.18); border-left: 2px solid rgba(232,200,114,.52); background: linear-gradient(100deg, rgba(23,48,40,.66), rgba(7,18,15,.78)); }
.login-save-list article.is-empty { grid-template-columns: 30px minmax(0, 1fr); border-left-color: #425850; background: rgba(6,16,13,.62); }
.login-save-list article > b { color: #61776e; font-family: "Cinzel", serif; font-size: 15px; font-weight: 500; }
.login-save-avatar { width: 48px; height: 48px; display: grid; place-items: center; border: 1px solid rgba(232,200,114,.48); border-radius: 50%; color: var(--gold-strong); background: radial-gradient(circle at 50% 28%, #2c493f, #0b1a17); font: 20px "Cinzel", serif; }
.login-save-list article > span { min-width: 0; display: grid; }
.login-save-list article small { color: #6d8279; font-size: clamp(7px, 1.45cqw, 9px); font-style: normal; letter-spacing: .08em; text-transform: uppercase; }
.login-save-list article span > strong { overflow: hidden; font: 500 clamp(13px, 2.8cqw, 17px) "Cinzel", serif; text-overflow: ellipsis; white-space: nowrap; }
.login-save-list article em { overflow: hidden; color: #8fa49b; font-size: clamp(8px, 1.7cqw, 10px); font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
.login-save-list time { min-width: 86px; display: grid; justify-items: end; }
.login-save-list time strong { color: var(--gold); font-size: clamp(9px, 1.9cqw, 11px); }
.login-empty-copy { grid-column: 2 / -1; }
.login-save-footer { position: absolute; right: 5.5%; bottom: 19px; left: 5.5%; display: flex; justify-content: space-between; padding-top: 10px; border-top: 1px solid var(--line); color: #60756d; font-size: clamp(6px, 1.3cqw, 8px); letter-spacing: .08em; text-transform: uppercase; }
.login-save-footer b { color: #78988c; }
/* Save management */
.save-surface { padding: 0 30px; }
.save-slot-grid { height: 385px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; align-items: center; }
.save-slot { position: relative; height: 300px; display: flex; flex-direction: column; align-items: center; padding: 18px 16px; overflow: hidden; text-align: center; transition: transform 140ms ease, border-color 140ms ease; }
.save-slot-grid { height: 326px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; align-items: center; }
.save-slot { position: relative; height: 265px; display: flex; flex-direction: column; align-items: center; padding: 14px 16px; overflow: hidden; text-align: center; transition: transform 140ms ease, border-color 140ms ease; }
.save-slot::before { position: absolute; inset: 0; content: ""; background: radial-gradient(circle at 50% 36%, rgba(95,177,154,0.13), transparent 42%), linear-gradient(180deg, rgba(25, 48, 41, 0.55), rgba(7, 18, 15, 0.86)); }
.save-slot > * { position: relative; }
.save-slot:hover,
.save-slot.is-selected { border-color: rgba(232,200,114,0.68); transform: translateY(-4px); }
.save-slot.is-selected::after { position: absolute; inset: 5px; border: 1px solid rgba(232,200,114,0.18); content: ""; pointer-events: none; }
.slot-number { align-self: stretch; padding-bottom: 10px; border-bottom: 1px solid var(--line); color: #83978f; font-size: 9px; font-weight: 700; letter-spacing: 0.15em; text-align: left; text-transform: uppercase; }
.slot-portrait { width: 75px; height: 75px; display: grid; place-items: center; margin-top: 19px; border: 1px solid rgba(232,200,114,0.55); border-radius: 50%; color: var(--gold-strong); background: radial-gradient(circle at 50% 30%, #304d42, #0c1c18); font-family: "Cinzel", serif; font-size: 31px; box-shadow: 0 0 25px rgba(81,176,151,0.13); }
.slot-number { align-self: stretch; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-bottom: 10px; border-bottom: 1px solid var(--line); color: #83978f; font-size: 9px; font-weight: 700; letter-spacing: 0.15em; text-align: left; text-transform: uppercase; }
.slot-number b { padding: 2px 4px; color: #87d9b9; background: rgba(62,133,109,.17); font-size: 6px; letter-spacing: .08em; white-space: nowrap; }
.slot-portrait { width: 68px; height: 68px; display: grid; place-items: center; margin-top: 14px; border: 1px solid rgba(232,200,114,0.55); border-radius: 50%; color: var(--gold-strong); background: radial-gradient(circle at 50% 30%, #304d42, #0c1c18); font-family: "Cinzel", serif; font-size: 29px; box-shadow: 0 0 25px rgba(81,176,151,0.13); }
.slot-portrait i { position: absolute; right: -3px; bottom: 0; width: 23px; height: 23px; display: grid; place-items: center; border-radius: 50%; color: #192019; background: var(--gold); font-size: 10px; font-style: normal; }
.slot-name { display: grid; margin-top: 12px; }
.slot-name strong { font-family: "Cinzel", serif; font-size: 17px; font-weight: 500; }
@@ -1708,8 +1720,14 @@ button:focus-visible {
.empty-slot b { color: #6f8c81; font-size: 36px; font-weight: 300; }
.empty-slot strong { font-family: "Cinzel", serif; font-size: 14px; font-weight: 500; }
.empty-slot small { color: #5e736b; font-size: 8px; text-transform: uppercase; }
.save-footer { display: flex; align-items: center; justify-content: space-between; padding: 8px 2px; border-top: 1px solid var(--line); color: #6e847b; font-size: 8px; letter-spacing: 0.08em; text-transform: uppercase; }
.save-top-actions { display: grid; grid-template-columns: 1.75fr repeat(5, minmax(0, 1fr)); gap: 7px; }
.save-top-actions button { min-width: 0; min-height: 55px; display: grid; align-content: center; padding: 7px 9px; text-align: left; }
.save-top-actions button:not(.front-primary) strong { overflow: hidden; font-size: 9px; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; }
.save-top-actions button:not(.front-primary) small { overflow: hidden; color: #6d827a; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.save-top-actions .danger-link strong { color: #ed8c78; }
.save-footer { min-height: 31px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 16px; padding: 7px 2px 5px; border-top: 1px solid var(--line); color: #6e847b; font-size: 8px; letter-spacing: 0.08em; text-transform: uppercase; }
.save-footer > span b { color: #72cba9; }
.save-top-notice { overflow: hidden; color: #82978f; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.front-dialog { position: absolute; inset: 0; z-index: 5; display: grid; place-content: center; padding: 0 calc(50% - 190px); background: rgba(2,8,7,0.85); backdrop-filter: blur(7px); text-align: center; }
.front-dialog::before { position: absolute; top: 80px; right: calc(50% - 210px); bottom: 70px; left: calc(50% - 210px); z-index: -1; border: 1px solid rgba(232,200,114,0.35); border-top: 2px solid var(--gold); content: ""; background: #0b1916; box-shadow: 0 22px 60px rgba(0,0,0,0.6); }
.front-dialog > span { color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; }
@@ -1725,6 +1743,24 @@ button:focus-visible {
.dialog-actions button { min-width: 110px; padding: 10px 14px; font-weight: 700; }
.dialog-actions button small { display: block; color: #748a81; font-size: 7px; text-transform: uppercase; }
.dialog-actions button.is-danger { border-color: #d76551; color: #fff; background: #8e3025; }
.front-dialog.version-dialog { grid-template-columns: minmax(0, 630px); padding-right: calc(50% - 315px); padding-left: calc(50% - 315px); }
.front-dialog.version-dialog::before { top: 45px; right: calc(50% - 345px); bottom: 45px; left: calc(50% - 345px); }
.version-choice-dialog { width: 100%; }
.version-choice-dialog > span { color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: .15em; text-transform: uppercase; }
.version-choice-dialog h2 { margin: 5px 0; }
.version-choice-dialog > p { font-size: 10px; }
.version-comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 13px; text-align: left; }
.version-comparison article { display: grid; gap: 3px; padding: 11px 13px; border: 1px solid var(--line); background: rgba(5,16,13,.75); }
.version-comparison article.is-newer { border-color: rgba(111,208,168,.55); box-shadow: inset 3px 0 #6fd0a8; background: rgba(29,74,59,.2); }
.version-comparison header { display: flex; justify-content: space-between; color: #788d85; font-size: 7px; letter-spacing: .11em; text-transform: uppercase; }
.version-comparison header b { color: #70d0a8; }
.version-comparison article > strong { font: 500 13px "Cinzel", serif; }
.version-comparison time { color: var(--gold-strong); font-size: 11px; }
.version-comparison article > small { color: #6f847c; font-size: 8px; }
.version-actions { display: grid; grid-template-columns: 1.35fr 1.35fr .7fr; }
.version-actions button { min-width: 0; min-height: 47px; display: grid; align-content: center; text-align: left; }
.version-actions button > span { font-size: 10px; }
.version-choice-error { margin-top: 8px; padding: 6px 9px; border-left: 2px solid #e5634d; color: #f0a594; background: rgba(95,31,23,.2); font-size: 8px; text-align: left; }
.save-context { padding: 0 5.5% 18px; }
.save-context .context-header { margin: 0 -5.8%; }
.selected-save-summary { min-height: 105px; display: grid; grid-template-columns: 66px 1fr; align-items: center; gap: 15px; padding: 16px 0 11px; border-bottom: 1px solid var(--line); }
@@ -1735,18 +1771,21 @@ button:focus-visible {
.selected-save-summary h2 { margin: 2px 0 0; font-family: "Cinzel", serif; font-size: clamp(16px, 3.4cqw, 21px); font-weight: 500; }
.selected-save-summary p { margin: 2px 0; overflow: hidden; color: #9dafA8; font-size: clamp(8px, 1.9cqw, 11px); text-overflow: ellipsis; white-space: nowrap; }
.selected-save-summary time { color: #60756d; font-size: 8px; }
.online-record { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; border: 1px solid rgba(89,181,151,0.26); color: #9eb1aa; background: rgba(39,96,78,0.13); font-size: clamp(8px, 1.7cqw, 10px); }
.online-record span { display: flex; gap: 7px; }
.online-record b { color: #6ad0a8; font-size: 7px; letter-spacing: 0.1em; }
.online-record time { color: #6f847c; font-size: 8px; }
.save-actions { display: grid; gap: 8px; margin-top: 10px; }
.save-actions .front-primary { min-height: 42px; }
.sync-actions,
.record-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
.sync-actions button,
.record-actions button { min-height: 34px; padding: 6px 7px; font-size: clamp(8px, 1.7cqw, 10px); font-weight: 700; }
.record-actions { grid-template-columns: 1fr 1fr 0.7fr; }
.record-actions .danger-link { color: #ed8c78; }
.save-dossier-stats { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 13px; border: 1px solid var(--line); background: rgba(6,17,14,.68); }
.save-dossier-stats > span { min-width: 0; display: grid; padding: 11px 10px; border-right: 1px solid var(--line); }
.save-dossier-stats > span:last-child { border: 0; }
.save-dossier-stats small,
.save-dossier-records small { color: #62776f; font-size: clamp(6px, 1.35cqw, 8px); letter-spacing: .07em; text-transform: uppercase; }
.save-dossier-stats strong { color: var(--gold-strong); font: 500 clamp(15px, 3.1cqw, 19px) "Cinzel", serif; }
.save-dossier-stats em { overflow: hidden; color: #80958d; font-size: clamp(7px, 1.5cqw, 9px); font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
.save-dossier-records { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
.save-dossier-records > span { display: grid; padding: 9px 11px; border-left: 2px solid rgba(232,200,114,.45); background: rgba(46,40,21,.24); }
.save-dossier-records b { color: #c7d6d0; font-size: clamp(9px, 1.9cqw, 11px); }
.save-copy-state { display: grid; gap: 6px; margin-top: 12px; }
.save-copy-state > span { display: grid; grid-template-columns: 8px auto 1fr; align-items: center; gap: 7px; padding: 7px 9px; border: 1px solid var(--line); color: #90a49c; font-size: clamp(7px, 1.55cqw, 9px); text-transform: uppercase; }
.save-copy-state i { width: 6px; height: 6px; border-radius: 50%; background: #455750; }
.save-copy-state i.is-present { background: #6fd0a8; box-shadow: 0 0 8px rgba(111,208,168,.4); }
.save-copy-state b { justify-self: end; color: #647970; font-size: clamp(6px, 1.35cqw, 8px); font-weight: 600; }
.front-notice.is-lower { margin-top: auto; font-size: clamp(7px, 1.55cqw, 9px); }
/* Main menu */
@@ -2067,7 +2106,7 @@ button:focus-visible {
.profile-header { grid-template-columns: 125px minmax(0, 1fr) auto auto; }
.profile-view-tabs { gap: 2px; }
.profile-view-tabs button { min-height: 22px; padding: 3px 5px; font-size: 5px; }
.save-slot-grid { height: calc(100% - 75px); gap: 5px; }
.save-slot-grid { height: calc(100% - 125px); gap: 5px; }
.save-slot { height: 84%; padding: 7px 5px; }
.slot-portrait { width: 36px; height: 36px; margin-top: 7px; font-size: 14px; }
.slot-portrait i { width: 13px; height: 13px; font-size: 5px; }
@@ -2077,8 +2116,31 @@ button:focus-visible {
.slot-meta b { font-size: 7px; }
.empty-slot b { font-size: 19px; }
.empty-slot strong { font-size: 8px; }
.save-footer { position: absolute; right: 12px; bottom: 3px; left: 12px; }
.save-top-actions { grid-template-columns: 1.5fr repeat(5, minmax(0, 1fr)); gap: 3px; }
.save-top-actions button { min-height: 38px; padding: 3px 4px; }
.save-top-actions .front-primary { min-height: 38px; }
.save-top-actions .front-primary span,
.save-top-actions button:not(.front-primary) strong { font-size: 6px; }
.save-top-actions .front-primary small,
.save-top-actions button:not(.front-primary) small { display: none; }
.save-footer { min-height: 18px; gap: 5px; padding: 3px 1px; font-size: 5px; }
.save-top-notice { display: none; }
.save-footer .controller-legend { display: none; }
.front-dialog.version-dialog { grid-template-columns: minmax(0, 1fr); padding-right: 8%; padding-left: 8%; }
.front-dialog.version-dialog::before { top: 16px; right: 6%; bottom: 16px; left: 6%; }
.version-choice-dialog > span { font-size: 7px; }
.version-choice-dialog h2 { margin: 3px 0; font-size: 18px; }
.version-choice-dialog > p { font-size: 8px; }
.version-comparison { gap: 6px; margin-top: 8px; }
.version-comparison article { gap: 2px; padding: 8px 9px; }
.version-comparison header { font-size: 6px; }
.version-comparison article > strong { font-size: 10px; }
.version-comparison time { font-size: 8px; }
.version-comparison article > small { font-size: 6px; }
.version-actions { gap: 4px; margin-top: 7px; }
.version-actions button { min-width: 0; min-height: 38px; padding: 5px 7px; }
.version-actions button > span { font-size: 8px; }
.version-actions button small { font-size: 6px; }
.home-header { height: 35px; }
.home-header > span, .home-header > i { font-size: 5px; }
.mode-grid { grid-template-rows: repeat(2, 43px); gap: 5px; margin-top: 6px; }