Compare commits

..
7 Commits
Author SHA1 Message Date
Warren H 4b31480bec Release v0.1.13 2026-07-13 2026-07-13 22:50:51 -04:00
Warren H ed34c2503c Release v0.1.12 2026-07-13 2026-07-13 22:41:35 -04:00
Warren H 8e48bb4fb6 Release v0.1.11 2026-07-13 2026-07-13 21:31:36 -04:00
Warren H fb41147197 Release v0.1.10 2026-07-13 2026-07-13 20:47:56 -04:00
Warren H aa9f5eff20 Release v0.1.9 2026-07-13 2026-07-13 19:37:30 -04:00
Warren H 18c416d4d2 Release v0.1.8 2026-07-13 2026-07-13 17:21:10 -04:00
Warren H 77fd434226 Release v0.1.7 2026-07-12 2026-07-12 23:39:57 -04:00
92 changed files with 3917 additions and 441 deletions
+1
View File
@@ -12,3 +12,4 @@ vite.config.d.ts
!.env.example !.env.example
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
/public/basis/
+2 -2
View File
@@ -131,10 +131,10 @@ outside the repository.
- `WASD` / left stick: move - `WASD` / left stick: move
- `Q` and `E` / D-pad: cycle party target - `Q` and `E` / D-pad: cycle party target
- `1``6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal - `1``6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
- Gamepad: `X`, `Y`, `B`, `A`, `LB`, `RB` map to those abilities - Gamepad: PlayStation ``, ``, ``, ``, `L1`, `R1` map to those abilities
- `M`: tactical map - `M`: tactical map
- `I`: inventory and item tooltip - `I`: inventory and item tooltip
- `Enter` / Start: begin or reset encounter - `Enter` / `START`: begin or reset encounter
Touch controls on lower display support party targeting, ability casting, map, and inventory. Touch controls on lower display support party targeting, ability casting, map, and inventory.
+12
View File
@@ -54,3 +54,15 @@ CREATE TABLE IF NOT EXISTS roguelike_records (
CREATE INDEX IF NOT EXISTS roguelike_rank_idx CREATE INDEX IF NOT EXISTS roguelike_rank_idx
ON roguelike_records (highest_round DESC, updated_at ASC); ON roguelike_records (highest_round DESC, updated_at ASC);
CREATE TABLE IF NOT EXISTS rogue_trials_endless_records (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
highest_boss_kills INTEGER NOT NULL DEFAULT 0 CHECK (highest_boss_kills >= 0),
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, slot_id),
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS rogue_trials_endless_rank_idx
ON rogue_trials_endless_records (highest_boss_kills DESC, updated_at ASC);
+11 -2
View File
@@ -1,13 +1,15 @@
{ {
"name": "i-want-to-heal", "name": "i-want-to-heal",
"private": true, "private": true,
"version": "0.1.6", "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"
+379
View File
@@ -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
View File
@@ -1,2 +1,3 @@
allowBuilds: allowBuilds:
esbuild: true esbuild: true
sharp: true
@@ -0,0 +1,352 @@
"""Build two original low-poly creature bosses as animated runtime GLBs.
Replaces weak chicken and frog visuals while keeping stable boss IDs in game data.
Run with:
/Applications/Blender.app/Contents/MacOS/Blender --background --factory-startup \
--python scripts/blender/build_replacement_creature_bosses.py
"""
from __future__ import annotations
import json
import math
import sys
from pathlib import Path
import bpy
sys.path.insert(0, str(Path(__file__).parent))
from build_iwt2_boss_trio import ( # noqa: E402
OUT_ROOT,
actions,
armature,
cone,
ellipsoid,
export_asset,
finish,
join_parts,
plate,
prepare_materials,
reset_scene,
)
def add_metadata(asset_id: str, concept: str) -> None:
metadata_path = OUT_ROOT / asset_id / f"{asset_id}.asset.json"
metadata = json.loads(metadata_path.read_text())
metadata["sourceConcept"] = concept
metadata["license"] = "Original project-owned asset"
metadata["runtime"]["forward"] = "-Y"
metadata["runtime"]["unit"] = "meters"
metadata_path.write_text(json.dumps(metadata, indent=2) + "\n")
def build_brassbeak_basilisk() -> None:
"""Six-legged forge basilisk replacing Cluckhorn's chicken-cow model."""
asset_id = "brassbeak-basilisk"
reset_scene()
mats = prepare_materials({
"Scale": {"color": (0.035, 0.105, 0.12, 1), "metallic": 0.14, "roughness": 0.62},
"Underbelly": {"color": (0.12, 0.19, 0.18, 1), "metallic": 0.05, "roughness": 0.72},
"Copper": {"color": (0.45, 0.16, 0.055, 1), "metallic": 0.48, "roughness": 0.33},
"Brass": {"color": (0.78, 0.48, 0.09, 1), "metallic": 0.62, "roughness": 0.25},
"Blade": {"color": (0.50, 0.58, 0.55, 1), "metallic": 0.72, "roughness": 0.2},
"Furnace": {"color": (0.02, 0.82, 0.72, 1), "roughness": 0.18, "emission": (0.01, 0.72, 0.64, 1), "strength": 5.5},
})
specs = [
("Root", (0, 0, 0), (0, 0, 0.45), None),
("Body", (0, 0.06, 1.18), (0, 0.05, 2.05), "Root"),
("Head", (0, -1.0, 1.48), (0, -1.82, 1.38), "Body"),
("Jaw", (0, -1.42, 1.28), (0, -2.05, 1.12), "Head"),
("Wing.L", (-0.62, -0.08, 1.72), (-1.55, -0.28, 1.5), "Body"),
("Wing.R", (0.62, -0.08, 1.72), (1.55, -0.28, 1.5), "Body"),
("Leg.FL", (-0.62, -0.72, 1.12), (-0.88, -0.84, 0.24), "Body"),
("Leg.FR", (0.62, -0.72, 1.12), (0.88, -0.84, 0.24), "Body"),
("Leg.ML", (-0.78, 0.03, 1.06), (-1.0, 0.02, 0.22), "Body"),
("Leg.MR", (0.78, 0.03, 1.06), (1.0, 0.02, 0.22), "Body"),
("Leg.BL", (-0.66, 0.76, 1.12), (-0.9, 0.88, 0.24), "Body"),
("Leg.BR", (0.66, 0.76, 1.12), (0.9, 0.88, 0.24), "Body"),
("Tail.1", (0, 1.0, 1.3), (0, 1.85, 1.12), "Body"),
("Tail.2", (0, 1.8, 1.12), (0, 2.7, 0.92), "Tail.1"),
]
rig = armature("BrassbeakBasilisk", specs)
# Broad armored silhouette with glowing furnace seams.
ellipsoid("BasiliskBody", (0, 0.08, 1.36), (1.08, 1.43, 0.72), mats["Scale"], "Body", 2)
ellipsoid("FurnaceBelly", (0, -0.15, 1.08), (0.78, 1.08, 0.46), mats["Underbelly"], "Body", 2)
for index, y in enumerate((-0.7, -0.22, 0.28, 0.76)):
width = 0.82 + (0.12 if index in (1, 2) else 0)
plate(
f"BackPlate{index}", (0, y, 1.92 + 0.08 * math.sin(index)),
(width, 0.55, 0.27), (math.radians(84), 0, 0),
mats["Copper"] if index % 2 == 0 else mats["Brass"], "Body",
)
cone(
f"ChimneySpine{index}", (0, y, 2.02), (0, y + 0.03, 2.52 - index * 0.04),
0.13, 0, mats["Blade"], "Body", 5,
)
for side in (-1, 1):
cone(
f"FurnaceSeam{side:+d}", (side * 0.58, -0.76, 1.34), (side * 0.72, 0.72, 1.34),
0.035, 0.022, mats["Furnace"], "Body", 5,
)
# Hammerhead, brass beak, split jaw, and crown blades.
ellipsoid("HammerHead", (0, -1.28, 1.5), (0.78, 0.74, 0.56), mats["Copper"], "Head", 2)
ellipsoid("FaceMask", (0, -1.72, 1.5), (0.58, 0.34, 0.42), mats["Brass"], "Head", 1)
cone("UpperBeak", (0, -1.68, 1.51), (0, -2.58, 1.34), 0.42, 0.035, mats["Brass"], "Head", 6)
cone("LowerBeak", (0, -1.64, 1.3), (0, -2.32, 1.18), 0.3, 0.025, mats["Blade"], "Jaw", 6)
for side, suffix in ((-1, "L"), (1, "R")):
ellipsoid(f"Eye{suffix}", (side * 0.43, -1.72, 1.66), (0.09, 0.055, 0.09), mats["Furnace"], "Head", 1)
cone(
f"BrowHorn{suffix}", (side * 0.42, -1.38, 1.78), (side * 0.88, -1.7, 2.03),
0.13, 0, mats["Blade"], "Head", 5,
)
for index, (x, z) in enumerate(((-0.34, 2.0), (0, 2.14), (0.34, 2.0))):
cone(f"CrownBlade{index}", (x, -1.2, 1.8), (x * 1.35, -1.15, z + 0.54), 0.12, 0, mats["Brass"], "Head", 5)
# Blade-like vestigial wings make lateral cleaves readable from camera.
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Wing.{suffix}"
plate(
f"WingShield{suffix}", (side * 1.05, -0.08, 1.62), (0.7, 0.56, 0.13),
(math.radians(78), math.radians(side * 18), math.radians(side * 8)), mats["Copper"], bone,
)
cone(
f"WingBlade{suffix}", (side * 0.72, -0.24, 1.7), (side * 1.95, -0.58, 1.42),
0.19, 0.015, mats["Blade"], bone, 6,
)
cone(
f"WingGlow{suffix}", (side * 0.88, -0.3, 1.69), (side * 1.7, -0.52, 1.5),
0.05, 0.008, mats["Furnace"], bone, 5,
)
# Six short piston legs: stable, strange, easy to read while scuttling.
leg_rows = (("F", -0.72), ("M", 0.03), ("B", 0.76))
for row_index, (row, y) in enumerate(leg_rows):
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Leg.{row}{suffix}"
hip_x = side * (0.64 if row != "M" else 0.78)
foot_x = side * (0.98 if row != "M" else 1.1)
ellipsoid(f"Hip{row}{suffix}", (hip_x, y, 1.05), (0.3, 0.34, 0.3), mats["Copper"], bone, 1)
cone(f"Shin{row}{suffix}", (hip_x, y, 1.0), (foot_x, y - 0.04, 0.28), 0.22, 0.14, mats["Scale"], bone, 6)
ellipsoid(f"Foot{row}{suffix}", (foot_x, y - 0.22, 0.2), (0.31, 0.48, 0.19), mats["Brass"], bone, 1)
for toe_index, toe_x in enumerate((-0.13, 0.13)):
cone(
f"Toe{row}{suffix}{toe_index}", (foot_x + toe_x, y - 0.42, 0.2),
(foot_x + toe_x * 1.4, y - 0.75, 0.1), 0.055, 0.004, mats["Blade"], bone, 5,
)
cone("TailCore1", (0, 0.95, 1.3), (0, 1.85, 1.1), 0.48, 0.3, mats["Scale"], "Tail.1", 7)
cone("TailCore2", (0, 1.78, 1.1), (0, 2.72, 0.88), 0.31, 0.07, mats["Copper"], "Tail.2", 7)
cone("TailBladeTop", (0, 2.45, 0.9), (0, 3.18, 1.45), 0.2, 0.015, mats["Blade"], "Tail.2", 5)
cone("TailBladeBottom", (0, 2.45, 0.9), (0, 3.15, 0.48), 0.18, 0.015, mats["Brass"], "Tail.2", 5)
ellipsoid("TailCoreGlow", (0, 2.54, 0.91), (0.14, 0.17, 0.14), mats["Furnace"], "Tail.2", 1)
body = join_parts("BrassbeakBasilisk", rig)
clips = actions(rig, brassbeak_actions())
export_asset(
asset_id, "Brassbeak Basilisk", rig, body, clips,
[
("Body", (0, 0, 1.25), (1.25, 1.55, 0.9)),
("Head", (0, -1.65, 1.42), (0.95, 1.05, 0.75)),
("Tail", (0, 2.08, 1.0), (0.55, 1.25, 0.78)),
],
(0, 0, 1.25), 8.8, "FurnaceBurst", 20,
)
add_metadata(asset_id, "Original six-legged forge basilisk designed for I Want to Heal")
def brassbeak_actions():
return [
("Idle", 60, True, [
{"frame": 1},
{"frame": 15, "locations": {"Root": (0, 0, 0.04)}, "rotations": {"Head": (3, 0, -3), "Jaw": (7, 0, 0), "Tail.2": (0, 0, 7), "Wing.L": (0, 0, -4), "Wing.R": (0, 0, 4)}},
{"frame": 30, "rotations": {"Head": (0, 0, 3), "Jaw": (0, 0, 0), "Tail.2": (0, 0, -7)}},
{"frame": 45, "locations": {"Root": (0, 0, 0.04)}, "rotations": {"Head": (3, 0, -3), "Jaw": (7, 0, 0), "Tail.2": (0, 0, 7), "Wing.L": (0, 0, -4), "Wing.R": (0, 0, 4)}},
{"frame": 60},
]),
("Scuttle", 30, True, [
{"frame": 1, "rotations": {"Leg.FL": (-18, 0, -5), "Leg.MR": (-18, 0, 4), "Leg.BL": (-18, 0, -4), "Leg.FR": (18, 0, 5), "Leg.ML": (18, 0, -4), "Leg.BR": (18, 0, 4), "Tail.2": (0, 0, -9)}},
{"frame": 8, "locations": {"Root": (0, 0, 0.08)}, "rotations": {"Body": (-3, 0, 0)}},
{"frame": 16, "rotations": {"Leg.FL": (18, 0, 5), "Leg.MR": (18, 0, -4), "Leg.BL": (18, 0, 4), "Leg.FR": (-18, 0, -5), "Leg.ML": (-18, 0, 4), "Leg.BR": (-18, 0, -4), "Tail.2": (0, 0, 9)}},
{"frame": 23, "locations": {"Root": (0, 0, 0.08)}, "rotations": {"Body": (3, 0, 0)}},
{"frame": 30, "rotations": {"Leg.FL": (-18, 0, -5), "Leg.MR": (-18, 0, 4), "Leg.BL": (-18, 0, -4), "Leg.FR": (18, 0, 5), "Leg.ML": (18, 0, -4), "Leg.BR": (18, 0, 4), "Tail.2": (0, 0, -9)}},
]),
("BeakRend", 34, False, [
{"frame": 1},
{"frame": 9, "locations": {"Root": (0, 0.12, -0.05)}, "rotations": {"Body": (-9, 0, 0), "Head": (-24, 0, 0), "Jaw": (24, 0, 0), "Wing.L": (0, -16, -10), "Wing.R": (0, 16, 10)}},
{"frame": 15, "locations": {"Root": (0, -0.2, 0.05)}, "rotations": {"Body": (15, 0, 0), "Head": (28, 0, 0), "Jaw": (-6, 0, 0)}},
{"frame": 23, "rotations": {"Head": (-8, 0, 0), "Jaw": (12, 0, 0)}},
{"frame": 34},
]),
("FurnaceBurst", 46, False, [
{"frame": 1},
{"frame": 12, "locations": {"Root": (0, 0, 0.1)}, "scales": {"Body": (0.94, 0.94, 0.94)}, "rotations": {"Wing.L": (-18, 18, -22), "Wing.R": (-18, -18, 22), "Head": (-12, 0, 0), "Jaw": (18, 0, 0), "Tail.1": (-12, 0, 0)}},
{"frame": 20, "locations": {"Root": (0, -0.08, 0.22)}, "scales": {"Body": (1.1, 1.1, 1.1)}, "rotations": {"Wing.L": (18, -62, -64), "Wing.R": (18, 62, 64), "Head": (20, 0, 0), "Jaw": (30, 0, 0), "Tail.1": (18, 0, 0), "Tail.2": (-22, 0, 0)}},
{"frame": 30, "scales": {"Body": (0.97, 0.97, 0.97)}, "rotations": {"Wing.L": (4, -18, -20), "Wing.R": (4, 18, 20), "Jaw": (4, 0, 0), "Tail.2": (8, 0, 0)}},
{"frame": 46},
]),
("Stagger", 28, False, [
{"frame": 1},
{"frame": 6, "locations": {"Root": (0.12, 0.12, -0.09)}, "rotations": {"Body": (-14, 0, 13), "Head": (22, 0, -12), "Wing.L": (24, 0, -18), "Wing.R": (-8, 0, 12)}},
{"frame": 15, "rotations": {"Body": (7, 0, -6), "Head": (-8, 0, 5)}},
{"frame": 28},
]),
("Death", 72, False, [
{"frame": 1},
{"frame": 20, "locations": {"Root": (0.15, 0.08, -0.25)}, "rotations": {"Root": (0, 25, 32), "Body": (18, 0, 12), "Head": (24, 0, -10), "Jaw": (20, 0, 0), "Wing.L": (32, 0, -26), "Wing.R": (16, 0, 20)}},
{"frame": 46, "locations": {"Root": (0.28, 0.08, -0.78)}, "rotations": {"Root": (0, 52, 82), "Body": (30, 0, 20), "Head": (42, 0, -20), "Leg.FL": (30, 0, 0), "Leg.ML": (-24, 0, 0), "Leg.BL": (20, 0, 0), "Tail.1": (-32, 0, 0), "Tail.2": (-25, 0, 0)}},
{"frame": 72, "locations": {"Root": (0.28, 0.08, -0.82)}, "rotations": {"Root": (0, 52, 82), "Body": (30, 0, 20), "Head": (44, 0, -20), "Leg.FL": (30, 0, 0), "Leg.ML": (-24, 0, 0), "Leg.BL": (20, 0, 0), "Tail.1": (-32, 0, 0), "Tail.2": (-25, 0, 0)}},
]),
]
def build_bogbell_myconid() -> None:
"""Bell-capped fungal brute replacing Mirelord's frog model."""
asset_id = "bogbell-myconid"
reset_scene()
mats = prepare_materials({
"Bark": {"color": (0.12, 0.18, 0.095, 1), "roughness": 0.88},
"Root": {"color": (0.25, 0.31, 0.16, 1), "roughness": 0.8},
"Cap": {"color": (0.29, 0.055, 0.31, 1), "roughness": 0.6},
"CapEdge": {"color": (0.52, 0.15, 0.42, 1), "roughness": 0.52},
"Gill": {"color": (0.62, 0.55, 0.31, 1), "roughness": 0.7},
"Spore": {"color": (0.48, 1.0, 0.32, 1), "roughness": 0.16, "emission": (0.22, 0.92, 0.16, 1), "strength": 4.8},
})
specs = [
("Root", (0, 0, 0), (0, 0, 0.5), None),
("Body", (0, 0, 1.15), (0, 0, 2.25), "Root"),
("Cap", (0, -0.05, 2.18), (0, -0.05, 3.18), "Body"),
("Arm.L", (-0.58, -0.15, 1.72), (-1.28, -0.62, 0.7), "Body"),
("Arm.R", (0.58, -0.15, 1.72), (1.28, -0.62, 0.7), "Body"),
("Leg.L", (-0.38, 0.08, 1.08), (-0.58, -0.1, 0.2), "Body"),
("Leg.R", (0.38, 0.08, 1.08), (0.58, -0.1, 0.2), "Body"),
("Tendril.L", (-0.42, 0.58, 1.45), (-1.05, 1.45, 0.82), "Body"),
("Tendril.R", (0.42, 0.58, 1.45), (1.05, 1.45, 0.82), "Body"),
]
rig = armature("BogbellMyconid", specs)
# Gnarled trunk and hanging bell cap.
ellipsoid("Trunk", (0, 0.05, 1.48), (0.82, 0.68, 1.05), mats["Bark"], "Body", 2)
ellipsoid("ChestKnot", (0, -0.5, 1.62), (0.58, 0.28, 0.62), mats["Root"], "Body", 1)
cone("NeckStalk", (0, -0.02, 1.95), (0, -0.04, 2.65), 0.48, 0.36, mats["Gill"], "Cap", 8)
plate("BellCap", (0, -0.04, 2.78), (1.55, 1.38, 0.55), (0, 0, 0), mats["Cap"], "Cap", 9)
ellipsoid("CapCrown", (0, 0.02, 3.04), (1.25, 1.08, 0.42), mats["CapEdge"], "Cap", 2)
plate("GillBell", (0, -0.03, 2.58), (1.28, 1.12, 0.28), (0, 0, math.radians(180)), mats["Gill"], "Cap", 9)
for index, angle in enumerate(range(0, 360, 45)):
radians = math.radians(angle)
x, y = math.cos(radians) * 1.02, math.sin(radians) * 0.87
cone(
f"CapHorn{index}", (x * 0.9, y * 0.9, 3.12), (x * 1.38, y * 1.35, 3.34 + 0.08 * (index % 2)),
0.11, 0, mats["CapEdge"], "Cap", 5,
)
for side, suffix in ((-1, "L"), (1, "R")):
ellipsoid(f"Eye{suffix}", (side * 0.29, -0.65, 2.18), (0.095, 0.055, 0.11), mats["Spore"], "Cap", 1)
cone(
f"FaceRoot{suffix}", (side * 0.25, -0.52, 2.03), (side * 0.42, -0.78, 1.72),
0.07, 0.015, mats["Root"], "Cap", 5,
)
ellipsoid("MouthHollow", (0, -0.68, 1.94), (0.22, 0.055, 0.13), mats["Cap"], "Cap", 1)
# Root arms end in broad knuckles for readable pummel animation.
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Arm.{suffix}"
cone(f"UpperArm{suffix}", (side * 0.55, -0.12, 1.78), (side * 1.04, -0.45, 1.05), 0.3, 0.22, mats["Bark"], bone, 7)
cone(f"Forearm{suffix}", (side * 1.02, -0.44, 1.06), (side * 1.34, -0.84, 0.58), 0.24, 0.18, mats["Root"], bone, 7)
ellipsoid(f"Knuckle{suffix}", (side * 1.38, -0.91, 0.48), (0.43, 0.38, 0.32), mats["Bark"], bone, 1)
for finger in (-0.16, 0, 0.16):
cone(
f"Finger{suffix}{finger}", (side * 1.36 + finger, -1.02, 0.43),
(side * 1.46 + finger, -1.35, 0.22), 0.065, 0.012, mats["Root"], bone, 5,
)
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Leg.{suffix}"
ellipsoid(f"Hip{suffix}", (side * 0.4, 0.08, 1.0), (0.42, 0.46, 0.5), mats["Bark"], bone, 1)
cone(f"RootLeg{suffix}", (side * 0.4, 0.06, 0.95), (side * 0.62, -0.12, 0.25), 0.34, 0.22, mats["Root"], bone, 7)
for toe_index, toe_x in enumerate((-0.22, 0, 0.22)):
cone(
f"RootToe{suffix}{toe_index}", (side * 0.62 + toe_x, -0.2, 0.25),
(side * 0.72 + toe_x * 1.25, -0.78 - abs(toe_x), 0.08), 0.095, 0.015, mats["Bark"], bone, 6,
)
# Rear tendrils drag through mire. Spore sacs pulse during eruption.
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Tendril.{suffix}"
cone(f"TendrilBase{suffix}", (side * 0.4, 0.5, 1.38), (side * 0.78, 1.22, 0.82), 0.22, 0.12, mats["Root"], bone, 7)
cone(f"TendrilTip{suffix}", (side * 0.76, 1.18, 0.84), (side * 1.28, 1.85, 0.3), 0.13, 0.018, mats["Bark"], bone, 6)
ellipsoid(f"SporeSac{suffix}", (side * 0.82, 0.72, 1.25), (0.24, 0.31, 0.3), mats["Spore"], bone, 1)
for index, (x, y, z, size) in enumerate(((-0.5, 0.45, 1.88, 0.16), (0.48, 0.5, 1.72, 0.2), (-0.28, 0.62, 1.35, 0.13))):
ellipsoid(f"BodySpore{index}", (x, y, z), (size, size * 0.82, size * 1.1), mats["Spore"], "Body", 1)
body = join_parts("BogbellMyconid", rig)
clips = actions(rig, bogbell_actions())
export_asset(
asset_id, "Bogbell Myconid", rig, body, clips,
[
("Body", (0, 0, 1.45), (1.05, 0.95, 1.35)),
("Cap", (0, 0, 2.82), (1.68, 1.48, 0.72)),
("Roots", (0, 0.38, 0.62), (1.58, 1.75, 0.72)),
],
(0, 0, 1.65), 8.5, "SporeEruption", 21,
)
add_metadata(asset_id, "Original bell-capped fungal mire creature designed for I Want to Heal")
def bogbell_actions():
return [
("Idle", 60, True, [
{"frame": 1},
{"frame": 15, "locations": {"Root": (0, 0, 0.04)}, "scales": {"Cap": (1.03, 1.03, 0.98)}, "rotations": {"Cap": (2, 0, -3), "Arm.L": (0, 0, -3), "Arm.R": (0, 0, 3), "Tendril.L": (0, 0, 7), "Tendril.R": (0, 0, -7)}},
{"frame": 30, "scales": {"Cap": (0.98, 0.98, 1.03)}, "rotations": {"Cap": (-1, 0, 3), "Tendril.L": (0, 0, -7), "Tendril.R": (0, 0, 7)}},
{"frame": 45, "locations": {"Root": (0, 0, 0.04)}, "scales": {"Cap": (1.03, 1.03, 0.98)}, "rotations": {"Cap": (2, 0, -3), "Arm.L": (0, 0, -3), "Arm.R": (0, 0, 3), "Tendril.L": (0, 0, 7), "Tendril.R": (0, 0, -7)}},
{"frame": 60},
]),
("BurrowRush", 32, True, [
{"frame": 1, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (10, 0, 0), "Arm.L": (26, 0, -12), "Arm.R": (26, 0, 12), "Leg.L": (-16, 0, 0), "Leg.R": (16, 0, 0), "Tendril.L": (-18, 0, -12), "Tendril.R": (-18, 0, 12)}},
{"frame": 9, "locations": {"Root": (0, 0, -0.26)}, "rotations": {"Cap": (-9, 0, 0), "Leg.L": (16, 0, 0), "Leg.R": (-16, 0, 0), "Tendril.L": (14, 0, 10), "Tendril.R": (14, 0, -10)}},
{"frame": 17, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (10, 0, 0), "Arm.L": (26, 0, -12), "Arm.R": (26, 0, 12), "Leg.L": (-16, 0, 0), "Leg.R": (16, 0, 0), "Tendril.L": (-18, 0, -12), "Tendril.R": (-18, 0, 12)}},
{"frame": 25, "locations": {"Root": (0, 0, -0.26)}, "rotations": {"Cap": (-9, 0, 0), "Leg.L": (16, 0, 0), "Leg.R": (-16, 0, 0), "Tendril.L": (14, 0, 10), "Tendril.R": (14, 0, -10)}},
{"frame": 32, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (10, 0, 0), "Arm.L": (26, 0, -12), "Arm.R": (26, 0, 12), "Leg.L": (-16, 0, 0), "Leg.R": (16, 0, 0), "Tendril.L": (-18, 0, -12), "Tendril.R": (-18, 0, 12)}},
]),
("RootPummel", 36, False, [
{"frame": 1},
{"frame": 10, "locations": {"Root": (0, 0.1, 0.06)}, "rotations": {"Body": (-12, 0, 0), "Cap": (-8, 0, 0), "Arm.L": (-42, 0, -30), "Arm.R": (-42, 0, 30)}},
{"frame": 17, "locations": {"Root": (0, -0.12, -0.14)}, "rotations": {"Body": (22, 0, 0), "Cap": (18, 0, 0), "Arm.L": (58, 0, 16), "Arm.R": (58, 0, -16)}},
{"frame": 25, "rotations": {"Body": (-5, 0, 0), "Arm.L": (12, 0, -6), "Arm.R": (12, 0, 6)}},
{"frame": 36},
]),
("SporeEruption", 48, False, [
{"frame": 1},
{"frame": 13, "locations": {"Root": (0, 0, -0.12)}, "scales": {"Cap": (0.88, 0.88, 1.16)}, "rotations": {"Body": (-13, 0, 0), "Cap": (-15, 0, 0), "Arm.L": (-26, 0, -28), "Arm.R": (-26, 0, 28), "Tendril.L": (-28, 0, -24), "Tendril.R": (-28, 0, 24)}},
{"frame": 21, "locations": {"Root": (0, -0.04, 0.24)}, "scales": {"Cap": (1.18, 1.18, 0.9), "Body": (1.08, 1.08, 1.08)}, "rotations": {"Body": (18, 0, 0), "Cap": (19, 0, 0), "Arm.L": (18, 0, 62), "Arm.R": (18, 0, -62), "Tendril.L": (32, 0, 48), "Tendril.R": (32, 0, -48)}},
{"frame": 32, "scales": {"Cap": (0.97, 0.97, 1.04), "Body": (0.97, 0.97, 0.97)}, "rotations": {"Cap": (-5, 0, 0), "Arm.L": (4, 0, 12), "Arm.R": (4, 0, -12)}},
{"frame": 48},
]),
("Stagger", 28, False, [
{"frame": 1},
{"frame": 6, "locations": {"Root": (0.14, 0.1, -0.08)}, "rotations": {"Body": (-15, 0, 13), "Cap": (24, 0, -18), "Arm.L": (20, 0, -18), "Arm.R": (-8, 0, 12)}},
{"frame": 15, "rotations": {"Body": (7, 0, -6), "Cap": (-8, 0, 7)}},
{"frame": 28},
]),
("Death", 74, False, [
{"frame": 1},
{"frame": 20, "locations": {"Root": (0.14, 0.1, -0.3)}, "rotations": {"Root": (0, 24, 30), "Body": (20, 0, 12), "Cap": (28, 0, -18), "Arm.L": (30, 0, -26), "Arm.R": (16, 0, 20), "Tendril.L": (-24, 0, -14), "Tendril.R": (-16, 0, 18)}},
{"frame": 48, "locations": {"Root": (0.28, 0.1, -0.86)}, "rotations": {"Root": (0, 54, 84), "Body": (34, 0, 24), "Cap": (48, 0, -30), "Arm.L": (52, 0, -42), "Arm.R": (28, 0, 34), "Leg.L": (24, 0, 0), "Leg.R": (-18, 0, 0), "Tendril.L": (-40, 0, -24), "Tendril.R": (-34, 0, 26)}},
{"frame": 74, "locations": {"Root": (0.28, 0.1, -0.9)}, "rotations": {"Root": (0, 54, 84), "Body": (34, 0, 24), "Cap": (50, 0, -30), "Arm.L": (52, 0, -42), "Arm.R": (28, 0, 34), "Leg.L": (24, 0, 0), "Leg.R": (-18, 0, 0), "Tendril.L": (-40, 0, -24), "Tendril.R": (-34, 0, 26)}},
]),
]
def main() -> None:
bpy.context.preferences.filepaths.save_version = 0
OUT_ROOT.mkdir(parents=True, exist_ok=True)
build_brassbeak_basilisk()
build_bogbell_myconid()
if __name__ == "__main__":
main()
+80
View File
@@ -0,0 +1,80 @@
import { access } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
EXTMeshoptCompression,
} from "@gltf-transform/extensions";
import { dedup, mergeDocuments, prune, unpartition } from "@gltf-transform/functions";
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourceDirectory = path.join(
repositoryRoot,
"game_assets",
"models",
"original",
"environment",
"kaykit-dungeon",
);
const outputPath = path.join(sourceDirectory, "dungeon-kit-uastc.glb");
const sources = ["pillar-decorated.glb", "wall-pillar.glb", "torch-lit.glb"];
const expectedMeshNames = ["pillar_decorated", "wall_pillar", "torch_lit"];
await Promise.all(sources.map((source) => access(path.join(sourceDirectory, source))));
const io = await createGameAssetIO();
const documents = await Promise.all(sources.map((source) => io.read(path.join(sourceDirectory, source))));
const document = documents[0];
const destinationScene = document.getRoot().listScenes()[0].setName("dungeon_kit");
for (const sourceDocument of documents.slice(1)) {
const sourceScene = sourceDocument.getRoot().listScenes()[0];
const propertyMap = mergeDocuments(document, sourceDocument);
const copiedScene = propertyMap.get(sourceScene);
if (!copiedScene) throw new Error(`Could not merge scene from ${sourceScene.getName() || "unnamed source"}.`);
for (const child of [...copiedScene.listChildren()]) destinationScene.addChild(child);
copiedScene.dispose();
}
await document.transform(
dedup({ keepUniqueNames: false }),
unpartition(),
prune({ keepSolidTextures: true }),
);
const root = document.getRoot();
const meshNames = root.listMeshes().map((mesh) => mesh.getName()).sort();
const expectedSorted = [...expectedMeshNames].sort();
if (JSON.stringify(meshNames) !== JSON.stringify(expectedSorted)) {
throw new Error(`Dungeon kit mesh names changed: expected ${expectedSorted.join(", ")}; received ${meshNames.join(", ")}.`);
}
if (root.listTextures().length !== 1) {
throw new Error(`Dungeon kit must contain exactly one shared texture; received ${root.listTextures().length}.`);
}
if (root.listMaterials().length !== 1) {
throw new Error(`Dungeon kit must contain exactly one shared material; received ${root.listMaterials().length}.`);
}
await convertDocumentTexturesToKtx2(document, "iwt-dungeon-kit-");
const meshoptExtension = root.listExtensionsUsed()
.find((extension) => extension.extensionName === EXTMeshoptCompression.EXTENSION_NAME)
?? document.createExtension(EXTMeshoptCompression);
meshoptExtension
.setRequired(true)
.setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.QUANTIZE });
await io.write(outputPath, document);
const outputDocument = await io.read(outputPath);
const outputRoot = outputDocument.getRoot();
const outputTexture = outputRoot.listTextures()[0];
if (
outputRoot.listMeshes().length !== 3
|| outputRoot.listMaterials().length !== 1
|| outputRoot.listTextures().length !== 1
|| outputTexture.getMimeType() !== "image/ktx2"
) {
throw new Error("Generated dungeon kit failed structural validation.");
}
console.log(`Built ${path.relative(repositoryRoot, outputPath)}`);
console.log("Meshes: pillar_decorated, wall_pillar, torch_lit; shared textures: 1; encoding: KTX2/UASTC.");
+170
View File
@@ -0,0 +1,170 @@
import { access, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Box3, Vector3 } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
import { dedup, meshopt, prune, resample } from "@gltf-transform/functions";
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const assetDirectory = path.join(repositoryRoot, "game_assets", "models", "sketchfab-opensource");
const sourcePath = path.join(assetDirectory, "animated_triceratops_skeleton.glb");
const legacyPath = path.join(assetDirectory, "gravehorn-triceratops.glb");
const optimizedPath = path.join(assetDirectory, "gravehorn-triceratops-uastc.glb");
const requiredClips = [
"Armature|RiseUp",
"Armature|Roar",
"Armature|Walk",
"Armature|Fall",
"Gravehorn|Idle",
];
const unusedClips = new Set(["Armature|IdleGround", "Armature|RoarToWalk"]);
function sortedNames(properties) {
return properties.map((property) => property.getName()).sort();
}
function addUprightIdle(document) {
const walk = document.getRoot().listAnimations().find((animation) => animation.getName() === "Armature|Walk");
const buffer = document.getRoot().listBuffers()[0];
if (!walk || !buffer) throw new Error("Gravehorn source is missing its Walk animation or binary buffer.");
const idle = document.createAnimation("Gravehorn|Idle");
for (const [index, sourceChannel] of walk.listChannels().entries()) {
const sourceSampler = sourceChannel.getSampler();
const sourceOutput = sourceSampler?.getOutput();
const sourceValues = sourceOutput?.getArray();
const targetNode = sourceChannel.getTargetNode();
const targetPath = sourceChannel.getTargetPath();
if (!sourceSampler || !sourceOutput || !sourceValues || !targetNode || !targetPath) {
throw new Error(`Gravehorn Walk channel ${index} is incomplete.`);
}
if (sourceSampler.getInterpolation() === "CUBICSPLINE") {
throw new Error("Gravehorn upright idle builder does not support cubic animation tracks.");
}
const elementSize = sourceOutput.getElementSize();
const values = new sourceValues.constructor(elementSize * 2);
values.set(sourceValues.subarray(0, elementSize), 0);
values.set(sourceValues.subarray(0, elementSize), elementSize);
const input = document.createAccessor(`gravehorn_idle_time_${index}`)
.setType("SCALAR")
.setArray(new Float32Array([0, 1]))
.setBuffer(buffer);
const output = document.createAccessor(`gravehorn_idle_value_${index}`)
.setType(sourceOutput.getType())
.setNormalized(sourceOutput.getNormalized())
.setArray(values)
.setBuffer(buffer);
const sampler = document.createAnimationSampler(`gravehorn_idle_sampler_${index}`)
.setInput(input)
.setOutput(output)
.setInterpolation("LINEAR");
const channel = document.createAnimationChannel(`gravehorn_idle_channel_${index}`)
.setSampler(sampler)
.setTargetNode(targetNode)
.setTargetPath(targetPath);
idle.addSampler(sampler).addChannel(channel);
}
}
async function runtimeBounds(assetPath) {
const data = await readFile(assetPath);
const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
globalThis.self ??= globalThis;
globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} });
const gltf = await new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).parseAsync(arrayBuffer, "");
gltf.scene.updateMatrixWorld(true);
const bounds = new Box3().setFromObject(gltf.scene);
return {
min: bounds.min.toArray(),
max: bounds.max.toArray(),
center: bounds.getCenter(new Vector3()).toArray(),
};
}
function validateStructure(document, { optimized }) {
const root = document.getRoot();
const material = root.listMaterials()[0];
const clips = sortedNames(root.listAnimations());
const textureMimeTypes = root.listTextures().map((texture) => texture.getMimeType());
const extensionNames = new Set(root.listExtensionsUsed().map((extension) => extension.extensionName));
if (JSON.stringify(clips) !== JSON.stringify([...requiredClips].sort())) {
throw new Error(`Gravehorn animation clips changed: ${clips.join(", ")}.`);
}
if (root.listMeshes().length !== 1 || root.listMaterials().length !== 1 || root.listTextures().length !== 3 || root.listSkins().length !== 1) {
throw new Error("Gravehorn must contain one mesh, one material, three textures, and one skin.");
}
if (root.listSkins()[0].listJoints().length !== 140) {
throw new Error(`Gravehorn joint count changed: ${root.listSkins()[0].listJoints().length}.`);
}
if (material.getDoubleSided()) {
throw new Error("Gravehorn material must keep backface culling enabled.");
}
if (!extensionNames.has("EXT_meshopt_compression")) {
throw new Error("Gravehorn is missing Meshopt compression.");
}
if (optimized && (textureMimeTypes.some((mimeType) => mimeType !== "image/ktx2") || !extensionNames.has("KHR_texture_basisu"))) {
throw new Error("Optimized Gravehorn asset must contain only KTX2 textures.");
}
if (!optimized && textureMimeTypes.some((mimeType) => mimeType === "image/ktx2")) {
throw new Error("Legacy Gravehorn fallback must retain standard textures.");
}
}
await access(sourcePath);
await MeshoptDecoder.ready;
await MeshoptEncoder.ready;
const io = await createGameAssetIO();
const document = await io.read(sourcePath);
const root = document.getRoot();
const scene = root.listScenes()[0].setName("gravehorn_triceratops");
const sceneRoot = scene.listChildren()[0];
const sourceBounds = await runtimeBounds(sourcePath);
const sourceTranslation = sceneRoot.getTranslation();
sceneRoot.setTranslation([
sourceTranslation[0] - sourceBounds.center[0],
sourceTranslation[1] - sourceBounds.min[1],
sourceTranslation[2] - sourceBounds.center[2],
]);
root.listMeshes()[0].setName("gravehorn_triceratops");
root.listMaterials()[0]
.setName("gravehorn_bone")
.setDoubleSided(false)
.setEmissiveFactor([0.1, 0.06, 0.02]);
addUprightIdle(document);
for (const animation of root.listAnimations()) {
if (!unusedClips.has(animation.getName())) continue;
for (const channel of animation.listChannels()) channel.dispose();
for (const sampler of animation.listSamplers()) sampler.dispose();
animation.dispose();
}
await document.transform(
resample({ tolerance: 1e-4 }),
dedup({ keepUniqueNames: true }),
prune({ keepLeaves: true, keepSolidTextures: true }),
meshopt({ encoder: MeshoptEncoder, level: "high" }),
);
await io.write(legacyPath, document);
const legacyDocument = await io.read(legacyPath);
validateStructure(legacyDocument, { optimized: false });
const legacyBounds = await runtimeBounds(legacyPath);
if (Math.abs(legacyBounds.min[1]) > 0.015 || Math.abs(legacyBounds.center[0]) > 0.015 || Math.abs(legacyBounds.center[2]) > 0.015) {
throw new Error(`Gravehorn pivot is not grounded and centered: minY=${legacyBounds.min[1]}, centerX=${legacyBounds.center[0]}, centerZ=${legacyBounds.center[2]}.`);
}
const optimizedDocument = await io.read(legacyPath);
await convertDocumentTexturesToKtx2(optimizedDocument, "iwt-gravehorn-");
await io.write(optimizedPath, optimizedDocument);
validateStructure(await io.read(optimizedPath), { optimized: true });
const sourceSize = (await stat(sourcePath)).size;
const legacySize = (await stat(legacyPath)).size;
const optimizedSize = (await stat(optimizedPath)).size;
console.log(`Built ${path.relative(repositoryRoot, legacyPath)} (${sourceSize} -> ${legacySize} bytes).`);
console.log(`Built ${path.relative(repositoryRoot, optimizedPath)} (${optimizedSize} bytes, KTX2/UASTC).`);
+70
View File
@@ -0,0 +1,70 @@
import { access } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourceRoot = path.join(repositoryRoot, "game_assets");
const sourceAssets = [
"models/claudecraft/chars/players/druid.glb",
"models/claudecraft/chars/players/knight.glb",
"models/claudecraft/chars/players/mage.glb",
"models/claudecraft/chars/players/ranger.glb",
"models/claudecraft/chars/players/rogue.glb",
"models/claudecraft/weapons/adv_dagger.glb",
"models/claudecraft/weapons/adv_druid_staff.glb",
"models/claudecraft/weapons/adv_sword_1handed.glb",
"models/claudecraft/weapons/adv_wand.glb",
"models/claudecraft/weapons/crossbow_2handed.glb",
"models/claudecraft/weapons/shield_badge.glb",
"models/claudecraft/weapons/spellbook_open.glb",
];
function optimizedPath(sourcePath) {
return sourcePath.replace(/\.glb$/, "-uastc.glb");
}
function sortedNames(properties) {
return properties.map((property) => property.getName()).sort();
}
const io = await createGameAssetIO();
for (const relativePath of sourceAssets) {
const inputPath = path.join(sourceRoot, relativePath);
const outputRelativePath = optimizedPath(relativePath);
const outputPath = path.join(sourceRoot, outputRelativePath);
await access(inputPath);
const document = await io.read(inputPath);
const sourceRootProperties = document.getRoot();
const expected = {
animations: sortedNames(sourceRootProperties.listAnimations()),
materials: sortedNames(sourceRootProperties.listMaterials()),
meshes: sortedNames(sourceRootProperties.listMeshes()),
nodes: sortedNames(sourceRootProperties.listNodes()),
scenes: sortedNames(sourceRootProperties.listScenes()),
textureCount: sourceRootProperties.listTextures().length,
};
await convertDocumentTexturesToKtx2(document, "iwt-ktx2-");
await io.write(outputPath, document);
const outputDocument = await io.read(outputPath);
const outputRoot = outputDocument.getRoot();
const actual = {
animations: sortedNames(outputRoot.listAnimations()),
materials: sortedNames(outputRoot.listMaterials()),
meshes: sortedNames(outputRoot.listMeshes()),
nodes: sortedNames(outputRoot.listNodes()),
scenes: sortedNames(outputRoot.listScenes()),
textureCount: outputRoot.listTextures().length,
};
if (
JSON.stringify(actual) !== JSON.stringify(expected)
|| outputRoot.listTextures().some((texture) => texture.getMimeType() !== "image/ktx2")
|| outputRoot.listExtensionsUsed().some((extension) => extension.extensionName === "EXT_texture_webp")
) {
throw new Error(`Generated asset failed round-trip validation: ${outputRelativePath}`);
}
console.log(`Built game_assets/${outputRelativePath}`);
}
+72
View File
@@ -0,0 +1,72 @@
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS, EXTTextureWebP, KHRTextureBasisu } from "@gltf-transform/extensions";
import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
import sharp from "sharp";
export const TOKTX_COMMAND = process.env.TOKTX ?? "toktx";
export async function createGameAssetIO() {
await MeshoptDecoder.ready;
await MeshoptEncoder.ready;
return new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({
"meshopt.decoder": MeshoptDecoder,
"meshopt.encoder": MeshoptEncoder,
});
}
function run(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: "inherit" });
child.once("error", reject);
child.once("exit", (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} exited with code ${code ?? "unknown"}.`));
});
});
}
export async function convertDocumentTexturesToKtx2(document, temporaryPrefix) {
const textures = document.getRoot().listTextures();
if (textures.length === 0) throw new Error("Asset contains no textures to convert.");
const temporaryDirectory = await mkdtemp(path.join(tmpdir(), temporaryPrefix));
try {
for (const [index, texture] of textures.entries()) {
const sourceImage = texture.getImage();
if (!sourceImage) throw new Error(`Texture ${texture.getName() || index} contains no image data.`);
const pngPath = path.join(temporaryDirectory, `texture-${index}.png`);
const ktx2Path = path.join(temporaryDirectory, `texture-${index}.ktx2`);
await writeFile(pngPath, await sharp(sourceImage).png().toBuffer());
await run(TOKTX_COMMAND, [
"--t2",
"--encode", "uastc",
"--uastc_quality", "4",
"--zcmp", "18",
"--threads", process.env.TOKTX_THREADS ?? "4",
"--genmipmap",
"--assign_oetf", "srgb",
"--assign_primaries", "bt709",
ktx2Path,
pngPath,
]);
texture
.setName(`${texture.getName() || `texture_${index}`}_uastc`)
.setMimeType("image/ktx2")
.setImage(new Uint8Array(await readFile(ktx2Path)));
}
document.getRoot().listExtensionsUsed()
.find((extension) => extension.extensionName === EXTTextureWebP.EXTENSION_NAME)
?.dispose();
document.createExtension(KHRTextureBasisu).setRequired(true);
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
+39 -20
View File
@@ -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}")
+15
View File
@@ -0,0 +1,15 @@
import { copyFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourceDirectory = path.join(repositoryRoot, "node_modules", "three", "examples", "jsm", "libs", "basis");
const outputDirectory = path.join(repositoryRoot, "public", "basis");
const files = ["basis_transcoder.js", "basis_transcoder.wasm"];
await mkdir(outputDirectory, { recursive: true });
for (const file of files) {
await copyFile(path.join(sourceDirectory, file), path.join(outputDirectory, file));
}
console.log(`Synced Three.js Basis transcoder ${files.join(", ")} -> public/basis`);
+38
View File
@@ -217,6 +217,14 @@ function syncLeaderboardStats(database, accountId, slotId, save) {
highest_round = excluded.highest_round, highest_round = excluded.highest_round,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, highestRound); `).run(accountId, slotId, highestRound);
const highestEndlessKills = normalizeNonNegativeInteger(save.stats?.highestRogueTrialsEndlessKills);
database.prepare(`
INSERT INTO rogue_trials_endless_records (account_id, slot_id, highest_boss_kills, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
highest_boss_kills = excluded.highest_boss_kills,
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, highestEndlessKills);
} }
function writeSave(database, accountId, slotId, rawSave) { function writeSave(database, accountId, slotId, rawSave) {
@@ -324,6 +332,32 @@ function roguelikeLeaderboard(database, accountId, slotId) {
}; };
} }
function rogueTrialsEndlessLeaderboard(database, accountId, slotId) {
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (ORDER BY records.highest_boss_kills DESC) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.highest_boss_kills AS highestBossKills,
accounts.username,
saves.hunter_name AS hunterName,
records.updated_at AS updatedAt
FROM rogue_trials_endless_records records
JOIN accounts ON accounts.id = records.account_id
JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id
WHERE records.highest_boss_kills > 0
)
SELECT * FROM ranked ORDER BY highestBossKills DESC, updatedAt ASC, accountId ASC, slotId ASC
`).all();
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
return {
kind: "rogue-trials-endless",
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestBossKills")),
current: current ? leaderboardEntry(current, "highestBossKills") : null,
};
}
export function createGameApiHandler(options = {}) { export function createGameApiHandler(options = {}) {
const dataDirectory = resolve(options.dataDirectory ?? process.env.DATA_DIR ?? "data"); const dataDirectory = resolve(options.dataDirectory ?? process.env.DATA_DIR ?? "data");
mkdirSync(dataDirectory, { recursive: true }); mkdirSync(dataDirectory, { recursive: true });
@@ -387,6 +421,10 @@ export function createGameApiHandler(options = {}) {
const slotId = validateSlotId(url.searchParams.get("slot")); const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, roguelikeLeaderboard(database, session.accountId, slotId)); return sendJson(response, 200, roguelikeLeaderboard(database, session.accountId, slotId));
} }
if (path === "/api/leaderboards/rogue-trials-endless" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, rogueTrialsEndlessLeaderboard(database, session.accountId, slotId));
}
return sendJson(response, 404, { error: "API route not found." }); return sendJson(response, 404, { error: "API route not found." });
} catch (error) { } catch (error) {
const status = Number(error?.status) || 500; const status = Number(error?.status) || 500;
+14 -4
View File
@@ -34,12 +34,12 @@ async function json(path, init = {}) {
return { response, body }; return { response, body };
} }
function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound) { function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound, highestRogueTrialsEndlessKills) {
return { return {
schemaVersion: 5, schemaVersion: 5,
slotId, slotId,
hunterName, hunterName,
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound }, stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills },
}; };
} }
@@ -62,13 +62,14 @@ test("accounts, server saves, and top-five plus current rankings work end to end
const token = registration.body.token; const token = registration.body.token;
const kills = 60 - index * 10; const kills = 60 - index * 10;
const highestRound = 30 - index * 4; const highestRound = 30 - index * 4;
const highestEndlessKills = 24 - index * 3;
const upload = await json("/api/saves/1", { const upload = await json("/api/saves/1", {
method: "PUT", method: "PUT",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound) }), body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound, highestEndlessKills) }),
}); });
assert.equal(upload.response.status, 200); assert.equal(upload.response.status, 200);
players.push({ token, kills, highestRound }); players.push({ token, kills, highestRound, highestEndlessKills });
} }
const current = players[5]; const current = players[5];
@@ -87,6 +88,15 @@ test("accounts, server saves, and top-five plus current rankings work end to end
assert.equal(rogueBoard.body.current.rank, 6); assert.equal(rogueBoard.body.current.rank, 6);
assert.equal(rogueBoard.body.current.value, current.highestRound); assert.equal(rogueBoard.body.current.value, current.highestRound);
const endlessBoard = await json("/api/leaderboards/rogue-trials-endless?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(endlessBoard.body.kind, "rogue-trials-endless");
assert.equal(endlessBoard.body.top.length, 5);
assert.equal(endlessBoard.body.top[0].value, 24);
assert.equal(endlessBoard.body.current.rank, 6);
assert.equal(endlessBoard.body.current.value, current.highestEndlessKills);
const download = await json("/api/saves/1", { const download = await json("/api/saves/1", {
headers: { Authorization: `Bearer ${current.token}` }, headers: { Authorization: `Bearer ${current.token}` },
}); });
+21 -9
View File
@@ -8,7 +8,7 @@ import type { BossId } from "./game/types";
import type { DifficultySlug } from "./game/progression/loot"; import type { DifficultySlug } from "./game/progression/loot";
import { useActionBindings } from "./game/useGameLoop"; import { useActionBindings } from "./game/useGameLoop";
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen"; import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
import { DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync"; import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen }))); const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen }))); const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
@@ -33,6 +33,7 @@ export default function App() {
const updateActiveHealerInventory = useFrontendStore((state) => state.updateActiveHealerInventory); const updateActiveHealerInventory = useFrontendStore((state) => state.updateActiveHealerInventory);
const recordBossVictory = useFrontendStore((state) => state.recordBossVictory); const recordBossVictory = useFrontendStore((state) => state.recordBossVictory);
const recordRoguelikeDefeat = useFrontendStore((state) => state.recordRoguelikeDefeat); const recordRoguelikeDefeat = useFrontendStore((state) => state.recordRoguelikeDefeat);
const recordRogueTrialsEndlessDefeat = useFrontendStore((state) => state.recordRogueTrialsEndlessDefeat);
const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards); const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards);
const rewardedBossInstances = useRef(new Set<string>()); const rewardedBossInstances = useRef(new Set<string>());
const screenRef = useRef(screen); const screenRef = useRef(screen);
@@ -69,6 +70,11 @@ export default function App() {
return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch); return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
}, [launchGame]); }, [launchGame]);
useEffect(() => {
window.addEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
return () => window.removeEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
}, [leaveGame]);
useActionBindings(screen === "game", leaveGame); useActionBindings(screen === "game", leaveGame);
useEffect(() => { useEffect(() => {
@@ -79,8 +85,11 @@ export default function App() {
useEffect(() => { useEffect(() => {
return useGameStore.subscribe((state, previousState) => { return useGameStore.subscribe((state, previousState) => {
const startedFreshEncounter = state.phase === "briefing" && previousState.phase !== "briefing" const startedFreshEncounter = state.phase === "briefing" && previousState.phase !== "briefing"
|| previousState.phase === "intermission" && state.phase === "combat"; || previousState.phase === "intermission" && state.phase === "combat"
if (state.phase === "briefing" || previousState.phase === "intermission" && state.phase === "combat") { || previousState.phase === "victory" && state.phase === "combat" && state.endlessMode;
if (state.phase === "briefing"
|| previousState.phase === "intermission" && state.phase === "combat"
|| previousState.phase === "victory" && state.phase === "combat" && state.endlessMode) {
rewardedBossInstances.current.clear(); rewardedBossInstances.current.clear();
} }
if (startedFreshEncounter) clearRecentRewards(); if (startedFreshEncounter) clearRecentRewards();
@@ -88,11 +97,14 @@ export default function App() {
if (state.runMode === "roguelike" && state.phase === "defeat" && previousState.phase !== "defeat") { if (state.runMode === "roguelike" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordRoguelikeDefeat(state.round); recordRoguelikeDefeat(state.round);
} }
if (state.endlessMode && state.phase === "defeat" && previousState.phase !== "defeat") {
recordRogueTrialsEndlessDefeat(state.endlessBossKills);
}
const bossCount = 1 + state.additionalBosses.length; const bossCount = 1 + state.additionalBosses.length;
if (state.boss.hp <= 0 && previousState.boss.hp > 0) { if (state.boss.hp <= 0 && previousState.boss.hp > 0) {
const primaryInstanceId = `boss-0-${state.boss.id}`; const primaryInstanceId = state.bossInstanceId;
if (!rewardedBossInstances.current.has(primaryInstanceId)) { if (!rewardedBossInstances.current.has(primaryInstanceId)) {
rewardedBossInstances.current.add(primaryInstanceId); if (!state.endlessMode) rewardedBossInstances.current.add(primaryInstanceId);
const defeatedBefore = (state.round - 1) * bossCount; const defeatedBefore = (state.round - 1) * bossCount;
const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug; const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
recordBossVictory(state.boss.id, rewardDifficulty); recordBossVictory(state.boss.id, rewardDifficulty);
@@ -102,14 +114,14 @@ export default function App() {
const entry = state.additionalBosses[index]; const entry = state.additionalBosses[index];
const previous = previousState.additionalBosses[index]; const previous = previousState.additionalBosses[index];
const justDefeated = entry.boss.hp <= 0 && (!previous || previous.instanceId !== entry.instanceId || previous.boss.hp > 0); const justDefeated = entry.boss.hp <= 0 && (!previous || previous.instanceId !== entry.instanceId || previous.boss.hp > 0);
if (!justDefeated || rewardedBossInstances.current.has(entry.instanceId)) continue; if (!justDefeated || !state.endlessMode && rewardedBossInstances.current.has(entry.instanceId)) continue;
rewardedBossInstances.current.add(entry.instanceId); if (!state.endlessMode) rewardedBossInstances.current.add(entry.instanceId);
const defeatedBefore = (state.round - 1) * bossCount + index + 1; const defeatedBefore = (state.round - 1) * bossCount + index + 1;
const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug; const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
recordBossVictory(entry.boss.id, rewardDifficulty); recordBossVictory(entry.boss.id, rewardDifficulty);
} }
}); });
}, [clearRecentRewards, recordBossVictory, recordRoguelikeDefeat]); }, [clearRecentRewards, recordBossVictory, recordRoguelikeDefeat, recordRogueTrialsEndlessDefeat]);
return ( return (
<main className="app-shell"> <main className="app-shell">
@@ -118,7 +130,7 @@ export default function App() {
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p> <p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
</header> </header>
{screen === "game" {screen === "game"
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} bottom={<BottomScreen />} /></Suspense> ? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} bottom={<BottomScreen onExit={leaveGame} />} /></Suspense>
: <FrontEnd onLaunch={launchGame} />} : <FrontEnd onLaunch={launchGame} />}
</main> </main>
); );
+16
View File
@@ -3,3 +3,19 @@
Only assets referenced by the game ship from this folder. They are copied from the ignored `game_assets` source library, retain their source-relative path, and are imported with Vite `new URL(...)` calls. 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.
@@ -0,0 +1,7 @@
# Animated Triceratops Skeleton
- Creator: Zacxophone — https://sketchfab.com/Zacxophone
- Source: https://sketchfab.com/3d-models/animated-triceratops-skeleton-06cb55f941d94dc8b95ac46f92d89e7c
- License: CC0 1.0 Universal — https://creativecommons.org/publicdomain/zero/1.0/
Runtime files named `gravehorn-triceratops*.glb` are optimized derivatives of this model.
+97 -17
View File
@@ -1,15 +1,22 @@
import { useFrame } from "@react-three/fiber"; import { 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);
}
+5 -3
View File
@@ -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>
+39 -15
View File
@@ -6,6 +6,7 @@ import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../g
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat"; import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
import type { BottomTab, PartyMember } from "../game/types"; import type { BottomTab, PartyMember } from "../game/types";
import { useFrontendStore } from "../frontend/store"; import { useFrontendStore } from "../frontend/store";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
function RewardSummary() { function RewardSummary() {
const rewards = useFrontendStore((state) => state.recentRewards); const rewards = useFrontendStore((state) => state.recentRewards);
@@ -83,6 +84,7 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil); const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
const mana = useGameStore((state) => state.mana); const mana = useGameStore((state) => state.mana);
const phase = useGameStore((state) => state.phase); const phase = useGameStore((state) => state.phase);
const healerAlive = useGameStore((state) => state.party.some((member) => member.id === "aelia" && member.hp > 0));
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!); const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const activeCast = useGameStore((state) => state.activeCast); const activeCast = useGameStore((state) => state.activeCast);
const castAbility = useGameStore((state) => state.castAbility); const castAbility = useGameStore((state) => state.castAbility);
@@ -94,7 +96,7 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
const globalRemaining = Math.max(0, globalCooldownUntil - time); const globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = abilityId === "purify" && selected.debuffs.length === 0; const noDispel = abilityId === "purify" && selected.debuffs.length === 0;
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0; const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
const disabled = phase !== "combat" || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget; const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const resourceCopy = `${manaCost ? `${manaCost} mana` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`; const resourceCopy = `${manaCost ? `${manaCost} mana` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
return ( return (
@@ -170,7 +172,7 @@ function BriefingPanel() {
<span>Chosen discipline</span> <span>Chosen discipline</span>
<h2>{healer.specialization}</h2> <h2>{healer.specialization}</h2>
<p>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</p> <p>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</p>
<button className="start-button" onClick={startEncounter}><span>Face {bossNames}</span><small>START / ENTER</small></button> <button className="start-button" onClick={startEncounter}><span>Face {bossNames}</span><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ENTER</small></button>
</div> </div>
<div className="briefing-kit"> <div className="briefing-kit">
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div> <div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
@@ -189,29 +191,51 @@ function BriefingPanel() {
); );
} }
function EndPanel() { function EndPanel({ onExit }: { onExit?: () => void }) {
const phase = useGameStore((state) => state.phase); const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
const round = useGameStore((state) => state.round);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const endlessChoiceSelection = useGameStore((state) => state.endlessChoiceSelection);
const setEndlessChoiceSelection = useGameStore((state) => state.setEndlessChoiceSelection);
const startRogueTrialsEndless = useGameStore((state) => state.startRogueTrialsEndless);
const time = useGameStore((state) => state.time); const time = useGameStore((state) => state.time);
const party = useGameStore((state) => state.party); const party = useGameStore((state) => state.party);
const restart = useGameStore((state) => state.restart); const restart = useGameStore((state) => state.restart);
const startEncounter = useGameStore((state) => state.startEncounter); const startEncounter = useGameStore((state) => state.startEncounter);
const totalHp = party.reduce((sum, member) => sum + member.hp, 0); const totalHp = party.reduce((sum, member) => sum + member.hp, 0);
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0); const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
const endlessDefeat = phase === "defeat" && endlessMode;
return ( return (
<div className={`end-panel end-${phase}`}> <div className={`end-panel end-${phase}`}>
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span> <span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
<small>{phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small> <small>{showEndlessChoice ? "ROGUE TRIALS CLEARED" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
<h2>{phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2> <h2>{showEndlessChoice ? "The trial can continue" : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
<div className="result-stats"> <div className="result-stats">
<span><small>Duration</small><strong>{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}</strong></span> <span><small>Duration</small><strong>{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}</strong></span>
<span><small>Party vitality</small><strong>{Math.round((totalHp / totalMax) * 100)}%</strong></span> <span><small>Party vitality</small><strong>{Math.round((totalHp / totalMax) * 100)}%</strong></span>
<span><small>Boss</small><strong>{phase === "victory" ? "Defeated" : "Standing"}</strong></span> <span><small>{endlessDefeat ? "Endless kills" : "Boss"}</small><strong>{endlessDefeat ? endlessBossKills : phase === "victory" ? "Defeated" : "Standing"}</strong></span>
</div> </div>
{phase === "victory" && <RewardSummary />} {phase === "victory" && <RewardSummary />}
<div className="end-actions"> {showEndlessChoice ? <div className="end-actions endless-choice-actions">
<button
className={endlessChoiceSelection === "continue" ? "is-controller-focused" : ""}
onFocus={() => setEndlessChoiceSelection("continue")}
onPointerEnter={() => setEndlessChoiceSelection("continue")}
onClick={startRogueTrialsEndless}
>Continue Endless</button>
<button
className={`secondary ${endlessChoiceSelection === "quit" ? "is-controller-focused" : ""}`}
onFocus={() => setEndlessChoiceSelection("quit")}
onPointerEnter={() => setEndlessChoiceSelection("quit")}
onClick={onExit}
>Quit to Main Menu</button>
</div> : <div className="end-actions">
<button onClick={() => { restart(); startEncounter(); }}>Run again</button> <button onClick={() => { restart(); startEncounter(); }}>Run again</button>
<button className="secondary" onClick={restart}>Return to briefing</button> <button className="secondary" onClick={endlessDefeat ? onExit : restart}>{endlessDefeat ? "Return to main menu" : "Return to briefing"}</button>
</div> </div>}
</div> </div>
); );
} }
@@ -225,16 +249,16 @@ function IntermissionStatusPanel() {
<h2>Choose on top display</h2> <h2>Choose on top display</h2>
<p>Next encounter stays locked until one blessing is claimed.</p> <p>Next encounter stays locked until one blessing is claimed.</p>
<RewardSummary /> <RewardSummary />
<small>Use D-pad to choose · A to claim</small> <small>Use D-pad to choose · {DEFAULT_CONTROLLER_GLYPHS.confirm} to claim</small>
</div> </div>
); );
} }
function CombatPanel() { function CombatPanel({ onExit }: { onExit?: () => void }) {
const phase = useGameStore((state) => state.phase); const phase = useGameStore((state) => state.phase);
if (phase === "briefing") return <BriefingPanel />; if (phase === "briefing") return <BriefingPanel />;
if (phase === "intermission") return <IntermissionStatusPanel />; if (phase === "intermission") return <IntermissionStatusPanel />;
if (phase === "victory" || phase === "defeat") return <EndPanel />; if (phase === "victory" || phase === "defeat") return <EndPanel onExit={onExit} />;
return <div className="combat-panel"><PartyList /><AbilityTray /></div>; return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
} }
@@ -340,7 +364,7 @@ const tabs: { id: BottomTab; label: string; icon: string; key: string }[] = [
{ id: "pack", label: "Pack", icon: "▧", key: "I" }, { id: "pack", label: "Pack", icon: "▧", key: "I" },
]; ];
export function BottomScreen() { export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
const activeTab = useGameStore((state) => state.activeTab); const activeTab = useGameStore((state) => state.activeTab);
const setActiveTab = useGameStore((state) => state.setActiveTab); const setActiveTab = useGameStore((state) => state.setActiveTab);
const phase = useGameStore((state) => state.phase); const phase = useGameStore((state) => state.phase);
@@ -358,13 +382,13 @@ export function BottomScreen() {
</nav> </nav>
</header> </header>
<main className="lower-content"> <main className="lower-content">
{activeTab === "combat" && <CombatPanel />} {activeTab === "combat" && <CombatPanel onExit={onExit} />}
{activeTab === "map" && <MapPanel />} {activeTab === "map" && <MapPanel />}
{activeTab === "pack" && <PackPanel />} {activeTab === "pack" && <PackPanel />}
</main> </main>
{paused && ( {paused && (
<div className="lower-pause-overlay" aria-hidden="true"> <div className="lower-pause-overlay" aria-hidden="true">
<span>PAUSED</span><strong>Encounter suspended</strong><small>START / ESC resumes · selects menu action</small> <span>PAUSED</span><strong>Encounter suspended</strong><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC resumes · selects menu action</small>
</div> </div>
)} )}
</section> </section>
+2 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { ROGUE_TRIALS_TRIO_ROUND, RUN_BUFFS, bossHealthMultiplier, effectiveRunBuffRank, formatRunBuffEffect } from "../game/roguelike"; import { ROGUE_TRIALS_TRIO_ROUND, RUN_BUFFS, bossHealthMultiplier, effectiveRunBuffRank, formatRunBuffEffect } from "../game/roguelike";
import { HEALER_CLASSES } from "../game/healers"; import { HEALER_CLASSES } from "../game/healers";
import { isRunBuffInputLocked, useGameStore } from "../game/store"; import { isRunBuffInputLocked, useGameStore } from "../game/store";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
export function BuffDraftPanel({ className = "" }: { className?: string }) { export function BuffDraftPanel({ className = "" }: { className?: string }) {
const round = useGameStore((state) => state.round); const round = useGameStore((state) => state.round);
@@ -67,7 +68,7 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
</button> </button>
)} )}
</div> </div>
<footer>{inputLocked ? <b>Choices ready in a moment</b> : <>{choices.length > 0 && <><b> / </b> Choose <i /></>} <b>A / ENTER</b> {choices.length > 0 ? "Claim" : "Continue"}</>}</footer> <footer>{inputLocked ? <b>Choices ready in a moment</b> : <>{choices.length > 0 && <><b> / </b> Choose <i /></>} <b>{DEFAULT_CONTROLLER_GLYPHS.confirm} / ENTER</b> {choices.length > 0 ? "Claim" : "Continue"}</>}</footer>
</div> </div>
); );
} }
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting"; import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
import { subscribeControllerToken } from "../input/controller"; import { subscribeControllerToken } from "../input/controller";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) { export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
const dedicatedSurface = new URLSearchParams(window.location.search).get("display"); const dedicatedSurface = new URLSearchParams(window.location.search).get("display");
@@ -45,7 +46,7 @@ export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: Reac
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")} onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")}
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"} aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
> >
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>SELECT / TAB</small> <b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
</button> </button>
</div> </div>
); );
+203 -72
View File
@@ -1,6 +1,7 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { buildCollections, MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName } from "../frontend/data"; import { buildCollections, MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName } from "../frontend/data";
import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository"; import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
import { resolveSaveContinuation, saveVersionsMatch } from "../frontend/saveContinuation";
import { useActiveHunter, useFrontendStore } from "../frontend/store"; import { useActiveHunter, useFrontendStore } from "../frontend/store";
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types"; import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
import { useMenuController, type MenuAction } from "../input/useMenuController"; import { useMenuController, type MenuAction } from "../input/useMenuController";
@@ -37,6 +38,7 @@ import {
import { requestDisplaySurface } from "../platform/displayRouting"; import { requestDisplaySurface } from "../platform/displayRouting";
import { onlineRepository, type LeaderboardResult } from "../frontend/onlineRepository"; import { onlineRepository, type LeaderboardResult } from "../frontend/onlineRepository";
import { DualDisplayFrame } from "./DualDisplayFrame"; import { DualDisplayFrame } from "./DualDisplayFrame";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
const BossTrophyPortrait = lazy(() => import("./BossTrophyPortrait").then((module) => ({ default: module.BossTrophyPortrait }))); const BossTrophyPortrait = lazy(() => import("./BossTrophyPortrait").then((module) => ({ default: module.BossTrophyPortrait })));
@@ -76,7 +78,40 @@ function BrandMark({ compact = false }: { compact?: boolean }) {
} }
function ControllerLegend({ back = false }: { back?: boolean }) { function ControllerLegend({ back = false }: { back?: boolean }) {
return <div className="controller-legend"><span><b>A</b> Select</span>{back && <span><b>B</b> Back</span>}<span><b></b> Navigate</span></div>; return <div className="controller-legend"><span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Select</span>{back && <span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back</span>}<span><b></b> Navigate</span></div>;
}
function SaveLibraryContext({ slots, accountId }: { slots: readonly SaveSlotState[]; accountId: string | null }) {
return (
<FrontSurface className="login-save-context" bottom ariaLabel="Save slot information">
<header className="context-header"><span>Device saves</span><b>{accountId ? "SERVER LINKED" : "OFFLINE READY"}</b></header>
<div className="login-save-list">
{slots.map((slot) => {
const save = slot.local ?? slot.online;
const healer = save ? HEALER_CLASSES[save.activeClassId] : null;
return (
<article key={slot.id} className={save ? "has-save" : "is-empty"}>
<b>{String(slot.id).padStart(2, "0")}</b>
{save ? (
<>
<div className="login-save-avatar">{save.hunterName[0]}</div>
<span>
<small>{slot.local ? "On this Thor" : "Online copy"}</small>
<strong>{save.hunterName}</strong>
<em>Level {save.healers[save.activeClassId].level} {healer?.name} · {save.location}</em>
</span>
<time><strong>{formatPlayTime(save.playSeconds)}</strong><small>{formatSaveTimestamp(save.updatedAt)}</small></time>
</>
) : (
<span className="login-empty-copy"><small>Available slot</small><strong>New hunter</strong><em>Continue offline to create</em></span>
)}
</article>
);
})}
</div>
<footer className="login-save-footer"><span>Save details update from upper-screen selection</span><b>LOWER DISPLAY · INFORMATION ONLY</b></footer>
</FrontSurface>
);
} }
function LoginScreen() { function LoginScreen() {
@@ -84,6 +119,8 @@ function LoginScreen() {
const signIn = useFrontendStore((state) => state.signIn); const signIn = useFrontendStore((state) => state.signIn);
const createAccount = useFrontendStore((state) => state.createAccount); const createAccount = useFrontendStore((state) => state.createAccount);
const continueOffline = useFrontendStore((state) => state.continueOffline); const continueOffline = useFrontendStore((state) => state.continueOffline);
const slots = useFrontendStore((state) => state.slots);
const accountId = useFrontendStore((state) => state.accountId);
const notice = useFrontendStore((state) => state.notice); const notice = useFrontendStore((state) => state.notice);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -100,7 +137,7 @@ function LoginScreen() {
{ id: "password", run: () => passwordRef.current?.focus() }, { id: "password", run: () => passwordRef.current?.focus() },
{ id: "sign-in", run: () => { void signIn(username, password); } }, { id: "sign-in", run: () => { void signIn(username, password); } },
{ id: "create-account", run: () => { void createAccount(username, password); } }, { id: "create-account", run: () => { void createAccount(username, password); } },
{ id: "offline", run: continueOffline }, { id: "continue", run: continueOffline },
], [continueOffline, createAccount, password, signIn, username]); ], [continueOffline, createAccount, password, signIn, username]);
const controller = useMenuController(actions); const controller = useMenuController(actions);
@@ -115,7 +152,7 @@ function LoginScreen() {
<div className="login-copy"> <div className="login-copy">
<span>Offline-first hunter records</span> <span>Offline-first hunter records</span>
<h1>Keep everyone standing.</h1> <h1>Keep everyone standing.</h1>
<p>Your save always lives on this device. Sign in only when you want a second copy for PC AYN Thor handoff.</p> <p>Continue to your saved hunters. Sign in when you want online copies for PC AYN Thor handoff.</p>
</div> </div>
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); submitSignIn(); }}> <form className="login-panel" onSubmit={(event) => { event.preventDefault(); submitSignIn(); }}>
<label htmlFor="account-username">Username</label> <label htmlFor="account-username">Username</label>
@@ -152,38 +189,27 @@ function LoginScreen() {
<FocusButton id="create-account" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={() => { void createAccount(username, password); }}> <FocusButton id="create-account" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={() => { void createAccount(username, password); }}>
<span>Create account</span><small>Required for first sync</small> <span>Create account</span><small>Required for first sync</small>
</FocusButton> </FocusButton>
<FocusButton id="offline" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}> <FocusButton id="continue" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}>
<span>Continue with offline save</span><small>No account required</small> <span>Continue</span><small>Choose saved hunter</small>
</FocusButton> </FocusButton>
</form> </form>
{notice && <div className="front-notice" role="status" aria-live="polite">{notice}</div>} {notice && <div className="front-notice" role="status" aria-live="polite">{notice}</div>}
<ControllerLegend /> <ControllerLegend />
</FrontSurface> </FrontSurface>
} }
bottom={ bottom={<SaveLibraryContext slots={slots} accountId={accountId} />}
<FrontSurface className="login-context" bottom ariaLabel="Offline save explanation">
<BrandMark compact />
<div className="offline-promise">
<span className="context-kicker">How saving works</span>
<ol>
<li><b>01</b><span><strong>Play offline</strong><small>Every change writes to device storage first.</small></span></li>
<li><b>02</b><span><strong>Create or sign in</strong><small>Account is secured by the TrueNAS game server.</small></span></li>
<li><b>03</b><span><strong>Move devices</strong><small>Upload or download any of your three server save slots.</small></span></li>
</ol>
</div>
<div className="device-route"><span>PC</span><i></i><b>ONLINE COPY</b><i></i><span>THOR</span></div>
</FrontSurface>
}
/> />
); );
} }
function SlotCard({ slot, selected, focused, onSelect, onFocus }: { slot: SaveSlotState; selected: boolean; focused: boolean; onSelect: () => void; onFocus: () => void }) { function SlotCard({ slot, selected, focused, onSelect, onFocus }: { slot: SaveSlotState; selected: boolean; focused: boolean; onSelect: () => void; onFocus: () => void }) {
const save = slot.local; const continuation = resolveSaveContinuation(slot);
const save = slot.local ?? slot.online;
const healer = save ? HEALER_CLASSES[save.activeClassId] : null; const healer = save ? HEALER_CLASSES[save.activeClassId] : null;
const copyStatus = continuation === "choose" ? "Newer online" : continuation === "online" ? "Online only" : null;
return ( return (
<button className={`save-slot ${selected ? "is-selected" : ""} ${focused ? "is-controller-focused" : ""}`} onClick={onSelect} onFocus={onFocus} onPointerEnter={onFocus}> <button className={`save-slot ${selected ? "is-selected" : ""} ${focused ? "is-controller-focused" : ""}`} onClick={onSelect} onFocus={onFocus} onPointerEnter={onFocus}>
<span className="slot-number">Slot {String(slot.id).padStart(2, "0")}</span> <span className="slot-number">Slot {String(slot.id).padStart(2, "0")}{copyStatus && <b>{copyStatus}</b>}</span>
{save ? ( {save ? (
<> <>
<div className="slot-portrait">{save.hunterName[0]}<i></i></div> <div className="slot-portrait">{save.hunterName[0]}<i></i></div>
@@ -212,11 +238,15 @@ function SaveScreen() {
const copySlot = useFrontendStore((state) => state.copySlot); const copySlot = useFrontendStore((state) => state.copySlot);
const deleteSlot = useFrontendStore((state) => state.deleteSlot); const deleteSlot = useFrontendStore((state) => state.deleteSlot);
const navigate = useFrontendStore((state) => state.navigate); const navigate = useFrontendStore((state) => state.navigate);
const [dialog, setDialog] = useState<"create" | "copy" | "delete" | null>(null); const [dialog, setDialog] = useState<"create" | "copy" | "delete" | "version" | null>(null);
const [hunterName, setHunterName] = useState(""); const [hunterName, setHunterName] = useState("");
const [resolvingOnline, setResolvingOnline] = useState(false);
const [versionError, setVersionError] = useState("");
const selected = slots.find((slot) => slot.id === selectedSlotId)!; const selected = slots.find((slot) => slot.id === selectedSlotId)!;
const hasLocal = Boolean(selected.local); const hasLocal = Boolean(selected.local);
const hasOnline = Boolean(selected.online); const hasOnline = Boolean(selected.online);
const continuation = resolveSaveContinuation(selected);
const primaryActionId = continuation === "create" ? "create" : "play";
const finishCreation = () => { const finishCreation = () => {
if (createSlot(selectedSlotId, hunterName)) { if (createSlot(selectedSlotId, hunterName)) {
@@ -234,7 +264,41 @@ function SaveScreen() {
requestDisplaySurface("top"); requestDisplaySurface("top");
}; };
const actions = useMemo<MenuAction[]>(() => dialog === "create" const continueWithOnline = async () => {
if (resolvingOnline) return;
setResolvingOnline(true);
setVersionError("");
await downloadSlot(selectedSlotId);
const refreshed = useFrontendStore.getState().slots.find((slot) => slot.id === selectedSlotId);
if (refreshed && saveVersionsMatch(refreshed.local, refreshed.online)) {
setDialog(null);
setResolvingOnline(false);
playSlot(selectedSlotId);
return;
}
setVersionError(useFrontendStore.getState().notice || "Online save could not be loaded.");
setResolvingOnline(false);
};
const continueSelected = () => {
if (continuation === "create") return openCreation();
if (continuation === "local") return playSlot(selectedSlotId);
if (continuation === "online") {
void continueWithOnline();
return;
}
setVersionError("");
setDialog("version");
requestDisplaySurface("top");
};
const actions = useMemo<MenuAction[]>(() => dialog === "version"
? [
{ id: "version-online", run: () => { void continueWithOnline(); }, enabled: !resolvingOnline },
{ id: "version-local", run: () => { setDialog(null); playSlot(selectedSlotId); }, enabled: !resolvingOnline },
{ id: "cancel-version", run: () => setDialog(null), enabled: !resolvingOnline },
]
: dialog === "create"
? [ ? [
{ id: "confirm-create", run: finishCreation }, { id: "confirm-create", run: finishCreation },
{ id: "cancel-create", run: () => setDialog(null) }, { id: "cancel-create", run: () => setDialog(null) },
@@ -247,17 +311,25 @@ function SaveScreen() {
{ id: "cancel-delete", run: () => setDialog(null) }, { id: "cancel-delete", run: () => setDialog(null) },
] ]
: [ : [
...slots.map((slot) => ({ id: `slot-${slot.id}`, run: () => selectSlot(slot.id) })), ...slots.map((slot, index) => ({
{ id: hasLocal ? "play" : "create", run: () => hasLocal ? playSlot(selectedSlotId) : openCreation() }, id: `slot-${slot.id}`,
{ id: "upload", run: () => uploadSlot(selectedSlotId), enabled: hasLocal && Boolean(accountId) }, run: () => selectSlot(slot.id),
{ id: "download", run: () => downloadSlot(selectedSlotId), enabled: hasOnline && Boolean(accountId) }, neighbors: {
{ id: "copy", run: () => openSaveDialog("copy"), enabled: hasLocal }, left: `slot-${slots[Math.max(0, index - 1)].id}`,
{ id: "delete", run: () => openSaveDialog("delete"), enabled: hasLocal }, right: `slot-${slots[Math.min(slots.length - 1, index + 1)].id}`,
{ id: "back", run: () => navigate("login") }, down: primaryActionId,
], [accountId, copySlot, createSlot, deleteSlot, dialog, downloadSlot, hasLocal, hasOnline, hunterName, navigate, playSlot, selectSlot, selectedSlotId, slots, uploadSlot]); },
const controller = useMenuController(actions, { onBack: () => dialog ? setDialog(null) : navigate("login") }); })),
{ id: primaryActionId, run: continueSelected, enabled: !resolvingOnline, neighbors: { up: `slot-${selectedSlotId}`, right: "upload" } },
{ id: "upload", run: () => uploadSlot(selectedSlotId), enabled: hasLocal && Boolean(accountId), neighbors: { left: primaryActionId, right: "download", up: "slot-1" } },
{ id: "download", run: () => downloadSlot(selectedSlotId), enabled: hasOnline && Boolean(accountId), neighbors: { left: "upload", right: "copy", up: "slot-2" } },
{ id: "copy", run: () => openSaveDialog("copy"), enabled: hasLocal, neighbors: { left: "download", right: "delete", up: "slot-2" } },
{ id: "delete", run: () => openSaveDialog("delete"), enabled: hasLocal, neighbors: { left: "copy", right: "back", up: "slot-3" } },
{ id: "back", run: () => navigate("login"), neighbors: { left: "delete", up: "slot-3" } },
], [accountId, continuation, copySlot, createSlot, deleteSlot, dialog, downloadSlot, hasLocal, hasOnline, hunterName, navigate, playSlot, primaryActionId, resolvingOnline, selectSlot, selectedSlotId, slots, uploadSlot]);
const controller = useMenuController(actions, { onBack: () => dialog ? resolvingOnline ? undefined : setDialog(null) : navigate("login") });
const cloudStatus = !accountId ? "Offline mode" : selected.online ? "Online version available" : "No online version"; const cloudStatus = continuation === "choose" ? "Newer online save" : !accountId ? "Offline mode" : selected.online ? "Online version available" : "No online version";
return ( return (
<DualDisplayFrame <DualDisplayFrame
top={ top={
@@ -275,10 +347,47 @@ function SaveScreen() {
/> />
))} ))}
</div> </div>
<div className="save-footer"><span>Autosave <b>OFFLINE FIRST</b></span><ControllerLegend back /></div> <div className="save-top-actions">
<FocusButton id={primaryActionId} focusedId={controller.focusedId} focus={controller.focus} className="front-primary" disabled={resolvingOnline} onClick={continueSelected}>
<span>{continuation === "create" ? "Create hunter" : resolvingOnline ? "Loading online save…" : "Continue"}</span>
<small>{continuation === "create" ? `Use slot ${selectedSlotId}` : continuation === "online" ? "Download online copy" : continuation === "choose" ? "Choose online or device copy" : `Slot ${selectedSlotId} · ${selected.local?.hunterName}`}</small>
</FocusButton>
<FocusButton id="upload" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}><strong>Upload</strong><small>Device server</small></FocusButton>
<FocusButton id="download" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasOnline || !accountId} onClick={() => downloadSlot(selectedSlotId)}><strong>Download</strong><small>Server device</small></FocusButton>
<FocusButton id="copy" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} onClick={() => openSaveDialog("copy")}><strong>Copy</strong><small>Duplicate save</small></FocusButton>
<FocusButton id="delete" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} className="danger-link" onClick={() => openSaveDialog("delete")}><strong>Delete</strong><small>Erase device copy</small></FocusButton>
<FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("login")}><strong>Back</strong><small>Login screen</small></FocusButton>
</div>
<div className="save-footer"><span>Autosave <b>OFFLINE FIRST</b></span><div className="save-top-notice" role="status" aria-live="polite">{notice || "Lower display shows selected save details."}</div><ControllerLegend back /></div>
{dialog && ( {dialog && (
<div className="front-dialog" role="dialog" aria-modal="true" aria-label={dialog === "create" ? "Name new hunter" : dialog === "copy" ? "Copy save" : "Delete save"}> <div className={`front-dialog ${dialog === "version" ? "version-dialog" : ""}`} role="dialog" aria-modal="true" aria-label={dialog === "version" ? "Choose save version" : dialog === "create" ? "Name new hunter" : dialog === "copy" ? "Copy save" : "Delete save"}>
{dialog === "create" ? ( {dialog === "version" && selected.local && selected.online ? (
<div className="version-choice-dialog">
<span>Newer online save found</span>
<h2>Which save do you want?</h2>
<p>Online choice replaces this device copy. Device choice keeps online copy unchanged.</p>
<div className="version-comparison">
<article className="is-newer">
<header><span>Online copy</span><b>NEWER</b></header>
<strong>{selected.online.hunterName}</strong>
<time>{formatSaveTimestamp(selected.online.updatedAt)}</time>
<small>{formatPlayTime(selected.online.playSeconds)} · Level {selected.online.healers[selected.online.activeClassId].level}</small>
</article>
<article>
<header><span>Device copy</span><b>OFFLINE</b></header>
<strong>{selected.local.hunterName}</strong>
<time>{formatSaveTimestamp(selected.local.updatedAt)}</time>
<small>{formatPlayTime(selected.local.playSeconds)} · Level {selected.local.healers[selected.local.activeClassId].level}</small>
</article>
</div>
<div className="dialog-actions version-actions">
<FocusButton id="version-online" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" disabled={resolvingOnline} onClick={() => { void continueWithOnline(); }}><span>{resolvingOnline ? "Loading…" : "Continue online copy"}</span><small>{formatSaveTimestamp(selected.online.updatedAt)}</small></FocusButton>
<FocusButton id="version-local" focusedId={controller.focusedId} focus={controller.focus} disabled={resolvingOnline} onClick={() => { setDialog(null); playSlot(selectedSlotId); }}><span>Continue device copy</span><small>{formatSaveTimestamp(selected.local.updatedAt)}</small></FocusButton>
<FocusButton id="cancel-version" focusedId={controller.focusedId} focus={controller.focus} disabled={resolvingOnline} onClick={() => setDialog(null)}>Cancel</FocusButton>
</div>
{versionError && <div className="version-choice-error" role="alert">{versionError}</div>}
</div>
) : dialog === "create" ? (
<form onSubmit={(event) => { event.preventDefault(); finishCreation(); }}> <form onSubmit={(event) => { event.preventDefault(); finishCreation(); }}>
<span>New offline save</span><h2>Name your hunter</h2><p>This name identifies the character in local and online save lists.</p> <span>New offline save</span><h2>Name your hunter</h2><p>This name identifies the character in local and online save lists.</p>
<label htmlFor="new-hunter-name">Hunter name</label> <label htmlFor="new-hunter-name">Hunter name</label>
@@ -323,31 +432,37 @@ function SaveScreen() {
</FrontSurface> </FrontSurface>
} }
bottom={ bottom={
<FrontSurface className="save-context" bottom ariaLabel="Selected save management"> <FrontSurface className="save-context" bottom ariaLabel="Selected save information">
<header className="context-header"><span>Slot {selectedSlotId}</span><b>{cloudStatus}</b></header> <header className="context-header"><span>Slot {selectedSlotId}</span><b>{cloudStatus}</b></header>
<div className="selected-save-summary"> <div className="selected-save-summary">
{selected.local ? ( {selected.local ?? selected.online ? (
<><div className="summary-avatar">{selected.local.hunterName[0]}</div><span><small>Local record</small><h2>{selected.local.hunterName}</h2><p>{selected.local.location} · {formatPlayTime(selected.local.playSeconds)}</p><time>{formatSaveTimestamp(selected.local.updatedAt)}</time></span></> <><div className="summary-avatar">{(selected.local ?? selected.online)!.hunterName[0]}</div><span><small>{selected.local ? "Device save" : "Online copy only"}</small><h2>{(selected.local ?? selected.online)!.hunterName}</h2><p>{(selected.local ?? selected.online)!.location}</p><time>{formatSaveTimestamp((selected.local ?? selected.online)!.updatedAt)}</time></span></>
) : ( ) : (
<><div className="summary-avatar is-empty"></div><span><small>Local record</small><h2>Empty slot</h2><p>Create a hunter or download an online version.</p></span></> <><div className="summary-avatar is-empty"></div><span><small>Local record</small><h2>Empty slot</h2><p>Create a hunter or download an online version.</p></span></>
)} )}
</div> </div>
{selected.online && <div className="online-record"><span><b>ONLINE</b>{selected.online.hunterName}</span><time>{formatSaveTimestamp(selected.online.updatedAt)}</time></div>} {(selected.local ?? selected.online) && (() => {
<div className="save-actions"> const save = (selected.local ?? selected.online)!;
<FocusButton id={hasLocal ? "play" : "create"} focusedId={controller.focusedId} focus={controller.focus} className="front-primary" onClick={() => hasLocal ? playSlot(selectedSlotId) : openCreation()}> const healer = HEALER_CLASSES[save.activeClassId];
{hasLocal ? "Continue offline save" : "Create new hunter"}<small>A</small> return (
</FocusButton> <>
<div className="sync-actions"> <div className="save-dossier-stats">
<FocusButton id="upload" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}> Sync offline to server</FocusButton> <span><small>Active healer</small><strong>Lv {save.healers[save.activeClassId].level}</strong><em>{healer.name}</em></span>
<FocusButton id="download" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasOnline || !accountId} onClick={() => downloadSlot(selectedSlotId)}> Overwrite with online</FocusButton> <span><small>Play time</small><strong>{formatPlayTime(save.playSeconds)}</strong><em>Local activity</em></span>
</div> <span><small>Boss kills</small><strong>{save.stats.totalBossKills}</strong><em>{save.stats.flawlessClears} flawless</em></span>
<div className="record-actions"> </div>
<FocusButton id="copy" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} onClick={() => openSaveDialog("copy")}>Copy save</FocusButton> <div className="save-dossier-records">
<FocusButton id="delete" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} className="danger-link" onClick={() => openSaveDialog("delete")}>Delete save</FocusButton> <span><small>Roguelike best</small><b>Round {save.stats.highestRoguelikeRound}</b></span>
<FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("login")}>Back</FocusButton> <span><small>Endless best</small><b>{save.stats.highestRogueTrialsEndlessKills} kills</b></span>
</div> </div>
</>
);
})()}
<div className="save-copy-state">
<span><i className={selected.local ? "is-present" : ""} />Device copy<b>{selected.local ? formatSaveTimestamp(selected.local.updatedAt) : "Not present"}</b></span>
<span><i className={selected.online ? "is-present" : ""} />Online copy<b>{selected.online ? formatSaveTimestamp(selected.online.updatedAt) : accountId ? "Not uploaded" : "Sign-in required"}</b></span>
</div> </div>
<div className="front-notice is-lower">{notice || "All gameplay changes save to local storage automatically."}</div> <div className="front-notice is-lower">{notice || "Use upper display for every save action. Details here follow selected slot."}</div>
</FrontSurface> </FrontSurface>
} }
/> />
@@ -399,7 +514,6 @@ function HomeScreen() {
top={ top={
<FrontSurface className="home-surface" ariaLabel="Main menu"> <FrontSurface className="home-surface" ariaLabel="Main menu">
<header className="home-header"><BrandMark compact /><span>Welcome back, <b>{hunter.hunterName}</b></span><i>{accountId ? "● SYNC READY" : "○ OFFLINE"}</i></header> <header className="home-header"><BrandMark compact /><span>Welcome back, <b>{hunter.hunterName}</b></span><i>{accountId ? "● SYNC READY" : "○ OFFLINE"}</i></header>
<div className="home-title"><span>Choose your hunt</span><h1>Where are you needed?</h1></div>
<div className="mode-grid"> <div className="mode-grid">
{HOME_MODES.map((mode) => ( {HOME_MODES.map((mode) => (
<FocusButton key={mode.id} id={mode.id} focusedId={controller.focusedId} focus={controller.focus} className="mode-card" onClick={() => selectMode(mode.id)}> <FocusButton key={mode.id} id={mode.id} focusedId={controller.focusedId} focus={controller.focus} className="mode-card" onClick={() => selectMode(mode.id)}>
@@ -441,6 +555,8 @@ function HomeScreen() {
); );
} }
type ProfileStatId = BossId | "roguelike" | "rogue-trials-endless";
function ProfileScreen() { function ProfileScreen() {
const hunter = useActiveHunter(); const hunter = useActiveHunter();
const accountId = useFrontendStore((state) => state.accountId); const accountId = useFrontendStore((state) => state.accountId);
@@ -449,11 +565,11 @@ function ProfileScreen() {
const [groupId, setGroupId] = useState(collections[0]?.groupId ?? ""); const [groupId, setGroupId] = useState(collections[0]?.groupId ?? "");
const [collectionView, setCollectionView] = useState<"loot" | "trophies" | "stats">("trophies"); const [collectionView, setCollectionView] = useState<"loot" | "trophies" | "stats">("trophies");
const collection = collections.find((group) => group.groupId === groupId) ?? collections[0]; const collection = collections.find((group) => group.groupId === groupId) ?? collections[0];
const [selectedStat, setSelectedStat] = useState<BossId | "roguelike">("roguelike"); const [selectedStat, setSelectedStat] = useState<ProfileStatId>("roguelike");
const [leaderboard, setLeaderboard] = useState<LeaderboardResult | null>(null); const [leaderboard, setLeaderboard] = useState<LeaderboardResult | null>(null);
const [leaderboardStatus, setLeaderboardStatus] = useState(""); const [leaderboardStatus, setLeaderboardStatus] = useState("");
useEffect(() => { useEffect(() => {
if (selectedStat === "roguelike" || collection?.bosses.some((boss) => boss.bossId === selectedStat)) return; if (selectedStat === "roguelike" || selectedStat === "rogue-trials-endless" || collection?.bosses.some((boss) => boss.bossId === selectedStat)) return;
setSelectedStat(collection?.bosses[0]?.bossId ?? "roguelike"); setSelectedStat(collection?.bosses[0]?.bossId ?? "roguelike");
}, [collection, selectedStat]); }, [collection, selectedStat]);
useEffect(() => { useEffect(() => {
@@ -467,7 +583,9 @@ function ProfileScreen() {
setLeaderboardStatus("Loading overall rankings…"); setLeaderboardStatus("Loading overall rankings…");
const request = selectedStat === "roguelike" const request = selectedStat === "roguelike"
? onlineRepository.roguelikeLeaderboard(hunter.slotId) ? onlineRepository.roguelikeLeaderboard(hunter.slotId)
: onlineRepository.bossLeaderboard(selectedStat, hunter.slotId); : selectedStat === "rogue-trials-endless"
? onlineRepository.rogueTrialsEndlessLeaderboard(hunter.slotId)
: onlineRepository.bossLeaderboard(selectedStat, hunter.slotId);
void request.then((result) => { void request.then((result) => {
if (cancelled) return; if (cancelled) return;
setLeaderboard(result); setLeaderboard(result);
@@ -484,12 +602,13 @@ function ProfileScreen() {
{ id: "view-stats", run: () => setCollectionView("stats"), neighbors: { left: "view-trophies", right: "view-loot", down: collectionView === "stats" ? "stat-roguelike" : undefined } }, { id: "view-stats", run: () => setCollectionView("stats"), neighbors: { left: "view-trophies", right: "view-loot", down: collectionView === "stats" ? "stat-roguelike" : undefined } },
{ id: "view-loot", run: () => setCollectionView("loot"), neighbors: { left: "view-stats" } }, { id: "view-loot", run: () => setCollectionView("loot"), neighbors: { left: "view-stats" } },
...(collectionView === "stats" ? [ ...(collectionView === "stats" ? [
{ id: "stat-roguelike", run: () => setSelectedStat("roguelike"), neighbors: { up: "view-stats", down: `stat-${collection.bosses[0].bossId}` } }, { id: "stat-roguelike", run: () => setSelectedStat("roguelike"), neighbors: { up: "view-stats", down: "stat-rogue-trials-endless" } },
{ id: "stat-rogue-trials-endless", run: () => setSelectedStat("rogue-trials-endless"), neighbors: { up: "stat-roguelike", down: `stat-${collection.bosses[0].bossId}` } },
...collection.bosses.map((boss, index) => ({ ...collection.bosses.map((boss, index) => ({
id: `stat-${boss.bossId}`, id: `stat-${boss.bossId}`,
run: () => setSelectedStat(boss.bossId), run: () => setSelectedStat(boss.bossId),
neighbors: { neighbors: {
up: index === 0 ? "stat-roguelike" : `stat-${collection.bosses[index - 1].bossId}`, up: index === 0 ? "stat-rogue-trials-endless" : `stat-${collection.bosses[index - 1].bossId}`,
down: index === collection.bosses.length - 1 ? `group-${collection.groupId}` : `stat-${collection.bosses[index + 1].bossId}`, down: index === collection.bosses.length - 1 ? `group-${collection.groupId}` : `stat-${collection.bosses[index + 1].bossId}`,
}, },
})), })),
@@ -503,6 +622,16 @@ function ProfileScreen() {
const activeProgress = hunter.healers[hunter.activeClassId]; const activeProgress = hunter.healers[hunter.activeClassId];
const earned = collection.drops.filter((drop) => drop.count > 0).length; const earned = collection.drops.filter((drop) => drop.count > 0).length;
const trophiesEarned = collection.bosses.filter((boss) => boss.pet.count > 0).length; const trophiesEarned = collection.bosses.filter((boss) => boss.pet.count > 0).length;
const selectedStatValue = selectedStat === "roguelike"
? hunter.stats.highestRoguelikeRound
: selectedStat === "rogue-trials-endless"
? hunter.stats.highestRogueTrialsEndlessKills
: hunter.stats.bossKills[selectedStat] ?? 0;
const selectedStatLabel = selectedStat === "roguelike"
? "Roguelike rounds"
: selectedStat === "rogue-trials-endless"
? "Rogue Trials endless kills"
: BOSS_DEFINITIONS[selectedStat].name;
return ( return (
<DualDisplayFrame <DualDisplayFrame
@@ -512,7 +641,7 @@ function ProfileScreen() {
<FocusButton id="view-trophies" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "trophies" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "trophies"} onClick={() => setCollectionView("trophies")}>Trophy Case</FocusButton> <FocusButton id="view-trophies" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "trophies" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "trophies"} onClick={() => setCollectionView("trophies")}>Trophy Case</FocusButton>
<FocusButton id="view-stats" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "stats" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "stats"} onClick={() => setCollectionView("stats")}>Boss Stats</FocusButton> <FocusButton id="view-stats" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "stats" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "stats"} onClick={() => setCollectionView("stats")}>Boss Stats</FocusButton>
<FocusButton id="view-loot" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "loot" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "loot"} onClick={() => setCollectionView("loot")}>Group Loot</FocusButton> <FocusButton id="view-loot" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "loot" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "loot"} onClick={() => setCollectionView("loot")}>Group Loot</FocusButton>
</div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header> </div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
{collectionView === "loot" ? <> {collectionView === "loot" ? <>
<div className="collection-heading"><span><small>Shared group drops · Core: {collection.coreMechanic}</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div> <div className="collection-heading"><span><small>Shared group drops · Core: {collection.coreMechanic}</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div>
<div className="collection-grid"> <div className="collection-grid">
@@ -540,18 +669,19 @@ function ProfileScreen() {
</div> </div>
<div className="collection-note trophy-note"><i></i><span><strong>Each guardian keeps its own trophy.</strong><small>Defeat that boss for a 1 in 500 pet roll.</small></span></div> <div className="collection-note trophy-note"><i></i><span><strong>Each guardian keeps its own trophy.</strong><small>Defeat that boss for a 1 in 500 pet roll.</small></span></div>
</> : <> </> : <>
<div className="collection-heading boss-stats-heading"><span><small>Lifetime records · Overall leaderboards</small><h2>Boss Stats</h2></span><b>Highest roguelike round {hunter.stats.highestRoguelikeRound}</b></div> <div className="collection-heading boss-stats-heading"><span><small>Lifetime records · Overall leaderboards</small><h2>Boss Stats</h2></span><b>Endless best {hunter.stats.highestRogueTrialsEndlessKills} kills</b></div>
<div className="boss-stats-layout"> <div className="boss-stats-layout">
<section className="boss-stat-selector" aria-label="Boss statistic selection"> <section className="boss-stat-selector" aria-label="Boss statistic selection">
<FocusButton id="stat-roguelike" focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === "roguelike" ? "is-selected" : ""} onClick={() => setSelectedStat("roguelike")}><i></i><span><strong>Roguelike</strong><small>Highest round before defeat</small></span><b>{hunter.stats.highestRoguelikeRound}</b></FocusButton> <FocusButton id="stat-roguelike" focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === "roguelike" ? "is-selected" : ""} onClick={() => setSelectedStat("roguelike")}><i></i><span><strong>Roguelike</strong><small>Highest round before defeat</small></span><b>{hunter.stats.highestRoguelikeRound}</b></FocusButton>
<FocusButton id="stat-rogue-trials-endless" focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === "rogue-trials-endless" ? "is-selected" : ""} onClick={() => setSelectedStat("rogue-trials-endless")}><i></i><span><strong>Trials Endless</strong><small>Most bosses in one run</small></span><b>{hunter.stats.highestRogueTrialsEndlessKills}</b></FocusButton>
{collection.bosses.map((boss) => <FocusButton key={boss.bossId} id={`stat-${boss.bossId}`} focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === boss.bossId ? "is-selected" : ""} onClick={() => setSelectedStat(boss.bossId)}><i>{BOSS_DEFINITIONS[boss.bossId].icon}</i><span><strong>{boss.bossName}</strong><small>Lifetime boss kills</small></span><b>{boss.kills}</b></FocusButton>)} {collection.bosses.map((boss) => <FocusButton key={boss.bossId} id={`stat-${boss.bossId}`} focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === boss.bossId ? "is-selected" : ""} onClick={() => setSelectedStat(boss.bossId)}><i>{BOSS_DEFINITIONS[boss.bossId].icon}</i><span><strong>{boss.bossName}</strong><small>Lifetime boss kills</small></span><b>{boss.kills}</b></FocusButton>)}
</section> </section>
<section className="leaderboard-panel" aria-label="Overall leaderboard"> <section className="leaderboard-panel" aria-label="Overall leaderboard">
<header><span><small>Overall Top 5</small><strong>{selectedStat === "roguelike" ? "Roguelike rounds" : BOSS_DEFINITIONS[selectedStat].name}</strong></span><b>{selectedStat === "roguelike" ? `${hunter.stats.highestRoguelikeRound} best` : `${hunter.stats.bossKills[selectedStat] ?? 0} kills`}</b></header> <header><span><small>Overall Top 5</small><strong>{selectedStatLabel}</strong></span><b>{selectedStatValue} {selectedStat === "roguelike" ? "round" : "kills"}</b></header>
{leaderboardStatus ? <div className="leaderboard-status">{leaderboardStatus}</div> : <div className="leaderboard-rows"> {leaderboardStatus ? <div className="leaderboard-status">{leaderboardStatus}</div> : <div className="leaderboard-rows">
{leaderboard?.top.length ? leaderboard.top.map((entry) => <div key={`${entry.username}-${entry.slotId}`} className={entry.username === accountId && entry.slotId === hunter.slotId ? "is-you" : ""}><b>#{entry.rank}</b><span><strong>{entry.hunterName}</strong><small>{entry.username}</small></span><em>{entry.value}</em></div>) : <div className="leaderboard-empty">No ranked hunters yet.</div>} {leaderboard?.top.length ? leaderboard.top.map((entry) => <div key={`${entry.username}-${entry.slotId}`} className={entry.username === accountId && entry.slotId === hunter.slotId ? "is-you" : ""}><b>#{entry.rank}</b><span><strong>{entry.hunterName}</strong><small>{entry.username}</small></span><em>{entry.value}</em></div>) : <div className="leaderboard-empty">No ranked hunters yet.</div>}
</div>} </div>}
<div className="leaderboard-self"><b>{leaderboard?.current ? `#${leaderboard.current.rank}` : "—"}</b><span><strong>Your rank · {hunter.hunterName}</strong><small>{accountId ?? "Offline hunter"}</small></span><em>{selectedStat === "roguelike" ? hunter.stats.highestRoguelikeRound : hunter.stats.bossKills[selectedStat] ?? 0}</em></div> <div className="leaderboard-self"><b>{leaderboard?.current ? `#${leaderboard.current.rank}` : "—"}</b><span><strong>Your rank · {hunter.hunterName}</strong><small>{accountId ?? "Offline hunter"}</small></span><em>{selectedStatValue}</em></div>
</section> </section>
</div> </div>
<div className="collection-note trophy-note"><i></i><span><strong>Rankings update with server saves.</strong><small>Top five always shown; your row stays visible at any rank.</small></span></div> <div className="collection-note trophy-note"><i></i><span><strong>Rankings update with server saves.</strong><small>Top five always shown; your row stays visible at any rank.</small></span></div>
@@ -567,6 +697,7 @@ function ProfileScreen() {
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span> <span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
<span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span> <span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span>
<span><small>Highest roguelike round</small><strong>{hunter.stats.highestRoguelikeRound}</strong></span> <span><small>Highest roguelike round</small><strong>{hunter.stats.highestRoguelikeRound}</strong></span>
<span><small>Endless best</small><strong>{hunter.stats.highestRogueTrialsEndlessKills}</strong></span>
</div> </div>
<div className="boss-log"><span>Mechanic groups</span>{collections.map((group) => ( <div className="boss-log"><span>Mechanic groups</span>{collections.map((group) => (
<FocusButton key={group.groupId} id={`group-${group.groupId}`} focusedId={controller.focusedId} focus={controller.focus} className={group.groupId === collection.groupId ? "is-selected" : ""} onClick={() => setGroupId(group.groupId)}> <FocusButton key={group.groupId} id={`group-${group.groupId}`} focusedId={controller.focusedId} focus={controller.focus} className={group.groupId === collection.groupId ? "is-selected" : ""} onClick={() => setGroupId(group.groupId)}>
@@ -701,7 +832,7 @@ function GearScreen() {
<DualDisplayFrame <DualDisplayFrame
top={ top={
<FrontSurface className="gear-surface" ariaLabel="Gear upgrade workshop"> <FrontSurface className="gear-surface" ariaLabel="Gear upgrade workshop">
<header className="front-screen-header"><BrandMark compact /><div><span>Group drop workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header> <header className="front-screen-header"><BrandMark compact /><div><span>Group drop workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
<div className="gear-workshop-layout"> <div className="gear-workshop-layout">
<section className="gear-owner-list" aria-label="Party gear owners"> <section className="gear-owner-list" aria-label="Party gear owners">
{GEAR_OWNER_ORDER.map((ownerId) => { {GEAR_OWNER_ORDER.map((ownerId) => {
@@ -764,7 +895,7 @@ function GearScreen() {
return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>; return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>;
}) : <article className="is-met"><i></i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>} }) : <article className="is-met"><i></i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>}
</div> </div>
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : passiveContext ? <div className="gear-passive-context-action"><span>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : "A · Equip selected passive"}</span><small>Applies at rank 1 next encounter.</small></div> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>} {workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : passiveContext ? <div className="gear-passive-context-action"><span>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : `${DEFAULT_CONTROLLER_GLYPHS.confirm} · Equip selected passive`}</span><small>Applies at rank 1 next encounter.</small></div> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div> <div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
</FrontSurface> </FrontSurface>
} }
@@ -795,7 +926,7 @@ function SettingsScreen() {
<DualDisplayFrame <DualDisplayFrame
top={ top={
<FrontSurface className="settings-surface" ariaLabel="Settings"> <FrontSurface className="settings-surface" ariaLabel="Settings">
<header className="front-screen-header"><BrandMark compact /><div><span>Field configuration</span><h1>Settings</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header> <header className="front-screen-header"><BrandMark compact /><div><span>Field configuration</span><h1>Settings</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
<div className="settings-layout"> <div className="settings-layout">
<section><span className="settings-section-title">Audio</span><div className="volume-setting"><span><strong>Master volume</strong><small>All music, effects, and voice</small></span><div><FocusButton id="volume-down" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}></FocusButton><b>{settings.masterVolume}%</b><FocusButton id="volume-up" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}></FocusButton></div><i><em style={{ width: `${settings.masterVolume}%` }} /></i></div></section> <section><span className="settings-section-title">Audio</span><div className="volume-setting"><span><strong>Master volume</strong><small>All music, effects, and voice</small></span><div><FocusButton id="volume-down" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}></FocusButton><b>{settings.masterVolume}%</b><FocusButton id="volume-up" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}></FocusButton></div><i><em style={{ width: `${settings.masterVolume}%` }} /></i></div></section>
<section><span className="settings-section-title">Display & accessibility</span><SettingToggle id="motion" label="Reduced motion" copy="Limit non-essential UI movement" value={settings.reducedMotion} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("reducedMotion", !settings.reducedMotion)} /><SettingToggle id="numbers" label="Damage numbers" copy="Show combat values over units" value={settings.damageNumbers} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("damageNumbers", !settings.damageNumbers)} /><SettingToggle id="text" label="Large interface text" copy="Increase menu and tactical labels" value={settings.largeText} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("largeText", !settings.largeText)} /></section> <section><span className="settings-section-title">Display & accessibility</span><SettingToggle id="motion" label="Reduced motion" copy="Limit non-essential UI movement" value={settings.reducedMotion} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("reducedMotion", !settings.reducedMotion)} /><SettingToggle id="numbers" label="Damage numbers" copy="Show combat values over units" value={settings.damageNumbers} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("damageNumbers", !settings.damageNumbers)} /><SettingToggle id="text" label="Large interface text" copy="Increase menu and tactical labels" value={settings.largeText} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("largeText", !settings.largeText)} /></section>
@@ -808,9 +939,9 @@ function SettingsScreen() {
<header className="context-header"><span>Controller</span><b>BUILT-IN THOR PAD</b></header> <header className="context-header"><span>Controller</span><b>BUILT-IN THOR PAD</b></header>
<div className="controller-map"> <div className="controller-map">
<div className="pad-diagram"><i></i><span><b></b></span><i></i></div> <div className="pad-diagram"><i></i><span><b></b></span><i></i></div>
<div className="face-diagram"><i className="y">Y</i><span><i className="x">X</i><b></b><i className="b">B</i></span><i className="a">A</i></div> <div className="face-diagram"><i className="triangle">{DEFAULT_CONTROLLER_GLYPHS.faceTop}</i><span><i className="square">{DEFAULT_CONTROLLER_GLYPHS.faceLeft}</i><b></b><i className="circle">{DEFAULT_CONTROLLER_GLYPHS.faceRight}</i></span><i className="cross">{DEFAULT_CONTROLLER_GLYPHS.faceBottom}</i></div>
</div> </div>
<div className="mapping-list"><span><b>A</b> Confirm / cast Purify</span><span><b>B</b> Back / cast Shield</span><span><b>D-Pad</b> Navigate / target party</span><span><b>Right stick</b> Rotate camera</span><span><b>Start</b> Pause / menu</span></div> <div className="mapping-list"><span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Confirm / cast Purify</span><span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back / cast Shield</span><span><b>D-Pad</b> Navigate / target party</span><span><b>Right stick</b> Rotate camera</span><span><b>{DEFAULT_CONTROLLER_GLYPHS.start}</b> Pause / menu</span></div>
<div className="control-assurance"><i></i><span><strong>No click-to-focus required</strong><small>Controller input routes through app-level actions.</small></span></div> <div className="control-assurance"><i></i><span><strong>No click-to-focus required</strong><small>Controller input routes through app-level actions.</small></span></div>
</FrontSurface> </FrontSurface>
} }
@@ -908,7 +1039,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
? [ ? [
["Four dual rounds", "Clear four randomized pairs while drafting one stacking buff after each win."], ["Four dual rounds", "Clear four randomized pairs while drafting one stacking buff after each win."],
["Unseen trio finale", "Round 5 selects three bosses that have not appeared earlier in that run."], ["Unseen trio finale", "Round 5 selects three bosses that have not appeared earlier in that run."],
["Trial victory", "Defeat all three final bosses together to complete Rogue Trials."], ["Endless choice", "After the trio falls, quit with the clear or continue while every dead boss is replaced."],
] ]
: isPve : isPve
? [ ? [
@@ -925,7 +1056,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
<DualDisplayFrame <DualDisplayFrame
top={ top={
<FrontSurface className={`mode-surface mode-${modeId}`} ariaLabel={`${mode.title} details`}> <FrontSurface className={`mode-surface mode-${modeId}`} ariaLabel={`${mode.title} details`}>
<header className="front-screen-header"><BrandMark compact /><div><span>Game mode</span><h1>{mode.title}</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header> <header className="front-screen-header"><BrandMark compact /><div><span>Game mode</span><h1>{mode.title}</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
{!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>} {!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
{isDungeon && ( {isDungeon && (
<div className="boss-picker" aria-label="Choose boss encounter"> <div className="boss-picker" aria-label="Choose boss encounter">
@@ -973,7 +1104,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
{DIFFICULTIES.map((difficulty) => <FocusButton key={difficulty.slug} id={`difficulty-${difficulty.slug}`} focusedId={controller.focusedId} focus={controller.focus} className={difficulty.slug === selectedDifficultySlug ? "is-selected" : ""} onClick={() => selectDifficulty(difficulty.slug)}><strong>{difficulty.name}</strong><small>iLvl {difficulty.itemLevel}</small></FocusButton>)} {DIFFICULTIES.map((difficulty) => <FocusButton key={difficulty.slug} id={`difficulty-${difficulty.slug}`} focusedId={controller.focusedId} focus={controller.focus} className={difficulty.slug === selectedDifficultySlug ? "is-selected" : ""} onClick={() => selectDifficulty(difficulty.slug)}><strong>{difficulty.name}</strong><small>iLvl {difficulty.itemLevel}</small></FocusButton>)}
</div> </div>
)} )}
<FocusButton id="launch" focusedId={controller.focusedId} focus={controller.focus} className="mode-launch" onClick={launch}><span>{launchLabel}</span><small>{mode.status} · A</small></FocusButton> <FocusButton id="launch" focusedId={controller.focusedId} focus={controller.focus} className="mode-launch" onClick={launch}><span>{launchLabel}</span><small>{mode.status} · {DEFAULT_CONTROLLER_GLYPHS.confirm}</small></FocusButton>
{message && <div className="front-notice">{message}</div>} {message && <div className="front-notice">{message}</div>}
</FrontSurface> </FrontSurface>
} }
+43
View File
@@ -0,0 +1,43 @@
import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { KTX2Loader } from "three-stdlib";
const BASIS_TRANSCODER_PATH = `${import.meta.env.BASE_URL}basis/`;
const query = typeof window === "undefined" ? null : new URLSearchParams(window.location.search);
export const LEGACY_GAME_ASSETS_FORCED = import.meta.env.VITE_LEGACY_GAME_ASSETS === "1"
|| import.meta.env.VITE_LEGACY_DUNGEON_ASSETS === "1"
|| query?.has("legacyGameAssets") === true
|| query?.has("legacyDungeonAssets") === true;
const Ktx2LoaderContext = createContext<KTX2Loader | null>(null);
export function selectedGameAssetUrl(legacyUrl: string, optimizedUrl: string) {
return LEGACY_GAME_ASSETS_FORCED ? legacyUrl : optimizedUrl;
}
export function GameAssetProvider({ children }: { children: ReactNode }) {
const gl = useThree((state) => state.gl);
const [ktx2Loader] = useState(() => LEGACY_GAME_ASSETS_FORCED
? null
: new KTX2Loader().setTranscoderPath(BASIS_TRANSCODER_PATH).detectSupport(gl));
useEffect(() => () => {
ktx2Loader?.dispose();
}, [ktx2Loader]);
return <Ktx2LoaderContext.Provider value={ktx2Loader}>{children}</Ktx2LoaderContext.Provider>;
}
export function useGameGLTF(url: string) {
const ktx2Loader = useContext(Ktx2LoaderContext);
return useGLTF(
url,
false,
true,
(loader) => {
if (ktx2Loader) loader.setKTX2Loader(ktx2Loader);
},
);
}
+478 -93
View File
@@ -1,6 +1,6 @@
import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber"; import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
import { useAnimations, useGLTF } from "@react-three/drei"; import { useAnimations, useGLTF } from "@react-three/drei";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type RefObject } from "react";
import * as THREE from "three"; import * as THREE from "three";
import { getControllerMovement } from "../input/controller"; import { getControllerMovement } from "../input/controller";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
@@ -25,20 +25,38 @@ import {
type ActorAnimationState, type ActorAnimationState,
} from "../game/actorAnimation"; } from "../game/actorAnimation";
import { PERFORMANCE_PROBE_ENABLED, recordSimulationTick, simulationTickSnapshot } from "../game/performance"; import { PERFORMANCE_PROBE_ENABLED, recordSimulationTick, simulationTickSnapshot } from "../game/performance";
import type { PartyAbilityId } from "../game/partyCombat";
import { partyAttackVfxProfile } from "../game/partyAttackVisuals";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import type { BossId, MemberId, PulseKind } from "../game/types"; import type { BossId, MemberId, PulseKind } from "../game/types";
import { BossRoom } from "./BossRoom"; import { BossRoom } from "./BossRoom";
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators"; import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
import { bossCanTrackTarget } from "./boss/bossDeathVisuals"; import { bossBurrowPositionY, bossIsBurrowing } from "./boss/bossBurrowVisuals";
import { bossCanTrackTarget, bossDeathOpacity } from "./boss/bossDeathVisuals";
import { GameAssetProvider, LEGACY_GAME_ASSETS_FORCED, selectedGameAssetUrl, useGameGLTF } from "./GameAssetProvider";
const PARTY_MODEL_URLS: Record<MemberId, string> = { const PARTY_MODEL_LEGACY_URLS: Record<MemberId, string> = {
aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href, aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
brann: new URL("../assets/game/models/claudecraft/chars/players/knight.glb", import.meta.url).href, brann: new URL("../assets/game/models/claudecraft/chars/players/knight.glb", import.meta.url).href,
nia: new URL("../assets/game/models/claudecraft/chars/players/ranger.glb", import.meta.url).href, nia: new URL("../assets/game/models/claudecraft/chars/players/ranger.glb", import.meta.url).href,
orin: new URL("../assets/game/models/claudecraft/chars/players/mage.glb", import.meta.url).href, orin: new URL("../assets/game/models/claudecraft/chars/players/mage.glb", import.meta.url).href,
vale: new URL("../assets/game/models/claudecraft/chars/players/rogue.glb", import.meta.url).href, vale: new URL("../assets/game/models/claudecraft/chars/players/rogue.glb", import.meta.url).href,
}; };
const PARTY_WEAPON_URLS: Record<MemberId, { right: string; left?: string }> = { const PARTY_MODEL_OPTIMIZED_URLS: Record<MemberId, string> = {
aelia: new URL("../assets/game/models/claudecraft/chars/players/druid-uastc.glb", import.meta.url).href,
brann: new URL("../assets/game/models/claudecraft/chars/players/knight-uastc.glb", import.meta.url).href,
nia: new URL("../assets/game/models/claudecraft/chars/players/ranger-uastc.glb", import.meta.url).href,
orin: new URL("../assets/game/models/claudecraft/chars/players/mage-uastc.glb", import.meta.url).href,
vale: new URL("../assets/game/models/claudecraft/chars/players/rogue-uastc.glb", import.meta.url).href,
};
const PARTY_MODEL_URLS = Object.fromEntries(Object.keys(PARTY_MODEL_LEGACY_URLS).map((memberId) => [
memberId,
selectedGameAssetUrl(
PARTY_MODEL_LEGACY_URLS[memberId as MemberId],
PARTY_MODEL_OPTIMIZED_URLS[memberId as MemberId],
),
])) as Record<MemberId, string>;
const PARTY_WEAPON_LEGACY_URLS: Record<MemberId, { right: string; left?: string }> = {
aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff.glb", import.meta.url).href }, aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff.glb", import.meta.url).href },
brann: { brann: {
right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed.glb", import.meta.url).href, right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed.glb", import.meta.url).href,
@@ -54,6 +72,30 @@ const PARTY_WEAPON_URLS: Record<MemberId, { right: string; left?: string }> = {
left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href, left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
}, },
}; };
const PARTY_WEAPON_OPTIMIZED_URLS: Record<MemberId, { right: string; left?: string }> = {
aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff-uastc.glb", import.meta.url).href },
brann: {
right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed-uastc.glb", import.meta.url).href,
left: new URL("../assets/game/models/claudecraft/weapons/shield_badge-uastc.glb", import.meta.url).href,
},
nia: { right: new URL("../assets/game/models/claudecraft/weapons/crossbow_2handed-uastc.glb", import.meta.url).href },
orin: {
right: new URL("../assets/game/models/claudecraft/weapons/adv_wand-uastc.glb", import.meta.url).href,
left: new URL("../assets/game/models/claudecraft/weapons/spellbook_open-uastc.glb", import.meta.url).href,
},
vale: {
right: new URL("../assets/game/models/claudecraft/weapons/adv_dagger-uastc.glb", import.meta.url).href,
left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger-uastc.glb", import.meta.url).href,
},
};
const PARTY_WEAPON_URLS = Object.fromEntries(Object.keys(PARTY_WEAPON_LEGACY_URLS).map((memberId) => {
const legacy = PARTY_WEAPON_LEGACY_URLS[memberId as MemberId];
const optimized = PARTY_WEAPON_OPTIMIZED_URLS[memberId as MemberId];
return [memberId, {
right: selectedGameAssetUrl(legacy.right, optimized.right),
left: legacy.left && optimized.left ? selectedGameAssetUrl(legacy.left, optimized.left) : undefined,
}];
})) as Record<MemberId, { right: string; left?: string }>;
const PARTY_MODEL_SCALES: Record<MemberId, number> = { aelia: 0.62, brann: 0.68, nia: 0.7, orin: 0.64, vale: 0.72 }; const PARTY_MODEL_SCALES: Record<MemberId, number> = { aelia: 0.62, brann: 0.68, nia: 0.7, orin: 0.64, vale: 0.72 };
const PARTY_ATTACK_CLIPS: Record<MemberId, string> = { const PARTY_ATTACK_CLIPS: Record<MemberId, string> = {
aelia: "2H_Melee_Attack_Chop", aelia: "2H_Melee_Attack_Chop",
@@ -66,6 +108,88 @@ const CRITICAL_PARTY_MEMBER_IDS: readonly MemberId[] = ["aelia", "brann"];
const SUPPORT_PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia" | "brann">[] = ["nia", "orin", "vale"]; const SUPPORT_PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia" | "brann">[] = ["nia", "orin", "vale"];
type GameStoreState = ReturnType<typeof useGameStore.getState>; type GameStoreState = ReturnType<typeof useGameStore.getState>;
interface BossFadeMaterial {
material: THREE.Material;
baseOpacity: number;
baseTransparent: boolean;
baseDepthWrite: boolean;
}
function createBossRenderModel(source: THREE.Object3D) {
const model = cloneSkeleton(source);
const materialClones = new Map<THREE.Material, THREE.Material>();
model.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
object.castShadow = true;
object.receiveShadow = true;
const cloneMaterial = (material: THREE.Material) => {
const existing = materialClones.get(material);
if (existing) return existing;
const clone = material.clone();
materialClones.set(material, clone);
return clone;
};
object.material = Array.isArray(object.material)
? object.material.map(cloneMaterial)
: cloneMaterial(object.material);
});
model.updateMatrixWorld(true);
const modelTopY = new THREE.Box3().setFromObject(model).max.y;
return {
model,
modelTopY,
fadeMaterials: [...materialClones.values()].map((material): BossFadeMaterial => ({
material,
baseOpacity: material.opacity,
baseTransparent: material.transparent,
baseDepthWrite: material.depthWrite,
})),
};
}
function applyBossOpacity(materials: readonly BossFadeMaterial[], opacity: number) {
const fading = opacity < 0.999;
for (const entry of materials) {
const transparent = entry.baseTransparent || fading;
if (entry.material.transparent !== transparent) {
entry.material.transparent = transparent;
entry.material.needsUpdate = true;
}
entry.material.opacity = entry.baseOpacity * opacity;
entry.material.depthWrite = fading ? false : entry.baseDepthWrite;
}
}
function useBossDeathFade(
group: RefObject<THREE.Group | null>,
light: RefObject<THREE.PointLight | null>,
materials: readonly BossFadeMaterial[],
defeated: boolean,
baseLightIntensity: number,
) {
const elapsed = useRef(0);
const lastOpacity = useRef(1);
useFrame((_, delta) => {
if (!defeated) {
elapsed.current = 0;
if (lastOpacity.current !== 1) {
lastOpacity.current = 1;
if (group.current) group.current.visible = true;
if (light.current) light.current.intensity = baseLightIntensity;
applyBossOpacity(materials, 1);
}
return;
}
elapsed.current += delta;
const opacity = bossDeathOpacity(elapsed.current);
if (opacity === lastOpacity.current) return;
lastOpacity.current = opacity;
if (group.current) group.current.visible = opacity > 0;
if (light.current) light.current.intensity = baseLightIntensity * opacity;
applyBossOpacity(materials, opacity);
});
}
function encounterBossAt(state: GameStoreState, bossIndex: number) { function encounterBossAt(state: GameStoreState, bossIndex: number) {
return bossIndex === 0 return bossIndex === 0
? { boss: state.boss, motion: state.bossMotion } ? { boss: state.boss, motion: state.bossMotion }
@@ -148,12 +272,12 @@ function PartyCharacterModel({
animationState: MutableRefObject<ActorAnimationState>; animationState: MutableRefObject<ActorAnimationState>;
animationTrigger: MutableRefObject<number>; animationTrigger: MutableRefObject<number>;
}) { }) {
const gltf = useGLTF(PARTY_MODEL_URLS[memberId], false, true); const gltf = useGameGLTF(PARTY_MODEL_URLS[memberId]);
const actorScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]); const actorScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
const loadout = PARTY_WEAPON_URLS[memberId]; const loadout = PARTY_WEAPON_URLS[memberId];
const grips = PARTY_WEAPON_GRIPS[memberId]; const grips = PARTY_WEAPON_GRIPS[memberId];
const rightWeapon = useGLTF(loadout.right, false, true); const rightWeapon = useGameGLTF(loadout.right);
const leftWeapon = useGLTF(loadout.left ?? loadout.right, false, true); const leftWeapon = useGameGLTF(loadout.left ?? loadout.right);
const rightHandSlot = resolveRigNode(actorScene, "handslot.r"); const rightHandSlot = resolveRigNode(actorScene, "handslot.r");
const leftHandSlot = resolveRigNode(actorScene, "handslot.l"); const leftHandSlot = resolveRigNode(actorScene, "handslot.l");
const rightWeaponScene = useMemo( const rightWeaponScene = useMemo(
@@ -587,14 +711,34 @@ function PartyFallback({ memberIds }: { memberIds: readonly MemberId[] }) {
function BossFallback({ bossIndex }: { bossIndex: number }) { function BossFallback({ bossIndex }: { bossIndex: number }) {
const boss = useGameStore((state) => bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss); const boss = useGameStore((state) => bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss);
const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion); const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion);
const group = useRef<THREE.Group>(null);
const material = useRef<THREE.MeshStandardMaterial>(null);
const deathElapsed = useRef(0);
useFrame((_, delta) => {
const defeated = (boss?.hp ?? 1) <= 0;
deathElapsed.current = defeated ? deathElapsed.current + delta : 0;
const opacity = bossDeathOpacity(deathElapsed.current);
if (group.current) group.current.visible = opacity > 0;
if (material.current) {
const transparent = opacity < 0.999;
if (material.current.transparent !== transparent) {
material.current.transparent = transparent;
material.current.needsUpdate = true;
}
material.current.opacity = opacity;
material.current.depthWrite = opacity >= 0.999;
}
});
if (!boss || !motion) return null; if (!boss || !motion) return null;
const position = motion.position; const position = motion.position;
const bossId = boss.id; const bossId = boss.id;
return ( return (
<mesh castShadow position={[position[0], 1.1, position[1]]}> <group ref={group} position={[position[0], 1.1, position[1]]}>
<dodecahedronGeometry args={[1.1, 0]} /> <mesh castShadow>
<meshStandardMaterial color={BOSS_ARCHETYPE_BY_ID[bossId] === "web-caster" ? "#56306f" : BOSS_ARCHETYPE_BY_ID[bossId] === "sky-sweeper" ? "#9d4c24" : BOSS_ARCHETYPE_BY_ID[bossId] === "burrower" ? "#b78b32" : BOSS_ARCHETYPE_BY_ID[bossId] === "duelist" || BOSS_ARCHETYPE_BY_ID[bossId] === "ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} /> <dodecahedronGeometry args={[1.1, 0]} />
</mesh> <meshStandardMaterial ref={material} color={BOSS_ARCHETYPE_BY_ID[bossId] === "web-caster" ? "#56306f" : BOSS_ARCHETYPE_BY_ID[bossId] === "sky-sweeper" ? "#9d4c24" : BOSS_ARCHETYPE_BY_ID[bossId] === "burrower" ? "#b78b32" : BOSS_ARCHETYPE_BY_ID[bossId] === "duelist" || BOSS_ARCHETYPE_BY_ID[bossId] === "ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
</mesh>
</group>
); );
} }
@@ -606,19 +750,17 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0); const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
const defeated = bossHp <= 0; const defeated = bossHp <= 0;
const group = useRef<THREE.Group>(null); const group = useRef<THREE.Group>(null);
const light = useRef<THREE.PointLight>(null);
const gltf = useGLTF(BULL_URL, false, true); const gltf = useGLTF(BULL_URL, false, true);
const bullScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]); const { model: bullScene, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]);
const { actions } = useAnimations(gltf.animations, bullScene); const { actions } = useAnimations(gltf.animations, bullScene);
const targetPosition = useMemo(() => new THREE.Vector3(), []); const targetPosition = useMemo(() => new THREE.Vector3(), []);
useEffect(() => { useEffect(() => {
bullScene.traverse((object) => { return () => { for (const entry of fadeMaterials) entry.material.dispose(); };
if (object instanceof THREE.Mesh) { }, [fadeMaterials]);
object.castShadow = true;
object.receiveShadow = true; useBossDeathFade(group, light, fadeMaterials, defeated, 2.8);
}
});
}, [bullScene]);
const clipName = phase === "victory" || defeated const clipName = phase === "victory" || defeated
? "Death" ? "Death"
@@ -675,7 +817,7 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
return ( return (
<group ref={group}> <group ref={group}>
<primitive object={bullScene} scale={0.81} /> <primitive object={bullScene} scale={0.81} />
<pointLight color="#ff9b5c" intensity={2.8} distance={7} position={[0, 2.3, 0.8]} /> <pointLight ref={light} color="#ff9b5c" intensity={2.8} distance={7} position={[0, 2.3, 0.8]} />
</group> </group>
); );
} }
@@ -695,19 +837,19 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0); const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
const defeated = bossHp <= 0; const defeated = bossHp <= 0;
const group = useRef<THREE.Group>(null); const group = useRef<THREE.Group>(null);
const gltf = useGLTF(config.url, false, true); const light = useRef<THREE.PointLight>(null);
const model = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]); const assetUrl = selectedGameAssetUrl(config.url, config.optimizedUrl ?? config.url);
const gltf = useGameGLTF(assetUrl);
const { model, modelTopY, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]);
const { actions } = useAnimations(gltf.animations, model); const { actions } = useAnimations(gltf.animations, model);
const targetPosition = useMemo(() => new THREE.Vector3(), []); const targetPosition = useMemo(() => new THREE.Vector3(), []);
const burrowPositionY = bossBurrowPositionY(modelTopY, config.scale);
useEffect(() => { useEffect(() => {
model.traverse((object) => { return () => { for (const entry of fadeMaterials) entry.material.dispose(); };
if (object instanceof THREE.Mesh) { }, [fadeMaterials]);
object.castShadow = true;
object.receiveShadow = true; useBossDeathFade(group, light, fadeMaterials, defeated, 2.5);
}
});
}, [kind, model]);
const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motion ?? useGameStore.getState().bossMotion); const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motion ?? useGameStore.getState().bossMotion);
@@ -738,9 +880,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
if (!current) return; if (!current) return;
const motion = current.motion; const motion = current.motion;
const airborne = archetype === "sky-sweeper" && motion.mode === "skyfall"; const airborne = archetype === "sky-sweeper" && motion.mode === "skyfall";
const burrowed = archetype === "burrower" && motion.activeMechanicId === "burrow-rush" && motion.mode === "charging"; const burrowing = archetype === "burrower" && bossIsBurrowing(motion.activeMechanicId, motion.mode);
const floatingHeight = config.floating ? 0.2 : 0.03; const floatingHeight = config.floating ? 0.2 : 0.03;
targetPosition.set(motion.position[0], airborne ? 3.2 : burrowed ? -0.58 : floatingHeight, motion.position[1]); targetPosition.set(motion.position[0], airborne ? 3.2 : burrowing ? burrowPositionY : floatingHeight, motion.position[1]);
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta)); group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
if (!bossCanTrackTarget(current.boss.hp)) return; if (!bossCanTrackTarget(current.boss.hp)) return;
@@ -770,7 +912,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
return ( return (
<group ref={group}> <group ref={group}>
<primitive object={model} scale={config.scale} rotation={[0, config.rotationOffset, 0]} /> <primitive object={model} scale={config.scale} rotation={[0, config.rotationOffset, 0]} />
<pointLight color={config.light} intensity={2.5} distance={7} position={[0, 2.2, 0.5]} /> <pointLight ref={light} color={config.light} intensity={2.5} distance={7} position={[0, 2.2, 0.5]} />
</group> </group>
); );
} }
@@ -859,7 +1001,15 @@ function TankAuraField() {
} }
function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) { function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
const group = useRef<THREE.Group>(null); const projectile = useRef<THREE.Group>(null);
const impact = useRef<THREE.Group>(null);
const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
const accentMaterial = useRef<THREE.MeshBasicMaterial>(null);
const trailMaterial = useRef<THREE.MeshBasicMaterial>(null);
const trail = useRef<THREE.Mesh>(null);
const impactMaterial = useRef<THREE.MeshBasicMaterial>(null);
const impactCoreMaterial = useRef<THREE.MeshBasicMaterial>(null);
const lastAbilityId = useRef<PartyAbilityId | null>(null);
const start = useMemo(() => new THREE.Vector3(), []); const start = useMemo(() => new THREE.Vector3(), []);
const end = useMemo(() => new THREE.Vector3(), []); const end = useMemo(() => new THREE.Vector3(), []);
const current = useMemo(() => new THREE.Vector3(), []); const current = useMemo(() => new THREE.Vector3(), []);
@@ -867,7 +1017,7 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
const up = useMemo(() => new THREE.Vector3(0, 1, 0), []); const up = useMemo(() => new THREE.Vector3(0, 1, 0), []);
useFrame(({ clock }) => { useFrame(({ clock }) => {
if (!group.current) return; if (!projectile.current || !impact.current) return;
const state = useGameStore.getState(); const state = useGameStore.getState();
const action = state.partyCombat.combatants[memberId].visualAction; const action = state.partyCombat.combatants[memberId].visualAction;
const member = state.party.find((entry) => entry.id === memberId)!; const member = state.party.find((entry) => entry.id === memberId)!;
@@ -878,8 +1028,19 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
&& action.abilityId !== "overcharge" && action.abilityId !== "overcharge"
&& state.time >= action.startedAt && state.time >= action.startedAt
&& (rapid ? state.time <= action.endsAt : state.time <= action.impactAt); && (rapid ? state.time <= action.endsAt : state.time <= action.impactAt);
group.current.visible = active; projectile.current.visible = active;
if (!active || !action) return; impact.current.visible = false;
if (!action || state.phase !== "combat" || member.hp <= 0 || action.abilityId === "overcharge") return;
const profile = partyAttackVfxProfile(action.abilityId);
if (lastAbilityId.current !== action.abilityId) {
lastAbilityId.current = action.abilityId;
coreMaterial.current?.color.set(profile.primary);
accentMaterial.current?.color.set(profile.accent);
trailMaterial.current?.color.set(profile.primary);
impactMaterial.current?.color.set(profile.accent);
impactCoreMaterial.current?.color.set(profile.primary);
}
const targetMotion = targetBossMotionByInstance(state, action.targetInstanceId); const targetMotion = targetBossMotionByInstance(state, action.targetInstanceId);
const projectileDuration = Math.max(0.12, action.impactAt - action.startedAt); const projectileDuration = Math.max(0.12, action.impactAt - action.startedAt);
@@ -890,44 +1051,81 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
const target = targetMotion.position; const target = targetMotion.position;
start.set(source[0], 1.18, source[1]); start.set(source[0], 1.18, source[1]);
end.set(target[0], 1.12, target[1]); end.set(target[0], 1.12, target[1]);
current.copy(start).lerp(end, progress); if (active) {
current.y += Math.sin(progress * Math.PI) * (memberId === "orin" ? 0.95 : 0.34); current.copy(start).lerp(end, progress);
group.current.position.copy(current); current.y += Math.sin(progress * Math.PI) * (memberId === "orin" ? 0.95 : 0.34);
direction.subVectors(end, start).normalize(); projectile.current.position.copy(current);
group.current.quaternion.setFromUnitVectors(up, direction); direction.subVectors(end, start).normalize();
if (memberId === "orin") group.current.scale.setScalar(0.9 + Math.sin(clock.elapsedTime * 14) * 0.12); projectile.current.quaternion.setFromUnitVectors(up, direction);
const pulseScale = memberId === "orin" ? 1 + Math.sin(clock.elapsedTime * 14) * 0.12 : 1;
projectile.current.scale.setScalar(profile.scale * pulseScale);
trail.current?.scale.set(1, profile.trail, 1);
if (trailMaterial.current) trailMaterial.current.opacity = 0.34 + Math.sin(clock.elapsedTime * 10) * 0.08;
}
const impactProgress = rapid
? progress > 0.72 ? (progress - 0.72) / 0.28 : -1
: (state.time - action.impactAt) / 0.3;
const impactVisible = impactProgress >= 0 && impactProgress <= 1 && state.time <= action.endsAt + 0.3;
impact.current.visible = impactVisible;
if (impactVisible) {
impact.current.position.copy(end);
impact.current.scale.setScalar(profile.scale * (0.45 + impactProgress * 2.15));
if (impactMaterial.current) impactMaterial.current.opacity = (1 - impactProgress) * 0.9;
if (impactCoreMaterial.current) impactCoreMaterial.current.opacity = (1 - impactProgress) * 0.72;
}
}); });
return ( return (
<group ref={group} visible={false}> <>
{memberId === "nia" ? ( <group ref={projectile} visible={false}>
<> {memberId === "nia" ? (
<mesh> <>
<cylinderGeometry args={[0.026, 0.026, 0.82, 6]} /> <mesh>
<meshBasicMaterial color="#d7b477" /> <cylinderGeometry args={[0.026, 0.026, 0.82, 6]} />
</mesh> <meshBasicMaterial ref={coreMaterial} color="#77d596" />
<mesh position={[0, 0.5, 0]}> </mesh>
<coneGeometry args={[0.085, 0.2, 6]} /> <mesh position={[0, 0.5, 0]}>
<meshBasicMaterial color="#f1ddaa" /> <coneGeometry args={[0.085, 0.2, 6]} />
</mesh> <meshBasicMaterial ref={accentMaterial} color="#e5ffb8" />
<mesh position={[0, -0.4, 0]}> </mesh>
<coneGeometry args={[0.1, 0.18, 4]} /> <mesh position={[0, -0.4, 0]}>
<meshBasicMaterial color="#70cf8e" /> <coneGeometry args={[0.1, 0.18, 4]} />
</mesh> <meshBasicMaterial color="#d7b477" />
</> </mesh>
) : ( <mesh ref={trail} position={[0, -0.62, 0]}>
<> <coneGeometry args={[0.12, 0.72, 6, 1, true]} />
<mesh> <meshBasicMaterial ref={trailMaterial} color="#77d596" transparent opacity={0.34} depthWrite={false} blending={THREE.AdditiveBlending} />
<sphereGeometry args={[0.19, 12, 10]} /> </mesh>
<meshBasicMaterial color="#bc8cff" /> </>
</mesh> ) : (
<mesh rotation={[Math.PI / 2, 0, 0]}> <>
<torusGeometry args={[0.25, 0.025, 6, 20]} /> <mesh>
<meshBasicMaterial color="#ead8ff" transparent opacity={0.8} /> <sphereGeometry args={[0.19, 12, 10]} />
</mesh> <meshBasicMaterial ref={coreMaterial} color="#a87cff" toneMapped={false} />
</> </mesh>
)} <mesh rotation={[Math.PI / 2, 0, 0]}>
</group> <torusGeometry args={[0.25, 0.025, 6, 20]} />
<meshBasicMaterial ref={accentMaterial} color="#ead9ff" transparent opacity={0.8} depthWrite={false} />
</mesh>
<mesh ref={trail} position={[0, -0.48, 0]}>
<coneGeometry args={[0.18, 0.95, 8, 1, true]} />
<meshBasicMaterial ref={trailMaterial} color="#a87cff" transparent opacity={0.34} depthWrite={false} blending={THREE.AdditiveBlending} />
</mesh>
</>
)}
</group>
<group ref={impact} visible={false}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.28, 0.45, 24]} />
<meshBasicMaterial ref={impactMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<mesh>
<octahedronGeometry args={[0.24, 0]} />
<meshBasicMaterial ref={impactCoreMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
</group>
</>
); );
} }
@@ -940,6 +1138,144 @@ function RangedProjectiles() {
); );
} }
function CloseAttackVfx({ memberId }: { memberId: "brann" | "orin" | "vale" }) {
const group = useRef<THREE.Group>(null);
const firstArc = useRef<THREE.Mesh>(null);
const secondArc = useRef<THREE.Mesh>(null);
const groundRing = useRef<THREE.Mesh>(null);
const core = useRef<THREE.Mesh>(null);
const primaryMaterial = useRef<THREE.MeshBasicMaterial>(null);
const secondaryMaterial = useRef<THREE.MeshBasicMaterial>(null);
const ringMaterial = useRef<THREE.MeshBasicMaterial>(null);
const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
const lastAbilityId = useRef<PartyAbilityId | null>(null);
useFrame(({ clock }) => {
if (!group.current || !firstArc.current || !secondArc.current || !groundRing.current || !core.current) return;
const state = useGameStore.getState();
const actor = state.partyCombat.combatants[memberId];
const action = actor.visualAction;
const member = state.party.find((entry) => entry.id === memberId)!;
const visible = action !== null
&& state.phase === "combat"
&& member.hp > 0
&& state.time >= action.startedAt
&& state.time <= action.endsAt + 0.18
&& partyAttackVfxProfile(action.abilityId).style !== "projectile";
group.current.visible = visible;
if (!visible || !action) return;
const profile = partyAttackVfxProfile(action.abilityId);
if (lastAbilityId.current !== action.abilityId) {
lastAbilityId.current = action.abilityId;
primaryMaterial.current?.color.set(profile.primary);
secondaryMaterial.current?.color.set(profile.accent);
ringMaterial.current?.color.set(profile.primary);
coreMaterial.current?.color.set(profile.accent);
}
const duration = Math.max(0.2, action.endsAt - action.startedAt);
const progress = THREE.MathUtils.clamp((state.time - action.startedAt) / duration, 0, 1);
const impactProgress = THREE.MathUtils.clamp((state.time - action.impactAt + 0.08) / 0.3, 0, 1);
const source = state.partyPositions[memberId];
const targetMotion = targetBossMotionByInstance(state, action.targetInstanceId);
const target = targetMotion.position;
const sourceStyle = profile.style === "buff" || profile.style === "spin";
const effectHeight = sourceStyle ? 0.22 : profile.style === "slam" ? 0.18 : 1.05;
group.current.position.set(sourceStyle ? source[0] : target[0], effectHeight, sourceStyle ? source[1] : target[1]);
group.current.rotation.y = sourceStyle
? clock.elapsedTime * 0.8
: Math.atan2(target[0] - source[0], target[1] - source[1]);
const slashStyle = profile.style === "slash" || profile.style === "double-slash";
firstArc.current.visible = slashStyle;
secondArc.current.visible = profile.style === "double-slash";
groundRing.current.visible = profile.style === "slam" || profile.style === "spin" || profile.style === "buff";
core.current.visible = profile.style === "slam" || profile.style === "buff";
const actionScale = profile.scale * (0.65 + Math.sin(progress * Math.PI) * 0.75);
firstArc.current.scale.setScalar(actionScale);
firstArc.current.rotation.z = -Math.PI * (0.82 - progress * 0.34);
secondArc.current.scale.setScalar(actionScale * 0.92);
secondArc.current.rotation.z = -Math.PI * (0.15 + progress * 0.36);
const burstScale = profile.scale * (0.5 + impactProgress * 2.2);
groundRing.current.scale.setScalar(burstScale);
groundRing.current.rotation.z = clock.elapsedTime * (memberId === "vale" ? -1.6 : 0.85);
core.current.scale.setScalar(profile.scale * (0.6 + Math.sin(progress * Math.PI) * 1.1));
core.current.rotation.set(clock.elapsedTime, clock.elapsedTime * 1.4, 0);
if (primaryMaterial.current) primaryMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.9;
if (secondaryMaterial.current) secondaryMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.84;
if (ringMaterial.current) ringMaterial.current.opacity = (1 - impactProgress * 0.7) * 0.76;
if (coreMaterial.current) coreMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.72;
});
return (
<group ref={group} visible={false}>
<mesh ref={firstArc} rotation={[0, 0, -Math.PI * 0.72]}>
<torusGeometry args={[0.72, 0.055, 6, 28, Math.PI * 1.28]} />
<meshBasicMaterial ref={primaryMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<mesh ref={secondArc} position={[0.16, 0.02, 0.05]} rotation={[0, 0, -Math.PI * 0.2]}>
<torusGeometry args={[0.64, 0.045, 6, 26, Math.PI * 1.18]} />
<meshBasicMaterial ref={secondaryMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<mesh ref={groundRing} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.38, 0.54, 28]} />
<meshBasicMaterial ref={ringMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<mesh ref={core} position={[0, 0.3, 0]}>
<octahedronGeometry args={[0.26, 0]} />
<meshBasicMaterial ref={coreMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
</group>
);
}
function PartyPowerAuraVfx({ memberId }: { memberId: "orin" | "vale" }) {
const group = useRef<THREE.Group>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
if (!group.current) return;
const state = useGameStore.getState();
const actor = state.partyCombat.combatants[memberId];
const member = state.party.find((entry) => entry.id === memberId)!;
const active = state.phase === "combat" && member.hp > 0 && (memberId === "orin" ? actor.overchargeStacks > 0 : actor.bladeFlurryUntil > state.time);
group.current.visible = active;
if (!active) return;
const position = state.partyPositions[memberId];
group.current.position.set(position[0], 0.16, position[1]);
group.current.rotation.y = clock.elapsedTime * (memberId === "orin" ? 1.4 : -1.8);
const pulse = 0.92 + Math.sin(clock.elapsedTime * 5.5) * 0.12;
group.current.scale.setScalar(pulse);
if (material.current) material.current.opacity = 0.38 + Math.sin(clock.elapsedTime * 4.2) * 0.1;
});
const color = memberId === "orin" ? "#bc72ff" : "#9a86ff";
return (
<group ref={group} visible={false}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.7, 0.035, 6, 28]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.4} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<mesh position={[0, 0.72, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.46, 0.025, 6, 24]} />
<meshBasicMaterial color={color} transparent opacity={0.5} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
</group>
);
}
function PartyCombatVfx() {
return (
<>
<RangedProjectiles />
<CloseAttackVfx memberId="brann" />
<CloseAttackVfx memberId="orin" />
<CloseAttackVfx memberId="vale" />
<PartyPowerAuraVfx memberId="orin" />
<PartyPowerAuraVfx memberId="vale" />
</>
);
}
function BossActor() { function BossActor() {
const phase = useGameStore((state) => state.phase); const phase = useGameStore((state) => state.phase);
const primaryBossId = useGameStore((state) => state.boss.id); const primaryBossId = useGameStore((state) => state.boss.id);
@@ -1045,27 +1381,72 @@ function PerformanceProbe() {
return null; return null;
} }
const FX_BURST_PARTICLE_COUNT = 8;
function scenePulseColor(kind: PulseKind) {
if (kind === "shield") return "#62bdff";
if (kind === "purify") return "#c39bff";
if (kind === "renew") return "#72e0a1";
if (kind === "breath") return "#66dcff";
if (kind === "venom") return "#8fdb4f";
if (kind === "tether") return "#d482ff";
if (kind === "skyfall") return "#ffd36b";
if (kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" || kind === "slash") return "#ff643c";
return "#ffe087";
}
function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) { function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
const group = useRef<THREE.Group>(null);
const ring = useRef<THREE.Mesh>(null); const ring = useRef<THREE.Mesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null); const material = useRef<THREE.MeshBasicMaterial>(null);
const particles = useRef<THREE.InstancedMesh>(null);
const particleMaterial = useRef<THREE.MeshBasicMaterial>(null);
const core = useRef<THREE.Mesh>(null);
const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
const age = useRef(0); const age = useRef(0);
const isBossFx = kind === "boss"; const isBossFx = kind === "boss";
const state = useGameStore.getState(); const state = useGameStore.getState();
const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0]; const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0];
const position: [number, number, number] = [worldPosition[0], 0.15, worldPosition[1]]; const position: [number, number, number] = [worldPosition[0], 0.15, worldPosition[1]];
const color = kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" || kind === "slash" ? "#ff643c" : kind === "shield" ? "#62bdff" : kind === "purify" ? "#c39bff" : "#ffe087"; const color = scenePulseColor(kind);
useFrame((_, delta) => { useFrame(({ clock }, delta) => {
age.current += delta; age.current += delta;
if (!ring.current || !material.current) return; if (!group.current || !ring.current || !material.current || !particles.current || !core.current) return;
const progress = Math.min(1, age.current / 0.7); const progress = Math.min(1, age.current / 0.7);
ring.current.scale.setScalar(0.5 + progress * 3.6); ring.current.scale.setScalar(0.5 + progress * 3.6);
material.current.opacity = (1 - progress) * 0.85; material.current.opacity = (1 - progress) * 0.85;
core.current.scale.setScalar(0.45 + Math.sin(progress * Math.PI) * 1.8);
core.current.rotation.set(clock.elapsedTime * 1.5, clock.elapsedTime * 2, 0);
if (coreMaterial.current) coreMaterial.current.opacity = (1 - progress) * 0.72;
for (let index = 0; index < FX_BURST_PARTICLE_COUNT; index += 1) {
const angle = index / FX_BURST_PARTICLE_COUNT * Math.PI * 2 + clock.elapsedTime * 0.6;
const radial = progress * (kind === "boss" ? 3.2 : 1.65);
const scale = (1 - progress) * (kind === "boss" ? 1.4 : 0.9);
transform.position.set(Math.sin(angle) * radial, 0.18 + Math.sin(progress * Math.PI) * (0.7 + (index % 2) * 0.45), Math.cos(angle) * radial);
transform.rotation.set(angle, progress * Math.PI * 2 + index, clock.elapsedTime);
transform.scale.setScalar(scale);
transform.updateMatrix();
particles.current.setMatrixAt(index, transform.matrix);
}
particles.current.instanceMatrix.needsUpdate = true;
if (particleMaterial.current) particleMaterial.current.opacity = (1 - progress) * 0.82;
}); });
return ( return (
<mesh ref={ring} position={position} rotation={[-Math.PI / 2, 0, 0]}> <group ref={group} position={position}>
<ringGeometry args={[0.38, 0.5, 32]} /> <mesh ref={ring} rotation={[-Math.PI / 2, 0, 0]}>
<meshBasicMaterial ref={material} color={color} transparent depthWrite={false} /> <ringGeometry args={[0.38, 0.5, 32]} />
</mesh> <meshBasicMaterial ref={material} color={color} transparent depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<mesh ref={core} position={[0, 0.42, 0]}>
<octahedronGeometry args={[0.24, 0]} />
<meshBasicMaterial ref={coreMaterial} color={color} transparent opacity={0.72} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<instancedMesh ref={particles} args={[undefined, undefined, FX_BURST_PARTICLE_COUNT]} frustumCulled={false}>
<tetrahedronGeometry args={[0.18, 0]} />
<meshBasicMaterial ref={particleMaterial} color={color} transparent opacity={0.82} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</instancedMesh>
</group>
); );
} }
@@ -1089,23 +1470,27 @@ export function GameScene() {
camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }} camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }}
gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }} gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }}
> >
<SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} /> <GameAssetProvider>
<BossRoom /> <SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} />
<BossMechanicIndicators /> <BossRoom />
<BarrierField /> <BossMechanicIndicators />
<TankAuraField /> <BarrierField />
<Party /> <TankAuraField />
<BossActor /> <Party />
<RangedProjectiles /> <BossActor />
<CombatFx /> <PartyCombatVfx />
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />} <CombatFx />
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
</GameAssetProvider>
</Canvas> </Canvas>
); );
} }
for (const memberId of CRITICAL_PARTY_MEMBER_IDS) { if (LEGACY_GAME_ASSETS_FORCED) {
useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true); for (const memberId of CRITICAL_PARTY_MEMBER_IDS) {
const loadout = PARTY_WEAPON_URLS[memberId]; useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true);
useGLTF.preload(loadout.right, false, true); const loadout = PARTY_WEAPON_URLS[memberId];
if (loadout.left) useGLTF.preload(loadout.left, false, true); useGLTF.preload(loadout.right, false, true);
if (loadout.left) useGLTF.preload(loadout.left, false, true);
}
} }
+20 -12
View File
@@ -5,6 +5,7 @@ import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { bossRoomFor } from "../game/bossRooms"; import { bossRoomFor } from "../game/bossRooms";
import { tankAuraProtects } from "../game/partyCombat"; import { tankAuraProtects } from "../game/partyCombat";
import { BuffDraftPanel } from "./BuffDraftPanel"; import { BuffDraftPanel } from "./BuffDraftPanel";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene }))); const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })));
@@ -109,6 +110,9 @@ function EncounterCallout() {
function PhaseOverlay() { function PhaseOverlay() {
const phase = useGameStore((state) => state.phase); const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode); const runMode = useGameStore((state) => state.runMode);
const round = useGameStore((state) => state.round);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const primaryBoss = useGameStore((state) => state.boss); const primaryBoss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses); const additionalBosses = useGameStore((state) => state.additionalBosses);
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />; if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
@@ -116,6 +120,8 @@ function PhaseOverlay() {
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]); const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
const room = bossRoomFor(primaryBoss.id); const room = bossRoomFor(primaryBoss.id);
const bossNames = bosses.map((boss) => boss.name).join(" & "); const bossNames = bosses.map((boss) => boss.name).join(" & ");
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
const endlessDefeat = phase === "defeat" && endlessMode;
const briefingMode = runMode === "rogue-trials" const briefingMode = runMode === "rogue-trials"
? bosses.length === 3 ? "Rogue Trials · Trio Finale" : "Rogue Trials · Dual Round" ? bosses.length === 3 ? "Rogue Trials · Trio Finale" : "Rogue Trials · Dual Round"
: bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial; : bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial;
@@ -123,25 +129,25 @@ function PhaseOverlay() {
const title = phase === "briefing" const title = phase === "briefing"
? room.name ? room.name
: phase === "victory" : phase === "victory"
? `${bossNames} Broken` ? showEndlessChoice ? "Rogue Trials Cleared" : `${bossNames} Broken`
: "Party Broken"; : endlessDefeat ? "Endless Run Ended" : "Party Broken";
const eyebrow = phase === "briefing" const eyebrow = phase === "briefing"
? `${briefingMode} · ${room.biome}` ? `${briefingMode} · ${room.biome}`
: phase === "victory" : phase === "victory"
? "Encounter Complete" ? showEndlessChoice ? "Endless Path Unlocked" : "Encounter Complete"
: "Encounter Failed"; : endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed";
const copy = phase === "briefing" const copy = phase === "briefing"
? definitions.map((boss) => boss.briefing).join(" ") ? definitions.map((boss) => boss.briefing).join(" ")
: phase === "victory" : phase === "victory"
? "Five entered. Five endured." ? showEndlessChoice ? "Leave with the clear, or continue against an unbroken chain of replacement bosses." : "Five entered. Five endured."
: definitions.map((boss) => boss.failure).join(" "); : endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
return ( return (
<div className={`phase-overlay phase-${phase}`}> <div className={`phase-overlay phase-${phase}`}>
<div className="phase-sigil"></div> <div className="phase-sigil"></div>
<span>{eyebrow}</span> <span>{eyebrow}</span>
<h1>{title}</h1> <h1>{title}</h1>
<p>{copy}</p> <p>{copy}</p>
<small>{phase === "briefing" ? "Begin from lower display" : "Restart from lower display"}</small> <small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Continue or Quit on lower display" : "Restart from lower display"}</small>
</div> </div>
); );
} }
@@ -168,15 +174,15 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) {
onFocus={() => setPauseSelection("resume")} onFocus={() => setPauseSelection("resume")}
onPointerEnter={() => setPauseSelection("resume")} onPointerEnter={() => setPauseSelection("resume")}
onClick={() => setPaused(false)} onClick={() => setPaused(false)}
>Resume <small>START / ESC</small></button> >Resume <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>
<button <button
className={`secondary ${selection === "exit" ? "is-controller-focused" : ""}`} className={`secondary ${selection === "exit" ? "is-controller-focused" : ""}`}
onFocus={() => setPauseSelection("exit")} onFocus={() => setPauseSelection("exit")}
onPointerEnter={() => setPauseSelection("exit")} onPointerEnter={() => setPauseSelection("exit")}
onClick={exit} onClick={exit}
>Return to main menu <small>A</small></button> >Return to main menu <small>{DEFAULT_CONTROLLER_GLYPHS.confirm}</small></button>
</div> </div>
<footer><b> / </b> Choose <i /> <b>A / ENTER</b> Confirm</footer> <footer><b> / </b> Choose <i /> <b>{DEFAULT_CONTROLLER_GLYPHS.confirm} / ENTER</b> Confirm</footer>
</div> </div>
</div> </div>
); );
@@ -187,6 +193,8 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
const bossCount = useGameStore((state) => state.additionalBosses.length + 1); const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
const round = useGameStore((state) => state.round); const round = useGameStore((state) => state.round);
const runMode = useGameStore((state) => state.runMode); const runMode = useGameStore((state) => state.runMode);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const setPaused = useGameStore((state) => state.setPaused); const setPaused = useGameStore((state) => state.setPaused);
return ( return (
<section className="display top-display" aria-label="Main game viewport"> <section className="display top-display" aria-label="Main game viewport">
@@ -197,11 +205,11 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
<div className="top-hud"> <div className="top-hud">
<CompactParty /> <CompactParty />
<BossBar /> <BossBar />
<div className="objective-chip"><span>{runMode !== "encounter" ? `Round ${round}` : "Objective"}</span><strong>{bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div> <div className="objective-chip"><span>{endlessMode ? `Endless · ${endlessBossKills} kills` : runMode !== "encounter" ? `Round ${round}` : "Objective"}</span><strong>{endlessMode ? "Defeat bosses · replacements incoming" : bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
<EncounterCallout /> <EncounterCallout />
<CastingBar /> <CastingBar />
<div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>16</b> Cast</div> <div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>16</b> Cast</div>
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b></b> Menu <small>START / ESC</small></button>} {onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b></b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
</div> </div>
<PhaseOverlay /> <PhaseOverlay />
<PauseOverlay onExit={onExit} /> <PauseOverlay onExit={onExit} />
+215
View File
@@ -0,0 +1,215 @@
import { useFrame } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import * as THREE from "three";
import type { PoolTelegraphKind, WorldPosition } from "../../game/types";
const TAU = Math.PI * 2;
const LANE_PARTICLE_COUNT = 7;
const AREA_PARTICLE_COUNT = 8;
const BREATH_PARTICLE_COUNT = 12;
function useReducedMotion() {
return useMemo(() => (
document.documentElement.classList.contains("force-reduced-motion")
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches
), []);
}
export type LaneVfxVariant = "beam" | "charge" | "slash" | "web";
function LaneParticleGeometry({ variant }: { variant: LaneVfxVariant }) {
if (variant === "beam" || variant === "web") return <sphereGeometry args={[0.11, 7, 5]} />;
if (variant === "slash") return <octahedronGeometry args={[0.15, 0]} />;
return <tetrahedronGeometry args={[0.13, 0]} />;
}
/** One instanced draw for motion/readability layered over an existing lane telegraph. */
export function LaneEnergyVfx({
start,
end,
width,
color,
active,
variant,
height = 0.13,
}: {
start: WorldPosition;
end: WorldPosition;
width: number;
color: string;
active: boolean;
variant: LaneVfxVariant;
height?: number;
}) {
const particles = useRef<THREE.InstancedMesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
const reducedMotion = useReducedMotion();
const dx = end[0] - start[0];
const dz = end[1] - start[1];
const length = Math.hypot(dx, dz);
const angle = Math.atan2(dx, dz);
const midpoint: [number, number, number] = [(start[0] + end[0]) * 0.5, height, (start[1] + end[1]) * 0.5];
useFrame(({ clock }) => {
if (!particles.current) return;
const time = reducedMotion ? 0.35 : clock.elapsedTime;
const speed = active ? 2.6 : variant === "web" ? 0.5 : 0.85;
for (let index = 0; index < LANE_PARTICLE_COUNT; index += 1) {
const offset = index / LANE_PARTICLE_COUNT;
const cycle = (time * speed + offset) % 1;
const cross = Math.sin(time * 2.4 + index * 1.7) * width * (variant === "web" ? 0.08 : 0.28);
const rise = variant === "slash"
? Math.sin(cycle * Math.PI) * (active ? 0.65 : 0.28)
: variant === "web" ? Math.sin(time * 3 + index) * 0.07 : Math.sin(cycle * Math.PI) * 0.18;
transform.position.set(cross, rise, -length * 0.5 + cycle * length);
transform.rotation.set(time * 0.8 + index, index * 0.63, active ? time * 1.5 : 0);
const scale = (active ? 1.25 : 0.78) * (0.75 + Math.sin(cycle * Math.PI) * 0.4);
transform.scale.set(
variant === "slash" ? scale * 0.7 : scale,
variant === "charge" ? scale * 0.55 : scale,
variant === "charge" ? scale * 2.1 : scale,
);
transform.updateMatrix();
particles.current.setMatrixAt(index, transform.matrix);
}
particles.current.instanceMatrix.needsUpdate = true;
if (material.current) {
const pulse = reducedMotion ? 0.65 : 0.58 + Math.sin(time * 6.2) * 0.14;
material.current.opacity = active ? pulse + 0.22 : pulse * 0.62;
}
});
return (
<group position={midpoint} rotation={[0, angle, 0]}>
<instancedMesh ref={particles} args={[undefined, undefined, LANE_PARTICLE_COUNT]} frustumCulled={false}>
<LaneParticleGeometry variant={variant} />
<meshBasicMaterial
ref={material}
color={color}
transparent
opacity={active ? 0.82 : 0.38}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</instancedMesh>
</group>
);
}
export function BreathParticleVfx({
position,
angle,
range,
halfAngle,
active,
}: {
position: WorldPosition;
angle: number;
range: number;
halfAngle: number;
active: boolean;
}) {
const particles = useRef<THREE.InstancedMesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
const reducedMotion = useReducedMotion();
useFrame(({ clock }) => {
if (!particles.current) return;
const time = reducedMotion ? 0.4 : clock.elapsedTime;
for (let index = 0; index < BREATH_PARTICLE_COUNT; index += 1) {
const row = index % 4;
const lane = Math.floor(index / 4);
const offsetAngle = -halfAngle + (lane / 2) * halfAngle * 2;
const cycle = (time * (active ? 1.7 : 0.52) + row * 0.24 + lane * 0.08) % 1;
const distance = range * (0.08 + cycle * 0.9);
const scale = (active ? 1 : 0.55) * (1.15 - cycle * 0.55);
transform.position.set(
Math.sin(offsetAngle) * distance,
0.18 + Math.sin(cycle * Math.PI) * (active ? 0.72 : 0.32),
Math.cos(offsetAngle) * distance,
);
transform.rotation.set(time * 1.2 + index, -offsetAngle, cycle * TAU);
transform.scale.set(scale * 0.72, scale * 1.45, scale * 0.72);
transform.updateMatrix();
particles.current.setMatrixAt(index, transform.matrix);
}
particles.current.instanceMatrix.needsUpdate = true;
if (material.current) material.current.opacity = active ? 0.74 : 0.26;
});
return (
<group position={[position[0], 0.1, position[1]]} rotation={[0, angle, 0]}>
<instancedMesh ref={particles} args={[undefined, undefined, BREATH_PARTICLE_COUNT]} frustumCulled={false}>
<tetrahedronGeometry args={[0.2, 0]} />
<meshBasicMaterial
ref={material}
color={active ? "#7ceaff" : "#b8f4ff"}
transparent
opacity={active ? 0.74 : 0.26}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</instancedMesh>
</group>
);
}
export function CircleMechanicVfx({
radius,
innerRadius = 0,
kind,
color,
active,
}: {
radius: number;
innerRadius?: number;
kind: Extract<PoolTelegraphKind, "donut" | "soak" | "spread"> | "pounce";
color: string;
active: boolean;
}) {
const particles = useRef<THREE.InstancedMesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
const reducedMotion = useReducedMotion();
useFrame(({ clock }) => {
if (!particles.current) return;
const time = reducedMotion ? 0.3 : clock.elapsedTime;
for (let index = 0; index < AREA_PARTICLE_COUNT; index += 1) {
const offset = index / AREA_PARTICLE_COUNT;
const cycle = (time * (active ? 1.2 : 0.55) + offset) % 1;
const orbit = offset * TAU + time * (kind === "soak" ? -0.55 : 0.7);
let radial = radius * 0.82;
if (kind === "spread") radial = radius * (0.18 + cycle * 0.75);
if (kind === "soak" || kind === "pounce") radial = radius * (0.92 - cycle * 0.68);
if (kind === "donut") radial = index % 2 === 0 ? Math.max(0.2, innerRadius + 0.18) : radius - 0.18;
const scale = (active ? 1 : 0.64) * (0.72 + Math.sin(cycle * Math.PI) * 0.48);
transform.position.set(Math.sin(orbit) * radial, 0.12 + Math.sin(cycle * Math.PI) * 0.52, Math.cos(orbit) * radial);
transform.rotation.set(time + index, orbit, cycle * TAU);
transform.scale.setScalar(scale);
transform.updateMatrix();
particles.current.setMatrixAt(index, transform.matrix);
}
particles.current.instanceMatrix.needsUpdate = true;
if (material.current) material.current.opacity = active ? 0.84 : 0.42;
});
return (
<instancedMesh ref={particles} args={[undefined, undefined, AREA_PARTICLE_COUNT]} frustumCulled={false}>
<octahedronGeometry args={[0.16, 0]} />
<meshBasicMaterial
ref={material}
color={color}
transparent
opacity={active ? 0.84 : 0.42}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</instancedMesh>
);
}
+151 -60
View File
@@ -3,10 +3,13 @@ import { Html } from "@react-three/drei";
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentType } from "react"; import { 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,
+198
View File
@@ -0,0 +1,198 @@
import { useFrame } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import * as THREE from "three";
import { bossHazardVfxProfile, bossHazardVfxSeed, type BossHazardVfxShape } from "../../game/bossHazardVisuals";
import { useGameStore } from "../../game/store";
import type { CircleHazard } from "../../game/types";
const TAU = Math.PI * 2;
function ParticleGeometry({ shape }: { shape: BossHazardVfxShape }) {
if (shape === "orb") return <sphereGeometry args={[0.16, 8, 6]} />;
if (shape === "spike") return <coneGeometry args={[0.16, 0.9, 5]} />;
return <octahedronGeometry args={[0.19, 0]} />;
}
/**
* One instanced particle draw plus one transient accent and an optional column.
* Effects mutate preallocated transforms and never touch combat state.
*/
export function CircleHazardVfx({ hazard, active }: { hazard: CircleHazard; active: boolean }) {
const profile = bossHazardVfxProfile(hazard.kind);
const particles = useRef<THREE.InstancedMesh>(null);
const particleMaterial = useRef<THREE.MeshBasicMaterial>(null);
const warningRing = useRef<THREE.Mesh>(null);
const warningMaterial = useRef<THREE.MeshBasicMaterial>(null);
const impactRing = useRef<THREE.Mesh>(null);
const impactMaterial = useRef<THREE.MeshBasicMaterial>(null);
const column = useRef<THREE.Mesh>(null);
const columnMaterial = useRef<THREE.MeshBasicMaterial>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
const seed = useMemo(() => bossHazardVfxSeed(hazard.id), [hazard.id]);
const reducedMotion = useMemo(() => (
document.documentElement.classList.contains("force-reduced-motion")
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches
), []);
useFrame(({ clock }) => {
const time = useGameStore.getState().time;
const activeAge = Math.max(0, time - hazard.activatesAt);
const warningRemaining = Math.max(0, hazard.activatesAt - time);
const effectTime = reducedMotion ? seed * 2 : clock.elapsedTime + seed * 7.3;
const activeDuration = Math.max(0.22, hazard.expiresAt - hazard.activatesAt);
const impactDuration = Math.min(0.48, activeDuration);
const impactProgress = THREE.MathUtils.clamp(activeAge / impactDuration, 0, 1);
if (particles.current) {
for (let index = 0; index < profile.particleCount; index += 1) {
const offset = (index + 0.5) / profile.particleCount;
const baseAngle = offset * TAU + seed * TAU;
const angle = baseAngle + effectTime * profile.spinSpeed;
const stagger = (offset + seed * 0.37) % 1;
let radial = hazard.radius * (0.28 + (index % 3) * 0.2);
let y = 0.12;
let width = 0.72;
let height = 0.72;
if (profile.motion === "bubble") {
const cycle = (effectTime * 0.48 + stagger) % 1;
radial = hazard.radius * (0.22 + (index % 3) * 0.22);
y = 0.08 + cycle * profile.height;
width = 0.55 + Math.sin(cycle * Math.PI) * 0.65;
height = width;
} else if (profile.motion === "fall") {
const cycle = 1 - ((effectTime * 0.72 + stagger) % 1);
radial = hazard.radius * (0.18 + (index % 3) * 0.2);
y = 0.22 + cycle * profile.height;
width = active ? 0.82 : 0.55;
height = active ? 2.9 : 2.15;
} else if (profile.motion === "flame") {
const cycle = (effectTime * 0.95 + stagger) % 1;
radial = hazard.radius * (0.2 + (index % 3) * 0.21);
y = 0.12 + cycle * profile.height * 0.62;
width = 0.5 + Math.sin(cycle * Math.PI) * 0.85;
height = 0.75 + Math.sin(cycle * Math.PI) * 1.6;
} else if (profile.motion === "orbit") {
radial = hazard.radius * (0.3 + (index % 2) * 0.24);
y = 0.28 + ((index % 4) / 3) * profile.height + Math.sin(effectTime * 2.2 + index) * 0.16;
width = 0.65 + (index % 2) * 0.35;
height = profile.shape === "crystal" ? 1.55 : width;
} else if (profile.motion === "shockwave") {
const wave = active ? impactProgress : 0.22 + (1 - Math.min(1, warningRemaining / 1.6)) * 0.18;
radial = hazard.radius * wave;
y = 0.13 + Math.sin(offset * Math.PI) * profile.height * 0.12;
width = active ? 0.72 + impactProgress * 0.75 : 0.58;
height = active ? 1.25 : 0.75;
} else if (profile.motion === "spike") {
const rise = active ? THREE.MathUtils.clamp(activeAge / 0.16, 0, 1) : 0.12;
radial = hazard.radius * (0.2 + (index % 3) * 0.23);
width = 0.75 + (index % 2) * 0.35;
height = Math.max(0.18, rise * (profile.height + (index % 3) * 0.34));
y = height * 0.45;
} else {
const cycle = (effectTime * 0.72 + stagger) % 1;
radial = hazard.radius * (0.18 + cycle * 0.68);
y = 0.12 + Math.sin(cycle * Math.PI) * profile.height;
width = 0.6 + Math.sin(cycle * Math.PI) * 0.72;
height = width * 1.4;
}
const warningScale = active ? 1 : 0.56;
transform.position.set(Math.sin(angle) * radial, y, Math.cos(angle) * radial);
transform.rotation.set(index * 0.73, -angle, profile.motion === "fall" ? 0 : effectTime * 0.45 + index);
transform.scale.set(width * warningScale, height * warningScale, width * warningScale);
transform.updateMatrix();
particles.current.setMatrixAt(index, transform.matrix);
}
particles.current.instanceMatrix.needsUpdate = true;
}
if (particleMaterial.current) {
const pulse = reducedMotion ? 0.72 : 0.64 + Math.sin(effectTime * 4.6) * 0.12;
particleMaterial.current.opacity = active ? pulse + 0.16 : pulse * 0.48;
}
if (warningRing.current && warningMaterial.current) {
const countdown = THREE.MathUtils.clamp(warningRemaining / 1.8, 0, 1);
const scale = hazard.radius * (0.18 + countdown * 0.82);
warningRing.current.visible = !active;
warningRing.current.scale.setScalar(scale);
warningRing.current.rotation.z = reducedMotion ? seed * TAU : effectTime * profile.spinSpeed * 0.42;
warningMaterial.current.opacity = 0.48 + (reducedMotion ? 0 : Math.sin(effectTime * 7) * 0.16);
}
if (impactRing.current && impactMaterial.current) {
impactRing.current.visible = active && impactProgress < 0.999;
impactRing.current.scale.setScalar(hazard.radius * (0.16 + impactProgress * 1.02));
impactMaterial.current.opacity = (1 - impactProgress) * 0.92;
}
if (column.current && columnMaterial.current) {
const columnPulse = reducedMotion ? 0.78 : 0.7 + Math.sin(effectTime * 3.8) * 0.18;
column.current.rotation.y = effectTime * profile.spinSpeed * 0.3;
column.current.scale.set(
hazard.radius * (active ? 0.9 : 0.62),
profile.height * (active ? 1 : 0.72),
hazard.radius * (active ? 0.9 : 0.62),
);
columnMaterial.current.opacity = profile.columnOpacity * columnPulse * (active ? 1 : 0.52);
}
});
const color = active ? profile.activeColor : profile.warningColor;
return (
<group>
<instancedMesh ref={particles} args={[undefined, undefined, profile.particleCount]} frustumCulled={false}>
<ParticleGeometry shape={profile.shape} />
<meshBasicMaterial
ref={particleMaterial}
color={color}
transparent
opacity={active ? 0.8 : 0.35}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</instancedMesh>
<mesh ref={warningRing} position={[0, 0.085, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<torusGeometry args={[1, 0.026, 5, 36]} />
<meshBasicMaterial
ref={warningMaterial}
color={profile.highlightColor}
transparent
opacity={0.58}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</mesh>
<mesh ref={impactRing} position={[0, 0.12, 0]} rotation={[-Math.PI / 2, 0, 0]} visible={false}>
<ringGeometry args={[0.84, 1, 40]} />
<meshBasicMaterial
ref={impactMaterial}
color={profile.highlightColor}
transparent
opacity={0}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</mesh>
{profile.columnOpacity > 0 && (
<mesh ref={column} position={[0, profile.height * 0.5, 0]}>
<cylinderGeometry args={[0.48, 0.16, 1, 18, 1, true]} />
<meshBasicMaterial
ref={columnMaterial}
color={color}
transparent
opacity={profile.columnOpacity}
depthWrite={false}
side={THREE.DoubleSide}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</mesh>
)}
</group>
);
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { BOSS_BURROW_CLEARANCE, bossBurrowPositionY, bossIsBurrowing } from "./bossBurrowVisuals";
describe("boss burrow visuals", () => {
it("places the scaled top of the model below the arena floor", () => {
const modelTopY = 2.8371768724243784;
const modelScale = 0.82;
const positionY = bossBurrowPositionY(modelTopY, modelScale);
expect(positionY + modelTopY * modelScale).toBeCloseTo(-BOSS_BURROW_CLEARANCE, 6);
});
it("starts sinking during the warning and remains submerged through the charge", () => {
expect(bossIsBurrowing("burrow-rush", "telegraph")).toBe(true);
expect(bossIsBurrowing("burrow-rush", "charging")).toBe(true);
expect(bossIsBurrowing("burrow-rush", "holding")).toBe(false);
expect(bossIsBurrowing("bull-charge", "charging")).toBe(false);
});
});
+18
View File
@@ -0,0 +1,18 @@
import type { BossMechanicId, BossMotionMode } from "../../game/types";
export const BOSS_BURROW_CLEARANCE = 0.58;
export function bossBurrowPositionY(
modelTopY: number,
modelScale: number,
clearance = BOSS_BURROW_CLEARANCE,
): number {
return -(Math.max(0, modelTopY) * Math.max(0, modelScale) + Math.max(0, clearance));
}
export function bossIsBurrowing(
activeMechanicId: BossMechanicId | null,
mode: BossMotionMode,
): boolean {
return activeMechanicId === "burrow-rush" && (mode === "telegraph" || mode === "charging");
}
@@ -1,8 +1,12 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
BOSS_INDICATOR_DEATH_FADE_MS, BOSS_INDICATOR_DEATH_FADE_MS,
BOSS_DEATH_DESPAWN_SECONDS,
BOSS_DEATH_FADE_SECONDS,
BOSS_DEATH_HOLD_SECONDS,
advanceBossIndicatorOpacity, advanceBossIndicatorOpacity,
bossCanTrackTarget, bossCanTrackTarget,
bossDeathOpacity,
} from "./bossDeathVisuals"; } from "./bossDeathVisuals";
describe("boss death visuals", () => { describe("boss death visuals", () => {
@@ -18,4 +22,10 @@ describe("boss death visuals", () => {
expect(advanceBossIndicatorOpacity(halfway, true, BOSS_INDICATOR_DEATH_FADE_MS / 2_000)).toBe(0); expect(advanceBossIndicatorOpacity(halfway, true, BOSS_INDICATOR_DEATH_FADE_MS / 2_000)).toBe(0);
expect(advanceBossIndicatorOpacity(0.4, false, 1 / 60)).toBe(1); expect(advanceBossIndicatorOpacity(0.4, false, 1 / 60)).toBe(1);
}); });
it("holds the death pose for a few seconds, then fades the model", () => {
expect(bossDeathOpacity(BOSS_DEATH_HOLD_SECONDS)).toBe(1);
expect(bossDeathOpacity(BOSS_DEATH_HOLD_SECONDS + BOSS_DEATH_FADE_SECONDS / 2)).toBeCloseTo(0.5);
expect(bossDeathOpacity(BOSS_DEATH_DESPAWN_SECONDS)).toBe(0);
});
}); });
+7
View File
@@ -1,3 +1,10 @@
export {
BOSS_DEATH_DESPAWN_SECONDS,
BOSS_DEATH_FADE_SECONDS,
BOSS_DEATH_HOLD_SECONDS,
bossDeathOpacity,
} from "../../game/bossDeath";
export const BOSS_INDICATOR_DEATH_FADE_MS = 250; export const BOSS_INDICATOR_DEATH_FADE_MS = 250;
export function bossCanTrackTarget(hp: number) { export function bossCanTrackTarget(hp: number) {
+1 -1
View File
@@ -7,7 +7,7 @@ import { AVAILABLE_BOSS_IDS, BOSS_GROUPS } from "../game/bossCatalog";
describe("game mode configuration", () => { describe("game mode configuration", () => {
it("separates randomized PVE from selectable Dungeons", () => { it("separates randomized PVE from selectable Dungeons", () => {
expect(MODE_COPY["roguelike-pve"].title).toBe("PVE"); expect(MODE_COPY["roguelike-pve"].title).toBe("PVE");
expect(MODE_COPY["rogue-trials"].detail).toContain("unseen"); expect(MODE_COPY["rogue-trials"].detail).toContain("Endless");
expect(MODE_COPY.dungeons.title).toBe("Dungeons"); expect(MODE_COPY.dungeons.title).toBe("Dungeons");
}); });
+3 -2
View File
@@ -75,8 +75,8 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
"rogue-trials": { "rogue-trials": {
eyebrow: "14 hunters · five-round PVE trial", eyebrow: "14 hunters · five-round PVE trial",
title: "Rogue Trials", title: "Rogue Trials",
description: "Build through four randomized dual-boss rounds, then face three bosses together in a final trial.", description: "Build through four randomized dual-boss rounds, defeat an unseen trio, then leave with the clear or continue into endless combat.",
detail: "Round 5 trio always uses bosses unseen during that run", detail: "Endless mode replaces every fallen boss and tracks your best kill count",
status: "Playable now", status: "Playable now",
}, },
dungeons: { dungeons: {
@@ -139,6 +139,7 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
healingDone: 0, healingDone: 0,
bossKills: {}, bossKills: {},
highestRoguelikeRound: 0, highestRoguelikeRound: 0,
highestRogueTrialsEndlessKills: 0,
}, },
materials: [] as MaterialStack[], materials: [] as MaterialStack[],
collectionLog: createEmptyCollectionLog(), collectionLog: createEmptyCollectionLog(),
+5 -1
View File
@@ -22,7 +22,7 @@ export interface LeaderboardEntry {
} }
export interface LeaderboardResult { export interface LeaderboardResult {
kind: "boss" | "roguelike"; kind: "boss" | "roguelike" | "rogue-trials-endless";
bossId?: BossId; bossId?: BossId;
top: LeaderboardEntry[]; top: LeaderboardEntry[];
current: LeaderboardEntry | null; current: LeaderboardEntry | null;
@@ -147,6 +147,10 @@ export class OnlineRepository {
roguelikeLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> { roguelikeLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/roguelike?slot=${slotId}`); return this.request(`/api/leaderboards/roguelike?slot=${slotId}`);
} }
rogueTrialsEndlessLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/rogue-trials-endless?slot=${slotId}`);
}
} }
export const onlineRepository = new OnlineRepository(); export const onlineRepository = new OnlineRepository();
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { createHunterSave } from "./data";
import { resolveSaveContinuation, saveVersionsMatch } from "./saveContinuation";
function save(updatedAt: string) {
return createHunterSave(1, updatedAt, "Test Hunter");
}
describe("save continuation", () => {
it("creates only when neither copy exists", () => {
expect(resolveSaveContinuation({ local: null, online: null })).toBe("create");
});
it("uses whichever single copy exists", () => {
const local = save("2026-07-13T12:00:00.000Z");
const online = save("2026-07-13T13:00:00.000Z");
expect(resolveSaveContinuation({ local, online: null })).toBe("local");
expect(resolveSaveContinuation({ local: null, online })).toBe("online");
});
it("asks only when online copy is newer", () => {
const local = save("2026-07-13T12:00:00.000Z");
expect(resolveSaveContinuation({ local, online: save("2026-07-13T13:00:00.000Z") })).toBe("choose");
expect(resolveSaveContinuation({ local, online: save("2026-07-13T11:00:00.000Z") })).toBe("local");
expect(resolveSaveContinuation({ local, online: save(local.updatedAt) })).toBe("local");
});
it("matches downloaded copies by slot and timestamp", () => {
const local = save("2026-07-13T12:00:00.000Z");
expect(saveVersionsMatch(local, save(local.updatedAt))).toBe(true);
expect(saveVersionsMatch(local, save("2026-07-13T13:00:00.000Z"))).toBe(false);
expect(saveVersionsMatch(local, null)).toBe(false);
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { HunterSave, SaveSlotState } from "./types";
export type SaveContinuation = "create" | "local" | "online" | "choose";
function saveTimestamp(save: HunterSave): number | null {
const timestamp = Date.parse(save.updatedAt);
return Number.isFinite(timestamp) ? timestamp : null;
}
export function resolveSaveContinuation(slot: Pick<SaveSlotState, "local" | "online">): SaveContinuation {
if (!slot.local) return slot.online ? "online" : "create";
if (!slot.online) return "local";
const localTimestamp = saveTimestamp(slot.local);
const onlineTimestamp = saveTimestamp(slot.online);
if (localTimestamp !== null && onlineTimestamp !== null && onlineTimestamp > localTimestamp) return "choose";
return "local";
}
export function saveVersionsMatch(local: HunterSave | null, online: HunterSave | null): boolean {
return Boolean(local && online && local.slotId === online.slotId && local.updatedAt === online.updatedAt);
}
+3 -1
View File
@@ -108,7 +108,7 @@ describe("SaveRepository", () => {
expect(migrated.activeClassId).toBe("priest"); expect(migrated.activeClassId).toBe("priest");
expect(migrated.playSeconds).toBe(0); expect(migrated.playSeconds).toBe(0);
expect(Object.values(migrated.healers).every((healer) => healer.level === 1 && healer.inventory.length > 0)).toBe(true); expect(Object.values(migrated.healers).every((healer) => healer.level === 1 && healer.inventory.length > 0)).toBe(true);
expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {}, highestRoguelikeRound: 0 }); expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {}, highestRoguelikeRound: 0, highestRogueTrialsEndlessKills: 0 });
expect(migrated.materials).toEqual([]); expect(migrated.materials).toEqual([]);
expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} }); expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} });
expect(Object.values(migrated.gearProgress).every((owner) => owner.infusionAbilityId === null && owner.passiveInfusionId === null && Object.values(owner.slots).every((slot) => slot.level === 0))).toBe(true); expect(Object.values(migrated.gearProgress).every((owner) => owner.infusionAbilityId === null && owner.passiveInfusionId === null && Object.values(owner.slots).every((slot) => slot.level === 0))).toBe(true);
@@ -122,6 +122,7 @@ describe("SaveRepository", () => {
const drop = groupDrop("charge", "veteran"); const drop = groupDrop("charge", "veteran");
created.healers.priest.level = 8; created.healers.priest.level = 8;
created.stats = { ...created.stats, totalBossKills: 2, bossKills: { bulldrome: 2 } }; created.stats = { ...created.stats, totalBossKills: 2, bossKills: { bulldrome: 2 } };
created.stats.highestRogueTrialsEndlessKills = 14;
created.materials = [{ id: drop.id, name: drop.name, quantity: 4, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }]; created.materials = [{ id: drop.id, name: drop.name, quantity: 4, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }];
created.collectionLog = { dropsFound: { [drop.id]: 4 }, petsFound: { "bulldrome-pet": 1 } }; created.collectionLog = { dropsFound: { [drop.id]: 4 }, petsFound: { "bulldrome-pet": 1 } };
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created })); storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
@@ -130,6 +131,7 @@ describe("SaveRepository", () => {
expect(migrated.schemaVersion).toBe(5); expect(migrated.schemaVersion).toBe(5);
expect(migrated.healers.priest.level).toBe(8); expect(migrated.healers.priest.level).toBe(8);
expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 }); expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 });
expect(migrated.stats.highestRogueTrialsEndlessKills).toBe(14);
expect(migrated.materials[0]).toMatchObject({ id: drop.id, quantity: 4 }); expect(migrated.materials[0]).toMatchObject({ id: drop.id, quantity: 4 });
expect(migrated.collectionLog).toEqual(created.collectionLog); expect(migrated.collectionLog).toEqual(created.collectionLog);
}); });
+1
View File
@@ -150,6 +150,7 @@ function normalizeSave(value: unknown): HunterSave | null {
healingDone: Math.max(0, candidate.stats?.healingDone ?? 0), healingDone: Math.max(0, candidate.stats?.healingDone ?? 0),
bossKills, bossKills,
highestRoguelikeRound: Math.max(0, Math.floor(candidate.stats?.highestRoguelikeRound ?? 0)), highestRoguelikeRound: Math.max(0, Math.floor(candidate.stats?.highestRoguelikeRound ?? 0)),
highestRogueTrialsEndlessKills: Math.max(0, Math.floor(candidate.stats?.highestRogueTrialsEndlessKills ?? 0)),
}, },
materials: normalizeMaterials(candidate.materials, collectionLog), materials: normalizeMaterials(candidate.materials, collectionLog),
collectionLog, collectionLog,
+18 -2
View File
@@ -13,7 +13,7 @@ import {
infusionsForOwner, infusionsForOwner,
} from "../game/progression/infusions"; } from "../game/progression/infusions";
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot"; import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
import { highestRoguelikeRoundAfterDefeat } from "../game/progression/hunterStats"; import { highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat } from "../game/progression/hunterStats";
const repository = new SaveRepository(); const repository = new SaveRepository();
const accounts = new AccountRepository(); const accounts = new AccountRepository();
@@ -125,6 +125,7 @@ export interface FrontendState {
touchActiveSave: () => void; touchActiveSave: () => void;
recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null; recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null;
recordRoguelikeDefeat: (round: number) => void; recordRoguelikeDefeat: (round: number) => void;
recordRogueTrialsEndlessDefeat: (bossKills: number) => void;
clearRecentRewards: () => void; clearRecentRewards: () => void;
clearNotice: () => void; clearNotice: () => void;
} }
@@ -388,7 +389,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
}); });
set((state) => ({ set((state) => ({
slots: refreshLocalSlots(state.slots), slots: refreshLocalSlots(state.slots),
recentRewards: awarded ? [...state.recentRewards, awarded] : state.recentRewards, recentRewards: awarded ? [...state.recentRewards, awarded].slice(-12) : state.recentRewards,
notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved.` : "Boss clear saved.", notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved.` : "Boss clear saved.",
})); }));
return awarded; return awarded;
@@ -406,6 +407,19 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
if (!updated) return; if (!updated) return;
set((state) => ({ slots: refreshLocalSlots(state.slots) })); set((state) => ({ slots: refreshLocalSlots(state.slots) }));
}, },
recordRogueTrialsEndlessDefeat: (bossKills) => {
const { activeSlotId } = get();
if (!activeSlotId) return;
const updated = repository.updateLocal(activeSlotId, (save) => ({
...save,
stats: {
...save.stats,
highestRogueTrialsEndlessKills: highestEndlessBossKillsAfterDefeat(save.stats.highestRogueTrialsEndlessKills, bossKills),
},
}));
if (!updated) return;
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
},
clearRecentRewards: () => set({ recentRewards: [] }), clearRecentRewards: () => set({ recentRewards: [] }),
clearNotice: () => set({ notice: "" }), clearNotice: () => set({ notice: "" }),
})); }));
@@ -442,6 +456,7 @@ export type FrontendSnapshot = Omit<FrontendState,
| "touchActiveSave" | "touchActiveSave"
| "recordBossVictory" | "recordBossVictory"
| "recordRoguelikeDefeat" | "recordRoguelikeDefeat"
| "recordRogueTrialsEndlessDefeat"
| "clearRecentRewards" | "clearRecentRewards"
| "clearNotice" | "clearNotice"
>; >;
@@ -479,6 +494,7 @@ export function getFrontendSnapshot(): FrontendSnapshot {
touchActiveSave: _touchActiveSave, touchActiveSave: _touchActiveSave,
recordBossVictory: _recordBossVictory, recordBossVictory: _recordBossVictory,
recordRoguelikeDefeat: _recordRoguelikeDefeat, recordRoguelikeDefeat: _recordRoguelikeDefeat,
recordRogueTrialsEndlessDefeat: _recordRogueTrialsEndlessDefeat,
clearRecentRewards: _clearRecentRewards, clearRecentRewards: _clearRecentRewards,
clearNotice: _clearNotice, clearNotice: _clearNotice,
...snapshot ...snapshot
+1
View File
@@ -42,6 +42,7 @@ export interface HunterStats {
healingDone: number; healingDone: number;
bossKills: Record<string, number>; bossKills: Record<string, number>;
highestRoguelikeRound: number; highestRoguelikeRound: number;
highestRogueTrialsEndlessKills: number;
} }
export interface HealerProgress { export interface HealerProgress {
+4 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createClassInventory } from "./healers"; import { createClassInventory } from "./healers";
import { AVAILABLE_BOSS_IDS } from "./bossCatalog"; import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
import { canAddBossToEncounter } from "./bossSelection";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
import type { BossId } from "./types"; import type { BossId } from "./types";
@@ -27,7 +28,9 @@ function simulateControlledBattle(bossIds: readonly [BossId, BossId], maxSeconds
describe("full-mechanics dual-boss battle simulations", () => { describe("full-mechanics dual-boss battle simulations", () => {
const combinations: readonly (readonly [BossId, BossId])[] = AVAILABLE_BOSS_IDS.flatMap((first, index) => const combinations: readonly (readonly [BossId, BossId])[] = AVAILABLE_BOSS_IDS.flatMap((first, index) =>
AVAILABLE_BOSS_IDS.slice(index + 1).map((second) => [first, second] as const), AVAILABLE_BOSS_IDS.slice(index + 1)
.filter((second) => canAddBossToEncounter([first], second))
.map((second) => [first, second] as const),
); );
it.each(combinations)("party rotations defeat %s + %s", (first, second) => { it.each(combinations)("party rotations defeat %s + %s", (first, second) => {
+29 -1
View File
@@ -2,11 +2,12 @@ import { describe, expect, it } from "vitest";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "./bossCatalog"; import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "./bossCatalog";
import { createBossMotionState, createBossState } from "./bossMechanics"; import { createBossMotionState, createBossState } from "./bossMechanics";
import { BOSS_MECHANIC_POOL, BOSS_MECHANIC_REGISTRY, bossMechanicName } from "./bosses/mechanicPool"; import { BOSS_MECHANIC_POOL, BOSS_MECHANIC_REGISTRY, bossMechanicName } from "./bosses/mechanicPool";
import { ALTERNATE_BOSS_CONFIG } from "./bossVisuals";
describe("boss catalog", () => { describe("boss catalog", () => {
it("derives the available roster from every catalog definition", () => { it("derives the available roster from every catalog definition", () => {
expect(AVAILABLE_BOSS_IDS).toEqual(Object.keys(BOSS_DEFINITIONS)); expect(AVAILABLE_BOSS_IDS).toEqual(Object.keys(BOSS_DEFINITIONS));
expect(new Set(AVAILABLE_BOSS_IDS)).toHaveLength(27); expect(new Set(AVAILABLE_BOSS_IDS)).toHaveLength(28);
}); });
it("assigns every boss to one compatible mechanic group", () => { it("assigns every boss to one compatible mechanic group", () => {
@@ -60,4 +61,31 @@ describe("boss catalog", () => {
const kits = Object.values(BOSS_DEFINITIONS).map((boss) => boss.mechanicIds.join(",")); const kits = Object.values(BOSS_DEFINITIONS).map((boss) => boss.mechanicIds.join(","));
expect(new Set(kits)).toHaveLength(AVAILABLE_BOSS_IDS.length); expect(new Set(kits)).toHaveLength(AVAILABLE_BOSS_IDS.length);
}); });
it("uses the boar model's full authored death performance", () => {
expect(ALTERNATE_BOSS_CONFIG["bristlequake-boar"].death).toBe("Dying");
});
it("assigns Gravehorn to the underfilled burrow group with its optimized animation set", () => {
expect(BOSS_GROUPS.find((group) => group.id === "burrow-eruption")?.bossIds).toContain("gravehorn-triceratops");
expect(ALTERNATE_BOSS_CONFIG["gravehorn-triceratops"]).toMatchObject({
idle: "Gravehorn|Idle",
move: "Armature|Walk",
attack: "Armature|Roar",
special: "Armature|RiseUp",
death: "Armature|Fall",
});
expect(ALTERNATE_BOSS_CONFIG["gravehorn-triceratops"].optimizedUrl).toContain("gravehorn-triceratops-uastc");
});
it("uses original animated creatures instead of the retired chicken and frog visuals", () => {
expect(ALTERNATE_BOSS_CONFIG["cluckhorn-colossus"]).toMatchObject({
idle: "Idle", move: "Scuttle", attack: "BeakRend", special: "FurnaceBurst", death: "Death",
});
expect(ALTERNATE_BOSS_CONFIG["cluckhorn-colossus"].url).toContain("brassbeak-basilisk");
expect(ALTERNATE_BOSS_CONFIG["mirelord-frog"]).toMatchObject({
idle: "Idle", move: "BurrowRush", attack: "RootPummel", special: "SporeEruption", death: "Death",
});
expect(ALTERNATE_BOSS_CONFIG["mirelord-frog"].url).toContain("bogbell-myconid");
});
}); });
+9 -5
View File
@@ -112,8 +112,8 @@ const BOSS_SEEDS: Record<BossId, BossSeed> = {
summary: "Gallops through charged lanes before crashing onto the marked healer.", mechanicIds: ["bull-charge", "crushing-pounce", "stormfall"], maxHp: 480, archetype: "bull", summary: "Gallops through charged lanes before crashing onto the marked healer.", mechanicIds: ["bull-charge", "crushing-pounce", "stormfall"], maxHp: 480, archetype: "bull",
}, },
"cluckhorn-colossus": { "cluckhorn-colossus": {
name: "Cluckhorn Colossus", title: "The Roostbreaker", icon: "✹", accent: "#f0b85d", name: "Brassbeak Basilisk", title: "The Furnace Nest", icon: "✹", accent: "#dba33e",
summary: "Stampedes sideways and drops cracking shell bursts on spread targets.", mechanicIds: ["sidewinder-rush", "crushing-tide", "meteor-spread"], maxHp: 475, archetype: "crab", summary: "Scuttles through lateral lanes and vents furnace bursts on spread targets.", mechanicIds: ["sidewinder-rush", "crushing-tide", "meteor-spread"], maxHp: 475, archetype: "crab",
}, },
"ashwing-demon": { "ashwing-demon": {
name: "Ashwing", title: "The Cinder Choir", icon: "♠", accent: "#df665d", name: "Ashwing", title: "The Cinder Choir", icon: "♠", accent: "#df665d",
@@ -132,8 +132,8 @@ const BOSS_SEEDS: Record<BossId, BossSeed> = {
summary: "Ricochets across the arena and leaves fire at every landing.", mechanicIds: ["ricochet-rush", "meteor-slam", "ember-brand"], maxHp: 465, archetype: "ricochet", summary: "Ricochets across the arena and leaves fire at every landing.", mechanicIds: ["ricochet-rush", "meteor-slam", "ember-brand"], maxHp: 465, archetype: "ricochet",
}, },
"mirelord-frog": { "mirelord-frog": {
name: "Mirelord", title: "The Drowned Bell", icon: "●", accent: "#73c96b", name: "Bogbell Myconid", title: "The Drowned Bell", icon: "●", accent: "#8fdc69",
summary: "Dives below the mire before erupting through timed bog zones.", mechanicIds: ["burrow-rush", "hourglass-eruption", "hollow-collapse"], maxHp: 490, archetype: "burrower", summary: "Roots below the mire before erupting through timed spore blooms.", mechanicIds: ["burrow-rush", "hourglass-eruption", "hollow-collapse"], maxHp: 490, archetype: "burrower",
}, },
"stonebreaker-giant": { "stonebreaker-giant": {
name: "Stonebreaker", title: "The Walking Crag", icon: "▰", accent: "#c89563", name: "Stonebreaker", title: "The Walking Crag", icon: "▰", accent: "#c89563",
@@ -191,6 +191,10 @@ const BOSS_SEEDS: Record<BossId, BossSeed> = {
name: "Rimeclaw", title: "The Frozen Duel", icon: "✥", accent: "#75bfe8", name: "Rimeclaw", title: "The Frozen Duel", icon: "✥", accent: "#75bfe8",
summary: "Sidesteps between frost strikes before forming a lethal ice cross.", mechanicIds: ["elemental-beam", "guardian-cross", "memory-sequence"], maxHp: 500, archetype: "duelist", summary: "Sidesteps between frost strikes before forming a lethal ice cross.", mechanicIds: ["elemental-beam", "guardian-cross", "memory-sequence"], maxHp: 500, archetype: "duelist",
}, },
"gravehorn-triceratops": {
name: "Gravehorn", title: "The Fossil Wake", icon: "☠", accent: "#d8c79b",
summary: "Burrows through fossil trails, erupts beneath the party, and releases a radial grave pulse.", mechanicIds: ["burrow-rush", "hourglass-eruption", "destruction-pulse"], maxHp: 540, archetype: "burrower",
},
}; };
/** /**
@@ -220,7 +224,7 @@ export const BOSS_GROUPS: readonly BossGroupDefinition[] = [
}, },
{ {
id: "burrow-eruption", letter: "F", name: "Burrow / Eruption", coreMechanic: "Burrow and Eruption", id: "burrow-eruption", letter: "F", name: "Burrow / Eruption", coreMechanic: "Burrow and Eruption",
bossIds: ["sandglass-scorpion", "mirelord-frog"], archetypes: ["burrower"], bossIds: ["sandglass-scorpion", "mirelord-frog", "gravehorn-triceratops"], archetypes: ["burrower"],
}, },
{ {
id: "scuttle-burst", letter: "G", name: "Scuttle / Burst", coreMechanic: "Scuttle and Burst", id: "scuttle-burst", letter: "G", name: "Scuttle / Burst", coreMechanic: "Scuttle and Burst",
+8
View File
@@ -0,0 +1,8 @@
export const BOSS_DEATH_HOLD_SECONDS = 2.5;
export const BOSS_DEATH_FADE_SECONDS = 0.75;
export const BOSS_DEATH_DESPAWN_SECONDS = BOSS_DEATH_HOLD_SECONDS + BOSS_DEATH_FADE_SECONDS;
export function bossDeathOpacity(elapsedSeconds: number) {
if (elapsedSeconds <= BOSS_DEATH_HOLD_SECONDS) return 1;
return Math.max(0, 1 - (elapsedSeconds - BOSS_DEATH_HOLD_SECONDS) / BOSS_DEATH_FADE_SECONDS);
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { BOSS_HAZARD_VFX, bossHazardVfxProfile, bossHazardVfxSeed } from "./bossHazardVisuals";
import type { CircleHazardKind } from "./types";
const HAZARD_KINDS: readonly CircleHazardKind[] = [
"venom_pool",
"skyfall",
"quake",
"lava_pool",
"stinger_eruption",
"hourglass",
"tidal_burst",
"soul_rift",
"crownfall",
"royal_shockwave",
];
describe("boss hazard visuals", () => {
it("defines a bounded effect profile for every circular hazard kind", () => {
expect(Object.keys(BOSS_HAZARD_VFX).sort()).toEqual([...HAZARD_KINDS].sort());
for (const kind of HAZARD_KINDS) {
const profile = bossHazardVfxProfile(kind);
expect(profile.particleCount).toBeGreaterThanOrEqual(6);
expect(profile.particleCount).toBeLessThanOrEqual(9);
expect(profile.height).toBeGreaterThan(0);
expect(profile.warningColor).not.toBe(profile.highlightColor);
}
});
it("uses shape and motion, not color alone, to distinguish common attacks", () => {
expect(bossHazardVfxProfile("venom_pool")).toMatchObject({ motion: "bubble", shape: "orb" });
expect(bossHazardVfxProfile("stinger_eruption")).toMatchObject({ motion: "spike", shape: "spike" });
expect(bossHazardVfxProfile("royal_shockwave")).toMatchObject({ motion: "shockwave", shape: "crystal" });
expect(bossHazardVfxProfile("soul_rift")).toMatchObject({ motion: "orbit", shape: "orb" });
});
it("creates stable normalized seeds without runtime randomness", () => {
expect(bossHazardVfxSeed("meteor-12")).toBe(bossHazardVfxSeed("meteor-12"));
expect(bossHazardVfxSeed("meteor-12")).not.toBe(bossHazardVfxSeed("meteor-13"));
expect(bossHazardVfxSeed("meteor-12")).toBeGreaterThanOrEqual(0);
expect(bossHazardVfxSeed("meteor-12")).toBeLessThanOrEqual(1);
});
});
+146
View File
@@ -0,0 +1,146 @@
import type { CircleHazardKind } from "./types";
export type BossHazardVfxMotion = "bubble" | "fall" | "flame" | "orbit" | "shockwave" | "spike" | "surge";
export type BossHazardVfxShape = "crystal" | "orb" | "spike";
export interface BossHazardVfxProfile {
readonly warningColor: string;
readonly activeColor: string;
readonly highlightColor: string;
readonly motion: BossHazardVfxMotion;
readonly shape: BossHazardVfxShape;
readonly particleCount: number;
readonly height: number;
readonly spinSpeed: number;
readonly columnOpacity: number;
}
/**
* Rendering-only identity for circular hazards. Combat rules remain in boss
* mechanics; renderers can project this profile without branching on boss id.
*/
export const BOSS_HAZARD_VFX: Record<CircleHazardKind, BossHazardVfxProfile> = {
venom_pool: {
warningColor: "#9a54d6",
activeColor: "#6fca45",
highlightColor: "#d6ff7d",
motion: "bubble",
shape: "orb",
particleCount: 7,
height: 0.8,
spinSpeed: 0.35,
columnOpacity: 0,
},
skyfall: {
warningColor: "#ff4d42",
activeColor: "#ff792e",
highlightColor: "#ffe0a1",
motion: "fall",
shape: "crystal",
particleCount: 6,
height: 5.2,
spinSpeed: 0.55,
columnOpacity: 0.13,
},
quake: {
warningColor: "#ff5549",
activeColor: "#d64226",
highlightColor: "#ffd08a",
motion: "shockwave",
shape: "crystal",
particleCount: 8,
height: 0.75,
spinSpeed: 0.18,
columnOpacity: 0,
},
lava_pool: {
warningColor: "#ff6a31",
activeColor: "#ff3b18",
highlightColor: "#ffd05a",
motion: "flame",
shape: "spike",
particleCount: 7,
height: 1.25,
spinSpeed: 0.28,
columnOpacity: 0.04,
},
stinger_eruption: {
warningColor: "#ff4b42",
activeColor: "#cf2442",
highlightColor: "#ffd2b3",
motion: "spike",
shape: "spike",
particleCount: 7,
height: 1.75,
spinSpeed: 0.12,
columnOpacity: 0,
},
hourglass: {
warningColor: "#ed8b3a",
activeColor: "#c16a28",
highlightColor: "#ffe3a3",
motion: "orbit",
shape: "crystal",
particleCount: 8,
height: 2.2,
spinSpeed: 1.35,
columnOpacity: 0.1,
},
tidal_burst: {
warningColor: "#36cae2",
activeColor: "#178fc6",
highlightColor: "#c9fbff",
motion: "surge",
shape: "orb",
particleCount: 8,
height: 1.65,
spinSpeed: 0.75,
columnOpacity: 0.08,
},
soul_rift: {
warningColor: "#aa78ff",
activeColor: "#6f42cf",
highlightColor: "#ebd8ff",
motion: "orbit",
shape: "orb",
particleCount: 8,
height: 2.45,
spinSpeed: 1.65,
columnOpacity: 0.14,
},
crownfall: {
warningColor: "#e7c94d",
activeColor: "#d95331",
highlightColor: "#fff1a8",
motion: "fall",
shape: "crystal",
particleCount: 7,
height: 5.6,
spinSpeed: -0.65,
columnOpacity: 0.15,
},
royal_shockwave: {
warningColor: "#edcb55",
activeColor: "#c94631",
highlightColor: "#fff0a6",
motion: "shockwave",
shape: "crystal",
particleCount: 9,
height: 0.85,
spinSpeed: -0.22,
columnOpacity: 0,
},
};
export function bossHazardVfxProfile(kind: CircleHazardKind) {
return BOSS_HAZARD_VFX[kind];
}
export function bossHazardVfxSeed(id: string) {
let hash = 2166136261;
for (let index = 0; index < id.length; index += 1) {
hash ^= id.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) / 0xffffffff;
}
+7 -4
View File
@@ -9,7 +9,7 @@ import {
} from "./bosses/mechanicPool"; } from "./bosses/mechanicPool";
import { createBaseMotion } from "./bosses/shared"; import { createBaseMotion } from "./bosses/shared";
import type { BossMechanicContext, BossMechanicResult } from "./bosses/types"; import type { BossMechanicContext, BossMechanicResult } from "./bosses/types";
import type { BossId, BossMotionState, BossState, MemberId, WorldPosition } from "./types"; import type { BossId, BossMotionState, BossState, Debuff, MemberId, WorldPosition } from "./types";
export { BOSS_MECHANIC_REGISTRY, BULL_CHARGE, BULL_POUNCE }; export { BOSS_MECHANIC_REGISTRY, BULL_CHARGE, BULL_POUNCE };
@@ -38,14 +38,17 @@ export function advanceBossMechanics(context: BossMechanicContext): BossMechanic
} }
export function handleBossDispel( export function handleBossDispel(
_bossId: BossId, bossId: BossId,
motion: BossMotionState, motion: BossMotionState,
memberId: MemberId, memberId: MemberId,
position: WorldPosition, position: WorldPosition,
time: number, time: number,
debuffNames: readonly string[], debuffs: readonly Debuff[],
) { ) {
return handleMechanicDispel(motion, memberId, position, time, debuffNames); if (!BOSS_DEFINITIONS[bossId].mechanicIds.includes("venom-purge")) {
return { motion, message: "Harmful magic removed." };
}
return handleMechanicDispel(motion, memberId, position, time, debuffs);
} }
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) { export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
+5 -2
View File
@@ -73,8 +73,8 @@ export const BOSS_ROOMS = {
"stormwool-alpaca": room("thunder-fleece", "The Thunder Fleece", "Wind-scoured highland", "storm", { "stormwool-alpaca": room("thunder-fleece", "The Thunder Fleece", "Wind-scoured highland", "storm", {
background: "#071321", fog: "#274d71", sky: "#a4d5ff", ground: "#071019", floorColor: "#233f5d", wallColor: "#365676", accent: "#9bd3ff", accentSecondary: "#eef9ff", wallHeight: 2.8, background: "#071321", fog: "#274d71", sky: "#a4d5ff", ground: "#071019", floorColor: "#233f5d", wallColor: "#365676", accent: "#9bd3ff", accentSecondary: "#eef9ff", wallHeight: 2.8,
}), }),
"cluckhorn-colossus": room("roostbreaker-yard", "The Roostbreaker Yard", "Ruinous farmstead", "wilds", { "cluckhorn-colossus": room("furnace-nest", "The Furnace Nest", "Overgrown brass hatchery", "junkyard", {
background: "#171308", fog: "#49542a", sky: "#d8c675", ground: "#0b1207", floorColor: "#37421e", wallColor: "#554626", accent: "#f1b95c", accentSecondary: "#b8d065", wallHeight: 2.6, background: "#081615", fog: "#284c45", sky: "#6edccb", ground: "#07100d", floorColor: "#263b32", wallColor: "#5a4123", accent: "#dfaa43", accentSecondary: "#62e3d1", wallHeight: 3.2,
}), }),
"ashwing-demon": room("cinder-choir", "The Cinder Choir", "Ashen cathedral", "cinder", { "ashwing-demon": room("cinder-choir", "The Cinder Choir", "Ashen cathedral", "cinder", {
background: "#1b0608", fog: "#511721", sky: "#ee7566", ground: "#120407", floorColor: "#4b1720", wallColor: "#592029", accent: "#ef625c", accentSecondary: "#ffb16e", wallHeight: 5.2, background: "#1b0608", fog: "#511721", sky: "#ee7566", ground: "#120407", floorColor: "#4b1720", wallColor: "#592029", accent: "#ef625c", accentSecondary: "#ffb16e", wallHeight: 5.2,
@@ -133,6 +133,9 @@ export const BOSS_ROOMS = {
"rimeclaw-yeti": room("frozen-duel", "The Frozen Duel", "Frozen mirror palace", "frost", { "rimeclaw-yeti": room("frozen-duel", "The Frozen Duel", "Frozen mirror palace", "frost", {
background: "#07162a", fog: "#285a83", sky: "#8fd8ff", ground: "#030c19", floorColor: "#17476d", wallColor: "#2d6691", accent: "#74c8f4", accentSecondary: "#c1f6ff", wallHeight: 4.9, background: "#07162a", fog: "#285a83", sky: "#8fd8ff", ground: "#030c19", floorColor: "#17476d", wallColor: "#2d6691", accent: "#74c8f4", accentSecondary: "#c1f6ff", wallHeight: 4.9,
}), }),
"gravehorn-triceratops": room("fossil-wake", "The Fossil Wake", "Buried ossuary", "reliquary", {
background: "#100d08", fog: "#40382a", sky: "#d8c79b", ground: "#090704", floorColor: "#332b20", wallColor: "#4c4030", accent: "#d8c79b", accentSecondary: "#8eb9a6", wallHeight: 4.5,
}),
} as const satisfies Record<BossId, BossRoomDefinition>; } as const satisfies Record<BossId, BossRoomDefinition>;
export function bossRoomFor(bossId: BossId): BossRoomDefinition { export function bossRoomFor(bossId: BossId): BossRoomDefinition {
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { canAddBossToEncounter, normalizeEncounterBossIds } from "./bossSelection";
describe("boss encounter selection", () => {
it("allows only one Memory Sequence boss in an encounter", () => {
expect(canAddBossToEncounter(["sandglass-scorpion"], "crystal-bat-matriarch")).toBe(false);
expect(canAddBossToEncounter(["sandglass-scorpion"], "rimeclaw-yeti")).toBe(false);
expect(canAddBossToEncounter(["sandglass-scorpion"], "bulldrome")).toBe(true);
const normalized = normalizeEncounterBossIds([
"sandglass-scorpion",
"crystal-bat-matriarch",
"bulldrome",
"rimeclaw-yeti",
]);
expect(normalized).toEqual(["sandglass-scorpion", "bulldrome"]);
expect(normalized.filter((bossId) => BOSS_DEFINITIONS[bossId].mechanicIds.includes("memory-sequence"))).toHaveLength(1);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { BOSS_DEFINITIONS } from "./bossCatalog";
import type { BossId, BossMechanicId } from "./types";
/** Mechanics that cannot be resolved safely when two bosses own them at once. */
export const ENCOUNTER_EXCLUSIVE_MECHANICS: ReadonlySet<BossMechanicId> = new Set(["memory-sequence"]);
export function canAddBossToEncounter(selectedBossIds: readonly BossId[], candidateBossId: BossId) {
if (selectedBossIds.includes(candidateBossId)) return false;
const candidateMechanics = BOSS_DEFINITIONS[candidateBossId].mechanicIds;
for (const mechanicId of candidateMechanics) {
if (!ENCOUNTER_EXCLUSIVE_MECHANICS.has(mechanicId)) continue;
if (selectedBossIds.some((bossId) => BOSS_DEFINITIONS[bossId].mechanicIds.includes(mechanicId))) return false;
}
return true;
}
export function normalizeEncounterBossIds(requestedBossIds: readonly BossId[], limit = 3): BossId[] {
const selected: BossId[] = [];
for (const bossId of requestedBossIds) {
if (selected.length >= limit) break;
if (canAddBossToEncounter(selected, bossId)) selected.push(bossId);
}
return selected.length ? selected : ["bulldrome"];
}
+17 -7
View File
@@ -4,6 +4,7 @@ export type AlternateBossKind = Exclude<BossId, "bulldrome">;
export interface AlternateBossConfig { export interface AlternateBossConfig {
url: string; url: string;
optimizedUrl?: string;
scale: number; scale: number;
idle: string; idle: string;
move: string; move: string;
@@ -19,6 +20,10 @@ export const BULL_URL = new URL("../assets/game/models/claudecraft/creatures/bul
const SANDGLASS_URL = new URL("../assets/game/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href; const SANDGLASS_URL = new URL("../assets/game/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href;
const CRYSTAL_BAT_MATRIARCH_URL = new URL("../assets/game/models/original/bosses/crystal-bat-matriarch/crystal-bat-matriarch.glb", import.meta.url).href; const CRYSTAL_BAT_MATRIARCH_URL = new URL("../assets/game/models/original/bosses/crystal-bat-matriarch/crystal-bat-matriarch.glb", import.meta.url).href;
const BRASSBEAK_BASILISK_URL = new URL("../assets/game/models/original/bosses/brassbeak-basilisk/brassbeak-basilisk.glb", import.meta.url).href;
const BOGBELL_MYCONID_URL = new URL("../assets/game/models/original/bosses/bogbell-myconid/bogbell-myconid.glb", import.meta.url).href;
const GRAVEHORN_URL = new URL("../assets/game/models/sketchfab-opensource/gravehorn-triceratops.glb", import.meta.url).href;
const GRAVEHORN_OPTIMIZED_URL = new URL("../assets/game/models/sketchfab-opensource/gravehorn-triceratops-uastc.glb", import.meta.url).href;
const CRAGCLAW_URL = new URL("../assets/game/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href; const CRAGCLAW_URL = new URL("../assets/game/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href;
const MOURNVEIL_URL = new URL("../assets/game/models/claudecraft/creatures/ghost.glb", import.meta.url).href; const MOURNVEIL_URL = new URL("../assets/game/models/claudecraft/creatures/ghost.glb", import.meta.url).href;
const CROWNSHARD_URL = new URL("../assets/game/models/claudecraft/creatures/golelingevolved.glb", import.meta.url).href; const CROWNSHARD_URL = new URL("../assets/game/models/claudecraft/creatures/golelingevolved.glb", import.meta.url).href;
@@ -30,14 +35,15 @@ const CLAUDE_BOSS_URLS: Record<Exclude<BossId,
| "mournveil-ghost" | "mournveil-ghost"
| "crownshard-golem" | "crownshard-golem"
| "crystal-bat-matriarch" | "crystal-bat-matriarch"
| "cluckhorn-colossus"
| "mirelord-frog"
| "gravehorn-triceratops"
>, string> = { >, string> = {
"stormwool-alpaca": new URL("../assets/game/models/claudecraft/creatures/alpaca.glb", import.meta.url).href, "stormwool-alpaca": new URL("../assets/game/models/claudecraft/creatures/alpaca.glb", import.meta.url).href,
"cluckhorn-colossus": new URL("../assets/game/models/claudecraft/creatures/chicken_cow.glb", import.meta.url).href,
"ashwing-demon": new URL("../assets/game/models/claudecraft/creatures/demon.glb", import.meta.url).href, "ashwing-demon": new URL("../assets/game/models/claudecraft/creatures/demon.glb", import.meta.url).href,
"riftclaw-demon": new URL("../assets/game/models/claudecraft/creatures/demonalt.glb", import.meta.url).href, "riftclaw-demon": new URL("../assets/game/models/claudecraft/creatures/demonalt.glb", import.meta.url).href,
"tempestscale-dragon": new URL("../assets/game/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href, "tempestscale-dragon": new URL("../assets/game/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href,
emberfox: new URL("../assets/game/models/claudecraft/creatures/fox.glb", import.meta.url).href, emberfox: new URL("../assets/game/models/claudecraft/creatures/fox.glb", import.meta.url).href,
"mirelord-frog": new URL("../assets/game/models/claudecraft/creatures/frog.glb", import.meta.url).href,
"stonebreaker-giant": new URL("../assets/game/models/claudecraft/creatures/giant.glb", import.meta.url).href, "stonebreaker-giant": new URL("../assets/game/models/claudecraft/creatures/giant.glb", import.meta.url).href,
"glub-sovereign": new URL("../assets/game/models/claudecraft/creatures/glubevolved.glb", import.meta.url).href, "glub-sovereign": new URL("../assets/game/models/claudecraft/creatures/glubevolved.glb", import.meta.url).href,
"scrapking-goblin": new URL("../assets/game/models/claudecraft/creatures/goblin.glb", import.meta.url).href, "scrapking-goblin": new URL("../assets/game/models/claudecraft/creatures/goblin.glb", import.meta.url).href,
@@ -61,12 +67,13 @@ export const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfi
"crownshard-golem": { url: CROWNSHARD_URL, scale: 1.15, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#e0bd45", rotationOffset: 0, floating: true }, "crownshard-golem": { url: CROWNSHARD_URL, scale: 1.15, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#e0bd45", rotationOffset: 0, floating: true },
"crystal-bat-matriarch": { url: CRYSTAL_BAT_MATRIARCH_URL, scale: 0.828, idle: "Idle", move: "Swoop", attack: "SonicPulse", special: "MirrorShatter", death: "Death", light: "#8eeaff", rotationOffset: 0, floating: true }, "crystal-bat-matriarch": { url: CRYSTAL_BAT_MATRIARCH_URL, scale: 0.828, idle: "Idle", move: "Swoop", attack: "SonicPulse", special: "MirrorShatter", death: "Death", light: "#8eeaff", rotationOffset: 0, floating: true },
"stormwool-alpaca": { url: CLAUDE_BOSS_URLS["stormwool-alpaca"], scale: 0.72, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#8fc7ff", rotationOffset: 0 }, "stormwool-alpaca": { url: CLAUDE_BOSS_URLS["stormwool-alpaca"], scale: 0.72, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#8fc7ff", rotationOffset: 0 },
"cluckhorn-colossus": { url: CLAUDE_BOSS_URLS["cluckhorn-colossus"], scale: 2.2, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#f0b85d", rotationOffset: 0 }, // IDs remain stable so existing saves and trophies keep working after visual replacement.
"cluckhorn-colossus": { url: BRASSBEAK_BASILISK_URL, scale: 0.75, idle: "Idle", move: "Scuttle", attack: "BeakRend", special: "FurnaceBurst", death: "Death", light: "#5cebd7", rotationOffset: 0 },
"ashwing-demon": { url: CLAUDE_BOSS_URLS["ashwing-demon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#df665d", rotationOffset: 0, floating: true }, "ashwing-demon": { url: CLAUDE_BOSS_URLS["ashwing-demon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#df665d", rotationOffset: 0, floating: true },
"riftclaw-demon": { url: CLAUDE_BOSS_URLS["riftclaw-demon"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#d45cff", rotationOffset: 0 }, "riftclaw-demon": { url: CLAUDE_BOSS_URLS["riftclaw-demon"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#d45cff", rotationOffset: 0 },
"tempestscale-dragon": { url: CLAUDE_BOSS_URLS["tempestscale-dragon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#5fc8e8", rotationOffset: 0, floating: true }, "tempestscale-dragon": { url: CLAUDE_BOSS_URLS["tempestscale-dragon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#5fc8e8", rotationOffset: 0, floating: true },
emberfox: { url: CLAUDE_BOSS_URLS.emberfox, scale: 1, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#ff7b45", rotationOffset: 0 }, emberfox: { url: CLAUDE_BOSS_URLS.emberfox, scale: 1, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#ff7b45", rotationOffset: 0 },
"mirelord-frog": { url: CLAUDE_BOSS_URLS["mirelord-frog"], scale: 1.4, idle: "Idle", move: "Run", attack: "Punch", special: "Jump", death: "Death", light: "#73c96b", rotationOffset: 0 }, "mirelord-frog": { url: BOGBELL_MYCONID_URL, scale: 0.78, idle: "Idle", move: "BurrowRush", attack: "RootPummel", special: "SporeEruption", death: "Death", light: "#8df06b", rotationOffset: 0 },
"stonebreaker-giant": { url: CLAUDE_BOSS_URLS["stonebreaker-giant"], scale: 1, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#c89563", rotationOffset: 0 }, "stonebreaker-giant": { url: CLAUDE_BOSS_URLS["stonebreaker-giant"], scale: 1, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#c89563", rotationOffset: 0 },
"glub-sovereign": { url: CLAUDE_BOSS_URLS["glub-sovereign"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#6ce0b8", rotationOffset: 0, floating: true }, "glub-sovereign": { url: CLAUDE_BOSS_URLS["glub-sovereign"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#6ce0b8", rotationOffset: 0, floating: true },
"scrapking-goblin": { url: CLAUDE_BOSS_URLS["scrapking-goblin"], scale: 1.5, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#d7a34b", rotationOffset: 0 }, "scrapking-goblin": { url: CLAUDE_BOSS_URLS["scrapking-goblin"], scale: 1.5, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#d7a34b", rotationOffset: 0 },
@@ -77,12 +84,15 @@ export const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfi
"thorncrown-stag": { url: CLAUDE_BOSS_URLS["thorncrown-stag"], scale: 0.85, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#7fc46b", rotationOffset: 0 }, "thorncrown-stag": { url: CLAUDE_BOSS_URLS["thorncrown-stag"], scale: 0.85, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#7fc46b", rotationOffset: 0 },
"sky-totem": { url: CLAUDE_BOSS_URLS["sky-totem"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#69d4d1", rotationOffset: 0, floating: true }, "sky-totem": { url: CLAUDE_BOSS_URLS["sky-totem"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#69d4d1", rotationOffset: 0, floating: true },
"razorcrest-raptor": { url: CLAUDE_BOSS_URLS["razorcrest-raptor"], scale: 1.1, idle: "Velociraptor_Idle", move: "Velociraptor_Run", attack: "Velociraptor_Attack", special: "Velociraptor_Jump", death: "Velociraptor_Death", light: "#d9c45a", rotationOffset: 0 }, "razorcrest-raptor": { url: CLAUDE_BOSS_URLS["razorcrest-raptor"], scale: 1.1, idle: "Velociraptor_Idle", move: "Velociraptor_Run", attack: "Velociraptor_Attack", special: "Velociraptor_Jump", death: "Velociraptor_Death", light: "#d9c45a", rotationOffset: 0 },
"bristlequake-boar": { url: CLAUDE_BOSS_URLS["bristlequake-boar"], scale: 0.475, idle: "Idle_AnimalArmature", move: "Gallop_AnimalArmature", attack: "Attack_Headbutt_AnimalArmature", special: "Attack_Kick_AnimalArmature", death: "Death_AnimalArmature", light: "#d47b45", rotationOffset: 0 }, "bristlequake-boar": { url: CLAUDE_BOSS_URLS["bristlequake-boar"], scale: 0.475, idle: "Idle_AnimalArmature", move: "Gallop_AnimalArmature", attack: "Attack_Headbutt_AnimalArmature", special: "Attack_Kick_AnimalArmature", death: "Dying", light: "#d47b45", rotationOffset: 0 },
"moonfang-wolf": { url: CLAUDE_BOSS_URLS["moonfang-wolf"], scale: 1.05, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#9db9e5", rotationOffset: 0 }, "moonfang-wolf": { url: CLAUDE_BOSS_URLS["moonfang-wolf"], scale: 1.05, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#9db9e5", rotationOffset: 0 },
"frostmaw-yeti": { url: CLAUDE_BOSS_URLS["frostmaw-yeti"], scale: 1.35, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#8ed8ef", rotationOffset: 0 }, "frostmaw-yeti": { url: CLAUDE_BOSS_URLS["frostmaw-yeti"], scale: 1.35, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#8ed8ef", rotationOffset: 0 },
"rimeclaw-yeti": { url: CLAUDE_BOSS_URLS["rimeclaw-yeti"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#75bfe8", rotationOffset: 0 }, "rimeclaw-yeti": { url: CLAUDE_BOSS_URLS["rimeclaw-yeti"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#75bfe8", rotationOffset: 0 },
"gravehorn-triceratops": { url: GRAVEHORN_URL, optimizedUrl: GRAVEHORN_OPTIMIZED_URL, scale: 0.82, idle: "Gravehorn|Idle", move: "Armature|Walk", attack: "Armature|Roar", special: "Armature|RiseUp", death: "Armature|Fall", light: "#ffd9a0", rotationOffset: 0 },
}; };
export function bossVisualUrl(bossId: BossId): string { export function bossVisualUrl(bossId: BossId, optimized = false): string {
return bossId === "bulldrome" ? BULL_URL : ALTERNATE_BOSS_CONFIG[bossId].url; if (bossId === "bulldrome") return BULL_URL;
const config = ALTERNATE_BOSS_CONFIG[bossId];
return optimized ? config.optimizedUrl ?? config.url : config.url;
} }
+18
View File
@@ -141,6 +141,24 @@ describe("shared boss mechanic pool", () => {
expect(bossAnimationCue(started.motion)).toBe("attack"); expect(bossAnimationCue(started.motion)).toBe("attack");
}); });
it("moves a web caster sideways during Binding Web and animates its return home", () => {
const source = context(0);
source.motion.nextMechanicAt = 0;
const started = advanceMechanicLoadout(source, ["binding-web", "venom-purge"]);
expect(bossAnimationCue(started.motion)).toBe("move");
const next = context(0.1);
next.boss = started.boss;
next.motion = started.motion;
next.party = started.party;
const advanced = advanceMechanicLoadout(next, ["binding-web", "venom-purge"]);
expect(advanced.motion.position).not.toEqual(started.motion.position);
advanced.motion.activeMechanicId = null;
advanced.motion.mode = "holding";
expect(bossAnimationCue(advanced.motion)).toBe("move");
});
it("splits soak damage across allies inside its indicator", () => { it("splits soak damage across allies inside its indicator", () => {
const source = context(0); const source = context(0);
const activatesAt = 1.5; const activatesAt = 1.5;
+29 -12
View File
@@ -1,6 +1,6 @@
import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena"; import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry"; import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossAnimationCue, BossMechanicId, BossMotionState, CircleHazard, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, SlashLane, WorldPosition } from "../types"; import type { BossAnimationCue, BossMechanicId, BossMotionState, CircleHazard, Debuff, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createCircleHazard, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared"; import { applyMelee, chooseLivingTarget, cloneMotion, createCircleHazard, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types"; import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
@@ -11,7 +11,7 @@ export const BOSS_MECHANIC_POOL = [
{ id: "cinder-nova", name: "Cinder Nova", instruction: "Heal the party through raidwide damage." }, { id: "cinder-nova", name: "Cinder Nova", instruction: "Heal the party through raidwide damage." },
{ id: "ember-brand", name: "Ember Brand", instruction: "Purify the marked ally." }, { id: "ember-brand", name: "Ember Brand", instruction: "Purify the marked ally." },
{ id: "binding-web", name: "Binding Web", instruction: "Separate the linked allies." }, { id: "binding-web", name: "Binding Web", instruction: "Separate the linked allies." },
{ id: "venom-purge", name: "Venom Purge", instruction: "Move away before cleansing Widow Venom." }, { id: "venom-purge", name: "Venom Purge", instruction: "Move apart, then cleanse Widow Venom before it expires." },
{ id: "storm-breath", name: "Storm Breath", instruction: "Rotate behind the sweeping cone." }, { id: "storm-breath", name: "Storm Breath", instruction: "Rotate behind the sweeping cone." },
{ id: "stormfall", name: "Stormfall", instruction: "Spread before the marked impacts." }, { id: "stormfall", name: "Stormfall", instruction: "Spread before the marked impacts." },
{ id: "elemental-beam", name: "Elemental Beam", instruction: "Clear the glowing lane." }, { id: "elemental-beam", name: "Elemental Beam", instruction: "Clear the glowing lane." },
@@ -110,10 +110,13 @@ export const VENOM_PURGE = {
tickDamage: 5, tickDamage: 5,
castDuration: 2.5, castDuration: 2.5,
poolRadius: 2, poolRadius: 2,
poolDuration: 7, poolArmDelay: 1.25,
poolDamage: 14, poolDuration: 6,
poolDamage: 8,
} as const; } as const;
const BINDING_WEB_SCUTTLE_SPEED = 3.2;
export const MEMORY_SEQUENCE = { export const MEMORY_SEQUENCE = {
sequenceLength: 4, sequenceLength: 4,
flashDuration: 0.9, flashDuration: 0.9,
@@ -942,11 +945,22 @@ const bindingWeb: BossMechanicDefinition = {
runtime.motion.mode = "tethering"; runtime.motion.mode = "tethering";
runtime.motion.tetherIds = [first, second]; runtime.motion.tetherIds = [first, second];
runtime.motion.tetherBreakDistance = 6.8; runtime.motion.tetherBreakDistance = 6.8;
runtime.motion.chargeStart = [...runtime.motion.position];
const scuttleDirection = runtime.motion.mechanicCount % 2 === 0 ? -1 : 1;
runtime.motion.chargeEnd = clampToArena([
ARENA_CENTER[0] + runtime.motion.formationOffsetX + scuttleDirection * 4.2,
ARENA_CENTER[1] - 1.25,
]);
runtime.motion.phaseStartedAt = runtime.context.time; runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + 4.5; runtime.motion.phaseEndsAt = runtime.context.time + 4.5;
runtime.events.push({ at: runtime.context.time, message: `Binding Web links ${memberName(runtime.party, first)} and ${memberName(runtime.party, second)}. Spread apart.`, tone: "danger", pulseKind: "tether", targetId: first }); runtime.events.push({ at: runtime.context.time, message: `Binding Web links ${memberName(runtime.party, first)} and ${memberName(runtime.party, second)}. Spread apart.`, tone: "danger", pulseKind: "tether", targetId: first });
}, },
advance(runtime) { advance(runtime) {
runtime.motion.position = moveToward(
runtime.motion.position,
runtime.motion.chargeEnd,
BINDING_WEB_SCUTTLE_SPEED * runtime.context.delta,
);
const [first, second] = runtime.motion.tetherIds; const [first, second] = runtime.motion.tetherIds;
if (!first || !second || distance(runtime.context.partyPositions[first], runtime.context.partyPositions[second]) >= runtime.motion.tetherBreakDistance) { if (!first || !second || distance(runtime.context.partyPositions[first], runtime.context.partyPositions[second]) >= runtime.motion.tetherBreakDistance) {
runtime.events.push({ at: runtime.context.time, message: "Binding Web snaps. Formation is free.", pulseKind: "tether" }); runtime.events.push({ at: runtime.context.time, message: "Binding Web snaps. Formation is free.", pulseKind: "tether" });
@@ -960,19 +974,19 @@ const bindingWeb: BossMechanicDefinition = {
runtime.events.push({ at: runtime.context.time, message: "Binding Web constricts and roots its targets.", tone: "danger", pulseKind: "tether" }); runtime.events.push({ at: runtime.context.time, message: "Binding Web constricts and roots its targets.", tone: "danger", pulseKind: "tether" });
finishMechanic(runtime, 4); finishMechanic(runtime, 4);
}, },
animationCue: () => "attack", animationCue: (motion) => distance(motion.position, motion.chargeEnd) > 0.08 ? "move" : "attack",
}; };
bindingWeb.upcoming = (motion, time) => defaultUpcoming(bindingWeb, motion, time); bindingWeb.upcoming = (motion, time) => defaultUpcoming(bindingWeb, motion, time);
const venomPurge = instantTimedDefinition("venom-purge", 4, (runtime) => { const venomPurge = instantTimedDefinition("venom-purge", 4, (runtime) => {
const targets = [0, 1].map((offset) => chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount + offset)); const targets = [0, 1].map((offset) => chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount + offset));
runtime.motion.mode = "venom_cast"; runtime.motion.mode = "venom_cast";
runtime.motion.phaseEndsAt = runtime.context.time + VENOM_PURGE.castDuration; runtime.motion.phaseEndsAt = runtime.context.time + VENOM_PURGE.castDuration;
runtime.party = runtime.party.map((member) => targets.includes(member.id) ? { runtime.party = runtime.party.map((member) => targets.includes(member.id) ? {
...member, ...member,
debuffs: [...member.debuffs, { id: `widow-venom-${runtime.motion.mechanicCount}-${member.id}`, name: "Widow Venom", expiresAt: runtime.context.time + VENOM_PURGE.duration, nextTickAt: runtime.context.time + 1, tickDamage: VENOM_PURGE.tickDamage }], debuffs: [...member.debuffs, { id: `widow-venom-${runtime.motion.bossId}-${runtime.motion.mechanicCount}-${member.id}`, name: "Widow Venom", expiresAt: runtime.context.time + VENOM_PURGE.duration, nextTickAt: runtime.context.time + 1, tickDamage: VENOM_PURGE.tickDamage, sourceBossId: runtime.motion.bossId }],
} : member); } : member);
runtime.events.push({ at: runtime.context.time, message: "Venom Purge applies Widow Venom. Move away before cleansing.", tone: "danger", pulseKind: "venom", targetId: targets[0] }); runtime.events.push({ at: runtime.context.time, message: "Venom Purge applies Widow Venom. Move apart, then cleanse.", tone: "danger", pulseKind: "venom", targetId: targets[0] });
}); });
const stormBreath: BossMechanicDefinition = { const stormBreath: BossMechanicDefinition = {
@@ -1264,12 +1278,15 @@ export function upcomingLoadoutMechanic(
} }
export function bossAnimationCue(motion: BossMotionState): BossAnimationCue { export function bossAnimationCue(motion: BossMotionState): BossAnimationCue {
return motion.activeMechanicId ? BOSS_MECHANIC_REGISTRY[motion.activeMechanicId].animationCue(motion) : "idle"; if (motion.activeMechanicId) return BOSS_MECHANIC_REGISTRY[motion.activeMechanicId].animationCue(motion);
const homeDx = motion.position[0] - (ARENA_CENTER[0] + motion.formationOffsetX);
const homeDz = motion.position[1] - ARENA_CENTER[1];
return Math.hypot(homeDx, homeDz) > 0.08 ? "move" : "idle";
} }
export function dropVenomPool(motion: BossMotionState, memberId: MemberId, center: WorldPosition, time: number) { export function dropVenomPool(motion: BossMotionState, memberId: MemberId, center: WorldPosition, time: number) {
const next = cloneMotion(motion); const next = cloneMotion(motion);
next.hazards.push(createCircleHazard({ id: `venom-pool-${memberId}-${time.toFixed(2)}`, kind: "venom_pool", center, radius: VENOM_PURGE.poolRadius, activatesAt: time + 0.25, duration: VENOM_PURGE.poolDuration, damage: VENOM_PURGE.poolDamage, tickInterval: 1 })); next.hazards.push(createCircleHazard({ id: `venom-pool-${memberId}-${time.toFixed(2)}`, kind: "venom_pool", center, radius: VENOM_PURGE.poolRadius, activatesAt: time + VENOM_PURGE.poolArmDelay, duration: VENOM_PURGE.poolDuration, damage: VENOM_PURGE.poolDamage, tickInterval: 1 }));
return next; return next;
} }
@@ -1278,9 +1295,9 @@ export function handleMechanicDispel(
memberId: MemberId, memberId: MemberId,
position: WorldPosition, position: WorldPosition,
time: number, time: number,
debuffNames: readonly string[], debuffs: readonly Debuff[],
) { ) {
if (debuffNames.includes("Widow Venom")) { if (debuffs.some((debuff) => debuff.name === "Widow Venom" && debuff.sourceBossId === motion.bossId)) {
return { return {
motion: dropVenomPool(motion, memberId, position, time), motion: dropVenomPool(motion, memberId, position, time),
message: "Widow Venom purged. A venom pool forms where the target stood.", message: "Widow Venom purged. A venom pool forms where the target stood.",
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { ABILITY_BY_CONTROLLER_BUTTON, ABILITY_CONTROLLER_BINDINGS } from "./controllerBindings";
describe("PlayStation controller ability bindings", () => {
it("keeps prompts aligned with standard gamepad button indices", () => {
expect(ABILITY_BY_CONTROLLER_BUTTON).toEqual({
0: "purify",
1: "shield",
2: "mend",
3: "renew",
4: "radiance",
5: "barrier",
});
expect(Object.values(ABILITY_CONTROLLER_BINDINGS).map(({ glyph }) => glyph)).toEqual([
"□",
"△",
"○",
"✕",
"L1",
"R1",
]);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
import type { AbilityId } from "./types";
interface AbilityControllerBinding {
buttonIndex: number;
glyph: string;
}
export const ABILITY_CONTROLLER_BINDINGS: Record<AbilityId, AbilityControllerBinding> = {
mend: { buttonIndex: 2, glyph: DEFAULT_CONTROLLER_GLYPHS.faceLeft },
renew: { buttonIndex: 3, glyph: DEFAULT_CONTROLLER_GLYPHS.faceTop },
shield: { buttonIndex: 1, glyph: DEFAULT_CONTROLLER_GLYPHS.faceRight },
purify: { buttonIndex: 0, glyph: DEFAULT_CONTROLLER_GLYPHS.faceBottom },
radiance: { buttonIndex: 4, glyph: DEFAULT_CONTROLLER_GLYPHS.leftShoulder },
barrier: { buttonIndex: 5, glyph: DEFAULT_CONTROLLER_GLYPHS.rightShoulder },
};
export const ABILITY_BY_CONTROLLER_BUTTON = Object.fromEntries(
Object.entries(ABILITY_CONTROLLER_BINDINGS).map(([abilityId, binding]) => [binding.buttonIndex, abilityId]),
) as Partial<Record<number, AbilityId>>;
+7 -6
View File
@@ -1,12 +1,13 @@
import type { AbilityDefinition, AbilityId, HealerClassDefinition, HealerClassId, InventoryItem } from "./types"; import type { AbilityDefinition, AbilityId, HealerClassDefinition, HealerClassId, InventoryItem } from "./types";
import { ABILITY_CONTROLLER_BINDINGS } from "./controllerBindings";
const bindings: Record<AbilityId, Pick<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">> = { const bindings: Record<AbilityId, Pick<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">> = {
mend: { id: "mend", key: "1", gamepad: "X", targeting: "ally" }, mend: { id: "mend", key: "1", gamepad: ABILITY_CONTROLLER_BINDINGS.mend.glyph, targeting: "ally" },
renew: { id: "renew", key: "2", gamepad: "Y", targeting: "ally" }, renew: { id: "renew", key: "2", gamepad: ABILITY_CONTROLLER_BINDINGS.renew.glyph, targeting: "ally" },
shield: { id: "shield", key: "3", gamepad: "B", targeting: "ally" }, shield: { id: "shield", key: "3", gamepad: ABILITY_CONTROLLER_BINDINGS.shield.glyph, targeting: "ally" },
purify: { id: "purify", key: "4", gamepad: "A", targeting: "ally" }, purify: { id: "purify", key: "4", gamepad: ABILITY_CONTROLLER_BINDINGS.purify.glyph, targeting: "ally" },
radiance: { id: "radiance", key: "5", gamepad: "LB", targeting: "party" }, radiance: { id: "radiance", key: "5", gamepad: ABILITY_CONTROLLER_BINDINGS.radiance.glyph, targeting: "party" },
barrier: { id: "barrier", key: "6", gamepad: "RB", targeting: "party" }, barrier: { id: "barrier", key: "6", gamepad: ABILITY_CONTROLLER_BINDINGS.barrier.glyph, targeting: "party" },
}; };
function ability(id: AbilityId, definition: Omit<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">): AbilityDefinition { function ability(id: AbilityId, definition: Omit<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">): AbilityDefinition {
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { PARTY_ABILITY_LOADOUTS, PARTY_ABILITY_NAMES } from "./partyCombat";
import { PARTY_ATTACK_VFX, partyAttackVfxProfile } from "./partyAttackVisuals";
describe("party attack visuals", () => {
it("covers every party combat ability", () => {
expect(Object.keys(PARTY_ATTACK_VFX).sort()).toEqual(Object.keys(PARTY_ABILITY_NAMES).sort());
});
it("keeps every profile attached to its loadout owner", () => {
for (const [ownerId, abilityIds] of Object.entries(PARTY_ABILITY_LOADOUTS)) {
for (const abilityId of abilityIds) {
expect(partyAttackVfxProfile(abilityId).ownerId).toBe(ownerId);
}
}
});
it("gives every ranged attack a visible trail and every melee loadout a non-projectile style", () => {
for (const abilityId of [...PARTY_ABILITY_LOADOUTS.nia, ...PARTY_ABILITY_LOADOUTS.orin]) {
const profile = partyAttackVfxProfile(abilityId);
if (profile.style === "buff") continue;
expect(profile.style).toBe("projectile");
expect(profile.trail).toBeGreaterThan(0);
}
for (const abilityId of [...PARTY_ABILITY_LOADOUTS.brann, ...PARTY_ABILITY_LOADOUTS.vale]) {
expect(partyAttackVfxProfile(abilityId).style).not.toBe("projectile");
}
});
});
+43
View File
@@ -0,0 +1,43 @@
import type { AiCombatantId, PartyAbilityId } from "./partyCombat";
export type PartyAttackVfxStyle = "buff" | "double-slash" | "projectile" | "slam" | "slash" | "spin";
export interface PartyAttackVfxProfile {
readonly ownerId: AiCombatantId;
readonly style: PartyAttackVfxStyle;
readonly primary: string;
readonly accent: string;
readonly scale: number;
readonly trail: number;
}
/** Visual identity only. Damage, timing, targeting, and resources stay in partyCombat. */
export const PARTY_ATTACK_VFX: Record<PartyAbilityId, PartyAttackVfxProfile> = {
sword_slash: { ownerId: "brann", style: "slash", primary: "#f0c56b", accent: "#fff0b0", scale: 0.9, trail: 0 },
shield_slam: { ownerId: "brann", style: "slam", primary: "#62b9ff", accent: "#d9f2ff", scale: 1.05, trail: 0 },
revenge: { ownerId: "brann", style: "double-slash", primary: "#ef6c4d", accent: "#ffd0a5", scale: 1.05, trail: 0 },
sweeping_guard: { ownerId: "brann", style: "spin", primary: "#77d7ff", accent: "#e5f8ff", scale: 1.15, trail: 0 },
bulwark_march: { ownerId: "brann", style: "buff", primary: "#4da8ff", accent: "#d5efff", scale: 1.25, trail: 0 },
quick_shot: { ownerId: "nia", style: "projectile", primary: "#77d596", accent: "#e5ffb8", scale: 0.78, trail: 0.45 },
aimed_shot: { ownerId: "nia", style: "projectile", primary: "#f0c961", accent: "#fff4be", scale: 1, trail: 0.7 },
rapid_fire: { ownerId: "nia", style: "projectile", primary: "#65dcba", accent: "#d8fff1", scale: 0.72, trail: 0.38 },
kill_shot: { ownerId: "nia", style: "projectile", primary: "#f05d57", accent: "#ffd3a3", scale: 1.18, trail: 0.9 },
deadeye: { ownerId: "nia", style: "projectile", primary: "#fff0a8", accent: "#ffffff", scale: 1.35, trail: 1.15 },
arcane_bolt: { ownerId: "orin", style: "projectile", primary: "#a87cff", accent: "#ead9ff", scale: 0.9, trail: 0.75 },
ember_lance: { ownerId: "orin", style: "projectile", primary: "#ff713d", accent: "#ffd080", scale: 0.92, trail: 0.9 },
arcane_burst: { ownerId: "orin", style: "projectile", primary: "#5d9fff", accent: "#dcf3ff", scale: 1.25, trail: 1.05 },
comet: { ownerId: "orin", style: "projectile", primary: "#7ce5ff", accent: "#f0fdff", scale: 1.55, trail: 1.35 },
overcharge: { ownerId: "orin", style: "buff", primary: "#b15cff", accent: "#f0d6ff", scale: 1.15, trail: 0 },
quick_cut: { ownerId: "vale", style: "slash", primary: "#c68aff", accent: "#f1ddff", scale: 0.78, trail: 0 },
twin_fang: { ownerId: "vale", style: "double-slash", primary: "#e36cff", accent: "#ffd5ff", scale: 0.92, trail: 0 },
backstab: { ownerId: "vale", style: "slash", primary: "#f15b7a", accent: "#ffd4df", scale: 1.12, trail: 0 },
fan_of_blades: { ownerId: "vale", style: "spin", primary: "#c9d5df", accent: "#ffffff", scale: 1.2, trail: 0 },
blade_flurry: { ownerId: "vale", style: "buff", primary: "#8a7cff", accent: "#e8e3ff", scale: 1.15, trail: 0 },
};
export function partyAttackVfxProfile(abilityId: PartyAbilityId) {
return PARTY_ATTACK_VFX[abilityId];
}
+5
View File
@@ -0,0 +1,5 @@
import type { PartyMember } from "./types";
export function isPartyWiped(party: readonly PartyMember[]) {
return party.length > 0 && party.every((member) => member.hp <= 0);
}
+14 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { highestRoguelikeRoundAfterDefeat } from "./hunterStats"; import { highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat } from "./hunterStats";
describe("roguelike hunter records", () => { describe("roguelike hunter records", () => {
it("records the reached defeat round without lowering a previous best", () => { it("records the reached defeat round without lowering a previous best", () => {
@@ -13,3 +13,16 @@ describe("roguelike hunter records", () => {
expect(highestRoguelikeRoundAfterDefeat(4.9, 8.9)).toBe(8); expect(highestRoguelikeRoundAfterDefeat(4.9, 8.9)).toBe(8);
}); });
}); });
describe("Rogue Trials endless hunter records", () => {
it("keeps the highest boss count from one endless run", () => {
expect(highestEndlessBossKillsAfterDefeat(0, 17)).toBe(17);
expect(highestEndlessBossKillsAfterDefeat(17, 9)).toBe(17);
expect(highestEndlessBossKillsAfterDefeat(17, 23)).toBe(23);
});
it("normalizes invalid and fractional kill counts", () => {
expect(highestEndlessBossKillsAfterDefeat(Number.NaN, Number.NaN)).toBe(0);
expect(highestEndlessBossKillsAfterDefeat(4.9, 8.9)).toBe(8);
});
});
+6
View File
@@ -3,3 +3,9 @@ export function highestRoguelikeRoundAfterDefeat(currentRecord: number, reachedR
const normalizedRound = Math.max(1, Math.floor(Number(reachedRound) || 1)); const normalizedRound = Math.max(1, Math.floor(Number(reachedRound) || 1));
return Math.max(normalizedRecord, normalizedRound); return Math.max(normalizedRecord, normalizedRound);
} }
export function highestEndlessBossKillsAfterDefeat(currentRecord: number, bossKills: number): number {
const normalizedRecord = Math.max(0, Math.floor(Number(currentRecord) || 0));
const normalizedKills = Math.max(0, Math.floor(Number(bossKills) || 0));
return Math.max(normalizedRecord, normalizedKills);
}
+12 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { AVAILABLE_BOSS_IDS } from "./bossCatalog"; import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS } from "./bossCatalog";
import { import {
RUN_BUFF_ORDER, RUN_BUFF_ORDER,
RUN_BUFFS, RUN_BUFFS,
@@ -97,6 +97,17 @@ describe("roguelike progression", () => {
expect(trio.every((bossId) => !seen.includes(bossId as typeof seen[number]))).toBe(true); expect(trio.every((bossId) => !seen.includes(bossId as typeof seen[number]))).toBe(true);
}); });
it("never selects two Memory Sequence bosses in one pair or trio", () => {
const pair = selectRandomBossPair([], () => 0.04);
const trio = selectUnseenBosses(3, [], () => 0);
const memoryBossCount = (bossIds: readonly (typeof AVAILABLE_BOSS_IDS)[number][]) => bossIds
.filter((bossId) => BOSS_DEFINITIONS[bossId].mechanicIds.includes("memory-sequence"))
.length;
expect(memoryBossCount(pair)).toBeLessThanOrEqual(1);
expect(memoryBossCount(trio)).toBeLessThanOrEqual(1);
});
it("fails instead of silently reusing seen bosses when unseen pool is too small", () => { it("fails instead of silently reusing seen bosses when unseen pool is too small", () => {
expect(() => selectUnseenBosses(3, AVAILABLE_BOSS_IDS.slice(0, -2))).toThrow(/Cannot select 3 unseen bosses/); expect(() => selectUnseenBosses(3, AVAILABLE_BOSS_IDS.slice(0, -2))).toThrow(/Cannot select 3 unseen bosses/);
}); });
+14 -5
View File
@@ -1,4 +1,5 @@
import { AVAILABLE_BOSS_IDS } from "./bossCatalog"; import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
import { canAddBossToEncounter } from "./bossSelection";
import type { AbilityId, BossId, RunBuffId, RunBuffRanks } from "./types"; import type { AbilityId, BossId, RunBuffId, RunBuffRanks } from "./types";
export type RunBuffEffectKind = export type RunBuffEffectKind =
@@ -210,6 +211,7 @@ export function selectUnseenBosses(
count: number, count: number,
seenBossIds: readonly BossId[] = [], seenBossIds: readonly BossId[] = [],
random: () => number = Math.random, random: () => number = Math.random,
selectedBossIds: readonly BossId[] = [],
): BossId[] { ): BossId[] {
const seen = new Set(seenBossIds); const seen = new Set(seenBossIds);
const pool = AVAILABLE_BOSS_IDS.filter((bossId) => !seen.has(bossId)); const pool = AVAILABLE_BOSS_IDS.filter((bossId) => !seen.has(bossId));
@@ -219,11 +221,16 @@ export function selectUnseenBosses(
} }
const bosses: BossId[] = []; const bosses: BossId[] = [];
while (bosses.length < requestedCount) { while (bosses.length < requestedCount) {
const compatiblePool = pool.filter((bossId) => canAddBossToEncounter([...selectedBossIds, ...bosses], bossId));
if (!compatiblePool.length) {
throw new Error(`Cannot select ${requestedCount} unseen bosses without duplicating an encounter-exclusive mechanic.`);
}
const sample = random(); const sample = random();
const randomValue = Number.isFinite(sample) ? Math.max(0, Math.min(0.999999999, sample)) : 0; const randomValue = Number.isFinite(sample) ? Math.max(0, Math.min(0.999999999, sample)) : 0;
const index = Math.floor(randomValue * pool.length); const index = Math.floor(randomValue * compatiblePool.length);
bosses.push(pool[index]); const selected = compatiblePool[index];
pool.splice(index, 1); bosses.push(selected);
pool.splice(pool.indexOf(selected), 1);
} }
return bosses; return bosses;
} }
@@ -245,6 +252,8 @@ export function selectRandomBossPair(
const eligibleBosses = AVAILABLE_BOSS_IDS.filter((bossId) => !excluded.has(bossId)); const eligibleBosses = AVAILABLE_BOSS_IDS.filter((bossId) => !excluded.has(bossId));
const pool = eligibleBosses.length >= 2 ? eligibleBosses : AVAILABLE_BOSS_IDS; const pool = eligibleBosses.length >= 2 ? eligibleBosses : AVAILABLE_BOSS_IDS;
const firstIndex = Math.floor(random() * pool.length) % pool.length; const firstIndex = Math.floor(random() * pool.length) % pool.length;
const secondOffset = 1 + (Math.floor(random() * (pool.length - 1)) % (pool.length - 1)); const first = pool[firstIndex];
return [pool[firstIndex], pool[(firstIndex + secondOffset) % pool.length]]; const compatiblePool = pool.filter((bossId) => canAddBossToEncounter([first], bossId));
const secondIndex = Math.floor(random() * compatiblePool.length) % compatiblePool.length;
return [first, compatiblePool[secondIndex]];
} }
+174 -2
View File
@@ -6,6 +6,7 @@ import { createClassInventory, HEALER_CLASSES } from "./healers";
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool"; import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
import { ARENA_CENTER, isInsideArena } from "./arena"; import { ARENA_CENTER, isInsideArena } from "./arena";
import { BOSS_DEFINITIONS } from "./bossCatalog"; import { BOSS_DEFINITIONS } from "./bossCatalog";
import { BOSS_DEATH_DESPAWN_SECONDS } from "./bossDeath";
import { RUN_BUFF_ORDER, RUN_BUFFS, compileRunModifiers } from "./roguelike"; import { RUN_BUFF_ORDER, RUN_BUFFS, compileRunModifiers } from "./roguelike";
import type { RunBuffRanks } from "./types"; import type { RunBuffRanks } from "./types";
@@ -192,6 +193,26 @@ describe("Disc Priest combat simulation", () => {
expect(paused.castAbility("renew")).toBe(false); expect(paused.castAbility("renew")).toBe(false);
}); });
it("cancels an active cast and blocks new abilities when the healer falls", () => {
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
party: state.party.map((member) => member.id === "nia" ? { ...member, hp: 40 } : member),
}));
useGameStore.getState().selectMember("nia");
expect(useGameStore.getState().castAbility("mend")).toBe(true);
useGameStore.setState((state) => ({
party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 0 } : member),
}));
useGameStore.getState().tick(0.6);
const state = useGameStore.getState();
expect(state.phase).toBe("combat");
expect(state.activeCast).toBeNull();
expect(state.party.find((member) => member.id === "nia")?.hp).toBe(40);
expect(state.castAbility("renew")).toBe(false);
});
it("does not publish unchanged player positions while idle", () => { it("does not publish unchanged player positions while idle", () => {
let updates = 0; let updates = 0;
const unsubscribe = useGameStore.subscribe(() => { updates += 1; }); const unsubscribe = useGameStore.subscribe(() => { updates += 1; });
@@ -311,6 +332,64 @@ describe("Disc Priest combat simulation", () => {
}); });
}); });
describe("shared party wipe rules", () => {
it.each(["encounter", "roguelike", "rogue-trials"] as const)(
"keeps %s combat running after individual party deaths",
(runMode) => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
["bulldrome", "broodfang-spider"],
runMode,
);
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
boss: { ...state.boss, hp: 1_000_000, maxHp: 1_000_000, nextMeleeAt: 999 },
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
additionalBosses: state.additionalBosses.map((entry) => ({
...entry,
boss: { ...entry.boss, hp: 1_000_000, maxHp: 1_000_000, nextMeleeAt: 999 },
motion: { ...entry.motion, nextMechanicAt: 999 },
})),
party: state.party.map((member) => member.id === "aelia" || member.id === "brann" ? { ...member, hp: 0 } : member),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe("combat");
useGameStore.setState((state) => ({
party: state.party.map((member) => ({ ...member, hp: 0 })),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe("defeat");
},
);
it.each([
["encounter", "victory"],
["roguelike", "intermission"],
["rogue-trials", "intermission"],
] as const)("awards %s boss completion when both sides fall on the same tick", (runMode, expectedPhase) => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
["bulldrome", "broodfang-spider"],
runMode,
);
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
boss: { ...state.boss, hp: 0 },
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
party: state.party.map((member) => ({ ...member, hp: 0 })),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe(expectedPhase);
});
});
describe("Broodfang encounter", () => { describe("Broodfang encounter", () => {
beforeEach(() => { beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "broodfang-spider"); useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "broodfang-spider");
@@ -351,7 +430,7 @@ describe("Broodfang encounter", () => {
expect(state.bossMotion.hazards[0]).toMatchObject({ kind: "venom_pool", center: state.partyPositions[poisoned.id] }); expect(state.bossMotion.hazards[0]).toMatchObject({ kind: "venom_pool", center: state.partyPositions[poisoned.id] });
}); });
it("repeatedly damages the player while they remain in a venom pool", () => { it("arms a lower-damage venom pool after giving the party time to move", () => {
useGameStore.getState().setPlayerPosition([0, 0]); useGameStore.getState().setPlayerPosition([0, 0]);
useGameStore.setState((state) => ({ useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 }, boss: { ...state.boss, nextMeleeAt: 999 },
@@ -362,7 +441,10 @@ describe("Broodfang encounter", () => {
})); }));
const startingHp = useGameStore.getState().party[0].hp; const startingHp = useGameStore.getState().party[0].hp;
useGameStore.getState().tick(0.3); useGameStore.getState().tick(VENOM_PURGE.poolArmDelay - 0.05);
expect(useGameStore.getState().party[0].hp).toBe(startingHp);
useGameStore.getState().tick(0.1);
const firstTickHp = useGameStore.getState().party[0].hp; const firstTickHp = useGameStore.getState().party[0].hp;
useGameStore.getState().tick(1); useGameStore.getState().tick(1);
const secondTickHp = useGameStore.getState().party[0].hp; const secondTickHp = useGameStore.getState().party[0].hp;
@@ -427,6 +509,23 @@ describe("PVE dual-boss encounter", () => {
useGameStore.getState().tick(1); useGameStore.getState().tick(1);
expect(useGameStore.getState().phase).toBe("victory"); expect(useGameStore.getState().phase).toBe("victory");
}); });
it("drops a dispelled venom pool only for the spider that applied the debuff", () => {
useGameStore.setState((state) => ({
bossMotion: { ...state.bossMotion, nextMechanicAt: state.time, mechanicCount: 1 },
additionalBosses: state.additionalBosses.map((entry) => ({
...entry,
motion: { ...entry.motion, nextMechanicAt: 999 },
})),
}));
useGameStore.getState().tick(0.05);
const poisoned = useGameStore.getState().party.find((member) => member.debuffs.some((debuff) => debuff.name === "Widow Venom"))!;
useGameStore.getState().selectMember(poisoned.id);
expect(useGameStore.getState().castAbility("purify")).toBe(true);
expect(useGameStore.getState().bossMotion.hazards.filter((hazard) => hazard.kind === "venom_pool")).toHaveLength(1);
expect(useGameStore.getState().additionalBosses[0].motion.hazards.filter((hazard) => hazard.kind === "venom_pool")).toHaveLength(0);
});
}); });
describe("Roguelike ability buffs", () => { describe("Roguelike ability buffs", () => {
@@ -707,6 +806,79 @@ describe("Rogue Trials", () => {
expect(useGameStore.getState().phase).toBe("victory"); expect(useGameStore.getState().phase).toBe("victory");
}); });
it("offers endless mode, counts each kill, and refills the dead boss slot", () => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
["ashwing-demon", "riftclaw-demon", "tempestscale-dragon"],
"rogue-trials",
);
useGameStore.setState({ round: 5, phase: "victory" });
expect(useGameStore.getState().startRogueTrialsEndless()).toBe(true);
const started = useGameStore.getState();
expect(started.phase).toBe("combat");
expect(started.endlessMode).toBe(true);
expect(started.endlessBossKills).toBe(0);
expect(started.additionalBosses).toHaveLength(2);
useGameStore.setState((state) => ({
boss: { ...state.boss, hp: 1, nextMeleeAt: 999 },
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
additionalBosses: state.additionalBosses.map((entry) => ({
...entry,
boss: { ...entry.boss, hp: 1_000_000, maxHp: 1_000_000, nextMeleeAt: 999 },
motion: { ...entry.motion, nextMechanicAt: 999 },
})),
}));
for (let step = 0; step < 50 && useGameStore.getState().endlessBossKills === 0; step += 1) {
useGameStore.getState().tick(0.1);
}
const defeated = useGameStore.getState();
expect(defeated.endlessBossKills).toBe(1);
expect(defeated.boss.hp).toBe(0);
const defeatedInstanceId = defeated.bossInstanceId;
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().bossInstanceId).toBe(defeatedInstanceId);
const despawnAt = useGameStore.getState().boss.defeatedAt! + BOSS_DEATH_DESPAWN_SECONDS;
while (useGameStore.getState().time + 0.11 < despawnAt) useGameStore.getState().tick(0.1);
expect(useGameStore.getState().bossInstanceId).toBe(defeatedInstanceId);
useGameStore.getState().tick(0.12);
const replaced = useGameStore.getState();
expect(replaced.phase).toBe("combat");
expect(replaced.boss.hp).toBe(replaced.boss.maxHp);
expect(replaced.bossInstanceId).not.toBe(defeatedInstanceId);
expect(replaced.endlessBossKills).toBe(1);
expect(new Set([replaced.boss.id, ...replaced.additionalBosses.map((entry) => entry.boss.id)])).toHaveLength(3);
});
it("keeps endless mode running until the whole party falls", () => {
useGameStore.setState((state) => ({
round: 5,
phase: "victory",
boss: { ...state.boss, hp: 0 },
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
}));
expect(useGameStore.getState().startRogueTrialsEndless()).toBe(true);
useGameStore.setState((state) => ({
endlessBossKills: 7,
party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 0 } : member),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe("combat");
expect(useGameStore.getState().endlessBossKills).toBe(7);
useGameStore.setState((state) => ({
party: state.party.map((member) => ({ ...member, hp: 0 })),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe("defeat");
expect(useGameStore.getState().endlessBossKills).toBe(7);
});
it("restarts a completed trial with a fresh two-boss first round", () => { it("restarts a completed trial with a fresh two-boss first round", () => {
useGameStore.getState().configureHealer( useGameStore.getState().configureHealer(
"priest", "priest",
+134 -10
View File
@@ -7,6 +7,8 @@ import {
upcomingMechanic, upcomingMechanic,
} from "./bossMechanics"; } from "./bossMechanics";
import { BOSS_DEFINITIONS } from "./bossCatalog"; import { BOSS_DEFINITIONS } from "./bossCatalog";
import { BOSS_DEATH_DESPAWN_SECONDS } from "./bossDeath";
import { normalizeEncounterBossIds } from "./bossSelection";
import { clampToArena, constrainBossMotion } from "./arena"; import { clampToArena, constrainBossMotion } from "./arena";
import { cloneMotion } from "./bosses/shared"; import { cloneMotion } from "./bosses/shared";
import { freshParty } from "./data"; import { freshParty } from "./data";
@@ -14,6 +16,7 @@ import { distance } from "./geometry";
import { createClassInventory, HEALER_CLASSES } from "./healers"; import { createClassInventory, HEALER_CLASSES } from "./healers";
import { combatFormation, updatePartyPositions } from "./partyBehaviors"; import { combatFormation, updatePartyPositions } from "./partyBehaviors";
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat"; import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
import { isPartyWiped } from "./partyState";
import { import {
RUN_BUFFS, RUN_BUFFS,
bossHealthMultiplier, bossHealthMultiplier,
@@ -25,6 +28,7 @@ import {
selectRunBuffDraft, selectRunBuffDraft,
selectRandomBossPair, selectRandomBossPair,
selectRogueTrialsBosses, selectRogueTrialsBosses,
selectUnseenBosses,
ROGUE_TRIALS_TRIO_ROUND, ROGUE_TRIALS_TRIO_ROUND,
type CompiledRunModifiers, type CompiledRunModifiers,
} from "./roguelike"; } from "./roguelike";
@@ -67,6 +71,7 @@ export interface AdditionalBossState {
export interface GameState { export interface GameState {
bossId: BossId; bossId: BossId;
bossInstanceId: string;
paused: boolean; paused: boolean;
pauseSelection: "resume" | "exit"; pauseSelection: "resume" | "exit";
healerClassId: HealerClassId; healerClassId: HealerClassId;
@@ -75,6 +80,10 @@ export interface GameState {
runMode: RunMode; runMode: RunMode;
round: number; round: number;
seenBossIds: BossId[]; seenBossIds: BossId[];
endlessMode: boolean;
endlessBossKills: number;
endlessSpawnSequence: number;
endlessChoiceSelection: "continue" | "quit";
runBuffRanks: RunBuffRanks; runBuffRanks: RunBuffRanks;
draftBuffIds: RunBuffId[]; draftBuffIds: RunBuffId[];
selectedRunBuffId: RunBuffId | null; selectedRunBuffId: RunBuffId | null;
@@ -123,6 +132,8 @@ export interface GameState {
setSelectedRunBuff: (buffId: RunBuffId) => void; setSelectedRunBuff: (buffId: RunBuffId) => void;
chooseRunBuff: (buffId: RunBuffId) => boolean; chooseRunBuff: (buffId: RunBuffId) => boolean;
continueRoguelikeRound: () => boolean; continueRoguelikeRound: () => boolean;
startRogueTrialsEndless: () => boolean;
setEndlessChoiceSelection: (selection: "continue" | "quit") => void;
} }
const emptyCooldowns = (): Record<AbilityId, number> => ({ const emptyCooldowns = (): Record<AbilityId, number> => ({
@@ -142,8 +153,7 @@ export const BARRIER_DAMAGE_REDUCTION = 0.3;
const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): BossId[] => { const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): BossId[] => {
const requested = typeof bossIds === "string" ? [bossIds] : [...bossIds]; const requested = typeof bossIds === "string" ? [bossIds] : [...bossIds];
const unique = requested.filter((bossId, index) => requested.indexOf(bossId) === index).slice(0, 3); return normalizeEncounterBossIds(requested);
return unique.length ? unique : ["bulldrome"];
}; };
function createEncounterMotion(bossId: BossId, index: number, count: number): BossMotionState { function createEncounterMotion(bossId: BossId, index: number, count: number): BossMotionState {
@@ -305,6 +315,7 @@ function initialState(
const maxMana = 100; const maxMana = 100;
return { return {
bossId: primary.boss.id, bossId: primary.boss.id,
bossInstanceId: primary.instanceId,
paused: false, paused: false,
pauseSelection: "resume" as const, pauseSelection: "resume" as const,
healerClassId, healerClassId,
@@ -313,6 +324,10 @@ function initialState(
runMode, runMode,
round, round,
seenBossIds: [...new Set([...seenBossIds, ...bossIds])], seenBossIds: [...new Set([...seenBossIds, ...bossIds])],
endlessMode: false,
endlessBossKills: 0,
endlessSpawnSequence: 0,
endlessChoiceSelection: "continue" as const,
runBuffRanks: { ...runBuffRanks }, runBuffRanks: { ...runBuffRanks },
draftBuffIds, draftBuffIds,
selectedRunBuffId: draftBuffIds[0] ?? null, selectedRunBuffId: draftBuffIds[0] ?? null,
@@ -441,6 +456,46 @@ export const useGameStore = create<GameState>((set, get) => ({
}); });
return true; return true;
}, },
startRogueTrialsEndless: () => {
const state = get();
if (state.runMode !== "rogue-trials"
|| state.round !== ROGUE_TRIALS_TRIO_ROUND
|| state.phase !== "victory"
|| state.endlessMode) return false;
const bossIds = selectRogueTrialsBosses(ROGUE_TRIALS_TRIO_ROUND, []);
const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
const healthMultiplier = bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier;
const encounterBosses = bossIds.map((bossId, index) => {
const entry = createEncounterBoss(bossId, index, bossIds.length, healthMultiplier);
return { ...entry, instanceId: `endless-${index + 1}-${bossId}` };
});
const primary = encounterBosses[0];
set({
bossId: primary.boss.id,
bossInstanceId: primary.instanceId,
boss: primary.boss,
bossMotion: primary.motion,
additionalBosses: encounterBosses.slice(1),
phase: "combat",
endlessMode: true,
endlessBossKills: 0,
endlessSpawnSequence: encounterBosses.length,
partyCombat: createPartyCombatState(state.party),
partyDamageEvents: [],
partyPositions: freshPartyPositions(bossIds),
playerPosition: [0, 4.5],
activeCast: null,
activeTab: "combat",
combatLog: [{
id: Date.now(),
time: state.time,
message: `${encounterBosses.map((entry) => entry.boss.name).join(", ")} enter the endless trial.`,
tone: "danger",
}],
});
return true;
},
setEndlessChoiceSelection: (endlessChoiceSelection) => set({ endlessChoiceSelection }),
setPlayerPosition: (playerPosition) => set((state) => { setPlayerPosition: (playerPosition) => set((state) => {
playerPosition = clampToArena(playerPosition); playerPosition = clampToArena(playerPosition);
const current = state.playerPosition; const current = state.playerPosition;
@@ -462,6 +517,8 @@ export const useGameStore = create<GameState>((set, get) => ({
if (state.phase !== "combat") return false; if (state.phase !== "combat") return false;
if (state.paused) return false; if (state.paused) return false;
if (state.activeCast) return false; if (state.activeCast) return false;
const healer = state.party.find((member) => member.id === "aelia");
if (!healer || healer.hp <= 0) return false;
const ability = HEALER_CLASSES[state.healerClassId].abilities[abilityId]; const ability = HEALER_CLASSES[state.healerClassId].abilities[abilityId];
const manaCost = runAbilityManaCost(abilityId, ability.mana, state.runModifiers); const manaCost = runAbilityManaCost(abilityId, ability.mana, state.runModifiers);
@@ -525,11 +582,11 @@ export const useGameStore = create<GameState>((set, get) => ({
const primaryNames = party[selectedIndex].debuffs.map((debuff) => debuff.name); const primaryNames = party[selectedIndex].debuffs.map((debuff) => debuff.name);
for (const index of cleanseIndexes) { for (const index of cleanseIndexes) {
const target = party[index]; const target = party[index];
const dispelledNames = target.debuffs.map((debuff) => debuff.name); const dispelledDebuffs = target.debuffs;
const primaryDispel = handleBossDispel(state.boss.id, bossMotion, target.id, state.partyPositions[target.id], state.time, dispelledNames); const primaryDispel = handleBossDispel(state.boss.id, bossMotion, target.id, state.partyPositions[target.id], state.time, dispelledDebuffs);
bossMotion = primaryDispel.motion; bossMotion = primaryDispel.motion;
additionalBosses = additionalBosses.map((entry) => { additionalBosses = additionalBosses.map((entry) => {
const dispel = handleBossDispel(entry.boss.id, entry.motion, target.id, state.partyPositions[target.id], state.time, dispelledNames); const dispel = handleBossDispel(entry.boss.id, entry.motion, target.id, state.partyPositions[target.id], state.time, dispelledDebuffs);
return { ...entry, motion: dispel.motion }; return { ...entry, motion: dispel.motion };
}); });
party[index] = { ...party[index], debuffs: [] }; party[index] = { ...party[index], debuffs: [] };
@@ -601,6 +658,7 @@ export const useGameStore = create<GameState>((set, get) => ({
// as well doubled short-lived allocations for every simulation step. // as well doubled short-lived allocations for every simulation step.
let party = state.party.map((member) => ({ ...member })); let party = state.party.map((member) => ({ ...member }));
let boss = { ...state.boss }; let boss = { ...state.boss };
let bossInstanceId = state.bossInstanceId;
let bossMotion = { ...state.bossMotion }; let bossMotion = { ...state.bossMotion };
let additionalBosses = state.additionalBosses.map((entry) => ({ let additionalBosses = state.additionalBosses.map((entry) => ({
...entry, ...entry,
@@ -614,6 +672,41 @@ export const useGameStore = create<GameState>((set, get) => ({
let partyCombat = state.partyCombat; let partyCombat = state.partyCombat;
let partyDamageEvents = state.partyDamageEvents; let partyDamageEvents = state.partyDamageEvents;
let barrier = { ...state.barrier }; let barrier = { ...state.barrier };
let endlessBossKills = state.endlessBossKills;
let endlessSpawnSequence = state.endlessSpawnSequence;
if (!party.some((member) => member.id === "aelia" && member.hp > 0)) activeCast = null;
if (state.endlessMode) {
const slots: AdditionalBossState[] = [
{ instanceId: bossInstanceId, boss, motion: bossMotion },
...additionalBosses,
];
for (let index = 0; index < slots.length; index += 1) {
if (slots[index].boss.hp > 0) continue;
const defeatedAt = slots[index].boss.defeatedAt ?? oldTime;
slots[index].boss.defeatedAt = defeatedAt;
if (time < defeatedAt + BOSS_DEATH_DESPAWN_SECONDS) continue;
const activeBossIds = slots
.filter((entry, slotIndex) => slotIndex !== index && entry.boss.hp > 0)
.map((entry) => entry.boss.id);
const replacementId = selectUnseenBosses(1, [slots[index].boss.id, ...activeBossIds], Math.random, activeBossIds)[0];
endlessSpawnSequence += 1;
const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
const replacement = createEncounterBoss(
replacementId,
index,
slots.length,
bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier,
);
slots[index] = { ...replacement, instanceId: `endless-${endlessSpawnSequence}-${replacementId}` };
combatLog = addLog(combatLog, time, `${replacement.boss.name} replaces the fallen boss.`, "danger");
}
boss = slots[0].boss;
bossInstanceId = slots[0].instanceId;
bossMotion = slots[0].motion;
additionalBosses = slots.slice(1);
}
if (activeCast && activeCast.completesAt <= time) { if (activeCast && activeCast.completesAt <= time) {
const targetIndex = party.findIndex((member) => member.id === activeCast!.targetId); const targetIndex = party.findIndex((member) => member.id === activeCast!.targetId);
@@ -683,7 +776,7 @@ export const useGameStore = create<GameState>((set, get) => ({
}); });
const encounterBosses: AdditionalBossState[] = [ const encounterBosses: AdditionalBossState[] = [
{ instanceId: `boss-0-${boss.id}`, boss, motion: bossMotion }, { instanceId: bossInstanceId, boss, motion: bossMotion },
...additionalBosses, ...additionalBosses,
]; ];
for (let index = 0; index < encounterBosses.length; index += 1) { for (let index = 0; index < encounterBosses.length; index += 1) {
@@ -732,26 +825,51 @@ export const useGameStore = create<GameState>((set, get) => ({
const target = encounterBosses.find((entry) => entry.instanceId === event.targetInstanceId); const target = encounterBosses.find((entry) => entry.instanceId === event.targetInstanceId);
if (target) target.boss.hp = Math.max(0, target.boss.hp - event.amount); if (target) target.boss.hp = Math.max(0, target.boss.hp - event.amount);
} }
for (const entry of encounterBosses) {
if (entry.boss.hp <= 0 && entry.boss.defeatedAt === undefined) entry.boss.defeatedAt = time;
}
boss = encounterBosses[0].boss; boss = encounterBosses[0].boss;
bossInstanceId = encounterBosses[0].instanceId;
bossMotion = encounterBosses[0].motion; bossMotion = encounterBosses[0].motion;
additionalBosses = encounterBosses.slice(1); additionalBosses = encounterBosses.slice(1);
const tank = party.find((member) => member.id === "brann")!;
const healer = party.find((member) => member.id === "aelia")!; const healer = party.find((member) => member.id === "aelia")!;
if (healer.hp <= 0) activeCast = null;
const partyWiped = isPartyWiped(party);
let phase: GamePhase = state.phase; let phase: GamePhase = state.phase;
let runBuffInputUnlockAt = state.runBuffInputUnlockAt; let runBuffInputUnlockAt = state.runBuffInputUnlockAt;
if (encounterBosses.every((entry) => entry.boss.hp <= 0)) { let newlyDefeatedBossCount = 0;
if (state.endlessMode) {
for (let index = 0; index < encounterBosses.length; index += 1) {
const current = encounterBosses[index];
const previous = index === 0
? { instanceId: state.bossInstanceId, boss: state.boss }
: state.additionalBosses[index - 1];
if (current.boss.hp > 0 || previous?.instanceId !== current.instanceId || previous.boss.hp <= 0) continue;
newlyDefeatedBossCount += 1;
combatLog = addLog(combatLog, time, `${current.boss.name} falls. Endless kill ${endlessBossKills + newlyDefeatedBossCount}.`, "good");
}
}
endlessBossKills += newlyDefeatedBossCount;
if (state.endlessMode && partyWiped) {
phase = "defeat";
combatLog = addLog(combatLog, time, `${endlessBossKills} endless bosses defeated before the party fell.`, "danger");
} else if (state.endlessMode) {
phase = "combat";
} else if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
const rogueTrialsComplete = state.runMode === "rogue-trials" && state.round === ROGUE_TRIALS_TRIO_ROUND; const rogueTrialsComplete = state.runMode === "rogue-trials" && state.round === ROGUE_TRIALS_TRIO_ROUND;
phase = state.runMode !== "encounter" && !rogueTrialsComplete ? "intermission" : "victory"; phase = state.runMode !== "encounter" && !rogueTrialsComplete ? "intermission" : "victory";
if (phase === "intermission") runBuffInputUnlockAt = Date.now() + RUN_BUFF_INPUT_LOCK_MS; if (phase === "intermission") runBuffInputUnlockAt = Date.now() + RUN_BUFF_INPUT_LOCK_MS;
combatLog = addLog(combatLog, time, `${encounterBosses.map((entry) => entry.boss.name).join(" and ")} fall. Party survives.`, "good"); combatLog = addLog(combatLog, time, `${encounterBosses.map((entry) => entry.boss.name).join(" and ")} fall. Party survives.`, "good");
} else if (tank.hp <= 0 || healer.hp <= 0) { } else if (partyWiped) {
phase = "defeat"; phase = "defeat";
combatLog = addLog(combatLog, time, tank.hp <= 0 ? `Brann falls. ${boss.name} breaks formation.` : `${healer.name} falls. Healing ends.`, "danger"); combatLog = addLog(combatLog, time, `The party falls. ${boss.name} claims the vault.`, "danger");
} }
set({ set({
time, time,
party, party,
bossId: boss.id,
bossInstanceId,
boss, boss,
additionalBosses, additionalBosses,
partyCombat, partyCombat,
@@ -759,6 +877,8 @@ export const useGameStore = create<GameState>((set, get) => ({
partyPositions, partyPositions,
bossMotion, bossMotion,
phase, phase,
endlessBossKills,
endlessSpawnSequence,
runBuffInputUnlockAt, runBuffInputUnlockAt,
mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)), mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)),
activeCast, activeCast,
@@ -786,6 +906,8 @@ export type GameSnapshot = Omit<GameState,
| "setSelectedRunBuff" | "setSelectedRunBuff"
| "chooseRunBuff" | "chooseRunBuff"
| "continueRoguelikeRound" | "continueRoguelikeRound"
| "startRogueTrialsEndless"
| "setEndlessChoiceSelection"
>; >;
export function getGameSnapshot(): GameSnapshot { export function getGameSnapshot(): GameSnapshot {
@@ -806,6 +928,8 @@ export function getGameSnapshot(): GameSnapshot {
setSelectedRunBuff: _setSelectedRunBuff, setSelectedRunBuff: _setSelectedRunBuff,
chooseRunBuff: _chooseRunBuff, chooseRunBuff: _chooseRunBuff,
continueRoguelikeRound: _continueRoguelikeRound, continueRoguelikeRound: _continueRoguelikeRound,
startRogueTrialsEndless: _startRogueTrialsEndless,
setEndlessChoiceSelection: _setEndlessChoiceSelection,
...snapshot ...snapshot
} = useGameStore.getState(); } = useGameStore.getState();
return snapshot; return snapshot;
+5 -1
View File
@@ -27,7 +27,8 @@ export type BossId =
| "bristlequake-boar" | "bristlequake-boar"
| "moonfang-wolf" | "moonfang-wolf"
| "frostmaw-yeti" | "frostmaw-yeti"
| "rimeclaw-yeti"; | "rimeclaw-yeti"
| "gravehorn-triceratops";
export type BossMechanicId = export type BossMechanicId =
| "basic-melee" | "basic-melee"
| "bull-charge" | "bull-charge"
@@ -198,6 +199,7 @@ export interface Debuff {
expiresAt: number; expiresAt: number;
nextTickAt: number; nextTickAt: number;
tickDamage: number; tickDamage: number;
sourceBossId?: BossId;
} }
export interface PartyMember { export interface PartyMember {
@@ -221,6 +223,8 @@ export interface BossState {
maxHp: number; maxHp: number;
hp: number; hp: number;
nextMeleeAt: number; nextMeleeAt: number;
/** Simulation time when health first reached zero. Used for delayed endless replacement. */
defeatedAt?: number;
} }
export interface BossMotionState { export interface BossMotionState {
+23 -11
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { subscribeControllerToken } from "../input/controller"; import { subscribeControllerToken } from "../input/controller";
import { ABILITY_ORDER } from "./data"; import { ABILITY_ORDER } from "./data";
import { isRunBuffInputLocked, useGameStore } from "./store"; import { isRunBuffInputLocked, useGameStore } from "./store";
import type { AbilityId } from "./types"; import { ABILITY_BY_CONTROLLER_BUTTON } from "./controllerBindings";
function cycleRunBuff(direction: 1 | -1) { function cycleRunBuff(direction: 1 | -1) {
const store = useGameStore.getState(); const store = useGameStore.getState();
@@ -12,15 +12,6 @@ function cycleRunBuff(direction: 1 | -1) {
store.setSelectedRunBuff(store.draftBuffIds[nextIndex]); store.setSelectedRunBuff(store.draftBuffIds[nextIndex]);
} }
const gamepadAbilityMap: Record<number, AbilityId> = {
0: "purify",
1: "shield",
2: "mend",
3: "renew",
4: "radiance",
5: "barrier",
};
export function useActionBindings(enabled = true, onExit?: () => void) { export function useActionBindings(enabled = true, onExit?: () => void) {
const exitRef = useRef(onExit); const exitRef = useRef(onExit);
exitRef.current = onExit; exitRef.current = onExit;
@@ -53,6 +44,17 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
if (key === "escape") exitRef.current?.(); if (key === "escape") exitRef.current?.();
return; return;
} }
if (store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) {
if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter"].includes(key)) event.preventDefault();
if (key === "arrowleft" || key === "arrowup") store.setEndlessChoiceSelection("continue");
if (key === "arrowright" || key === "arrowdown") store.setEndlessChoiceSelection("quit");
if (key === "enter") {
if (store.endlessChoiceSelection === "continue") store.startRogueTrialsEndless();
else exitRef.current?.();
}
if (key === "escape") exitRef.current?.();
return;
}
const numberIndex = Number(event.key) - 1; const numberIndex = Number(event.key) - 1;
if (numberIndex >= 0 && numberIndex < ABILITY_ORDER.length) { if (numberIndex >= 0 && numberIndex < ABILITY_ORDER.length) {
store.castAbility(ABILITY_ORDER[numberIndex]); store.castAbility(ABILITY_ORDER[numberIndex]);
@@ -110,9 +112,19 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
if (!repeat && token === "Button1") exitRef.current?.(); if (!repeat && token === "Button1") exitRef.current?.();
return; return;
} }
if (store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) {
if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) store.setEndlessChoiceSelection("continue");
if (["Button13", "Button15", "Axis0+", "Axis1+"].includes(token)) store.setEndlessChoiceSelection("quit");
if (!repeat && token === "Button0") {
if (store.endlessChoiceSelection === "continue") store.startRogueTrialsEndless();
else exitRef.current?.();
}
if (!repeat && token === "Button1") exitRef.current?.();
return;
}
if (repeat) return; if (repeat) return;
if (token.startsWith("Button")) { if (token.startsWith("Button")) {
const ability = gamepadAbilityMap[Number(token.slice("Button".length))]; const ability = ABILITY_BY_CONTROLLER_BUTTON[Number(token.slice("Button".length))];
if (ability && store.phase === "combat") store.castAbility(ability); if (ability && store.phase === "combat") store.castAbility(ability);
} }
if (token === "Button12") store.cycleMember(-1); if (token === "Button12") store.cycleMember(-1);
+12
View File
@@ -0,0 +1,12 @@
export const DEFAULT_CONTROLLER_GLYPHS = {
confirm: "✕",
back: "○",
faceBottom: "✕",
faceRight: "○",
faceLeft: "□",
faceTop: "△",
leftShoulder: "L1",
rightShoulder: "R1",
select: "SELECT",
start: "START",
} as const;
+9 -3
View File
@@ -9,6 +9,7 @@ import type { BossId } from "../game/types";
import type { DifficultySlug } from "../game/progression/loot"; import type { DifficultySlug } from "../game/progression/loot";
import { useForcedThorDisplays } from "./useThorDualScreen"; import { useForcedThorDisplays } from "./useThorDualScreen";
import { createRateLimitedPublisher } from "./rateLimitedPublisher"; import { createRateLimitedPublisher } from "./rateLimitedPublisher";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
const BottomScreen = lazy(() => import("../components/BottomScreen").then((module) => ({ default: module.BottomScreen }))); const BottomScreen = lazy(() => import("../components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
const CONTROLLER_MOTION_SYNC_INTERVAL_MS = 33; const CONTROLLER_MOTION_SYNC_INTERVAL_MS = 33;
@@ -45,8 +46,8 @@ function CompanionStandby({ screen, hunterName, notice }: {
</main> </main>
<footer> <footer>
<span><b></b> Navigate</span> <span><b></b> Navigate</span>
<span><b>A</b> Select</span> <span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Select</span>
<span><b>B</b> Back</span> <span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back</span>
</footer> </footer>
</section> </section>
); );
@@ -168,6 +169,11 @@ export function BottomDisplayApp() {
postCommand({ name: "continueRoguelikeRound" }); postCommand({ name: "continueRoguelikeRound" });
return false; return false;
}, },
startRogueTrialsEndless: () => {
postCommand({ name: "startRogueTrialsEndless" });
return false;
},
setEndlessChoiceSelection: (selection) => postCommand({ name: "setEndlessChoiceSelection", selection }),
}); });
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => { channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === "authoritative-ready") { if (event.data.type === "authoritative-ready") {
@@ -221,7 +227,7 @@ export function BottomDisplayApp() {
return ( return (
<main className="bottom-display-root"> <main className="bottom-display-root">
{surface.screen === "game" {surface.screen === "game"
? <Suspense fallback={<CompanionStandby screen="game" hunterName={surface.hunterName} notice="Loading field controls…" />}><BottomScreen /></Suspense> ? <Suspense fallback={<CompanionStandby screen="game" hunterName={surface.hunterName} notice="Loading field controls…" />}><BottomScreen onExit={() => postFrontendCommand({ name: "exitGame" })} /></Suspense>
: surface.notice === "Linking upper display…" : surface.notice === "Linking upper display…"
? <CompanionStandby screen={surface.screen} hunterName={surface.hunterName} notice={surface.notice} /> ? <CompanionStandby screen={surface.screen} hunterName={surface.hunterName} notice={surface.notice} />
: <FrontEnd onLaunch={launchGame} />} : <FrontEnd onLaunch={launchGame} />}
+12 -3
View File
@@ -6,10 +6,14 @@ import { useFrontendStore } from "../frontend/store";
function snapshot(): BottomGameSnapshot { function snapshot(): BottomGameSnapshot {
return { return {
bossId: "bulldrome", bossId: "bulldrome",
bossInstanceId: "boss-0-bulldrome",
paused: false, paused: false,
healerClassId: "priest", healerClassId: "priest",
phase: "combat", phase: "combat",
round: 1, round: 1,
endlessMode: false,
endlessBossKills: 0,
endlessChoiceSelection: "continue",
runModifiers: { runModifiers: {
mendExtraTargets: 0, mendManaMultiplier: 1, mendCastTimeMultiplier: 1, mendExtraTargets: 0, mendManaMultiplier: 1, mendCastTimeMultiplier: 1,
renewExtraTargets: 0, renewDurationBonus: 0, renewHealingMultiplier: 1, renewExtraTargets: 0, renewDurationBonus: 0, renewHealingMultiplier: 1,
@@ -67,21 +71,26 @@ describe("dual-screen game snapshots", () => {
it("routes maxed-run continuation and passive filter commands", () => { it("routes maxed-run continuation and passive filter commands", () => {
const originalContinue = useGameStore.getState().continueRoguelikeRound; const originalContinue = useGameStore.getState().continueRoguelikeRound;
const originalStartEndless = useGameStore.getState().startRogueTrialsEndless;
const originalSelectAbility = useFrontendStore.getState().selectPassiveAbility; const originalSelectAbility = useFrontendStore.getState().selectPassiveAbility;
const originalSelectPassive = useFrontendStore.getState().selectPassiveInfusion; const originalSelectPassive = useFrontendStore.getState().selectPassiveInfusion;
const calls: string[] = []; const calls: string[] = [];
useGameStore.setState({ continueRoguelikeRound: () => { calls.push("continue"); return true; } }); useGameStore.setState({
continueRoguelikeRound: () => { calls.push("continue"); return true; },
startRogueTrialsEndless: () => { calls.push("endless"); return true; },
});
useFrontendStore.setState({ useFrontendStore.setState({
selectPassiveAbility: (abilityId) => { calls.push(`ability:${abilityId}`); }, selectPassiveAbility: (abilityId) => { calls.push(`ability:${abilityId}`); },
selectPassiveInfusion: (passiveId) => { calls.push(`passive:${passiveId}`); }, selectPassiveInfusion: (passiveId) => { calls.push(`passive:${passiveId}`); },
}); });
executeGameCommand({ name: "continueRoguelikeRound" }); executeGameCommand({ name: "continueRoguelikeRound" });
executeGameCommand({ name: "startRogueTrialsEndless" });
executeFrontendCommand({ name: "selectPassiveAbility", abilityId: "shield" }); executeFrontendCommand({ name: "selectPassiveAbility", abilityId: "shield" });
executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" }); executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" });
expect(calls).toEqual(["continue", "ability:shield", "passive:shield-guard"]); expect(calls).toEqual(["continue", "endless", "ability:shield", "passive:shield-guard"]);
useGameStore.setState({ continueRoguelikeRound: originalContinue }); useGameStore.setState({ continueRoguelikeRound: originalContinue, startRogueTrialsEndless: originalStartEndless });
useFrontendStore.setState({ selectPassiveAbility: originalSelectAbility, selectPassiveInfusion: originalSelectPassive }); useFrontendStore.setState({ selectPassiveAbility: originalSelectAbility, selectPassiveInfusion: originalSelectPassive });
}); });
}); });
+17 -2
View File
@@ -23,7 +23,9 @@ export type GameCommand =
| { name: "setPauseSelection"; selection: "resume" | "exit" } | { name: "setPauseSelection"; selection: "resume" | "exit" }
| { name: "setSelectedRunBuff"; buffId: RunBuffId } | { name: "setSelectedRunBuff"; buffId: RunBuffId }
| { name: "chooseRunBuff"; buffId: RunBuffId } | { name: "chooseRunBuff"; buffId: RunBuffId }
| { name: "continueRoguelikeRound" }; | { name: "continueRoguelikeRound" }
| { name: "startRogueTrialsEndless" }
| { name: "setEndlessChoiceSelection"; selection: "continue" | "quit" };
export type FrontendCommand = export type FrontendCommand =
| { name: "signIn"; username: string; password: string } | { name: "signIn"; username: string; password: string }
@@ -52,9 +54,11 @@ export type FrontendCommand =
| { name: "equipPassiveInfusion"; passiveId: RunBuffId } | { name: "equipPassiveInfusion"; passiveId: RunBuffId }
| { name: "selectHealerClass"; classId: HealerClassId } | { name: "selectHealerClass"; classId: HealerClassId }
| { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] } | { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] }
| { name: "exitGame" }
| { name: "launchGame"; bossIds: readonly BossId[]; difficultySlug?: DifficultySlug }; | { name: "launchGame"; bossIds: readonly BossId[]; difficultySlug?: DifficultySlug };
export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game"; export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game";
export const DUAL_SCREEN_EXIT_EVENT = "iwt:dual-screen-exit-game";
export type DualScreenMessage = export type DualScreenMessage =
| { type: "app-state"; screen: AppScreen; hunterName: string | null; notice: string; frontend?: FrontendSnapshot; game?: Partial<BottomGameSnapshot> } | { type: "app-state"; screen: AppScreen; hunterName: string | null; notice: string; frontend?: FrontendSnapshot; game?: Partial<BottomGameSnapshot> }
@@ -86,6 +90,8 @@ export function executeGameCommand(command: GameCommand) {
case "setSelectedRunBuff": game.setSelectedRunBuff(command.buffId); break; case "setSelectedRunBuff": game.setSelectedRunBuff(command.buffId); break;
case "chooseRunBuff": game.chooseRunBuff(command.buffId); break; case "chooseRunBuff": game.chooseRunBuff(command.buffId); break;
case "continueRoguelikeRound": game.continueRoguelikeRound(); break; case "continueRoguelikeRound": game.continueRoguelikeRound(); break;
case "startRogueTrialsEndless": game.startRogueTrialsEndless(); break;
case "setEndlessChoiceSelection": game.setEndlessChoiceSelection(command.selection); break;
} }
} }
@@ -118,6 +124,7 @@ export function executeFrontendCommand(command: FrontendCommand) {
case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break; case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break;
case "selectHealerClass": frontend.selectHealerClass(command.classId); break; case "selectHealerClass": frontend.selectHealerClass(command.classId); break;
case "updateSetting": frontend.updateSetting(command.key, command.value); break; case "updateSetting": frontend.updateSetting(command.key, command.value); break;
case "exitGame": window.dispatchEvent(new Event(DUAL_SCREEN_EXIT_EVENT)); break;
case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: { bossIds: command.bossIds, difficultySlug: command.difficultySlug } })); break; case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: { bossIds: command.bossIds, difficultySlug: command.difficultySlug } })); break;
} }
} }
@@ -132,10 +139,14 @@ export function receiveAuthoritativeMessage(message: DualScreenMessage) {
/** State the lower display actually renders. Renderer-only and progression data stay local to the authoritative screen. */ /** State the lower display actually renders. Renderer-only and progression data stay local to the authoritative screen. */
export type BottomGameSnapshot = Pick<GameState, export type BottomGameSnapshot = Pick<GameState,
| "bossId" | "bossId"
| "bossInstanceId"
| "paused" | "paused"
| "healerClassId" | "healerClassId"
| "phase" | "phase"
| "round" | "round"
| "endlessMode"
| "endlessBossKills"
| "endlessChoiceSelection"
| "runModifiers" | "runModifiers"
| "time" | "time"
| "party" | "party"
@@ -158,7 +169,7 @@ export type BottomGameSnapshot = Pick<GameState,
>; >;
const BOTTOM_GAME_SNAPSHOT_KEYS: readonly (keyof BottomGameSnapshot)[] = [ const BOTTOM_GAME_SNAPSHOT_KEYS: readonly (keyof BottomGameSnapshot)[] = [
"bossId", "paused", "healerClassId", "phase", "round", "runModifiers", "time", "party", "boss", "additionalBosses", "bossId", "bossInstanceId", "paused", "healerClassId", "phase", "round", "endlessMode", "endlessBossKills", "endlessChoiceSelection", "runModifiers", "time", "party", "boss", "additionalBosses",
"partyPositions", "bossMotion", "partyCombat", "mana", "maxMana", "selectedMemberId", "cooldowns", "partyPositions", "bossMotion", "partyCombat", "mana", "maxMana", "selectedMemberId", "cooldowns",
"globalCooldownUntil", "activeTab", "selectedItemId", "inventory", "playerPosition", "activeCast", "barrier", "globalCooldownUntil", "activeTab", "selectedItemId", "inventory", "playerPosition", "activeCast", "barrier",
]; ];
@@ -184,10 +195,14 @@ export function currentBottomGameSnapshot(): BottomGameSnapshot {
const state = useGameStore.getState(); const state = useGameStore.getState();
return { return {
bossId: state.bossId, bossId: state.bossId,
bossInstanceId: state.bossInstanceId,
paused: state.paused, paused: state.paused,
healerClassId: state.healerClassId, healerClassId: state.healerClassId,
phase: state.phase, phase: state.phase,
round: state.round, round: state.round,
endlessMode: state.endlessMode,
endlessBossKills: state.endlessBossKills,
endlessChoiceSelection: state.endlessChoiceSelection,
runModifiers: state.runModifiers, runModifiers: state.runModifiers,
time: state.time, time: state.time,
party: state.party, party: state.party,
+104 -46
View File
@@ -20,6 +20,7 @@
/* Android lays out at logical CSS size, not AMOLED framebuffer resolution. */ /* Android lays out at logical CSS size, not AMOLED framebuffer resolution. */
--thor-main-css-width: 960px; --thor-main-css-width: 960px;
--thor-secondary-width-ratio: 64.583333%; --thor-secondary-width-ratio: 64.583333%;
--thor-top-bottom-overscan: 8px;
} }
* { * {
@@ -1075,6 +1076,7 @@ button:focus-visible {
.end-actions { display: flex; gap: 9px; margin-top: 18px; } .end-actions { display: flex; gap: 9px; margin-top: 18px; }
.end-actions button { padding: 9px 18px; border: 1px solid var(--gold); color: #13170f; background: var(--gold); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; } .end-actions button { padding: 9px 18px; border: 1px solid var(--gold); color: #13170f; background: var(--gold); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; }
.end-actions button.secondary { color: #9eafa8; background: transparent; border-color: #43564f; } .end-actions button.secondary { color: #9eafa8; background: transparent; border-color: #43564f; }
.end-actions button.is-controller-focused { outline: 2px solid #fff1b6; outline-offset: 2px; }
.buff-draft { .buff-draft {
height: 100%; height: 100%;
@@ -1361,6 +1363,13 @@ button:focus-visible {
background: #030706; background: #030706;
} }
/* Thor top panel hides a thin lower edge in immersive mode. Keep content and
render surface above that measured strip, plus any Android-reported inset. */
.native-platform .surface-slot.top-slot,
.native-platform[data-display-surface="top"] .dedicated-display-surface {
padding-bottom: max(var(--thor-top-bottom-overscan), env(safe-area-inset-bottom, 0px));
}
.native-platform .surface-slot.is-active { .native-platform .surface-slot.is-active {
display: grid; display: grid;
} }
@@ -1667,32 +1676,36 @@ button:focus-visible {
.login-panel .front-secondary { min-height: 40px; padding-top: 6px; padding-bottom: 6px; } .login-panel .front-secondary { min-height: 40px; padding-top: 6px; padding-bottom: 6px; }
.login-surface > .front-notice { position: absolute; right: 44px; bottom: 83px; width: 300px; } .login-surface > .front-notice { position: absolute; right: 44px; bottom: 83px; width: 300px; }
.login-surface > .controller-legend { position: absolute; right: 44px; bottom: 47px; } .login-surface > .controller-legend { position: absolute; right: 44px; bottom: 47px; }
.login-context { padding: 0; } .login-save-context { padding: 0 5.5% 18px; }
.login-context > .front-brand { margin: 22px 5.5% 0; } .login-save-context .context-header { margin: 0 -5.8%; }
.offline-promise { margin: 32px 7% 0; } .login-save-list { display: grid; gap: 9px; margin-top: 16px; }
.context-kicker { color: var(--gold); font-size: 9px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; } .login-save-list article { min-height: 105px; display: grid; grid-template-columns: 30px 52px minmax(0, 1fr) auto; align-items: center; gap: 11px; padding: 11px 13px; border: 1px solid rgba(150,190,175,.18); border-left: 2px solid rgba(232,200,114,.52); background: linear-gradient(100deg, rgba(23,48,40,.66), rgba(7,18,15,.78)); }
.offline-promise ol { display: grid; gap: 13px; margin: 14px 0 0; padding: 0; list-style: none; } .login-save-list article.is-empty { grid-template-columns: 30px minmax(0, 1fr); border-left-color: #425850; background: rgba(6,16,13,.62); }
.offline-promise li { display: grid; grid-template-columns: 35px 1fr; align-items: center; gap: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--line); } .login-save-list article > b { color: #61776e; font-family: "Cinzel", serif; font-size: 15px; font-weight: 500; }
.offline-promise li > b { color: #536a61; font-family: "Cinzel", serif; font-size: 17px; } .login-save-avatar { width: 48px; height: 48px; display: grid; place-items: center; border: 1px solid rgba(232,200,114,.48); border-radius: 50%; color: var(--gold-strong); background: radial-gradient(circle at 50% 28%, #2c493f, #0b1a17); font: 20px "Cinzel", serif; }
.offline-promise li > span { display: grid; } .login-save-list article > span { min-width: 0; display: grid; }
.offline-promise li strong { font-size: clamp(11px, 2.35cqw, 14px); letter-spacing: 0.03em; } .login-save-list article small { color: #6d8279; font-size: clamp(7px, 1.45cqw, 9px); font-style: normal; letter-spacing: .08em; text-transform: uppercase; }
.offline-promise li small { color: #748a81; font-size: clamp(8px, 1.7cqw, 10px); } .login-save-list article span > strong { overflow: hidden; font: 500 clamp(13px, 2.8cqw, 17px) "Cinzel", serif; text-overflow: ellipsis; white-space: nowrap; }
.device-route { position: absolute; right: 7%; bottom: 25px; left: 7%; display: flex; align-items: center; justify-content: center; gap: 13px; color: #70857c; font-size: 9px; font-weight: 700; letter-spacing: 0.1em; } .login-save-list article em { overflow: hidden; color: #8fa49b; font-size: clamp(8px, 1.7cqw, 10px); font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
.device-route i { color: var(--gold); font-style: normal; } .login-save-list time { min-width: 86px; display: grid; justify-items: end; }
.device-route b { padding: 7px 10px; border: 1px solid #486159; color: #b8c9c2; background: #0b1b17; font-size: 8px; } .login-save-list time strong { color: var(--gold); font-size: clamp(9px, 1.9cqw, 11px); }
.login-empty-copy { grid-column: 2 / -1; }
.login-save-footer { position: absolute; right: 5.5%; bottom: 19px; left: 5.5%; display: flex; justify-content: space-between; padding-top: 10px; border-top: 1px solid var(--line); color: #60756d; font-size: clamp(6px, 1.3cqw, 8px); letter-spacing: .08em; text-transform: uppercase; }
.login-save-footer b { color: #78988c; }
/* Save management */ /* Save management */
.save-surface { padding: 0 30px; } .save-surface { padding: 0 30px; }
.save-slot-grid { height: 385px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; align-items: center; } .save-slot-grid { height: 326px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; align-items: center; }
.save-slot { position: relative; height: 300px; display: flex; flex-direction: column; align-items: center; padding: 18px 16px; overflow: hidden; text-align: center; transition: transform 140ms ease, border-color 140ms ease; } .save-slot { position: relative; height: 265px; display: flex; flex-direction: column; align-items: center; padding: 14px 16px; overflow: hidden; text-align: center; transition: transform 140ms ease, border-color 140ms ease; }
.save-slot::before { position: absolute; inset: 0; content: ""; background: radial-gradient(circle at 50% 36%, rgba(95,177,154,0.13), transparent 42%), linear-gradient(180deg, rgba(25, 48, 41, 0.55), rgba(7, 18, 15, 0.86)); } .save-slot::before { position: absolute; inset: 0; content: ""; background: radial-gradient(circle at 50% 36%, rgba(95,177,154,0.13), transparent 42%), linear-gradient(180deg, rgba(25, 48, 41, 0.55), rgba(7, 18, 15, 0.86)); }
.save-slot > * { position: relative; } .save-slot > * { position: relative; }
.save-slot:hover, .save-slot:hover,
.save-slot.is-selected { border-color: rgba(232,200,114,0.68); transform: translateY(-4px); } .save-slot.is-selected { border-color: rgba(232,200,114,0.68); transform: translateY(-4px); }
.save-slot.is-selected::after { position: absolute; inset: 5px; border: 1px solid rgba(232,200,114,0.18); content: ""; pointer-events: none; } .save-slot.is-selected::after { position: absolute; inset: 5px; border: 1px solid rgba(232,200,114,0.18); content: ""; pointer-events: none; }
.slot-number { align-self: stretch; padding-bottom: 10px; border-bottom: 1px solid var(--line); color: #83978f; font-size: 9px; font-weight: 700; letter-spacing: 0.15em; text-align: left; text-transform: uppercase; } .slot-number { align-self: stretch; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-bottom: 10px; border-bottom: 1px solid var(--line); color: #83978f; font-size: 9px; font-weight: 700; letter-spacing: 0.15em; text-align: left; text-transform: uppercase; }
.slot-portrait { width: 75px; height: 75px; display: grid; place-items: center; margin-top: 19px; border: 1px solid rgba(232,200,114,0.55); border-radius: 50%; color: var(--gold-strong); background: radial-gradient(circle at 50% 30%, #304d42, #0c1c18); font-family: "Cinzel", serif; font-size: 31px; box-shadow: 0 0 25px rgba(81,176,151,0.13); } .slot-number b { padding: 2px 4px; color: #87d9b9; background: rgba(62,133,109,.17); font-size: 6px; letter-spacing: .08em; white-space: nowrap; }
.slot-portrait { width: 68px; height: 68px; display: grid; place-items: center; margin-top: 14px; border: 1px solid rgba(232,200,114,0.55); border-radius: 50%; color: var(--gold-strong); background: radial-gradient(circle at 50% 30%, #304d42, #0c1c18); font-family: "Cinzel", serif; font-size: 29px; box-shadow: 0 0 25px rgba(81,176,151,0.13); }
.slot-portrait i { position: absolute; right: -3px; bottom: 0; width: 23px; height: 23px; display: grid; place-items: center; border-radius: 50%; color: #192019; background: var(--gold); font-size: 10px; font-style: normal; } .slot-portrait i { position: absolute; right: -3px; bottom: 0; width: 23px; height: 23px; display: grid; place-items: center; border-radius: 50%; color: #192019; background: var(--gold); font-size: 10px; font-style: normal; }
.slot-name { display: grid; margin-top: 12px; } .slot-name { display: grid; margin-top: 12px; }
.slot-name strong { font-family: "Cinzel", serif; font-size: 17px; font-weight: 500; } .slot-name strong { font-family: "Cinzel", serif; font-size: 17px; font-weight: 500; }
@@ -1707,8 +1720,14 @@ button:focus-visible {
.empty-slot b { color: #6f8c81; font-size: 36px; font-weight: 300; } .empty-slot b { color: #6f8c81; font-size: 36px; font-weight: 300; }
.empty-slot strong { font-family: "Cinzel", serif; font-size: 14px; font-weight: 500; } .empty-slot strong { font-family: "Cinzel", serif; font-size: 14px; font-weight: 500; }
.empty-slot small { color: #5e736b; font-size: 8px; text-transform: uppercase; } .empty-slot small { color: #5e736b; font-size: 8px; text-transform: uppercase; }
.save-footer { display: flex; align-items: center; justify-content: space-between; padding: 8px 2px; border-top: 1px solid var(--line); color: #6e847b; font-size: 8px; letter-spacing: 0.08em; text-transform: uppercase; } .save-top-actions { display: grid; grid-template-columns: 1.75fr repeat(5, minmax(0, 1fr)); gap: 7px; }
.save-top-actions button { min-width: 0; min-height: 55px; display: grid; align-content: center; padding: 7px 9px; text-align: left; }
.save-top-actions button:not(.front-primary) strong { overflow: hidden; font-size: 9px; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; }
.save-top-actions button:not(.front-primary) small { overflow: hidden; color: #6d827a; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.save-top-actions .danger-link strong { color: #ed8c78; }
.save-footer { min-height: 31px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 16px; padding: 7px 2px 5px; border-top: 1px solid var(--line); color: #6e847b; font-size: 8px; letter-spacing: 0.08em; text-transform: uppercase; }
.save-footer > span b { color: #72cba9; } .save-footer > span b { color: #72cba9; }
.save-top-notice { overflow: hidden; color: #82978f; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.front-dialog { position: absolute; inset: 0; z-index: 5; display: grid; place-content: center; padding: 0 calc(50% - 190px); background: rgba(2,8,7,0.85); backdrop-filter: blur(7px); text-align: center; } .front-dialog { position: absolute; inset: 0; z-index: 5; display: grid; place-content: center; padding: 0 calc(50% - 190px); background: rgba(2,8,7,0.85); backdrop-filter: blur(7px); text-align: center; }
.front-dialog::before { position: absolute; top: 80px; right: calc(50% - 210px); bottom: 70px; left: calc(50% - 210px); z-index: -1; border: 1px solid rgba(232,200,114,0.35); border-top: 2px solid var(--gold); content: ""; background: #0b1916; box-shadow: 0 22px 60px rgba(0,0,0,0.6); } .front-dialog::before { position: absolute; top: 80px; right: calc(50% - 210px); bottom: 70px; left: calc(50% - 210px); z-index: -1; border: 1px solid rgba(232,200,114,0.35); border-top: 2px solid var(--gold); content: ""; background: #0b1916; box-shadow: 0 22px 60px rgba(0,0,0,0.6); }
.front-dialog > span { color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; } .front-dialog > span { color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; }
@@ -1724,6 +1743,24 @@ button:focus-visible {
.dialog-actions button { min-width: 110px; padding: 10px 14px; font-weight: 700; } .dialog-actions button { min-width: 110px; padding: 10px 14px; font-weight: 700; }
.dialog-actions button small { display: block; color: #748a81; font-size: 7px; text-transform: uppercase; } .dialog-actions button small { display: block; color: #748a81; font-size: 7px; text-transform: uppercase; }
.dialog-actions button.is-danger { border-color: #d76551; color: #fff; background: #8e3025; } .dialog-actions button.is-danger { border-color: #d76551; color: #fff; background: #8e3025; }
.front-dialog.version-dialog { grid-template-columns: minmax(0, 630px); padding-right: calc(50% - 315px); padding-left: calc(50% - 315px); }
.front-dialog.version-dialog::before { top: 45px; right: calc(50% - 345px); bottom: 45px; left: calc(50% - 345px); }
.version-choice-dialog { width: 100%; }
.version-choice-dialog > span { color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: .15em; text-transform: uppercase; }
.version-choice-dialog h2 { margin: 5px 0; }
.version-choice-dialog > p { font-size: 10px; }
.version-comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 13px; text-align: left; }
.version-comparison article { display: grid; gap: 3px; padding: 11px 13px; border: 1px solid var(--line); background: rgba(5,16,13,.75); }
.version-comparison article.is-newer { border-color: rgba(111,208,168,.55); box-shadow: inset 3px 0 #6fd0a8; background: rgba(29,74,59,.2); }
.version-comparison header { display: flex; justify-content: space-between; color: #788d85; font-size: 7px; letter-spacing: .11em; text-transform: uppercase; }
.version-comparison header b { color: #70d0a8; }
.version-comparison article > strong { font: 500 13px "Cinzel", serif; }
.version-comparison time { color: var(--gold-strong); font-size: 11px; }
.version-comparison article > small { color: #6f847c; font-size: 8px; }
.version-actions { display: grid; grid-template-columns: 1.35fr 1.35fr .7fr; }
.version-actions button { min-width: 0; min-height: 47px; display: grid; align-content: center; text-align: left; }
.version-actions button > span { font-size: 10px; }
.version-choice-error { margin-top: 8px; padding: 6px 9px; border-left: 2px solid #e5634d; color: #f0a594; background: rgba(95,31,23,.2); font-size: 8px; text-align: left; }
.save-context { padding: 0 5.5% 18px; } .save-context { padding: 0 5.5% 18px; }
.save-context .context-header { margin: 0 -5.8%; } .save-context .context-header { margin: 0 -5.8%; }
.selected-save-summary { min-height: 105px; display: grid; grid-template-columns: 66px 1fr; align-items: center; gap: 15px; padding: 16px 0 11px; border-bottom: 1px solid var(--line); } .selected-save-summary { min-height: 105px; display: grid; grid-template-columns: 66px 1fr; align-items: center; gap: 15px; padding: 16px 0 11px; border-bottom: 1px solid var(--line); }
@@ -1734,18 +1771,21 @@ button:focus-visible {
.selected-save-summary h2 { margin: 2px 0 0; font-family: "Cinzel", serif; font-size: clamp(16px, 3.4cqw, 21px); font-weight: 500; } .selected-save-summary h2 { margin: 2px 0 0; font-family: "Cinzel", serif; font-size: clamp(16px, 3.4cqw, 21px); font-weight: 500; }
.selected-save-summary p { margin: 2px 0; overflow: hidden; color: #9dafA8; font-size: clamp(8px, 1.9cqw, 11px); text-overflow: ellipsis; white-space: nowrap; } .selected-save-summary p { margin: 2px 0; overflow: hidden; color: #9dafA8; font-size: clamp(8px, 1.9cqw, 11px); text-overflow: ellipsis; white-space: nowrap; }
.selected-save-summary time { color: #60756d; font-size: 8px; } .selected-save-summary time { color: #60756d; font-size: 8px; }
.online-record { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; border: 1px solid rgba(89,181,151,0.26); color: #9eb1aa; background: rgba(39,96,78,0.13); font-size: clamp(8px, 1.7cqw, 10px); } .save-dossier-stats { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 13px; border: 1px solid var(--line); background: rgba(6,17,14,.68); }
.online-record span { display: flex; gap: 7px; } .save-dossier-stats > span { min-width: 0; display: grid; padding: 11px 10px; border-right: 1px solid var(--line); }
.online-record b { color: #6ad0a8; font-size: 7px; letter-spacing: 0.1em; } .save-dossier-stats > span:last-child { border: 0; }
.online-record time { color: #6f847c; font-size: 8px; } .save-dossier-stats small,
.save-actions { display: grid; gap: 8px; margin-top: 10px; } .save-dossier-records small { color: #62776f; font-size: clamp(6px, 1.35cqw, 8px); letter-spacing: .07em; text-transform: uppercase; }
.save-actions .front-primary { min-height: 42px; } .save-dossier-stats strong { color: var(--gold-strong); font: 500 clamp(15px, 3.1cqw, 19px) "Cinzel", serif; }
.sync-actions, .save-dossier-stats em { overflow: hidden; color: #80958d; font-size: clamp(7px, 1.5cqw, 9px); font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
.record-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; } .save-dossier-records { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
.sync-actions button, .save-dossier-records > span { display: grid; padding: 9px 11px; border-left: 2px solid rgba(232,200,114,.45); background: rgba(46,40,21,.24); }
.record-actions button { min-height: 34px; padding: 6px 7px; font-size: clamp(8px, 1.7cqw, 10px); font-weight: 700; } .save-dossier-records b { color: #c7d6d0; font-size: clamp(9px, 1.9cqw, 11px); }
.record-actions { grid-template-columns: 1fr 1fr 0.7fr; } .save-copy-state { display: grid; gap: 6px; margin-top: 12px; }
.record-actions .danger-link { color: #ed8c78; } .save-copy-state > span { display: grid; grid-template-columns: 8px auto 1fr; align-items: center; gap: 7px; padding: 7px 9px; border: 1px solid var(--line); color: #90a49c; font-size: clamp(7px, 1.55cqw, 9px); text-transform: uppercase; }
.save-copy-state i { width: 6px; height: 6px; border-radius: 50%; background: #455750; }
.save-copy-state i.is-present { background: #6fd0a8; box-shadow: 0 0 8px rgba(111,208,168,.4); }
.save-copy-state b { justify-self: end; color: #647970; font-size: clamp(6px, 1.35cqw, 8px); font-weight: 600; }
.front-notice.is-lower { margin-top: auto; font-size: clamp(7px, 1.55cqw, 9px); } .front-notice.is-lower { margin-top: auto; font-size: clamp(7px, 1.55cqw, 9px); }
/* Main menu */ /* Main menu */
@@ -1755,10 +1795,7 @@ button:focus-visible {
.home-header > span { margin-left: auto; color: #8fa39b; font-size: 10px; } .home-header > span { margin-left: auto; color: #8fa39b; font-size: 10px; }
.home-header > span b { color: #dce9e4; } .home-header > span b { color: #dce9e4; }
.home-header > i { color: #6ecaa7; font-size: 8px; font-style: normal; font-weight: 700; letter-spacing: 0.08em; } .home-header > i { color: #6ecaa7; font-size: 8px; font-style: normal; font-weight: 700; letter-spacing: 0.08em; }
.home-title { padding: 17px 2px 12px; } .mode-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(2, 78px); gap: 10px; margin-top: 18px; }
.home-title span { color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; }
.home-title h1 { margin: 2px 0 0; font-family: "Cinzel", serif; font-size: 25px; font-weight: 500; }
.mode-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(2, 78px); gap: 10px; }
.mode-card { position: relative; display: grid; grid-template-columns: 54px 1fr 17px; align-items: center; gap: 12px; padding: 13px; overflow: hidden; text-align: left; } .mode-card { position: relative; display: grid; grid-template-columns: 54px 1fr 17px; align-items: center; gap: 12px; padding: 13px; overflow: hidden; text-align: left; }
.mode-card::after { position: absolute; inset: 0; content: ""; background: linear-gradient(110deg, rgba(69,153,131,0.13), transparent 60%); pointer-events: none; } .mode-card::after { position: absolute; inset: 0; content: ""; background: linear-gradient(110deg, rgba(69,153,131,0.13), transparent 60%); pointer-events: none; }
.mode-card.is-wide { grid-row: 1 / 3; } .mode-card.is-wide { grid-row: 1 / 3; }
@@ -1869,7 +1906,7 @@ button:focus-visible {
.boss-stats-heading { padding-top: 10px; } .boss-stats-heading { padding-top: 10px; }
.boss-stats-layout { height: 331px; display: grid; grid-template-columns: minmax(235px, .78fr) minmax(0, 1.22fr); gap: 11px; } .boss-stats-layout { height: 331px; display: grid; grid-template-columns: minmax(235px, .78fr) minmax(0, 1.22fr); gap: 11px; }
.boss-stat-selector { min-width: 0; display: grid; align-content: start; gap: 5px; } .boss-stat-selector { min-width: 0; display: grid; align-content: start; gap: 5px; }
.boss-stat-selector button { width: 100%; min-height: 52px; display: grid; grid-template-columns: 30px minmax(0, 1fr) 32px; align-items: center; gap: 8px; padding: 6px 9px; text-align: left; } .boss-stat-selector button { width: 100%; min-height: 51px; display: grid; grid-template-columns: 30px minmax(0, 1fr) 32px; align-items: center; gap: 8px; padding: 6px 9px; text-align: left; }
.boss-stat-selector button.is-selected { border-color: var(--gold); box-shadow: inset 3px 0 var(--gold); background: linear-gradient(90deg, rgba(93,73,23,.27), rgba(8,18,15,.88)); } .boss-stat-selector button.is-selected { border-color: var(--gold); box-shadow: inset 3px 0 var(--gold); background: linear-gradient(90deg, rgba(93,73,23,.27), rgba(8,18,15,.88)); }
.boss-stat-selector button > i { width: 27px; height: 27px; display: grid; place-items: center; border: 1px solid #496159; color: var(--gold); font-style: normal; } .boss-stat-selector button > i { width: 27px; height: 27px; display: grid; place-items: center; border: 1px solid #496159; color: var(--gold); font-style: normal; }
.boss-stat-selector button > span { min-width: 0; display: grid; } .boss-stat-selector button > span { min-width: 0; display: grid; }
@@ -1895,7 +1932,7 @@ button:focus-visible {
.leaderboard-empty { min-height: 200px !important; border: 0 !important; } .leaderboard-empty { min-height: 200px !important; border: 0 !important; }
.profile-context { padding: 0 5.5% 18px; } .profile-context { padding: 0 5.5% 18px; }
.profile-context .context-header { margin: 0 -5.8%; } .profile-context .context-header { margin: 0 -5.8%; }
.profile-stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); border-bottom: 1px solid var(--line); } .profile-stats { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); border-bottom: 1px solid var(--line); }
.profile-stats span { min-width: 0; display: grid; padding: 8px 6px; border-right: 1px solid var(--line); } .profile-stats span { min-width: 0; display: grid; padding: 8px 6px; border-right: 1px solid var(--line); }
.profile-stats span:last-child { border-right: 0; } .profile-stats span:last-child { border-right: 0; }
.profile-stats small { color: #6d8179; font-size: clamp(7px, 1.5cqw, 9px); text-transform: uppercase; } .profile-stats small { color: #6d8179; font-size: clamp(7px, 1.5cqw, 9px); text-transform: uppercase; }
@@ -1942,10 +1979,10 @@ button:focus-visible {
.pad-diagram b, .pad-diagram b,
.face-diagram b { color: #40534c; } .face-diagram b { color: #40534c; }
.face-diagram i { width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid currentColor; border-radius: 50%; font-size: 10px; font-style: normal; } .face-diagram i { width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid currentColor; border-radius: 50%; font-size: 10px; font-style: normal; }
.face-diagram .a { color: #67c394; } .face-diagram .triangle { color: #70c18b; }
.face-diagram .b { color: #d66b61; } .face-diagram .circle { color: #dc6f78; }
.face-diagram .x { color: #5db1d0; } .face-diagram .cross { color: #78a9dd; }
.face-diagram .y { color: #d6ba67; } .face-diagram .square { color: #cf8fc5; }
.mapping-list { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 15px; } .mapping-list { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 15px; }
.mapping-list span { padding: 8px; border: 1px solid var(--line); color: #83978f; font-size: clamp(8px, 1.7cqw, 10px); } .mapping-list span { padding: 8px; border: 1px solid var(--line); color: #83978f; font-size: clamp(8px, 1.7cqw, 10px); }
.mapping-list b { color: #d6e3de; } .mapping-list b { color: #d6e3de; }
@@ -2069,7 +2106,7 @@ button:focus-visible {
.profile-header { grid-template-columns: 125px minmax(0, 1fr) auto auto; } .profile-header { grid-template-columns: 125px minmax(0, 1fr) auto auto; }
.profile-view-tabs { gap: 2px; } .profile-view-tabs { gap: 2px; }
.profile-view-tabs button { min-height: 22px; padding: 3px 5px; font-size: 5px; } .profile-view-tabs button { min-height: 22px; padding: 3px 5px; font-size: 5px; }
.save-slot-grid { height: calc(100% - 75px); gap: 5px; } .save-slot-grid { height: calc(100% - 125px); gap: 5px; }
.save-slot { height: 84%; padding: 7px 5px; } .save-slot { height: 84%; padding: 7px 5px; }
.slot-portrait { width: 36px; height: 36px; margin-top: 7px; font-size: 14px; } .slot-portrait { width: 36px; height: 36px; margin-top: 7px; font-size: 14px; }
.slot-portrait i { width: 13px; height: 13px; font-size: 5px; } .slot-portrait i { width: 13px; height: 13px; font-size: 5px; }
@@ -2079,13 +2116,34 @@ button:focus-visible {
.slot-meta b { font-size: 7px; } .slot-meta b { font-size: 7px; }
.empty-slot b { font-size: 19px; } .empty-slot b { font-size: 19px; }
.empty-slot strong { font-size: 8px; } .empty-slot strong { font-size: 8px; }
.save-footer { position: absolute; right: 12px; bottom: 3px; left: 12px; } .save-top-actions { grid-template-columns: 1.5fr repeat(5, minmax(0, 1fr)); gap: 3px; }
.save-top-actions button { min-height: 38px; padding: 3px 4px; }
.save-top-actions .front-primary { min-height: 38px; }
.save-top-actions .front-primary span,
.save-top-actions button:not(.front-primary) strong { font-size: 6px; }
.save-top-actions .front-primary small,
.save-top-actions button:not(.front-primary) small { display: none; }
.save-footer { min-height: 18px; gap: 5px; padding: 3px 1px; font-size: 5px; }
.save-top-notice { display: none; }
.save-footer .controller-legend { display: none; } .save-footer .controller-legend { display: none; }
.front-dialog.version-dialog { grid-template-columns: minmax(0, 1fr); padding-right: 8%; padding-left: 8%; }
.front-dialog.version-dialog::before { top: 16px; right: 6%; bottom: 16px; left: 6%; }
.version-choice-dialog > span { font-size: 7px; }
.version-choice-dialog h2 { margin: 3px 0; font-size: 18px; }
.version-choice-dialog > p { font-size: 8px; }
.version-comparison { gap: 6px; margin-top: 8px; }
.version-comparison article { gap: 2px; padding: 8px 9px; }
.version-comparison header { font-size: 6px; }
.version-comparison article > strong { font-size: 10px; }
.version-comparison time { font-size: 8px; }
.version-comparison article > small { font-size: 6px; }
.version-actions { gap: 4px; margin-top: 7px; }
.version-actions button { min-width: 0; min-height: 38px; padding: 5px 7px; }
.version-actions button > span { font-size: 8px; }
.version-actions button small { font-size: 6px; }
.home-header { height: 35px; } .home-header { height: 35px; }
.home-header > span, .home-header > i { font-size: 5px; } .home-header > span, .home-header > i { font-size: 5px; }
.home-title { padding: 6px 0; } .mode-grid { grid-template-rows: repeat(2, 43px); gap: 5px; margin-top: 6px; }
.home-title h1 { font-size: 12px; }
.mode-grid { grid-template-rows: repeat(2, 43px); gap: 5px; }
.mode-card { grid-template-columns: 25px 1fr 8px; gap: 4px; padding: 4px; } .mode-card { grid-template-columns: 25px 1fr 8px; gap: 4px; padding: 4px; }
.mode-card > i, .mode-card.is-wide > i { width: 23px; height: 23px; font-size: 10px; } .mode-card > i, .mode-card.is-wide > i { width: 23px; height: 23px; font-size: 10px; }
.mode-card strong, .mode-card.is-wide strong { font-size: 8px; } .mode-card strong, .mode-card.is-wide strong { font-size: 8px; }
@@ -2134,7 +2192,7 @@ button:focus-visible {
.boss-stats-heading { padding-top: 3px; } .boss-stats-heading { padding-top: 3px; }
.boss-stats-layout { height: 213px; grid-template-columns: minmax(138px, .82fr) minmax(0, 1.18fr); gap: 4px; } .boss-stats-layout { height: 213px; grid-template-columns: minmax(138px, .82fr) minmax(0, 1.18fr); gap: 4px; }
.boss-stat-selector { gap: 2px; } .boss-stat-selector { gap: 2px; }
.boss-stat-selector button { min-height: 34px; grid-template-columns: 18px minmax(0, 1fr) 18px; gap: 3px; padding: 2px 4px; } .boss-stat-selector button { min-height: 33px; grid-template-columns: 18px minmax(0, 1fr) 18px; gap: 3px; padding: 2px 4px; }
.boss-stat-selector button > i { width: 16px; height: 16px; font-size: 7px; } .boss-stat-selector button > i { width: 16px; height: 16px; font-size: 7px; }
.boss-stat-selector strong { font-size: 6px; } .boss-stat-selector strong { font-size: 6px; }
.boss-stat-selector small { font-size: 4px; } .boss-stat-selector small { font-size: 4px; }