Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de7b59ce77 | ||
|
|
4b31480bec | ||
|
|
ed34c2503c | ||
|
|
8e48bb4fb6 | ||
|
|
fb41147197 | ||
|
|
aa9f5eff20 | ||
|
|
18c416d4d2 | ||
|
|
77fd434226 | ||
|
|
122f159b94 | ||
|
|
35553c18dd |
@@ -1,5 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
data/
|
||||||
|
backups/
|
||||||
game_assets/
|
game_assets/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
vite.config.js
|
vite.config.js
|
||||||
@@ -10,3 +12,4 @@ vite.config.d.ts
|
|||||||
!.env.example
|
!.env.example
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
/public/basis/
|
||||||
|
|||||||
@@ -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.
|
- 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 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.
|
- 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.
|
- 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.
|
- State what was verified and any deliberately unverified scope in the handoff.
|
||||||
|
|
||||||
## Definition of done
|
## 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.
|
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.
|
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.
|
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.
|
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.
|
Every code change is not complete until:
|
||||||
7. Representative Thor hardware sustains the locked 60 FPS target, or the change includes measured evidence and an explicit approved exception.
|
|
||||||
|
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
@@ -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
|
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.
|
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
|
## Paths
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -13,6 +24,9 @@ Local Gitea bare repository:
|
|||||||
Runnable working checkout:
|
Runnable working checkout:
|
||||||
/mnt/usbssds/apps/iwanttoheal-mmo/app
|
/mnt/usbssds/apps/iwanttoheal-mmo/app
|
||||||
|
|
||||||
|
Persistent game data:
|
||||||
|
/mnt/usbssds/apps/iwanttoheal-mmo/data
|
||||||
|
|
||||||
Public URL:
|
Public URL:
|
||||||
https://iwanttoheal.phenomrom.com
|
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:
|
Clone entirely through the local filesystem:
|
||||||
|
|
||||||
```sh
|
```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 \
|
sudo git config --global --add safe.directory \
|
||||||
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
|
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
|
||||||
sudo git clone \
|
sudo git clone \
|
||||||
@@ -72,8 +86,10 @@ services:
|
|||||||
iwanttoheal:
|
iwanttoheal:
|
||||||
image: node:24-bookworm-slim
|
image: node:24-bookworm-slim
|
||||||
command: >-
|
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:
|
environment:
|
||||||
|
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost"
|
||||||
|
DATA_DIR: /app/data
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: "4173"
|
PORT: "4173"
|
||||||
init: true
|
init: true
|
||||||
@@ -82,11 +98,21 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
|
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
|
||||||
|
- /mnt/usbssds/apps/iwanttoheal-mmo/data:/app/data
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
```
|
```
|
||||||
|
|
||||||
This game has no server database. Do not add `db:init`, `/app/data`, cookie,
|
Do not remove the `/app/data` mount or place the SQLite database inside the source
|
||||||
CORS, or proxy environment settings from the older game.
|
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
|
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
|
host port `4173`, so the old and MMO apps cannot run simultaneously while both
|
||||||
|
|||||||
@@ -2,16 +2,23 @@
|
|||||||
|
|
||||||
Playable low-poly third-person combat vertical slice for AYN Thor's dual displays.
|
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
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
|
pnpm dev:api
|
||||||
|
# In a second terminal:
|
||||||
pnpm dev
|
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
|
## Android / AYN Thor test APK
|
||||||
|
|
||||||
@@ -23,6 +30,10 @@ installable debug APK:
|
|||||||
pnpm android: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
|
Output is written under `android/app/build/outputs/apk/debug/`. With Android
|
||||||
platform tools and a connected Thor, build and install it with:
|
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:
|
Complete first-install, local-Gitea clone, YAML, update, and verification steps:
|
||||||
[DEPLOYMENT.md](DEPLOYMENT.md).
|
[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`
|
- Public URL: `https://iwanttoheal.phenomrom.com`
|
||||||
- Host/container port: `4173`
|
- Host/container port: `4173`
|
||||||
- App directory: `/mnt/usbssds/apps/iwanttoheal-mmo/app`
|
- 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
|
Clone from the TrueNAS-local Gitea bare repository into the app directory, then
|
||||||
deploy `compose.yaml`. The expected source path is:
|
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
|
/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`;
|
- use `corepack pnpm install --frozen-lockfile`, not `npm ci`;
|
||||||
- remove `npm run db:init` because this game has no server database;
|
- run `pnpm db:init` before starting the production server;
|
||||||
- remove `COOKIE_SECURE`, `CORS_ORIGINS`, and `TRUST_PROXY`; the static Vite
|
- mount `/mnt/usbssds/apps/iwanttoheal-mmo/data` at `/app/data`;
|
||||||
preview server does not consume them;
|
- keep `CORS_ORIGINS` configured for the public site and Capacitor host;
|
||||||
- remove `/app/data`; offline saves live in each player's browser/Android WebView
|
- keep the source checkout and persistent data in separate mounts.
|
||||||
storage, not on TrueNAS.
|
|
||||||
|
|
||||||
The existing reverse proxy can keep forwarding the public hostname to port
|
The existing reverse proxy forwards the public hostname to port `4173`, including
|
||||||
`4173`. Add server data and authentication environment variables only when an
|
all `/api` routes.
|
||||||
actual sync API is introduced.
|
|
||||||
|
|
||||||
## Publish updates to Gitea
|
## Publish updates to Gitea
|
||||||
|
|
||||||
@@ -109,18 +131,18 @@ outside the repository.
|
|||||||
- `WASD` / left stick: move
|
- `WASD` / left stick: move
|
||||||
- `Q` and `E` / D-pad: cycle party target
|
- `Q` and `E` / D-pad: cycle party target
|
||||||
- `1`–`6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
|
- `1`–`6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
|
||||||
- Gamepad: `X`, `Y`, `B`, `A`, `LB`, `RB` map to those abilities
|
- Gamepad: PlayStation `□`, `△`, `○`, `✕`, `L1`, `R1` map to those abilities
|
||||||
- `M`: tactical map
|
- `M`: tactical map
|
||||||
- `I`: inventory and item tooltip
|
- `I`: inventory and item tooltip
|
||||||
- `Enter` / Start: begin or reset encounter
|
- `Enter` / `START`: begin or reset encounter
|
||||||
|
|
||||||
Touch controls on lower display support party targeting, ability casting, map, and inventory.
|
Touch controls on lower display support party targeting, ability casting, map, and inventory.
|
||||||
|
|
||||||
## Prototype scope
|
## Current game scope
|
||||||
|
|
||||||
- Five-member AI party with Disc Priest healer
|
- Five-member AI party with Disc Priest healer
|
||||||
- Animated Druid healer, Knight tank, Ranger, Rogue, and Mage party models
|
- 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
|
- 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
|
- Three-charge cycle into a five-second stack marker and 300-damage shared pounce
|
||||||
- Tank pressure, party-wide Cinder Nova, dispellable Ember Brand
|
- 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 leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
|
||||||
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
|
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<>();
|
Set<String> currentTokens = new HashSet<>();
|
||||||
addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_X), "Button14", "Button15");
|
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);
|
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) {
|
private void dispatchNativeControllerToken(String token, boolean repeat) {
|
||||||
if (bridge == null || bridge.getWebView() == null) return;
|
if (bridge == null || bridge.getWebView() == null) return;
|
||||||
String script =
|
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;
|
if (bridge == null || bridge.getWebView() == null) return;
|
||||||
String script =
|
String script =
|
||||||
"window.dispatchEvent(new CustomEvent('iwt-native-controller-motion',"
|
"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));
|
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void clearHeldControllerState() {
|
private void clearHeldControllerState() {
|
||||||
activeMotionTokens.clear();
|
activeMotionTokens.clear();
|
||||||
lastMotionDispatchAt.clear();
|
lastMotionDispatchAt.clear();
|
||||||
dispatchNativeControllerMotion(0.0f, 0.0f);
|
dispatchNativeControllerMotion(0.0f, 0.0f, 0.0f, 0.0f);
|
||||||
if (bridge == null || bridge.getWebView() == null) return;
|
if (bridge == null || bridge.getWebView() == null) return;
|
||||||
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(
|
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(
|
||||||
"window.dispatchEvent(new Event('iwt-native-controller-reset'));",
|
"window.dispatchEvent(new Event('iwt-native-controller-reset'));",
|
||||||
|
|||||||
+4
-1
@@ -2,8 +2,10 @@ services:
|
|||||||
iwanttoheal:
|
iwanttoheal:
|
||||||
image: node:24-bookworm-slim
|
image: node:24-bookworm-slim
|
||||||
command: >-
|
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:
|
environment:
|
||||||
|
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost"
|
||||||
|
DATA_DIR: /app/data
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: "4173"
|
PORT: "4173"
|
||||||
init: true
|
init: true
|
||||||
@@ -12,4 +14,5 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
|
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
|
||||||
|
- /mnt/usbssds/apps/iwanttoheal-mmo/data:/app/data
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
|
|||||||
@@ -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
@@ -1,20 +1,31 @@
|
|||||||
{
|
{
|
||||||
"name": "i-want-to-heal",
|
"name": "i-want-to-heal",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.4",
|
"version": "0.1.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"predev": "node scripts/sync_basis_transcoder.mjs",
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"dev:api": "HOST=127.0.0.1 PORT=4174 node server/production.mjs",
|
||||||
|
"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",
|
"build": "tsc -b && vite build",
|
||||||
"android:sync": "pnpm run build && cap sync android",
|
"android:sync": "pnpm run build && cap sync android",
|
||||||
|
"android:sync:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com pnpm run android:sync",
|
||||||
"android:apk": "pnpm run android:sync && cd android && ./gradlew --no-daemon clean assembleDebug",
|
"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",
|
"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",
|
"publish:gitea": "python3 scripts/publish_gitea.py",
|
||||||
"test": "vitest run",
|
"test": "vitest run && node --test server/game-api.test.mjs",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
"assets:build-dungeon-kit": "node scripts/build_dungeon_kit.mjs",
|
||||||
|
"assets:build-gravehorn": "node scripts/build_gravehorn_triceratops.mjs",
|
||||||
|
"assets:build-ktx2": "node scripts/build_ktx2_game_assets.mjs",
|
||||||
"assets:prune-party-animations": "node scripts/prune_party_animations.mjs --write",
|
"assets:prune-party-animations": "node scripts/prune_party_animations.mjs --write",
|
||||||
"assets:import": "node scripts/import-game-asset.mjs"
|
"assets:import": "node scripts/import-game-asset.mjs",
|
||||||
|
"assets:sync-basis-transcoder": "node scripts/sync_basis_transcoder.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
@@ -24,17 +35,20 @@
|
|||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"three": "^0.179.1",
|
"three": "^0.179.1",
|
||||||
|
"three-stdlib": "2.36.1",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "8.4.1",
|
"@capacitor/cli": "8.4.1",
|
||||||
"@gltf-transform/core": "^4.4.1",
|
"@gltf-transform/core": "^4.4.1",
|
||||||
"@gltf-transform/extensions": "^4.4.1",
|
"@gltf-transform/extensions": "^4.4.1",
|
||||||
|
"@gltf-transform/functions": "4.4.1",
|
||||||
"@types/react": "^19.1.10",
|
"@types/react": "^19.1.10",
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.1.7",
|
||||||
"@types/three": "^0.179.0",
|
"@types/three": "^0.179.0",
|
||||||
"@vitejs/plugin-react": "^5.0.2",
|
"@vitejs/plugin-react": "^5.0.2",
|
||||||
"meshoptimizer": "^1.2.0",
|
"meshoptimizer": "^1.2.0",
|
||||||
|
"sharp": "0.34.5",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^7.1.3",
|
"vite": "^7.1.3",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
|
|||||||
Generated
+379
@@ -29,6 +29,9 @@ importers:
|
|||||||
three:
|
three:
|
||||||
specifier: ^0.179.1
|
specifier: ^0.179.1
|
||||||
version: 0.179.1
|
version: 0.179.1
|
||||||
|
three-stdlib:
|
||||||
|
specifier: 2.36.1
|
||||||
|
version: 2.36.1(three@0.179.1)
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.8
|
specifier: ^5.0.8
|
||||||
version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
|
version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
|
||||||
@@ -42,6 +45,9 @@ importers:
|
|||||||
'@gltf-transform/extensions':
|
'@gltf-transform/extensions':
|
||||||
specifier: ^4.4.1
|
specifier: ^4.4.1
|
||||||
version: 4.4.1
|
version: 4.4.1
|
||||||
|
'@gltf-transform/functions':
|
||||||
|
specifier: 4.4.1
|
||||||
|
version: 4.4.1
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.1.10
|
specifier: ^19.1.10
|
||||||
version: 19.2.17
|
version: 19.2.17
|
||||||
@@ -57,6 +63,9 @@ importers:
|
|||||||
meshoptimizer:
|
meshoptimizer:
|
||||||
specifier: ^1.2.0
|
specifier: ^1.2.0
|
||||||
version: 1.2.0
|
version: 1.2.0
|
||||||
|
sharp:
|
||||||
|
specifier: 0.34.5
|
||||||
|
version: 0.34.5
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ~5.8.3
|
specifier: ~5.8.3
|
||||||
version: 5.8.3
|
version: 5.8.3
|
||||||
@@ -172,6 +181,9 @@ packages:
|
|||||||
'@dimforge/rapier3d-compat@0.12.0':
|
'@dimforge/rapier3d-compat@0.12.0':
|
||||||
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.11.2':
|
||||||
|
resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.28.1':
|
'@esbuild/aix-ppc64@0.28.1':
|
||||||
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -334,6 +346,162 @@ packages:
|
|||||||
'@gltf-transform/extensions@4.4.1':
|
'@gltf-transform/extensions@4.4.1':
|
||||||
resolution: {integrity: sha512-dZZ9D/NdpNeJUmQKExtISYtd3W6OxU4njk8UI3IKm6j97uVskYQ24BNi2YP40uUpdWPiREsS/DhjNhWAbGU/1A==}
|
resolution: {integrity: sha512-dZZ9D/NdpNeJUmQKExtISYtd3W6OxU4njk8UI3IKm6j97uVskYQ24BNi2YP40uUpdWPiREsS/DhjNhWAbGU/1A==}
|
||||||
|
|
||||||
|
'@gltf-transform/functions@4.4.1':
|
||||||
|
resolution: {integrity: sha512-CAU6hczuRz7NIEtpn7BARuwVEwRawwIcGqmoMCaEwA4XC+CSUi3xptXuf2zDG/5AftFO7IeQxXl/56rDsck0GQ==}
|
||||||
|
|
||||||
|
'@img/colour@1.1.0':
|
||||||
|
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||||
|
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||||
|
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.34.5':
|
||||||
|
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-ppc64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-riscv64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-s390x@0.34.5':
|
||||||
|
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-wasm32@0.34.5':
|
||||||
|
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [wasm32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-ia32@0.34.5':
|
||||||
|
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@ionic/cli-framework-output@2.2.8':
|
'@ionic/cli-framework-output@2.2.8':
|
||||||
resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==}
|
resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
@@ -601,6 +769,9 @@ packages:
|
|||||||
'@types/fs-extra@8.1.5':
|
'@types/fs-extra@8.1.5':
|
||||||
resolution: {integrity: sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==}
|
resolution: {integrity: sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==}
|
||||||
|
|
||||||
|
'@types/ndarray@1.0.14':
|
||||||
|
resolution: {integrity: sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg==}
|
||||||
|
|
||||||
'@types/node@26.1.1':
|
'@types/node@26.1.1':
|
||||||
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
|
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
|
||||||
|
|
||||||
@@ -791,6 +962,9 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
cwise-compiler@1.1.3:
|
||||||
|
resolution: {integrity: sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -811,6 +985,10 @@ packages:
|
|||||||
detect-gpu@5.0.70:
|
detect-gpu@5.0.70:
|
||||||
resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==}
|
resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==}
|
||||||
|
|
||||||
|
detect-libc@2.1.2:
|
||||||
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
draco3d@1.5.7:
|
draco3d@1.5.7:
|
||||||
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
|
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
|
||||||
|
|
||||||
@@ -908,6 +1086,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==}
|
resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==}
|
||||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||||
|
|
||||||
|
iota-array@1.0.0:
|
||||||
|
resolution: {integrity: sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==}
|
||||||
|
|
||||||
|
is-buffer@1.1.6:
|
||||||
|
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
|
||||||
|
|
||||||
is-docker@2.2.1:
|
is-docker@2.2.1:
|
||||||
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
|
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1020,6 +1204,18 @@ packages:
|
|||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
ndarray-lanczos@0.3.0:
|
||||||
|
resolution: {integrity: sha512-5kBmmG3Zvyj77qxIAC4QFLKuYdDIBJwCG+DukT6jQHNa1Ft74/hPH1z5mbQXeHBt8yvGPBGVrr3wEOdJPYYZYg==}
|
||||||
|
|
||||||
|
ndarray-ops@1.2.2:
|
||||||
|
resolution: {integrity: sha512-BppWAFRjMYF7N/r6Ie51q6D4fs0iiGmeXIACKY66fLpnwIui3Wc3CXiD/30mgLbDjPpSLrsqcp3Z62+IcHZsDw==}
|
||||||
|
|
||||||
|
ndarray-pixels@5.0.1:
|
||||||
|
resolution: {integrity: sha512-IBtrpefpqlI8SPDCGjXk4v5NV5z7r3JSuCbfuEEXaM0vrOJtNGgYUa4C3Lt5H+qWdYF4BCPVFsnXhNC7QvZwkw==}
|
||||||
|
|
||||||
|
ndarray@1.0.19:
|
||||||
|
resolution: {integrity: sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==}
|
||||||
|
|
||||||
node-releases@2.0.50:
|
node-releases@2.0.50:
|
||||||
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
|
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1139,6 +1335,10 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
sharp@0.34.5:
|
||||||
|
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1275,6 +1475,9 @@ packages:
|
|||||||
undici-types@8.3.0:
|
undici-types@8.3.0:
|
||||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||||
|
|
||||||
|
uniq@1.0.1:
|
||||||
|
resolution: {integrity: sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==}
|
||||||
|
|
||||||
universalify@2.0.1:
|
universalify@2.0.1:
|
||||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
@@ -1597,6 +1800,11 @@ snapshots:
|
|||||||
|
|
||||||
'@dimforge/rapier3d-compat@0.12.0': {}
|
'@dimforge/rapier3d-compat@0.12.0': {}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.11.2':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.28.1':
|
'@esbuild/aix-ppc64@0.28.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1684,6 +1892,111 @@ snapshots:
|
|||||||
'@gltf-transform/core': 4.4.1
|
'@gltf-transform/core': 4.4.1
|
||||||
ktx-parse: 1.1.0
|
ktx-parse: 1.1.0
|
||||||
|
|
||||||
|
'@gltf-transform/functions@4.4.1':
|
||||||
|
dependencies:
|
||||||
|
'@gltf-transform/core': 4.4.1
|
||||||
|
'@gltf-transform/extensions': 4.4.1
|
||||||
|
ktx-parse: 1.1.0
|
||||||
|
ndarray: 1.0.19
|
||||||
|
ndarray-lanczos: 0.3.0
|
||||||
|
ndarray-pixels: 5.0.1
|
||||||
|
|
||||||
|
'@img/colour@1.1.0': {}
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-ppc64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-riscv64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-s390x@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-x64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-wasm32@0.34.5':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/runtime': 1.11.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-arm64@0.34.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-ia32@0.34.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.34.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@ionic/cli-framework-output@2.2.8':
|
'@ionic/cli-framework-output@2.2.8':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ionic/utils-terminal': 2.3.5
|
'@ionic/utils-terminal': 2.3.5
|
||||||
@@ -1958,6 +2271,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 26.1.1
|
'@types/node': 26.1.1
|
||||||
|
|
||||||
|
'@types/ndarray@1.0.14': {}
|
||||||
|
|
||||||
'@types/node@26.1.1':
|
'@types/node@26.1.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 8.3.0
|
undici-types: 8.3.0
|
||||||
@@ -2146,6 +2461,10 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
cwise-compiler@1.1.3:
|
||||||
|
dependencies:
|
||||||
|
uniq: 1.0.1
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -2158,6 +2477,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
webgl-constants: 1.1.1
|
webgl-constants: 1.1.1
|
||||||
|
|
||||||
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
draco3d@1.5.7: {}
|
draco3d@1.5.7: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.389: {}
|
electron-to-chromium@1.5.389: {}
|
||||||
@@ -2259,6 +2580,10 @@ snapshots:
|
|||||||
|
|
||||||
ini@4.1.3: {}
|
ini@4.1.3: {}
|
||||||
|
|
||||||
|
iota-array@1.0.0: {}
|
||||||
|
|
||||||
|
is-buffer@1.1.6: {}
|
||||||
|
|
||||||
is-docker@2.2.1: {}
|
is-docker@2.2.1: {}
|
||||||
|
|
||||||
is-fullwidth-code-point@3.0.0: {}
|
is-fullwidth-code-point@3.0.0: {}
|
||||||
@@ -2357,6 +2682,27 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
ndarray-lanczos@0.3.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/ndarray': 1.0.14
|
||||||
|
ndarray: 1.0.19
|
||||||
|
|
||||||
|
ndarray-ops@1.2.2:
|
||||||
|
dependencies:
|
||||||
|
cwise-compiler: 1.1.3
|
||||||
|
|
||||||
|
ndarray-pixels@5.0.1:
|
||||||
|
dependencies:
|
||||||
|
'@types/ndarray': 1.0.14
|
||||||
|
ndarray: 1.0.19
|
||||||
|
ndarray-ops: 1.2.2
|
||||||
|
sharp: 0.34.5
|
||||||
|
|
||||||
|
ndarray@1.0.19:
|
||||||
|
dependencies:
|
||||||
|
iota-array: 1.0.0
|
||||||
|
is-buffer: 1.1.6
|
||||||
|
|
||||||
node-releases@2.0.50: {}
|
node-releases@2.0.50: {}
|
||||||
|
|
||||||
open@8.4.2:
|
open@8.4.2:
|
||||||
@@ -2481,6 +2827,37 @@ snapshots:
|
|||||||
|
|
||||||
semver@7.8.5: {}
|
semver@7.8.5: {}
|
||||||
|
|
||||||
|
sharp@0.34.5:
|
||||||
|
dependencies:
|
||||||
|
'@img/colour': 1.1.0
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
semver: 7.8.5
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-darwin-arm64': 0.34.5
|
||||||
|
'@img/sharp-darwin-x64': 0.34.5
|
||||||
|
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||||
|
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||||
|
'@img/sharp-linux-arm': 0.34.5
|
||||||
|
'@img/sharp-linux-arm64': 0.34.5
|
||||||
|
'@img/sharp-linux-ppc64': 0.34.5
|
||||||
|
'@img/sharp-linux-riscv64': 0.34.5
|
||||||
|
'@img/sharp-linux-s390x': 0.34.5
|
||||||
|
'@img/sharp-linux-x64': 0.34.5
|
||||||
|
'@img/sharp-linuxmusl-arm64': 0.34.5
|
||||||
|
'@img/sharp-linuxmusl-x64': 0.34.5
|
||||||
|
'@img/sharp-wasm32': 0.34.5
|
||||||
|
'@img/sharp-win32-arm64': 0.34.5
|
||||||
|
'@img/sharp-win32-ia32': 0.34.5
|
||||||
|
'@img/sharp-win32-x64': 0.34.5
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 3.0.0
|
shebang-regex: 3.0.0
|
||||||
@@ -2609,6 +2986,8 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@8.3.0: {}
|
undici-types@8.3.0: {}
|
||||||
|
|
||||||
|
uniq@1.0.1: {}
|
||||||
|
|
||||||
universalify@2.0.1: {}
|
universalify@2.0.1: {}
|
||||||
|
|
||||||
untildify@4.0.0: {}
|
untildify@4.0.0: {}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: true
|
esbuild: true
|
||||||
|
sharp: true
|
||||||
|
|||||||
@@ -0,0 +1,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()
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { access } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
EXTMeshoptCompression,
|
||||||
|
} from "@gltf-transform/extensions";
|
||||||
|
import { dedup, mergeDocuments, prune, unpartition } from "@gltf-transform/functions";
|
||||||
|
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const sourceDirectory = path.join(
|
||||||
|
repositoryRoot,
|
||||||
|
"game_assets",
|
||||||
|
"models",
|
||||||
|
"original",
|
||||||
|
"environment",
|
||||||
|
"kaykit-dungeon",
|
||||||
|
);
|
||||||
|
const outputPath = path.join(sourceDirectory, "dungeon-kit-uastc.glb");
|
||||||
|
const sources = ["pillar-decorated.glb", "wall-pillar.glb", "torch-lit.glb"];
|
||||||
|
const expectedMeshNames = ["pillar_decorated", "wall_pillar", "torch_lit"];
|
||||||
|
await Promise.all(sources.map((source) => access(path.join(sourceDirectory, source))));
|
||||||
|
const io = await createGameAssetIO();
|
||||||
|
|
||||||
|
const documents = await Promise.all(sources.map((source) => io.read(path.join(sourceDirectory, source))));
|
||||||
|
const document = documents[0];
|
||||||
|
const destinationScene = document.getRoot().listScenes()[0].setName("dungeon_kit");
|
||||||
|
|
||||||
|
for (const sourceDocument of documents.slice(1)) {
|
||||||
|
const sourceScene = sourceDocument.getRoot().listScenes()[0];
|
||||||
|
const propertyMap = mergeDocuments(document, sourceDocument);
|
||||||
|
const copiedScene = propertyMap.get(sourceScene);
|
||||||
|
if (!copiedScene) throw new Error(`Could not merge scene from ${sourceScene.getName() || "unnamed source"}.`);
|
||||||
|
for (const child of [...copiedScene.listChildren()]) destinationScene.addChild(child);
|
||||||
|
copiedScene.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
await document.transform(
|
||||||
|
dedup({ keepUniqueNames: false }),
|
||||||
|
unpartition(),
|
||||||
|
prune({ keepSolidTextures: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = document.getRoot();
|
||||||
|
const meshNames = root.listMeshes().map((mesh) => mesh.getName()).sort();
|
||||||
|
const expectedSorted = [...expectedMeshNames].sort();
|
||||||
|
if (JSON.stringify(meshNames) !== JSON.stringify(expectedSorted)) {
|
||||||
|
throw new Error(`Dungeon kit mesh names changed: expected ${expectedSorted.join(", ")}; received ${meshNames.join(", ")}.`);
|
||||||
|
}
|
||||||
|
if (root.listTextures().length !== 1) {
|
||||||
|
throw new Error(`Dungeon kit must contain exactly one shared texture; received ${root.listTextures().length}.`);
|
||||||
|
}
|
||||||
|
if (root.listMaterials().length !== 1) {
|
||||||
|
throw new Error(`Dungeon kit must contain exactly one shared material; received ${root.listMaterials().length}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await convertDocumentTexturesToKtx2(document, "iwt-dungeon-kit-");
|
||||||
|
const meshoptExtension = root.listExtensionsUsed()
|
||||||
|
.find((extension) => extension.extensionName === EXTMeshoptCompression.EXTENSION_NAME)
|
||||||
|
?? document.createExtension(EXTMeshoptCompression);
|
||||||
|
meshoptExtension
|
||||||
|
.setRequired(true)
|
||||||
|
.setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.QUANTIZE });
|
||||||
|
|
||||||
|
await io.write(outputPath, document);
|
||||||
|
|
||||||
|
const outputDocument = await io.read(outputPath);
|
||||||
|
const outputRoot = outputDocument.getRoot();
|
||||||
|
const outputTexture = outputRoot.listTextures()[0];
|
||||||
|
if (
|
||||||
|
outputRoot.listMeshes().length !== 3
|
||||||
|
|| outputRoot.listMaterials().length !== 1
|
||||||
|
|| outputRoot.listTextures().length !== 1
|
||||||
|
|| outputTexture.getMimeType() !== "image/ktx2"
|
||||||
|
) {
|
||||||
|
throw new Error("Generated dungeon kit failed structural validation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Built ${path.relative(repositoryRoot, outputPath)}`);
|
||||||
|
console.log("Meshes: pillar_decorated, wall_pillar, torch_lit; shared textures: 1; encoding: KTX2/UASTC.");
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { access, readFile, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { Box3, Vector3 } from "three";
|
||||||
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||||
|
import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
|
||||||
|
import { dedup, meshopt, prune, resample } from "@gltf-transform/functions";
|
||||||
|
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const assetDirectory = path.join(repositoryRoot, "game_assets", "models", "sketchfab-opensource");
|
||||||
|
const sourcePath = path.join(assetDirectory, "animated_triceratops_skeleton.glb");
|
||||||
|
const legacyPath = path.join(assetDirectory, "gravehorn-triceratops.glb");
|
||||||
|
const optimizedPath = path.join(assetDirectory, "gravehorn-triceratops-uastc.glb");
|
||||||
|
const requiredClips = [
|
||||||
|
"Armature|RiseUp",
|
||||||
|
"Armature|Roar",
|
||||||
|
"Armature|Walk",
|
||||||
|
"Armature|Fall",
|
||||||
|
"Gravehorn|Idle",
|
||||||
|
];
|
||||||
|
const unusedClips = new Set(["Armature|IdleGround", "Armature|RoarToWalk"]);
|
||||||
|
|
||||||
|
function sortedNames(properties) {
|
||||||
|
return properties.map((property) => property.getName()).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUprightIdle(document) {
|
||||||
|
const walk = document.getRoot().listAnimations().find((animation) => animation.getName() === "Armature|Walk");
|
||||||
|
const buffer = document.getRoot().listBuffers()[0];
|
||||||
|
if (!walk || !buffer) throw new Error("Gravehorn source is missing its Walk animation or binary buffer.");
|
||||||
|
|
||||||
|
const idle = document.createAnimation("Gravehorn|Idle");
|
||||||
|
for (const [index, sourceChannel] of walk.listChannels().entries()) {
|
||||||
|
const sourceSampler = sourceChannel.getSampler();
|
||||||
|
const sourceOutput = sourceSampler?.getOutput();
|
||||||
|
const sourceValues = sourceOutput?.getArray();
|
||||||
|
const targetNode = sourceChannel.getTargetNode();
|
||||||
|
const targetPath = sourceChannel.getTargetPath();
|
||||||
|
if (!sourceSampler || !sourceOutput || !sourceValues || !targetNode || !targetPath) {
|
||||||
|
throw new Error(`Gravehorn Walk channel ${index} is incomplete.`);
|
||||||
|
}
|
||||||
|
if (sourceSampler.getInterpolation() === "CUBICSPLINE") {
|
||||||
|
throw new Error("Gravehorn upright idle builder does not support cubic animation tracks.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const elementSize = sourceOutput.getElementSize();
|
||||||
|
const values = new sourceValues.constructor(elementSize * 2);
|
||||||
|
values.set(sourceValues.subarray(0, elementSize), 0);
|
||||||
|
values.set(sourceValues.subarray(0, elementSize), elementSize);
|
||||||
|
const input = document.createAccessor(`gravehorn_idle_time_${index}`)
|
||||||
|
.setType("SCALAR")
|
||||||
|
.setArray(new Float32Array([0, 1]))
|
||||||
|
.setBuffer(buffer);
|
||||||
|
const output = document.createAccessor(`gravehorn_idle_value_${index}`)
|
||||||
|
.setType(sourceOutput.getType())
|
||||||
|
.setNormalized(sourceOutput.getNormalized())
|
||||||
|
.setArray(values)
|
||||||
|
.setBuffer(buffer);
|
||||||
|
const sampler = document.createAnimationSampler(`gravehorn_idle_sampler_${index}`)
|
||||||
|
.setInput(input)
|
||||||
|
.setOutput(output)
|
||||||
|
.setInterpolation("LINEAR");
|
||||||
|
const channel = document.createAnimationChannel(`gravehorn_idle_channel_${index}`)
|
||||||
|
.setSampler(sampler)
|
||||||
|
.setTargetNode(targetNode)
|
||||||
|
.setTargetPath(targetPath);
|
||||||
|
idle.addSampler(sampler).addChannel(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runtimeBounds(assetPath) {
|
||||||
|
const data = await readFile(assetPath);
|
||||||
|
const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
||||||
|
globalThis.self ??= globalThis;
|
||||||
|
globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} });
|
||||||
|
const gltf = await new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).parseAsync(arrayBuffer, "");
|
||||||
|
gltf.scene.updateMatrixWorld(true);
|
||||||
|
const bounds = new Box3().setFromObject(gltf.scene);
|
||||||
|
return {
|
||||||
|
min: bounds.min.toArray(),
|
||||||
|
max: bounds.max.toArray(),
|
||||||
|
center: bounds.getCenter(new Vector3()).toArray(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateStructure(document, { optimized }) {
|
||||||
|
const root = document.getRoot();
|
||||||
|
const material = root.listMaterials()[0];
|
||||||
|
const clips = sortedNames(root.listAnimations());
|
||||||
|
const textureMimeTypes = root.listTextures().map((texture) => texture.getMimeType());
|
||||||
|
const extensionNames = new Set(root.listExtensionsUsed().map((extension) => extension.extensionName));
|
||||||
|
|
||||||
|
if (JSON.stringify(clips) !== JSON.stringify([...requiredClips].sort())) {
|
||||||
|
throw new Error(`Gravehorn animation clips changed: ${clips.join(", ")}.`);
|
||||||
|
}
|
||||||
|
if (root.listMeshes().length !== 1 || root.listMaterials().length !== 1 || root.listTextures().length !== 3 || root.listSkins().length !== 1) {
|
||||||
|
throw new Error("Gravehorn must contain one mesh, one material, three textures, and one skin.");
|
||||||
|
}
|
||||||
|
if (root.listSkins()[0].listJoints().length !== 140) {
|
||||||
|
throw new Error(`Gravehorn joint count changed: ${root.listSkins()[0].listJoints().length}.`);
|
||||||
|
}
|
||||||
|
if (material.getDoubleSided()) {
|
||||||
|
throw new Error("Gravehorn material must keep backface culling enabled.");
|
||||||
|
}
|
||||||
|
if (!extensionNames.has("EXT_meshopt_compression")) {
|
||||||
|
throw new Error("Gravehorn is missing Meshopt compression.");
|
||||||
|
}
|
||||||
|
if (optimized && (textureMimeTypes.some((mimeType) => mimeType !== "image/ktx2") || !extensionNames.has("KHR_texture_basisu"))) {
|
||||||
|
throw new Error("Optimized Gravehorn asset must contain only KTX2 textures.");
|
||||||
|
}
|
||||||
|
if (!optimized && textureMimeTypes.some((mimeType) => mimeType === "image/ktx2")) {
|
||||||
|
throw new Error("Legacy Gravehorn fallback must retain standard textures.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await access(sourcePath);
|
||||||
|
await MeshoptDecoder.ready;
|
||||||
|
await MeshoptEncoder.ready;
|
||||||
|
const io = await createGameAssetIO();
|
||||||
|
const document = await io.read(sourcePath);
|
||||||
|
const root = document.getRoot();
|
||||||
|
const scene = root.listScenes()[0].setName("gravehorn_triceratops");
|
||||||
|
const sceneRoot = scene.listChildren()[0];
|
||||||
|
const sourceBounds = await runtimeBounds(sourcePath);
|
||||||
|
const sourceTranslation = sceneRoot.getTranslation();
|
||||||
|
|
||||||
|
sceneRoot.setTranslation([
|
||||||
|
sourceTranslation[0] - sourceBounds.center[0],
|
||||||
|
sourceTranslation[1] - sourceBounds.min[1],
|
||||||
|
sourceTranslation[2] - sourceBounds.center[2],
|
||||||
|
]);
|
||||||
|
root.listMeshes()[0].setName("gravehorn_triceratops");
|
||||||
|
root.listMaterials()[0]
|
||||||
|
.setName("gravehorn_bone")
|
||||||
|
.setDoubleSided(false)
|
||||||
|
.setEmissiveFactor([0.1, 0.06, 0.02]);
|
||||||
|
addUprightIdle(document);
|
||||||
|
for (const animation of root.listAnimations()) {
|
||||||
|
if (!unusedClips.has(animation.getName())) continue;
|
||||||
|
for (const channel of animation.listChannels()) channel.dispose();
|
||||||
|
for (const sampler of animation.listSamplers()) sampler.dispose();
|
||||||
|
animation.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
await document.transform(
|
||||||
|
resample({ tolerance: 1e-4 }),
|
||||||
|
dedup({ keepUniqueNames: true }),
|
||||||
|
prune({ keepLeaves: true, keepSolidTextures: true }),
|
||||||
|
meshopt({ encoder: MeshoptEncoder, level: "high" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await io.write(legacyPath, document);
|
||||||
|
const legacyDocument = await io.read(legacyPath);
|
||||||
|
validateStructure(legacyDocument, { optimized: false });
|
||||||
|
const legacyBounds = await runtimeBounds(legacyPath);
|
||||||
|
if (Math.abs(legacyBounds.min[1]) > 0.015 || Math.abs(legacyBounds.center[0]) > 0.015 || Math.abs(legacyBounds.center[2]) > 0.015) {
|
||||||
|
throw new Error(`Gravehorn pivot is not grounded and centered: minY=${legacyBounds.min[1]}, centerX=${legacyBounds.center[0]}, centerZ=${legacyBounds.center[2]}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const optimizedDocument = await io.read(legacyPath);
|
||||||
|
await convertDocumentTexturesToKtx2(optimizedDocument, "iwt-gravehorn-");
|
||||||
|
await io.write(optimizedPath, optimizedDocument);
|
||||||
|
validateStructure(await io.read(optimizedPath), { optimized: true });
|
||||||
|
|
||||||
|
const sourceSize = (await stat(sourcePath)).size;
|
||||||
|
const legacySize = (await stat(legacyPath)).size;
|
||||||
|
const optimizedSize = (await stat(optimizedPath)).size;
|
||||||
|
console.log(`Built ${path.relative(repositoryRoot, legacyPath)} (${sourceSize} -> ${legacySize} bytes).`);
|
||||||
|
console.log(`Built ${path.relative(repositoryRoot, optimizedPath)} (${optimizedSize} bytes, KTX2/UASTC).`);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { access } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const sourceRoot = path.join(repositoryRoot, "game_assets");
|
||||||
|
const sourceAssets = [
|
||||||
|
"models/claudecraft/chars/players/druid.glb",
|
||||||
|
"models/claudecraft/chars/players/knight.glb",
|
||||||
|
"models/claudecraft/chars/players/mage.glb",
|
||||||
|
"models/claudecraft/chars/players/ranger.glb",
|
||||||
|
"models/claudecraft/chars/players/rogue.glb",
|
||||||
|
"models/claudecraft/weapons/adv_dagger.glb",
|
||||||
|
"models/claudecraft/weapons/adv_druid_staff.glb",
|
||||||
|
"models/claudecraft/weapons/adv_sword_1handed.glb",
|
||||||
|
"models/claudecraft/weapons/adv_wand.glb",
|
||||||
|
"models/claudecraft/weapons/crossbow_2handed.glb",
|
||||||
|
"models/claudecraft/weapons/shield_badge.glb",
|
||||||
|
"models/claudecraft/weapons/spellbook_open.glb",
|
||||||
|
];
|
||||||
|
|
||||||
|
function optimizedPath(sourcePath) {
|
||||||
|
return sourcePath.replace(/\.glb$/, "-uastc.glb");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedNames(properties) {
|
||||||
|
return properties.map((property) => property.getName()).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const io = await createGameAssetIO();
|
||||||
|
for (const relativePath of sourceAssets) {
|
||||||
|
const inputPath = path.join(sourceRoot, relativePath);
|
||||||
|
const outputRelativePath = optimizedPath(relativePath);
|
||||||
|
const outputPath = path.join(sourceRoot, outputRelativePath);
|
||||||
|
await access(inputPath);
|
||||||
|
|
||||||
|
const document = await io.read(inputPath);
|
||||||
|
const sourceRootProperties = document.getRoot();
|
||||||
|
const expected = {
|
||||||
|
animations: sortedNames(sourceRootProperties.listAnimations()),
|
||||||
|
materials: sortedNames(sourceRootProperties.listMaterials()),
|
||||||
|
meshes: sortedNames(sourceRootProperties.listMeshes()),
|
||||||
|
nodes: sortedNames(sourceRootProperties.listNodes()),
|
||||||
|
scenes: sortedNames(sourceRootProperties.listScenes()),
|
||||||
|
textureCount: sourceRootProperties.listTextures().length,
|
||||||
|
};
|
||||||
|
|
||||||
|
await convertDocumentTexturesToKtx2(document, "iwt-ktx2-");
|
||||||
|
await io.write(outputPath, document);
|
||||||
|
|
||||||
|
const outputDocument = await io.read(outputPath);
|
||||||
|
const outputRoot = outputDocument.getRoot();
|
||||||
|
const actual = {
|
||||||
|
animations: sortedNames(outputRoot.listAnimations()),
|
||||||
|
materials: sortedNames(outputRoot.listMaterials()),
|
||||||
|
meshes: sortedNames(outputRoot.listMeshes()),
|
||||||
|
nodes: sortedNames(outputRoot.listNodes()),
|
||||||
|
scenes: sortedNames(outputRoot.listScenes()),
|
||||||
|
textureCount: outputRoot.listTextures().length,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
JSON.stringify(actual) !== JSON.stringify(expected)
|
||||||
|
|| outputRoot.listTextures().some((texture) => texture.getMimeType() !== "image/ktx2")
|
||||||
|
|| outputRoot.listExtensionsUsed().some((extension) => extension.extensionName === "EXT_texture_webp")
|
||||||
|
) {
|
||||||
|
throw new Error(`Generated asset failed round-trip validation: ${outputRelativePath}`);
|
||||||
|
}
|
||||||
|
console.log(`Built game_assets/${outputRelativePath}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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")}`);
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { NodeIO } from "@gltf-transform/core";
|
||||||
|
import { ALL_EXTENSIONS, EXTTextureWebP, KHRTextureBasisu } from "@gltf-transform/extensions";
|
||||||
|
import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
export const TOKTX_COMMAND = process.env.TOKTX ?? "toktx";
|
||||||
|
|
||||||
|
export async function createGameAssetIO() {
|
||||||
|
await MeshoptDecoder.ready;
|
||||||
|
await MeshoptEncoder.ready;
|
||||||
|
return new NodeIO()
|
||||||
|
.registerExtensions(ALL_EXTENSIONS)
|
||||||
|
.registerDependencies({
|
||||||
|
"meshopt.decoder": MeshoptDecoder,
|
||||||
|
"meshopt.encoder": MeshoptEncoder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, { stdio: "inherit" });
|
||||||
|
child.once("error", reject);
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error(`${command} exited with code ${code ?? "unknown"}.`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertDocumentTexturesToKtx2(document, temporaryPrefix) {
|
||||||
|
const textures = document.getRoot().listTextures();
|
||||||
|
if (textures.length === 0) throw new Error("Asset contains no textures to convert.");
|
||||||
|
|
||||||
|
const temporaryDirectory = await mkdtemp(path.join(tmpdir(), temporaryPrefix));
|
||||||
|
try {
|
||||||
|
for (const [index, texture] of textures.entries()) {
|
||||||
|
const sourceImage = texture.getImage();
|
||||||
|
if (!sourceImage) throw new Error(`Texture ${texture.getName() || index} contains no image data.`);
|
||||||
|
|
||||||
|
const pngPath = path.join(temporaryDirectory, `texture-${index}.png`);
|
||||||
|
const ktx2Path = path.join(temporaryDirectory, `texture-${index}.ktx2`);
|
||||||
|
await writeFile(pngPath, await sharp(sourceImage).png().toBuffer());
|
||||||
|
await run(TOKTX_COMMAND, [
|
||||||
|
"--t2",
|
||||||
|
"--encode", "uastc",
|
||||||
|
"--uastc_quality", "4",
|
||||||
|
"--zcmp", "18",
|
||||||
|
"--threads", process.env.TOKTX_THREADS ?? "4",
|
||||||
|
"--genmipmap",
|
||||||
|
"--assign_oetf", "srgb",
|
||||||
|
"--assign_primaries", "bt709",
|
||||||
|
ktx2Path,
|
||||||
|
pngPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
texture
|
||||||
|
.setName(`${texture.getName() || `texture_${index}`}_uastc`)
|
||||||
|
.setMimeType("image/ktx2")
|
||||||
|
.setImage(new Uint8Array(await readFile(ktx2Path)));
|
||||||
|
}
|
||||||
|
document.getRoot().listExtensionsUsed()
|
||||||
|
.find((extension) => extension.extensionName === EXTTextureWebP.EXTENSION_NAME)
|
||||||
|
?.dispose();
|
||||||
|
document.createExtension(KHRTextureBasisu).setRequired(true);
|
||||||
|
} finally {
|
||||||
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-20
@@ -8,7 +8,6 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -339,17 +338,6 @@ def create_release(tag: str, commit: str, message: str, token: str) -> dict[str,
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def multipart_asset(path: Path, media_type: str) -> tuple[bytes, str]:
|
|
||||||
boundary = f"----iwanttoheal{secrets.token_hex(12)}"
|
|
||||||
prefix = (
|
|
||||||
f"--{boundary}\r\n"
|
|
||||||
f'Content-Disposition: form-data; name="attachment"; filename="{path.name}"\r\n'
|
|
||||||
f"Content-Type: {media_type}\r\n\r\n"
|
|
||||||
).encode()
|
|
||||||
body = prefix + path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
|
|
||||||
return body, f"multipart/form-data; boundary={boundary}"
|
|
||||||
|
|
||||||
|
|
||||||
def upload_release_asset(
|
def upload_release_asset(
|
||||||
release_id: int,
|
release_id: int,
|
||||||
path: Path,
|
path: Path,
|
||||||
@@ -357,15 +345,46 @@ def upload_release_asset(
|
|||||||
media_type: str,
|
media_type: str,
|
||||||
token: str,
|
token: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
body, content_type = multipart_asset(path, media_type)
|
curl = shutil.which("curl")
|
||||||
gitea_request(
|
if not curl:
|
||||||
f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets"
|
raise SystemExit("curl is required to upload Gitea release assets")
|
||||||
f"?name={urllib.parse.quote(path.name)}",
|
|
||||||
token=token,
|
url = (
|
||||||
method="POST",
|
f"{GITEA_API}/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets"
|
||||||
body=body,
|
f"?name={urllib.parse.quote(path.name)}"
|
||||||
content_type=content_type,
|
|
||||||
)
|
)
|
||||||
|
headers = (
|
||||||
|
"Accept: application/json\n"
|
||||||
|
f"Authorization: token {token}\n"
|
||||||
|
"Expect: 100-continue\n"
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
curl,
|
||||||
|
"--silent",
|
||||||
|
"--show-error",
|
||||||
|
"--fail-with-body",
|
||||||
|
"--connect-timeout",
|
||||||
|
"30",
|
||||||
|
"--max-time",
|
||||||
|
"300",
|
||||||
|
"--header",
|
||||||
|
"@-",
|
||||||
|
"--form",
|
||||||
|
f"attachment=@{path};type={media_type}",
|
||||||
|
url,
|
||||||
|
],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
input=headers,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
details = result.stdout.strip() or result.stderr.strip()
|
||||||
|
raise SystemExit(
|
||||||
|
f"Gitea release asset upload failed (curl {result.returncode}): {details}"
|
||||||
|
)
|
||||||
print(f"Release asset uploaded: {path.name}")
|
print(f"Release asset uploaded: {path.name}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { copyFile, mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const sourceDirectory = path.join(repositoryRoot, "node_modules", "three", "examples", "jsm", "libs", "basis");
|
||||||
|
const outputDirectory = path.join(repositoryRoot, "public", "basis");
|
||||||
|
const files = ["basis_transcoder.js", "basis_transcoder.wasm"];
|
||||||
|
|
||||||
|
await mkdir(outputDirectory, { recursive: true });
|
||||||
|
for (const file of files) {
|
||||||
|
await copyFile(path.join(sourceDirectory, file), path.join(outputDirectory, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Synced Three.js Basis transcoder ${files.join(", ")} -> public/basis`);
|
||||||
@@ -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 3–20 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 10–128 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() };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
@@ -8,7 +8,7 @@ import type { BossId } from "./game/types";
|
|||||||
import type { DifficultySlug } from "./game/progression/loot";
|
import type { DifficultySlug } from "./game/progression/loot";
|
||||||
import { useActionBindings } from "./game/useGameLoop";
|
import { useActionBindings } from "./game/useGameLoop";
|
||||||
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
||||||
import { DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
|
import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
|
||||||
|
|
||||||
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
|
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
|
||||||
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
|
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
|
||||||
@@ -32,20 +32,25 @@ export default function App() {
|
|||||||
const touchActiveSave = useFrontendStore((state) => state.touchActiveSave);
|
const touchActiveSave = useFrontendStore((state) => state.touchActiveSave);
|
||||||
const updateActiveHealerInventory = useFrontendStore((state) => state.updateActiveHealerInventory);
|
const updateActiveHealerInventory = useFrontendStore((state) => state.updateActiveHealerInventory);
|
||||||
const recordBossVictory = useFrontendStore((state) => state.recordBossVictory);
|
const recordBossVictory = useFrontendStore((state) => state.recordBossVictory);
|
||||||
|
const recordRoguelikeDefeat = useFrontendStore((state) => state.recordRoguelikeDefeat);
|
||||||
|
const recordRogueTrialsEndlessDefeat = useFrontendStore((state) => state.recordRogueTrialsEndlessDefeat);
|
||||||
const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards);
|
const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards);
|
||||||
const rewardedBossInstances = useRef(new Set<string>());
|
const rewardedBossInstances = useRef(new Set<string>());
|
||||||
const screenRef = useRef(screen);
|
const screenRef = useRef(screen);
|
||||||
screenRef.current = screen;
|
screenRef.current = screen;
|
||||||
const leaveGame = useCallback(() => {
|
const leaveGame = useCallback(() => {
|
||||||
|
const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState();
|
||||||
updateActiveHealerInventory(useGameStore.getState().inventory);
|
updateActiveHealerInventory(useGameStore.getState().inventory);
|
||||||
touchActiveSave();
|
touchActiveSave();
|
||||||
navigate("home");
|
navigate("home");
|
||||||
|
if (accountId && activeSlotId) void uploadSlot(activeSlotId);
|
||||||
}, [navigate, touchActiveSave, updateActiveHealerInventory]);
|
}, [navigate, touchActiveSave, updateActiveHealerInventory]);
|
||||||
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug) => {
|
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug) => {
|
||||||
if (!hunter) return;
|
if (!hunter) return;
|
||||||
const progress = hunter.healers[hunter.activeClassId];
|
const progress = hunter.healers[hunter.activeClassId];
|
||||||
const runMode = useFrontendStore.getState().selectedMode === "roguelike-pve" ? "roguelike" : "encounter";
|
const selectedMode = useFrontendStore.getState().selectedMode;
|
||||||
const launchDifficulty = runMode === "roguelike"
|
const runMode = selectedMode === "roguelike-pve" ? "roguelike" : selectedMode === "rogue-trials" ? "rogue-trials" : "encounter";
|
||||||
|
const launchDifficulty = runMode !== "encounter"
|
||||||
? "initiate"
|
? "initiate"
|
||||||
: requestedDifficultySlug ?? useFrontendStore.getState().selectedDifficultySlug;
|
: requestedDifficultySlug ?? useFrontendStore.getState().selectedDifficultySlug;
|
||||||
rewardedBossInstances.current.clear();
|
rewardedBossInstances.current.clear();
|
||||||
@@ -65,6 +70,11 @@ export default function App() {
|
|||||||
return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
|
return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
|
||||||
}, [launchGame]);
|
}, [launchGame]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
|
||||||
|
return () => window.removeEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
|
||||||
|
}, [leaveGame]);
|
||||||
|
|
||||||
useActionBindings(screen === "game", leaveGame);
|
useActionBindings(screen === "game", leaveGame);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -75,19 +85,28 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return useGameStore.subscribe((state, previousState) => {
|
return useGameStore.subscribe((state, previousState) => {
|
||||||
const startedFreshEncounter = state.phase === "briefing" && previousState.phase !== "briefing"
|
const startedFreshEncounter = state.phase === "briefing" && previousState.phase !== "briefing"
|
||||||
|| previousState.phase === "intermission" && state.phase === "combat";
|
|| previousState.phase === "intermission" && state.phase === "combat"
|
||||||
if (state.phase === "briefing" || previousState.phase === "intermission" && state.phase === "combat") {
|
|| previousState.phase === "victory" && state.phase === "combat" && state.endlessMode;
|
||||||
|
if (state.phase === "briefing"
|
||||||
|
|| previousState.phase === "intermission" && state.phase === "combat"
|
||||||
|
|| previousState.phase === "victory" && state.phase === "combat" && state.endlessMode) {
|
||||||
rewardedBossInstances.current.clear();
|
rewardedBossInstances.current.clear();
|
||||||
}
|
}
|
||||||
if (startedFreshEncounter) clearRecentRewards();
|
if (startedFreshEncounter) clearRecentRewards();
|
||||||
if (screenRef.current !== "game") return;
|
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;
|
const bossCount = 1 + state.additionalBosses.length;
|
||||||
if (state.boss.hp <= 0 && previousState.boss.hp > 0) {
|
if (state.boss.hp <= 0 && previousState.boss.hp > 0) {
|
||||||
const primaryInstanceId = `boss-0-${state.boss.id}`;
|
const primaryInstanceId = state.bossInstanceId;
|
||||||
if (!rewardedBossInstances.current.has(primaryInstanceId)) {
|
if (!rewardedBossInstances.current.has(primaryInstanceId)) {
|
||||||
rewardedBossInstances.current.add(primaryInstanceId);
|
if (!state.endlessMode) rewardedBossInstances.current.add(primaryInstanceId);
|
||||||
const defeatedBefore = (state.round - 1) * bossCount;
|
const defeatedBefore = (state.round - 1) * bossCount;
|
||||||
const rewardDifficulty = state.runMode === "roguelike" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
|
const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
|
||||||
recordBossVictory(state.boss.id, rewardDifficulty);
|
recordBossVictory(state.boss.id, rewardDifficulty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,23 +114,23 @@ export default function App() {
|
|||||||
const entry = state.additionalBosses[index];
|
const entry = state.additionalBosses[index];
|
||||||
const previous = previousState.additionalBosses[index];
|
const previous = previousState.additionalBosses[index];
|
||||||
const justDefeated = entry.boss.hp <= 0 && (!previous || previous.instanceId !== entry.instanceId || previous.boss.hp > 0);
|
const justDefeated = entry.boss.hp <= 0 && (!previous || previous.instanceId !== entry.instanceId || previous.boss.hp > 0);
|
||||||
if (!justDefeated || rewardedBossInstances.current.has(entry.instanceId)) continue;
|
if (!justDefeated || !state.endlessMode && rewardedBossInstances.current.has(entry.instanceId)) continue;
|
||||||
rewardedBossInstances.current.add(entry.instanceId);
|
if (!state.endlessMode) rewardedBossInstances.current.add(entry.instanceId);
|
||||||
const defeatedBefore = (state.round - 1) * bossCount + index + 1;
|
const defeatedBefore = (state.round - 1) * bossCount + index + 1;
|
||||||
const rewardDifficulty = state.runMode === "roguelike" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
|
const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
|
||||||
recordBossVictory(entry.boss.id, rewardDifficulty);
|
recordBossVictory(entry.boss.id, rewardDifficulty);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [clearRecentRewards, recordBossVictory]);
|
}, [clearRecentRewards, recordBossVictory, recordRoguelikeDefeat, recordRogueTrialsEndlessDefeat]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="prototype-shell">
|
<main className="app-shell">
|
||||||
<header className="prototype-header">
|
<header className="app-header">
|
||||||
<div><span>THOR / DUAL DISPLAY</span><strong>I Want To Heal</strong></div>
|
<div><span>THOR / DUAL DISPLAY</span><strong>I Want To Heal</strong></div>
|
||||||
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
|
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
|
||||||
</header>
|
</header>
|
||||||
{screen === "game"
|
{screen === "game"
|
||||||
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} bottom={<BottomScreen />} /></Suspense>
|
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} bottom={<BottomScreen onExit={leaveGame} />} /></Suspense>
|
||||||
: <FrontEnd onLaunch={launchGame} />}
|
: <FrontEnd onLaunch={launchGame} />}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,3 +3,19 @@
|
|||||||
Only assets referenced by the game ship from this folder. They are copied from the ignored `game_assets` source library, retain their source-relative path, and are imported with Vite `new URL(...)` calls.
|
Only assets referenced by the game ship from this folder. They are copied from the ignored `game_assets` source library, retain their source-relative path, and are imported with Vite `new URL(...)` calls.
|
||||||
|
|
||||||
Use `pnpm assets:import <path-within-game_assets>` to add a source asset. Add `--replace` only when deliberately updating an existing runtime copy.
|
Use `pnpm assets:import <path-within-game_assets>` to add a source asset. Add `--replace` only when deliberately updating an existing runtime copy.
|
||||||
|
|
||||||
|
## KTX2/UASTC pilot
|
||||||
|
|
||||||
|
The tracked `*-uastc.glb` files are parallel optimized copies. Their original GLBs remain the rollback path and must not be replaced or deleted during this pilot.
|
||||||
|
|
||||||
|
KTX-Software `toktx` is an authoring dependency. Rebuild source copies with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
TOKTX=/path/to/toktx pnpm assets:build-ktx2
|
||||||
|
TOKTX=/path/to/toktx pnpm assets:build-dungeon-kit
|
||||||
|
TOKTX=/path/to/toktx pnpm assets:build-gravehorn
|
||||||
|
```
|
||||||
|
|
||||||
|
Import each generated path from `game_assets/` with `pnpm assets:import <path> --replace`. Builds and dev startup copy Three.js's matching Basis transcoder into the ignored `public/basis/` generated directory.
|
||||||
|
|
||||||
|
Force all original GLBs at runtime with `?legacyGameAssets=1`. Force them in an Android/browser build with `VITE_LEGACY_GAME_ASSETS=1 pnpm build`. `legacyDungeonAssets` and `VITE_LEGACY_DUNGEON_ASSETS=1` remain supported aliases.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
# Animated Triceratops Skeleton
|
||||||
|
|
||||||
|
- Creator: Zacxophone — https://sketchfab.com/Zacxophone
|
||||||
|
- Source: https://sketchfab.com/3d-models/animated-triceratops-skeleton-06cb55f941d94dc8b95ac46f92d89e7c
|
||||||
|
- License: CC0 1.0 Universal — https://creativecommons.org/publicdomain/zero/1.0/
|
||||||
|
|
||||||
|
Runtime files named `gravehorn-triceratops*.glb` are optimized derivatives of this model.
|
||||||
Binary file not shown.
+97
-17
@@ -1,15 +1,22 @@
|
|||||||
import { useFrame } from "@react-three/fiber";
|
import { useFrame } from "@react-three/fiber";
|
||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import { Suspense, useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
import { Component, Suspense, useEffect, useLayoutEffect, useMemo, useRef, type ReactNode } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { ARENA_CENTER, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "../game/arena";
|
import { ARENA_CENTER, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "../game/arena";
|
||||||
import { bossRoomFor, type BossRoomDefinition, type BossRoomFloor } from "../game/bossRooms";
|
import { bossRoomFor, type BossRoomDefinition, type BossRoomFloor } from "../game/bossRooms";
|
||||||
import { useGameStore } from "../game/store";
|
import { useGameStore } from "../game/store";
|
||||||
|
import { LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider";
|
||||||
|
|
||||||
const ROOM_CENTER_Z = ARENA_CENTER[1];
|
const ROOM_CENTER_Z = ARENA_CENTER[1];
|
||||||
const KAYKIT_DUNGEON_PILLAR_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/pillar-decorated.glb", import.meta.url).href;
|
const KAYKIT_DUNGEON_PILLAR_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/pillar-decorated.glb", import.meta.url).href;
|
||||||
const KAYKIT_DUNGEON_WALL_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/wall-pillar.glb", import.meta.url).href;
|
const KAYKIT_DUNGEON_WALL_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/wall-pillar.glb", import.meta.url).href;
|
||||||
const KAYKIT_DUNGEON_TORCH_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/torch-lit.glb", import.meta.url).href;
|
const KAYKIT_DUNGEON_TORCH_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/torch-lit.glb", import.meta.url).href;
|
||||||
|
const KAYKIT_DUNGEON_KIT_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/dungeon-kit-uastc.glb", import.meta.url).href;
|
||||||
|
const DUNGEON_MESH_NAMES = {
|
||||||
|
pillar: "pillar_decorated",
|
||||||
|
wall: "wall_pillar",
|
||||||
|
torch: "torch_lit",
|
||||||
|
} as const;
|
||||||
|
|
||||||
type ArenaFixture = {
|
type ArenaFixture = {
|
||||||
position: readonly [number, number, number];
|
position: readonly [number, number, number];
|
||||||
@@ -106,21 +113,19 @@ function firstMesh(scene: THREE.Object3D) {
|
|||||||
return mesh as THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
|
return mesh as THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DungeonAssetInstances({
|
function DungeonMeshInstances({
|
||||||
url,
|
mesh,
|
||||||
fixtures,
|
fixtures,
|
||||||
tint,
|
tint,
|
||||||
opacity = 1,
|
opacity = 1,
|
||||||
colors,
|
colors,
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
mesh: THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
|
||||||
fixtures: readonly ArenaFixture[];
|
fixtures: readonly ArenaFixture[];
|
||||||
tint: string;
|
tint: string;
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
colors?: readonly THREE.Color[];
|
colors?: readonly THREE.Color[];
|
||||||
}) {
|
}) {
|
||||||
const gltf = useGLTF(url, false, true);
|
|
||||||
const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]);
|
|
||||||
const sourceMaterial = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
|
const sourceMaterial = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
|
||||||
const material = useMemo(() => {
|
const material = useMemo(() => {
|
||||||
const next = sourceMaterial.clone();
|
const next = sourceMaterial.clone();
|
||||||
@@ -156,20 +161,77 @@ function DungeonAssetInstances({
|
|||||||
return <instancedMesh ref={instances} args={[mesh.geometry, material, fixtures.length]} castShadow receiveShadow />;
|
return <instancedMesh ref={instances} args={[mesh.geometry, material, fixtures.length]} castShadow receiveShadow />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArenaArchitecture({ room }: { room: BossRoomDefinition }) {
|
function LegacyDungeonAssetInstances({
|
||||||
const walls = useMemo(() => ARENA_WALL_SEGMENTS.map((fixture) => ({
|
url,
|
||||||
|
...props
|
||||||
|
}: Omit<Parameters<typeof DungeonMeshInstances>[0], "mesh"> & { url: string }) {
|
||||||
|
const gltf = useGLTF(url, false, true);
|
||||||
|
const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]);
|
||||||
|
return <DungeonMeshInstances mesh={mesh} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arenaWalls(room: BossRoomDefinition) {
|
||||||
|
return ARENA_WALL_SEGMENTS.map((fixture) => ({
|
||||||
...fixture,
|
...fixture,
|
||||||
scaleY: room.wallHeight / 4,
|
scaleY: room.wallHeight / 4,
|
||||||
})), [room.wallHeight]);
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegacyArenaArchitecture({ room }: { room: BossRoomDefinition }) {
|
||||||
|
const walls = useMemo(() => arenaWalls(room), [room]);
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
<DungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
|
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
|
||||||
<DungeonAssetInstances url={KAYKIT_DUNGEON_PILLAR_URL} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
|
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_PILLAR_URL} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
|
||||||
<DungeonAssetInstances url={KAYKIT_DUNGEON_TORCH_URL} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
|
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_TORCH_URL} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function namedMesh(scene: THREE.Object3D, name: string) {
|
||||||
|
const object = scene.getObjectByName(name);
|
||||||
|
if (object instanceof THREE.Mesh) {
|
||||||
|
return object as THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
|
||||||
|
}
|
||||||
|
throw new Error(`Dungeon kit is missing mesh ${name}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DungeonKitArchitecture({ room }: { room: BossRoomDefinition }) {
|
||||||
|
const gltf = useGameGLTF(KAYKIT_DUNGEON_KIT_URL);
|
||||||
|
const walls = useMemo(() => arenaWalls(room), [room]);
|
||||||
|
const meshes = useMemo(() => ({
|
||||||
|
pillar: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.pillar),
|
||||||
|
wall: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.wall),
|
||||||
|
torch: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.torch),
|
||||||
|
}), [gltf.scene]);
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<DungeonMeshInstances mesh={meshes.wall} fixtures={walls} tint={room.wallColor} opacity={0.54} />
|
||||||
|
<DungeonMeshInstances mesh={meshes.pillar} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
|
||||||
|
<DungeonMeshInstances mesh={meshes.torch} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class DungeonAssetErrorBoundary extends Component<{
|
||||||
|
children: ReactNode;
|
||||||
|
fallback: ReactNode;
|
||||||
|
}, { failed: boolean }> {
|
||||||
|
state = { failed: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return { failed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: unknown) {
|
||||||
|
console.warn("Optimized dungeon asset failed; using legacy GLBs.", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return this.state.failed ? this.props.fallback : this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
|
function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
|
||||||
const walls = useRef<THREE.Group>(null);
|
const walls = useRef<THREE.Group>(null);
|
||||||
const previousCameraPosition = useRef<THREE.Vector3 | null>(null);
|
const previousCameraPosition = useRef<THREE.Vector3 | null>(null);
|
||||||
@@ -205,14 +267,30 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoomWalls({ room }: { room: BossRoomDefinition }) {
|
function LegacyRoomWalls({ room }: { room: BossRoomDefinition }) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<RoomWallFallback room={room} />}>
|
<Suspense fallback={<RoomWallFallback room={room} />}>
|
||||||
<ArenaArchitecture room={room} />
|
<LegacyArenaArchitecture room={room} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OptimizedRoomWalls({ room }: { room: BossRoomDefinition }) {
|
||||||
|
return (
|
||||||
|
<DungeonAssetErrorBoundary fallback={<LegacyRoomWalls room={room} />}>
|
||||||
|
<Suspense fallback={<RoomWallFallback room={room} />}>
|
||||||
|
<DungeonKitArchitecture room={room} />
|
||||||
|
</Suspense>
|
||||||
|
</DungeonAssetErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoomWalls({ room }: { room: BossRoomDefinition }) {
|
||||||
|
return LEGACY_GAME_ASSETS_FORCED
|
||||||
|
? <LegacyRoomWalls room={room} />
|
||||||
|
: <OptimizedRoomWalls room={room} />;
|
||||||
|
}
|
||||||
|
|
||||||
function RoomMarks({ room }: { room: BossRoomDefinition }) {
|
function RoomMarks({ room }: { room: BossRoomDefinition }) {
|
||||||
const rays = useRef<THREE.InstancedMesh>(null);
|
const rays = useRef<THREE.InstancedMesh>(null);
|
||||||
const pattern = ROOM_PATTERNS[room.floor];
|
const pattern = ROOM_PATTERNS[room.floor];
|
||||||
@@ -345,6 +423,8 @@ export function BossRoom() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true);
|
if (LEGACY_GAME_ASSETS_FORCED) {
|
||||||
useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true);
|
useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true);
|
||||||
useGLTF.preload(KAYKIT_DUNGEON_TORCH_URL, false, true);
|
useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true);
|
||||||
|
useGLTF.preload(KAYKIT_DUNGEON_TORCH_URL, false, true);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@ import { ABILITY_ORDER } from "../game/data";
|
|||||||
import { HEALER_CLASSES } from "../game/healers";
|
import { HEALER_CLASSES } from "../game/healers";
|
||||||
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||||
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
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 { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
|
||||||
import type { BottomTab, PartyMember } from "../game/types";
|
import type { BottomTab, PartyMember } from "../game/types";
|
||||||
import { useFrontendStore } from "../frontend/store";
|
import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
||||||
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
|
|
||||||
function RewardSummary() {
|
function RewardSummary() {
|
||||||
const rewards = useFrontendStore((state) => state.recentRewards);
|
const rewards = useFrontendStore((state) => state.recentRewards);
|
||||||
@@ -82,15 +84,20 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
|
|||||||
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
|
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
|
||||||
const mana = useGameStore((state) => state.mana);
|
const mana = useGameStore((state) => state.mana);
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
|
const healerAlive = useGameStore((state) => state.party.some((member) => member.id === "aelia" && member.hp > 0));
|
||||||
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
|
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
|
||||||
const activeCast = useGameStore((state) => state.activeCast);
|
const activeCast = useGameStore((state) => state.activeCast);
|
||||||
const castAbility = useGameStore((state) => state.castAbility);
|
const castAbility = useGameStore((state) => state.castAbility);
|
||||||
|
const runModifiers = useGameStore((state) => state.runModifiers);
|
||||||
const remaining = abilityRemaining(abilityId, time, cooldowns);
|
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 globalRemaining = Math.max(0, globalCooldownUntil - time);
|
||||||
const noDispel = abilityId === "purify" && selected.debuffs.length === 0;
|
const noDispel = abilityId === "purify" && selected.debuffs.length === 0;
|
||||||
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
|
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
|
||||||
const disabled = phase !== "combat" || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < ability.mana || noDispel || invalidTarget;
|
const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
|
||||||
const resourceCopy = `${ability.mana ? `${ability.mana} mana` : "free"}${ability.castTime ? ` · ${ability.castTime.toFixed(1)}s` : ""}`;
|
const resourceCopy = `${manaCost ? `${manaCost} mana` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<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-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
|
||||||
<span className="ability-pad">{ability.gamepad}</span>
|
<span className="ability-pad">{ability.gamepad}</span>
|
||||||
{remaining > 0 && (
|
{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>
|
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -165,7 +172,7 @@ function BriefingPanel() {
|
|||||||
<span>Chosen discipline</span>
|
<span>Chosen discipline</span>
|
||||||
<h2>{healer.specialization}</h2>
|
<h2>{healer.specialization}</h2>
|
||||||
<p>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</p>
|
<p>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</p>
|
||||||
<button className="start-button" onClick={startEncounter}><span>Face {bossNames}</span><small>START / ENTER</small></button>
|
<button className="start-button" onClick={startEncounter}><span>Face {bossNames}</span><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ENTER</small></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="briefing-kit">
|
<div className="briefing-kit">
|
||||||
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
|
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
|
||||||
@@ -184,29 +191,55 @@ function BriefingPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EndPanel() {
|
function EndPanel({ onExit }: { onExit?: () => void }) {
|
||||||
|
const hunter = useActiveHunter();
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
|
const runMode = useGameStore((state) => state.runMode);
|
||||||
|
const round = useGameStore((state) => state.round);
|
||||||
|
const endlessMode = useGameStore((state) => state.endlessMode);
|
||||||
|
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
|
||||||
|
const endlessChoiceSelection = useGameStore((state) => state.endlessChoiceSelection);
|
||||||
|
const setEndlessChoiceSelection = useGameStore((state) => state.setEndlessChoiceSelection);
|
||||||
|
const startRogueTrialsEndless = useGameStore((state) => state.startRogueTrialsEndless);
|
||||||
const time = useGameStore((state) => state.time);
|
const time = useGameStore((state) => state.time);
|
||||||
const party = useGameStore((state) => state.party);
|
const party = useGameStore((state) => state.party);
|
||||||
const restart = useGameStore((state) => state.restart);
|
const restart = useGameStore((state) => state.restart);
|
||||||
const startEncounter = useGameStore((state) => state.startEncounter);
|
const startEncounter = useGameStore((state) => state.startEncounter);
|
||||||
const totalHp = party.reduce((sum, member) => sum + member.hp, 0);
|
const totalHp = party.reduce((sum, member) => sum + member.hp, 0);
|
||||||
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
|
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
|
||||||
|
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
|
||||||
|
const endlessDefeat = phase === "defeat" && endlessMode;
|
||||||
|
const endlessHighScore = hunter?.stats.highestRogueTrialsEndlessKills ?? 0;
|
||||||
|
const endlessHighScoreLabel = `${endlessHighScore} ${endlessHighScore === 1 ? "boss" : "bosses"}`;
|
||||||
return (
|
return (
|
||||||
<div className={`end-panel end-${phase}`}>
|
<div className={`end-panel end-${phase}`}>
|
||||||
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
|
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
|
||||||
<small>{phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
|
<small>{showEndlessChoice ? "ROGUE TRIALS CLEARED" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
|
||||||
<h2>{phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
|
<h2>{showEndlessChoice ? "The trial can continue" : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
|
||||||
<div className="result-stats">
|
<div className="result-stats">
|
||||||
<span><small>Duration</small><strong>{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}</strong></span>
|
<span><small>Duration</small><strong>{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}</strong></span>
|
||||||
<span><small>Party vitality</small><strong>{Math.round((totalHp / totalMax) * 100)}%</strong></span>
|
<span><small>Party vitality</small><strong>{Math.round((totalHp / totalMax) * 100)}%</strong></span>
|
||||||
<span><small>Boss</small><strong>{phase === "victory" ? "Defeated" : "Standing"}</strong></span>
|
<span><small>{endlessDefeat ? "Endless kills" : "Boss"}</small><strong>{endlessDefeat ? endlessBossKills : phase === "victory" ? "Defeated" : "Standing"}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
{phase === "victory" && <RewardSummary />}
|
{phase === "victory" && <RewardSummary />}
|
||||||
<div className="end-actions">
|
{showEndlessChoice ? <div className="end-actions endless-choice-actions">
|
||||||
|
<button
|
||||||
|
className={endlessChoiceSelection === "continue" ? "is-controller-focused" : ""}
|
||||||
|
onFocus={() => setEndlessChoiceSelection("continue")}
|
||||||
|
onPointerEnter={() => setEndlessChoiceSelection("continue")}
|
||||||
|
onClick={startRogueTrialsEndless}
|
||||||
|
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 onClick={() => { restart(); startEncounter(); }}>Run again</button>
|
||||||
<button className="secondary" onClick={restart}>Return to briefing</button>
|
<button className="secondary" onClick={endlessDefeat ? onExit : restart}>{endlessDefeat ? "Return to main menu" : "Return to briefing"}</button>
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -220,16 +253,16 @@ function IntermissionStatusPanel() {
|
|||||||
<h2>Choose on top display</h2>
|
<h2>Choose on top display</h2>
|
||||||
<p>Next encounter stays locked until one blessing is claimed.</p>
|
<p>Next encounter stays locked until one blessing is claimed.</p>
|
||||||
<RewardSummary />
|
<RewardSummary />
|
||||||
<small>Use D-pad to choose · A to claim</small>
|
<small>Use D-pad to choose · {DEFAULT_CONTROLLER_GLYPHS.confirm} to claim</small>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CombatPanel() {
|
function CombatPanel({ onExit }: { onExit?: () => void }) {
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
if (phase === "briefing") return <BriefingPanel />;
|
if (phase === "briefing") return <BriefingPanel />;
|
||||||
if (phase === "intermission") return <IntermissionStatusPanel />;
|
if (phase === "intermission") return <IntermissionStatusPanel />;
|
||||||
if (phase === "victory" || phase === "defeat") return <EndPanel />;
|
if (phase === "victory" || phase === "defeat") return <EndPanel onExit={onExit} />;
|
||||||
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
|
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +368,7 @@ const tabs: { id: BottomTab; label: string; icon: string; key: string }[] = [
|
|||||||
{ id: "pack", label: "Pack", icon: "▧", key: "I" },
|
{ id: "pack", label: "Pack", icon: "▧", key: "I" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function BottomScreen() {
|
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
|
||||||
const activeTab = useGameStore((state) => state.activeTab);
|
const activeTab = useGameStore((state) => state.activeTab);
|
||||||
const setActiveTab = useGameStore((state) => state.setActiveTab);
|
const setActiveTab = useGameStore((state) => state.setActiveTab);
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
@@ -353,13 +386,13 @@ export function BottomScreen() {
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main className="lower-content">
|
<main className="lower-content">
|
||||||
{activeTab === "combat" && <CombatPanel />}
|
{activeTab === "combat" && <CombatPanel onExit={onExit} />}
|
||||||
{activeTab === "map" && <MapPanel />}
|
{activeTab === "map" && <MapPanel />}
|
||||||
{activeTab === "pack" && <PackPanel />}
|
{activeTab === "pack" && <PackPanel />}
|
||||||
</main>
|
</main>
|
||||||
{paused && (
|
{paused && (
|
||||||
<div className="lower-pause-overlay" aria-hidden="true">
|
<div className="lower-pause-overlay" aria-hidden="true">
|
||||||
<span>PAUSED</span><strong>Encounter suspended</strong><small>START / ESC resumes · ↑↓ selects menu action</small>
|
<span>PAUSED</span><strong>Encounter suspended</strong><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC resumes · ↑↓ selects menu action</small>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,25 +1,47 @@
|
|||||||
import { RUN_BUFFS, bossHealthMultiplier, countRunBuff } from "../game/roguelike";
|
import { useEffect, useState } from "react";
|
||||||
import { useGameStore } from "../game/store";
|
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 }) {
|
export function BuffDraftPanel({ className = "" }: { className?: string }) {
|
||||||
const round = useGameStore((state) => state.round);
|
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 choices = useGameStore((state) => state.draftBuffIds);
|
||||||
const selected = useGameStore((state) => state.selectedRunBuffId);
|
const selected = useGameStore((state) => state.selectedRunBuffId);
|
||||||
|
const inputUnlockAt = useGameStore((state) => state.runBuffInputUnlockAt);
|
||||||
const setSelected = useGameStore((state) => state.setSelectedRunBuff);
|
const setSelected = useGameStore((state) => state.setSelectedRunBuff);
|
||||||
const choose = useGameStore((state) => state.chooseRunBuff);
|
const choose = useGameStore((state) => state.chooseRunBuff);
|
||||||
|
const continueRun = useGameStore((state) => state.continueRoguelikeRound);
|
||||||
const nextRound = round + 1;
|
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 (
|
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>
|
<header>
|
||||||
<span>Round {round} cleared</span>
|
<span>Round {round} cleared</span>
|
||||||
<h2>Choose one blessing</h2>
|
<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>
|
</header>
|
||||||
<div className="buff-choice-grid">
|
<div className={`buff-choice-grid choice-count-${choices.length}`}>
|
||||||
{choices.map((buffId) => {
|
{choices.length > 0 ? choices.map((buffId) => {
|
||||||
const buff = RUN_BUFFS[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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={buffId}
|
key={buffId}
|
||||||
@@ -28,17 +50,25 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
|
|||||||
onFocus={() => setSelected(buffId)}
|
onFocus={() => setSelected(buffId)}
|
||||||
onPointerEnter={() => setSelected(buffId)}
|
onPointerEnter={() => setSelected(buffId)}
|
||||||
onClick={() => choose(buffId)}
|
onClick={() => choose(buffId)}
|
||||||
|
disabled={inputLocked}
|
||||||
aria-pressed={selected === buffId}
|
aria-pressed={selected === buffId}
|
||||||
>
|
>
|
||||||
<i>{buff.icon}</i>
|
<i>{buff.icon}</i>
|
||||||
<span><small>{stacks ? `${stacks} owned` : "New blessing"}</small><strong>{buff.name}</strong></span>
|
<span><small>{rank ? `Rank ${rank} → ${nextRank} / ${buff.maxRank}` : `New blessing · Rank 1 / ${buff.maxRank}`}</small><strong>{ability.shortName}: {buff.name}</strong></span>
|
||||||
<b>{buff.summary}</b>
|
<b>{formatRunBuffEffect(buffId, nextRank)}</b>
|
||||||
<p>{buff.detail}</p>
|
<p>{buff.detail}</p>
|
||||||
</button>
|
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
|
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
|
||||||
import { subscribeControllerToken } from "../input/controller";
|
import { subscribeControllerToken } from "../input/controller";
|
||||||
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
|
|
||||||
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
|
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
|
||||||
const dedicatedSurface = new URLSearchParams(window.location.search).get("display");
|
const dedicatedSurface = new URLSearchParams(window.location.search).get("display");
|
||||||
@@ -45,7 +46,7 @@ export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: Reac
|
|||||||
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")}
|
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")}
|
||||||
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
|
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
|
||||||
>
|
>
|
||||||
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>SELECT / TAB</small>
|
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+457
-141
@@ -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 { buildCollections, MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName } from "../frontend/data";
|
||||||
import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
|
import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
|
||||||
|
import { resolveSaveContinuation, saveVersionsMatch } from "../frontend/saveContinuation";
|
||||||
import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
||||||
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
|
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
|
||||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||||
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers";
|
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers";
|
||||||
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUP_BY_ID } from "../game/bossCatalog";
|
import { ABILITY_ORDER } from "../game/data";
|
||||||
import { selectRandomBossPair } from "../game/roguelike";
|
import { BOSS_DEFINITIONS, BOSS_GROUP_BY_ID, BOSS_GROUPS } from "../game/bossCatalog";
|
||||||
import type { BossId } from "../game/types";
|
import { bossMechanicIsPassive, bossMechanicName } from "../game/bosses/mechanicPool";
|
||||||
|
import { RUN_BUFFS, formatRunBuffEffect, selectRandomBossPair } from "../game/roguelike";
|
||||||
|
import type { AbilityId, BossId } from "../game/types";
|
||||||
import {
|
import {
|
||||||
GEAR_OWNER_LABELS,
|
GEAR_OWNER_LABELS,
|
||||||
GEAR_OWNER_ORDER,
|
GEAR_OWNER_ORDER,
|
||||||
@@ -17,8 +20,10 @@ import {
|
|||||||
GEAR_STAT_LABELS,
|
GEAR_STAT_LABELS,
|
||||||
MAX_GEAR_LEVEL,
|
MAX_GEAR_LEVEL,
|
||||||
canAffordGearUpgrade,
|
canAffordGearUpgrade,
|
||||||
|
canUpgradeGearSlot,
|
||||||
gearBonusText,
|
gearBonusText,
|
||||||
gearUpgradeCosts,
|
gearUpgradeCosts,
|
||||||
|
type GearOwnerId,
|
||||||
} from "../game/progression/gear";
|
} from "../game/progression/gear";
|
||||||
import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossGroupDrop } from "../game/progression/loot";
|
import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossGroupDrop } from "../game/progression/loot";
|
||||||
import {
|
import {
|
||||||
@@ -31,7 +36,11 @@ import {
|
|||||||
passiveInfusionUnlocked,
|
passiveInfusionUnlocked,
|
||||||
} from "../game/progression/infusions";
|
} from "../game/progression/infusions";
|
||||||
import { requestDisplaySurface } from "../platform/displayRouting";
|
import { requestDisplaySurface } from "../platform/displayRouting";
|
||||||
|
import { onlineRepository, type LeaderboardResult } from "../frontend/onlineRepository";
|
||||||
import { DualDisplayFrame } from "./DualDisplayFrame";
|
import { DualDisplayFrame } from "./DualDisplayFrame";
|
||||||
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
|
|
||||||
|
const BossTrophyPortrait = lazy(() => import("./BossTrophyPortrait").then((module) => ({ default: module.BossTrophyPortrait })));
|
||||||
|
|
||||||
function FocusButton({
|
function FocusButton({
|
||||||
id,
|
id,
|
||||||
@@ -69,24 +78,66 @@ function BrandMark({ compact = false }: { compact?: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ControllerLegend({ back = false }: { back?: boolean }) {
|
function ControllerLegend({ back = false }: { back?: boolean }) {
|
||||||
return <div className="controller-legend"><span><b>A</b> Select</span>{back && <span><b>B</b> Back</span>}<span><b>+</b> Navigate</span></div>;
|
return <div className="controller-legend"><span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Select</span>{back && <span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back</span>}<span><b>+</b> Navigate</span></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SaveLibraryContext({ slots, accountId }: { slots: readonly SaveSlotState[]; accountId: string | null }) {
|
||||||
|
return (
|
||||||
|
<FrontSurface className="login-save-context" bottom ariaLabel="Save slot information">
|
||||||
|
<header className="context-header"><span>Device saves</span><b>{accountId ? "SERVER LINKED" : "OFFLINE READY"}</b></header>
|
||||||
|
<div className="login-save-list">
|
||||||
|
{slots.map((slot) => {
|
||||||
|
const save = slot.local ?? slot.online;
|
||||||
|
const healer = save ? HEALER_CLASSES[save.activeClassId] : null;
|
||||||
|
return (
|
||||||
|
<article key={slot.id} className={save ? "has-save" : "is-empty"}>
|
||||||
|
<b>{String(slot.id).padStart(2, "0")}</b>
|
||||||
|
{save ? (
|
||||||
|
<>
|
||||||
|
<div className="login-save-avatar">{save.hunterName[0]}</div>
|
||||||
|
<span>
|
||||||
|
<small>{slot.local ? "On this Thor" : "Online copy"}</small>
|
||||||
|
<strong>{save.hunterName}</strong>
|
||||||
|
<em>Level {save.healers[save.activeClassId].level} {healer?.name} · {save.location}</em>
|
||||||
|
</span>
|
||||||
|
<time><strong>{formatPlayTime(save.playSeconds)}</strong><small>{formatSaveTimestamp(save.updatedAt)}</small></time>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="login-empty-copy"><small>Available slot</small><strong>New hunter</strong><em>Continue offline to create</em></span>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<footer className="login-save-footer"><span>Save details update from upper-screen selection</span><b>LOWER DISPLAY · INFORMATION ONLY</b></footer>
|
||||||
|
</FrontSurface>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoginScreen() {
|
function LoginScreen() {
|
||||||
|
const restoreSession = useFrontendStore((state) => state.restoreSession);
|
||||||
const signIn = useFrontendStore((state) => state.signIn);
|
const signIn = useFrontendStore((state) => state.signIn);
|
||||||
const createAccount = useFrontendStore((state) => state.createAccount);
|
const createAccount = useFrontendStore((state) => state.createAccount);
|
||||||
const continueOffline = useFrontendStore((state) => state.continueOffline);
|
const continueOffline = useFrontendStore((state) => state.continueOffline);
|
||||||
|
const slots = useFrontendStore((state) => state.slots);
|
||||||
|
const accountId = useFrontendStore((state) => state.accountId);
|
||||||
const notice = useFrontendStore((state) => state.notice);
|
const notice = useFrontendStore((state) => state.notice);
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const usernameRef = useRef<HTMLInputElement>(null);
|
const usernameRef = useRef<HTMLInputElement>(null);
|
||||||
const passwordRef = 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[]>(() => [
|
const actions = useMemo<MenuAction[]>(() => [
|
||||||
{ id: "username", run: () => usernameRef.current?.focus() },
|
{ id: "username", run: () => usernameRef.current?.focus() },
|
||||||
{ id: "password", run: () => passwordRef.current?.focus() },
|
{ id: "password", run: () => passwordRef.current?.focus() },
|
||||||
{ id: "sign-in", run: () => { void signIn(username, password); } },
|
{ id: "sign-in", run: () => { void signIn(username, password); } },
|
||||||
{ id: "create-account", run: () => { void createAccount(username, password); } },
|
{ id: "create-account", run: () => { void createAccount(username, password); } },
|
||||||
{ id: "offline", run: continueOffline },
|
{ id: "continue", run: continueOffline },
|
||||||
], [continueOffline, createAccount, password, signIn, username]);
|
], [continueOffline, createAccount, password, signIn, username]);
|
||||||
const controller = useMenuController(actions);
|
const controller = useMenuController(actions);
|
||||||
|
|
||||||
@@ -101,7 +152,7 @@ function LoginScreen() {
|
|||||||
<div className="login-copy">
|
<div className="login-copy">
|
||||||
<span>Offline-first hunter records</span>
|
<span>Offline-first hunter records</span>
|
||||||
<h1>Keep everyone standing.</h1>
|
<h1>Keep everyone standing.</h1>
|
||||||
<p>Your save always lives on this device. Sign in only when you want a second copy for PC ↔ AYN Thor handoff.</p>
|
<p>Continue to your saved hunters. Sign in when you want online copies for PC ↔ AYN Thor handoff.</p>
|
||||||
</div>
|
</div>
|
||||||
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); submitSignIn(); }}>
|
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); submitSignIn(); }}>
|
||||||
<label htmlFor="account-username">Username</label>
|
<label htmlFor="account-username">Username</label>
|
||||||
@@ -113,6 +164,9 @@ function LoginScreen() {
|
|||||||
onChange={(event) => setUsername(event.target.value)}
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
onFocus={() => controller.focus("username")}
|
onFocus={() => controller.focus("username")}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
|
maxLength={20}
|
||||||
|
minLength={3}
|
||||||
|
pattern="[A-Za-z0-9_]+"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<label htmlFor="account-password">Password</label>
|
<label htmlFor="account-password">Password</label>
|
||||||
@@ -125,6 +179,8 @@ function LoginScreen() {
|
|||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
onFocus={() => controller.focus("password")}
|
onFocus={() => controller.focus("password")}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
maxLength={128}
|
||||||
|
minLength={10}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<FocusButton id="sign-in" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" type="submit">
|
<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); }}>
|
<FocusButton id="create-account" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={() => { void createAccount(username, password); }}>
|
||||||
<span>Create account</span><small>Required for first sync</small>
|
<span>Create account</span><small>Required for first sync</small>
|
||||||
</FocusButton>
|
</FocusButton>
|
||||||
<FocusButton id="offline" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}>
|
<FocusButton id="continue" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}>
|
||||||
<span>Continue with offline save</span><small>No account required</small>
|
<span>Continue</span><small>Choose saved hunter</small>
|
||||||
</FocusButton>
|
</FocusButton>
|
||||||
</form>
|
</form>
|
||||||
{notice && <div className="front-notice" role="status" aria-live="polite">{notice}</div>}
|
{notice && <div className="front-notice" role="status" aria-live="polite">{notice}</div>}
|
||||||
<ControllerLegend />
|
<ControllerLegend />
|
||||||
</FrontSurface>
|
</FrontSurface>
|
||||||
}
|
}
|
||||||
bottom={
|
bottom={<SaveLibraryContext slots={slots} accountId={accountId} />}
|
||||||
<FrontSurface className="login-context" bottom ariaLabel="Offline save explanation">
|
|
||||||
<BrandMark compact />
|
|
||||||
<div className="offline-promise">
|
|
||||||
<span className="context-kicker">How saving works</span>
|
|
||||||
<ol>
|
|
||||||
<li><b>01</b><span><strong>Play offline</strong><small>Every change writes to device storage first.</small></span></li>
|
|
||||||
<li><b>02</b><span><strong>Create or sign in</strong><small>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>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SlotCard({ slot, selected, focused, onSelect, onFocus }: { slot: SaveSlotState; selected: boolean; focused: boolean; onSelect: () => void; onFocus: () => void }) {
|
function SlotCard({ slot, selected, focused, onSelect, onFocus }: { slot: SaveSlotState; selected: boolean; focused: boolean; onSelect: () => void; onFocus: () => void }) {
|
||||||
const save = slot.local;
|
const continuation = resolveSaveContinuation(slot);
|
||||||
|
const save = slot.local ?? slot.online;
|
||||||
const healer = save ? HEALER_CLASSES[save.activeClassId] : null;
|
const healer = save ? HEALER_CLASSES[save.activeClassId] : null;
|
||||||
|
const copyStatus = continuation === "choose" ? "Newer online" : continuation === "online" ? "Online only" : null;
|
||||||
return (
|
return (
|
||||||
<button className={`save-slot ${selected ? "is-selected" : ""} ${focused ? "is-controller-focused" : ""}`} onClick={onSelect} onFocus={onFocus} onPointerEnter={onFocus}>
|
<button className={`save-slot ${selected ? "is-selected" : ""} ${focused ? "is-controller-focused" : ""}`} onClick={onSelect} onFocus={onFocus} onPointerEnter={onFocus}>
|
||||||
<span className="slot-number">Slot {String(slot.id).padStart(2, "0")}</span>
|
<span className="slot-number">Slot {String(slot.id).padStart(2, "0")}{copyStatus && <b>{copyStatus}</b>}</span>
|
||||||
{save ? (
|
{save ? (
|
||||||
<>
|
<>
|
||||||
<div className="slot-portrait">{save.hunterName[0]}<i>✦</i></div>
|
<div className="slot-portrait">{save.hunterName[0]}<i>✦</i></div>
|
||||||
@@ -193,11 +238,15 @@ function SaveScreen() {
|
|||||||
const copySlot = useFrontendStore((state) => state.copySlot);
|
const copySlot = useFrontendStore((state) => state.copySlot);
|
||||||
const deleteSlot = useFrontendStore((state) => state.deleteSlot);
|
const deleteSlot = useFrontendStore((state) => state.deleteSlot);
|
||||||
const navigate = useFrontendStore((state) => state.navigate);
|
const navigate = useFrontendStore((state) => state.navigate);
|
||||||
const [dialog, setDialog] = useState<"create" | "copy" | "delete" | null>(null);
|
const [dialog, setDialog] = useState<"create" | "copy" | "delete" | "version" | null>(null);
|
||||||
const [hunterName, setHunterName] = useState("");
|
const [hunterName, setHunterName] = useState("");
|
||||||
|
const [resolvingOnline, setResolvingOnline] = useState(false);
|
||||||
|
const [versionError, setVersionError] = useState("");
|
||||||
const selected = slots.find((slot) => slot.id === selectedSlotId)!;
|
const selected = slots.find((slot) => slot.id === selectedSlotId)!;
|
||||||
const hasLocal = Boolean(selected.local);
|
const hasLocal = Boolean(selected.local);
|
||||||
const hasOnline = Boolean(selected.online);
|
const hasOnline = Boolean(selected.online);
|
||||||
|
const continuation = resolveSaveContinuation(selected);
|
||||||
|
const primaryActionId = continuation === "create" ? "create" : "play";
|
||||||
|
|
||||||
const finishCreation = () => {
|
const finishCreation = () => {
|
||||||
if (createSlot(selectedSlotId, hunterName)) {
|
if (createSlot(selectedSlotId, hunterName)) {
|
||||||
@@ -215,7 +264,41 @@ function SaveScreen() {
|
|||||||
requestDisplaySurface("top");
|
requestDisplaySurface("top");
|
||||||
};
|
};
|
||||||
|
|
||||||
const actions = useMemo<MenuAction[]>(() => dialog === "create"
|
const continueWithOnline = async () => {
|
||||||
|
if (resolvingOnline) return;
|
||||||
|
setResolvingOnline(true);
|
||||||
|
setVersionError("");
|
||||||
|
await downloadSlot(selectedSlotId);
|
||||||
|
const refreshed = useFrontendStore.getState().slots.find((slot) => slot.id === selectedSlotId);
|
||||||
|
if (refreshed && saveVersionsMatch(refreshed.local, refreshed.online)) {
|
||||||
|
setDialog(null);
|
||||||
|
setResolvingOnline(false);
|
||||||
|
playSlot(selectedSlotId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVersionError(useFrontendStore.getState().notice || "Online save could not be loaded.");
|
||||||
|
setResolvingOnline(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const continueSelected = () => {
|
||||||
|
if (continuation === "create") return openCreation();
|
||||||
|
if (continuation === "local") return playSlot(selectedSlotId);
|
||||||
|
if (continuation === "online") {
|
||||||
|
void continueWithOnline();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVersionError("");
|
||||||
|
setDialog("version");
|
||||||
|
requestDisplaySurface("top");
|
||||||
|
};
|
||||||
|
|
||||||
|
const actions = useMemo<MenuAction[]>(() => dialog === "version"
|
||||||
|
? [
|
||||||
|
{ id: "version-online", run: () => { void continueWithOnline(); }, enabled: !resolvingOnline },
|
||||||
|
{ id: "version-local", run: () => { setDialog(null); playSlot(selectedSlotId); }, enabled: !resolvingOnline },
|
||||||
|
{ id: "cancel-version", run: () => setDialog(null), enabled: !resolvingOnline },
|
||||||
|
]
|
||||||
|
: dialog === "create"
|
||||||
? [
|
? [
|
||||||
{ id: "confirm-create", run: finishCreation },
|
{ id: "confirm-create", run: finishCreation },
|
||||||
{ id: "cancel-create", run: () => setDialog(null) },
|
{ id: "cancel-create", run: () => setDialog(null) },
|
||||||
@@ -228,17 +311,25 @@ function SaveScreen() {
|
|||||||
{ id: "cancel-delete", run: () => setDialog(null) },
|
{ id: "cancel-delete", run: () => setDialog(null) },
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
...slots.map((slot) => ({ id: `slot-${slot.id}`, run: () => selectSlot(slot.id) })),
|
...slots.map((slot, index) => ({
|
||||||
{ id: hasLocal ? "play" : "create", run: () => hasLocal ? playSlot(selectedSlotId) : openCreation() },
|
id: `slot-${slot.id}`,
|
||||||
{ id: "upload", run: () => uploadSlot(selectedSlotId), enabled: hasLocal && Boolean(accountId) },
|
run: () => selectSlot(slot.id),
|
||||||
{ id: "download", run: () => downloadSlot(selectedSlotId), enabled: hasOnline && Boolean(accountId) },
|
neighbors: {
|
||||||
{ id: "copy", run: () => openSaveDialog("copy"), enabled: hasLocal },
|
left: `slot-${slots[Math.max(0, index - 1)].id}`,
|
||||||
{ id: "delete", run: () => openSaveDialog("delete"), enabled: hasLocal },
|
right: `slot-${slots[Math.min(slots.length - 1, index + 1)].id}`,
|
||||||
{ id: "back", run: () => navigate("login") },
|
down: primaryActionId,
|
||||||
], [accountId, copySlot, createSlot, deleteSlot, dialog, downloadSlot, hasLocal, hasOnline, hunterName, navigate, playSlot, selectSlot, selectedSlotId, slots, uploadSlot]);
|
},
|
||||||
const controller = useMenuController(actions, { onBack: () => dialog ? setDialog(null) : navigate("login") });
|
})),
|
||||||
|
{ id: primaryActionId, run: continueSelected, enabled: !resolvingOnline, neighbors: { up: `slot-${selectedSlotId}`, right: "upload" } },
|
||||||
|
{ id: "upload", run: () => uploadSlot(selectedSlotId), enabled: hasLocal && Boolean(accountId), neighbors: { left: primaryActionId, right: "download", up: "slot-1" } },
|
||||||
|
{ id: "download", run: () => downloadSlot(selectedSlotId), enabled: hasOnline && Boolean(accountId), neighbors: { left: "upload", right: "copy", up: "slot-2" } },
|
||||||
|
{ id: "copy", run: () => openSaveDialog("copy"), enabled: hasLocal, neighbors: { left: "download", right: "delete", up: "slot-2" } },
|
||||||
|
{ id: "delete", run: () => openSaveDialog("delete"), enabled: hasLocal, neighbors: { left: "copy", right: "back", up: "slot-3" } },
|
||||||
|
{ id: "back", run: () => navigate("login"), neighbors: { left: "delete", up: "slot-3" } },
|
||||||
|
], [accountId, continuation, copySlot, createSlot, deleteSlot, dialog, downloadSlot, hasLocal, hasOnline, hunterName, navigate, playSlot, primaryActionId, resolvingOnline, selectSlot, selectedSlotId, slots, uploadSlot]);
|
||||||
|
const controller = useMenuController(actions, { onBack: () => dialog ? resolvingOnline ? undefined : setDialog(null) : navigate("login") });
|
||||||
|
|
||||||
const cloudStatus = !accountId ? "Offline mode" : selected.online ? "Online version available" : "No online version";
|
const cloudStatus = continuation === "choose" ? "Newer online save" : !accountId ? "Offline mode" : selected.online ? "Online version available" : "No online version";
|
||||||
return (
|
return (
|
||||||
<DualDisplayFrame
|
<DualDisplayFrame
|
||||||
top={
|
top={
|
||||||
@@ -256,10 +347,47 @@ function SaveScreen() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="save-footer"><span>Autosave <b>OFFLINE FIRST</b></span><ControllerLegend back /></div>
|
<div className="save-top-actions">
|
||||||
|
<FocusButton id={primaryActionId} focusedId={controller.focusedId} focus={controller.focus} className="front-primary" disabled={resolvingOnline} onClick={continueSelected}>
|
||||||
|
<span>{continuation === "create" ? "Create hunter" : resolvingOnline ? "Loading online save…" : "Continue"}</span>
|
||||||
|
<small>{continuation === "create" ? `Use slot ${selectedSlotId}` : continuation === "online" ? "Download online copy" : continuation === "choose" ? "Choose online or device copy" : `Slot ${selectedSlotId} · ${selected.local?.hunterName}`}</small>
|
||||||
|
</FocusButton>
|
||||||
|
<FocusButton id="upload" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}><strong>Upload</strong><small>Device → server</small></FocusButton>
|
||||||
|
<FocusButton id="download" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasOnline || !accountId} onClick={() => downloadSlot(selectedSlotId)}><strong>Download</strong><small>Server → device</small></FocusButton>
|
||||||
|
<FocusButton id="copy" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} onClick={() => openSaveDialog("copy")}><strong>Copy</strong><small>Duplicate save</small></FocusButton>
|
||||||
|
<FocusButton id="delete" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} className="danger-link" onClick={() => openSaveDialog("delete")}><strong>Delete</strong><small>Erase device copy</small></FocusButton>
|
||||||
|
<FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("login")}><strong>Back</strong><small>Login screen</small></FocusButton>
|
||||||
|
</div>
|
||||||
|
<div className="save-footer"><span>Autosave <b>OFFLINE FIRST</b></span><div className="save-top-notice" role="status" aria-live="polite">{notice || "Lower display shows selected save details."}</div><ControllerLegend back /></div>
|
||||||
{dialog && (
|
{dialog && (
|
||||||
<div className="front-dialog" role="dialog" aria-modal="true" aria-label={dialog === "create" ? "Name new hunter" : dialog === "copy" ? "Copy save" : "Delete save"}>
|
<div className={`front-dialog ${dialog === "version" ? "version-dialog" : ""}`} role="dialog" aria-modal="true" aria-label={dialog === "version" ? "Choose save version" : dialog === "create" ? "Name new hunter" : dialog === "copy" ? "Copy save" : "Delete save"}>
|
||||||
{dialog === "create" ? (
|
{dialog === "version" && selected.local && selected.online ? (
|
||||||
|
<div className="version-choice-dialog">
|
||||||
|
<span>Newer online save found</span>
|
||||||
|
<h2>Which save do you want?</h2>
|
||||||
|
<p>Online choice replaces this device copy. Device choice keeps online copy unchanged.</p>
|
||||||
|
<div className="version-comparison">
|
||||||
|
<article className="is-newer">
|
||||||
|
<header><span>Online copy</span><b>NEWER</b></header>
|
||||||
|
<strong>{selected.online.hunterName}</strong>
|
||||||
|
<time>{formatSaveTimestamp(selected.online.updatedAt)}</time>
|
||||||
|
<small>{formatPlayTime(selected.online.playSeconds)} · Level {selected.online.healers[selected.online.activeClassId].level}</small>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<header><span>Device copy</span><b>OFFLINE</b></header>
|
||||||
|
<strong>{selected.local.hunterName}</strong>
|
||||||
|
<time>{formatSaveTimestamp(selected.local.updatedAt)}</time>
|
||||||
|
<small>{formatPlayTime(selected.local.playSeconds)} · Level {selected.local.healers[selected.local.activeClassId].level}</small>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div className="dialog-actions version-actions">
|
||||||
|
<FocusButton id="version-online" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" disabled={resolvingOnline} onClick={() => { void continueWithOnline(); }}><span>{resolvingOnline ? "Loading…" : "Continue online copy"}</span><small>{formatSaveTimestamp(selected.online.updatedAt)}</small></FocusButton>
|
||||||
|
<FocusButton id="version-local" focusedId={controller.focusedId} focus={controller.focus} disabled={resolvingOnline} onClick={() => { setDialog(null); playSlot(selectedSlotId); }}><span>Continue device copy</span><small>{formatSaveTimestamp(selected.local.updatedAt)}</small></FocusButton>
|
||||||
|
<FocusButton id="cancel-version" focusedId={controller.focusedId} focus={controller.focus} disabled={resolvingOnline} onClick={() => setDialog(null)}>Cancel</FocusButton>
|
||||||
|
</div>
|
||||||
|
{versionError && <div className="version-choice-error" role="alert">{versionError}</div>}
|
||||||
|
</div>
|
||||||
|
) : dialog === "create" ? (
|
||||||
<form onSubmit={(event) => { event.preventDefault(); finishCreation(); }}>
|
<form onSubmit={(event) => { event.preventDefault(); finishCreation(); }}>
|
||||||
<span>New offline save</span><h2>Name your hunter</h2><p>This name identifies the character in local and online save lists.</p>
|
<span>New offline save</span><h2>Name your hunter</h2><p>This name identifies the character in local and online save lists.</p>
|
||||||
<label htmlFor="new-hunter-name">Hunter name</label>
|
<label htmlFor="new-hunter-name">Hunter name</label>
|
||||||
@@ -304,31 +432,37 @@ function SaveScreen() {
|
|||||||
</FrontSurface>
|
</FrontSurface>
|
||||||
}
|
}
|
||||||
bottom={
|
bottom={
|
||||||
<FrontSurface className="save-context" bottom ariaLabel="Selected save management">
|
<FrontSurface className="save-context" bottom ariaLabel="Selected save information">
|
||||||
<header className="context-header"><span>Slot {selectedSlotId}</span><b>{cloudStatus}</b></header>
|
<header className="context-header"><span>Slot {selectedSlotId}</span><b>{cloudStatus}</b></header>
|
||||||
<div className="selected-save-summary">
|
<div className="selected-save-summary">
|
||||||
{selected.local ? (
|
{selected.local ?? selected.online ? (
|
||||||
<><div className="summary-avatar">{selected.local.hunterName[0]}</div><span><small>Local record</small><h2>{selected.local.hunterName}</h2><p>{selected.local.location} · {formatPlayTime(selected.local.playSeconds)}</p><time>{formatSaveTimestamp(selected.local.updatedAt)}</time></span></>
|
<><div className="summary-avatar">{(selected.local ?? selected.online)!.hunterName[0]}</div><span><small>{selected.local ? "Device save" : "Online copy only"}</small><h2>{(selected.local ?? selected.online)!.hunterName}</h2><p>{(selected.local ?? selected.online)!.location}</p><time>{formatSaveTimestamp((selected.local ?? selected.online)!.updatedAt)}</time></span></>
|
||||||
) : (
|
) : (
|
||||||
<><div className="summary-avatar is-empty">+</div><span><small>Local record</small><h2>Empty slot</h2><p>Create a hunter or download an online version.</p></span></>
|
<><div className="summary-avatar is-empty">+</div><span><small>Local record</small><h2>Empty slot</h2><p>Create a hunter or download an online version.</p></span></>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{selected.online && <div className="online-record"><span><b>ONLINE</b>{selected.online.hunterName}</span><time>{formatSaveTimestamp(selected.online.updatedAt)}</time></div>}
|
{(selected.local ?? selected.online) && (() => {
|
||||||
<div className="save-actions">
|
const save = (selected.local ?? selected.online)!;
|
||||||
<FocusButton id={hasLocal ? "play" : "create"} focusedId={controller.focusedId} focus={controller.focus} className="front-primary" onClick={() => hasLocal ? playSlot(selectedSlotId) : openCreation()}>
|
const healer = HEALER_CLASSES[save.activeClassId];
|
||||||
{hasLocal ? "Continue offline save" : "Create new hunter"}<small>A</small>
|
return (
|
||||||
</FocusButton>
|
<>
|
||||||
<div className="sync-actions">
|
<div className="save-dossier-stats">
|
||||||
<FocusButton id="upload" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}>↑ Sync offline to server</FocusButton>
|
<span><small>Active healer</small><strong>Lv {save.healers[save.activeClassId].level}</strong><em>{healer.name}</em></span>
|
||||||
<FocusButton id="download" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasOnline || !accountId} onClick={() => downloadSlot(selectedSlotId)}>↓ Overwrite with online</FocusButton>
|
<span><small>Play time</small><strong>{formatPlayTime(save.playSeconds)}</strong><em>Local activity</em></span>
|
||||||
</div>
|
<span><small>Boss kills</small><strong>{save.stats.totalBossKills}</strong><em>{save.stats.flawlessClears} flawless</em></span>
|
||||||
<div className="record-actions">
|
</div>
|
||||||
<FocusButton id="copy" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} onClick={() => openSaveDialog("copy")}>Copy save</FocusButton>
|
<div className="save-dossier-records">
|
||||||
<FocusButton id="delete" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} className="danger-link" onClick={() => openSaveDialog("delete")}>Delete save</FocusButton>
|
<span><small>Roguelike best</small><b>Round {save.stats.highestRoguelikeRound}</b></span>
|
||||||
<FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("login")}>Back</FocusButton>
|
<span><small>Endless best</small><b>{save.stats.highestRogueTrialsEndlessKills} kills</b></span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<div className="save-copy-state">
|
||||||
|
<span><i className={selected.local ? "is-present" : ""} />Device copy<b>{selected.local ? formatSaveTimestamp(selected.local.updatedAt) : "Not present"}</b></span>
|
||||||
|
<span><i className={selected.online ? "is-present" : ""} />Online copy<b>{selected.online ? formatSaveTimestamp(selected.online.updatedAt) : accountId ? "Not uploaded" : "Sign-in required"}</b></span>
|
||||||
</div>
|
</div>
|
||||||
<div className="front-notice is-lower">{notice || "All gameplay changes save to local storage automatically."}</div>
|
<div className="front-notice is-lower">{notice || "Use upper display for every save action. Details here follow selected slot."}</div>
|
||||||
</FrontSurface>
|
</FrontSurface>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -337,6 +471,7 @@ function SaveScreen() {
|
|||||||
|
|
||||||
const HOME_MODES: { id: GameModeId; icon: string; label: string; copy: string }[] = [
|
const HOME_MODES: { id: GameModeId; icon: string; label: string; copy: string }[] = [
|
||||||
{ id: "roguelike-pve", icon: "✦", label: "PVE", copy: "Randomized roguelike runs" },
|
{ 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: "dungeons", icon: "♜", label: "Dungeons", copy: "Choose your boss encounter" },
|
||||||
{ id: "roguelike-pvp", icon: "⚔", label: "Roguelike PvP", copy: "Draft, race, sabotage" },
|
{ id: "roguelike-pvp", icon: "⚔", label: "Roguelike PvP", copy: "Draft, race, sabotage" },
|
||||||
{ id: "stadium-pvp", icon: "◉", label: "Stadium PvP", copy: "Prepared 5v5 rounds" },
|
{ id: "stadium-pvp", icon: "◉", label: "Stadium PvP", copy: "Prepared 5v5 rounds" },
|
||||||
@@ -349,10 +484,11 @@ function HomeScreen() {
|
|||||||
const selectHealerClass = useFrontendStore((state) => state.selectHealerClass);
|
const selectHealerClass = useFrontendStore((state) => state.selectHealerClass);
|
||||||
const navigate = useFrontendStore((state) => state.navigate);
|
const navigate = useFrontendStore((state) => state.navigate);
|
||||||
const actions = useMemo<MenuAction[]>(() => [
|
const actions = useMemo<MenuAction[]>(() => [
|
||||||
{ id: "roguelike-pve", run: () => selectMode("roguelike-pve"), neighbors: { right: "dungeons", down: "roguelike-pvp" } },
|
{ id: "roguelike-pve", run: () => selectMode("roguelike-pve"), neighbors: { right: "rogue-trials", down: "roguelike-pvp" } },
|
||||||
{ id: "dungeons", run: () => selectMode("dungeons"), neighbors: { left: "roguelike-pve", down: "stadium-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: "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: "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: "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" } },
|
{ id: "settings", run: () => navigate("settings"), neighbors: { up: "stadium-pvp", left: "gear", down: "class-shaman" } },
|
||||||
@@ -378,7 +514,6 @@ function HomeScreen() {
|
|||||||
top={
|
top={
|
||||||
<FrontSurface className="home-surface" ariaLabel="Main menu">
|
<FrontSurface className="home-surface" ariaLabel="Main menu">
|
||||||
<header className="home-header"><BrandMark compact /><span>Welcome back, <b>{hunter.hunterName}</b></span><i>{accountId ? "● SYNC READY" : "○ OFFLINE"}</i></header>
|
<header className="home-header"><BrandMark compact /><span>Welcome back, <b>{hunter.hunterName}</b></span><i>{accountId ? "● SYNC READY" : "○ OFFLINE"}</i></header>
|
||||||
<div className="home-title"><span>Choose your hunt</span><h1>Where are you needed?</h1></div>
|
|
||||||
<div className="mode-grid">
|
<div className="mode-grid">
|
||||||
{HOME_MODES.map((mode) => (
|
{HOME_MODES.map((mode) => (
|
||||||
<FocusButton key={mode.id} id={mode.id} focusedId={controller.focusedId} focus={controller.focus} className="mode-card" onClick={() => selectMode(mode.id)}>
|
<FocusButton key={mode.id} id={mode.id} focusedId={controller.focusedId} focus={controller.focus} className="mode-card" onClick={() => selectMode(mode.id)}>
|
||||||
@@ -420,39 +555,137 @@ function HomeScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProfileStatId = BossId | "roguelike" | "rogue-trials-endless";
|
||||||
|
|
||||||
function ProfileScreen() {
|
function ProfileScreen() {
|
||||||
const hunter = useActiveHunter();
|
const hunter = useActiveHunter();
|
||||||
|
const accountId = useFrontendStore((state) => state.accountId);
|
||||||
const navigate = useFrontendStore((state) => state.navigate);
|
const navigate = useFrontendStore((state) => state.navigate);
|
||||||
const collections = useMemo(() => hunter ? buildCollections(hunter.collectionLog, hunter.stats.bossKills) : [], [hunter]);
|
const collections = useMemo(() => hunter ? buildCollections(hunter.collectionLog, hunter.stats.bossKills) : [], [hunter]);
|
||||||
const [groupId, setGroupId] = useState(collections[0]?.groupId ?? "");
|
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 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[]>(() => [
|
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) })),
|
...collections.map((group) => ({ id: `group-${group.groupId}`, run: () => setGroupId(group.groupId) })),
|
||||||
{ id: "back", run: () => navigate("home") },
|
{ id: "back", run: () => navigate("home") },
|
||||||
], [collections, navigate]);
|
], [collection, collectionView, collections, navigate]);
|
||||||
const controller = useMenuController(actions, { onBack: () => navigate("home") });
|
const controller = useMenuController(actions, { onBack: () => navigate("home") });
|
||||||
if (!hunter || !collection) return null;
|
if (!hunter || !collection) return null;
|
||||||
const activeHealer = HEALER_CLASSES[hunter.activeClassId];
|
const activeHealer = HEALER_CLASSES[hunter.activeClassId];
|
||||||
const activeProgress = hunter.healers[hunter.activeClassId];
|
const activeProgress = hunter.healers[hunter.activeClassId];
|
||||||
const earned = collection.drops.filter((drop) => drop.count > 0).length;
|
const earned = collection.drops.filter((drop) => drop.count > 0).length;
|
||||||
|
const trophiesEarned = collection.bosses.filter((boss) => boss.pet.count > 0).length;
|
||||||
|
const selectedStatValue = selectedStat === "roguelike"
|
||||||
|
? hunter.stats.highestRoguelikeRound
|
||||||
|
: selectedStat === "rogue-trials-endless"
|
||||||
|
? hunter.stats.highestRogueTrialsEndlessKills
|
||||||
|
: hunter.stats.bossKills[selectedStat] ?? 0;
|
||||||
|
const selectedStatLabel = selectedStat === "roguelike"
|
||||||
|
? "Roguelike rounds"
|
||||||
|
: selectedStat === "rogue-trials-endless"
|
||||||
|
? "Rogue Trials endless kills"
|
||||||
|
: BOSS_DEFINITIONS[selectedStat].name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DualDisplayFrame
|
<DualDisplayFrame
|
||||||
top={
|
top={
|
||||||
<FrontSurface className="profile-surface" ariaLabel="Hunter profile collection log">
|
<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>
|
<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">
|
||||||
<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>
|
<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>
|
||||||
<div className="collection-grid">
|
<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>
|
||||||
{collection.drops.map((drop) => (
|
<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>
|
||||||
<article key={drop.id} className={`collection-drop rarity-${drop.rarity.toLowerCase()} ${drop.count === 0 ? "is-missing" : ""}`}>
|
</div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
|
||||||
<span className="drop-icon">{drop.icon}<b>{drop.count}</b></span>
|
{collectionView === "loot" ? <>
|
||||||
<small>{drop.rarity}</small><strong>{drop.name}</strong>
|
<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>
|
||||||
<p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : `Defeat a Group ${collection.groupLetter} boss`}</p>
|
<div className="collection-grid">
|
||||||
<small>{drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""}</small>
|
{collection.drops.map((drop) => (
|
||||||
</article>
|
<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>
|
||||||
</div>
|
<small>{drop.rarity}</small><strong>{drop.name}</strong>
|
||||||
<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>
|
<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>
|
</FrontSurface>
|
||||||
}
|
}
|
||||||
bottom={
|
bottom={
|
||||||
@@ -463,6 +696,8 @@ function ProfileScreen() {
|
|||||||
<span><small>Flawless clears</small><strong>{hunter.stats.flawlessClears}</strong></span>
|
<span><small>Flawless clears</small><strong>{hunter.stats.flawlessClears}</strong></span>
|
||||||
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
|
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
|
||||||
<span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span>
|
<span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span>
|
||||||
|
<span><small>Highest roguelike round</small><strong>{hunter.stats.highestRoguelikeRound}</strong></span>
|
||||||
|
<span><small>Endless best</small><strong>{hunter.stats.highestRogueTrialsEndlessKills}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
<div className="boss-log"><span>Mechanic groups</span>{collections.map((group) => (
|
<div className="boss-log"><span>Mechanic groups</span>{collections.map((group) => (
|
||||||
<FocusButton key={group.groupId} id={`group-${group.groupId}`} focusedId={controller.focusedId} focus={controller.focus} className={group.groupId === collection.groupId ? "is-selected" : ""} onClick={() => setGroupId(group.groupId)}>
|
<FocusButton key={group.groupId} id={`group-${group.groupId}`} focusedId={controller.focusedId} focus={controller.focus} className={group.groupId === collection.groupId ? "is-selected" : ""} onClick={() => setGroupId(group.groupId)}>
|
||||||
@@ -483,17 +718,35 @@ function GearScreen() {
|
|||||||
const selectedSlotId = useFrontendStore((state) => state.selectedGearSlotId);
|
const selectedSlotId = useFrontendStore((state) => state.selectedGearSlotId);
|
||||||
const workshopMode = useFrontendStore((state) => state.gearWorkshopMode);
|
const workshopMode = useFrontendStore((state) => state.gearWorkshopMode);
|
||||||
const selectedInfusionId = useFrontendStore((state) => state.selectedInfusionId);
|
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 selectOwner = useFrontendStore((state) => state.selectGearOwner);
|
||||||
const selectSlot = useFrontendStore((state) => state.selectGearSlot);
|
const selectSlot = useFrontendStore((state) => state.selectGearSlot);
|
||||||
const selectWorkshopMode = useFrontendStore((state) => state.selectGearWorkshopMode);
|
const selectWorkshopMode = useFrontendStore((state) => state.selectGearWorkshopMode);
|
||||||
const selectInfusion = useFrontendStore((state) => state.selectInfusion);
|
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 upgrade = useFrontendStore((state) => state.upgradeSelectedGear);
|
||||||
const installInfusion = useFrontendStore((state) => state.equipSelectedInfusion);
|
const installInfusion = useFrontendStore((state) => state.equipSelectedInfusion);
|
||||||
const installPassive = useFrontendStore((state) => state.equipPassiveInfusion);
|
const installPassive = useFrontendStore((state) => state.equipPassiveInfusion);
|
||||||
const slot = hunter?.gearProgress[selectedOwnerId].slots[selectedSlotId];
|
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 recipe = GEAR_RECIPES[selectedOwnerId][selectedSlotId];
|
||||||
const costs = hunter && slot ? gearUpgradeCosts(selectedOwnerId, selectedSlotId, slot.level) : [];
|
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 infusionChoices = infusionsForOwner(selectedOwnerId);
|
||||||
const selectedInfusion = infusionChoices.find((choice) => choice.id === selectedInfusionId) ?? infusionChoices[0];
|
const selectedInfusion = infusionChoices.find((choice) => choice.id === selectedInfusionId) ?? infusionChoices[0];
|
||||||
const selectedInfusionCosts = hunter ? infusionCosts(selectedOwnerId, selectedSlotId, selectedInfusion.id) : [];
|
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 canInstallInfusion = Boolean(hunter && activeUnlocked && anchorUnlocked && !infusionEquipped && canAffordGearUpgrade(hunter.materials, selectedInfusionCosts));
|
||||||
const passiveUnlocked = Boolean(hunter && passiveInfusionUnlocked(hunter.gearProgress));
|
const passiveUnlocked = Boolean(hunter && passiveInfusionUnlocked(hunter.gearProgress));
|
||||||
const healerOwner = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman";
|
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 previewEntryId = workshopMode === "upgrade" ? "upgrade" : `infusion-${infusionChoices[0].id}`;
|
||||||
const actions = useMemo<MenuAction[]>(() => [
|
const actions = useMemo<MenuAction[]>(() => [
|
||||||
...GEAR_OWNER_ORDER.map((ownerId, index) => ({
|
...GEAR_OWNER_ORDER.map((ownerId, index) => ({
|
||||||
@@ -518,7 +775,7 @@ function GearScreen() {
|
|||||||
id: `slot-${slotId}`,
|
id: `slot-${slotId}`,
|
||||||
run: () => selectSlot(slotId),
|
run: () => selectSlot(slotId),
|
||||||
neighbors: {
|
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]}`,
|
down: index === GEAR_SLOT_ORDER.length - 1 ? previewEntryId : `slot-${GEAR_SLOT_ORDER[index + 1]}`,
|
||||||
left: `owner-${selectedOwnerId}`,
|
left: `owner-${selectedOwnerId}`,
|
||||||
right: previewEntryId,
|
right: previewEntryId,
|
||||||
@@ -531,25 +788,42 @@ function GearScreen() {
|
|||||||
run: () => selectInfusion(infusion.id),
|
run: () => selectInfusion(infusion.id),
|
||||||
neighbors: {
|
neighbors: {
|
||||||
up: index === 0 ? "workshop-infusion" : `infusion-${infusionChoices[index - 1].id}`,
|
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}`,
|
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}`,
|
id: `passive-${passive.id}`,
|
||||||
run: () => installPassive(passive.id),
|
run: () => {
|
||||||
|
selectPassiveInfusion(passive.id);
|
||||||
|
installPassive(passive.id);
|
||||||
|
},
|
||||||
enabled: passiveUnlocked,
|
enabled: passiveUnlocked,
|
||||||
neighbors: {
|
neighbors: {
|
||||||
up: index === 0 ? `infusion-${infusionChoices[infusionChoices.length - 1].id}` : `passive-${PASSIVE_INFUSIONS[index - 1].id}`,
|
up: index === 0 ? `passive-ability-${selectedPassiveAbilityId}` : `passive-${passiveChoices[index - 1].id}`,
|
||||||
down: index === PASSIVE_INFUSIONS.length - 1 ? "install-infusion" : `passive-${PASSIVE_INFUSIONS[index + 1].id}`,
|
down: index === passiveChoices.length - 1 ? `passive-ability-${selectedPassiveAbilityId}` : `passive-${passiveChoices[index + 1].id}`,
|
||||||
left: `slot-${selectedSlotId}`,
|
left: `slot-${selectedSlotId}`,
|
||||||
},
|
},
|
||||||
})) : []),
|
})) : []),
|
||||||
{ id: "upgrade", run: upgrade, enabled: canUpgrade, neighbors: { left: `slot-${selectedSlotId}`, up: `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: "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: { down: `owner-${GEAR_OWNER_ORDER[0]}` } },
|
{ id: "back", run: () => navigate("home"), neighbors: { left: "workshop-infusion", down: `owner-${GEAR_OWNER_ORDER[0]}` } },
|
||||||
], [canInstallInfusion, canUpgrade, healerOwner, infusionChoices, installInfusion, installPassive, navigate, passiveUnlocked, previewEntryId, selectInfusion, selectOwner, selectSlot, selectWorkshopMode, selectedOwnerId, selectedSlotId, upgrade]);
|
], [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 controller = useMenuController(actions, { onBack: () => navigate("home") });
|
||||||
|
const passiveContext = workshopMode === "infusion" && healerOwner && controller.focusedId.startsWith("passive-");
|
||||||
if (!hunter || !slot) return null;
|
if (!hunter || !slot) return null;
|
||||||
const currentBonus = gearBonusText(recipe.statId, slot.level);
|
const currentBonus = gearBonusText(recipe.statId, slot.level);
|
||||||
const nextBonus = gearBonusText(recipe.statId, Math.min(MAX_GEAR_LEVEL, slot.level + 1));
|
const nextBonus = gearBonusText(recipe.statId, Math.min(MAX_GEAR_LEVEL, slot.level + 1));
|
||||||
@@ -558,19 +832,21 @@ function GearScreen() {
|
|||||||
<DualDisplayFrame
|
<DualDisplayFrame
|
||||||
top={
|
top={
|
||||||
<FrontSurface className="gear-surface" ariaLabel="Gear upgrade workshop">
|
<FrontSurface className="gear-surface" ariaLabel="Gear upgrade workshop">
|
||||||
<header className="front-screen-header"><BrandMark compact /><div><span>Group drop workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
|
<header className="front-screen-header"><BrandMark compact /><div><span>Group drop workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
|
||||||
<div className="gear-workshop-layout">
|
<div className="gear-workshop-layout">
|
||||||
<section className="gear-owner-list" aria-label="Party gear owners">
|
<section className="gear-owner-list" aria-label="Party gear owners">
|
||||||
{GEAR_OWNER_ORDER.map((ownerId) => {
|
{GEAR_OWNER_ORDER.map((ownerId) => {
|
||||||
const highest = Math.max(...GEAR_SLOT_ORDER.map((slotId) => hunter.gearProgress[ownerId].slots[slotId].level));
|
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>
|
||||||
<section className="gear-slot-list" aria-label={`${GEAR_OWNER_LABELS[selectedOwnerId]} gear slots`}>
|
<section className="gear-slot-list" aria-label={`${GEAR_OWNER_LABELS[selectedOwnerId]} gear slots`}>
|
||||||
{GEAR_SLOT_ORDER.map((slotId) => {
|
{GEAR_SLOT_ORDER.map((slotId) => {
|
||||||
const progress = hunter.gearProgress[selectedOwnerId].slots[slotId];
|
const progress = hunter.gearProgress[selectedOwnerId].slots[slotId];
|
||||||
const slotRecipe = GEAR_RECIPES[selectedOwnerId][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>
|
</section>
|
||||||
{workshopMode === "upgrade" ? <article className="gear-preview">
|
{workshopMode === "upgrade" ? <article className="gear-preview">
|
||||||
@@ -585,7 +861,25 @@ function GearScreen() {
|
|||||||
<div className="gear-infusion-options">
|
<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>)}
|
{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>
|
</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>}
|
</article>}
|
||||||
</div>
|
</div>
|
||||||
<ControllerLegend back />
|
<ControllerLegend back />
|
||||||
@@ -593,15 +887,15 @@ function GearScreen() {
|
|||||||
}
|
}
|
||||||
bottom={
|
bottom={
|
||||||
<FrontSurface className="gear-context" bottom ariaLabel="Gear recipe and material inventory">
|
<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">
|
<div className="gear-costs">
|
||||||
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
|
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : passiveContext ? "Passive blessing · Rank 1" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
|
||||||
{(workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => {
|
{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;
|
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>;
|
return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>;
|
||||||
}) : <article className="is-met"><i>✓</i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>}
|
}) : <article className="is-met"><i>✓</i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>}
|
||||||
</div>
|
</div>
|
||||||
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
|
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : passiveContext ? <div className="gear-passive-context-action"><span>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : `${DEFAULT_CONTROLLER_GLYPHS.confirm} · Equip selected passive`}</span><small>Applies at rank 1 next encounter.</small></div> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
|
||||||
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
|
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
|
||||||
</FrontSurface>
|
</FrontSurface>
|
||||||
}
|
}
|
||||||
@@ -632,7 +926,7 @@ function SettingsScreen() {
|
|||||||
<DualDisplayFrame
|
<DualDisplayFrame
|
||||||
top={
|
top={
|
||||||
<FrontSurface className="settings-surface" ariaLabel="Settings">
|
<FrontSurface className="settings-surface" ariaLabel="Settings">
|
||||||
<header className="front-screen-header"><BrandMark compact /><div><span>Field configuration</span><h1>Settings</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
|
<header className="front-screen-header"><BrandMark compact /><div><span>Field configuration</span><h1>Settings</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
|
||||||
<div className="settings-layout">
|
<div className="settings-layout">
|
||||||
<section><span className="settings-section-title">Audio</span><div className="volume-setting"><span><strong>Master volume</strong><small>All music, effects, and voice</small></span><div><FocusButton id="volume-down" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}>−</FocusButton><b>{settings.masterVolume}%</b><FocusButton id="volume-up" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}>+</FocusButton></div><i><em style={{ width: `${settings.masterVolume}%` }} /></i></div></section>
|
<section><span className="settings-section-title">Audio</span><div className="volume-setting"><span><strong>Master volume</strong><small>All music, effects, and voice</small></span><div><FocusButton id="volume-down" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}>−</FocusButton><b>{settings.masterVolume}%</b><FocusButton id="volume-up" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}>+</FocusButton></div><i><em style={{ width: `${settings.masterVolume}%` }} /></i></div></section>
|
||||||
<section><span className="settings-section-title">Display & accessibility</span><SettingToggle id="motion" label="Reduced motion" copy="Limit non-essential UI movement" value={settings.reducedMotion} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("reducedMotion", !settings.reducedMotion)} /><SettingToggle id="numbers" label="Damage numbers" copy="Show combat values over units" value={settings.damageNumbers} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("damageNumbers", !settings.damageNumbers)} /><SettingToggle id="text" label="Large interface text" copy="Increase menu and tactical labels" value={settings.largeText} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("largeText", !settings.largeText)} /></section>
|
<section><span className="settings-section-title">Display & accessibility</span><SettingToggle id="motion" label="Reduced motion" copy="Limit non-essential UI movement" value={settings.reducedMotion} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("reducedMotion", !settings.reducedMotion)} /><SettingToggle id="numbers" label="Damage numbers" copy="Show combat values over units" value={settings.damageNumbers} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("damageNumbers", !settings.damageNumbers)} /><SettingToggle id="text" label="Large interface text" copy="Increase menu and tactical labels" value={settings.largeText} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("largeText", !settings.largeText)} /></section>
|
||||||
@@ -645,9 +939,9 @@ function SettingsScreen() {
|
|||||||
<header className="context-header"><span>Controller</span><b>BUILT-IN THOR PAD</b></header>
|
<header className="context-header"><span>Controller</span><b>BUILT-IN THOR PAD</b></header>
|
||||||
<div className="controller-map">
|
<div className="controller-map">
|
||||||
<div className="pad-diagram"><i>↑</i><span>←<b>●</b>→</span><i>↓</i></div>
|
<div className="pad-diagram"><i>↑</i><span>←<b>●</b>→</span><i>↓</i></div>
|
||||||
<div className="face-diagram"><i className="y">Y</i><span><i className="x">X</i><b>●</b><i className="b">B</i></span><i className="a">A</i></div>
|
<div className="face-diagram"><i className="triangle">{DEFAULT_CONTROLLER_GLYPHS.faceTop}</i><span><i className="square">{DEFAULT_CONTROLLER_GLYPHS.faceLeft}</i><b>●</b><i className="circle">{DEFAULT_CONTROLLER_GLYPHS.faceRight}</i></span><i className="cross">{DEFAULT_CONTROLLER_GLYPHS.faceBottom}</i></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mapping-list"><span><b>A</b> Confirm / cast Purify</span><span><b>B</b> Back / cast Shield</span><span><b>D-Pad</b> Navigate / target party</span><span><b>Start</b> Pause / menu</span></div>
|
<div className="mapping-list"><span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Confirm / cast Purify</span><span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back / cast Shield</span><span><b>D-Pad</b> Navigate / target party</span><span><b>Right stick</b> Rotate camera</span><span><b>{DEFAULT_CONTROLLER_GLYPHS.start}</b> Pause / menu</span></div>
|
||||||
<div className="control-assurance"><i>✓</i><span><strong>No click-to-focus required</strong><small>Controller input routes through app-level actions.</small></span></div>
|
<div className="control-assurance"><i>✓</i><span><strong>No click-to-focus required</strong><small>Controller input routes through app-level actions.</small></span></div>
|
||||||
</FrontSurface>
|
</FrontSurface>
|
||||||
}
|
}
|
||||||
@@ -664,55 +958,62 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
|||||||
const selectDifficulty = useFrontendStore((state) => state.selectDifficulty);
|
const selectDifficulty = useFrontendStore((state) => state.selectDifficulty);
|
||||||
const navigate = useFrontendStore((state) => state.navigate);
|
const navigate = useFrontendStore((state) => state.navigate);
|
||||||
const [message, setMessage] = useState("");
|
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 mode = MODE_COPY[modeId];
|
||||||
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
|
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
|
||||||
const progress = hunter?.healers[hunter.activeClassId];
|
const progress = hunter?.healers[hunter.activeClassId];
|
||||||
const selectedBoss = BOSS_DEFINITIONS[selectedBossId];
|
const selectedBoss = BOSS_DEFINITIONS[selectedBossId];
|
||||||
const selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug];
|
const selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug];
|
||||||
const isPve = modeId === "roguelike-pve";
|
const isPve = modeId === "roguelike-pve";
|
||||||
|
const isRogueTrials = modeId === "rogue-trials";
|
||||||
|
const isPveRun = isPve || isRogueTrials;
|
||||||
const isDungeon = modeId === "dungeons";
|
const isDungeon = modeId === "dungeons";
|
||||||
const bossPageCount = Math.ceil(AVAILABLE_BOSS_IDS.length / bossPageSize);
|
const selectedBossGroup = BOSS_GROUP_BY_ID[selectedBoss.groupId];
|
||||||
const visibleBossIds = AVAILABLE_BOSS_IDS.slice(bossPage * bossPageSize, (bossPage + 1) * bossPageSize);
|
const visibleBossIds = selectedBossGroup.bossIds;
|
||||||
const bossGridRows = Math.ceil(visibleBossIds.length / 3);
|
const bossGridColumns = Math.min(2, visibleBossIds.length);
|
||||||
const bossGridColumns = Math.ceil(visibleBossIds.length / bossGridRows);
|
const selectBossGroup = (groupId: (typeof BOSS_GROUPS)[number]["id"]) => {
|
||||||
const changeBossPage = (nextPage: number) => {
|
selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]);
|
||||||
const page = Math.max(0, Math.min(bossPageCount - 1, nextPage));
|
|
||||||
setBossPage(page);
|
|
||||||
selectBoss(AVAILABLE_BOSS_IDS[page * bossPageSize]);
|
|
||||||
};
|
};
|
||||||
const launch = () => {
|
const launch = () => {
|
||||||
if (isPve) return onLaunch(selectRandomBossPair(), "initiate");
|
if (isPveRun) return onLaunch(selectRandomBossPair(), "initiate");
|
||||||
if (isDungeon) return onLaunch([selectedBossId], selectedDifficultySlug);
|
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[]>(() => [
|
const actions = useMemo<MenuAction[]>(() => [
|
||||||
...(isDungeon ? visibleBossIds.map((bossId, index) => {
|
...(isDungeon ? BOSS_GROUPS.map((group, index) => {
|
||||||
const column = Math.floor(index / bossGridRows);
|
const groupColumns = 5;
|
||||||
const row = index % bossGridRows;
|
const row = Math.floor(index / groupColumns);
|
||||||
const neighborInColumn = (targetColumn: number) => {
|
const column = index % groupColumns;
|
||||||
const columnStart = targetColumn * bossGridRows;
|
const groupAt = (targetRow: number, targetColumn: number) => BOSS_GROUPS[targetRow * groupColumns + targetColumn];
|
||||||
if (columnStart >= visibleBossIds.length || targetColumn < 0) return undefined;
|
|
||||||
const columnEnd = Math.min(columnStart + bossGridRows, visibleBossIds.length) - 1;
|
return {
|
||||||
return `boss-${visibleBossIds[Math.min(columnStart + row, columnEnd)]}`;
|
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 {
|
return {
|
||||||
id: `boss-${bossId}`,
|
id: `boss-${bossId}`,
|
||||||
run: () => selectBoss(bossId),
|
run: () => selectBoss(bossId),
|
||||||
neighbors: {
|
neighbors: {
|
||||||
up: row > 0 ? `boss-${visibleBossIds[index - 1]}` : "back",
|
up: row > 0 ? `boss-${bossAt(row - 1, column)}` : `boss-group-${selectedBossGroup.id}`,
|
||||||
down: index + 1 < Math.min((column + 1) * bossGridRows, visibleBossIds.length)
|
down: bossAt(row + 1, column) ? `boss-${bossAt(row + 1, column)}` : `difficulty-${DIFFICULTIES[0].slug}`,
|
||||||
? `boss-${visibleBossIds[index + 1]}`
|
left: column > 0 ? `boss-${bossAt(row, column - 1)}` : undefined,
|
||||||
: `difficulty-${DIFFICULTIES[0].slug}`,
|
right: bossAt(row, column + 1) ? `boss-${bossAt(row, column + 1)}` : undefined,
|
||||||
left: neighborInColumn(column - 1) ?? (bossPage > 0 ? "boss-page-prev" : undefined),
|
|
||||||
right: neighborInColumn(column + 1) ?? (bossPage < bossPageCount - 1 ? "boss-page-next" : 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) => ({
|
...(isDungeon ? DIFFICULTIES.map((difficulty, index) => ({
|
||||||
id: `difficulty-${difficulty.slug}`,
|
id: `difficulty-${difficulty.slug}`,
|
||||||
run: () => selectDifficulty(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: "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" } },
|
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-group-${selectedBossGroup.id}` } : { down: "launch" } },
|
||||||
], [bossGridRows, bossPage, bossPageCount, isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossId, selectedDifficultySlug, visibleBossIds]);
|
], [bossGridColumns, isDungeon, isPveRun, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossGroup, selectedBossId, selectedDifficultySlug, visibleBossIds]);
|
||||||
const controller = useMenuController(actions, { onBack: () => navigate("home") });
|
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
|
const contextRules = isDungeon
|
||||||
? [
|
? [
|
||||||
[selectedBoss.name, selectedBoss.summary],
|
[selectedBoss.name, selectedBoss.summary],
|
||||||
[selectedBoss.mechanics[0], selectedBoss.briefing],
|
[bossMechanicName(selectedBoss.mechanicIds[0]), selectedBoss.briefing],
|
||||||
[selectedBoss.mechanics[1], "Controller-ready party behavior and full lower-display support."],
|
[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
|
: isPve
|
||||||
? [
|
? [
|
||||||
["Randomized pair", "Two distinct bosses are selected only when the run begins."],
|
["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
|
<DualDisplayFrame
|
||||||
top={
|
top={
|
||||||
<FrontSurface className={`mode-surface mode-${modeId}`} ariaLabel={`${mode.title} details`}>
|
<FrontSurface className={`mode-surface mode-${modeId}`} ariaLabel={`${mode.title} details`}>
|
||||||
<header className="front-screen-header"><BrandMark compact /><div><span>Game mode</span><h1>{mode.title}</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
|
<header className="front-screen-header"><BrandMark compact /><div><span>Game mode</span><h1>{mode.title}</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
|
||||||
{!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
|
{!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
|
||||||
{isDungeon && (
|
{isDungeon && (
|
||||||
<div className="boss-picker" aria-label="Choose boss encounter">
|
<div className="boss-picker" aria-label="Choose boss encounter">
|
||||||
<div className="boss-picker-heading">
|
<div className="boss-picker-heading"><span>Choose a mechanic group</span></div>
|
||||||
<span>Choose encounter · Page {bossPage + 1}/{bossPageCount}</span>
|
<div className="boss-group-grid" aria-label="Choose boss group">
|
||||||
<div>
|
{BOSS_GROUPS.map((group) => (
|
||||||
<FocusButton id="boss-page-prev" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === 0} onClick={() => changeBossPage(bossPage - 1)}>◀ Previous</FocusButton>
|
<FocusButton
|
||||||
<FocusButton id="boss-page-next" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === bossPageCount - 1} onClick={() => changeBossPage(bossPage + 1)}>Next ▶</FocusButton>
|
key={group.id}
|
||||||
</div>
|
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>
|
||||||
<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) => {
|
{visibleBossIds.map((bossId) => {
|
||||||
const boss = BOSS_DEFINITIONS[bossId];
|
const boss = BOSS_DEFINITIONS[bossId];
|
||||||
const group = BOSS_GROUP_BY_ID[boss.groupId];
|
|
||||||
return (
|
return (
|
||||||
<FocusButton
|
<FocusButton
|
||||||
key={bossId}
|
key={bossId}
|
||||||
@@ -775,7 +1091,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
|||||||
aria-pressed={selectedBossId === bossId}
|
aria-pressed={selectedBossId === bossId}
|
||||||
onClick={() => selectBoss(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>
|
</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>)}
|
{DIFFICULTIES.map((difficulty) => <FocusButton key={difficulty.slug} id={`difficulty-${difficulty.slug}`} focusedId={controller.focusedId} focus={controller.focus} className={difficulty.slug === selectedDifficultySlug ? "is-selected" : ""} onClick={() => selectDifficulty(difficulty.slug)}><strong>{difficulty.name}</strong><small>iLvl {difficulty.itemLevel}</small></FocusButton>)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<FocusButton id="launch" focusedId={controller.focusedId} focus={controller.focus} className="mode-launch" onClick={launch}><span>{launchLabel}</span><small>{mode.status} · A</small></FocusButton>
|
<FocusButton id="launch" focusedId={controller.focusedId} focus={controller.focus} className="mode-launch" onClick={launch}><span>{launchLabel}</span><small>{mode.status} · {DEFAULT_CONTROLLER_GLYPHS.confirm}</small></FocusButton>
|
||||||
{message && <div className="front-notice">{message}</div>}
|
{message && <div className="front-notice">{message}</div>}
|
||||||
</FrontSurface>
|
</FrontSurface>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
+538
-359
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
|||||||
import { bossRoomFor } from "../game/bossRooms";
|
import { bossRoomFor } from "../game/bossRooms";
|
||||||
import { tankAuraProtects } from "../game/partyCombat";
|
import { tankAuraProtects } from "../game/partyCombat";
|
||||||
import { BuffDraftPanel } from "./BuffDraftPanel";
|
import { BuffDraftPanel } from "./BuffDraftPanel";
|
||||||
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
|
|
||||||
const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })));
|
const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })));
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ function BossBar() {
|
|||||||
if (phase === "briefing") return null;
|
if (phase === "briefing") return null;
|
||||||
const bosses = [boss, ...additionalBosses.map((entry) => entry.boss)];
|
const bosses = [boss, ...additionalBosses.map((entry) => entry.boss)];
|
||||||
return (
|
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}>
|
{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-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>
|
<div className="boss-bar"><i style={{ width: `${(entry.hp / entry.maxHp) * 100}%` }} /></div>
|
||||||
@@ -108,6 +109,10 @@ function EncounterCallout() {
|
|||||||
|
|
||||||
function PhaseOverlay() {
|
function PhaseOverlay() {
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
|
const runMode = useGameStore((state) => state.runMode);
|
||||||
|
const round = useGameStore((state) => state.round);
|
||||||
|
const endlessMode = useGameStore((state) => state.endlessMode);
|
||||||
|
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
|
||||||
const primaryBoss = useGameStore((state) => state.boss);
|
const primaryBoss = useGameStore((state) => state.boss);
|
||||||
const additionalBosses = useGameStore((state) => state.additionalBosses);
|
const additionalBosses = useGameStore((state) => state.additionalBosses);
|
||||||
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
|
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
|
||||||
@@ -115,29 +120,34 @@ function PhaseOverlay() {
|
|||||||
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
|
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
|
||||||
const room = bossRoomFor(primaryBoss.id);
|
const room = bossRoomFor(primaryBoss.id);
|
||||||
const bossNames = bosses.map((boss) => boss.name).join(" & ");
|
const bossNames = bosses.map((boss) => boss.name).join(" & ");
|
||||||
|
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
|
||||||
|
const endlessDefeat = phase === "defeat" && endlessMode;
|
||||||
|
const briefingMode = runMode === "rogue-trials"
|
||||||
|
? 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;
|
if (phase === "combat") return null;
|
||||||
const title = phase === "briefing"
|
const title = phase === "briefing"
|
||||||
? room.name
|
? room.name
|
||||||
: phase === "victory"
|
: phase === "victory"
|
||||||
? `${bossNames} Broken`
|
? showEndlessChoice ? "Rogue Trials Cleared" : `${bossNames} Broken`
|
||||||
: "Party Broken";
|
: endlessDefeat ? "Endless Run Ended" : "Party Broken";
|
||||||
const eyebrow = phase === "briefing"
|
const eyebrow = phase === "briefing"
|
||||||
? `${bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial} · ${room.biome}`
|
? `${briefingMode} · ${room.biome}`
|
||||||
: phase === "victory"
|
: phase === "victory"
|
||||||
? "Encounter Complete"
|
? showEndlessChoice ? "Endless Path Unlocked" : "Encounter Complete"
|
||||||
: "Encounter Failed";
|
: endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed";
|
||||||
const copy = phase === "briefing"
|
const copy = phase === "briefing"
|
||||||
? definitions.map((boss) => boss.briefing).join(" ")
|
? definitions.map((boss) => boss.briefing).join(" ")
|
||||||
: phase === "victory"
|
: phase === "victory"
|
||||||
? "Five entered. Five endured."
|
? showEndlessChoice ? "Leave with the clear, or continue against an unbroken chain of replacement bosses." : "Five entered. Five endured."
|
||||||
: definitions.map((boss) => boss.failure).join(" ");
|
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
|
||||||
return (
|
return (
|
||||||
<div className={`phase-overlay phase-${phase}`}>
|
<div className={`phase-overlay phase-${phase}`}>
|
||||||
<div className="phase-sigil">✦</div>
|
<div className="phase-sigil">✦</div>
|
||||||
<span>{eyebrow}</span>
|
<span>{eyebrow}</span>
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<p>{copy}</p>
|
<p>{copy}</p>
|
||||||
<small>{phase === "briefing" ? "Begin from lower display" : "Restart from lower display"}</small>
|
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : "Restart from lower display"}</small>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -164,15 +174,15 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) {
|
|||||||
onFocus={() => setPauseSelection("resume")}
|
onFocus={() => setPauseSelection("resume")}
|
||||||
onPointerEnter={() => setPauseSelection("resume")}
|
onPointerEnter={() => setPauseSelection("resume")}
|
||||||
onClick={() => setPaused(false)}
|
onClick={() => setPaused(false)}
|
||||||
>Resume <small>START / ESC</small></button>
|
>Resume <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>
|
||||||
<button
|
<button
|
||||||
className={`secondary ${selection === "exit" ? "is-controller-focused" : ""}`}
|
className={`secondary ${selection === "exit" ? "is-controller-focused" : ""}`}
|
||||||
onFocus={() => setPauseSelection("exit")}
|
onFocus={() => setPauseSelection("exit")}
|
||||||
onPointerEnter={() => setPauseSelection("exit")}
|
onPointerEnter={() => setPauseSelection("exit")}
|
||||||
onClick={exit}
|
onClick={exit}
|
||||||
>Return to main menu <small>A</small></button>
|
>Return to main menu <small>{DEFAULT_CONTROLLER_GLYPHS.confirm}</small></button>
|
||||||
</div>
|
</div>
|
||||||
<footer><b>↑ / ↓</b> Choose <i /> <b>A / ENTER</b> Confirm</footer>
|
<footer><b>↑ / ↓</b> Choose <i /> <b>{DEFAULT_CONTROLLER_GLYPHS.confirm} / ENTER</b> Confirm</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -183,6 +193,8 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
|
|||||||
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
|
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
|
||||||
const round = useGameStore((state) => state.round);
|
const round = useGameStore((state) => state.round);
|
||||||
const runMode = useGameStore((state) => state.runMode);
|
const runMode = useGameStore((state) => state.runMode);
|
||||||
|
const endlessMode = useGameStore((state) => state.endlessMode);
|
||||||
|
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
|
||||||
const setPaused = useGameStore((state) => state.setPaused);
|
const setPaused = useGameStore((state) => state.setPaused);
|
||||||
return (
|
return (
|
||||||
<section className="display top-display" aria-label="Main game viewport">
|
<section className="display top-display" aria-label="Main game viewport">
|
||||||
@@ -193,11 +205,11 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
|
|||||||
<div className="top-hud">
|
<div className="top-hud">
|
||||||
<CompactParty />
|
<CompactParty />
|
||||||
<BossBar />
|
<BossBar />
|
||||||
<div className="objective-chip"><span>{runMode === "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 />
|
<EncounterCallout />
|
||||||
<CastingBar />
|
<CastingBar />
|
||||||
<div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>1–6</b> Cast</div>
|
<div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>1–6</b> Cast</div>
|
||||||
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b>☰</b> Menu <small>START / ESC</small></button>}
|
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b>☰</b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
|
||||||
</div>
|
</div>
|
||||||
<PhaseOverlay />
|
<PhaseOverlay />
|
||||||
<PauseOverlay onExit={onExit} />
|
<PauseOverlay onExit={onExit} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
import { useFrame } from "@react-three/fiber";
|
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 * as THREE from "three";
|
||||||
import { BULL_CHARGE, BULL_POUNCE } from "../../game/bossMechanics";
|
import { BULL_CHARGE, BULL_POUNCE, MEMORY_SEQUENCE, MEMORY_SYMBOLS, SKY_SWEEPER_BREATH } from "../../game/bosses/mechanicPool";
|
||||||
import { MEMORY_SEQUENCE, MEMORY_SYMBOLS } from "../../game/bosses/mechanicPool";
|
import { bossHazardVfxProfile } from "../../game/bossHazardVisuals";
|
||||||
import { SKY_SWEEPER_BREATH } from "../../game/bosses/skySweeper";
|
import { angleTo } from "../../game/geometry";
|
||||||
import { useGameStore } from "../../game/store";
|
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 CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
|
||||||
const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
|
const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
|
||||||
const EMPTY_HAZARDS: never[] = [];
|
const EMPTY_HAZARDS: never[] = [];
|
||||||
const EMPTY_SLASH_LANES: never[] = [];
|
const EMPTY_SLASH_LANES: never[] = [];
|
||||||
const EMPTY_POOL_TELEGRAPHS: 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_WARNING_COLOR = "#ff3b30";
|
||||||
const DANGER_ACTIVE_COLOR = "#d4142a";
|
const DANGER_ACTIVE_COLOR = "#d4142a";
|
||||||
const DANGER_HIGHLIGHT_COLOR = "#ff8a80";
|
const DANGER_HIGHLIGHT_COLOR = "#ff8a80";
|
||||||
@@ -27,12 +31,95 @@ const INWARD_ARROW_SHAPE = new THREE.Shape()
|
|||||||
.lineTo(-0.29, 0.04)
|
.lineTo(-0.29, 0.04)
|
||||||
.lineTo(-0.13, 0.04)
|
.lineTo(-0.13, 0.04)
|
||||||
.lineTo(-0.13, -0.32);
|
.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>;
|
type GameStoreState = ReturnType<typeof useGameStore.getState>;
|
||||||
|
|
||||||
function motionAt(state: GameStoreState, bossIndex: number) {
|
function motionAt(state: GameStoreState, bossIndex: number) {
|
||||||
return bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion;
|
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 }) {
|
export function ChargeLaneIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
const motionMode = useGameStore((state) => motionAt(state, bossIndex)?.mode);
|
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,
|
(lane.start[1] + lane.end[1]) * 0.5,
|
||||||
];
|
];
|
||||||
const active = ACTIVE_LANE_MODES.has(motion.mode);
|
const active = ACTIVE_LANE_MODES.has(motion.mode);
|
||||||
|
const chargeEffect = motion.mode === "telegraph" || motion.mode === "charging";
|
||||||
const color = active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
const color = active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
||||||
return (
|
return (
|
||||||
<group position={midpoint} rotation={[0, angle, 0]}>
|
<>
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
<group position={midpoint} rotation={[0, angle, 0]}>
|
||||||
<planeGeometry args={[lane.width, length]} />
|
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
<meshBasicMaterial ref={material} color={color} transparent opacity={0.22} depthWrite={false} />
|
<planeGeometry args={[lane.width, length]} />
|
||||||
</mesh>
|
<meshBasicMaterial ref={material} color={color} transparent opacity={0.22} depthWrite={false} />
|
||||||
{[-1, 1].map((side) => (
|
|
||||||
<mesh key={side} position={[side * lane.width * 0.5, 0.018, 0]}>
|
|
||||||
<boxGeometry args={[0.07, 0.03, length]} />
|
|
||||||
<meshBasicMaterial ref={side < 0 ? edgeMaterial : undefined} color={color} transparent opacity={0.8} depthWrite={false} />
|
|
||||||
</mesh>
|
</mesh>
|
||||||
))}
|
{[-1, 1].map((side) => (
|
||||||
{active && (
|
<mesh key={side} position={[side * lane.width * 0.5, 0.018, 0]}>
|
||||||
<mesh position={[0, 0.055, 0]}>
|
<boxGeometry args={[0.07, 0.03, length]} />
|
||||||
<boxGeometry args={[0.16, 0.08, length]} />
|
<meshBasicMaterial ref={side < 0 ? edgeMaterial : undefined} color={color} transparent opacity={0.8} depthWrite={false} />
|
||||||
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.92} depthWrite={false} />
|
</mesh>
|
||||||
</mesh>
|
))}
|
||||||
)}
|
{active && (
|
||||||
</group>
|
<mesh position={[0, 0.055, 0]}>
|
||||||
|
<boxGeometry args={[0.16, 0.08, length]} />
|
||||||
|
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.92} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
<LaneEnergyVfx
|
||||||
|
start={lane.start}
|
||||||
|
end={lane.end}
|
||||||
|
width={lane.width}
|
||||||
|
color={active ? DANGER_HIGHLIGHT_COLOR : color}
|
||||||
|
active={active}
|
||||||
|
variant={chargeEffect ? "charge" : "slash"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,9 +250,10 @@ export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number })
|
|||||||
if (!warningMaterial.current) return;
|
if (!warningMaterial.current) return;
|
||||||
warningMaterial.current.opacity = 0.14 + (Math.sin(clock.elapsedTime * 8) + 1) * 0.08;
|
warningMaterial.current.opacity = 0.14 + (Math.sin(clock.elapsedTime * 8) + 1) * 0.08;
|
||||||
});
|
});
|
||||||
if (phase !== "combat" || motionMode !== "stacking") return null;
|
if (phase !== "combat" || (motionMode !== "stacking" && motionMode !== "pouncing")) return null;
|
||||||
|
|
||||||
const radius = BULL_POUNCE.stackRadius;
|
const radius = BULL_POUNCE.stackRadius;
|
||||||
|
const active = motionMode === "pouncing";
|
||||||
return (
|
return (
|
||||||
<group ref={group}>
|
<group ref={group}>
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
@@ -181,6 +280,7 @@ export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number })
|
|||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
))}
|
))}
|
||||||
|
<CircleMechanicVfx radius={radius} kind="pounce" color={active ? "#ffe0a8" : "#ff7278"} active={active} />
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -211,6 +311,7 @@ export function BindingWebIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
|||||||
<meshBasicMaterial color={color} transparent opacity={0.92} depthWrite={false} />
|
<meshBasicMaterial color={color} transparent opacity={0.92} depthWrite={false} />
|
||||||
</mesh>
|
</mesh>
|
||||||
))}
|
))}
|
||||||
|
<LaneEnergyVfx start={first} end={second} width={0.34} color={color} active={stretched} variant="web" height={0.2} />
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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;
|
if (!motion || phase !== "combat" || (motion.mode !== "breath_telegraph" && motion.mode !== "breath_sweeping")) return null;
|
||||||
const color = motion.mode === "breath_sweeping" ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
const color = motion.mode === "breath_sweeping" ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
||||||
return (
|
return (
|
||||||
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
|
<>
|
||||||
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - SKY_SWEEPER_BREATH.halfAngle]}>
|
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
|
||||||
<circleGeometry args={[SKY_SWEEPER_BREATH.range, 64, 0, SKY_SWEEPER_BREATH.halfAngle * 2]} />
|
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - SKY_SWEEPER_BREATH.halfAngle]}>
|
||||||
<meshBasicMaterial ref={material} color={color} transparent opacity={0.25} depthWrite={false} />
|
<circleGeometry args={[SKY_SWEEPER_BREATH.range, 64, 0, SKY_SWEEPER_BREATH.halfAngle * 2]} />
|
||||||
</mesh>
|
<meshBasicMaterial ref={material} color={color} transparent opacity={0.25} depthWrite={false} />
|
||||||
<mesh position={[0, 0.02, SKY_SWEEPER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
|
</mesh>
|
||||||
<ringGeometry args={[SKY_SWEEPER_BREATH.range * 0.47, SKY_SWEEPER_BREATH.range * 0.49, 48, 1, -SKY_SWEEPER_BREATH.halfAngle, SKY_SWEEPER_BREATH.halfAngle * 2]} />
|
<mesh position={[0, 0.02, SKY_SWEEPER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
|
<ringGeometry args={[SKY_SWEEPER_BREATH.range * 0.47, SKY_SWEEPER_BREATH.range * 0.49, 48, 1, -SKY_SWEEPER_BREATH.halfAngle, SKY_SWEEPER_BREATH.halfAngle * 2]} />
|
||||||
</mesh>
|
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
|
||||||
</group>
|
</mesh>
|
||||||
|
</group>
|
||||||
|
<BreathParticleVfx
|
||||||
|
position={motion.position}
|
||||||
|
angle={motion.breathAngle}
|
||||||
|
range={SKY_SWEEPER_BREATH.range}
|
||||||
|
halfAngle={SKY_SWEEPER_BREATH.halfAngle}
|
||||||
|
active={motion.mode === "breath_sweeping"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,19 +358,8 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss
|
|||||||
});
|
});
|
||||||
if (!hazard) return null;
|
if (!hazard) return null;
|
||||||
const active = time >= hazard.activatesAt;
|
const active = time >= hazard.activatesAt;
|
||||||
const colors = {
|
const visual = bossHazardVfxProfile(hazard.kind);
|
||||||
venom_pool: "#a94ee6",
|
const color = active ? visual.activeColor : visual.warningColor;
|
||||||
skyfall: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
|
|
||||||
quake: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
|
|
||||||
lava_pool: "#ff5a24",
|
|
||||||
stinger_eruption: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
|
|
||||||
hourglass: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
|
|
||||||
tidal_burst: active ? DANGER_ACTIVE_COLOR : "#39c9df",
|
|
||||||
soul_rift: active ? "#7446d8" : "#aa78ff",
|
|
||||||
crownfall: active ? DANGER_ACTIVE_COLOR : "#e4c548",
|
|
||||||
royal_shockwave: active ? DANGER_ACTIVE_COLOR : "#f0ca4d",
|
|
||||||
} as const;
|
|
||||||
const color = colors[hazard.kind];
|
|
||||||
return (
|
return (
|
||||||
<group position={[hazard.center[0], 0.065, hazard.center[1]]}>
|
<group position={[hazard.center[0], 0.065, hazard.center[1]]}>
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
@@ -278,9 +377,10 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss
|
|||||||
{!active && (
|
{!active && (
|
||||||
<mesh position={[0, 0.03, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
<mesh position={[0, 0.03, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
<ringGeometry args={[0.22, 0.34, 24]} />
|
<ringGeometry args={[0.22, 0.34, 24]} />
|
||||||
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.95} depthWrite={false} />
|
<meshBasicMaterial color={visual.highlightColor} transparent opacity={0.95} depthWrite={false} />
|
||||||
</mesh>
|
</mesh>
|
||||||
)}
|
)}
|
||||||
|
<CircleHazardVfx hazard={hazard} active={active} />
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -290,7 +390,7 @@ export function CircleHazardIndicators({ bossIndex = 0 }: { bossIndex?: number }
|
|||||||
return <>{hazards.map((hazard) => <CircleHazardIndicator key={hazard.id} hazardId={hazard.id} bossIndex={bossIndex} />)}</>;
|
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 }) {
|
export function SoulSiphonIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
const motion = useGameStore((state) => motionAt(state, bossIndex));
|
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 color = MEMORY_SYMBOLS[tile.symbol].color;
|
||||||
const halfSize = MEMORY_SEQUENCE.tileSize * 0.5;
|
const halfSize = MEMORY_SEQUENCE.tileSize * 0.5;
|
||||||
const gridOffsets = [-0.48, 0, 0.48] as const;
|
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 (
|
return (
|
||||||
<group position={[tile.center[0], 0.075, tile.center[1]]}>
|
<group ref={group} position={[tile.center[0], 0.075, tile.center[1]]}>
|
||||||
<mesh>
|
<mesh>
|
||||||
<boxGeometry args={[MEMORY_SEQUENCE.tileSize, 0.04, MEMORY_SEQUENCE.tileSize]} />
|
<boxGeometry args={[MEMORY_SEQUENCE.tileSize, 0.04, MEMORY_SEQUENCE.tileSize]} />
|
||||||
<meshBasicMaterial color={color} transparent opacity={inputActive ? 0.28 : 0.16} depthWrite={false} />
|
<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 flashSymbol = telegraph.sequence[flashIndex];
|
||||||
const source = bossPosition ?? telegraph.center;
|
const source = bossPosition ?? telegraph.center;
|
||||||
|
const completedCount = showingSequence ? 0 : telegraph.inputIndex ?? 0;
|
||||||
return (
|
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 && (
|
{showingSequence && (
|
||||||
<group position={[source[0], 2.5, source[1]]}>
|
<Html position={[source[0], 3.1, source[1] + 0.3]} center zIndexRange={[30, 20]} style={{ pointerEvents: "none" }}>
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
<div style={{ display: "grid", justifyItems: "center", gap: 7 }}>
|
||||||
<circleGeometry args={[0.88, 32]} />
|
<div
|
||||||
<meshBasicMaterial color="#111827" transparent opacity={0.9} depthWrite={false} />
|
style={{
|
||||||
</mesh>
|
width: 64,
|
||||||
<mesh position={[0, 0.015, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
height: 64,
|
||||||
<ringGeometry args={[0.8, 0.9, 32]} />
|
display: "grid",
|
||||||
<meshBasicMaterial color={MEMORY_SYMBOLS[flashSymbol].color} transparent opacity={1} depthWrite={false} />
|
placeItems: "center",
|
||||||
</mesh>
|
borderRadius: "50%",
|
||||||
<MemorySymbolMark symbol={flashSymbol} size={1.05} />
|
border: `4px solid ${MEMORY_SYMBOLS[flashSymbol].color}`,
|
||||||
</group>
|
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"
|
: telegraph.kind === "spread" ? "#e869ff"
|
||||||
: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
||||||
|
|
||||||
if (telegraph.kind === "beam" && telegraph.start && telegraph.end) {
|
if (telegraph.kind === "beam") {
|
||||||
|
if (!telegraph.start || !telegraph.end) return null;
|
||||||
const dx = telegraph.end[0] - telegraph.start[0];
|
const dx = telegraph.end[0] - telegraph.start[0];
|
||||||
const dz = telegraph.end[1] - telegraph.start[1];
|
const dz = telegraph.end[1] - telegraph.start[1];
|
||||||
const length = Math.hypot(dx, dz);
|
const length = Math.hypot(dx, dz);
|
||||||
const angle = Math.atan2(dx, dz);
|
const angle = Math.atan2(dx, dz);
|
||||||
return (
|
return (
|
||||||
<group position={[(telegraph.start[0] + telegraph.end[0]) * 0.5, 0.07, (telegraph.start[1] + telegraph.end[1]) * 0.5]} rotation={[0, angle, 0]}>
|
<>
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
<group position={[(telegraph.start[0] + telegraph.end[0]) * 0.5, 0.07, (telegraph.start[1] + telegraph.end[1]) * 0.5]} rotation={[0, angle, 0]}>
|
||||||
<planeGeometry args={[telegraph.width ?? 1, length]} />
|
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
|
<planeGeometry args={[telegraph.width ?? 1, length]} />
|
||||||
</mesh>
|
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
|
||||||
{[-1, 1].map((side) => (
|
|
||||||
<mesh key={side} position={[side * (telegraph.width ?? 1) * 0.5, 0.022, 0]}>
|
|
||||||
<boxGeometry args={[0.06, 0.035, length]} />
|
|
||||||
<meshBasicMaterial color={color} transparent opacity={0.95} depthWrite={false} />
|
|
||||||
</mesh>
|
</mesh>
|
||||||
))}
|
{[-1, 1].map((side) => (
|
||||||
{CHARGE_MARKERS.map((index) => (
|
<mesh key={side} position={[side * (telegraph.width ?? 1) * 0.5, 0.022, 0]}>
|
||||||
<mesh key={index} position={[0, 0.034, -length * 0.44 + (index / 6) * length * 0.88]}>
|
<boxGeometry args={[0.06, 0.035, length]} />
|
||||||
<boxGeometry args={[(telegraph.width ?? 1) * 0.56, 0.035, 0.13]} />
|
<meshBasicMaterial color={color} transparent opacity={0.95} depthWrite={false} />
|
||||||
<meshBasicMaterial color="#ffe1b3" transparent opacity={active ? 1 : 0.7} />
|
</mesh>
|
||||||
</mesh>
|
))}
|
||||||
))}
|
{CHARGE_MARKERS.map((index) => (
|
||||||
</group>
|
<mesh key={index} position={[0, 0.034, -length * 0.44 + (index / 6) * length * 0.88]}>
|
||||||
|
<boxGeometry args={[(telegraph.width ?? 1) * 0.56, 0.035, 0.13]} />
|
||||||
|
<meshBasicMaterial color="#ffe1b3" transparent opacity={active ? 1 : 0.7} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
<LaneEnergyVfx
|
||||||
|
start={telegraph.start}
|
||||||
|
end={telegraph.end}
|
||||||
|
width={telegraph.width ?? 1}
|
||||||
|
color={active ? "#ffe1b3" : color}
|
||||||
|
active={active}
|
||||||
|
variant="beam"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,6 +736,13 @@ function PooledTelegraphIndicator({ telegraphId, bossIndex }: { telegraphId: str
|
|||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
))}
|
))}
|
||||||
|
<CircleMechanicVfx
|
||||||
|
radius={telegraph.radius}
|
||||||
|
innerRadius={innerRadius}
|
||||||
|
kind={telegraph.kind}
|
||||||
|
color={color}
|
||||||
|
active={active}
|
||||||
|
/>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -558,7 +752,60 @@ export function PooledMechanicIndicators({ bossIndex = 0 }: { bossIndex?: number
|
|||||||
return <>{telegraphs.map((telegraph) => <PooledTelegraphIndicator key={telegraph.id} telegraphId={telegraph.id} bossIndex={bossIndex} />)}</>;
|
return <>{telegraphs.map((telegraph) => <PooledTelegraphIndicator key={telegraph.id} telegraphId={telegraph.id} bossIndex={bossIndex} />)}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BasicMeleeVfx({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||||
|
const group = useRef<THREE.Group>(null);
|
||||||
|
const slashMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const impactMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const initialBoss = bossAt(useGameStore.getState(), bossIndex);
|
||||||
|
const previousNextMeleeAt = useRef(initialBoss?.nextMeleeAt ?? 0);
|
||||||
|
const age = useRef(Number.POSITIVE_INFINITY);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
const state = useGameStore.getState();
|
||||||
|
const boss = bossAt(state, bossIndex);
|
||||||
|
const motion = motionAt(state, bossIndex);
|
||||||
|
if (!group.current || !boss || !motion) return;
|
||||||
|
if (boss.nextMeleeAt !== previousNextMeleeAt.current) {
|
||||||
|
if (state.phase === "combat" && motion.mode === "holding") age.current = 0;
|
||||||
|
previousNextMeleeAt.current = boss.nextMeleeAt;
|
||||||
|
}
|
||||||
|
age.current += delta;
|
||||||
|
const visible = state.phase === "combat" && age.current < 0.42 && state.party[1].hp > 0;
|
||||||
|
group.current.visible = visible;
|
||||||
|
if (!visible) return;
|
||||||
|
const target = state.partyPositions.brann;
|
||||||
|
const progress = THREE.MathUtils.clamp(age.current / 0.42, 0, 1);
|
||||||
|
group.current.position.set(target[0], 1.02, target[1]);
|
||||||
|
group.current.rotation.y = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
|
||||||
|
group.current.scale.setScalar(0.68 + progress * 0.72);
|
||||||
|
if (slashMaterial.current) slashMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.92;
|
||||||
|
if (impactMaterial.current) impactMaterial.current.opacity = (1 - progress) * 0.68;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={group} visible={false}>
|
||||||
|
<mesh rotation={[0, 0, -Math.PI * 0.72]}>
|
||||||
|
<torusGeometry args={[0.7, 0.055, 6, 28, Math.PI * 1.35]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
ref={slashMaterial}
|
||||||
|
color="#ffd58a"
|
||||||
|
transparent
|
||||||
|
opacity={0}
|
||||||
|
depthWrite={false}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
toneMapped={false}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, -0.94, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<ringGeometry args={[0.32, 0.5, 24]} />
|
||||||
|
<meshBasicMaterial ref={impactMaterial} color="#ff8d5c" transparent opacity={0} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
|
const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
|
||||||
|
BasicMeleeVfx,
|
||||||
ChargeLaneIndicator,
|
ChargeLaneIndicator,
|
||||||
SlashLaneIndicators,
|
SlashLaneIndicators,
|
||||||
PounceStackIndicator,
|
PounceStackIndicator,
|
||||||
@@ -569,14 +816,63 @@ const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[]
|
|||||||
PooledMechanicIndicators,
|
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() {
|
export function BossMechanicIndicators() {
|
||||||
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
|
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<SoulSiphonGuidanceIndicator />
|
||||||
{Array.from({ length: bossCount }, (_, bossIndex) => (
|
{Array.from({ length: bossCount }, (_, bossIndex) => (
|
||||||
<group key={bossIndex}>
|
<BossMechanicIndicatorSet key={bossIndex} bossIndex={bossIndex} />
|
||||||
{BOSS_MECHANIC_INDICATORS.map((Indicator) => <Indicator key={Indicator.name} bossIndex={bossIndex} />)}
|
|
||||||
</group>
|
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import { useMemo, useRef } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { bossHazardVfxProfile, bossHazardVfxSeed, type BossHazardVfxShape } from "../../game/bossHazardVisuals";
|
||||||
|
import { useGameStore } from "../../game/store";
|
||||||
|
import type { CircleHazard } from "../../game/types";
|
||||||
|
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
|
||||||
|
function ParticleGeometry({ shape }: { shape: BossHazardVfxShape }) {
|
||||||
|
if (shape === "orb") return <sphereGeometry args={[0.16, 8, 6]} />;
|
||||||
|
if (shape === "spike") return <coneGeometry args={[0.16, 0.9, 5]} />;
|
||||||
|
return <octahedronGeometry args={[0.19, 0]} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One instanced particle draw plus one transient accent and an optional column.
|
||||||
|
* Effects mutate preallocated transforms and never touch combat state.
|
||||||
|
*/
|
||||||
|
export function CircleHazardVfx({ hazard, active }: { hazard: CircleHazard; active: boolean }) {
|
||||||
|
const profile = bossHazardVfxProfile(hazard.kind);
|
||||||
|
const particles = useRef<THREE.InstancedMesh>(null);
|
||||||
|
const particleMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const warningRing = useRef<THREE.Mesh>(null);
|
||||||
|
const warningMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const impactRing = useRef<THREE.Mesh>(null);
|
||||||
|
const impactMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const column = useRef<THREE.Mesh>(null);
|
||||||
|
const columnMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const transform = useMemo(() => new THREE.Object3D(), []);
|
||||||
|
const seed = useMemo(() => bossHazardVfxSeed(hazard.id), [hazard.id]);
|
||||||
|
const reducedMotion = useMemo(() => (
|
||||||
|
document.documentElement.classList.contains("force-reduced-motion")
|
||||||
|
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
), []);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
const time = useGameStore.getState().time;
|
||||||
|
const activeAge = Math.max(0, time - hazard.activatesAt);
|
||||||
|
const warningRemaining = Math.max(0, hazard.activatesAt - time);
|
||||||
|
const effectTime = reducedMotion ? seed * 2 : clock.elapsedTime + seed * 7.3;
|
||||||
|
const activeDuration = Math.max(0.22, hazard.expiresAt - hazard.activatesAt);
|
||||||
|
const impactDuration = Math.min(0.48, activeDuration);
|
||||||
|
const impactProgress = THREE.MathUtils.clamp(activeAge / impactDuration, 0, 1);
|
||||||
|
|
||||||
|
if (particles.current) {
|
||||||
|
for (let index = 0; index < profile.particleCount; index += 1) {
|
||||||
|
const offset = (index + 0.5) / profile.particleCount;
|
||||||
|
const baseAngle = offset * TAU + seed * TAU;
|
||||||
|
const angle = baseAngle + effectTime * profile.spinSpeed;
|
||||||
|
const stagger = (offset + seed * 0.37) % 1;
|
||||||
|
let radial = hazard.radius * (0.28 + (index % 3) * 0.2);
|
||||||
|
let y = 0.12;
|
||||||
|
let width = 0.72;
|
||||||
|
let height = 0.72;
|
||||||
|
|
||||||
|
if (profile.motion === "bubble") {
|
||||||
|
const cycle = (effectTime * 0.48 + stagger) % 1;
|
||||||
|
radial = hazard.radius * (0.22 + (index % 3) * 0.22);
|
||||||
|
y = 0.08 + cycle * profile.height;
|
||||||
|
width = 0.55 + Math.sin(cycle * Math.PI) * 0.65;
|
||||||
|
height = width;
|
||||||
|
} else if (profile.motion === "fall") {
|
||||||
|
const cycle = 1 - ((effectTime * 0.72 + stagger) % 1);
|
||||||
|
radial = hazard.radius * (0.18 + (index % 3) * 0.2);
|
||||||
|
y = 0.22 + cycle * profile.height;
|
||||||
|
width = active ? 0.82 : 0.55;
|
||||||
|
height = active ? 2.9 : 2.15;
|
||||||
|
} else if (profile.motion === "flame") {
|
||||||
|
const cycle = (effectTime * 0.95 + stagger) % 1;
|
||||||
|
radial = hazard.radius * (0.2 + (index % 3) * 0.21);
|
||||||
|
y = 0.12 + cycle * profile.height * 0.62;
|
||||||
|
width = 0.5 + Math.sin(cycle * Math.PI) * 0.85;
|
||||||
|
height = 0.75 + Math.sin(cycle * Math.PI) * 1.6;
|
||||||
|
} else if (profile.motion === "orbit") {
|
||||||
|
radial = hazard.radius * (0.3 + (index % 2) * 0.24);
|
||||||
|
y = 0.28 + ((index % 4) / 3) * profile.height + Math.sin(effectTime * 2.2 + index) * 0.16;
|
||||||
|
width = 0.65 + (index % 2) * 0.35;
|
||||||
|
height = profile.shape === "crystal" ? 1.55 : width;
|
||||||
|
} else if (profile.motion === "shockwave") {
|
||||||
|
const wave = active ? impactProgress : 0.22 + (1 - Math.min(1, warningRemaining / 1.6)) * 0.18;
|
||||||
|
radial = hazard.radius * wave;
|
||||||
|
y = 0.13 + Math.sin(offset * Math.PI) * profile.height * 0.12;
|
||||||
|
width = active ? 0.72 + impactProgress * 0.75 : 0.58;
|
||||||
|
height = active ? 1.25 : 0.75;
|
||||||
|
} else if (profile.motion === "spike") {
|
||||||
|
const rise = active ? THREE.MathUtils.clamp(activeAge / 0.16, 0, 1) : 0.12;
|
||||||
|
radial = hazard.radius * (0.2 + (index % 3) * 0.23);
|
||||||
|
width = 0.75 + (index % 2) * 0.35;
|
||||||
|
height = Math.max(0.18, rise * (profile.height + (index % 3) * 0.34));
|
||||||
|
y = height * 0.45;
|
||||||
|
} else {
|
||||||
|
const cycle = (effectTime * 0.72 + stagger) % 1;
|
||||||
|
radial = hazard.radius * (0.18 + cycle * 0.68);
|
||||||
|
y = 0.12 + Math.sin(cycle * Math.PI) * profile.height;
|
||||||
|
width = 0.6 + Math.sin(cycle * Math.PI) * 0.72;
|
||||||
|
height = width * 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
const warningScale = active ? 1 : 0.56;
|
||||||
|
transform.position.set(Math.sin(angle) * radial, y, Math.cos(angle) * radial);
|
||||||
|
transform.rotation.set(index * 0.73, -angle, profile.motion === "fall" ? 0 : effectTime * 0.45 + index);
|
||||||
|
transform.scale.set(width * warningScale, height * warningScale, width * warningScale);
|
||||||
|
transform.updateMatrix();
|
||||||
|
particles.current.setMatrixAt(index, transform.matrix);
|
||||||
|
}
|
||||||
|
particles.current.instanceMatrix.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (particleMaterial.current) {
|
||||||
|
const pulse = reducedMotion ? 0.72 : 0.64 + Math.sin(effectTime * 4.6) * 0.12;
|
||||||
|
particleMaterial.current.opacity = active ? pulse + 0.16 : pulse * 0.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warningRing.current && warningMaterial.current) {
|
||||||
|
const countdown = THREE.MathUtils.clamp(warningRemaining / 1.8, 0, 1);
|
||||||
|
const scale = hazard.radius * (0.18 + countdown * 0.82);
|
||||||
|
warningRing.current.visible = !active;
|
||||||
|
warningRing.current.scale.setScalar(scale);
|
||||||
|
warningRing.current.rotation.z = reducedMotion ? seed * TAU : effectTime * profile.spinSpeed * 0.42;
|
||||||
|
warningMaterial.current.opacity = 0.48 + (reducedMotion ? 0 : Math.sin(effectTime * 7) * 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (impactRing.current && impactMaterial.current) {
|
||||||
|
impactRing.current.visible = active && impactProgress < 0.999;
|
||||||
|
impactRing.current.scale.setScalar(hazard.radius * (0.16 + impactProgress * 1.02));
|
||||||
|
impactMaterial.current.opacity = (1 - impactProgress) * 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column.current && columnMaterial.current) {
|
||||||
|
const columnPulse = reducedMotion ? 0.78 : 0.7 + Math.sin(effectTime * 3.8) * 0.18;
|
||||||
|
column.current.rotation.y = effectTime * profile.spinSpeed * 0.3;
|
||||||
|
column.current.scale.set(
|
||||||
|
hazard.radius * (active ? 0.9 : 0.62),
|
||||||
|
profile.height * (active ? 1 : 0.72),
|
||||||
|
hazard.radius * (active ? 0.9 : 0.62),
|
||||||
|
);
|
||||||
|
columnMaterial.current.opacity = profile.columnOpacity * columnPulse * (active ? 1 : 0.52);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const color = active ? profile.activeColor : profile.warningColor;
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<instancedMesh ref={particles} args={[undefined, undefined, profile.particleCount]} frustumCulled={false}>
|
||||||
|
<ParticleGeometry shape={profile.shape} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
ref={particleMaterial}
|
||||||
|
color={color}
|
||||||
|
transparent
|
||||||
|
opacity={active ? 0.8 : 0.35}
|
||||||
|
depthWrite={false}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
toneMapped={false}
|
||||||
|
/>
|
||||||
|
</instancedMesh>
|
||||||
|
<mesh ref={warningRing} position={[0, 0.085, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<torusGeometry args={[1, 0.026, 5, 36]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
ref={warningMaterial}
|
||||||
|
color={profile.highlightColor}
|
||||||
|
transparent
|
||||||
|
opacity={0.58}
|
||||||
|
depthWrite={false}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
toneMapped={false}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
<mesh ref={impactRing} position={[0, 0.12, 0]} rotation={[-Math.PI / 2, 0, 0]} visible={false}>
|
||||||
|
<ringGeometry args={[0.84, 1, 40]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
ref={impactMaterial}
|
||||||
|
color={profile.highlightColor}
|
||||||
|
transparent
|
||||||
|
opacity={0}
|
||||||
|
depthWrite={false}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
toneMapped={false}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
{profile.columnOpacity > 0 && (
|
||||||
|
<mesh ref={column} position={[0, profile.height * 0.5, 0]}>
|
||||||
|
<cylinderGeometry args={[0.48, 0.16, 1, 18, 1, true]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
ref={columnMaterial}
|
||||||
|
color={color}
|
||||||
|
transparent
|
||||||
|
opacity={profile.columnOpacity}
|
||||||
|
depthWrite={false}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
toneMapped={false}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { BOSS_BURROW_CLEARANCE, bossBurrowPositionY, bossIsBurrowing } from "./bossBurrowVisuals";
|
||||||
|
|
||||||
|
describe("boss burrow visuals", () => {
|
||||||
|
it("places the scaled top of the model below the arena floor", () => {
|
||||||
|
const modelTopY = 2.8371768724243784;
|
||||||
|
const modelScale = 0.82;
|
||||||
|
const positionY = bossBurrowPositionY(modelTopY, modelScale);
|
||||||
|
|
||||||
|
expect(positionY + modelTopY * modelScale).toBeCloseTo(-BOSS_BURROW_CLEARANCE, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts sinking during the warning and remains submerged through the charge", () => {
|
||||||
|
expect(bossIsBurrowing("burrow-rush", "telegraph")).toBe(true);
|
||||||
|
expect(bossIsBurrowing("burrow-rush", "charging")).toBe(true);
|
||||||
|
expect(bossIsBurrowing("burrow-rush", "holding")).toBe(false);
|
||||||
|
expect(bossIsBurrowing("bull-charge", "charging")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { BossMechanicId, BossMotionMode } from "../../game/types";
|
||||||
|
|
||||||
|
export const BOSS_BURROW_CLEARANCE = 0.58;
|
||||||
|
|
||||||
|
export function bossBurrowPositionY(
|
||||||
|
modelTopY: number,
|
||||||
|
modelScale: number,
|
||||||
|
clearance = BOSS_BURROW_CLEARANCE,
|
||||||
|
): number {
|
||||||
|
return -(Math.max(0, modelTopY) * Math.max(0, modelScale) + Math.max(0, clearance));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bossIsBurrowing(
|
||||||
|
activeMechanicId: BossMechanicId | null,
|
||||||
|
mode: BossMotionMode,
|
||||||
|
): boolean {
|
||||||
|
return activeMechanicId === "burrow-rush" && (mode === "telegraph" || mode === "charging");
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,53 +1,38 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { StorageAdapter } from "./saveRepository";
|
|
||||||
import { AccountRepository } from "./accountRepository";
|
import { AccountRepository } from "./accountRepository";
|
||||||
|
import { OnlineApiError, type OnlineRepository } from "./onlineRepository";
|
||||||
|
|
||||||
function memoryStorage(): StorageAdapter {
|
function onlineStub(overrides: Partial<OnlineRepository> = {}): OnlineRepository {
|
||||||
const data = new Map<string, string>();
|
|
||||||
return {
|
return {
|
||||||
getItem: (key) => data.get(key) ?? null,
|
register: vi.fn(async (username: string) => ({ id: 1, username })),
|
||||||
setItem: (key, value) => { data.set(key, value); },
|
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", () => {
|
describe("AccountRepository", () => {
|
||||||
it("requires both a username and password", async () => {
|
it("requires both username and password before contacting server", async () => {
|
||||||
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
|
const online = onlineStub();
|
||||||
|
const repository = new AccountRepository(online);
|
||||||
await expect(repository.create("", "secret")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
|
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" });
|
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 () => {
|
it("creates and authenticates real server accounts", async () => {
|
||||||
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
|
const repository = new AccountRepository(onlineStub());
|
||||||
|
await expect(repository.create("Wayfinder", "long-password")).resolves.toEqual({ ok: true, username: "Wayfinder" });
|
||||||
await expect(repository.authenticate("new-healer", "secret")).resolves.toEqual({ ok: false, reason: "account-not-found" });
|
await expect(repository.authenticate("Wayfinder", "long-password")).resolves.toEqual({ ok: true, username: "Wayfinder" });
|
||||||
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("rejects an incorrect password and duplicate account names", async () => {
|
it("maps server conflicts, invalid credentials, and outages", async () => {
|
||||||
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
|
const conflict = new AccountRepository(onlineStub({ register: vi.fn(async () => { throw new OnlineApiError("exists", 409); }) }));
|
||||||
await repository.create("Wayfinder", "correct");
|
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(repository.authenticate("wayfinder", "wrong")).resolves.toEqual({ ok: false, reason: "invalid-password" });
|
await expect(invalid.authenticate("Wayfinder", "wrong-password")).resolves.toMatchObject({ ok: false, reason: "invalid-password" });
|
||||||
await expect(repository.create(" wayfinder ", "another")).resolves.toEqual({ ok: false, reason: "account-exists" });
|
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" });
|
||||||
|
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,132 +1,40 @@
|
|||||||
import type { StorageAdapter } from "./saveRepository";
|
import { OnlineApiError, OnlineRepository, onlineRepository } from "./onlineRepository";
|
||||||
|
|
||||||
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>;
|
|
||||||
|
|
||||||
export type AccountResult =
|
export type AccountResult =
|
||||||
| { ok: true; username: string }
|
| { 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>();
|
function failure(error: unknown): Extract<AccountResult, { ok: false }> {
|
||||||
const fallbackStorage: StorageAdapter = {
|
if (!(error instanceof OnlineApiError)) return { ok: false, reason: "server-unavailable" };
|
||||||
getItem: (key) => fallbackMemory.get(key) ?? null,
|
if (error.status === 0 || error.status >= 500) return { ok: false, reason: "server-unavailable", message: error.message };
|
||||||
setItem: (key, value) => { fallbackMemory.set(key, value); },
|
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 browserStorage(): StorageAdapter {
|
|
||||||
try {
|
|
||||||
if (typeof localStorage !== "undefined") return localStorage;
|
|
||||||
} catch {
|
|
||||||
// Android WebView can deny storage before its host is ready.
|
|
||||||
}
|
|
||||||
return fallbackStorage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
export class AccountRepository {
|
||||||
constructor(
|
constructor(private readonly online: OnlineRepository = onlineRepository) {}
|
||||||
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" };
|
|
||||||
|
|
||||||
|
async create(username: string, password: string): Promise<AccountResult> {
|
||||||
|
if (!username.trim() || !password) return { ok: false, reason: "missing-credentials" };
|
||||||
try {
|
try {
|
||||||
const salt = this.createSalt();
|
const account = await this.online.register(username, password);
|
||||||
accounts[canonical] = { username, salt, passwordHash: await this.hasher(password, salt) };
|
return { ok: true, username: account.username };
|
||||||
this.storage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
|
} catch (error) {
|
||||||
return { ok: true, username };
|
return failure(error);
|
||||||
} catch {
|
|
||||||
return { ok: false, reason: "storage-unavailable" };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async authenticate(usernameInput: string, password: string): Promise<AccountResult> {
|
async authenticate(username: string, password: string): Promise<AccountResult> {
|
||||||
const canonical = canonicalUsername(usernameInput);
|
if (!username.trim() || !password) return { ok: false, reason: "missing-credentials" };
|
||||||
if (!canonical || !password) return { ok: false, reason: "missing-credentials" };
|
|
||||||
|
|
||||||
const account = this.read()[canonical];
|
|
||||||
if (!account) return { ok: false, reason: "account-not-found" };
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const passwordHash = await this.hasher(password, account.salt);
|
const account = await this.online.login(username, password);
|
||||||
return passwordHash === account.passwordHash
|
return { ok: true, username: account.username };
|
||||||
? { ok: true, username: account.username }
|
} catch (error) {
|
||||||
: { ok: false, reason: "invalid-password" };
|
return failure(error);
|
||||||
} catch {
|
|
||||||
return { ok: false, reason: "storage-unavailable" };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private read() {
|
session() { return this.online.session(); }
|
||||||
return parseAccounts(this.storage.getItem(ACCOUNTS_KEY));
|
logout() { return this.online.logout(); }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AVAILABLE_BOSS_IDS, BOSS_GROUPS } from "../game/bossCatalog";
|
|||||||
describe("game mode configuration", () => {
|
describe("game mode configuration", () => {
|
||||||
it("separates randomized PVE from selectable Dungeons", () => {
|
it("separates randomized PVE from selectable Dungeons", () => {
|
||||||
expect(MODE_COPY["roguelike-pve"].title).toBe("PVE");
|
expect(MODE_COPY["roguelike-pve"].title).toBe("PVE");
|
||||||
|
expect(MODE_COPY["rogue-trials"].detail).toContain("Endless");
|
||||||
expect(MODE_COPY.dungeons.title).toBe("Dungeons");
|
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
@@ -70,7 +70,14 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
|
|||||||
title: "PVE",
|
title: "PVE",
|
||||||
description: "Enter without an encounter briefing, adapt to two randomized guardians, and build toward a full roguelike run.",
|
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",
|
detail: "Two bosses selected when the run begins",
|
||||||
status: "Playable prototype",
|
status: "Playable now",
|
||||||
|
},
|
||||||
|
"rogue-trials": {
|
||||||
|
eyebrow: "1–4 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: {
|
dungeons: {
|
||||||
eyebrow: "1–4 hunters · chosen encounter",
|
eyebrow: "1–4 hunters · chosen encounter",
|
||||||
@@ -131,6 +138,8 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
|
|||||||
alliesSaved: 0,
|
alliesSaved: 0,
|
||||||
healingDone: 0,
|
healingDone: 0,
|
||||||
bossKills: {},
|
bossKills: {},
|
||||||
|
highestRoguelikeRound: 0,
|
||||||
|
highestRogueTrialsEndlessKills: 0,
|
||||||
},
|
},
|
||||||
materials: [] as MaterialStack[],
|
materials: [] as MaterialStack[],
|
||||||
collectionLog: createEmptyCollectionLog(),
|
collectionLog: createEmptyCollectionLog(),
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { SaveRepository, type StorageAdapter } from "./saveRepository";
|
import { SaveRepository, type StorageAdapter } from "./saveRepository";
|
||||||
import { groupDrop } from "../game/progression/loot";
|
import { groupDrop } from "../game/progression/loot";
|
||||||
|
import { RUN_BUFF_ORDER } from "../game/roguelike";
|
||||||
|
|
||||||
function memoryStorage(): StorageAdapter {
|
function memoryStorage(): StorageAdapter {
|
||||||
const data = new Map<string, string>();
|
const data = new Map<string, string>();
|
||||||
@@ -15,7 +16,7 @@ describe("SaveRepository", () => {
|
|||||||
const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z");
|
const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z");
|
||||||
repository.create(2, "Seraphine");
|
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.map((slot) => slot.id)).toEqual([1, 2, 3]);
|
||||||
expect(slots[1].local?.updatedAt).toBe("2026-07-10T12:00:00.000Z");
|
expect(slots[1].local?.updatedAt).toBe("2026-07-10T12:00:00.000Z");
|
||||||
expect(slots[1].local?.hunterName).toBe("Seraphine");
|
expect(slots[1].local?.hunterName).toBe("Seraphine");
|
||||||
@@ -33,41 +34,30 @@ describe("SaveRepository", () => {
|
|||||||
healers: { ...save.healers, druid: { ...save.healers.druid, level: 99 } },
|
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[0].local?.healers.druid.level).toBe(1);
|
||||||
expect(slots[2].local?.healers.druid.level).toBe(99);
|
expect(slots[2].local?.healers.druid.level).toBe(99);
|
||||||
expect(slots[2].local?.slotId).toBe(3);
|
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";
|
let now = "2026-07-10T12:00:00.000Z";
|
||||||
const repository = new SaveRepository(memoryStorage(), () => now);
|
const repository = new SaveRepository(memoryStorage(), () => now);
|
||||||
repository.create(1, "Aelia");
|
const serverSave = repository.create(1, "Aelia");
|
||||||
now = "2026-07-10T13:00:00.000Z";
|
serverSave.healers.priest.level = 40;
|
||||||
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);
|
|
||||||
|
|
||||||
now = "2026-07-10T14:00:00.000Z";
|
now = "2026-07-10T14:00:00.000Z";
|
||||||
repository.download(1, "healer@example.com");
|
repository.replaceLocal(serverSave);
|
||||||
expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(1);
|
expect(repository.listLocal()[0].local?.healers.priest.level).toBe(40);
|
||||||
expect(repository.list("healer@example.com")[0].local?.updatedAt).toBe(now);
|
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");
|
const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z");
|
||||||
repository.create(1, "Aelia");
|
repository.create(1, "Aelia");
|
||||||
repository.upload(1, "healer");
|
|
||||||
repository.deleteLocal(1);
|
repository.deleteLocal(1);
|
||||||
|
const slot = repository.listLocal()[0];
|
||||||
const slot = repository.list("healer")[0];
|
|
||||||
expect(slot.local).toBeNull();
|
expect(slot.local).toBeNull();
|
||||||
expect(slot.online).not.toBeNull();
|
expect(slot.online).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps class progression and inventories independent under one hunter name", () => {
|
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.hunterName).toBe("Aelia");
|
||||||
expect(save.activeClassId).toBe("druid");
|
expect(save.activeClassId).toBe("druid");
|
||||||
expect(save.healers.druid.level).toBe(8);
|
expect(save.healers.druid.level).toBe(8);
|
||||||
@@ -101,7 +91,7 @@ describe("SaveRepository", () => {
|
|||||||
activeClassId: "druid",
|
activeClassId: "druid",
|
||||||
playSeconds: 999,
|
playSeconds: 999,
|
||||||
healers: Object.fromEntries(Object.entries(created.healers).map(([id, healer]) => [id, { ...healer, level: 27 }])),
|
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" }],
|
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 } },
|
collectionLog: { dropsFound: { "legacy-boss-coin": 99 }, petsFound: { "bulldrome-pet": 1 } },
|
||||||
gearProgress: Object.fromEntries(Object.entries(created.gearProgress).map(([id, owner]) => [id, {
|
gearProgress: Object.fromEntries(Object.entries(created.gearProgress).map(([id, owner]) => [id, {
|
||||||
@@ -112,39 +102,19 @@ describe("SaveRepository", () => {
|
|||||||
} as Record<string, unknown>;
|
} as Record<string, unknown>;
|
||||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
|
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.schemaVersion).toBe(5);
|
||||||
expect(migrated.hunterName).toBe("Legacy");
|
expect(migrated.hunterName).toBe("Legacy");
|
||||||
expect(migrated.activeClassId).toBe("priest");
|
expect(migrated.activeClassId).toBe("priest");
|
||||||
expect(migrated.playSeconds).toBe(0);
|
expect(migrated.playSeconds).toBe(0);
|
||||||
expect(Object.values(migrated.healers).every((healer) => healer.level === 1 && healer.inventory.length > 0)).toBe(true);
|
expect(Object.values(migrated.healers).every((healer) => healer.level === 1 && healer.inventory.length > 0)).toBe(true);
|
||||||
expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {} });
|
expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {}, highestRoguelikeRound: 0, highestRogueTrialsEndlessKills: 0 });
|
||||||
expect(migrated.materials).toEqual([]);
|
expect(migrated.materials).toEqual([]);
|
||||||
expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} });
|
expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} });
|
||||||
expect(Object.values(migrated.gearProgress).every((owner) => owner.infusionAbilityId === null && owner.passiveInfusionId === null && Object.values(owner.slots).every((slot) => slot.level === 0))).toBe(true);
|
expect(Object.values(migrated.gearProgress).every((owner) => owner.infusionAbilityId === null && owner.passiveInfusionId === null && Object.values(owner.slots).every((slot) => slot.level === 0))).toBe(true);
|
||||||
expect(JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}")["1"].schemaVersion).toBe(5);
|
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", () => {
|
it("preserves valid v5 progression and group-drop inventory", () => {
|
||||||
const storage = memoryStorage();
|
const storage = memoryStorage();
|
||||||
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
||||||
@@ -152,14 +122,16 @@ describe("SaveRepository", () => {
|
|||||||
const drop = groupDrop("charge", "veteran");
|
const drop = groupDrop("charge", "veteran");
|
||||||
created.healers.priest.level = 8;
|
created.healers.priest.level = 8;
|
||||||
created.stats = { ...created.stats, totalBossKills: 2, bossKills: { bulldrome: 2 } };
|
created.stats = { ...created.stats, totalBossKills: 2, bossKills: { bulldrome: 2 } };
|
||||||
|
created.stats.highestRogueTrialsEndlessKills = 14;
|
||||||
created.materials = [{ id: drop.id, name: drop.name, quantity: 4, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }];
|
created.materials = [{ id: drop.id, name: drop.name, quantity: 4, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }];
|
||||||
created.collectionLog = { dropsFound: { [drop.id]: 4 }, petsFound: { "bulldrome-pet": 1 } };
|
created.collectionLog = { dropsFound: { [drop.id]: 4 }, petsFound: { "bulldrome-pet": 1 } };
|
||||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
|
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
|
||||||
|
|
||||||
const migrated = repository.list(null)[0].local!;
|
const migrated = repository.listLocal()[0].local!;
|
||||||
expect(migrated.schemaVersion).toBe(5);
|
expect(migrated.schemaVersion).toBe(5);
|
||||||
expect(migrated.healers.priest.level).toBe(8);
|
expect(migrated.healers.priest.level).toBe(8);
|
||||||
expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 });
|
expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 });
|
||||||
|
expect(migrated.stats.highestRogueTrialsEndlessKills).toBe(14);
|
||||||
expect(migrated.materials[0]).toMatchObject({ id: drop.id, quantity: 4 });
|
expect(migrated.materials[0]).toMatchObject({ id: drop.id, quantity: 4 });
|
||||||
expect(migrated.collectionLog).toEqual(created.collectionLog);
|
expect(migrated.collectionLog).toEqual(created.collectionLog);
|
||||||
});
|
});
|
||||||
@@ -169,16 +141,29 @@ describe("SaveRepository", () => {
|
|||||||
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
||||||
const created = repository.create(1, "Infused");
|
const created = repository.create(1, "Infused");
|
||||||
created.gearProgress.priest.infusionAbilityId = "priest-sanctuary";
|
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.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 }));
|
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.schemaVersion).toBe(5);
|
||||||
expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary");
|
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.infusionAbilityId).toBeNull();
|
||||||
expect(migrated.gearProgress.brann.passiveInfusionId).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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export interface StorageAdapter {
|
|||||||
type SaveMap = Partial<Record<SaveSlotId, HunterSave>>;
|
type SaveMap = Partial<Record<SaveSlotId, HunterSave>>;
|
||||||
|
|
||||||
const LOCAL_KEY = "i-want-to-heal:saves:local:v1";
|
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 SLOT_IDS: SaveSlotId[] = [1, 2, 3];
|
||||||
|
|
||||||
const fallbackMemory = new Map<string, string>();
|
const fallbackMemory = new Map<string, string>();
|
||||||
@@ -150,6 +149,8 @@ function normalizeSave(value: unknown): HunterSave | null {
|
|||||||
alliesSaved: Math.max(0, candidate.stats?.alliesSaved ?? 0),
|
alliesSaved: Math.max(0, candidate.stats?.alliesSaved ?? 0),
|
||||||
healingDone: Math.max(0, candidate.stats?.healingDone ?? 0),
|
healingDone: Math.max(0, candidate.stats?.healingDone ?? 0),
|
||||||
bossKills,
|
bossKills,
|
||||||
|
highestRoguelikeRound: Math.max(0, Math.floor(candidate.stats?.highestRoguelikeRound ?? 0)),
|
||||||
|
highestRogueTrialsEndlessKills: Math.max(0, Math.floor(candidate.stats?.highestRogueTrialsEndlessKills ?? 0)),
|
||||||
},
|
},
|
||||||
materials: normalizeMaterials(candidate.materials, collectionLog),
|
materials: normalizeMaterials(candidate.materials, collectionLog),
|
||||||
collectionLog,
|
collectionLog,
|
||||||
@@ -181,10 +182,9 @@ export class SaveRepository {
|
|||||||
private readonly now: () => string = () => new Date().toISOString(),
|
private readonly now: () => string = () => new Date().toISOString(),
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
list(accountId: string | null): SaveSlotState[] {
|
listLocal(): SaveSlotState[] {
|
||||||
const local = this.read(LOCAL_KEY);
|
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: null }));
|
||||||
return SLOT_IDS.map((id) => ({ id, local: local[id] ?? null, online: online[id] ?? null }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
create(slotId: SaveSlotId, hunterName: string): HunterSave {
|
create(slotId: SaveSlotId, hunterName: string): HunterSave {
|
||||||
@@ -223,23 +223,10 @@ export class SaveRepository {
|
|||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
upload(slotId: SaveSlotId, accountId: string): HunterSave | null {
|
replaceLocal(save: HunterSave): HunterSave {
|
||||||
const local = this.read(LOCAL_KEY)[slotId];
|
const normalized = { ...cloneSave(save), updatedAt: this.now() };
|
||||||
if (!local) return null;
|
this.setLocal(normalized);
|
||||||
const cloud = this.read(CLOUD_KEY(accountId));
|
return normalized;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private setLocal(save: HunterSave): void {
|
private setLocal(save: HunterSave): void {
|
||||||
|
|||||||
+163
-35
@@ -2,20 +2,34 @@ import { create } from "zustand";
|
|||||||
import { DEFAULT_SETTINGS, normalizeHunterName } from "./data";
|
import { DEFAULT_SETTINGS, normalizeHunterName } from "./data";
|
||||||
import { SaveRepository } from "./saveRepository";
|
import { SaveRepository } from "./saveRepository";
|
||||||
import { AccountRepository, type AccountResult } from "./accountRepository";
|
import { AccountRepository, type AccountResult } from "./accountRepository";
|
||||||
|
import { onlineRepository, type OnlineSaveSlot } from "./onlineRepository";
|
||||||
import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
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 { upgradeGearSlot, type GearOwnerId, type GearSlotId } from "../game/progression/gear";
|
||||||
import {
|
import {
|
||||||
equipActiveInfusion,
|
equipActiveInfusion,
|
||||||
equipPassiveInfusion,
|
equipPassiveInfusion,
|
||||||
infusionsForOwner,
|
infusionsForOwner,
|
||||||
} from "../game/progression/infusions";
|
} from "../game/progression/infusions";
|
||||||
import type { RunBuffId } from "../game/types";
|
|
||||||
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
|
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
|
||||||
|
import { highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat } from "../game/progression/hunterStats";
|
||||||
|
|
||||||
const repository = new SaveRepository();
|
const repository = new SaveRepository();
|
||||||
const accounts = new AccountRepository();
|
const accounts = new AccountRepository();
|
||||||
const SETTINGS_KEY = "i-want-to-heal:settings:v1";
|
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 {
|
function loadSettings(): GameSettings {
|
||||||
try {
|
try {
|
||||||
@@ -38,14 +52,30 @@ function accountNotice(result: Extract<AccountResult, { ok: false }>, action: "s
|
|||||||
switch (result.reason) {
|
switch (result.reason) {
|
||||||
case "missing-credentials": return "Enter both username and password.";
|
case "missing-credentials": return "Enter both username and password.";
|
||||||
case "account-exists": return "Account already exists. Sign in with its 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 "invalid-password": return "Username or password is incorrect.";
|
||||||
case "storage-unavailable": return action === "create"
|
case "server-unavailable": return "Online server is unreachable. Continue offline or try again.";
|
||||||
? "Account could not be saved on this device. Continue offline or try again."
|
case "invalid-request": return result.message ?? (action === "create" ? "Account could not be created." : "Sign-in failed.");
|
||||||
: "Account could not be verified on this device. Continue offline or try again.";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
export interface FrontendState {
|
||||||
screen: AppScreen;
|
screen: AppScreen;
|
||||||
accountId: string | null;
|
accountId: string | null;
|
||||||
@@ -59,9 +89,12 @@ export interface FrontendState {
|
|||||||
selectedGearSlotId: GearSlotId;
|
selectedGearSlotId: GearSlotId;
|
||||||
gearWorkshopMode: "upgrade" | "infusion";
|
gearWorkshopMode: "upgrade" | "infusion";
|
||||||
selectedInfusionId: string;
|
selectedInfusionId: string;
|
||||||
|
selectedPassiveAbilityId: AbilityId;
|
||||||
|
selectedPassiveInfusionId: RunBuffId;
|
||||||
recentRewards: BossRewardAward[];
|
recentRewards: BossRewardAward[];
|
||||||
settings: GameSettings;
|
settings: GameSettings;
|
||||||
notice: string;
|
notice: string;
|
||||||
|
restoreSession: () => Promise<boolean>;
|
||||||
signIn: (username: string, password: string) => Promise<boolean>;
|
signIn: (username: string, password: string) => Promise<boolean>;
|
||||||
createAccount: (username: string, password: string) => Promise<boolean>;
|
createAccount: (username: string, password: string) => Promise<boolean>;
|
||||||
continueOffline: () => void;
|
continueOffline: () => void;
|
||||||
@@ -72,8 +105,8 @@ export interface FrontendState {
|
|||||||
playSlot: (slotId: SaveSlotId) => void;
|
playSlot: (slotId: SaveSlotId) => void;
|
||||||
deleteSlot: (slotId: SaveSlotId) => void;
|
deleteSlot: (slotId: SaveSlotId) => void;
|
||||||
copySlot: (sourceId: SaveSlotId, targetId: SaveSlotId) => void;
|
copySlot: (sourceId: SaveSlotId, targetId: SaveSlotId) => void;
|
||||||
uploadSlot: (slotId: SaveSlotId) => void;
|
uploadSlot: (slotId: SaveSlotId) => Promise<void>;
|
||||||
downloadSlot: (slotId: SaveSlotId) => void;
|
downloadSlot: (slotId: SaveSlotId) => Promise<void>;
|
||||||
selectMode: (mode: GameModeId) => void;
|
selectMode: (mode: GameModeId) => void;
|
||||||
selectBoss: (bossId: BossId) => void;
|
selectBoss: (bossId: BossId) => void;
|
||||||
selectDifficulty: (difficultySlug: DifficultySlug) => void;
|
selectDifficulty: (difficultySlug: DifficultySlug) => void;
|
||||||
@@ -81,6 +114,8 @@ export interface FrontendState {
|
|||||||
selectGearSlot: (slotId: GearSlotId) => void;
|
selectGearSlot: (slotId: GearSlotId) => void;
|
||||||
selectGearWorkshopMode: (mode: "upgrade" | "infusion") => void;
|
selectGearWorkshopMode: (mode: "upgrade" | "infusion") => void;
|
||||||
selectInfusion: (infusionId: string) => void;
|
selectInfusion: (infusionId: string) => void;
|
||||||
|
selectPassiveAbility: (abilityId: AbilityId) => void;
|
||||||
|
selectPassiveInfusion: (passiveId: RunBuffId) => void;
|
||||||
upgradeSelectedGear: () => boolean;
|
upgradeSelectedGear: () => boolean;
|
||||||
equipSelectedInfusion: () => boolean;
|
equipSelectedInfusion: () => boolean;
|
||||||
equipPassiveInfusion: (passiveId: RunBuffId) => boolean;
|
equipPassiveInfusion: (passiveId: RunBuffId) => boolean;
|
||||||
@@ -89,6 +124,8 @@ export interface FrontendState {
|
|||||||
updateSetting: <K extends keyof GameSettings>(key: K, value: GameSettings[K]) => void;
|
updateSetting: <K extends keyof GameSettings>(key: K, value: GameSettings[K]) => void;
|
||||||
touchActiveSave: () => void;
|
touchActiveSave: () => void;
|
||||||
recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null;
|
recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null;
|
||||||
|
recordRoguelikeDefeat: (round: number) => void;
|
||||||
|
recordRogueTrialsEndlessDefeat: (bossKills: number) => void;
|
||||||
clearRecentRewards: () => void;
|
clearRecentRewards: () => void;
|
||||||
clearNotice: () => void;
|
clearNotice: () => void;
|
||||||
}
|
}
|
||||||
@@ -100,7 +137,7 @@ function activeSave(slots: SaveSlotState[], activeSlotId: SaveSlotId | null): Hu
|
|||||||
export const useFrontendStore = create<FrontendState>((set, get) => ({
|
export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||||
screen: "login",
|
screen: "login",
|
||||||
accountId: null,
|
accountId: null,
|
||||||
slots: repository.list(null),
|
slots: repository.listLocal(),
|
||||||
selectedSlotId: 1,
|
selectedSlotId: 1,
|
||||||
activeSlotId: null,
|
activeSlotId: null,
|
||||||
selectedMode: "roguelike-pve",
|
selectedMode: "roguelike-pve",
|
||||||
@@ -110,18 +147,38 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
|||||||
selectedGearSlotId: "weapon",
|
selectedGearSlotId: "weapon",
|
||||||
gearWorkshopMode: "upgrade",
|
gearWorkshopMode: "upgrade",
|
||||||
selectedInfusionId: infusionsForOwner("priest")[0].id,
|
selectedInfusionId: infusionsForOwner("priest")[0].id,
|
||||||
|
selectedPassiveAbilityId: "mend",
|
||||||
|
selectedPassiveInfusionId: "mend-echo",
|
||||||
recentRewards: [],
|
recentRewards: [],
|
||||||
settings: loadSettings(),
|
settings: loadSettings(),
|
||||||
notice: "",
|
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) => {
|
signIn: async (username, password) => {
|
||||||
const result = await accounts.authenticate(username, password);
|
const result = await accounts.authenticate(username, password);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
set({ notice: accountNotice(result, "sign-in") });
|
set({ notice: accountNotice(result, "sign-in") });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
set({ accountId: result.username, slots: repository.list(result.username), screen: "saves", notice: `Online sync connected as ${result.username}.` });
|
try {
|
||||||
return true;
|
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) => {
|
createAccount: async (username, password) => {
|
||||||
const result = await accounts.create(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") });
|
set({ notice: accountNotice(result, "create") });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
set({ accountId: result.username, slots: repository.list(result.username), screen: "saves", notice: `Account created. Online sync connected as ${result.username}.` });
|
try {
|
||||||
return true;
|
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: "" }),
|
navigate: (screen) => set({ screen, notice: "" }),
|
||||||
selectSlot: (selectedSlotId) => set({ selectedSlotId, notice: "" }),
|
selectSlot: (selectedSlotId) => set({ selectedSlotId, notice: "" }),
|
||||||
createSlot: (slotId, rawHunterName) => {
|
createSlot: (slotId, rawHunterName) => {
|
||||||
@@ -143,18 +209,18 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
repository.create(slotId, hunterName);
|
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;
|
return true;
|
||||||
},
|
},
|
||||||
playSlot: (slotId) => {
|
playSlot: (slotId) => {
|
||||||
const local = repository.touch(slotId);
|
const local = repository.touch(slotId);
|
||||||
if (!local) return;
|
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) => {
|
deleteSlot: (slotId) => {
|
||||||
repository.deleteLocal(slotId);
|
repository.deleteLocal(slotId);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
slots: repository.list(state.accountId),
|
slots: refreshLocalSlots(state.slots),
|
||||||
activeSlotId: state.activeSlotId === slotId ? null : state.activeSlotId,
|
activeSlotId: state.activeSlotId === slotId ? null : state.activeSlotId,
|
||||||
notice: `Local slot ${slotId} deleted. Online copy preserved.`,
|
notice: `Local slot ${slotId} deleted. Online copy preserved.`,
|
||||||
}));
|
}));
|
||||||
@@ -162,19 +228,36 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
|||||||
copySlot: (sourceId, targetId) => {
|
copySlot: (sourceId, targetId) => {
|
||||||
const copy = repository.copyLocal(sourceId, targetId);
|
const copy = repository.copyLocal(sourceId, targetId);
|
||||||
if (!copy) return;
|
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();
|
const { accountId } = get();
|
||||||
if (!accountId) return set({ notice: "Sign in before syncing online." });
|
if (!accountId) return set({ notice: "Sign in before syncing online." });
|
||||||
const uploaded = repository.upload(slotId, accountId);
|
const local = repository.listLocal().find((slot) => slot.id === slotId)?.local;
|
||||||
set({ slots: repository.list(accountId), notice: uploaded ? `Slot ${slotId} synced to server.` : "No offline save to sync." });
|
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();
|
const { accountId } = get();
|
||||||
if (!accountId) return set({ notice: "Sign in before downloading an online save." });
|
if (!accountId) return set({ notice: "Sign in before downloading an online save." });
|
||||||
const downloaded = repository.download(slotId, accountId);
|
try {
|
||||||
set({ slots: repository.list(accountId), notice: downloaded ? `Slot ${slotId} overwritten with online version.` : "No online version exists for this slot." });
|
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: "" }),
|
selectMode: (selectedMode) => set({ selectedMode, screen: "mode", notice: "" }),
|
||||||
selectBoss: (selectedBossId) => set({ selectedBossId, notice: "" }),
|
selectBoss: (selectedBossId) => set({ selectedBossId, notice: "" }),
|
||||||
@@ -187,6 +270,15 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
|||||||
selectGearSlot: (selectedGearSlotId) => set({ selectedGearSlotId, notice: "" }),
|
selectGearSlot: (selectedGearSlotId) => set({ selectedGearSlotId, notice: "" }),
|
||||||
selectGearWorkshopMode: (gearWorkshopMode) => set({ gearWorkshopMode, notice: "" }),
|
selectGearWorkshopMode: (gearWorkshopMode) => set({ gearWorkshopMode, notice: "" }),
|
||||||
selectInfusion: (selectedInfusionId) => set({ selectedInfusionId, 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: () => {
|
upgradeSelectedGear: () => {
|
||||||
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId } = get();
|
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId } = get();
|
||||||
if (!activeSlotId) return false;
|
if (!activeSlotId) return false;
|
||||||
@@ -203,7 +295,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
|||||||
return save;
|
return save;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
set({ slots: repository.list(accountId), notice: message });
|
set((state) => ({ slots: refreshLocalSlots(state.slots), notice: message }));
|
||||||
return upgraded;
|
return upgraded;
|
||||||
},
|
},
|
||||||
equipSelectedInfusion: () => {
|
equipSelectedInfusion: () => {
|
||||||
@@ -223,7 +315,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
|||||||
return save;
|
return save;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
set({ slots: repository.list(accountId), notice: message });
|
set((state) => ({ slots: refreshLocalSlots(state.slots), notice: message }));
|
||||||
return equipped;
|
return equipped;
|
||||||
},
|
},
|
||||||
equipPassiveInfusion: (passiveId) => {
|
equipPassiveInfusion: (passiveId) => {
|
||||||
@@ -242,7 +334,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
|||||||
return save;
|
return save;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
set({ slots: repository.list(accountId), notice: message });
|
set((state) => ({ slots: refreshLocalSlots(state.slots), notice: message }));
|
||||||
return equipped;
|
return equipped;
|
||||||
},
|
},
|
||||||
selectHealerClass: (classId) => {
|
selectHealerClass: (classId) => {
|
||||||
@@ -251,7 +343,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
|||||||
const updated = repository.updateLocal(activeSlotId, (save) => ({ ...save, activeClassId: classId }));
|
const updated = repository.updateLocal(activeSlotId, (save) => ({ ...save, activeClassId: classId }));
|
||||||
if (!updated) return;
|
if (!updated) return;
|
||||||
set({
|
set({
|
||||||
slots: repository.list(accountId),
|
slots: refreshLocalSlots(get().slots),
|
||||||
selectedGearOwnerId: classId,
|
selectedGearOwnerId: classId,
|
||||||
selectedInfusionId: infusionsForOwner(classId)[0].id,
|
selectedInfusionId: infusionsForOwner(classId)[0].id,
|
||||||
notice: `${updated.healers[classId].level > 1 ? "Level " + updated.healers[classId].level + " " : ""}${classId[0].toUpperCase() + classId.slice(1)} selected.`,
|
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) },
|
[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) => {
|
updateSetting: (key, value) => set((state) => {
|
||||||
const settings = { ...state.settings, [key]: value };
|
const settings = { ...state.settings, [key]: value };
|
||||||
@@ -278,10 +370,10 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
|||||||
const { activeSlotId, accountId } = get();
|
const { activeSlotId, accountId } = get();
|
||||||
if (!activeSlotId) return;
|
if (!activeSlotId) return;
|
||||||
repository.touch(activeSlotId);
|
repository.touch(activeSlotId);
|
||||||
set({ slots: repository.list(accountId) });
|
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||||
},
|
},
|
||||||
recordBossVictory: (bossId, difficultySlug) => {
|
recordBossVictory: (bossId, difficultySlug) => {
|
||||||
const { activeSlotId, accountId } = get();
|
const { activeSlotId } = get();
|
||||||
if (!activeSlotId) return null;
|
if (!activeSlotId) return null;
|
||||||
let awarded: BossRewardAward | null = null;
|
let awarded: BossRewardAward | null = null;
|
||||||
repository.updateLocal(activeSlotId, (save) => {
|
repository.updateLocal(activeSlotId, (save) => {
|
||||||
@@ -296,17 +388,44 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
slots: repository.list(accountId),
|
slots: refreshLocalSlots(state.slots),
|
||||||
recentRewards: awarded ? [...state.recentRewards, awarded] : state.recentRewards,
|
recentRewards: awarded ? [...state.recentRewards, awarded].slice(-12) : state.recentRewards,
|
||||||
notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved offline.` : "Boss clear saved offline.",
|
notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved.` : "Boss clear saved.",
|
||||||
}));
|
}));
|
||||||
return awarded;
|
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: [] }),
|
clearRecentRewards: () => set({ recentRewards: [] }),
|
||||||
clearNotice: () => set({ notice: "" }),
|
clearNotice: () => set({ notice: "" }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export type FrontendSnapshot = Omit<FrontendState,
|
export type FrontendSnapshot = Omit<FrontendState,
|
||||||
|
| "restoreSession"
|
||||||
| "signIn"
|
| "signIn"
|
||||||
| "createAccount"
|
| "createAccount"
|
||||||
| "continueOffline"
|
| "continueOffline"
|
||||||
@@ -326,6 +445,8 @@ export type FrontendSnapshot = Omit<FrontendState,
|
|||||||
| "selectGearSlot"
|
| "selectGearSlot"
|
||||||
| "selectGearWorkshopMode"
|
| "selectGearWorkshopMode"
|
||||||
| "selectInfusion"
|
| "selectInfusion"
|
||||||
|
| "selectPassiveAbility"
|
||||||
|
| "selectPassiveInfusion"
|
||||||
| "upgradeSelectedGear"
|
| "upgradeSelectedGear"
|
||||||
| "equipSelectedInfusion"
|
| "equipSelectedInfusion"
|
||||||
| "equipPassiveInfusion"
|
| "equipPassiveInfusion"
|
||||||
@@ -334,12 +455,15 @@ export type FrontendSnapshot = Omit<FrontendState,
|
|||||||
| "updateSetting"
|
| "updateSetting"
|
||||||
| "touchActiveSave"
|
| "touchActiveSave"
|
||||||
| "recordBossVictory"
|
| "recordBossVictory"
|
||||||
|
| "recordRoguelikeDefeat"
|
||||||
|
| "recordRogueTrialsEndlessDefeat"
|
||||||
| "clearRecentRewards"
|
| "clearRecentRewards"
|
||||||
| "clearNotice"
|
| "clearNotice"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export function getFrontendSnapshot(): FrontendSnapshot {
|
export function getFrontendSnapshot(): FrontendSnapshot {
|
||||||
const {
|
const {
|
||||||
|
restoreSession: _restoreSession,
|
||||||
signIn: _signIn,
|
signIn: _signIn,
|
||||||
createAccount: _createAccount,
|
createAccount: _createAccount,
|
||||||
continueOffline: _continueOffline,
|
continueOffline: _continueOffline,
|
||||||
@@ -359,6 +483,8 @@ export function getFrontendSnapshot(): FrontendSnapshot {
|
|||||||
selectGearSlot: _selectGearSlot,
|
selectGearSlot: _selectGearSlot,
|
||||||
selectGearWorkshopMode: _selectGearWorkshopMode,
|
selectGearWorkshopMode: _selectGearWorkshopMode,
|
||||||
selectInfusion: _selectInfusion,
|
selectInfusion: _selectInfusion,
|
||||||
|
selectPassiveAbility: _selectPassiveAbility,
|
||||||
|
selectPassiveInfusion: _selectPassiveInfusion,
|
||||||
upgradeSelectedGear: _upgradeSelectedGear,
|
upgradeSelectedGear: _upgradeSelectedGear,
|
||||||
equipSelectedInfusion: _equipSelectedInfusion,
|
equipSelectedInfusion: _equipSelectedInfusion,
|
||||||
equipPassiveInfusion: _equipPassiveInfusion,
|
equipPassiveInfusion: _equipPassiveInfusion,
|
||||||
@@ -367,6 +493,8 @@ export function getFrontendSnapshot(): FrontendSnapshot {
|
|||||||
updateSetting: _updateSetting,
|
updateSetting: _updateSetting,
|
||||||
touchActiveSave: _touchActiveSave,
|
touchActiveSave: _touchActiveSave,
|
||||||
recordBossVictory: _recordBossVictory,
|
recordBossVictory: _recordBossVictory,
|
||||||
|
recordRoguelikeDefeat: _recordRoguelikeDefeat,
|
||||||
|
recordRogueTrialsEndlessDefeat: _recordRogueTrialsEndlessDefeat,
|
||||||
clearRecentRewards: _clearRecentRewards,
|
clearRecentRewards: _clearRecentRewards,
|
||||||
clearNotice: _clearNotice,
|
clearNotice: _clearNotice,
|
||||||
...snapshot
|
...snapshot
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { CollectionLog, MaterialStack } from "../game/progression/loot";
|
|||||||
|
|
||||||
export type SaveSlotId = 1 | 2 | 3;
|
export type SaveSlotId = 1 | 2 | 3;
|
||||||
export type AppScreen = "login" | "saves" | "home" | "profile" | "gear" | "settings" | "mode" | "game";
|
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 {
|
export interface CollectionDrop {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -41,6 +41,8 @@ export interface HunterStats {
|
|||||||
alliesSaved: number;
|
alliesSaved: number;
|
||||||
healingDone: number;
|
healingDone: number;
|
||||||
bossKills: Record<string, number>;
|
bossKills: Record<string, number>;
|
||||||
|
highestRoguelikeRound: number;
|
||||||
|
highestRogueTrialsEndlessKills: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HealerProgress {
|
export interface HealerProgress {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { createClassInventory } from "./healers";
|
import { createClassInventory } from "./healers";
|
||||||
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
|
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
|
||||||
|
import { canAddBossToEncounter } from "./bossSelection";
|
||||||
import { useGameStore } from "./store";
|
import { useGameStore } from "./store";
|
||||||
import type { BossId } from "./types";
|
import type { BossId } from "./types";
|
||||||
|
|
||||||
@@ -27,7 +28,9 @@ function simulateControlledBattle(bossIds: readonly [BossId, BossId], maxSeconds
|
|||||||
|
|
||||||
describe("full-mechanics dual-boss battle simulations", () => {
|
describe("full-mechanics dual-boss battle simulations", () => {
|
||||||
const combinations: readonly (readonly [BossId, BossId])[] = AVAILABLE_BOSS_IDS.flatMap((first, index) =>
|
const combinations: readonly (readonly [BossId, BossId])[] = AVAILABLE_BOSS_IDS.flatMap((first, index) =>
|
||||||
AVAILABLE_BOSS_IDS.slice(index + 1).map((second) => [first, second] as const),
|
AVAILABLE_BOSS_IDS.slice(index + 1)
|
||||||
|
.filter((second) => canAddBossToEncounter([first], second))
|
||||||
|
.map((second) => [first, second] as const),
|
||||||
);
|
);
|
||||||
|
|
||||||
it.each(combinations)("party rotations defeat %s + %s", (first, second) => {
|
it.each(combinations)("party rotations defeat %s + %s", (first, second) => {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "./bossCatalog";
|
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "./bossCatalog";
|
||||||
import { createBossMotionState, createBossState } from "./bossMechanics";
|
import { createBossMotionState, createBossState } from "./bossMechanics";
|
||||||
|
import { BOSS_MECHANIC_POOL, BOSS_MECHANIC_REGISTRY, bossMechanicName } from "./bosses/mechanicPool";
|
||||||
|
import { ALTERNATE_BOSS_CONFIG } from "./bossVisuals";
|
||||||
|
|
||||||
describe("boss catalog", () => {
|
describe("boss catalog", () => {
|
||||||
it("derives the available roster from every catalog definition", () => {
|
it("derives the available roster from every catalog definition", () => {
|
||||||
expect(AVAILABLE_BOSS_IDS).toEqual(Object.keys(BOSS_DEFINITIONS));
|
expect(AVAILABLE_BOSS_IDS).toEqual(Object.keys(BOSS_DEFINITIONS));
|
||||||
expect(new Set(AVAILABLE_BOSS_IDS)).toHaveLength(27);
|
expect(new Set(AVAILABLE_BOSS_IDS)).toHaveLength(28);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("assigns every boss to one compatible mechanic group", () => {
|
it("assigns every boss to one compatible mechanic group", () => {
|
||||||
@@ -42,4 +44,48 @@ describe("boss catalog", () => {
|
|||||||
expect(state.hp).toBe(state.maxHp);
|
expect(state.hp).toBe(state.maxHp);
|
||||||
expect(motion.bossId).toBe(bossId);
|
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
@@ -1,4 +1,5 @@
|
|||||||
import type { BossId } from "./types";
|
import { bossMechanicName } from "./bosses/mechanicPool";
|
||||||
|
import type { BossId, BossMechanicId } from "./types";
|
||||||
|
|
||||||
export type BossArchetype =
|
export type BossArchetype =
|
||||||
| "bull"
|
| "bull"
|
||||||
@@ -46,7 +47,7 @@ export interface BossDefinition {
|
|||||||
failure: string;
|
failure: string;
|
||||||
mapTitle: string;
|
mapTitle: string;
|
||||||
mapCopy: string;
|
mapCopy: string;
|
||||||
mechanics: readonly [string, string, ...string[]];
|
mechanicIds: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]];
|
||||||
maxHp: number;
|
maxHp: number;
|
||||||
archetype: BossArchetype;
|
archetype: BossArchetype;
|
||||||
groupId: BossGroupId;
|
groupId: BossGroupId;
|
||||||
@@ -58,16 +59,20 @@ interface BossSeed {
|
|||||||
icon: string;
|
icon: string;
|
||||||
accent: string;
|
accent: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
mechanics: readonly [string, string, ...string[]];
|
mechanicIds: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]];
|
||||||
maxHp: number;
|
maxHp: number;
|
||||||
archetype: BossArchetype;
|
archetype: BossArchetype;
|
||||||
}
|
}
|
||||||
|
|
||||||
function boss(id: BossId, index: number, seed: BossSeed): BossDefinition {
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
...seed,
|
...seed,
|
||||||
|
mechanicIds,
|
||||||
groupId: BOSS_GROUP_BY_BOSS_ID[id],
|
groupId: BOSS_GROUP_BY_BOSS_ID[id],
|
||||||
trial: `Trial ${String(index + 1).padStart(2, "0")} · ${seed.title}`,
|
trial: `Trial ${String(index + 1).padStart(2, "0")} · ${seed.title}`,
|
||||||
briefing: `Read ${first}, then preserve open ground for ${second}.`,
|
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> = {
|
const BOSS_SEEDS: Record<BossId, BossSeed> = {
|
||||||
bulldrome: {
|
bulldrome: {
|
||||||
name: "Bulldrome", title: "Hall of the Cinder Bull", icon: "♜", accent: "#e2744e",
|
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": {
|
"sandglass-scorpion": {
|
||||||
name: "Sandglass Scorpion", title: "The Sunken Hour", icon: "⌛", accent: "#e9b94f",
|
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": {
|
"cragclaw-crab": {
|
||||||
name: "Cragclaw", title: "The Drowned Breakwater", icon: "♋", accent: "#49c7d4",
|
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": {
|
"mournveil-ghost": {
|
||||||
name: "Mournveil", title: "The Silent Reliquary", icon: "◉", accent: "#9d72ff",
|
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": {
|
"crownshard-golem": {
|
||||||
name: "Crownshard Golem", title: "The Broken Coronation", icon: "♛", accent: "#e0bd45",
|
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": {
|
"crystal-bat-matriarch": {
|
||||||
name: "Crystal Bat Matriarch", title: "The Prism Echo", icon: "◈", accent: "#8eeaff",
|
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": {
|
"stormwool-alpaca": {
|
||||||
name: "Stormwool", title: "The Thunder Fleece", icon: "ϟ", accent: "#8fc7ff",
|
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": {
|
"cluckhorn-colossus": {
|
||||||
name: "Cluckhorn Colossus", title: "The Roostbreaker", icon: "✹", accent: "#f0b85d",
|
name: "Brassbeak Basilisk", title: "The Furnace Nest", icon: "✹", accent: "#dba33e",
|
||||||
summary: "Stampedes sideways and drops cracking shell bursts on spread targets.", mechanics: ["Roost Rush", "Shellburst"], maxHp: 475, archetype: "crab",
|
summary: "Scuttles through lateral lanes and vents furnace bursts on spread targets.", mechanicIds: ["sidewinder-rush", "crushing-tide", "meteor-spread"], maxHp: 475, archetype: "crab",
|
||||||
},
|
},
|
||||||
"ashwing-demon": {
|
"ashwing-demon": {
|
||||||
name: "Ashwing", title: "The Cinder Choir", icon: "♠", accent: "#df665d",
|
name: "Ashwing", title: "The Cinder Choir", icon: "♠", accent: "#df665d",
|
||||||
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": {
|
"riftclaw-demon": {
|
||||||
name: "Riftclaw", title: "The Broken Duel", icon: "⚔", accent: "#d45cff",
|
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": {
|
"tempestscale-dragon": {
|
||||||
name: "Tempestscale", title: "The Living Storm", icon: "☈", accent: "#5fc8e8",
|
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: {
|
emberfox: {
|
||||||
name: "Emberfox", title: "The Burning Trail", icon: "✦", accent: "#ff7b45",
|
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": {
|
"mirelord-frog": {
|
||||||
name: "Mirelord", title: "The Drowned Bell", icon: "●", accent: "#73c96b",
|
name: "Bogbell Myconid", title: "The Drowned Bell", icon: "●", accent: "#8fdc69",
|
||||||
summary: "Dives below the mire before erupting through timed bog zones.", mechanics: ["Mire Dive", "Bogglass Eruption"], maxHp: 490, archetype: "burrower",
|
summary: "Roots below the mire before erupting through timed spore blooms.", mechanicIds: ["burrow-rush", "hourglass-eruption", "hollow-collapse"], maxHp: 490, archetype: "burrower",
|
||||||
},
|
},
|
||||||
"stonebreaker-giant": {
|
"stonebreaker-giant": {
|
||||||
name: "Stonebreaker", title: "The Walking Crag", icon: "▰", accent: "#c89563",
|
name: "Stonebreaker", title: "The Walking Crag", icon: "▰", accent: "#c89563",
|
||||||
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": {
|
"glub-sovereign": {
|
||||||
name: "Glub Sovereign", title: "The Binding Ooze", icon: "◌", accent: "#6ce0b8",
|
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": {
|
"scrapking-goblin": {
|
||||||
name: "Scrapking", title: "The Jagged Throne", icon: "⚒", accent: "#d7a34b",
|
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": {
|
"warcaller-orc": {
|
||||||
name: "Warcaller", title: "The Red Standard", icon: "⚑", accent: "#e4533f",
|
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": {
|
"tuskmaw-orc": {
|
||||||
name: "Tuskmaw", title: "The Breaker Below", icon: "◈", accent: "#9eb25d",
|
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": {
|
"broodfang-spider": {
|
||||||
name: "Broodfang", title: "The Silk Tyrant", icon: "✣", accent: "#b56cff",
|
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": {
|
"silkfang-spider": {
|
||||||
name: "Silkfang", title: "The Gloom Weaver", icon: "✤", accent: "#9d68d8",
|
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": {
|
"thorncrown-stag": {
|
||||||
name: "Thorncrown", title: "The Briar Hart", icon: "♧", accent: "#7fc46b",
|
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": {
|
"sky-totem": {
|
||||||
name: "Sky Totem", title: "The Hollow Idol", icon: "☼", accent: "#69d4d1",
|
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": {
|
"razorcrest-raptor": {
|
||||||
name: "Razorcrest", title: "The Hunting Circuit", icon: "➳", accent: "#d9c45a",
|
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": {
|
"bristlequake-boar": {
|
||||||
name: "Bristlequake", title: "The Iron Tusk", icon: "♞", accent: "#d47b45",
|
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": {
|
"moonfang-wolf": {
|
||||||
name: "Moonfang", title: "The Silver Pursuit", icon: "☾", accent: "#9db9e5",
|
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": {
|
"frostmaw-yeti": {
|
||||||
name: "Frostmaw", title: "The White Avalanche", icon: "❄", accent: "#8ed8ef",
|
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": {
|
"rimeclaw-yeti": {
|
||||||
name: "Rimeclaw", title: "The Frozen Duel", icon: "✥", accent: "#75bfe8",
|
name: "Rimeclaw", title: "The Frozen Duel", icon: "✥", accent: "#75bfe8",
|
||||||
summary: "Sidesteps between frost strikes before forming a lethal ice cross.", 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",
|
id: "burrow-eruption", letter: "F", name: "Burrow / Eruption", coreMechanic: "Burrow and Eruption",
|
||||||
bossIds: ["sandglass-scorpion", "mirelord-frog"], archetypes: ["burrower"],
|
bossIds: ["sandglass-scorpion", "mirelord-frog", "gravehorn-triceratops"], archetypes: ["burrower"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "scuttle-burst", letter: "G", name: "Scuttle / Burst", coreMechanic: "Scuttle and Burst",
|
id: "scuttle-burst", letter: "G", name: "Scuttle / Burst", coreMechanic: "Scuttle and Burst",
|
||||||
|
|||||||
@@ -0,0 +1,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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { BOSS_HAZARD_VFX, bossHazardVfxProfile, bossHazardVfxSeed } from "./bossHazardVisuals";
|
||||||
|
import type { CircleHazardKind } from "./types";
|
||||||
|
|
||||||
|
const HAZARD_KINDS: readonly CircleHazardKind[] = [
|
||||||
|
"venom_pool",
|
||||||
|
"skyfall",
|
||||||
|
"quake",
|
||||||
|
"lava_pool",
|
||||||
|
"stinger_eruption",
|
||||||
|
"hourglass",
|
||||||
|
"tidal_burst",
|
||||||
|
"soul_rift",
|
||||||
|
"crownfall",
|
||||||
|
"royal_shockwave",
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("boss hazard visuals", () => {
|
||||||
|
it("defines a bounded effect profile for every circular hazard kind", () => {
|
||||||
|
expect(Object.keys(BOSS_HAZARD_VFX).sort()).toEqual([...HAZARD_KINDS].sort());
|
||||||
|
for (const kind of HAZARD_KINDS) {
|
||||||
|
const profile = bossHazardVfxProfile(kind);
|
||||||
|
expect(profile.particleCount).toBeGreaterThanOrEqual(6);
|
||||||
|
expect(profile.particleCount).toBeLessThanOrEqual(9);
|
||||||
|
expect(profile.height).toBeGreaterThan(0);
|
||||||
|
expect(profile.warningColor).not.toBe(profile.highlightColor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses shape and motion, not color alone, to distinguish common attacks", () => {
|
||||||
|
expect(bossHazardVfxProfile("venom_pool")).toMatchObject({ motion: "bubble", shape: "orb" });
|
||||||
|
expect(bossHazardVfxProfile("stinger_eruption")).toMatchObject({ motion: "spike", shape: "spike" });
|
||||||
|
expect(bossHazardVfxProfile("royal_shockwave")).toMatchObject({ motion: "shockwave", shape: "crystal" });
|
||||||
|
expect(bossHazardVfxProfile("soul_rift")).toMatchObject({ motion: "orbit", shape: "orb" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates stable normalized seeds without runtime randomness", () => {
|
||||||
|
expect(bossHazardVfxSeed("meteor-12")).toBe(bossHazardVfxSeed("meteor-12"));
|
||||||
|
expect(bossHazardVfxSeed("meteor-12")).not.toBe(bossHazardVfxSeed("meteor-13"));
|
||||||
|
expect(bossHazardVfxSeed("meteor-12")).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(bossHazardVfxSeed("meteor-12")).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import type { CircleHazardKind } from "./types";
|
||||||
|
|
||||||
|
export type BossHazardVfxMotion = "bubble" | "fall" | "flame" | "orbit" | "shockwave" | "spike" | "surge";
|
||||||
|
export type BossHazardVfxShape = "crystal" | "orb" | "spike";
|
||||||
|
|
||||||
|
export interface BossHazardVfxProfile {
|
||||||
|
readonly warningColor: string;
|
||||||
|
readonly activeColor: string;
|
||||||
|
readonly highlightColor: string;
|
||||||
|
readonly motion: BossHazardVfxMotion;
|
||||||
|
readonly shape: BossHazardVfxShape;
|
||||||
|
readonly particleCount: number;
|
||||||
|
readonly height: number;
|
||||||
|
readonly spinSpeed: number;
|
||||||
|
readonly columnOpacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendering-only identity for circular hazards. Combat rules remain in boss
|
||||||
|
* mechanics; renderers can project this profile without branching on boss id.
|
||||||
|
*/
|
||||||
|
export const BOSS_HAZARD_VFX: Record<CircleHazardKind, BossHazardVfxProfile> = {
|
||||||
|
venom_pool: {
|
||||||
|
warningColor: "#9a54d6",
|
||||||
|
activeColor: "#6fca45",
|
||||||
|
highlightColor: "#d6ff7d",
|
||||||
|
motion: "bubble",
|
||||||
|
shape: "orb",
|
||||||
|
particleCount: 7,
|
||||||
|
height: 0.8,
|
||||||
|
spinSpeed: 0.35,
|
||||||
|
columnOpacity: 0,
|
||||||
|
},
|
||||||
|
skyfall: {
|
||||||
|
warningColor: "#ff4d42",
|
||||||
|
activeColor: "#ff792e",
|
||||||
|
highlightColor: "#ffe0a1",
|
||||||
|
motion: "fall",
|
||||||
|
shape: "crystal",
|
||||||
|
particleCount: 6,
|
||||||
|
height: 5.2,
|
||||||
|
spinSpeed: 0.55,
|
||||||
|
columnOpacity: 0.13,
|
||||||
|
},
|
||||||
|
quake: {
|
||||||
|
warningColor: "#ff5549",
|
||||||
|
activeColor: "#d64226",
|
||||||
|
highlightColor: "#ffd08a",
|
||||||
|
motion: "shockwave",
|
||||||
|
shape: "crystal",
|
||||||
|
particleCount: 8,
|
||||||
|
height: 0.75,
|
||||||
|
spinSpeed: 0.18,
|
||||||
|
columnOpacity: 0,
|
||||||
|
},
|
||||||
|
lava_pool: {
|
||||||
|
warningColor: "#ff6a31",
|
||||||
|
activeColor: "#ff3b18",
|
||||||
|
highlightColor: "#ffd05a",
|
||||||
|
motion: "flame",
|
||||||
|
shape: "spike",
|
||||||
|
particleCount: 7,
|
||||||
|
height: 1.25,
|
||||||
|
spinSpeed: 0.28,
|
||||||
|
columnOpacity: 0.04,
|
||||||
|
},
|
||||||
|
stinger_eruption: {
|
||||||
|
warningColor: "#ff4b42",
|
||||||
|
activeColor: "#cf2442",
|
||||||
|
highlightColor: "#ffd2b3",
|
||||||
|
motion: "spike",
|
||||||
|
shape: "spike",
|
||||||
|
particleCount: 7,
|
||||||
|
height: 1.75,
|
||||||
|
spinSpeed: 0.12,
|
||||||
|
columnOpacity: 0,
|
||||||
|
},
|
||||||
|
hourglass: {
|
||||||
|
warningColor: "#ed8b3a",
|
||||||
|
activeColor: "#c16a28",
|
||||||
|
highlightColor: "#ffe3a3",
|
||||||
|
motion: "orbit",
|
||||||
|
shape: "crystal",
|
||||||
|
particleCount: 8,
|
||||||
|
height: 2.2,
|
||||||
|
spinSpeed: 1.35,
|
||||||
|
columnOpacity: 0.1,
|
||||||
|
},
|
||||||
|
tidal_burst: {
|
||||||
|
warningColor: "#36cae2",
|
||||||
|
activeColor: "#178fc6",
|
||||||
|
highlightColor: "#c9fbff",
|
||||||
|
motion: "surge",
|
||||||
|
shape: "orb",
|
||||||
|
particleCount: 8,
|
||||||
|
height: 1.65,
|
||||||
|
spinSpeed: 0.75,
|
||||||
|
columnOpacity: 0.08,
|
||||||
|
},
|
||||||
|
soul_rift: {
|
||||||
|
warningColor: "#aa78ff",
|
||||||
|
activeColor: "#6f42cf",
|
||||||
|
highlightColor: "#ebd8ff",
|
||||||
|
motion: "orbit",
|
||||||
|
shape: "orb",
|
||||||
|
particleCount: 8,
|
||||||
|
height: 2.45,
|
||||||
|
spinSpeed: 1.65,
|
||||||
|
columnOpacity: 0.14,
|
||||||
|
},
|
||||||
|
crownfall: {
|
||||||
|
warningColor: "#e7c94d",
|
||||||
|
activeColor: "#d95331",
|
||||||
|
highlightColor: "#fff1a8",
|
||||||
|
motion: "fall",
|
||||||
|
shape: "crystal",
|
||||||
|
particleCount: 7,
|
||||||
|
height: 5.6,
|
||||||
|
spinSpeed: -0.65,
|
||||||
|
columnOpacity: 0.15,
|
||||||
|
},
|
||||||
|
royal_shockwave: {
|
||||||
|
warningColor: "#edcb55",
|
||||||
|
activeColor: "#c94631",
|
||||||
|
highlightColor: "#fff0a6",
|
||||||
|
motion: "shockwave",
|
||||||
|
shape: "crystal",
|
||||||
|
particleCount: 9,
|
||||||
|
height: 0.85,
|
||||||
|
spinSpeed: -0.22,
|
||||||
|
columnOpacity: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function bossHazardVfxProfile(kind: CircleHazardKind) {
|
||||||
|
return BOSS_HAZARD_VFX[kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bossHazardVfxSeed(id: string) {
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (let index = 0; index < id.length; index += 1) {
|
||||||
|
hash ^= id.charCodeAt(index);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
return (hash >>> 0) / 0xffffffff;
|
||||||
|
}
|
||||||
@@ -17,9 +17,7 @@ describe("boss home positioning", () => {
|
|||||||
it.each(AVAILABLE_BOSS_IDS)("moves %s back toward its center slot while idle", (bossId) => {
|
it.each(AVAILABLE_BOSS_IDS)("moves %s back toward its center slot while idle", (bossId) => {
|
||||||
const motion = createBossMotionState(bossId);
|
const motion = createBossMotionState(bossId);
|
||||||
motion.position = [7, 5];
|
motion.position = [7, 5];
|
||||||
motion.nextChargeAt = Number.POSITIVE_INFINITY;
|
|
||||||
motion.nextMechanicAt = Number.POSITIVE_INFINITY;
|
motion.nextMechanicAt = Number.POSITIVE_INFINITY;
|
||||||
motion.nextPoolMechanicAt = Number.POSITIVE_INFINITY;
|
|
||||||
const before = Math.hypot(
|
const before = Math.hypot(
|
||||||
motion.position[0] - (ARENA_CENTER[0] + motion.formationOffsetX),
|
motion.position[0] - (ARENA_CENTER[0] + motion.formationOffsetX),
|
||||||
motion.position[1] - ARENA_CENTER[1],
|
motion.position[1] - ARENA_CENTER[1],
|
||||||
|
|||||||
+31
-422
@@ -1,392 +1,40 @@
|
|||||||
import { BOSS_ARCHETYPE_BY_ID, BOSS_DEFINITIONS, type BossArchetype } from "./bossCatalog";
|
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
||||||
import { clampToArena } from "./arena";
|
import {
|
||||||
import { advanceSkySweeperMechanics, createSkySweeperMotion, createSkySweeperState, upcomingSkySweeperMechanic } from "./bosses/skySweeper";
|
advanceMechanicLoadout,
|
||||||
import { advanceCinderbackMechanics, createCinderbackMotion, createCinderbackState, upcomingCinderbackMechanic } from "./bosses/ricochet";
|
BOSS_MECHANIC_REGISTRY,
|
||||||
import { advanceCragclawMechanics, createCragclawMotion, createCragclawState, upcomingCragclawMechanic } from "./bosses/cragclawCrab";
|
BULL_CHARGE,
|
||||||
import { advanceCrownshardMechanics, createCrownshardMotion, createCrownshardState, upcomingCrownshardMechanic } from "./bosses/crownshardGolem";
|
BULL_POUNCE,
|
||||||
import { advanceEmberMantisMechanics, createEmberMantisMotion, createEmberMantisState, upcomingEmberMantisMechanic } from "./bosses/emberMantis";
|
handleMechanicDispel,
|
||||||
import { advanceMournveilMechanics, createMournveilMotion, createMournveilState, upcomingMournveilMechanic } from "./bosses/mournveilGhost";
|
upcomingLoadoutMechanic,
|
||||||
import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, upcomingObsidianRamMechanic } from "./bosses/obsidianRamGolem";
|
} from "./bosses/mechanicPool";
|
||||||
import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, upcomingSandglassMechanic } from "./bosses/sandglassScorpion";
|
import { createBaseMotion } from "./bosses/shared";
|
||||||
import { createBaseMotion, returnBossToArenaCenter } from "./bosses/shared";
|
import type { BossMechanicContext, BossMechanicResult } from "./bosses/types";
|
||||||
import { advancePooledBossMechanics, upcomingPooledMechanic } from "./bosses/mechanicPool";
|
import type { BossId, BossMotionState, BossState, Debuff, MemberId, WorldPosition } from "./types";
|
||||||
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";
|
|
||||||
|
|
||||||
export const BULL_CHARGE = {
|
export { BOSS_MECHANIC_REGISTRY, BULL_CHARGE, BULL_POUNCE };
|
||||||
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 function createBossState(bossId: BossId = "bulldrome"): BossState {
|
export function createBossState(bossId: BossId = "bulldrome"): BossState {
|
||||||
const definition = BOSS_DEFINITIONS[bossId];
|
const definition = BOSS_DEFINITIONS[bossId];
|
||||||
const archetype = BOSS_ARCHETYPE_BY_ID[bossId];
|
return {
|
||||||
let state: BossState;
|
id: bossId,
|
||||||
if (archetype === "web-caster") state = createVexaState(bossId);
|
name: definition.name,
|
||||||
else if (archetype === "sky-sweeper") state = createSkySweeperState(bossId);
|
maxHp: definition.maxHp,
|
||||||
else if (archetype === "duelist") state = createEmberMantisState(bossId);
|
hp: definition.maxHp,
|
||||||
else if (archetype === "ram") state = createObsidianRamState(bossId);
|
nextMeleeAt: 2,
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState {
|
export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState {
|
||||||
const archetype = BOSS_ARCHETYPE_BY_ID[bossId];
|
return {
|
||||||
let motion: BossMotionState;
|
...createBaseMotion(bossId),
|
||||||
if (archetype === "web-caster") motion = createVexaMotion(bossId);
|
position: [0, -6.8],
|
||||||
else if (archetype === "sky-sweeper") motion = createSkySweeperMotion(bossId);
|
chargeStart: [0, -6.8],
|
||||||
else if (archetype === "duelist") motion = createEmberMantisMotion(bossId);
|
nextMechanicAt: 5,
|
||||||
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 { ...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 {
|
export function advanceBossMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||||
const archetype = mechanicArchetype(context.boss.id);
|
return advanceMechanicLoadout(context, BOSS_DEFINITIONS[context.boss.id].mechanicIds);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleBossDispel(
|
export function handleBossDispel(
|
||||||
@@ -395,53 +43,14 @@ export function handleBossDispel(
|
|||||||
memberId: MemberId,
|
memberId: MemberId,
|
||||||
position: WorldPosition,
|
position: WorldPosition,
|
||||||
time: number,
|
time: number,
|
||||||
debuffNames: readonly string[],
|
debuffs: readonly Debuff[],
|
||||||
) {
|
) {
|
||||||
if (mechanicArchetype(bossId) === "web-caster" && debuffNames.includes("Widow Venom")) {
|
if (!BOSS_DEFINITIONS[bossId].mechanicIds.includes("venom-purge")) {
|
||||||
return {
|
return { motion, message: "Harmful magic removed." };
|
||||||
motion: dropVexaVenomPool(motion, memberId, [position[0], position[1]], time),
|
|
||||||
message: "Widow Venom purged. A venom pool forms where the target stood.",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return { motion, message: "Harmful magic removed." };
|
return handleMechanicDispel(motion, memberId, position, time, debuffs);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
|
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
|
||||||
const pooled = upcomingPooledMechanic(motion, time);
|
return upcomingLoadoutMechanic(BOSS_DEFINITIONS[boss.id].mechanicIds, 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 };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ export const BOSS_ROOMS = {
|
|||||||
"stormwool-alpaca": room("thunder-fleece", "The Thunder Fleece", "Wind-scoured highland", "storm", {
|
"stormwool-alpaca": room("thunder-fleece", "The Thunder Fleece", "Wind-scoured highland", "storm", {
|
||||||
background: "#071321", fog: "#274d71", sky: "#a4d5ff", ground: "#071019", floorColor: "#233f5d", wallColor: "#365676", accent: "#9bd3ff", accentSecondary: "#eef9ff", wallHeight: 2.8,
|
background: "#071321", fog: "#274d71", sky: "#a4d5ff", ground: "#071019", floorColor: "#233f5d", wallColor: "#365676", accent: "#9bd3ff", accentSecondary: "#eef9ff", wallHeight: 2.8,
|
||||||
}),
|
}),
|
||||||
"cluckhorn-colossus": room("roostbreaker-yard", "The Roostbreaker Yard", "Ruinous farmstead", "wilds", {
|
"cluckhorn-colossus": room("furnace-nest", "The Furnace Nest", "Overgrown brass hatchery", "junkyard", {
|
||||||
background: "#171308", fog: "#49542a", sky: "#d8c675", ground: "#0b1207", floorColor: "#37421e", wallColor: "#554626", accent: "#f1b95c", accentSecondary: "#b8d065", wallHeight: 2.6,
|
background: "#081615", fog: "#284c45", sky: "#6edccb", ground: "#07100d", floorColor: "#263b32", wallColor: "#5a4123", accent: "#dfaa43", accentSecondary: "#62e3d1", wallHeight: 3.2,
|
||||||
}),
|
}),
|
||||||
"ashwing-demon": room("cinder-choir", "The Cinder Choir", "Ashen cathedral", "cinder", {
|
"ashwing-demon": room("cinder-choir", "The Cinder Choir", "Ashen cathedral", "cinder", {
|
||||||
background: "#1b0608", fog: "#511721", sky: "#ee7566", ground: "#120407", floorColor: "#4b1720", wallColor: "#592029", accent: "#ef625c", accentSecondary: "#ffb16e", wallHeight: 5.2,
|
background: "#1b0608", fog: "#511721", sky: "#ee7566", ground: "#120407", floorColor: "#4b1720", wallColor: "#592029", accent: "#ef625c", accentSecondary: "#ffb16e", wallHeight: 5.2,
|
||||||
@@ -133,6 +133,9 @@ export const BOSS_ROOMS = {
|
|||||||
"rimeclaw-yeti": room("frozen-duel", "The Frozen Duel", "Frozen mirror palace", "frost", {
|
"rimeclaw-yeti": room("frozen-duel", "The Frozen Duel", "Frozen mirror palace", "frost", {
|
||||||
background: "#07162a", fog: "#285a83", sky: "#8fd8ff", ground: "#030c19", floorColor: "#17476d", wallColor: "#2d6691", accent: "#74c8f4", accentSecondary: "#c1f6ff", wallHeight: 4.9,
|
background: "#07162a", fog: "#285a83", sky: "#8fd8ff", ground: "#030c19", floorColor: "#17476d", wallColor: "#2d6691", accent: "#74c8f4", accentSecondary: "#c1f6ff", wallHeight: 4.9,
|
||||||
}),
|
}),
|
||||||
|
"gravehorn-triceratops": room("fossil-wake", "The Fossil Wake", "Buried ossuary", "reliquary", {
|
||||||
|
background: "#100d08", fog: "#40382a", sky: "#d8c79b", ground: "#090704", floorColor: "#332b20", wallColor: "#4c4030", accent: "#d8c79b", accentSecondary: "#8eb9a6", wallHeight: 4.5,
|
||||||
|
}),
|
||||||
} as const satisfies Record<BossId, BossRoomDefinition>;
|
} as const satisfies Record<BossId, BossRoomDefinition>;
|
||||||
|
|
||||||
export function bossRoomFor(bossId: BossId): BossRoomDefinition {
|
export function bossRoomFor(bossId: BossId): BossRoomDefinition {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { freshParty } from "../data";
|
import { freshParty } from "../data";
|
||||||
import { createBaseMotion } from "./shared";
|
import { createBaseMotion } from "./shared";
|
||||||
import { advancePooledBossMechanics, POOLED_MECHANIC_TIMING, SOUL_SIPHON } from "./mechanicPool";
|
import { advanceMechanicLoadout, BOSS_MECHANIC_POOL, bossAnimationCue, SOUL_SIPHON } from "./mechanicPool";
|
||||||
import type { BossMechanicContext, BossMechanicResult } from "./types";
|
import type { BossMechanicContext } from "./types";
|
||||||
import type { BossState, PoolTelegraph, WorldPosition } from "../types";
|
import type { BossState, PoolTelegraph, WorldPosition } from "../types";
|
||||||
|
|
||||||
const POSITIONS: BossMechanicContext["partyPositions"] = {
|
const POSITIONS: BossMechanicContext["partyPositions"] = {
|
||||||
@@ -20,9 +20,6 @@ function state(): BossState {
|
|||||||
maxHp: 500,
|
maxHp: 500,
|
||||||
hp: 500,
|
hp: 500,
|
||||||
nextMeleeAt: Number.POSITIVE_INFINITY,
|
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 {
|
function memoryTelegraph(): PoolTelegraph {
|
||||||
return {
|
return {
|
||||||
id: "test-memory",
|
id: "test-memory",
|
||||||
@@ -79,10 +72,10 @@ function advanceMemory(
|
|||||||
source.party = party;
|
source.party = party;
|
||||||
source.motion = {
|
source.motion = {
|
||||||
...source.motion,
|
...source.motion,
|
||||||
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
|
activeMechanicId: "memory-sequence",
|
||||||
poolTelegraphs: [telegraph],
|
poolTelegraphs: [telegraph],
|
||||||
};
|
};
|
||||||
return advancePooledBossMechanics(source, result(source));
|
return advanceMechanicLoadout(source, ["memory-sequence", "bull-charge"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function soulSiphonTelegraph(): PoolTelegraph {
|
function soulSiphonTelegraph(): PoolTelegraph {
|
||||||
@@ -121,21 +114,49 @@ function advanceSoulSiphon(
|
|||||||
source.party = party;
|
source.party = party;
|
||||||
source.motion = {
|
source.motion = {
|
||||||
...source.motion,
|
...source.motion,
|
||||||
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
|
activeMechanicId: "soul-siphon",
|
||||||
poolTelegraphs: [telegraph],
|
poolTelegraphs: [telegraph],
|
||||||
};
|
};
|
||||||
return advancePooledBossMechanics(source, result(source));
|
return advanceMechanicLoadout(source, ["soul-siphon", "bull-charge"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("shared boss mechanic pool", () => {
|
describe("shared boss mechanic pool", () => {
|
||||||
it("schedules a telegraphed pool mechanic and keeps its warning state independent of boss mode", () => {
|
it.each(BOSS_MECHANIC_POOL.filter(({ id }) => id !== "basic-melee"))("starts $name directly from its canonical ID", ({ id }) => {
|
||||||
const source = context(POOLED_MECHANIC_TIMING.firstAt);
|
const source = context(0);
|
||||||
const started = advancePooledBossMechanics(source, result(source));
|
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.motion.poolTelegraphs).not.toHaveLength(0);
|
||||||
expect(started.events[0].message).toContain(":");
|
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", () => {
|
it("splits soak damage across allies inside its indicator", () => {
|
||||||
@@ -156,14 +177,14 @@ describe("shared boss mechanic pool", () => {
|
|||||||
resolved: false,
|
resolved: false,
|
||||||
hitIds: [],
|
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);
|
const positions = structuredClone(POSITIONS);
|
||||||
positions.aelia = [0.2, 0];
|
positions.aelia = [0.2, 0];
|
||||||
positions.brann = [0, 0];
|
positions.brann = [0, 0];
|
||||||
positions.nia = [-0.2, 0];
|
positions.nia = [-0.2, 0];
|
||||||
positions.vale = [0, -4];
|
positions.vale = [0, -4];
|
||||||
const atImpact = { ...source, motion, partyPositions: positions, time: activatesAt };
|
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 === "aelia")!.hp).toBe(source.party[0].hp - 30);
|
||||||
expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].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];
|
positions.brann = [3, 0];
|
||||||
const atImpact = {
|
const atImpact = {
|
||||||
...source,
|
...source,
|
||||||
motion: { ...source.motion, nextPoolMechanicAt: Number.POSITIVE_INFINITY, poolTelegraphs: [donut] },
|
motion: { ...source.motion, activeMechanicId: "hollow-collapse" as const, poolTelegraphs: [donut] },
|
||||||
partyPositions: positions,
|
partyPositions: positions,
|
||||||
time: activatesAt,
|
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 === "aelia")!.hp).toBe(source.party[0].hp);
|
||||||
expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].hp - 25);
|
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.motion.poolTelegraphs).toHaveLength(0);
|
||||||
expect(cleansed.events[0].message).toContain("cleansing ward");
|
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
@@ -1,9 +1,34 @@
|
|||||||
import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena";
|
import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena";
|
||||||
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
|
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
|
||||||
import type { 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";
|
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||||
|
|
||||||
export const BOSS_MECHANIC_POOL = [
|
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",
|
id: "meteor-spread",
|
||||||
name: "Meteor Spread",
|
name: "Meteor Spread",
|
||||||
@@ -36,18 +61,65 @@ export const BOSS_MECHANIC_POOL = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] 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 = {
|
export const POOLED_MECHANIC_TIMING = {
|
||||||
firstAt: 15,
|
|
||||||
repeatDelay: 26,
|
|
||||||
activeDuration: 0.42,
|
activeDuration: 0.42,
|
||||||
warningDuration: 1.4,
|
warningDuration: 1.4,
|
||||||
} as const;
|
} 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 = {
|
export const MEMORY_SEQUENCE = {
|
||||||
sequenceLength: 4,
|
sequenceLength: 4,
|
||||||
flashDuration: 0.68,
|
flashDuration: 0.9,
|
||||||
inputDuration: 7,
|
inputDuration: 7,
|
||||||
tileSize: 2.15,
|
tileSize: 2.15,
|
||||||
raidwideDamage: 15,
|
raidwideDamage: 15,
|
||||||
@@ -58,8 +130,8 @@ export const SOUL_SIPHON = {
|
|||||||
wardDistance: ARENA_RADIUS - 1.05,
|
wardDistance: ARENA_RADIUS - 1.05,
|
||||||
ghostSpeed: 2.6,
|
ghostSpeed: 2.6,
|
||||||
tickInterval: 0.7,
|
tickInterval: 0.7,
|
||||||
tickDamage: 6,
|
tickDamage: 3,
|
||||||
tickRamp: 2,
|
tickRamp: 1,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const MEMORY_SYMBOLS: Record<MemorySymbolId, { label: string; color: string }> = {
|
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"];
|
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) {
|
function liveTarget(party: PartyMember[], targetId: MemberId) {
|
||||||
return party.some((member) => member.id === targetId && member.hp > 0)
|
return party.some((member) => member.id === targetId && member.hp > 0)
|
||||||
? targetId
|
? targetId
|
||||||
@@ -220,9 +285,10 @@ function beginPoolMechanic(
|
|||||||
party: PartyMember[],
|
party: PartyMember[],
|
||||||
positions: BossMechanicContext["partyPositions"],
|
positions: BossMechanicContext["partyPositions"],
|
||||||
time: number,
|
time: number,
|
||||||
|
requestedId: BossMechanicId,
|
||||||
) {
|
) {
|
||||||
const count = motion.poolMechanicCount + 1;
|
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;
|
const activatesAt = time + POOLED_MECHANIC_TIMING.warningDuration;
|
||||||
let telegraphs: PoolTelegraph[];
|
let telegraphs: PoolTelegraph[];
|
||||||
let targetId: MemberId | undefined;
|
let targetId: MemberId | undefined;
|
||||||
@@ -281,7 +347,6 @@ function beginPoolMechanic(
|
|||||||
motion: {
|
motion: {
|
||||||
...motion,
|
...motion,
|
||||||
poolMechanicCount: count,
|
poolMechanicCount: count,
|
||||||
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
|
|
||||||
poolTelegraphs: telegraphs,
|
poolTelegraphs: telegraphs,
|
||||||
},
|
},
|
||||||
event: {
|
event: {
|
||||||
@@ -498,61 +563,6 @@ function resolveTelegraph(
|
|||||||
return next;
|
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 {
|
export function upcomingPooledMechanic(motion: BossMotionState, time: number): UpcomingMechanic | null {
|
||||||
const telegraphs = motion.poolTelegraphs.filter((telegraph) => !telegraph.resolved && telegraph.expiresAt > time);
|
const telegraphs = motion.poolTelegraphs.filter((telegraph) => !telegraph.resolved && telegraph.expiresAt > time);
|
||||||
if (!telegraphs.length) return null;
|
if (!telegraphs.length) return null;
|
||||||
@@ -582,3 +592,716 @@ export function upcomingPooledMechanic(motion: BossMotionState, time: number): U
|
|||||||
urgent: true,
|
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." };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -39,19 +39,6 @@ export function returnBossToArenaCenter(motion: BossMotionState, delta: number,
|
|||||||
return false;
|
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({
|
export function createCircleHazard({
|
||||||
id,
|
id,
|
||||||
kind,
|
kind,
|
||||||
@@ -92,6 +79,7 @@ export function createCircleHazard({
|
|||||||
export function createBaseMotion(bossId: BossId): BossMotionState {
|
export function createBaseMotion(bossId: BossId): BossMotionState {
|
||||||
return {
|
return {
|
||||||
bossId,
|
bossId,
|
||||||
|
activeMechanicId: null,
|
||||||
formationOffsetX: 0,
|
formationOffsetX: 0,
|
||||||
mode: "holding",
|
mode: "holding",
|
||||||
position: [0, -8.2],
|
position: [0, -8.2],
|
||||||
@@ -100,12 +88,9 @@ export function createBaseMotion(bossId: BossId): BossMotionState {
|
|||||||
chargeTargetId: "nia",
|
chargeTargetId: "nia",
|
||||||
chargeHitIds: [],
|
chargeHitIds: [],
|
||||||
phaseEndsAt: 0,
|
phaseEndsAt: 0,
|
||||||
nextChargeAt: Number.POSITIVE_INFINITY,
|
|
||||||
chargeCount: 0,
|
chargeCount: 0,
|
||||||
chargesSincePounce: 0,
|
|
||||||
pounceTargetId: "aelia",
|
pounceTargetId: "aelia",
|
||||||
pounceCenter: [0, 4.5],
|
pounceCenter: [0, 4.5],
|
||||||
pounceCount: 0,
|
|
||||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||||
mechanicCount: 0,
|
mechanicCount: 0,
|
||||||
phaseStartedAt: 0,
|
phaseStartedAt: 0,
|
||||||
@@ -118,7 +103,6 @@ export function createBaseMotion(bossId: BossId): BossMotionState {
|
|||||||
breathEndAngle: 0,
|
breathEndAngle: 0,
|
||||||
hazards: [],
|
hazards: [],
|
||||||
slashLanes: [],
|
slashLanes: [],
|
||||||
nextPoolMechanicAt: 15,
|
|
||||||
poolMechanicCount: 0,
|
poolMechanicCount: 0,
|
||||||
poolTelegraphs: [],
|
poolTelegraphs: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -22,8 +22,6 @@ export interface BossMechanicContext {
|
|||||||
partyPositions: Record<MemberId, WorldPosition>;
|
partyPositions: Record<MemberId, WorldPosition>;
|
||||||
time: number;
|
time: number;
|
||||||
delta: 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;
|
damageMember: (member: PartyMember, amount: number, position: WorldPosition, at: number, kind?: "direct" | "hazard") => PartyMember;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user