Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed34c2503c | ||
|
|
8e48bb4fb6 | ||
|
|
fb41147197 | ||
|
|
aa9f5eff20 | ||
|
|
18c416d4d2 |
@@ -12,3 +12,4 @@ vite.config.d.ts
|
|||||||
!.env.example
|
!.env.example
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
/public/basis/
|
||||||
|
|||||||
+11
-2
@@ -1,13 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "i-want-to-heal",
|
"name": "i-want-to-heal",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.7",
|
"version": "0.1.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"predev": "pnpm assets:sync-basis-transcoder",
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"dev:api": "HOST=127.0.0.1 PORT=4174 node server/production.mjs",
|
"dev:api": "HOST=127.0.0.1 PORT=4174 node server/production.mjs",
|
||||||
"db:backup": "node scripts/backup-db.mjs",
|
"db:backup": "node scripts/backup-db.mjs",
|
||||||
"db:init": "node scripts/init-db.mjs",
|
"db:init": "node scripts/init-db.mjs",
|
||||||
|
"prebuild": "pnpm assets:sync-basis-transcoder",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"android:sync": "pnpm run build && cap sync android",
|
"android:sync": "pnpm run build && cap sync android",
|
||||||
"android:sync:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com pnpm run android:sync",
|
"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",
|
"publish:gitea": "python3 scripts/publish_gitea.py",
|
||||||
"test": "vitest run && node --test server/game-api.test.mjs",
|
"test": "vitest run && node --test server/game-api.test.mjs",
|
||||||
"test:watch": "vitest",
|
"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: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": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
@@ -29,17 +35,20 @@
|
|||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"three": "^0.179.1",
|
"three": "^0.179.1",
|
||||||
|
"three-stdlib": "2.36.1",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "8.4.1",
|
"@capacitor/cli": "8.4.1",
|
||||||
"@gltf-transform/core": "^4.4.1",
|
"@gltf-transform/core": "^4.4.1",
|
||||||
"@gltf-transform/extensions": "^4.4.1",
|
"@gltf-transform/extensions": "^4.4.1",
|
||||||
|
"@gltf-transform/functions": "4.4.1",
|
||||||
"@types/react": "^19.1.10",
|
"@types/react": "^19.1.10",
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.1.7",
|
||||||
"@types/three": "^0.179.0",
|
"@types/three": "^0.179.0",
|
||||||
"@vitejs/plugin-react": "^5.0.2",
|
"@vitejs/plugin-react": "^5.0.2",
|
||||||
"meshoptimizer": "^1.2.0",
|
"meshoptimizer": "^1.2.0",
|
||||||
|
"sharp": "0.34.5",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^7.1.3",
|
"vite": "^7.1.3",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
|
|||||||
Generated
+379
@@ -29,6 +29,9 @@ importers:
|
|||||||
three:
|
three:
|
||||||
specifier: ^0.179.1
|
specifier: ^0.179.1
|
||||||
version: 0.179.1
|
version: 0.179.1
|
||||||
|
three-stdlib:
|
||||||
|
specifier: 2.36.1
|
||||||
|
version: 2.36.1(three@0.179.1)
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.8
|
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))
|
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':
|
'@gltf-transform/extensions':
|
||||||
specifier: ^4.4.1
|
specifier: ^4.4.1
|
||||||
version: 4.4.1
|
version: 4.4.1
|
||||||
|
'@gltf-transform/functions':
|
||||||
|
specifier: 4.4.1
|
||||||
|
version: 4.4.1
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.1.10
|
specifier: ^19.1.10
|
||||||
version: 19.2.17
|
version: 19.2.17
|
||||||
@@ -57,6 +63,9 @@ importers:
|
|||||||
meshoptimizer:
|
meshoptimizer:
|
||||||
specifier: ^1.2.0
|
specifier: ^1.2.0
|
||||||
version: 1.2.0
|
version: 1.2.0
|
||||||
|
sharp:
|
||||||
|
specifier: 0.34.5
|
||||||
|
version: 0.34.5
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ~5.8.3
|
specifier: ~5.8.3
|
||||||
version: 5.8.3
|
version: 5.8.3
|
||||||
@@ -172,6 +181,9 @@ packages:
|
|||||||
'@dimforge/rapier3d-compat@0.12.0':
|
'@dimforge/rapier3d-compat@0.12.0':
|
||||||
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.11.2':
|
||||||
|
resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.28.1':
|
'@esbuild/aix-ppc64@0.28.1':
|
||||||
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -334,6 +346,162 @@ packages:
|
|||||||
'@gltf-transform/extensions@4.4.1':
|
'@gltf-transform/extensions@4.4.1':
|
||||||
resolution: {integrity: sha512-dZZ9D/NdpNeJUmQKExtISYtd3W6OxU4njk8UI3IKm6j97uVskYQ24BNi2YP40uUpdWPiREsS/DhjNhWAbGU/1A==}
|
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':
|
'@ionic/cli-framework-output@2.2.8':
|
||||||
resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==}
|
resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
@@ -601,6 +769,9 @@ packages:
|
|||||||
'@types/fs-extra@8.1.5':
|
'@types/fs-extra@8.1.5':
|
||||||
resolution: {integrity: sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==}
|
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':
|
'@types/node@26.1.1':
|
||||||
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
|
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
|
||||||
|
|
||||||
@@ -791,6 +962,9 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
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:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -811,6 +985,10 @@ packages:
|
|||||||
detect-gpu@5.0.70:
|
detect-gpu@5.0.70:
|
||||||
resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==}
|
resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==}
|
||||||
|
|
||||||
|
detect-libc@2.1.2:
|
||||||
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
draco3d@1.5.7:
|
draco3d@1.5.7:
|
||||||
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
|
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
|
||||||
|
|
||||||
@@ -908,6 +1086,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==}
|
resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==}
|
||||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
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:
|
is-docker@2.2.1:
|
||||||
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
|
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1020,6 +1204,18 @@ packages:
|
|||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
hasBin: true
|
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:
|
node-releases@2.0.50:
|
||||||
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
|
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1139,6 +1335,10 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
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:
|
shebang-command@2.0.0:
|
||||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1275,6 +1475,9 @@ packages:
|
|||||||
undici-types@8.3.0:
|
undici-types@8.3.0:
|
||||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||||
|
|
||||||
|
uniq@1.0.1:
|
||||||
|
resolution: {integrity: sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==}
|
||||||
|
|
||||||
universalify@2.0.1:
|
universalify@2.0.1:
|
||||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
@@ -1597,6 +1800,11 @@ snapshots:
|
|||||||
|
|
||||||
'@dimforge/rapier3d-compat@0.12.0': {}
|
'@dimforge/rapier3d-compat@0.12.0': {}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.11.2':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.28.1':
|
'@esbuild/aix-ppc64@0.28.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1684,6 +1892,111 @@ snapshots:
|
|||||||
'@gltf-transform/core': 4.4.1
|
'@gltf-transform/core': 4.4.1
|
||||||
ktx-parse: 1.1.0
|
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':
|
'@ionic/cli-framework-output@2.2.8':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ionic/utils-terminal': 2.3.5
|
'@ionic/utils-terminal': 2.3.5
|
||||||
@@ -1958,6 +2271,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 26.1.1
|
'@types/node': 26.1.1
|
||||||
|
|
||||||
|
'@types/ndarray@1.0.14': {}
|
||||||
|
|
||||||
'@types/node@26.1.1':
|
'@types/node@26.1.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 8.3.0
|
undici-types: 8.3.0
|
||||||
@@ -2146,6 +2461,10 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
cwise-compiler@1.1.3:
|
||||||
|
dependencies:
|
||||||
|
uniq: 1.0.1
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -2158,6 +2477,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
webgl-constants: 1.1.1
|
webgl-constants: 1.1.1
|
||||||
|
|
||||||
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
draco3d@1.5.7: {}
|
draco3d@1.5.7: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.389: {}
|
electron-to-chromium@1.5.389: {}
|
||||||
@@ -2259,6 +2580,10 @@ snapshots:
|
|||||||
|
|
||||||
ini@4.1.3: {}
|
ini@4.1.3: {}
|
||||||
|
|
||||||
|
iota-array@1.0.0: {}
|
||||||
|
|
||||||
|
is-buffer@1.1.6: {}
|
||||||
|
|
||||||
is-docker@2.2.1: {}
|
is-docker@2.2.1: {}
|
||||||
|
|
||||||
is-fullwidth-code-point@3.0.0: {}
|
is-fullwidth-code-point@3.0.0: {}
|
||||||
@@ -2357,6 +2682,27 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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: {}
|
node-releases@2.0.50: {}
|
||||||
|
|
||||||
open@8.4.2:
|
open@8.4.2:
|
||||||
@@ -2481,6 +2827,37 @@ snapshots:
|
|||||||
|
|
||||||
semver@7.8.5: {}
|
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:
|
shebang-command@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 3.0.0
|
shebang-regex: 3.0.0
|
||||||
@@ -2609,6 +2986,8 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@8.3.0: {}
|
undici-types@8.3.0: {}
|
||||||
|
|
||||||
|
uniq@1.0.1: {}
|
||||||
|
|
||||||
universalify@2.0.1: {}
|
universalify@2.0.1: {}
|
||||||
|
|
||||||
untildify@4.0.0: {}
|
untildify@4.0.0: {}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: true
|
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()
|
||||||
@@ -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.");
|
||||||
@@ -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).`);
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
@@ -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.
|
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.
|
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.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||||
Binary file not shown.
+97
-17
@@ -1,15 +1,22 @@
|
|||||||
import { useFrame } from "@react-three/fiber";
|
import { useFrame } from "@react-three/fiber";
|
||||||
import { useGLTF } from "@react-three/drei";
|
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 * as THREE from "three";
|
||||||
import { ARENA_CENTER, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "../game/arena";
|
import { ARENA_CENTER, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "../game/arena";
|
||||||
import { bossRoomFor, type BossRoomDefinition, type BossRoomFloor } from "../game/bossRooms";
|
import { bossRoomFor, type BossRoomDefinition, type BossRoomFloor } from "../game/bossRooms";
|
||||||
import { useGameStore } from "../game/store";
|
import { useGameStore } from "../game/store";
|
||||||
|
import { LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider";
|
||||||
|
|
||||||
const ROOM_CENTER_Z = ARENA_CENTER[1];
|
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_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_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_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 = {
|
type ArenaFixture = {
|
||||||
position: readonly [number, number, number];
|
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[]>;
|
return mesh as THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DungeonAssetInstances({
|
function DungeonMeshInstances({
|
||||||
url,
|
mesh,
|
||||||
fixtures,
|
fixtures,
|
||||||
tint,
|
tint,
|
||||||
opacity = 1,
|
opacity = 1,
|
||||||
colors,
|
colors,
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
mesh: THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
|
||||||
fixtures: readonly ArenaFixture[];
|
fixtures: readonly ArenaFixture[];
|
||||||
tint: string;
|
tint: string;
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
colors?: readonly THREE.Color[];
|
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 sourceMaterial = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
|
||||||
const material = useMemo(() => {
|
const material = useMemo(() => {
|
||||||
const next = sourceMaterial.clone();
|
const next = sourceMaterial.clone();
|
||||||
@@ -156,20 +161,77 @@ function DungeonAssetInstances({
|
|||||||
return <instancedMesh ref={instances} args={[mesh.geometry, material, fixtures.length]} castShadow receiveShadow />;
|
return <instancedMesh ref={instances} args={[mesh.geometry, material, fixtures.length]} castShadow receiveShadow />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArenaArchitecture({ room }: { room: BossRoomDefinition }) {
|
function LegacyDungeonAssetInstances({
|
||||||
const walls = useMemo(() => ARENA_WALL_SEGMENTS.map((fixture) => ({
|
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,
|
...fixture,
|
||||||
scaleY: room.wallHeight / 4,
|
scaleY: room.wallHeight / 4,
|
||||||
})), [room.wallHeight]);
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegacyArenaArchitecture({ room }: { room: BossRoomDefinition }) {
|
||||||
|
const walls = useMemo(() => arenaWalls(room), [room]);
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
<DungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
|
<LegacyDungeonAssetInstances 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} />
|
<LegacyDungeonAssetInstances 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_TORCH_URL} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
|
||||||
</group>
|
</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 }) {
|
function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
|
||||||
const walls = useRef<THREE.Group>(null);
|
const walls = useRef<THREE.Group>(null);
|
||||||
const previousCameraPosition = useRef<THREE.Vector3 | null>(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 (
|
return (
|
||||||
<Suspense fallback={<RoomWallFallback room={room} />}>
|
<Suspense fallback={<RoomWallFallback room={room} />}>
|
||||||
<ArenaArchitecture room={room} />
|
<LegacyArenaArchitecture room={room} />
|
||||||
</Suspense>
|
</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 }) {
|
function RoomMarks({ room }: { room: BossRoomDefinition }) {
|
||||||
const rays = useRef<THREE.InstancedMesh>(null);
|
const rays = useRef<THREE.InstancedMesh>(null);
|
||||||
const pattern = ROOM_PATTERNS[room.floor];
|
const pattern = ROOM_PATTERNS[room.floor];
|
||||||
@@ -345,6 +423,8 @@ export function BossRoom() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true);
|
if (LEGACY_GAME_ASSETS_FORCED) {
|
||||||
useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true);
|
useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true);
|
||||||
useGLTF.preload(KAYKIT_DUNGEON_TORCH_URL, false, true);
|
useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true);
|
||||||
|
useGLTF.preload(KAYKIT_DUNGEON_TORCH_URL, false, true);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Canvas } from "@react-three/fiber";
|
import { Canvas } from "@react-three/fiber";
|
||||||
import { useGLTF } from "@react-three/drei";
|
|
||||||
import { Suspense, useMemo } from "react";
|
import { Suspense, useMemo } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||||
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||||
import { ALTERNATE_BOSS_CONFIG, bossVisualUrl } from "../game/bossVisuals";
|
import { ALTERNATE_BOSS_CONFIG, bossVisualUrl } from "../game/bossVisuals";
|
||||||
import type { BossId } from "../game/types";
|
import type { BossId } from "../game/types";
|
||||||
|
import { GameAssetProvider, LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider";
|
||||||
|
|
||||||
function PortraitModel({ bossId }: { bossId: BossId }) {
|
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 model = useMemo(() => {
|
||||||
const clone = cloneSkeleton(gltf.scene);
|
const clone = cloneSkeleton(gltf.scene);
|
||||||
clone.updateMatrixWorld(true);
|
clone.updateMatrixWorld(true);
|
||||||
@@ -49,7 +49,9 @@ export function BossTrophyPortrait({ bossId }: { bossId: BossId }) {
|
|||||||
<ambientLight intensity={1.9} />
|
<ambientLight intensity={1.9} />
|
||||||
<directionalLight color="#fff3cf" intensity={3.2} position={[3, 5, 4]} />
|
<directionalLight color="#fff3cf" intensity={3.2} position={[3, 5, 4]} />
|
||||||
<directionalLight color={boss.accent} intensity={2.1} position={[-4, 2, -2]} />
|
<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>
|
</Canvas>
|
||||||
<span aria-hidden="true">{boss.icon}</span>
|
<span aria-hidden="true">{boss.icon}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
|
|||||||
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
|
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
|
||||||
const mana = useGameStore((state) => state.mana);
|
const mana = useGameStore((state) => state.mana);
|
||||||
const phase = useGameStore((state) => state.phase);
|
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 selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
|
||||||
const activeCast = useGameStore((state) => state.activeCast);
|
const activeCast = useGameStore((state) => state.activeCast);
|
||||||
const castAbility = useGameStore((state) => state.castAbility);
|
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 globalRemaining = Math.max(0, globalCooldownUntil - time);
|
||||||
const noDispel = abilityId === "purify" && selected.debuffs.length === 0;
|
const noDispel = abilityId === "purify" && selected.debuffs.length === 0;
|
||||||
const invalidTarget = ability.targeting === "ally" && selected.hp <= 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` : ""}`;
|
const resourceCopy = `${manaCost ? `${manaCost} mana` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+167
-53
@@ -1,6 +1,7 @@
|
|||||||
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
|
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { buildCollections, MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName } from "../frontend/data";
|
import { buildCollections, MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName } from "../frontend/data";
|
||||||
import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
|
import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
|
||||||
|
import { resolveSaveContinuation, saveVersionsMatch } from "../frontend/saveContinuation";
|
||||||
import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
||||||
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
|
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
|
||||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
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>;
|
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() {
|
function LoginScreen() {
|
||||||
const restoreSession = useFrontendStore((state) => state.restoreSession);
|
const restoreSession = useFrontendStore((state) => state.restoreSession);
|
||||||
const signIn = useFrontendStore((state) => state.signIn);
|
const signIn = useFrontendStore((state) => state.signIn);
|
||||||
const createAccount = useFrontendStore((state) => state.createAccount);
|
const createAccount = useFrontendStore((state) => state.createAccount);
|
||||||
const continueOffline = useFrontendStore((state) => state.continueOffline);
|
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 notice = useFrontendStore((state) => state.notice);
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -101,7 +137,7 @@ function LoginScreen() {
|
|||||||
{ id: "password", run: () => passwordRef.current?.focus() },
|
{ id: "password", run: () => passwordRef.current?.focus() },
|
||||||
{ id: "sign-in", run: () => { void signIn(username, password); } },
|
{ id: "sign-in", run: () => { void signIn(username, password); } },
|
||||||
{ id: "create-account", run: () => { void createAccount(username, password); } },
|
{ id: "create-account", run: () => { void createAccount(username, password); } },
|
||||||
{ id: "offline", run: continueOffline },
|
{ id: "continue", run: continueOffline },
|
||||||
], [continueOffline, createAccount, password, signIn, username]);
|
], [continueOffline, createAccount, password, signIn, username]);
|
||||||
const controller = useMenuController(actions);
|
const controller = useMenuController(actions);
|
||||||
|
|
||||||
@@ -116,7 +152,7 @@ function LoginScreen() {
|
|||||||
<div className="login-copy">
|
<div className="login-copy">
|
||||||
<span>Offline-first hunter records</span>
|
<span>Offline-first hunter records</span>
|
||||||
<h1>Keep everyone standing.</h1>
|
<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>
|
</div>
|
||||||
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); submitSignIn(); }}>
|
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); submitSignIn(); }}>
|
||||||
<label htmlFor="account-username">Username</label>
|
<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); }}>
|
<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>
|
<span>Create account</span><small>Required for first sync</small>
|
||||||
</FocusButton>
|
</FocusButton>
|
||||||
<FocusButton id="offline" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}>
|
<FocusButton id="continue" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}>
|
||||||
<span>Continue with offline save</span><small>No account required</small>
|
<span>Continue</span><small>Choose saved hunter</small>
|
||||||
</FocusButton>
|
</FocusButton>
|
||||||
</form>
|
</form>
|
||||||
{notice && <div className="front-notice" role="status" aria-live="polite">{notice}</div>}
|
{notice && <div className="front-notice" role="status" aria-live="polite">{notice}</div>}
|
||||||
<ControllerLegend />
|
<ControllerLegend />
|
||||||
</FrontSurface>
|
</FrontSurface>
|
||||||
}
|
}
|
||||||
bottom={
|
bottom={<SaveLibraryContext slots={slots} accountId={accountId} />}
|
||||||
<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>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SlotCard({ slot, selected, focused, onSelect, onFocus }: { slot: SaveSlotState; selected: boolean; focused: boolean; onSelect: () => void; onFocus: () => void }) {
|
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 healer = save ? HEALER_CLASSES[save.activeClassId] : null;
|
||||||
|
const copyStatus = continuation === "choose" ? "Newer online" : continuation === "online" ? "Online only" : null;
|
||||||
return (
|
return (
|
||||||
<button className={`save-slot ${selected ? "is-selected" : ""} ${focused ? "is-controller-focused" : ""}`} onClick={onSelect} onFocus={onFocus} onPointerEnter={onFocus}>
|
<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 ? (
|
{save ? (
|
||||||
<>
|
<>
|
||||||
<div className="slot-portrait">{save.hunterName[0]}<i>✦</i></div>
|
<div className="slot-portrait">{save.hunterName[0]}<i>✦</i></div>
|
||||||
@@ -213,11 +238,15 @@ function SaveScreen() {
|
|||||||
const copySlot = useFrontendStore((state) => state.copySlot);
|
const copySlot = useFrontendStore((state) => state.copySlot);
|
||||||
const deleteSlot = useFrontendStore((state) => state.deleteSlot);
|
const deleteSlot = useFrontendStore((state) => state.deleteSlot);
|
||||||
const navigate = useFrontendStore((state) => state.navigate);
|
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 [hunterName, setHunterName] = useState("");
|
||||||
|
const [resolvingOnline, setResolvingOnline] = useState(false);
|
||||||
|
const [versionError, setVersionError] = useState("");
|
||||||
const selected = slots.find((slot) => slot.id === selectedSlotId)!;
|
const selected = slots.find((slot) => slot.id === selectedSlotId)!;
|
||||||
const hasLocal = Boolean(selected.local);
|
const hasLocal = Boolean(selected.local);
|
||||||
const hasOnline = Boolean(selected.online);
|
const hasOnline = Boolean(selected.online);
|
||||||
|
const continuation = resolveSaveContinuation(selected);
|
||||||
|
const primaryActionId = continuation === "create" ? "create" : "play";
|
||||||
|
|
||||||
const finishCreation = () => {
|
const finishCreation = () => {
|
||||||
if (createSlot(selectedSlotId, hunterName)) {
|
if (createSlot(selectedSlotId, hunterName)) {
|
||||||
@@ -235,7 +264,41 @@ function SaveScreen() {
|
|||||||
requestDisplaySurface("top");
|
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: "confirm-create", run: finishCreation },
|
||||||
{ id: "cancel-create", run: () => setDialog(null) },
|
{ id: "cancel-create", run: () => setDialog(null) },
|
||||||
@@ -248,17 +311,25 @@ function SaveScreen() {
|
|||||||
{ id: "cancel-delete", run: () => setDialog(null) },
|
{ id: "cancel-delete", run: () => setDialog(null) },
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
...slots.map((slot) => ({ id: `slot-${slot.id}`, run: () => selectSlot(slot.id) })),
|
...slots.map((slot, index) => ({
|
||||||
{ id: hasLocal ? "play" : "create", run: () => hasLocal ? playSlot(selectedSlotId) : openCreation() },
|
id: `slot-${slot.id}`,
|
||||||
{ id: "upload", run: () => uploadSlot(selectedSlotId), enabled: hasLocal && Boolean(accountId) },
|
run: () => selectSlot(slot.id),
|
||||||
{ id: "download", run: () => downloadSlot(selectedSlotId), enabled: hasOnline && Boolean(accountId) },
|
neighbors: {
|
||||||
{ id: "copy", run: () => openSaveDialog("copy"), enabled: hasLocal },
|
left: `slot-${slots[Math.max(0, index - 1)].id}`,
|
||||||
{ id: "delete", run: () => openSaveDialog("delete"), enabled: hasLocal },
|
right: `slot-${slots[Math.min(slots.length - 1, index + 1)].id}`,
|
||||||
{ id: "back", run: () => navigate("login") },
|
down: primaryActionId,
|
||||||
], [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") });
|
})),
|
||||||
|
{ 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 (
|
return (
|
||||||
<DualDisplayFrame
|
<DualDisplayFrame
|
||||||
top={
|
top={
|
||||||
@@ -276,10 +347,47 @@ function SaveScreen() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 && (
|
{dialog && (
|
||||||
<div className="front-dialog" role="dialog" aria-modal="true" aria-label={dialog === "create" ? "Name new hunter" : dialog === "copy" ? "Copy save" : "Delete save"}>
|
<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 === "create" ? (
|
{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(); }}>
|
<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>
|
<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>
|
<label htmlFor="new-hunter-name">Hunter name</label>
|
||||||
@@ -324,31 +432,37 @@ function SaveScreen() {
|
|||||||
</FrontSurface>
|
</FrontSurface>
|
||||||
}
|
}
|
||||||
bottom={
|
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>
|
<header className="context-header"><span>Slot {selectedSlotId}</span><b>{cloudStatus}</b></header>
|
||||||
<div className="selected-save-summary">
|
<div className="selected-save-summary">
|
||||||
{selected.local ? (
|
{selected.local ?? selected.online ? (
|
||||||
<><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></>
|
<><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 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>
|
</div>
|
||||||
{selected.online && <div className="online-record"><span><b>ONLINE</b>{selected.online.hunterName}</span><time>{formatSaveTimestamp(selected.online.updatedAt)}</time></div>}
|
{(selected.local ?? selected.online) && (() => {
|
||||||
<div className="save-actions">
|
const save = (selected.local ?? selected.online)!;
|
||||||
<FocusButton id={hasLocal ? "play" : "create"} focusedId={controller.focusedId} focus={controller.focus} className="front-primary" onClick={() => hasLocal ? playSlot(selectedSlotId) : openCreation()}>
|
const healer = HEALER_CLASSES[save.activeClassId];
|
||||||
{hasLocal ? "Continue offline save" : "Create new hunter"}<small>{DEFAULT_CONTROLLER_GLYPHS.confirm}</small>
|
return (
|
||||||
</FocusButton>
|
<>
|
||||||
<div className="sync-actions">
|
<div className="save-dossier-stats">
|
||||||
<FocusButton id="upload" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}>↑ Sync offline to server</FocusButton>
|
<span><small>Active healer</small><strong>Lv {save.healers[save.activeClassId].level}</strong><em>{healer.name}</em></span>
|
||||||
<FocusButton id="download" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasOnline || !accountId} onClick={() => downloadSlot(selectedSlotId)}>↓ Overwrite with online</FocusButton>
|
<span><small>Play time</small><strong>{formatPlayTime(save.playSeconds)}</strong><em>Local activity</em></span>
|
||||||
</div>
|
<span><small>Boss kills</small><strong>{save.stats.totalBossKills}</strong><em>{save.stats.flawlessClears} flawless</em></span>
|
||||||
<div className="record-actions">
|
</div>
|
||||||
<FocusButton id="copy" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} onClick={() => openSaveDialog("copy")}>Copy save</FocusButton>
|
<div className="save-dossier-records">
|
||||||
<FocusButton id="delete" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} className="danger-link" onClick={() => openSaveDialog("delete")}>Delete save</FocusButton>
|
<span><small>Roguelike best</small><b>Round {save.stats.highestRoguelikeRound}</b></span>
|
||||||
<FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("login")}>Back</FocusButton>
|
<span><small>Endless best</small><b>{save.stats.highestRogueTrialsEndlessKills} kills</b></span>
|
||||||
</div>
|
</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>
|
||||||
<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>
|
</FrontSurface>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
|
import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
|
||||||
import { useAnimations, useGLTF } from "@react-three/drei";
|
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 * as THREE from "three";
|
||||||
import { getControllerMovement } from "../input/controller";
|
import { getControllerMovement } from "../input/controller";
|
||||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||||
@@ -25,20 +25,38 @@ import {
|
|||||||
type ActorAnimationState,
|
type ActorAnimationState,
|
||||||
} from "../game/actorAnimation";
|
} from "../game/actorAnimation";
|
||||||
import { PERFORMANCE_PROBE_ENABLED, recordSimulationTick, simulationTickSnapshot } from "../game/performance";
|
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 { useGameStore } from "../game/store";
|
||||||
import type { BossId, MemberId, PulseKind } from "../game/types";
|
import type { BossId, MemberId, PulseKind } from "../game/types";
|
||||||
import { BossRoom } from "./BossRoom";
|
import { BossRoom } from "./BossRoom";
|
||||||
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
|
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,
|
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,
|
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,
|
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,
|
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,
|
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 },
|
aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff.glb", import.meta.url).href },
|
||||||
brann: {
|
brann: {
|
||||||
right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed.glb", import.meta.url).href,
|
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,
|
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_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> = {
|
const PARTY_ATTACK_CLIPS: Record<MemberId, string> = {
|
||||||
aelia: "2H_Melee_Attack_Chop",
|
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"];
|
const SUPPORT_PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia" | "brann">[] = ["nia", "orin", "vale"];
|
||||||
type GameStoreState = ReturnType<typeof useGameStore.getState>;
|
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) {
|
function encounterBossAt(state: GameStoreState, bossIndex: number) {
|
||||||
return bossIndex === 0
|
return bossIndex === 0
|
||||||
? { boss: state.boss, motion: state.bossMotion }
|
? { boss: state.boss, motion: state.bossMotion }
|
||||||
@@ -148,12 +272,12 @@ function PartyCharacterModel({
|
|||||||
animationState: MutableRefObject<ActorAnimationState>;
|
animationState: MutableRefObject<ActorAnimationState>;
|
||||||
animationTrigger: MutableRefObject<number>;
|
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 actorScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
|
||||||
const loadout = PARTY_WEAPON_URLS[memberId];
|
const loadout = PARTY_WEAPON_URLS[memberId];
|
||||||
const grips = PARTY_WEAPON_GRIPS[memberId];
|
const grips = PARTY_WEAPON_GRIPS[memberId];
|
||||||
const rightWeapon = useGLTF(loadout.right, false, true);
|
const rightWeapon = useGameGLTF(loadout.right);
|
||||||
const leftWeapon = useGLTF(loadout.left ?? loadout.right, false, true);
|
const leftWeapon = useGameGLTF(loadout.left ?? loadout.right);
|
||||||
const rightHandSlot = resolveRigNode(actorScene, "handslot.r");
|
const rightHandSlot = resolveRigNode(actorScene, "handslot.r");
|
||||||
const leftHandSlot = resolveRigNode(actorScene, "handslot.l");
|
const leftHandSlot = resolveRigNode(actorScene, "handslot.l");
|
||||||
const rightWeaponScene = useMemo(
|
const rightWeaponScene = useMemo(
|
||||||
@@ -587,14 +711,34 @@ function PartyFallback({ memberIds }: { memberIds: readonly MemberId[] }) {
|
|||||||
function BossFallback({ bossIndex }: { bossIndex: number }) {
|
function BossFallback({ bossIndex }: { bossIndex: number }) {
|
||||||
const boss = useGameStore((state) => bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss);
|
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 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;
|
if (!boss || !motion) return null;
|
||||||
const position = motion.position;
|
const position = motion.position;
|
||||||
const bossId = boss.id;
|
const bossId = boss.id;
|
||||||
return (
|
return (
|
||||||
<mesh castShadow position={[position[0], 1.1, position[1]]}>
|
<group ref={group} position={[position[0], 1.1, position[1]]}>
|
||||||
<dodecahedronGeometry args={[1.1, 0]} />
|
<mesh castShadow>
|
||||||
<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} />
|
<dodecahedronGeometry args={[1.1, 0]} />
|
||||||
</mesh>
|
<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 bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
|
||||||
const defeated = bossHp <= 0;
|
const defeated = bossHp <= 0;
|
||||||
const group = useRef<THREE.Group>(null);
|
const group = useRef<THREE.Group>(null);
|
||||||
|
const light = useRef<THREE.PointLight>(null);
|
||||||
const gltf = useGLTF(BULL_URL, false, true);
|
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 { actions } = useAnimations(gltf.animations, bullScene);
|
||||||
const targetPosition = useMemo(() => new THREE.Vector3(), []);
|
const targetPosition = useMemo(() => new THREE.Vector3(), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bullScene.traverse((object) => {
|
return () => { for (const entry of fadeMaterials) entry.material.dispose(); };
|
||||||
if (object instanceof THREE.Mesh) {
|
}, [fadeMaterials]);
|
||||||
object.castShadow = true;
|
|
||||||
object.receiveShadow = true;
|
useBossDeathFade(group, light, fadeMaterials, defeated, 2.8);
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [bullScene]);
|
|
||||||
|
|
||||||
const clipName = phase === "victory" || defeated
|
const clipName = phase === "victory" || defeated
|
||||||
? "Death"
|
? "Death"
|
||||||
@@ -675,7 +817,7 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
|
|||||||
return (
|
return (
|
||||||
<group ref={group}>
|
<group ref={group}>
|
||||||
<primitive object={bullScene} scale={0.81} />
|
<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>
|
</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 bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
|
||||||
const defeated = bossHp <= 0;
|
const defeated = bossHp <= 0;
|
||||||
const group = useRef<THREE.Group>(null);
|
const group = useRef<THREE.Group>(null);
|
||||||
const gltf = useGLTF(config.url, false, true);
|
const light = useRef<THREE.PointLight>(null);
|
||||||
const model = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
|
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 { actions } = useAnimations(gltf.animations, model);
|
||||||
const targetPosition = useMemo(() => new THREE.Vector3(), []);
|
const targetPosition = useMemo(() => new THREE.Vector3(), []);
|
||||||
|
const burrowPositionY = bossBurrowPositionY(modelTopY, config.scale);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
model.traverse((object) => {
|
return () => { for (const entry of fadeMaterials) entry.material.dispose(); };
|
||||||
if (object instanceof THREE.Mesh) {
|
}, [fadeMaterials]);
|
||||||
object.castShadow = true;
|
|
||||||
object.receiveShadow = true;
|
useBossDeathFade(group, light, fadeMaterials, defeated, 2.5);
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [kind, model]);
|
|
||||||
|
|
||||||
const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motion ?? useGameStore.getState().bossMotion);
|
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;
|
if (!current) return;
|
||||||
const motion = current.motion;
|
const motion = current.motion;
|
||||||
const airborne = archetype === "sky-sweeper" && motion.mode === "skyfall";
|
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;
|
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));
|
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
|
||||||
if (!bossCanTrackTarget(current.boss.hp)) return;
|
if (!bossCanTrackTarget(current.boss.hp)) return;
|
||||||
|
|
||||||
@@ -770,7 +912,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
|||||||
return (
|
return (
|
||||||
<group ref={group}>
|
<group ref={group}>
|
||||||
<primitive object={model} scale={config.scale} rotation={[0, config.rotationOffset, 0]} />
|
<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>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -859,7 +1001,15 @@ function TankAuraField() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
|
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 start = useMemo(() => new THREE.Vector3(), []);
|
||||||
const end = useMemo(() => new THREE.Vector3(), []);
|
const end = useMemo(() => new THREE.Vector3(), []);
|
||||||
const current = 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), []);
|
const up = useMemo(() => new THREE.Vector3(0, 1, 0), []);
|
||||||
|
|
||||||
useFrame(({ clock }) => {
|
useFrame(({ clock }) => {
|
||||||
if (!group.current) return;
|
if (!projectile.current || !impact.current) return;
|
||||||
const state = useGameStore.getState();
|
const state = useGameStore.getState();
|
||||||
const action = state.partyCombat.combatants[memberId].visualAction;
|
const action = state.partyCombat.combatants[memberId].visualAction;
|
||||||
const member = state.party.find((entry) => entry.id === memberId)!;
|
const member = state.party.find((entry) => entry.id === memberId)!;
|
||||||
@@ -878,8 +1028,19 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
|
|||||||
&& action.abilityId !== "overcharge"
|
&& action.abilityId !== "overcharge"
|
||||||
&& state.time >= action.startedAt
|
&& state.time >= action.startedAt
|
||||||
&& (rapid ? state.time <= action.endsAt : state.time <= action.impactAt);
|
&& (rapid ? state.time <= action.endsAt : state.time <= action.impactAt);
|
||||||
group.current.visible = active;
|
projectile.current.visible = active;
|
||||||
if (!active || !action) return;
|
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 targetMotion = targetBossMotionByInstance(state, action.targetInstanceId);
|
||||||
const projectileDuration = Math.max(0.12, action.impactAt - action.startedAt);
|
const projectileDuration = Math.max(0.12, action.impactAt - action.startedAt);
|
||||||
@@ -890,44 +1051,81 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
|
|||||||
const target = targetMotion.position;
|
const target = targetMotion.position;
|
||||||
start.set(source[0], 1.18, source[1]);
|
start.set(source[0], 1.18, source[1]);
|
||||||
end.set(target[0], 1.12, target[1]);
|
end.set(target[0], 1.12, target[1]);
|
||||||
current.copy(start).lerp(end, progress);
|
if (active) {
|
||||||
current.y += Math.sin(progress * Math.PI) * (memberId === "orin" ? 0.95 : 0.34);
|
current.copy(start).lerp(end, progress);
|
||||||
group.current.position.copy(current);
|
current.y += Math.sin(progress * Math.PI) * (memberId === "orin" ? 0.95 : 0.34);
|
||||||
direction.subVectors(end, start).normalize();
|
projectile.current.position.copy(current);
|
||||||
group.current.quaternion.setFromUnitVectors(up, direction);
|
direction.subVectors(end, start).normalize();
|
||||||
if (memberId === "orin") group.current.scale.setScalar(0.9 + Math.sin(clock.elapsedTime * 14) * 0.12);
|
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 (
|
return (
|
||||||
<group ref={group} visible={false}>
|
<>
|
||||||
{memberId === "nia" ? (
|
<group ref={projectile} visible={false}>
|
||||||
<>
|
{memberId === "nia" ? (
|
||||||
<mesh>
|
<>
|
||||||
<cylinderGeometry args={[0.026, 0.026, 0.82, 6]} />
|
<mesh>
|
||||||
<meshBasicMaterial color="#d7b477" />
|
<cylinderGeometry args={[0.026, 0.026, 0.82, 6]} />
|
||||||
</mesh>
|
<meshBasicMaterial ref={coreMaterial} color="#77d596" />
|
||||||
<mesh position={[0, 0.5, 0]}>
|
</mesh>
|
||||||
<coneGeometry args={[0.085, 0.2, 6]} />
|
<mesh position={[0, 0.5, 0]}>
|
||||||
<meshBasicMaterial color="#f1ddaa" />
|
<coneGeometry args={[0.085, 0.2, 6]} />
|
||||||
</mesh>
|
<meshBasicMaterial ref={accentMaterial} color="#e5ffb8" />
|
||||||
<mesh position={[0, -0.4, 0]}>
|
</mesh>
|
||||||
<coneGeometry args={[0.1, 0.18, 4]} />
|
<mesh position={[0, -0.4, 0]}>
|
||||||
<meshBasicMaterial color="#70cf8e" />
|
<coneGeometry args={[0.1, 0.18, 4]} />
|
||||||
</mesh>
|
<meshBasicMaterial color="#d7b477" />
|
||||||
</>
|
</mesh>
|
||||||
) : (
|
<mesh ref={trail} position={[0, -0.62, 0]}>
|
||||||
<>
|
<coneGeometry args={[0.12, 0.72, 6, 1, true]} />
|
||||||
<mesh>
|
<meshBasicMaterial ref={trailMaterial} color="#77d596" transparent opacity={0.34} depthWrite={false} blending={THREE.AdditiveBlending} />
|
||||||
<sphereGeometry args={[0.19, 12, 10]} />
|
</mesh>
|
||||||
<meshBasicMaterial color="#bc8cff" />
|
</>
|
||||||
</mesh>
|
) : (
|
||||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
<>
|
||||||
<torusGeometry args={[0.25, 0.025, 6, 20]} />
|
<mesh>
|
||||||
<meshBasicMaterial color="#ead8ff" transparent opacity={0.8} />
|
<sphereGeometry args={[0.19, 12, 10]} />
|
||||||
</mesh>
|
<meshBasicMaterial ref={coreMaterial} color="#a87cff" toneMapped={false} />
|
||||||
</>
|
</mesh>
|
||||||
)}
|
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||||
</group>
|
<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() {
|
function BossActor() {
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
const primaryBossId = useGameStore((state) => state.boss.id);
|
const primaryBossId = useGameStore((state) => state.boss.id);
|
||||||
@@ -1045,27 +1381,72 @@ function PerformanceProbe() {
|
|||||||
return null;
|
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 }) {
|
function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
|
||||||
|
const group = useRef<THREE.Group>(null);
|
||||||
const ring = useRef<THREE.Mesh>(null);
|
const ring = useRef<THREE.Mesh>(null);
|
||||||
const material = useRef<THREE.MeshBasicMaterial>(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 age = useRef(0);
|
||||||
const isBossFx = kind === "boss";
|
const isBossFx = kind === "boss";
|
||||||
const state = useGameStore.getState();
|
const state = useGameStore.getState();
|
||||||
const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0];
|
const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0];
|
||||||
const position: [number, number, number] = [worldPosition[0], 0.15, worldPosition[1]];
|
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";
|
const color = scenePulseColor(kind);
|
||||||
useFrame((_, delta) => {
|
useFrame(({ clock }, delta) => {
|
||||||
age.current += 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);
|
const progress = Math.min(1, age.current / 0.7);
|
||||||
ring.current.scale.setScalar(0.5 + progress * 3.6);
|
ring.current.scale.setScalar(0.5 + progress * 3.6);
|
||||||
material.current.opacity = (1 - progress) * 0.85;
|
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 (
|
return (
|
||||||
<mesh ref={ring} position={position} rotation={[-Math.PI / 2, 0, 0]}>
|
<group ref={group} position={position}>
|
||||||
<ringGeometry args={[0.38, 0.5, 32]} />
|
<mesh ref={ring} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
<meshBasicMaterial ref={material} color={color} transparent depthWrite={false} />
|
<ringGeometry args={[0.38, 0.5, 32]} />
|
||||||
</mesh>
|
<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 }}
|
camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }}
|
||||||
gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }}
|
gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }}
|
||||||
>
|
>
|
||||||
<SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} />
|
<GameAssetProvider>
|
||||||
<BossRoom />
|
<SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} />
|
||||||
<BossMechanicIndicators />
|
<BossRoom />
|
||||||
<BarrierField />
|
<BossMechanicIndicators />
|
||||||
<TankAuraField />
|
<BarrierField />
|
||||||
<Party />
|
<TankAuraField />
|
||||||
<BossActor />
|
<Party />
|
||||||
<RangedProjectiles />
|
<BossActor />
|
||||||
<CombatFx />
|
<PartyCombatVfx />
|
||||||
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
|
<CombatFx />
|
||||||
|
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
|
||||||
|
</GameAssetProvider>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const memberId of CRITICAL_PARTY_MEMBER_IDS) {
|
if (LEGACY_GAME_ASSETS_FORCED) {
|
||||||
useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true);
|
for (const memberId of CRITICAL_PARTY_MEMBER_IDS) {
|
||||||
const loadout = PARTY_WEAPON_URLS[memberId];
|
useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true);
|
||||||
useGLTF.preload(loadout.right, false, true);
|
const loadout = PARTY_WEAPON_URLS[memberId];
|
||||||
if (loadout.left) useGLTF.preload(loadout.left, false, true);
|
useGLTF.preload(loadout.right, false, true);
|
||||||
|
if (loadout.left) useGLTF.preload(loadout.left, false, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,10 +3,13 @@ import { Html } from "@react-three/drei";
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentType } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentType } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { BULL_CHARGE, BULL_POUNCE, MEMORY_SEQUENCE, MEMORY_SYMBOLS, SKY_SWEEPER_BREATH } from "../../game/bosses/mechanicPool";
|
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 { angleTo } from "../../game/geometry";
|
||||||
import { useGameStore } from "../../game/store";
|
import { useGameStore } from "../../game/store";
|
||||||
import type { BossMotionMode, BossMotionState, MemorySymbolId, MemoryTile, PoolTelegraph, SoulSiphonState, WorldPosition } from "../../game/types";
|
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 { 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 CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
|
||||||
const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
|
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;
|
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) {
|
function earliestSoulSiphonInMotion(motion: BossMotionState | undefined, current?: SoulSiphonState) {
|
||||||
if (!motion) return current;
|
if (!motion) return current;
|
||||||
let earliest = current;
|
let earliest = current;
|
||||||
@@ -192,26 +199,37 @@ function SlashLaneIndicator({ laneId, bossIndex }: { laneId: string; bossIndex:
|
|||||||
(lane.start[1] + lane.end[1]) * 0.5,
|
(lane.start[1] + lane.end[1]) * 0.5,
|
||||||
];
|
];
|
||||||
const active = ACTIVE_LANE_MODES.has(motion.mode);
|
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;
|
const color = active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
||||||
return (
|
return (
|
||||||
<group position={midpoint} rotation={[0, angle, 0]}>
|
<>
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
<group position={midpoint} rotation={[0, angle, 0]}>
|
||||||
<planeGeometry args={[lane.width, length]} />
|
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
<meshBasicMaterial ref={material} color={color} transparent opacity={0.22} depthWrite={false} />
|
<planeGeometry args={[lane.width, length]} />
|
||||||
</mesh>
|
<meshBasicMaterial ref={material} color={color} transparent opacity={0.22} depthWrite={false} />
|
||||||
{[-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>
|
</mesh>
|
||||||
))}
|
{[-1, 1].map((side) => (
|
||||||
{active && (
|
<mesh key={side} position={[side * lane.width * 0.5, 0.018, 0]}>
|
||||||
<mesh position={[0, 0.055, 0]}>
|
<boxGeometry args={[0.07, 0.03, length]} />
|
||||||
<boxGeometry args={[0.16, 0.08, length]} />
|
<meshBasicMaterial ref={side < 0 ? edgeMaterial : undefined} color={color} transparent opacity={0.8} depthWrite={false} />
|
||||||
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.92} depthWrite={false} />
|
</mesh>
|
||||||
</mesh>
|
))}
|
||||||
)}
|
{active && (
|
||||||
</group>
|
<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;
|
if (!warningMaterial.current) return;
|
||||||
warningMaterial.current.opacity = 0.14 + (Math.sin(clock.elapsedTime * 8) + 1) * 0.08;
|
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 radius = BULL_POUNCE.stackRadius;
|
||||||
|
const active = motionMode === "pouncing";
|
||||||
return (
|
return (
|
||||||
<group ref={group}>
|
<group ref={group}>
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
@@ -261,6 +280,7 @@ export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number })
|
|||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
))}
|
))}
|
||||||
|
<CircleMechanicVfx radius={radius} kind="pounce" color={active ? "#ffe0a8" : "#ff7278"} active={active} />
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -291,6 +311,7 @@ export function BindingWebIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
|||||||
<meshBasicMaterial color={color} transparent opacity={0.92} depthWrite={false} />
|
<meshBasicMaterial color={color} transparent opacity={0.92} depthWrite={false} />
|
||||||
</mesh>
|
</mesh>
|
||||||
))}
|
))}
|
||||||
|
<LaneEnergyVfx start={first} end={second} width={0.34} color={color} active={stretched} variant="web" height={0.2} />
|
||||||
</group>
|
</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;
|
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;
|
const color = motion.mode === "breath_sweeping" ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
||||||
return (
|
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]}>
|
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
|
||||||
<circleGeometry args={[SKY_SWEEPER_BREATH.range, 64, 0, SKY_SWEEPER_BREATH.halfAngle * 2]} />
|
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - SKY_SWEEPER_BREATH.halfAngle]}>
|
||||||
<meshBasicMaterial ref={material} color={color} transparent opacity={0.25} depthWrite={false} />
|
<circleGeometry args={[SKY_SWEEPER_BREATH.range, 64, 0, SKY_SWEEPER_BREATH.halfAngle * 2]} />
|
||||||
</mesh>
|
<meshBasicMaterial ref={material} color={color} transparent opacity={0.25} depthWrite={false} />
|
||||||
<mesh position={[0, 0.02, SKY_SWEEPER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
|
</mesh>
|
||||||
<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]} />
|
<mesh position={[0, 0.02, SKY_SWEEPER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
|
<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]} />
|
||||||
</mesh>
|
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
|
||||||
</group>
|
</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;
|
if (!hazard) return null;
|
||||||
const active = time >= hazard.activatesAt;
|
const active = time >= hazard.activatesAt;
|
||||||
const colors = {
|
const visual = bossHazardVfxProfile(hazard.kind);
|
||||||
venom_pool: "#a94ee6",
|
const color = active ? visual.activeColor : visual.warningColor;
|
||||||
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];
|
|
||||||
return (
|
return (
|
||||||
<group position={[hazard.center[0], 0.065, hazard.center[1]]}>
|
<group position={[hazard.center[0], 0.065, hazard.center[1]]}>
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
@@ -358,9 +377,10 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss
|
|||||||
{!active && (
|
{!active && (
|
||||||
<mesh position={[0, 0.03, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
<mesh position={[0, 0.03, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
<ringGeometry args={[0.22, 0.34, 24]} />
|
<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>
|
</mesh>
|
||||||
)}
|
)}
|
||||||
|
<CircleHazardVfx hazard={hazard} active={active} />
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -639,30 +659,41 @@ function PooledTelegraphIndicator({ telegraphId, bossIndex }: { telegraphId: str
|
|||||||
: telegraph.kind === "spread" ? "#e869ff"
|
: telegraph.kind === "spread" ? "#e869ff"
|
||||||
: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
: 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 dx = telegraph.end[0] - telegraph.start[0];
|
||||||
const dz = telegraph.end[1] - telegraph.start[1];
|
const dz = telegraph.end[1] - telegraph.start[1];
|
||||||
const length = Math.hypot(dx, dz);
|
const length = Math.hypot(dx, dz);
|
||||||
const angle = Math.atan2(dx, dz);
|
const angle = Math.atan2(dx, dz);
|
||||||
return (
|
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]}>
|
<group position={[(telegraph.start[0] + telegraph.end[0]) * 0.5, 0.07, (telegraph.start[1] + telegraph.end[1]) * 0.5]} rotation={[0, angle, 0]}>
|
||||||
<planeGeometry args={[telegraph.width ?? 1, length]} />
|
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
|
<planeGeometry args={[telegraph.width ?? 1, length]} />
|
||||||
</mesh>
|
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
|
||||||
{[-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>
|
</mesh>
|
||||||
))}
|
{[-1, 1].map((side) => (
|
||||||
{CHARGE_MARKERS.map((index) => (
|
<mesh key={side} position={[side * (telegraph.width ?? 1) * 0.5, 0.022, 0]}>
|
||||||
<mesh key={index} position={[0, 0.034, -length * 0.44 + (index / 6) * length * 0.88]}>
|
<boxGeometry args={[0.06, 0.035, length]} />
|
||||||
<boxGeometry args={[(telegraph.width ?? 1) * 0.56, 0.035, 0.13]} />
|
<meshBasicMaterial color={color} transparent opacity={0.95} depthWrite={false} />
|
||||||
<meshBasicMaterial color="#ffe1b3" transparent opacity={active ? 1 : 0.7} />
|
</mesh>
|
||||||
</mesh>
|
))}
|
||||||
))}
|
{CHARGE_MARKERS.map((index) => (
|
||||||
</group>
|
<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>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
))}
|
))}
|
||||||
|
<CircleMechanicVfx
|
||||||
|
radius={telegraph.radius}
|
||||||
|
innerRadius={innerRadius}
|
||||||
|
kind={telegraph.kind}
|
||||||
|
color={color}
|
||||||
|
active={active}
|
||||||
|
/>
|
||||||
</group>
|
</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} />)}</>;
|
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 }>[] = [
|
const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
|
||||||
|
BasicMeleeVfx,
|
||||||
ChargeLaneIndicator,
|
ChargeLaneIndicator,
|
||||||
SlashLaneIndicators,
|
SlashLaneIndicators,
|
||||||
PounceStackIndicator,
|
PounceStackIndicator,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
BOSS_INDICATOR_DEATH_FADE_MS,
|
BOSS_INDICATOR_DEATH_FADE_MS,
|
||||||
|
BOSS_DEATH_DESPAWN_SECONDS,
|
||||||
|
BOSS_DEATH_FADE_SECONDS,
|
||||||
|
BOSS_DEATH_HOLD_SECONDS,
|
||||||
advanceBossIndicatorOpacity,
|
advanceBossIndicatorOpacity,
|
||||||
bossCanTrackTarget,
|
bossCanTrackTarget,
|
||||||
|
bossDeathOpacity,
|
||||||
} from "./bossDeathVisuals";
|
} from "./bossDeathVisuals";
|
||||||
|
|
||||||
describe("boss death visuals", () => {
|
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(halfway, true, BOSS_INDICATOR_DEATH_FADE_MS / 2_000)).toBe(0);
|
||||||
expect(advanceBossIndicatorOpacity(0.4, false, 1 / 60)).toBe(1);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 const BOSS_INDICATOR_DEATH_FADE_MS = 250;
|
||||||
|
|
||||||
export function bossCanTrackTarget(hp: number) {
|
export function bossCanTrackTarget(hp: number) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { createClassInventory } from "./healers";
|
import { createClassInventory } from "./healers";
|
||||||
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
|
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
|
||||||
|
import { canAddBossToEncounter } from "./bossSelection";
|
||||||
import { useGameStore } from "./store";
|
import { useGameStore } from "./store";
|
||||||
import type { BossId } from "./types";
|
import type { BossId } from "./types";
|
||||||
|
|
||||||
@@ -27,7 +28,9 @@ function simulateControlledBattle(bossIds: readonly [BossId, BossId], maxSeconds
|
|||||||
|
|
||||||
describe("full-mechanics dual-boss battle simulations", () => {
|
describe("full-mechanics dual-boss battle simulations", () => {
|
||||||
const combinations: readonly (readonly [BossId, BossId])[] = AVAILABLE_BOSS_IDS.flatMap((first, index) =>
|
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) => {
|
it.each(combinations)("party rotations defeat %s + %s", (first, second) => {
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "./bossCatalog";
|
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "./bossCatalog";
|
||||||
import { createBossMotionState, createBossState } from "./bossMechanics";
|
import { createBossMotionState, createBossState } from "./bossMechanics";
|
||||||
import { BOSS_MECHANIC_POOL, BOSS_MECHANIC_REGISTRY, bossMechanicName } from "./bosses/mechanicPool";
|
import { BOSS_MECHANIC_POOL, BOSS_MECHANIC_REGISTRY, bossMechanicName } from "./bosses/mechanicPool";
|
||||||
|
import { ALTERNATE_BOSS_CONFIG } from "./bossVisuals";
|
||||||
|
|
||||||
describe("boss catalog", () => {
|
describe("boss catalog", () => {
|
||||||
it("derives the available roster from every catalog definition", () => {
|
it("derives the available roster from every catalog definition", () => {
|
||||||
expect(AVAILABLE_BOSS_IDS).toEqual(Object.keys(BOSS_DEFINITIONS));
|
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", () => {
|
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(","));
|
const kits = Object.values(BOSS_DEFINITIONS).map((boss) => boss.mechanicIds.join(","));
|
||||||
expect(new Set(kits)).toHaveLength(AVAILABLE_BOSS_IDS.length);
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
summary: "Gallops through charged lanes before crashing onto the marked healer.", mechanicIds: ["bull-charge", "crushing-pounce", "stormfall"], maxHp: 480, archetype: "bull",
|
||||||
},
|
},
|
||||||
"cluckhorn-colossus": {
|
"cluckhorn-colossus": {
|
||||||
name: "Cluckhorn Colossus", title: "The Roostbreaker", icon: "✹", accent: "#f0b85d",
|
name: "Brassbeak Basilisk", title: "The Furnace Nest", icon: "✹", accent: "#dba33e",
|
||||||
summary: "Stampedes sideways and drops cracking shell bursts on spread targets.", mechanicIds: ["sidewinder-rush", "crushing-tide", "meteor-spread"], maxHp: 475, archetype: "crab",
|
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": {
|
"ashwing-demon": {
|
||||||
name: "Ashwing", title: "The Cinder Choir", icon: "♠", accent: "#df665d",
|
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",
|
summary: "Ricochets across the arena and leaves fire at every landing.", mechanicIds: ["ricochet-rush", "meteor-slam", "ember-brand"], maxHp: 465, archetype: "ricochet",
|
||||||
},
|
},
|
||||||
"mirelord-frog": {
|
"mirelord-frog": {
|
||||||
name: "Mirelord", title: "The Drowned Bell", icon: "●", accent: "#73c96b",
|
name: "Bogbell Myconid", title: "The Drowned Bell", icon: "●", accent: "#8fdc69",
|
||||||
summary: "Dives below the mire before erupting through timed bog zones.", mechanicIds: ["burrow-rush", "hourglass-eruption", "hollow-collapse"], maxHp: 490, archetype: "burrower",
|
summary: "Roots below the mire before erupting through timed spore blooms.", mechanicIds: ["burrow-rush", "hourglass-eruption", "hollow-collapse"], maxHp: 490, archetype: "burrower",
|
||||||
},
|
},
|
||||||
"stonebreaker-giant": {
|
"stonebreaker-giant": {
|
||||||
name: "Stonebreaker", title: "The Walking Crag", icon: "▰", accent: "#c89563",
|
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",
|
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",
|
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",
|
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",
|
id: "scuttle-burst", letter: "G", name: "Scuttle / Burst", coreMechanic: "Scuttle and Burst",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "./bosses/mechanicPool";
|
} from "./bosses/mechanicPool";
|
||||||
import { createBaseMotion } from "./bosses/shared";
|
import { createBaseMotion } from "./bosses/shared";
|
||||||
import type { BossMechanicContext, BossMechanicResult } from "./bosses/types";
|
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 };
|
export { BOSS_MECHANIC_REGISTRY, BULL_CHARGE, BULL_POUNCE };
|
||||||
|
|
||||||
@@ -38,14 +38,17 @@ export function advanceBossMechanics(context: BossMechanicContext): BossMechanic
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function handleBossDispel(
|
export function handleBossDispel(
|
||||||
_bossId: BossId,
|
bossId: BossId,
|
||||||
motion: BossMotionState,
|
motion: BossMotionState,
|
||||||
memberId: MemberId,
|
memberId: MemberId,
|
||||||
position: WorldPosition,
|
position: WorldPosition,
|
||||||
time: number,
|
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) {
|
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ export const BOSS_ROOMS = {
|
|||||||
"stormwool-alpaca": room("thunder-fleece", "The Thunder Fleece", "Wind-scoured highland", "storm", {
|
"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,
|
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", {
|
"cluckhorn-colossus": room("furnace-nest", "The Furnace Nest", "Overgrown brass hatchery", "junkyard", {
|
||||||
background: "#171308", fog: "#49542a", sky: "#d8c675", ground: "#0b1207", floorColor: "#37421e", wallColor: "#554626", accent: "#f1b95c", accentSecondary: "#b8d065", wallHeight: 2.6,
|
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", {
|
"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,
|
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", {
|
"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,
|
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>;
|
} as const satisfies Record<BossId, BossRoomDefinition>;
|
||||||
|
|
||||||
export function bossRoomFor(bossId: BossId): BossRoomDefinition {
|
export function bossRoomFor(bossId: BossId): BossRoomDefinition {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -4,6 +4,7 @@ export type AlternateBossKind = Exclude<BossId, "bulldrome">;
|
|||||||
|
|
||||||
export interface AlternateBossConfig {
|
export interface AlternateBossConfig {
|
||||||
url: string;
|
url: string;
|
||||||
|
optimizedUrl?: string;
|
||||||
scale: number;
|
scale: number;
|
||||||
idle: string;
|
idle: string;
|
||||||
move: 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 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 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 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 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;
|
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"
|
| "mournveil-ghost"
|
||||||
| "crownshard-golem"
|
| "crownshard-golem"
|
||||||
| "crystal-bat-matriarch"
|
| "crystal-bat-matriarch"
|
||||||
|
| "cluckhorn-colossus"
|
||||||
|
| "mirelord-frog"
|
||||||
|
| "gravehorn-triceratops"
|
||||||
>, string> = {
|
>, string> = {
|
||||||
"stormwool-alpaca": new URL("../assets/game/models/claudecraft/creatures/alpaca.glb", import.meta.url).href,
|
"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,
|
"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,
|
"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,
|
"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,
|
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,
|
"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,
|
"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,
|
"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 },
|
"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 },
|
"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 },
|
"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 },
|
"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 },
|
"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 },
|
"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 },
|
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 },
|
"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 },
|
"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 },
|
"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 },
|
"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 },
|
"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 },
|
"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 },
|
"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 },
|
"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 },
|
"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 {
|
export function bossVisualUrl(bossId: BossId, optimized = false): string {
|
||||||
return bossId === "bulldrome" ? BULL_URL : ALTERNATE_BOSS_CONFIG[bossId].url;
|
if (bossId === "bulldrome") return BULL_URL;
|
||||||
|
const config = ALTERNATE_BOSS_CONFIG[bossId];
|
||||||
|
return optimized ? config.optimizedUrl ?? config.url : config.url;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,24 @@ describe("shared boss mechanic pool", () => {
|
|||||||
expect(bossAnimationCue(started.motion)).toBe("attack");
|
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", () => {
|
it("splits soak damage across allies inside its indicator", () => {
|
||||||
const source = context(0);
|
const source = context(0);
|
||||||
const activatesAt = 1.5;
|
const activatesAt = 1.5;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena";
|
import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena";
|
||||||
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
|
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 { applyMelee, chooseLivingTarget, cloneMotion, createCircleHazard, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
|
||||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
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: "cinder-nova", name: "Cinder Nova", instruction: "Heal the party through raidwide damage." },
|
||||||
{ id: "ember-brand", name: "Ember Brand", instruction: "Purify the marked ally." },
|
{ id: "ember-brand", name: "Ember Brand", instruction: "Purify the marked ally." },
|
||||||
{ id: "binding-web", name: "Binding Web", instruction: "Separate the linked allies." },
|
{ 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: "storm-breath", name: "Storm Breath", instruction: "Rotate behind the sweeping cone." },
|
||||||
{ id: "stormfall", name: "Stormfall", instruction: "Spread before the marked impacts." },
|
{ id: "stormfall", name: "Stormfall", instruction: "Spread before the marked impacts." },
|
||||||
{ id: "elemental-beam", name: "Elemental Beam", instruction: "Clear the glowing lane." },
|
{ id: "elemental-beam", name: "Elemental Beam", instruction: "Clear the glowing lane." },
|
||||||
@@ -110,10 +110,13 @@ export const VENOM_PURGE = {
|
|||||||
tickDamage: 5,
|
tickDamage: 5,
|
||||||
castDuration: 2.5,
|
castDuration: 2.5,
|
||||||
poolRadius: 2,
|
poolRadius: 2,
|
||||||
poolDuration: 7,
|
poolArmDelay: 1.25,
|
||||||
poolDamage: 14,
|
poolDuration: 6,
|
||||||
|
poolDamage: 8,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const BINDING_WEB_SCUTTLE_SPEED = 3.2;
|
||||||
|
|
||||||
export const MEMORY_SEQUENCE = {
|
export const MEMORY_SEQUENCE = {
|
||||||
sequenceLength: 4,
|
sequenceLength: 4,
|
||||||
flashDuration: 0.9,
|
flashDuration: 0.9,
|
||||||
@@ -942,11 +945,22 @@ const bindingWeb: BossMechanicDefinition = {
|
|||||||
runtime.motion.mode = "tethering";
|
runtime.motion.mode = "tethering";
|
||||||
runtime.motion.tetherIds = [first, second];
|
runtime.motion.tetherIds = [first, second];
|
||||||
runtime.motion.tetherBreakDistance = 6.8;
|
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.phaseStartedAt = runtime.context.time;
|
||||||
runtime.motion.phaseEndsAt = runtime.context.time + 4.5;
|
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 });
|
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) {
|
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;
|
const [first, second] = runtime.motion.tetherIds;
|
||||||
if (!first || !second || distance(runtime.context.partyPositions[first], runtime.context.partyPositions[second]) >= runtime.motion.tetherBreakDistance) {
|
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" });
|
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" });
|
runtime.events.push({ at: runtime.context.time, message: "Binding Web constricts and roots its targets.", tone: "danger", pulseKind: "tether" });
|
||||||
finishMechanic(runtime, 4);
|
finishMechanic(runtime, 4);
|
||||||
},
|
},
|
||||||
animationCue: () => "attack",
|
animationCue: (motion) => distance(motion.position, motion.chargeEnd) > 0.08 ? "move" : "attack",
|
||||||
};
|
};
|
||||||
bindingWeb.upcoming = (motion, time) => defaultUpcoming(bindingWeb, motion, time);
|
bindingWeb.upcoming = (motion, time) => defaultUpcoming(bindingWeb, motion, time);
|
||||||
|
|
||||||
const venomPurge = instantTimedDefinition("venom-purge", 4, (runtime) => {
|
const venomPurge = instantTimedDefinition("venom-purge", 4, (runtime) => {
|
||||||
const targets = [0, 1].map((offset) => chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount + offset));
|
const targets = [0, 1].map((offset) => chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount + offset));
|
||||||
runtime.motion.mode = "venom_cast";
|
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) ? {
|
runtime.party = runtime.party.map((member) => targets.includes(member.id) ? {
|
||||||
...member,
|
...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);
|
} : 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 = {
|
const stormBreath: BossMechanicDefinition = {
|
||||||
@@ -1264,12 +1278,15 @@ export function upcomingLoadoutMechanic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function bossAnimationCue(motion: BossMotionState): BossAnimationCue {
|
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) {
|
export function dropVenomPool(motion: BossMotionState, memberId: MemberId, center: WorldPosition, time: number) {
|
||||||
const next = cloneMotion(motion);
|
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;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1278,9 +1295,9 @@ export function handleMechanicDispel(
|
|||||||
memberId: MemberId,
|
memberId: MemberId,
|
||||||
position: WorldPosition,
|
position: WorldPosition,
|
||||||
time: number,
|
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 {
|
return {
|
||||||
motion: dropVenomPool(motion, memberId, position, time),
|
motion: dropVenomPool(motion, memberId, position, time),
|
||||||
message: "Widow Venom purged. A venom pool forms where the target stood.",
|
message: "Widow Venom purged. A venom pool forms where the target stood.",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
|
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS } from "./bossCatalog";
|
||||||
import {
|
import {
|
||||||
RUN_BUFF_ORDER,
|
RUN_BUFF_ORDER,
|
||||||
RUN_BUFFS,
|
RUN_BUFFS,
|
||||||
@@ -97,6 +97,17 @@ describe("roguelike progression", () => {
|
|||||||
expect(trio.every((bossId) => !seen.includes(bossId as typeof seen[number]))).toBe(true);
|
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", () => {
|
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/);
|
expect(() => selectUnseenBosses(3, AVAILABLE_BOSS_IDS.slice(0, -2))).toThrow(/Cannot select 3 unseen bosses/);
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-5
@@ -1,4 +1,5 @@
|
|||||||
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
|
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
|
||||||
|
import { canAddBossToEncounter } from "./bossSelection";
|
||||||
import type { AbilityId, BossId, RunBuffId, RunBuffRanks } from "./types";
|
import type { AbilityId, BossId, RunBuffId, RunBuffRanks } from "./types";
|
||||||
|
|
||||||
export type RunBuffEffectKind =
|
export type RunBuffEffectKind =
|
||||||
@@ -210,6 +211,7 @@ export function selectUnseenBosses(
|
|||||||
count: number,
|
count: number,
|
||||||
seenBossIds: readonly BossId[] = [],
|
seenBossIds: readonly BossId[] = [],
|
||||||
random: () => number = Math.random,
|
random: () => number = Math.random,
|
||||||
|
selectedBossIds: readonly BossId[] = [],
|
||||||
): BossId[] {
|
): BossId[] {
|
||||||
const seen = new Set(seenBossIds);
|
const seen = new Set(seenBossIds);
|
||||||
const pool = AVAILABLE_BOSS_IDS.filter((bossId) => !seen.has(bossId));
|
const pool = AVAILABLE_BOSS_IDS.filter((bossId) => !seen.has(bossId));
|
||||||
@@ -219,11 +221,16 @@ export function selectUnseenBosses(
|
|||||||
}
|
}
|
||||||
const bosses: BossId[] = [];
|
const bosses: BossId[] = [];
|
||||||
while (bosses.length < requestedCount) {
|
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 sample = random();
|
||||||
const randomValue = Number.isFinite(sample) ? Math.max(0, Math.min(0.999999999, sample)) : 0;
|
const randomValue = Number.isFinite(sample) ? Math.max(0, Math.min(0.999999999, sample)) : 0;
|
||||||
const index = Math.floor(randomValue * pool.length);
|
const index = Math.floor(randomValue * compatiblePool.length);
|
||||||
bosses.push(pool[index]);
|
const selected = compatiblePool[index];
|
||||||
pool.splice(index, 1);
|
bosses.push(selected);
|
||||||
|
pool.splice(pool.indexOf(selected), 1);
|
||||||
}
|
}
|
||||||
return bosses;
|
return bosses;
|
||||||
}
|
}
|
||||||
@@ -245,6 +252,8 @@ export function selectRandomBossPair(
|
|||||||
const eligibleBosses = AVAILABLE_BOSS_IDS.filter((bossId) => !excluded.has(bossId));
|
const eligibleBosses = AVAILABLE_BOSS_IDS.filter((bossId) => !excluded.has(bossId));
|
||||||
const pool = eligibleBosses.length >= 2 ? eligibleBosses : AVAILABLE_BOSS_IDS;
|
const pool = eligibleBosses.length >= 2 ? eligibleBosses : AVAILABLE_BOSS_IDS;
|
||||||
const firstIndex = Math.floor(random() * pool.length) % pool.length;
|
const firstIndex = Math.floor(random() * pool.length) % pool.length;
|
||||||
const secondOffset = 1 + (Math.floor(random() * (pool.length - 1)) % (pool.length - 1));
|
const first = pool[firstIndex];
|
||||||
return [pool[firstIndex], pool[(firstIndex + secondOffset) % pool.length]];
|
const compatiblePool = pool.filter((bossId) => canAddBossToEncounter([first], bossId));
|
||||||
|
const secondIndex = Math.floor(random() * compatiblePool.length) % compatiblePool.length;
|
||||||
|
return [first, compatiblePool[secondIndex]];
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-3
@@ -6,6 +6,7 @@ import { createClassInventory, HEALER_CLASSES } from "./healers";
|
|||||||
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
|
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
|
||||||
import { ARENA_CENTER, isInsideArena } from "./arena";
|
import { ARENA_CENTER, isInsideArena } from "./arena";
|
||||||
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
||||||
|
import { BOSS_DEATH_DESPAWN_SECONDS } from "./bossDeath";
|
||||||
import { RUN_BUFF_ORDER, RUN_BUFFS, compileRunModifiers } from "./roguelike";
|
import { RUN_BUFF_ORDER, RUN_BUFFS, compileRunModifiers } from "./roguelike";
|
||||||
import type { RunBuffRanks } from "./types";
|
import type { RunBuffRanks } from "./types";
|
||||||
|
|
||||||
@@ -192,6 +193,26 @@ describe("Disc Priest combat simulation", () => {
|
|||||||
expect(paused.castAbility("renew")).toBe(false);
|
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", () => {
|
it("does not publish unchanged player positions while idle", () => {
|
||||||
let updates = 0;
|
let updates = 0;
|
||||||
const unsubscribe = useGameStore.subscribe(() => { updates += 1; });
|
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", () => {
|
describe("Broodfang encounter", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "broodfang-spider");
|
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] });
|
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.getState().setPlayerPosition([0, 0]);
|
||||||
useGameStore.setState((state) => ({
|
useGameStore.setState((state) => ({
|
||||||
boss: { ...state.boss, nextMeleeAt: 999 },
|
boss: { ...state.boss, nextMeleeAt: 999 },
|
||||||
@@ -362,7 +441,10 @@ describe("Broodfang encounter", () => {
|
|||||||
}));
|
}));
|
||||||
const startingHp = useGameStore.getState().party[0].hp;
|
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;
|
const firstTickHp = useGameStore.getState().party[0].hp;
|
||||||
useGameStore.getState().tick(1);
|
useGameStore.getState().tick(1);
|
||||||
const secondTickHp = useGameStore.getState().party[0].hp;
|
const secondTickHp = useGameStore.getState().party[0].hp;
|
||||||
@@ -427,6 +509,23 @@ describe("PVE dual-boss encounter", () => {
|
|||||||
useGameStore.getState().tick(1);
|
useGameStore.getState().tick(1);
|
||||||
expect(useGameStore.getState().phase).toBe("victory");
|
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", () => {
|
describe("Roguelike ability buffs", () => {
|
||||||
@@ -742,6 +841,11 @@ describe("Rogue Trials", () => {
|
|||||||
const defeatedInstanceId = defeated.bossInstanceId;
|
const defeatedInstanceId = defeated.bossInstanceId;
|
||||||
|
|
||||||
useGameStore.getState().tick(0.05);
|
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();
|
const replaced = useGameStore.getState();
|
||||||
expect(replaced.phase).toBe("combat");
|
expect(replaced.phase).toBe("combat");
|
||||||
expect(replaced.boss.hp).toBe(replaced.boss.maxHp);
|
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);
|
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) => ({
|
useGameStore.setState((state) => ({
|
||||||
round: 5,
|
round: 5,
|
||||||
phase: "victory",
|
phase: "victory",
|
||||||
@@ -763,6 +867,13 @@ describe("Rogue Trials", () => {
|
|||||||
party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 0 } : member),
|
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);
|
useGameStore.getState().tick(0.05);
|
||||||
expect(useGameStore.getState().phase).toBe("defeat");
|
expect(useGameStore.getState().phase).toBe("defeat");
|
||||||
expect(useGameStore.getState().endlessBossKills).toBe(7);
|
expect(useGameStore.getState().endlessBossKills).toBe(7);
|
||||||
|
|||||||
+24
-11
@@ -7,6 +7,8 @@ import {
|
|||||||
upcomingMechanic,
|
upcomingMechanic,
|
||||||
} from "./bossMechanics";
|
} from "./bossMechanics";
|
||||||
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
||||||
|
import { BOSS_DEATH_DESPAWN_SECONDS } from "./bossDeath";
|
||||||
|
import { normalizeEncounterBossIds } from "./bossSelection";
|
||||||
import { clampToArena, constrainBossMotion } from "./arena";
|
import { clampToArena, constrainBossMotion } from "./arena";
|
||||||
import { cloneMotion } from "./bosses/shared";
|
import { cloneMotion } from "./bosses/shared";
|
||||||
import { freshParty } from "./data";
|
import { freshParty } from "./data";
|
||||||
@@ -14,6 +16,7 @@ import { distance } from "./geometry";
|
|||||||
import { createClassInventory, HEALER_CLASSES } from "./healers";
|
import { createClassInventory, HEALER_CLASSES } from "./healers";
|
||||||
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
|
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
|
||||||
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
|
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
|
||||||
|
import { isPartyWiped } from "./partyState";
|
||||||
import {
|
import {
|
||||||
RUN_BUFFS,
|
RUN_BUFFS,
|
||||||
bossHealthMultiplier,
|
bossHealthMultiplier,
|
||||||
@@ -150,8 +153,7 @@ export const BARRIER_DAMAGE_REDUCTION = 0.3;
|
|||||||
|
|
||||||
const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): BossId[] => {
|
const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): BossId[] => {
|
||||||
const requested = typeof bossIds === "string" ? [bossIds] : [...bossIds];
|
const requested = typeof bossIds === "string" ? [bossIds] : [...bossIds];
|
||||||
const unique = requested.filter((bossId, index) => requested.indexOf(bossId) === index).slice(0, 3);
|
return normalizeEncounterBossIds(requested);
|
||||||
return unique.length ? unique : ["bulldrome"];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function createEncounterMotion(bossId: BossId, index: number, count: number): BossMotionState {
|
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.phase !== "combat") return false;
|
||||||
if (state.paused) return false;
|
if (state.paused) return false;
|
||||||
if (state.activeCast) 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 ability = HEALER_CLASSES[state.healerClassId].abilities[abilityId];
|
||||||
const manaCost = runAbilityManaCost(abilityId, ability.mana, state.runModifiers);
|
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);
|
const primaryNames = party[selectedIndex].debuffs.map((debuff) => debuff.name);
|
||||||
for (const index of cleanseIndexes) {
|
for (const index of cleanseIndexes) {
|
||||||
const target = party[index];
|
const target = party[index];
|
||||||
const dispelledNames = target.debuffs.map((debuff) => debuff.name);
|
const dispelledDebuffs = target.debuffs;
|
||||||
const primaryDispel = handleBossDispel(state.boss.id, bossMotion, target.id, state.partyPositions[target.id], state.time, dispelledNames);
|
const primaryDispel = handleBossDispel(state.boss.id, bossMotion, target.id, state.partyPositions[target.id], state.time, dispelledDebuffs);
|
||||||
bossMotion = primaryDispel.motion;
|
bossMotion = primaryDispel.motion;
|
||||||
additionalBosses = additionalBosses.map((entry) => {
|
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 };
|
return { ...entry, motion: dispel.motion };
|
||||||
});
|
});
|
||||||
party[index] = { ...party[index], debuffs: [] };
|
party[index] = { ...party[index], debuffs: [] };
|
||||||
@@ -671,6 +675,8 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
let endlessBossKills = state.endlessBossKills;
|
let endlessBossKills = state.endlessBossKills;
|
||||||
let endlessSpawnSequence = state.endlessSpawnSequence;
|
let endlessSpawnSequence = state.endlessSpawnSequence;
|
||||||
|
|
||||||
|
if (!party.some((member) => member.id === "aelia" && member.hp > 0)) activeCast = null;
|
||||||
|
|
||||||
if (state.endlessMode) {
|
if (state.endlessMode) {
|
||||||
const slots: AdditionalBossState[] = [
|
const slots: AdditionalBossState[] = [
|
||||||
{ instanceId: bossInstanceId, boss, motion: bossMotion },
|
{ 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) {
|
for (let index = 0; index < slots.length; index += 1) {
|
||||||
if (slots[index].boss.hp > 0) continue;
|
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
|
const activeBossIds = slots
|
||||||
.filter((entry, slotIndex) => slotIndex !== index && entry.boss.hp > 0)
|
.filter((entry, slotIndex) => slotIndex !== index && entry.boss.hp > 0)
|
||||||
.map((entry) => entry.boss.id);
|
.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;
|
endlessSpawnSequence += 1;
|
||||||
const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
|
const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
|
||||||
const replacement = createEncounterBoss(
|
const replacement = createEncounterBoss(
|
||||||
@@ -816,12 +825,16 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
const target = encounterBosses.find((entry) => entry.instanceId === event.targetInstanceId);
|
const target = encounterBosses.find((entry) => entry.instanceId === event.targetInstanceId);
|
||||||
if (target) target.boss.hp = Math.max(0, target.boss.hp - event.amount);
|
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;
|
boss = encounterBosses[0].boss;
|
||||||
bossInstanceId = encounterBosses[0].instanceId;
|
bossInstanceId = encounterBosses[0].instanceId;
|
||||||
bossMotion = encounterBosses[0].motion;
|
bossMotion = encounterBosses[0].motion;
|
||||||
additionalBosses = encounterBosses.slice(1);
|
additionalBosses = encounterBosses.slice(1);
|
||||||
const tank = party.find((member) => member.id === "brann")!;
|
|
||||||
const healer = party.find((member) => member.id === "aelia")!;
|
const healer = party.find((member) => member.id === "aelia")!;
|
||||||
|
if (healer.hp <= 0) activeCast = null;
|
||||||
|
const partyWiped = isPartyWiped(party);
|
||||||
let phase: GamePhase = state.phase;
|
let phase: GamePhase = state.phase;
|
||||||
let runBuffInputUnlockAt = state.runBuffInputUnlockAt;
|
let runBuffInputUnlockAt = state.runBuffInputUnlockAt;
|
||||||
let newlyDefeatedBossCount = 0;
|
let newlyDefeatedBossCount = 0;
|
||||||
@@ -837,9 +850,9 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
endlessBossKills += newlyDefeatedBossCount;
|
endlessBossKills += newlyDefeatedBossCount;
|
||||||
if (state.endlessMode && (tank.hp <= 0 || healer.hp <= 0)) {
|
if (state.endlessMode && partyWiped) {
|
||||||
phase = "defeat";
|
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) {
|
} else if (state.endlessMode) {
|
||||||
phase = "combat";
|
phase = "combat";
|
||||||
} else if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
|
} 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";
|
phase = state.runMode !== "encounter" && !rogueTrialsComplete ? "intermission" : "victory";
|
||||||
if (phase === "intermission") runBuffInputUnlockAt = Date.now() + RUN_BUFF_INPUT_LOCK_MS;
|
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");
|
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";
|
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({
|
set({
|
||||||
|
|||||||
+5
-1
@@ -27,7 +27,8 @@ export type BossId =
|
|||||||
| "bristlequake-boar"
|
| "bristlequake-boar"
|
||||||
| "moonfang-wolf"
|
| "moonfang-wolf"
|
||||||
| "frostmaw-yeti"
|
| "frostmaw-yeti"
|
||||||
| "rimeclaw-yeti";
|
| "rimeclaw-yeti"
|
||||||
|
| "gravehorn-triceratops";
|
||||||
export type BossMechanicId =
|
export type BossMechanicId =
|
||||||
| "basic-melee"
|
| "basic-melee"
|
||||||
| "bull-charge"
|
| "bull-charge"
|
||||||
@@ -198,6 +199,7 @@ export interface Debuff {
|
|||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
nextTickAt: number;
|
nextTickAt: number;
|
||||||
tickDamage: number;
|
tickDamage: number;
|
||||||
|
sourceBossId?: BossId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PartyMember {
|
export interface PartyMember {
|
||||||
@@ -221,6 +223,8 @@ export interface BossState {
|
|||||||
maxHp: number;
|
maxHp: number;
|
||||||
hp: number;
|
hp: number;
|
||||||
nextMeleeAt: number;
|
nextMeleeAt: number;
|
||||||
|
/** Simulation time when health first reached zero. Used for delayed endless replacement. */
|
||||||
|
defeatedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BossMotionState {
|
export interface BossMotionState {
|
||||||
|
|||||||
+94
-32
@@ -20,6 +20,7 @@
|
|||||||
/* Android lays out at logical CSS size, not AMOLED framebuffer resolution. */
|
/* Android lays out at logical CSS size, not AMOLED framebuffer resolution. */
|
||||||
--thor-main-css-width: 960px;
|
--thor-main-css-width: 960px;
|
||||||
--thor-secondary-width-ratio: 64.583333%;
|
--thor-secondary-width-ratio: 64.583333%;
|
||||||
|
--thor-top-bottom-overscan: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -1362,6 +1363,13 @@ button:focus-visible {
|
|||||||
background: #030706;
|
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 {
|
.native-platform .surface-slot.is-active {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
@@ -1668,32 +1676,36 @@ button:focus-visible {
|
|||||||
.login-panel .front-secondary { min-height: 40px; padding-top: 6px; padding-bottom: 6px; }
|
.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 > .front-notice { position: absolute; right: 44px; bottom: 83px; width: 300px; }
|
||||||
.login-surface > .controller-legend { position: absolute; right: 44px; bottom: 47px; }
|
.login-surface > .controller-legend { position: absolute; right: 44px; bottom: 47px; }
|
||||||
.login-context { padding: 0; }
|
.login-save-context { padding: 0 5.5% 18px; }
|
||||||
.login-context > .front-brand { margin: 22px 5.5% 0; }
|
.login-save-context .context-header { margin: 0 -5.8%; }
|
||||||
.offline-promise { margin: 32px 7% 0; }
|
.login-save-list { display: grid; gap: 9px; margin-top: 16px; }
|
||||||
.context-kicker { color: var(--gold); font-size: 9px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; }
|
.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)); }
|
||||||
.offline-promise ol { display: grid; gap: 13px; margin: 14px 0 0; padding: 0; list-style: none; }
|
.login-save-list article.is-empty { grid-template-columns: 30px minmax(0, 1fr); border-left-color: #425850; background: rgba(6,16,13,.62); }
|
||||||
.offline-promise li { display: grid; grid-template-columns: 35px 1fr; align-items: center; gap: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
|
.login-save-list article > b { color: #61776e; font-family: "Cinzel", serif; font-size: 15px; font-weight: 500; }
|
||||||
.offline-promise li > b { color: #536a61; font-family: "Cinzel", serif; font-size: 17px; }
|
.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; }
|
||||||
.offline-promise li > span { display: grid; }
|
.login-save-list article > span { min-width: 0; display: grid; }
|
||||||
.offline-promise li strong { font-size: clamp(11px, 2.35cqw, 14px); letter-spacing: 0.03em; }
|
.login-save-list article small { color: #6d8279; font-size: clamp(7px, 1.45cqw, 9px); font-style: normal; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
.offline-promise li small { color: #748a81; font-size: clamp(8px, 1.7cqw, 10px); }
|
.login-save-list article span > strong { overflow: hidden; font: 500 clamp(13px, 2.8cqw, 17px) "Cinzel", serif; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.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; }
|
.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; }
|
||||||
.device-route i { color: var(--gold); font-style: normal; }
|
.login-save-list time { min-width: 86px; display: grid; justify-items: end; }
|
||||||
.device-route b { padding: 7px 10px; border: 1px solid #486159; color: #b8c9c2; background: #0b1b17; font-size: 8px; }
|
.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 management */
|
||||||
|
|
||||||
.save-surface { padding: 0 30px; }
|
.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-grid { height: 326px; 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 { 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::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 > * { position: relative; }
|
||||||
.save-slot:hover,
|
.save-slot:hover,
|
||||||
.save-slot.is-selected { border-color: rgba(232,200,114,0.68); transform: translateY(-4px); }
|
.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; }
|
.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-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-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 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-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 { display: grid; margin-top: 12px; }
|
||||||
.slot-name strong { font-family: "Cinzel", serif; font-size: 17px; font-weight: 500; }
|
.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 b { color: #6f8c81; font-size: 36px; font-weight: 300; }
|
||||||
.empty-slot strong { font-family: "Cinzel", serif; font-size: 14px; font-weight: 500; }
|
.empty-slot strong { font-family: "Cinzel", serif; font-size: 14px; font-weight: 500; }
|
||||||
.empty-slot small { color: #5e736b; font-size: 8px; text-transform: uppercase; }
|
.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-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 { 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::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; }
|
.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 { 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 small { display: block; color: #748a81; font-size: 7px; text-transform: uppercase; }
|
||||||
.dialog-actions button.is-danger { border-color: #d76551; color: #fff; background: #8e3025; }
|
.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 { padding: 0 5.5% 18px; }
|
||||||
.save-context .context-header { margin: 0 -5.8%; }
|
.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); }
|
.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 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 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; }
|
.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); }
|
.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); }
|
||||||
.online-record span { display: flex; gap: 7px; }
|
.save-dossier-stats > span { min-width: 0; display: grid; padding: 11px 10px; border-right: 1px solid var(--line); }
|
||||||
.online-record b { color: #6ad0a8; font-size: 7px; letter-spacing: 0.1em; }
|
.save-dossier-stats > span:last-child { border: 0; }
|
||||||
.online-record time { color: #6f847c; font-size: 8px; }
|
.save-dossier-stats small,
|
||||||
.save-actions { display: grid; gap: 8px; margin-top: 10px; }
|
.save-dossier-records small { color: #62776f; font-size: clamp(6px, 1.35cqw, 8px); letter-spacing: .07em; text-transform: uppercase; }
|
||||||
.save-actions .front-primary { min-height: 42px; }
|
.save-dossier-stats strong { color: var(--gold-strong); font: 500 clamp(15px, 3.1cqw, 19px) "Cinzel", serif; }
|
||||||
.sync-actions,
|
.save-dossier-stats em { overflow: hidden; color: #80958d; font-size: clamp(7px, 1.5cqw, 9px); font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.record-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
|
.save-dossier-records { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
|
||||||
.sync-actions button,
|
.save-dossier-records > span { display: grid; padding: 9px 11px; border-left: 2px solid rgba(232,200,114,.45); background: rgba(46,40,21,.24); }
|
||||||
.record-actions button { min-height: 34px; padding: 6px 7px; font-size: clamp(8px, 1.7cqw, 10px); font-weight: 700; }
|
.save-dossier-records b { color: #c7d6d0; font-size: clamp(9px, 1.9cqw, 11px); }
|
||||||
.record-actions { grid-template-columns: 1fr 1fr 0.7fr; }
|
.save-copy-state { display: grid; gap: 6px; margin-top: 12px; }
|
||||||
.record-actions .danger-link { color: #ed8c78; }
|
.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); }
|
.front-notice.is-lower { margin-top: auto; font-size: clamp(7px, 1.55cqw, 9px); }
|
||||||
|
|
||||||
/* Main menu */
|
/* Main menu */
|
||||||
@@ -2067,7 +2106,7 @@ button:focus-visible {
|
|||||||
.profile-header { grid-template-columns: 125px minmax(0, 1fr) auto auto; }
|
.profile-header { grid-template-columns: 125px minmax(0, 1fr) auto auto; }
|
||||||
.profile-view-tabs { gap: 2px; }
|
.profile-view-tabs { gap: 2px; }
|
||||||
.profile-view-tabs button { min-height: 22px; padding: 3px 5px; font-size: 5px; }
|
.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; }
|
.save-slot { height: 84%; padding: 7px 5px; }
|
||||||
.slot-portrait { width: 36px; height: 36px; margin-top: 7px; font-size: 14px; }
|
.slot-portrait { width: 36px; height: 36px; margin-top: 7px; font-size: 14px; }
|
||||||
.slot-portrait i { width: 13px; height: 13px; font-size: 5px; }
|
.slot-portrait i { width: 13px; height: 13px; font-size: 5px; }
|
||||||
@@ -2077,8 +2116,31 @@ button:focus-visible {
|
|||||||
.slot-meta b { font-size: 7px; }
|
.slot-meta b { font-size: 7px; }
|
||||||
.empty-slot b { font-size: 19px; }
|
.empty-slot b { font-size: 19px; }
|
||||||
.empty-slot strong { font-size: 8px; }
|
.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; }
|
.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 { height: 35px; }
|
||||||
.home-header > span, .home-header > i { font-size: 5px; }
|
.home-header > span, .home-header > i { font-size: 5px; }
|
||||||
.mode-grid { grid-template-rows: repeat(2, 43px); gap: 5px; margin-top: 6px; }
|
.mode-grid { grid-template-rows: repeat(2, 43px); gap: 5px; margin-top: 6px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user