Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b31480bec | ||
|
|
ed34c2503c | ||
|
|
8e48bb4fb6 | ||
|
|
fb41147197 | ||
|
|
aa9f5eff20 |
@@ -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.8",
|
"version": "0.1.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"predev": "node scripts/sync_basis_transcoder.mjs",
|
||||||
"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": "node scripts/sync_basis_transcoder.mjs",
|
||||||
"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,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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-20
@@ -8,7 +8,6 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -339,17 +338,6 @@ def create_release(tag: str, commit: str, message: str, token: str) -> dict[str,
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def multipart_asset(path: Path, media_type: str) -> tuple[bytes, str]:
|
|
||||||
boundary = f"----iwanttoheal{secrets.token_hex(12)}"
|
|
||||||
prefix = (
|
|
||||||
f"--{boundary}\r\n"
|
|
||||||
f'Content-Disposition: form-data; name="attachment"; filename="{path.name}"\r\n'
|
|
||||||
f"Content-Type: {media_type}\r\n\r\n"
|
|
||||||
).encode()
|
|
||||||
body = prefix + path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
|
|
||||||
return body, f"multipart/form-data; boundary={boundary}"
|
|
||||||
|
|
||||||
|
|
||||||
def upload_release_asset(
|
def upload_release_asset(
|
||||||
release_id: int,
|
release_id: int,
|
||||||
path: Path,
|
path: Path,
|
||||||
@@ -357,15 +345,46 @@ def upload_release_asset(
|
|||||||
media_type: str,
|
media_type: str,
|
||||||
token: str,
|
token: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
body, content_type = multipart_asset(path, media_type)
|
curl = shutil.which("curl")
|
||||||
gitea_request(
|
if not curl:
|
||||||
f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets"
|
raise SystemExit("curl is required to upload Gitea release assets")
|
||||||
f"?name={urllib.parse.quote(path.name)}",
|
|
||||||
token=token,
|
url = (
|
||||||
method="POST",
|
f"{GITEA_API}/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets"
|
||||||
body=body,
|
f"?name={urllib.parse.quote(path.name)}"
|
||||||
content_type=content_type,
|
|
||||||
)
|
)
|
||||||
|
headers = (
|
||||||
|
"Accept: application/json\n"
|
||||||
|
f"Authorization: token {token}\n"
|
||||||
|
"Expect: 100-continue\n"
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
curl,
|
||||||
|
"--silent",
|
||||||
|
"--show-error",
|
||||||
|
"--fail-with-body",
|
||||||
|
"--connect-timeout",
|
||||||
|
"30",
|
||||||
|
"--max-time",
|
||||||
|
"300",
|
||||||
|
"--header",
|
||||||
|
"@-",
|
||||||
|
"--form",
|
||||||
|
f"attachment=@{path};type={media_type}",
|
||||||
|
url,
|
||||||
|
],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
input=headers,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
details = result.stdout.strip() or result.stderr.strip()
|
||||||
|
raise SystemExit(
|
||||||
|
f"Gitea release asset upload failed (curl {result.returncode}): {details}"
|
||||||
|
)
|
||||||
print(f"Release asset uploaded: {path.name}")
|
print(f"Release asset uploaded: {path.name}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
+360
-70
@@ -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 { bossBurrowPositionY, bossIsBurrowing } from "./boss/bossBurrowVisuals";
|
||||||
import { bossCanTrackTarget, bossDeathOpacity } from "./boss/bossDeathVisuals";
|
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",
|
||||||
@@ -91,8 +133,11 @@ function createBossRenderModel(source: THREE.Object3D) {
|
|||||||
? object.material.map(cloneMaterial)
|
? object.material.map(cloneMaterial)
|
||||||
: cloneMaterial(object.material);
|
: cloneMaterial(object.material);
|
||||||
});
|
});
|
||||||
|
model.updateMatrixWorld(true);
|
||||||
|
const modelTopY = new THREE.Box3().setFromObject(model).max.y;
|
||||||
return {
|
return {
|
||||||
model,
|
model,
|
||||||
|
modelTopY,
|
||||||
fadeMaterials: [...materialClones.values()].map((material): BossFadeMaterial => ({
|
fadeMaterials: [...materialClones.values()].map((material): BossFadeMaterial => ({
|
||||||
material,
|
material,
|
||||||
baseOpacity: material.opacity,
|
baseOpacity: material.opacity,
|
||||||
@@ -227,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(
|
||||||
@@ -793,10 +838,12 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
|||||||
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 light = useRef<THREE.PointLight>(null);
|
||||||
const gltf = useGLTF(config.url, false, true);
|
const assetUrl = selectedGameAssetUrl(config.url, config.optimizedUrl ?? config.url);
|
||||||
const { model, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]);
|
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(() => {
|
||||||
return () => { for (const entry of fadeMaterials) entry.material.dispose(); };
|
return () => { for (const entry of fadeMaterials) entry.material.dispose(); };
|
||||||
@@ -833,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;
|
||||||
|
|
||||||
@@ -954,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(), []);
|
||||||
@@ -962,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)!;
|
||||||
@@ -973,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);
|
||||||
@@ -985,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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1035,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);
|
||||||
@@ -1140,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1184,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");
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ 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", () => {
|
||||||
@@ -66,6 +66,18 @@ describe("boss catalog", () => {
|
|||||||
expect(ALTERNATE_BOSS_CONFIG["bristlequake-boar"].death).toBe("Dying");
|
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", () => {
|
it("uses original animated creatures instead of the retired chicken and frog visuals", () => {
|
||||||
expect(ALTERNATE_BOSS_CONFIG["cluckhorn-colossus"]).toMatchObject({
|
expect(ALTERNATE_BOSS_CONFIG["cluckhorn-colossus"]).toMatchObject({
|
||||||
idle: "Idle", move: "Scuttle", attack: "BeakRend", special: "FurnaceBurst", death: "Death",
|
idle: "Idle", move: "Scuttle", attack: "BeakRend", special: "FurnaceBurst", death: "Death",
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -21,6 +22,8 @@ const SANDGLASS_URL = new URL("../assets/game/models/original/bosses/sandglass-s
|
|||||||
const CRYSTAL_BAT_MATRIARCH_URL = new URL("../assets/game/models/original/bosses/crystal-bat-matriarch/crystal-bat-matriarch.glb", import.meta.url).href;
|
const 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 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 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;
|
||||||
@@ -34,6 +37,7 @@ const CLAUDE_BOSS_URLS: Record<Exclude<BossId,
|
|||||||
| "crystal-bat-matriarch"
|
| "crystal-bat-matriarch"
|
||||||
| "cluckhorn-colossus"
|
| "cluckhorn-colossus"
|
||||||
| "mirelord-frog"
|
| "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,
|
||||||
"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,
|
||||||
@@ -84,8 +88,11 @@ export const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfi
|
|||||||
"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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
+2
-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"
|
||||||
|
|||||||
Reference in New Issue
Block a user