Compare commits

...
11 Commits
Author SHA1 Message Date
Warren H ea5d4550a5 Release v0.1.15 2026-07-18 2026-07-18 20:37:45 -04:00
Warren H 40aa23be46 new help section explaining classes and their mechanics 2026-07-18 12:05:34 -04:00
Warren H 638aef22b2 updated some performance stuff 2026-07-17 15:39:31 -04:00
Warren H 10ff124466 new rpg mode 2026-07-17 12:47:42 -04:00
Warren H de7b59ce77 Release v0.1.14 2026-07-13 2026-07-13 23:19:40 -04:00
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
237 changed files with 26374 additions and 1460 deletions
+1
View File
@@ -12,3 +12,4 @@ vite.config.d.ts
!.env.example
__pycache__/
*.py[cod]
/public/basis/
+26
View File
@@ -138,6 +138,32 @@ outside the repository.
Touch controls on lower display support party targeting, ability casting, map, and inventory.
## Character model rollout and rollback
Healer characters use the modular `Rig_Medium` renderer by default. Version 1 composes
head, upper body, lower body, headwear, back item, main hand, and offhand slots while
continuing to use Aelia's shared animation set.
Load a hunter, then open **Appearance Lab** from the main menu. The upper display shows
the real in-game healer renderer with idle, walk, and cast previews. The lower display
selects healer class and cycles every available part. **Save look** persists the current
class, **Reset** restores its authored default, and **Cancel** discards drafts. **Compare
legacy** shows the previous whole-character model without deleting the modular selection.
Some source assets currently fuse related pieces, so version 1 exposes honest combined
slots such as face + hair, shirt + arms, and pants + shoes. These can split into finer
customization slots when compatible rigged assets are added.
The previous whole-GLB renderer remains intact during rollout. Use either rollback:
```text
?characterModels=legacy
VITE_CHARACTER_MODEL_MODE=legacy
```
The query changes one browser/app launch. The environment variable produces a legacy
build. Remove the switch to return to modular rendering.
## Current game scope
- Five-member AI party with Disc Priest healer
@@ -20,7 +20,7 @@ import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/** Routes every Thor controller event into one JavaScript input service. */
/** Routes every Thor controller event into one JavaScript input service without WebView focus. */
public abstract class ControllerBridgeActivity extends BridgeActivity {
private static final float AXIS_DEAD_ZONE = 0.45f;
private static final long REPEAT_THROTTLE_MS = 55L;
@@ -43,9 +43,6 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
getWindow().setAttributes(attributes);
if (bridge != null && bridge.getWebView() != null) {
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
bridge.getWebView().setFocusable(true);
bridge.getWebView().setFocusableInTouchMode(true);
bridge.getWebView().requestFocus();
}
enterImmersiveMode();
}
@@ -60,22 +57,6 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
public void onResume() {
super.onResume();
enterImmersiveMode();
if (bridge != null && bridge.getWebView() != null) bridge.getWebView().requestFocus();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) enterImmersiveMode();
else clearHeldControllerState();
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
if (event.getActionMasked() == MotionEvent.ACTION_DOWN && bridge != null) {
bridge.getWebView().requestFocus();
}
return super.dispatchTouchEvent(event);
}
@Override
@@ -165,10 +146,7 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
String script =
"window.dispatchEvent(new CustomEvent('iwt-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
bridge.getWebView().post(() -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
});
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null));
}
private void dispatchNativeControllerMotion(float moveX, float moveY, float lookX, float lookY) {
+74
View File
@@ -66,3 +66,77 @@ CREATE TABLE IF NOT EXISTS rogue_trials_endless_records (
CREATE INDEX IF NOT EXISTS rogue_trials_endless_rank_idx
ON rogue_trials_endless_records (highest_boss_kills DESC, updated_at ASC);
CREATE TABLE IF NOT EXISTS hockey_healing_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_returns INTEGER NOT NULL DEFAULT 0 CHECK (highest_returns >= 0),
duration_seconds REAL NOT NULL DEFAULT 0 CHECK (duration_seconds >= 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 hockey_healing_rank_idx
ON hockey_healing_records (highest_returns DESC, duration_seconds DESC, updated_at ASC);
CREATE TABLE IF NOT EXISTS hockey_pvp_records (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
wins INTEGER NOT NULL DEFAULT 0 CHECK (wins >= 0),
losses INTEGER NOT NULL DEFAULT 0 CHECK (losses >= 0),
boss_kills INTEGER NOT NULL DEFAULT 0 CHECK (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 hockey_pvp_wins_rank_idx
ON hockey_pvp_records (wins DESC, losses ASC, updated_at ASC);
CREATE INDEX IF NOT EXISTS hockey_pvp_boss_kills_rank_idx
ON hockey_pvp_records (boss_kills DESC, wins DESC, updated_at ASC);
CREATE TABLE IF NOT EXISTS blockbreaker_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_bricks INTEGER NOT NULL DEFAULT 0 CHECK (highest_bricks >= 0),
bricks_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
longest_seconds REAL NOT NULL DEFAULT 0 CHECK (longest_seconds >= 0),
time_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
highest_score INTEGER NOT NULL DEFAULT 0 CHECK (highest_score >= 0),
score_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
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 blockbreaker_bricks_rank_idx
ON blockbreaker_records (highest_bricks DESC, bricks_achieved_at ASC, account_id ASC, slot_id ASC);
CREATE INDEX IF NOT EXISTS blockbreaker_time_rank_idx
ON blockbreaker_records (longest_seconds DESC, time_achieved_at ASC, account_id ASC, slot_id ASC);
CREATE INDEX IF NOT EXISTS blockbreaker_score_rank_idx
ON blockbreaker_records (highest_score DESC, score_achieved_at ASC, account_id ASC, slot_id ASC);
CREATE TABLE IF NOT EXISTS aether_assault_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_score INTEGER NOT NULL DEFAULT 0 CHECK (highest_score >= 0),
wave_at_best INTEGER NOT NULL DEFAULT 0 CHECK (wave_at_best >= 0),
duration_at_best REAL NOT NULL DEFAULT 0 CHECK (duration_at_best >= 0),
score_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
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 aether_assault_rank_idx
ON aether_assault_records (
highest_score DESC,
wave_at_best DESC,
score_achieved_at ASC,
account_id ASC,
slot_id ASC
);
+12 -2
View File
@@ -1,13 +1,15 @@
{
"name": "i-want-to-heal",
"private": true,
"version": "0.1.7",
"version": "0.1.15",
"type": "module",
"scripts": {
"predev": "node scripts/sync_basis_transcoder.mjs",
"dev": "vite --host 0.0.0.0",
"dev:api": "HOST=127.0.0.1 PORT=4174 node server/production.mjs",
"db:backup": "node scripts/backup-db.mjs",
"db:init": "node scripts/init-db.mjs",
"prebuild": "node scripts/sync_basis_transcoder.mjs",
"build": "tsc -b && vite build",
"android:sync": "pnpm run build && cap sync android",
"android:sync:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com pnpm run android:sync",
@@ -18,8 +20,13 @@
"publish:gitea": "python3 scripts/publish_gitea.py",
"test": "vitest run && node --test server/game-api.test.mjs",
"test:watch": "vitest",
"assets:build-dungeon-kit": "node scripts/build_dungeon_kit.mjs",
"assets:build-gravehorn": "node scripts/build_gravehorn_triceratops.mjs",
"assets:build-ktx2": "node scripts/build_ktx2_game_assets.mjs",
"assets:build-priest-palette": "node scripts/build_priest_palette.mjs",
"assets:prune-party-animations": "node scripts/prune_party_animations.mjs --write",
"assets:import": "node scripts/import-game-asset.mjs"
"assets:import": "node scripts/import-game-asset.mjs",
"assets:sync-basis-transcoder": "node scripts/sync_basis_transcoder.mjs"
},
"dependencies": {
"@capacitor/android": "8.4.1",
@@ -29,17 +36,20 @@
"react": "^19.1.1",
"react-dom": "^19.1.1",
"three": "^0.179.1",
"three-stdlib": "2.36.1",
"zustand": "^5.0.8"
},
"devDependencies": {
"@capacitor/cli": "8.4.1",
"@gltf-transform/core": "^4.4.1",
"@gltf-transform/extensions": "^4.4.1",
"@gltf-transform/functions": "4.4.1",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@types/three": "^0.179.0",
"@vitejs/plugin-react": "^5.0.2",
"meshoptimizer": "^1.2.0",
"sharp": "0.34.5",
"typescript": "~5.8.3",
"vite": "^7.1.3",
"vitest": "^3.2.4"
+379
View File
@@ -29,6 +29,9 @@ importers:
three:
specifier: ^0.179.1
version: 0.179.1
three-stdlib:
specifier: 2.36.1
version: 2.36.1(three@0.179.1)
zustand:
specifier: ^5.0.8
version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
@@ -42,6 +45,9 @@ importers:
'@gltf-transform/extensions':
specifier: ^4.4.1
version: 4.4.1
'@gltf-transform/functions':
specifier: 4.4.1
version: 4.4.1
'@types/react':
specifier: ^19.1.10
version: 19.2.17
@@ -57,6 +63,9 @@ importers:
meshoptimizer:
specifier: ^1.2.0
version: 1.2.0
sharp:
specifier: 0.34.5
version: 0.34.5
typescript:
specifier: ~5.8.3
version: 5.8.3
@@ -172,6 +181,9 @@ packages:
'@dimforge/rapier3d-compat@0.12.0':
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
'@emnapi/runtime@1.11.2':
resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
@@ -334,6 +346,162 @@ packages:
'@gltf-transform/extensions@4.4.1':
resolution: {integrity: sha512-dZZ9D/NdpNeJUmQKExtISYtd3W6OxU4njk8UI3IKm6j97uVskYQ24BNi2YP40uUpdWPiREsS/DhjNhWAbGU/1A==}
'@gltf-transform/functions@4.4.1':
resolution: {integrity: sha512-CAU6hczuRz7NIEtpn7BARuwVEwRawwIcGqmoMCaEwA4XC+CSUi3xptXuf2zDG/5AftFO7IeQxXl/56rDsck0GQ==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@ionic/cli-framework-output@2.2.8':
resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==}
engines: {node: '>=16.0.0'}
@@ -601,6 +769,9 @@ packages:
'@types/fs-extra@8.1.5':
resolution: {integrity: sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==}
'@types/ndarray@1.0.14':
resolution: {integrity: sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg==}
'@types/node@26.1.1':
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
@@ -791,6 +962,9 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
cwise-compiler@1.1.3:
resolution: {integrity: sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -811,6 +985,10 @@ packages:
detect-gpu@5.0.70:
resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
draco3d@1.5.7:
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
@@ -908,6 +1086,12 @@ packages:
resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
iota-array@1.0.0:
resolution: {integrity: sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==}
is-buffer@1.1.6:
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
is-docker@2.2.1:
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
engines: {node: '>=8'}
@@ -1020,6 +1204,18 @@ packages:
engines: {node: '>=16.0.0'}
hasBin: true
ndarray-lanczos@0.3.0:
resolution: {integrity: sha512-5kBmmG3Zvyj77qxIAC4QFLKuYdDIBJwCG+DukT6jQHNa1Ft74/hPH1z5mbQXeHBt8yvGPBGVrr3wEOdJPYYZYg==}
ndarray-ops@1.2.2:
resolution: {integrity: sha512-BppWAFRjMYF7N/r6Ie51q6D4fs0iiGmeXIACKY66fLpnwIui3Wc3CXiD/30mgLbDjPpSLrsqcp3Z62+IcHZsDw==}
ndarray-pixels@5.0.1:
resolution: {integrity: sha512-IBtrpefpqlI8SPDCGjXk4v5NV5z7r3JSuCbfuEEXaM0vrOJtNGgYUa4C3Lt5H+qWdYF4BCPVFsnXhNC7QvZwkw==}
ndarray@1.0.19:
resolution: {integrity: sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==}
node-releases@2.0.50:
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
engines: {node: '>=18'}
@@ -1139,6 +1335,10 @@ packages:
engines: {node: '>=10'}
hasBin: true
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -1275,6 +1475,9 @@ packages:
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
uniq@1.0.1:
resolution: {integrity: sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==}
universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
@@ -1597,6 +1800,11 @@ snapshots:
'@dimforge/rapier3d-compat@0.12.0': {}
'@emnapi/runtime@1.11.2':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.28.1':
optional: true
@@ -1684,6 +1892,111 @@ snapshots:
'@gltf-transform/core': 4.4.1
ktx-parse: 1.1.0
'@gltf-transform/functions@4.4.1':
dependencies:
'@gltf-transform/core': 4.4.1
'@gltf-transform/extensions': 4.4.1
ktx-parse: 1.1.0
ndarray: 1.0.19
ndarray-lanczos: 0.3.0
ndarray-pixels: 5.0.1
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.11.2
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@ionic/cli-framework-output@2.2.8':
dependencies:
'@ionic/utils-terminal': 2.3.5
@@ -1958,6 +2271,8 @@ snapshots:
dependencies:
'@types/node': 26.1.1
'@types/ndarray@1.0.14': {}
'@types/node@26.1.1':
dependencies:
undici-types: 8.3.0
@@ -2146,6 +2461,10 @@ snapshots:
csstype@3.2.3: {}
cwise-compiler@1.1.3:
dependencies:
uniq: 1.0.1
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -2158,6 +2477,8 @@ snapshots:
dependencies:
webgl-constants: 1.1.1
detect-libc@2.1.2: {}
draco3d@1.5.7: {}
electron-to-chromium@1.5.389: {}
@@ -2259,6 +2580,10 @@ snapshots:
ini@4.1.3: {}
iota-array@1.0.0: {}
is-buffer@1.1.6: {}
is-docker@2.2.1: {}
is-fullwidth-code-point@3.0.0: {}
@@ -2357,6 +2682,27 @@ snapshots:
transitivePeerDependencies:
- supports-color
ndarray-lanczos@0.3.0:
dependencies:
'@types/ndarray': 1.0.14
ndarray: 1.0.19
ndarray-ops@1.2.2:
dependencies:
cwise-compiler: 1.1.3
ndarray-pixels@5.0.1:
dependencies:
'@types/ndarray': 1.0.14
ndarray: 1.0.19
ndarray-ops: 1.2.2
sharp: 0.34.5
ndarray@1.0.19:
dependencies:
iota-array: 1.0.0
is-buffer: 1.1.6
node-releases@2.0.50: {}
open@8.4.2:
@@ -2481,6 +2827,37 @@ snapshots:
semver@7.8.5: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -2609,6 +2986,8 @@ snapshots:
undici-types@8.3.0: {}
uniq@1.0.1: {}
universalify@2.0.1: {}
untildify@4.0.0: {}
+1
View File
@@ -1,2 +1,3 @@
allowBuilds:
esbuild: true
sharp: true
@@ -0,0 +1,352 @@
"""Build two original low-poly creature bosses as animated runtime GLBs.
Replaces weak chicken and frog visuals while keeping stable boss IDs in game data.
Run with:
/Applications/Blender.app/Contents/MacOS/Blender --background --factory-startup \
--python scripts/blender/build_replacement_creature_bosses.py
"""
from __future__ import annotations
import json
import math
import sys
from pathlib import Path
import bpy
sys.path.insert(0, str(Path(__file__).parent))
from build_iwt2_boss_trio import ( # noqa: E402
OUT_ROOT,
actions,
armature,
cone,
ellipsoid,
export_asset,
finish,
join_parts,
plate,
prepare_materials,
reset_scene,
)
def add_metadata(asset_id: str, concept: str) -> None:
metadata_path = OUT_ROOT / asset_id / f"{asset_id}.asset.json"
metadata = json.loads(metadata_path.read_text())
metadata["sourceConcept"] = concept
metadata["license"] = "Original project-owned asset"
metadata["runtime"]["forward"] = "-Y"
metadata["runtime"]["unit"] = "meters"
metadata_path.write_text(json.dumps(metadata, indent=2) + "\n")
def build_brassbeak_basilisk() -> None:
"""Six-legged forge basilisk replacing Cluckhorn's chicken-cow model."""
asset_id = "brassbeak-basilisk"
reset_scene()
mats = prepare_materials({
"Scale": {"color": (0.035, 0.105, 0.12, 1), "metallic": 0.14, "roughness": 0.62},
"Underbelly": {"color": (0.12, 0.19, 0.18, 1), "metallic": 0.05, "roughness": 0.72},
"Copper": {"color": (0.45, 0.16, 0.055, 1), "metallic": 0.48, "roughness": 0.33},
"Brass": {"color": (0.78, 0.48, 0.09, 1), "metallic": 0.62, "roughness": 0.25},
"Blade": {"color": (0.50, 0.58, 0.55, 1), "metallic": 0.72, "roughness": 0.2},
"Furnace": {"color": (0.02, 0.82, 0.72, 1), "roughness": 0.18, "emission": (0.01, 0.72, 0.64, 1), "strength": 5.5},
})
specs = [
("Root", (0, 0, 0), (0, 0, 0.45), None),
("Body", (0, 0.06, 1.18), (0, 0.05, 2.05), "Root"),
("Head", (0, -1.0, 1.48), (0, -1.82, 1.38), "Body"),
("Jaw", (0, -1.42, 1.28), (0, -2.05, 1.12), "Head"),
("Wing.L", (-0.62, -0.08, 1.72), (-1.55, -0.28, 1.5), "Body"),
("Wing.R", (0.62, -0.08, 1.72), (1.55, -0.28, 1.5), "Body"),
("Leg.FL", (-0.62, -0.72, 1.12), (-0.88, -0.84, 0.24), "Body"),
("Leg.FR", (0.62, -0.72, 1.12), (0.88, -0.84, 0.24), "Body"),
("Leg.ML", (-0.78, 0.03, 1.06), (-1.0, 0.02, 0.22), "Body"),
("Leg.MR", (0.78, 0.03, 1.06), (1.0, 0.02, 0.22), "Body"),
("Leg.BL", (-0.66, 0.76, 1.12), (-0.9, 0.88, 0.24), "Body"),
("Leg.BR", (0.66, 0.76, 1.12), (0.9, 0.88, 0.24), "Body"),
("Tail.1", (0, 1.0, 1.3), (0, 1.85, 1.12), "Body"),
("Tail.2", (0, 1.8, 1.12), (0, 2.7, 0.92), "Tail.1"),
]
rig = armature("BrassbeakBasilisk", specs)
# Broad armored silhouette with glowing furnace seams.
ellipsoid("BasiliskBody", (0, 0.08, 1.36), (1.08, 1.43, 0.72), mats["Scale"], "Body", 2)
ellipsoid("FurnaceBelly", (0, -0.15, 1.08), (0.78, 1.08, 0.46), mats["Underbelly"], "Body", 2)
for index, y in enumerate((-0.7, -0.22, 0.28, 0.76)):
width = 0.82 + (0.12 if index in (1, 2) else 0)
plate(
f"BackPlate{index}", (0, y, 1.92 + 0.08 * math.sin(index)),
(width, 0.55, 0.27), (math.radians(84), 0, 0),
mats["Copper"] if index % 2 == 0 else mats["Brass"], "Body",
)
cone(
f"ChimneySpine{index}", (0, y, 2.02), (0, y + 0.03, 2.52 - index * 0.04),
0.13, 0, mats["Blade"], "Body", 5,
)
for side in (-1, 1):
cone(
f"FurnaceSeam{side:+d}", (side * 0.58, -0.76, 1.34), (side * 0.72, 0.72, 1.34),
0.035, 0.022, mats["Furnace"], "Body", 5,
)
# Hammerhead, brass beak, split jaw, and crown blades.
ellipsoid("HammerHead", (0, -1.28, 1.5), (0.78, 0.74, 0.56), mats["Copper"], "Head", 2)
ellipsoid("FaceMask", (0, -1.72, 1.5), (0.58, 0.34, 0.42), mats["Brass"], "Head", 1)
cone("UpperBeak", (0, -1.68, 1.51), (0, -2.58, 1.34), 0.42, 0.035, mats["Brass"], "Head", 6)
cone("LowerBeak", (0, -1.64, 1.3), (0, -2.32, 1.18), 0.3, 0.025, mats["Blade"], "Jaw", 6)
for side, suffix in ((-1, "L"), (1, "R")):
ellipsoid(f"Eye{suffix}", (side * 0.43, -1.72, 1.66), (0.09, 0.055, 0.09), mats["Furnace"], "Head", 1)
cone(
f"BrowHorn{suffix}", (side * 0.42, -1.38, 1.78), (side * 0.88, -1.7, 2.03),
0.13, 0, mats["Blade"], "Head", 5,
)
for index, (x, z) in enumerate(((-0.34, 2.0), (0, 2.14), (0.34, 2.0))):
cone(f"CrownBlade{index}", (x, -1.2, 1.8), (x * 1.35, -1.15, z + 0.54), 0.12, 0, mats["Brass"], "Head", 5)
# Blade-like vestigial wings make lateral cleaves readable from camera.
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Wing.{suffix}"
plate(
f"WingShield{suffix}", (side * 1.05, -0.08, 1.62), (0.7, 0.56, 0.13),
(math.radians(78), math.radians(side * 18), math.radians(side * 8)), mats["Copper"], bone,
)
cone(
f"WingBlade{suffix}", (side * 0.72, -0.24, 1.7), (side * 1.95, -0.58, 1.42),
0.19, 0.015, mats["Blade"], bone, 6,
)
cone(
f"WingGlow{suffix}", (side * 0.88, -0.3, 1.69), (side * 1.7, -0.52, 1.5),
0.05, 0.008, mats["Furnace"], bone, 5,
)
# Six short piston legs: stable, strange, easy to read while scuttling.
leg_rows = (("F", -0.72), ("M", 0.03), ("B", 0.76))
for row_index, (row, y) in enumerate(leg_rows):
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Leg.{row}{suffix}"
hip_x = side * (0.64 if row != "M" else 0.78)
foot_x = side * (0.98 if row != "M" else 1.1)
ellipsoid(f"Hip{row}{suffix}", (hip_x, y, 1.05), (0.3, 0.34, 0.3), mats["Copper"], bone, 1)
cone(f"Shin{row}{suffix}", (hip_x, y, 1.0), (foot_x, y - 0.04, 0.28), 0.22, 0.14, mats["Scale"], bone, 6)
ellipsoid(f"Foot{row}{suffix}", (foot_x, y - 0.22, 0.2), (0.31, 0.48, 0.19), mats["Brass"], bone, 1)
for toe_index, toe_x in enumerate((-0.13, 0.13)):
cone(
f"Toe{row}{suffix}{toe_index}", (foot_x + toe_x, y - 0.42, 0.2),
(foot_x + toe_x * 1.4, y - 0.75, 0.1), 0.055, 0.004, mats["Blade"], bone, 5,
)
cone("TailCore1", (0, 0.95, 1.3), (0, 1.85, 1.1), 0.48, 0.3, mats["Scale"], "Tail.1", 7)
cone("TailCore2", (0, 1.78, 1.1), (0, 2.72, 0.88), 0.31, 0.07, mats["Copper"], "Tail.2", 7)
cone("TailBladeTop", (0, 2.45, 0.9), (0, 3.18, 1.45), 0.2, 0.015, mats["Blade"], "Tail.2", 5)
cone("TailBladeBottom", (0, 2.45, 0.9), (0, 3.15, 0.48), 0.18, 0.015, mats["Brass"], "Tail.2", 5)
ellipsoid("TailCoreGlow", (0, 2.54, 0.91), (0.14, 0.17, 0.14), mats["Furnace"], "Tail.2", 1)
body = join_parts("BrassbeakBasilisk", rig)
clips = actions(rig, brassbeak_actions())
export_asset(
asset_id, "Brassbeak Basilisk", rig, body, clips,
[
("Body", (0, 0, 1.25), (1.25, 1.55, 0.9)),
("Head", (0, -1.65, 1.42), (0.95, 1.05, 0.75)),
("Tail", (0, 2.08, 1.0), (0.55, 1.25, 0.78)),
],
(0, 0, 1.25), 8.8, "FurnaceBurst", 20,
)
add_metadata(asset_id, "Original six-legged forge basilisk designed for I Want to Heal")
def brassbeak_actions():
return [
("Idle", 60, True, [
{"frame": 1},
{"frame": 15, "locations": {"Root": (0, 0, 0.04)}, "rotations": {"Head": (3, 0, -3), "Jaw": (7, 0, 0), "Tail.2": (0, 0, 7), "Wing.L": (0, 0, -4), "Wing.R": (0, 0, 4)}},
{"frame": 30, "rotations": {"Head": (0, 0, 3), "Jaw": (0, 0, 0), "Tail.2": (0, 0, -7)}},
{"frame": 45, "locations": {"Root": (0, 0, 0.04)}, "rotations": {"Head": (3, 0, -3), "Jaw": (7, 0, 0), "Tail.2": (0, 0, 7), "Wing.L": (0, 0, -4), "Wing.R": (0, 0, 4)}},
{"frame": 60},
]),
("Scuttle", 30, True, [
{"frame": 1, "rotations": {"Leg.FL": (-18, 0, -5), "Leg.MR": (-18, 0, 4), "Leg.BL": (-18, 0, -4), "Leg.FR": (18, 0, 5), "Leg.ML": (18, 0, -4), "Leg.BR": (18, 0, 4), "Tail.2": (0, 0, -9)}},
{"frame": 8, "locations": {"Root": (0, 0, 0.08)}, "rotations": {"Body": (-3, 0, 0)}},
{"frame": 16, "rotations": {"Leg.FL": (18, 0, 5), "Leg.MR": (18, 0, -4), "Leg.BL": (18, 0, 4), "Leg.FR": (-18, 0, -5), "Leg.ML": (-18, 0, 4), "Leg.BR": (-18, 0, -4), "Tail.2": (0, 0, 9)}},
{"frame": 23, "locations": {"Root": (0, 0, 0.08)}, "rotations": {"Body": (3, 0, 0)}},
{"frame": 30, "rotations": {"Leg.FL": (-18, 0, -5), "Leg.MR": (-18, 0, 4), "Leg.BL": (-18, 0, -4), "Leg.FR": (18, 0, 5), "Leg.ML": (18, 0, -4), "Leg.BR": (18, 0, 4), "Tail.2": (0, 0, -9)}},
]),
("BeakRend", 34, False, [
{"frame": 1},
{"frame": 9, "locations": {"Root": (0, 0.12, -0.05)}, "rotations": {"Body": (-9, 0, 0), "Head": (-24, 0, 0), "Jaw": (24, 0, 0), "Wing.L": (0, -16, -10), "Wing.R": (0, 16, 10)}},
{"frame": 15, "locations": {"Root": (0, -0.2, 0.05)}, "rotations": {"Body": (15, 0, 0), "Head": (28, 0, 0), "Jaw": (-6, 0, 0)}},
{"frame": 23, "rotations": {"Head": (-8, 0, 0), "Jaw": (12, 0, 0)}},
{"frame": 34},
]),
("FurnaceBurst", 46, False, [
{"frame": 1},
{"frame": 12, "locations": {"Root": (0, 0, 0.1)}, "scales": {"Body": (0.94, 0.94, 0.94)}, "rotations": {"Wing.L": (-18, 18, -22), "Wing.R": (-18, -18, 22), "Head": (-12, 0, 0), "Jaw": (18, 0, 0), "Tail.1": (-12, 0, 0)}},
{"frame": 20, "locations": {"Root": (0, -0.08, 0.22)}, "scales": {"Body": (1.1, 1.1, 1.1)}, "rotations": {"Wing.L": (18, -62, -64), "Wing.R": (18, 62, 64), "Head": (20, 0, 0), "Jaw": (30, 0, 0), "Tail.1": (18, 0, 0), "Tail.2": (-22, 0, 0)}},
{"frame": 30, "scales": {"Body": (0.97, 0.97, 0.97)}, "rotations": {"Wing.L": (4, -18, -20), "Wing.R": (4, 18, 20), "Jaw": (4, 0, 0), "Tail.2": (8, 0, 0)}},
{"frame": 46},
]),
("Stagger", 28, False, [
{"frame": 1},
{"frame": 6, "locations": {"Root": (0.12, 0.12, -0.09)}, "rotations": {"Body": (-14, 0, 13), "Head": (22, 0, -12), "Wing.L": (24, 0, -18), "Wing.R": (-8, 0, 12)}},
{"frame": 15, "rotations": {"Body": (7, 0, -6), "Head": (-8, 0, 5)}},
{"frame": 28},
]),
("Death", 72, False, [
{"frame": 1},
{"frame": 20, "locations": {"Root": (0.15, 0.08, -0.25)}, "rotations": {"Root": (0, 25, 32), "Body": (18, 0, 12), "Head": (24, 0, -10), "Jaw": (20, 0, 0), "Wing.L": (32, 0, -26), "Wing.R": (16, 0, 20)}},
{"frame": 46, "locations": {"Root": (0.28, 0.08, -0.78)}, "rotations": {"Root": (0, 52, 82), "Body": (30, 0, 20), "Head": (42, 0, -20), "Leg.FL": (30, 0, 0), "Leg.ML": (-24, 0, 0), "Leg.BL": (20, 0, 0), "Tail.1": (-32, 0, 0), "Tail.2": (-25, 0, 0)}},
{"frame": 72, "locations": {"Root": (0.28, 0.08, -0.82)}, "rotations": {"Root": (0, 52, 82), "Body": (30, 0, 20), "Head": (44, 0, -20), "Leg.FL": (30, 0, 0), "Leg.ML": (-24, 0, 0), "Leg.BL": (20, 0, 0), "Tail.1": (-32, 0, 0), "Tail.2": (-25, 0, 0)}},
]),
]
def build_bogbell_myconid() -> None:
"""Bell-capped fungal brute replacing Mirelord's frog model."""
asset_id = "bogbell-myconid"
reset_scene()
mats = prepare_materials({
"Bark": {"color": (0.12, 0.18, 0.095, 1), "roughness": 0.88},
"Root": {"color": (0.25, 0.31, 0.16, 1), "roughness": 0.8},
"Cap": {"color": (0.29, 0.055, 0.31, 1), "roughness": 0.6},
"CapEdge": {"color": (0.52, 0.15, 0.42, 1), "roughness": 0.52},
"Gill": {"color": (0.62, 0.55, 0.31, 1), "roughness": 0.7},
"Spore": {"color": (0.48, 1.0, 0.32, 1), "roughness": 0.16, "emission": (0.22, 0.92, 0.16, 1), "strength": 4.8},
})
specs = [
("Root", (0, 0, 0), (0, 0, 0.5), None),
("Body", (0, 0, 1.15), (0, 0, 2.25), "Root"),
("Cap", (0, -0.05, 2.18), (0, -0.05, 3.18), "Body"),
("Arm.L", (-0.58, -0.15, 1.72), (-1.28, -0.62, 0.7), "Body"),
("Arm.R", (0.58, -0.15, 1.72), (1.28, -0.62, 0.7), "Body"),
("Leg.L", (-0.38, 0.08, 1.08), (-0.58, -0.1, 0.2), "Body"),
("Leg.R", (0.38, 0.08, 1.08), (0.58, -0.1, 0.2), "Body"),
("Tendril.L", (-0.42, 0.58, 1.45), (-1.05, 1.45, 0.82), "Body"),
("Tendril.R", (0.42, 0.58, 1.45), (1.05, 1.45, 0.82), "Body"),
]
rig = armature("BogbellMyconid", specs)
# Gnarled trunk and hanging bell cap.
ellipsoid("Trunk", (0, 0.05, 1.48), (0.82, 0.68, 1.05), mats["Bark"], "Body", 2)
ellipsoid("ChestKnot", (0, -0.5, 1.62), (0.58, 0.28, 0.62), mats["Root"], "Body", 1)
cone("NeckStalk", (0, -0.02, 1.95), (0, -0.04, 2.65), 0.48, 0.36, mats["Gill"], "Cap", 8)
plate("BellCap", (0, -0.04, 2.78), (1.55, 1.38, 0.55), (0, 0, 0), mats["Cap"], "Cap", 9)
ellipsoid("CapCrown", (0, 0.02, 3.04), (1.25, 1.08, 0.42), mats["CapEdge"], "Cap", 2)
plate("GillBell", (0, -0.03, 2.58), (1.28, 1.12, 0.28), (0, 0, math.radians(180)), mats["Gill"], "Cap", 9)
for index, angle in enumerate(range(0, 360, 45)):
radians = math.radians(angle)
x, y = math.cos(radians) * 1.02, math.sin(radians) * 0.87
cone(
f"CapHorn{index}", (x * 0.9, y * 0.9, 3.12), (x * 1.38, y * 1.35, 3.34 + 0.08 * (index % 2)),
0.11, 0, mats["CapEdge"], "Cap", 5,
)
for side, suffix in ((-1, "L"), (1, "R")):
ellipsoid(f"Eye{suffix}", (side * 0.29, -0.65, 2.18), (0.095, 0.055, 0.11), mats["Spore"], "Cap", 1)
cone(
f"FaceRoot{suffix}", (side * 0.25, -0.52, 2.03), (side * 0.42, -0.78, 1.72),
0.07, 0.015, mats["Root"], "Cap", 5,
)
ellipsoid("MouthHollow", (0, -0.68, 1.94), (0.22, 0.055, 0.13), mats["Cap"], "Cap", 1)
# Root arms end in broad knuckles for readable pummel animation.
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Arm.{suffix}"
cone(f"UpperArm{suffix}", (side * 0.55, -0.12, 1.78), (side * 1.04, -0.45, 1.05), 0.3, 0.22, mats["Bark"], bone, 7)
cone(f"Forearm{suffix}", (side * 1.02, -0.44, 1.06), (side * 1.34, -0.84, 0.58), 0.24, 0.18, mats["Root"], bone, 7)
ellipsoid(f"Knuckle{suffix}", (side * 1.38, -0.91, 0.48), (0.43, 0.38, 0.32), mats["Bark"], bone, 1)
for finger in (-0.16, 0, 0.16):
cone(
f"Finger{suffix}{finger}", (side * 1.36 + finger, -1.02, 0.43),
(side * 1.46 + finger, -1.35, 0.22), 0.065, 0.012, mats["Root"], bone, 5,
)
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Leg.{suffix}"
ellipsoid(f"Hip{suffix}", (side * 0.4, 0.08, 1.0), (0.42, 0.46, 0.5), mats["Bark"], bone, 1)
cone(f"RootLeg{suffix}", (side * 0.4, 0.06, 0.95), (side * 0.62, -0.12, 0.25), 0.34, 0.22, mats["Root"], bone, 7)
for toe_index, toe_x in enumerate((-0.22, 0, 0.22)):
cone(
f"RootToe{suffix}{toe_index}", (side * 0.62 + toe_x, -0.2, 0.25),
(side * 0.72 + toe_x * 1.25, -0.78 - abs(toe_x), 0.08), 0.095, 0.015, mats["Bark"], bone, 6,
)
# Rear tendrils drag through mire. Spore sacs pulse during eruption.
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Tendril.{suffix}"
cone(f"TendrilBase{suffix}", (side * 0.4, 0.5, 1.38), (side * 0.78, 1.22, 0.82), 0.22, 0.12, mats["Root"], bone, 7)
cone(f"TendrilTip{suffix}", (side * 0.76, 1.18, 0.84), (side * 1.28, 1.85, 0.3), 0.13, 0.018, mats["Bark"], bone, 6)
ellipsoid(f"SporeSac{suffix}", (side * 0.82, 0.72, 1.25), (0.24, 0.31, 0.3), mats["Spore"], bone, 1)
for index, (x, y, z, size) in enumerate(((-0.5, 0.45, 1.88, 0.16), (0.48, 0.5, 1.72, 0.2), (-0.28, 0.62, 1.35, 0.13))):
ellipsoid(f"BodySpore{index}", (x, y, z), (size, size * 0.82, size * 1.1), mats["Spore"], "Body", 1)
body = join_parts("BogbellMyconid", rig)
clips = actions(rig, bogbell_actions())
export_asset(
asset_id, "Bogbell Myconid", rig, body, clips,
[
("Body", (0, 0, 1.45), (1.05, 0.95, 1.35)),
("Cap", (0, 0, 2.82), (1.68, 1.48, 0.72)),
("Roots", (0, 0.38, 0.62), (1.58, 1.75, 0.72)),
],
(0, 0, 1.65), 8.5, "SporeEruption", 21,
)
add_metadata(asset_id, "Original bell-capped fungal mire creature designed for I Want to Heal")
def bogbell_actions():
return [
("Idle", 60, True, [
{"frame": 1},
{"frame": 15, "locations": {"Root": (0, 0, 0.04)}, "scales": {"Cap": (1.03, 1.03, 0.98)}, "rotations": {"Cap": (2, 0, -3), "Arm.L": (0, 0, -3), "Arm.R": (0, 0, 3), "Tendril.L": (0, 0, 7), "Tendril.R": (0, 0, -7)}},
{"frame": 30, "scales": {"Cap": (0.98, 0.98, 1.03)}, "rotations": {"Cap": (-1, 0, 3), "Tendril.L": (0, 0, -7), "Tendril.R": (0, 0, 7)}},
{"frame": 45, "locations": {"Root": (0, 0, 0.04)}, "scales": {"Cap": (1.03, 1.03, 0.98)}, "rotations": {"Cap": (2, 0, -3), "Arm.L": (0, 0, -3), "Arm.R": (0, 0, 3), "Tendril.L": (0, 0, 7), "Tendril.R": (0, 0, -7)}},
{"frame": 60},
]),
("BurrowRush", 32, True, [
{"frame": 1, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (10, 0, 0), "Arm.L": (26, 0, -12), "Arm.R": (26, 0, 12), "Leg.L": (-16, 0, 0), "Leg.R": (16, 0, 0), "Tendril.L": (-18, 0, -12), "Tendril.R": (-18, 0, 12)}},
{"frame": 9, "locations": {"Root": (0, 0, -0.26)}, "rotations": {"Cap": (-9, 0, 0), "Leg.L": (16, 0, 0), "Leg.R": (-16, 0, 0), "Tendril.L": (14, 0, 10), "Tendril.R": (14, 0, -10)}},
{"frame": 17, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (10, 0, 0), "Arm.L": (26, 0, -12), "Arm.R": (26, 0, 12), "Leg.L": (-16, 0, 0), "Leg.R": (16, 0, 0), "Tendril.L": (-18, 0, -12), "Tendril.R": (-18, 0, 12)}},
{"frame": 25, "locations": {"Root": (0, 0, -0.26)}, "rotations": {"Cap": (-9, 0, 0), "Leg.L": (16, 0, 0), "Leg.R": (-16, 0, 0), "Tendril.L": (14, 0, 10), "Tendril.R": (14, 0, -10)}},
{"frame": 32, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (10, 0, 0), "Arm.L": (26, 0, -12), "Arm.R": (26, 0, 12), "Leg.L": (-16, 0, 0), "Leg.R": (16, 0, 0), "Tendril.L": (-18, 0, -12), "Tendril.R": (-18, 0, 12)}},
]),
("RootPummel", 36, False, [
{"frame": 1},
{"frame": 10, "locations": {"Root": (0, 0.1, 0.06)}, "rotations": {"Body": (-12, 0, 0), "Cap": (-8, 0, 0), "Arm.L": (-42, 0, -30), "Arm.R": (-42, 0, 30)}},
{"frame": 17, "locations": {"Root": (0, -0.12, -0.14)}, "rotations": {"Body": (22, 0, 0), "Cap": (18, 0, 0), "Arm.L": (58, 0, 16), "Arm.R": (58, 0, -16)}},
{"frame": 25, "rotations": {"Body": (-5, 0, 0), "Arm.L": (12, 0, -6), "Arm.R": (12, 0, 6)}},
{"frame": 36},
]),
("SporeEruption", 48, False, [
{"frame": 1},
{"frame": 13, "locations": {"Root": (0, 0, -0.12)}, "scales": {"Cap": (0.88, 0.88, 1.16)}, "rotations": {"Body": (-13, 0, 0), "Cap": (-15, 0, 0), "Arm.L": (-26, 0, -28), "Arm.R": (-26, 0, 28), "Tendril.L": (-28, 0, -24), "Tendril.R": (-28, 0, 24)}},
{"frame": 21, "locations": {"Root": (0, -0.04, 0.24)}, "scales": {"Cap": (1.18, 1.18, 0.9), "Body": (1.08, 1.08, 1.08)}, "rotations": {"Body": (18, 0, 0), "Cap": (19, 0, 0), "Arm.L": (18, 0, 62), "Arm.R": (18, 0, -62), "Tendril.L": (32, 0, 48), "Tendril.R": (32, 0, -48)}},
{"frame": 32, "scales": {"Cap": (0.97, 0.97, 1.04), "Body": (0.97, 0.97, 0.97)}, "rotations": {"Cap": (-5, 0, 0), "Arm.L": (4, 0, 12), "Arm.R": (4, 0, -12)}},
{"frame": 48},
]),
("Stagger", 28, False, [
{"frame": 1},
{"frame": 6, "locations": {"Root": (0.14, 0.1, -0.08)}, "rotations": {"Body": (-15, 0, 13), "Cap": (24, 0, -18), "Arm.L": (20, 0, -18), "Arm.R": (-8, 0, 12)}},
{"frame": 15, "rotations": {"Body": (7, 0, -6), "Cap": (-8, 0, 7)}},
{"frame": 28},
]),
("Death", 74, False, [
{"frame": 1},
{"frame": 20, "locations": {"Root": (0.14, 0.1, -0.3)}, "rotations": {"Root": (0, 24, 30), "Body": (20, 0, 12), "Cap": (28, 0, -18), "Arm.L": (30, 0, -26), "Arm.R": (16, 0, 20), "Tendril.L": (-24, 0, -14), "Tendril.R": (-16, 0, 18)}},
{"frame": 48, "locations": {"Root": (0.28, 0.1, -0.86)}, "rotations": {"Root": (0, 54, 84), "Body": (34, 0, 24), "Cap": (48, 0, -30), "Arm.L": (52, 0, -42), "Arm.R": (28, 0, 34), "Leg.L": (24, 0, 0), "Leg.R": (-18, 0, 0), "Tendril.L": (-40, 0, -24), "Tendril.R": (-34, 0, 26)}},
{"frame": 74, "locations": {"Root": (0.28, 0.1, -0.9)}, "rotations": {"Root": (0, 54, 84), "Body": (34, 0, 24), "Cap": (50, 0, -30), "Arm.L": (52, 0, -42), "Arm.R": (28, 0, 34), "Leg.L": (24, 0, 0), "Leg.R": (-18, 0, 0), "Tendril.L": (-40, 0, -24), "Tendril.R": (-34, 0, 26)}},
]),
]
def main() -> None:
bpy.context.preferences.filepaths.save_version = 0
OUT_ROOT.mkdir(parents=True, exist_ok=True)
build_brassbeak_basilisk()
build_bogbell_myconid()
if __name__ == "__main__":
main()
+80
View File
@@ -0,0 +1,80 @@
import { access } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
EXTMeshoptCompression,
} from "@gltf-transform/extensions";
import { dedup, mergeDocuments, prune, unpartition } from "@gltf-transform/functions";
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourceDirectory = path.join(
repositoryRoot,
"game_assets",
"models",
"original",
"environment",
"kaykit-dungeon",
);
const outputPath = path.join(sourceDirectory, "dungeon-kit-uastc.glb");
const sources = ["pillar-decorated.glb", "wall-pillar.glb", "torch-lit.glb"];
const expectedMeshNames = ["pillar_decorated", "wall_pillar", "torch_lit"];
await Promise.all(sources.map((source) => access(path.join(sourceDirectory, source))));
const io = await createGameAssetIO();
const documents = await Promise.all(sources.map((source) => io.read(path.join(sourceDirectory, source))));
const document = documents[0];
const destinationScene = document.getRoot().listScenes()[0].setName("dungeon_kit");
for (const sourceDocument of documents.slice(1)) {
const sourceScene = sourceDocument.getRoot().listScenes()[0];
const propertyMap = mergeDocuments(document, sourceDocument);
const copiedScene = propertyMap.get(sourceScene);
if (!copiedScene) throw new Error(`Could not merge scene from ${sourceScene.getName() || "unnamed source"}.`);
for (const child of [...copiedScene.listChildren()]) destinationScene.addChild(child);
copiedScene.dispose();
}
await document.transform(
dedup({ keepUniqueNames: false }),
unpartition(),
prune({ keepSolidTextures: true }),
);
const root = document.getRoot();
const meshNames = root.listMeshes().map((mesh) => mesh.getName()).sort();
const expectedSorted = [...expectedMeshNames].sort();
if (JSON.stringify(meshNames) !== JSON.stringify(expectedSorted)) {
throw new Error(`Dungeon kit mesh names changed: expected ${expectedSorted.join(", ")}; received ${meshNames.join(", ")}.`);
}
if (root.listTextures().length !== 1) {
throw new Error(`Dungeon kit must contain exactly one shared texture; received ${root.listTextures().length}.`);
}
if (root.listMaterials().length !== 1) {
throw new Error(`Dungeon kit must contain exactly one shared material; received ${root.listMaterials().length}.`);
}
await convertDocumentTexturesToKtx2(document, "iwt-dungeon-kit-");
const meshoptExtension = root.listExtensionsUsed()
.find((extension) => extension.extensionName === EXTMeshoptCompression.EXTENSION_NAME)
?? document.createExtension(EXTMeshoptCompression);
meshoptExtension
.setRequired(true)
.setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.QUANTIZE });
await io.write(outputPath, document);
const outputDocument = await io.read(outputPath);
const outputRoot = outputDocument.getRoot();
const outputTexture = outputRoot.listTextures()[0];
if (
outputRoot.listMeshes().length !== 3
|| outputRoot.listMaterials().length !== 1
|| outputRoot.listTextures().length !== 1
|| outputTexture.getMimeType() !== "image/ktx2"
) {
throw new Error("Generated dungeon kit failed structural validation.");
}
console.log(`Built ${path.relative(repositoryRoot, outputPath)}`);
console.log("Meshes: pillar_decorated, wall_pillar, torch_lit; shared textures: 1; encoding: KTX2/UASTC.");
+170
View File
@@ -0,0 +1,170 @@
import { access, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Box3, Vector3 } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
import { dedup, meshopt, prune, resample } from "@gltf-transform/functions";
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const assetDirectory = path.join(repositoryRoot, "game_assets", "models", "sketchfab-opensource");
const sourcePath = path.join(assetDirectory, "animated_triceratops_skeleton.glb");
const legacyPath = path.join(assetDirectory, "gravehorn-triceratops.glb");
const optimizedPath = path.join(assetDirectory, "gravehorn-triceratops-uastc.glb");
const requiredClips = [
"Armature|RiseUp",
"Armature|Roar",
"Armature|Walk",
"Armature|Fall",
"Gravehorn|Idle",
];
const unusedClips = new Set(["Armature|IdleGround", "Armature|RoarToWalk"]);
function sortedNames(properties) {
return properties.map((property) => property.getName()).sort();
}
function addUprightIdle(document) {
const walk = document.getRoot().listAnimations().find((animation) => animation.getName() === "Armature|Walk");
const buffer = document.getRoot().listBuffers()[0];
if (!walk || !buffer) throw new Error("Gravehorn source is missing its Walk animation or binary buffer.");
const idle = document.createAnimation("Gravehorn|Idle");
for (const [index, sourceChannel] of walk.listChannels().entries()) {
const sourceSampler = sourceChannel.getSampler();
const sourceOutput = sourceSampler?.getOutput();
const sourceValues = sourceOutput?.getArray();
const targetNode = sourceChannel.getTargetNode();
const targetPath = sourceChannel.getTargetPath();
if (!sourceSampler || !sourceOutput || !sourceValues || !targetNode || !targetPath) {
throw new Error(`Gravehorn Walk channel ${index} is incomplete.`);
}
if (sourceSampler.getInterpolation() === "CUBICSPLINE") {
throw new Error("Gravehorn upright idle builder does not support cubic animation tracks.");
}
const elementSize = sourceOutput.getElementSize();
const values = new sourceValues.constructor(elementSize * 2);
values.set(sourceValues.subarray(0, elementSize), 0);
values.set(sourceValues.subarray(0, elementSize), elementSize);
const input = document.createAccessor(`gravehorn_idle_time_${index}`)
.setType("SCALAR")
.setArray(new Float32Array([0, 1]))
.setBuffer(buffer);
const output = document.createAccessor(`gravehorn_idle_value_${index}`)
.setType(sourceOutput.getType())
.setNormalized(sourceOutput.getNormalized())
.setArray(values)
.setBuffer(buffer);
const sampler = document.createAnimationSampler(`gravehorn_idle_sampler_${index}`)
.setInput(input)
.setOutput(output)
.setInterpolation("LINEAR");
const channel = document.createAnimationChannel(`gravehorn_idle_channel_${index}`)
.setSampler(sampler)
.setTargetNode(targetNode)
.setTargetPath(targetPath);
idle.addSampler(sampler).addChannel(channel);
}
}
async function runtimeBounds(assetPath) {
const data = await readFile(assetPath);
const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
globalThis.self ??= globalThis;
globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} });
const gltf = await new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).parseAsync(arrayBuffer, "");
gltf.scene.updateMatrixWorld(true);
const bounds = new Box3().setFromObject(gltf.scene);
return {
min: bounds.min.toArray(),
max: bounds.max.toArray(),
center: bounds.getCenter(new Vector3()).toArray(),
};
}
function validateStructure(document, { optimized }) {
const root = document.getRoot();
const material = root.listMaterials()[0];
const clips = sortedNames(root.listAnimations());
const textureMimeTypes = root.listTextures().map((texture) => texture.getMimeType());
const extensionNames = new Set(root.listExtensionsUsed().map((extension) => extension.extensionName));
if (JSON.stringify(clips) !== JSON.stringify([...requiredClips].sort())) {
throw new Error(`Gravehorn animation clips changed: ${clips.join(", ")}.`);
}
if (root.listMeshes().length !== 1 || root.listMaterials().length !== 1 || root.listTextures().length !== 3 || root.listSkins().length !== 1) {
throw new Error("Gravehorn must contain one mesh, one material, three textures, and one skin.");
}
if (root.listSkins()[0].listJoints().length !== 140) {
throw new Error(`Gravehorn joint count changed: ${root.listSkins()[0].listJoints().length}.`);
}
if (material.getDoubleSided()) {
throw new Error("Gravehorn material must keep backface culling enabled.");
}
if (!extensionNames.has("EXT_meshopt_compression")) {
throw new Error("Gravehorn is missing Meshopt compression.");
}
if (optimized && (textureMimeTypes.some((mimeType) => mimeType !== "image/ktx2") || !extensionNames.has("KHR_texture_basisu"))) {
throw new Error("Optimized Gravehorn asset must contain only KTX2 textures.");
}
if (!optimized && textureMimeTypes.some((mimeType) => mimeType === "image/ktx2")) {
throw new Error("Legacy Gravehorn fallback must retain standard textures.");
}
}
await access(sourcePath);
await MeshoptDecoder.ready;
await MeshoptEncoder.ready;
const io = await createGameAssetIO();
const document = await io.read(sourcePath);
const root = document.getRoot();
const scene = root.listScenes()[0].setName("gravehorn_triceratops");
const sceneRoot = scene.listChildren()[0];
const sourceBounds = await runtimeBounds(sourcePath);
const sourceTranslation = sceneRoot.getTranslation();
sceneRoot.setTranslation([
sourceTranslation[0] - sourceBounds.center[0],
sourceTranslation[1] - sourceBounds.min[1],
sourceTranslation[2] - sourceBounds.center[2],
]);
root.listMeshes()[0].setName("gravehorn_triceratops");
root.listMaterials()[0]
.setName("gravehorn_bone")
.setDoubleSided(false)
.setEmissiveFactor([0.1, 0.06, 0.02]);
addUprightIdle(document);
for (const animation of root.listAnimations()) {
if (!unusedClips.has(animation.getName())) continue;
for (const channel of animation.listChannels()) channel.dispose();
for (const sampler of animation.listSamplers()) sampler.dispose();
animation.dispose();
}
await document.transform(
resample({ tolerance: 1e-4 }),
dedup({ keepUniqueNames: true }),
prune({ keepLeaves: true, keepSolidTextures: true }),
meshopt({ encoder: MeshoptEncoder, level: "high" }),
);
await io.write(legacyPath, document);
const legacyDocument = await io.read(legacyPath);
validateStructure(legacyDocument, { optimized: false });
const legacyBounds = await runtimeBounds(legacyPath);
if (Math.abs(legacyBounds.min[1]) > 0.015 || Math.abs(legacyBounds.center[0]) > 0.015 || Math.abs(legacyBounds.center[2]) > 0.015) {
throw new Error(`Gravehorn pivot is not grounded and centered: minY=${legacyBounds.min[1]}, centerX=${legacyBounds.center[0]}, centerZ=${legacyBounds.center[2]}.`);
}
const optimizedDocument = await io.read(legacyPath);
await convertDocumentTexturesToKtx2(optimizedDocument, "iwt-gravehorn-");
await io.write(optimizedPath, optimizedDocument);
validateStructure(await io.read(optimizedPath), { optimized: true });
const sourceSize = (await stat(sourcePath)).size;
const legacySize = (await stat(legacyPath)).size;
const optimizedSize = (await stat(optimizedPath)).size;
console.log(`Built ${path.relative(repositoryRoot, legacyPath)} (${sourceSize} -> ${legacySize} bytes).`);
console.log(`Built ${path.relative(repositoryRoot, optimizedPath)} (${optimizedSize} bytes, KTX2/UASTC).`);
+70
View File
@@ -0,0 +1,70 @@
import { access } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourceRoot = path.join(repositoryRoot, "game_assets");
const sourceAssets = [
"models/claudecraft/chars/players/druid.glb",
"models/claudecraft/chars/players/knight.glb",
"models/claudecraft/chars/players/mage.glb",
"models/claudecraft/chars/players/ranger.glb",
"models/claudecraft/chars/players/rogue.glb",
"models/claudecraft/weapons/adv_dagger.glb",
"models/claudecraft/weapons/adv_druid_staff.glb",
"models/claudecraft/weapons/adv_sword_1handed.glb",
"models/claudecraft/weapons/adv_wand.glb",
"models/claudecraft/weapons/crossbow_2handed.glb",
"models/claudecraft/weapons/shield_badge.glb",
"models/claudecraft/weapons/spellbook_open.glb",
];
function optimizedPath(sourcePath) {
return sourcePath.replace(/\.glb$/, "-uastc.glb");
}
function sortedNames(properties) {
return properties.map((property) => property.getName()).sort();
}
const io = await createGameAssetIO();
for (const relativePath of sourceAssets) {
const inputPath = path.join(sourceRoot, relativePath);
const outputRelativePath = optimizedPath(relativePath);
const outputPath = path.join(sourceRoot, outputRelativePath);
await access(inputPath);
const document = await io.read(inputPath);
const sourceRootProperties = document.getRoot();
const expected = {
animations: sortedNames(sourceRootProperties.listAnimations()),
materials: sortedNames(sourceRootProperties.listMaterials()),
meshes: sortedNames(sourceRootProperties.listMeshes()),
nodes: sortedNames(sourceRootProperties.listNodes()),
scenes: sortedNames(sourceRootProperties.listScenes()),
textureCount: sourceRootProperties.listTextures().length,
};
await convertDocumentTexturesToKtx2(document, "iwt-ktx2-");
await io.write(outputPath, document);
const outputDocument = await io.read(outputPath);
const outputRoot = outputDocument.getRoot();
const actual = {
animations: sortedNames(outputRoot.listAnimations()),
materials: sortedNames(outputRoot.listMaterials()),
meshes: sortedNames(outputRoot.listMeshes()),
nodes: sortedNames(outputRoot.listNodes()),
scenes: sortedNames(outputRoot.listScenes()),
textureCount: outputRoot.listTextures().length,
};
if (
JSON.stringify(actual) !== JSON.stringify(expected)
|| outputRoot.listTextures().some((texture) => texture.getMimeType() !== "image/ktx2")
|| outputRoot.listExtensionsUsed().some((extension) => extension.extensionName === "EXT_texture_webp")
) {
throw new Error(`Generated asset failed round-trip validation: ${outputRelativePath}`);
}
console.log(`Built game_assets/${outputRelativePath}`);
}
+66
View File
@@ -0,0 +1,66 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import { createGameAssetIO } from "./lib/ktx2.mjs";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourcePath = path.join(repositoryRoot, "game_assets/models/claudecraft/chars/players/mage.glb");
const outputPath = path.join(repositoryRoot, "game_assets/textures/claudecraft/chars/players/priest-vestments.webp");
function clamp01(value) {
return Math.max(0, Math.min(1, value));
}
function luminance(red, green, blue) {
return (red * 0.2126 + green * 0.7152 + blue * 0.0722) / 255;
}
function paintSwatch(pixels, width, channels, rectangle, shadow, highlight) {
const samples = [];
for (let y = rectangle.y; y < rectangle.y + rectangle.height; y += 1) {
for (let x = rectangle.x; x < rectangle.x + rectangle.width; x += 1) {
const offset = (y * width + x) * channels;
if (pixels[offset + 3] === 0) continue;
samples.push(luminance(pixels[offset], pixels[offset + 1], pixels[offset + 2]));
}
}
const minimum = Math.min(...samples);
const maximum = Math.max(...samples);
const range = Math.max(0.001, maximum - minimum);
for (let y = rectangle.y; y < rectangle.y + rectangle.height; y += 1) {
for (let x = rectangle.x; x < rectangle.x + rectangle.width; x += 1) {
const offset = (y * width + x) * channels;
if (pixels[offset + 3] === 0) continue;
const sourceLuminance = luminance(pixels[offset], pixels[offset + 1], pixels[offset + 2]);
const mix = clamp01((sourceLuminance - minimum) / range);
for (let channel = 0; channel < 3; channel += 1) {
pixels[offset + channel] = Math.round(shadow[channel] + (highlight[channel] - shadow[channel]) * mix);
}
}
}
}
const io = await createGameAssetIO();
const document = await io.read(sourcePath);
const sourceTexture = document.getRoot().listTextures().find((texture) => texture.getName() === "mage_texture");
if (!sourceTexture?.getImage()) throw new Error("Mage source is missing mage_texture.");
const decoded = await sharp(sourceTexture.getImage()).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const { width, height, channels } = decoded.info;
if (width !== 512 || height !== 512 || channels !== 4) {
throw new Error(`Unexpected mage palette shape: ${width}x${height}x${channels}.`);
}
const pixels = new Uint8Array(decoded.data);
paintSwatch(pixels, width, channels, { x: 0, y: 128, width: 128, height: 128 }, [48, 58, 94], [244, 239, 211]);
paintSwatch(pixels, width, channels, { x: 128, y: 128, width: 64, height: 128 }, [105, 66, 18], [255, 218, 109]);
paintSwatch(pixels, width, channels, { x: 64, y: 256, width: 64, height: 128 }, [105, 66, 18], [255, 218, 109]);
paintSwatch(pixels, width, channels, { x: 128, y: 256, width: 64, height: 128 }, [37, 71, 101], [157, 215, 221]);
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, await sharp(pixels, { raw: { width, height, channels } })
.webp({ quality: 92, smartSubsample: true })
.toBuffer());
console.log(`Built ${path.relative(repositoryRoot, outputPath)}`);
+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 os
import re
import secrets
import shutil
import subprocess
import sys
@@ -339,17 +338,6 @@ def create_release(tag: str, commit: str, message: str, token: str) -> dict[str,
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(
release_id: int,
path: Path,
@@ -357,15 +345,46 @@ def upload_release_asset(
media_type: str,
token: str,
) -> None:
body, content_type = multipart_asset(path, media_type)
gitea_request(
f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets"
f"?name={urllib.parse.quote(path.name)}",
token=token,
method="POST",
body=body,
content_type=content_type,
curl = shutil.which("curl")
if not curl:
raise SystemExit("curl is required to upload Gitea release assets")
url = (
f"{GITEA_API}/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets"
f"?name={urllib.parse.quote(path.name)}"
)
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}")
+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`);
+428 -10
View File
@@ -180,14 +180,15 @@ function validateSlotId(value) {
}
function validateSave(value, slotId) {
if (!value || typeof value !== "object" || Number(value.schemaVersion) !== 5) {
const schemaVersion = Number(value?.schemaVersion);
if (!value || typeof value !== "object" || schemaVersion !== 5 && schemaVersion !== 6) {
throw apiError("Save snapshot is invalid.");
}
if (Number(value.slotId) !== slotId) throw apiError("Save slot does not match request.");
if (typeof value.hunterName !== "string" || !value.hunterName.trim()) {
throw apiError("Save snapshot has no hunter name.");
}
return value;
return { ...value, schemaVersion: 6 };
}
function normalizeNonNegativeInteger(value) {
@@ -195,6 +196,60 @@ function normalizeNonNegativeInteger(value) {
return Number.isFinite(number) ? Math.max(0, number) : 0;
}
function normalizeNonNegativeNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? Math.max(0, number) : 0;
}
function mergeLeaderboardHighWater(database, accountId, slotId, save) {
const blockbreaker = database.prepare(`
SELECT highest_bricks AS highestBricks, longest_seconds AS longestSeconds, highest_score AS highestScore
FROM blockbreaker_records WHERE account_id = ? AND slot_id = ?
`).get(accountId, slotId);
const aether = database.prepare(`
SELECT highest_score AS highestScore, wave_at_best AS waveAtBest, duration_at_best AS durationAtBest
FROM aether_assault_records WHERE account_id = ? AND slot_id = ?
`).get(accountId, slotId);
const stats = save.stats && typeof save.stats === "object" ? save.stats : {};
const candidateAether = {
score: normalizeNonNegativeInteger(stats.highestAetherAssaultScore),
wave: normalizeNonNegativeInteger(stats.highestAetherAssaultWaveAtBest),
duration: normalizeNonNegativeNumber(stats.longestAetherAssaultSecondsAtBest),
};
const storedAether = {
score: normalizeNonNegativeInteger(aether?.highestScore),
wave: normalizeNonNegativeInteger(aether?.waveAtBest),
duration: normalizeNonNegativeNumber(aether?.durationAtBest),
};
const aetherRecord = candidateAether.score > storedAether.score
|| candidateAether.score === storedAether.score && candidateAether.wave > storedAether.wave
|| candidateAether.score === storedAether.score && candidateAether.wave === storedAether.wave && candidateAether.duration > storedAether.duration
? candidateAether
: storedAether;
return {
...save,
schemaVersion: 6,
stats: {
...stats,
highestBlockbreakerBricks: Math.max(
normalizeNonNegativeInteger(stats.highestBlockbreakerBricks),
normalizeNonNegativeInteger(blockbreaker?.highestBricks),
),
longestBlockbreakerSeconds: Math.max(
normalizeNonNegativeNumber(stats.longestBlockbreakerSeconds),
normalizeNonNegativeNumber(blockbreaker?.longestSeconds),
),
highestBlockbreakerScore: Math.max(
normalizeNonNegativeInteger(stats.highestBlockbreakerScore),
normalizeNonNegativeInteger(blockbreaker?.highestScore),
),
highestAetherAssaultScore: aetherRecord.score,
highestAetherAssaultWaveAtBest: aetherRecord.wave,
longestAetherAssaultSecondsAtBest: aetherRecord.duration,
},
};
}
function syncLeaderboardStats(database, accountId, slotId, save) {
database.prepare("DELETE FROM boss_kill_records WHERE account_id = ? AND slot_id = ?").run(accountId, slotId);
const insertBoss = database.prepare(`
@@ -225,14 +280,95 @@ function syncLeaderboardStats(database, accountId, slotId, save) {
highest_boss_kills = excluded.highest_boss_kills,
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, highestEndlessKills);
const highestHockeyReturns = normalizeNonNegativeInteger(save.stats?.highestHockeyHealingReturns);
const hockeyDurationSeconds = normalizeNonNegativeNumber(save.stats?.longestHockeyHealingSecondsAtBest);
database.prepare(`
INSERT INTO hockey_healing_records (account_id, slot_id, highest_returns, duration_seconds, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
highest_returns = excluded.highest_returns,
duration_seconds = excluded.duration_seconds,
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, highestHockeyReturns, hockeyDurationSeconds);
const hockeyPvpWins = normalizeNonNegativeInteger(save.stats?.hockeyHealingPvpWins);
const hockeyPvpLosses = normalizeNonNegativeInteger(save.stats?.hockeyHealingPvpLosses);
const hockeyPvpBossKills = normalizeNonNegativeInteger(save.stats?.hockeyHealingPvpBossKills);
database.prepare(`
INSERT INTO hockey_pvp_records (account_id, slot_id, wins, losses, boss_kills, updated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
wins = excluded.wins,
losses = excluded.losses,
boss_kills = excluded.boss_kills,
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, hockeyPvpWins, hockeyPvpLosses, hockeyPvpBossKills);
const highestBlockbreakerBricks = normalizeNonNegativeInteger(save.stats?.highestBlockbreakerBricks);
const longestBlockbreakerSeconds = normalizeNonNegativeNumber(save.stats?.longestBlockbreakerSeconds);
const highestBlockbreakerScore = normalizeNonNegativeInteger(save.stats?.highestBlockbreakerScore);
database.prepare(`
INSERT INTO blockbreaker_records (
account_id, slot_id,
highest_bricks, bricks_achieved_at,
longest_seconds, time_achieved_at,
highest_score, score_achieved_at,
updated_at
) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, CURRENT_TIMESTAMP, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
bricks_achieved_at = CASE
WHEN excluded.highest_bricks > blockbreaker_records.highest_bricks THEN CURRENT_TIMESTAMP
ELSE blockbreaker_records.bricks_achieved_at
END,
highest_bricks = MAX(blockbreaker_records.highest_bricks, excluded.highest_bricks),
time_achieved_at = CASE
WHEN excluded.longest_seconds > blockbreaker_records.longest_seconds THEN CURRENT_TIMESTAMP
ELSE blockbreaker_records.time_achieved_at
END,
longest_seconds = MAX(blockbreaker_records.longest_seconds, excluded.longest_seconds),
score_achieved_at = CASE
WHEN excluded.highest_score > blockbreaker_records.highest_score THEN CURRENT_TIMESTAMP
ELSE blockbreaker_records.score_achieved_at
END,
highest_score = MAX(blockbreaker_records.highest_score, excluded.highest_score),
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, highestBlockbreakerBricks, longestBlockbreakerSeconds, highestBlockbreakerScore);
const highestAetherScore = normalizeNonNegativeInteger(save.stats?.highestAetherAssaultScore);
const aetherWaveAtBest = normalizeNonNegativeInteger(save.stats?.highestAetherAssaultWaveAtBest);
const aetherDurationAtBest = normalizeNonNegativeNumber(save.stats?.longestAetherAssaultSecondsAtBest);
database.prepare(`
INSERT INTO aether_assault_records (
account_id, slot_id, highest_score, wave_at_best, duration_at_best, score_achieved_at, updated_at
) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
highest_score = excluded.highest_score,
wave_at_best = excluded.wave_at_best,
duration_at_best = excluded.duration_at_best,
score_achieved_at = CASE
WHEN excluded.highest_score > aether_assault_records.highest_score
OR excluded.highest_score = aether_assault_records.highest_score
AND excluded.wave_at_best > aether_assault_records.wave_at_best
THEN CURRENT_TIMESTAMP
ELSE aether_assault_records.score_achieved_at
END,
updated_at = CURRENT_TIMESTAMP
WHERE excluded.highest_score > aether_assault_records.highest_score
OR excluded.highest_score = aether_assault_records.highest_score
AND excluded.wave_at_best > aether_assault_records.wave_at_best
OR excluded.highest_score = aether_assault_records.highest_score
AND excluded.wave_at_best = aether_assault_records.wave_at_best
AND excluded.duration_at_best > aether_assault_records.duration_at_best
`).run(accountId, slotId, highestAetherScore, aetherWaveAtBest, aetherDurationAtBest);
}
function writeSave(database, accountId, slotId, rawSave) {
const save = validateSave(rawSave, slotId);
const serialized = JSON.stringify(save);
if (Buffer.byteLength(serialized) > MAX_JSON_BYTES) throw apiError("Save snapshot is too large.", 413);
const validatedSave = validateSave(rawSave, slotId);
database.exec("BEGIN IMMEDIATE");
try {
const save = mergeLeaderboardHighWater(database, accountId, slotId, validatedSave);
const serialized = JSON.stringify(save);
if (Buffer.byteLength(serialized) > MAX_JSON_BYTES) throw apiError("Save snapshot is too large.", 413);
database.prepare(`
INSERT INTO hunter_saves (account_id, slot_id, hunter_name, save_json, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
@@ -243,11 +379,11 @@ function writeSave(database, accountId, slotId, rawSave) {
`).run(accountId, slotId, save.hunterName.trim().slice(0, 20), serialized);
syncLeaderboardStats(database, accountId, slotId, save);
database.exec("COMMIT");
return save;
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
return save;
}
function readSave(database, accountId, slotId) {
@@ -255,7 +391,12 @@ function readSave(database, accountId, slotId) {
SELECT save_json AS saveJson FROM hunter_saves WHERE account_id = ? AND slot_id = ?
`).get(accountId, slotId);
if (!row) return null;
try { return JSON.parse(row.saveJson); } catch { return null; }
try {
const save = JSON.parse(row.saveJson);
return mergeLeaderboardHighWater(database, accountId, slotId, save);
} catch {
return null;
}
}
function listSaves(database, accountId) {
@@ -263,18 +404,22 @@ function listSaves(database, accountId) {
SELECT slot_id AS slotId, save_json AS saveJson, updated_at AS updatedAt
FROM hunter_saves WHERE account_id = ? ORDER BY slot_id
`).all(accountId).flatMap((row) => {
try { return [{ slotId: row.slotId, save: JSON.parse(row.saveJson), updatedAt: row.updatedAt }]; }
try {
const save = mergeLeaderboardHighWater(database, accountId, row.slotId, JSON.parse(row.saveJson));
return [{ slotId: row.slotId, save, updatedAt: row.updatedAt }];
}
catch { return []; }
});
}
function leaderboardEntry(row, valueKey) {
function leaderboardEntry(row, valueKey, secondaryValueKey) {
return {
rank: row.rank,
username: row.username,
hunterName: row.hunterName,
slotId: row.slotId,
value: row[valueKey],
...(secondaryValueKey ? { secondaryValue: row[secondaryValueKey] } : {}),
};
}
@@ -358,11 +503,224 @@ function rogueTrialsEndlessLeaderboard(database, accountId, slotId) {
};
}
function hockeyHealingLeaderboard(database, accountId, slotId) {
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (ORDER BY records.highest_returns DESC, records.duration_seconds DESC) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.highest_returns AS highestReturns,
records.duration_seconds AS durationSeconds,
accounts.username,
saves.hunter_name AS hunterName,
records.updated_at AS updatedAt
FROM hockey_healing_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_returns > 0 OR records.duration_seconds > 0
)
SELECT * FROM ranked
ORDER BY highestReturns DESC, durationSeconds DESC, updatedAt ASC, accountId ASC, slotId ASC
`).all();
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
return {
kind: "hockey-healing",
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestReturns", "durationSeconds")),
current: current ? leaderboardEntry(current, "highestReturns", "durationSeconds") : null,
};
}
function hockeyPvpLeaderboard(database, accountId, slotId, kind) {
const winsBoard = kind === "hockey-pvp-wins";
const order = winsBoard
? "records.wins DESC, records.losses ASC"
: "records.boss_kills DESC, records.wins DESC";
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (ORDER BY ${order}) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.wins,
records.losses,
records.boss_kills AS bossKills,
accounts.username,
saves.hunter_name AS hunterName,
records.updated_at AS updatedAt
FROM hockey_pvp_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 ${winsBoard ? "records.wins > 0 OR records.losses > 0" : "records.boss_kills > 0"}
)
SELECT * FROM ranked
ORDER BY ${winsBoard ? "wins DESC, losses ASC" : "bossKills DESC, wins DESC"}, updatedAt ASC, accountId ASC, slotId ASC
`).all();
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
const valueKey = winsBoard ? "wins" : "bossKills";
const secondaryKey = winsBoard ? "losses" : undefined;
return {
kind,
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, valueKey, secondaryKey)),
current: current ? leaderboardEntry(current, valueKey, secondaryKey) : null,
};
}
function blockbreakerLeaderboard(database, accountId, slotId, kind) {
const boards = {
"blockbreaker-bricks": { column: "highest_bricks", valueKey: "highestBricks", achieved: "bricks_achieved_at" },
"blockbreaker-time": { column: "longest_seconds", valueKey: "longestSeconds", achieved: "time_achieved_at" },
"blockbreaker-score": { column: "highest_score", valueKey: "highestScore", achieved: "score_achieved_at" },
};
const board = boards[kind];
if (!board) throw apiError("Invalid Blockbreaker leaderboard.");
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (ORDER BY records.${board.column} DESC) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.${board.column} AS ${board.valueKey},
records.${board.achieved} AS achievedAt,
accounts.username,
saves.hunter_name AS hunterName
FROM blockbreaker_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.${board.column} > 0
)
SELECT * FROM ranked
ORDER BY ${board.valueKey} DESC, achievedAt ASC, accountId ASC, slotId ASC
`).all();
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
return {
kind,
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, board.valueKey)),
current: current ? leaderboardEntry(current, board.valueKey) : null,
};
}
function aetherAssaultLeaderboard(database, accountId, slotId) {
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (
ORDER BY records.highest_score DESC, records.wave_at_best DESC,
records.score_achieved_at ASC, records.account_id ASC, records.slot_id ASC
) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.highest_score AS highestScore,
records.wave_at_best AS waveAtBest,
records.score_achieved_at AS achievedAt,
accounts.username,
saves.hunter_name AS hunterName
FROM aether_assault_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_score > 0
)
SELECT * FROM ranked
ORDER BY highestScore DESC, waveAtBest DESC, achievedAt ASC, accountId ASC, slotId ASC
`).all();
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
return {
kind: "aether-assault",
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestScore", "waveAtBest")),
current: current ? leaderboardEntry(current, "highestScore", "waveAtBest") : null,
};
}
export function createGameApiHandler(options = {}) {
const dataDirectory = resolve(options.dataDirectory ?? process.env.DATA_DIR ?? "data");
mkdirSync(dataDirectory, { recursive: true });
const database = new DatabaseSync(resolve(dataDirectory, "game.db"));
database.exec(readFileSync(new URL("../db/schema.sql", import.meta.url), "utf8"));
const hockeyPvpTickets = new Map();
const hockeyPvpMatches = new Map();
function queueResult(ticket) {
const match = ticket.matchId ? hockeyPvpMatches.get(ticket.matchId) : null;
if (!match) return { ticketId: ticket.id, status: "waiting" };
const opponentSide = ticket.side === "host" ? "guest" : "host";
const opponent = match.players[opponentSide];
return {
ticketId: ticket.id,
status: "matched",
match: {
id: match.id,
seed: match.seed,
opponentName: opponent.hunterName,
role: ticket.side,
},
};
}
function joinHockeyPvpQueue(session, payload) {
const slotId = validateSlotId(payload?.slotId);
const hunterName = String(payload?.hunterName ?? "").trim().slice(0, 20);
if (!hunterName) throw apiError("Hunter name is required.");
const existing = [...hockeyPvpTickets.values()].find((ticket) =>
ticket.accountId === session.accountId && !ticket.matchId && !ticket.cancelled && !ticket.completed);
if (existing) return queueResult(existing);
const now = Date.now();
for (const ticket of hockeyPvpTickets.values()) {
if (!ticket.matchId && now - ticket.createdAt > 30_000) {
ticket.cancelled = true;
ticket.completed = true;
}
}
const opponent = [...hockeyPvpTickets.values()].find((ticket) =>
!ticket.matchId && !ticket.cancelled && !ticket.completed && ticket.accountId !== session.accountId);
const ticket = {
id: randomBytes(18).toString("base64url"),
accountId: session.accountId,
username: session.username,
slotId,
hunterName,
createdAt: now,
matchId: null,
side: null,
cancelled: false,
completed: false,
};
hockeyPvpTickets.set(ticket.id, ticket);
if (!opponent) return queueResult(ticket);
const matchId = randomBytes(18).toString("base64url");
const match = {
id: matchId,
seed: randomBytes(4).readUInt32BE(0) || 1,
createdAt: now,
players: { host: opponent, guest: ticket },
snapshots: { host: null, guest: null },
};
opponent.matchId = matchId;
opponent.side = "host";
ticket.matchId = matchId;
ticket.side = "guest";
hockeyPvpMatches.set(matchId, match);
return queueResult(ticket);
}
function requireHockeyPvpTicket(session, ticketId) {
const ticket = hockeyPvpTickets.get(ticketId);
if (!ticket || ticket.accountId !== session.accountId || ticket.cancelled) throw apiError("Queue ticket not found.", 404);
return ticket;
}
function requireHockeyPvpMatch(session, matchId) {
const match = hockeyPvpMatches.get(matchId);
if (!match) throw apiError("PVP match not found.", 404);
const side = match.players.host.accountId === session.accountId
? "host"
: match.players.guest.accountId === session.accountId
? "guest"
: null;
if (!side) throw apiError("PVP match access denied.", 403);
return { match, side };
}
async function handle(request, response, next) {
if (!request.url?.startsWith("/api/")) return next();
@@ -400,6 +758,31 @@ export function createGameApiHandler(options = {}) {
}
const session = requireSession(database, request);
if (path === "/api/hockey-pvp/queue" && request.method === "POST") {
return sendJson(response, 200, joinHockeyPvpQueue(session, await readJson(request)));
}
const queueMatch = path.match(/^\/api\/hockey-pvp\/queue\/([A-Za-z0-9_-]+)$/);
if (queueMatch && request.method === "GET") {
return sendJson(response, 200, queueResult(requireHockeyPvpTicket(session, queueMatch[1])));
}
if (queueMatch && request.method === "DELETE") {
const ticket = requireHockeyPvpTicket(session, queueMatch[1]);
if (ticket.matchId) throw apiError("Matched queue cannot be cancelled.", 409);
ticket.cancelled = true;
ticket.completed = true;
return sendJson(response, 200, { ok: true });
}
const pvpStateMatch = path.match(/^\/api\/hockey-pvp\/matches\/([A-Za-z0-9_-]+)\/state$/);
if (pvpStateMatch && request.method === "PUT") {
const { match, side } = requireHockeyPvpMatch(session, pvpStateMatch[1]);
const payload = await readJson(request);
if (!payload?.snapshot || typeof payload.snapshot !== "object") throw apiError("PVP snapshot is invalid.");
match.snapshots[side] = payload.snapshot;
return sendJson(response, 200, {
opponentSnapshot: match.snapshots[side === "host" ? "guest" : "host"],
hostSnapshot: match.snapshots.host,
});
}
if (path === "/api/saves" && request.method === "GET") {
return sendJson(response, 200, { slots: listSaves(database, session.accountId) });
}
@@ -425,6 +808,34 @@ export function createGameApiHandler(options = {}) {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, rogueTrialsEndlessLeaderboard(database, session.accountId, slotId));
}
if (path === "/api/leaderboards/hockey-healing" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, hockeyHealingLeaderboard(database, session.accountId, slotId));
}
if (path === "/api/leaderboards/hockey-pvp-wins" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, hockeyPvpLeaderboard(database, session.accountId, slotId, "hockey-pvp-wins"));
}
if (path === "/api/leaderboards/hockey-pvp-boss-kills" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, hockeyPvpLeaderboard(database, session.accountId, slotId, "hockey-pvp-boss-kills"));
}
if (path === "/api/leaderboards/blockbreaker-bricks" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, blockbreakerLeaderboard(database, session.accountId, slotId, "blockbreaker-bricks"));
}
if (path === "/api/leaderboards/blockbreaker-time" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, blockbreakerLeaderboard(database, session.accountId, slotId, "blockbreaker-time"));
}
if (path === "/api/leaderboards/blockbreaker-score" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, blockbreakerLeaderboard(database, session.accountId, slotId, "blockbreaker-score"));
}
if (path === "/api/leaderboards/aether-assault" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, aetherAssaultLeaderboard(database, session.accountId, slotId));
}
return sendJson(response, 404, { error: "API route not found." });
} catch (error) {
const status = Number(error?.status) || 500;
@@ -434,5 +845,12 @@ export function createGameApiHandler(options = {}) {
}
}
return { handle, close: () => database.close() };
return {
handle,
close: () => {
hockeyPvpTickets.clear();
hockeyPvpMatches.clear();
database.close();
},
};
}
+154 -5
View File
@@ -34,12 +34,12 @@ async function json(path, init = {}) {
return { response, body };
}
function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound, highestRogueTrialsEndlessKills) {
function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins = 0, hockeyHealingPvpLosses = 0, hockeyHealingPvpBossKills = 0, highestBlockbreakerBricks = 0, longestBlockbreakerSeconds = 0, highestBlockbreakerScore = 0, highestAetherAssaultScore = 0, highestAetherAssaultWaveAtBest = 0, longestAetherAssaultSecondsAtBest = 0) {
return {
schemaVersion: 5,
schemaVersion: 6,
slotId,
hunterName,
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills },
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins, hockeyHealingPvpLosses, hockeyHealingPvpBossKills, highestBlockbreakerBricks, longestBlockbreakerSeconds, highestBlockbreakerScore, highestAetherAssaultScore, highestAetherAssaultWaveAtBest, longestAetherAssaultSecondsAtBest },
};
}
@@ -63,13 +63,24 @@ test("accounts, server saves, and top-five plus current rankings work end to end
const kills = 60 - index * 10;
const highestRound = 30 - index * 4;
const highestEndlessKills = 24 - index * 3;
const highestHockeyReturns = 30 - index * 4;
const hockeyDuration = 180 - index * 10;
const pvpWins = 30 - index * 4;
const pvpLosses = index + 1;
const pvpBossKills = 120 - index * 12;
const blockbreakerBricks = index < 2 ? 600 : 700 - index * 100;
const blockbreakerSeconds = 360 - index * 30;
const blockbreakerScore = 20_000 - index * 2_000;
const aetherScore = index < 2 ? 50_000 : 54_000 - index * 5_000;
const aetherWave = index < 2 ? 12 : 10 - index;
const aetherDuration = 300 - index * 20;
const upload = await json("/api/saves/1", {
method: "PUT",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound, highestEndlessKills) }),
body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound, highestEndlessKills, highestHockeyReturns, hockeyDuration, pvpWins, pvpLosses, pvpBossKills, blockbreakerBricks, blockbreakerSeconds, blockbreakerScore, aetherScore, aetherWave, aetherDuration) }),
});
assert.equal(upload.response.status, 200);
players.push({ token, kills, highestRound, highestEndlessKills });
players.push({ token, kills, highestRound, highestEndlessKills, highestHockeyReturns, hockeyDuration, pvpWins, pvpLosses, pvpBossKills, blockbreakerBricks, blockbreakerSeconds, blockbreakerScore, aetherScore, aetherWave, aetherDuration });
}
const current = players[5];
@@ -97,12 +108,150 @@ test("accounts, server saves, and top-five plus current rankings work end to end
assert.equal(endlessBoard.body.current.rank, 6);
assert.equal(endlessBoard.body.current.value, current.highestEndlessKills);
const hockeyBoard = await json("/api/leaderboards/hockey-healing?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(hockeyBoard.body.kind, "hockey-healing");
assert.equal(hockeyBoard.body.top.length, 5);
assert.equal(hockeyBoard.body.top[0].value, 30);
assert.equal(hockeyBoard.body.current.rank, 6);
assert.equal(hockeyBoard.body.current.value, current.highestHockeyReturns);
assert.equal(hockeyBoard.body.current.secondaryValue, current.hockeyDuration);
const pvpWinsBoard = await json("/api/leaderboards/hockey-pvp-wins?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(pvpWinsBoard.body.kind, "hockey-pvp-wins");
assert.equal(pvpWinsBoard.body.top[0].value, 30);
assert.equal(pvpWinsBoard.body.current.value, current.pvpWins);
assert.equal(pvpWinsBoard.body.current.secondaryValue, current.pvpLosses);
const pvpKillsBoard = await json("/api/leaderboards/hockey-pvp-boss-kills?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(pvpKillsBoard.body.kind, "hockey-pvp-boss-kills");
assert.equal(pvpKillsBoard.body.top[0].value, 120);
assert.equal(pvpKillsBoard.body.current.value, current.pvpBossKills);
const blockbreakerBricksBoard = await json("/api/leaderboards/blockbreaker-bricks?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(blockbreakerBricksBoard.body.kind, "blockbreaker-bricks");
assert.equal(blockbreakerBricksBoard.body.top.length, 5);
assert.equal(blockbreakerBricksBoard.body.top[0].value, 600);
assert.equal(blockbreakerBricksBoard.body.top[0].rank, 1);
assert.equal(blockbreakerBricksBoard.body.top[1].rank, 1);
assert.equal(blockbreakerBricksBoard.body.current.rank, 6);
assert.equal(blockbreakerBricksBoard.body.current.value, current.blockbreakerBricks);
const blockbreakerTimeBoard = await json("/api/leaderboards/blockbreaker-time?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(blockbreakerTimeBoard.body.kind, "blockbreaker-time");
assert.equal(blockbreakerTimeBoard.body.top[0].value, 360);
assert.equal(blockbreakerTimeBoard.body.current.value, current.blockbreakerSeconds);
const blockbreakerScoreBoard = await json("/api/leaderboards/blockbreaker-score?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(blockbreakerScoreBoard.body.kind, "blockbreaker-score");
assert.equal(blockbreakerScoreBoard.body.top[0].value, 20_000);
assert.equal(blockbreakerScoreBoard.body.current.value, current.blockbreakerScore);
const aetherBoard = await json("/api/leaderboards/aether-assault?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(aetherBoard.body.kind, "aether-assault");
assert.equal(aetherBoard.body.top.length, 5);
assert.equal(aetherBoard.body.top[0].value, 50_000);
assert.equal(aetherBoard.body.top[0].secondaryValue, 12);
assert.equal(aetherBoard.body.top[0].username, "hunter_0");
assert.equal(aetherBoard.body.top[0].rank, 1);
assert.equal(aetherBoard.body.top[1].value, 50_000);
assert.equal(aetherBoard.body.top[1].secondaryValue, 12);
assert.equal(aetherBoard.body.top[1].username, "hunter_1");
assert.equal(aetherBoard.body.top[1].rank, 2);
assert.equal(aetherBoard.body.current.rank, 6);
assert.equal(aetherBoard.body.current.value, current.aetherScore);
assert.equal(aetherBoard.body.current.secondaryValue, current.aetherWave);
const legacySnapshot = save(1, "Hero 5", current.kills, current.highestRound, current.highestEndlessKills, current.highestHockeyReturns, current.hockeyDuration, current.pvpWins, current.pvpLosses, current.pvpBossKills);
delete legacySnapshot.stats.highestBlockbreakerBricks;
delete legacySnapshot.stats.longestBlockbreakerSeconds;
delete legacySnapshot.stats.highestBlockbreakerScore;
delete legacySnapshot.stats.highestAetherAssaultScore;
delete legacySnapshot.stats.highestAetherAssaultWaveAtBest;
delete legacySnapshot.stats.longestAetherAssaultSecondsAtBest;
legacySnapshot.schemaVersion = 5;
const legacyUpload = await json("/api/saves/1", {
method: "PUT",
headers: { Authorization: `Bearer ${current.token}`, "Content-Type": "application/json" },
body: JSON.stringify({ save: legacySnapshot }),
});
assert.equal(legacyUpload.body.save.stats.highestBlockbreakerBricks, current.blockbreakerBricks);
assert.equal(legacyUpload.body.save.stats.longestBlockbreakerSeconds, current.blockbreakerSeconds);
assert.equal(legacyUpload.body.save.stats.highestBlockbreakerScore, current.blockbreakerScore);
assert.equal(legacyUpload.body.save.schemaVersion, 6);
assert.equal(legacyUpload.body.save.stats.highestAetherAssaultScore, current.aetherScore);
assert.equal(legacyUpload.body.save.stats.highestAetherAssaultWaveAtBest, current.aetherWave);
assert.equal(legacyUpload.body.save.stats.longestAetherAssaultSecondsAtBest, current.aetherDuration);
const download = await json("/api/saves/1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(download.body.save.hunterName, "Hero 5");
});
test("Healing Hockey PVP queue pairs players and relays match snapshots", async () => {
const registerPlayer = async (username) => {
const registration = await json("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password: `long-password-${username}` }),
});
return registration.body.token;
};
const alphaToken = await registerPlayer("pvp_alpha");
const betaToken = await registerPlayer("pvp_beta");
const alphaQueue = await json("/api/hockey-pvp/queue", {
method: "POST",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ slotId: 1, hunterName: "Alpha" }),
});
assert.equal(alphaQueue.body.status, "waiting");
const betaQueue = await json("/api/hockey-pvp/queue", {
method: "POST",
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ slotId: 1, hunterName: "Beta" }),
});
assert.equal(betaQueue.body.status, "matched");
assert.equal(betaQueue.body.match.role, "guest");
assert.equal(betaQueue.body.match.opponentName, "Alpha");
const alphaMatched = await json(`/api/hockey-pvp/queue/${alphaQueue.body.ticketId}`, {
headers: { Authorization: `Bearer ${alphaToken}` },
});
assert.equal(alphaMatched.body.status, "matched");
assert.equal(alphaMatched.body.match.role, "host");
assert.equal(alphaMatched.body.match.id, betaQueue.body.match.id);
assert.equal(alphaMatched.body.match.seed, betaQueue.body.match.seed);
const hostSnapshot = { sequence: 1, party: [], puck: { goalSequence: 0 } };
await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ snapshot: hostSnapshot }),
});
const guestExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ snapshot: { sequence: 1, party: [] } }),
});
assert.deepEqual(guestExchange.body.opponentSnapshot, hostSnapshot);
assert.deepEqual(guestExchange.body.hostSnapshot, hostSnapshot);
});
test("invalid credentials cannot access server saves", async () => {
const login = await json("/api/auth/login", {
method: "POST",
+113 -11
View File
@@ -3,15 +3,19 @@ import packageJson from "../package.json";
import { DualDisplayFrame } from "./components/DualDisplayFrame";
import { FrontEnd } from "./components/FrontEnd";
import { useActiveHunter, useFrontendStore } from "./frontend/store";
import { useGameStore } from "./game/store";
import { getHockeyPvpNetworkSnapshot, useGameStore } from "./game/store";
import type { BossId } from "./game/types";
import type { DifficultySlug } from "./game/progression/loot";
import { useActionBindings } from "./game/useGameLoop";
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
import { startSaveSyncCoordinator } from "./frontend/saveSync";
import type { HockeyPvpMatchConfig } from "./game/hockeyHealingPvp";
import { onlineRepository } from "./frontend/onlineRepository";
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
const HealerModelGallery = lazy(() => import("./components/GameScene").then((module) => ({ default: module.HealerModelGallery })));
function GameLoadingScreen() {
return (
@@ -22,10 +26,11 @@ function GameLoadingScreen() {
);
}
export default function App() {
function MainApp() {
useForcedThorDisplays();
useAuthoritativeDualScreenSync();
const screen = useFrontendStore((state) => state.screen);
const accountId = useFrontendStore((state) => state.accountId);
const hunter = useActiveHunter();
const settings = useFrontendStore((state) => state.settings);
const navigate = useFrontendStore((state) => state.navigate);
@@ -34,36 +39,65 @@ export default function App() {
const recordBossVictory = useFrontendStore((state) => state.recordBossVictory);
const recordRoguelikeDefeat = useFrontendStore((state) => state.recordRoguelikeDefeat);
const recordRogueTrialsEndlessDefeat = useFrontendStore((state) => state.recordRogueTrialsEndlessDefeat);
const recordHockeyHealingDefeat = useFrontendStore((state) => state.recordHockeyHealingDefeat);
const recordHockeyPvpResult = useFrontendStore((state) => state.recordHockeyPvpResult);
const recordHockeyPvpBossKill = useFrontendStore((state) => state.recordHockeyPvpBossKill);
const recordBlockbreakerDefeat = useFrontendStore((state) => state.recordBlockbreakerDefeat);
const recordAetherAssaultDefeat = useFrontendStore((state) => state.recordAetherAssaultDefeat);
const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards);
const rewardedBossInstances = useRef(new Set<string>());
const screenRef = useRef(screen);
screenRef.current = screen;
const leaveGame = useCallback(() => {
const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState();
updateActiveHealerInventory(useGameStore.getState().inventory);
const game = useGameStore.getState();
// RPG Roguelike equipment belongs only to its current run. Never leak it
// into the hunter's permanent inventory when leaving the expedition.
if (game.runMode !== "rpg-roguelike") updateActiveHealerInventory(game.inventory);
touchActiveSave();
navigate("home");
if (accountId && activeSlotId) void uploadSlot(activeSlotId);
}, [navigate, touchActiveSave, updateActiveHealerInventory]);
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug) => {
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug, hockeyPvpMatch?: HockeyPvpMatchConfig) => {
if (!hunter) return;
const progress = hunter.healers[hunter.activeClassId];
const selectedMode = useFrontendStore.getState().selectedMode;
const runMode = selectedMode === "roguelike-pve" ? "roguelike" : selectedMode === "rogue-trials" ? "rogue-trials" : "encounter";
const runMode = selectedMode === "roguelike-pve"
? "rpg-roguelike"
: selectedMode === "rogue-trials"
? "rogue-trials"
: selectedMode === "hockey-healing"
? "hockey-healing"
: selectedMode === "hockey-healing-pvp"
? "hockey-healing-pvp"
: selectedMode === "blockbreaker"
? "blockbreaker"
: selectedMode === "aether-assault"
? "aether-assault"
: "encounter";
const launchDifficulty = runMode !== "encounter"
? "initiate"
: requestedDifficultySlug ?? useFrontendStore.getState().selectedDifficultySlug;
rewardedBossInstances.current.clear();
clearRecentRewards();
useGameStore.getState().configureHealer(hunter.activeClassId, hunter.hunterName, progress.inventory, bossIds, runMode, hunter.gearProgress, launchDifficulty);
useGameStore.getState().configureHealer(
hunter.activeClassId,
hunter.hunterName,
progress.inventory,
bossIds,
runMode,
hunter.gearProgress,
launchDifficulty,
hockeyPvpMatch,
);
touchActiveSave();
navigate("game");
}, [clearRecentRewards, hunter, navigate, touchActiveSave]);
useEffect(() => {
const onDualScreenLaunch = (event: Event) => {
const detail = (event as CustomEvent<{ bossIds: readonly BossId[]; difficultySlug?: DifficultySlug } | readonly BossId[]>).detail;
if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug);
const detail = (event as CustomEvent<{ bossIds: readonly BossId[]; difficultySlug?: DifficultySlug; hockeyPvpMatch?: HockeyPvpMatchConfig } | readonly BossId[]>).detail;
if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug, detail.hockeyPvpMatch);
else launchGame(detail);
};
window.addEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
@@ -75,6 +109,44 @@ export default function App() {
return () => window.removeEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
}, [leaveGame]);
useEffect(() => {
if (!accountId) return;
return startSaveSyncCoordinator((slotId) => useFrontendStore.getState().uploadSlot(slotId));
}, [accountId]);
useEffect(() => {
if (screen !== "game") return;
let stopped = false;
let exchangeActive = false;
const exchange = async () => {
if (stopped || exchangeActive) return;
const state = useGameStore.getState();
if (state.runMode !== "hockey-healing-pvp" || !state.hockeyPvp.matchId || state.hockeyPvp.role === "cpu") return;
const snapshot = getHockeyPvpNetworkSnapshot();
if (!snapshot) return;
exchangeActive = true;
try {
const result = await onlineRepository.exchangeHockeyPvpState(state.hockeyPvp.matchId, snapshot);
if (!stopped && result.opponentSnapshot) {
useGameStore.getState().applyHockeyPvpRemoteSnapshot(
result.opponentSnapshot,
result.hostSnapshot?.puck,
);
}
} catch {
// Last authoritative snapshot remains playable through short network gaps.
} finally {
exchangeActive = false;
}
};
void exchange();
const timer = window.setInterval(() => { void exchange(); }, 120);
return () => {
stopped = true;
window.clearInterval(timer);
};
}, [screen]);
useActionBindings(screen === "game", leaveGame);
useEffect(() => {
@@ -97,9 +169,31 @@ export default function App() {
if (state.runMode === "roguelike" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordRoguelikeDefeat(state.round);
}
if (state.endlessMode && state.phase === "defeat" && previousState.phase !== "defeat") {
if (state.runMode === "rpg-roguelike"
&& (state.phase === "defeat" || state.phase === "victory")
&& state.phase !== previousState.phase) {
recordRoguelikeDefeat(Math.max(1, state.rpgRun?.bossesDefeated ?? 0));
}
if (state.runMode === "rogue-trials" && state.endlessMode && state.phase === "defeat" && previousState.phase !== "defeat") {
recordRogueTrialsEndlessDefeat(state.endlessBossKills);
}
if (state.runMode === "hockey-healing" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordHockeyHealingDefeat(state.hockey.returns, state.time);
}
if (state.runMode === "hockey-healing-pvp"
&& (state.phase === "victory" || state.phase === "defeat")
&& state.phase !== previousState.phase) {
recordHockeyPvpResult(state.phase === "victory");
}
if (state.runMode === "blockbreaker" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordBlockbreakerDefeat(state.blockbreaker.bricksBroken, state.time, state.blockbreaker.score);
}
if (state.runMode === "aether-assault" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordAetherAssaultDefeat(state.aetherAssault.score, state.aetherAssault.wave, state.time);
}
// RPG rewards are generated inside the run reducer. Permanent boss loot
// here would duplicate its chest and break run-only progression.
if (state.runMode === "rpg-roguelike") return;
const bossCount = 1 + state.additionalBosses.length;
if (state.boss.hp <= 0 && previousState.boss.hp > 0) {
const primaryInstanceId = state.bossInstanceId;
@@ -108,6 +202,7 @@ export default function App() {
const defeatedBefore = (state.round - 1) * bossCount;
const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
recordBossVictory(state.boss.id, rewardDifficulty);
if (state.runMode === "hockey-healing-pvp") recordHockeyPvpBossKill();
}
}
for (let index = 0; index < state.additionalBosses.length; index += 1) {
@@ -121,7 +216,7 @@ export default function App() {
recordBossVictory(entry.boss.id, rewardDifficulty);
}
});
}, [clearRecentRewards, recordBossVictory, recordRoguelikeDefeat, recordRogueTrialsEndlessDefeat]);
}, [clearRecentRewards, recordAetherAssaultDefeat, recordBlockbreakerDefeat, recordBossVictory, recordHockeyHealingDefeat, recordHockeyPvpBossKill, recordHockeyPvpResult, recordRoguelikeDefeat, recordRogueTrialsEndlessDefeat]);
return (
<main className="app-shell">
@@ -130,8 +225,15 @@ export default function App() {
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
</header>
{screen === "game"
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} bottom={<BottomScreen onExit={leaveGame} />} /></Suspense>
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<BottomScreen onExit={leaveGame} />} /></Suspense>
: <FrontEnd onLaunch={launchGame} />}
</main>
);
}
export default function App() {
if (import.meta.env.DEV && new URLSearchParams(window.location.search).get("preview") === "healer-models") {
return <Suspense fallback={null}><HealerModelGallery /></Suspense>;
}
return <MainApp />;
}
+16
View File
@@ -3,3 +3,19 @@
Only assets referenced by the game ship from this folder. They are copied from the ignored `game_assets` source library, retain their source-relative path, and are imported with Vite `new URL(...)` calls.
Use `pnpm assets:import <path-within-game_assets>` to add a source asset. Add `--replace` only when deliberately updating an existing runtime copy.
## KTX2/UASTC pilot
The tracked `*-uastc.glb` files are parallel optimized copies. Their original GLBs remain the rollback path and must not be replaced or deleted during this pilot.
KTX-Software `toktx` is an authoring dependency. Rebuild source copies with:
```sh
TOKTX=/path/to/toktx pnpm assets:build-ktx2
TOKTX=/path/to/toktx pnpm assets:build-dungeon-kit
TOKTX=/path/to/toktx pnpm assets:build-gravehorn
```
Import each generated path from `game_assets/` with `pnpm assets:import <path> --replace`. Builds and dev startup copy Three.js's matching Basis transcoder into the ignored `public/basis/` generated directory.
Force all original GLBs at runtime with `?legacyGameAssets=1`. Force them in an Android/browser build with `VITE_LEGACY_GAME_ASSETS=1 pnpm build`. `legacyDungeonAssets` and `VITE_LEGACY_DUNGEON_ASSETS=1` remain supported aliases.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
# Animated Triceratops Skeleton
- Creator: Zacxophone — https://sketchfab.com/Zacxophone
- Source: https://sketchfab.com/3d-models/animated-triceratops-skeleton-06cb55f941d94dc8b95ac46f92d89e7c
- License: CC0 1.0 Universal — https://creativecommons.org/publicdomain/zero/1.0/
Runtime files named `gravehorn-triceratops*.glb` are optimized derivatives of this model.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+513 -23
View File
@@ -1,15 +1,50 @@
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { Suspense, useEffect, useLayoutEffect, useMemo, useRef } from "react";
import { Component, Suspense, useEffect, useLayoutEffect, useMemo, useRef, type ReactNode } from "react";
import * as THREE from "three";
import { ARENA_CENTER, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "../game/arena";
import {
BLOCKBREAKER_BIOMES,
blockbreakerBiomeForSeed,
type BlockbreakerArenaBiome,
type BlockbreakerBiomeFixture,
} from "../game/blockbreakerBiomes";
import { bossRoomFor, type BossRoomDefinition, type BossRoomFloor } from "../game/bossRooms";
import {
HOCKEY_ARENA_CENTER_Z,
HOCKEY_ARENA_LENGTH,
HOCKEY_ARENA_MAX_X,
HOCKEY_ARENA_MAX_Z,
HOCKEY_ARENA_MIN_X,
HOCKEY_ARENA_MIN_Z,
HOCKEY_ARENA_WIDTH,
HOCKEY_GOAL_HALF_WIDTH,
HOCKEY_HEALER_GOAL_Z,
HOCKEY_MIDLINE_Z,
HOCKEY_NPC_GOAL_Z,
} from "../game/hockeyHealing";
import { useGameStore } from "../game/store";
import { LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider";
import {
HOCKEY_PVP_ARENA_MAX_X,
HOCKEY_PVP_ARENA_MAX_Z,
HOCKEY_PVP_ARENA_MIN_X,
HOCKEY_PVP_ARENA_MIN_Z,
HOCKEY_PVP_GOAL_HALF_WIDTH,
HOCKEY_PVP_GOAL_Z,
} from "../game/hockeyHealingPvp";
import { RpgRoomPortals } from "./rpgRoguelike/RpgRoomPortals";
const ROOM_CENTER_Z = ARENA_CENTER[1];
const KAYKIT_DUNGEON_PILLAR_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/pillar-decorated.glb", import.meta.url).href;
const KAYKIT_DUNGEON_WALL_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/wall-pillar.glb", import.meta.url).href;
const KAYKIT_DUNGEON_TORCH_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/torch-lit.glb", import.meta.url).href;
const KAYKIT_DUNGEON_KIT_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/dungeon-kit-uastc.glb", import.meta.url).href;
const DUNGEON_MESH_NAMES = {
pillar: "pillar_decorated",
wall: "wall_pillar",
torch: "torch_lit",
} as const;
type ArenaFixture = {
position: readonly [number, number, number];
@@ -106,21 +141,19 @@ function firstMesh(scene: THREE.Object3D) {
return mesh as THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
}
function DungeonAssetInstances({
url,
function DungeonMeshInstances({
mesh,
fixtures,
tint,
opacity = 1,
colors,
}: {
url: string;
mesh: THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
fixtures: readonly ArenaFixture[];
tint: string;
opacity?: number;
colors?: readonly THREE.Color[];
}) {
const gltf = useGLTF(url, false, true);
const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]);
const sourceMaterial = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
const material = useMemo(() => {
const next = sourceMaterial.clone();
@@ -156,23 +189,84 @@ function DungeonAssetInstances({
return <instancedMesh ref={instances} args={[mesh.geometry, material, fixtures.length]} castShadow receiveShadow />;
}
function ArenaArchitecture({ room }: { room: BossRoomDefinition }) {
const walls = useMemo(() => ARENA_WALL_SEGMENTS.map((fixture) => ({
function LegacyDungeonAssetInstances({
url,
...props
}: Omit<Parameters<typeof DungeonMeshInstances>[0], "mesh"> & { url: string }) {
const gltf = useGLTF(url, false, true);
const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]);
return <DungeonMeshInstances mesh={mesh} {...props} />;
}
function arenaWalls(room: BossRoomDefinition, portalOpenings = false) {
return ARENA_WALL_SEGMENTS.filter((_, index) => !portalOpenings || index !== 0 && index !== 8).map((fixture) => ({
...fixture,
scaleY: room.wallHeight / 4,
})), [room.wallHeight]);
}));
}
function LegacyArenaArchitecture({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
const walls = useMemo(() => arenaWalls(room, portalOpenings), [portalOpenings, room]);
return (
<group>
<DungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
<DungeonAssetInstances url={KAYKIT_DUNGEON_PILLAR_URL} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
<DungeonAssetInstances url={KAYKIT_DUNGEON_TORCH_URL} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_PILLAR_URL} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_TORCH_URL} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
</group>
);
}
function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
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, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
const gltf = useGameGLTF(KAYKIT_DUNGEON_KIT_URL);
const walls = useMemo(() => arenaWalls(room, portalOpenings), [portalOpenings, 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, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
const walls = useRef<THREE.Group>(null);
const previousCameraPosition = useRef<THREE.Vector3 | null>(null);
const fixtures = useMemo(
() => ARENA_WALL_SEGMENTS.filter((_, index) => !portalOpenings || index !== 0 && index !== 8),
[portalOpenings],
);
useFrame(({ camera }) => {
if (!walls.current) return;
@@ -190,7 +284,7 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
return (
<group ref={walls}>
{ARENA_WALL_SEGMENTS.map((fixture, index) => (
{fixtures.map((fixture, index) => (
<mesh
key={index}
position={[fixture.position[0], room.wallHeight / 2, fixture.position[2]]}
@@ -205,14 +299,30 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
);
}
function RoomWalls({ room }: { room: BossRoomDefinition }) {
function LegacyRoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
return (
<Suspense fallback={<RoomWallFallback room={room} />}>
<ArenaArchitecture room={room} />
<Suspense fallback={<RoomWallFallback room={room} portalOpenings={portalOpenings} />}>
<LegacyArenaArchitecture room={room} portalOpenings={portalOpenings} />
</Suspense>
);
}
function OptimizedRoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
return (
<DungeonAssetErrorBoundary fallback={<LegacyRoomWalls room={room} portalOpenings={portalOpenings} />}>
<Suspense fallback={<RoomWallFallback room={room} portalOpenings={portalOpenings} />}>
<DungeonKitArchitecture room={room} portalOpenings={portalOpenings} />
</Suspense>
</DungeonAssetErrorBoundary>
);
}
function RoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
return LEGACY_GAME_ASSETS_FORCED
? <LegacyRoomWalls room={room} portalOpenings={portalOpenings} />
: <OptimizedRoomWalls room={room} portalOpenings={portalOpenings} />;
}
function RoomMarks({ room }: { room: BossRoomDefinition }) {
const rays = useRef<THREE.InstancedMesh>(null);
const pattern = ROOM_PATTERNS[room.floor];
@@ -306,7 +416,7 @@ function RoomScenery({ room }: { room: BossRoomDefinition }) {
);
}
function RoomFloor({ room }: { room: BossRoomDefinition }) {
function RoomFloor({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
return (
<group>
<mesh position={[0, -0.45, ROOM_CENTER_Z]} receiveShadow>
@@ -323,14 +433,384 @@ function RoomFloor({ room }: { room: BossRoomDefinition }) {
</mesh>
<RoomMarks room={room} />
<RoomScenery room={room} />
<RoomWalls room={room} />
<RoomWalls room={room} portalOpenings={portalOpenings} />
</group>
);
}
function HockeyGoal({ z, color }: { z: number; color: string }) {
const goalWidth = HOCKEY_GOAL_HALF_WIDTH * 2;
return (
<group position={[0, 0, z]}>
<mesh position={[-HOCKEY_GOAL_HALF_WIDTH, 1.05, 0]} castShadow>
<boxGeometry args={[0.18, 2.1, 0.24]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={1.7} metalness={0.45} roughness={0.32} />
</mesh>
<mesh position={[HOCKEY_GOAL_HALF_WIDTH, 1.05, 0]} castShadow>
<boxGeometry args={[0.18, 2.1, 0.24]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={1.7} metalness={0.45} roughness={0.32} />
</mesh>
<mesh position={[0, 2.03, 0]} castShadow>
<boxGeometry args={[goalWidth, 0.16, 0.22]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={1.2} metalness={0.45} roughness={0.32} />
</mesh>
<mesh position={[0, 1.05, z === HOCKEY_HEALER_GOAL_Z ? 0.08 : -0.08]}>
<planeGeometry args={[goalWidth, 2]} />
<meshBasicMaterial color={color} transparent opacity={0.14} wireframe depthWrite={false} />
</mesh>
<pointLight color={color} intensity={2.4} distance={7} position={[0, 1.3, 0]} />
</group>
);
}
function HockeyHealingRoom() {
const goalSideWidth = (HOCKEY_ARENA_WIDTH - HOCKEY_GOAL_HALF_WIDTH * 2) * 0.5;
const leftGoalSideX = HOCKEY_ARENA_MIN_X + goalSideWidth * 0.5;
const rightGoalSideX = HOCKEY_ARENA_MAX_X - goalSideWidth * 0.5;
const wallMaterial = <meshStandardMaterial color="#172d36" roughness={0.58} metalness={0.48} />;
return (
<group>
<color attach="background" args={["#020b11"]} />
<fog attach="fog" args={["#061a24", 18, 46]} />
<hemisphereLight args={["#80dfff", "#061015", 1.08]} />
<directionalLight castShadow position={[6, 12, 8]} intensity={1.8} color="#d6f7ff" shadow-mapSize={[512, 512]} />
<pointLight color="#50dfff" intensity={2.2} distance={14} position={[0, 4, HOCKEY_MIDLINE_Z]} />
<mesh position={[0, -0.42, HOCKEY_ARENA_CENTER_Z]} receiveShadow>
<boxGeometry args={[HOCKEY_ARENA_WIDTH + 1.2, 0.8, HOCKEY_ARENA_LENGTH + 1.2]} />
<meshStandardMaterial color="#061117" roughness={0.9} />
</mesh>
<mesh position={[0, -0.01, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[HOCKEY_ARENA_WIDTH, HOCKEY_ARENA_LENGTH]} />
<meshStandardMaterial color="#102731" roughness={0.68} metalness={0.3} />
</mesh>
<mesh position={[0, 0.012, HOCKEY_MIDLINE_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH - 0.5, 0.09]} />
<meshBasicMaterial color="#64e7ff" transparent opacity={0.62} />
</mesh>
<mesh position={[0, 0.018, HOCKEY_MIDLINE_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[2.45, 2.55, 48]} />
<meshBasicMaterial color="#64e7ff" transparent opacity={0.44} />
</mesh>
<mesh position={[HOCKEY_ARENA_MIN_X - 0.15, 1.5, HOCKEY_ARENA_CENTER_Z]} castShadow receiveShadow>
<boxGeometry args={[0.3, 3, HOCKEY_ARENA_LENGTH + 0.6]} />
{wallMaterial}
</mesh>
<mesh position={[HOCKEY_ARENA_MAX_X + 0.15, 1.5, HOCKEY_ARENA_CENTER_Z]} castShadow receiveShadow>
<boxGeometry args={[0.3, 3, HOCKEY_ARENA_LENGTH + 0.6]} />
{wallMaterial}
</mesh>
{[HOCKEY_ARENA_MIN_Z - 0.15, HOCKEY_ARENA_MAX_Z + 0.15].flatMap((z) => [leftGoalSideX, rightGoalSideX].map((x) => (
<mesh key={`${x}-${z}`} position={[x, 1.5, z]} castShadow receiveShadow>
<boxGeometry args={[goalSideWidth, 3, 0.3]} />
<meshStandardMaterial color="#172d36" roughness={0.58} metalness={0.48} />
</mesh>
)))}
<HockeyGoal z={HOCKEY_NPC_GOAL_Z} color="#ff6e5c" />
<HockeyGoal z={HOCKEY_HEALER_GOAL_Z} color="#67e8ff" />
</group>
);
}
const BLOCKBREAKER_BIOME_FIXTURES = [
[-12.75, -12.5, 0.94],
[-12.75, -1, 1.08],
[-12.75, 10.5, 0.9],
[12.75, -12.5, 1.02],
[12.75, -1, 0.88],
[12.75, 10.5, 1.12],
] as const;
function BlockbreakerFixtureGeometry({ fixture }: { fixture: BlockbreakerBiomeFixture }) {
if (fixture === "crystal") return <octahedronGeometry args={[1.25, 0]} />;
if (fixture === "forge") return <cylinderGeometry args={[0.72, 1.18, 2.8, 6]} />;
if (fixture === "spire") return <coneGeometry args={[1.05, 3.7, 5]} />;
if (fixture === "monolith") return <boxGeometry args={[1.25, 3.5, 1.25]} />;
return <torusGeometry args={[1.08, 0.3, 8, 18]} />;
}
function BlockbreakerBiomeFixtures({ biome }: { biome: BlockbreakerArenaBiome }) {
const mesh = useRef<THREE.InstancedMesh>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
useLayoutEffect(() => {
if (!mesh.current) return;
BLOCKBREAKER_BIOME_FIXTURES.forEach(([x, z, scale], index) => {
transform.position.set(x, biome.fixture === "reactor" ? 1.45 : 1.75, z);
transform.rotation.set(
biome.fixture === "reactor" ? 0 : index % 2 === 0 ? -0.08 : 0.08,
index * 0.73,
biome.fixture === "reactor" ? 0 : index % 2 === 0 ? 0.06 : -0.06,
);
transform.scale.setScalar(scale);
transform.updateMatrix();
mesh.current!.setMatrixAt(index, transform.matrix);
});
mesh.current.instanceMatrix.needsUpdate = true;
mesh.current.computeBoundingSphere();
}, [biome.fixture, transform]);
return (
<instancedMesh ref={mesh} args={[undefined, undefined, BLOCKBREAKER_BIOME_FIXTURES.length]} castShadow receiveShadow>
<BlockbreakerFixtureGeometry fixture={biome.fixture} />
<meshStandardMaterial
color={biome.fixtureColor}
emissive={biome.fixtureEmissive}
emissiveIntensity={0.72}
roughness={biome.fixture === "monolith" ? 0.26 : 0.48}
metalness={biome.fixture === "forge" || biome.fixture === "reactor" ? 0.42 : 0.12}
/>
</instancedMesh>
);
}
function BrightArcadeRoom({
variant,
biome = BLOCKBREAKER_BIOMES[0],
}: {
variant: "blockbreaker" | "aether-assault";
biome?: BlockbreakerArenaBiome;
}) {
const roomWidth = HOCKEY_ARENA_WIDTH + 10;
const roomLength = HOCKEY_ARENA_LENGTH + 12;
const roomMinX = HOCKEY_ARENA_MIN_X - 5;
const roomMaxX = HOCKEY_ARENA_MAX_X + 5;
const roomMinZ = HOCKEY_ARENA_MIN_Z - 6;
const roomMaxZ = HOCKEY_ARENA_MAX_Z + 6;
const wallHeight = 4.8;
const wallMaterial = (
<meshStandardMaterial
color={biome.wall}
emissive={biome.wallEmissive}
emissiveIntensity={biome.wallEmissiveIntensity}
roughness={0.62}
metalness={0.12}
/>
);
return (
<group key={variant === "blockbreaker" ? biome.id : variant}>
<color attach="background" args={[biome.background]} />
<fog attach="fog" args={[biome.fog, 34, 72]} />
<ambientLight color={biome.ambient} intensity={biome.ambientIntensity} />
<hemisphereLight args={[biome.sky, biome.ground, biome.hemisphereIntensity]} />
<directionalLight
castShadow
position={[8, 15, 10]}
intensity={biome.keyLightIntensity}
color={biome.keyLight}
shadow-mapSize={[512, 512]}
/>
<pointLight color={biome.fillLightA} intensity={biome.fillLightIntensityA} distance={30} position={[-8, 7, 7]} />
<pointLight color={biome.fillLightB} intensity={biome.fillLightIntensityB} distance={30} position={[8, 7, -9]} />
<mesh position={[0, -0.48, HOCKEY_ARENA_CENTER_Z]} receiveShadow>
<boxGeometry args={[roomWidth + 1.2, 0.9, roomLength + 1.2]} />
<meshStandardMaterial color={biome.foundation} roughness={0.92} metalness={0.04} />
</mesh>
<mesh position={[0, -0.015, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[roomWidth, roomLength]} />
<meshStandardMaterial color={biome.floor} roughness={biome.floorRoughness} metalness={biome.floorMetalness} />
</mesh>
<mesh position={[0, 0.006, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH, HOCKEY_ARENA_LENGTH]} />
<meshStandardMaterial color={biome.playfield} transparent opacity={0.72} roughness={0.52} metalness={0.05} />
</mesh>
{[HOCKEY_ARENA_MIN_X, HOCKEY_ARENA_MAX_X].map((x) => (
<mesh key={`lane-x-${x}`} position={[x, 0.025, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[0.1, HOCKEY_ARENA_LENGTH]} />
<meshBasicMaterial color={biome.boundary} transparent opacity={0.72} toneMapped={false} />
</mesh>
))}
{[HOCKEY_ARENA_MIN_Z, HOCKEY_ARENA_MAX_Z].map((z) => (
<mesh key={`lane-z-${z}`} position={[0, 0.025, z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH, 0.1]} />
<meshBasicMaterial color={biome.boundary} transparent opacity={0.72} toneMapped={false} />
</mesh>
))}
{variant === "blockbreaker" ? (
<mesh position={[0, 0.028, HOCKEY_MIDLINE_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH, 0.12]} />
<meshBasicMaterial color={biome.midline} transparent opacity={0.86} toneMapped={false} />
</mesh>
) : (
<group>
{[-6.7, -3.35, 0, 3.35, 6.7].map((x) => (
<mesh key={`aether-lane-${x}`} position={[x, 0.029, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[0.055, HOCKEY_ARENA_LENGTH - 0.8]} />
<meshBasicMaterial color="#c6fbff" transparent opacity={x === 0 ? 0.58 : 0.3} toneMapped={false} />
</mesh>
))}
{[-10.7, -8.45, -6.2, -3.95].map((z) => (
<mesh key={`aether-rank-${z}`} position={[0, 0.03, z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH - 1, 0.045]} />
<meshBasicMaterial color="#fff2bf" transparent opacity={0.36} toneMapped={false} />
</mesh>
))}
</group>
)}
{[roomMinX - 0.18, roomMaxX + 0.18].map((x) => (
<mesh key={`room-x-${x}`} position={[x, wallHeight * 0.5, HOCKEY_ARENA_CENTER_Z]} castShadow receiveShadow>
<boxGeometry args={[0.36, wallHeight, roomLength + 0.7]} />
{wallMaterial}
</mesh>
))}
{[roomMinZ - 0.18, roomMaxZ + 0.18].map((z) => (
<mesh key={`room-z-${z}`} position={[0, wallHeight * 0.5, z]} castShadow receiveShadow>
<boxGeometry args={[roomWidth + 0.7, wallHeight, 0.36]} />
{wallMaterial}
</mesh>
))}
{[roomMinX - 0.36, roomMaxX + 0.36].map((x) => (
<mesh key={`light-x-${x}`} position={[x, 3.7, HOCKEY_ARENA_CENTER_Z]}>
<boxGeometry args={[0.12, 0.18, roomLength - 1]} />
<meshBasicMaterial color={biome.railA} toneMapped={false} />
</mesh>
))}
{[roomMinZ - 0.36, roomMaxZ + 0.36].map((z) => (
<mesh key={`light-z-${z}`} position={[0, 3.7, z]}>
<boxGeometry args={[roomWidth - 1, 0.18, 0.12]} />
<meshBasicMaterial color={biome.railB} toneMapped={false} />
</mesh>
))}
{variant === "blockbreaker" && <BlockbreakerBiomeFixtures biome={biome} />}
</group>
);
}
function HealingHockeyPvpRoom() {
const width = HOCKEY_PVP_ARENA_MAX_X - HOCKEY_PVP_ARENA_MIN_X;
const length = HOCKEY_PVP_ARENA_MAX_Z - HOCKEY_PVP_ARENA_MIN_Z;
const goalSideWidth = (width - HOCKEY_PVP_GOAL_HALF_WIDTH * 2) * 0.5;
const sideCenters = [
HOCKEY_PVP_ARENA_MIN_X + goalSideWidth * 0.5,
HOCKEY_PVP_ARENA_MAX_X - goalSideWidth * 0.5,
];
return (
<group>
<color attach="background" args={["#040912"]} />
<fog attach="fog" args={["#071522", 28, 68]} />
<hemisphereLight args={["#8deaff", "#130812", 1.1]} />
<directionalLight castShadow position={[7, 14, 10]} intensity={1.9} color="#d8fbff" shadow-mapSize={[512, 512]} />
<pointLight color="#62e7ff" intensity={2.5} distance={18} position={[0, 4, 13]} />
<pointLight color="#ff6f83" intensity={2.5} distance={18} position={[0, 4, -13]} />
<mesh position={[0, -0.42, 0]} receiveShadow>
<boxGeometry args={[width + 1.2, 0.8, length + 1.2]} />
<meshStandardMaterial color="#070d17" roughness={0.9} />
</mesh>
<mesh position={[0, -0.01, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[width, length]} />
<meshStandardMaterial color="#112632" roughness={0.7} metalness={0.32} />
</mesh>
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[width - 0.5, 0.11]} />
<meshBasicMaterial color="#f4d978" transparent opacity={0.7} />
</mesh>
{[-11, 11].map((z) => <mesh key={z} position={[0, 0.014, z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[width - 0.8, 0.07]} />
<meshBasicMaterial color={z > 0 ? "#64e7ff" : "#ff7688"} transparent opacity={0.38} />
</mesh>)}
{[HOCKEY_PVP_ARENA_MIN_X - 0.15, HOCKEY_PVP_ARENA_MAX_X + 0.15].map((x) => (
<mesh key={x} position={[x, 1.5, 0]} castShadow receiveShadow>
<boxGeometry args={[0.3, 3, length + 0.6]} />
<meshStandardMaterial color="#172b38" roughness={0.58} metalness={0.48} />
</mesh>
))}
{[-HOCKEY_PVP_ARENA_MAX_Z - 0.15, HOCKEY_PVP_ARENA_MAX_Z + 0.15].flatMap((z) => sideCenters.map((x) => (
<mesh key={`${x}-${z}`} position={[x, 1.5, z]} castShadow receiveShadow>
<boxGeometry args={[goalSideWidth, 3, 0.3]} />
<meshStandardMaterial color="#172b38" roughness={0.58} metalness={0.48} />
</mesh>
)))}
<HockeyGoal z={-HOCKEY_PVP_GOAL_Z} color="#ff647b" />
<HockeyGoal z={HOCKEY_PVP_GOAL_Z} color="#62e7ff" />
<HockeyPvpScoreboards />
</group>
);
}
function HockeyPvpScoreboards() {
const localGoals = useGameStore((state) => state.hockeyPvp.opponentGoalsConceded);
const opponentGoals = useGameStore((state) => state.hockeyPvp.localGoalsConceded);
const texture = useMemo(() => {
const canvas = document.createElement("canvas");
canvas.width = 512;
canvas.height = 192;
const next = new THREE.CanvasTexture(canvas);
next.colorSpace = THREE.SRGBColorSpace;
next.minFilter = THREE.LinearMipmapLinearFilter;
next.magFilter = THREE.LinearFilter;
return next;
}, []);
useEffect(() => {
const canvas = texture.image as HTMLCanvasElement;
const context = canvas.getContext("2d");
if (!context) return;
const gradient = context.createLinearGradient(0, 0, canvas.width, canvas.height);
gradient.addColorStop(0, "#061d28");
gradient.addColorStop(0.5, "#05080d");
gradient.addColorStop(1, "#2b0a17");
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
context.strokeStyle = "#89efff";
context.lineWidth = 5;
context.strokeRect(5, 5, canvas.width - 10, canvas.height - 10);
context.fillStyle = "#a8c4c9";
context.font = "700 20px Inter, sans-serif";
context.textAlign = "center";
context.fillText("HEALING HOCKEY", canvas.width / 2, 31);
context.fillStyle = "#74ecff";
context.font = "700 18px Inter, sans-serif";
context.fillText("YOU", 132, 58);
context.fillStyle = "#ff819f";
context.fillText("RIVAL", 380, 58);
context.font = "700 94px Impact, Inter, sans-serif";
context.fillStyle = "#eaffff";
context.fillText(String(Math.min(99, localGoals)).padStart(2, "0"), 132, 151);
context.fillStyle = "#ffedf4";
context.fillText(String(Math.min(99, opponentGoals)).padStart(2, "0"), 380, 151);
context.fillStyle = "#f3d87c";
context.font = "700 56px Inter, sans-serif";
context.fillText("", 256, 137);
texture.needsUpdate = true;
}, [localGoals, opponentGoals, texture]);
useEffect(() => () => texture.dispose(), [texture]);
return <>{([-1, 1] as const).map((side) => (
<group
key={side}
position={[side * 9.78, 4.05, 5.5]}
rotation={[0, side < 0 ? Math.PI / 2 : -Math.PI / 2, 0]}
>
<mesh castShadow>
<boxGeometry args={[7.6, 3.25, 0.24]} />
<meshStandardMaterial color="#101923" roughness={0.32} metalness={0.72} />
</mesh>
<mesh position={[0, 0, 0.126]}>
<planeGeometry args={[7.28, 2.93]} />
<meshBasicMaterial map={texture} toneMapped={false} />
</mesh>
</group>
))}</>;
}
/** Main-display room projection. Gameplay stays in the shared arena domain. */
export function BossRoom() {
const bossId = useGameStore((state) => state.boss.id);
const blockbreakerSeed = useGameStore((state) => state.blockbreaker.seed);
const hockeyMode = useGameStore((state) => state.activityMode === "hockey-healing");
const blockbreakerMode = useGameStore((state) => state.activityMode === "blockbreaker");
const aetherAssaultMode = useGameStore((state) => state.activityMode === "aether-assault");
const hockeyPvpMode = useGameStore((state) => state.activityMode === "hockey-healing-pvp");
const rpgPhase = useGameStore((state) => state.rpgRun?.phase ?? null);
const rpgBossRoom = useGameStore((state) => state.runMode === "rpg-roguelike" && state.activityMode === "boss");
if (hockeyPvpMode) return <HealingHockeyPvpRoom />;
if (aetherAssaultMode) return <BrightArcadeRoom variant="aether-assault" />;
if (blockbreakerMode) return <BrightArcadeRoom variant="blockbreaker" biome={blockbreakerBiomeForSeed(blockbreakerSeed)} />;
if (hockeyMode) return <HockeyHealingRoom />;
const room = bossRoomFor(bossId);
return (
<group key={room.id}>
@@ -340,11 +820,21 @@ export function BossRoom() {
<directionalLight castShadow position={[5, 10, 8]} intensity={2.1} color={room.accentSecondary} shadow-mapSize={[512, 512]} />
<pointLight color={room.accent} intensity={2.35} distance={8} position={[-6, 2.8, ROOM_CENTER_Z]} />
<pointLight color={room.accentSecondary} intensity={1.75} distance={7} position={[6, 2.4, ROOM_CENTER_Z - 1]} />
<RoomFloor room={room} />
<RoomFloor room={room} portalOpenings={rpgBossRoom} />
{rpgBossRoom && rpgPhase && (
<RpgRoomPortals
entryOpen
exitOpen={rpgPhase === "boss-cleared" || rpgPhase === "reward"}
accent={room.accent}
wallColor={room.wallColor}
/>
)}
</group>
);
}
useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true);
useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true);
useGLTF.preload(KAYKIT_DUNGEON_TORCH_URL, false, true);
if (LEGACY_GAME_ASSETS_FORCED) {
useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true);
useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true);
useGLTF.preload(KAYKIT_DUNGEON_TORCH_URL, false, true);
}
+5 -3
View File
@@ -1,14 +1,14 @@
import { Canvas } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { Suspense, useMemo } from "react";
import * as THREE from "three";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { ALTERNATE_BOSS_CONFIG, bossVisualUrl } from "../game/bossVisuals";
import type { BossId } from "../game/types";
import { GameAssetProvider, LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider";
function PortraitModel({ bossId }: { bossId: BossId }) {
const gltf = useGLTF(bossVisualUrl(bossId), false, true);
const gltf = useGameGLTF(bossVisualUrl(bossId, !LEGACY_GAME_ASSETS_FORCED));
const model = useMemo(() => {
const clone = cloneSkeleton(gltf.scene);
clone.updateMatrixWorld(true);
@@ -49,7 +49,9 @@ export function BossTrophyPortrait({ bossId }: { bossId: BossId }) {
<ambientLight intensity={1.9} />
<directionalLight color="#fff3cf" intensity={3.2} position={[3, 5, 4]} />
<directionalLight color={boss.accent} intensity={2.1} position={[-4, 2, -2]} />
<Suspense fallback={null}><PortraitModel bossId={bossId} /></Suspense>
<GameAssetProvider>
<Suspense fallback={null}><PortraitModel bossId={bossId} /></Suspense>
</GameAssetProvider>
</Canvas>
<span aria-hidden="true">{boss.icon}</span>
</div>
+395 -43
View File
@@ -1,12 +1,39 @@
import { ABILITY_ORDER } from "../game/data";
import { HEALER_CLASSES } from "../game/healers";
import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { BARRIER_RADIUS, GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
import type { BottomTab, PartyMember } from "../game/types";
import { useFrontendStore } from "../frontend/store";
import { useActiveHunter, useFrontendStore } from "../frontend/store";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
import {
HOCKEY_ARENA_CENTER_Z,
HOCKEY_GOAL_HALF_WIDTH,
HOCKEY_HEALER_GOAL_Z,
HOCKEY_NPC_PADDLE_HALF_WIDTH,
HOCKEY_NPC_PADDLE_Z,
HOCKEY_NPC_GOAL_Z,
} from "../game/hockeyHealing";
import {
HOCKEY_PVP_GOAL_DAMAGE,
HOCKEY_PVP_GOAL_HALF_WIDTH,
HOCKEY_PVP_GOAL_Z,
HOCKEY_PVP_SIDE_OFFSET_Z,
} from "../game/hockeyHealingPvp";
import { bottomTabsFor } from "../game/bottomTabs";
import {
BLOCKBREAKER_BREACH_DAMAGE,
BLOCKBREAKER_BRICK_COLORS,
BLOCKBREAKER_DANGER_Z,
blockbreakerColumnX,
blockbreakerRowZ,
blockbreakerTimeMultiplier,
} from "../game/blockbreaker";
import { aetherShipColor } from "./aetherAssaultVisuals";
import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings";
import { RpgRunTacticalPanel } from "./rpgRoguelike/RpgRunTacticalPanel";
import { isBeaconOfLightTarget } from "../game/healerMechanics";
function RewardSummary() {
const rewards = useFrontendStore((state) => state.recentRewards);
@@ -30,23 +57,27 @@ function PartyFrame({ member }: { member: PartyMember }) {
const selected = useGameStore((state) => state.selectedMemberId === member.id);
const selectMember = useGameStore((state) => state.selectMember);
const time = useGameStore((state) => state.time);
const renewRemaining = Math.max(0, member.renewExpiresAt - time);
const healerMechanic = useGameStore((state) => state.healerMechanic);
const beaconed = isBeaconOfLightTarget(member.id, healerMechanic, time);
const activeHealingEffects = member.healingEffects.filter((effect) => effect.expiresAt > time).slice(0, 3);
const knockedRemaining = Math.max(0, member.knockedUntil - time);
const barrier = useGameStore((state) => state.barrier);
const tankAura = useGameStore((state) => state.partyCombat.tankAura);
const tankPosition = useGameStore((state) => state.partyPositions.brann);
const tankPosition = useGameStore((state) => state.partyPositions[state.partyCombat.tankAura.sourceId]);
const combatant = useGameStore((state) => member.id === "aelia" ? undefined : state.partyCombat.combatants[member.id]);
const position = useGameStore((state) => state.partyPositions[member.id]);
const protectedByBarrier = barrierProtects(position, barrier, time);
const linkedBySpirit = barrier.kind === "spirit-link" && healerFieldContains(position, barrier, time);
const protectedByTank = tankAuraProtects(position, tankPosition, tankAura, time);
const currentAction = combatant?.visualAction && combatant.visualAction.endsAt > time
? PARTY_ABILITY_NAMES[combatant.visualAction.abilityId]
: null;
return (
<button
className={`party-frame ${selected ? "is-selected" : ""} ${member.hp <= 0 ? "is-down" : ""}`}
className={`party-frame ${selected ? "is-selected" : ""} ${beaconed ? "is-beacon" : ""} ${member.hp <= 0 ? "is-down" : ""}`}
onClick={() => selectMember(member.id)}
aria-pressed={selected}
aria-label={`${member.name}, ${Math.ceil(member.hp)} health${beaconed ? ", Beacon of Light" : ""}`}
>
<span className="party-avatar" style={{ "--member-color": member.color } as React.CSSProperties}>{member.name[0]}</span>
<span className="party-data">
@@ -55,10 +86,16 @@ function PartyFrame({ member }: { member: PartyMember }) {
<small>{currentAction ?? member.className}</small>
</span>
<span className="effect-stack">
{beaconed && <i className="effect beacon-effect" title={`Beacon of Light: ${Math.max(0, healerMechanic.beaconExpiresAt - time).toFixed(1)} seconds`}></i>}
{member.absorb > 0 && <i className="effect shield-effect" title={`${Math.ceil(member.absorb)} absorption`}></i>}
{renewRemaining > 0 && <i className="effect renew-effect" title={`Renew: ${renewRemaining.toFixed(1)} seconds`}>{Math.ceil(renewRemaining)}</i>}
{activeHealingEffects.map((effect) => {
const label = effect.id === "renew" ? "R" : effect.id === "regrowth" ? "G" : effect.id === "rejuvenation" ? "J" : effect.id === "lifebloom" ? `L${effect.stacks}` : effect.id === "wild-growth" ? "W" : "T";
return <i key={effect.id} className="effect renew-effect" title={`${effect.id}: ${Math.max(0, effect.expiresAt - time).toFixed(1)} seconds`}>{label}</i>;
})}
{member.reactiveHeal && member.reactiveHeal.expiresAt > time && <i className="effect earth-shield-effect" title={`Earth Shield: ${member.reactiveHeal.charges} charges`}>{member.reactiveHeal.charges}</i>}
{member.debuffs.length > 0 && <i className="effect debuff-effect" title={`${member.debuffs[0].name} — Purify`}>!</i>}
{protectedByBarrier && <i className="effect barrier-effect" title="Barrier: 30% reduced damage">B</i>}
{linkedBySpirit && <i className="effect spirit-link-effect" title="Spirit Link: health equalized each second">S</i>}
{protectedByTank && <i className="effect tank-aura-effect" title="Bulwark March: 30% reduced damage">T</i>}
{knockedRemaining > 0 && <i className="effect knock-effect" title={`Knocked down: ${knockedRemaining.toFixed(1)} seconds`}>KD</i>}
</span>
@@ -78,25 +115,32 @@ function PartyList() {
function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number] }) {
const healerClassId = useGameStore((state) => state.healerClassId);
const ability = HEALER_CLASSES[healerClassId].abilities[abilityId];
const ability = useGameStore((state) => resolveSlottedAbility(state.abilityLoadout, abilityId));
const time = useGameStore((state) => state.time);
const cooldowns = useGameStore((state) => state.cooldowns);
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
const mana = useGameStore((state) => state.mana);
const phase = useGameStore((state) => state.phase);
const healerAlive = useGameStore((state) => state.party.some((member) => member.id === "aelia" && member.hp > 0));
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const activeCast = useGameStore((state) => state.activeCast);
const castAbility = useGameStore((state) => state.castAbility);
const runModifiers = useGameStore((state) => state.runModifiers);
const healerMechanic = useGameStore((state) => state.healerMechanic);
if (!ability) {
return <button className={`ability ability-${abilityId} is-empty`} disabled aria-label={`Empty ${abilityId}`}><span className="ability-key">{Number(abilityId.slice(-1))}</span><span className="ability-icon"></span><span className="ability-copy"><strong>Empty</strong><small>No spell drafted</small></span><span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span></button>;
}
const remaining = abilityRemaining(abilityId, time, cooldowns);
const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
const castTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
const baseCastTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
const castTime = ability.id === "shaman-healing-wave" && healerMechanic.resource > 0 ? baseCastTime * 0.5 : baseCastTime;
const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
const globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = abilityId === "purify" && selected.debuffs.length === 0;
const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0;
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
const disabled = phase !== "combat" || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const resourceCopy = `${manaCost ? `${manaCost} mana` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const resourceName = HEALER_CLASSES[healerClassId].resourceName.toLowerCase();
const resourceCopy = `${manaCost ? `${manaCost} ${resourceName}` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
return (
<button
@@ -107,10 +151,10 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
title={ability.description}
aria-label={`${ability.name}. ${ability.description}`}
>
<span className="ability-key">{ability.key}</span>
<span className="ability-key">{Number(abilityId.slice(-1))}</span>
<span className="ability-icon">{ability.icon}</span>
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
<span className="ability-pad">{ability.gamepad}</span>
<span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span>
{remaining > 0 && (
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / cooldownDuration) } as React.CSSProperties}>
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
@@ -130,17 +174,37 @@ function AbilityTray() {
const healer = HEALER_CLASSES[healerClassId];
const mana = useGameStore((state) => state.mana);
const maxMana = useGameStore((state) => state.maxMana);
const healerMechanic = useGameStore((state) => state.healerMechanic);
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const time = useGameStore((state) => state.time);
const boss = useGameStore((state) => state.boss);
const bossMotion = useGameStore((state) => state.bossMotion);
const additionalBosses = useGameStore((state) => state.additionalBosses);
const mechanic = upcomingEncounterMechanic({ boss, bossMotion, additionalBosses, time });
const activityMode = useGameStore((state) => state.activityMode);
const hockeyReturns = useGameStore((state) => state.hockey.returns);
const bossKills = useGameStore((state) => state.endlessBossKills);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyMode = activityMode === "hockey-healing";
const pvpMode = activityMode === "hockey-healing-pvp";
const blockbreakerMode = activityMode === "blockbreaker";
const aetherMode = activityMode === "aether-assault";
const duration = `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`;
const nextRowSeconds = Math.max(0, blockbreaker.nextRowAt - time);
return (
<div className="ability-column">
<div className="ability-meta">
<div className={`ability-meta ${hockeyMode || pvpMode || blockbreakerMode || aetherMode ? "is-hockey" : ""} ${pvpMode ? "is-pvp" : ""}`}>
<div className="target-chip"><span>Target</span><strong>{selected.name}</strong></div>
<div className="mana-wrap"><span>{healer.resourceName}</span><b>{Math.ceil(mana)}</b><i><em style={{ width: `${(mana / maxMana) * 100}%` }} /></i></div>
<div className="resource-stack">
<div className="mana-wrap"><span>{healer.resourceName}</span><b>{Math.ceil(mana)}</b><i><em style={{ width: `${(mana / maxMana) * 100}%` }} /></i></div>
{healer.secondaryResourceName && <div className="class-resource"><span>{healer.secondaryResourceName}</span><b>{healerMechanic.resource} / {healerMechanic.maxResource}</b></div>}
</div>
{hockeyMode && <div className="hockey-run-meta"><span>Returns</span><strong>{hockeyReturns}</strong><small>{duration} · {bossKills} KOs</small></div>}
{pvpMode && <div className="hockey-run-meta pvp-run-meta"><span>Goals · Bosses</span><strong>{hockeyPvp.opponentGoalsConceded}{hockeyPvp.localGoalsConceded}</strong><small>{bossKills}{hockeyPvp.opponentBossKills} · {hockeyPvp.opponentName}</small></div>}
{blockbreakerMode && <div className="hockey-run-meta blockbreaker-run-meta"><span>Score · Bricks</span><strong>{blockbreaker.score} · {blockbreaker.bricksBroken}</strong><small>{blockbreakerTimeMultiplier(time).toFixed(1)}× · next row {nextRowSeconds.toFixed(1)}s</small></div>}
{aetherMode && <div className="hockey-run-meta aether-run-meta"><span>Score · Wave</span><strong>{aetherAssault.score} · {aetherAssault.wave}</strong><small>{aetherAssault.multiplier.toFixed(2)}× · {aetherAssault.ships.length} ships</small></div>}
</div>
<div className="ability-grid">
{ABILITY_ORDER.map((abilityId) => <AbilityButton key={abilityId} abilityId={abilityId} />)}
@@ -164,14 +228,20 @@ function BriefingPanel() {
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
const bossNames = bosses.map((boss) => boss.name).join(" & ");
const activityMode = useGameStore((state) => state.activityMode);
const hockeyMode = activityMode === "hockey-healing";
const pvpMode = activityMode === "hockey-healing-pvp";
const blockbreakerMode = activityMode === "blockbreaker";
const aetherMode = activityMode === "aether-assault";
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
return (
<div className="briefing-panel">
<div className="briefing-class">
<div className="class-crest" style={{ color: healer.color }}>{healer.icon}</div>
<span>Chosen discipline</span>
<h2>{healer.specialization}</h2>
<p>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</p>
<button className="start-button" onClick={startEncounter}><span>Face {bossNames}</span><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ENTER</small></button>
<p>{hockeyMode ? "Defend the wide goal while healing through two bosses. Left-stick direction sets every puck return; the moving enemy paddle tracks it and strikes it back. Each fallen boss awards loot and rolls its pet chance before replacement." : pvpMode ? `Face ${opponentName}. Both parties use normalized base gear and fight the same boss order. Every boss kill adds 5% global healing Dampening. Aim returns with left stick. A goal deals ${HOCKEY_PVP_GOAL_DAMAGE} damage to every member of the conceding party. Boss kills still award loot and pet chances.` : blockbreakerMode ? `Aim the puck into advancing five-column color rows while healing through two bosses. Orthogonal matching clusters break together. Rows start every 10 seconds and accelerate. Misses safely re-serve; each breach deals ${BLOCKBREAKER_BREACH_DAMAGE} damage to every party member.` : aetherMode ? "Move freely through the full runway while your arcane focus fires automatically. Heal with your normal kit. Dodge enemy volleys and diving ships; ship hits only threaten the healer. Bosses and rewards continue independently." : <>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</>}</p>
<button className="start-button" onClick={startEncounter}><span>{hockeyMode ? "Begin Hockey Healing" : pvpMode ? `Face ${opponentName}` : blockbreakerMode ? "Begin Blockbreaker" : aetherMode ? "Begin Aether Assault" : `Face ${bossNames}`}</span><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ENTER</small></button>
</div>
<div className="briefing-kit">
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
@@ -191,8 +261,10 @@ function BriefingPanel() {
}
function EndPanel({ onExit }: { onExit?: () => void }) {
const hunter = useActiveHunter();
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
const activityMode = useGameStore((state) => state.activityMode);
const round = useGameStore((state) => state.round);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
@@ -205,35 +277,44 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
const startEncounter = useGameStore((state) => state.startEncounter);
const totalHp = party.reduce((sum, member) => sum + member.hp, 0);
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
const hockey = useGameStore((state) => state.hockey);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
const endlessDefeat = phase === "defeat" && endlessMode;
const hockeyDefeat = phase === "defeat" && activityMode === "hockey-healing";
const blockbreakerDefeat = phase === "defeat" && activityMode === "blockbreaker";
const aetherDefeat = phase === "defeat" && activityMode === "aether-assault";
const pvpMatch = activityMode === "hockey-healing-pvp";
const endlessDefeat = phase === "defeat" && endlessMode && !hockeyDefeat && !blockbreakerDefeat && !aetherDefeat;
const endlessHighScore = hunter?.stats.highestRogueTrialsEndlessKills ?? 0;
const endlessHighScoreLabel = `${endlessHighScore} ${endlessHighScore === 1 ? "boss" : "bosses"}`;
return (
<div className={`end-panel end-${phase}`}>
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
<small>{showEndlessChoice ? "ROGUE TRIALS CLEARED" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
<h2>{showEndlessChoice ? "The trial can continue" : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
<small>{showEndlessChoice ? "ROGUE TRIALS CLEARED" : hockeyDefeat ? "HOCKEY HEALING COMPLETE" : blockbreakerDefeat ? "BLOCKBREAKER RUN COMPLETE" : aetherDefeat ? "AETHER ASSAULT COMPLETE" : pvpMatch ? phase === "victory" ? "PVP MATCH WON" : "PVP MATCH LOST" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
<h2>{showEndlessChoice ? "The trial can continue" : hockeyDefeat ? `${hockey.returns} pucks returned` : blockbreakerDefeat ? `${blockbreaker.score} points scored` : aetherDefeat ? `${aetherAssault.score} points scored` : pvpMatch ? phase === "victory" ? `${hockeyPvp.opponentName} fell first` : `${hockeyPvp.opponentName} wins` : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
<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>Party vitality</small><strong>{Math.round((totalHp / totalMax) * 100)}%</strong></span>
<span><small>{endlessDefeat ? "Endless kills" : "Boss"}</small><strong>{endlessDefeat ? endlessBossKills : phase === "victory" ? "Defeated" : "Standing"}</strong></span>
<span><small>{hockeyDefeat ? "Puck returns" : blockbreakerDefeat ? "Bricks broken" : aetherDefeat ? "Wave reached" : pvpMatch ? "Goals" : "Party vitality"}</small><strong>{hockeyDefeat ? hockey.returns : blockbreakerDefeat ? blockbreaker.bricksBroken : aetherDefeat ? aetherAssault.wave : pvpMatch ? `${hockeyPvp.opponentGoalsConceded}${hockeyPvp.localGoalsConceded}` : `${Math.round((totalHp / totalMax) * 100)}%`}</strong></span>
<span><small>{hockeyDefeat || blockbreakerDefeat || aetherDefeat ? "Boss kills" : pvpMatch ? "Boss kills" : endlessDefeat ? "Endless kills" : "Boss"}</small><strong>{hockeyDefeat || blockbreakerDefeat || aetherDefeat || endlessDefeat ? endlessBossKills : pvpMatch ? `${endlessBossKills}${hockeyPvp.opponentBossKills}` : phase === "victory" ? "Defeated" : "Standing"}</strong></span>
</div>
{phase === "victory" && <RewardSummary />}
{(phase === "victory" || hockeyDefeat || blockbreakerDefeat || aetherDefeat) && <RewardSummary />}
{showEndlessChoice ? <div className="end-actions endless-choice-actions">
<button
className={endlessChoiceSelection === "continue" ? "is-controller-focused" : ""}
onFocus={() => setEndlessChoiceSelection("continue")}
className={endlessChoiceSelection === "continue" ? "is-controller-selected" : ""}
onPointerEnter={() => setEndlessChoiceSelection("continue")}
onClick={startRogueTrialsEndless}
>Continue Endless</button>
aria-label={`Endless Mode. Current high score: ${endlessHighScoreLabel}.`}
><span>Endless Mode</span><small>High score · {endlessHighScoreLabel}</small></button>
<button
className={`secondary ${endlessChoiceSelection === "quit" ? "is-controller-focused" : ""}`}
onFocus={() => setEndlessChoiceSelection("quit")}
className={`secondary ${endlessChoiceSelection === "quit" ? "is-controller-selected" : ""}`}
onPointerEnter={() => setEndlessChoiceSelection("quit")}
onClick={onExit}
>Quit to Main Menu</button>
</div> : <div className="end-actions">
<button onClick={() => { restart(); startEncounter(); }}>Run again</button>
<button className="secondary" onClick={endlessDefeat ? onExit : restart}>{endlessDefeat ? "Return to main menu" : "Return to briefing"}</button>
<button onClick={() => { if (pvpMatch) onExit?.(); else { restart(); startEncounter(); } }}>{pvpMatch ? "Find new opponent" : "Run again"}</button>
<button className="secondary" onClick={endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat || pvpMatch ? onExit : restart}>{endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat || pvpMatch ? "Return to main menu" : "Return to briefing"}</button>
</div>}
</div>
);
@@ -270,10 +351,182 @@ function MapPanel() {
const time = useGameStore((state) => state.time);
const phase = useGameStore((state) => state.phase);
const bossId = useGameStore((state) => state.bossId);
const activityMode = useGameStore((state) => state.activityMode);
const hockey = useGameStore((state) => state.hockey);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyPvpOpponent = useGameStore((state) => state.hockeyPvpOpponent);
const bossDefinition = BOSS_DEFINITIONS[bossId];
const playerX = 120 + playerPosition[0] * 7;
const playerY = 143 + playerPosition[1] * 5.3;
const bossMotions = [bossMotion, ...additionalBosses.map((entry) => entry.motion)];
if (activityMode === "aether-assault") {
const mapX = (x: number) => 120 + x * 8.5;
const mapY = (z: number) => 140 + (z - HOCKEY_ARENA_CENTER_Z) * 8;
return (
<div className="map-panel hockey-map-panel aether-map-panel">
<div className="map-copy">
<span>Aether Assault</span>
<h2>Arcane Formation Runway</h2>
<p>Focus fire stays automatic. Move anywhere in the rink, heal freely, and evade red volleys plus amber dive warnings.</p>
<div className="map-legend"><i className="legend-party" /> Party <i className="legend-boss" /> Bosses <i className="legend-ship" /> Ships <i className="legend-shot" /> Shots</div>
</div>
<div className="map-canvas">
<svg viewBox="0 0 240 280" role="img" aria-label="Aether Assault tactical map">
<defs><linearGradient id="aether-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#18394a" /><stop offset="0.55" stopColor="#d9f3ef" /><stop offset="1" stopColor="#dff8f4" /></linearGradient></defs>
<rect className="map-room hockey-map-room" x="35" y="20" width="170" height="240" rx="3" fill="url(#aether-room)" />
<path className="map-ring aether-map-lanes" d="M63 20 V260 M91 20 V260 M120 20 V260 M149 20 V260 M177 20 V260" />
{aetherAssault.ships.map((ship, index) => {
const color = aetherShipColor(aetherAssault.seed, aetherAssault.wave, index, ship.kind);
return <g key={ship.id}>
{ship.phase === "diving" && <circle className="aether-map-warning" cx={mapX(ship.position[0])} cy={mapY(ship.position[1])} r="8" />}
<path className={`aether-map-ship is-${ship.kind}`} style={{ fill: color, filter: `drop-shadow(0 0 4px ${color})` }} d={`M${mapX(ship.position[0])} ${mapY(ship.position[1]) - 5} l6 9 h-12 z`} />
</g>;
})}
{aetherAssault.playerShots.map((shot) => <circle key={`player-shot-${shot.id}`} className="aether-map-player-shot" cx={mapX(shot.position[0])} cy={mapY(shot.position[1])} r="2" />)}
{aetherAssault.enemyShots.map((shot) => <circle key={`enemy-shot-${shot.id}`} className="aether-map-enemy-shot" cx={mapX(shot.position[0])} cy={mapY(shot.position[1])} r="2.5" />)}
{bossMotions.map((motion, index) => <circle key={`${motion.bossId}-${index}`} className="map-boss" cx={mapX(motion.position[0])} cy={mapY(motion.position[1])} r="7" />)}
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(partyPositions[memberId][1])} r="4" />)}
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="11" />
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="6" />
{barrier.expiresAt > time && <ellipse className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} rx={BARRIER_RADIUS * 8.5} ry={BARRIER_RADIUS * 8} />}
</svg>
<span className="map-state">{phase === "combat" ? `WAVE ${aetherAssault.wave} · ${aetherAssault.score} SCORE` : "FORMATION PREVIEW"}</span>
</div>
</div>
);
}
if (activityMode === "blockbreaker") {
const mapX = (x: number) => 120 + x * 8.5;
const mapY = (z: number) => 140 + (z - HOCKEY_ARENA_CENTER_Z) * 8;
const colors: Record<(typeof BLOCKBREAKER_BRICK_COLORS)[number], string> = {
cyan: "#36d9ef",
amber: "#f1b74f",
magenta: "#e85aa9",
lime: "#93db54",
};
return (
<div className="map-panel hockey-map-panel blockbreaker-map-panel">
<div className="map-copy">
<span>Blockbreaker</span>
<h2>Advancing Color Wall</h2>
<p>Match orthogonal colors. Crossing bricks disappear and deal {BLOCKBREAKER_BREACH_DAMAGE} partywide damage.</p>
<div className="map-legend"><i className="legend-party" /> Party <i className="legend-boss" /> Bosses <i className="legend-brick" /> Bricks <i className="legend-exit" /> Puck</div>
</div>
<div className="map-canvas">
<svg viewBox="0 0 240 280" role="img" aria-label="Blockbreaker tactical map">
<defs><linearGradient id="blockbreaker-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#251717" /><stop offset="0.5" stopColor="#10262d" /><stop offset="1" stopColor="#092d39" /></linearGradient></defs>
<rect className="map-room hockey-map-room" x="35" y="20" width="170" height="240" rx="3" fill="url(#blockbreaker-room)" />
<path className="map-ring" d="M35 140 H205" />
<path className="blockbreaker-map-danger" d={`M35 ${mapY(BLOCKBREAKER_DANGER_Z)} H205`} />
{blockbreaker.bricks.map((brick) => (
<rect
className="blockbreaker-map-brick"
key={brick.id}
x={mapX(blockbreakerColumnX(brick.column)) - 12.5}
y={mapY(blockbreakerRowZ(brick.row)) - 5}
width="25"
height="10"
rx="2"
fill={colors[brick.color]}
/>
))}
{bossMotions.map((motion, index) => <circle key={`${motion.bossId}-${index}`} className="map-boss" cx={mapX(motion.position[0])} cy={mapY(motion.position[1])} r="7" />)}
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="12" />
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="6" />
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(partyPositions[memberId][1])} r="4" />)}
<circle className="hockey-map-puck" cx={mapX(blockbreaker.puckPosition[0])} cy={mapY(blockbreaker.puckPosition[1])} r="5" />
{barrier.expiresAt > time && <ellipse className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} rx={BARRIER_RADIUS * 8.5} ry={BARRIER_RADIUS * 8} />}
</svg>
<span className="map-state">{phase === "combat" ? `${blockbreaker.bricks.length} BRICKS · ${blockbreaker.score} SCORE` : "WALL PREVIEW"}</span>
</div>
</div>
);
}
if (activityMode === "hockey-healing-pvp") {
const mapX = (x: number) => 120 + x * 8.2;
const mapY = (z: number) => 140 + z * 4.85;
const localWorldZ = (z: number) => z + HOCKEY_PVP_SIDE_OFFSET_Z;
const opponentWorldX = (x: number) => -x;
const opponentWorldZ = (z: number) => -z - HOCKEY_PVP_SIDE_OFFSET_Z;
return (
<div className="map-panel hockey-map-panel pvp-map-panel">
<div className="map-copy">
<span>Healing Hockey PVP</span>
<h2>You vs {hockeyPvp.opponentName}</h2>
<p>Matching boss order. Each goal hits all five allies for {HOCKEY_PVP_GOAL_DAMAGE} damage.</p>
<div className="map-legend"><i className="legend-party" /> Your party <i className="legend-boss" /> Bosses <i className="legend-opponent" /> Rival <i className="legend-exit" /> Puck</div>
</div>
<div className="map-canvas">
<svg viewBox="0 0 240 280" role="img" aria-label="Healing Hockey PVP tactical map">
<defs><linearGradient id="pvp-hockey-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#361819" /><stop offset="0.5" stopColor="#15262b" /><stop offset="1" stopColor="#0a3040" /></linearGradient></defs>
<rect className="map-room hockey-map-room" x="35" y="14" width="170" height="252" rx="3" fill="url(#pvp-hockey-room)" />
<path className="map-ring" d="M35 140 H205 M120 116 A24 24 0 1 0 120 164 A24 24 0 1 0 120 116" />
<path className="hockey-map-goal is-npc" d={`M${mapX(-HOCKEY_PVP_GOAL_HALF_WIDTH)} ${mapY(-HOCKEY_PVP_GOAL_Z)} H${mapX(HOCKEY_PVP_GOAL_HALF_WIDTH)}`} />
<path className="hockey-map-goal is-healer" d={`M${mapX(-HOCKEY_PVP_GOAL_HALF_WIDTH)} ${mapY(HOCKEY_PVP_GOAL_Z)} H${mapX(HOCKEY_PVP_GOAL_HALF_WIDTH)}`} />
<circle className="map-boss" cx={mapX(bossMotion.position[0])} cy={mapY(localWorldZ(bossMotion.position[1]))} r="7" />
<circle className="map-boss is-opponent" cx={mapX(opponentWorldX(hockeyPvpOpponent.bossMotion.position[0]))} cy={mapY(opponentWorldZ(hockeyPvpOpponent.bossMotion.position[1]))} r="7" />
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(localWorldZ(playerPosition[1]))} r="10" />
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(localWorldZ(playerPosition[1]))} r="5" />
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(localWorldZ(partyPositions[memberId][1]))} r="3.5" />)}
{(["aelia", "brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={`opponent-${memberId}`} className="map-ally map-opponent" cx={mapX(opponentWorldX(hockeyPvpOpponent.partyPositions[memberId][0]))} cy={mapY(opponentWorldZ(hockeyPvpOpponent.partyPositions[memberId][1]))} r="3.5" />)}
<circle className="hockey-map-puck" cx={mapX(hockeyPvp.puckPosition[0])} cy={mapY(hockeyPvp.puckPosition[1])} r="5" />
</svg>
<span className="map-state">{phase === "combat" ? `GOALS ${hockeyPvp.opponentGoalsConceded}${hockeyPvp.localGoalsConceded} · LIVE` : "VERSUS RINK"}</span>
</div>
</div>
);
}
if (activityMode === "hockey-healing") {
const mapX = (x: number) => 120 + x * 8.5;
const mapY = (z: number) => 140 + (z - HOCKEY_ARENA_CENTER_Z) * 8;
const npcGoalLeft = mapX(-HOCKEY_GOAL_HALF_WIDTH);
const npcGoalRight = mapX(HOCKEY_GOAL_HALF_WIDTH);
return (
<div className="map-panel hockey-map-panel">
<div className="map-copy">
<span>Hockey Healing</span>
<h2>Rectangular Boss Rink</h2>
<p>Party fights enemy half. Moving Pong paddle tracks each return and strikes it back toward healer.</p>
<div className="map-legend"><i className="legend-party" /> Party <i className="legend-boss" /> Boss <i className="legend-paddle" /> Paddle <i className="legend-exit" /> Puck</div>
</div>
<div className="map-canvas">
<svg viewBox="0 0 240 280" role="img" aria-label="Hockey Healing tactical map">
<defs>
<linearGradient id="hockey-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#251717" /><stop offset="0.5" stopColor="#10262d" /><stop offset="1" stopColor="#092d39" /></linearGradient>
</defs>
<rect className="map-room hockey-map-room" x="35" y="20" width="170" height="240" rx="3" fill="url(#hockey-room)" />
<path className="map-ring" d="M35 140 H205 M120 116 A24 24 0 1 0 120 164 A24 24 0 1 0 120 116" />
<path className="hockey-map-goal is-npc" d={`M${npcGoalLeft} ${mapY(HOCKEY_NPC_GOAL_Z)} H${npcGoalRight}`} />
<path className="hockey-map-goal is-healer" d={`M${npcGoalLeft} ${mapY(HOCKEY_HEALER_GOAL_Z)} H${npcGoalRight}`} />
<rect
className="hockey-map-paddle"
x={mapX(hockey.paddleX - HOCKEY_NPC_PADDLE_HALF_WIDTH)}
y={mapY(HOCKEY_NPC_PADDLE_Z) - 4}
width={HOCKEY_NPC_PADDLE_HALF_WIDTH * 2 * 8.5}
height="8"
rx="3"
/>
{bossMotions.map((motion, index) => (
<g key={`${motion.bossId}-${index}`}>
<circle className="map-boss" cx={mapX(motion.position[0])} cy={mapY(motion.position[1])} r="8" />
<path className="map-boss-arrow" d={`M${mapX(motion.position[0])} ${mapY(motion.position[1]) - 14} l6 9 h-12 z`} />
</g>
))}
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="12" />
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="6" />
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => (
<circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(partyPositions[memberId][1])} r="4" />
))}
<circle className="hockey-map-puck" cx={mapX(hockey.puckPosition[0])} cy={mapY(hockey.puckPosition[1])} r="5" />
{barrier.expiresAt > time && <ellipse className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} rx={BARRIER_RADIUS * 8.5} ry={BARRIER_RADIUS * 8} />}
</svg>
<span className="map-state">{phase === "combat" ? `${hockey.returns} RETURNS · LIVE` : "RINK PREVIEW"}</span>
</div>
</div>
);
}
return (
<div className="map-panel">
<div className="map-copy">
@@ -309,8 +562,8 @@ function MapPanel() {
className="map-barrier"
cx={120 + barrier.center[0] * 7}
cy={143 + barrier.center[1] * 5.3}
rx="21"
ry="15.9"
rx={BARRIER_RADIUS * 7}
ry={BARRIER_RADIUS * 5.3}
/>
)}
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => (
@@ -357,33 +610,132 @@ function PackPanel() {
);
}
const tabs: { id: BottomTab; label: string; icon: string; key: string }[] = [
{ id: "combat", label: "Heal", icon: "✦", key: "" },
{ id: "map", label: "Map", icon: "⌁", key: "M" },
{ id: "pack", label: "Pack", icon: "▧", key: "I" },
];
function PvpPanel() {
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
const opponentParty = useGameStore((state) => state.hockeyPvpOpponent.party);
const opponentGoalsConceded = useGameStore((state) => state.hockeyPvp.opponentGoalsConceded);
const localGoalsConceded = useGameStore((state) => state.hockeyPvp.localGoalsConceded);
const opponentBossKills = useGameStore((state) => state.hockeyPvp.opponentBossKills);
const living = opponentParty.filter((member) => member.hp > 0).length;
const currentHealth = opponentParty.reduce((total, member) => total + Math.max(0, member.hp), 0);
const maximumHealth = opponentParty.reduce((total, member) => total + member.maxHp, 0);
return (
<div className="pvp-panel">
<header className="pvp-roster-header">
<span><small>Opponent party</small><strong>{opponentName}</strong></span>
<div><small>Goals</small><strong>{opponentGoalsConceded}{localGoalsConceded}</strong></div>
<div><small>Boss KOs</small><strong>{opponentBossKills}</strong></div>
<div><small>Standing</small><strong>{living} / {opponentParty.length}</strong></div>
</header>
<div className="pvp-party-list" aria-label={`${opponentName} party health`}>
<div className="section-label"><span>Rival health feed</span><small>{Math.ceil(currentHealth)} / {maximumHealth} total</small></div>
{opponentParty.map((member) => (
<article className={`pvp-party-frame ${member.hp <= 0 ? "is-down" : ""}`} key={member.id}>
<span className="party-avatar" style={{ "--member-color": member.color } as React.CSSProperties}>{member.name[0]}</span>
<span className="party-data">
<span className="party-name"><strong>{member.name}</strong><em>{Math.ceil(member.hp)} / {member.maxHp}</em></span>
<HealthBar member={member} />
<small>{member.className}</small>
</span>
<b>{member.hp <= 0 ? "DOWN" : `${Math.ceil((member.hp / member.maxHp) * 100)}%`}</b>
</article>
))}
</div>
</div>
);
}
const tabPresentation: Record<BottomTab, { label: string; icon: string; key: string }> = {
combat: { label: "Heal", icon: "✦", key: "" },
map: { label: "Map", icon: "⌁", key: "M" },
pack: { label: "Pack", icon: "▧", key: "I" },
pvp: { label: "PVP", icon: "⚔", key: "P" },
};
function RpgBottomDisplay({ run, focusedId, paused, onExit }: {
readonly run: NonNullable<ReturnType<typeof useGameStore.getState>["rpgRun"]>;
readonly focusedId: string | null;
readonly paused: boolean;
readonly onExit?: () => void;
}) {
const party = useGameStore((state) => state.party);
const selectedMemberId = useGameStore((state) => state.selectedMemberId);
const mana = useGameStore((state) => state.mana);
const maxMana = useGameStore((state) => state.maxMana);
const cooldowns = useGameStore((state) => state.cooldowns);
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
const time = useGameStore((state) => state.time);
const activeCast = useGameStore((state) => state.activeCast);
const spellResources = useGameStore((state) => state.rpgSpellResources);
const selectMember = useGameStore((state) => state.selectMember);
const castAbility = useGameStore((state) => state.castAbility);
const dispatchRpgAction = useGameStore((state) => state.dispatchRpgAction);
const setRpgFocusId = useGameStore((state) => state.setRpgFocusId);
const restart = useGameStore((state) => state.restart);
return (
<section className="display bottom-display rpg-bottom-display" aria-label="RPG Roguelike tactical display">
<RpgRunTacticalPanel
run={run}
focusedId={focusedId}
onFocusChange={setRpgFocusId}
onAction={dispatchRpgAction}
onRestartRun={restart}
onExitRun={onExit}
liveCombat={{
party,
selectedMemberId,
mana,
maxMana,
cooldowns,
globalCooldownUntil,
time,
activeCast,
spellResources,
onSelectMember: selectMember,
onCastAbility: castAbility,
}}
/>
{paused && (
<div className="lower-pause-overlay" aria-hidden="true">
<span>PAUSED</span><strong>Expedition suspended</strong><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC resumes · selects menu action</small>
</div>
)}
</section>
);
}
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
const activeTab = useGameStore((state) => state.activeTab);
const setActiveTab = useGameStore((state) => state.setActiveTab);
const phase = useGameStore((state) => state.phase);
const paused = useGameStore((state) => state.paused);
const runMode = useGameStore((state) => state.runMode);
const activityMode = useGameStore((state) => state.activityMode);
const rpgRun = useGameStore((state) => state.rpgRun);
const rpgFocusId = useGameStore((state) => state.rpgFocusId);
const tabs = bottomTabsFor(runMode);
if (runMode === "rpg-roguelike" && rpgRun) {
return <RpgBottomDisplay run={rpgRun} focusedId={rpgFocusId} paused={paused} onExit={onExit} />;
}
return (
<section className="display bottom-display" aria-label="Tactical touch display">
<header className="lower-header">
<div className="lower-brand"><span>IH</span><strong>I Want To Heal</strong><small>{phase === "combat" ? "Encounter live" : "Field console"}</small></div>
<nav aria-label="Lower display sections">
{tabs.map((tab) => (
<button key={tab.id} className={activeTab === tab.id ? "is-active" : ""} onClick={() => setActiveTab(tab.id)}>
<nav aria-label="Lower display sections" role="tablist">
{tabs.map((tabId) => {
const tab = tabPresentation[tabId];
return <button key={tabId} role="tab" aria-selected={activeTab === tabId} className={activeTab === tabId ? "is-active" : ""} onClick={() => setActiveTab(tabId)}>
<i>{tab.icon}</i><span>{tab.label}</span>{tab.key && <small>{tab.key}</small>}
</button>
))}
})}
</nav>
</header>
<main className="lower-content">
{activeTab === "combat" && <CombatPanel onExit={onExit} />}
{activeTab === "map" && <MapPanel />}
{activeTab === "pack" && <PackPanel />}
{activeTab === "pack" && activityMode !== "hockey-healing-pvp" && <PackPanel />}
{activeTab === "pvp" && activityMode === "hockey-healing-pvp" && <PvpPanel />}
</main>
{paused && (
<div className="lower-pause-overlay" aria-hidden="true">
+4 -5
View File
@@ -41,13 +41,12 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
const buff = RUN_BUFFS[buffId];
const rank = effectiveRunBuffRank(runBuffRanks, buffId, passiveRunBuffId);
const nextRank = Math.min(buff.maxRank, rank + 1);
const ability = abilities[buff.abilityId];
const ability = abilities[buff.abilitySlotId];
return (
<button
key={buffId}
className={selected === buffId ? "is-controller-focused" : ""}
className={selected === buffId ? "is-controller-selected" : ""}
style={{ "--buff-accent": buff.accent } as React.CSSProperties}
onFocus={() => setSelected(buffId)}
onPointerEnter={() => setSelected(buffId)}
onClick={() => choose(buffId)}
disabled={inputLocked}
@@ -55,12 +54,12 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
>
<i>{buff.icon}</i>
<span><small>{rank ? `Rank ${rank}${nextRank} / ${buff.maxRank}` : `New blessing · Rank 1 / ${buff.maxRank}`}</small><strong>{ability.shortName}: {buff.name}</strong></span>
<b>{formatRunBuffEffect(buffId, nextRank)}</b>
<b>{formatRunBuffEffect(buffId, nextRank, ability.shortName)}</b>
<p>{buff.detail}</p>
</button>
);
}) : (
<button className="buff-mastery-continue is-controller-focused" onClick={continueRun} disabled={inputLocked}>
<button className="buff-mastery-continue is-controller-selected" onClick={continueRun} disabled={inputLocked}>
<i></i>
<span><small>Full mastery</small><strong>Continue Without Buff</strong></span>
<b>All 18 blessings reached maximum rank.</b>
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { CLAUDECRAFT_WEAPON_CATALOG } from "../game/weaponCatalog";
import { characterEquipmentAssetUrl, characterEquipmentAssetUrls } from "./CharacterEquipmentAssets";
describe("Claudecraft runtime weapon URLs", () => {
it("resolves every catalog entry to one emitted GLB URL", () => {
const urls = characterEquipmentAssetUrls();
expect(Object.keys(urls)).toHaveLength(55);
for (const definition of CLAUDECRAFT_WEAPON_CATALOG) {
expect(characterEquipmentAssetUrl(definition.id)).toMatch(/\.glb(?:\?|$)/);
}
});
});
@@ -0,0 +1,35 @@
import {
CLAUDECRAFT_WEAPON_CATALOG,
weaponDefinition,
type WeaponCatalogId,
} from "../game/weaponCatalog";
import { selectedGameAssetUrl } from "./GameAssetProvider";
const WEAPON_ASSET_PREFIX = "../assets/game/models/claudecraft/weapons/";
const WEAPON_ASSET_URLS = import.meta.glob<string>(
"../assets/game/models/claudecraft/weapons/*.glb",
{ eager: true, import: "default", query: "?url" },
);
function importedWeaponUrl(fileName: string) {
const url = WEAPON_ASSET_URLS[`${WEAPON_ASSET_PREFIX}${fileName}`];
if (!url) throw new Error(`Missing imported Claudecraft weapon asset: ${fileName}`);
return url;
}
/** Resolves one selected asset. Creating the URL table does not fetch or decode every GLB. */
export function characterEquipmentAssetUrl(modelId: WeaponCatalogId) {
const definition = weaponDefinition(modelId);
const sourceUrl = importedWeaponUrl(definition.sourceFile);
const optimizedUrl = definition.optimizedFile
? importedWeaponUrl(definition.optimizedFile)
: sourceUrl;
return selectedGameAssetUrl(sourceUrl, optimizedUrl);
}
export function characterEquipmentAssetUrls() {
return Object.fromEntries(CLAUDECRAFT_WEAPON_CATALOG.map((definition) => [
definition.id,
characterEquipmentAssetUrl(definition.id),
])) as Record<WeaponCatalogId, string>;
}
+1103 -251
View File
File diff suppressed because it is too large Load Diff
+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);
},
);
}
+2338 -366
View File
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
import { useFrame } from "@react-three/fiber";
import { useLayoutEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import type { HealerVisualProfile } from "../game/healerVisuals";
function RelicMaterial({ color }: { color: string }) {
return (
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.45}
metalness={0.35}
roughness={0.42}
/>
);
}
function DawnNimbus({ accentColor, secondaryColor }: { accentColor: string; secondaryColor: string }) {
const rays = useRef<THREE.InstancedMesh>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
useLayoutEffect(() => {
if (!rays.current) return;
for (let index = 0; index < 8; index += 1) {
const angle = index * Math.PI / 4;
transform.position.set(Math.sin(angle) * 0.54, Math.cos(angle) * 0.54, 0);
transform.rotation.set(0, 0, -angle);
transform.updateMatrix();
rays.current.setMatrixAt(index, transform.matrix);
}
rays.current.instanceMatrix.needsUpdate = true;
}, [transform]);
return (
<group position={[0, 0.68, -0.04]}>
<instancedMesh ref={rays} args={[undefined, undefined, 8]}>
<boxGeometry args={[0.045, 0.16, 0.035]} />
<RelicMaterial color={accentColor} />
</instancedMesh>
<mesh>
<torusGeometry args={[0.42, 0.04, 6, 20]} />
<RelicMaterial color={accentColor} />
</mesh>
<mesh position={[0, 0.08, 0.015]} rotation={[0, 0, Math.PI / 4]}>
<octahedronGeometry args={[0.1, 0]} />
<RelicMaterial color={secondaryColor} />
</mesh>
</group>
);
}
export function HealerClassAccessory({ profile }: { profile: HealerVisualProfile }) {
const animated = useRef<THREE.Group>(null);
useFrame((_, delta) => {
if (!animated.current || profile.accessory !== "clockwork-rings") return;
animated.current.rotation.z += delta * 0.42;
animated.current.rotation.y -= delta * 0.18;
});
if (profile.accessory === "sun-halo") {
return <DawnNimbus accentColor={profile.accentColor} secondaryColor={profile.secondaryColor} />;
}
if (profile.accessory === "grove-antlers") {
return (
<group position={[0, 0.34, 0]}>
<mesh position={[-0.2, 0.18, 0]} rotation={[0, 0, -0.36]}>
<coneGeometry args={[0.055, 0.5, 5]} />
<RelicMaterial color={profile.secondaryColor} />
</mesh>
<mesh position={[0.2, 0.18, 0]} rotation={[0, 0, 0.36]}>
<coneGeometry args={[0.055, 0.5, 5]} />
<RelicMaterial color={profile.secondaryColor} />
</mesh>
<mesh position={[-0.31, 0.28, 0]} rotation={[0, 0, -0.62]}>
<tetrahedronGeometry args={[0.1, 0]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
<mesh position={[0.31, 0.28, 0]} rotation={[0, 0, 0.62]}>
<tetrahedronGeometry args={[0.1, 0]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
</group>
);
}
if (profile.accessory === "storm-totem") {
return (
<group position={[0, 0.78, 0]}>
<mesh>
<cylinderGeometry args={[0.09, 0.12, 0.42, 6]} />
<RelicMaterial color={profile.secondaryColor} />
</mesh>
<mesh position={[-0.22, 0.1, 0]}>
<octahedronGeometry args={[0.11, 0]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
<mesh position={[0.22, -0.08, 0]} scale={0.75}>
<octahedronGeometry args={[0.11, 0]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
</group>
);
}
if (profile.accessory === "sun-crest") {
return (
<group position={[0, 0.68, 0]}>
<mesh>
<torusGeometry args={[0.46, 0.055, 6, 14]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
<mesh rotation={[0, 0, Math.PI / 4]}>
<octahedronGeometry args={[0.23, 0]} />
<RelicMaterial color={profile.secondaryColor} />
</mesh>
</group>
);
}
return (
<group ref={animated} position={[0, 0.68, 0]}>
<mesh>
<torusGeometry args={[0.46, 0.04, 6, 18]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
<mesh rotation={[Math.PI / 3, 0, 0]}>
<torusGeometry args={[0.25, 0.035, 6, 14]} />
<RelicMaterial color={profile.secondaryColor} />
</mesh>
<mesh rotation={[0, 0, Math.PI / 4]}>
<octahedronGeometry args={[0.11, 0]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
</group>
);
}
+178
View File
@@ -0,0 +1,178 @@
import { createPortal } from "@react-three/fiber";
import { useTexture } from "@react-three/drei";
import { useEffect, useMemo } from "react";
import * as THREE from "three";
import {
CHARACTER_PART_CATALOG,
characterAppearancePartIds,
type CharacterAppearanceV1,
type CharacterMaterialVariant,
type CharacterPartId,
} from "../game/characterAppearance";
import type { MemberId } from "../game/types";
import { useGameGLTF } from "./GameAssetProvider";
interface BoundCharacterPart {
group: THREE.Group;
materials: THREE.Material[];
skeletons: THREE.Skeleton[];
}
const CHARACTER_MATERIAL_VARIANT_URLS: Record<CharacterMaterialVariant, string> = {
"priest-vestments": new URL("../assets/game/textures/claudecraft/chars/players/priest-vestments.webp", import.meta.url).href,
};
function rigBonesByName(rigScene: THREE.Object3D) {
const bones = new Map<string, THREE.Bone>();
rigScene.traverse((object) => {
if (object instanceof THREE.Bone) bones.set(object.name, object);
});
return bones;
}
function createBoundCharacterPart(
sourceScene: THREE.Object3D,
rigScene: THREE.Object3D,
partId: CharacterPartId,
materialTexture?: THREE.Texture,
): BoundCharacterPart {
const definition = CHARACTER_PART_CATALOG[partId];
const rigBones = rigBonesByName(rigScene);
const group = new THREE.Group();
group.name = `character-part:${partId}`;
const materialClones = new Map<THREE.Material, THREE.Material>();
const skeletons: THREE.Skeleton[] = [];
sourceScene.updateMatrixWorld(true);
for (const nodeName of definition.nodeNames) {
const sourceNode = sourceScene.getObjectByName(nodeName);
if (!sourceNode) throw new Error(`Character part ${partId} is missing node ${nodeName}.`);
const partNode = sourceNode.clone(true);
partNode.matrix.copy(sourceNode.matrixWorld);
partNode.matrix.decompose(partNode.position, partNode.quaternion, partNode.scale);
partNode.traverse((object) => {
if (!(object instanceof THREE.SkinnedMesh)) return;
if (materialTexture) {
const cloneMaterial = (source: THREE.Material) => {
const existing = materialClones.get(source);
if (existing) return existing;
const clone = source.clone();
if ("map" in clone) clone.map = materialTexture;
clone.needsUpdate = true;
materialClones.set(source, clone);
return clone;
};
object.material = Array.isArray(object.material)
? object.material.map(cloneMaterial)
: cloneMaterial(object.material);
}
const mappedBones = object.skeleton.bones.map((sourceBone) => {
const rigBone = rigBones.get(sourceBone.name);
if (!rigBone) throw new Error(`Character part ${partId} cannot resolve rig bone ${sourceBone.name}.`);
return rigBone;
});
const skeleton = new THREE.Skeleton(
mappedBones,
object.skeleton.boneInverses.map((inverse) => inverse.clone()),
);
const bindMatrix = object.bindMatrix.clone();
object.bind(skeleton, bindMatrix);
object.castShadow = true;
object.receiveShadow = true;
object.frustumCulled = false;
skeletons.push(skeleton);
});
group.add(partNode);
}
return { group, materials: [...materialClones.values()], skeletons };
}
function BoundCharacterPart({
actorScene,
materialTexture,
modelUrls,
partId,
}: {
actorScene: THREE.Object3D;
materialTexture?: THREE.Texture;
modelUrls: Record<MemberId, string>;
partId: CharacterPartId;
}) {
const definition = CHARACTER_PART_CATALOG[partId];
const gltf = useGameGLTF(modelUrls[definition.sourceMemberId]);
const boundPart = useMemo(
() => createBoundCharacterPart(gltf.scene, actorScene, partId, materialTexture),
[actorScene, gltf.scene, materialTexture, partId],
);
useEffect(() => () => {
for (const skeleton of boundPart.skeletons) skeleton.dispose();
for (const material of boundPart.materials) material.dispose();
}, [boundPart]);
const rigRoot = actorScene.getObjectByName("Rig_Medium") ?? actorScene;
return createPortal(<primitive object={boundPart.group} />, rigRoot);
}
function MaterialVariantCharacterPart({
actorScene,
materialVariant,
modelUrls,
partId,
}: {
actorScene: THREE.Object3D;
materialVariant: CharacterMaterialVariant;
modelUrls: Record<MemberId, string>;
partId: CharacterPartId;
}) {
const sourceTexture = useTexture(CHARACTER_MATERIAL_VARIANT_URLS[materialVariant]);
const materialTexture = useMemo(() => {
sourceTexture.flipY = false;
sourceTexture.colorSpace = THREE.SRGBColorSpace;
sourceTexture.magFilter = THREE.NearestFilter;
sourceTexture.needsUpdate = true;
return sourceTexture;
}, [sourceTexture]);
return <BoundCharacterPart actorScene={actorScene} materialTexture={materialTexture} modelUrls={modelUrls} partId={partId} />;
}
function ModularCharacterPart({
actorScene,
modelUrls,
partId,
}: {
actorScene: THREE.Object3D;
modelUrls: Record<MemberId, string>;
partId: CharacterPartId;
}) {
const definition = CHARACTER_PART_CATALOG[partId];
const materialVariant = "materialVariant" in definition ? definition.materialVariant : undefined;
return materialVariant
? <MaterialVariantCharacterPart actorScene={actorScene} materialVariant={materialVariant} modelUrls={modelUrls} partId={partId} />
: <BoundCharacterPart actorScene={actorScene} modelUrls={modelUrls} partId={partId} />;
}
export function ModularCharacterBody({
actorScene,
appearance,
modelUrls,
}: {
actorScene: THREE.Object3D;
appearance: CharacterAppearanceV1;
modelUrls: Record<MemberId, string>;
}) {
return (
<>
{characterAppearancePartIds(appearance).map((partId) => (
<ModularCharacterPart
key={`${partId}:${appearance.version}`}
actorScene={actorScene}
modelUrls={modelUrls}
partId={partId}
/>
))}
</>
);
}
+239 -35
View File
@@ -1,13 +1,47 @@
import { lazy, Suspense } from "react";
import { barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { HEALER_CLASSES } from "../game/healers";
import { lazy, memo, Suspense, useEffect, useRef, useState } from "react";
import { barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { HEALER_ABILITIES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { bossRoomFor } from "../game/bossRooms";
import { tankAuraProtects } from "../game/partyCombat";
import { BuffDraftPanel } from "./BuffDraftPanel";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
import { hockeyPvpDampeningPercent } from "../game/hockeyHealingPvp";
import { blockbreakerTimeMultiplier } from "../game/blockbreaker";
import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay";
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
import { healerMaxResource, isBeaconOfLightTarget } from "../game/healerMechanics";
const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })));
const GameScene = memo(lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene }))));
GameScene.displayName = "MemoizedGameScene";
const CONVICTION_PIPS = [0, 1, 2] as const;
const MAX_CONVICTION = healerMaxResource("paladin");
function ConvictionMeter() {
const healerClassId = useGameStore((state) => state.healerClassId);
const runMode = useGameStore((state) => state.runMode);
const encounterConviction = useGameStore((state) => state.healerMechanic.resource);
const rpgConviction = useGameStore((state) => state.rpgSpellResources.conviction);
if (healerClassId !== "paladin") return null;
const conviction = Math.max(0, Math.min(MAX_CONVICTION, runMode === "rpg-roguelike" ? rpgConviction : encounterConviction));
return (
<div
className={`top-conviction-meter ${conviction === MAX_CONVICTION ? "is-full" : ""}`}
role="meter"
aria-label="Conviction"
aria-valuemin={0}
aria-valuemax={MAX_CONVICTION}
aria-valuenow={conviction}
aria-valuetext={`${conviction} of ${MAX_CONVICTION} Conviction`}
title={`${conviction} of ${MAX_CONVICTION} Conviction`}
>
<span><b>Conviction</b><small>{conviction} / {MAX_CONVICTION}</small></span>
<span className="top-conviction-pips" aria-hidden="true">
{CONVICTION_PIPS.map((pip) => <i className={pip < conviction ? "is-filled" : ""} key={pip} />)}
</span>
</div>
);
}
function CompactParty() {
const party = useGameStore((state) => state.party);
@@ -19,17 +53,19 @@ function CompactParty() {
const selectMember = useGameStore((state) => state.selectMember);
const mana = useGameStore((state) => state.mana);
const maxMana = useGameStore((state) => state.maxMana);
const healerMechanic = useGameStore((state) => state.healerMechanic);
return (
<div className="top-party" aria-label="Party health">
<div className="top-party" aria-label="Party health and player resources">
{party.map((member) => {
const health = (member.hp / member.maxHp) * 100;
const shield = (member.absorb / member.maxHp) * 100;
const beaconed = isBeaconOfLightTarget(member.id, healerMechanic, time);
return (
<button
className={`top-party-member ${selected === member.id ? "is-selected" : ""}`}
className={`top-party-member ${selected === member.id ? "is-selected" : ""} ${beaconed ? "is-beacon" : ""}`}
key={member.id}
onClick={() => selectMember(member.id)}
aria-label={`Target ${member.name}, ${Math.ceil(member.hp)} health`}
aria-label={`Target ${member.name}, ${Math.ceil(member.hp)} health${beaconed ? ", Beacon of Light" : ""}`}
>
<span className="portrait-dot" style={{ background: member.color }}>{member.name[0]}</span>
<span className="top-party-copy"><strong>{member.name}</strong><small>{member.role}</small></span>
@@ -43,26 +79,29 @@ function CompactParty() {
</span>
)}
<span className="top-effects">
{member.renewExpiresAt > 0 && <em className="renew-pip">R</em>}
{beaconed && <em className="beacon-pip" title={`Beacon of Light: ${Math.max(0, healerMechanic.beaconExpiresAt - time).toFixed(1)} seconds`}></em>}
{member.healingEffects.some((effect) => effect.expiresAt > time) && <em className="renew-pip">H</em>}
{member.reactiveHeal && member.reactiveHeal.expiresAt > time && <em className="renew-pip">E{member.reactiveHeal.charges}</em>}
{member.debuffs.length > 0 && <em className="debuff-pip">!</em>}
{barrierProtects(partyPositions[member.id], barrier, time) && <em className="barrier-pip">B</em>}
{tankAuraProtects(partyPositions[member.id], partyPositions.brann, tankAura, time) && <em className="tank-aura-pip">T</em>}
{barrier.kind === "spirit-link" && healerFieldContains(partyPositions[member.id], barrier, time) && <em className="barrier-pip">S</em>}
{tankAuraProtects(partyPositions[member.id], partyPositions[tankAura.sourceId], tankAura, time) && <em className="tank-aura-pip">T</em>}
{member.knockedUntil > time && <em className="knockdown-pip">KD</em>}
</span>
</button>
);
})}
<ConvictionMeter />
</div>
);
}
function CastingBar() {
const healerClassId = useGameStore((state) => state.healerClassId);
const abilityName = HEALER_CLASSES[healerClassId].abilities.mend.name;
const activeCast = useGameStore((state) => state.activeCast);
const time = useGameStore((state) => state.time);
const party = useGameStore((state) => state.party);
if (!activeCast) return null;
const abilityName = HEALER_ABILITIES[activeCast.abilityId].name;
const duration = activeCast.completesAt - activeCast.startedAt;
const progress = Math.min(1, Math.max(0, (time - activeCast.startedAt) / duration));
const target = party.find((member) => member.id === activeCast.targetId);
@@ -78,13 +117,19 @@ function CastingBar() {
function BossBar() {
const boss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
const opponentBoss = useGameStore((state) => state.hockeyPvpOpponent.boss);
const phase = useGameStore((state) => state.phase);
const activityMode = useGameStore((state) => state.activityMode);
if (phase === "briefing") return null;
const bosses = [boss, ...additionalBosses.map((entry) => entry.boss)];
const hockeyMode = activityMode === "hockey-healing";
const blockbreakerMode = activityMode === "blockbreaker";
const aetherAssaultMode = activityMode === "aether-assault";
const pvpMode = activityMode === "hockey-healing-pvp";
const bosses = pvpMode ? [boss, opponentBoss] : [boss, ...additionalBosses.map((entry) => entry.boss)];
return (
<div className={`boss-bar-wrap ${bosses.length > 1 ? "is-multi" : ""} ${bosses.length === 3 ? "is-trio" : ""}`}>
{bosses.map((entry) => <div className="boss-bar-entry" key={entry.id}>
<div className="boss-name"><span>Vault Beast</span><strong>{entry.name}</strong><span>{Math.ceil((entry.hp / entry.maxHp) * 100)}%</span></div>
<div className={`boss-bar-wrap ${bosses.length > 1 ? "is-multi" : ""} ${bosses.length === 3 ? "is-trio" : ""} ${pvpMode ? "is-pvp" : ""}`}>
{bosses.map((entry, index) => <div className={`boss-bar-entry ${pvpMode && index === 1 ? "is-opponent" : ""}`} key={`${entry.id}-${index}`}>
<div className="boss-name"><span>{pvpMode ? index === 0 ? "Your target" : "Rival target" : hockeyMode ? index === 0 ? "Striker · paddle + damage" : "Frontline · party damage" : blockbreakerMode ? index === 0 ? "Breaker flank" : "Wall pressure" : aetherAssaultMode ? index === 0 ? "Arcade flank" : "Party pressure" : "Vault Beast"}</span><strong>{entry.name}</strong><span>{Math.ceil((entry.hp / entry.maxHp) * 100)}%</span></div>
<div className="boss-bar"><i style={{ width: `${(entry.hp / entry.maxHp) * 100}%` }} /></div>
</div>)}
</div>
@@ -110,48 +155,123 @@ function EncounterCallout() {
function PhaseOverlay() {
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
const activityMode = useGameStore((state) => state.activityMode);
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 additionalBosses = useGameStore((state) => state.additionalBosses);
const hockey = useGameStore((state) => state.hockey);
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
if (runMode === "rpg-roguelike") return null;
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
const room = bossRoomFor(primaryBoss.id);
const bossNames = bosses.map((boss) => boss.name).join(" & ");
const hockeyMode = activityMode === "hockey-healing";
const blockbreakerMode = activityMode === "blockbreaker";
const aetherAssaultMode = activityMode === "aether-assault";
const pvpMode = activityMode === "hockey-healing-pvp";
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
const endlessDefeat = phase === "defeat" && endlessMode;
const briefingMode = runMode === "rogue-trials"
const endlessDefeat = phase === "defeat" && endlessMode && !hockeyMode && !blockbreakerMode && !aetherAssaultMode;
const briefingMode = hockeyMode
? "Endless Goal Defense"
: blockbreakerMode
? "Endless Color Break"
: aetherAssaultMode
? "Endless Arcade Assault"
: pvpMode
? `Versus ${hockeyPvp.opponentName}`
: runMode === "rogue-trials"
? bosses.length === 3 ? "Rogue Trials · Trio Finale" : "Rogue Trials · Dual Round"
: bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial;
if (phase === "combat") return null;
const title = phase === "briefing"
? room.name
? hockeyMode ? "Hockey Healing" : blockbreakerMode ? "Blockbreaker" : aetherAssaultMode ? "Aether Assault" : pvpMode ? "Healing Hockey PVP" : room.name
: phase === "victory"
? showEndlessChoice ? "Rogue Trials Cleared" : `${bossNames} Broken`
: endlessDefeat ? "Endless Run Ended" : "Party Broken";
? showEndlessChoice ? "Rogue Trials Cleared" : pvpMode ? "Match Won" : `${bossNames} Broken`
: hockeyMode ? "Goal Breached" : blockbreakerMode && blockbreaker.status === "lost" ? "Wall Breached" : aetherAssaultMode ? "Formation Lost" : pvpMode ? "Match Lost" : endlessDefeat ? "Endless Run Ended" : "Party Broken";
const eyebrow = phase === "briefing"
? `${briefingMode} · ${room.biome}`
? hockeyMode ? `${briefingMode} · Rectangular Boss Rink` : blockbreakerMode ? `${briefingMode} · Advancing Brick Rink` : aetherAssaultMode ? `${briefingMode} · Bright Five-Lane Rink` : pvpMode ? `${briefingMode} · Extended Versus Rink` : `${briefingMode} · ${room.biome}`
: phase === "victory"
? showEndlessChoice ? "Endless Path Unlocked" : "Encounter Complete"
: endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed";
? showEndlessChoice ? "Endless Path Unlocked" : pvpMode ? `${hockeyPvp.opponentGoalsConceded} Rival Goals · ${endlessBossKills} Boss Kills` : "Encounter Complete"
: hockeyMode ? `${hockey.returns} Pucks Returned · ${endlessBossKills} Bosses Defeated` : blockbreakerMode ? `${blockbreaker.bricksBroken} Bricks · ${blockbreaker.score.toLocaleString()} Points` : aetherAssaultMode ? `${aetherAssault.score.toLocaleString()} Points · Wave ${aetherAssault.wave}` : pvpMode ? `${hockeyPvp.localGoalsConceded} Goals Conceded · ${hockeyPvp.opponentBossKills} Rival Boss Kills` : endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed";
const copy = phase === "briefing"
? definitions.map((boss) => boss.briefing).join(" ")
? hockeyMode
? "Defend the wide blue goal. Aim each return; the moving Pong paddle strikes it back. Party fights both bosses on enemy half. Fallen bosses are replaced."
: blockbreakerMode
? "Aim the puck into advancing five-brick rows. Matching orthogonal colors break as one combo while two bosses pressure the party. Missed pucks safely re-serve."
: aetherAssaultMode
? "Move across the full bright rink while spellfire launches automatically. Line up ship formations, dodge red bolts and dives, and keep healing through two endless bosses."
: pvpMode
? "Two parties fight matching boss sequences. Defend your goal and aim each return. Every goal deals 45 damage to all five players. Fallen bosses respawn instantly."
: definitions.map((boss) => boss.briefing).join(" ")
: phase === "victory"
? showEndlessChoice ? "Leave with the clear, or continue against an unbroken chain of replacement bosses." : "Five entered. Five endured."
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
? showEndlessChoice ? "Leave with the clear, or continue against an unbroken chain of replacement bosses." : pvpMode ? `${hockeyPvp.opponentName}'s party fell first.` : "Five entered. Five endured."
: hockeyMode
? `Run ended after ${hockey.returns} returns and ${endlessBossKills} boss kills.`
: blockbreakerMode
? `Run record: ${blockbreaker.bricksBroken} bricks, ${blockbreaker.score.toLocaleString()} points, and ${endlessBossKills} boss kills.`
: aetherAssaultMode
? `Run record: ${aetherAssault.score.toLocaleString()} points, wave ${aetherAssault.wave}, ${aetherAssault.kills} ships, and ${endlessBossKills} boss kills.`
: pvpMode ? `${hockeyPvp.opponentName} kept their party standing.`
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
return (
<div className={`phase-overlay phase-${phase}`}>
<div className="phase-sigil"></div>
<span>{eyebrow}</span>
<h1>{title}</h1>
<p>{copy}</p>
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Continue or Quit on lower display" : "Restart from lower display"}</small>
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : pvpMode ? "Choose next match from lower display" : "Restart from lower display"}</small>
</div>
);
}
function BlockbreakerScorePopup() {
const activityMode = useGameStore((state) => state.activityMode);
const lastBreakAt = useGameStore((state) => state.blockbreaker.lastBreakAt);
const count = useGameStore((state) => state.blockbreaker.lastBreakCount);
const award = useGameStore((state) => state.blockbreaker.lastScoreAward);
const [visibleAt, setVisibleAt] = useState<number | null>(null);
useEffect(() => {
if (activityMode !== "blockbreaker" || !Number.isFinite(lastBreakAt)) {
setVisibleAt(null);
return;
}
setVisibleAt(lastBreakAt);
const timeout = window.setTimeout(() => setVisibleAt((current) => current === lastBreakAt ? null : current), 850);
return () => window.clearTimeout(timeout);
}, [activityMode, lastBreakAt]);
if (visibleAt === null) return null;
return <div className="blockbreaker-score-popup" role="status" aria-live="polite"><strong>+{award.toLocaleString()}</strong><small>{count} {count === 1 ? "brick" : "brick combo"}</small></div>;
}
function AetherScorePopup() {
const activityMode = useGameStore((state) => state.activityMode);
const lastKillAt = useGameStore((state) => state.aetherAssault.lastKillAt);
const award = useGameStore((state) => state.aetherAssault.lastKillScore);
const multiplier = useGameStore((state) => state.aetherAssault.multiplier);
const [visibleAt, setVisibleAt] = useState<number | null>(null);
useEffect(() => {
if (activityMode !== "aether-assault" || !Number.isFinite(lastKillAt)) {
setVisibleAt(null);
return;
}
setVisibleAt(lastKillAt);
const timeout = window.setTimeout(() => setVisibleAt((current) => current === lastKillAt ? null : current), 700);
return () => window.clearTimeout(timeout);
}, [activityMode, lastKillAt]);
if (visibleAt === null) return null;
return <div className="blockbreaker-score-popup aether-score-popup" role="status" aria-live="polite"><strong>+{award.toLocaleString()}</strong><small>{multiplier.toFixed(2)}× streak</small></div>;
}
function PauseOverlay({ onExit }: { onExit?: () => void }) {
const paused = useGameStore((state) => state.paused);
const selection = useGameStore((state) => state.pauseSelection);
@@ -170,14 +290,12 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) {
<p>Simulation, damage, and movement are stopped.</p>
<div className="pause-actions">
<button
className={selection === "resume" ? "is-controller-focused" : ""}
onFocus={() => setPauseSelection("resume")}
className={selection === "resume" ? "is-controller-selected" : ""}
onPointerEnter={() => setPauseSelection("resume")}
onClick={() => setPaused(false)}
>Resume <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>
<button
className={`secondary ${selection === "exit" ? "is-controller-focused" : ""}`}
onFocus={() => setPauseSelection("exit")}
className={`secondary ${selection === "exit" ? "is-controller-selected" : ""}`}
onPointerEnter={() => setPauseSelection("exit")}
onClick={exit}
>Return to main menu <small>{DEFAULT_CONTROLLER_GLYPHS.confirm}</small></button>
@@ -188,30 +306,116 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) {
);
}
export function TopScreen({ onExit }: { onExit?: () => void }) {
function GoalPopup() {
const activityMode = useGameStore((state) => state.activityMode);
const goalSequence = useGameStore((state) => state.hockeyPvp.goalSequence);
const lastGoalSide = useGameStore((state) => state.hockeyPvp.lastGoalSide);
const lastSeenSequence = useRef(goalSequence);
const [visibleSequence, setVisibleSequence] = useState<number | null>(null);
useEffect(() => {
if (activityMode !== "hockey-healing-pvp" || goalSequence < lastSeenSequence.current) {
lastSeenSequence.current = goalSequence;
setVisibleSequence(null);
return;
}
if (goalSequence === lastSeenSequence.current) return;
lastSeenSequence.current = goalSequence;
setVisibleSequence(goalSequence);
const timeout = window.setTimeout(() => {
setVisibleSequence((current) => current === goalSequence ? null : current);
}, 1_250);
return () => window.clearTimeout(timeout);
}, [activityMode, goalSequence]);
if (visibleSequence === null) return null;
return (
<div
key={visibleSequence}
className={`goal-popup ${lastGoalSide === "local" ? "is-conceded" : "is-scored"}`}
role="status"
aria-live="assertive"
aria-atomic="true"
>
<strong>GOAL</strong>
</div>
);
}
function DampeningIndicator() {
const activityMode = useGameStore((state) => state.activityMode);
const localBossKills = useGameStore((state) => state.endlessBossKills);
const opponentBossKills = useGameStore((state) => state.hockeyPvp.opponentBossKills);
if (activityMode !== "hockey-healing-pvp") return null;
const percent = hockeyPvpDampeningPercent(localBossKills, opponentBossKills);
return (
<div className="dampening-indicator" role="status" aria-label={`${percent}% healing dampening`}>
<span>Dampening</span><strong>{percent}%</strong>
<i><em style={{ width: `${percent}%` }} /></i>
</div>
);
}
export function TopScreen({
onExit,
playerAppearance,
}: {
onExit?: () => void;
playerAppearance?: CharacterAppearanceV1;
}) {
const phase = useGameStore((state) => state.phase);
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
const round = useGameStore((state) => state.round);
const runMode = useGameStore((state) => state.runMode);
const activityMode = useGameStore((state) => state.activityMode);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const hockeyReturns = useGameStore((state) => state.hockey.returns);
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const time = useGameStore((state) => state.time);
const setPaused = useGameStore((state) => state.setPaused);
const rpgRun = useGameStore((state) => state.rpgRun);
const rpgFocusId = useGameStore((state) => state.rpgFocusId);
const dispatchRpgAction = useGameStore((state) => state.dispatchRpgAction);
const setRpgFocusId = useGameStore((state) => state.setRpgFocusId);
const restart = useGameStore((state) => state.restart);
const hockeyMode = activityMode === "hockey-healing";
const blockbreakerMode = activityMode === "blockbreaker";
const aetherAssaultMode = activityMode === "aether-assault";
const pvpMode = activityMode === "hockey-healing-pvp";
const duration = `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`;
return (
<section className="display top-display" aria-label="Main game viewport">
<Suspense fallback={<div className="scene-loading" aria-label="Loading 3D scene" />}>
<GameScene />
<GameScene playerAppearance={playerAppearance} />
</Suspense>
<div className="top-vignette" />
<div className="top-hud">
<CompactParty />
<BossBar />
<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>
<div className={`objective-chip ${hockeyMode || pvpMode || blockbreakerMode || aetherAssaultMode ? "is-hockey" : ""} ${pvpMode ? "is-pvp" : ""} ${blockbreakerMode ? "is-blockbreaker" : ""} ${aetherAssaultMode ? "is-aether" : ""}`}><span>{hockeyMode ? `Hockey Healing · ${hockeyReturns} returns · ${duration}` : blockbreakerMode ? `Blockbreaker · ${blockbreaker.score.toLocaleString()} pts · ${duration}` : aetherAssaultMode ? `Aether Assault · ${aetherAssault.score.toLocaleString()} pts · ${duration}` : pvpMode ? `VS ${hockeyPvp.opponentName} · ${duration}` : endlessMode ? `Endless · ${endlessBossKills} kills` : runMode !== "encounter" ? `Round ${round}` : "Objective"}</span><strong>{hockeyMode ? `Defend wide goal · ${endlessBossKills} boss kills` : blockbreakerMode ? `${blockbreaker.bricksBroken} bricks · ${blockbreakerTimeMultiplier(time).toFixed(1)}× · row in ${Math.max(0, blockbreaker.nextRowAt - time).toFixed(1)}s` : aetherAssaultMode ? `Wave ${aetherAssault.wave} · ${aetherAssault.ships.length} ships · ${aetherAssault.multiplier.toFixed(2)}×` : pvpMode ? `Goals ${hockeyPvp.opponentGoalsConceded}${hockeyPvp.localGoalsConceded} · Bosses ${endlessBossKills}${hockeyPvp.opponentBossKills}` : 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 />
<DampeningIndicator />
<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{aetherAssaultMode ? " + auto-fire" : ""} <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>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
</div>
<GoalPopup />
<BlockbreakerScorePopup />
<AetherScorePopup />
<PhaseOverlay />
{runMode === "rpg-roguelike" && rpgRun && (
<RpgRunOverlay
run={rpgRun}
focusedId={rpgFocusId}
onFocusChange={setRpgFocusId}
onAction={dispatchRpgAction}
onRestartRun={restart}
onExitRun={onExit}
/>
)}
<PauseOverlay onExit={onExit} />
</section>
);
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
AETHER_ARMORED_SHIP_COLORS,
AETHER_STANDARD_SHIP_COLORS,
aetherShipColor,
aetherShipColorIndex,
} from "./aetherAssaultVisuals";
describe("Aether Assault ship colors", () => {
it("assigns a deterministic mixed palette across a formation", () => {
const first = Array.from({ length: 6 }, (_, index) => aetherShipColor(1234, 1, index, "standard"));
const replay = Array.from({ length: 6 }, (_, index) => aetherShipColor(1234, 1, index, "standard"));
expect(replay).toEqual(first);
expect(new Set(first).size).toBe(AETHER_STANDARD_SHIP_COLORS.length);
});
it("changes palette ordering between waves and keeps armored variants distinct", () => {
const waveOneIndex = aetherShipColorIndex(42, 1, 0);
const waveTwoIndex = aetherShipColorIndex(42, 2, 0);
expect(waveTwoIndex).not.toBe(waveOneIndex);
expect(aetherShipColor(42, 1, 0, "standard")).toBe(AETHER_STANDARD_SHIP_COLORS[waveOneIndex]);
expect(aetherShipColor(42, 1, 0, "armored")).toBe(AETHER_ARMORED_SHIP_COLORS[waveOneIndex]);
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { AetherShipKind } from "../game/aetherAssault";
export const AETHER_STANDARD_SHIP_COLORS = [
"#20c8e8",
"#8a5cff",
"#f044b5",
"#31cf74",
"#f06b3c",
"#d6b91c",
] as const;
export const AETHER_ARMORED_SHIP_COLORS = [
"#9ef5ff",
"#d0b8ff",
"#ff9bdc",
"#9af0b8",
"#ffb088",
"#ffe976",
] as const;
export function aetherShipColorIndex(seed: number, wave: number, shipIndex: number) {
const normalizedSeed = Math.floor(Number.isFinite(seed) ? seed : 0) >>> 0;
const mixedSeed = (normalizedSeed ^ (normalizedSeed >>> 16)) >>> 0;
const normalizedWave = Math.max(1, Math.floor(Number.isFinite(wave) ? wave : 1));
const normalizedIndex = Math.max(0, Math.floor(Number.isFinite(shipIndex) ? shipIndex : 0));
return (mixedSeed + (normalizedWave - 1) * 3 + normalizedIndex * 5) % AETHER_STANDARD_SHIP_COLORS.length;
}
export function aetherShipColor(seed: number, wave: number, shipIndex: number, kind: AetherShipKind) {
const palette = kind === "armored" ? AETHER_ARMORED_SHIP_COLORS : AETHER_STANDARD_SHIP_COLORS;
return palette[aetherShipColorIndex(seed, wave, shipIndex)];
}

Some files were not shown because too many files have changed in this diff Show More