Release v0.1.9 2026-07-13
This commit is contained in:
@@ -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.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"predev": "pnpm assets:sync-basis-transcoder",
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"dev:api": "HOST=127.0.0.1 PORT=4174 node server/production.mjs",
|
"dev:api": "HOST=127.0.0.1 PORT=4174 node server/production.mjs",
|
||||||
"db:backup": "node scripts/backup-db.mjs",
|
"db:backup": "node scripts/backup-db.mjs",
|
||||||
"db:init": "node scripts/init-db.mjs",
|
"db:init": "node scripts/init-db.mjs",
|
||||||
|
"prebuild": "pnpm assets:sync-basis-transcoder",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"android:sync": "pnpm run build && cap sync android",
|
"android:sync": "pnpm run build && cap sync android",
|
||||||
"android:sync:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com pnpm run android:sync",
|
"android:sync:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com pnpm run android:sync",
|
||||||
@@ -18,8 +20,12 @@
|
|||||||
"publish:gitea": "python3 scripts/publish_gitea.py",
|
"publish:gitea": "python3 scripts/publish_gitea.py",
|
||||||
"test": "vitest run && node --test server/game-api.test.mjs",
|
"test": "vitest run && node --test server/game-api.test.mjs",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
"assets:build-dungeon-kit": "node scripts/build_dungeon_kit.mjs",
|
||||||
|
"assets:build-gravehorn": "node scripts/build_gravehorn_triceratops.mjs",
|
||||||
|
"assets:build-ktx2": "node scripts/build_ktx2_game_assets.mjs",
|
||||||
"assets:prune-party-animations": "node scripts/prune_party_animations.mjs --write",
|
"assets:prune-party-animations": "node scripts/prune_party_animations.mjs --write",
|
||||||
"assets:import": "node scripts/import-game-asset.mjs"
|
"assets:import": "node scripts/import-game-asset.mjs",
|
||||||
|
"assets:sync-basis-transcoder": "node scripts/sync_basis_transcoder.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
@@ -29,17 +35,20 @@
|
|||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"three": "^0.179.1",
|
"three": "^0.179.1",
|
||||||
|
"three-stdlib": "2.36.1",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "8.4.1",
|
"@capacitor/cli": "8.4.1",
|
||||||
"@gltf-transform/core": "^4.4.1",
|
"@gltf-transform/core": "^4.4.1",
|
||||||
"@gltf-transform/extensions": "^4.4.1",
|
"@gltf-transform/extensions": "^4.4.1",
|
||||||
|
"@gltf-transform/functions": "4.4.1",
|
||||||
"@types/react": "^19.1.10",
|
"@types/react": "^19.1.10",
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.1.7",
|
||||||
"@types/three": "^0.179.0",
|
"@types/three": "^0.179.0",
|
||||||
"@vitejs/plugin-react": "^5.0.2",
|
"@vitejs/plugin-react": "^5.0.2",
|
||||||
"meshoptimizer": "^1.2.0",
|
"meshoptimizer": "^1.2.0",
|
||||||
|
"sharp": "0.34.5",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^7.1.3",
|
"vite": "^7.1.3",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
|
|||||||
Generated
+379
@@ -29,6 +29,9 @@ importers:
|
|||||||
three:
|
three:
|
||||||
specifier: ^0.179.1
|
specifier: ^0.179.1
|
||||||
version: 0.179.1
|
version: 0.179.1
|
||||||
|
three-stdlib:
|
||||||
|
specifier: 2.36.1
|
||||||
|
version: 2.36.1(three@0.179.1)
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.8
|
specifier: ^5.0.8
|
||||||
version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
|
version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
|
||||||
@@ -42,6 +45,9 @@ importers:
|
|||||||
'@gltf-transform/extensions':
|
'@gltf-transform/extensions':
|
||||||
specifier: ^4.4.1
|
specifier: ^4.4.1
|
||||||
version: 4.4.1
|
version: 4.4.1
|
||||||
|
'@gltf-transform/functions':
|
||||||
|
specifier: 4.4.1
|
||||||
|
version: 4.4.1
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.1.10
|
specifier: ^19.1.10
|
||||||
version: 19.2.17
|
version: 19.2.17
|
||||||
@@ -57,6 +63,9 @@ importers:
|
|||||||
meshoptimizer:
|
meshoptimizer:
|
||||||
specifier: ^1.2.0
|
specifier: ^1.2.0
|
||||||
version: 1.2.0
|
version: 1.2.0
|
||||||
|
sharp:
|
||||||
|
specifier: 0.34.5
|
||||||
|
version: 0.34.5
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ~5.8.3
|
specifier: ~5.8.3
|
||||||
version: 5.8.3
|
version: 5.8.3
|
||||||
@@ -172,6 +181,9 @@ packages:
|
|||||||
'@dimforge/rapier3d-compat@0.12.0':
|
'@dimforge/rapier3d-compat@0.12.0':
|
||||||
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.11.2':
|
||||||
|
resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.28.1':
|
'@esbuild/aix-ppc64@0.28.1':
|
||||||
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -334,6 +346,162 @@ packages:
|
|||||||
'@gltf-transform/extensions@4.4.1':
|
'@gltf-transform/extensions@4.4.1':
|
||||||
resolution: {integrity: sha512-dZZ9D/NdpNeJUmQKExtISYtd3W6OxU4njk8UI3IKm6j97uVskYQ24BNi2YP40uUpdWPiREsS/DhjNhWAbGU/1A==}
|
resolution: {integrity: sha512-dZZ9D/NdpNeJUmQKExtISYtd3W6OxU4njk8UI3IKm6j97uVskYQ24BNi2YP40uUpdWPiREsS/DhjNhWAbGU/1A==}
|
||||||
|
|
||||||
|
'@gltf-transform/functions@4.4.1':
|
||||||
|
resolution: {integrity: sha512-CAU6hczuRz7NIEtpn7BARuwVEwRawwIcGqmoMCaEwA4XC+CSUi3xptXuf2zDG/5AftFO7IeQxXl/56rDsck0GQ==}
|
||||||
|
|
||||||
|
'@img/colour@1.1.0':
|
||||||
|
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||||
|
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||||
|
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.34.5':
|
||||||
|
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-ppc64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-riscv64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-s390x@0.34.5':
|
||||||
|
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-wasm32@0.34.5':
|
||||||
|
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [wasm32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-ia32@0.34.5':
|
||||||
|
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@ionic/cli-framework-output@2.2.8':
|
'@ionic/cli-framework-output@2.2.8':
|
||||||
resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==}
|
resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
@@ -601,6 +769,9 @@ packages:
|
|||||||
'@types/fs-extra@8.1.5':
|
'@types/fs-extra@8.1.5':
|
||||||
resolution: {integrity: sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==}
|
resolution: {integrity: sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==}
|
||||||
|
|
||||||
|
'@types/ndarray@1.0.14':
|
||||||
|
resolution: {integrity: sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg==}
|
||||||
|
|
||||||
'@types/node@26.1.1':
|
'@types/node@26.1.1':
|
||||||
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
|
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
|
||||||
|
|
||||||
@@ -791,6 +962,9 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
cwise-compiler@1.1.3:
|
||||||
|
resolution: {integrity: sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -811,6 +985,10 @@ packages:
|
|||||||
detect-gpu@5.0.70:
|
detect-gpu@5.0.70:
|
||||||
resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==}
|
resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==}
|
||||||
|
|
||||||
|
detect-libc@2.1.2:
|
||||||
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
draco3d@1.5.7:
|
draco3d@1.5.7:
|
||||||
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
|
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
|
||||||
|
|
||||||
@@ -908,6 +1086,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==}
|
resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==}
|
||||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||||
|
|
||||||
|
iota-array@1.0.0:
|
||||||
|
resolution: {integrity: sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==}
|
||||||
|
|
||||||
|
is-buffer@1.1.6:
|
||||||
|
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
|
||||||
|
|
||||||
is-docker@2.2.1:
|
is-docker@2.2.1:
|
||||||
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
|
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1020,6 +1204,18 @@ packages:
|
|||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
ndarray-lanczos@0.3.0:
|
||||||
|
resolution: {integrity: sha512-5kBmmG3Zvyj77qxIAC4QFLKuYdDIBJwCG+DukT6jQHNa1Ft74/hPH1z5mbQXeHBt8yvGPBGVrr3wEOdJPYYZYg==}
|
||||||
|
|
||||||
|
ndarray-ops@1.2.2:
|
||||||
|
resolution: {integrity: sha512-BppWAFRjMYF7N/r6Ie51q6D4fs0iiGmeXIACKY66fLpnwIui3Wc3CXiD/30mgLbDjPpSLrsqcp3Z62+IcHZsDw==}
|
||||||
|
|
||||||
|
ndarray-pixels@5.0.1:
|
||||||
|
resolution: {integrity: sha512-IBtrpefpqlI8SPDCGjXk4v5NV5z7r3JSuCbfuEEXaM0vrOJtNGgYUa4C3Lt5H+qWdYF4BCPVFsnXhNC7QvZwkw==}
|
||||||
|
|
||||||
|
ndarray@1.0.19:
|
||||||
|
resolution: {integrity: sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==}
|
||||||
|
|
||||||
node-releases@2.0.50:
|
node-releases@2.0.50:
|
||||||
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
|
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1139,6 +1335,10 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
sharp@0.34.5:
|
||||||
|
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1275,6 +1475,9 @@ packages:
|
|||||||
undici-types@8.3.0:
|
undici-types@8.3.0:
|
||||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||||
|
|
||||||
|
uniq@1.0.1:
|
||||||
|
resolution: {integrity: sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==}
|
||||||
|
|
||||||
universalify@2.0.1:
|
universalify@2.0.1:
|
||||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
@@ -1597,6 +1800,11 @@ snapshots:
|
|||||||
|
|
||||||
'@dimforge/rapier3d-compat@0.12.0': {}
|
'@dimforge/rapier3d-compat@0.12.0': {}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.11.2':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.28.1':
|
'@esbuild/aix-ppc64@0.28.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1684,6 +1892,111 @@ snapshots:
|
|||||||
'@gltf-transform/core': 4.4.1
|
'@gltf-transform/core': 4.4.1
|
||||||
ktx-parse: 1.1.0
|
ktx-parse: 1.1.0
|
||||||
|
|
||||||
|
'@gltf-transform/functions@4.4.1':
|
||||||
|
dependencies:
|
||||||
|
'@gltf-transform/core': 4.4.1
|
||||||
|
'@gltf-transform/extensions': 4.4.1
|
||||||
|
ktx-parse: 1.1.0
|
||||||
|
ndarray: 1.0.19
|
||||||
|
ndarray-lanczos: 0.3.0
|
||||||
|
ndarray-pixels: 5.0.1
|
||||||
|
|
||||||
|
'@img/colour@1.1.0': {}
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-ppc64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-riscv64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-s390x@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-x64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-wasm32@0.34.5':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/runtime': 1.11.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-arm64@0.34.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-ia32@0.34.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.34.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@ionic/cli-framework-output@2.2.8':
|
'@ionic/cli-framework-output@2.2.8':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ionic/utils-terminal': 2.3.5
|
'@ionic/utils-terminal': 2.3.5
|
||||||
@@ -1958,6 +2271,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 26.1.1
|
'@types/node': 26.1.1
|
||||||
|
|
||||||
|
'@types/ndarray@1.0.14': {}
|
||||||
|
|
||||||
'@types/node@26.1.1':
|
'@types/node@26.1.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 8.3.0
|
undici-types: 8.3.0
|
||||||
@@ -2146,6 +2461,10 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
cwise-compiler@1.1.3:
|
||||||
|
dependencies:
|
||||||
|
uniq: 1.0.1
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -2158,6 +2477,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
webgl-constants: 1.1.1
|
webgl-constants: 1.1.1
|
||||||
|
|
||||||
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
draco3d@1.5.7: {}
|
draco3d@1.5.7: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.389: {}
|
electron-to-chromium@1.5.389: {}
|
||||||
@@ -2259,6 +2580,10 @@ snapshots:
|
|||||||
|
|
||||||
ini@4.1.3: {}
|
ini@4.1.3: {}
|
||||||
|
|
||||||
|
iota-array@1.0.0: {}
|
||||||
|
|
||||||
|
is-buffer@1.1.6: {}
|
||||||
|
|
||||||
is-docker@2.2.1: {}
|
is-docker@2.2.1: {}
|
||||||
|
|
||||||
is-fullwidth-code-point@3.0.0: {}
|
is-fullwidth-code-point@3.0.0: {}
|
||||||
@@ -2357,6 +2682,27 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
ndarray-lanczos@0.3.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/ndarray': 1.0.14
|
||||||
|
ndarray: 1.0.19
|
||||||
|
|
||||||
|
ndarray-ops@1.2.2:
|
||||||
|
dependencies:
|
||||||
|
cwise-compiler: 1.1.3
|
||||||
|
|
||||||
|
ndarray-pixels@5.0.1:
|
||||||
|
dependencies:
|
||||||
|
'@types/ndarray': 1.0.14
|
||||||
|
ndarray: 1.0.19
|
||||||
|
ndarray-ops: 1.2.2
|
||||||
|
sharp: 0.34.5
|
||||||
|
|
||||||
|
ndarray@1.0.19:
|
||||||
|
dependencies:
|
||||||
|
iota-array: 1.0.0
|
||||||
|
is-buffer: 1.1.6
|
||||||
|
|
||||||
node-releases@2.0.50: {}
|
node-releases@2.0.50: {}
|
||||||
|
|
||||||
open@8.4.2:
|
open@8.4.2:
|
||||||
@@ -2481,6 +2827,37 @@ snapshots:
|
|||||||
|
|
||||||
semver@7.8.5: {}
|
semver@7.8.5: {}
|
||||||
|
|
||||||
|
sharp@0.34.5:
|
||||||
|
dependencies:
|
||||||
|
'@img/colour': 1.1.0
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
semver: 7.8.5
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-darwin-arm64': 0.34.5
|
||||||
|
'@img/sharp-darwin-x64': 0.34.5
|
||||||
|
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||||
|
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||||
|
'@img/sharp-linux-arm': 0.34.5
|
||||||
|
'@img/sharp-linux-arm64': 0.34.5
|
||||||
|
'@img/sharp-linux-ppc64': 0.34.5
|
||||||
|
'@img/sharp-linux-riscv64': 0.34.5
|
||||||
|
'@img/sharp-linux-s390x': 0.34.5
|
||||||
|
'@img/sharp-linux-x64': 0.34.5
|
||||||
|
'@img/sharp-linuxmusl-arm64': 0.34.5
|
||||||
|
'@img/sharp-linuxmusl-x64': 0.34.5
|
||||||
|
'@img/sharp-wasm32': 0.34.5
|
||||||
|
'@img/sharp-win32-arm64': 0.34.5
|
||||||
|
'@img/sharp-win32-ia32': 0.34.5
|
||||||
|
'@img/sharp-win32-x64': 0.34.5
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 3.0.0
|
shebang-regex: 3.0.0
|
||||||
@@ -2609,6 +2986,8 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@8.3.0: {}
|
undici-types@8.3.0: {}
|
||||||
|
|
||||||
|
uniq@1.0.1: {}
|
||||||
|
|
||||||
universalify@2.0.1: {}
|
universalify@2.0.1: {}
|
||||||
|
|
||||||
untildify@4.0.0: {}
|
untildify@4.0.0: {}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: true
|
esbuild: true
|
||||||
|
sharp: true
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { access } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
EXTMeshoptCompression,
|
||||||
|
} from "@gltf-transform/extensions";
|
||||||
|
import { dedup, mergeDocuments, prune, unpartition } from "@gltf-transform/functions";
|
||||||
|
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const sourceDirectory = path.join(
|
||||||
|
repositoryRoot,
|
||||||
|
"game_assets",
|
||||||
|
"models",
|
||||||
|
"original",
|
||||||
|
"environment",
|
||||||
|
"kaykit-dungeon",
|
||||||
|
);
|
||||||
|
const outputPath = path.join(sourceDirectory, "dungeon-kit-uastc.glb");
|
||||||
|
const sources = ["pillar-decorated.glb", "wall-pillar.glb", "torch-lit.glb"];
|
||||||
|
const expectedMeshNames = ["pillar_decorated", "wall_pillar", "torch_lit"];
|
||||||
|
await Promise.all(sources.map((source) => access(path.join(sourceDirectory, source))));
|
||||||
|
const io = await createGameAssetIO();
|
||||||
|
|
||||||
|
const documents = await Promise.all(sources.map((source) => io.read(path.join(sourceDirectory, source))));
|
||||||
|
const document = documents[0];
|
||||||
|
const destinationScene = document.getRoot().listScenes()[0].setName("dungeon_kit");
|
||||||
|
|
||||||
|
for (const sourceDocument of documents.slice(1)) {
|
||||||
|
const sourceScene = sourceDocument.getRoot().listScenes()[0];
|
||||||
|
const propertyMap = mergeDocuments(document, sourceDocument);
|
||||||
|
const copiedScene = propertyMap.get(sourceScene);
|
||||||
|
if (!copiedScene) throw new Error(`Could not merge scene from ${sourceScene.getName() || "unnamed source"}.`);
|
||||||
|
for (const child of [...copiedScene.listChildren()]) destinationScene.addChild(child);
|
||||||
|
copiedScene.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
await document.transform(
|
||||||
|
dedup({ keepUniqueNames: false }),
|
||||||
|
unpartition(),
|
||||||
|
prune({ keepSolidTextures: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = document.getRoot();
|
||||||
|
const meshNames = root.listMeshes().map((mesh) => mesh.getName()).sort();
|
||||||
|
const expectedSorted = [...expectedMeshNames].sort();
|
||||||
|
if (JSON.stringify(meshNames) !== JSON.stringify(expectedSorted)) {
|
||||||
|
throw new Error(`Dungeon kit mesh names changed: expected ${expectedSorted.join(", ")}; received ${meshNames.join(", ")}.`);
|
||||||
|
}
|
||||||
|
if (root.listTextures().length !== 1) {
|
||||||
|
throw new Error(`Dungeon kit must contain exactly one shared texture; received ${root.listTextures().length}.`);
|
||||||
|
}
|
||||||
|
if (root.listMaterials().length !== 1) {
|
||||||
|
throw new Error(`Dungeon kit must contain exactly one shared material; received ${root.listMaterials().length}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await convertDocumentTexturesToKtx2(document, "iwt-dungeon-kit-");
|
||||||
|
const meshoptExtension = root.listExtensionsUsed()
|
||||||
|
.find((extension) => extension.extensionName === EXTMeshoptCompression.EXTENSION_NAME)
|
||||||
|
?? document.createExtension(EXTMeshoptCompression);
|
||||||
|
meshoptExtension
|
||||||
|
.setRequired(true)
|
||||||
|
.setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.QUANTIZE });
|
||||||
|
|
||||||
|
await io.write(outputPath, document);
|
||||||
|
|
||||||
|
const outputDocument = await io.read(outputPath);
|
||||||
|
const outputRoot = outputDocument.getRoot();
|
||||||
|
const outputTexture = outputRoot.listTextures()[0];
|
||||||
|
if (
|
||||||
|
outputRoot.listMeshes().length !== 3
|
||||||
|
|| outputRoot.listMaterials().length !== 1
|
||||||
|
|| outputRoot.listTextures().length !== 1
|
||||||
|
|| outputTexture.getMimeType() !== "image/ktx2"
|
||||||
|
) {
|
||||||
|
throw new Error("Generated dungeon kit failed structural validation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Built ${path.relative(repositoryRoot, outputPath)}`);
|
||||||
|
console.log("Meshes: pillar_decorated, wall_pillar, torch_lit; shared textures: 1; encoding: KTX2/UASTC.");
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { access, readFile, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { Box3, Vector3 } from "three";
|
||||||
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||||
|
import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
|
||||||
|
import { dedup, meshopt, prune, resample } from "@gltf-transform/functions";
|
||||||
|
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const assetDirectory = path.join(repositoryRoot, "game_assets", "models", "sketchfab-opensource");
|
||||||
|
const sourcePath = path.join(assetDirectory, "animated_triceratops_skeleton.glb");
|
||||||
|
const legacyPath = path.join(assetDirectory, "gravehorn-triceratops.glb");
|
||||||
|
const optimizedPath = path.join(assetDirectory, "gravehorn-triceratops-uastc.glb");
|
||||||
|
const requiredClips = [
|
||||||
|
"Armature|RiseUp",
|
||||||
|
"Armature|Roar",
|
||||||
|
"Armature|Walk",
|
||||||
|
"Armature|Fall",
|
||||||
|
"Gravehorn|Idle",
|
||||||
|
];
|
||||||
|
const unusedClips = new Set(["Armature|IdleGround", "Armature|RoarToWalk"]);
|
||||||
|
|
||||||
|
function sortedNames(properties) {
|
||||||
|
return properties.map((property) => property.getName()).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUprightIdle(document) {
|
||||||
|
const walk = document.getRoot().listAnimations().find((animation) => animation.getName() === "Armature|Walk");
|
||||||
|
const buffer = document.getRoot().listBuffers()[0];
|
||||||
|
if (!walk || !buffer) throw new Error("Gravehorn source is missing its Walk animation or binary buffer.");
|
||||||
|
|
||||||
|
const idle = document.createAnimation("Gravehorn|Idle");
|
||||||
|
for (const [index, sourceChannel] of walk.listChannels().entries()) {
|
||||||
|
const sourceSampler = sourceChannel.getSampler();
|
||||||
|
const sourceOutput = sourceSampler?.getOutput();
|
||||||
|
const sourceValues = sourceOutput?.getArray();
|
||||||
|
const targetNode = sourceChannel.getTargetNode();
|
||||||
|
const targetPath = sourceChannel.getTargetPath();
|
||||||
|
if (!sourceSampler || !sourceOutput || !sourceValues || !targetNode || !targetPath) {
|
||||||
|
throw new Error(`Gravehorn Walk channel ${index} is incomplete.`);
|
||||||
|
}
|
||||||
|
if (sourceSampler.getInterpolation() === "CUBICSPLINE") {
|
||||||
|
throw new Error("Gravehorn upright idle builder does not support cubic animation tracks.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const elementSize = sourceOutput.getElementSize();
|
||||||
|
const values = new sourceValues.constructor(elementSize * 2);
|
||||||
|
values.set(sourceValues.subarray(0, elementSize), 0);
|
||||||
|
values.set(sourceValues.subarray(0, elementSize), elementSize);
|
||||||
|
const input = document.createAccessor(`gravehorn_idle_time_${index}`)
|
||||||
|
.setType("SCALAR")
|
||||||
|
.setArray(new Float32Array([0, 1]))
|
||||||
|
.setBuffer(buffer);
|
||||||
|
const output = document.createAccessor(`gravehorn_idle_value_${index}`)
|
||||||
|
.setType(sourceOutput.getType())
|
||||||
|
.setNormalized(sourceOutput.getNormalized())
|
||||||
|
.setArray(values)
|
||||||
|
.setBuffer(buffer);
|
||||||
|
const sampler = document.createAnimationSampler(`gravehorn_idle_sampler_${index}`)
|
||||||
|
.setInput(input)
|
||||||
|
.setOutput(output)
|
||||||
|
.setInterpolation("LINEAR");
|
||||||
|
const channel = document.createAnimationChannel(`gravehorn_idle_channel_${index}`)
|
||||||
|
.setSampler(sampler)
|
||||||
|
.setTargetNode(targetNode)
|
||||||
|
.setTargetPath(targetPath);
|
||||||
|
idle.addSampler(sampler).addChannel(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runtimeBounds(assetPath) {
|
||||||
|
const data = await readFile(assetPath);
|
||||||
|
const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
||||||
|
globalThis.self ??= globalThis;
|
||||||
|
globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} });
|
||||||
|
const gltf = await new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).parseAsync(arrayBuffer, "");
|
||||||
|
gltf.scene.updateMatrixWorld(true);
|
||||||
|
const bounds = new Box3().setFromObject(gltf.scene);
|
||||||
|
return {
|
||||||
|
min: bounds.min.toArray(),
|
||||||
|
max: bounds.max.toArray(),
|
||||||
|
center: bounds.getCenter(new Vector3()).toArray(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateStructure(document, { optimized }) {
|
||||||
|
const root = document.getRoot();
|
||||||
|
const material = root.listMaterials()[0];
|
||||||
|
const clips = sortedNames(root.listAnimations());
|
||||||
|
const textureMimeTypes = root.listTextures().map((texture) => texture.getMimeType());
|
||||||
|
const extensionNames = new Set(root.listExtensionsUsed().map((extension) => extension.extensionName));
|
||||||
|
|
||||||
|
if (JSON.stringify(clips) !== JSON.stringify([...requiredClips].sort())) {
|
||||||
|
throw new Error(`Gravehorn animation clips changed: ${clips.join(", ")}.`);
|
||||||
|
}
|
||||||
|
if (root.listMeshes().length !== 1 || root.listMaterials().length !== 1 || root.listTextures().length !== 3 || root.listSkins().length !== 1) {
|
||||||
|
throw new Error("Gravehorn must contain one mesh, one material, three textures, and one skin.");
|
||||||
|
}
|
||||||
|
if (root.listSkins()[0].listJoints().length !== 140) {
|
||||||
|
throw new Error(`Gravehorn joint count changed: ${root.listSkins()[0].listJoints().length}.`);
|
||||||
|
}
|
||||||
|
if (material.getDoubleSided()) {
|
||||||
|
throw new Error("Gravehorn material must keep backface culling enabled.");
|
||||||
|
}
|
||||||
|
if (!extensionNames.has("EXT_meshopt_compression")) {
|
||||||
|
throw new Error("Gravehorn is missing Meshopt compression.");
|
||||||
|
}
|
||||||
|
if (optimized && (textureMimeTypes.some((mimeType) => mimeType !== "image/ktx2") || !extensionNames.has("KHR_texture_basisu"))) {
|
||||||
|
throw new Error("Optimized Gravehorn asset must contain only KTX2 textures.");
|
||||||
|
}
|
||||||
|
if (!optimized && textureMimeTypes.some((mimeType) => mimeType === "image/ktx2")) {
|
||||||
|
throw new Error("Legacy Gravehorn fallback must retain standard textures.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await access(sourcePath);
|
||||||
|
await MeshoptDecoder.ready;
|
||||||
|
await MeshoptEncoder.ready;
|
||||||
|
const io = await createGameAssetIO();
|
||||||
|
const document = await io.read(sourcePath);
|
||||||
|
const root = document.getRoot();
|
||||||
|
const scene = root.listScenes()[0].setName("gravehorn_triceratops");
|
||||||
|
const sceneRoot = scene.listChildren()[0];
|
||||||
|
const sourceBounds = await runtimeBounds(sourcePath);
|
||||||
|
const sourceTranslation = sceneRoot.getTranslation();
|
||||||
|
|
||||||
|
sceneRoot.setTranslation([
|
||||||
|
sourceTranslation[0] - sourceBounds.center[0],
|
||||||
|
sourceTranslation[1] - sourceBounds.min[1],
|
||||||
|
sourceTranslation[2] - sourceBounds.center[2],
|
||||||
|
]);
|
||||||
|
root.listMeshes()[0].setName("gravehorn_triceratops");
|
||||||
|
root.listMaterials()[0]
|
||||||
|
.setName("gravehorn_bone")
|
||||||
|
.setDoubleSided(false)
|
||||||
|
.setEmissiveFactor([0.1, 0.06, 0.02]);
|
||||||
|
addUprightIdle(document);
|
||||||
|
for (const animation of root.listAnimations()) {
|
||||||
|
if (!unusedClips.has(animation.getName())) continue;
|
||||||
|
for (const channel of animation.listChannels()) channel.dispose();
|
||||||
|
for (const sampler of animation.listSamplers()) sampler.dispose();
|
||||||
|
animation.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
await document.transform(
|
||||||
|
resample({ tolerance: 1e-4 }),
|
||||||
|
dedup({ keepUniqueNames: true }),
|
||||||
|
prune({ keepLeaves: true, keepSolidTextures: true }),
|
||||||
|
meshopt({ encoder: MeshoptEncoder, level: "high" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await io.write(legacyPath, document);
|
||||||
|
const legacyDocument = await io.read(legacyPath);
|
||||||
|
validateStructure(legacyDocument, { optimized: false });
|
||||||
|
const legacyBounds = await runtimeBounds(legacyPath);
|
||||||
|
if (Math.abs(legacyBounds.min[1]) > 0.015 || Math.abs(legacyBounds.center[0]) > 0.015 || Math.abs(legacyBounds.center[2]) > 0.015) {
|
||||||
|
throw new Error(`Gravehorn pivot is not grounded and centered: minY=${legacyBounds.min[1]}, centerX=${legacyBounds.center[0]}, centerZ=${legacyBounds.center[2]}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const optimizedDocument = await io.read(legacyPath);
|
||||||
|
await convertDocumentTexturesToKtx2(optimizedDocument, "iwt-gravehorn-");
|
||||||
|
await io.write(optimizedPath, optimizedDocument);
|
||||||
|
validateStructure(await io.read(optimizedPath), { optimized: true });
|
||||||
|
|
||||||
|
const sourceSize = (await stat(sourcePath)).size;
|
||||||
|
const legacySize = (await stat(legacyPath)).size;
|
||||||
|
const optimizedSize = (await stat(optimizedPath)).size;
|
||||||
|
console.log(`Built ${path.relative(repositoryRoot, legacyPath)} (${sourceSize} -> ${legacySize} bytes).`);
|
||||||
|
console.log(`Built ${path.relative(repositoryRoot, optimizedPath)} (${optimizedSize} bytes, KTX2/UASTC).`);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { access } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const sourceRoot = path.join(repositoryRoot, "game_assets");
|
||||||
|
const sourceAssets = [
|
||||||
|
"models/claudecraft/chars/players/druid.glb",
|
||||||
|
"models/claudecraft/chars/players/knight.glb",
|
||||||
|
"models/claudecraft/chars/players/mage.glb",
|
||||||
|
"models/claudecraft/chars/players/ranger.glb",
|
||||||
|
"models/claudecraft/chars/players/rogue.glb",
|
||||||
|
"models/claudecraft/weapons/adv_dagger.glb",
|
||||||
|
"models/claudecraft/weapons/adv_druid_staff.glb",
|
||||||
|
"models/claudecraft/weapons/adv_sword_1handed.glb",
|
||||||
|
"models/claudecraft/weapons/adv_wand.glb",
|
||||||
|
"models/claudecraft/weapons/crossbow_2handed.glb",
|
||||||
|
"models/claudecraft/weapons/shield_badge.glb",
|
||||||
|
"models/claudecraft/weapons/spellbook_open.glb",
|
||||||
|
];
|
||||||
|
|
||||||
|
function optimizedPath(sourcePath) {
|
||||||
|
return sourcePath.replace(/\.glb$/, "-uastc.glb");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedNames(properties) {
|
||||||
|
return properties.map((property) => property.getName()).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const io = await createGameAssetIO();
|
||||||
|
for (const relativePath of sourceAssets) {
|
||||||
|
const inputPath = path.join(sourceRoot, relativePath);
|
||||||
|
const outputRelativePath = optimizedPath(relativePath);
|
||||||
|
const outputPath = path.join(sourceRoot, outputRelativePath);
|
||||||
|
await access(inputPath);
|
||||||
|
|
||||||
|
const document = await io.read(inputPath);
|
||||||
|
const sourceRootProperties = document.getRoot();
|
||||||
|
const expected = {
|
||||||
|
animations: sortedNames(sourceRootProperties.listAnimations()),
|
||||||
|
materials: sortedNames(sourceRootProperties.listMaterials()),
|
||||||
|
meshes: sortedNames(sourceRootProperties.listMeshes()),
|
||||||
|
nodes: sortedNames(sourceRootProperties.listNodes()),
|
||||||
|
scenes: sortedNames(sourceRootProperties.listScenes()),
|
||||||
|
textureCount: sourceRootProperties.listTextures().length,
|
||||||
|
};
|
||||||
|
|
||||||
|
await convertDocumentTexturesToKtx2(document, "iwt-ktx2-");
|
||||||
|
await io.write(outputPath, document);
|
||||||
|
|
||||||
|
const outputDocument = await io.read(outputPath);
|
||||||
|
const outputRoot = outputDocument.getRoot();
|
||||||
|
const actual = {
|
||||||
|
animations: sortedNames(outputRoot.listAnimations()),
|
||||||
|
materials: sortedNames(outputRoot.listMaterials()),
|
||||||
|
meshes: sortedNames(outputRoot.listMeshes()),
|
||||||
|
nodes: sortedNames(outputRoot.listNodes()),
|
||||||
|
scenes: sortedNames(outputRoot.listScenes()),
|
||||||
|
textureCount: outputRoot.listTextures().length,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
JSON.stringify(actual) !== JSON.stringify(expected)
|
||||||
|
|| outputRoot.listTextures().some((texture) => texture.getMimeType() !== "image/ktx2")
|
||||||
|
|| outputRoot.listExtensionsUsed().some((extension) => extension.extensionName === "EXT_texture_webp")
|
||||||
|
) {
|
||||||
|
throw new Error(`Generated asset failed round-trip validation: ${outputRelativePath}`);
|
||||||
|
}
|
||||||
|
console.log(`Built game_assets/${outputRelativePath}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { NodeIO } from "@gltf-transform/core";
|
||||||
|
import { ALL_EXTENSIONS, EXTTextureWebP, KHRTextureBasisu } from "@gltf-transform/extensions";
|
||||||
|
import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
export const TOKTX_COMMAND = process.env.TOKTX ?? "toktx";
|
||||||
|
|
||||||
|
export async function createGameAssetIO() {
|
||||||
|
await MeshoptDecoder.ready;
|
||||||
|
await MeshoptEncoder.ready;
|
||||||
|
return new NodeIO()
|
||||||
|
.registerExtensions(ALL_EXTENSIONS)
|
||||||
|
.registerDependencies({
|
||||||
|
"meshopt.decoder": MeshoptDecoder,
|
||||||
|
"meshopt.encoder": MeshoptEncoder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, { stdio: "inherit" });
|
||||||
|
child.once("error", reject);
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error(`${command} exited with code ${code ?? "unknown"}.`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertDocumentTexturesToKtx2(document, temporaryPrefix) {
|
||||||
|
const textures = document.getRoot().listTextures();
|
||||||
|
if (textures.length === 0) throw new Error("Asset contains no textures to convert.");
|
||||||
|
|
||||||
|
const temporaryDirectory = await mkdtemp(path.join(tmpdir(), temporaryPrefix));
|
||||||
|
try {
|
||||||
|
for (const [index, texture] of textures.entries()) {
|
||||||
|
const sourceImage = texture.getImage();
|
||||||
|
if (!sourceImage) throw new Error(`Texture ${texture.getName() || index} contains no image data.`);
|
||||||
|
|
||||||
|
const pngPath = path.join(temporaryDirectory, `texture-${index}.png`);
|
||||||
|
const ktx2Path = path.join(temporaryDirectory, `texture-${index}.ktx2`);
|
||||||
|
await writeFile(pngPath, await sharp(sourceImage).png().toBuffer());
|
||||||
|
await run(TOKTX_COMMAND, [
|
||||||
|
"--t2",
|
||||||
|
"--encode", "uastc",
|
||||||
|
"--uastc_quality", "4",
|
||||||
|
"--zcmp", "18",
|
||||||
|
"--threads", process.env.TOKTX_THREADS ?? "4",
|
||||||
|
"--genmipmap",
|
||||||
|
"--assign_oetf", "srgb",
|
||||||
|
"--assign_primaries", "bt709",
|
||||||
|
ktx2Path,
|
||||||
|
pngPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
texture
|
||||||
|
.setName(`${texture.getName() || `texture_${index}`}_uastc`)
|
||||||
|
.setMimeType("image/ktx2")
|
||||||
|
.setImage(new Uint8Array(await readFile(ktx2Path)));
|
||||||
|
}
|
||||||
|
document.getRoot().listExtensionsUsed()
|
||||||
|
.find((extension) => extension.extensionName === EXTTextureWebP.EXTENSION_NAME)
|
||||||
|
?.dispose();
|
||||||
|
document.createExtension(KHRTextureBasisu).setRequired(true);
|
||||||
|
} finally {
|
||||||
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { copyFile, mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const sourceDirectory = path.join(repositoryRoot, "node_modules", "three", "examples", "jsm", "libs", "basis");
|
||||||
|
const outputDirectory = path.join(repositoryRoot, "public", "basis");
|
||||||
|
const files = ["basis_transcoder.js", "basis_transcoder.wasm"];
|
||||||
|
|
||||||
|
await mkdir(outputDirectory, { recursive: true });
|
||||||
|
for (const file of files) {
|
||||||
|
await copyFile(path.join(sourceDirectory, file), path.join(outputDirectory, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Synced Three.js Basis transcoder ${files.join(", ")} -> public/basis`);
|
||||||
@@ -3,3 +3,19 @@
|
|||||||
Only assets referenced by the game ship from this folder. They are copied from the ignored `game_assets` source library, retain their source-relative path, and are imported with Vite `new URL(...)` calls.
|
Only assets referenced by the game ship from this folder. They are copied from the ignored `game_assets` source library, retain their source-relative path, and are imported with Vite `new URL(...)` calls.
|
||||||
|
|
||||||
Use `pnpm assets:import <path-within-game_assets>` to add a source asset. Add `--replace` only when deliberately updating an existing runtime copy.
|
Use `pnpm assets:import <path-within-game_assets>` to add a source asset. Add `--replace` only when deliberately updating an existing runtime copy.
|
||||||
|
|
||||||
|
## KTX2/UASTC pilot
|
||||||
|
|
||||||
|
The tracked `*-uastc.glb` files are parallel optimized copies. Their original GLBs remain the rollback path and must not be replaced or deleted during this pilot.
|
||||||
|
|
||||||
|
KTX-Software `toktx` is an authoring dependency. Rebuild source copies with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
TOKTX=/path/to/toktx pnpm assets:build-ktx2
|
||||||
|
TOKTX=/path/to/toktx pnpm assets:build-dungeon-kit
|
||||||
|
TOKTX=/path/to/toktx pnpm assets:build-gravehorn
|
||||||
|
```
|
||||||
|
|
||||||
|
Import each generated path from `game_assets/` with `pnpm assets:import <path> --replace`. Builds and dev startup copy Three.js's matching Basis transcoder into the ignored `public/basis/` generated directory.
|
||||||
|
|
||||||
|
Force all original GLBs at runtime with `?legacyGameAssets=1`. Force them in an Android/browser build with `VITE_LEGACY_GAME_ASSETS=1 pnpm build`. `legacyDungeonAssets` and `VITE_LEGACY_DUNGEON_ASSETS=1` remain supported aliases.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
+94
-14
@@ -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() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (LEGACY_GAME_ASSETS_FORCED) {
|
||||||
useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true);
|
useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true);
|
||||||
useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true);
|
useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true);
|
||||||
useGLTF.preload(KAYKIT_DUNGEON_TORCH_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]} />
|
||||||
|
<GameAssetProvider>
|
||||||
<Suspense fallback={null}><PortraitModel bossId={bossId} /></Suspense>
|
<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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,15 +30,30 @@ import type { BossId, MemberId, PulseKind } from "../game/types";
|
|||||||
import { BossRoom } from "./BossRoom";
|
import { BossRoom } from "./BossRoom";
|
||||||
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
|
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
|
||||||
import { bossCanTrackTarget, 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 +69,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",
|
||||||
@@ -227,12 +266,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,7 +832,8 @@ 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 gltf = useGameGLTF(assetUrl);
|
||||||
const { model, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]);
|
const { model, 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(), []);
|
||||||
@@ -1184,6 +1224,7 @@ 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" }}
|
||||||
>
|
>
|
||||||
|
<GameAssetProvider>
|
||||||
<SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} />
|
<SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} />
|
||||||
<BossRoom />
|
<BossRoom />
|
||||||
<BossMechanicIndicators />
|
<BossMechanicIndicators />
|
||||||
@@ -1194,13 +1235,16 @@ export function GameScene() {
|
|||||||
<RangedProjectiles />
|
<RangedProjectiles />
|
||||||
<CombatFx />
|
<CombatFx />
|
||||||
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
|
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
|
||||||
|
</GameAssetProvider>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (LEGACY_GAME_ASSETS_FORCED) {
|
||||||
for (const memberId of CRITICAL_PARTY_MEMBER_IDS) {
|
for (const memberId of CRITICAL_PARTY_MEMBER_IDS) {
|
||||||
useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true);
|
useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true);
|
||||||
const loadout = PARTY_WEAPON_URLS[memberId];
|
const loadout = PARTY_WEAPON_URLS[memberId];
|
||||||
useGLTF.preload(loadout.right, false, true);
|
useGLTF.preload(loadout.right, false, true);
|
||||||
if (loadout.left) useGLTF.preload(loadout.left, false, true);
|
if (loadout.left) useGLTF.preload(loadout.left, false, true);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+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