Compare commits

..
10 Commits
Author SHA1 Message Date
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
Warren H 77fd434226 Release v0.1.7 2026-07-12 2026-07-12 23:39:57 -04:00
Warren H 122f159b94 Release v0.1.6 2026-07-12 2026-07-12 23:20:15 -04:00
Warren H 35553c18dd Release v0.1.5 2026-07-12 2026-07-12 22:10:12 -04:00
130 changed files with 8273 additions and 3602 deletions
+3
View File
@@ -1,5 +1,7 @@
node_modules/
dist/
data/
backups/
game_assets/
*.tsbuildinfo
vite.config.js
@@ -10,3 +12,4 @@ vite.config.d.ts
!.env.example
__pycache__/
*.py[cod]
/public/basis/
+11 -4
View File
@@ -96,17 +96,24 @@ Stable locked 60 FPS is a release requirement, not a best-effort goal.
- Verify the changed code and its direct dependencies. Do not run tests, visual QA, or controller checks for unrelated screens or systems by default.
- For a screen change, check that screen's applicable display layouts, browser fallback, and controller path only.
- For a reusable mechanic or domain change, run its focused tests plus direct consumers affected by the change.
- Run viewport, display-layout, and browser-fallback testing only when a change adds or modifies a screen, menu, modal, HUD, overlay, or other UI/layout behavior.
- Combat rules, encounter mechanics, world-space telegraphs, animations, VFX, tuning, and other gameplay-only changes do not require viewport testing unless they also change UI or screen layout.
- Expand to broader regression or full-suite verification only for shared foundations, cross-cutting changes, risky refactors, release validation, or when explicitly requested.
- State what was verified and any deliberately unverified scope in the handoff.
## Definition of done
A screen or mechanic is not complete until:
A screen or UI change is not complete until:
1. Its main-display UI is designed and verified at the approximately `960 x 540` Android CSS/layout viewport, with rendering validated against the `1920 x 1080` physical panel.
2. Its applicable secondary UI is designed and verified at the approximately `620 x 540` Android CSS/layout viewport, with rendering validated against the `1240 x 1080` physical panel.
3. It has a complete single-display browser fallback.
4. Every action is reachable and understandable using only a controller, with no click-to-focus step.
5. Shared game logic is modular, typed, and testable outside the renderer/UI.
6. It introduces no known resource leak, unbounded work, or avoidable hot-path allocation.
7. Representative Thor hardware sustains the locked 60 FPS target, or the change includes measured evidence and an explicit approved exception.
Every code change is not complete until:
1. Shared game logic is modular, typed, and testable outside the renderer/UI.
2. It introduces no known resource leak, unbounded work, or avoidable hot-path allocation.
3. Representative Thor hardware sustains the locked 60 FPS target, or the change includes measured evidence and an explicit approved exception.
Gameplay-only mechanic and domain changes do not inherit the screen/UI viewport requirements above.
+30 -4
View File
@@ -4,6 +4,17 @@ This game uses the same proven local-Gitea pattern as `testgame`: clone from the
Gitea bare repository on the TrueNAS filesystem, mount that working checkout
into one Node container, and update it with a local Git pull plus app restart.
## What the TrueNAS server does
The `iwanttoheal-mmo` TrueNAS app is the game's live online server. One Node
process serves the browser bundle and authenticated `/api` routes on port `4173`.
The reverse proxy exposes both at `https://iwanttoheal.phenomrom.com`.
SQLite persists accounts, sessions, cloud saves, boss-kill rankings, and
roguelike records under `/app/data/game.db`. The separate data mount survives
container replacement. TrueNAS Gitea remains the source repository used for
deployment; it is not the game database.
## Paths
```text
@@ -13,6 +24,9 @@ Local Gitea bare repository:
Runnable working checkout:
/mnt/usbssds/apps/iwanttoheal-mmo/app
Persistent game data:
/mnt/usbssds/apps/iwanttoheal-mmo/data
Public URL:
https://iwanttoheal.phenomrom.com
@@ -39,7 +53,7 @@ sudo find /mnt -type d -name "i-want-to-heal-mmo.git" -prune -print 2>/dev/null
Clone entirely through the local filesystem:
```sh
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal-mmo
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal-mmo/{app,data}
sudo git config --global --add safe.directory \
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
sudo git clone \
@@ -72,8 +86,10 @@ services:
iwanttoheal:
image: node:24-bookworm-slim
command: >-
sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run build && corepack pnpm start"
sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run db:init && corepack pnpm run build && corepack pnpm start"
environment:
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost"
DATA_DIR: /app/data
HOST: 0.0.0.0
PORT: "4173"
init: true
@@ -82,11 +98,21 @@ services:
restart: unless-stopped
volumes:
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
- /mnt/usbssds/apps/iwanttoheal-mmo/data:/app/data
working_dir: /app
```
This game has no server database. Do not add `db:init`, `/app/data`, cookie,
CORS, or proxy environment settings from the older game.
Do not remove the `/app/data` mount or place the SQLite database inside the source
checkout. Back up `/mnt/usbssds/apps/iwanttoheal-mmo/data/game.db` before database
migrations or destructive maintenance.
From the app checkout, create a consistent SQLite backup with:
```sh
DATA_DIR=/mnt/usbssds/apps/iwanttoheal-mmo/data \
BACKUP_DIR=/mnt/usbssds/apps/iwanttoheal-mmo/backups \
corepack pnpm run db:backup
```
The separate volume protects the old app files and data. The YAML still maps
host port `4173`, so the old and MMO apps cannot run simultaneously while both
+38 -16
View File
@@ -2,16 +2,23 @@
Playable low-poly third-person combat vertical slice for AYN Thor's dual displays.
Offline-first frontend includes three timestamped save slots, optional account sync, local/online overwrite controls, Hunter Profile statistics, boss collection logs, Settings, and PvE/PvP mode entry points.
Offline-first frontend includes three timestamped save slots, TrueNAS accounts
and cloud saves, Hunter Profile statistics, boss and roguelike leaderboards,
boss collection logs, Settings, and PvE/PvP mode entry points. Offline saves
remain playable without an account and can be uploaded after sign-in.
## Run
```bash
pnpm install
pnpm dev:api
# In a second terminal:
pnpm dev
```
The development server listens on `0.0.0.0:4173`.
The Vite development server listens on `0.0.0.0:4173` and proxies `/api` to the
local production/API server on `127.0.0.1:4174`. Both processes use the same
client API contract as TrueNAS.
## Android / AYN Thor test APK
@@ -23,6 +30,10 @@ installable debug APK:
pnpm android:apk
```
Build explicitly against the TrueNAS API with `pnpm android:apk:truenas`.
Native builds also default to `https://iwanttoheal.phenomrom.com` when no API
base override is supplied.
Output is written under `android/app/build/outputs/apk/debug/`. With Android
platform tools and a connected Thor, build and install it with:
@@ -41,12 +52,25 @@ state rather than two independent WebViews.
Complete first-install, local-Gitea clone, YAML, update, and verification steps:
[DEPLOYMENT.md](DEPLOYMENT.md).
The production preview server uses the existing deployment address and port:
The live TrueNAS web server uses the existing deployment address and port:
- Public URL: `https://iwanttoheal.phenomrom.com`
- Host/container port: `4173`
- App directory: `/mnt/usbssds/apps/iwanttoheal-mmo/app`
### Server architecture
TrueNAS is the online production game server. Its Node process serves the browser
application and authenticated `/api` routes on port `4173`; the reverse proxy
exposes both at the public URL above. SQLite data persists at `/app/data/game.db`
through the separate TrueNAS data mount.
The server owns account credentials, 30-day sessions, three cloud-save slots per
account, boss-kill rankings, and roguelike highest-round rankings. Passwords use
scrypt with per-account salts; clients store only opaque session tokens. Local
saves remain available for offline play. Gitea is a separate TrueNAS service used
for source hosting and deployment.
Clone from the TrueNAS-local Gitea bare repository into the app directory, then
deploy `compose.yaml`. The expected source path is:
@@ -54,18 +78,16 @@ deploy `compose.yaml`. The expected source path is:
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
```
Compared with the old game configuration:
Current configuration:
- use `corepack pnpm install --frozen-lockfile`, not `npm ci`;
- remove `npm run db:init` because this game has no server database;
- remove `COOKIE_SECURE`, `CORS_ORIGINS`, and `TRUST_PROXY`; the static Vite
preview server does not consume them;
- remove `/app/data`; offline saves live in each player's browser/Android WebView
storage, not on TrueNAS.
- run `pnpm db:init` before starting the production server;
- mount `/mnt/usbssds/apps/iwanttoheal-mmo/data` at `/app/data`;
- keep `CORS_ORIGINS` configured for the public site and Capacitor host;
- keep the source checkout and persistent data in separate mounts.
The existing reverse proxy can keep forwarding the public hostname to port
`4173`. Add server data and authentication environment variables only when an
actual sync API is introduced.
The existing reverse proxy forwards the public hostname to port `4173`, including
all `/api` routes.
## Publish updates to Gitea
@@ -109,18 +131,18 @@ outside the repository.
- `WASD` / left stick: move
- `Q` and `E` / D-pad: cycle party target
- `1``6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
- Gamepad: `X`, `Y`, `B`, `A`, `LB`, `RB` map to those abilities
- Gamepad: PlayStation ``, ``, ``, ``, `L1`, `R1` map to those abilities
- `M`: tactical map
- `I`: inventory and item tooltip
- `Enter` / Start: begin or reset encounter
- `Enter` / `START`: begin or reset encounter
Touch controls on lower display support party targeting, ability casting, map, and inventory.
## Prototype scope
## Current game scope
- Five-member AI party with Disc Priest healer
- Animated Druid healer, Knight tank, Ranger, Rogue, and Mage party models
- One animated boss: Bulldrome, using the Bull model at 180% of its original prototype scale
- Animated bosses using canonical tracked game models
- Telegraph, charge, 0.75-second knockdown, and return-to-tank behavior
- Three-charge cycle into a five-second stack marker and 300-damage shared pounce
- Tank pressure, party-wide Cinder Nova, dispellable Ember Brand
@@ -102,7 +102,9 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
dispatchNativeControllerMotion(leftStickX, leftStickY);
float rightStickX = controllerAxisValue(event, MotionEvent.AXIS_Z, MotionEvent.AXIS_RX);
float rightStickY = controllerAxisValue(event, MotionEvent.AXIS_RZ, MotionEvent.AXIS_RY);
dispatchNativeControllerMotion(leftStickX, leftStickY, rightStickX, rightStickY);
Set<String> currentTokens = new HashSet<>();
addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_X), "Button14", "Button15");
@@ -150,6 +152,14 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
if (value >= AXIS_DEAD_ZONE) tokens.add(positive);
}
private float controllerAxisValue(MotionEvent event, int primaryAxis, int fallbackAxis) {
InputDevice device = event.getDevice();
if (device != null && device.getMotionRange(primaryAxis) != null) {
return event.getAxisValue(primaryAxis);
}
return event.getAxisValue(fallbackAxis);
}
private void dispatchNativeControllerToken(String token, boolean repeat) {
if (bridge == null || bridge.getWebView() == null) return;
String script =
@@ -161,18 +171,19 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
});
}
private void dispatchNativeControllerMotion(float x, float y) {
private void dispatchNativeControllerMotion(float moveX, float moveY, float lookX, float lookY) {
if (bridge == null || bridge.getWebView() == null) return;
String script =
"window.dispatchEvent(new CustomEvent('iwt-native-controller-motion',"
+ "{detail:{x:" + x + ",y:" + y + "}}));";
+ "{detail:{moveX:" + moveX + ",moveY:" + moveY
+ ",lookX:" + lookX + ",lookY:" + lookY + "}}));";
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null));
}
private void clearHeldControllerState() {
activeMotionTokens.clear();
lastMotionDispatchAt.clear();
dispatchNativeControllerMotion(0.0f, 0.0f);
dispatchNativeControllerMotion(0.0f, 0.0f, 0.0f, 0.0f);
if (bridge == null || bridge.getWebView() == null) return;
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(
"window.dispatchEvent(new Event('iwt-native-controller-reset'));",
+4 -1
View File
@@ -2,8 +2,10 @@ services:
iwanttoheal:
image: node:24-bookworm-slim
command: >-
sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run build && corepack pnpm start"
sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run db:init && corepack pnpm run build && corepack pnpm start"
environment:
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost"
DATA_DIR: /app/data
HOST: 0.0.0.0
PORT: "4173"
init: true
@@ -12,4 +14,5 @@ services:
restart: unless-stopped
volumes:
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
- /mnt/usbssds/apps/iwanttoheal-mmo/data:/app/data
working_dir: /app
+68
View File
@@ -0,0 +1,68 @@
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL,
canonical_username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
password_salt TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS sessions_token_hash_idx ON sessions(token_hash);
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS hunter_saves (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
hunter_name TEXT NOT NULL,
save_json TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, slot_id)
);
CREATE TABLE IF NOT EXISTS boss_kill_records (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
boss_id TEXT NOT NULL,
kills INTEGER NOT NULL DEFAULT 0 CHECK (kills >= 0),
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, slot_id, boss_id),
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS boss_kill_rank_idx
ON boss_kill_records (boss_id, kills DESC, updated_at ASC);
CREATE TABLE IF NOT EXISTS roguelike_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_round INTEGER NOT NULL DEFAULT 0 CHECK (highest_round >= 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 roguelike_rank_idx
ON roguelike_records (highest_round DESC, updated_at ASC);
CREATE TABLE IF NOT EXISTS rogue_trials_endless_records (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
highest_boss_kills INTEGER NOT NULL DEFAULT 0 CHECK (highest_boss_kills >= 0),
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, slot_id),
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS rogue_trials_endless_rank_idx
ON rogue_trials_endless_records (highest_boss_kills DESC, updated_at ASC);
+18 -4
View File
@@ -1,20 +1,31 @@
{
"name": "i-want-to-heal",
"private": true,
"version": "0.1.4",
"version": "0.1.14",
"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",
"android:apk": "pnpm run android:sync && cd android && ./gradlew --no-daemon clean assembleDebug",
"android:apk:truenas": "pnpm run android:sync:truenas && cd android && ./gradlew --no-daemon clean assembleDebug",
"android:install": "pnpm run android:apk && adb install -r android/app/build/outputs/apk/debug/*.apk",
"start": "vite preview --host ${HOST:-0.0.0.0} --port ${PORT:-4173} --strictPort",
"start": "node server/production.mjs",
"publish:gitea": "python3 scripts/publish_gitea.py",
"test": "vitest run",
"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: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",
@@ -24,17 +35,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
+17
View File
@@ -0,0 +1,17 @@
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
const dataDirectory = resolve(process.env.DATA_DIR ?? "data");
const backupDirectory = resolve(process.env.BACKUP_DIR ?? "backups");
const timestamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
const backupPath = resolve(backupDirectory, `game-${timestamp}.db`);
mkdirSync(backupDirectory, { recursive: true });
const database = new DatabaseSync(resolve(dataDirectory, "game.db"));
try {
database.exec(`VACUUM INTO '${backupPath.replaceAll("'", "''")}'`);
console.log(`SQLite backup created: ${backupPath}`);
} finally {
database.close();
}
@@ -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}`);
}
+14
View File
@@ -0,0 +1,14 @@
import { mkdirSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
const dataDirectory = resolve(process.env.DATA_DIR ?? "data");
mkdirSync(dataDirectory, { recursive: true });
const database = new DatabaseSync(resolve(dataDirectory, "game.db"));
const schema = await readFile(new URL("../db/schema.sql", import.meta.url), "utf8");
database.exec(schema);
database.close();
console.log(`Database ready: ${resolve(dataDirectory, "game.db")}`);
+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`);
+438
View File
@@ -0,0 +1,438 @@
import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
import { mkdirSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
const MAX_JSON_BYTES = 1024 * 1024;
const AUTH_WINDOW_MS = 15 * 60 * 1000;
const AUTH_ATTEMPTS_PER_WINDOW = 20;
const authAttempts = new Map();
function apiError(message, status = 400) {
const error = new Error(message);
error.status = status;
return error;
}
function sendJson(response, status, body) {
response.statusCode = status;
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.setHeader("X-Content-Type-Options", "nosniff");
response.end(JSON.stringify(body));
}
function configuredCorsOrigins() {
return String(process.env.CORS_ORIGINS ?? "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
}
function setCorsHeaders(request, response) {
const origin = request.headers.origin;
if (!origin) return;
const configured = configuredCorsOrigins();
if (!configured.includes("*") && !configured.includes(origin)) return;
response.setHeader("Access-Control-Allow-Origin", origin);
response.setHeader("Access-Control-Allow-Headers", "Authorization,Content-Type");
response.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
response.setHeader("Access-Control-Max-Age", "86400");
response.setHeader("Vary", "Origin");
}
async function readJson(request) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > MAX_JSON_BYTES) throw apiError("Request body is too large.", 413);
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw apiError("Request body must be valid JSON.");
}
}
function canonicalUsername(value) {
return String(value ?? "").trim().toLocaleLowerCase();
}
function validateUsername(value) {
const username = String(value ?? "").trim();
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
throw apiError("Username must be 320 letters, numbers, or underscores.");
}
return username;
}
function validatePassword(value) {
const password = String(value ?? "");
if (password.length < 10 || password.length > 128) {
throw apiError("Password must be 10128 characters.");
}
return password;
}
function passwordDigest(password, salt) {
return scryptSync(password, salt, 64).toString("hex");
}
function verifyPassword(password, account) {
const actual = Buffer.from(passwordDigest(password, account.passwordSalt), "hex");
const expected = Buffer.from(account.passwordHash, "hex");
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
function tokenHash(token) {
return createHash("sha256").update(token).digest("hex");
}
function bearerToken(request) {
const authorization = String(request.headers.authorization ?? "");
return authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : "";
}
function createSession(database, accountId) {
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + SESSION_LIFETIME_MS).toISOString();
database.prepare(`
INSERT INTO sessions (account_id, token_hash, expires_at)
VALUES (?, ?, ?)
`).run(accountId, tokenHash(token), expiresAt);
return token;
}
function currentSession(database, request) {
const token = bearerToken(request);
if (!token) return null;
return database.prepare(`
SELECT accounts.id AS accountId, accounts.username
FROM sessions
JOIN accounts ON accounts.id = sessions.account_id
WHERE sessions.token_hash = ? AND sessions.expires_at > CURRENT_TIMESTAMP
`).get(tokenHash(token)) ?? null;
}
function requireSession(database, request) {
const session = currentSession(database, request);
if (!session) throw apiError("Sign in required.", 401);
return session;
}
function clientAddress(request) {
return request.socket?.remoteAddress ?? "unknown";
}
function enforceAuthRateLimit(request) {
const now = Date.now();
const key = clientAddress(request);
const existing = authAttempts.get(key);
const bucket = existing && now - existing.startedAt < AUTH_WINDOW_MS
? existing
: { startedAt: now, count: 0 };
bucket.count += 1;
authAttempts.set(key, bucket);
if (bucket.count > AUTH_ATTEMPTS_PER_WINDOW) {
throw apiError("Too many authentication attempts. Try again later.", 429);
}
}
function register(database, payload) {
const username = validateUsername(payload?.username);
const password = validatePassword(payload?.password);
const canonical = canonicalUsername(username);
if (database.prepare("SELECT id FROM accounts WHERE canonical_username = ?").get(canonical)) {
throw apiError("Account already exists.", 409);
}
const salt = randomBytes(16).toString("hex");
const result = database.prepare(`
INSERT INTO accounts (username, canonical_username, password_hash, password_salt)
VALUES (?, ?, ?, ?)
`).run(username, canonical, passwordDigest(password, salt), salt);
const accountId = Number(result.lastInsertRowid);
return { account: { id: accountId, username }, token: createSession(database, accountId) };
}
function login(database, payload) {
const canonical = canonicalUsername(payload?.username);
const password = String(payload?.password ?? "");
const account = database.prepare(`
SELECT id, username, password_hash AS passwordHash, password_salt AS passwordSalt
FROM accounts WHERE canonical_username = ?
`).get(canonical);
if (!account || !verifyPassword(password, account)) {
throw apiError("Username or password is incorrect.", 401);
}
return {
account: { id: account.id, username: account.username },
token: createSession(database, account.id),
};
}
function validateSlotId(value) {
const slotId = Number(value);
if (!Number.isInteger(slotId) || slotId < 1 || slotId > 3) throw apiError("Invalid save slot.");
return slotId;
}
function validateSave(value, slotId) {
if (!value || typeof value !== "object" || Number(value.schemaVersion) !== 5) {
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;
}
function normalizeNonNegativeInteger(value) {
const number = Math.floor(Number(value));
return Number.isFinite(number) ? Math.max(0, number) : 0;
}
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(`
INSERT INTO boss_kill_records (account_id, slot_id, boss_id, kills, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
`);
const bossKills = save.stats?.bossKills && typeof save.stats.bossKills === "object"
? save.stats.bossKills
: {};
for (const [bossId, rawKills] of Object.entries(bossKills)) {
if (!/^[a-z0-9-]{1,64}$/.test(bossId)) continue;
const kills = normalizeNonNegativeInteger(rawKills);
if (kills > 0) insertBoss.run(accountId, slotId, bossId, kills);
}
const highestRound = normalizeNonNegativeInteger(save.stats?.highestRoguelikeRound);
database.prepare(`
INSERT INTO roguelike_records (account_id, slot_id, highest_round, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
highest_round = excluded.highest_round,
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, highestRound);
const highestEndlessKills = normalizeNonNegativeInteger(save.stats?.highestRogueTrialsEndlessKills);
database.prepare(`
INSERT INTO rogue_trials_endless_records (account_id, slot_id, highest_boss_kills, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
highest_boss_kills = excluded.highest_boss_kills,
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, highestEndlessKills);
}
function writeSave(database, accountId, slotId, rawSave) {
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);
database.exec("BEGIN IMMEDIATE");
try {
database.prepare(`
INSERT INTO hunter_saves (account_id, slot_id, hunter_name, save_json, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
hunter_name = excluded.hunter_name,
save_json = excluded.save_json,
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, save.hunterName.trim().slice(0, 20), serialized);
syncLeaderboardStats(database, accountId, slotId, save);
database.exec("COMMIT");
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
return save;
}
function readSave(database, accountId, slotId) {
const row = database.prepare(`
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; }
}
function listSaves(database, accountId) {
return database.prepare(`
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 }]; }
catch { return []; }
});
}
function leaderboardEntry(row, valueKey) {
return {
rank: row.rank,
username: row.username,
hunterName: row.hunterName,
slotId: row.slotId,
value: row[valueKey],
};
}
function bossLeaderboard(database, accountId, slotId, bossId) {
if (!/^[a-z0-9-]{1,64}$/.test(bossId)) throw apiError("Invalid boss.");
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (ORDER BY records.kills DESC) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.kills,
accounts.username,
saves.hunter_name AS hunterName,
records.updated_at AS updatedAt
FROM boss_kill_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.boss_id = ?
)
SELECT * FROM ranked ORDER BY kills DESC, updatedAt ASC, accountId ASC, slotId ASC
`).all(bossId);
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
return {
kind: "boss",
bossId,
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "kills")),
current: current ? leaderboardEntry(current, "kills") : null,
};
}
function roguelikeLeaderboard(database, accountId, slotId) {
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (ORDER BY records.highest_round DESC) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.highest_round AS highestRound,
accounts.username,
saves.hunter_name AS hunterName,
records.updated_at AS updatedAt
FROM roguelike_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_round > 0
)
SELECT * FROM ranked ORDER BY highestRound DESC, updatedAt ASC, accountId ASC, slotId ASC
`).all();
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
return {
kind: "roguelike",
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestRound")),
current: current ? leaderboardEntry(current, "highestRound") : null,
};
}
function rogueTrialsEndlessLeaderboard(database, accountId, slotId) {
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (ORDER BY records.highest_boss_kills DESC) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.highest_boss_kills AS highestBossKills,
accounts.username,
saves.hunter_name AS hunterName,
records.updated_at AS updatedAt
FROM rogue_trials_endless_records records
JOIN accounts ON accounts.id = records.account_id
JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id
WHERE records.highest_boss_kills > 0
)
SELECT * FROM ranked ORDER BY highestBossKills DESC, updatedAt ASC, accountId ASC, slotId ASC
`).all();
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
return {
kind: "rogue-trials-endless",
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestBossKills")),
current: current ? leaderboardEntry(current, "highestBossKills") : null,
};
}
export function createGameApiHandler(options = {}) {
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"));
async function handle(request, response, next) {
if (!request.url?.startsWith("/api/")) return next();
setCorsHeaders(request, response);
if (request.method === "OPTIONS") {
response.statusCode = 204;
return response.end();
}
try {
database.prepare("DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP").run();
const url = new URL(request.url, "http://localhost");
const path = url.pathname;
if (path === "/api/health" && request.method === "GET") {
return sendJson(response, 200, { ok: true, database: "ready" });
}
if (path === "/api/auth/register" && request.method === "POST") {
enforceAuthRateLimit(request);
return sendJson(response, 201, register(database, await readJson(request)));
}
if (path === "/api/auth/login" && request.method === "POST") {
enforceAuthRateLimit(request);
return sendJson(response, 200, login(database, await readJson(request)));
}
if (path === "/api/auth/session" && request.method === "GET") {
const session = currentSession(database, request);
return sendJson(response, session ? 200 : 401, session
? { account: { id: session.accountId, username: session.username } }
: { error: "Sign in required." });
}
if (path === "/api/auth/logout" && request.method === "POST") {
const token = bearerToken(request);
if (token) database.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash(token));
return sendJson(response, 200, { ok: true });
}
const session = requireSession(database, request);
if (path === "/api/saves" && request.method === "GET") {
return sendJson(response, 200, { slots: listSaves(database, session.accountId) });
}
const saveMatch = path.match(/^\/api\/saves\/([1-3])$/);
if (saveMatch && request.method === "GET") {
return sendJson(response, 200, { save: readSave(database, session.accountId, validateSlotId(saveMatch[1])) });
}
if (saveMatch && request.method === "PUT") {
const slotId = validateSlotId(saveMatch[1]);
const payload = await readJson(request);
return sendJson(response, 200, { save: writeSave(database, session.accountId, slotId, payload?.save) });
}
const bossMatch = path.match(/^\/api\/leaderboards\/boss\/([a-z0-9-]+)$/);
if (bossMatch && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, bossLeaderboard(database, session.accountId, slotId, bossMatch[1]));
}
if (path === "/api/leaderboards/roguelike" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, roguelikeLeaderboard(database, session.accountId, slotId));
}
if (path === "/api/leaderboards/rogue-trials-endless" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, rogueTrialsEndlessLeaderboard(database, session.accountId, slotId));
}
return sendJson(response, 404, { error: "API route not found." });
} catch (error) {
const status = Number(error?.status) || 500;
const message = status >= 500 ? "Server error." : error.message;
if (status >= 500) console.error(error);
return sendJson(response, status, { error: message });
}
}
return { handle, close: () => database.close() };
}
+115
View File
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, test } from "node:test";
import { createGameApiHandler } from "./game-api.mjs";
const dataDirectory = mkdtempSync(join(tmpdir(), "iwt-heal-api-"));
const api = createGameApiHandler({ dataDirectory });
const server = createServer((request, response) => {
void api.handle(request, response, () => {
response.statusCode = 404;
response.end();
});
});
let baseUrl;
before(async () => {
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
baseUrl = `http://127.0.0.1:${address.port}`;
});
after(async () => {
await new Promise((resolve) => server.close(resolve));
api.close();
rmSync(dataDirectory, { recursive: true, force: true });
});
async function json(path, init = {}) {
const response = await fetch(`${baseUrl}${path}`, init);
const body = await response.json();
return { response, body };
}
function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound, highestRogueTrialsEndlessKills) {
return {
schemaVersion: 5,
slotId,
hunterName,
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills },
};
}
test("health endpoint reports persistent database readiness", async () => {
const { response, body } = await json("/api/health");
assert.equal(response.status, 200);
assert.deepEqual(body, { ok: true, database: "ready" });
});
test("accounts, server saves, and top-five plus current rankings work end to end", async () => {
const players = [];
for (let index = 0; index < 6; index += 1) {
const username = `hunter_${index}`;
const registration = await json("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password: `long-password-${index}` }),
});
assert.equal(registration.response.status, 201);
const token = registration.body.token;
const kills = 60 - index * 10;
const highestRound = 30 - index * 4;
const highestEndlessKills = 24 - index * 3;
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) }),
});
assert.equal(upload.response.status, 200);
players.push({ token, kills, highestRound, highestEndlessKills });
}
const current = players[5];
const bossBoard = await json("/api/leaderboards/boss/bulldrome?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(bossBoard.body.top.length, 5);
assert.equal(bossBoard.body.top[0].value, 60);
assert.equal(bossBoard.body.current.rank, 6);
assert.equal(bossBoard.body.current.value, current.kills);
const rogueBoard = await json("/api/leaderboards/roguelike?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(rogueBoard.body.top.length, 5);
assert.equal(rogueBoard.body.current.rank, 6);
assert.equal(rogueBoard.body.current.value, current.highestRound);
const endlessBoard = await json("/api/leaderboards/rogue-trials-endless?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(endlessBoard.body.kind, "rogue-trials-endless");
assert.equal(endlessBoard.body.top.length, 5);
assert.equal(endlessBoard.body.top[0].value, 24);
assert.equal(endlessBoard.body.current.rank, 6);
assert.equal(endlessBoard.body.current.value, current.highestEndlessKills);
const download = await json("/api/saves/1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(download.body.save.hunterName, "Hero 5");
});
test("invalid credentials cannot access server saves", async () => {
const login = await json("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "hunter_0", password: "incorrect-password" }),
});
assert.equal(login.response.status, 401);
const saves = await json("/api/saves");
assert.equal(saves.response.status, 401);
});
+62
View File
@@ -0,0 +1,62 @@
import { createReadStream, existsSync, statSync } from "node:fs";
import { createServer } from "node:http";
import { extname, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { createGameApiHandler } from "./game-api.mjs";
const distPath = fileURLToPath(new URL("../dist", import.meta.url));
const indexPath = resolve(distPath, "index.html");
const host = process.env.HOST ?? "127.0.0.1";
const port = Number(process.env.PORT ?? 4173);
const contentTypes = {
".css": "text/css; charset=utf-8",
".glb": "model/gltf-binary",
".html": "text/html; charset=utf-8",
".ico": "image/x-icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
};
function sendFile(response, filePath) {
response.statusCode = 200;
response.setHeader("Content-Type", contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream");
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("Referrer-Policy", "same-origin");
response.setHeader("X-Frame-Options", "DENY");
createReadStream(filePath).pipe(response);
}
function serveStatic(request, response) {
let requestPath;
try { requestPath = decodeURIComponent(new URL(request.url, "http://localhost").pathname); }
catch { response.statusCode = 400; return response.end("Bad request"); }
const candidate = resolve(distPath, `.${requestPath}`);
const insideDist = candidate === distPath || candidate.startsWith(`${distPath}${sep}`);
if (insideDist && existsSync(candidate) && statSync(candidate).isFile()) return sendFile(response, candidate);
if (!existsSync(indexPath)) {
response.statusCode = 503;
return response.end("Build missing. Run pnpm build.");
}
return sendFile(response, indexPath);
}
const api = createGameApiHandler();
const server = createServer((request, response) => {
void api.handle(request, response, () => serveStatic(request, response));
});
server.listen(port, host, () => console.log(`I Want To Heal listening on http://${host}:${port}`));
function shutdown() {
server.close(() => {
api.close();
process.exit(0);
});
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
+34 -15
View File
@@ -8,7 +8,7 @@ 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_LAUNCH_EVENT } from "./platform/dualScreenSync";
import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
@@ -32,20 +32,25 @@ export default function App() {
const touchActiveSave = useFrontendStore((state) => state.touchActiveSave);
const updateActiveHealerInventory = useFrontendStore((state) => state.updateActiveHealerInventory);
const recordBossVictory = useFrontendStore((state) => state.recordBossVictory);
const recordRoguelikeDefeat = useFrontendStore((state) => state.recordRoguelikeDefeat);
const recordRogueTrialsEndlessDefeat = useFrontendStore((state) => state.recordRogueTrialsEndlessDefeat);
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);
touchActiveSave();
navigate("home");
if (accountId && activeSlotId) void uploadSlot(activeSlotId);
}, [navigate, touchActiveSave, updateActiveHealerInventory]);
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug) => {
if (!hunter) return;
const progress = hunter.healers[hunter.activeClassId];
const runMode = useFrontendStore.getState().selectedMode === "roguelike-pve" ? "roguelike" : "encounter";
const launchDifficulty = runMode === "roguelike"
const selectedMode = useFrontendStore.getState().selectedMode;
const runMode = selectedMode === "roguelike-pve" ? "roguelike" : selectedMode === "rogue-trials" ? "rogue-trials" : "encounter";
const launchDifficulty = runMode !== "encounter"
? "initiate"
: requestedDifficultySlug ?? useFrontendStore.getState().selectedDifficultySlug;
rewardedBossInstances.current.clear();
@@ -65,6 +70,11 @@ export default function App() {
return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
}, [launchGame]);
useEffect(() => {
window.addEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
return () => window.removeEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
}, [leaveGame]);
useActionBindings(screen === "game", leaveGame);
useEffect(() => {
@@ -75,19 +85,28 @@ export default function App() {
useEffect(() => {
return useGameStore.subscribe((state, previousState) => {
const startedFreshEncounter = state.phase === "briefing" && previousState.phase !== "briefing"
|| previousState.phase === "intermission" && state.phase === "combat";
if (state.phase === "briefing" || previousState.phase === "intermission" && state.phase === "combat") {
|| previousState.phase === "intermission" && state.phase === "combat"
|| previousState.phase === "victory" && state.phase === "combat" && state.endlessMode;
if (state.phase === "briefing"
|| previousState.phase === "intermission" && state.phase === "combat"
|| previousState.phase === "victory" && state.phase === "combat" && state.endlessMode) {
rewardedBossInstances.current.clear();
}
if (startedFreshEncounter) clearRecentRewards();
if (screenRef.current !== "game") return;
if (state.runMode === "roguelike" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordRoguelikeDefeat(state.round);
}
if (state.endlessMode && state.phase === "defeat" && previousState.phase !== "defeat") {
recordRogueTrialsEndlessDefeat(state.endlessBossKills);
}
const bossCount = 1 + state.additionalBosses.length;
if (state.boss.hp <= 0 && previousState.boss.hp > 0) {
const primaryInstanceId = `boss-0-${state.boss.id}`;
const primaryInstanceId = state.bossInstanceId;
if (!rewardedBossInstances.current.has(primaryInstanceId)) {
rewardedBossInstances.current.add(primaryInstanceId);
if (!state.endlessMode) rewardedBossInstances.current.add(primaryInstanceId);
const defeatedBefore = (state.round - 1) * bossCount;
const rewardDifficulty = state.runMode === "roguelike" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
recordBossVictory(state.boss.id, rewardDifficulty);
}
}
@@ -95,23 +114,23 @@ export default function App() {
const entry = state.additionalBosses[index];
const previous = previousState.additionalBosses[index];
const justDefeated = entry.boss.hp <= 0 && (!previous || previous.instanceId !== entry.instanceId || previous.boss.hp > 0);
if (!justDefeated || rewardedBossInstances.current.has(entry.instanceId)) continue;
rewardedBossInstances.current.add(entry.instanceId);
if (!justDefeated || !state.endlessMode && rewardedBossInstances.current.has(entry.instanceId)) continue;
if (!state.endlessMode) rewardedBossInstances.current.add(entry.instanceId);
const defeatedBefore = (state.round - 1) * bossCount + index + 1;
const rewardDifficulty = state.runMode === "roguelike" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
recordBossVictory(entry.boss.id, rewardDifficulty);
}
});
}, [clearRecentRewards, recordBossVictory]);
}, [clearRecentRewards, recordBossVictory, recordRoguelikeDefeat, recordRogueTrialsEndlessDefeat]);
return (
<main className="prototype-shell">
<header className="prototype-header">
<main className="app-shell">
<header className="app-header">
<div><span>THOR / DUAL DISPLAY</span><strong>I Want To Heal</strong></div>
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
</header>
{screen === "game"
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} bottom={<BottomScreen />} /></Suspense>
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} bottom={<BottomScreen onExit={leaveGame} />} /></Suspense>
: <FrontEnd onLaunch={launchGame} />}
</main>
);
+16
View File
@@ -3,3 +3,19 @@
Only assets referenced by the game ship from this folder. They are copied from the ignored `game_assets` source library, retain their source-relative path, and are imported with Vite `new URL(...)` calls.
Use `pnpm assets:import <path-within-game_assets>` to add a source asset. Add `--replace` only when deliberately updating an existing runtime copy.
## KTX2/UASTC pilot
The tracked `*-uastc.glb` files are parallel optimized copies. Their original GLBs remain the rollback path and must not be replaced or deleted during this pilot.
KTX-Software `toktx` is an authoring dependency. Rebuild source copies with:
```sh
TOKTX=/path/to/toktx pnpm assets:build-ktx2
TOKTX=/path/to/toktx pnpm assets:build-dungeon-kit
TOKTX=/path/to/toktx pnpm assets:build-gravehorn
```
Import each generated path from `game_assets/` with `pnpm assets:import <path> --replace`. Builds and dev startup copy Three.js's matching Basis transcoder into the ignored `public/basis/` generated directory.
Force all original GLBs at runtime with `?legacyGameAssets=1`. Force them in an Android/browser build with `VITE_LEGACY_GAME_ASSETS=1 pnpm build`. `legacyDungeonAssets` and `VITE_LEGACY_DUNGEON_ASSETS=1` remain supported aliases.
@@ -0,0 +1,7 @@
# Animated Triceratops Skeleton
- Creator: Zacxophone — https://sketchfab.com/Zacxophone
- Source: https://sketchfab.com/3d-models/animated-triceratops-skeleton-06cb55f941d94dc8b95ac46f92d89e7c
- License: CC0 1.0 Universal — https://creativecommons.org/publicdomain/zero/1.0/
Runtime files named `gravehorn-triceratops*.glb` are optimized derivatives of this model.
+97 -17
View File
@@ -1,15 +1,22 @@
import { useFrame } from "@react-three/fiber";
import { 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 { bossRoomFor, type BossRoomDefinition, type BossRoomFloor } from "../game/bossRooms";
import { useGameStore } from "../game/store";
import { LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider";
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 +113,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,20 +161,77 @@ 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) {
return ARENA_WALL_SEGMENTS.map((fixture) => ({
...fixture,
scaleY: room.wallHeight / 4,
})), [room.wallHeight]);
}));
}
function LegacyArenaArchitecture({ room }: { room: BossRoomDefinition }) {
const walls = useMemo(() => arenaWalls(room), [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 namedMesh(scene: THREE.Object3D, name: string) {
const object = scene.getObjectByName(name);
if (object instanceof THREE.Mesh) {
return object as THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
}
throw new Error(`Dungeon kit is missing mesh ${name}.`);
}
function DungeonKitArchitecture({ room }: { room: BossRoomDefinition }) {
const gltf = useGameGLTF(KAYKIT_DUNGEON_KIT_URL);
const walls = useMemo(() => arenaWalls(room), [room]);
const meshes = useMemo(() => ({
pillar: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.pillar),
wall: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.wall),
torch: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.torch),
}), [gltf.scene]);
return (
<group>
<DungeonMeshInstances mesh={meshes.wall} fixtures={walls} tint={room.wallColor} opacity={0.54} />
<DungeonMeshInstances mesh={meshes.pillar} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
<DungeonMeshInstances mesh={meshes.torch} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
</group>
);
}
class DungeonAssetErrorBoundary extends Component<{
children: ReactNode;
fallback: ReactNode;
}, { failed: boolean }> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch(error: unknown) {
console.warn("Optimized dungeon asset failed; using legacy GLBs.", error);
}
render() {
return this.state.failed ? this.props.fallback : this.props.children;
}
}
function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
const walls = useRef<THREE.Group>(null);
const previousCameraPosition = useRef<THREE.Vector3 | null>(null);
@@ -205,14 +267,30 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
);
}
function RoomWalls({ room }: { room: BossRoomDefinition }) {
function LegacyRoomWalls({ room }: { room: BossRoomDefinition }) {
return (
<Suspense fallback={<RoomWallFallback room={room} />}>
<ArenaArchitecture room={room} />
<LegacyArenaArchitecture room={room} />
</Suspense>
);
}
function OptimizedRoomWalls({ room }: { room: BossRoomDefinition }) {
return (
<DungeonAssetErrorBoundary fallback={<LegacyRoomWalls room={room} />}>
<Suspense fallback={<RoomWallFallback room={room} />}>
<DungeonKitArchitecture room={room} />
</Suspense>
</DungeonAssetErrorBoundary>
);
}
function RoomWalls({ room }: { room: BossRoomDefinition }) {
return LEGACY_GAME_ASSETS_FORCED
? <LegacyRoomWalls room={room} />
: <OptimizedRoomWalls room={room} />;
}
function RoomMarks({ room }: { room: BossRoomDefinition }) {
const rays = useRef<THREE.InstancedMesh>(null);
const pattern = ROOM_PATTERNS[room.floor];
@@ -345,6 +423,8 @@ export function BossRoom() {
);
}
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);
}
+59
View File
@@ -0,0 +1,59 @@
import { Canvas } from "@react-three/fiber";
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 = useGameGLTF(bossVisualUrl(bossId, !LEGACY_GAME_ASSETS_FORCED));
const model = useMemo(() => {
const clone = cloneSkeleton(gltf.scene);
clone.updateMatrixWorld(true);
const bounds = new THREE.Box3().setFromObject(clone);
const center = bounds.getCenter(new THREE.Vector3());
const size = bounds.getSize(new THREE.Vector3());
const scale = 2.35 / Math.max(size.x, size.y, size.z, 0.001);
clone.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.castShadow = false;
object.receiveShadow = false;
}
});
return { clone, center, scale };
}, [gltf.scene]);
return (
<primitive
object={model.clone}
position={[-model.center.x * model.scale, -model.center.y * model.scale, -model.center.z * model.scale]}
rotation={[0, bossId === "bulldrome" ? 0.35 : ALTERNATE_BOSS_CONFIG[bossId].rotationOffset + 0.35, 0]}
scale={model.scale}
/>
);
}
export function BossTrophyPortrait({ bossId }: { bossId: BossId }) {
const boss = BOSS_DEFINITIONS[bossId];
return (
<div className="trophy-portrait" aria-label={`${boss.name} portrait`} role="img">
<Canvas
camera={{ position: [3.4, 2.35, 4.8], zoom: 70, near: 0.1, far: 30 }}
dpr={1}
frameloop="demand"
gl={{ alpha: true, antialias: true, powerPreference: "low-power" }}
orthographic
>
<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]} />
<GameAssetProvider>
<Suspense fallback={null}><PortraitModel bossId={bossId} /></Suspense>
</GameAssetProvider>
</Canvas>
<span aria-hidden="true">{boss.icon}</span>
</div>
);
}
+51 -18
View File
@@ -2,9 +2,11 @@ import { ABILITY_ORDER } from "../game/data";
import { HEALER_CLASSES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, 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";
function RewardSummary() {
const rewards = useFrontendStore((state) => state.recentRewards);
@@ -82,15 +84,20 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
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 remaining = abilityRemaining(abilityId, time, cooldowns);
const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
const castTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
const globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = abilityId === "purify" && selected.debuffs.length === 0;
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
const disabled = phase !== "combat" || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < ability.mana || noDispel || invalidTarget;
const resourceCopy = `${ability.mana ? `${ability.mana} mana` : "free"}${ability.castTime ? ` · ${ability.castTime.toFixed(1)}s` : ""}`;
const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const resourceCopy = `${manaCost ? `${manaCost} mana` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
return (
<button
@@ -106,7 +113,7 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
<span className="ability-pad">{ability.gamepad}</span>
{remaining > 0 && (
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / ability.cooldown) } as React.CSSProperties}>
<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>
</span>
)}
@@ -165,7 +172,7 @@ function BriefingPanel() {
<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>START / ENTER</small></button>
<button className="start-button" onClick={startEncounter}><span>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>
@@ -184,29 +191,55 @@ function BriefingPanel() {
);
}
function EndPanel() {
function EndPanel({ onExit }: { onExit?: () => void }) {
const hunter = useActiveHunter();
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
const round = useGameStore((state) => state.round);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const endlessChoiceSelection = useGameStore((state) => state.endlessChoiceSelection);
const setEndlessChoiceSelection = useGameStore((state) => state.setEndlessChoiceSelection);
const startRogueTrialsEndless = useGameStore((state) => state.startRogueTrialsEndless);
const time = useGameStore((state) => state.time);
const party = useGameStore((state) => state.party);
const restart = useGameStore((state) => state.restart);
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 showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
const endlessDefeat = phase === "defeat" && endlessMode;
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>{phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
<h2>{phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
<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>
<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>Boss</small><strong>{phase === "victory" ? "Defeated" : "Standing"}</strong></span>
<span><small>{endlessDefeat ? "Endless kills" : "Boss"}</small><strong>{endlessDefeat ? endlessBossKills : phase === "victory" ? "Defeated" : "Standing"}</strong></span>
</div>
{phase === "victory" && <RewardSummary />}
<div className="end-actions">
{showEndlessChoice ? <div className="end-actions endless-choice-actions">
<button
className={endlessChoiceSelection === "continue" ? "is-controller-focused" : ""}
onFocus={() => setEndlessChoiceSelection("continue")}
onPointerEnter={() => setEndlessChoiceSelection("continue")}
onClick={startRogueTrialsEndless}
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")}
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={restart}>Return to briefing</button>
</div>
<button className="secondary" onClick={endlessDefeat ? onExit : restart}>{endlessDefeat ? "Return to main menu" : "Return to briefing"}</button>
</div>}
</div>
);
}
@@ -220,16 +253,16 @@ function IntermissionStatusPanel() {
<h2>Choose on top display</h2>
<p>Next encounter stays locked until one blessing is claimed.</p>
<RewardSummary />
<small>Use D-pad to choose · A to claim</small>
<small>Use D-pad to choose · {DEFAULT_CONTROLLER_GLYPHS.confirm} to claim</small>
</div>
);
}
function CombatPanel() {
function CombatPanel({ onExit }: { onExit?: () => void }) {
const phase = useGameStore((state) => state.phase);
if (phase === "briefing") return <BriefingPanel />;
if (phase === "intermission") return <IntermissionStatusPanel />;
if (phase === "victory" || phase === "defeat") return <EndPanel />;
if (phase === "victory" || phase === "defeat") return <EndPanel onExit={onExit} />;
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
}
@@ -335,7 +368,7 @@ const tabs: { id: BottomTab; label: string; icon: string; key: string }[] = [
{ id: "pack", label: "Pack", icon: "▧", key: "I" },
];
export function BottomScreen() {
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
const activeTab = useGameStore((state) => state.activeTab);
const setActiveTab = useGameStore((state) => state.setActiveTab);
const phase = useGameStore((state) => state.phase);
@@ -353,13 +386,13 @@ export function BottomScreen() {
</nav>
</header>
<main className="lower-content">
{activeTab === "combat" && <CombatPanel />}
{activeTab === "combat" && <CombatPanel onExit={onExit} />}
{activeTab === "map" && <MapPanel />}
{activeTab === "pack" && <PackPanel />}
</main>
{paused && (
<div className="lower-pause-overlay" aria-hidden="true">
<span>PAUSED</span><strong>Encounter suspended</strong><small>START / ESC resumes · selects menu action</small>
<span>PAUSED</span><strong>Encounter suspended</strong><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC resumes · selects menu action</small>
</div>
)}
</section>
+42 -12
View File
@@ -1,25 +1,47 @@
import { RUN_BUFFS, bossHealthMultiplier, countRunBuff } from "../game/roguelike";
import { useGameStore } from "../game/store";
import { useEffect, useState } from "react";
import { ROGUE_TRIALS_TRIO_ROUND, RUN_BUFFS, bossHealthMultiplier, effectiveRunBuffRank, formatRunBuffEffect } from "../game/roguelike";
import { HEALER_CLASSES } from "../game/healers";
import { isRunBuffInputLocked, useGameStore } from "../game/store";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
export function BuffDraftPanel({ className = "" }: { className?: string }) {
const round = useGameStore((state) => state.round);
const runBuffs = useGameStore((state) => state.runBuffs);
const runMode = useGameStore((state) => state.runMode);
const healerClassId = useGameStore((state) => state.healerClassId);
const runBuffRanks = useGameStore((state) => state.runBuffRanks);
const passiveRunBuffId = useGameStore((state) => state.passiveRunBuffId);
const choices = useGameStore((state) => state.draftBuffIds);
const selected = useGameStore((state) => state.selectedRunBuffId);
const inputUnlockAt = useGameStore((state) => state.runBuffInputUnlockAt);
const setSelected = useGameStore((state) => state.setSelectedRunBuff);
const choose = useGameStore((state) => state.chooseRunBuff);
const continueRun = useGameStore((state) => state.continueRoguelikeRound);
const nextRound = round + 1;
const nextBossCount = runMode === "rogue-trials" && nextRound === ROGUE_TRIALS_TRIO_ROUND ? 3 : 2;
const abilities = HEALER_CLASSES[healerClassId].abilities;
const [inputLocked, setInputLocked] = useState(() => isRunBuffInputLocked(useGameStore.getState()));
useEffect(() => {
const remaining = inputUnlockAt - Date.now();
setInputLocked(remaining > 0);
if (remaining <= 0) return;
const timer = window.setTimeout(() => setInputLocked(false), remaining);
return () => window.clearTimeout(timer);
}, [inputUnlockAt]);
return (
<div className={`buff-draft ${className}`.trim()} role="dialog" aria-modal="true" aria-label={`Choose a buff for round ${nextRound}`}>
<div className={`buff-draft ${inputLocked ? "is-input-locked" : ""} ${className}`.trim()} role="dialog" aria-modal="true" aria-label={`Choose a buff for round ${nextRound}`} aria-busy={inputLocked}>
<header>
<span>Round {round} cleared</span>
<h2>Choose one blessing</h2>
<p>Claim required. Round {nextRound} begins with two new bosses at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.</p>
<p>Claim required. Round {nextRound} begins with {nextBossCount === 3 ? "an unseen trio" : "two new bosses"} at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.</p>
</header>
<div className="buff-choice-grid">
{choices.map((buffId) => {
<div className={`buff-choice-grid choice-count-${choices.length}`}>
{choices.length > 0 ? choices.map((buffId) => {
const buff = RUN_BUFFS[buffId];
const stacks = countRunBuff(runBuffs, buffId);
const rank = effectiveRunBuffRank(runBuffRanks, buffId, passiveRunBuffId);
const nextRank = Math.min(buff.maxRank, rank + 1);
const ability = abilities[buff.abilityId];
return (
<button
key={buffId}
@@ -28,17 +50,25 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
onFocus={() => setSelected(buffId)}
onPointerEnter={() => setSelected(buffId)}
onClick={() => choose(buffId)}
disabled={inputLocked}
aria-pressed={selected === buffId}
>
<i>{buff.icon}</i>
<span><small>{stacks ? `${stacks} owned` : "New blessing"}</small><strong>{buff.name}</strong></span>
<b>{buff.summary}</b>
<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>
<p>{buff.detail}</p>
</button>
);
})}
}) : (
<button className="buff-mastery-continue is-controller-focused" 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>
<p>Keep completed build and begin next randomized encounter.</p>
</button>
)}
</div>
<footer><b> / </b> Choose <i /> <b>A / ENTER</b> Claim</footer>
<footer>{inputLocked ? <b>Choices ready in a moment</b> : <>{choices.length > 0 && <><b> / </b> Choose <i /></>} <b>{DEFAULT_CONTROLLER_GLYPHS.confirm} / ENTER</b> {choices.length > 0 ? "Claim" : "Continue"}</>}</footer>
</div>
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
import { subscribeControllerToken } from "../input/controller";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
const dedicatedSurface = new URLSearchParams(window.location.search).get("display");
@@ -45,7 +46,7 @@ export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: Reac
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")}
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
>
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>SELECT / TAB</small>
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
</button>
</div>
);
+457 -141
View File
@@ -1,13 +1,16 @@
import { useMemo, useRef, useState } from "react";
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { buildCollections, MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName } from "../frontend/data";
import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
import { resolveSaveContinuation, saveVersionsMatch } from "../frontend/saveContinuation";
import { useActiveHunter, useFrontendStore } from "../frontend/store";
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUP_BY_ID } from "../game/bossCatalog";
import { selectRandomBossPair } from "../game/roguelike";
import type { BossId } from "../game/types";
import { ABILITY_ORDER } from "../game/data";
import { BOSS_DEFINITIONS, BOSS_GROUP_BY_ID, BOSS_GROUPS } from "../game/bossCatalog";
import { bossMechanicIsPassive, bossMechanicName } from "../game/bosses/mechanicPool";
import { RUN_BUFFS, formatRunBuffEffect, selectRandomBossPair } from "../game/roguelike";
import type { AbilityId, BossId } from "../game/types";
import {
GEAR_OWNER_LABELS,
GEAR_OWNER_ORDER,
@@ -17,8 +20,10 @@ import {
GEAR_STAT_LABELS,
MAX_GEAR_LEVEL,
canAffordGearUpgrade,
canUpgradeGearSlot,
gearBonusText,
gearUpgradeCosts,
type GearOwnerId,
} from "../game/progression/gear";
import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossGroupDrop } from "../game/progression/loot";
import {
@@ -31,7 +36,11 @@ import {
passiveInfusionUnlocked,
} from "../game/progression/infusions";
import { requestDisplaySurface } from "../platform/displayRouting";
import { onlineRepository, type LeaderboardResult } from "../frontend/onlineRepository";
import { DualDisplayFrame } from "./DualDisplayFrame";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
const BossTrophyPortrait = lazy(() => import("./BossTrophyPortrait").then((module) => ({ default: module.BossTrophyPortrait })));
function FocusButton({
id,
@@ -69,24 +78,66 @@ function BrandMark({ compact = false }: { compact?: boolean }) {
}
function ControllerLegend({ back = false }: { back?: boolean }) {
return <div className="controller-legend"><span><b>A</b> Select</span>{back && <span><b>B</b> Back</span>}<span><b></b> Navigate</span></div>;
return <div className="controller-legend"><span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Select</span>{back && <span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back</span>}<span><b></b> Navigate</span></div>;
}
function SaveLibraryContext({ slots, accountId }: { slots: readonly SaveSlotState[]; accountId: string | null }) {
return (
<FrontSurface className="login-save-context" bottom ariaLabel="Save slot information">
<header className="context-header"><span>Device saves</span><b>{accountId ? "SERVER LINKED" : "OFFLINE READY"}</b></header>
<div className="login-save-list">
{slots.map((slot) => {
const save = slot.local ?? slot.online;
const healer = save ? HEALER_CLASSES[save.activeClassId] : null;
return (
<article key={slot.id} className={save ? "has-save" : "is-empty"}>
<b>{String(slot.id).padStart(2, "0")}</b>
{save ? (
<>
<div className="login-save-avatar">{save.hunterName[0]}</div>
<span>
<small>{slot.local ? "On this Thor" : "Online copy"}</small>
<strong>{save.hunterName}</strong>
<em>Level {save.healers[save.activeClassId].level} {healer?.name} · {save.location}</em>
</span>
<time><strong>{formatPlayTime(save.playSeconds)}</strong><small>{formatSaveTimestamp(save.updatedAt)}</small></time>
</>
) : (
<span className="login-empty-copy"><small>Available slot</small><strong>New hunter</strong><em>Continue offline to create</em></span>
)}
</article>
);
})}
</div>
<footer className="login-save-footer"><span>Save details update from upper-screen selection</span><b>LOWER DISPLAY · INFORMATION ONLY</b></footer>
</FrontSurface>
);
}
function LoginScreen() {
const restoreSession = useFrontendStore((state) => state.restoreSession);
const signIn = useFrontendStore((state) => state.signIn);
const createAccount = useFrontendStore((state) => state.createAccount);
const continueOffline = useFrontendStore((state) => state.continueOffline);
const slots = useFrontendStore((state) => state.slots);
const accountId = useFrontendStore((state) => state.accountId);
const notice = useFrontendStore((state) => state.notice);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const restoreStarted = useRef(false);
useEffect(() => {
if (restoreStarted.current) return;
restoreStarted.current = true;
void restoreSession();
}, [restoreSession]);
const actions = useMemo<MenuAction[]>(() => [
{ id: "username", run: () => usernameRef.current?.focus() },
{ id: "password", run: () => passwordRef.current?.focus() },
{ id: "sign-in", run: () => { void signIn(username, password); } },
{ id: "create-account", run: () => { void createAccount(username, password); } },
{ id: "offline", run: continueOffline },
{ id: "continue", run: continueOffline },
], [continueOffline, createAccount, password, signIn, username]);
const controller = useMenuController(actions);
@@ -101,7 +152,7 @@ function LoginScreen() {
<div className="login-copy">
<span>Offline-first hunter records</span>
<h1>Keep everyone standing.</h1>
<p>Your save always lives on this device. Sign in only when you want a second copy for PC AYN Thor handoff.</p>
<p>Continue to your saved hunters. Sign in when you want online copies for PC AYN Thor handoff.</p>
</div>
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); submitSignIn(); }}>
<label htmlFor="account-username">Username</label>
@@ -113,6 +164,9 @@ function LoginScreen() {
onChange={(event) => setUsername(event.target.value)}
onFocus={() => controller.focus("username")}
autoComplete="username"
maxLength={20}
minLength={3}
pattern="[A-Za-z0-9_]+"
required
/>
<label htmlFor="account-password">Password</label>
@@ -125,6 +179,8 @@ function LoginScreen() {
onChange={(event) => setPassword(event.target.value)}
onFocus={() => controller.focus("password")}
autoComplete="current-password"
maxLength={128}
minLength={10}
required
/>
<FocusButton id="sign-in" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" type="submit">
@@ -133,38 +189,27 @@ function LoginScreen() {
<FocusButton id="create-account" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={() => { void createAccount(username, password); }}>
<span>Create account</span><small>Required for first sync</small>
</FocusButton>
<FocusButton id="offline" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}>
<span>Continue with offline save</span><small>No account required</small>
<FocusButton id="continue" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}>
<span>Continue</span><small>Choose saved hunter</small>
</FocusButton>
</form>
{notice && <div className="front-notice" role="status" aria-live="polite">{notice}</div>}
<ControllerLegend />
</FrontSurface>
}
bottom={
<FrontSurface className="login-context" bottom ariaLabel="Offline save explanation">
<BrandMark compact />
<div className="offline-promise">
<span className="context-kicker">How saving works</span>
<ol>
<li><b>01</b><span><strong>Play offline</strong><small>Every change writes to device storage first.</small></span></li>
<li><b>02</b><span><strong>Create or sign in</strong><small>Username and password unlock online sync.</small></span></li>
<li><b>03</b><span><strong>Move devices</strong><small>Sign in, then upload or download an online copy.</small></span></li>
</ol>
</div>
<div className="device-route"><span>PC</span><i></i><b>ONLINE COPY</b><i></i><span>THOR</span></div>
</FrontSurface>
}
bottom={<SaveLibraryContext slots={slots} accountId={accountId} />}
/>
);
}
function SlotCard({ slot, selected, focused, onSelect, onFocus }: { slot: SaveSlotState; selected: boolean; focused: boolean; onSelect: () => void; onFocus: () => void }) {
const save = slot.local;
const continuation = resolveSaveContinuation(slot);
const save = slot.local ?? slot.online;
const healer = save ? HEALER_CLASSES[save.activeClassId] : null;
const copyStatus = continuation === "choose" ? "Newer online" : continuation === "online" ? "Online only" : null;
return (
<button className={`save-slot ${selected ? "is-selected" : ""} ${focused ? "is-controller-focused" : ""}`} onClick={onSelect} onFocus={onFocus} onPointerEnter={onFocus}>
<span className="slot-number">Slot {String(slot.id).padStart(2, "0")}</span>
<span className="slot-number">Slot {String(slot.id).padStart(2, "0")}{copyStatus && <b>{copyStatus}</b>}</span>
{save ? (
<>
<div className="slot-portrait">{save.hunterName[0]}<i></i></div>
@@ -193,11 +238,15 @@ function SaveScreen() {
const copySlot = useFrontendStore((state) => state.copySlot);
const deleteSlot = useFrontendStore((state) => state.deleteSlot);
const navigate = useFrontendStore((state) => state.navigate);
const [dialog, setDialog] = useState<"create" | "copy" | "delete" | null>(null);
const [dialog, setDialog] = useState<"create" | "copy" | "delete" | "version" | null>(null);
const [hunterName, setHunterName] = useState("");
const [resolvingOnline, setResolvingOnline] = useState(false);
const [versionError, setVersionError] = useState("");
const selected = slots.find((slot) => slot.id === selectedSlotId)!;
const hasLocal = Boolean(selected.local);
const hasOnline = Boolean(selected.online);
const continuation = resolveSaveContinuation(selected);
const primaryActionId = continuation === "create" ? "create" : "play";
const finishCreation = () => {
if (createSlot(selectedSlotId, hunterName)) {
@@ -215,7 +264,41 @@ function SaveScreen() {
requestDisplaySurface("top");
};
const actions = useMemo<MenuAction[]>(() => dialog === "create"
const continueWithOnline = async () => {
if (resolvingOnline) return;
setResolvingOnline(true);
setVersionError("");
await downloadSlot(selectedSlotId);
const refreshed = useFrontendStore.getState().slots.find((slot) => slot.id === selectedSlotId);
if (refreshed && saveVersionsMatch(refreshed.local, refreshed.online)) {
setDialog(null);
setResolvingOnline(false);
playSlot(selectedSlotId);
return;
}
setVersionError(useFrontendStore.getState().notice || "Online save could not be loaded.");
setResolvingOnline(false);
};
const continueSelected = () => {
if (continuation === "create") return openCreation();
if (continuation === "local") return playSlot(selectedSlotId);
if (continuation === "online") {
void continueWithOnline();
return;
}
setVersionError("");
setDialog("version");
requestDisplaySurface("top");
};
const actions = useMemo<MenuAction[]>(() => dialog === "version"
? [
{ id: "version-online", run: () => { void continueWithOnline(); }, enabled: !resolvingOnline },
{ id: "version-local", run: () => { setDialog(null); playSlot(selectedSlotId); }, enabled: !resolvingOnline },
{ id: "cancel-version", run: () => setDialog(null), enabled: !resolvingOnline },
]
: dialog === "create"
? [
{ id: "confirm-create", run: finishCreation },
{ id: "cancel-create", run: () => setDialog(null) },
@@ -228,17 +311,25 @@ function SaveScreen() {
{ id: "cancel-delete", run: () => setDialog(null) },
]
: [
...slots.map((slot) => ({ id: `slot-${slot.id}`, run: () => selectSlot(slot.id) })),
{ id: hasLocal ? "play" : "create", run: () => hasLocal ? playSlot(selectedSlotId) : openCreation() },
{ id: "upload", run: () => uploadSlot(selectedSlotId), enabled: hasLocal && Boolean(accountId) },
{ id: "download", run: () => downloadSlot(selectedSlotId), enabled: hasOnline && Boolean(accountId) },
{ id: "copy", run: () => openSaveDialog("copy"), enabled: hasLocal },
{ id: "delete", run: () => openSaveDialog("delete"), enabled: hasLocal },
{ id: "back", run: () => navigate("login") },
], [accountId, copySlot, createSlot, deleteSlot, dialog, downloadSlot, hasLocal, hasOnline, hunterName, navigate, playSlot, selectSlot, selectedSlotId, slots, uploadSlot]);
const controller = useMenuController(actions, { onBack: () => dialog ? setDialog(null) : navigate("login") });
...slots.map((slot, index) => ({
id: `slot-${slot.id}`,
run: () => selectSlot(slot.id),
neighbors: {
left: `slot-${slots[Math.max(0, index - 1)].id}`,
right: `slot-${slots[Math.min(slots.length - 1, index + 1)].id}`,
down: primaryActionId,
},
})),
{ id: primaryActionId, run: continueSelected, enabled: !resolvingOnline, neighbors: { up: `slot-${selectedSlotId}`, right: "upload" } },
{ id: "upload", run: () => uploadSlot(selectedSlotId), enabled: hasLocal && Boolean(accountId), neighbors: { left: primaryActionId, right: "download", up: "slot-1" } },
{ id: "download", run: () => downloadSlot(selectedSlotId), enabled: hasOnline && Boolean(accountId), neighbors: { left: "upload", right: "copy", up: "slot-2" } },
{ id: "copy", run: () => openSaveDialog("copy"), enabled: hasLocal, neighbors: { left: "download", right: "delete", up: "slot-2" } },
{ id: "delete", run: () => openSaveDialog("delete"), enabled: hasLocal, neighbors: { left: "copy", right: "back", up: "slot-3" } },
{ id: "back", run: () => navigate("login"), neighbors: { left: "delete", up: "slot-3" } },
], [accountId, continuation, copySlot, createSlot, deleteSlot, dialog, downloadSlot, hasLocal, hasOnline, hunterName, navigate, playSlot, primaryActionId, resolvingOnline, selectSlot, selectedSlotId, slots, uploadSlot]);
const controller = useMenuController(actions, { onBack: () => dialog ? resolvingOnline ? undefined : setDialog(null) : navigate("login") });
const cloudStatus = !accountId ? "Offline mode" : selected.online ? "Online version available" : "No online version";
const cloudStatus = continuation === "choose" ? "Newer online save" : !accountId ? "Offline mode" : selected.online ? "Online version available" : "No online version";
return (
<DualDisplayFrame
top={
@@ -256,10 +347,47 @@ function SaveScreen() {
/>
))}
</div>
<div className="save-footer"><span>Autosave <b>OFFLINE FIRST</b></span><ControllerLegend back /></div>
<div className="save-top-actions">
<FocusButton id={primaryActionId} focusedId={controller.focusedId} focus={controller.focus} className="front-primary" disabled={resolvingOnline} onClick={continueSelected}>
<span>{continuation === "create" ? "Create hunter" : resolvingOnline ? "Loading online save…" : "Continue"}</span>
<small>{continuation === "create" ? `Use slot ${selectedSlotId}` : continuation === "online" ? "Download online copy" : continuation === "choose" ? "Choose online or device copy" : `Slot ${selectedSlotId} · ${selected.local?.hunterName}`}</small>
</FocusButton>
<FocusButton id="upload" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}><strong>Upload</strong><small>Device server</small></FocusButton>
<FocusButton id="download" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasOnline || !accountId} onClick={() => downloadSlot(selectedSlotId)}><strong>Download</strong><small>Server device</small></FocusButton>
<FocusButton id="copy" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} onClick={() => openSaveDialog("copy")}><strong>Copy</strong><small>Duplicate save</small></FocusButton>
<FocusButton id="delete" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} className="danger-link" onClick={() => openSaveDialog("delete")}><strong>Delete</strong><small>Erase device copy</small></FocusButton>
<FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("login")}><strong>Back</strong><small>Login screen</small></FocusButton>
</div>
<div className="save-footer"><span>Autosave <b>OFFLINE FIRST</b></span><div className="save-top-notice" role="status" aria-live="polite">{notice || "Lower display shows selected save details."}</div><ControllerLegend back /></div>
{dialog && (
<div className="front-dialog" role="dialog" aria-modal="true" aria-label={dialog === "create" ? "Name new hunter" : dialog === "copy" ? "Copy save" : "Delete save"}>
{dialog === "create" ? (
<div className={`front-dialog ${dialog === "version" ? "version-dialog" : ""}`} role="dialog" aria-modal="true" aria-label={dialog === "version" ? "Choose save version" : dialog === "create" ? "Name new hunter" : dialog === "copy" ? "Copy save" : "Delete save"}>
{dialog === "version" && selected.local && selected.online ? (
<div className="version-choice-dialog">
<span>Newer online save found</span>
<h2>Which save do you want?</h2>
<p>Online choice replaces this device copy. Device choice keeps online copy unchanged.</p>
<div className="version-comparison">
<article className="is-newer">
<header><span>Online copy</span><b>NEWER</b></header>
<strong>{selected.online.hunterName}</strong>
<time>{formatSaveTimestamp(selected.online.updatedAt)}</time>
<small>{formatPlayTime(selected.online.playSeconds)} · Level {selected.online.healers[selected.online.activeClassId].level}</small>
</article>
<article>
<header><span>Device copy</span><b>OFFLINE</b></header>
<strong>{selected.local.hunterName}</strong>
<time>{formatSaveTimestamp(selected.local.updatedAt)}</time>
<small>{formatPlayTime(selected.local.playSeconds)} · Level {selected.local.healers[selected.local.activeClassId].level}</small>
</article>
</div>
<div className="dialog-actions version-actions">
<FocusButton id="version-online" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" disabled={resolvingOnline} onClick={() => { void continueWithOnline(); }}><span>{resolvingOnline ? "Loading…" : "Continue online copy"}</span><small>{formatSaveTimestamp(selected.online.updatedAt)}</small></FocusButton>
<FocusButton id="version-local" focusedId={controller.focusedId} focus={controller.focus} disabled={resolvingOnline} onClick={() => { setDialog(null); playSlot(selectedSlotId); }}><span>Continue device copy</span><small>{formatSaveTimestamp(selected.local.updatedAt)}</small></FocusButton>
<FocusButton id="cancel-version" focusedId={controller.focusedId} focus={controller.focus} disabled={resolvingOnline} onClick={() => setDialog(null)}>Cancel</FocusButton>
</div>
{versionError && <div className="version-choice-error" role="alert">{versionError}</div>}
</div>
) : dialog === "create" ? (
<form onSubmit={(event) => { event.preventDefault(); finishCreation(); }}>
<span>New offline save</span><h2>Name your hunter</h2><p>This name identifies the character in local and online save lists.</p>
<label htmlFor="new-hunter-name">Hunter name</label>
@@ -304,31 +432,37 @@ function SaveScreen() {
</FrontSurface>
}
bottom={
<FrontSurface className="save-context" bottom ariaLabel="Selected save management">
<FrontSurface className="save-context" bottom ariaLabel="Selected save information">
<header className="context-header"><span>Slot {selectedSlotId}</span><b>{cloudStatus}</b></header>
<div className="selected-save-summary">
{selected.local ? (
<><div className="summary-avatar">{selected.local.hunterName[0]}</div><span><small>Local record</small><h2>{selected.local.hunterName}</h2><p>{selected.local.location} · {formatPlayTime(selected.local.playSeconds)}</p><time>{formatSaveTimestamp(selected.local.updatedAt)}</time></span></>
{selected.local ?? selected.online ? (
<><div className="summary-avatar">{(selected.local ?? selected.online)!.hunterName[0]}</div><span><small>{selected.local ? "Device save" : "Online copy only"}</small><h2>{(selected.local ?? selected.online)!.hunterName}</h2><p>{(selected.local ?? selected.online)!.location}</p><time>{formatSaveTimestamp((selected.local ?? selected.online)!.updatedAt)}</time></span></>
) : (
<><div className="summary-avatar is-empty"></div><span><small>Local record</small><h2>Empty slot</h2><p>Create a hunter or download an online version.</p></span></>
)}
</div>
{selected.online && <div className="online-record"><span><b>ONLINE</b>{selected.online.hunterName}</span><time>{formatSaveTimestamp(selected.online.updatedAt)}</time></div>}
<div className="save-actions">
<FocusButton id={hasLocal ? "play" : "create"} focusedId={controller.focusedId} focus={controller.focus} className="front-primary" onClick={() => hasLocal ? playSlot(selectedSlotId) : openCreation()}>
{hasLocal ? "Continue offline save" : "Create new hunter"}<small>A</small>
</FocusButton>
<div className="sync-actions">
<FocusButton id="upload" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}> Sync offline to server</FocusButton>
<FocusButton id="download" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasOnline || !accountId} onClick={() => downloadSlot(selectedSlotId)}> Overwrite with online</FocusButton>
</div>
<div className="record-actions">
<FocusButton id="copy" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} onClick={() => openSaveDialog("copy")}>Copy save</FocusButton>
<FocusButton id="delete" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} className="danger-link" onClick={() => openSaveDialog("delete")}>Delete save</FocusButton>
<FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("login")}>Back</FocusButton>
</div>
{(selected.local ?? selected.online) && (() => {
const save = (selected.local ?? selected.online)!;
const healer = HEALER_CLASSES[save.activeClassId];
return (
<>
<div className="save-dossier-stats">
<span><small>Active healer</small><strong>Lv {save.healers[save.activeClassId].level}</strong><em>{healer.name}</em></span>
<span><small>Play time</small><strong>{formatPlayTime(save.playSeconds)}</strong><em>Local activity</em></span>
<span><small>Boss kills</small><strong>{save.stats.totalBossKills}</strong><em>{save.stats.flawlessClears} flawless</em></span>
</div>
<div className="save-dossier-records">
<span><small>Roguelike best</small><b>Round {save.stats.highestRoguelikeRound}</b></span>
<span><small>Endless best</small><b>{save.stats.highestRogueTrialsEndlessKills} kills</b></span>
</div>
</>
);
})()}
<div className="save-copy-state">
<span><i className={selected.local ? "is-present" : ""} />Device copy<b>{selected.local ? formatSaveTimestamp(selected.local.updatedAt) : "Not present"}</b></span>
<span><i className={selected.online ? "is-present" : ""} />Online copy<b>{selected.online ? formatSaveTimestamp(selected.online.updatedAt) : accountId ? "Not uploaded" : "Sign-in required"}</b></span>
</div>
<div className="front-notice is-lower">{notice || "All gameplay changes save to local storage automatically."}</div>
<div className="front-notice is-lower">{notice || "Use upper display for every save action. Details here follow selected slot."}</div>
</FrontSurface>
}
/>
@@ -337,6 +471,7 @@ function SaveScreen() {
const HOME_MODES: { id: GameModeId; icon: string; label: string; copy: string }[] = [
{ id: "roguelike-pve", icon: "✦", label: "PVE", copy: "Randomized roguelike runs" },
{ id: "rogue-trials", icon: "Ⅲ", label: "Rogue Trials", copy: "Four rounds, then a boss trio" },
{ id: "dungeons", icon: "♜", label: "Dungeons", copy: "Choose your boss encounter" },
{ id: "roguelike-pvp", icon: "⚔", label: "Roguelike PvP", copy: "Draft, race, sabotage" },
{ id: "stadium-pvp", icon: "◉", label: "Stadium PvP", copy: "Prepared 5v5 rounds" },
@@ -349,10 +484,11 @@ function HomeScreen() {
const selectHealerClass = useFrontendStore((state) => state.selectHealerClass);
const navigate = useFrontendStore((state) => state.navigate);
const actions = useMemo<MenuAction[]>(() => [
{ id: "roguelike-pve", run: () => selectMode("roguelike-pve"), neighbors: { right: "dungeons", down: "roguelike-pvp" } },
{ id: "dungeons", run: () => selectMode("dungeons"), neighbors: { left: "roguelike-pve", down: "stadium-pvp" } },
{ id: "roguelike-pve", run: () => selectMode("roguelike-pve"), neighbors: { right: "rogue-trials", down: "roguelike-pvp" } },
{ id: "rogue-trials", run: () => selectMode("rogue-trials"), neighbors: { left: "roguelike-pve", right: "dungeons", down: "stadium-pvp" } },
{ id: "dungeons", run: () => selectMode("dungeons"), neighbors: { left: "rogue-trials", down: "stadium-pvp" } },
{ id: "roguelike-pvp", run: () => selectMode("roguelike-pvp"), neighbors: { left: "roguelike-pve", right: "stadium-pvp", up: "roguelike-pve", down: "profile" } },
{ id: "stadium-pvp", run: () => selectMode("stadium-pvp"), neighbors: { left: "roguelike-pvp", up: "dungeons", down: "settings" } },
{ id: "stadium-pvp", run: () => selectMode("stadium-pvp"), neighbors: { left: "roguelike-pvp", up: "rogue-trials", down: "settings" } },
{ id: "profile", run: () => navigate("profile"), neighbors: { up: "roguelike-pvp", right: "gear", down: "class-priest" } },
{ id: "gear", run: () => navigate("gear"), neighbors: { up: "roguelike-pvp", left: "profile", right: "settings", down: "class-druid" } },
{ id: "settings", run: () => navigate("settings"), neighbors: { up: "stadium-pvp", left: "gear", down: "class-shaman" } },
@@ -378,7 +514,6 @@ function HomeScreen() {
top={
<FrontSurface className="home-surface" ariaLabel="Main menu">
<header className="home-header"><BrandMark compact /><span>Welcome back, <b>{hunter.hunterName}</b></span><i>{accountId ? "● SYNC READY" : "○ OFFLINE"}</i></header>
<div className="home-title"><span>Choose your hunt</span><h1>Where are you needed?</h1></div>
<div className="mode-grid">
{HOME_MODES.map((mode) => (
<FocusButton key={mode.id} id={mode.id} focusedId={controller.focusedId} focus={controller.focus} className="mode-card" onClick={() => selectMode(mode.id)}>
@@ -420,39 +555,137 @@ function HomeScreen() {
);
}
type ProfileStatId = BossId | "roguelike" | "rogue-trials-endless";
function ProfileScreen() {
const hunter = useActiveHunter();
const accountId = useFrontendStore((state) => state.accountId);
const navigate = useFrontendStore((state) => state.navigate);
const collections = useMemo(() => hunter ? buildCollections(hunter.collectionLog, hunter.stats.bossKills) : [], [hunter]);
const [groupId, setGroupId] = useState(collections[0]?.groupId ?? "");
const [collectionView, setCollectionView] = useState<"loot" | "trophies" | "stats">("trophies");
const collection = collections.find((group) => group.groupId === groupId) ?? collections[0];
const [selectedStat, setSelectedStat] = useState<ProfileStatId>("roguelike");
const [leaderboard, setLeaderboard] = useState<LeaderboardResult | null>(null);
const [leaderboardStatus, setLeaderboardStatus] = useState("");
useEffect(() => {
if (selectedStat === "roguelike" || selectedStat === "rogue-trials-endless" || collection?.bosses.some((boss) => boss.bossId === selectedStat)) return;
setSelectedStat(collection?.bosses[0]?.bossId ?? "roguelike");
}, [collection, selectedStat]);
useEffect(() => {
if (!hunter || collectionView !== "stats") return;
if (!accountId) {
setLeaderboard(null);
setLeaderboardStatus("Sign in to view overall rankings.");
return;
}
let cancelled = false;
setLeaderboardStatus("Loading overall rankings…");
const request = selectedStat === "roguelike"
? onlineRepository.roguelikeLeaderboard(hunter.slotId)
: selectedStat === "rogue-trials-endless"
? onlineRepository.rogueTrialsEndlessLeaderboard(hunter.slotId)
: onlineRepository.bossLeaderboard(selectedStat, hunter.slotId);
void request.then((result) => {
if (cancelled) return;
setLeaderboard(result);
setLeaderboardStatus("");
}).catch((error) => {
if (cancelled) return;
setLeaderboard(null);
setLeaderboardStatus(error instanceof Error ? error.message : "Leaderboard unavailable.");
});
return () => { cancelled = true; };
}, [accountId, collectionView, hunter, selectedStat]);
const actions = useMemo<MenuAction[]>(() => [
{ id: "view-trophies", run: () => setCollectionView("trophies"), neighbors: { right: "view-stats" } },
{ id: "view-stats", run: () => setCollectionView("stats"), neighbors: { left: "view-trophies", right: "view-loot", down: collectionView === "stats" ? "stat-roguelike" : undefined } },
{ id: "view-loot", run: () => setCollectionView("loot"), neighbors: { left: "view-stats" } },
...(collectionView === "stats" ? [
{ id: "stat-roguelike", run: () => setSelectedStat("roguelike"), neighbors: { up: "view-stats", down: "stat-rogue-trials-endless" } },
{ id: "stat-rogue-trials-endless", run: () => setSelectedStat("rogue-trials-endless"), neighbors: { up: "stat-roguelike", down: `stat-${collection.bosses[0].bossId}` } },
...collection.bosses.map((boss, index) => ({
id: `stat-${boss.bossId}`,
run: () => setSelectedStat(boss.bossId),
neighbors: {
up: index === 0 ? "stat-rogue-trials-endless" : `stat-${collection.bosses[index - 1].bossId}`,
down: index === collection.bosses.length - 1 ? `group-${collection.groupId}` : `stat-${collection.bosses[index + 1].bossId}`,
},
})),
] : []),
...collections.map((group) => ({ id: `group-${group.groupId}`, run: () => setGroupId(group.groupId) })),
{ id: "back", run: () => navigate("home") },
], [collections, navigate]);
], [collection, collectionView, collections, navigate]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
if (!hunter || !collection) return null;
const activeHealer = HEALER_CLASSES[hunter.activeClassId];
const activeProgress = hunter.healers[hunter.activeClassId];
const earned = collection.drops.filter((drop) => drop.count > 0).length;
const trophiesEarned = collection.bosses.filter((boss) => boss.pet.count > 0).length;
const selectedStatValue = selectedStat === "roguelike"
? hunter.stats.highestRoguelikeRound
: selectedStat === "rogue-trials-endless"
? hunter.stats.highestRogueTrialsEndlessKills
: hunter.stats.bossKills[selectedStat] ?? 0;
const selectedStatLabel = selectedStat === "roguelike"
? "Roguelike rounds"
: selectedStat === "rogue-trials-endless"
? "Rogue Trials endless kills"
: BOSS_DEFINITIONS[selectedStat].name;
return (
<DualDisplayFrame
top={
<FrontSurface className="profile-surface" ariaLabel="Hunter profile collection log">
<header className="front-screen-header"><BrandMark compact /><div><span>Hunter profile</span><h1>Collection log</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<div className="collection-heading"><span><small>Shared group drops · Core: {collection.coreMechanic}</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div>
<div className="collection-grid">
{collection.drops.map((drop) => (
<article key={drop.id} className={`collection-drop rarity-${drop.rarity.toLowerCase()} ${drop.count === 0 ? "is-missing" : ""}`}>
<span className="drop-icon">{drop.icon}<b>{drop.count}</b></span>
<small>{drop.rarity}</small><strong>{drop.name}</strong>
<p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : `Defeat a Group ${collection.groupLetter} boss`}</p>
<small>{drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""}</small>
</article>
))}
</div>
<div className="collection-note"><i></i><span><strong>Boss pets stay individual.</strong><small>{collection.bosses.map((boss) => `${boss.bossName}: ${boss.kills} kills · ${boss.pet.count} pets`).join(" · ")}</small></span></div>
<header className="front-screen-header profile-header"><BrandMark compact /><div><span>Hunter profile</span><h1>Collection log</h1></div><div className="profile-view-tabs" role="tablist" aria-label="Collection view">
<FocusButton id="view-trophies" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "trophies" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "trophies"} onClick={() => setCollectionView("trophies")}>Trophy Case</FocusButton>
<FocusButton id="view-stats" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "stats" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "stats"} onClick={() => setCollectionView("stats")}>Boss Stats</FocusButton>
<FocusButton id="view-loot" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "loot" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "loot"} onClick={() => setCollectionView("loot")}>Group Loot</FocusButton>
</div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
{collectionView === "loot" ? <>
<div className="collection-heading"><span><small>Shared group drops · Core: {collection.coreMechanic}</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div>
<div className="collection-grid">
{collection.drops.map((drop) => (
<article key={drop.id} className={`collection-drop rarity-${drop.rarity.toLowerCase()} ${drop.count === 0 ? "is-missing" : ""}`}>
<span className="drop-icon">{drop.icon}<b>{drop.count}</b></span>
<small>{drop.rarity}</small><strong>{drop.name}</strong>
<p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : `Defeat a Group ${collection.groupLetter} boss`}</p>
<small>{drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""}</small>
</article>
))}
</div>
<div className="collection-note"><i></i><span><strong>Boss pets stay individual.</strong><small>Open Trophy Case to inspect every guardian pet.</small></span></div>
</> : collectionView === "trophies" ? <>
<div className="collection-heading trophy-heading"><span><small>Boss pets · 1 in 500 per victory</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{trophiesEarned} / {collection.bosses.length} trophies lit</b></div>
<div className={`trophy-case trophy-count-${collection.bosses.length}`}>
{collection.bosses.map((boss) => {
const owned = boss.pet.count > 0;
return <article key={boss.bossId} className={`boss-trophy ${owned ? "is-owned" : "is-locked"}`} style={{ "--boss-accent": BOSS_DEFINITIONS[boss.bossId].accent } as React.CSSProperties}>
<Suspense fallback={<div className="trophy-portrait trophy-portrait-fallback" role="img" aria-label={`${boss.bossName} portrait`}><span>{BOSS_DEFINITIONS[boss.bossId].icon}</span></div>}><BossTrophyPortrait bossId={boss.bossId} /></Suspense>
<div className="trophy-plaque"><small>{owned ? "Pet secured" : "Pet undiscovered"}</small><strong>{boss.bossName}</strong><span>{boss.kills} kills · {boss.pet.chance}</span></div>
<b className="trophy-state">{owned ? `Owned${boss.pet.count > 1 ? ` ×${boss.pet.count}` : ""}` : "Locked"}</b>
</article>;
})}
</div>
<div className="collection-note trophy-note"><i></i><span><strong>Each guardian keeps its own trophy.</strong><small>Defeat that boss for a 1 in 500 pet roll.</small></span></div>
</> : <>
<div className="collection-heading boss-stats-heading"><span><small>Lifetime records · Overall leaderboards</small><h2>Boss Stats</h2></span><b>Endless best {hunter.stats.highestRogueTrialsEndlessKills} kills</b></div>
<div className="boss-stats-layout">
<section className="boss-stat-selector" aria-label="Boss statistic selection">
<FocusButton id="stat-roguelike" focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === "roguelike" ? "is-selected" : ""} onClick={() => setSelectedStat("roguelike")}><i></i><span><strong>Roguelike</strong><small>Highest round before defeat</small></span><b>{hunter.stats.highestRoguelikeRound}</b></FocusButton>
<FocusButton id="stat-rogue-trials-endless" focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === "rogue-trials-endless" ? "is-selected" : ""} onClick={() => setSelectedStat("rogue-trials-endless")}><i></i><span><strong>Trials Endless</strong><small>Most bosses in one run</small></span><b>{hunter.stats.highestRogueTrialsEndlessKills}</b></FocusButton>
{collection.bosses.map((boss) => <FocusButton key={boss.bossId} id={`stat-${boss.bossId}`} focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === boss.bossId ? "is-selected" : ""} onClick={() => setSelectedStat(boss.bossId)}><i>{BOSS_DEFINITIONS[boss.bossId].icon}</i><span><strong>{boss.bossName}</strong><small>Lifetime boss kills</small></span><b>{boss.kills}</b></FocusButton>)}
</section>
<section className="leaderboard-panel" aria-label="Overall leaderboard">
<header><span><small>Overall Top 5</small><strong>{selectedStatLabel}</strong></span><b>{selectedStatValue} {selectedStat === "roguelike" ? "round" : "kills"}</b></header>
{leaderboardStatus ? <div className="leaderboard-status">{leaderboardStatus}</div> : <div className="leaderboard-rows">
{leaderboard?.top.length ? leaderboard.top.map((entry) => <div key={`${entry.username}-${entry.slotId}`} className={entry.username === accountId && entry.slotId === hunter.slotId ? "is-you" : ""}><b>#{entry.rank}</b><span><strong>{entry.hunterName}</strong><small>{entry.username}</small></span><em>{entry.value}</em></div>) : <div className="leaderboard-empty">No ranked hunters yet.</div>}
</div>}
<div className="leaderboard-self"><b>{leaderboard?.current ? `#${leaderboard.current.rank}` : "—"}</b><span><strong>Your rank · {hunter.hunterName}</strong><small>{accountId ?? "Offline hunter"}</small></span><em>{selectedStatValue}</em></div>
</section>
</div>
<div className="collection-note trophy-note"><i></i><span><strong>Rankings update with server saves.</strong><small>Top five always shown; your row stays visible at any rank.</small></span></div>
</>}
</FrontSurface>
}
bottom={
@@ -463,6 +696,8 @@ function ProfileScreen() {
<span><small>Flawless clears</small><strong>{hunter.stats.flawlessClears}</strong></span>
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
<span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span>
<span><small>Highest roguelike round</small><strong>{hunter.stats.highestRoguelikeRound}</strong></span>
<span><small>Endless best</small><strong>{hunter.stats.highestRogueTrialsEndlessKills}</strong></span>
</div>
<div className="boss-log"><span>Mechanic groups</span>{collections.map((group) => (
<FocusButton key={group.groupId} id={`group-${group.groupId}`} focusedId={controller.focusedId} focus={controller.focus} className={group.groupId === collection.groupId ? "is-selected" : ""} onClick={() => setGroupId(group.groupId)}>
@@ -483,17 +718,35 @@ function GearScreen() {
const selectedSlotId = useFrontendStore((state) => state.selectedGearSlotId);
const workshopMode = useFrontendStore((state) => state.gearWorkshopMode);
const selectedInfusionId = useFrontendStore((state) => state.selectedInfusionId);
const selectedPassiveAbilityId = useFrontendStore((state) => state.selectedPassiveAbilityId);
const selectedPassiveInfusionId = useFrontendStore((state) => state.selectedPassiveInfusionId);
const selectOwner = useFrontendStore((state) => state.selectGearOwner);
const selectSlot = useFrontendStore((state) => state.selectGearSlot);
const selectWorkshopMode = useFrontendStore((state) => state.selectGearWorkshopMode);
const selectInfusion = useFrontendStore((state) => state.selectInfusion);
const selectPassiveAbility = useFrontendStore((state) => state.selectPassiveAbility);
const selectPassiveInfusion = useFrontendStore((state) => state.selectPassiveInfusion);
const upgrade = useFrontendStore((state) => state.upgradeSelectedGear);
const installInfusion = useFrontendStore((state) => state.equipSelectedInfusion);
const installPassive = useFrontendStore((state) => state.equipPassiveInfusion);
const slot = hunter?.gearProgress[selectedOwnerId].slots[selectedSlotId];
const upgradeReadiness = useMemo(() => {
const owners = new Set<GearOwnerId>();
const slots = new Set<string>();
if (!hunter) return { owners, slots };
for (const ownerId of GEAR_OWNER_ORDER) {
for (const slotId of GEAR_SLOT_ORDER) {
if (!canUpgradeGearSlot(hunter.gearProgress, hunter.materials, ownerId, slotId)) continue;
owners.add(ownerId);
slots.add(`${ownerId}:${slotId}`);
}
}
return { owners, slots };
}, [hunter]);
const recipe = GEAR_RECIPES[selectedOwnerId][selectedSlotId];
const costs = hunter && slot ? gearUpgradeCosts(selectedOwnerId, selectedSlotId, slot.level) : [];
const canUpgrade = Boolean(hunter && slot && slot.level < MAX_GEAR_LEVEL && canAffordGearUpgrade(hunter.materials, costs));
const canUpgrade = upgradeReadiness.slots.has(`${selectedOwnerId}:${selectedSlotId}`);
const infusionChoices = infusionsForOwner(selectedOwnerId);
const selectedInfusion = infusionChoices.find((choice) => choice.id === selectedInfusionId) ?? infusionChoices[0];
const selectedInfusionCosts = hunter ? infusionCosts(selectedOwnerId, selectedSlotId, selectedInfusion.id) : [];
@@ -503,6 +756,10 @@ function GearScreen() {
const canInstallInfusion = Boolean(hunter && activeUnlocked && anchorUnlocked && !infusionEquipped && canAffordGearUpgrade(hunter.materials, selectedInfusionCosts));
const passiveUnlocked = Boolean(hunter && passiveInfusionUnlocked(hunter.gearProgress));
const healerOwner = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman";
const passiveHealerClassId = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman" ? selectedOwnerId : "priest";
const passiveChoices = PASSIVE_INFUSIONS.filter((passive) => passive.abilityId === selectedPassiveAbilityId);
const selectedPassive = RUN_BUFFS[selectedPassiveInfusionId];
const healerAbilities = HEALER_CLASSES[passiveHealerClassId].abilities;
const previewEntryId = workshopMode === "upgrade" ? "upgrade" : `infusion-${infusionChoices[0].id}`;
const actions = useMemo<MenuAction[]>(() => [
...GEAR_OWNER_ORDER.map((ownerId, index) => ({
@@ -518,7 +775,7 @@ function GearScreen() {
id: `slot-${slotId}`,
run: () => selectSlot(slotId),
neighbors: {
up: index === 0 ? "back" : `slot-${GEAR_SLOT_ORDER[index - 1]}`,
up: index === 0 ? "workshop-upgrade" : `slot-${GEAR_SLOT_ORDER[index - 1]}`,
down: index === GEAR_SLOT_ORDER.length - 1 ? previewEntryId : `slot-${GEAR_SLOT_ORDER[index + 1]}`,
left: `owner-${selectedOwnerId}`,
right: previewEntryId,
@@ -531,25 +788,42 @@ function GearScreen() {
run: () => selectInfusion(infusion.id),
neighbors: {
up: index === 0 ? "workshop-infusion" : `infusion-${infusionChoices[index - 1].id}`,
down: index === infusionChoices.length - 1 ? (healerOwner ? `passive-${PASSIVE_INFUSIONS[0].id}` : "install-infusion") : `infusion-${infusionChoices[index + 1].id}`,
down: index === infusionChoices.length - 1 ? (healerOwner ? `passive-ability-${selectedPassiveAbilityId}` : "install-infusion") : `infusion-${infusionChoices[index + 1].id}`,
left: `slot-${selectedSlotId}`,
right: index === infusionChoices.length - 1 ? "install-infusion" : undefined,
},
})),
...(healerOwner ? PASSIVE_INFUSIONS.map((passive, index) => ({
...(healerOwner ? ABILITY_ORDER.map((abilityId, index) => ({
id: `passive-ability-${abilityId}`,
run: () => selectPassiveAbility(abilityId),
neighbors: {
up: index < 3 ? `infusion-${infusionChoices[infusionChoices.length - 1].id}` : `passive-ability-${ABILITY_ORDER[index - 3]}`,
down: index < 3
? `passive-ability-${ABILITY_ORDER[index + 3]}`
: `passive-${passiveChoices[Math.min(index - 3, passiveChoices.length - 1)].id}`,
left: index % 3 > 0 ? `passive-ability-${ABILITY_ORDER[index - 1]}` : `slot-${selectedSlotId}`,
right: index % 3 < 2 ? `passive-ability-${ABILITY_ORDER[index + 1]}` : undefined,
},
})) : []),
...(healerOwner ? passiveChoices.map((passive, index) => ({
id: `passive-${passive.id}`,
run: () => installPassive(passive.id),
run: () => {
selectPassiveInfusion(passive.id);
installPassive(passive.id);
},
enabled: passiveUnlocked,
neighbors: {
up: index === 0 ? `infusion-${infusionChoices[infusionChoices.length - 1].id}` : `passive-${PASSIVE_INFUSIONS[index - 1].id}`,
down: index === PASSIVE_INFUSIONS.length - 1 ? "install-infusion" : `passive-${PASSIVE_INFUSIONS[index + 1].id}`,
up: index === 0 ? `passive-ability-${selectedPassiveAbilityId}` : `passive-${passiveChoices[index - 1].id}`,
down: index === passiveChoices.length - 1 ? `passive-ability-${selectedPassiveAbilityId}` : `passive-${passiveChoices[index + 1].id}`,
left: `slot-${selectedSlotId}`,
},
})) : []),
{ id: "upgrade", run: upgrade, enabled: canUpgrade, neighbors: { left: `slot-${selectedSlotId}`, up: `slot-${selectedSlotId}` } },
{ id: "install-infusion", run: installInfusion, enabled: canInstallInfusion, neighbors: { left: `slot-${selectedSlotId}`, up: healerOwner ? `passive-${PASSIVE_INFUSIONS[PASSIVE_INFUSIONS.length - 1].id}` : `infusion-${infusionChoices[infusionChoices.length - 1].id}` } },
{ id: "back", run: () => navigate("home"), neighbors: { down: `owner-${GEAR_OWNER_ORDER[0]}` } },
], [canInstallInfusion, canUpgrade, healerOwner, infusionChoices, installInfusion, installPassive, navigate, passiveUnlocked, previewEntryId, selectInfusion, selectOwner, selectSlot, selectWorkshopMode, selectedOwnerId, selectedSlotId, upgrade]);
{ id: "install-infusion", run: installInfusion, enabled: canInstallInfusion, neighbors: { left: `slot-${selectedSlotId}`, up: `infusion-${infusionChoices[infusionChoices.length - 1].id}`, down: healerOwner ? `passive-ability-${selectedPassiveAbilityId}` : undefined } },
{ id: "back", run: () => navigate("home"), neighbors: { left: "workshop-infusion", down: `owner-${GEAR_OWNER_ORDER[0]}` } },
], [canInstallInfusion, canUpgrade, healerOwner, infusionChoices, installInfusion, installPassive, navigate, passiveChoices, passiveUnlocked, previewEntryId, selectInfusion, selectOwner, selectPassiveAbility, selectPassiveInfusion, selectSlot, selectWorkshopMode, selectedOwnerId, selectedPassiveAbilityId, selectedSlotId, upgrade]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
const passiveContext = workshopMode === "infusion" && healerOwner && controller.focusedId.startsWith("passive-");
if (!hunter || !slot) return null;
const currentBonus = gearBonusText(recipe.statId, slot.level);
const nextBonus = gearBonusText(recipe.statId, Math.min(MAX_GEAR_LEVEL, slot.level + 1));
@@ -558,19 +832,21 @@ function GearScreen() {
<DualDisplayFrame
top={
<FrontSurface className="gear-surface" ariaLabel="Gear upgrade workshop">
<header className="front-screen-header"><BrandMark compact /><div><span>Group drop workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<header className="front-screen-header"><BrandMark compact /><div><span>Group drop workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
<div className="gear-workshop-layout">
<section className="gear-owner-list" aria-label="Party gear owners">
{GEAR_OWNER_ORDER.map((ownerId) => {
const highest = Math.max(...GEAR_SLOT_ORDER.map((slotId) => hunter.gearProgress[ownerId].slots[slotId].level));
return <FocusButton key={ownerId} id={`owner-${ownerId}`} focusedId={controller.focusedId} focus={controller.focus} className={ownerId === selectedOwnerId ? "is-selected" : ""} onClick={() => selectOwner(ownerId)}><span><strong>{GEAR_OWNER_LABELS[ownerId]}</strong><small>Highest slot +{highest}</small></span><b>{ownerId === selectedOwnerId ? "✓" : ""}</b></FocusButton>;
const upgradeReady = upgradeReadiness.owners.has(ownerId);
return <FocusButton key={ownerId} id={`owner-${ownerId}`} focusedId={controller.focusedId} focus={controller.focus} aria-label={`${GEAR_OWNER_LABELS[ownerId]}${upgradeReady ? ", upgrade available" : ""}`} className={`${ownerId === selectedOwnerId ? "is-selected" : ""} ${upgradeReady ? "is-upgrade-ready" : ""}`} onClick={() => selectOwner(ownerId)}><span><strong>{GEAR_OWNER_LABELS[ownerId]}</strong><small>Highest slot +{highest}</small></span><b>{ownerId === selectedOwnerId ? "✓" : ""}</b></FocusButton>;
})}
</section>
<section className="gear-slot-list" aria-label={`${GEAR_OWNER_LABELS[selectedOwnerId]} gear slots`}>
{GEAR_SLOT_ORDER.map((slotId) => {
const progress = hunter.gearProgress[selectedOwnerId].slots[slotId];
const slotRecipe = GEAR_RECIPES[selectedOwnerId][slotId];
return <FocusButton key={slotId} id={`slot-${slotId}`} focusedId={controller.focusedId} focus={controller.focus} className={slotId === selectedSlotId ? "is-selected" : ""} onClick={() => selectSlot(slotId)}><i>{slotId === "weapon" ? "⚔" : slotId === "helmet" ? "♙" : slotId === "chest" ? "▧" : slotId === "legs" ? "Ⅱ" : "⌁"}</i><span><strong>{GEAR_SLOT_LABELS[slotId]}</strong><small>{GEAR_STAT_LABELS[slotRecipe.statId]}</small></span><b>+{progress.level}</b></FocusButton>;
const upgradeReady = upgradeReadiness.slots.has(`${selectedOwnerId}:${slotId}`);
return <FocusButton key={slotId} id={`slot-${slotId}`} focusedId={controller.focusedId} focus={controller.focus} aria-label={`${GEAR_SLOT_LABELS[slotId]} +${progress.level}${upgradeReady ? ", upgrade available" : ""}`} className={`${slotId === selectedSlotId ? "is-selected" : ""} ${upgradeReady ? "is-upgrade-ready" : ""}`} onClick={() => selectSlot(slotId)}><i>{slotId === "weapon" ? "⚔" : slotId === "helmet" ? "♙" : slotId === "chest" ? "▧" : slotId === "legs" ? "Ⅱ" : "⌁"}</i><span><strong>{GEAR_SLOT_LABELS[slotId]}</strong><small>{GEAR_STAT_LABELS[slotRecipe.statId]}</small></span><b>+{progress.level}</b></FocusButton>;
})}
</section>
{workshopMode === "upgrade" ? <article className="gear-preview">
@@ -585,7 +861,25 @@ function GearScreen() {
<div className="gear-infusion-options">
{infusionChoices.map((infusion) => <FocusButton key={infusion.id} id={`infusion-${infusion.id}`} focusedId={controller.focusedId} focus={controller.focus} className={`${infusion.id === selectedInfusion.id ? "is-selected" : ""} ${hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "is-equipped" : ""}`} onClick={() => selectInfusion(infusion.id)}><i>{infusion.icon}</i><span><strong>{infusion.name}</strong><small>{infusion.description}</small></span><b>{hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "✓" : ""}</b></FocusButton>)}
</div>
{healerOwner && <div className="gear-passive-options"><span>Passive · global +{PASSIVE_INFUSION_MIN_GEAR_LEVEL}</span>{PASSIVE_INFUSIONS.map((passive) => <FocusButton key={passive.id} id={`passive-${passive.id}`} focusedId={controller.focusedId} focus={controller.focus} disabled={!passiveUnlocked} className={hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "is-equipped" : ""} onClick={() => installPassive(passive.id)}><i>{passive.icon}</i><span><strong>{passive.name}</strong><small>{passive.summary}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""}</b></FocusButton>)}</div>}
{healerOwner && <div className="gear-passive-options">
<span>Passive blessing · global +{PASSIVE_INFUSION_MIN_GEAR_LEVEL}</span>
<div className="gear-passive-ability-filter">
{ABILITY_ORDER.map((abilityId) => <FocusButton key={abilityId} id={`passive-ability-${abilityId}`} focusedId={controller.focusedId} focus={controller.focus} className={selectedPassiveAbilityId === abilityId ? "is-selected" : ""} onClick={() => selectPassiveAbility(abilityId)}>{healerAbilities[abilityId].shortName}</FocusButton>)}
</div>
<div className="gear-passive-choice-list">
{passiveChoices.map((passive) => <FocusButton
key={passive.id}
id={`passive-${passive.id}`}
focusedId={controller.focusedId}
focus={controller.focus}
disabled={!passiveUnlocked}
className={`${selectedPassiveInfusionId === passive.id ? "is-selected" : ""} ${hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "is-equipped" : ""}`}
onFocus={() => selectPassiveInfusion(passive.id)}
onPointerEnter={() => selectPassiveInfusion(passive.id)}
onClick={() => { selectPassiveInfusion(passive.id); installPassive(passive.id); }}
><i>{passive.icon}</i><span><strong>{healerAbilities[passive.abilityId].shortName}: {passive.name}</strong><small>{formatRunBuffEffect(passive.id, 1)}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""}</b></FocusButton>)}
</div>
</div>}
</article>}
</div>
<ControllerLegend back />
@@ -593,15 +887,15 @@ function GearScreen() {
}
bottom={
<FrontSurface className="gear-context" bottom ariaLabel="Gear recipe and material inventory">
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} DROPS</b></header>
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : passiveContext ? `${healerAbilities[selectedPassive.abilityId].name}: ${selectedPassive.name}` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} DROPS</b></header>
<div className="gear-costs">
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
{(workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => {
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : passiveContext ? "Passive blessing · Rank 1" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
{passiveContext ? <article className={passiveUnlocked ? "is-met" : "is-missing"}><i>{passiveUnlocked ? "✓" : "×"}</i><span><strong>{formatRunBuffEffect(selectedPassive.id, 1)}</strong><small>{selectedPassive.detail}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "EQUIPPED" : "RANK 1"}</b></article> : (workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => {
const owned = hunter.materials.find((item) => item.id === cost.itemId)?.quantity ?? 0;
return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>;
}) : <article className="is-met"><i></i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>}
</div>
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : passiveContext ? <div className="gear-passive-context-action"><span>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : `${DEFAULT_CONTROLLER_GLYPHS.confirm} · Equip selected passive`}</span><small>Applies at rank 1 next encounter.</small></div> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
</FrontSurface>
}
@@ -632,7 +926,7 @@ function SettingsScreen() {
<DualDisplayFrame
top={
<FrontSurface className="settings-surface" ariaLabel="Settings">
<header className="front-screen-header"><BrandMark compact /><div><span>Field configuration</span><h1>Settings</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<header className="front-screen-header"><BrandMark compact /><div><span>Field configuration</span><h1>Settings</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
<div className="settings-layout">
<section><span className="settings-section-title">Audio</span><div className="volume-setting"><span><strong>Master volume</strong><small>All music, effects, and voice</small></span><div><FocusButton id="volume-down" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}></FocusButton><b>{settings.masterVolume}%</b><FocusButton id="volume-up" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}></FocusButton></div><i><em style={{ width: `${settings.masterVolume}%` }} /></i></div></section>
<section><span className="settings-section-title">Display & accessibility</span><SettingToggle id="motion" label="Reduced motion" copy="Limit non-essential UI movement" value={settings.reducedMotion} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("reducedMotion", !settings.reducedMotion)} /><SettingToggle id="numbers" label="Damage numbers" copy="Show combat values over units" value={settings.damageNumbers} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("damageNumbers", !settings.damageNumbers)} /><SettingToggle id="text" label="Large interface text" copy="Increase menu and tactical labels" value={settings.largeText} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("largeText", !settings.largeText)} /></section>
@@ -645,9 +939,9 @@ function SettingsScreen() {
<header className="context-header"><span>Controller</span><b>BUILT-IN THOR PAD</b></header>
<div className="controller-map">
<div className="pad-diagram"><i></i><span><b></b></span><i></i></div>
<div className="face-diagram"><i className="y">Y</i><span><i className="x">X</i><b></b><i className="b">B</i></span><i className="a">A</i></div>
<div className="face-diagram"><i className="triangle">{DEFAULT_CONTROLLER_GLYPHS.faceTop}</i><span><i className="square">{DEFAULT_CONTROLLER_GLYPHS.faceLeft}</i><b></b><i className="circle">{DEFAULT_CONTROLLER_GLYPHS.faceRight}</i></span><i className="cross">{DEFAULT_CONTROLLER_GLYPHS.faceBottom}</i></div>
</div>
<div className="mapping-list"><span><b>A</b> Confirm / cast Purify</span><span><b>B</b> Back / cast Shield</span><span><b>D-Pad</b> Navigate / target party</span><span><b>Start</b> Pause / menu</span></div>
<div className="mapping-list"><span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Confirm / cast Purify</span><span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back / cast Shield</span><span><b>D-Pad</b> Navigate / target party</span><span><b>Right stick</b> Rotate camera</span><span><b>{DEFAULT_CONTROLLER_GLYPHS.start}</b> Pause / menu</span></div>
<div className="control-assurance"><i></i><span><strong>No click-to-focus required</strong><small>Controller input routes through app-level actions.</small></span></div>
</FrontSurface>
}
@@ -664,55 +958,62 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
const selectDifficulty = useFrontendStore((state) => state.selectDifficulty);
const navigate = useFrontendStore((state) => state.navigate);
const [message, setMessage] = useState("");
const bossPageSize = 12;
const [bossPage, setBossPage] = useState(() => Math.max(0, Math.floor(AVAILABLE_BOSS_IDS.indexOf(selectedBossId) / bossPageSize)));
const mode = MODE_COPY[modeId];
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
const progress = hunter?.healers[hunter.activeClassId];
const selectedBoss = BOSS_DEFINITIONS[selectedBossId];
const selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug];
const isPve = modeId === "roguelike-pve";
const isRogueTrials = modeId === "rogue-trials";
const isPveRun = isPve || isRogueTrials;
const isDungeon = modeId === "dungeons";
const bossPageCount = Math.ceil(AVAILABLE_BOSS_IDS.length / bossPageSize);
const visibleBossIds = AVAILABLE_BOSS_IDS.slice(bossPage * bossPageSize, (bossPage + 1) * bossPageSize);
const bossGridRows = Math.ceil(visibleBossIds.length / 3);
const bossGridColumns = Math.ceil(visibleBossIds.length / bossGridRows);
const changeBossPage = (nextPage: number) => {
const page = Math.max(0, Math.min(bossPageCount - 1, nextPage));
setBossPage(page);
selectBoss(AVAILABLE_BOSS_IDS[page * bossPageSize]);
const selectedBossGroup = BOSS_GROUP_BY_ID[selectedBoss.groupId];
const visibleBossIds = selectedBossGroup.bossIds;
const bossGridColumns = Math.min(2, visibleBossIds.length);
const selectBossGroup = (groupId: (typeof BOSS_GROUPS)[number]["id"]) => {
selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]);
};
const launch = () => {
if (isPve) return onLaunch(selectRandomBossPair(), "initiate");
if (isPveRun) return onLaunch(selectRandomBossPair(), "initiate");
if (isDungeon) return onLaunch([selectedBossId], selectedDifficultySlug);
setMessage("Online matchmaking connects here when game server is configured.");
setMessage("Online matchmaking is not available for this mode yet.");
};
const actions = useMemo<MenuAction[]>(() => [
...(isDungeon ? visibleBossIds.map((bossId, index) => {
const column = Math.floor(index / bossGridRows);
const row = index % bossGridRows;
const neighborInColumn = (targetColumn: number) => {
const columnStart = targetColumn * bossGridRows;
if (columnStart >= visibleBossIds.length || targetColumn < 0) return undefined;
const columnEnd = Math.min(columnStart + bossGridRows, visibleBossIds.length) - 1;
return `boss-${visibleBossIds[Math.min(columnStart + row, columnEnd)]}`;
...(isDungeon ? BOSS_GROUPS.map((group, index) => {
const groupColumns = 5;
const row = Math.floor(index / groupColumns);
const column = index % groupColumns;
const groupAt = (targetRow: number, targetColumn: number) => BOSS_GROUPS[targetRow * groupColumns + targetColumn];
return {
id: `boss-group-${group.id}`,
run: () => selectBossGroup(group.id),
neighbors: {
up: row > 0 ? `boss-group-${groupAt(row - 1, column)?.id}` : "back",
down: groupAt(row + 1, column)
? `boss-group-${groupAt(row + 1, column)?.id}`
: group.id === selectedBossGroup.id ? `boss-${selectedBossGroup.bossIds[0]}` : undefined,
left: column > 0 ? `boss-group-${groupAt(row, column - 1)?.id}` : undefined,
right: groupAt(row, column + 1) ? `boss-group-${groupAt(row, column + 1)?.id}` : undefined,
},
};
}) : []),
...(isDungeon ? visibleBossIds.map((bossId, index) => {
const row = Math.floor(index / bossGridColumns);
const column = index % bossGridColumns;
const bossAt = (targetRow: number, targetColumn: number) => visibleBossIds[targetRow * bossGridColumns + targetColumn];
return {
id: `boss-${bossId}`,
run: () => selectBoss(bossId),
neighbors: {
up: row > 0 ? `boss-${visibleBossIds[index - 1]}` : "back",
down: index + 1 < Math.min((column + 1) * bossGridRows, visibleBossIds.length)
? `boss-${visibleBossIds[index + 1]}`
: `difficulty-${DIFFICULTIES[0].slug}`,
left: neighborInColumn(column - 1) ?? (bossPage > 0 ? "boss-page-prev" : undefined),
right: neighborInColumn(column + 1) ?? (bossPage < bossPageCount - 1 ? "boss-page-next" : undefined),
up: row > 0 ? `boss-${bossAt(row - 1, column)}` : `boss-group-${selectedBossGroup.id}`,
down: bossAt(row + 1, column) ? `boss-${bossAt(row + 1, column)}` : `difficulty-${DIFFICULTIES[0].slug}`,
left: column > 0 ? `boss-${bossAt(row, column - 1)}` : undefined,
right: bossAt(row, column + 1) ? `boss-${bossAt(row, column + 1)}` : undefined,
},
};
}) : []),
...(isDungeon && bossPage > 0 ? [{ id: "boss-page-prev", run: () => changeBossPage(bossPage - 1), neighbors: { right: `boss-${visibleBossIds[0]}`, down: `boss-${visibleBossIds[0]}`, up: "back" } }] : []),
...(isDungeon && bossPage < bossPageCount - 1 ? [{ id: "boss-page-next", run: () => changeBossPage(bossPage + 1), neighbors: { left: `boss-${visibleBossIds[visibleBossIds.length - 1]}`, down: `boss-${visibleBossIds[0]}`, up: "back" } }] : []),
...(isDungeon ? DIFFICULTIES.map((difficulty, index) => ({
id: `difficulty-${difficulty.slug}`,
run: () => selectDifficulty(difficulty.slug),
@@ -724,16 +1025,22 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
},
})) : []),
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `difficulty-${DIFFICULTIES[DIFFICULTIES.length - 1].slug}` } : { up: "back" } },
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-${AVAILABLE_BOSS_IDS[0]}` } : { down: "launch" } },
], [bossGridRows, bossPage, bossPageCount, isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossId, selectedDifficultySlug, visibleBossIds]);
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-group-${selectedBossGroup.id}` } : { down: "launch" } },
], [bossGridColumns, isDungeon, isPveRun, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossGroup, selectedBossId, selectedDifficultySlug, visibleBossIds]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
const launchLabel = isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking";
const launchLabel = isRogueTrials ? "Begin Rogue Trials" : isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking";
const contextRules = isDungeon
? [
[selectedBoss.name, selectedBoss.summary],
[selectedBoss.mechanics[0], selectedBoss.briefing],
[selectedBoss.mechanics[1], "Controller-ready party behavior and full lower-display support."],
[bossMechanicName(selectedBoss.mechanicIds[0]), selectedBoss.briefing],
[bossMechanicName(selectedBoss.mechanicIds[1]), "Controller-ready party behavior and full lower-display support."],
]
: isRogueTrials
? [
["Four dual rounds", "Clear four randomized pairs while drafting one stacking buff after each win."],
["Unseen trio finale", "Round 5 selects three bosses that have not appeared earlier in that run."],
["Endless choice", "After the trio falls, quit with the clear or continue while every dead boss is replaced."],
]
: isPve
? [
["Randomized pair", "Two distinct bosses are selected only when the run begins."],
@@ -749,21 +1056,30 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
<DualDisplayFrame
top={
<FrontSurface className={`mode-surface mode-${modeId}`} ariaLabel={`${mode.title} details`}>
<header className="front-screen-header"><BrandMark compact /><div><span>Game mode</span><h1>{mode.title}</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<header className="front-screen-header"><BrandMark compact /><div><span>Game mode</span><h1>{mode.title}</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
{!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
{isDungeon && (
<div className="boss-picker" aria-label="Choose boss encounter">
<div className="boss-picker-heading">
<span>Choose encounter · Page {bossPage + 1}/{bossPageCount}</span>
<div>
<FocusButton id="boss-page-prev" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === 0} onClick={() => changeBossPage(bossPage - 1)}> Previous</FocusButton>
<FocusButton id="boss-page-next" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === bossPageCount - 1} onClick={() => changeBossPage(bossPage + 1)}>Next </FocusButton>
</div>
<div className="boss-picker-heading"><span>Choose a mechanic group</span></div>
<div className="boss-group-grid" aria-label="Choose boss group">
{BOSS_GROUPS.map((group) => (
<FocusButton
key={group.id}
id={`boss-group-${group.id}`}
focusedId={controller.focusedId}
focus={controller.focus}
className={`boss-group-choice ${group.id === selectedBossGroup.id ? "is-selected" : ""}`}
aria-pressed={group.id === selectedBossGroup.id}
onClick={() => selectBossGroup(group.id)}
>
<b>{group.letter}</b><span><strong>Group {group.letter}</strong><small>{group.name}</small></span>
</FocusButton>
))}
</div>
<div className="boss-choice-grid" style={{ "--boss-grid-rows": bossGridRows, "--boss-grid-columns": bossGridColumns } as React.CSSProperties}>
<div className="boss-group-heading"><span>Group {selectedBossGroup.letter} · {selectedBossGroup.name}</span><small>{selectedBossGroup.coreMechanic} mechanics · {visibleBossIds.length} guardians</small></div>
<div className="boss-choice-grid">
{visibleBossIds.map((bossId) => {
const boss = BOSS_DEFINITIONS[bossId];
const group = BOSS_GROUP_BY_ID[boss.groupId];
return (
<FocusButton
key={bossId}
@@ -775,7 +1091,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
aria-pressed={selectedBossId === bossId}
onClick={() => selectBoss(bossId)}
>
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>Group {group.letter} · {group.name} · {boss.mechanics[0]}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>{boss.mechanicIds.filter((id) => !bossMechanicIsPassive(id)).map(bossMechanicName).join(" · ")}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
</FocusButton>
);
})}
@@ -788,7 +1104,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
{DIFFICULTIES.map((difficulty) => <FocusButton key={difficulty.slug} id={`difficulty-${difficulty.slug}`} focusedId={controller.focusedId} focus={controller.focus} className={difficulty.slug === selectedDifficultySlug ? "is-selected" : ""} onClick={() => selectDifficulty(difficulty.slug)}><strong>{difficulty.name}</strong><small>iLvl {difficulty.itemLevel}</small></FocusButton>)}
</div>
)}
<FocusButton id="launch" focusedId={controller.focusedId} focus={controller.focus} className="mode-launch" onClick={launch}><span>{launchLabel}</span><small>{mode.status} · A</small></FocusButton>
<FocusButton id="launch" focusedId={controller.focusedId} focus={controller.focus} className="mode-launch" onClick={launch}><span>{launchLabel}</span><small>{mode.status} · {DEFAULT_CONTROLLER_GLYPHS.confirm}</small></FocusButton>
{message && <div className="front-notice">{message}</div>}
</FrontSurface>
}
+43
View File
@@ -0,0 +1,43 @@
import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { KTX2Loader } from "three-stdlib";
const BASIS_TRANSCODER_PATH = `${import.meta.env.BASE_URL}basis/`;
const query = typeof window === "undefined" ? null : new URLSearchParams(window.location.search);
export const LEGACY_GAME_ASSETS_FORCED = import.meta.env.VITE_LEGACY_GAME_ASSETS === "1"
|| import.meta.env.VITE_LEGACY_DUNGEON_ASSETS === "1"
|| query?.has("legacyGameAssets") === true
|| query?.has("legacyDungeonAssets") === true;
const Ktx2LoaderContext = createContext<KTX2Loader | null>(null);
export function selectedGameAssetUrl(legacyUrl: string, optimizedUrl: string) {
return LEGACY_GAME_ASSETS_FORCED ? legacyUrl : optimizedUrl;
}
export function GameAssetProvider({ children }: { children: ReactNode }) {
const gl = useThree((state) => state.gl);
const [ktx2Loader] = useState(() => LEGACY_GAME_ASSETS_FORCED
? null
: new KTX2Loader().setTranscoderPath(BASIS_TRANSCODER_PATH).detectSupport(gl));
useEffect(() => () => {
ktx2Loader?.dispose();
}, [ktx2Loader]);
return <Ktx2LoaderContext.Provider value={ktx2Loader}>{children}</Ktx2LoaderContext.Provider>;
}
export function useGameGLTF(url: string) {
const ktx2Loader = useContext(Ktx2LoaderContext);
return useGLTF(
url,
false,
true,
(loader) => {
if (ktx2Loader) loader.setKTX2Loader(ktx2Loader);
},
);
}
File diff suppressed because it is too large Load Diff
+26 -14
View File
@@ -5,6 +5,7 @@ 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";
const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })));
@@ -81,7 +82,7 @@ function BossBar() {
if (phase === "briefing") return null;
const bosses = [boss, ...additionalBosses.map((entry) => entry.boss)];
return (
<div className={`boss-bar-wrap ${bosses.length > 1 ? "is-dual" : ""}`}>
<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"><i style={{ width: `${(entry.hp / entry.maxHp) * 100}%` }} /></div>
@@ -108,6 +109,10 @@ function EncounterCallout() {
function PhaseOverlay() {
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
const round = useGameStore((state) => state.round);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const primaryBoss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
@@ -115,29 +120,34 @@ function PhaseOverlay() {
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
const room = bossRoomFor(primaryBoss.id);
const bossNames = bosses.map((boss) => boss.name).join(" & ");
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
const endlessDefeat = phase === "defeat" && endlessMode;
const briefingMode = runMode === "rogue-trials"
? 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
: phase === "victory"
? `${bossNames} Broken`
: "Party Broken";
? showEndlessChoice ? "Rogue Trials Cleared" : `${bossNames} Broken`
: endlessDefeat ? "Endless Run Ended" : "Party Broken";
const eyebrow = phase === "briefing"
? `${bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial} · ${room.biome}`
? `${briefingMode} · ${room.biome}`
: phase === "victory"
? "Encounter Complete"
: "Encounter Failed";
? showEndlessChoice ? "Endless Path Unlocked" : "Encounter Complete"
: endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed";
const copy = phase === "briefing"
? definitions.map((boss) => boss.briefing).join(" ")
: phase === "victory"
? "Five entered. Five endured."
: definitions.map((boss) => boss.failure).join(" ");
? 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(" ");
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" : "Restart from lower display"}</small>
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : "Restart from lower display"}</small>
</div>
);
}
@@ -164,15 +174,15 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) {
onFocus={() => setPauseSelection("resume")}
onPointerEnter={() => setPauseSelection("resume")}
onClick={() => setPaused(false)}
>Resume <small>START / ESC</small></button>
>Resume <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>
<button
className={`secondary ${selection === "exit" ? "is-controller-focused" : ""}`}
onFocus={() => setPauseSelection("exit")}
onPointerEnter={() => setPauseSelection("exit")}
onClick={exit}
>Return to main menu <small>A</small></button>
>Return to main menu <small>{DEFAULT_CONTROLLER_GLYPHS.confirm}</small></button>
</div>
<footer><b> / </b> Choose <i /> <b>A / ENTER</b> Confirm</footer>
<footer><b> / </b> Choose <i /> <b>{DEFAULT_CONTROLLER_GLYPHS.confirm} / ENTER</b> Confirm</footer>
</div>
</div>
);
@@ -183,6 +193,8 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
const round = useGameStore((state) => state.round);
const runMode = useGameStore((state) => state.runMode);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const setPaused = useGameStore((state) => state.setPaused);
return (
<section className="display top-display" aria-label="Main game viewport">
@@ -193,11 +205,11 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
<div className="top-hud">
<CompactParty />
<BossBar />
<div className="objective-chip"><span>{runMode === "roguelike" ? `Round ${round}` : "Objective"}</span><strong>{bossCount > 1 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
<div className="objective-chip"><span>{endlessMode ? `Endless · ${endlessBossKills} kills` : runMode !== "encounter" ? `Round ${round}` : "Objective"}</span><strong>{endlessMode ? "Defeat bosses · replacements incoming" : bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
<EncounterCallout />
<CastingBar />
<div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>16</b> Cast</div>
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b></b> Menu <small>START / ESC</small></button>}
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b></b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
</div>
<PhaseOverlay />
<PauseOverlay onExit={onExit} />
+215
View File
@@ -0,0 +1,215 @@
import { useFrame } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import * as THREE from "three";
import type { PoolTelegraphKind, WorldPosition } from "../../game/types";
const TAU = Math.PI * 2;
const LANE_PARTICLE_COUNT = 7;
const AREA_PARTICLE_COUNT = 8;
const BREATH_PARTICLE_COUNT = 12;
function useReducedMotion() {
return useMemo(() => (
document.documentElement.classList.contains("force-reduced-motion")
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches
), []);
}
export type LaneVfxVariant = "beam" | "charge" | "slash" | "web";
function LaneParticleGeometry({ variant }: { variant: LaneVfxVariant }) {
if (variant === "beam" || variant === "web") return <sphereGeometry args={[0.11, 7, 5]} />;
if (variant === "slash") return <octahedronGeometry args={[0.15, 0]} />;
return <tetrahedronGeometry args={[0.13, 0]} />;
}
/** One instanced draw for motion/readability layered over an existing lane telegraph. */
export function LaneEnergyVfx({
start,
end,
width,
color,
active,
variant,
height = 0.13,
}: {
start: WorldPosition;
end: WorldPosition;
width: number;
color: string;
active: boolean;
variant: LaneVfxVariant;
height?: number;
}) {
const particles = useRef<THREE.InstancedMesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
const reducedMotion = useReducedMotion();
const dx = end[0] - start[0];
const dz = end[1] - start[1];
const length = Math.hypot(dx, dz);
const angle = Math.atan2(dx, dz);
const midpoint: [number, number, number] = [(start[0] + end[0]) * 0.5, height, (start[1] + end[1]) * 0.5];
useFrame(({ clock }) => {
if (!particles.current) return;
const time = reducedMotion ? 0.35 : clock.elapsedTime;
const speed = active ? 2.6 : variant === "web" ? 0.5 : 0.85;
for (let index = 0; index < LANE_PARTICLE_COUNT; index += 1) {
const offset = index / LANE_PARTICLE_COUNT;
const cycle = (time * speed + offset) % 1;
const cross = Math.sin(time * 2.4 + index * 1.7) * width * (variant === "web" ? 0.08 : 0.28);
const rise = variant === "slash"
? Math.sin(cycle * Math.PI) * (active ? 0.65 : 0.28)
: variant === "web" ? Math.sin(time * 3 + index) * 0.07 : Math.sin(cycle * Math.PI) * 0.18;
transform.position.set(cross, rise, -length * 0.5 + cycle * length);
transform.rotation.set(time * 0.8 + index, index * 0.63, active ? time * 1.5 : 0);
const scale = (active ? 1.25 : 0.78) * (0.75 + Math.sin(cycle * Math.PI) * 0.4);
transform.scale.set(
variant === "slash" ? scale * 0.7 : scale,
variant === "charge" ? scale * 0.55 : scale,
variant === "charge" ? scale * 2.1 : scale,
);
transform.updateMatrix();
particles.current.setMatrixAt(index, transform.matrix);
}
particles.current.instanceMatrix.needsUpdate = true;
if (material.current) {
const pulse = reducedMotion ? 0.65 : 0.58 + Math.sin(time * 6.2) * 0.14;
material.current.opacity = active ? pulse + 0.22 : pulse * 0.62;
}
});
return (
<group position={midpoint} rotation={[0, angle, 0]}>
<instancedMesh ref={particles} args={[undefined, undefined, LANE_PARTICLE_COUNT]} frustumCulled={false}>
<LaneParticleGeometry variant={variant} />
<meshBasicMaterial
ref={material}
color={color}
transparent
opacity={active ? 0.82 : 0.38}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</instancedMesh>
</group>
);
}
export function BreathParticleVfx({
position,
angle,
range,
halfAngle,
active,
}: {
position: WorldPosition;
angle: number;
range: number;
halfAngle: number;
active: boolean;
}) {
const particles = useRef<THREE.InstancedMesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
const reducedMotion = useReducedMotion();
useFrame(({ clock }) => {
if (!particles.current) return;
const time = reducedMotion ? 0.4 : clock.elapsedTime;
for (let index = 0; index < BREATH_PARTICLE_COUNT; index += 1) {
const row = index % 4;
const lane = Math.floor(index / 4);
const offsetAngle = -halfAngle + (lane / 2) * halfAngle * 2;
const cycle = (time * (active ? 1.7 : 0.52) + row * 0.24 + lane * 0.08) % 1;
const distance = range * (0.08 + cycle * 0.9);
const scale = (active ? 1 : 0.55) * (1.15 - cycle * 0.55);
transform.position.set(
Math.sin(offsetAngle) * distance,
0.18 + Math.sin(cycle * Math.PI) * (active ? 0.72 : 0.32),
Math.cos(offsetAngle) * distance,
);
transform.rotation.set(time * 1.2 + index, -offsetAngle, cycle * TAU);
transform.scale.set(scale * 0.72, scale * 1.45, scale * 0.72);
transform.updateMatrix();
particles.current.setMatrixAt(index, transform.matrix);
}
particles.current.instanceMatrix.needsUpdate = true;
if (material.current) material.current.opacity = active ? 0.74 : 0.26;
});
return (
<group position={[position[0], 0.1, position[1]]} rotation={[0, angle, 0]}>
<instancedMesh ref={particles} args={[undefined, undefined, BREATH_PARTICLE_COUNT]} frustumCulled={false}>
<tetrahedronGeometry args={[0.2, 0]} />
<meshBasicMaterial
ref={material}
color={active ? "#7ceaff" : "#b8f4ff"}
transparent
opacity={active ? 0.74 : 0.26}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</instancedMesh>
</group>
);
}
export function CircleMechanicVfx({
radius,
innerRadius = 0,
kind,
color,
active,
}: {
radius: number;
innerRadius?: number;
kind: Extract<PoolTelegraphKind, "donut" | "soak" | "spread"> | "pounce";
color: string;
active: boolean;
}) {
const particles = useRef<THREE.InstancedMesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
const reducedMotion = useReducedMotion();
useFrame(({ clock }) => {
if (!particles.current) return;
const time = reducedMotion ? 0.3 : clock.elapsedTime;
for (let index = 0; index < AREA_PARTICLE_COUNT; index += 1) {
const offset = index / AREA_PARTICLE_COUNT;
const cycle = (time * (active ? 1.2 : 0.55) + offset) % 1;
const orbit = offset * TAU + time * (kind === "soak" ? -0.55 : 0.7);
let radial = radius * 0.82;
if (kind === "spread") radial = radius * (0.18 + cycle * 0.75);
if (kind === "soak" || kind === "pounce") radial = radius * (0.92 - cycle * 0.68);
if (kind === "donut") radial = index % 2 === 0 ? Math.max(0.2, innerRadius + 0.18) : radius - 0.18;
const scale = (active ? 1 : 0.64) * (0.72 + Math.sin(cycle * Math.PI) * 0.48);
transform.position.set(Math.sin(orbit) * radial, 0.12 + Math.sin(cycle * Math.PI) * 0.52, Math.cos(orbit) * radial);
transform.rotation.set(time + index, orbit, cycle * TAU);
transform.scale.setScalar(scale);
transform.updateMatrix();
particles.current.setMatrixAt(index, transform.matrix);
}
particles.current.instanceMatrix.needsUpdate = true;
if (material.current) material.current.opacity = active ? 0.84 : 0.42;
});
return (
<instancedMesh ref={particles} args={[undefined, undefined, AREA_PARTICLE_COUNT]} frustumCulled={false}>
<octahedronGeometry args={[0.16, 0]} />
<meshBasicMaterial
ref={material}
color={color}
transparent
opacity={active ? 0.84 : 0.42}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</instancedMesh>
);
}
+380 -84
View File
@@ -1,18 +1,22 @@
import { useFrame } from "@react-three/fiber";
import { useRef, type ComponentType } from "react";
import { Html } from "@react-three/drei";
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentType } from "react";
import * as THREE from "three";
import { BULL_CHARGE, BULL_POUNCE } from "../../game/bossMechanics";
import { MEMORY_SEQUENCE, MEMORY_SYMBOLS } from "../../game/bosses/mechanicPool";
import { SKY_SWEEPER_BREATH } from "../../game/bosses/skySweeper";
import { BULL_CHARGE, BULL_POUNCE, MEMORY_SEQUENCE, MEMORY_SYMBOLS, SKY_SWEEPER_BREATH } from "../../game/bosses/mechanicPool";
import { bossHazardVfxProfile } from "../../game/bossHazardVisuals";
import { angleTo } from "../../game/geometry";
import { useGameStore } from "../../game/store";
import type { MemorySymbolId, MemoryTile, PoolTelegraph } from "../../game/types";
import type { BossMotionMode, BossMotionState, MemorySymbolId, MemoryTile, PoolTelegraph, SoulSiphonState, WorldPosition } from "../../game/types";
import { BreathParticleVfx, CircleMechanicVfx, LaneEnergyVfx } from "./BossAttackVfx";
import { BOSS_INDICATOR_DEATH_FADE_MS, advanceBossIndicatorOpacity } from "./bossDeathVisuals";
import { CircleHazardVfx } from "./CircleHazardVfx";
const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
const EMPTY_HAZARDS: never[] = [];
const EMPTY_SLASH_LANES: never[] = [];
const EMPTY_POOL_TELEGRAPHS: never[] = [];
const ACTIVE_LANE_MODES = new Set(["mantis_recover", "ram_charging", "ram_recover", "cinderback_ricochet", "cinderback_recover", "sandglass_burrowing", "sandglass_recover", "crab_scuttling", "crab_recover", "ghost_recover"]);
const ACTIVE_LANE_MODES = new Set<BossMotionMode>(["charging"]);
const DANGER_WARNING_COLOR = "#ff3b30";
const DANGER_ACTIVE_COLOR = "#d4142a";
const DANGER_HIGHLIGHT_COLOR = "#ff8a80";
@@ -27,12 +31,95 @@ const INWARD_ARROW_SHAPE = new THREE.Shape()
.lineTo(-0.29, 0.04)
.lineTo(-0.13, 0.04)
.lineTo(-0.13, -0.32);
const SOUL_SIPHON_GUIDANCE_HEIGHT = 2.2;
const SOUL_SIPHON_GUIDANCE_COLOR = "#9dff78";
const SOUL_SIPHON_GUIDANCE_OUTLINE = "#2b210b";
type GameStoreState = ReturnType<typeof useGameStore.getState>;
function motionAt(state: GameStoreState, bossIndex: number) {
return bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion;
}
function bossHpAt(state: GameStoreState, bossIndex: number) {
return bossIndex === 0 ? state.boss.hp : state.additionalBosses[bossIndex - 1]?.boss.hp ?? 0;
}
function bossAt(state: GameStoreState, bossIndex: number) {
return bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss;
}
function earliestSoulSiphonInMotion(motion: BossMotionState | undefined, current?: SoulSiphonState) {
if (!motion) return current;
let earliest = current;
for (const telegraph of motion.poolTelegraphs) {
const siphon = telegraph.kind === "soul-siphon" && !telegraph.resolved ? telegraph.soulSiphon : undefined;
if (siphon && (!earliest || siphon.nextDamageAt < earliest.nextDamageAt)) earliest = siphon;
}
return earliest;
}
function activeSoulSiphonGuidance(state: GameStoreState) {
if (state.phase !== "combat") return undefined;
let earliest = state.boss.hp > 0 ? earliestSoulSiphonInMotion(state.bossMotion) : undefined;
for (const entry of state.additionalBosses) {
if (entry.boss.hp > 0) earliest = earliestSoulSiphonInMotion(entry.motion, earliest);
}
return earliest;
}
function WorldDirectionIndicator({
origin,
destination,
height,
color,
}: {
origin: WorldPosition;
destination: WorldPosition;
height: number;
color: string;
}) {
const group = useRef<THREE.Group>(null);
const fill = useRef<THREE.MeshBasicMaterial>(null);
const reducedMotion = document.documentElement.classList.contains("force-reduced-motion")
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches;
useFrame(({ clock }) => {
if (!group.current) return;
const wave = reducedMotion ? 0.5 : (Math.sin(clock.elapsedTime * 5.2) + 1) * 0.5;
group.current.position.set(origin[0], height + (reducedMotion ? 0 : (wave - 0.5) * 0.12), origin[1]);
group.current.rotation.y = angleTo(origin, destination) + Math.PI;
group.current.scale.setScalar(1.55 + wave * 0.16);
if (fill.current) fill.current.opacity = 0.82 + wave * 0.18;
});
return (
<group ref={group} position={[origin[0], height, origin[1]]} rotation={[0, angleTo(origin, destination) + Math.PI, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]} scale={1.28} renderOrder={30}>
<shapeGeometry args={[INWARD_ARROW_SHAPE]} />
<meshBasicMaterial color={SOUL_SIPHON_GUIDANCE_OUTLINE} transparent opacity={0.92} depthTest={false} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={31}>
<shapeGeometry args={[INWARD_ARROW_SHAPE]} />
<meshBasicMaterial ref={fill} color={color} transparent opacity={1} depthTest={false} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
</group>
);
}
function SoulSiphonGuidanceIndicator() {
const guidance = useGameStore(activeSoulSiphonGuidance);
const playerPosition = useGameStore((state) => state.partyPositions.aelia);
if (!guidance) return null;
return (
<WorldDirectionIndicator
origin={playerPosition}
destination={guidance.wardPosition}
height={SOUL_SIPHON_GUIDANCE_HEIGHT}
color={SOUL_SIPHON_GUIDANCE_COLOR}
/>
);
}
export function ChargeLaneIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const phase = useGameStore((state) => state.phase);
const motionMode = useGameStore((state) => motionAt(state, bossIndex)?.mode);
@@ -112,26 +199,37 @@ function SlashLaneIndicator({ laneId, bossIndex }: { laneId: string; bossIndex:
(lane.start[1] + lane.end[1]) * 0.5,
];
const active = ACTIVE_LANE_MODES.has(motion.mode);
const chargeEffect = motion.mode === "telegraph" || motion.mode === "charging";
const color = active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
return (
<group position={midpoint} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[lane.width, length]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.22} depthWrite={false} />
</mesh>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * lane.width * 0.5, 0.018, 0]}>
<boxGeometry args={[0.07, 0.03, length]} />
<meshBasicMaterial ref={side < 0 ? edgeMaterial : undefined} color={color} transparent opacity={0.8} depthWrite={false} />
<>
<group position={midpoint} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[lane.width, length]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.22} depthWrite={false} />
</mesh>
))}
{active && (
<mesh position={[0, 0.055, 0]}>
<boxGeometry args={[0.16, 0.08, length]} />
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.92} depthWrite={false} />
</mesh>
)}
</group>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * lane.width * 0.5, 0.018, 0]}>
<boxGeometry args={[0.07, 0.03, length]} />
<meshBasicMaterial ref={side < 0 ? edgeMaterial : undefined} color={color} transparent opacity={0.8} depthWrite={false} />
</mesh>
))}
{active && (
<mesh position={[0, 0.055, 0]}>
<boxGeometry args={[0.16, 0.08, length]} />
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.92} depthWrite={false} />
</mesh>
)}
</group>
<LaneEnergyVfx
start={lane.start}
end={lane.end}
width={lane.width}
color={active ? DANGER_HIGHLIGHT_COLOR : color}
active={active}
variant={chargeEffect ? "charge" : "slash"}
/>
</>
);
}
@@ -152,9 +250,10 @@ export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number })
if (!warningMaterial.current) return;
warningMaterial.current.opacity = 0.14 + (Math.sin(clock.elapsedTime * 8) + 1) * 0.08;
});
if (phase !== "combat" || motionMode !== "stacking") return null;
if (phase !== "combat" || (motionMode !== "stacking" && motionMode !== "pouncing")) return null;
const radius = BULL_POUNCE.stackRadius;
const active = motionMode === "pouncing";
return (
<group ref={group}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
@@ -181,6 +280,7 @@ export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number })
</mesh>
</group>
))}
<CircleMechanicVfx radius={radius} kind="pounce" color={active ? "#ffe0a8" : "#ff7278"} active={active} />
</group>
);
}
@@ -211,6 +311,7 @@ export function BindingWebIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
<meshBasicMaterial color={color} transparent opacity={0.92} depthWrite={false} />
</mesh>
))}
<LaneEnergyVfx start={first} end={second} width={0.34} color={color} active={stretched} variant="web" height={0.2} />
</group>
);
}
@@ -225,16 +326,25 @@ export function BreathConeIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
if (!motion || phase !== "combat" || (motion.mode !== "breath_telegraph" && motion.mode !== "breath_sweeping")) return null;
const color = motion.mode === "breath_sweeping" ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
return (
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - SKY_SWEEPER_BREATH.halfAngle]}>
<circleGeometry args={[SKY_SWEEPER_BREATH.range, 64, 0, SKY_SWEEPER_BREATH.halfAngle * 2]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.25} depthWrite={false} />
</mesh>
<mesh position={[0, 0.02, SKY_SWEEPER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[SKY_SWEEPER_BREATH.range * 0.47, SKY_SWEEPER_BREATH.range * 0.49, 48, 1, -SKY_SWEEPER_BREATH.halfAngle, SKY_SWEEPER_BREATH.halfAngle * 2]} />
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
</mesh>
</group>
<>
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - SKY_SWEEPER_BREATH.halfAngle]}>
<circleGeometry args={[SKY_SWEEPER_BREATH.range, 64, 0, SKY_SWEEPER_BREATH.halfAngle * 2]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.25} depthWrite={false} />
</mesh>
<mesh position={[0, 0.02, SKY_SWEEPER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[SKY_SWEEPER_BREATH.range * 0.47, SKY_SWEEPER_BREATH.range * 0.49, 48, 1, -SKY_SWEEPER_BREATH.halfAngle, SKY_SWEEPER_BREATH.halfAngle * 2]} />
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
</mesh>
</group>
<BreathParticleVfx
position={motion.position}
angle={motion.breathAngle}
range={SKY_SWEEPER_BREATH.range}
halfAngle={SKY_SWEEPER_BREATH.halfAngle}
active={motion.mode === "breath_sweeping"}
/>
</>
);
}
@@ -248,19 +358,8 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss
});
if (!hazard) return null;
const active = time >= hazard.activatesAt;
const colors = {
venom_pool: "#a94ee6",
skyfall: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
quake: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
lava_pool: "#ff5a24",
stinger_eruption: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
hourglass: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
tidal_burst: active ? DANGER_ACTIVE_COLOR : "#39c9df",
soul_rift: active ? "#7446d8" : "#aa78ff",
crownfall: active ? DANGER_ACTIVE_COLOR : "#e4c548",
royal_shockwave: active ? DANGER_ACTIVE_COLOR : "#f0ca4d",
} as const;
const color = colors[hazard.kind];
const visual = bossHazardVfxProfile(hazard.kind);
const color = active ? visual.activeColor : visual.warningColor;
return (
<group position={[hazard.center[0], 0.065, hazard.center[1]]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
@@ -278,9 +377,10 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss
{!active && (
<mesh position={[0, 0.03, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.22, 0.34, 24]} />
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.95} depthWrite={false} />
<meshBasicMaterial color={visual.highlightColor} transparent opacity={0.95} depthWrite={false} />
</mesh>
)}
<CircleHazardVfx hazard={hazard} active={active} />
</group>
);
}
@@ -290,7 +390,7 @@ export function CircleHazardIndicators({ bossIndex = 0 }: { bossIndex?: number }
return <>{hazards.map((hazard) => <CircleHazardIndicator key={hazard.id} hazardId={hazard.id} bossIndex={bossIndex} />)}</>;
}
/** Tall gold ward plus a lightweight spectral pursuer for Mournveil's healer run. */
/** Tall gold ward plus a lightweight spectral pursuer for the shared Soul Siphon mechanic. */
export function SoulSiphonIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const phase = useGameStore((state) => state.phase);
const motion = useGameStore((state) => motionAt(state, bossIndex));
@@ -392,12 +492,49 @@ function MemorySymbolMark({ symbol, size = 1, opacity = 1 }: { symbol: MemorySym
);
}
function MemoryTileIndicator({ tile, inputActive }: { tile: MemoryTile; inputActive: boolean }) {
const MEMORY_SYMBOL_GLYPHS: Record<MemorySymbolId, string> = {
triangle: "△",
cross: "+",
circle: "○",
square: "◇",
};
function MemoryTileIndicator({ tile, inputActive, completed }: { tile: MemoryTile; inputActive: boolean; completed: boolean }) {
const color = MEMORY_SYMBOLS[tile.symbol].color;
const halfSize = MEMORY_SEQUENCE.tileSize * 0.5;
const gridOffsets = [-0.48, 0, 0.48] as const;
const group = useRef<THREE.Group>(null);
const fade = useRef(1);
const materials = useRef<{ material: THREE.MeshBasicMaterial; baseOpacity: number }[]>([]);
useLayoutEffect(() => {
const nextMaterials: { material: THREE.MeshBasicMaterial; baseOpacity: number }[] = [];
group.current?.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
const childMaterials = Array.isArray(child.material) ? child.material : [child.material];
for (const material of childMaterials) {
if (material instanceof THREE.MeshBasicMaterial) {
nextMaterials.push({ material, baseOpacity: material.opacity });
}
}
});
materials.current = nextMaterials;
}, [inputActive]);
useFrame((_, delta) => {
if (!completed && fade.current >= 0.999) return;
if (completed && fade.current <= 0.01) {
fade.current = 0;
if (group.current) group.current.visible = false;
return;
}
if (group.current) group.current.visible = true;
fade.current = THREE.MathUtils.damp(fade.current, completed ? 0 : 1, 18, delta);
for (const entry of materials.current) entry.material.opacity = entry.baseOpacity * fade.current;
});
return (
<group position={[tile.center[0], 0.075, tile.center[1]]}>
<group ref={group} position={[tile.center[0], 0.075, tile.center[1]]}>
<mesh>
<boxGeometry args={[MEMORY_SEQUENCE.tileSize, 0.04, MEMORY_SEQUENCE.tileSize]} />
<meshBasicMaterial color={color} transparent opacity={inputActive ? 0.28 : 0.16} depthWrite={false} />
@@ -440,21 +577,60 @@ function MemorySequenceIndicator({ telegraph, bossPosition, time }: { telegraph:
);
const flashSymbol = telegraph.sequence[flashIndex];
const source = bossPosition ?? telegraph.center;
const completedCount = showingSequence ? 0 : telegraph.inputIndex ?? 0;
return (
<>
{telegraph.tiles.map((tile) => <MemoryTileIndicator key={tile.symbol} tile={tile} inputActive={!showingSequence} />)}
{telegraph.tiles.map((tile) => {
const sequenceIndex = telegraph.sequence!.indexOf(tile.symbol);
return (
<MemoryTileIndicator
key={tile.symbol}
tile={tile}
inputActive={!showingSequence}
completed={sequenceIndex >= 0 && sequenceIndex < completedCount}
/>
);
})}
{showingSequence && (
<group position={[source[0], 2.5, source[1]]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[0.88, 32]} />
<meshBasicMaterial color="#111827" transparent opacity={0.9} depthWrite={false} />
</mesh>
<mesh position={[0, 0.015, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.8, 0.9, 32]} />
<meshBasicMaterial color={MEMORY_SYMBOLS[flashSymbol].color} transparent opacity={1} depthWrite={false} />
</mesh>
<MemorySymbolMark symbol={flashSymbol} size={1.05} />
</group>
<Html position={[source[0], 3.1, source[1] + 0.3]} center zIndexRange={[30, 20]} style={{ pointerEvents: "none" }}>
<div style={{ display: "grid", justifyItems: "center", gap: 7 }}>
<div
style={{
width: 64,
height: 64,
display: "grid",
placeItems: "center",
borderRadius: "50%",
border: `4px solid ${MEMORY_SYMBOLS[flashSymbol].color}`,
background: "rgba(17, 24, 39, 0.96)",
boxShadow: "0 0 0 4px rgba(216, 184, 92, 0.92), 0 0 18px rgba(255, 244, 199, 0.8)",
color: MEMORY_SYMBOLS[flashSymbol].color,
fontSize: 47,
fontWeight: 900,
lineHeight: 1,
textShadow: "0 0 8px currentColor",
}}
>
{MEMORY_SYMBOL_GLYPHS[flashSymbol]}
</div>
<div style={{ display: "flex", gap: 6 }}>
{telegraph.sequence.map((_, index) => (
<span
key={index}
style={{
width: index === flashIndex ? 9 : 7,
height: index === flashIndex ? 9 : 7,
borderRadius: "50%",
background: index === flashIndex
? MEMORY_SYMBOLS[flashSymbol].color
: index < flashIndex ? "#fff4c7" : "#64748b",
boxShadow: index === flashIndex ? "0 0 7px currentColor" : "none",
}}
/>
))}
</div>
</div>
</Html>
)}
</>
);
@@ -483,30 +659,41 @@ function PooledTelegraphIndicator({ telegraphId, bossIndex }: { telegraphId: str
: telegraph.kind === "spread" ? "#e869ff"
: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
if (telegraph.kind === "beam" && telegraph.start && telegraph.end) {
if (telegraph.kind === "beam") {
if (!telegraph.start || !telegraph.end) return null;
const dx = telegraph.end[0] - telegraph.start[0];
const dz = telegraph.end[1] - telegraph.start[1];
const length = Math.hypot(dx, dz);
const angle = Math.atan2(dx, dz);
return (
<group position={[(telegraph.start[0] + telegraph.end[0]) * 0.5, 0.07, (telegraph.start[1] + telegraph.end[1]) * 0.5]} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[telegraph.width ?? 1, length]} />
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
</mesh>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * (telegraph.width ?? 1) * 0.5, 0.022, 0]}>
<boxGeometry args={[0.06, 0.035, length]} />
<meshBasicMaterial color={color} transparent opacity={0.95} depthWrite={false} />
<>
<group position={[(telegraph.start[0] + telegraph.end[0]) * 0.5, 0.07, (telegraph.start[1] + telegraph.end[1]) * 0.5]} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[telegraph.width ?? 1, length]} />
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
</mesh>
))}
{CHARGE_MARKERS.map((index) => (
<mesh key={index} position={[0, 0.034, -length * 0.44 + (index / 6) * length * 0.88]}>
<boxGeometry args={[(telegraph.width ?? 1) * 0.56, 0.035, 0.13]} />
<meshBasicMaterial color="#ffe1b3" transparent opacity={active ? 1 : 0.7} />
</mesh>
))}
</group>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * (telegraph.width ?? 1) * 0.5, 0.022, 0]}>
<boxGeometry args={[0.06, 0.035, length]} />
<meshBasicMaterial color={color} transparent opacity={0.95} depthWrite={false} />
</mesh>
))}
{CHARGE_MARKERS.map((index) => (
<mesh key={index} position={[0, 0.034, -length * 0.44 + (index / 6) * length * 0.88]}>
<boxGeometry args={[(telegraph.width ?? 1) * 0.56, 0.035, 0.13]} />
<meshBasicMaterial color="#ffe1b3" transparent opacity={active ? 1 : 0.7} />
</mesh>
))}
</group>
<LaneEnergyVfx
start={telegraph.start}
end={telegraph.end}
width={telegraph.width ?? 1}
color={active ? "#ffe1b3" : color}
active={active}
variant="beam"
/>
</>
);
}
@@ -549,6 +736,13 @@ function PooledTelegraphIndicator({ telegraphId, bossIndex }: { telegraphId: str
</mesh>
</group>
))}
<CircleMechanicVfx
radius={telegraph.radius}
innerRadius={innerRadius}
kind={telegraph.kind}
color={color}
active={active}
/>
</group>
);
}
@@ -558,7 +752,60 @@ export function PooledMechanicIndicators({ bossIndex = 0 }: { bossIndex?: number
return <>{telegraphs.map((telegraph) => <PooledTelegraphIndicator key={telegraph.id} telegraphId={telegraph.id} bossIndex={bossIndex} />)}</>;
}
export function BasicMeleeVfx({ bossIndex = 0 }: { bossIndex?: number }) {
const group = useRef<THREE.Group>(null);
const slashMaterial = useRef<THREE.MeshBasicMaterial>(null);
const impactMaterial = useRef<THREE.MeshBasicMaterial>(null);
const initialBoss = bossAt(useGameStore.getState(), bossIndex);
const previousNextMeleeAt = useRef(initialBoss?.nextMeleeAt ?? 0);
const age = useRef(Number.POSITIVE_INFINITY);
useFrame((_, delta) => {
const state = useGameStore.getState();
const boss = bossAt(state, bossIndex);
const motion = motionAt(state, bossIndex);
if (!group.current || !boss || !motion) return;
if (boss.nextMeleeAt !== previousNextMeleeAt.current) {
if (state.phase === "combat" && motion.mode === "holding") age.current = 0;
previousNextMeleeAt.current = boss.nextMeleeAt;
}
age.current += delta;
const visible = state.phase === "combat" && age.current < 0.42 && state.party[1].hp > 0;
group.current.visible = visible;
if (!visible) return;
const target = state.partyPositions.brann;
const progress = THREE.MathUtils.clamp(age.current / 0.42, 0, 1);
group.current.position.set(target[0], 1.02, target[1]);
group.current.rotation.y = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
group.current.scale.setScalar(0.68 + progress * 0.72);
if (slashMaterial.current) slashMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.92;
if (impactMaterial.current) impactMaterial.current.opacity = (1 - progress) * 0.68;
});
return (
<group ref={group} visible={false}>
<mesh rotation={[0, 0, -Math.PI * 0.72]}>
<torusGeometry args={[0.7, 0.055, 6, 28, Math.PI * 1.35]} />
<meshBasicMaterial
ref={slashMaterial}
color="#ffd58a"
transparent
opacity={0}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</mesh>
<mesh position={[0, -0.94, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.32, 0.5, 24]} />
<meshBasicMaterial ref={impactMaterial} color="#ff8d5c" transparent opacity={0} depthWrite={false} />
</mesh>
</group>
);
}
const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
BasicMeleeVfx,
ChargeLaneIndicator,
SlashLaneIndicators,
PounceStackIndicator,
@@ -569,14 +816,63 @@ const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[]
PooledMechanicIndicators,
];
function BossMechanicIndicatorSet({ bossIndex }: { bossIndex: number }) {
const defeated = useGameStore((state) => bossHpAt(state, bossIndex) <= 0);
const group = useRef<THREE.Group>(null);
const opacity = useRef(defeated ? 0 : 1);
const patchedMeshes = useRef(new WeakSet<THREE.Mesh>());
const [retainIndicators, setRetainIndicators] = useState(!defeated);
const patchMeshOpacity = useCallback((child: THREE.Object3D) => {
if (!(child instanceof THREE.Mesh) || patchedMeshes.current.has(child)) return;
patchedMeshes.current.add(child);
const previousBeforeRender = child.onBeforeRender;
const previousAfterRender = child.onAfterRender;
const materials = Array.isArray(child.material) ? child.material : [child.material];
const sourceOpacities = new Float32Array(materials.length);
child.onBeforeRender = (renderer, scene, camera, geometry, material, renderGroup) => {
previousBeforeRender.call(child, renderer, scene, camera, geometry, material, renderGroup);
for (let index = 0; index < materials.length; index += 1) {
sourceOpacities[index] = materials[index].opacity;
materials[index].opacity *= opacity.current;
}
};
child.onAfterRender = (renderer, scene, camera, geometry, material, renderGroup) => {
previousAfterRender.call(child, renderer, scene, camera, geometry, material, renderGroup);
for (let index = 0; index < materials.length; index += 1) materials[index].opacity = sourceOpacities[index];
};
}, []);
useEffect(() => {
if (!defeated) {
setRetainIndicators(true);
return;
}
const timeout = window.setTimeout(() => setRetainIndicators(false), BOSS_INDICATOR_DEATH_FADE_MS);
return () => window.clearTimeout(timeout);
}, [defeated]);
useFrame((_, delta) => {
if (!group.current) return;
opacity.current = advanceBossIndicatorOpacity(opacity.current, defeated, delta);
group.current.visible = opacity.current > 0;
group.current.traverse(patchMeshOpacity);
});
return (
<group ref={group}>
{retainIndicators && BOSS_MECHANIC_INDICATORS.map((Indicator) => <Indicator key={Indicator.name} bossIndex={bossIndex} />)}
</group>
);
}
export function BossMechanicIndicators() {
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
return (
<>
<SoulSiphonGuidanceIndicator />
{Array.from({ length: bossCount }, (_, bossIndex) => (
<group key={bossIndex}>
{BOSS_MECHANIC_INDICATORS.map((Indicator) => <Indicator key={Indicator.name} bossIndex={bossIndex} />)}
</group>
<BossMechanicIndicatorSet key={bossIndex} bossIndex={bossIndex} />
))}
</>
);
+198
View File
@@ -0,0 +1,198 @@
import { useFrame } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import * as THREE from "three";
import { bossHazardVfxProfile, bossHazardVfxSeed, type BossHazardVfxShape } from "../../game/bossHazardVisuals";
import { useGameStore } from "../../game/store";
import type { CircleHazard } from "../../game/types";
const TAU = Math.PI * 2;
function ParticleGeometry({ shape }: { shape: BossHazardVfxShape }) {
if (shape === "orb") return <sphereGeometry args={[0.16, 8, 6]} />;
if (shape === "spike") return <coneGeometry args={[0.16, 0.9, 5]} />;
return <octahedronGeometry args={[0.19, 0]} />;
}
/**
* One instanced particle draw plus one transient accent and an optional column.
* Effects mutate preallocated transforms and never touch combat state.
*/
export function CircleHazardVfx({ hazard, active }: { hazard: CircleHazard; active: boolean }) {
const profile = bossHazardVfxProfile(hazard.kind);
const particles = useRef<THREE.InstancedMesh>(null);
const particleMaterial = useRef<THREE.MeshBasicMaterial>(null);
const warningRing = useRef<THREE.Mesh>(null);
const warningMaterial = useRef<THREE.MeshBasicMaterial>(null);
const impactRing = useRef<THREE.Mesh>(null);
const impactMaterial = useRef<THREE.MeshBasicMaterial>(null);
const column = useRef<THREE.Mesh>(null);
const columnMaterial = useRef<THREE.MeshBasicMaterial>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
const seed = useMemo(() => bossHazardVfxSeed(hazard.id), [hazard.id]);
const reducedMotion = useMemo(() => (
document.documentElement.classList.contains("force-reduced-motion")
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches
), []);
useFrame(({ clock }) => {
const time = useGameStore.getState().time;
const activeAge = Math.max(0, time - hazard.activatesAt);
const warningRemaining = Math.max(0, hazard.activatesAt - time);
const effectTime = reducedMotion ? seed * 2 : clock.elapsedTime + seed * 7.3;
const activeDuration = Math.max(0.22, hazard.expiresAt - hazard.activatesAt);
const impactDuration = Math.min(0.48, activeDuration);
const impactProgress = THREE.MathUtils.clamp(activeAge / impactDuration, 0, 1);
if (particles.current) {
for (let index = 0; index < profile.particleCount; index += 1) {
const offset = (index + 0.5) / profile.particleCount;
const baseAngle = offset * TAU + seed * TAU;
const angle = baseAngle + effectTime * profile.spinSpeed;
const stagger = (offset + seed * 0.37) % 1;
let radial = hazard.radius * (0.28 + (index % 3) * 0.2);
let y = 0.12;
let width = 0.72;
let height = 0.72;
if (profile.motion === "bubble") {
const cycle = (effectTime * 0.48 + stagger) % 1;
radial = hazard.radius * (0.22 + (index % 3) * 0.22);
y = 0.08 + cycle * profile.height;
width = 0.55 + Math.sin(cycle * Math.PI) * 0.65;
height = width;
} else if (profile.motion === "fall") {
const cycle = 1 - ((effectTime * 0.72 + stagger) % 1);
radial = hazard.radius * (0.18 + (index % 3) * 0.2);
y = 0.22 + cycle * profile.height;
width = active ? 0.82 : 0.55;
height = active ? 2.9 : 2.15;
} else if (profile.motion === "flame") {
const cycle = (effectTime * 0.95 + stagger) % 1;
radial = hazard.radius * (0.2 + (index % 3) * 0.21);
y = 0.12 + cycle * profile.height * 0.62;
width = 0.5 + Math.sin(cycle * Math.PI) * 0.85;
height = 0.75 + Math.sin(cycle * Math.PI) * 1.6;
} else if (profile.motion === "orbit") {
radial = hazard.radius * (0.3 + (index % 2) * 0.24);
y = 0.28 + ((index % 4) / 3) * profile.height + Math.sin(effectTime * 2.2 + index) * 0.16;
width = 0.65 + (index % 2) * 0.35;
height = profile.shape === "crystal" ? 1.55 : width;
} else if (profile.motion === "shockwave") {
const wave = active ? impactProgress : 0.22 + (1 - Math.min(1, warningRemaining / 1.6)) * 0.18;
radial = hazard.radius * wave;
y = 0.13 + Math.sin(offset * Math.PI) * profile.height * 0.12;
width = active ? 0.72 + impactProgress * 0.75 : 0.58;
height = active ? 1.25 : 0.75;
} else if (profile.motion === "spike") {
const rise = active ? THREE.MathUtils.clamp(activeAge / 0.16, 0, 1) : 0.12;
radial = hazard.radius * (0.2 + (index % 3) * 0.23);
width = 0.75 + (index % 2) * 0.35;
height = Math.max(0.18, rise * (profile.height + (index % 3) * 0.34));
y = height * 0.45;
} else {
const cycle = (effectTime * 0.72 + stagger) % 1;
radial = hazard.radius * (0.18 + cycle * 0.68);
y = 0.12 + Math.sin(cycle * Math.PI) * profile.height;
width = 0.6 + Math.sin(cycle * Math.PI) * 0.72;
height = width * 1.4;
}
const warningScale = active ? 1 : 0.56;
transform.position.set(Math.sin(angle) * radial, y, Math.cos(angle) * radial);
transform.rotation.set(index * 0.73, -angle, profile.motion === "fall" ? 0 : effectTime * 0.45 + index);
transform.scale.set(width * warningScale, height * warningScale, width * warningScale);
transform.updateMatrix();
particles.current.setMatrixAt(index, transform.matrix);
}
particles.current.instanceMatrix.needsUpdate = true;
}
if (particleMaterial.current) {
const pulse = reducedMotion ? 0.72 : 0.64 + Math.sin(effectTime * 4.6) * 0.12;
particleMaterial.current.opacity = active ? pulse + 0.16 : pulse * 0.48;
}
if (warningRing.current && warningMaterial.current) {
const countdown = THREE.MathUtils.clamp(warningRemaining / 1.8, 0, 1);
const scale = hazard.radius * (0.18 + countdown * 0.82);
warningRing.current.visible = !active;
warningRing.current.scale.setScalar(scale);
warningRing.current.rotation.z = reducedMotion ? seed * TAU : effectTime * profile.spinSpeed * 0.42;
warningMaterial.current.opacity = 0.48 + (reducedMotion ? 0 : Math.sin(effectTime * 7) * 0.16);
}
if (impactRing.current && impactMaterial.current) {
impactRing.current.visible = active && impactProgress < 0.999;
impactRing.current.scale.setScalar(hazard.radius * (0.16 + impactProgress * 1.02));
impactMaterial.current.opacity = (1 - impactProgress) * 0.92;
}
if (column.current && columnMaterial.current) {
const columnPulse = reducedMotion ? 0.78 : 0.7 + Math.sin(effectTime * 3.8) * 0.18;
column.current.rotation.y = effectTime * profile.spinSpeed * 0.3;
column.current.scale.set(
hazard.radius * (active ? 0.9 : 0.62),
profile.height * (active ? 1 : 0.72),
hazard.radius * (active ? 0.9 : 0.62),
);
columnMaterial.current.opacity = profile.columnOpacity * columnPulse * (active ? 1 : 0.52);
}
});
const color = active ? profile.activeColor : profile.warningColor;
return (
<group>
<instancedMesh ref={particles} args={[undefined, undefined, profile.particleCount]} frustumCulled={false}>
<ParticleGeometry shape={profile.shape} />
<meshBasicMaterial
ref={particleMaterial}
color={color}
transparent
opacity={active ? 0.8 : 0.35}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</instancedMesh>
<mesh ref={warningRing} position={[0, 0.085, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<torusGeometry args={[1, 0.026, 5, 36]} />
<meshBasicMaterial
ref={warningMaterial}
color={profile.highlightColor}
transparent
opacity={0.58}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</mesh>
<mesh ref={impactRing} position={[0, 0.12, 0]} rotation={[-Math.PI / 2, 0, 0]} visible={false}>
<ringGeometry args={[0.84, 1, 40]} />
<meshBasicMaterial
ref={impactMaterial}
color={profile.highlightColor}
transparent
opacity={0}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</mesh>
{profile.columnOpacity > 0 && (
<mesh ref={column} position={[0, profile.height * 0.5, 0]}>
<cylinderGeometry args={[0.48, 0.16, 1, 18, 1, true]} />
<meshBasicMaterial
ref={columnMaterial}
color={color}
transparent
opacity={profile.columnOpacity}
depthWrite={false}
side={THREE.DoubleSide}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</mesh>
)}
</group>
);
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { BOSS_BURROW_CLEARANCE, bossBurrowPositionY, bossIsBurrowing } from "./bossBurrowVisuals";
describe("boss burrow visuals", () => {
it("places the scaled top of the model below the arena floor", () => {
const modelTopY = 2.8371768724243784;
const modelScale = 0.82;
const positionY = bossBurrowPositionY(modelTopY, modelScale);
expect(positionY + modelTopY * modelScale).toBeCloseTo(-BOSS_BURROW_CLEARANCE, 6);
});
it("starts sinking during the warning and remains submerged through the charge", () => {
expect(bossIsBurrowing("burrow-rush", "telegraph")).toBe(true);
expect(bossIsBurrowing("burrow-rush", "charging")).toBe(true);
expect(bossIsBurrowing("burrow-rush", "holding")).toBe(false);
expect(bossIsBurrowing("bull-charge", "charging")).toBe(false);
});
});
+18
View File
@@ -0,0 +1,18 @@
import type { BossMechanicId, BossMotionMode } from "../../game/types";
export const BOSS_BURROW_CLEARANCE = 0.58;
export function bossBurrowPositionY(
modelTopY: number,
modelScale: number,
clearance = BOSS_BURROW_CLEARANCE,
): number {
return -(Math.max(0, modelTopY) * Math.max(0, modelScale) + Math.max(0, clearance));
}
export function bossIsBurrowing(
activeMechanicId: BossMechanicId | null,
mode: BossMotionMode,
): boolean {
return activeMechanicId === "burrow-rush" && (mode === "telegraph" || mode === "charging");
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import {
BOSS_INDICATOR_DEATH_FADE_MS,
BOSS_DEATH_DESPAWN_SECONDS,
BOSS_DEATH_FADE_SECONDS,
BOSS_DEATH_HOLD_SECONDS,
advanceBossIndicatorOpacity,
bossCanTrackTarget,
bossDeathOpacity,
} from "./bossDeathVisuals";
describe("boss death visuals", () => {
it("stops target tracking as soon as boss health reaches zero", () => {
expect(bossCanTrackTarget(1)).toBe(true);
expect(bossCanTrackTarget(0)).toBe(false);
expect(bossCanTrackTarget(-1)).toBe(false);
});
it("fades mechanic indicators to zero over the death transition", () => {
const halfway = advanceBossIndicatorOpacity(1, true, BOSS_INDICATOR_DEATH_FADE_MS / 2_000);
expect(halfway).toBeCloseTo(0.5);
expect(advanceBossIndicatorOpacity(halfway, true, BOSS_INDICATOR_DEATH_FADE_MS / 2_000)).toBe(0);
expect(advanceBossIndicatorOpacity(0.4, false, 1 / 60)).toBe(1);
});
it("holds the death pose for a few seconds, then fades the model", () => {
expect(bossDeathOpacity(BOSS_DEATH_HOLD_SECONDS)).toBe(1);
expect(bossDeathOpacity(BOSS_DEATH_HOLD_SECONDS + BOSS_DEATH_FADE_SECONDS / 2)).toBeCloseTo(0.5);
expect(bossDeathOpacity(BOSS_DEATH_DESPAWN_SECONDS)).toBe(0);
});
});
+17
View File
@@ -0,0 +1,17 @@
export {
BOSS_DEATH_DESPAWN_SECONDS,
BOSS_DEATH_FADE_SECONDS,
BOSS_DEATH_HOLD_SECONDS,
bossDeathOpacity,
} from "../../game/bossDeath";
export const BOSS_INDICATOR_DEATH_FADE_MS = 250;
export function bossCanTrackTarget(hp: number) {
return hp > 0;
}
export function advanceBossIndicatorOpacity(current: number, defeated: boolean, deltaSeconds: number) {
if (!defeated) return 1;
return Math.max(0, current - (deltaSeconds * 1_000) / BOSS_INDICATOR_DEATH_FADE_MS);
}
+24 -39
View File
@@ -1,53 +1,38 @@
import { describe, expect, it } from "vitest";
import type { StorageAdapter } from "./saveRepository";
import { describe, expect, it, vi } from "vitest";
import { AccountRepository } from "./accountRepository";
import { OnlineApiError, type OnlineRepository } from "./onlineRepository";
function memoryStorage(): StorageAdapter {
const data = new Map<string, string>();
function onlineStub(overrides: Partial<OnlineRepository> = {}): OnlineRepository {
return {
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => { data.set(key, value); },
};
register: vi.fn(async (username: string) => ({ id: 1, username })),
login: vi.fn(async (username: string) => ({ id: 1, username })),
session: vi.fn(async () => null),
logout: vi.fn(async () => undefined),
...overrides,
} as unknown as OnlineRepository;
}
const testHasher = async (password: string, salt: string) => {
const checksum = [...password].reduce((total, character) => total + character.charCodeAt(0), 0);
return `derived:${salt}:${checksum}`;
};
describe("AccountRepository", () => {
it("requires both a username and password", async () => {
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
it("requires both username and password before contacting server", async () => {
const online = onlineStub();
const repository = new AccountRepository(online);
await expect(repository.create("", "secret")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
await expect(repository.create("healer", "")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
await expect(repository.authenticate("healer", "")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
expect(online.register).not.toHaveBeenCalled();
});
it("requires account creation before sign-in", async () => {
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
await expect(repository.authenticate("new-healer", "secret")).resolves.toEqual({ ok: false, reason: "account-not-found" });
await expect(repository.create("new-healer", "secret")).resolves.toEqual({ ok: true, username: "new-healer" });
await expect(repository.authenticate("new-healer", "secret")).resolves.toEqual({ ok: true, username: "new-healer" });
it("creates and authenticates real server accounts", async () => {
const repository = new AccountRepository(onlineStub());
await expect(repository.create("Wayfinder", "long-password")).resolves.toEqual({ ok: true, username: "Wayfinder" });
await expect(repository.authenticate("Wayfinder", "long-password")).resolves.toEqual({ ok: true, username: "Wayfinder" });
});
it("rejects an incorrect password and duplicate account names", async () => {
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
await repository.create("Wayfinder", "correct");
await expect(repository.authenticate("wayfinder", "wrong")).resolves.toEqual({ ok: false, reason: "invalid-password" });
await expect(repository.create(" wayfinder ", "another")).resolves.toEqual({ ok: false, reason: "account-exists" });
});
it("persists only a derived password verifier", async () => {
const storage = memoryStorage();
const repository = new AccountRepository(storage, testHasher, () => "unique-salt");
await repository.create("healer", "plaintext-secret");
const persisted = storage.getItem("i-want-to-heal:accounts:v1") ?? "";
expect(persisted).toContain("derived:unique-salt:");
expect(persisted).not.toContain("plaintext-secret");
expect(JSON.parse(persisted).healer).not.toHaveProperty("password");
it("maps server conflicts, invalid credentials, and outages", async () => {
const conflict = new AccountRepository(onlineStub({ register: vi.fn(async () => { throw new OnlineApiError("exists", 409); }) }));
await expect(conflict.create("Wayfinder", "long-password")).resolves.toMatchObject({ ok: false, reason: "account-exists" });
const invalid = new AccountRepository(onlineStub({ login: vi.fn(async () => { throw new OnlineApiError("bad login", 401); }) }));
await expect(invalid.authenticate("Wayfinder", "wrong-password")).resolves.toMatchObject({ ok: false, reason: "invalid-password" });
const outage = new AccountRepository(onlineStub({ login: vi.fn(async () => { throw new OnlineApiError("offline", 0); }) }));
await expect(outage.authenticate("Wayfinder", "long-password")).resolves.toMatchObject({ ok: false, reason: "server-unavailable" });
});
});
+23 -115
View File
@@ -1,132 +1,40 @@
import type { StorageAdapter } from "./saveRepository";
const ACCOUNTS_KEY = "i-want-to-heal:accounts:v1";
const PASSWORD_ITERATIONS = 120_000;
interface AccountRecord {
username: string;
salt: string;
passwordHash: string;
}
type AccountMap = Record<string, AccountRecord>;
type PasswordHasher = (password: string, salt: string) => Promise<string>;
import { OnlineApiError, OnlineRepository, onlineRepository } from "./onlineRepository";
export type AccountResult =
| { ok: true; username: string }
| { ok: false; reason: "missing-credentials" | "account-exists" | "account-not-found" | "invalid-password" | "storage-unavailable" };
| { ok: false; reason: "missing-credentials" | "account-exists" | "invalid-password" | "server-unavailable" | "invalid-request"; message?: string };
const fallbackMemory = new Map<string, string>();
const fallbackStorage: StorageAdapter = {
getItem: (key) => fallbackMemory.get(key) ?? null,
setItem: (key, value) => { fallbackMemory.set(key, value); },
};
function browserStorage(): StorageAdapter {
try {
if (typeof localStorage !== "undefined") return localStorage;
} catch {
// Android WebView can deny storage before its host is ready.
}
return fallbackStorage;
function failure(error: unknown): Extract<AccountResult, { ok: false }> {
if (!(error instanceof OnlineApiError)) return { ok: false, reason: "server-unavailable" };
if (error.status === 0 || error.status >= 500) return { ok: false, reason: "server-unavailable", message: error.message };
if (error.status === 409) return { ok: false, reason: "account-exists", message: error.message };
if (error.status === 401) return { ok: false, reason: "invalid-password", message: error.message };
return { ok: false, reason: "invalid-request", message: error.message };
}
function encodeBytes(bytes: Uint8Array) {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function decodeBytes(value: string) {
const binary = atob(value);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
async function hashPassword(password: string, salt: string) {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits"],
);
const bits = await crypto.subtle.deriveBits({
name: "PBKDF2",
hash: "SHA-256",
salt: decodeBytes(salt),
iterations: PASSWORD_ITERATIONS,
}, key, 256);
return encodeBytes(new Uint8Array(bits));
}
function randomSalt() {
const salt = new Uint8Array(16);
crypto.getRandomValues(salt);
return encodeBytes(salt);
}
function canonicalUsername(username: string) {
return username.trim().toLocaleLowerCase();
}
function parseAccounts(raw: string | null): AccountMap {
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as AccountMap;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
/**
* Local prototype account registry. Passwords are salted and derived before
* persistence; replace this adapter with the server authentication API when
* remote sync leaves local prototype storage.
*/
export class AccountRepository {
constructor(
private readonly storage: StorageAdapter = browserStorage(),
private readonly hasher: PasswordHasher = hashPassword,
private readonly createSalt: () => string = randomSalt,
) {}
async create(usernameInput: string, password: string): Promise<AccountResult> {
const username = usernameInput.trim();
const canonical = canonicalUsername(username);
if (!canonical || !password) return { ok: false, reason: "missing-credentials" };
const accounts = this.read();
if (accounts[canonical]) return { ok: false, reason: "account-exists" };
constructor(private readonly online: OnlineRepository = onlineRepository) {}
async create(username: string, password: string): Promise<AccountResult> {
if (!username.trim() || !password) return { ok: false, reason: "missing-credentials" };
try {
const salt = this.createSalt();
accounts[canonical] = { username, salt, passwordHash: await this.hasher(password, salt) };
this.storage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
return { ok: true, username };
} catch {
return { ok: false, reason: "storage-unavailable" };
const account = await this.online.register(username, password);
return { ok: true, username: account.username };
} catch (error) {
return failure(error);
}
}
async authenticate(usernameInput: string, password: string): Promise<AccountResult> {
const canonical = canonicalUsername(usernameInput);
if (!canonical || !password) return { ok: false, reason: "missing-credentials" };
const account = this.read()[canonical];
if (!account) return { ok: false, reason: "account-not-found" };
async authenticate(username: string, password: string): Promise<AccountResult> {
if (!username.trim() || !password) return { ok: false, reason: "missing-credentials" };
try {
const passwordHash = await this.hasher(password, account.salt);
return passwordHash === account.passwordHash
? { ok: true, username: account.username }
: { ok: false, reason: "invalid-password" };
} catch {
return { ok: false, reason: "storage-unavailable" };
const account = await this.online.login(username, password);
return { ok: true, username: account.username };
} catch (error) {
return failure(error);
}
}
private read() {
return parseAccounts(this.storage.getItem(ACCOUNTS_KEY));
}
session() { return this.online.session(); }
logout() { return this.online.logout(); }
}
+9
View File
@@ -7,6 +7,7 @@ import { AVAILABLE_BOSS_IDS, BOSS_GROUPS } from "../game/bossCatalog";
describe("game mode configuration", () => {
it("separates randomized PVE from selectable Dungeons", () => {
expect(MODE_COPY["roguelike-pve"].title).toBe("PVE");
expect(MODE_COPY["rogue-trials"].detail).toContain("Endless");
expect(MODE_COPY.dungeons.title).toBe("Dungeons");
});
@@ -33,4 +34,12 @@ describe("game mode configuration", () => {
);
}
});
it("derives trophy ownership from the saved boss pet collection", () => {
const collections = buildCollections({ dropsFound: {}, petsFound: { "bulldrome-pet": 1 } }, { bulldrome: 37 });
const bulldrome = collections.flatMap((group) => group.bosses).find((boss) => boss.bossId === "bulldrome");
expect(bulldrome?.pet.count).toBe(1);
expect(bulldrome?.pet.chance).toBe("1 in 500");
expect(bulldrome?.kills).toBe(37);
});
});
+10 -1
View File
@@ -70,7 +70,14 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
title: "PVE",
description: "Enter without an encounter briefing, adapt to two randomized guardians, and build toward a full roguelike run.",
detail: "Two bosses selected when the run begins",
status: "Playable prototype",
status: "Playable now",
},
"rogue-trials": {
eyebrow: "14 hunters · five-round PVE trial",
title: "Rogue Trials",
description: "Build through four randomized dual-boss rounds, defeat an unseen trio, then leave with the clear or continue into endless combat.",
detail: "Endless mode replaces every fallen boss and tracks your best kill count",
status: "Playable now",
},
dungeons: {
eyebrow: "14 hunters · chosen encounter",
@@ -131,6 +138,8 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
alliesSaved: 0,
healingDone: 0,
bossKills: {},
highestRoguelikeRound: 0,
highestRogueTrialsEndlessKills: 0,
},
materials: [] as MaterialStack[],
collectionLog: createEmptyCollectionLog(),
+156
View File
@@ -0,0 +1,156 @@
import { Capacitor } from "@capacitor/core";
import type { HunterSave, SaveSlotId } from "./types";
import type { BossId } from "../game/types";
export interface OnlineAccount {
id: number;
username: string;
}
export interface OnlineSaveSlot {
slotId: SaveSlotId;
save: HunterSave;
updatedAt: string;
}
export interface LeaderboardEntry {
rank: number;
username: string;
hunterName: string;
slotId: SaveSlotId;
value: number;
}
export interface LeaderboardResult {
kind: "boss" | "roguelike" | "rogue-trials-endless";
bossId?: BossId;
top: LeaderboardEntry[];
current: LeaderboardEntry | null;
}
interface TokenStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
type Requester = typeof fetch;
const TOKEN_KEY = "i-want-to-heal:auth-token:v1";
const PRODUCTION_API_URL = "https://iwanttoheal.phenomrom.com";
function defaultApiBaseUrl() {
const configured = String(import.meta.env.VITE_API_BASE_URL ?? "");
if (configured) return configured;
return Capacitor.isNativePlatform() ? PRODUCTION_API_URL : "";
}
function browserStorage(): TokenStorage {
if (typeof localStorage !== "undefined") return localStorage;
const memory = new Map<string, string>();
return {
getItem: (key) => memory.get(key) ?? null,
setItem: (key, value) => { memory.set(key, value); },
removeItem: (key) => { memory.delete(key); },
};
}
export class OnlineApiError extends Error {
constructor(message: string, readonly status: number) {
super(message);
}
}
export class OnlineRepository {
private readonly baseUrl: string;
constructor(
private readonly requester: Requester = (...args) => fetch(...args),
private readonly storage: TokenStorage = browserStorage(),
baseUrl: string = defaultApiBaseUrl(),
) {
this.baseUrl = baseUrl.replace(/\/$/, "");
}
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
const token = this.storage.getItem(TOKEN_KEY);
if (token) headers.set("Authorization", `Bearer ${token}`);
let response: Response;
try {
response = await this.requester(`${this.baseUrl}${path}`, { ...init, headers });
} catch {
throw new OnlineApiError("Online server is unreachable.", 0);
}
const body = await response.json().catch(() => ({})) as { error?: string } & T;
if (!response.ok) throw new OnlineApiError(body.error ?? "Online request failed.", response.status);
return body;
}
private rememberAuth(result: { account: OnlineAccount; token: string }) {
this.storage.setItem(TOKEN_KEY, result.token);
return result.account;
}
async register(username: string, password: string): Promise<OnlineAccount> {
return this.rememberAuth(await this.request<{ account: OnlineAccount; token: string }>("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
}));
}
async login(username: string, password: string): Promise<OnlineAccount> {
return this.rememberAuth(await this.request<{ account: OnlineAccount; token: string }>("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
}));
}
async session(): Promise<OnlineAccount | null> {
if (!this.storage.getItem(TOKEN_KEY)) return null;
try {
return (await this.request<{ account: OnlineAccount }>("/api/auth/session")).account;
} catch (error) {
if (error instanceof OnlineApiError && error.status === 401) this.storage.removeItem(TOKEN_KEY);
if (error instanceof OnlineApiError && error.status === 401) return null;
throw error;
}
}
async logout(): Promise<void> {
try { await this.request("/api/auth/logout", { method: "POST" }); }
finally { this.storage.removeItem(TOKEN_KEY); }
}
async listSaves(): Promise<OnlineSaveSlot[]> {
return (await this.request<{ slots: OnlineSaveSlot[] }>("/api/saves")).slots;
}
async writeSave(save: HunterSave): Promise<HunterSave> {
return (await this.request<{ save: HunterSave }>(`/api/saves/${save.slotId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ save }),
})).save;
}
async readSave(slotId: SaveSlotId): Promise<HunterSave | null> {
return (await this.request<{ save: HunterSave | null }>(`/api/saves/${slotId}`)).save;
}
bossLeaderboard(bossId: BossId, slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/boss/${encodeURIComponent(bossId)}?slot=${slotId}`);
}
roguelikeLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/roguelike?slot=${slotId}`);
}
rogueTrialsEndlessLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/rogue-trials-endless?slot=${slotId}`);
}
}
export const onlineRepository = new OnlineRepository();
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { createHunterSave } from "./data";
import { resolveSaveContinuation, saveVersionsMatch } from "./saveContinuation";
function save(updatedAt: string) {
return createHunterSave(1, updatedAt, "Test Hunter");
}
describe("save continuation", () => {
it("creates only when neither copy exists", () => {
expect(resolveSaveContinuation({ local: null, online: null })).toBe("create");
});
it("uses whichever single copy exists", () => {
const local = save("2026-07-13T12:00:00.000Z");
const online = save("2026-07-13T13:00:00.000Z");
expect(resolveSaveContinuation({ local, online: null })).toBe("local");
expect(resolveSaveContinuation({ local: null, online })).toBe("online");
});
it("asks only when online copy is newer", () => {
const local = save("2026-07-13T12:00:00.000Z");
expect(resolveSaveContinuation({ local, online: save("2026-07-13T13:00:00.000Z") })).toBe("choose");
expect(resolveSaveContinuation({ local, online: save("2026-07-13T11:00:00.000Z") })).toBe("local");
expect(resolveSaveContinuation({ local, online: save(local.updatedAt) })).toBe("local");
});
it("matches downloaded copies by slot and timestamp", () => {
const local = save("2026-07-13T12:00:00.000Z");
expect(saveVersionsMatch(local, save(local.updatedAt))).toBe(true);
expect(saveVersionsMatch(local, save("2026-07-13T13:00:00.000Z"))).toBe(false);
expect(saveVersionsMatch(local, null)).toBe(false);
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { HunterSave, SaveSlotState } from "./types";
export type SaveContinuation = "create" | "local" | "online" | "choose";
function saveTimestamp(save: HunterSave): number | null {
const timestamp = Date.parse(save.updatedAt);
return Number.isFinite(timestamp) ? timestamp : null;
}
export function resolveSaveContinuation(slot: Pick<SaveSlotState, "local" | "online">): SaveContinuation {
if (!slot.local) return slot.online ? "online" : "create";
if (!slot.online) return "local";
const localTimestamp = saveTimestamp(slot.local);
const onlineTimestamp = saveTimestamp(slot.online);
if (localTimestamp !== null && onlineTimestamp !== null && onlineTimestamp > localTimestamp) return "choose";
return "local";
}
export function saveVersionsMatch(local: HunterSave | null, online: HunterSave | null): boolean {
return Boolean(local && online && local.slotId === online.slotId && local.updatedAt === online.updatedAt);
}
+36 -51
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { SaveRepository, type StorageAdapter } from "./saveRepository";
import { groupDrop } from "../game/progression/loot";
import { RUN_BUFF_ORDER } from "../game/roguelike";
function memoryStorage(): StorageAdapter {
const data = new Map<string, string>();
@@ -15,7 +16,7 @@ describe("SaveRepository", () => {
const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z");
repository.create(2, "Seraphine");
const slots = repository.list(null);
const slots = repository.listLocal();
expect(slots.map((slot) => slot.id)).toEqual([1, 2, 3]);
expect(slots[1].local?.updatedAt).toBe("2026-07-10T12:00:00.000Z");
expect(slots[1].local?.hunterName).toBe("Seraphine");
@@ -33,41 +34,30 @@ describe("SaveRepository", () => {
healers: { ...save.healers, druid: { ...save.healers.druid, level: 99 } },
}));
const slots = repository.list(null);
const slots = repository.listLocal();
expect(slots[0].local?.healers.druid.level).toBe(1);
expect(slots[2].local?.healers.druid.level).toBe(99);
expect(slots[2].local?.slotId).toBe(3);
});
it("uploads local state and can later overwrite it with the online version", () => {
it("replaces a local slot with a downloaded server snapshot", () => {
let now = "2026-07-10T12:00:00.000Z";
const repository = new SaveRepository(memoryStorage(), () => now);
repository.create(1, "Aelia");
now = "2026-07-10T13:00:00.000Z";
repository.upload(1, "healer@example.com");
repository.updateLocal(1, (save) => ({
...save,
healers: { ...save.healers, priest: { ...save.healers.priest, level: 40 } },
}));
expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(40);
expect(repository.list("healer@example.com")[0].online?.healers.priest.level).toBe(1);
const serverSave = repository.create(1, "Aelia");
serverSave.healers.priest.level = 40;
now = "2026-07-10T14:00:00.000Z";
repository.download(1, "healer@example.com");
expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(1);
expect(repository.list("healer@example.com")[0].local?.updatedAt).toBe(now);
repository.replaceLocal(serverSave);
expect(repository.listLocal()[0].local?.healers.priest.level).toBe(40);
expect(repository.listLocal()[0].local?.updatedAt).toBe(now);
});
it("deletes only the local copy so the online record can restore it", () => {
it("deletes the local copy without inventing an online record", () => {
const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z");
repository.create(1, "Aelia");
repository.upload(1, "healer");
repository.deleteLocal(1);
const slot = repository.list("healer")[0];
const slot = repository.listLocal()[0];
expect(slot.local).toBeNull();
expect(slot.online).not.toBeNull();
expect(slot.online).toBeNull();
});
it("keeps class progression and inventories independent under one hunter name", () => {
@@ -82,7 +72,7 @@ describe("SaveRepository", () => {
},
}));
const save = repository.list(null)[0].local!;
const save = repository.listLocal()[0].local!;
expect(save.hunterName).toBe("Aelia");
expect(save.activeClassId).toBe("druid");
expect(save.healers.druid.level).toBe(8);
@@ -101,7 +91,7 @@ describe("SaveRepository", () => {
activeClassId: "druid",
playSeconds: 999,
healers: Object.fromEntries(Object.entries(created.healers).map(([id, healer]) => [id, { ...healer, level: 27 }])),
stats: { totalBossKills: 22, flawlessClears: 9, alliesSaved: 4, healingDone: 1200, bossKills: { bulldrome: 22 } },
stats: { totalBossKills: 22, flawlessClears: 9, alliesSaved: 4, healingDone: 1200, bossKills: { bulldrome: 22 }, highestRoguelikeRound: 12 },
materials: [{ id: "legacy-boss-coin", name: "Legacy coin", quantity: 99, rarity: "common", itemLevel: 1, glyph: "R" }],
collectionLog: { dropsFound: { "legacy-boss-coin": 99 }, petsFound: { "bulldrome-pet": 1 } },
gearProgress: Object.fromEntries(Object.entries(created.gearProgress).map(([id, owner]) => [id, {
@@ -112,39 +102,19 @@ describe("SaveRepository", () => {
} as Record<string, unknown>;
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
const migrated = repository.list(null)[0].local!;
const migrated = repository.listLocal()[0].local!;
expect(migrated.schemaVersion).toBe(5);
expect(migrated.hunterName).toBe("Legacy");
expect(migrated.activeClassId).toBe("priest");
expect(migrated.playSeconds).toBe(0);
expect(Object.values(migrated.healers).every((healer) => healer.level === 1 && healer.inventory.length > 0)).toBe(true);
expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {} });
expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {}, highestRoguelikeRound: 0, highestRogueTrialsEndlessKills: 0 });
expect(migrated.materials).toEqual([]);
expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} });
expect(Object.values(migrated.gearProgress).every((owner) => owner.infusionAbilityId === null && owner.passiveInfusionId === null && Object.values(owner.slots).every((slot) => slot.level === 0))).toBe(true);
expect(JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}")["1"].schemaVersion).toBe(5);
});
it("resets and persists legacy cloud saves when they are listed", () => {
const storage = memoryStorage();
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
const created = repository.create(1, "Cloud Legacy");
const legacy = {
...created,
schemaVersion: 4,
stats: { ...created.stats, totalBossKills: 8, bossKills: { bulldrome: 8 } },
materials: [{ id: "legacy-boss-coin", name: "Legacy coin", quantity: 8, rarity: "common", itemLevel: 1, glyph: "R" }],
};
const cloudKey = "i-want-to-heal:saves:cloud:v1:cloud@example.com";
storage.setItem(cloudKey, JSON.stringify({ 1: legacy }));
const online = repository.list("cloud@example.com")[0].online!;
expect(online.schemaVersion).toBe(5);
expect(online.stats.totalBossKills).toBe(0);
expect(online.materials).toEqual([]);
expect(JSON.parse(storage.getItem(cloudKey) ?? "{}")["1"].schemaVersion).toBe(5);
});
it("preserves valid v5 progression and group-drop inventory", () => {
const storage = memoryStorage();
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
@@ -152,14 +122,16 @@ describe("SaveRepository", () => {
const drop = groupDrop("charge", "veteran");
created.healers.priest.level = 8;
created.stats = { ...created.stats, totalBossKills: 2, bossKills: { bulldrome: 2 } };
created.stats.highestRogueTrialsEndlessKills = 14;
created.materials = [{ id: drop.id, name: drop.name, quantity: 4, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }];
created.collectionLog = { dropsFound: { [drop.id]: 4 }, petsFound: { "bulldrome-pet": 1 } };
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
const migrated = repository.list(null)[0].local!;
const migrated = repository.listLocal()[0].local!;
expect(migrated.schemaVersion).toBe(5);
expect(migrated.healers.priest.level).toBe(8);
expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 });
expect(migrated.stats.highestRogueTrialsEndlessKills).toBe(14);
expect(migrated.materials[0]).toMatchObject({ id: drop.id, quantity: 4 });
expect(migrated.collectionLog).toEqual(created.collectionLog);
});
@@ -169,16 +141,29 @@ describe("SaveRepository", () => {
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
const created = repository.create(1, "Infused");
created.gearProgress.priest.infusionAbilityId = "priest-sanctuary";
created.gearProgress.priest.passiveInfusionId = "restoring-grace";
created.gearProgress.priest.passiveInfusionId = "restoring-grace" as never;
created.gearProgress.druid.passiveInfusionId = "mend-echo";
created.gearProgress.brann.infusionAbilityId = "removed-infusion";
created.gearProgress.brann.passiveInfusionId = "deep-wells";
created.gearProgress.brann.passiveInfusionId = "deep-wells" as never;
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
const migrated = repository.list(null)[0].local!;
const migrated = repository.listLocal()[0].local!;
expect(migrated.schemaVersion).toBe(5);
expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary");
expect(migrated.gearProgress.priest.passiveInfusionId).toBe("restoring-grace");
expect(migrated.gearProgress.priest.passiveInfusionId).toBeNull();
expect(migrated.gearProgress.druid.passiveInfusionId).toBe("mend-echo");
expect(migrated.gearProgress.brann.infusionAbilityId).toBeNull();
expect(migrated.gearProgress.brann.passiveInfusionId).toBeNull();
});
it("persists every new roguelike buff as a healer passive infusion", () => {
for (const passiveId of RUN_BUFF_ORDER) {
const storage = memoryStorage();
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
const created = repository.create(1, "Infused");
created.gearProgress.priest.passiveInfusionId = passiveId;
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
expect(repository.listLocal()[0].local?.gearProgress.priest.passiveInfusionId).toBe(passiveId);
}
});
});
+8 -21
View File
@@ -15,7 +15,6 @@ export interface StorageAdapter {
type SaveMap = Partial<Record<SaveSlotId, HunterSave>>;
const LOCAL_KEY = "i-want-to-heal:saves:local:v1";
const CLOUD_KEY = (accountId: string) => `i-want-to-heal:saves:cloud:v1:${accountId.toLowerCase()}`;
const SLOT_IDS: SaveSlotId[] = [1, 2, 3];
const fallbackMemory = new Map<string, string>();
@@ -150,6 +149,8 @@ function normalizeSave(value: unknown): HunterSave | null {
alliesSaved: Math.max(0, candidate.stats?.alliesSaved ?? 0),
healingDone: Math.max(0, candidate.stats?.healingDone ?? 0),
bossKills,
highestRoguelikeRound: Math.max(0, Math.floor(candidate.stats?.highestRoguelikeRound ?? 0)),
highestRogueTrialsEndlessKills: Math.max(0, Math.floor(candidate.stats?.highestRogueTrialsEndlessKills ?? 0)),
},
materials: normalizeMaterials(candidate.materials, collectionLog),
collectionLog,
@@ -181,10 +182,9 @@ export class SaveRepository {
private readonly now: () => string = () => new Date().toISOString(),
) {}
list(accountId: string | null): SaveSlotState[] {
listLocal(): SaveSlotState[] {
const local = this.read(LOCAL_KEY);
const online = accountId ? this.read(CLOUD_KEY(accountId)) : {};
return SLOT_IDS.map((id) => ({ id, local: local[id] ?? null, online: online[id] ?? null }));
return SLOT_IDS.map((id) => ({ id, local: local[id] ?? null, online: null }));
}
create(slotId: SaveSlotId, hunterName: string): HunterSave {
@@ -223,23 +223,10 @@ export class SaveRepository {
return copy;
}
upload(slotId: SaveSlotId, accountId: string): HunterSave | null {
const local = this.read(LOCAL_KEY)[slotId];
if (!local) return null;
const cloud = this.read(CLOUD_KEY(accountId));
const uploaded = { ...cloneSave(local), updatedAt: this.now() };
cloud[slotId] = uploaded;
this.write(CLOUD_KEY(accountId), cloud);
this.setLocal(uploaded);
return uploaded;
}
download(slotId: SaveSlotId, accountId: string): HunterSave | null {
const cloud = this.read(CLOUD_KEY(accountId))[slotId];
if (!cloud) return null;
const downloaded = { ...cloneSave(cloud), slotId, updatedAt: this.now() };
this.setLocal(downloaded);
return downloaded;
replaceLocal(save: HunterSave): HunterSave {
const normalized = { ...cloneSave(save), updatedAt: this.now() };
this.setLocal(normalized);
return normalized;
}
private setLocal(save: HunterSave): void {
+163 -35
View File
@@ -2,20 +2,34 @@ import { create } from "zustand";
import { DEFAULT_SETTINGS, normalizeHunterName } from "./data";
import { SaveRepository } from "./saveRepository";
import { AccountRepository, type AccountResult } from "./accountRepository";
import { onlineRepository, type OnlineSaveSlot } from "./onlineRepository";
import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types";
import type { BossId, HealerClassId, InventoryItem } from "../game/types";
import type { AbilityId, BossId, HealerClassId, InventoryItem, RunBuffId } from "../game/types";
import { RUN_BUFF_ORDER, RUN_BUFFS } from "../game/roguelike";
import { upgradeGearSlot, type GearOwnerId, type GearSlotId } from "../game/progression/gear";
import {
equipActiveInfusion,
equipPassiveInfusion,
infusionsForOwner,
} from "../game/progression/infusions";
import type { RunBuffId } from "../game/types";
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
import { highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat } from "../game/progression/hunterStats";
const repository = new SaveRepository();
const accounts = new AccountRepository();
const SETTINGS_KEY = "i-want-to-heal:settings:v1";
const onlineSaveQueues = new Map<SaveSlotId, Promise<HunterSave>>();
function writeServerSaveSerially(save: HunterSave): Promise<HunterSave> {
const previous = onlineSaveQueues.get(save.slotId);
const next = (previous ? previous.catch(() => save) : Promise.resolve(save))
.then(() => onlineRepository.writeSave(save));
onlineSaveQueues.set(save.slotId, next);
void next.finally(() => {
if (onlineSaveQueues.get(save.slotId) === next) onlineSaveQueues.delete(save.slotId);
}).catch(() => undefined);
return next;
}
function loadSettings(): GameSettings {
try {
@@ -38,14 +52,30 @@ function accountNotice(result: Extract<AccountResult, { ok: false }>, action: "s
switch (result.reason) {
case "missing-credentials": return "Enter both username and password.";
case "account-exists": return "Account already exists. Sign in with its password.";
case "account-not-found": return "Account not found. Create an account before enabling online sync.";
case "invalid-password": return "Username or password is incorrect.";
case "storage-unavailable": return action === "create"
? "Account could not be saved on this device. Continue offline or try again."
: "Account could not be verified on this device. Continue offline or try again.";
case "server-unavailable": return "Online server is unreachable. Continue offline or try again.";
case "invalid-request": return result.message ?? (action === "create" ? "Account could not be created." : "Sign-in failed.");
}
}
function refreshLocalSlots(current: readonly SaveSlotState[]): SaveSlotState[] {
return repository.listLocal().map((slot) => ({
...slot,
online: current.find((candidate) => candidate.id === slot.id)?.online ?? null,
}));
}
function mergeServerSlots(serverSlots: readonly OnlineSaveSlot[]): SaveSlotState[] {
return repository.listLocal().map((slot) => ({
...slot,
online: serverSlots.find((candidate) => candidate.slotId === slot.id)?.save ?? null,
}));
}
function replaceOnlineSlot(current: readonly SaveSlotState[], save: HunterSave): SaveSlotState[] {
return refreshLocalSlots(current).map((slot) => slot.id === save.slotId ? { ...slot, online: save } : slot);
}
export interface FrontendState {
screen: AppScreen;
accountId: string | null;
@@ -59,9 +89,12 @@ export interface FrontendState {
selectedGearSlotId: GearSlotId;
gearWorkshopMode: "upgrade" | "infusion";
selectedInfusionId: string;
selectedPassiveAbilityId: AbilityId;
selectedPassiveInfusionId: RunBuffId;
recentRewards: BossRewardAward[];
settings: GameSettings;
notice: string;
restoreSession: () => Promise<boolean>;
signIn: (username: string, password: string) => Promise<boolean>;
createAccount: (username: string, password: string) => Promise<boolean>;
continueOffline: () => void;
@@ -72,8 +105,8 @@ export interface FrontendState {
playSlot: (slotId: SaveSlotId) => void;
deleteSlot: (slotId: SaveSlotId) => void;
copySlot: (sourceId: SaveSlotId, targetId: SaveSlotId) => void;
uploadSlot: (slotId: SaveSlotId) => void;
downloadSlot: (slotId: SaveSlotId) => void;
uploadSlot: (slotId: SaveSlotId) => Promise<void>;
downloadSlot: (slotId: SaveSlotId) => Promise<void>;
selectMode: (mode: GameModeId) => void;
selectBoss: (bossId: BossId) => void;
selectDifficulty: (difficultySlug: DifficultySlug) => void;
@@ -81,6 +114,8 @@ export interface FrontendState {
selectGearSlot: (slotId: GearSlotId) => void;
selectGearWorkshopMode: (mode: "upgrade" | "infusion") => void;
selectInfusion: (infusionId: string) => void;
selectPassiveAbility: (abilityId: AbilityId) => void;
selectPassiveInfusion: (passiveId: RunBuffId) => void;
upgradeSelectedGear: () => boolean;
equipSelectedInfusion: () => boolean;
equipPassiveInfusion: (passiveId: RunBuffId) => boolean;
@@ -89,6 +124,8 @@ export interface FrontendState {
updateSetting: <K extends keyof GameSettings>(key: K, value: GameSettings[K]) => void;
touchActiveSave: () => void;
recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null;
recordRoguelikeDefeat: (round: number) => void;
recordRogueTrialsEndlessDefeat: (bossKills: number) => void;
clearRecentRewards: () => void;
clearNotice: () => void;
}
@@ -100,7 +137,7 @@ function activeSave(slots: SaveSlotState[], activeSlotId: SaveSlotId | null): Hu
export const useFrontendStore = create<FrontendState>((set, get) => ({
screen: "login",
accountId: null,
slots: repository.list(null),
slots: repository.listLocal(),
selectedSlotId: 1,
activeSlotId: null,
selectedMode: "roguelike-pve",
@@ -110,18 +147,38 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
selectedGearSlotId: "weapon",
gearWorkshopMode: "upgrade",
selectedInfusionId: infusionsForOwner("priest")[0].id,
selectedPassiveAbilityId: "mend",
selectedPassiveInfusionId: "mend-echo",
recentRewards: [],
settings: loadSettings(),
notice: "",
restoreSession: async () => {
try {
const account = await accounts.session();
if (!account) return false;
const serverSlots = await onlineRepository.listSaves();
set({ accountId: account.username, slots: mergeServerSlots(serverSlots), screen: "saves", notice: `Online session restored for ${account.username}.` });
return true;
} catch {
return false;
}
},
signIn: async (username, password) => {
const result = await accounts.authenticate(username, password);
if (!result.ok) {
set({ notice: accountNotice(result, "sign-in") });
return false;
}
set({ accountId: result.username, slots: repository.list(result.username), screen: "saves", notice: `Online sync connected as ${result.username}.` });
return true;
try {
const serverSlots = await onlineRepository.listSaves();
set({ accountId: result.username, slots: mergeServerSlots(serverSlots), screen: "saves", notice: `Online sync connected as ${result.username}.` });
return true;
} catch {
set({ notice: "Signed in, but server saves could not be loaded." });
return false;
}
},
createAccount: async (username, password) => {
const result = await accounts.create(username, password);
@@ -129,11 +186,20 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
set({ notice: accountNotice(result, "create") });
return false;
}
set({ accountId: result.username, slots: repository.list(result.username), screen: "saves", notice: `Account created. Online sync connected as ${result.username}.` });
return true;
try {
const serverSlots = await onlineRepository.listSaves();
set({ accountId: result.username, slots: mergeServerSlots(serverSlots), screen: "saves", notice: `Account created. Online sync connected as ${result.username}.` });
return true;
} catch {
set({ notice: "Account created, but server saves could not be loaded." });
return false;
}
},
continueOffline: () => set({ accountId: null, slots: repository.listLocal(), screen: "saves", notice: "Offline saves ready." }),
signOut: () => {
void accounts.logout();
set({ accountId: null, slots: repository.listLocal(), activeSlotId: null, screen: "login", notice: "Signed out. Offline saves remain on this device." });
},
continueOffline: () => set({ accountId: null, slots: repository.list(null), screen: "saves", notice: "Offline saves ready." }),
signOut: () => set({ accountId: null, slots: repository.list(null), activeSlotId: null, screen: "login", notice: "Signed out. Offline saves remain on this device." }),
navigate: (screen) => set({ screen, notice: "" }),
selectSlot: (selectedSlotId) => set({ selectedSlotId, notice: "" }),
createSlot: (slotId, rawHunterName) => {
@@ -143,18 +209,18 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
return false;
}
repository.create(slotId, hunterName);
set((state) => ({ slots: repository.list(state.accountId), selectedSlotId: slotId, notice: `${hunterName} created in offline slot ${slotId}.` }));
set((state) => ({ slots: refreshLocalSlots(state.slots), selectedSlotId: slotId, notice: `${hunterName} created in offline slot ${slotId}.` }));
return true;
},
playSlot: (slotId) => {
const local = repository.touch(slotId);
if (!local) return;
set((state) => ({ activeSlotId: slotId, selectedSlotId: slotId, slots: repository.list(state.accountId), screen: "home", notice: "Offline save loaded." }));
set((state) => ({ activeSlotId: slotId, selectedSlotId: slotId, slots: refreshLocalSlots(state.slots), screen: "home", notice: "Save loaded." }));
},
deleteSlot: (slotId) => {
repository.deleteLocal(slotId);
set((state) => ({
slots: repository.list(state.accountId),
slots: refreshLocalSlots(state.slots),
activeSlotId: state.activeSlotId === slotId ? null : state.activeSlotId,
notice: `Local slot ${slotId} deleted. Online copy preserved.`,
}));
@@ -162,19 +228,36 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
copySlot: (sourceId, targetId) => {
const copy = repository.copyLocal(sourceId, targetId);
if (!copy) return;
set((state) => ({ slots: repository.list(state.accountId), selectedSlotId: targetId, notice: `Slot ${sourceId} copied to slot ${targetId}.` }));
set((state) => ({ slots: refreshLocalSlots(state.slots), selectedSlotId: targetId, notice: `Slot ${sourceId} copied to slot ${targetId}.` }));
},
uploadSlot: (slotId) => {
uploadSlot: async (slotId) => {
const { accountId } = get();
if (!accountId) return set({ notice: "Sign in before syncing online." });
const uploaded = repository.upload(slotId, accountId);
set({ slots: repository.list(accountId), notice: uploaded ? `Slot ${slotId} synced to server.` : "No offline save to sync." });
const local = repository.listLocal().find((slot) => slot.id === slotId)?.local;
if (!local) return set({ notice: "No offline save to sync." });
try {
const uploaded = await writeServerSaveSerially(local);
if (get().accountId === accountId) {
set((state) => ({ slots: replaceOnlineSlot(state.slots, uploaded), notice: `Slot ${slotId} synced to TrueNAS.` }));
}
} catch (error) {
set({ notice: error instanceof Error ? error.message : "Save upload failed." });
}
},
downloadSlot: (slotId) => {
downloadSlot: async (slotId) => {
const { accountId } = get();
if (!accountId) return set({ notice: "Sign in before downloading an online save." });
const downloaded = repository.download(slotId, accountId);
set({ slots: repository.list(accountId), notice: downloaded ? `Slot ${slotId} overwritten with online version.` : "No online version exists for this slot." });
try {
await onlineSaveQueues.get(slotId)?.catch(() => undefined);
const serverSave = await onlineRepository.readSave(slotId);
if (!serverSave) return set({ notice: "No online version exists for this slot." });
const downloaded = repository.replaceLocal(serverSave);
if (get().accountId === accountId) {
set((state) => ({ slots: replaceOnlineSlot(state.slots, downloaded), notice: `Slot ${slotId} downloaded from TrueNAS.` }));
}
} catch (error) {
set({ notice: error instanceof Error ? error.message : "Save download failed." });
}
},
selectMode: (selectedMode) => set({ selectedMode, screen: "mode", notice: "" }),
selectBoss: (selectedBossId) => set({ selectedBossId, notice: "" }),
@@ -187,6 +270,15 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
selectGearSlot: (selectedGearSlotId) => set({ selectedGearSlotId, notice: "" }),
selectGearWorkshopMode: (gearWorkshopMode) => set({ gearWorkshopMode, notice: "" }),
selectInfusion: (selectedInfusionId) => set({ selectedInfusionId, notice: "" }),
selectPassiveAbility: (selectedPassiveAbilityId) => {
const selectedPassiveInfusionId = RUN_BUFF_ORDER.find((id) => RUN_BUFFS[id].abilityId === selectedPassiveAbilityId) ?? "mend-echo";
set({ selectedPassiveAbilityId, selectedPassiveInfusionId, notice: "" });
},
selectPassiveInfusion: (selectedPassiveInfusionId) => set({
selectedPassiveAbilityId: RUN_BUFFS[selectedPassiveInfusionId].abilityId,
selectedPassiveInfusionId,
notice: "",
}),
upgradeSelectedGear: () => {
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId } = get();
if (!activeSlotId) return false;
@@ -203,7 +295,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
return save;
}
});
set({ slots: repository.list(accountId), notice: message });
set((state) => ({ slots: refreshLocalSlots(state.slots), notice: message }));
return upgraded;
},
equipSelectedInfusion: () => {
@@ -223,7 +315,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
return save;
}
});
set({ slots: repository.list(accountId), notice: message });
set((state) => ({ slots: refreshLocalSlots(state.slots), notice: message }));
return equipped;
},
equipPassiveInfusion: (passiveId) => {
@@ -242,7 +334,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
return save;
}
});
set({ slots: repository.list(accountId), notice: message });
set((state) => ({ slots: refreshLocalSlots(state.slots), notice: message }));
return equipped;
},
selectHealerClass: (classId) => {
@@ -251,7 +343,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
const updated = repository.updateLocal(activeSlotId, (save) => ({ ...save, activeClassId: classId }));
if (!updated) return;
set({
slots: repository.list(accountId),
slots: refreshLocalSlots(get().slots),
selectedGearOwnerId: classId,
selectedInfusionId: infusionsForOwner(classId)[0].id,
notice: `${updated.healers[classId].level > 1 ? "Level " + updated.healers[classId].level + " " : ""}${classId[0].toUpperCase() + classId.slice(1)} selected.`,
@@ -267,7 +359,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
[save.activeClassId]: { ...save.healers[save.activeClassId], inventory: structuredClone(inventory) },
},
}));
set({ slots: repository.list(accountId) });
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
},
updateSetting: (key, value) => set((state) => {
const settings = { ...state.settings, [key]: value };
@@ -278,10 +370,10 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
const { activeSlotId, accountId } = get();
if (!activeSlotId) return;
repository.touch(activeSlotId);
set({ slots: repository.list(accountId) });
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
},
recordBossVictory: (bossId, difficultySlug) => {
const { activeSlotId, accountId } = get();
const { activeSlotId } = get();
if (!activeSlotId) return null;
let awarded: BossRewardAward | null = null;
repository.updateLocal(activeSlotId, (save) => {
@@ -296,17 +388,44 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
};
});
set((state) => ({
slots: repository.list(accountId),
recentRewards: awarded ? [...state.recentRewards, awarded] : state.recentRewards,
notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved offline.` : "Boss clear saved offline.",
slots: refreshLocalSlots(state.slots),
recentRewards: awarded ? [...state.recentRewards, awarded].slice(-12) : state.recentRewards,
notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved.` : "Boss clear saved.",
}));
return awarded;
},
recordRoguelikeDefeat: (round) => {
const { activeSlotId } = get();
if (!activeSlotId) return;
const updated = repository.updateLocal(activeSlotId, (save) => ({
...save,
stats: {
...save.stats,
highestRoguelikeRound: highestRoguelikeRoundAfterDefeat(save.stats.highestRoguelikeRound, round),
},
}));
if (!updated) return;
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
},
recordRogueTrialsEndlessDefeat: (bossKills) => {
const { activeSlotId } = get();
if (!activeSlotId) return;
const updated = repository.updateLocal(activeSlotId, (save) => ({
...save,
stats: {
...save.stats,
highestRogueTrialsEndlessKills: highestEndlessBossKillsAfterDefeat(save.stats.highestRogueTrialsEndlessKills, bossKills),
},
}));
if (!updated) return;
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
},
clearRecentRewards: () => set({ recentRewards: [] }),
clearNotice: () => set({ notice: "" }),
}));
export type FrontendSnapshot = Omit<FrontendState,
| "restoreSession"
| "signIn"
| "createAccount"
| "continueOffline"
@@ -326,6 +445,8 @@ export type FrontendSnapshot = Omit<FrontendState,
| "selectGearSlot"
| "selectGearWorkshopMode"
| "selectInfusion"
| "selectPassiveAbility"
| "selectPassiveInfusion"
| "upgradeSelectedGear"
| "equipSelectedInfusion"
| "equipPassiveInfusion"
@@ -334,12 +455,15 @@ export type FrontendSnapshot = Omit<FrontendState,
| "updateSetting"
| "touchActiveSave"
| "recordBossVictory"
| "recordRoguelikeDefeat"
| "recordRogueTrialsEndlessDefeat"
| "clearRecentRewards"
| "clearNotice"
>;
export function getFrontendSnapshot(): FrontendSnapshot {
const {
restoreSession: _restoreSession,
signIn: _signIn,
createAccount: _createAccount,
continueOffline: _continueOffline,
@@ -359,6 +483,8 @@ export function getFrontendSnapshot(): FrontendSnapshot {
selectGearSlot: _selectGearSlot,
selectGearWorkshopMode: _selectGearWorkshopMode,
selectInfusion: _selectInfusion,
selectPassiveAbility: _selectPassiveAbility,
selectPassiveInfusion: _selectPassiveInfusion,
upgradeSelectedGear: _upgradeSelectedGear,
equipSelectedInfusion: _equipSelectedInfusion,
equipPassiveInfusion: _equipPassiveInfusion,
@@ -367,6 +493,8 @@ export function getFrontendSnapshot(): FrontendSnapshot {
updateSetting: _updateSetting,
touchActiveSave: _touchActiveSave,
recordBossVictory: _recordBossVictory,
recordRoguelikeDefeat: _recordRoguelikeDefeat,
recordRogueTrialsEndlessDefeat: _recordRogueTrialsEndlessDefeat,
clearRecentRewards: _clearRecentRewards,
clearNotice: _clearNotice,
...snapshot
+3 -1
View File
@@ -5,7 +5,7 @@ import type { CollectionLog, MaterialStack } from "../game/progression/loot";
export type SaveSlotId = 1 | 2 | 3;
export type AppScreen = "login" | "saves" | "home" | "profile" | "gear" | "settings" | "mode" | "game";
export type GameModeId = "roguelike-pve" | "dungeons" | "roguelike-pvp" | "stadium-pvp";
export type GameModeId = "roguelike-pve" | "rogue-trials" | "dungeons" | "roguelike-pvp" | "stadium-pvp";
export interface CollectionDrop {
id: string;
@@ -41,6 +41,8 @@ export interface HunterStats {
alliesSaved: number;
healingDone: number;
bossKills: Record<string, number>;
highestRoguelikeRound: number;
highestRogueTrialsEndlessKills: number;
}
export interface HealerProgress {
+4 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { createClassInventory } from "./healers";
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
import { canAddBossToEncounter } from "./bossSelection";
import { useGameStore } from "./store";
import type { BossId } from "./types";
@@ -27,7 +28,9 @@ function simulateControlledBattle(bossIds: readonly [BossId, BossId], maxSeconds
describe("full-mechanics dual-boss battle simulations", () => {
const combinations: readonly (readonly [BossId, BossId])[] = AVAILABLE_BOSS_IDS.flatMap((first, index) =>
AVAILABLE_BOSS_IDS.slice(index + 1).map((second) => [first, second] as const),
AVAILABLE_BOSS_IDS.slice(index + 1)
.filter((second) => canAddBossToEncounter([first], second))
.map((second) => [first, second] as const),
);
it.each(combinations)("party rotations defeat %s + %s", (first, second) => {
+47 -1
View File
@@ -1,11 +1,13 @@
import { describe, expect, it } from "vitest";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "./bossCatalog";
import { createBossMotionState, createBossState } from "./bossMechanics";
import { BOSS_MECHANIC_POOL, BOSS_MECHANIC_REGISTRY, bossMechanicName } from "./bosses/mechanicPool";
import { ALTERNATE_BOSS_CONFIG } from "./bossVisuals";
describe("boss catalog", () => {
it("derives the available roster from every catalog definition", () => {
expect(AVAILABLE_BOSS_IDS).toEqual(Object.keys(BOSS_DEFINITIONS));
expect(new Set(AVAILABLE_BOSS_IDS)).toHaveLength(27);
expect(new Set(AVAILABLE_BOSS_IDS)).toHaveLength(28);
});
it("assigns every boss to one compatible mechanic group", () => {
@@ -42,4 +44,48 @@ describe("boss catalog", () => {
expect(state.hp).toBe(state.maxHp);
expect(motion.bossId).toBe(bossId);
});
it("resolves every boss loadout through the canonical mechanic registry", () => {
const assigned = new Set(Object.values(BOSS_DEFINITIONS).flatMap((boss) => boss.mechanicIds));
expect(assigned).toEqual(new Set(Object.keys(BOSS_MECHANIC_REGISTRY)));
expect(Object.keys(BOSS_MECHANIC_REGISTRY)).toEqual(BOSS_MECHANIC_POOL.map((mechanic) => mechanic.id));
for (const boss of Object.values(BOSS_DEFINITIONS)) {
expect(boss.mechanicIds).toContain("basic-melee");
for (const mechanicId of boss.mechanicIds) {
expect(BOSS_MECHANIC_REGISTRY[mechanicId].name).toBe(bossMechanicName(mechanicId));
}
}
});
it("gives every boss a distinct mechanic kit", () => {
const kits = Object.values(BOSS_DEFINITIONS).map((boss) => boss.mechanicIds.join(","));
expect(new Set(kits)).toHaveLength(AVAILABLE_BOSS_IDS.length);
});
it("uses the boar model's full authored death performance", () => {
expect(ALTERNATE_BOSS_CONFIG["bristlequake-boar"].death).toBe("Dying");
});
it("assigns Gravehorn to the underfilled burrow group with its optimized animation set", () => {
expect(BOSS_GROUPS.find((group) => group.id === "burrow-eruption")?.bossIds).toContain("gravehorn-triceratops");
expect(ALTERNATE_BOSS_CONFIG["gravehorn-triceratops"]).toMatchObject({
idle: "Gravehorn|Idle",
move: "Armature|Walk",
attack: "Armature|Roar",
special: "Armature|RiseUp",
death: "Armature|Fall",
});
expect(ALTERNATE_BOSS_CONFIG["gravehorn-triceratops"].optimizedUrl).toContain("gravehorn-triceratops-uastc");
});
it("uses original animated creatures instead of the retired chicken and frog visuals", () => {
expect(ALTERNATE_BOSS_CONFIG["cluckhorn-colossus"]).toMatchObject({
idle: "Idle", move: "Scuttle", attack: "BeakRend", special: "FurnaceBurst", death: "Death",
});
expect(ALTERNATE_BOSS_CONFIG["cluckhorn-colossus"].url).toContain("brassbeak-basilisk");
expect(ALTERNATE_BOSS_CONFIG["mirelord-frog"]).toMatchObject({
idle: "Idle", move: "BurrowRush", attack: "RootPummel", special: "SporeEruption", death: "Death",
});
expect(ALTERNATE_BOSS_CONFIG["mirelord-frog"].url).toContain("bogbell-myconid");
});
});
+43 -34
View File
@@ -1,4 +1,5 @@
import type { BossId } from "./types";
import { bossMechanicName } from "./bosses/mechanicPool";
import type { BossId, BossMechanicId } from "./types";
export type BossArchetype =
| "bull"
@@ -46,7 +47,7 @@ export interface BossDefinition {
failure: string;
mapTitle: string;
mapCopy: string;
mechanics: readonly [string, string, ...string[]];
mechanicIds: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]];
maxHp: number;
archetype: BossArchetype;
groupId: BossGroupId;
@@ -58,16 +59,20 @@ interface BossSeed {
icon: string;
accent: string;
summary: string;
mechanics: readonly [string, string, ...string[]];
mechanicIds: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]];
maxHp: number;
archetype: BossArchetype;
}
function boss(id: BossId, index: number, seed: BossSeed): BossDefinition {
const [first, second] = seed.mechanics;
const [firstId, secondId] = seed.mechanicIds;
const first = bossMechanicName(firstId);
const second = bossMechanicName(secondId);
const mechanicIds = [...seed.mechanicIds, "basic-melee"] as BossDefinition["mechanicIds"];
return {
id,
...seed,
mechanicIds,
groupId: BOSS_GROUP_BY_BOSS_ID[id],
trial: `Trial ${String(index + 1).padStart(2, "0")} · ${seed.title}`,
briefing: `Read ${first}, then preserve open ground for ${second}.`,
@@ -80,111 +85,115 @@ function boss(id: BossId, index: number, seed: BossSeed): BossDefinition {
const BOSS_SEEDS: Record<BossId, BossSeed> = {
bulldrome: {
name: "Bulldrome", title: "Hall of the Cinder Bull", icon: "♜", accent: "#e2744e",
summary: "Charges marked lanes and crushes grouped targets.", mechanics: ["Bull Charge", "Crushing Pounce"], maxHp: 500, archetype: "bull",
summary: "Charges marked lanes and crushes grouped targets.", mechanicIds: ["bull-charge", "crushing-pounce", "cinder-nova", "ember-brand"], maxHp: 500, archetype: "bull",
},
"sandglass-scorpion": {
name: "Sandglass Scorpion", title: "The Sunken Hour", icon: "⌛", accent: "#e9b94f",
summary: "Burrows beneath marked paths and erupts through timed hourglass zones.", mechanics: ["Burrow Rush", "Hourglass Eruption"], maxHp: 515, archetype: "burrower",
summary: "Burrows beneath marked paths and erupts through timed hourglass zones.", mechanicIds: ["burrow-rush", "hourglass-eruption", "memory-sequence"], maxHp: 515, archetype: "burrower",
},
"cragclaw-crab": {
name: "Cragclaw", title: "The Drowned Breakwater", icon: "♋", accent: "#49c7d4",
summary: "Scuttles through marked lanes and crushes the arena beneath tidal bursts.", mechanics: ["Sidewinder Rush", "Crushing Tide"], maxHp: 505, archetype: "crab",
summary: "Scuttles through marked lanes and crushes the arena beneath tidal bursts.", mechanicIds: ["sidewinder-rush", "crushing-tide", "aetheric-soak"], maxHp: 505, archetype: "crab",
},
"mournveil-ghost": {
name: "Mournveil", title: "The Silent Reliquary", icon: "◉", accent: "#9d72ff",
summary: "Cuts the arena twice with spectral lanes and leaves hungry rifts beneath allies.", mechanics: ["Soul Scissors", "Haunting Rifts"], maxHp: 505, archetype: "ghost",
summary: "Cuts the arena twice with spectral lanes and leaves hungry rifts beneath allies.", mechanicIds: ["vine-scissors", "haunting-rifts", "soul-siphon"], maxHp: 505, archetype: "ghost",
},
"crownshard-golem": {
name: "Crownshard Golem", title: "The Broken Coronation", icon: "♛", accent: "#e0bd45",
summary: "Sends royal shockwaves across the floor and calls crushing crown shards from above.", mechanics: ["Royal Shockwave", "Crownfall"], maxHp: 500, archetype: "golem",
summary: "Sends royal shockwaves across the floor and calls crushing crown shards from above.", mechanicIds: ["tri-burst", "ultimate-skyfall", "aetheric-soak"], maxHp: 500, archetype: "golem",
},
"crystal-bat-matriarch": {
name: "Crystal Bat Matriarch", title: "The Prism Echo", icon: "◈", accent: "#8eeaff",
summary: "Sonic rings force precise spacing while orbiting mirror shards fracture safe ground.", mechanics: ["Sonic Ring", "Mirror Shards"], maxHp: 480, archetype: "golem",
summary: "Sonic rings force precise spacing while orbiting mirror shards fracture safe ground.", mechanicIds: ["tri-burst", "prism-beam", "memory-sequence"], maxHp: 480, archetype: "golem",
},
"stormwool-alpaca": {
name: "Stormwool", title: "The Thunder Fleece", icon: "ϟ", accent: "#8fc7ff",
summary: "Gallops through charged lanes before crashing onto the marked healer.", mechanics: ["Storm Charge", "Cloudburst Pounce"], maxHp: 480, archetype: "bull",
summary: "Gallops through charged lanes before crashing onto the marked healer.", mechanicIds: ["bull-charge", "crushing-pounce", "stormfall"], maxHp: 480, archetype: "bull",
},
"cluckhorn-colossus": {
name: "Cluckhorn Colossus", title: "The Roostbreaker", icon: "✹", accent: "#f0b85d",
summary: "Stampedes sideways and drops cracking shell bursts on spread targets.", mechanics: ["Roost Rush", "Shellburst"], maxHp: 475, archetype: "crab",
name: "Brassbeak Basilisk", title: "The Furnace Nest", icon: "✹", accent: "#dba33e",
summary: "Scuttles through lateral lanes and vents furnace bursts on spread targets.", mechanicIds: ["sidewinder-rush", "crushing-tide", "meteor-spread"], maxHp: 475, archetype: "crab",
},
"ashwing-demon": {
name: "Ashwing", title: "The Cinder Choir", icon: "♠", accent: "#df665d",
summary: "Carves crossing fire lanes and opens persistent ember rifts.", mechanics: ["Ash Scissors", "Cinder Rifts"], maxHp: 515, archetype: "ghost",
summary: "Carves crossing fire lanes and opens persistent ember rifts.", mechanicIds: ["vine-scissors", "haunting-rifts", "cinder-nova"], maxHp: 515, archetype: "ghost",
},
"riftclaw-demon": {
name: "Riftclaw", title: "The Broken Duel", icon: "⚔", accent: "#d45cff",
summary: "Sidesteps around the tank before cutting single and crossed void lanes.", mechanics: ["Rift Blade", "Abyss Cross"], maxHp: 495, archetype: "duelist",
summary: "Sidesteps around the tank before cutting single and crossed void lanes.", mechanicIds: ["elemental-beam", "guardian-cross", "soul-siphon"], maxHp: 495, archetype: "duelist",
},
"tempestscale-dragon": {
name: "Tempestscale", title: "The Living Storm", icon: "☈", accent: "#5fc8e8",
summary: "Sweeps the arena with storm breath before marking allies for sky strikes.", mechanics: ["Tempest Breath", "Stormfall"], maxHp: 500, archetype: "sky-sweeper",
summary: "Sweeps the arena with storm breath before marking allies for sky strikes.", mechanicIds: ["storm-breath", "stormfall", "prism-beam"], maxHp: 500, archetype: "sky-sweeper",
},
emberfox: {
name: "Emberfox", title: "The Burning Trail", icon: "✦", accent: "#ff7b45",
summary: "Ricochets across the arena and leaves fire at every landing.", mechanics: ["Foxfire Rush", "Ember Pounce"], maxHp: 465, archetype: "ricochet",
summary: "Ricochets across the arena and leaves fire at every landing.", mechanicIds: ["ricochet-rush", "meteor-slam", "ember-brand"], maxHp: 465, archetype: "ricochet",
},
"mirelord-frog": {
name: "Mirelord", title: "The Drowned Bell", icon: "●", accent: "#73c96b",
summary: "Dives below the mire before erupting through timed bog zones.", mechanics: ["Mire Dive", "Bogglass Eruption"], maxHp: 490, archetype: "burrower",
name: "Bogbell Myconid", title: "The Drowned Bell", icon: "●", accent: "#8fdc69",
summary: "Roots below the mire before erupting through timed spore blooms.", mechanicIds: ["burrow-rush", "hourglass-eruption", "hollow-collapse"], maxHp: 490, archetype: "burrower",
},
"stonebreaker-giant": {
name: "Stonebreaker", title: "The Walking Crag", icon: "▰", accent: "#c89563",
summary: "Sends quake bands across the floor and rains boulders on spread allies.", mechanics: ["Crag Shockwave", "Boulderfall"], maxHp: 500, archetype: "golem",
summary: "Sends quake bands across the floor and rains boulders on spread allies.", mechanicIds: ["tri-burst", "ruin-quake", "meteor-spread"], maxHp: 500, archetype: "golem",
},
"glub-sovereign": {
name: "Glub Sovereign", title: "The Binding Ooze", icon: "◌", accent: "#6ce0b8",
summary: "Links two allies with living slime before seeding toxic pools.", mechanics: ["Ooze Tether", "Caustic Brood"], maxHp: 495, archetype: "web-caster",
summary: "Links two allies with living slime before seeding toxic pools.", mechanicIds: ["binding-web", "venom-purge", "hollow-collapse"], maxHp: 495, archetype: "web-caster",
},
"scrapking-goblin": {
name: "Scrapking", title: "The Jagged Throne", icon: "⚒", accent: "#d7a34b",
summary: "Repositions between attacks and fires improvised blade lanes.", mechanics: ["Scrap Blade", "Junkyard Cross"], maxHp: 485, archetype: "duelist",
summary: "Repositions between attacks and fires improvised blade lanes.", mechanicIds: ["elemental-beam", "guardian-cross", "meteor-spread"], maxHp: 485, archetype: "duelist",
},
"warcaller-orc": {
name: "Warcaller", title: "The Red Standard", icon: "⚑", accent: "#e4533f",
summary: "Tracks the party flank before cleaving single and crossed war lanes.", mechanics: ["Warpath Cleave", "Banner Cross"], maxHp: 500, archetype: "duelist",
summary: "Tracks the party flank before cleaving single and crossed war lanes.", mechanicIds: ["elemental-beam", "guardian-cross", "aetheric-soak"], maxHp: 500, archetype: "duelist",
},
"tuskmaw-orc": {
name: "Tuskmaw", title: "The Breaker Below", icon: "◈", accent: "#9eb25d",
summary: "Rushes laterally and crushes three marked allies beneath tusk bursts.", mechanics: ["Tusk Rush", "Groundbreaker"], maxHp: 525, archetype: "crab",
summary: "Rushes laterally and crushes three marked allies beneath tusk bursts.", mechanicIds: ["sidewinder-rush", "crushing-tide", "destruction-pulse"], maxHp: 525, archetype: "crab",
},
"broodfang-spider": {
name: "Broodfang", title: "The Silk Tyrant", icon: "✣", accent: "#b56cff",
summary: "Binds paired prey with silk before flooding safe ground with venom.", mechanics: ["Binding Web", "Venom Brood"], maxHp: 535, archetype: "web-caster",
summary: "Binds paired prey with silk before flooding safe ground with venom.", mechanicIds: ["binding-web", "venom-purge", "meteor-spread"], maxHp: 535, archetype: "web-caster",
},
"silkfang-spider": {
name: "Silkfang", title: "The Gloom Weaver", icon: "✤", accent: "#9d68d8",
summary: "Snares paired allies before seeding the arena with toxic nests.", mechanics: ["Silk Snare", "Venom Nest"], maxHp: 505, archetype: "web-caster",
summary: "Snares paired allies before seeding the arena with toxic nests.", mechanicIds: ["binding-web", "venom-purge", "soul-siphon"], maxHp: 505, archetype: "web-caster",
},
"thorncrown-stag": {
name: "Thorncrown", title: "The Briar Hart", icon: "♧", accent: "#7fc46b",
summary: "Charges through thorn lanes and leaps onto grouped prey.", mechanics: ["Briar Charge", "Crown Pounce"], maxHp: 510, archetype: "bull",
summary: "Charges through thorn lanes and leaps onto grouped prey.", mechanicIds: ["bull-charge", "crushing-pounce", "hollow-collapse"], maxHp: 510, archetype: "bull",
},
"sky-totem": {
name: "Sky Totem", title: "The Hollow Idol", icon: "☼", accent: "#69d4d1",
summary: "Cuts the floor with spirit lanes and anchors hungry wind rifts.", mechanics: ["Spirit Scissors", "Wind Rifts"], maxHp: 505, archetype: "ghost",
summary: "Cuts the floor with spirit lanes and anchors hungry wind rifts.", mechanicIds: ["vine-scissors", "haunting-rifts", "prism-beam"], maxHp: 505, archetype: "ghost",
},
"razorcrest-raptor": {
name: "Razorcrest", title: "The Hunting Circuit", icon: "➳", accent: "#d9c45a",
summary: "Rebounds through hunting lanes and tears open impact pools.", mechanics: ["Raptor Rush", "Talon Slam"], maxHp: 500, archetype: "ricochet",
summary: "Rebounds through hunting lanes and tears open impact pools.", mechanicIds: ["ricochet-rush", "meteor-slam", "prism-beam"], maxHp: 500, archetype: "ricochet",
},
"bristlequake-boar": {
name: "Bristlequake", title: "The Iron Tusk", icon: "♞", accent: "#d47b45",
summary: "Breaks formation with armored rushes, quakes, and radial fault lines.", mechanics: ["Tusk Charge", "Bristle Quake"], maxHp: 545, archetype: "ram",
summary: "Breaks formation with armored rushes, quakes, and radial fault lines.", mechanicIds: ["destruction-rush", "ruin-quake", "destruction-pulse"], maxHp: 545, archetype: "ram",
},
"moonfang-wolf": {
name: "Moonfang", title: "The Silver Pursuit", icon: "☾", accent: "#9db9e5",
summary: "Ricochets between marked lanes and leaves moonfire at each strike.", mechanics: ["Lunar Rush", "Moonfall"], maxHp: 495, archetype: "ricochet",
summary: "Ricochets between marked lanes and leaves moonfire at each strike.", mechanicIds: ["ricochet-rush", "meteor-slam", "soul-siphon"], maxHp: 495, archetype: "ricochet",
},
"frostmaw-yeti": {
name: "Frostmaw", title: "The White Avalanche", icon: "❄", accent: "#8ed8ef",
summary: "Scuttles through ice lanes and buries spread allies beneath frost bursts.", mechanics: ["Avalanche Rush", "Frost Crush"], maxHp: 550, archetype: "crab",
summary: "Scuttles through ice lanes and buries spread allies beneath frost bursts.", mechanicIds: ["sidewinder-rush", "crushing-tide", "hollow-collapse"], maxHp: 550, archetype: "crab",
},
"rimeclaw-yeti": {
name: "Rimeclaw", title: "The Frozen Duel", icon: "✥", accent: "#75bfe8",
summary: "Sidesteps between frost strikes before forming a lethal ice cross.", mechanics: ["Rime Blade", "Glacier Cross"], maxHp: 500, archetype: "duelist",
summary: "Sidesteps between frost strikes before forming a lethal ice cross.", mechanicIds: ["elemental-beam", "guardian-cross", "memory-sequence"], maxHp: 500, archetype: "duelist",
},
"gravehorn-triceratops": {
name: "Gravehorn", title: "The Fossil Wake", icon: "☠", accent: "#d8c79b",
summary: "Burrows through fossil trails, erupts beneath the party, and releases a radial grave pulse.", mechanicIds: ["burrow-rush", "hourglass-eruption", "destruction-pulse"], maxHp: 540, archetype: "burrower",
},
};
@@ -215,7 +224,7 @@ export const BOSS_GROUPS: readonly BossGroupDefinition[] = [
},
{
id: "burrow-eruption", letter: "F", name: "Burrow / Eruption", coreMechanic: "Burrow and Eruption",
bossIds: ["sandglass-scorpion", "mirelord-frog"], archetypes: ["burrower"],
bossIds: ["sandglass-scorpion", "mirelord-frog", "gravehorn-triceratops"], archetypes: ["burrower"],
},
{
id: "scuttle-burst", letter: "G", name: "Scuttle / Burst", coreMechanic: "Scuttle and Burst",
+8
View File
@@ -0,0 +1,8 @@
export const BOSS_DEATH_HOLD_SECONDS = 2.5;
export const BOSS_DEATH_FADE_SECONDS = 0.75;
export const BOSS_DEATH_DESPAWN_SECONDS = BOSS_DEATH_HOLD_SECONDS + BOSS_DEATH_FADE_SECONDS;
export function bossDeathOpacity(elapsedSeconds: number) {
if (elapsedSeconds <= BOSS_DEATH_HOLD_SECONDS) return 1;
return Math.max(0, 1 - (elapsedSeconds - BOSS_DEATH_HOLD_SECONDS) / BOSS_DEATH_FADE_SECONDS);
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { BOSS_HAZARD_VFX, bossHazardVfxProfile, bossHazardVfxSeed } from "./bossHazardVisuals";
import type { CircleHazardKind } from "./types";
const HAZARD_KINDS: readonly CircleHazardKind[] = [
"venom_pool",
"skyfall",
"quake",
"lava_pool",
"stinger_eruption",
"hourglass",
"tidal_burst",
"soul_rift",
"crownfall",
"royal_shockwave",
];
describe("boss hazard visuals", () => {
it("defines a bounded effect profile for every circular hazard kind", () => {
expect(Object.keys(BOSS_HAZARD_VFX).sort()).toEqual([...HAZARD_KINDS].sort());
for (const kind of HAZARD_KINDS) {
const profile = bossHazardVfxProfile(kind);
expect(profile.particleCount).toBeGreaterThanOrEqual(6);
expect(profile.particleCount).toBeLessThanOrEqual(9);
expect(profile.height).toBeGreaterThan(0);
expect(profile.warningColor).not.toBe(profile.highlightColor);
}
});
it("uses shape and motion, not color alone, to distinguish common attacks", () => {
expect(bossHazardVfxProfile("venom_pool")).toMatchObject({ motion: "bubble", shape: "orb" });
expect(bossHazardVfxProfile("stinger_eruption")).toMatchObject({ motion: "spike", shape: "spike" });
expect(bossHazardVfxProfile("royal_shockwave")).toMatchObject({ motion: "shockwave", shape: "crystal" });
expect(bossHazardVfxProfile("soul_rift")).toMatchObject({ motion: "orbit", shape: "orb" });
});
it("creates stable normalized seeds without runtime randomness", () => {
expect(bossHazardVfxSeed("meteor-12")).toBe(bossHazardVfxSeed("meteor-12"));
expect(bossHazardVfxSeed("meteor-12")).not.toBe(bossHazardVfxSeed("meteor-13"));
expect(bossHazardVfxSeed("meteor-12")).toBeGreaterThanOrEqual(0);
expect(bossHazardVfxSeed("meteor-12")).toBeLessThanOrEqual(1);
});
});
+146
View File
@@ -0,0 +1,146 @@
import type { CircleHazardKind } from "./types";
export type BossHazardVfxMotion = "bubble" | "fall" | "flame" | "orbit" | "shockwave" | "spike" | "surge";
export type BossHazardVfxShape = "crystal" | "orb" | "spike";
export interface BossHazardVfxProfile {
readonly warningColor: string;
readonly activeColor: string;
readonly highlightColor: string;
readonly motion: BossHazardVfxMotion;
readonly shape: BossHazardVfxShape;
readonly particleCount: number;
readonly height: number;
readonly spinSpeed: number;
readonly columnOpacity: number;
}
/**
* Rendering-only identity for circular hazards. Combat rules remain in boss
* mechanics; renderers can project this profile without branching on boss id.
*/
export const BOSS_HAZARD_VFX: Record<CircleHazardKind, BossHazardVfxProfile> = {
venom_pool: {
warningColor: "#9a54d6",
activeColor: "#6fca45",
highlightColor: "#d6ff7d",
motion: "bubble",
shape: "orb",
particleCount: 7,
height: 0.8,
spinSpeed: 0.35,
columnOpacity: 0,
},
skyfall: {
warningColor: "#ff4d42",
activeColor: "#ff792e",
highlightColor: "#ffe0a1",
motion: "fall",
shape: "crystal",
particleCount: 6,
height: 5.2,
spinSpeed: 0.55,
columnOpacity: 0.13,
},
quake: {
warningColor: "#ff5549",
activeColor: "#d64226",
highlightColor: "#ffd08a",
motion: "shockwave",
shape: "crystal",
particleCount: 8,
height: 0.75,
spinSpeed: 0.18,
columnOpacity: 0,
},
lava_pool: {
warningColor: "#ff6a31",
activeColor: "#ff3b18",
highlightColor: "#ffd05a",
motion: "flame",
shape: "spike",
particleCount: 7,
height: 1.25,
spinSpeed: 0.28,
columnOpacity: 0.04,
},
stinger_eruption: {
warningColor: "#ff4b42",
activeColor: "#cf2442",
highlightColor: "#ffd2b3",
motion: "spike",
shape: "spike",
particleCount: 7,
height: 1.75,
spinSpeed: 0.12,
columnOpacity: 0,
},
hourglass: {
warningColor: "#ed8b3a",
activeColor: "#c16a28",
highlightColor: "#ffe3a3",
motion: "orbit",
shape: "crystal",
particleCount: 8,
height: 2.2,
spinSpeed: 1.35,
columnOpacity: 0.1,
},
tidal_burst: {
warningColor: "#36cae2",
activeColor: "#178fc6",
highlightColor: "#c9fbff",
motion: "surge",
shape: "orb",
particleCount: 8,
height: 1.65,
spinSpeed: 0.75,
columnOpacity: 0.08,
},
soul_rift: {
warningColor: "#aa78ff",
activeColor: "#6f42cf",
highlightColor: "#ebd8ff",
motion: "orbit",
shape: "orb",
particleCount: 8,
height: 2.45,
spinSpeed: 1.65,
columnOpacity: 0.14,
},
crownfall: {
warningColor: "#e7c94d",
activeColor: "#d95331",
highlightColor: "#fff1a8",
motion: "fall",
shape: "crystal",
particleCount: 7,
height: 5.6,
spinSpeed: -0.65,
columnOpacity: 0.15,
},
royal_shockwave: {
warningColor: "#edcb55",
activeColor: "#c94631",
highlightColor: "#fff0a6",
motion: "shockwave",
shape: "crystal",
particleCount: 9,
height: 0.85,
spinSpeed: -0.22,
columnOpacity: 0,
},
};
export function bossHazardVfxProfile(kind: CircleHazardKind) {
return BOSS_HAZARD_VFX[kind];
}
export function bossHazardVfxSeed(id: string) {
let hash = 2166136261;
for (let index = 0; index < id.length; index += 1) {
hash ^= id.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) / 0xffffffff;
}
-2
View File
@@ -17,9 +17,7 @@ describe("boss home positioning", () => {
it.each(AVAILABLE_BOSS_IDS)("moves %s back toward its center slot while idle", (bossId) => {
const motion = createBossMotionState(bossId);
motion.position = [7, 5];
motion.nextChargeAt = Number.POSITIVE_INFINITY;
motion.nextMechanicAt = Number.POSITIVE_INFINITY;
motion.nextPoolMechanicAt = Number.POSITIVE_INFINITY;
const before = Math.hypot(
motion.position[0] - (ARENA_CENTER[0] + motion.formationOffsetX),
motion.position[1] - ARENA_CENTER[1],
+31 -422
View File
@@ -1,392 +1,40 @@
import { BOSS_ARCHETYPE_BY_ID, BOSS_DEFINITIONS, type BossArchetype } from "./bossCatalog";
import { clampToArena } from "./arena";
import { advanceSkySweeperMechanics, createSkySweeperMotion, createSkySweeperState, upcomingSkySweeperMechanic } from "./bosses/skySweeper";
import { advanceCinderbackMechanics, createCinderbackMotion, createCinderbackState, upcomingCinderbackMechanic } from "./bosses/ricochet";
import { advanceCragclawMechanics, createCragclawMotion, createCragclawState, upcomingCragclawMechanic } from "./bosses/cragclawCrab";
import { advanceCrownshardMechanics, createCrownshardMotion, createCrownshardState, upcomingCrownshardMechanic } from "./bosses/crownshardGolem";
import { advanceEmberMantisMechanics, createEmberMantisMotion, createEmberMantisState, upcomingEmberMantisMechanic } from "./bosses/emberMantis";
import { advanceMournveilMechanics, createMournveilMotion, createMournveilState, upcomingMournveilMechanic } from "./bosses/mournveilGhost";
import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, upcomingObsidianRamMechanic } from "./bosses/obsidianRamGolem";
import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, upcomingSandglassMechanic } from "./bosses/sandglassScorpion";
import { createBaseMotion, returnBossToArenaCenter } from "./bosses/shared";
import { advancePooledBossMechanics, upcomingPooledMechanic } from "./bosses/mechanicPool";
import type { BossMechanicContext, BossMechanicEvent, BossMechanicResult } from "./bosses/types";
import { advanceVexaMechanics, createVexaMotion, createVexaState, dropVexaVenomPool, upcomingVexaMechanic } from "./bosses/vexa";
import { distance, moveToward, pointToSegmentDistance } from "./geometry";
import type { BossId, BossMotionState, BossState, MemberId, PartyMember, WorldPosition } from "./types";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import {
advanceMechanicLoadout,
BOSS_MECHANIC_REGISTRY,
BULL_CHARGE,
BULL_POUNCE,
handleMechanicDispel,
upcomingLoadoutMechanic,
} from "./bosses/mechanicPool";
import { createBaseMotion } from "./bosses/shared";
import type { BossMechanicContext, BossMechanicResult } from "./bosses/types";
import type { BossId, BossMotionState, BossState, Debuff, MemberId, WorldPosition } from "./types";
export const BULL_CHARGE = {
firstAt: 7,
repeatDelay: 4,
telegraphDuration: 1.8,
distance: 13.5,
speed: 10.5,
hitRadius: 1.35,
damage: 18,
knockdownDuration: 0.75,
aiClearance: 1.9,
aiEvadeSpeed: 3.4,
} as const;
export const BULL_POUNCE = {
afterCharges: 3,
stackDuration: 5,
stackRadius: 2.2,
sharedDamage: 200,
leapDuration: 0.55,
} as const;
export const BOSS_PERIODIC_MECHANICS = {
melee: { firstAt: 2, interval: 2.5, damage: 15 },
nova: { firstAt: 9, interval: 12, damage: 13 },
brand: { firstAt: 5, interval: 9, duration: 7, tickDamage: 6 },
} as const;
const CHARGE_TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"];
const POUNCE_TARGET_ORDER: readonly MemberId[] = ["aelia", "nia", "orin", "vale", "brann"];
export { BOSS_MECHANIC_REGISTRY, BULL_CHARGE, BULL_POUNCE };
export function createBossState(bossId: BossId = "bulldrome"): BossState {
const definition = BOSS_DEFINITIONS[bossId];
const archetype = BOSS_ARCHETYPE_BY_ID[bossId];
let state: BossState;
if (archetype === "web-caster") state = createVexaState(bossId);
else if (archetype === "sky-sweeper") state = createSkySweeperState(bossId);
else if (archetype === "duelist") state = createEmberMantisState(bossId);
else if (archetype === "ram") state = createObsidianRamState(bossId);
else if (archetype === "ricochet") state = createCinderbackState(bossId);
else if (archetype === "burrower") state = createSandglassState();
else if (archetype === "crab") state = createCragclawState();
else if (archetype === "ghost") state = createMournveilState();
else if (archetype === "golem") state = createCrownshardState();
else {
state = {
id: bossId,
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: BOSS_PERIODIC_MECHANICS.melee.firstAt,
nextNovaAt: BOSS_PERIODIC_MECHANICS.nova.firstAt,
nextBrandAt: BOSS_PERIODIC_MECHANICS.brand.firstAt,
brandCount: 0,
};
}
return { ...state, id: bossId, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp };
return {
id: bossId,
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: 2,
};
}
export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState {
const archetype = BOSS_ARCHETYPE_BY_ID[bossId];
let motion: BossMotionState;
if (archetype === "web-caster") motion = createVexaMotion(bossId);
else if (archetype === "sky-sweeper") motion = createSkySweeperMotion(bossId);
else if (archetype === "duelist") motion = createEmberMantisMotion(bossId);
else if (archetype === "ram") motion = createObsidianRamMotion(bossId);
else if (archetype === "ricochet") motion = createCinderbackMotion(bossId);
else if (archetype === "burrower") motion = createSandglassMotion();
else if (archetype === "crab") motion = createCragclawMotion();
else if (archetype === "ghost") motion = createMournveilMotion();
else if (archetype === "golem") motion = createCrownshardMotion();
else motion = {
...createBaseMotion("bulldrome"),
mode: "holding",
position: [0, -8.2],
chargeStart: [0, -8.2],
chargeEnd: [0, 5.5],
chargeTargetId: "nia",
chargeHitIds: [],
phaseEndsAt: 0,
nextChargeAt: BULL_CHARGE.firstAt,
chargeCount: 0,
chargesSincePounce: 0,
pounceTargetId: "aelia",
pounceCenter: [0, 4.5],
pounceCount: 0,
return {
...createBaseMotion(bossId),
position: [0, -6.8],
chargeStart: [0, -6.8],
nextMechanicAt: 5,
};
return { ...motion, bossId };
}
function mechanicArchetype(bossId: BossId): BossArchetype {
return BOSS_ARCHETYPE_BY_ID[bossId];
}
function chargeEndpoint(start: WorldPosition, target: WorldPosition): WorldPosition {
const dx = target[0] - start[0];
const dz = target[1] - start[1];
const length = Math.max(0.001, Math.hypot(dx, dz));
return clampToArena([
start[0] + (dx / length) * BULL_CHARGE.distance,
start[1] + (dz / length) * BULL_CHARGE.distance,
]);
}
function livingMember(party: PartyMember[], memberId: MemberId) {
return party.find((member) => member.id === memberId && member.hp > 0);
}
function chooseTarget(party: PartyMember[], order: readonly MemberId[], startIndex: number): MemberId {
for (let offset = 0; offset < order.length; offset += 1) {
const candidate = order[(startIndex + offset) % order.length];
if (livingMember(party, candidate)) return candidate;
}
return order[0];
}
function advanceMotionMechanics(
source: BossMotionState,
party: PartyMember[],
partyPositions: Record<MemberId, WorldPosition>,
time: number,
delta: number,
damageMember: BossMechanicContext["damageMember"],
events: BossMechanicEvent[],
) {
let motion: BossMotionState = {
...source,
position: [source.position[0], source.position[1]],
chargeStart: [source.chargeStart[0], source.chargeStart[1]],
chargeEnd: [source.chargeEnd[0], source.chargeEnd[1]],
chargeHitIds: [...source.chargeHitIds],
pounceCenter: [source.pounceCenter[0], source.pounceCenter[1]],
};
let updatedParty = party;
if (motion.mode === "holding") {
returnBossToArenaCenter(motion, delta, 1.8);
if (time >= motion.nextChargeAt) {
const targetId = chooseTarget(updatedParty, CHARGE_TARGET_ORDER, motion.chargeCount);
const target = partyPositions[targetId];
const targetName = updatedParty.find((member) => member.id === targetId)?.name ?? targetId;
motion = {
...motion,
mode: "telegraph",
chargeStart: [motion.position[0], motion.position[1]],
chargeEnd: chargeEndpoint(motion.position, target),
chargeTargetId: targetId,
chargeHitIds: [],
phaseEndsAt: time + BULL_CHARGE.telegraphDuration,
nextChargeAt: Number.POSITIVE_INFINITY,
chargeCount: motion.chargeCount + 1,
chargesSincePounce: motion.chargesSincePounce + 1,
};
events.push({
at: time,
message: `Bulldrome lines up a charge on ${targetName}.`,
tone: "danger",
pulseKind: "charge",
targetId,
});
}
} else if (motion.mode === "telegraph" && time >= motion.phaseEndsAt) {
const chargeDuration = distance(motion.chargeStart, motion.chargeEnd) / BULL_CHARGE.speed;
motion = { ...motion, mode: "charging", phaseEndsAt: time + chargeDuration };
events.push({ at: time, message: "Bulldrome charges! Clear the marked lane.", tone: "danger" });
} else if (motion.mode === "charging") {
const previousPosition: WorldPosition = [motion.position[0], motion.position[1]];
motion.position = moveToward(motion.position, motion.chargeEnd, BULL_CHARGE.speed * delta);
updatedParty = updatedParty.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id)) return member;
if (pointToSegmentDistance(partyPositions[member.id], previousPosition, motion.position) > BULL_CHARGE.hitRadius) {
return member;
}
motion.chargeHitIds = [...motion.chargeHitIds, member.id];
events.push({
at: time,
message: `${member.name} is knocked down by the charge.`,
tone: "danger",
pulseKind: "charge",
targetId: member.id,
});
return {
...damageMember(member, BULL_CHARGE.damage, partyPositions[member.id], time),
knockedUntil: time + BULL_CHARGE.knockdownDuration,
};
});
if (distance(motion.position, motion.chargeEnd) < 0.05 || time >= motion.phaseEndsAt) {
motion = { ...motion, position: [motion.chargeEnd[0], motion.chargeEnd[1]], mode: "returning", phaseEndsAt: 0 };
}
} else if (motion.mode === "returning") {
if (returnBossToArenaCenter(motion, delta, 4.4)) {
if (motion.chargesSincePounce >= BULL_POUNCE.afterCharges) {
const targetId = chooseTarget(updatedParty, POUNCE_TARGET_ORDER, motion.pounceCount);
const targetName = updatedParty.find((member) => member.id === targetId)?.name ?? targetId;
motion = {
...motion,
mode: "stacking",
phaseEndsAt: time + BULL_POUNCE.stackDuration,
nextChargeAt: Number.POSITIVE_INFINITY,
chargesSincePounce: 0,
pounceTargetId: targetId,
pounceCenter: [partyPositions[targetId][0], partyPositions[targetId][1]],
pounceCount: motion.pounceCount + 1,
};
events.push({
at: time,
message: `Bulldrome marks ${targetName}. Stack inside the circle!`,
tone: "danger",
pulseKind: "pounce",
targetId,
});
} else {
motion = {
...motion,
mode: "holding",
nextChargeAt: time + BULL_CHARGE.repeatDelay,
};
events.push({ at: time, message: "Bulldrome returns to Brann and paws at the stone." });
}
}
} else if (motion.mode === "stacking") {
motion.pounceCenter = [partyPositions[motion.pounceTargetId][0], partyPositions[motion.pounceTargetId][1]];
if (time >= motion.phaseEndsAt) {
const targetName = updatedParty.find((member) => member.id === motion.pounceTargetId)?.name ?? motion.pounceTargetId;
motion = {
...motion,
mode: "pouncing",
chargeStart: [motion.position[0], motion.position[1]],
chargeEnd: [motion.pounceCenter[0], motion.pounceCenter[1]],
phaseEndsAt: time + BULL_POUNCE.leapDuration,
};
events.push({ at: time, message: `Bulldrome leaps at ${targetName}!`, tone: "danger" });
}
} else if (motion.mode === "pouncing") {
const leapDistance = distance(motion.chargeStart, motion.chargeEnd);
const leapSpeed = Math.max(12, leapDistance / BULL_POUNCE.leapDuration);
motion.position = moveToward(motion.position, motion.chargeEnd, leapSpeed * delta);
if (distance(motion.position, motion.chargeEnd) < 0.05 || time >= motion.phaseEndsAt) {
const stackedIds = updatedParty
.filter((member) => member.hp > 0 && distance(partyPositions[member.id], motion.pounceCenter) <= BULL_POUNCE.stackRadius)
.map((member) => member.id);
const sharedDamage = BULL_POUNCE.sharedDamage / Math.max(1, stackedIds.length);
updatedParty = updatedParty.map((member) => stackedIds.includes(member.id)
? damageMember(member, sharedDamage, partyPositions[member.id], time)
: member);
motion = {
...motion,
position: [motion.chargeEnd[0], motion.chargeEnd[1]],
mode: "returning",
phaseEndsAt: 0,
};
events.push({
at: time,
message: `Bulldrome pounces for ${Math.round(sharedDamage)} damage across ${stackedIds.length} stacked allies.`,
tone: "danger",
pulseKind: "pounce",
targetId: motion.pounceTargetId,
});
}
}
return { motion, party: updatedParty };
}
function resolvePeriodicMechanics(
boss: BossState,
motion: BossMotionState,
party: PartyMember[],
partyPositions: Record<MemberId, WorldPosition>,
time: number,
damageMember: BossMechanicContext["damageMember"],
events: BossMechanicEvent[],
) {
let updatedParty = party;
while (boss.nextMeleeAt <= time) {
if (motion.mode === "holding") {
const tankIndex = updatedParty.findIndex((member) => member.id === "brann");
updatedParty[tankIndex] = damageMember(
updatedParty[tankIndex],
BOSS_PERIODIC_MECHANICS.melee.damage,
partyPositions.brann,
boss.nextMeleeAt,
);
}
boss.nextMeleeAt += BOSS_PERIODIC_MECHANICS.melee.interval;
}
while (boss.nextNovaAt <= time) {
updatedParty = updatedParty.map((member) => damageMember(
member,
BOSS_PERIODIC_MECHANICS.nova.damage,
partyPositions[member.id],
boss.nextNovaAt,
));
events.push({
at: boss.nextNovaAt,
message: "Cinder Nova strikes the party.",
tone: "danger",
pulseKind: "boss",
});
boss.nextNovaAt += BOSS_PERIODIC_MECHANICS.nova.interval;
}
while (boss.nextBrandAt <= time) {
const targetId = CHARGE_TARGET_ORDER[boss.brandCount % CHARGE_TARGET_ORDER.length];
const targetIndex = updatedParty.findIndex((member) => member.id === targetId);
if (updatedParty[targetIndex].hp > 0) {
const appliedAt = boss.nextBrandAt;
updatedParty[targetIndex] = {
...updatedParty[targetIndex],
debuffs: [
...updatedParty[targetIndex].debuffs,
{
id: `brand-${boss.brandCount}`,
name: "Ember Brand",
expiresAt: appliedAt + BOSS_PERIODIC_MECHANICS.brand.duration,
nextTickAt: appliedAt + 1,
tickDamage: BOSS_PERIODIC_MECHANICS.brand.tickDamage,
},
],
};
events.push({
at: appliedAt,
message: `Ember Brand afflicts ${updatedParty[targetIndex].name}.`,
tone: "danger",
pulseKind: "debuff",
targetId,
});
}
boss.brandCount += 1;
boss.nextBrandAt += BOSS_PERIODIC_MECHANICS.brand.interval;
}
return updatedParty;
}
function advanceBulldromeMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
const events: BossMechanicEvent[] = [];
const motionResult = advanceMotionMechanics(
context.motion,
context.party,
context.partyPositions,
context.time,
context.delta,
context.damageMember,
events,
);
const party = resolvePeriodicMechanics(
boss,
motionResult.motion,
motionResult.party,
context.partyPositions,
context.time,
context.damageMember,
events,
);
return { boss, motion: motionResult.motion, party, events };
}
export function advanceBossMechanics(context: BossMechanicContext): BossMechanicResult {
const archetype = mechanicArchetype(context.boss.id);
const result = archetype === "web-caster" ? advanceVexaMechanics(context)
: archetype === "sky-sweeper" ? advanceSkySweeperMechanics(context)
: archetype === "duelist" ? advanceEmberMantisMechanics(context)
: archetype === "ram" ? advanceObsidianRamMechanics(context)
: archetype === "ricochet" ? advanceCinderbackMechanics(context)
: archetype === "burrower" ? advanceSandglassMechanics(context)
: archetype === "crab" ? advanceCragclawMechanics(context)
: archetype === "ghost" ? advanceMournveilMechanics(context)
: archetype === "golem" ? advanceCrownshardMechanics(context)
: advanceBulldromeMechanics(context);
return advancePooledBossMechanics(context, result);
return advanceMechanicLoadout(context, BOSS_DEFINITIONS[context.boss.id].mechanicIds);
}
export function handleBossDispel(
@@ -395,53 +43,14 @@ export function handleBossDispel(
memberId: MemberId,
position: WorldPosition,
time: number,
debuffNames: readonly string[],
debuffs: readonly Debuff[],
) {
if (mechanicArchetype(bossId) === "web-caster" && debuffNames.includes("Widow Venom")) {
return {
motion: dropVexaVenomPool(motion, memberId, [position[0], position[1]], time),
message: "Widow Venom purged. A venom pool forms where the target stood.",
};
if (!BOSS_DEFINITIONS[bossId].mechanicIds.includes("venom-purge")) {
return { motion, message: "Harmful magic removed." };
}
return { motion, message: "Harmful magic removed." };
return handleMechanicDispel(motion, memberId, position, time, debuffs);
}
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
const pooled = upcomingPooledMechanic(motion, time);
if (pooled) return pooled;
const archetype = mechanicArchetype(boss.id);
if (archetype === "web-caster") return upcomingVexaMechanic(boss, motion, time);
if (archetype === "sky-sweeper") return upcomingSkySweeperMechanic(boss, motion, time);
if (archetype === "duelist") return upcomingEmberMantisMechanic(boss, motion, time);
if (archetype === "ram") return upcomingObsidianRamMechanic(boss, motion, time);
if (archetype === "ricochet") return upcomingCinderbackMechanic(boss, motion, time);
if (archetype === "burrower") return upcomingSandglassMechanic(boss, motion, time);
if (archetype === "crab") return upcomingCragclawMechanic(boss, motion, time);
if (archetype === "ghost") return upcomingMournveilMechanic(boss, motion, time);
if (archetype === "golem") return upcomingCrownshardMechanic(boss, motion, time);
if (motion.mode === "telegraph") {
return { name: "Bull Charge", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_CHARGE.telegraphDuration, urgent: true };
}
if (motion.mode === "charging") {
return { name: "Charge active", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 1.2, urgent: true };
}
if (motion.mode === "stacking") {
const names: Record<MemberId, string> = { aelia: "Aelia", brann: "Brann", nia: "Nia", orin: "Orin", vale: "Vale" };
return { name: `Stack on ${names[motion.pounceTargetId]}`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_POUNCE.stackDuration, urgent: true };
}
if (motion.mode === "pouncing") {
return { name: "Bulldrome Pounce", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 0.75, urgent: true };
}
const candidates: Array<{ name: string; remaining: number; cycle: number }> = [
{ name: "Cinder Nova", remaining: Math.max(0, boss.nextNovaAt - time), cycle: BOSS_PERIODIC_MECHANICS.nova.interval },
{ name: "Ember Brand", remaining: Math.max(0, boss.nextBrandAt - time), cycle: BOSS_PERIODIC_MECHANICS.brand.interval },
];
if (motion.mode === "holding" && Number.isFinite(motion.nextChargeAt)) {
candidates.push({ name: "Bull Charge", remaining: Math.max(0, motion.nextChargeAt - time), cycle: BULL_CHARGE.firstAt + 1 });
}
let next = candidates[0];
for (let index = 1; index < candidates.length; index += 1) {
if (candidates[index].remaining < next.remaining) next = candidates[index];
}
return { ...next, urgent: next.remaining < 2.5 };
return upcomingLoadoutMechanic(BOSS_DEFINITIONS[boss.id].mechanicIds, motion, time);
}
+5 -2
View File
@@ -73,8 +73,8 @@ export const BOSS_ROOMS = {
"stormwool-alpaca": room("thunder-fleece", "The Thunder Fleece", "Wind-scoured highland", "storm", {
background: "#071321", fog: "#274d71", sky: "#a4d5ff", ground: "#071019", floorColor: "#233f5d", wallColor: "#365676", accent: "#9bd3ff", accentSecondary: "#eef9ff", wallHeight: 2.8,
}),
"cluckhorn-colossus": room("roostbreaker-yard", "The Roostbreaker Yard", "Ruinous farmstead", "wilds", {
background: "#171308", fog: "#49542a", sky: "#d8c675", ground: "#0b1207", floorColor: "#37421e", wallColor: "#554626", accent: "#f1b95c", accentSecondary: "#b8d065", wallHeight: 2.6,
"cluckhorn-colossus": room("furnace-nest", "The Furnace Nest", "Overgrown brass hatchery", "junkyard", {
background: "#081615", fog: "#284c45", sky: "#6edccb", ground: "#07100d", floorColor: "#263b32", wallColor: "#5a4123", accent: "#dfaa43", accentSecondary: "#62e3d1", wallHeight: 3.2,
}),
"ashwing-demon": room("cinder-choir", "The Cinder Choir", "Ashen cathedral", "cinder", {
background: "#1b0608", fog: "#511721", sky: "#ee7566", ground: "#120407", floorColor: "#4b1720", wallColor: "#592029", accent: "#ef625c", accentSecondary: "#ffb16e", wallHeight: 5.2,
@@ -133,6 +133,9 @@ export const BOSS_ROOMS = {
"rimeclaw-yeti": room("frozen-duel", "The Frozen Duel", "Frozen mirror palace", "frost", {
background: "#07162a", fog: "#285a83", sky: "#8fd8ff", ground: "#030c19", floorColor: "#17476d", wallColor: "#2d6691", accent: "#74c8f4", accentSecondary: "#c1f6ff", wallHeight: 4.9,
}),
"gravehorn-triceratops": room("fossil-wake", "The Fossil Wake", "Buried ossuary", "reliquary", {
background: "#100d08", fog: "#40382a", sky: "#d8c79b", ground: "#090704", floorColor: "#332b20", wallColor: "#4c4030", accent: "#d8c79b", accentSecondary: "#8eb9a6", wallHeight: 4.5,
}),
} as const satisfies Record<BossId, BossRoomDefinition>;
export function bossRoomFor(bossId: BossId): BossRoomDefinition {
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { canAddBossToEncounter, normalizeEncounterBossIds } from "./bossSelection";
describe("boss encounter selection", () => {
it("allows only one Memory Sequence boss in an encounter", () => {
expect(canAddBossToEncounter(["sandglass-scorpion"], "crystal-bat-matriarch")).toBe(false);
expect(canAddBossToEncounter(["sandglass-scorpion"], "rimeclaw-yeti")).toBe(false);
expect(canAddBossToEncounter(["sandglass-scorpion"], "bulldrome")).toBe(true);
const normalized = normalizeEncounterBossIds([
"sandglass-scorpion",
"crystal-bat-matriarch",
"bulldrome",
"rimeclaw-yeti",
]);
expect(normalized).toEqual(["sandglass-scorpion", "bulldrome"]);
expect(normalized.filter((bossId) => BOSS_DEFINITIONS[bossId].mechanicIds.includes("memory-sequence"))).toHaveLength(1);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { BOSS_DEFINITIONS } from "./bossCatalog";
import type { BossId, BossMechanicId } from "./types";
/** Mechanics that cannot be resolved safely when two bosses own them at once. */
export const ENCOUNTER_EXCLUSIVE_MECHANICS: ReadonlySet<BossMechanicId> = new Set(["memory-sequence"]);
export function canAddBossToEncounter(selectedBossIds: readonly BossId[], candidateBossId: BossId) {
if (selectedBossIds.includes(candidateBossId)) return false;
const candidateMechanics = BOSS_DEFINITIONS[candidateBossId].mechanicIds;
for (const mechanicId of candidateMechanics) {
if (!ENCOUNTER_EXCLUSIVE_MECHANICS.has(mechanicId)) continue;
if (selectedBossIds.some((bossId) => BOSS_DEFINITIONS[bossId].mechanicIds.includes(mechanicId))) return false;
}
return true;
}
export function normalizeEncounterBossIds(requestedBossIds: readonly BossId[], limit = 3): BossId[] {
const selected: BossId[] = [];
for (const bossId of requestedBossIds) {
if (selected.length >= limit) break;
if (canAddBossToEncounter(selected, bossId)) selected.push(bossId);
}
return selected.length ? selected : ["bulldrome"];
}
+98
View File
@@ -0,0 +1,98 @@
import type { BossId } from "./types";
export type AlternateBossKind = Exclude<BossId, "bulldrome">;
export interface AlternateBossConfig {
url: string;
optimizedUrl?: string;
scale: number;
idle: string;
move: string;
attack: string;
special: string;
death: string;
light: string;
rotationOffset: number;
floating?: boolean;
}
export const BULL_URL = new URL("../assets/game/models/claudecraft/creatures/bull.glb", import.meta.url).href;
const SANDGLASS_URL = new URL("../assets/game/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href;
const CRYSTAL_BAT_MATRIARCH_URL = new URL("../assets/game/models/original/bosses/crystal-bat-matriarch/crystal-bat-matriarch.glb", import.meta.url).href;
const BRASSBEAK_BASILISK_URL = new URL("../assets/game/models/original/bosses/brassbeak-basilisk/brassbeak-basilisk.glb", import.meta.url).href;
const BOGBELL_MYCONID_URL = new URL("../assets/game/models/original/bosses/bogbell-myconid/bogbell-myconid.glb", import.meta.url).href;
const GRAVEHORN_URL = new URL("../assets/game/models/sketchfab-opensource/gravehorn-triceratops.glb", import.meta.url).href;
const GRAVEHORN_OPTIMIZED_URL = new URL("../assets/game/models/sketchfab-opensource/gravehorn-triceratops-uastc.glb", import.meta.url).href;
const CRAGCLAW_URL = new URL("../assets/game/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href;
const MOURNVEIL_URL = new URL("../assets/game/models/claudecraft/creatures/ghost.glb", import.meta.url).href;
const CROWNSHARD_URL = new URL("../assets/game/models/claudecraft/creatures/golelingevolved.glb", import.meta.url).href;
const CLAUDE_BOSS_URLS: Record<Exclude<BossId,
| "bulldrome"
| "sandglass-scorpion"
| "cragclaw-crab"
| "mournveil-ghost"
| "crownshard-golem"
| "crystal-bat-matriarch"
| "cluckhorn-colossus"
| "mirelord-frog"
| "gravehorn-triceratops"
>, string> = {
"stormwool-alpaca": new URL("../assets/game/models/claudecraft/creatures/alpaca.glb", import.meta.url).href,
"ashwing-demon": new URL("../assets/game/models/claudecraft/creatures/demon.glb", import.meta.url).href,
"riftclaw-demon": new URL("../assets/game/models/claudecraft/creatures/demonalt.glb", import.meta.url).href,
"tempestscale-dragon": new URL("../assets/game/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href,
emberfox: new URL("../assets/game/models/claudecraft/creatures/fox.glb", import.meta.url).href,
"stonebreaker-giant": new URL("../assets/game/models/claudecraft/creatures/giant.glb", import.meta.url).href,
"glub-sovereign": new URL("../assets/game/models/claudecraft/creatures/glubevolved.glb", import.meta.url).href,
"scrapking-goblin": new URL("../assets/game/models/claudecraft/creatures/goblin.glb", import.meta.url).href,
"warcaller-orc": new URL("../assets/game/models/claudecraft/creatures/orc.glb", import.meta.url).href,
"tuskmaw-orc": new URL("../assets/game/models/claudecraft/creatures/orcenemy.glb", import.meta.url).href,
"broodfang-spider": new URL("../assets/game/models/claudecraft/creatures/spider.glb", import.meta.url).href,
"silkfang-spider": new URL("../assets/game/models/claudecraft/creatures/spider.glb", import.meta.url).href,
"thorncrown-stag": new URL("../assets/game/models/claudecraft/creatures/stag.glb", import.meta.url).href,
"sky-totem": new URL("../assets/game/models/claudecraft/creatures/tribal.glb", import.meta.url).href,
"razorcrest-raptor": new URL("../assets/game/models/claudecraft/creatures/velociraptor.glb", import.meta.url).href,
"bristlequake-boar": new URL("../assets/game/models/claudecraft/creatures/wild_boar.glb", import.meta.url).href,
"moonfang-wolf": new URL("../assets/game/models/claudecraft/creatures/wolf.glb", import.meta.url).href,
"frostmaw-yeti": new URL("../assets/game/models/claudecraft/creatures/yeti.glb", import.meta.url).href,
"rimeclaw-yeti": new URL("../assets/game/models/claudecraft/creatures/yetialt.glb", import.meta.url).href,
};
export const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfig> = {
"sandglass-scorpion": { url: SANDGLASS_URL, scale: 0.7, idle: "Idle", move: "Burrow", attack: "Eruption", special: "Hourglass", death: "Death", light: "#e9b94f", rotationOffset: 0 },
"cragclaw-crab": { url: CRAGCLAW_URL, scale: 1.2, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Bite_InPlace", death: "Death", light: "#49d5df", rotationOffset: 0 },
"mournveil-ghost": { url: MOURNVEIL_URL, scale: 1.1, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#9d72ff", rotationOffset: 0, floating: true },
"crownshard-golem": { url: CROWNSHARD_URL, scale: 1.15, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#e0bd45", rotationOffset: 0, floating: true },
"crystal-bat-matriarch": { url: CRYSTAL_BAT_MATRIARCH_URL, scale: 0.828, idle: "Idle", move: "Swoop", attack: "SonicPulse", special: "MirrorShatter", death: "Death", light: "#8eeaff", rotationOffset: 0, floating: true },
"stormwool-alpaca": { url: CLAUDE_BOSS_URLS["stormwool-alpaca"], scale: 0.72, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#8fc7ff", rotationOffset: 0 },
// IDs remain stable so existing saves and trophies keep working after visual replacement.
"cluckhorn-colossus": { url: BRASSBEAK_BASILISK_URL, scale: 0.75, idle: "Idle", move: "Scuttle", attack: "BeakRend", special: "FurnaceBurst", death: "Death", light: "#5cebd7", rotationOffset: 0 },
"ashwing-demon": { url: CLAUDE_BOSS_URLS["ashwing-demon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#df665d", rotationOffset: 0, floating: true },
"riftclaw-demon": { url: CLAUDE_BOSS_URLS["riftclaw-demon"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#d45cff", rotationOffset: 0 },
"tempestscale-dragon": { url: CLAUDE_BOSS_URLS["tempestscale-dragon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#5fc8e8", rotationOffset: 0, floating: true },
emberfox: { url: CLAUDE_BOSS_URLS.emberfox, scale: 1, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#ff7b45", rotationOffset: 0 },
"mirelord-frog": { url: BOGBELL_MYCONID_URL, scale: 0.78, idle: "Idle", move: "BurrowRush", attack: "RootPummel", special: "SporeEruption", death: "Death", light: "#8df06b", rotationOffset: 0 },
"stonebreaker-giant": { url: CLAUDE_BOSS_URLS["stonebreaker-giant"], scale: 1, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#c89563", rotationOffset: 0 },
"glub-sovereign": { url: CLAUDE_BOSS_URLS["glub-sovereign"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#6ce0b8", rotationOffset: 0, floating: true },
"scrapking-goblin": { url: CLAUDE_BOSS_URLS["scrapking-goblin"], scale: 1.5, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#d7a34b", rotationOffset: 0 },
"warcaller-orc": { url: CLAUDE_BOSS_URLS["warcaller-orc"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#e4533f", rotationOffset: 0 },
"tuskmaw-orc": { url: CLAUDE_BOSS_URLS["tuskmaw-orc"], scale: 1.45, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#9eb25d", rotationOffset: 0 },
"broodfang-spider": { url: CLAUDE_BOSS_URLS["broodfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", death: "Spider_Death", light: "#b56cff", rotationOffset: 0 },
"silkfang-spider": { url: CLAUDE_BOSS_URLS["silkfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", death: "Spider_Death", light: "#9d68d8", rotationOffset: 0 },
"thorncrown-stag": { url: CLAUDE_BOSS_URLS["thorncrown-stag"], scale: 0.85, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#7fc46b", rotationOffset: 0 },
"sky-totem": { url: CLAUDE_BOSS_URLS["sky-totem"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#69d4d1", rotationOffset: 0, floating: true },
"razorcrest-raptor": { url: CLAUDE_BOSS_URLS["razorcrest-raptor"], scale: 1.1, idle: "Velociraptor_Idle", move: "Velociraptor_Run", attack: "Velociraptor_Attack", special: "Velociraptor_Jump", death: "Velociraptor_Death", light: "#d9c45a", rotationOffset: 0 },
"bristlequake-boar": { url: CLAUDE_BOSS_URLS["bristlequake-boar"], scale: 0.475, idle: "Idle_AnimalArmature", move: "Gallop_AnimalArmature", attack: "Attack_Headbutt_AnimalArmature", special: "Attack_Kick_AnimalArmature", death: "Dying", light: "#d47b45", rotationOffset: 0 },
"moonfang-wolf": { url: CLAUDE_BOSS_URLS["moonfang-wolf"], scale: 1.05, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#9db9e5", rotationOffset: 0 },
"frostmaw-yeti": { url: CLAUDE_BOSS_URLS["frostmaw-yeti"], scale: 1.35, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#8ed8ef", rotationOffset: 0 },
"rimeclaw-yeti": { url: CLAUDE_BOSS_URLS["rimeclaw-yeti"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#75bfe8", rotationOffset: 0 },
"gravehorn-triceratops": { url: GRAVEHORN_URL, optimizedUrl: GRAVEHORN_OPTIMIZED_URL, scale: 0.82, idle: "Gravehorn|Idle", move: "Armature|Walk", attack: "Armature|Roar", special: "Armature|RiseUp", death: "Armature|Fall", light: "#ffd9a0", rotationOffset: 0 },
};
export function bossVisualUrl(bossId: BossId, optimized = false): string {
if (bossId === "bulldrome") return BULL_URL;
const config = ALTERNATE_BOSS_CONFIG[bossId];
return optimized ? config.optimizedUrl ?? config.url : config.url;
}
-113
View File
@@ -1,113 +0,0 @@
import { describe, expect, it } from "vitest";
import { freshParty } from "../data";
import type { BossMotionState, BossState, WorldPosition } from "../types";
import { advanceCragclawMechanics, CRAGCLAW, createCragclawMotion, createCragclawState } from "./cragclawCrab";
import { advanceCrownshardMechanics, CROWNSHARD, createCrownshardMotion, createCrownshardState } from "./crownshardGolem";
import { advanceMournveilMechanics, createMournveilMotion, createMournveilState, MOURNVEIL } from "./mournveilGhost";
import type { BossMechanicContext } from "./types";
const POSITIONS: BossMechanicContext["partyPositions"] = {
aelia: [0, 4.5],
brann: [0, 0],
nia: [-3, 2],
orin: [3, 2],
vale: [0, -2],
};
function context(
boss: BossState,
motion: BossMotionState,
time: number,
delta = 0.1,
positions = POSITIONS,
party = freshParty(),
): BossMechanicContext {
return {
boss,
motion,
party,
partyPositions: structuredClone(positions),
time,
delta,
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
};
}
describe("ClaudeCraft boss trio mechanics", () => {
it("telegraphs and resolves Cragclaw Sidewinder Rush", () => {
const start = advanceCragclawMechanics(context(createCragclawState(), createCragclawMotion(), CRAGCLAW.firstAt));
expect(start.motion.mode).toBe("crab_scuttle_telegraph");
expect(start.motion.slashLanes).toHaveLength(1);
const active = advanceCragclawMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
expect(active.motion.mode).toBe("crab_scuttling");
const niaBefore = active.party.find((member) => member.id === "nia")!.hp;
const impact = advanceCragclawMechanics(context(active.boss, active.motion, active.motion.phaseEndsAt, 2, POSITIONS, active.party));
expect(impact.party.find((member) => member.id === "nia")!.hp).toBe(niaBefore - CRAGCLAW.scuttleDamage);
});
it("places three Crushing Tide warnings on party positions", () => {
const motion = { ...createCragclawMotion(), mechanicCount: 1, nextMechanicAt: 0 };
const start = advanceCragclawMechanics(context(createCragclawState(), motion, 0));
expect(start.motion.mode).toBe("crab_tidal_burst");
expect(start.motion.hazards.filter((hazard) => hazard.kind === "tidal_burst")).toHaveLength(3);
const positions = structuredClone(POSITIONS);
positions.aelia = [...start.motion.hazards[0].center] as WorldPosition;
const hpBefore = start.party.find((member) => member.id === "aelia")!.hp;
const impact = advanceCragclawMechanics(context(start.boss, start.motion, CRAGCLAW.tidalWarning + 0.05, 0.1, positions, start.party));
expect(impact.party.find((member) => member.id === "aelia")!.hp).toBe(hpBefore - CRAGCLAW.tidalDamage);
});
it("rotates Mournveil Soul Scissors for a second crossing pattern", () => {
const start = advanceMournveilMechanics(context(createMournveilState(), createMournveilMotion(), MOURNVEIL.firstAt));
const firstLaneIds = start.motion.slashLanes.map((lane) => lane.id);
expect(start.motion.mode).toBe("ghost_soul_cross");
expect(start.motion.slashLanes).toHaveLength(2);
const followup = advanceMournveilMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
expect(followup.motion.mode).toBe("ghost_soul_cross_followup");
expect(followup.motion.slashLanes).toHaveLength(2);
expect(followup.motion.slashLanes.map((lane) => lane.id)).not.toEqual(firstLaneIds);
const resolved = advanceMournveilMechanics(context(followup.boss, followup.motion, followup.motion.phaseEndsAt, 0.1, POSITIONS, followup.party));
expect(resolved.motion.mode).toBe("ghost_recover");
});
it("opens two persistent Haunting Rifts", () => {
const motion = { ...createMournveilMotion(), mechanicCount: 1, nextMechanicAt: 0 };
const start = advanceMournveilMechanics(context(createMournveilState(), motion, 0));
const rifts = start.motion.hazards.filter((hazard) => hazard.kind === "soul_rift");
expect(start.motion.mode).toBe("ghost_haunting");
expect(rifts).toHaveLength(2);
expect(rifts[0].expiresAt - rifts[0].activatesAt).toBe(MOURNVEIL.riftDuration);
});
it("builds three non-overlapping Crownshard shockwave bands", () => {
const start = advanceCrownshardMechanics(context(createCrownshardState(), createCrownshardMotion(), CROWNSHARD.firstAt));
const rings = start.motion.hazards.filter((hazard) => hazard.kind === "royal_shockwave");
expect(start.motion.mode).toBe("golem_shockwave");
expect(rings).toHaveLength(3);
expect(rings.map((ring) => [ring.innerRadius ?? 0, ring.radius])).toEqual([[0, 2.35], [2.35, 4.7], [4.7, 7.05]]);
const positions = structuredClone(POSITIONS);
positions.aelia = [...rings[0].center];
positions.nia = [rings[0].center[0] + 3, rings[0].center[1]];
const first = advanceCrownshardMechanics(context(start.boss, start.motion, rings[0].activatesAt + 0.05, 0.1, positions, start.party));
const aeliaAfterFirst = first.party.find((member) => member.id === "aelia")!.hp;
const niaAfterFirst = first.party.find((member) => member.id === "nia")!.hp;
expect(aeliaAfterFirst).toBe(start.party.find((member) => member.id === "aelia")!.hp - CROWNSHARD.shockwaveDamage);
expect(niaAfterFirst).toBe(start.party.find((member) => member.id === "nia")!.hp);
const second = advanceCrownshardMechanics(context(first.boss, first.motion, rings[1].activatesAt + 0.05, 0.1, positions, first.party));
expect(second.party.find((member) => member.id === "aelia")!.hp).toBe(aeliaAfterFirst);
expect(second.party.find((member) => member.id === "nia")!.hp).toBe(niaAfterFirst - CROWNSHARD.shockwaveDamage);
});
it("marks three allies with Crownfall", () => {
const motion = { ...createCrownshardMotion(), mechanicCount: 1, nextMechanicAt: 0 };
const result = advanceCrownshardMechanics(context(createCrownshardState(), motion, 0));
expect(result.motion.mode).toBe("golem_crownfall");
expect(result.motion.hazards.filter((hazard) => hazard.kind === "crownfall")).toHaveLength(3);
});
});
-146
View File
@@ -1,146 +0,0 @@
import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const CRAGCLAW = {
firstAt: 5.1,
repeatDelay: 3.7,
scuttleWarning: 1.35,
scuttleSpeed: 11.8,
scuttleDistance: 13,
scuttleWidth: 2.35,
scuttleDamage: 24,
tidalWarning: 1.45,
tidalRadius: 1.7,
tidalDamage: 26,
recoverDuration: 0.72,
} as const;
const SCUTTLE_TARGETS: readonly MemberId[] = ["nia", "orin", "aelia", "vale", "brann"];
const TIDAL_TARGETS: readonly (readonly MemberId[])[] = [
["aelia", "nia", "orin"],
["brann", "vale", "aelia"],
["nia", "orin", "vale"],
];
export function createCragclawState(): BossState {
const definition = BOSS_DEFINITIONS["cragclaw-crab"];
return createBossStateFor(definition.id, definition.name, definition.maxHp, 2.3);
}
export function createCragclawMotion(): BossMotionState {
return { ...createBaseMotion("cragclaw-crab"), position: [0, -6.4], nextMechanicAt: CRAGCLAW.firstAt };
}
function scuttleEnd(start: WorldPosition, target: WorldPosition): WorldPosition {
const angle = angleTo(start, target);
return clampToArena([
start[0] + Math.sin(angle) * CRAGCLAW.scuttleDistance,
start[1] + Math.cos(angle) * CRAGCLAW.scuttleDistance,
]);
}
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const mechanicCount = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const targetId = chooseLivingTarget(context.party, SCUTTLE_TARGETS, motion.mechanicCount);
const end = scuttleEnd(motion.position, context.partyPositions[targetId]);
const lane: SlashLane = {
id: `cragclaw-scuttle-${mechanicCount}`,
start: [...motion.position],
end,
width: CRAGCLAW.scuttleWidth,
damage: CRAGCLAW.scuttleDamage,
};
events.push({ at: context.time, message: `Cragclaw lines up Sidewinder Rush on ${memberName(context.party, targetId)}.`, tone: "danger", pulseKind: "charge", targetId });
return {
...motion,
mode: "crab_scuttle_telegraph" as const,
chargeTargetId: targetId,
chargeStart: [...motion.position] as WorldPosition,
chargeEnd: end,
chargeHitIds: [],
slashLanes: [lane],
phaseStartedAt: context.time,
phaseEndsAt: context.time + CRAGCLAW.scuttleWarning,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
};
}
const activatesAt = context.time + CRAGCLAW.tidalWarning;
const targetSet = TIDAL_TARGETS[Math.floor(motion.mechanicCount / 2) % TIDAL_TARGETS.length];
events.push({ at: context.time, message: "Crushing Tide marks three allies. Spread before the claws close.", tone: "danger", pulseKind: "skyfall" });
return {
...motion,
mode: "crab_tidal_burst" as const,
phaseStartedAt: context.time,
phaseEndsAt: activatesAt + 0.3,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [
...motion.hazards,
...targetSet.map((targetId, index) => createCircleHazard({
id: `cragclaw-tide-${mechanicCount}-${index}`,
kind: "tidal_burst",
center: context.partyPositions[targetId],
radius: CRAGCLAW.tidalRadius,
activatesAt,
duration: 0.3,
damage: CRAGCLAW.tidalDamage,
})),
],
};
}
export function advanceCragclawMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
returnBossToArenaCenter(motion, context.delta, 2.05);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if (motion.mode === "crab_scuttle_telegraph" && context.time >= motion.phaseEndsAt) {
motion = {
...motion,
mode: "crab_scuttling",
phaseStartedAt: context.time,
phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / CRAGCLAW.scuttleSpeed,
};
events.push({ at: context.time, message: "Sidewinder Rush! Clear the surf lane.", tone: "danger", pulseKind: "charge" });
} else if (motion.mode === "crab_scuttling") {
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, CRAGCLAW.scuttleSpeed * context.delta);
party = party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id)) return member;
if (pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > CRAGCLAW.scuttleWidth * 0.5) return member;
motion.chargeHitIds.push(member.id);
events.push({ at: context.time, message: `${member.name} is crushed by Sidewinder Rush.`, tone: "danger", pulseKind: "charge", targetId: member.id });
return { ...context.damageMember(member, CRAGCLAW.scuttleDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.42 };
});
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) {
motion = { ...motion, position: [...motion.chargeEnd], mode: "crab_recover", phaseEndsAt: context.time + CRAGCLAW.recoverDuration };
}
} else if (motion.mode === "crab_tidal_burst" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "crab_recover", phaseEndsAt: context.time + CRAGCLAW.recoverDuration };
} else if (motion.mode === "crab_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + CRAGCLAW.repeatDelay, slashLanes: [], chargeHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.2, 14, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingCragclawMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "crab_scuttle_telegraph" || motion.mode === "crab_scuttling") return { name: "Sidewinder Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CRAGCLAW.scuttleWarning, urgent: true };
if (motion.mode === "crab_tidal_burst") return { name: "Crushing Tide — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CRAGCLAW.tidalWarning, urgent: true };
if (motion.mode === "crab_recover") return { name: "Cragclaw exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CRAGCLAW.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Sidewinder Rush" : "Crushing Tide", remaining, cycle: CRAGCLAW.repeatDelay + CRAGCLAW.scuttleWarning, urgent: remaining < 2.5 };
}
-117
View File
@@ -1,117 +0,0 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import type { BossMotionState, BossState, MemberId } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const CROWNSHARD = {
firstAt: 5.5,
repeatDelay: 4,
shockwaveWarning: 1.2,
shockwaveInterval: 0.65,
shockwaveDamage: 19,
crownfallWarning: 1.5,
crownfallRadius: 1.85,
crownfallDamage: 28,
recoverDuration: 0.78,
} as const;
const CROWNFALL_TARGETS: readonly (readonly MemberId[])[] = [
["aelia", "nia", "orin"],
["brann", "vale", "aelia"],
["nia", "orin", "vale"],
];
export function createCrownshardState(): BossState {
const definition = BOSS_DEFINITIONS["crownshard-golem"];
return createBossStateFor(definition.id, definition.name, definition.maxHp, 2.4);
}
export function createCrownshardMotion(): BossMotionState {
return { ...createBaseMotion("crownshard-golem"), position: [0, -6.7], nextMechanicAt: CROWNSHARD.firstAt };
}
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const mechanicCount = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const bands = [
{ innerRadius: 0, radius: 2.35 },
{ innerRadius: 2.35, radius: 4.7 },
{ innerRadius: 4.7, radius: 7.05 },
];
const firstActivation = context.time + CROWNSHARD.shockwaveWarning;
events.push({ at: context.time, message: "Tri-Burst expands in three rings. Move with each head's safe band.", tone: "danger", pulseKind: "boss" });
return {
...motion,
mode: "golem_shockwave" as const,
phaseStartedAt: context.time,
phaseEndsAt: firstActivation + CROWNSHARD.shockwaveInterval * (bands.length - 1) + 0.32,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [
...motion.hazards,
...bands.map((band, index) => createCircleHazard({
id: `crownshard-shockwave-${mechanicCount}-${index}`,
kind: "royal_shockwave",
center: motion.position,
innerRadius: band.innerRadius,
radius: band.radius,
activatesAt: firstActivation + index * CROWNSHARD.shockwaveInterval,
duration: 0.3,
damage: CROWNSHARD.shockwaveDamage,
})),
],
};
}
const activatesAt = context.time + CROWNSHARD.crownfallWarning;
const targetSet = CROWNFALL_TARGETS[Math.floor(motion.mechanicCount / 2) % CROWNFALL_TARGETS.length];
events.push({ at: context.time, message: "Ultimate Skyfall marks three allies. Break formation before impact.", tone: "danger", pulseKind: "skyfall", targetId: targetSet[0] });
return {
...motion,
mode: "golem_crownfall" as const,
phaseStartedAt: context.time,
phaseEndsAt: activatesAt + 0.32,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [
...motion.hazards,
...targetSet.map((targetId, index) => createCircleHazard({
id: `crownshard-fall-${mechanicCount}-${index}`,
kind: "crownfall",
center: context.partyPositions[targetId],
radius: CROWNSHARD.crownfallRadius,
activatesAt,
duration: 0.3,
damage: CROWNSHARD.crownfallDamage,
})),
],
};
}
export function advanceCrownshardMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
returnBossToArenaCenter(motion, context.delta, 1.55);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if ((motion.mode === "golem_shockwave" || motion.mode === "golem_crownfall") && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "golem_recover", phaseEndsAt: context.time + CROWNSHARD.recoverDuration };
} else if (motion.mode === "golem_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + CROWNSHARD.repeatDelay };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.3, 15, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingCrownshardMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "golem_shockwave") return { name: "Tri-Burst — follow rings", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CROWNSHARD.shockwaveWarning + CROWNSHARD.shockwaveInterval * 2, urgent: true };
if (motion.mode === "golem_crownfall") return { name: "Ultimate Skyfall — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CROWNSHARD.crownfallWarning, urgent: true };
if (motion.mode === "golem_recover") return { name: "Ultimate Dragon exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CROWNSHARD.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Tri-Burst" : "Ultimate Skyfall", remaining, cycle: CROWNSHARD.repeatDelay + CROWNSHARD.shockwaveWarning, urgent: remaining < 2.5 };
}
-97
View File
@@ -1,97 +0,0 @@
import { describe, expect, it } from "vitest";
import { freshParty } from "../data";
import { pointToSegmentDistance } from "../geometry";
import { evadeSlashLanesBehavior } from "../partyBehaviors";
import type { BossMechanicContext } from "./types";
import {
advanceEmberMantisMechanics,
createEmberMantisMotion,
createEmberMantisState,
EMBER_MANTIS_SLASH,
} from "./emberMantis";
const POSITIONS: BossMechanicContext["partyPositions"] = {
aelia: [0, 4.5],
brann: [0, 0],
nia: [-3, 2],
orin: [3, 2],
vale: [0, -2],
};
function context(
boss: ReturnType<typeof createEmberMantisState>,
motion: ReturnType<typeof createEmberMantisMotion>,
party = freshParty(),
time = 0,
delta = 0.1,
): BossMechanicContext {
return {
boss,
motion,
party,
partyPositions: structuredClone(POSITIONS),
time,
delta,
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
};
}
describe("Warcaller mechanics", () => {
it("sidesteps, telegraphs Elemental Beam, then damages targets left in the lane", () => {
const boss = createEmberMantisState();
const sidestep = advanceEmberMantisMechanics(context(boss, createEmberMantisMotion(), freshParty(), 5, 0.1));
expect(sidestep.motion.mode).toBe("mantis_sidestep");
expect(sidestep.motion.chargeTargetId).toBe("nia");
const telegraph = advanceEmberMantisMechanics(context(sidestep.boss, sidestep.motion, sidestep.party, 5.6, 0.6));
expect(telegraph.motion.mode).toBe("mantis_line_telegraph");
expect(telegraph.motion.slashLanes).toHaveLength(1);
const niaBefore = telegraph.party.find((member) => member.id === "nia")!.hp;
const impact = advanceEmberMantisMechanics(context(telegraph.boss, telegraph.motion, telegraph.party, 6.51, 0.91));
expect(impact.motion.mode).toBe("mantis_recover");
expect(impact.motion.mechanicHitIds).toContain("nia");
expect(impact.party.find((member) => member.id === "nia")!.hp).toBe(niaBefore - EMBER_MANTIS_SLASH.lineDamage);
expect(impact.events.some((event) => event.message.includes("Elemental Beam"))).toBe(true);
});
it("alternates into two crossed slash lanes", () => {
const motion = {
...createEmberMantisMotion(),
mode: "mantis_sidestep" as const,
mechanicCount: 1,
chargeTargetId: "orin" as const,
chargeEnd: [0, -6.6] as [number, number],
phaseEndsAt: 0,
};
const result = advanceEmberMantisMechanics(context(createEmberMantisState(), motion, freshParty(), 1, 0.1));
expect(result.motion.mode).toBe("mantis_cross_telegraph");
expect(result.motion.slashLanes).toHaveLength(2);
expect(result.motion.slashLanes[0].id).toContain("cross");
});
it("gives mobile allies a target outside crossed lanes", () => {
const motion = {
...createEmberMantisMotion(),
mode: "mantis_sidestep" as const,
mechanicCount: 1,
chargeTargetId: "orin" as const,
chargeEnd: [0, -6.6] as [number, number],
phaseEndsAt: 0,
};
const telegraph = advanceEmberMantisMechanics(context(createEmberMantisState(), motion, freshParty(), 1, 0.1)).motion;
const decision = evadeSlashLanesBehavior.decide({
memberId: "orin",
current: POSITIONS.orin,
formationTarget: POSITIONS.orin,
bossMotion: telegraph,
partyPositions: POSITIONS,
time: 1,
});
expect(decision).not.toBeNull();
const minimumLaneDistance = Math.min(...telegraph.slashLanes.map((lane) =>
pointToSegmentDistance(decision!.target, lane.start, lane.end),
));
expect(minimumLaneDistance).toBeGreaterThan(EMBER_MANTIS_SLASH.aiClearance);
});
});
-241
View File
@@ -1,241 +0,0 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossId, BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const EMBER_MANTIS_SLASH = {
firstAt: 5,
repeatDelay: 3.4,
sidestepDuration: 0.55,
sidestepDistance: 3.8,
sidestepSpeed: 7.2,
telegraphDuration: 0.9,
recoverDuration: 0.7,
laneLength: 18,
lineWidth: 1.65,
crossWidth: 1.45,
lineDamage: 32,
crossDamage: 25,
crossAngle: Math.PI * 0.18,
staggerDuration: 0.35,
aiClearance: 1.35,
aiEvadeSpeed: 4.8,
} as const;
const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "aelia", "vale", "brann"];
const MIN_BOSS_X = -5.8;
const MAX_BOSS_X = 5.8;
export function createEmberMantisState(bossId: BossId = "warcaller-orc"): BossState {
const definition = BOSS_DEFINITIONS[bossId];
return {
id: definition.id,
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: 2.2,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
export function createEmberMantisMotion(bossId: BossId = "warcaller-orc"): BossMotionState {
return {
...createBaseMotion(bossId),
position: [0, -6.6],
nextMechanicAt: EMBER_MANTIS_SLASH.firstAt,
};
}
function clampBossX(value: number) {
return Math.max(MIN_BOSS_X, Math.min(MAX_BOSS_X, value));
}
function createLane(
id: string,
center: WorldPosition,
angle: number,
width: number,
damage: number,
): SlashLane {
const halfLength = EMBER_MANTIS_SLASH.laneLength * 0.5;
const dx = Math.sin(angle) * halfLength;
const dz = Math.cos(angle) * halfLength;
return {
id,
start: [center[0] - dx, center[1] - dz],
end: [center[0] + dx, center[1] + dz],
width,
damage,
};
}
function beginSidestep(
motion: BossMotionState,
party: BossMechanicContext["party"],
partyPositions: BossMechanicContext["partyPositions"],
time: number,
) {
const targetId = chooseLivingTarget(party, TARGET_ORDER, motion.mechanicCount);
const direction = motion.mechanicCount % 2 === 0 ? 1 : -1;
let targetX = clampBossX(motion.position[0] + direction * EMBER_MANTIS_SLASH.sidestepDistance);
if (Math.abs(targetX - motion.position[0]) < 1) {
targetX = clampBossX(motion.position[0] - direction * EMBER_MANTIS_SLASH.sidestepDistance);
}
return {
...motion,
mode: "mantis_sidestep" as const,
chargeTargetId: targetId,
chargeEnd: [targetX, motion.position[1]] as WorldPosition,
phaseStartedAt: time,
phaseEndsAt: time + EMBER_MANTIS_SLASH.sidestepDuration,
nextMechanicAt: Number.POSITIVE_INFINITY,
slashLanes: [],
mechanicHitIds: [],
pounceCenter: [partyPositions[targetId][0], partyPositions[targetId][1]] as WorldPosition,
};
}
function beginSlashTelegraph(
motion: BossMotionState,
partyPositions: BossMechanicContext["partyPositions"],
time: number,
) {
const target = partyPositions[motion.chargeTargetId];
const aimedAngle = angleTo(motion.position, target);
const isCrossSlash = motion.mechanicCount % 2 === 1;
const slashNumber = motion.mechanicCount + 1;
const lanes = isCrossSlash
? [
createLane(`cross-${slashNumber}-left`, target, aimedAngle - EMBER_MANTIS_SLASH.crossAngle, EMBER_MANTIS_SLASH.crossWidth, EMBER_MANTIS_SLASH.crossDamage),
createLane(`cross-${slashNumber}-right`, target, aimedAngle + EMBER_MANTIS_SLASH.crossAngle, EMBER_MANTIS_SLASH.crossWidth, EMBER_MANTIS_SLASH.crossDamage),
]
: [createLane(`line-${slashNumber}`, target, aimedAngle, EMBER_MANTIS_SLASH.lineWidth, EMBER_MANTIS_SLASH.lineDamage)];
return {
...motion,
mode: isCrossSlash ? "mantis_cross_telegraph" as const : "mantis_line_telegraph" as const,
phaseStartedAt: time,
phaseEndsAt: time + EMBER_MANTIS_SLASH.telegraphDuration,
mechanicCount: slashNumber,
slashLanes: lanes,
mechanicHitIds: [],
};
}
function resolveSlash(
motion: BossMotionState,
context: BossMechanicContext,
events: BossMechanicResult["events"],
) {
const isCrossSlash = motion.mode === "mantis_cross_telegraph";
const hitIds: MemberId[] = [];
const party = context.party.map((member) => {
if (member.hp <= 0) return member;
const lane = motion.slashLanes.find((candidate) =>
pointToSegmentDistance(context.partyPositions[member.id], candidate.start, candidate.end) <= candidate.width * 0.5,
);
if (!lane) return member;
hitIds.push(member.id);
events.push({
at: context.time,
message: `${member.name} is caught by ${isCrossSlash ? "Guardian Cross" : "Elemental Beam"}.`,
tone: "danger",
pulseKind: "slash",
targetId: member.id,
});
return {
...context.damageMember(member, lane.damage, context.partyPositions[member.id], context.time),
knockedUntil: Math.max(member.knockedUntil, context.time + EMBER_MANTIS_SLASH.staggerDuration),
};
});
return { party, hitIds };
}
export function advanceEmberMantisMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
let party = context.party;
const events: BossMechanicResult["events"] = [];
if (motion.mode === "holding") {
returnBossToArenaCenter(motion, context.delta, 2.5);
if (context.time >= motion.nextMechanicAt) {
motion = beginSidestep(motion, party, context.partyPositions, context.time);
events.push({
at: context.time,
message: `${boss.name} shifts toward ${memberName(party, motion.chargeTargetId)}. Track its attack.`,
tone: "danger",
pulseKind: "slash",
targetId: motion.chargeTargetId,
});
}
} else if (motion.mode === "mantis_sidestep") {
motion.position = moveToward(motion.position, motion.chargeEnd, EMBER_MANTIS_SLASH.sidestepSpeed * context.delta);
if (context.time >= motion.phaseEndsAt) {
motion = beginSlashTelegraph(motion, context.partyPositions, context.time);
const cross = motion.mode === "mantis_cross_telegraph";
events.push({
at: context.time,
message: cross ? "Guardian Cross! Find a safe quadrant." : "Elemental Beam! Clear the glowing lane.",
tone: "danger",
pulseKind: "slash",
targetId: motion.chargeTargetId,
});
}
} else if (motion.mode === "mantis_line_telegraph" || motion.mode === "mantis_cross_telegraph") {
if (context.time >= motion.phaseEndsAt) {
const resolved = resolveSlash(motion, context, events);
party = resolved.party;
motion = {
...motion,
mode: "mantis_recover",
phaseStartedAt: context.time,
phaseEndsAt: context.time + EMBER_MANTIS_SLASH.recoverDuration,
mechanicHitIds: resolved.hitIds,
};
}
} else if (motion.mode === "mantis_recover" && context.time >= motion.phaseEndsAt) {
motion = {
...motion,
mode: "holding",
phaseStartedAt: context.time,
phaseEndsAt: 0,
nextMechanicAt: context.time + EMBER_MANTIS_SLASH.repeatDelay,
slashLanes: [],
mechanicHitIds: [],
};
}
applyMelee(boss, motion, party, context.partyPositions, context.time, 1.9, 14, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingEmberMantisMechanic(
boss: BossState,
motion: BossMotionState,
time: number,
): UpcomingMechanic {
if (motion.mode === "mantis_sidestep") {
return { name: "Guardian repositioning", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.sidestepDuration, urgent: true };
}
if (motion.mode === "mantis_line_telegraph") {
return { name: "Elemental Beam — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
}
if (motion.mode === "mantis_cross_telegraph") {
return { name: "Guardian Cross — safe quadrant", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
}
if (motion.mode === "mantis_recover") {
return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.recoverDuration, urgent: false };
}
const nextIsCross = motion.mechanicCount % 2 === 1;
const remaining = Math.max(0, motion.nextMechanicAt - time);
return {
name: nextIsCross ? "Guardian Cross" : "Elemental Beam",
remaining,
cycle: EMBER_MANTIS_SLASH.repeatDelay + EMBER_MANTIS_SLASH.telegraphDuration,
urgent: remaining < 2.5,
};
}
-85
View File
@@ -1,85 +0,0 @@
import { describe, expect, it } from "vitest";
import { freshParty } from "../data";
import type { BossMotionState, BossState, WorldPosition } from "../types";
import { advanceCinderbackMechanics, CINDERBACK, createCinderbackMotion, createCinderbackState } from "./ricochet";
import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, OBSIDIAN_RAM } from "./obsidianRamGolem";
import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, SANDGLASS } from "./sandglassScorpion";
import type { BossMechanicContext } from "./types";
const POSITIONS: BossMechanicContext["partyPositions"] = {
aelia: [0, 4.5],
brann: [0, 0],
nia: [-3, 2],
orin: [3, 2],
vale: [0, -2],
};
function context(
boss: BossState,
motion: BossMotionState,
time: number,
delta = 0.1,
positions = POSITIONS,
party = freshParty(),
): BossMechanicContext {
return {
boss,
motion,
party,
partyPositions: structuredClone(positions),
time,
delta,
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
};
}
describe("IWT2 boss trio mechanics", () => {
it("telegraphs and resolves Bristlequake Tusk Charge", () => {
const start = advanceObsidianRamMechanics(context(createObsidianRamState(), createObsidianRamMotion(), OBSIDIAN_RAM.firstAt));
expect(start.motion.mode).toBe("ram_charge_telegraph");
expect(start.motion.slashLanes).toHaveLength(1);
const active = advanceObsidianRamMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
expect(active.motion.mode).toBe("ram_charging");
const niaBefore = active.party.find((member) => member.id === "nia")!.hp;
const impact = advanceObsidianRamMechanics(context(active.boss, active.motion, active.motion.phaseStartedAt + 1, 1, POSITIONS, active.party));
expect(impact.party.find((member) => member.id === "nia")!.hp).toBe(niaBefore - OBSIDIAN_RAM.chargeDamage);
});
it("builds three Armor Shatter fault lanes", () => {
const motion = { ...createObsidianRamMotion(), mechanicCount: 2, nextMechanicAt: 0 };
const result = advanceObsidianRamMechanics(context(createObsidianRamState(), motion, 0));
expect(result.motion.mode).toBe("ram_shatter");
expect(result.motion.slashLanes).toHaveLength(3);
});
it("executes both Cinderback rebounds and leaves lava at each impact", () => {
const start = advanceCinderbackMechanics(context(createCinderbackState(), createCinderbackMotion(), CINDERBACK.firstAt));
const firstRush = advanceCinderbackMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
const firstImpact = advanceCinderbackMechanics(context(firstRush.boss, firstRush.motion, firstRush.motion.phaseStartedAt + 2, 2, POSITIONS, firstRush.party));
expect(firstImpact.motion.mode).toBe("cinderback_ricochet");
expect(firstImpact.motion.chargeCount).toBe(1);
expect(firstImpact.motion.hazards.filter((hazard) => hazard.kind === "lava_pool")).toHaveLength(1);
const secondImpact = advanceCinderbackMechanics(context(firstImpact.boss, firstImpact.motion, firstImpact.motion.phaseEndsAt, 2, POSITIONS, firstImpact.party));
expect(secondImpact.motion.mode).toBe("cinderback_recover");
expect(secondImpact.motion.hazards.filter((hazard) => hazard.kind === "lava_pool")).toHaveLength(2);
});
it("turns Sandglass stinger warnings into an active hourglass zone", () => {
const motion = { ...createSandglassMotion(), mechanicCount: 1, nextMechanicAt: 0 };
const start = advanceSandglassMechanics(context(createSandglassState(), motion, 0));
expect(start.motion.mode).toBe("sandglass_eruption");
expect(start.motion.hazards.filter((hazard) => hazard.kind === "stinger_eruption")).toHaveLength(3);
const positions = structuredClone(POSITIONS);
positions.aelia = [...start.motion.hazards[0].center] as WorldPosition;
const aeliaBefore = start.party[0].hp;
const eruption = advanceSandglassMechanics(context(start.boss, start.motion, SANDGLASS.eruptionWarning + 0.05, 0.1, positions, start.party));
expect(eruption.party[0].hp).toBe(aeliaBefore - SANDGLASS.eruptionDamage);
const hourglass = advanceSandglassMechanics(context(eruption.boss, eruption.motion, eruption.motion.phaseEndsAt + 0.01, 0.1, POSITIONS, eruption.party));
expect(hourglass.motion.mode).toBe("sandglass_hourglass");
expect(hourglass.motion.hazards.some((hazard) => hazard.kind === "hourglass")).toBe(true);
});
});
+53 -22
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import { freshParty } from "../data";
import { createBaseMotion } from "./shared";
import { advancePooledBossMechanics, POOLED_MECHANIC_TIMING, SOUL_SIPHON } from "./mechanicPool";
import type { BossMechanicContext, BossMechanicResult } from "./types";
import { advanceMechanicLoadout, BOSS_MECHANIC_POOL, bossAnimationCue, SOUL_SIPHON } from "./mechanicPool";
import type { BossMechanicContext } from "./types";
import type { BossState, PoolTelegraph, WorldPosition } from "../types";
const POSITIONS: BossMechanicContext["partyPositions"] = {
@@ -20,9 +20,6 @@ function state(): BossState {
maxHp: 500,
hp: 500,
nextMeleeAt: Number.POSITIVE_INFINITY,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
@@ -38,10 +35,6 @@ function context(time: number, positions = POSITIONS): BossMechanicContext {
};
}
function result(contextValue: BossMechanicContext): BossMechanicResult {
return { boss: contextValue.boss, motion: contextValue.motion, party: contextValue.party, events: [] };
}
function memoryTelegraph(): PoolTelegraph {
return {
id: "test-memory",
@@ -79,10 +72,10 @@ function advanceMemory(
source.party = party;
source.motion = {
...source.motion,
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
activeMechanicId: "memory-sequence",
poolTelegraphs: [telegraph],
};
return advancePooledBossMechanics(source, result(source));
return advanceMechanicLoadout(source, ["memory-sequence", "bull-charge"]);
}
function soulSiphonTelegraph(): PoolTelegraph {
@@ -121,21 +114,49 @@ function advanceSoulSiphon(
source.party = party;
source.motion = {
...source.motion,
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
activeMechanicId: "soul-siphon",
poolTelegraphs: [telegraph],
};
return advancePooledBossMechanics(source, result(source));
return advanceMechanicLoadout(source, ["soul-siphon", "bull-charge"]);
}
describe("shared boss mechanic pool", () => {
it("schedules a telegraphed pool mechanic and keeps its warning state independent of boss mode", () => {
const source = context(POOLED_MECHANIC_TIMING.firstAt);
const started = advancePooledBossMechanics(source, result(source));
it.each(BOSS_MECHANIC_POOL.filter(({ id }) => id !== "basic-melee"))("starts $name directly from its canonical ID", ({ id }) => {
const source = context(0);
source.motion.nextMechanicAt = 0;
const started = advanceMechanicLoadout(source, [id, id]);
expect(started.motion.mode).toBe("holding");
expect(started.motion.activeMechanicId).toBe(id);
expect(["idle", "move", "attack", "special"]).toContain(bossAnimationCue(started.motion));
});
it("schedules the exact mechanic ID selected by a boss loadout", () => {
const source = context(0);
source.motion.nextMechanicAt = 0;
const started = advanceMechanicLoadout(source, ["meteor-spread", "bull-charge"]);
expect(started.motion.activeMechanicId).toBe("meteor-spread");
expect(started.motion.poolTelegraphs).not.toHaveLength(0);
expect(started.events[0].message).toContain(":");
expect(started.motion.nextPoolMechanicAt).toBe(Number.POSITIVE_INFINITY);
expect(bossAnimationCue(started.motion)).toBe("attack");
});
it("moves a web caster sideways during Binding Web and animates its return home", () => {
const source = context(0);
source.motion.nextMechanicAt = 0;
const started = advanceMechanicLoadout(source, ["binding-web", "venom-purge"]);
expect(bossAnimationCue(started.motion)).toBe("move");
const next = context(0.1);
next.boss = started.boss;
next.motion = started.motion;
next.party = started.party;
const advanced = advanceMechanicLoadout(next, ["binding-web", "venom-purge"]);
expect(advanced.motion.position).not.toEqual(started.motion.position);
advanced.motion.activeMechanicId = null;
advanced.motion.mode = "holding";
expect(bossAnimationCue(advanced.motion)).toBe("move");
});
it("splits soak damage across allies inside its indicator", () => {
@@ -156,14 +177,14 @@ describe("shared boss mechanic pool", () => {
resolved: false,
hitIds: [],
};
const motion = { ...source.motion, nextPoolMechanicAt: Number.POSITIVE_INFINITY, poolTelegraphs: [soak] };
const motion = { ...source.motion, activeMechanicId: "aetheric-soak" as const, poolTelegraphs: [soak] };
const positions = structuredClone(POSITIONS);
positions.aelia = [0.2, 0];
positions.brann = [0, 0];
positions.nia = [-0.2, 0];
positions.vale = [0, -4];
const atImpact = { ...source, motion, partyPositions: positions, time: activatesAt };
const resolved = advancePooledBossMechanics(atImpact, result(atImpact));
const resolved = advanceMechanicLoadout(atImpact, ["aetheric-soak", "bull-charge"]);
expect(resolved.party.find((member) => member.id === "aelia")!.hp).toBe(source.party[0].hp - 30);
expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].hp - 30);
@@ -192,11 +213,11 @@ describe("shared boss mechanic pool", () => {
positions.brann = [3, 0];
const atImpact = {
...source,
motion: { ...source.motion, nextPoolMechanicAt: Number.POSITIVE_INFINITY, poolTelegraphs: [donut] },
motion: { ...source.motion, activeMechanicId: "hollow-collapse" as const, poolTelegraphs: [donut] },
partyPositions: positions,
time: activatesAt,
};
const resolved = advancePooledBossMechanics(atImpact, result(atImpact));
const resolved = advanceMechanicLoadout(atImpact, ["hollow-collapse", "bull-charge"]);
expect(resolved.party.find((member) => member.id === "aelia")!.hp).toBe(source.party[0].hp);
expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].hp - 25);
@@ -245,4 +266,14 @@ describe("shared boss mechanic pool", () => {
expect(cleansed.motion.poolTelegraphs).toHaveLength(0);
expect(cleansed.events[0].message).toContain("cleansing ward");
});
it("deals half-strength ramping Soul Siphon damage and caps the ramp", () => {
const result = advanceSoulSiphon(SOUL_SIPHON.tickInterval * 6 + 0.01, soulSiphonTelegraph());
const aelia = result.party.find((member) => member.id === "aelia")!;
expect(SOUL_SIPHON.tickDamage).toBe(3);
expect(SOUL_SIPHON.tickRamp).toBe(1);
expect(aelia.hp).toBe(freshParty()[0].hp - (3 + 4 + 5 + 6 + 7 + 7));
expect(result.motion.poolTelegraphs[0].soulSiphon?.tickCount).toBe(6);
});
});
+794 -71
View File
@@ -1,9 +1,34 @@
import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, WorldPosition } from "../types";
import type { BossAnimationCue, BossMechanicId, BossMotionState, CircleHazard, Debuff, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createCircleHazard, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const BOSS_MECHANIC_POOL = [
{ id: "basic-melee", name: "Basic Melee", instruction: "Maintain tank pressure." },
{ id: "bull-charge", name: "Bull Charge", instruction: "Clear the marked charge lane." },
{ id: "crushing-pounce", name: "Crushing Pounce", instruction: "Stack to split the impact." },
{ id: "cinder-nova", name: "Cinder Nova", instruction: "Heal the party through raidwide damage." },
{ id: "ember-brand", name: "Ember Brand", instruction: "Purify the marked ally." },
{ id: "binding-web", name: "Binding Web", instruction: "Separate the linked allies." },
{ id: "venom-purge", name: "Venom Purge", instruction: "Move apart, then cleanse Widow Venom before it expires." },
{ id: "storm-breath", name: "Storm Breath", instruction: "Rotate behind the sweeping cone." },
{ id: "stormfall", name: "Stormfall", instruction: "Spread before the marked impacts." },
{ id: "elemental-beam", name: "Elemental Beam", instruction: "Clear the glowing lane." },
{ id: "guardian-cross", name: "Guardian Cross", instruction: "Find a safe quadrant." },
{ id: "destruction-rush", name: "Destruction Rush", instruction: "Clear the marked rush lane." },
{ id: "ruin-quake", name: "Ruin Quake", instruction: "Leave the destruction circle." },
{ id: "destruction-pulse", name: "Destruction Pulse", instruction: "Step between the radial beams." },
{ id: "ricochet-rush", name: "Ricochet Rush", instruction: "Dodge both rebound lanes." },
{ id: "meteor-slam", name: "Meteor Slam", instruction: "Leave the impact and spreading flame." },
{ id: "burrow-rush", name: "Burrow Rush", instruction: "Cross the marked trail." },
{ id: "hourglass-eruption", name: "Hourglass Eruption", instruction: "Leave the eruptions and moving zone." },
{ id: "sidewinder-rush", name: "Sidewinder Rush", instruction: "Clear the surf lane." },
{ id: "crushing-tide", name: "Crushing Tide", instruction: "Spread before the claws close." },
{ id: "vine-scissors", name: "Vine Scissors", instruction: "Dodge the first and rotated crosses." },
{ id: "haunting-rifts", name: "Haunting Rifts", instruction: "Carry persistent rifts away from formation." },
{ id: "tri-burst", name: "Tri-Burst", instruction: "Move through the three expanding rings." },
{ id: "ultimate-skyfall", name: "Ultimate Skyfall", instruction: "Spread before the marked impacts." },
{
id: "meteor-spread",
name: "Meteor Spread",
@@ -36,18 +61,65 @@ export const BOSS_MECHANIC_POOL = [
},
] as const;
export type PoolMechanicId = (typeof BOSS_MECHANIC_POOL)[number]["id"];
const MECHANIC_COPY_BY_ID = Object.fromEntries(
BOSS_MECHANIC_POOL.map((mechanic) => [mechanic.id, mechanic]),
) as Record<BossMechanicId, (typeof BOSS_MECHANIC_POOL)[number]>;
export function bossMechanicName(id: BossMechanicId) {
return MECHANIC_COPY_BY_ID[id].name;
}
export const POOLED_MECHANIC_TIMING = {
firstAt: 15,
repeatDelay: 26,
activeDuration: 0.42,
warningDuration: 1.4,
} as const;
export const BULL_CHARGE = {
warning: 1.8,
speed: 10.5,
distance: 13.5,
hitRadius: 1.35,
damage: 18,
knockdown: 0.75,
cooldown: 4,
aiClearance: 1.9,
aiEvadeSpeed: 3.4,
} as const;
export const BULL_POUNCE = {
stackDuration: 5,
stackRadius: 3.2,
sharedDamage: 200,
leapDuration: 0.55,
cooldown: 4,
} as const;
export const SKY_SWEEPER_BREATH = {
telegraphDuration: 2,
sweepDuration: 3.2,
range: 10.5,
halfAngle: Math.PI / 7,
sweepArc: Math.PI * 0.95,
tickDamage: 9,
tickInterval: 0.45,
cooldown: 4,
} as const;
export const VENOM_PURGE = {
duration: 10,
tickDamage: 5,
castDuration: 2.5,
poolRadius: 2,
poolArmDelay: 1.25,
poolDuration: 6,
poolDamage: 8,
} as const;
const BINDING_WEB_SCUTTLE_SPEED = 3.2;
export const MEMORY_SEQUENCE = {
sequenceLength: 4,
flashDuration: 0.68,
flashDuration: 0.9,
inputDuration: 7,
tileSize: 2.15,
raidwideDamage: 15,
@@ -58,8 +130,8 @@ export const SOUL_SIPHON = {
wardDistance: ARENA_RADIUS - 1.05,
ghostSpeed: 2.6,
tickInterval: 0.7,
tickDamage: 6,
tickRamp: 2,
tickDamage: 3,
tickRamp: 1,
} as const;
export const MEMORY_SYMBOLS: Record<MemorySymbolId, { label: string; color: string }> = {
@@ -84,13 +156,6 @@ const MEMORY_SEQUENCES: readonly (readonly MemorySymbolId[])[] = [
];
const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "brann", "aelia"];
function bossPoolOffset(bossId: string) {
let value = 0;
for (let index = 0; index < bossId.length; index += 1) value = (value + bossId.charCodeAt(index)) % BOSS_MECHANIC_POOL.length;
return value;
}
function liveTarget(party: PartyMember[], targetId: MemberId) {
return party.some((member) => member.id === targetId && member.hp > 0)
? targetId
@@ -220,9 +285,10 @@ function beginPoolMechanic(
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
time: number,
requestedId: BossMechanicId,
) {
const count = motion.poolMechanicCount + 1;
const entry = BOSS_MECHANIC_POOL[(bossPoolOffset(motion.bossId) + motion.poolMechanicCount) % BOSS_MECHANIC_POOL.length];
const entry = MECHANIC_COPY_BY_ID[requestedId];
const activatesAt = time + POOLED_MECHANIC_TIMING.warningDuration;
let telegraphs: PoolTelegraph[];
let targetId: MemberId | undefined;
@@ -281,7 +347,6 @@ function beginPoolMechanic(
motion: {
...motion,
poolMechanicCount: count,
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
poolTelegraphs: telegraphs,
},
event: {
@@ -498,61 +563,6 @@ function resolveTelegraph(
return next;
}
/** Composes the shared mechanic pool after a boss's signature mechanic update. */
export function advancePooledBossMechanics(
context: BossMechanicContext,
result: BossMechanicResult,
): BossMechanicResult {
if (context.allowPooledMechanics === false) return result;
const source = result.motion;
if (!source.poolTelegraphs.length && context.time < source.nextPoolMechanicAt) return result;
let motion: BossMotionState = {
...source,
poolTelegraphs: source.poolTelegraphs.map((telegraph) => ({
...telegraph,
center: [telegraph.center[0], telegraph.center[1]],
start: telegraph.start && [telegraph.start[0], telegraph.start[1]],
end: telegraph.end && [telegraph.end[0], telegraph.end[1]],
tiles: telegraph.tiles?.map((tile) => ({ ...tile, center: [tile.center[0], tile.center[1]] })),
soulSiphon: telegraph.soulSiphon && {
...telegraph.soulSiphon,
ghostPosition: [telegraph.soulSiphon.ghostPosition[0], telegraph.soulSiphon.ghostPosition[1]],
wardPosition: [telegraph.soulSiphon.wardPosition[0], telegraph.soulSiphon.wardPosition[1]],
},
hitIds: [...telegraph.hitIds],
})),
};
let party = result.party;
const events = [...result.events];
for (const telegraph of motion.poolTelegraphs) {
if (telegraph.kind === "memory") {
if (!telegraph.resolved) party = resolveMemorySequence(telegraph, party, context.partyPositions, context, events);
continue;
}
if (telegraph.kind === "soul-siphon") {
if (!telegraph.resolved) party = resolveSoulSiphon(telegraph, party, context.partyPositions, context, events);
continue;
}
if (telegraph.resolved || context.time < telegraph.activatesAt) continue;
party = resolveTelegraph(telegraph, party, context.partyPositions, context, events);
telegraph.resolved = true;
}
motion.poolTelegraphs = motion.poolTelegraphs.filter((telegraph) => telegraph.expiresAt > context.time);
if (!motion.poolTelegraphs.length && context.time >= motion.nextPoolMechanicAt) {
const started = beginPoolMechanic(motion, party, context.partyPositions, context.time);
motion = started.motion;
events.push(started.event);
}
if (!motion.poolTelegraphs.length && !Number.isFinite(motion.nextPoolMechanicAt)) {
motion.nextPoolMechanicAt = context.time + POOLED_MECHANIC_TIMING.repeatDelay;
}
return { ...result, motion, party, events };
}
export function upcomingPooledMechanic(motion: BossMotionState, time: number): UpcomingMechanic | null {
const telegraphs = motion.poolTelegraphs.filter((telegraph) => !telegraph.resolved && telegraph.expiresAt > time);
if (!telegraphs.length) return null;
@@ -582,3 +592,716 @@ export function upcomingPooledMechanic(motion: BossMotionState, time: number): U
urgent: true,
};
}
interface MechanicRuntime {
readonly context: BossMechanicContext;
boss: BossMechanicResult["boss"];
motion: BossMotionState;
party: PartyMember[];
events: BossMechanicResult["events"];
}
export interface BossMechanicDefinition {
readonly id: BossMechanicId;
readonly name: string;
readonly instruction: string;
readonly cooldown: number;
readonly passive?: boolean;
readonly start: (runtime: MechanicRuntime) => void;
readonly advance: (runtime: MechanicRuntime) => void;
upcoming?: (motion: BossMotionState, time: number) => UpcomingMechanic;
readonly animationCue: (motion: BossMotionState) => BossAnimationCue;
}
function finishMechanic(runtime: MechanicRuntime, cooldown: number) {
runtime.motion.activeMechanicId = null;
runtime.motion.mode = "holding";
runtime.motion.phaseEndsAt = 0;
runtime.motion.nextMechanicAt = runtime.context.time + cooldown;
runtime.motion.chargeHitIds = [];
runtime.motion.mechanicHitIds = [];
runtime.motion.tetherIds = [];
runtime.motion.slashLanes = [];
}
function timedAdvance(runtime: MechanicRuntime, cooldown: number) {
if (runtime.context.time >= runtime.motion.phaseEndsAt) finishMechanic(runtime, cooldown);
}
function defaultUpcoming(definition: Pick<BossMechanicDefinition, "name" | "cooldown">, motion: BossMotionState, time: number): UpcomingMechanic {
const remaining = Math.max(0, (motion.activeMechanicId ? motion.phaseEndsAt : motion.nextMechanicAt) - time);
return { name: definition.name, remaining, cycle: definition.cooldown, urgent: motion.activeMechanicId !== null || remaining < 2.5 };
}
function mechanicCopy(id: BossMechanicId) {
return MECHANIC_COPY_BY_ID[id];
}
export function bossMechanicIsPassive(id: BossMechanicId) {
return BOSS_MECHANIC_REGISTRY[id].passive === true;
}
interface LaneChargeConfig {
id: BossMechanicId;
warning: number;
speed: number;
distance: number;
width: number;
damage: number;
knockdown: number;
cooldown: number;
targetOrder: readonly MemberId[];
}
function chargeEndpoint(start: WorldPosition, target: WorldPosition, travelDistance: number): WorldPosition {
const angle = angleTo(start, target);
return clampToArena([
start[0] + Math.sin(angle) * travelDistance,
start[1] + Math.cos(angle) * travelDistance,
]);
}
function laneChargeDefinition(config: LaneChargeConfig): BossMechanicDefinition {
const copy = mechanicCopy(config.id);
const definition: BossMechanicDefinition = {
id: config.id,
name: copy.name,
instruction: copy.instruction,
cooldown: config.cooldown,
start(runtime) {
const targetId = chooseLivingTarget(runtime.party, config.targetOrder, runtime.motion.mechanicCount);
const end = chargeEndpoint(runtime.motion.position, runtime.context.partyPositions[targetId], config.distance);
runtime.motion.mode = "telegraph";
runtime.motion.chargeTargetId = targetId;
runtime.motion.chargeStart = [...runtime.motion.position];
runtime.motion.chargeEnd = end;
runtime.motion.chargeHitIds = [];
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + config.warning;
runtime.motion.slashLanes = [{
id: `${config.id}-${runtime.motion.mechanicCount}`,
start: [...runtime.motion.position],
end,
width: config.width,
damage: config.damage,
}];
runtime.events.push({
at: runtime.context.time,
message: `${copy.name} targets ${memberName(runtime.party, targetId)}. ${copy.instruction}`,
tone: "danger",
pulseKind: "charge",
targetId,
});
},
advance(runtime) {
const { context, motion } = runtime;
if (motion.mode === "telegraph" && context.time >= motion.phaseEndsAt) {
motion.mode = "charging";
motion.phaseStartedAt = context.time;
motion.phaseEndsAt = context.time + distance(motion.position, motion.chargeEnd) / config.speed;
return;
}
if (motion.mode !== "charging") return;
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, config.speed * context.delta);
runtime.party = runtime.party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id)) return member;
if (pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > config.width * 0.5) return member;
motion.chargeHitIds.push(member.id);
return {
...context.damageMember(member, config.damage, context.partyPositions[member.id], context.time),
knockedUntil: context.time + config.knockdown,
};
});
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) {
motion.position = [...motion.chargeEnd];
finishMechanic(runtime, config.cooldown);
}
},
animationCue: (motion) => motion.mode === "charging" ? "move" : "attack",
};
definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
return definition;
}
interface CircleAttackConfig {
id: BossMechanicId;
warning: number;
radius: number;
damage: number;
cooldown: number;
kind: CircleHazard["kind"];
targets: number;
duration?: number;
tickInterval?: number;
centeredOnBoss?: boolean;
stagger?: number;
}
function circleAttackDefinition(config: CircleAttackConfig): BossMechanicDefinition {
const copy = mechanicCopy(config.id);
const definition: BossMechanicDefinition = {
id: config.id,
name: copy.name,
instruction: copy.instruction,
cooldown: config.cooldown,
start(runtime) {
const activatesAt = runtime.context.time + config.warning;
const targetIds = Array.from({ length: config.targets }, (_, offset) =>
chooseLivingTarget(runtime.party, TARGET_ORDER, runtime.motion.mechanicCount + offset));
const centers = config.centeredOnBoss
? [[...runtime.motion.position] as WorldPosition]
: targetIds.map((targetId) => [...runtime.context.partyPositions[targetId]] as WorldPosition);
runtime.motion.mode = config.id === "stormfall" ? "skyfall" : "golem_crownfall";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = activatesAt + (centers.length - 1) * (config.stagger ?? 0) + Math.max(0.32, config.duration ?? 0.32);
runtime.motion.hazards.push(...centers.map((center, index) => createCircleHazard({
id: `${config.id}-${runtime.motion.mechanicCount}-${index}`,
kind: config.kind,
center,
radius: config.radius,
activatesAt: activatesAt + index * (config.stagger ?? 0),
duration: config.duration ?? 0.32,
damage: config.damage,
tickInterval: config.tickInterval,
})));
runtime.events.push({
at: runtime.context.time,
message: `${copy.name}: ${copy.instruction}`,
tone: "danger",
pulseKind: "skyfall",
targetId: targetIds[0],
});
},
advance(runtime) { timedAdvance(runtime, config.cooldown); },
animationCue: () => "special",
};
definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
return definition;
}
interface LaneAttackConfig {
id: BossMechanicId;
warning: number;
width: number;
damage: number;
cooldown: number;
angles: readonly number[];
rotateFollowup?: number;
}
function laneFromAngle(id: string, center: WorldPosition, angle: number, width: number, damage: number): SlashLane {
const dx = Math.sin(angle) * 9;
const dz = Math.cos(angle) * 9;
return { id, start: [center[0] - dx, center[1] - dz], end: [center[0] + dx, center[1] + dz], width, damage };
}
function resolveLanes(runtime: MechanicRuntime, name: string) {
const hitIds: MemberId[] = [];
runtime.party = runtime.party.map((member) => {
if (member.hp <= 0) return member;
const lane = runtime.motion.slashLanes.find((entry) =>
pointToSegmentDistance(runtime.context.partyPositions[member.id], entry.start, entry.end) <= entry.width * 0.5);
if (!lane) return member;
hitIds.push(member.id);
runtime.events.push({ at: runtime.context.time, message: `${member.name} is struck by ${name}.`, tone: "danger", pulseKind: "slash", targetId: member.id });
return runtime.context.damageMember(member, lane.damage, runtime.context.partyPositions[member.id], runtime.context.time);
});
runtime.motion.mechanicHitIds.push(...hitIds);
}
function laneAttackDefinition(config: LaneAttackConfig): BossMechanicDefinition {
const copy = mechanicCopy(config.id);
const startLanes = (runtime: MechanicRuntime, rotation = 0) => {
const targetId = chooseLivingTarget(runtime.party, TARGET_ORDER, runtime.motion.mechanicCount);
const center = runtime.context.partyPositions[targetId];
const aimed = angleTo(runtime.motion.position, center) + rotation;
runtime.motion.slashLanes = config.angles.map((offset, index) =>
laneFromAngle(`${config.id}-${runtime.motion.mechanicCount}-${index}-${rotation}`, center, aimed + offset, config.width, config.damage));
runtime.motion.chargeTargetId = targetId;
};
const definition: BossMechanicDefinition = {
id: config.id,
name: copy.name,
instruction: copy.instruction,
cooldown: config.cooldown,
start(runtime) {
runtime.motion.mode = "mantis_line_telegraph";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + config.warning;
runtime.motion.chargeCount = 0;
runtime.motion.mechanicHitIds = [];
startLanes(runtime);
runtime.events.push({ at: runtime.context.time, message: `${copy.name}: ${copy.instruction}`, tone: "danger", pulseKind: "slash", targetId: runtime.motion.chargeTargetId });
},
advance(runtime) {
if (runtime.context.time < runtime.motion.phaseEndsAt) return;
resolveLanes(runtime, copy.name);
if (config.rotateFollowup && runtime.motion.chargeCount === 0) {
runtime.motion.chargeCount = 1;
runtime.motion.mode = "mantis_cross_telegraph";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + config.warning;
startLanes(runtime, config.rotateFollowup);
runtime.events.push({ at: runtime.context.time, message: `${copy.name} rotates. Find new safe ground.`, tone: "danger", pulseKind: "slash" });
return;
}
finishMechanic(runtime, config.cooldown);
},
animationCue: () => "attack",
};
definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
return definition;
}
const BULL_TARGETS: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"];
const bullCharge = laneChargeDefinition({ id: "bull-charge", warning: BULL_CHARGE.warning, speed: BULL_CHARGE.speed, distance: BULL_CHARGE.distance, width: BULL_CHARGE.hitRadius * 2, damage: BULL_CHARGE.damage, knockdown: BULL_CHARGE.knockdown, cooldown: BULL_CHARGE.cooldown, targetOrder: BULL_TARGETS });
const destructionRush = laneChargeDefinition({ id: "destruction-rush", warning: 1.45, speed: 11.5, distance: 14, width: 2.5, damage: 27, knockdown: 0.55, cooldown: 3.8, targetOrder: BULL_TARGETS });
const burrowRush = laneChargeDefinition({ id: "burrow-rush", warning: 1.3, speed: 10.8, distance: 13, width: 2, damage: 25, knockdown: 0.35, cooldown: 3.8, targetOrder: ["aelia", "nia", "orin", "vale", "brann"] });
const sidewinderRush = laneChargeDefinition({ id: "sidewinder-rush", warning: 1.25, speed: 11, distance: 13.2, width: 2.3, damage: 24, knockdown: 0.42, cooldown: 3.7, targetOrder: BULL_TARGETS });
const crushingPounce: BossMechanicDefinition = {
id: "crushing-pounce",
name: bossMechanicName("crushing-pounce"),
instruction: mechanicCopy("crushing-pounce").instruction,
cooldown: 4,
start(runtime) {
const targetId = chooseLivingTarget(runtime.party, ["aelia", "nia", "orin", "vale", "brann"], runtime.motion.mechanicCount);
runtime.motion.mode = "stacking";
runtime.motion.pounceTargetId = targetId;
runtime.motion.pounceCenter = [...runtime.context.partyPositions[targetId]];
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + BULL_POUNCE.stackDuration;
runtime.events.push({ at: runtime.context.time, message: `Crushing Pounce marks ${memberName(runtime.party, targetId)}. Stack to split the impact.`, tone: "danger", pulseKind: "pounce", targetId });
},
advance(runtime) {
const { motion, context } = runtime;
if (motion.mode === "stacking") {
motion.pounceCenter = [...context.partyPositions[motion.pounceTargetId]];
if (context.time < motion.phaseEndsAt) return;
motion.mode = "pouncing";
motion.chargeStart = [...motion.position];
motion.chargeEnd = [...motion.pounceCenter];
motion.phaseStartedAt = context.time;
motion.phaseEndsAt = context.time + BULL_POUNCE.leapDuration;
return;
}
if (motion.mode !== "pouncing") return;
motion.position = moveToward(motion.position, motion.chargeEnd, 16 * context.delta);
if (context.time < motion.phaseEndsAt && distance(motion.position, motion.chargeEnd) >= 0.08) return;
const stackedIds = runtime.party.filter((member) => member.hp > 0 && distance(context.partyPositions[member.id], motion.pounceCenter) <= BULL_POUNCE.stackRadius).map((member) => member.id);
const damage = BULL_POUNCE.sharedDamage / Math.max(1, stackedIds.length);
runtime.party = runtime.party.map((member) => stackedIds.includes(member.id)
? context.damageMember(member, damage, context.partyPositions[member.id], context.time)
: member);
runtime.events.push({ at: context.time, message: `Crushing Pounce deals ${Math.round(damage)} damage across ${stackedIds.length} stacked allies.`, tone: "danger", pulseKind: "pounce", targetId: motion.pounceTargetId });
finishMechanic(runtime, BULL_POUNCE.cooldown);
},
animationCue: (motion) => motion.mode === "pouncing" ? "special" : "attack",
};
crushingPounce.upcoming = (motion, time) => defaultUpcoming(crushingPounce, motion, time);
function instantTimedDefinition(id: BossMechanicId, cooldown: number, start: (runtime: MechanicRuntime) => void, cue: BossAnimationCue = "attack"): BossMechanicDefinition {
const copy = mechanicCopy(id);
const definition: BossMechanicDefinition = {
id, name: copy.name, instruction: copy.instruction, cooldown,
start(runtime) {
runtime.motion.mode = "golem_shockwave";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + 0.55;
start(runtime);
},
advance(runtime) { timedAdvance(runtime, cooldown); },
animationCue: () => cue,
};
definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
return definition;
}
const cinderNova = instantTimedDefinition("cinder-nova", 5, (runtime) => {
runtime.party = runtime.party.map((member) => member.hp > 0
? runtime.context.damageMember(member, 13, runtime.context.partyPositions[member.id], runtime.context.time)
: member);
runtime.events.push({ at: runtime.context.time, message: "Cinder Nova strikes the party.", tone: "danger", pulseKind: "boss" });
}, "special");
const emberBrand = instantTimedDefinition("ember-brand", 5, (runtime) => {
const targetId = chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount);
runtime.party = runtime.party.map((member) => member.id === targetId ? {
...member,
debuffs: [...member.debuffs, { id: `ember-brand-${runtime.motion.mechanicCount}`, name: "Ember Brand", expiresAt: runtime.context.time + 7, nextTickAt: runtime.context.time + 1, tickDamage: 6 }],
} : member);
runtime.events.push({ at: runtime.context.time, message: `Ember Brand afflicts ${memberName(runtime.party, targetId)}.`, tone: "danger", pulseKind: "debuff", targetId });
});
const bindingWeb: BossMechanicDefinition = {
id: "binding-web", name: bossMechanicName("binding-web"), instruction: mechanicCopy("binding-web").instruction, cooldown: 4,
start(runtime) {
const pairs: readonly (readonly [MemberId, MemberId])[] = [["brann", "vale"], ["nia", "orin"], ["aelia", "nia"]];
const pair = pairs[(runtime.motion.mechanicCount - 1) % pairs.length];
const first = chooseLivingTarget(runtime.party, pair, 0);
const second = chooseLivingTarget(runtime.party, pair.filter((id) => id !== first), 0);
runtime.motion.mode = "tethering";
runtime.motion.tetherIds = [first, second];
runtime.motion.tetherBreakDistance = 6.8;
runtime.motion.chargeStart = [...runtime.motion.position];
const scuttleDirection = runtime.motion.mechanicCount % 2 === 0 ? -1 : 1;
runtime.motion.chargeEnd = clampToArena([
ARENA_CENTER[0] + runtime.motion.formationOffsetX + scuttleDirection * 4.2,
ARENA_CENTER[1] - 1.25,
]);
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + 4.5;
runtime.events.push({ at: runtime.context.time, message: `Binding Web links ${memberName(runtime.party, first)} and ${memberName(runtime.party, second)}. Spread apart.`, tone: "danger", pulseKind: "tether", targetId: first });
},
advance(runtime) {
runtime.motion.position = moveToward(
runtime.motion.position,
runtime.motion.chargeEnd,
BINDING_WEB_SCUTTLE_SPEED * runtime.context.delta,
);
const [first, second] = runtime.motion.tetherIds;
if (!first || !second || distance(runtime.context.partyPositions[first], runtime.context.partyPositions[second]) >= runtime.motion.tetherBreakDistance) {
runtime.events.push({ at: runtime.context.time, message: "Binding Web snaps. Formation is free.", pulseKind: "tether" });
finishMechanic(runtime, 4);
return;
}
if (runtime.context.time < runtime.motion.phaseEndsAt) return;
runtime.party = runtime.party.map((member) => runtime.motion.tetherIds.includes(member.id)
? { ...runtime.context.damageMember(member, 24, runtime.context.partyPositions[member.id], runtime.context.time), knockedUntil: runtime.context.time + 1.4 }
: member);
runtime.events.push({ at: runtime.context.time, message: "Binding Web constricts and roots its targets.", tone: "danger", pulseKind: "tether" });
finishMechanic(runtime, 4);
},
animationCue: (motion) => distance(motion.position, motion.chargeEnd) > 0.08 ? "move" : "attack",
};
bindingWeb.upcoming = (motion, time) => defaultUpcoming(bindingWeb, motion, time);
const venomPurge = instantTimedDefinition("venom-purge", 4, (runtime) => {
const targets = [0, 1].map((offset) => chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount + offset));
runtime.motion.mode = "venom_cast";
runtime.motion.phaseEndsAt = runtime.context.time + VENOM_PURGE.castDuration;
runtime.party = runtime.party.map((member) => targets.includes(member.id) ? {
...member,
debuffs: [...member.debuffs, { id: `widow-venom-${runtime.motion.bossId}-${runtime.motion.mechanicCount}-${member.id}`, name: "Widow Venom", expiresAt: runtime.context.time + VENOM_PURGE.duration, nextTickAt: runtime.context.time + 1, tickDamage: VENOM_PURGE.tickDamage, sourceBossId: runtime.motion.bossId }],
} : member);
runtime.events.push({ at: runtime.context.time, message: "Venom Purge applies Widow Venom. Move apart, then cleanse.", tone: "danger", pulseKind: "venom", targetId: targets[0] });
});
const stormBreath: BossMechanicDefinition = {
id: "storm-breath", name: bossMechanicName("storm-breath"), instruction: mechanicCopy("storm-breath").instruction, cooldown: 4,
start(runtime) {
const aimed = angleTo(runtime.motion.position, runtime.context.partyPositions.brann);
const direction = runtime.motion.mechanicCount % 2 === 0 ? 1 : -1;
runtime.motion.mode = "breath_telegraph";
runtime.motion.breathStartAngle = aimed - direction * Math.PI * 0.475;
runtime.motion.breathEndAngle = aimed + direction * Math.PI * 0.475;
runtime.motion.breathAngle = runtime.motion.breathStartAngle;
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + SKY_SWEEPER_BREATH.telegraphDuration;
runtime.motion.mechanicNextDamageAt = {};
runtime.events.push({ at: runtime.context.time, message: "Storm Breath gathers. Rotate behind the sweep.", tone: "danger", pulseKind: "breath" });
},
advance(runtime) {
const { motion, context } = runtime;
if (motion.mode === "breath_telegraph" && context.time >= motion.phaseEndsAt) {
motion.mode = "breath_sweeping";
motion.phaseStartedAt = context.time;
motion.phaseEndsAt = context.time + SKY_SWEEPER_BREATH.sweepDuration;
return;
}
if (motion.mode !== "breath_sweeping") return;
const progress = Math.max(0, Math.min(1, (context.time - motion.phaseStartedAt) / SKY_SWEEPER_BREATH.sweepDuration));
motion.breathAngle = motion.breathStartAngle + (motion.breathEndAngle - motion.breathStartAngle) * progress;
runtime.party = runtime.party.map((member) => {
if (member.hp <= 0) return member;
const dx = context.partyPositions[member.id][0] - motion.position[0];
const dz = context.partyPositions[member.id][1] - motion.position[1];
const memberAngle = Math.atan2(dx, dz);
const exposed = Math.hypot(dx, dz) <= SKY_SWEEPER_BREATH.range && Math.abs(Math.atan2(Math.sin(memberAngle - motion.breathAngle), Math.cos(memberAngle - motion.breathAngle))) <= SKY_SWEEPER_BREATH.halfAngle;
if (!exposed) return member;
const nextAt = motion.mechanicNextDamageAt[member.id] ?? context.time;
if (nextAt > context.time) return member;
motion.mechanicNextDamageAt[member.id] = context.time + SKY_SWEEPER_BREATH.tickInterval;
return context.damageMember(member, SKY_SWEEPER_BREATH.tickDamage, context.partyPositions[member.id], context.time);
});
if (context.time >= motion.phaseEndsAt) finishMechanic(runtime, SKY_SWEEPER_BREATH.cooldown);
},
animationCue: () => "attack",
};
stormBreath.upcoming = (motion, time) => defaultUpcoming(stormBreath, motion, time);
const stormfall = circleAttackDefinition({ id: "stormfall", warning: 2, radius: 1.8, damage: 30, cooldown: 4, kind: "skyfall", targets: 3, duration: 5, tickInterval: 1, stagger: 0.9 });
const crushingTide = circleAttackDefinition({ id: "crushing-tide", warning: 1.35, radius: 1.7, damage: 26, cooldown: 3.7, kind: "tidal_burst", targets: 3 });
const hauntingRifts = circleAttackDefinition({ id: "haunting-rifts", warning: 1.45, radius: 1.75, damage: 5, cooldown: 3.9, kind: "soul_rift", targets: 2, duration: 4.2, tickInterval: 0.8 });
const ultimateSkyfall = circleAttackDefinition({ id: "ultimate-skyfall", warning: 1.5, radius: 1.85, damage: 28, cooldown: 4, kind: "crownfall", targets: 3 });
const ruinQuake = circleAttackDefinition({ id: "ruin-quake", warning: 1.35, radius: 3.6, damage: 31, cooldown: 3.8, kind: "quake", targets: 1, centeredOnBoss: true });
const elementalBeam = laneAttackDefinition({ id: "elemental-beam", warning: 0.9, width: 1.65, damage: 32, cooldown: 3.4, angles: [0] });
const guardianCross = laneAttackDefinition({ id: "guardian-cross", warning: 0.9, width: 1.45, damage: 25, cooldown: 3.4, angles: [-Math.PI * 0.18, Math.PI * 0.18] });
const destructionPulse = laneAttackDefinition({ id: "destruction-pulse", warning: 1.2, width: 1.25, damage: 24, cooldown: 3.8, angles: [0, Math.PI / 3, -Math.PI / 3] });
const vineScissors = laneAttackDefinition({ id: "vine-scissors", warning: 1.25, width: 1.55, damage: 22, cooldown: 3.9, angles: [0, Math.PI / 2], rotateFollowup: Math.PI / 4 });
const ricochetRush: BossMechanicDefinition = {
...laneChargeDefinition({ id: "ricochet-rush", warning: 1.25, speed: 12.5, distance: 13, width: 2.25, damage: 22, knockdown: 0.4, cooldown: 3.6, targetOrder: ["orin", "nia", "aelia", "vale", "brann"] }),
advance(runtime) {
const motion = runtime.motion;
if (motion.mode === "telegraph" && runtime.context.time >= motion.phaseEndsAt) {
motion.mode = "charging";
motion.chargeCount = 0;
motion.phaseStartedAt = runtime.context.time;
motion.phaseEndsAt = runtime.context.time + distance(motion.position, motion.chargeEnd) / 12.5;
return;
}
if (motion.mode !== "charging") return;
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, 12.5 * runtime.context.delta);
runtime.party = runtime.party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(runtime.context.partyPositions[member.id], previous, motion.position) > 1.125) return member;
motion.chargeHitIds.push(member.id);
return runtime.context.damageMember(member, 22, runtime.context.partyPositions[member.id], runtime.context.time);
});
if (distance(motion.position, motion.chargeEnd) >= 0.08 && runtime.context.time < motion.phaseEndsAt) return;
motion.hazards.push(createCircleHazard({ id: `ricochet-lava-${motion.mechanicCount}-${motion.chargeCount}`, kind: "lava_pool", center: motion.chargeEnd, radius: 1.5, activatesAt: runtime.context.time, duration: 4.5, damage: 5, tickInterval: 0.8 }));
if (motion.chargeCount === 0) {
const targetId = chooseLivingTarget(runtime.party, BULL_TARGETS, motion.mechanicCount + 2);
const start = [...motion.chargeEnd] as WorldPosition;
const end = chargeEndpoint(start, runtime.context.partyPositions[targetId], 13);
motion.position = start;
motion.chargeStart = start;
motion.chargeEnd = end;
motion.chargeTargetId = targetId;
motion.chargeHitIds = [];
motion.chargeCount = 1;
motion.phaseEndsAt = runtime.context.time + distance(start, end) / 12.5;
motion.slashLanes = [{ id: `ricochet-${motion.mechanicCount}-1`, start, end, width: 2.25, damage: 22 }];
runtime.events.push({ at: runtime.context.time, message: `Ricochet Rush rebounds toward ${memberName(runtime.party, targetId)}.`, tone: "danger", pulseKind: "charge", targetId });
return;
}
finishMechanic(runtime, 3.6);
},
};
const meteorSlam: BossMechanicDefinition = {
id: "meteor-slam", name: bossMechanicName("meteor-slam"), instruction: mechanicCopy("meteor-slam").instruction, cooldown: 3.6,
start(runtime) {
const activatesAt = runtime.context.time + 1.3;
runtime.motion.mode = "cinderback_slam";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = activatesAt + 0.3;
runtime.motion.hazards.push(createCircleHazard({ id: `meteor-slam-${runtime.motion.mechanicCount}`, kind: "quake", center: runtime.motion.position, radius: 3.1, activatesAt, duration: 0.3, damage: 29 }));
for (let index = 0; index < 3; index += 1) {
const angle = index / 3 * Math.PI * 2;
runtime.motion.hazards.push(createCircleHazard({ id: `meteor-flame-${runtime.motion.mechanicCount}-${index}`, kind: "lava_pool", center: clampToArena([runtime.motion.position[0] + Math.sin(angle) * 3.7, runtime.motion.position[1] + Math.cos(angle) * 3.7]), radius: 1.5, activatesAt, duration: 4.5, damage: 5, tickInterval: 0.8 }));
}
runtime.events.push({ at: runtime.context.time, message: "Meteor Slam: leave the impact and spreading flame.", tone: "danger", pulseKind: "boss" });
},
advance(runtime) { timedAdvance(runtime, 3.6); },
animationCue: () => "special",
};
meteorSlam.upcoming = (motion, time) => defaultUpcoming(meteorSlam, motion, time);
const hourglassEruption: BossMechanicDefinition = {
id: "hourglass-eruption", name: bossMechanicName("hourglass-eruption"), instruction: mechanicCopy("hourglass-eruption").instruction, cooldown: 3.8,
start(runtime) {
const activatesAt = runtime.context.time + 1.55;
for (let index = 0; index < 3; index += 1) {
const targetId = chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount + index);
runtime.motion.hazards.push(createCircleHazard({ id: `stinger-${runtime.motion.mechanicCount}-${index}`, kind: "stinger_eruption", center: runtime.context.partyPositions[targetId], radius: 1.75, activatesAt, duration: 0.32, damage: 27 }));
}
runtime.motion.hazards.push(createCircleHazard({ id: `hourglass-${runtime.motion.mechanicCount}`, kind: "hourglass", center: clampToArena([runtime.motion.position[0], runtime.motion.position[1] + 3]), radius: 2.55, activatesAt: activatesAt + 0.9, duration: 3.6, damage: 6, tickInterval: 0.75 }));
runtime.motion.mode = "sandglass_hourglass";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = activatesAt + 4.5;
runtime.events.push({ at: runtime.context.time, message: "Hourglass Eruption: leave the eruptions and moving zone.", tone: "danger", pulseKind: "skyfall" });
},
advance(runtime) { timedAdvance(runtime, 3.8); },
animationCue: () => "special",
};
hourglassEruption.upcoming = (motion, time) => defaultUpcoming(hourglassEruption, motion, time);
const triBurst: BossMechanicDefinition = {
id: "tri-burst", name: bossMechanicName("tri-burst"), instruction: mechanicCopy("tri-burst").instruction, cooldown: 4,
start(runtime) {
const firstActivation = runtime.context.time + 1.2;
const bands = [{ innerRadius: 0, radius: 2.35 }, { innerRadius: 2.35, radius: 4.7 }, { innerRadius: 4.7, radius: 7.05 }];
runtime.motion.mode = "golem_shockwave";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = firstActivation + 1.62;
runtime.motion.hazards.push(...bands.map((band, index) => createCircleHazard({ id: `tri-burst-${runtime.motion.mechanicCount}-${index}`, kind: "royal_shockwave", center: runtime.motion.position, innerRadius: band.innerRadius, radius: band.radius, activatesAt: firstActivation + index * 0.65, duration: 0.3, damage: 19 })));
runtime.events.push({ at: runtime.context.time, message: "Tri-Burst expands in three rings. Follow the safe bands.", tone: "danger", pulseKind: "boss" });
},
advance(runtime) { timedAdvance(runtime, 4); },
animationCue: () => "special",
};
triBurst.upcoming = (motion, time) => defaultUpcoming(triBurst, motion, time);
function telegraphDefinition(id: BossMechanicId): BossMechanicDefinition {
const copy = mechanicCopy(id);
const definition: BossMechanicDefinition = {
id, name: copy.name, instruction: copy.instruction, cooldown: 5,
start(runtime) {
const started = beginPoolMechanic(runtime.motion, runtime.party, runtime.context.partyPositions, runtime.context.time, id);
runtime.motion = started.motion;
runtime.motion.mode = "golem_crownfall";
runtime.events.push(started.event);
},
advance(runtime) {
for (const telegraph of runtime.motion.poolTelegraphs) {
if (telegraph.kind === "memory") {
if (!telegraph.resolved) runtime.party = resolveMemorySequence(telegraph, runtime.party, runtime.context.partyPositions, runtime.context, runtime.events);
continue;
}
if (telegraph.kind === "soul-siphon") {
if (!telegraph.resolved) runtime.party = resolveSoulSiphon(telegraph, runtime.party, runtime.context.partyPositions, runtime.context, runtime.events);
continue;
}
if (telegraph.resolved || runtime.context.time < telegraph.activatesAt) continue;
runtime.party = resolveTelegraph(telegraph, runtime.party, runtime.context.partyPositions, runtime.context, runtime.events);
telegraph.resolved = true;
}
runtime.motion.poolTelegraphs = runtime.motion.poolTelegraphs.filter((telegraph) => telegraph.expiresAt > runtime.context.time);
if (!runtime.motion.poolTelegraphs.length) finishMechanic(runtime, 5);
},
upcoming(motion, time) { return upcomingPooledMechanic(motion, time) ?? defaultUpcoming(definition, motion, time); },
animationCue: () => id === "soul-siphon" || id === "memory-sequence" ? "special" : "attack",
};
return definition;
}
const basicMelee: BossMechanicDefinition = {
id: "basic-melee",
name: bossMechanicName("basic-melee"),
instruction: mechanicCopy("basic-melee").instruction,
cooldown: 0,
passive: true,
start() {},
advance(runtime) {
applyMelee(runtime.boss, runtime.motion, runtime.party, runtime.context.partyPositions, runtime.context.time, 2.5, 15, runtime.context.damageMember);
},
animationCue: () => "idle",
};
export const BOSS_MECHANIC_REGISTRY: Record<BossMechanicId, BossMechanicDefinition> = {
"basic-melee": basicMelee,
"bull-charge": bullCharge,
"crushing-pounce": crushingPounce,
"cinder-nova": cinderNova,
"ember-brand": emberBrand,
"binding-web": bindingWeb,
"venom-purge": venomPurge,
"storm-breath": stormBreath,
stormfall,
"elemental-beam": elementalBeam,
"guardian-cross": guardianCross,
"destruction-rush": destructionRush,
"ruin-quake": ruinQuake,
"destruction-pulse": destructionPulse,
"ricochet-rush": ricochetRush,
"meteor-slam": meteorSlam,
"burrow-rush": burrowRush,
"hourglass-eruption": hourglassEruption,
"sidewinder-rush": sidewinderRush,
"crushing-tide": crushingTide,
"vine-scissors": vineScissors,
"haunting-rifts": hauntingRifts,
"tri-burst": triBurst,
"ultimate-skyfall": ultimateSkyfall,
"meteor-spread": telegraphDefinition("meteor-spread"),
"hollow-collapse": telegraphDefinition("hollow-collapse"),
"aetheric-soak": telegraphDefinition("aetheric-soak"),
"prism-beam": telegraphDefinition("prism-beam"),
"memory-sequence": telegraphDefinition("memory-sequence"),
"soul-siphon": telegraphDefinition("soul-siphon"),
};
function scheduledMechanicId(
loadout: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]],
mechanicCount: number,
) {
let activeCount = 0;
for (const id of loadout) if (!BOSS_MECHANIC_REGISTRY[id].passive) activeCount += 1;
if (!activeCount) throw new Error("Boss loadout requires at least one active mechanic.");
let targetIndex = mechanicCount % activeCount;
for (const id of loadout) {
if (BOSS_MECHANIC_REGISTRY[id].passive) continue;
if (targetIndex === 0) return id;
targetIndex -= 1;
}
return loadout[0];
}
export function advanceMechanicLoadout(
context: BossMechanicContext,
loadout: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]],
): BossMechanicResult {
const runtime: MechanicRuntime = {
context,
boss: { ...context.boss },
motion: cloneMotion(context.motion),
party: context.party,
events: [],
};
if (runtime.motion.mode === "holding") returnBossToArenaCenter(runtime.motion, context.delta, 2);
for (const mechanicId of loadout) {
const definition = BOSS_MECHANIC_REGISTRY[mechanicId];
if (definition.passive) definition.advance(runtime);
}
if (runtime.motion.activeMechanicId) {
BOSS_MECHANIC_REGISTRY[runtime.motion.activeMechanicId].advance(runtime);
}
if (!runtime.motion.activeMechanicId && context.time >= runtime.motion.nextMechanicAt) {
const mechanicId = scheduledMechanicId(loadout, runtime.motion.mechanicCount);
runtime.motion.mechanicCount += 1;
runtime.motion.activeMechanicId = mechanicId;
runtime.motion.nextMechanicAt = Number.POSITIVE_INFINITY;
BOSS_MECHANIC_REGISTRY[mechanicId].start(runtime);
}
runtime.party = resolveCircleHazards(runtime.motion, runtime.party, context.partyPositions, context.time, context.damageMember, runtime.events);
return { boss: runtime.boss, motion: runtime.motion, party: runtime.party, events: runtime.events };
}
export function upcomingLoadoutMechanic(
loadout: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]],
motion: BossMotionState,
time: number,
): UpcomingMechanic {
const id = motion.activeMechanicId ?? scheduledMechanicId(loadout, motion.mechanicCount);
const definition = BOSS_MECHANIC_REGISTRY[id];
return definition.upcoming?.(motion, time) ?? defaultUpcoming(definition, motion, time);
}
export function bossAnimationCue(motion: BossMotionState): BossAnimationCue {
if (motion.activeMechanicId) return BOSS_MECHANIC_REGISTRY[motion.activeMechanicId].animationCue(motion);
const homeDx = motion.position[0] - (ARENA_CENTER[0] + motion.formationOffsetX);
const homeDz = motion.position[1] - ARENA_CENTER[1];
return Math.hypot(homeDx, homeDz) > 0.08 ? "move" : "idle";
}
export function dropVenomPool(motion: BossMotionState, memberId: MemberId, center: WorldPosition, time: number) {
const next = cloneMotion(motion);
next.hazards.push(createCircleHazard({ id: `venom-pool-${memberId}-${time.toFixed(2)}`, kind: "venom_pool", center, radius: VENOM_PURGE.poolRadius, activatesAt: time + VENOM_PURGE.poolArmDelay, duration: VENOM_PURGE.poolDuration, damage: VENOM_PURGE.poolDamage, tickInterval: 1 }));
return next;
}
export function handleMechanicDispel(
motion: BossMotionState,
memberId: MemberId,
position: WorldPosition,
time: number,
debuffs: readonly Debuff[],
) {
if (debuffs.some((debuff) => debuff.name === "Widow Venom" && debuff.sourceBossId === motion.bossId)) {
return {
motion: dropVenomPool(motion, memberId, position, time),
message: "Widow Venom purged. A venom pool forms where the target stood.",
};
}
return { motion, message: "Harmful magic removed." };
}
-154
View File
@@ -1,154 +0,0 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const MOURNVEIL = {
firstAt: 5.3,
repeatDelay: 3.9,
crossWarning: 1.35,
followupWarning: 1.05,
laneWidth: 1.55,
laneDamage: 22,
riftWarning: 1.45,
riftRadius: 1.75,
riftDamage: 5,
riftDuration: 4.2,
recoverDuration: 0.75,
} as const;
const RIFT_TARGETS: readonly (readonly MemberId[])[] = [
["aelia", "nia"],
["orin", "vale"],
["brann", "aelia"],
];
export function createMournveilState(): BossState {
const definition = BOSS_DEFINITIONS["mournveil-ghost"];
return createBossStateFor(definition.id, definition.name, definition.maxHp, 2.35);
}
export function createMournveilMotion(): BossMotionState {
return { ...createBaseMotion("mournveil-ghost"), position: [0, -6.6], nextMechanicAt: MOURNVEIL.firstAt };
}
function crossLanes(center: WorldPosition, angle: number, mechanicCount: number, phase: number): SlashLane[] {
return [angle, angle + Math.PI / 2].map((laneAngle, index) => {
const dx = Math.sin(laneAngle) * 9;
const dz = Math.cos(laneAngle) * 9;
return {
id: `mournveil-cross-${mechanicCount}-${phase}-${index}`,
start: [center[0] - dx, center[1] - dz],
end: [center[0] + dx, center[1] + dz],
width: MOURNVEIL.laneWidth,
damage: MOURNVEIL.laneDamage,
};
});
}
function resolveCross(motion: BossMotionState, context: BossMechanicContext, party: BossMechanicContext["party"], events: BossMechanicResult["events"]) {
const hitIds: MemberId[] = [];
const nextParty = party.map((member) => {
if (member.hp <= 0) return member;
const hit = motion.slashLanes.some((lane) => pointToSegmentDistance(context.partyPositions[member.id], lane.start, lane.end) <= lane.width * 0.5);
if (!hit) return member;
hitIds.push(member.id);
events.push({ at: context.time, message: `${member.name} is cut by Vine Scissors.`, tone: "danger", pulseKind: "slash", targetId: member.id });
return context.damageMember(member, MOURNVEIL.laneDamage, context.partyPositions[member.id], context.time);
});
return { party: nextParty, hitIds };
}
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const mechanicCount = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const targetId = (["aelia", "nia", "orin", "vale", "brann"] as const)[motion.mechanicCount % 5];
const angle = angleTo(motion.position, context.partyPositions[targetId]);
events.push({ at: context.time, message: "Vine Scissors carve a spectral cross. A second cut will rotate.", tone: "danger", pulseKind: "slash", targetId });
return {
...motion,
mode: "ghost_soul_cross" as const,
breathStartAngle: angle,
chargeCount: 0,
phaseStartedAt: context.time,
phaseEndsAt: context.time + MOURNVEIL.crossWarning,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
mechanicHitIds: [],
slashLanes: crossLanes(motion.position, angle, mechanicCount, 0),
};
}
const activatesAt = context.time + MOURNVEIL.riftWarning;
const targetSet = RIFT_TARGETS[Math.floor(motion.mechanicCount / 2) % RIFT_TARGETS.length];
events.push({ at: context.time, message: "Haunting Rifts follow two allies. Carry them away from formation.", tone: "danger", pulseKind: "skyfall", targetId: targetSet[0] });
return {
...motion,
mode: "ghost_haunting" as const,
phaseStartedAt: context.time,
phaseEndsAt: activatesAt + 0.35,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [
...motion.hazards,
...targetSet.map((targetId, index) => createCircleHazard({
id: `mournveil-rift-${mechanicCount}-${index}`,
kind: "soul_rift",
center: context.partyPositions[targetId],
radius: MOURNVEIL.riftRadius,
activatesAt,
duration: MOURNVEIL.riftDuration,
damage: MOURNVEIL.riftDamage,
tickInterval: 0.8,
})),
],
};
}
export function advanceMournveilMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
returnBossToArenaCenter(motion, context.delta, 1.7);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if (motion.mode === "ghost_soul_cross" && context.time >= motion.phaseEndsAt) {
const resolved = resolveCross(motion, { ...context, party }, party, events);
party = resolved.party;
const followupAngle = motion.breathStartAngle + Math.PI / 4;
motion = {
...motion,
mode: "ghost_soul_cross_followup",
chargeCount: 1,
mechanicHitIds: resolved.hitIds,
slashLanes: crossLanes(motion.position, followupAngle, motion.mechanicCount, 1),
phaseStartedAt: context.time,
phaseEndsAt: context.time + MOURNVEIL.followupWarning,
};
events.push({ at: context.time, message: "Vine Scissors rotate. Find the new safe quadrant.", tone: "danger", pulseKind: "slash" });
} else if (motion.mode === "ghost_soul_cross_followup" && context.time >= motion.phaseEndsAt) {
const resolved = resolveCross(motion, { ...context, party }, party, events);
party = resolved.party;
motion = { ...motion, mode: "ghost_recover", mechanicHitIds: [...new Set([...motion.mechanicHitIds, ...resolved.hitIds])], phaseEndsAt: context.time + MOURNVEIL.recoverDuration };
} else if (motion.mode === "ghost_haunting" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "ghost_recover", phaseEndsAt: context.time + MOURNVEIL.recoverDuration };
} else if (motion.mode === "ghost_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + MOURNVEIL.repeatDelay, slashLanes: [], mechanicHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.25, 14, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingMournveilMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "ghost_soul_cross") return { name: "Vine Scissors — first cross", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.crossWarning, urgent: true };
if (motion.mode === "ghost_soul_cross_followup") return { name: "Vine Scissors — rotated cross", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.followupWarning, urgent: true };
if (motion.mode === "ghost_haunting") return { name: "Haunting Rifts — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.riftWarning, urgent: true };
if (motion.mode === "ghost_recover") return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Vine Scissors" : "Haunting Rifts", remaining, cycle: MOURNVEIL.repeatDelay + MOURNVEIL.crossWarning, urgent: remaining < 2.5 };
}
-182
View File
@@ -1,182 +0,0 @@
import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossId, BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const OBSIDIAN_RAM = {
firstAt: 5.5,
repeatDelay: 3.8,
chargeWarning: 1.45,
chargeSpeed: 11.5,
chargeDistance: 14,
chargeWidth: 2.5,
chargeDamage: 27,
quakeWarning: 1.35,
quakeRadius: 3.6,
quakeDamage: 31,
shatterWarning: 1.2,
shatterWidth: 1.25,
shatterDamage: 24,
recoverDuration: 0.7,
} as const;
const TARGETS: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"];
export function createObsidianRamState(bossId: BossId = "bristlequake-boar"): BossState {
const definition = BOSS_DEFINITIONS[bossId];
return {
id: definition.id,
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: 2.3,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
export function createObsidianRamMotion(bossId: BossId = "bristlequake-boar"): BossMotionState {
return { ...createBaseMotion(bossId), position: [0, -6.8], nextMechanicAt: OBSIDIAN_RAM.firstAt };
}
function laneAt(center: WorldPosition, angle: number, id: string): SlashLane {
const half = 9;
const dx = Math.sin(angle) * half;
const dz = Math.cos(angle) * half;
return {
id,
start: [center[0] - dx, center[1] - dz],
end: [center[0] + dx, center[1] + dz],
width: OBSIDIAN_RAM.shatterWidth,
damage: OBSIDIAN_RAM.shatterDamage,
};
}
function endpoint(start: WorldPosition, target: WorldPosition): WorldPosition {
const angle = angleTo(start, target);
return clampToArena([
start[0] + Math.sin(angle) * OBSIDIAN_RAM.chargeDistance,
start[1] + Math.cos(angle) * OBSIDIAN_RAM.chargeDistance,
]);
}
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const index = motion.mechanicCount % 3;
const mechanicCount = motion.mechanicCount + 1;
if (index === 0) {
const targetId = chooseLivingTarget(context.party, TARGETS, motion.mechanicCount);
const end = endpoint(motion.position, context.partyPositions[targetId]);
events.push({ at: context.time, message: `Destruction Rush locks onto ${memberName(context.party, targetId)}.`, tone: "danger", pulseKind: "charge", targetId });
return {
...motion,
mode: "ram_charge_telegraph" as const,
chargeTargetId: targetId,
chargeStart: [...motion.position] as WorldPosition,
chargeEnd: end,
chargeHitIds: [],
slashLanes: [{ id: `ram-charge-${mechanicCount}`, start: [...motion.position] as WorldPosition, end, width: OBSIDIAN_RAM.chargeWidth, damage: OBSIDIAN_RAM.chargeDamage }],
phaseStartedAt: context.time,
phaseEndsAt: context.time + OBSIDIAN_RAM.chargeWarning,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
};
}
if (index === 1) {
const activatesAt = context.time + OBSIDIAN_RAM.quakeWarning;
events.push({ at: context.time, message: "Ruin Quake! Leave the destruction circle.", tone: "danger", pulseKind: "boss" });
return {
...motion,
mode: "ram_quake" as const,
phaseStartedAt: context.time,
phaseEndsAt: activatesAt + 0.25,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [...motion.hazards, {
id: `ram-quake-${mechanicCount}`,
kind: "quake" as const,
center: [...motion.position] as WorldPosition,
radius: OBSIDIAN_RAM.quakeRadius,
activatesAt,
expiresAt: activatesAt + 0.3,
damage: OBSIDIAN_RAM.quakeDamage,
nextDamageAt: {},
resolved: false,
hitIds: [],
}],
};
}
const targetId = chooseLivingTarget(context.party, TARGETS, motion.mechanicCount);
const aimed = angleTo(motion.position, context.partyPositions[targetId]);
events.push({ at: context.time, message: "Destruction Pulse! Step between the radial beams.", tone: "danger", pulseKind: "slash", targetId });
return {
...motion,
mode: "ram_shatter" as const,
phaseStartedAt: context.time,
phaseEndsAt: context.time + OBSIDIAN_RAM.shatterWarning,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
mechanicHitIds: [],
slashLanes: [0, Math.PI / 3, -Math.PI / 3].map((offset, laneIndex) => laneAt(motion.position, aimed + offset, `ram-shatter-${mechanicCount}-${laneIndex}`)),
};
}
function resolveShatter(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const hitIds: MemberId[] = [];
const party = context.party.map((member) => {
const lane = motion.slashLanes.find((entry) => pointToSegmentDistance(context.partyPositions[member.id], entry.start, entry.end) <= entry.width * 0.5);
if (member.hp <= 0 || !lane) return member;
hitIds.push(member.id);
events.push({ at: context.time, message: `${member.name} is struck by Destruction Pulse.`, tone: "danger", pulseKind: "slash", targetId: member.id });
return context.damageMember(member, lane.damage, context.partyPositions[member.id], context.time);
});
return { party, hitIds };
}
export function advanceObsidianRamMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
returnBossToArenaCenter(motion, context.delta, 1.8);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if (motion.mode === "ram_charge_telegraph" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "ram_charging", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / OBSIDIAN_RAM.chargeSpeed };
events.push({ at: context.time, message: "Destruction Rush! Clear the lane.", tone: "danger", pulseKind: "charge" });
} else if (motion.mode === "ram_charging") {
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, OBSIDIAN_RAM.chargeSpeed * context.delta);
party = party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > OBSIDIAN_RAM.chargeWidth * 0.5) return member;
motion.chargeHitIds.push(member.id);
return { ...context.damageMember(member, OBSIDIAN_RAM.chargeDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.55 };
});
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) motion = { ...motion, position: [...motion.chargeEnd], mode: "ram_recover", phaseEndsAt: context.time + OBSIDIAN_RAM.recoverDuration };
} else if (motion.mode === "ram_shatter" && context.time >= motion.phaseEndsAt) {
const resolved = resolveShatter(motion, { ...context, party }, events);
party = resolved.party;
motion = { ...motion, mode: "ram_recover", mechanicHitIds: resolved.hitIds, phaseEndsAt: context.time + OBSIDIAN_RAM.recoverDuration };
} else if (motion.mode === "ram_quake" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "ram_recover", phaseEndsAt: context.time + OBSIDIAN_RAM.recoverDuration };
} else if (motion.mode === "ram_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + OBSIDIAN_RAM.repeatDelay, slashLanes: [], chargeHitIds: [], mechanicHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.2, 15, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingObsidianRamMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "ram_charge_telegraph" || motion.mode === "ram_charging") return { name: "Destruction Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.chargeWarning, urgent: true };
if (motion.mode === "ram_quake") return { name: "Ruin Quake — move out", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.quakeWarning, urgent: true };
if (motion.mode === "ram_shatter") return { name: "Destruction Pulse — find gap", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.shatterWarning, urgent: true };
if (motion.mode === "ram_recover") return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.recoverDuration, urgent: false };
const names = ["Destruction Rush", "Ruin Quake", "Destruction Pulse"];
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: names[motion.mechanicCount % 3], remaining, cycle: OBSIDIAN_RAM.repeatDelay + OBSIDIAN_RAM.chargeWarning, urgent: remaining < 2.5 };
}
-114
View File
@@ -1,114 +0,0 @@
import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossId, BossMotionState, BossState, CircleHazard, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const CINDERBACK = {
firstAt: 5,
repeatDelay: 3.6,
curlWarning: 1.25,
speed: 12.5,
distance: 13,
laneWidth: 2.25,
rushDamage: 22,
slamWarning: 1.3,
slamRadius: 3.1,
slamDamage: 29,
lavaRadius: 1.5,
lavaDamage: 5,
lavaDuration: 4.5,
recoverDuration: 0.75,
} as const;
const TARGETS: readonly MemberId[] = ["orin", "nia", "aelia", "vale", "brann"];
export function createCinderbackState(bossId: BossId = "emberfox"): BossState {
const definition = BOSS_DEFINITIONS[bossId];
return { id: definition.id, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp, nextMeleeAt: 2.4, nextNovaAt: Infinity, nextBrandAt: Infinity, brandCount: 0 };
}
export function createCinderbackMotion(bossId: BossId = "emberfox"): BossMotionState {
return { ...createBaseMotion(bossId), position: [0, -6.4], nextMechanicAt: CINDERBACK.firstAt };
}
function rushEnd(start: WorldPosition, target: WorldPosition) {
const angle = angleTo(start, target);
return clampToArena([start[0] + Math.sin(angle) * CINDERBACK.distance, start[1] + Math.cos(angle) * CINDERBACK.distance] as WorldPosition);
}
function rushLane(id: string, start: WorldPosition, end: WorldPosition): SlashLane {
return { id, start: [...start], end: [...end], width: CINDERBACK.laneWidth, damage: CINDERBACK.rushDamage };
}
function lavaPool(id: string, center: WorldPosition, at: number): CircleHazard {
return { id, kind: "lava_pool", center: [...center], radius: CINDERBACK.lavaRadius, activatesAt: at, expiresAt: at + CINDERBACK.lavaDuration, damage: CINDERBACK.lavaDamage, tickInterval: 0.8, nextDamageAt: {}, resolved: false, hitIds: [] };
}
export function advanceCinderbackMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
returnBossToArenaCenter(motion, context.delta, 2);
if (context.time >= motion.nextMechanicAt) {
const count = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount);
const end = rushEnd(motion.position, context.partyPositions[targetId]);
motion = { ...motion, mode: "cinderback_curl", chargeTargetId: targetId, chargeStart: [...motion.position], chargeEnd: end, chargeHitIds: [], chargeCount: 0, phaseStartedAt: context.time, phaseEndsAt: context.time + CINDERBACK.curlWarning, nextMechanicAt: Infinity, mechanicCount: count, slashLanes: [rushLane(`ricochet-${count}-0`, motion.position, end)] };
events.push({ at: context.time, message: `${boss.name} dives toward ${memberName(party, targetId)}. Two rebounds incoming.`, tone: "danger", pulseKind: "charge", targetId });
} else {
const activatesAt = context.time + CINDERBACK.slamWarning;
const pools = [0, 1, 2].map((index) => {
const angle = (index / 3) * Math.PI * 2;
return lavaPool(`slam-lava-${count}-${index}`, clampToArena([motion.position[0] + Math.sin(angle) * 3.7, motion.position[1] + Math.cos(angle) * 3.7]), activatesAt);
});
motion = { ...motion, mode: "cinderback_slam", phaseStartedAt: context.time, phaseEndsAt: activatesAt + 0.25, nextMechanicAt: Infinity, mechanicCount: count, hazards: [...motion.hazards, { id: `armor-slam-${count}`, kind: "quake", center: [...motion.position], radius: CINDERBACK.slamRadius, activatesAt, expiresAt: activatesAt + 0.3, damage: CINDERBACK.slamDamage, nextDamageAt: {}, resolved: false, hitIds: [] }, ...pools] };
events.push({ at: context.time, message: "Meteor slam! Clear the spreading flame.", tone: "danger", pulseKind: "boss" });
}
}
} else if (motion.mode === "cinderback_curl" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "cinderback_ricochet", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / CINDERBACK.speed };
} else if (motion.mode === "cinderback_ricochet") {
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, CINDERBACK.speed * context.delta);
party = party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > CINDERBACK.laneWidth * 0.5) return member;
motion.chargeHitIds.push(member.id);
return { ...context.damageMember(member, CINDERBACK.rushDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.4 };
});
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) {
motion.hazards.push(lavaPool(`ricochet-lava-${motion.mechanicCount}-${motion.chargeCount}`, motion.chargeEnd, context.time));
if (motion.chargeCount === 0) {
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount + 2);
const start = [...motion.chargeEnd] as WorldPosition;
const end = rushEnd(start, context.partyPositions[targetId]);
motion = { ...motion, position: start, chargeStart: start, chargeEnd: end, chargeTargetId: targetId, chargeHitIds: [], chargeCount: 1, phaseEndsAt: context.time + distance(start, end) / CINDERBACK.speed, slashLanes: [rushLane(`ricochet-${motion.mechanicCount}-1`, start, end)] };
events.push({ at: context.time, message: `Ricochet rush rebounds toward ${memberName(party, targetId)}!`, tone: "danger", pulseKind: "charge", targetId });
} else {
motion = { ...motion, position: [...motion.chargeEnd], mode: "cinderback_recover", phaseEndsAt: context.time + CINDERBACK.recoverDuration };
}
}
} else if (motion.mode === "cinderback_slam" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "cinderback_recover", phaseEndsAt: context.time + CINDERBACK.recoverDuration };
} else if (motion.mode === "cinderback_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", nextMechanicAt: context.time + CINDERBACK.repeatDelay, phaseEndsAt: 0, slashLanes: [], chargeHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.1, 14, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingCinderbackMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "cinderback_curl") return { name: "Ricochet Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.curlWarning, urgent: true };
if (motion.mode === "cinderback_ricochet") return { name: motion.chargeCount === 0 ? "First rebound" : "Second rebound", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 1.2, urgent: true };
if (motion.mode === "cinderback_slam") return { name: "Meteor Slam — move out", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.slamWarning, urgent: true };
if (motion.mode === "cinderback_recover") return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Ricochet Rush" : "Meteor Slam", remaining, cycle: CINDERBACK.repeatDelay + CINDERBACK.curlWarning, urgent: remaining < 2.5 };
}
-103
View File
@@ -1,103 +0,0 @@
import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, CircleHazard, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const SANDGLASS = {
firstAt: 5.4,
repeatDelay: 3.8,
burrowWarning: 1.3,
burrowSpeed: 10.8,
burrowDistance: 13,
burrowWidth: 2,
burrowDamage: 25,
eruptionWarning: 1.55,
eruptionRadius: 1.75,
eruptionDamage: 27,
hourglassRadius: 2.55,
hourglassDamage: 6,
hourglassDuration: 3.6,
recoverDuration: 0.7,
} as const;
const TARGETS: readonly MemberId[] = ["aelia", "nia", "orin", "vale", "brann"];
const ERUPTION_TARGETS: readonly MemberId[][] = [["aelia", "nia", "orin"], ["brann", "vale", "aelia"], ["nia", "orin", "vale"]];
export function createSandglassState(): BossState {
const definition = BOSS_DEFINITIONS["sandglass-scorpion"];
return { id: definition.id, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp, nextMeleeAt: 2.2, nextNovaAt: Infinity, nextBrandAt: Infinity, brandCount: 0 };
}
export function createSandglassMotion(): BossMotionState {
return { ...createBaseMotion("sandglass-scorpion"), position: [0, -6.5], nextMechanicAt: SANDGLASS.firstAt };
}
function burrowEnd(start: WorldPosition, target: WorldPosition) {
const angle = angleTo(start, target);
return clampToArena([start[0] + Math.sin(angle) * SANDGLASS.burrowDistance, start[1] + Math.cos(angle) * SANDGLASS.burrowDistance] as WorldPosition);
}
function eruption(id: string, center: WorldPosition, activatesAt: number): CircleHazard {
return { id, kind: "stinger_eruption", center: [...center], radius: SANDGLASS.eruptionRadius, activatesAt, expiresAt: activatesAt + 0.32, damage: SANDGLASS.eruptionDamage, nextDamageAt: {}, resolved: false, hitIds: [] };
}
export function advanceSandglassMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
returnBossToArenaCenter(motion, context.delta, 2.1);
if (context.time >= motion.nextMechanicAt) {
const count = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount);
const end = burrowEnd(motion.position, context.partyPositions[targetId]);
const lane: SlashLane = { id: `burrow-${count}`, start: [...motion.position], end, width: SANDGLASS.burrowWidth, damage: SANDGLASS.burrowDamage };
motion = { ...motion, mode: "sandglass_burrow_telegraph", chargeTargetId: targetId, chargeStart: [...motion.position], chargeEnd: end, chargeHitIds: [], phaseStartedAt: context.time, phaseEndsAt: context.time + SANDGLASS.burrowWarning, nextMechanicAt: Infinity, mechanicCount: count, slashLanes: [lane] };
events.push({ at: context.time, message: `Burrow Rush tracks ${memberName(party, targetId)}. Cross the sand trail.`, tone: "danger", pulseKind: "charge", targetId });
} else {
const activatesAt = context.time + SANDGLASS.eruptionWarning;
const targets = ERUPTION_TARGETS[Math.floor(motion.mechanicCount / 2) % ERUPTION_TARGETS.length];
motion = { ...motion, mode: "sandglass_eruption", phaseStartedAt: context.time, phaseEndsAt: activatesAt + 0.3, nextMechanicAt: Infinity, mechanicCount: count, hazards: [...motion.hazards, ...targets.map((targetId, index) => eruption(`stinger-${count}-${index}`, context.partyPositions[targetId], activatesAt))] };
events.push({ at: context.time, message: "Stinger Eruption! Leave the timed sand circles.", tone: "danger", pulseKind: "skyfall" });
}
}
} else if (motion.mode === "sandglass_burrow_telegraph" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "sandglass_burrowing", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / SANDGLASS.burrowSpeed };
} else if (motion.mode === "sandglass_burrowing") {
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, SANDGLASS.burrowSpeed * context.delta);
party = party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > SANDGLASS.burrowWidth * 0.5) return member;
motion.chargeHitIds.push(member.id);
return { ...context.damageMember(member, SANDGLASS.burrowDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.35 };
});
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) motion = { ...motion, position: [...motion.chargeEnd], mode: "sandglass_recover", phaseEndsAt: context.time + SANDGLASS.recoverDuration };
} else if (motion.mode === "sandglass_eruption" && context.time >= motion.phaseEndsAt) {
const center = clampToArena([motion.position[0], motion.position[1] + 3]);
const activatesAt = context.time + 0.9;
motion = { ...motion, mode: "sandglass_hourglass", phaseStartedAt: context.time, phaseEndsAt: activatesAt + SANDGLASS.hourglassDuration, hazards: [...motion.hazards, { id: `hourglass-${motion.mechanicCount}`, kind: "hourglass", center, radius: SANDGLASS.hourglassRadius, activatesAt, expiresAt: activatesAt + SANDGLASS.hourglassDuration, damage: SANDGLASS.hourglassDamage, tickInterval: 0.75, nextDamageAt: {}, resolved: false, hitIds: [] }] };
events.push({ at: context.time, message: "Hourglass zone turns active. Keep moving.", tone: "danger", pulseKind: "skyfall" });
} else if (motion.mode === "sandglass_hourglass" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "sandglass_recover", phaseEndsAt: context.time + SANDGLASS.recoverDuration };
} else if (motion.mode === "sandglass_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + SANDGLASS.repeatDelay, slashLanes: [], chargeHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.15, 14, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingSandglassMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "sandglass_burrow_telegraph" || motion.mode === "sandglass_burrowing") return { name: "Burrow Rush — clear trail", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.burrowWarning, urgent: true };
if (motion.mode === "sandglass_eruption") return { name: "Stinger Eruption — move", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.eruptionWarning, urgent: true };
if (motion.mode === "sandglass_hourglass") return { name: "Hourglass zone active", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.hourglassDuration, urgent: true };
if (motion.mode === "sandglass_recover") return { name: "Chronarch exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Burrow Rush" : "Hourglass Eruption", remaining, cycle: SANDGLASS.repeatDelay + SANDGLASS.burrowWarning, urgent: remaining < 2.5 };
}
+1 -17
View File
@@ -39,19 +39,6 @@ export function returnBossToArenaCenter(motion: BossMotionState, delta: number,
return false;
}
export function createBossStateFor(bossId: BossId, name: string, maxHp: number, nextMeleeAt: number): BossState {
return {
id: bossId,
name,
maxHp,
hp: maxHp,
nextMeleeAt,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
export function createCircleHazard({
id,
kind,
@@ -92,6 +79,7 @@ export function createCircleHazard({
export function createBaseMotion(bossId: BossId): BossMotionState {
return {
bossId,
activeMechanicId: null,
formationOffsetX: 0,
mode: "holding",
position: [0, -8.2],
@@ -100,12 +88,9 @@ export function createBaseMotion(bossId: BossId): BossMotionState {
chargeTargetId: "nia",
chargeHitIds: [],
phaseEndsAt: 0,
nextChargeAt: Number.POSITIVE_INFINITY,
chargeCount: 0,
chargesSincePounce: 0,
pounceTargetId: "aelia",
pounceCenter: [0, 4.5],
pounceCount: 0,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount: 0,
phaseStartedAt: 0,
@@ -118,7 +103,6 @@ export function createBaseMotion(bossId: BossId): BossMotionState {
breathEndAngle: 0,
hazards: [],
slashLanes: [],
nextPoolMechanicAt: 15,
poolMechanicCount: 0,
poolTelegraphs: [],
};
-160
View File
@@ -1,160 +0,0 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, pointInCone } from "../geometry";
import type { BossId, BossMotionState, BossState, MemberId } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const SKY_SWEEPER_BREATH = {
firstAt: 6,
telegraphDuration: 2,
sweepDuration: 3.2,
range: 10.5,
halfAngle: Math.PI / 7,
sweepArc: Math.PI * 0.95,
tickDamage: 9,
tickInterval: 0.45,
} as const;
export const SKY_SWEEPER_SKYFALL = {
warning: 2,
stagger: 0.9,
radius: 1.8,
damage: 30,
fireDuration: 5,
} as const;
const SKYFALL_TARGETS: readonly MemberId[][] = [
["nia", "orin", "vale"],
["aelia", "brann", "orin"],
["vale", "nia", "aelia"],
];
export function createSkySweeperState(bossId: BossId): BossState {
const definition = BOSS_DEFINITIONS[bossId];
return {
id: bossId,
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: 2.5,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
export function createSkySweeperMotion(bossId: BossId): BossMotionState {
return { ...createBaseMotion(bossId), position: [0, -2.8], nextMechanicAt: SKY_SWEEPER_BREATH.firstAt };
}
export function advanceSkySweeperMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
let party = context.party;
const events: BossMechanicResult["events"] = [];
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
if (motion.mode === "holding") {
returnBossToArenaCenter(motion, context.delta, 1.9);
}
if (motion.mode === "holding" && context.time >= motion.nextMechanicAt) {
if (motion.mechanicCount % 2 === 0) {
const aimedAngle = angleTo(motion.position, context.partyPositions.brann);
const direction = motion.mechanicCount % 4 === 0 ? 1 : -1;
const startAngle = aimedAngle - direction * SKY_SWEEPER_BREATH.sweepArc * 0.5;
motion = {
...motion,
mode: "breath_telegraph",
phaseStartedAt: context.time,
phaseEndsAt: context.time + SKY_SWEEPER_BREATH.telegraphDuration,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount: motion.mechanicCount + 1,
breathAngle: startAngle,
breathStartAngle: startAngle,
breathEndAngle: startAngle + direction * SKY_SWEEPER_BREATH.sweepArc,
mechanicHitIds: [],
mechanicNextDamageAt: {},
};
events.push({ at: context.time, message: `${boss.name} gathers a sweeping storm breath. Rotate behind it!`, tone: "danger", pulseKind: "breath" });
} else {
const set = SKYFALL_TARGETS[Math.floor(motion.mechanicCount / 2) % SKYFALL_TARGETS.length];
const hazards = set.map((memberId, index) => {
const activatesAt = context.time + SKY_SWEEPER_SKYFALL.warning + index * SKY_SWEEPER_SKYFALL.stagger;
return {
id: `skyfall-${motion.mechanicCount}-${index}`,
kind: "skyfall" as const,
center: [context.partyPositions[memberId][0], context.partyPositions[memberId][1]] as [number, number],
radius: SKY_SWEEPER_SKYFALL.radius,
activatesAt,
expiresAt: activatesAt + SKY_SWEEPER_SKYFALL.fireDuration,
damage: SKY_SWEEPER_SKYFALL.damage,
nextDamageAt: {},
resolved: false,
hitIds: [],
};
});
motion = {
...motion,
mode: "skyfall",
phaseStartedAt: context.time,
phaseEndsAt: hazards[hazards.length - 1].activatesAt + 0.5,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount: motion.mechanicCount + 1,
hazards: [...motion.hazards, ...hazards],
};
events.push({ at: context.time, message: `${boss.name} takes flight. Three skyfalls incoming!`, tone: "danger", pulseKind: "skyfall", targetId: set[0] });
}
} else if (motion.mode === "breath_telegraph" && context.time >= motion.phaseEndsAt) {
motion = {
...motion,
mode: "breath_sweeping",
phaseStartedAt: context.time,
phaseEndsAt: context.time + SKY_SWEEPER_BREATH.sweepDuration,
breathAngle: motion.breathStartAngle,
};
events.push({ at: context.time, message: "Storm breath crosses the arena!", tone: "danger", pulseKind: "breath" });
} else if (motion.mode === "breath_sweeping") {
const progress = Math.max(0, Math.min(1, (context.time - motion.phaseStartedAt) / SKY_SWEEPER_BREATH.sweepDuration));
motion.breathAngle = motion.breathStartAngle + (motion.breathEndAngle - motion.breathStartAngle) * progress;
party = party.map((member) => {
if (member.hp <= 0) return member;
const exposed = pointInCone(context.partyPositions[member.id], motion.position, motion.breathAngle, SKY_SWEEPER_BREATH.halfAngle, SKY_SWEEPER_BREATH.range);
if (!exposed) {
motion.mechanicNextDamageAt[member.id] = context.time;
return member;
}
let next = member;
let tickAt = motion.mechanicNextDamageAt[member.id] ?? motion.phaseStartedAt;
while (tickAt <= context.time + 0.001) {
next = context.damageMember(next, SKY_SWEEPER_BREATH.tickDamage, context.partyPositions[member.id], tickAt);
tickAt += SKY_SWEEPER_BREATH.tickInterval;
}
motion.mechanicNextDamageAt[member.id] = tickAt;
if (!motion.mechanicHitIds.includes(member.id)) {
motion.mechanicHitIds.push(member.id);
events.push({ at: context.time, message: `${member.name} is struck by storm breath.`, tone: "danger", pulseKind: "breath", targetId: member.id });
}
return next;
});
if (context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + 4 };
}
} else if (motion.mode === "skyfall" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + 4 };
}
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.6, 17, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingSkySweeperMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
void boss;
if (motion.mode === "breath_telegraph") return { name: "Storm Breath", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SKY_SWEEPER_BREATH.telegraphDuration, urgent: true };
if (motion.mode === "breath_sweeping") return { name: "Rotate behind", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SKY_SWEEPER_BREATH.sweepDuration, urgent: true };
if (motion.mode === "skyfall") return { name: "Stormfall", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SKY_SWEEPER_SKYFALL.warning + SKY_SWEEPER_SKYFALL.stagger * 2, urgent: true };
const nextIsBreath = motion.mechanicCount % 2 === 0;
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: nextIsBreath ? "Storm Breath" : "Stormfall", remaining, cycle: 8, urgent: remaining < 2.5 };
}
-2
View File
@@ -22,8 +22,6 @@ export interface BossMechanicContext {
partyPositions: Record<MemberId, WorldPosition>;
time: number;
delta: number;
/** Shared pool mechanics are reserved for solo encounters to avoid unreadable overlap in multi-boss fights. */
allowPooledMechanics?: boolean;
damageMember: (member: PartyMember, amount: number, position: WorldPosition, at: number, kind?: "direct" | "hazard") => PartyMember;
}
-160
View File
@@ -1,160 +0,0 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { distance } from "../geometry";
import type { BossId, BossMotionState, BossState, MemberId } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const VEXA_TETHER = {
firstAt: 6,
duration: 4.5,
breakDistance: 6.8,
failureDamage: 24,
rootDuration: 1.4,
} as const;
export const VEXA_VENOM = {
duration: 10,
tickDamage: 5,
castDuration: 2.5,
poolRadius: 2,
poolDuration: 7,
poolDamage: 14,
} as const;
const TETHER_PAIRS: readonly (readonly [MemberId, MemberId])[] = [
["brann", "vale"],
["nia", "orin"],
["aelia", "nia"],
];
const VENOM_TARGETS: readonly MemberId[][] = [
["nia", "vale"],
["orin", "brann"],
["aelia", "vale"],
];
export function createVexaState(bossId: BossId = "broodfang-spider"): BossState {
const definition = BOSS_DEFINITIONS[bossId];
return {
id: bossId,
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: 2.5,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
export function createVexaMotion(bossId: BossId = "broodfang-spider"): BossMotionState {
return { ...createBaseMotion(bossId), position: [0, -7.4], nextMechanicAt: VEXA_TETHER.firstAt };
}
export function advanceVexaMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
let party = context.party;
const events: BossMechanicResult["events"] = [];
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
if (motion.mode === "holding") {
returnBossToArenaCenter(motion, context.delta, 2.1);
}
if (motion.mode === "holding" && context.time >= motion.nextMechanicAt) {
if (motion.mechanicCount % 2 === 0) {
const pair = TETHER_PAIRS[Math.floor(motion.mechanicCount / 2) % TETHER_PAIRS.length];
const livingPair = pair.filter((memberId) => party.some((member) => member.id === memberId && member.hp > 0));
if (livingPair.length === 2) {
motion = {
...motion,
mode: "tethering",
phaseStartedAt: context.time,
phaseEndsAt: context.time + VEXA_TETHER.duration,
nextMechanicAt: Number.POSITIVE_INFINITY,
tetherIds: [...livingPair],
tetherBreakDistance: VEXA_TETHER.breakDistance,
mechanicCount: motion.mechanicCount + 1,
};
events.push({ at: context.time, message: `${boss.name} binds ${memberName(party, livingPair[0])} to ${memberName(party, livingPair[1])}. Spread apart!`, tone: "danger", pulseKind: "tether", targetId: livingPair[0] });
}
} else {
const targetSet = VENOM_TARGETS[Math.floor(motion.mechanicCount / 2) % VENOM_TARGETS.length];
const targets = targetSet.filter((memberId) => party.some((member) => member.id === memberId && member.hp > 0));
party = party.map((member) => targets.includes(member.id)
? {
...member,
debuffs: [...member.debuffs, {
id: `widow-venom-${motion.mechanicCount}-${member.id}`,
name: "Widow Venom",
expiresAt: context.time + VEXA_VENOM.duration,
nextTickAt: context.time + 1,
tickDamage: VEXA_VENOM.tickDamage,
}],
}
: member);
motion = {
...motion,
mode: "venom_cast",
phaseStartedAt: context.time,
phaseEndsAt: context.time + VEXA_VENOM.castDuration,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount: motion.mechanicCount + 1,
};
events.push({ at: context.time, message: `${boss.name} injects Widow Venom. Move away before cleansing!`, tone: "danger", pulseKind: "venom", targetId: targets[0] });
}
} else if (motion.mode === "tethering") {
const [first, second] = motion.tetherIds;
if (!first || !second || distance(context.partyPositions[first], context.partyPositions[second]) >= motion.tetherBreakDistance) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, tetherIds: [], nextMechanicAt: context.time + 4 };
events.push({ at: context.time, message: "Binding Web snaps. Formation is free.", pulseKind: "tether" });
} else if (context.time >= motion.phaseEndsAt) {
party = party.map((member) => motion.tetherIds.includes(member.id)
? {
...context.damageMember(member, VEXA_TETHER.failureDamage, context.partyPositions[member.id], context.time),
knockedUntil: context.time + VEXA_TETHER.rootDuration,
}
: member);
motion = { ...motion, mode: "holding", phaseEndsAt: 0, tetherIds: [], nextMechanicAt: context.time + 4 };
events.push({ at: context.time, message: "Binding Web constricts and roots its targets.", tone: "danger", pulseKind: "tether" });
}
} else if (motion.mode === "venom_cast" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + 4 };
}
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.7, 13, context.damageMember);
return { boss, motion, party, events };
}
export function dropVexaVenomPool(
motion: BossMotionState,
memberId: MemberId,
center: [number, number],
time: number,
) {
const next = cloneMotion(motion);
next.hazards.push({
id: `venom-pool-${memberId}-${time.toFixed(2)}`,
kind: "venom_pool",
center: [center[0], center[1]],
radius: VEXA_VENOM.poolRadius,
activatesAt: time + 0.25,
expiresAt: time + VEXA_VENOM.poolDuration,
damage: VEXA_VENOM.poolDamage,
tickInterval: 1,
nextDamageAt: {},
resolved: false,
hitIds: [],
});
return next;
}
export function upcomingVexaMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
void boss;
if (motion.mode === "tethering") return { name: "Break Binding Web", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: VEXA_TETHER.duration, urgent: true };
if (motion.mode === "venom_cast") return { name: "Move, then Purify", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: VEXA_VENOM.castDuration, urgent: true };
const nextIsTether = motion.mechanicCount % 2 === 0;
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: nextIsTether ? "Binding Web" : "Venom Purge", remaining, cycle: 8, urgent: remaining < 2.5 };
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_CAMERA_PITCH,
MAX_CAMERA_PITCH,
MIN_CAMERA_PITCH,
setCameraRelativeMovement,
updateCameraOrbit,
} from "./cameraOrbit";
describe("third-person camera orbit", () => {
it("rotates horizontal movement with camera yaw", () => {
const movement = { x: 0, z: 0 };
setCameraRelativeMovement(movement, 0, -1, 0);
expect(movement.x).toBeCloseTo(0);
expect(movement.z).toBeCloseTo(-1);
setCameraRelativeMovement(movement, 0, -1, Math.PI / 2);
expect(movement.x).toBeCloseTo(-1);
expect(movement.z).toBeCloseTo(0);
});
it("applies look input and clamps vertical orbit", () => {
const orbit = { yaw: 0, pitch: DEFAULT_CAMERA_PITCH };
updateCameraOrbit(orbit, 1, 0, 1);
expect(orbit.yaw).toBeCloseTo(-2.25);
updateCameraOrbit(orbit, 0, 1, 10);
expect(orbit.pitch).toBe(MAX_CAMERA_PITCH);
updateCameraOrbit(orbit, 0, -1, 10);
expect(orbit.pitch).toBe(MIN_CAMERA_PITCH);
});
});
+49
View File
@@ -0,0 +1,49 @@
export interface CameraOrbitState {
yaw: number;
pitch: number;
}
export interface PlanarMovement {
x: number;
z: number;
}
export const DEFAULT_CAMERA_YAW = 0;
export const DEFAULT_CAMERA_PITCH = Math.atan2(4.45, 7.7);
export const CAMERA_ORBIT_DISTANCE = Math.hypot(4.45, 7.7);
export const CAMERA_FOCUS_HEIGHT = 0.65;
export const CAMERA_LOOK_AHEAD = 2.8;
export const CAMERA_YAW_SPEED = 2.25;
export const CAMERA_PITCH_SPEED = 1.25;
export const MIN_CAMERA_PITCH = 0.24;
export const MAX_CAMERA_PITCH = 0.9;
/** Updates one reusable orbit state without allocating in the render loop. */
export function updateCameraOrbit(
orbit: CameraOrbitState,
lookX: number,
lookY: number,
deltaSeconds: number,
) {
orbit.yaw -= lookX * CAMERA_YAW_SPEED * deltaSeconds;
orbit.pitch = Math.min(
MAX_CAMERA_PITCH,
Math.max(MIN_CAMERA_PITCH, orbit.pitch + lookY * CAMERA_PITCH_SPEED * deltaSeconds),
);
if (orbit.yaw > Math.PI || orbit.yaw < -Math.PI) {
orbit.yaw = Math.atan2(Math.sin(orbit.yaw), Math.cos(orbit.yaw));
}
}
/** Converts left-stick input into world movement relative to current camera yaw. */
export function setCameraRelativeMovement(
output: PlanarMovement,
moveX: number,
moveY: number,
cameraYaw: number,
) {
const sinYaw = Math.sin(cameraYaw);
const cosYaw = Math.cos(cameraYaw);
output.x = moveX * cosYaw + moveY * sinYaw;
output.z = -moveX * sinYaw + moveY * cosYaw;
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { ABILITY_BY_CONTROLLER_BUTTON, ABILITY_CONTROLLER_BINDINGS } from "./controllerBindings";
describe("PlayStation controller ability bindings", () => {
it("keeps prompts aligned with standard gamepad button indices", () => {
expect(ABILITY_BY_CONTROLLER_BUTTON).toEqual({
0: "purify",
1: "shield",
2: "mend",
3: "renew",
4: "radiance",
5: "barrier",
});
expect(Object.values(ABILITY_CONTROLLER_BINDINGS).map(({ glyph }) => glyph)).toEqual([
"□",
"△",
"○",
"✕",
"L1",
"R1",
]);
});
});

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