Compare commits

...
13 Commits
Author SHA1 Message Date
Warren H ea5d4550a5 Release v0.1.15 2026-07-18 2026-07-18 20:37:45 -04:00
Warren H 40aa23be46 new help section explaining classes and their mechanics 2026-07-18 12:05:34 -04:00
Warren H 638aef22b2 updated some performance stuff 2026-07-17 15:39:31 -04:00
Warren H 10ff124466 new rpg mode 2026-07-17 12:47:42 -04:00
Warren H de7b59ce77 Release v0.1.14 2026-07-13 2026-07-13 23:19:40 -04:00
Warren H 4b31480bec Release v0.1.13 2026-07-13 2026-07-13 22:50:51 -04:00
Warren H ed34c2503c Release v0.1.12 2026-07-13 2026-07-13 22:41:35 -04:00
Warren H 8e48bb4fb6 Release v0.1.11 2026-07-13 2026-07-13 21:31:36 -04:00
Warren H fb41147197 Release v0.1.10 2026-07-13 2026-07-13 20:47:56 -04:00
Warren H aa9f5eff20 Release v0.1.9 2026-07-13 2026-07-13 19:37:30 -04:00
Warren H 18c416d4d2 Release v0.1.8 2026-07-13 2026-07-13 17:21:10 -04:00
Warren H 77fd434226 Release v0.1.7 2026-07-12 2026-07-12 23:39:57 -04:00
Warren H 122f159b94 Release v0.1.6 2026-07-12 2026-07-12 23:20:15 -04:00
254 changed files with 28868 additions and 1880 deletions
+3
View File
@@ -1,5 +1,7 @@
node_modules/
dist/
data/
backups/
game_assets/
*.tsbuildinfo
vite.config.js
@@ -10,3 +12,4 @@ vite.config.d.ts
!.env.example
__pycache__/
*.py[cod]
/public/basis/
+30 -4
View File
@@ -4,6 +4,17 @@ This game uses the same proven local-Gitea pattern as `testgame`: clone from the
Gitea bare repository on the TrueNAS filesystem, mount that working checkout
into one Node container, and update it with a local Git pull plus app restart.
## What the TrueNAS server does
The `iwanttoheal-mmo` TrueNAS app is the game's live online server. One Node
process serves the browser bundle and authenticated `/api` routes on port `4173`.
The reverse proxy exposes both at `https://iwanttoheal.phenomrom.com`.
SQLite persists accounts, sessions, cloud saves, boss-kill rankings, and
roguelike records under `/app/data/game.db`. The separate data mount survives
container replacement. TrueNAS Gitea remains the source repository used for
deployment; it is not the game database.
## Paths
```text
@@ -13,6 +24,9 @@ Local Gitea bare repository:
Runnable working checkout:
/mnt/usbssds/apps/iwanttoheal-mmo/app
Persistent game data:
/mnt/usbssds/apps/iwanttoheal-mmo/data
Public URL:
https://iwanttoheal.phenomrom.com
@@ -39,7 +53,7 @@ sudo find /mnt -type d -name "i-want-to-heal-mmo.git" -prune -print 2>/dev/null
Clone entirely through the local filesystem:
```sh
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal-mmo
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal-mmo/{app,data}
sudo git config --global --add safe.directory \
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
sudo git clone \
@@ -72,8 +86,10 @@ services:
iwanttoheal:
image: node:24-bookworm-slim
command: >-
sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run build && corepack pnpm start"
sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run db:init && corepack pnpm run build && corepack pnpm start"
environment:
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost"
DATA_DIR: /app/data
HOST: 0.0.0.0
PORT: "4173"
init: true
@@ -82,11 +98,21 @@ services:
restart: unless-stopped
volumes:
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
- /mnt/usbssds/apps/iwanttoheal-mmo/data:/app/data
working_dir: /app
```
This game has no server database. Do not add `db:init`, `/app/data`, cookie,
CORS, or proxy environment settings from the older game.
Do not remove the `/app/data` mount or place the SQLite database inside the source
checkout. Back up `/mnt/usbssds/apps/iwanttoheal-mmo/data/game.db` before database
migrations or destructive maintenance.
From the app checkout, create a consistent SQLite backup with:
```sh
DATA_DIR=/mnt/usbssds/apps/iwanttoheal-mmo/data \
BACKUP_DIR=/mnt/usbssds/apps/iwanttoheal-mmo/backups \
corepack pnpm run db:backup
```
The separate volume protects the old app files and data. The YAML still maps
host port `4173`, so the old and MMO apps cannot run simultaneously while both
+64 -16
View File
@@ -2,16 +2,23 @@
Playable low-poly third-person combat vertical slice for AYN Thor's dual displays.
Offline-first frontend includes three timestamped save slots, optional account sync, local/online overwrite controls, Hunter Profile statistics, boss collection logs, Settings, and PvE/PvP mode entry points.
Offline-first frontend includes three timestamped save slots, TrueNAS accounts
and cloud saves, Hunter Profile statistics, boss and roguelike leaderboards,
boss collection logs, Settings, and PvE/PvP mode entry points. Offline saves
remain playable without an account and can be uploaded after sign-in.
## Run
```bash
pnpm install
pnpm dev:api
# In a second terminal:
pnpm dev
```
The development server listens on `0.0.0.0:4173`.
The Vite development server listens on `0.0.0.0:4173` and proxies `/api` to the
local production/API server on `127.0.0.1:4174`. Both processes use the same
client API contract as TrueNAS.
## Android / AYN Thor test APK
@@ -23,6 +30,10 @@ installable debug APK:
pnpm android:apk
```
Build explicitly against the TrueNAS API with `pnpm android:apk:truenas`.
Native builds also default to `https://iwanttoheal.phenomrom.com` when no API
base override is supplied.
Output is written under `android/app/build/outputs/apk/debug/`. With Android
platform tools and a connected Thor, build and install it with:
@@ -41,12 +52,25 @@ state rather than two independent WebViews.
Complete first-install, local-Gitea clone, YAML, update, and verification steps:
[DEPLOYMENT.md](DEPLOYMENT.md).
The production preview server uses the existing deployment address and port:
The live TrueNAS web server uses the existing deployment address and port:
- Public URL: `https://iwanttoheal.phenomrom.com`
- Host/container port: `4173`
- App directory: `/mnt/usbssds/apps/iwanttoheal-mmo/app`
### Server architecture
TrueNAS is the online production game server. Its Node process serves the browser
application and authenticated `/api` routes on port `4173`; the reverse proxy
exposes both at the public URL above. SQLite data persists at `/app/data/game.db`
through the separate TrueNAS data mount.
The server owns account credentials, 30-day sessions, three cloud-save slots per
account, boss-kill rankings, and roguelike highest-round rankings. Passwords use
scrypt with per-account salts; clients store only opaque session tokens. Local
saves remain available for offline play. Gitea is a separate TrueNAS service used
for source hosting and deployment.
Clone from the TrueNAS-local Gitea bare repository into the app directory, then
deploy `compose.yaml`. The expected source path is:
@@ -54,18 +78,16 @@ deploy `compose.yaml`. The expected source path is:
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
```
Compared with the old game configuration:
Current configuration:
- use `corepack pnpm install --frozen-lockfile`, not `npm ci`;
- remove `npm run db:init` because this game has no server database;
- remove `COOKIE_SECURE`, `CORS_ORIGINS`, and `TRUST_PROXY`; the static Vite
preview server does not consume them;
- remove `/app/data`; offline saves live in each player's browser/Android WebView
storage, not on TrueNAS.
- run `pnpm db:init` before starting the production server;
- mount `/mnt/usbssds/apps/iwanttoheal-mmo/data` at `/app/data`;
- keep `CORS_ORIGINS` configured for the public site and Capacitor host;
- keep the source checkout and persistent data in separate mounts.
The existing reverse proxy can keep forwarding the public hostname to port
`4173`. Add server data and authentication environment variables only when an
actual sync API is introduced.
The existing reverse proxy forwards the public hostname to port `4173`, including
all `/api` routes.
## Publish updates to Gitea
@@ -109,18 +131,44 @@ outside the repository.
- `WASD` / left stick: move
- `Q` and `E` / D-pad: cycle party target
- `1``6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
- Gamepad: `X`, `Y`, `B`, `A`, `LB`, `RB` map to those abilities
- Gamepad: PlayStation ``, ``, ``, ``, `L1`, `R1` map to those abilities
- `M`: tactical map
- `I`: inventory and item tooltip
- `Enter` / Start: begin or reset encounter
- `Enter` / `START`: begin or reset encounter
Touch controls on lower display support party targeting, ability casting, map, and inventory.
## Prototype scope
## Character model rollout and rollback
Healer characters use the modular `Rig_Medium` renderer by default. Version 1 composes
head, upper body, lower body, headwear, back item, main hand, and offhand slots while
continuing to use Aelia's shared animation set.
Load a hunter, then open **Appearance Lab** from the main menu. The upper display shows
the real in-game healer renderer with idle, walk, and cast previews. The lower display
selects healer class and cycles every available part. **Save look** persists the current
class, **Reset** restores its authored default, and **Cancel** discards drafts. **Compare
legacy** shows the previous whole-character model without deleting the modular selection.
Some source assets currently fuse related pieces, so version 1 exposes honest combined
slots such as face + hair, shirt + arms, and pants + shoes. These can split into finer
customization slots when compatible rigged assets are added.
The previous whole-GLB renderer remains intact during rollout. Use either rollback:
```text
?characterModels=legacy
VITE_CHARACTER_MODEL_MODE=legacy
```
The query changes one browser/app launch. The environment variable produces a legacy
build. Remove the switch to return to modular rendering.
## Current game scope
- Five-member AI party with Disc Priest healer
- Animated Druid healer, Knight tank, Ranger, Rogue, and Mage party models
- One animated boss: Bulldrome, using the Bull model at 180% of its original prototype scale
- Animated bosses using canonical tracked game models
- Telegraph, charge, 0.75-second knockdown, and return-to-tank behavior
- Three-charge cycle into a five-second stack marker and 300-damage shared pounce
- Tank pressure, party-wide Cinder Nova, dispellable Ember Brand
@@ -20,7 +20,7 @@ import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/** Routes every Thor controller event into one JavaScript input service. */
/** Routes every Thor controller event into one JavaScript input service without WebView focus. */
public abstract class ControllerBridgeActivity extends BridgeActivity {
private static final float AXIS_DEAD_ZONE = 0.45f;
private static final long REPEAT_THROTTLE_MS = 55L;
@@ -43,9 +43,6 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
getWindow().setAttributes(attributes);
if (bridge != null && bridge.getWebView() != null) {
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
bridge.getWebView().setFocusable(true);
bridge.getWebView().setFocusableInTouchMode(true);
bridge.getWebView().requestFocus();
}
enterImmersiveMode();
}
@@ -60,22 +57,6 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
public void onResume() {
super.onResume();
enterImmersiveMode();
if (bridge != null && bridge.getWebView() != null) bridge.getWebView().requestFocus();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) enterImmersiveMode();
else clearHeldControllerState();
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
if (event.getActionMasked() == MotionEvent.ACTION_DOWN && bridge != null) {
bridge.getWebView().requestFocus();
}
return super.dispatchTouchEvent(event);
}
@Override
@@ -102,7 +83,9 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
dispatchNativeControllerMotion(leftStickX, leftStickY);
float rightStickX = controllerAxisValue(event, MotionEvent.AXIS_Z, MotionEvent.AXIS_RX);
float rightStickY = controllerAxisValue(event, MotionEvent.AXIS_RZ, MotionEvent.AXIS_RY);
dispatchNativeControllerMotion(leftStickX, leftStickY, rightStickX, rightStickY);
Set<String> currentTokens = new HashSet<>();
addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_X), "Button14", "Button15");
@@ -150,29 +133,35 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
if (value >= AXIS_DEAD_ZONE) tokens.add(positive);
}
private float controllerAxisValue(MotionEvent event, int primaryAxis, int fallbackAxis) {
InputDevice device = event.getDevice();
if (device != null && device.getMotionRange(primaryAxis) != null) {
return event.getAxisValue(primaryAxis);
}
return event.getAxisValue(fallbackAxis);
}
private void dispatchNativeControllerToken(String token, boolean repeat) {
if (bridge == null || bridge.getWebView() == null) return;
String script =
"window.dispatchEvent(new CustomEvent('iwt-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
bridge.getWebView().post(() -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
});
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null));
}
private void dispatchNativeControllerMotion(float x, float y) {
private void dispatchNativeControllerMotion(float moveX, float moveY, float lookX, float lookY) {
if (bridge == null || bridge.getWebView() == null) return;
String script =
"window.dispatchEvent(new CustomEvent('iwt-native-controller-motion',"
+ "{detail:{x:" + x + ",y:" + y + "}}));";
+ "{detail:{moveX:" + moveX + ",moveY:" + moveY
+ ",lookX:" + lookX + ",lookY:" + lookY + "}}));";
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null));
}
private void clearHeldControllerState() {
activeMotionTokens.clear();
lastMotionDispatchAt.clear();
dispatchNativeControllerMotion(0.0f, 0.0f);
dispatchNativeControllerMotion(0.0f, 0.0f, 0.0f, 0.0f);
if (bridge == null || bridge.getWebView() == null) return;
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(
"window.dispatchEvent(new Event('iwt-native-controller-reset'));",
+4 -1
View File
@@ -2,8 +2,10 @@ services:
iwanttoheal:
image: node:24-bookworm-slim
command: >-
sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run build && corepack pnpm start"
sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run db:init && corepack pnpm run build && corepack pnpm start"
environment:
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost"
DATA_DIR: /app/data
HOST: 0.0.0.0
PORT: "4173"
init: true
@@ -12,4 +14,5 @@ services:
restart: unless-stopped
volumes:
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
- /mnt/usbssds/apps/iwanttoheal-mmo/data:/app/data
working_dir: /app
+142
View File
@@ -0,0 +1,142 @@
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);
CREATE TABLE IF NOT EXISTS hockey_healing_records (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
highest_returns INTEGER NOT NULL DEFAULT 0 CHECK (highest_returns >= 0),
duration_seconds REAL NOT NULL DEFAULT 0 CHECK (duration_seconds >= 0),
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, slot_id),
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS hockey_healing_rank_idx
ON hockey_healing_records (highest_returns DESC, duration_seconds DESC, updated_at ASC);
CREATE TABLE IF NOT EXISTS hockey_pvp_records (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
wins INTEGER NOT NULL DEFAULT 0 CHECK (wins >= 0),
losses INTEGER NOT NULL DEFAULT 0 CHECK (losses >= 0),
boss_kills INTEGER NOT NULL DEFAULT 0 CHECK (boss_kills >= 0),
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, slot_id),
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS hockey_pvp_wins_rank_idx
ON hockey_pvp_records (wins DESC, losses ASC, updated_at ASC);
CREATE INDEX IF NOT EXISTS hockey_pvp_boss_kills_rank_idx
ON hockey_pvp_records (boss_kills DESC, wins DESC, updated_at ASC);
CREATE TABLE IF NOT EXISTS blockbreaker_records (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
highest_bricks INTEGER NOT NULL DEFAULT 0 CHECK (highest_bricks >= 0),
bricks_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
longest_seconds REAL NOT NULL DEFAULT 0 CHECK (longest_seconds >= 0),
time_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
highest_score INTEGER NOT NULL DEFAULT 0 CHECK (highest_score >= 0),
score_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, slot_id),
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS blockbreaker_bricks_rank_idx
ON blockbreaker_records (highest_bricks DESC, bricks_achieved_at ASC, account_id ASC, slot_id ASC);
CREATE INDEX IF NOT EXISTS blockbreaker_time_rank_idx
ON blockbreaker_records (longest_seconds DESC, time_achieved_at ASC, account_id ASC, slot_id ASC);
CREATE INDEX IF NOT EXISTS blockbreaker_score_rank_idx
ON blockbreaker_records (highest_score DESC, score_achieved_at ASC, account_id ASC, slot_id ASC);
CREATE TABLE IF NOT EXISTS aether_assault_records (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
highest_score INTEGER NOT NULL DEFAULT 0 CHECK (highest_score >= 0),
wave_at_best INTEGER NOT NULL DEFAULT 0 CHECK (wave_at_best >= 0),
duration_at_best REAL NOT NULL DEFAULT 0 CHECK (duration_at_best >= 0),
score_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, slot_id),
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS aether_assault_rank_idx
ON aether_assault_records (
highest_score DESC,
wave_at_best DESC,
score_achieved_at ASC,
account_id ASC,
slot_id ASC
);
+19 -4
View File
@@ -1,20 +1,32 @@
{
"name": "i-want-to-heal",
"private": true,
"version": "0.1.5",
"version": "0.1.15",
"type": "module",
"scripts": {
"predev": "node scripts/sync_basis_transcoder.mjs",
"dev": "vite --host 0.0.0.0",
"dev:api": "HOST=127.0.0.1 PORT=4174 node server/production.mjs",
"db:backup": "node scripts/backup-db.mjs",
"db:init": "node scripts/init-db.mjs",
"prebuild": "node scripts/sync_basis_transcoder.mjs",
"build": "tsc -b && vite build",
"android:sync": "pnpm run build && cap sync android",
"android:sync:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com pnpm run android:sync",
"android:apk": "pnpm run android:sync && cd android && ./gradlew --no-daemon clean assembleDebug",
"android:apk:truenas": "pnpm run android:sync:truenas && cd android && ./gradlew --no-daemon clean assembleDebug",
"android:install": "pnpm run android:apk && adb install -r android/app/build/outputs/apk/debug/*.apk",
"start": "vite preview --host ${HOST:-0.0.0.0} --port ${PORT:-4173} --strictPort",
"start": "node server/production.mjs",
"publish:gitea": "python3 scripts/publish_gitea.py",
"test": "vitest run",
"test": "vitest run && node --test server/game-api.test.mjs",
"test:watch": "vitest",
"assets:build-dungeon-kit": "node scripts/build_dungeon_kit.mjs",
"assets:build-gravehorn": "node scripts/build_gravehorn_triceratops.mjs",
"assets:build-ktx2": "node scripts/build_ktx2_game_assets.mjs",
"assets:build-priest-palette": "node scripts/build_priest_palette.mjs",
"assets:prune-party-animations": "node scripts/prune_party_animations.mjs --write",
"assets:import": "node scripts/import-game-asset.mjs"
"assets:import": "node scripts/import-game-asset.mjs",
"assets:sync-basis-transcoder": "node scripts/sync_basis_transcoder.mjs"
},
"dependencies": {
"@capacitor/android": "8.4.1",
@@ -24,17 +36,20 @@
"react": "^19.1.1",
"react-dom": "^19.1.1",
"three": "^0.179.1",
"three-stdlib": "2.36.1",
"zustand": "^5.0.8"
},
"devDependencies": {
"@capacitor/cli": "8.4.1",
"@gltf-transform/core": "^4.4.1",
"@gltf-transform/extensions": "^4.4.1",
"@gltf-transform/functions": "4.4.1",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@types/three": "^0.179.0",
"@vitejs/plugin-react": "^5.0.2",
"meshoptimizer": "^1.2.0",
"sharp": "0.34.5",
"typescript": "~5.8.3",
"vite": "^7.1.3",
"vitest": "^3.2.4"
+379
View File
@@ -29,6 +29,9 @@ importers:
three:
specifier: ^0.179.1
version: 0.179.1
three-stdlib:
specifier: 2.36.1
version: 2.36.1(three@0.179.1)
zustand:
specifier: ^5.0.8
version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
@@ -42,6 +45,9 @@ importers:
'@gltf-transform/extensions':
specifier: ^4.4.1
version: 4.4.1
'@gltf-transform/functions':
specifier: 4.4.1
version: 4.4.1
'@types/react':
specifier: ^19.1.10
version: 19.2.17
@@ -57,6 +63,9 @@ importers:
meshoptimizer:
specifier: ^1.2.0
version: 1.2.0
sharp:
specifier: 0.34.5
version: 0.34.5
typescript:
specifier: ~5.8.3
version: 5.8.3
@@ -172,6 +181,9 @@ packages:
'@dimforge/rapier3d-compat@0.12.0':
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
'@emnapi/runtime@1.11.2':
resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
@@ -334,6 +346,162 @@ packages:
'@gltf-transform/extensions@4.4.1':
resolution: {integrity: sha512-dZZ9D/NdpNeJUmQKExtISYtd3W6OxU4njk8UI3IKm6j97uVskYQ24BNi2YP40uUpdWPiREsS/DhjNhWAbGU/1A==}
'@gltf-transform/functions@4.4.1':
resolution: {integrity: sha512-CAU6hczuRz7NIEtpn7BARuwVEwRawwIcGqmoMCaEwA4XC+CSUi3xptXuf2zDG/5AftFO7IeQxXl/56rDsck0GQ==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@ionic/cli-framework-output@2.2.8':
resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==}
engines: {node: '>=16.0.0'}
@@ -601,6 +769,9 @@ packages:
'@types/fs-extra@8.1.5':
resolution: {integrity: sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==}
'@types/ndarray@1.0.14':
resolution: {integrity: sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg==}
'@types/node@26.1.1':
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
@@ -791,6 +962,9 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
cwise-compiler@1.1.3:
resolution: {integrity: sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -811,6 +985,10 @@ packages:
detect-gpu@5.0.70:
resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
draco3d@1.5.7:
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
@@ -908,6 +1086,12 @@ packages:
resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
iota-array@1.0.0:
resolution: {integrity: sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==}
is-buffer@1.1.6:
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
is-docker@2.2.1:
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
engines: {node: '>=8'}
@@ -1020,6 +1204,18 @@ packages:
engines: {node: '>=16.0.0'}
hasBin: true
ndarray-lanczos@0.3.0:
resolution: {integrity: sha512-5kBmmG3Zvyj77qxIAC4QFLKuYdDIBJwCG+DukT6jQHNa1Ft74/hPH1z5mbQXeHBt8yvGPBGVrr3wEOdJPYYZYg==}
ndarray-ops@1.2.2:
resolution: {integrity: sha512-BppWAFRjMYF7N/r6Ie51q6D4fs0iiGmeXIACKY66fLpnwIui3Wc3CXiD/30mgLbDjPpSLrsqcp3Z62+IcHZsDw==}
ndarray-pixels@5.0.1:
resolution: {integrity: sha512-IBtrpefpqlI8SPDCGjXk4v5NV5z7r3JSuCbfuEEXaM0vrOJtNGgYUa4C3Lt5H+qWdYF4BCPVFsnXhNC7QvZwkw==}
ndarray@1.0.19:
resolution: {integrity: sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==}
node-releases@2.0.50:
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
engines: {node: '>=18'}
@@ -1139,6 +1335,10 @@ packages:
engines: {node: '>=10'}
hasBin: true
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -1275,6 +1475,9 @@ packages:
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
uniq@1.0.1:
resolution: {integrity: sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==}
universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
@@ -1597,6 +1800,11 @@ snapshots:
'@dimforge/rapier3d-compat@0.12.0': {}
'@emnapi/runtime@1.11.2':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.28.1':
optional: true
@@ -1684,6 +1892,111 @@ snapshots:
'@gltf-transform/core': 4.4.1
ktx-parse: 1.1.0
'@gltf-transform/functions@4.4.1':
dependencies:
'@gltf-transform/core': 4.4.1
'@gltf-transform/extensions': 4.4.1
ktx-parse: 1.1.0
ndarray: 1.0.19
ndarray-lanczos: 0.3.0
ndarray-pixels: 5.0.1
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.11.2
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@ionic/cli-framework-output@2.2.8':
dependencies:
'@ionic/utils-terminal': 2.3.5
@@ -1958,6 +2271,8 @@ snapshots:
dependencies:
'@types/node': 26.1.1
'@types/ndarray@1.0.14': {}
'@types/node@26.1.1':
dependencies:
undici-types: 8.3.0
@@ -2146,6 +2461,10 @@ snapshots:
csstype@3.2.3: {}
cwise-compiler@1.1.3:
dependencies:
uniq: 1.0.1
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -2158,6 +2477,8 @@ snapshots:
dependencies:
webgl-constants: 1.1.1
detect-libc@2.1.2: {}
draco3d@1.5.7: {}
electron-to-chromium@1.5.389: {}
@@ -2259,6 +2580,10 @@ snapshots:
ini@4.1.3: {}
iota-array@1.0.0: {}
is-buffer@1.1.6: {}
is-docker@2.2.1: {}
is-fullwidth-code-point@3.0.0: {}
@@ -2357,6 +2682,27 @@ snapshots:
transitivePeerDependencies:
- supports-color
ndarray-lanczos@0.3.0:
dependencies:
'@types/ndarray': 1.0.14
ndarray: 1.0.19
ndarray-ops@1.2.2:
dependencies:
cwise-compiler: 1.1.3
ndarray-pixels@5.0.1:
dependencies:
'@types/ndarray': 1.0.14
ndarray: 1.0.19
ndarray-ops: 1.2.2
sharp: 0.34.5
ndarray@1.0.19:
dependencies:
iota-array: 1.0.0
is-buffer: 1.1.6
node-releases@2.0.50: {}
open@8.4.2:
@@ -2481,6 +2827,37 @@ snapshots:
semver@7.8.5: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -2609,6 +2986,8 @@ snapshots:
undici-types@8.3.0: {}
uniq@1.0.1: {}
universalify@2.0.1: {}
untildify@4.0.0: {}
+1
View File
@@ -1,2 +1,3 @@
allowBuilds:
esbuild: true
sharp: true
+17
View File
@@ -0,0 +1,17 @@
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
const dataDirectory = resolve(process.env.DATA_DIR ?? "data");
const backupDirectory = resolve(process.env.BACKUP_DIR ?? "backups");
const timestamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
const backupPath = resolve(backupDirectory, `game-${timestamp}.db`);
mkdirSync(backupDirectory, { recursive: true });
const database = new DatabaseSync(resolve(dataDirectory, "game.db"));
try {
database.exec(`VACUUM INTO '${backupPath.replaceAll("'", "''")}'`);
console.log(`SQLite backup created: ${backupPath}`);
} finally {
database.close();
}
@@ -0,0 +1,352 @@
"""Build two original low-poly creature bosses as animated runtime GLBs.
Replaces weak chicken and frog visuals while keeping stable boss IDs in game data.
Run with:
/Applications/Blender.app/Contents/MacOS/Blender --background --factory-startup \
--python scripts/blender/build_replacement_creature_bosses.py
"""
from __future__ import annotations
import json
import math
import sys
from pathlib import Path
import bpy
sys.path.insert(0, str(Path(__file__).parent))
from build_iwt2_boss_trio import ( # noqa: E402
OUT_ROOT,
actions,
armature,
cone,
ellipsoid,
export_asset,
finish,
join_parts,
plate,
prepare_materials,
reset_scene,
)
def add_metadata(asset_id: str, concept: str) -> None:
metadata_path = OUT_ROOT / asset_id / f"{asset_id}.asset.json"
metadata = json.loads(metadata_path.read_text())
metadata["sourceConcept"] = concept
metadata["license"] = "Original project-owned asset"
metadata["runtime"]["forward"] = "-Y"
metadata["runtime"]["unit"] = "meters"
metadata_path.write_text(json.dumps(metadata, indent=2) + "\n")
def build_brassbeak_basilisk() -> None:
"""Six-legged forge basilisk replacing Cluckhorn's chicken-cow model."""
asset_id = "brassbeak-basilisk"
reset_scene()
mats = prepare_materials({
"Scale": {"color": (0.035, 0.105, 0.12, 1), "metallic": 0.14, "roughness": 0.62},
"Underbelly": {"color": (0.12, 0.19, 0.18, 1), "metallic": 0.05, "roughness": 0.72},
"Copper": {"color": (0.45, 0.16, 0.055, 1), "metallic": 0.48, "roughness": 0.33},
"Brass": {"color": (0.78, 0.48, 0.09, 1), "metallic": 0.62, "roughness": 0.25},
"Blade": {"color": (0.50, 0.58, 0.55, 1), "metallic": 0.72, "roughness": 0.2},
"Furnace": {"color": (0.02, 0.82, 0.72, 1), "roughness": 0.18, "emission": (0.01, 0.72, 0.64, 1), "strength": 5.5},
})
specs = [
("Root", (0, 0, 0), (0, 0, 0.45), None),
("Body", (0, 0.06, 1.18), (0, 0.05, 2.05), "Root"),
("Head", (0, -1.0, 1.48), (0, -1.82, 1.38), "Body"),
("Jaw", (0, -1.42, 1.28), (0, -2.05, 1.12), "Head"),
("Wing.L", (-0.62, -0.08, 1.72), (-1.55, -0.28, 1.5), "Body"),
("Wing.R", (0.62, -0.08, 1.72), (1.55, -0.28, 1.5), "Body"),
("Leg.FL", (-0.62, -0.72, 1.12), (-0.88, -0.84, 0.24), "Body"),
("Leg.FR", (0.62, -0.72, 1.12), (0.88, -0.84, 0.24), "Body"),
("Leg.ML", (-0.78, 0.03, 1.06), (-1.0, 0.02, 0.22), "Body"),
("Leg.MR", (0.78, 0.03, 1.06), (1.0, 0.02, 0.22), "Body"),
("Leg.BL", (-0.66, 0.76, 1.12), (-0.9, 0.88, 0.24), "Body"),
("Leg.BR", (0.66, 0.76, 1.12), (0.9, 0.88, 0.24), "Body"),
("Tail.1", (0, 1.0, 1.3), (0, 1.85, 1.12), "Body"),
("Tail.2", (0, 1.8, 1.12), (0, 2.7, 0.92), "Tail.1"),
]
rig = armature("BrassbeakBasilisk", specs)
# Broad armored silhouette with glowing furnace seams.
ellipsoid("BasiliskBody", (0, 0.08, 1.36), (1.08, 1.43, 0.72), mats["Scale"], "Body", 2)
ellipsoid("FurnaceBelly", (0, -0.15, 1.08), (0.78, 1.08, 0.46), mats["Underbelly"], "Body", 2)
for index, y in enumerate((-0.7, -0.22, 0.28, 0.76)):
width = 0.82 + (0.12 if index in (1, 2) else 0)
plate(
f"BackPlate{index}", (0, y, 1.92 + 0.08 * math.sin(index)),
(width, 0.55, 0.27), (math.radians(84), 0, 0),
mats["Copper"] if index % 2 == 0 else mats["Brass"], "Body",
)
cone(
f"ChimneySpine{index}", (0, y, 2.02), (0, y + 0.03, 2.52 - index * 0.04),
0.13, 0, mats["Blade"], "Body", 5,
)
for side in (-1, 1):
cone(
f"FurnaceSeam{side:+d}", (side * 0.58, -0.76, 1.34), (side * 0.72, 0.72, 1.34),
0.035, 0.022, mats["Furnace"], "Body", 5,
)
# Hammerhead, brass beak, split jaw, and crown blades.
ellipsoid("HammerHead", (0, -1.28, 1.5), (0.78, 0.74, 0.56), mats["Copper"], "Head", 2)
ellipsoid("FaceMask", (0, -1.72, 1.5), (0.58, 0.34, 0.42), mats["Brass"], "Head", 1)
cone("UpperBeak", (0, -1.68, 1.51), (0, -2.58, 1.34), 0.42, 0.035, mats["Brass"], "Head", 6)
cone("LowerBeak", (0, -1.64, 1.3), (0, -2.32, 1.18), 0.3, 0.025, mats["Blade"], "Jaw", 6)
for side, suffix in ((-1, "L"), (1, "R")):
ellipsoid(f"Eye{suffix}", (side * 0.43, -1.72, 1.66), (0.09, 0.055, 0.09), mats["Furnace"], "Head", 1)
cone(
f"BrowHorn{suffix}", (side * 0.42, -1.38, 1.78), (side * 0.88, -1.7, 2.03),
0.13, 0, mats["Blade"], "Head", 5,
)
for index, (x, z) in enumerate(((-0.34, 2.0), (0, 2.14), (0.34, 2.0))):
cone(f"CrownBlade{index}", (x, -1.2, 1.8), (x * 1.35, -1.15, z + 0.54), 0.12, 0, mats["Brass"], "Head", 5)
# Blade-like vestigial wings make lateral cleaves readable from camera.
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Wing.{suffix}"
plate(
f"WingShield{suffix}", (side * 1.05, -0.08, 1.62), (0.7, 0.56, 0.13),
(math.radians(78), math.radians(side * 18), math.radians(side * 8)), mats["Copper"], bone,
)
cone(
f"WingBlade{suffix}", (side * 0.72, -0.24, 1.7), (side * 1.95, -0.58, 1.42),
0.19, 0.015, mats["Blade"], bone, 6,
)
cone(
f"WingGlow{suffix}", (side * 0.88, -0.3, 1.69), (side * 1.7, -0.52, 1.5),
0.05, 0.008, mats["Furnace"], bone, 5,
)
# Six short piston legs: stable, strange, easy to read while scuttling.
leg_rows = (("F", -0.72), ("M", 0.03), ("B", 0.76))
for row_index, (row, y) in enumerate(leg_rows):
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Leg.{row}{suffix}"
hip_x = side * (0.64 if row != "M" else 0.78)
foot_x = side * (0.98 if row != "M" else 1.1)
ellipsoid(f"Hip{row}{suffix}", (hip_x, y, 1.05), (0.3, 0.34, 0.3), mats["Copper"], bone, 1)
cone(f"Shin{row}{suffix}", (hip_x, y, 1.0), (foot_x, y - 0.04, 0.28), 0.22, 0.14, mats["Scale"], bone, 6)
ellipsoid(f"Foot{row}{suffix}", (foot_x, y - 0.22, 0.2), (0.31, 0.48, 0.19), mats["Brass"], bone, 1)
for toe_index, toe_x in enumerate((-0.13, 0.13)):
cone(
f"Toe{row}{suffix}{toe_index}", (foot_x + toe_x, y - 0.42, 0.2),
(foot_x + toe_x * 1.4, y - 0.75, 0.1), 0.055, 0.004, mats["Blade"], bone, 5,
)
cone("TailCore1", (0, 0.95, 1.3), (0, 1.85, 1.1), 0.48, 0.3, mats["Scale"], "Tail.1", 7)
cone("TailCore2", (0, 1.78, 1.1), (0, 2.72, 0.88), 0.31, 0.07, mats["Copper"], "Tail.2", 7)
cone("TailBladeTop", (0, 2.45, 0.9), (0, 3.18, 1.45), 0.2, 0.015, mats["Blade"], "Tail.2", 5)
cone("TailBladeBottom", (0, 2.45, 0.9), (0, 3.15, 0.48), 0.18, 0.015, mats["Brass"], "Tail.2", 5)
ellipsoid("TailCoreGlow", (0, 2.54, 0.91), (0.14, 0.17, 0.14), mats["Furnace"], "Tail.2", 1)
body = join_parts("BrassbeakBasilisk", rig)
clips = actions(rig, brassbeak_actions())
export_asset(
asset_id, "Brassbeak Basilisk", rig, body, clips,
[
("Body", (0, 0, 1.25), (1.25, 1.55, 0.9)),
("Head", (0, -1.65, 1.42), (0.95, 1.05, 0.75)),
("Tail", (0, 2.08, 1.0), (0.55, 1.25, 0.78)),
],
(0, 0, 1.25), 8.8, "FurnaceBurst", 20,
)
add_metadata(asset_id, "Original six-legged forge basilisk designed for I Want to Heal")
def brassbeak_actions():
return [
("Idle", 60, True, [
{"frame": 1},
{"frame": 15, "locations": {"Root": (0, 0, 0.04)}, "rotations": {"Head": (3, 0, -3), "Jaw": (7, 0, 0), "Tail.2": (0, 0, 7), "Wing.L": (0, 0, -4), "Wing.R": (0, 0, 4)}},
{"frame": 30, "rotations": {"Head": (0, 0, 3), "Jaw": (0, 0, 0), "Tail.2": (0, 0, -7)}},
{"frame": 45, "locations": {"Root": (0, 0, 0.04)}, "rotations": {"Head": (3, 0, -3), "Jaw": (7, 0, 0), "Tail.2": (0, 0, 7), "Wing.L": (0, 0, -4), "Wing.R": (0, 0, 4)}},
{"frame": 60},
]),
("Scuttle", 30, True, [
{"frame": 1, "rotations": {"Leg.FL": (-18, 0, -5), "Leg.MR": (-18, 0, 4), "Leg.BL": (-18, 0, -4), "Leg.FR": (18, 0, 5), "Leg.ML": (18, 0, -4), "Leg.BR": (18, 0, 4), "Tail.2": (0, 0, -9)}},
{"frame": 8, "locations": {"Root": (0, 0, 0.08)}, "rotations": {"Body": (-3, 0, 0)}},
{"frame": 16, "rotations": {"Leg.FL": (18, 0, 5), "Leg.MR": (18, 0, -4), "Leg.BL": (18, 0, 4), "Leg.FR": (-18, 0, -5), "Leg.ML": (-18, 0, 4), "Leg.BR": (-18, 0, -4), "Tail.2": (0, 0, 9)}},
{"frame": 23, "locations": {"Root": (0, 0, 0.08)}, "rotations": {"Body": (3, 0, 0)}},
{"frame": 30, "rotations": {"Leg.FL": (-18, 0, -5), "Leg.MR": (-18, 0, 4), "Leg.BL": (-18, 0, -4), "Leg.FR": (18, 0, 5), "Leg.ML": (18, 0, -4), "Leg.BR": (18, 0, 4), "Tail.2": (0, 0, -9)}},
]),
("BeakRend", 34, False, [
{"frame": 1},
{"frame": 9, "locations": {"Root": (0, 0.12, -0.05)}, "rotations": {"Body": (-9, 0, 0), "Head": (-24, 0, 0), "Jaw": (24, 0, 0), "Wing.L": (0, -16, -10), "Wing.R": (0, 16, 10)}},
{"frame": 15, "locations": {"Root": (0, -0.2, 0.05)}, "rotations": {"Body": (15, 0, 0), "Head": (28, 0, 0), "Jaw": (-6, 0, 0)}},
{"frame": 23, "rotations": {"Head": (-8, 0, 0), "Jaw": (12, 0, 0)}},
{"frame": 34},
]),
("FurnaceBurst", 46, False, [
{"frame": 1},
{"frame": 12, "locations": {"Root": (0, 0, 0.1)}, "scales": {"Body": (0.94, 0.94, 0.94)}, "rotations": {"Wing.L": (-18, 18, -22), "Wing.R": (-18, -18, 22), "Head": (-12, 0, 0), "Jaw": (18, 0, 0), "Tail.1": (-12, 0, 0)}},
{"frame": 20, "locations": {"Root": (0, -0.08, 0.22)}, "scales": {"Body": (1.1, 1.1, 1.1)}, "rotations": {"Wing.L": (18, -62, -64), "Wing.R": (18, 62, 64), "Head": (20, 0, 0), "Jaw": (30, 0, 0), "Tail.1": (18, 0, 0), "Tail.2": (-22, 0, 0)}},
{"frame": 30, "scales": {"Body": (0.97, 0.97, 0.97)}, "rotations": {"Wing.L": (4, -18, -20), "Wing.R": (4, 18, 20), "Jaw": (4, 0, 0), "Tail.2": (8, 0, 0)}},
{"frame": 46},
]),
("Stagger", 28, False, [
{"frame": 1},
{"frame": 6, "locations": {"Root": (0.12, 0.12, -0.09)}, "rotations": {"Body": (-14, 0, 13), "Head": (22, 0, -12), "Wing.L": (24, 0, -18), "Wing.R": (-8, 0, 12)}},
{"frame": 15, "rotations": {"Body": (7, 0, -6), "Head": (-8, 0, 5)}},
{"frame": 28},
]),
("Death", 72, False, [
{"frame": 1},
{"frame": 20, "locations": {"Root": (0.15, 0.08, -0.25)}, "rotations": {"Root": (0, 25, 32), "Body": (18, 0, 12), "Head": (24, 0, -10), "Jaw": (20, 0, 0), "Wing.L": (32, 0, -26), "Wing.R": (16, 0, 20)}},
{"frame": 46, "locations": {"Root": (0.28, 0.08, -0.78)}, "rotations": {"Root": (0, 52, 82), "Body": (30, 0, 20), "Head": (42, 0, -20), "Leg.FL": (30, 0, 0), "Leg.ML": (-24, 0, 0), "Leg.BL": (20, 0, 0), "Tail.1": (-32, 0, 0), "Tail.2": (-25, 0, 0)}},
{"frame": 72, "locations": {"Root": (0.28, 0.08, -0.82)}, "rotations": {"Root": (0, 52, 82), "Body": (30, 0, 20), "Head": (44, 0, -20), "Leg.FL": (30, 0, 0), "Leg.ML": (-24, 0, 0), "Leg.BL": (20, 0, 0), "Tail.1": (-32, 0, 0), "Tail.2": (-25, 0, 0)}},
]),
]
def build_bogbell_myconid() -> None:
"""Bell-capped fungal brute replacing Mirelord's frog model."""
asset_id = "bogbell-myconid"
reset_scene()
mats = prepare_materials({
"Bark": {"color": (0.12, 0.18, 0.095, 1), "roughness": 0.88},
"Root": {"color": (0.25, 0.31, 0.16, 1), "roughness": 0.8},
"Cap": {"color": (0.29, 0.055, 0.31, 1), "roughness": 0.6},
"CapEdge": {"color": (0.52, 0.15, 0.42, 1), "roughness": 0.52},
"Gill": {"color": (0.62, 0.55, 0.31, 1), "roughness": 0.7},
"Spore": {"color": (0.48, 1.0, 0.32, 1), "roughness": 0.16, "emission": (0.22, 0.92, 0.16, 1), "strength": 4.8},
})
specs = [
("Root", (0, 0, 0), (0, 0, 0.5), None),
("Body", (0, 0, 1.15), (0, 0, 2.25), "Root"),
("Cap", (0, -0.05, 2.18), (0, -0.05, 3.18), "Body"),
("Arm.L", (-0.58, -0.15, 1.72), (-1.28, -0.62, 0.7), "Body"),
("Arm.R", (0.58, -0.15, 1.72), (1.28, -0.62, 0.7), "Body"),
("Leg.L", (-0.38, 0.08, 1.08), (-0.58, -0.1, 0.2), "Body"),
("Leg.R", (0.38, 0.08, 1.08), (0.58, -0.1, 0.2), "Body"),
("Tendril.L", (-0.42, 0.58, 1.45), (-1.05, 1.45, 0.82), "Body"),
("Tendril.R", (0.42, 0.58, 1.45), (1.05, 1.45, 0.82), "Body"),
]
rig = armature("BogbellMyconid", specs)
# Gnarled trunk and hanging bell cap.
ellipsoid("Trunk", (0, 0.05, 1.48), (0.82, 0.68, 1.05), mats["Bark"], "Body", 2)
ellipsoid("ChestKnot", (0, -0.5, 1.62), (0.58, 0.28, 0.62), mats["Root"], "Body", 1)
cone("NeckStalk", (0, -0.02, 1.95), (0, -0.04, 2.65), 0.48, 0.36, mats["Gill"], "Cap", 8)
plate("BellCap", (0, -0.04, 2.78), (1.55, 1.38, 0.55), (0, 0, 0), mats["Cap"], "Cap", 9)
ellipsoid("CapCrown", (0, 0.02, 3.04), (1.25, 1.08, 0.42), mats["CapEdge"], "Cap", 2)
plate("GillBell", (0, -0.03, 2.58), (1.28, 1.12, 0.28), (0, 0, math.radians(180)), mats["Gill"], "Cap", 9)
for index, angle in enumerate(range(0, 360, 45)):
radians = math.radians(angle)
x, y = math.cos(radians) * 1.02, math.sin(radians) * 0.87
cone(
f"CapHorn{index}", (x * 0.9, y * 0.9, 3.12), (x * 1.38, y * 1.35, 3.34 + 0.08 * (index % 2)),
0.11, 0, mats["CapEdge"], "Cap", 5,
)
for side, suffix in ((-1, "L"), (1, "R")):
ellipsoid(f"Eye{suffix}", (side * 0.29, -0.65, 2.18), (0.095, 0.055, 0.11), mats["Spore"], "Cap", 1)
cone(
f"FaceRoot{suffix}", (side * 0.25, -0.52, 2.03), (side * 0.42, -0.78, 1.72),
0.07, 0.015, mats["Root"], "Cap", 5,
)
ellipsoid("MouthHollow", (0, -0.68, 1.94), (0.22, 0.055, 0.13), mats["Cap"], "Cap", 1)
# Root arms end in broad knuckles for readable pummel animation.
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Arm.{suffix}"
cone(f"UpperArm{suffix}", (side * 0.55, -0.12, 1.78), (side * 1.04, -0.45, 1.05), 0.3, 0.22, mats["Bark"], bone, 7)
cone(f"Forearm{suffix}", (side * 1.02, -0.44, 1.06), (side * 1.34, -0.84, 0.58), 0.24, 0.18, mats["Root"], bone, 7)
ellipsoid(f"Knuckle{suffix}", (side * 1.38, -0.91, 0.48), (0.43, 0.38, 0.32), mats["Bark"], bone, 1)
for finger in (-0.16, 0, 0.16):
cone(
f"Finger{suffix}{finger}", (side * 1.36 + finger, -1.02, 0.43),
(side * 1.46 + finger, -1.35, 0.22), 0.065, 0.012, mats["Root"], bone, 5,
)
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Leg.{suffix}"
ellipsoid(f"Hip{suffix}", (side * 0.4, 0.08, 1.0), (0.42, 0.46, 0.5), mats["Bark"], bone, 1)
cone(f"RootLeg{suffix}", (side * 0.4, 0.06, 0.95), (side * 0.62, -0.12, 0.25), 0.34, 0.22, mats["Root"], bone, 7)
for toe_index, toe_x in enumerate((-0.22, 0, 0.22)):
cone(
f"RootToe{suffix}{toe_index}", (side * 0.62 + toe_x, -0.2, 0.25),
(side * 0.72 + toe_x * 1.25, -0.78 - abs(toe_x), 0.08), 0.095, 0.015, mats["Bark"], bone, 6,
)
# Rear tendrils drag through mire. Spore sacs pulse during eruption.
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Tendril.{suffix}"
cone(f"TendrilBase{suffix}", (side * 0.4, 0.5, 1.38), (side * 0.78, 1.22, 0.82), 0.22, 0.12, mats["Root"], bone, 7)
cone(f"TendrilTip{suffix}", (side * 0.76, 1.18, 0.84), (side * 1.28, 1.85, 0.3), 0.13, 0.018, mats["Bark"], bone, 6)
ellipsoid(f"SporeSac{suffix}", (side * 0.82, 0.72, 1.25), (0.24, 0.31, 0.3), mats["Spore"], bone, 1)
for index, (x, y, z, size) in enumerate(((-0.5, 0.45, 1.88, 0.16), (0.48, 0.5, 1.72, 0.2), (-0.28, 0.62, 1.35, 0.13))):
ellipsoid(f"BodySpore{index}", (x, y, z), (size, size * 0.82, size * 1.1), mats["Spore"], "Body", 1)
body = join_parts("BogbellMyconid", rig)
clips = actions(rig, bogbell_actions())
export_asset(
asset_id, "Bogbell Myconid", rig, body, clips,
[
("Body", (0, 0, 1.45), (1.05, 0.95, 1.35)),
("Cap", (0, 0, 2.82), (1.68, 1.48, 0.72)),
("Roots", (0, 0.38, 0.62), (1.58, 1.75, 0.72)),
],
(0, 0, 1.65), 8.5, "SporeEruption", 21,
)
add_metadata(asset_id, "Original bell-capped fungal mire creature designed for I Want to Heal")
def bogbell_actions():
return [
("Idle", 60, True, [
{"frame": 1},
{"frame": 15, "locations": {"Root": (0, 0, 0.04)}, "scales": {"Cap": (1.03, 1.03, 0.98)}, "rotations": {"Cap": (2, 0, -3), "Arm.L": (0, 0, -3), "Arm.R": (0, 0, 3), "Tendril.L": (0, 0, 7), "Tendril.R": (0, 0, -7)}},
{"frame": 30, "scales": {"Cap": (0.98, 0.98, 1.03)}, "rotations": {"Cap": (-1, 0, 3), "Tendril.L": (0, 0, -7), "Tendril.R": (0, 0, 7)}},
{"frame": 45, "locations": {"Root": (0, 0, 0.04)}, "scales": {"Cap": (1.03, 1.03, 0.98)}, "rotations": {"Cap": (2, 0, -3), "Arm.L": (0, 0, -3), "Arm.R": (0, 0, 3), "Tendril.L": (0, 0, 7), "Tendril.R": (0, 0, -7)}},
{"frame": 60},
]),
("BurrowRush", 32, True, [
{"frame": 1, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (10, 0, 0), "Arm.L": (26, 0, -12), "Arm.R": (26, 0, 12), "Leg.L": (-16, 0, 0), "Leg.R": (16, 0, 0), "Tendril.L": (-18, 0, -12), "Tendril.R": (-18, 0, 12)}},
{"frame": 9, "locations": {"Root": (0, 0, -0.26)}, "rotations": {"Cap": (-9, 0, 0), "Leg.L": (16, 0, 0), "Leg.R": (-16, 0, 0), "Tendril.L": (14, 0, 10), "Tendril.R": (14, 0, -10)}},
{"frame": 17, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (10, 0, 0), "Arm.L": (26, 0, -12), "Arm.R": (26, 0, 12), "Leg.L": (-16, 0, 0), "Leg.R": (16, 0, 0), "Tendril.L": (-18, 0, -12), "Tendril.R": (-18, 0, 12)}},
{"frame": 25, "locations": {"Root": (0, 0, -0.26)}, "rotations": {"Cap": (-9, 0, 0), "Leg.L": (16, 0, 0), "Leg.R": (-16, 0, 0), "Tendril.L": (14, 0, 10), "Tendril.R": (14, 0, -10)}},
{"frame": 32, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (10, 0, 0), "Arm.L": (26, 0, -12), "Arm.R": (26, 0, 12), "Leg.L": (-16, 0, 0), "Leg.R": (16, 0, 0), "Tendril.L": (-18, 0, -12), "Tendril.R": (-18, 0, 12)}},
]),
("RootPummel", 36, False, [
{"frame": 1},
{"frame": 10, "locations": {"Root": (0, 0.1, 0.06)}, "rotations": {"Body": (-12, 0, 0), "Cap": (-8, 0, 0), "Arm.L": (-42, 0, -30), "Arm.R": (-42, 0, 30)}},
{"frame": 17, "locations": {"Root": (0, -0.12, -0.14)}, "rotations": {"Body": (22, 0, 0), "Cap": (18, 0, 0), "Arm.L": (58, 0, 16), "Arm.R": (58, 0, -16)}},
{"frame": 25, "rotations": {"Body": (-5, 0, 0), "Arm.L": (12, 0, -6), "Arm.R": (12, 0, 6)}},
{"frame": 36},
]),
("SporeEruption", 48, False, [
{"frame": 1},
{"frame": 13, "locations": {"Root": (0, 0, -0.12)}, "scales": {"Cap": (0.88, 0.88, 1.16)}, "rotations": {"Body": (-13, 0, 0), "Cap": (-15, 0, 0), "Arm.L": (-26, 0, -28), "Arm.R": (-26, 0, 28), "Tendril.L": (-28, 0, -24), "Tendril.R": (-28, 0, 24)}},
{"frame": 21, "locations": {"Root": (0, -0.04, 0.24)}, "scales": {"Cap": (1.18, 1.18, 0.9), "Body": (1.08, 1.08, 1.08)}, "rotations": {"Body": (18, 0, 0), "Cap": (19, 0, 0), "Arm.L": (18, 0, 62), "Arm.R": (18, 0, -62), "Tendril.L": (32, 0, 48), "Tendril.R": (32, 0, -48)}},
{"frame": 32, "scales": {"Cap": (0.97, 0.97, 1.04), "Body": (0.97, 0.97, 0.97)}, "rotations": {"Cap": (-5, 0, 0), "Arm.L": (4, 0, 12), "Arm.R": (4, 0, -12)}},
{"frame": 48},
]),
("Stagger", 28, False, [
{"frame": 1},
{"frame": 6, "locations": {"Root": (0.14, 0.1, -0.08)}, "rotations": {"Body": (-15, 0, 13), "Cap": (24, 0, -18), "Arm.L": (20, 0, -18), "Arm.R": (-8, 0, 12)}},
{"frame": 15, "rotations": {"Body": (7, 0, -6), "Cap": (-8, 0, 7)}},
{"frame": 28},
]),
("Death", 74, False, [
{"frame": 1},
{"frame": 20, "locations": {"Root": (0.14, 0.1, -0.3)}, "rotations": {"Root": (0, 24, 30), "Body": (20, 0, 12), "Cap": (28, 0, -18), "Arm.L": (30, 0, -26), "Arm.R": (16, 0, 20), "Tendril.L": (-24, 0, -14), "Tendril.R": (-16, 0, 18)}},
{"frame": 48, "locations": {"Root": (0.28, 0.1, -0.86)}, "rotations": {"Root": (0, 54, 84), "Body": (34, 0, 24), "Cap": (48, 0, -30), "Arm.L": (52, 0, -42), "Arm.R": (28, 0, 34), "Leg.L": (24, 0, 0), "Leg.R": (-18, 0, 0), "Tendril.L": (-40, 0, -24), "Tendril.R": (-34, 0, 26)}},
{"frame": 74, "locations": {"Root": (0.28, 0.1, -0.9)}, "rotations": {"Root": (0, 54, 84), "Body": (34, 0, 24), "Cap": (50, 0, -30), "Arm.L": (52, 0, -42), "Arm.R": (28, 0, 34), "Leg.L": (24, 0, 0), "Leg.R": (-18, 0, 0), "Tendril.L": (-40, 0, -24), "Tendril.R": (-34, 0, 26)}},
]),
]
def main() -> None:
bpy.context.preferences.filepaths.save_version = 0
OUT_ROOT.mkdir(parents=True, exist_ok=True)
build_brassbeak_basilisk()
build_bogbell_myconid()
if __name__ == "__main__":
main()
+80
View File
@@ -0,0 +1,80 @@
import { access } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
EXTMeshoptCompression,
} from "@gltf-transform/extensions";
import { dedup, mergeDocuments, prune, unpartition } from "@gltf-transform/functions";
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourceDirectory = path.join(
repositoryRoot,
"game_assets",
"models",
"original",
"environment",
"kaykit-dungeon",
);
const outputPath = path.join(sourceDirectory, "dungeon-kit-uastc.glb");
const sources = ["pillar-decorated.glb", "wall-pillar.glb", "torch-lit.glb"];
const expectedMeshNames = ["pillar_decorated", "wall_pillar", "torch_lit"];
await Promise.all(sources.map((source) => access(path.join(sourceDirectory, source))));
const io = await createGameAssetIO();
const documents = await Promise.all(sources.map((source) => io.read(path.join(sourceDirectory, source))));
const document = documents[0];
const destinationScene = document.getRoot().listScenes()[0].setName("dungeon_kit");
for (const sourceDocument of documents.slice(1)) {
const sourceScene = sourceDocument.getRoot().listScenes()[0];
const propertyMap = mergeDocuments(document, sourceDocument);
const copiedScene = propertyMap.get(sourceScene);
if (!copiedScene) throw new Error(`Could not merge scene from ${sourceScene.getName() || "unnamed source"}.`);
for (const child of [...copiedScene.listChildren()]) destinationScene.addChild(child);
copiedScene.dispose();
}
await document.transform(
dedup({ keepUniqueNames: false }),
unpartition(),
prune({ keepSolidTextures: true }),
);
const root = document.getRoot();
const meshNames = root.listMeshes().map((mesh) => mesh.getName()).sort();
const expectedSorted = [...expectedMeshNames].sort();
if (JSON.stringify(meshNames) !== JSON.stringify(expectedSorted)) {
throw new Error(`Dungeon kit mesh names changed: expected ${expectedSorted.join(", ")}; received ${meshNames.join(", ")}.`);
}
if (root.listTextures().length !== 1) {
throw new Error(`Dungeon kit must contain exactly one shared texture; received ${root.listTextures().length}.`);
}
if (root.listMaterials().length !== 1) {
throw new Error(`Dungeon kit must contain exactly one shared material; received ${root.listMaterials().length}.`);
}
await convertDocumentTexturesToKtx2(document, "iwt-dungeon-kit-");
const meshoptExtension = root.listExtensionsUsed()
.find((extension) => extension.extensionName === EXTMeshoptCompression.EXTENSION_NAME)
?? document.createExtension(EXTMeshoptCompression);
meshoptExtension
.setRequired(true)
.setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.QUANTIZE });
await io.write(outputPath, document);
const outputDocument = await io.read(outputPath);
const outputRoot = outputDocument.getRoot();
const outputTexture = outputRoot.listTextures()[0];
if (
outputRoot.listMeshes().length !== 3
|| outputRoot.listMaterials().length !== 1
|| outputRoot.listTextures().length !== 1
|| outputTexture.getMimeType() !== "image/ktx2"
) {
throw new Error("Generated dungeon kit failed structural validation.");
}
console.log(`Built ${path.relative(repositoryRoot, outputPath)}`);
console.log("Meshes: pillar_decorated, wall_pillar, torch_lit; shared textures: 1; encoding: KTX2/UASTC.");
+170
View File
@@ -0,0 +1,170 @@
import { access, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Box3, Vector3 } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
import { dedup, meshopt, prune, resample } from "@gltf-transform/functions";
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const assetDirectory = path.join(repositoryRoot, "game_assets", "models", "sketchfab-opensource");
const sourcePath = path.join(assetDirectory, "animated_triceratops_skeleton.glb");
const legacyPath = path.join(assetDirectory, "gravehorn-triceratops.glb");
const optimizedPath = path.join(assetDirectory, "gravehorn-triceratops-uastc.glb");
const requiredClips = [
"Armature|RiseUp",
"Armature|Roar",
"Armature|Walk",
"Armature|Fall",
"Gravehorn|Idle",
];
const unusedClips = new Set(["Armature|IdleGround", "Armature|RoarToWalk"]);
function sortedNames(properties) {
return properties.map((property) => property.getName()).sort();
}
function addUprightIdle(document) {
const walk = document.getRoot().listAnimations().find((animation) => animation.getName() === "Armature|Walk");
const buffer = document.getRoot().listBuffers()[0];
if (!walk || !buffer) throw new Error("Gravehorn source is missing its Walk animation or binary buffer.");
const idle = document.createAnimation("Gravehorn|Idle");
for (const [index, sourceChannel] of walk.listChannels().entries()) {
const sourceSampler = sourceChannel.getSampler();
const sourceOutput = sourceSampler?.getOutput();
const sourceValues = sourceOutput?.getArray();
const targetNode = sourceChannel.getTargetNode();
const targetPath = sourceChannel.getTargetPath();
if (!sourceSampler || !sourceOutput || !sourceValues || !targetNode || !targetPath) {
throw new Error(`Gravehorn Walk channel ${index} is incomplete.`);
}
if (sourceSampler.getInterpolation() === "CUBICSPLINE") {
throw new Error("Gravehorn upright idle builder does not support cubic animation tracks.");
}
const elementSize = sourceOutput.getElementSize();
const values = new sourceValues.constructor(elementSize * 2);
values.set(sourceValues.subarray(0, elementSize), 0);
values.set(sourceValues.subarray(0, elementSize), elementSize);
const input = document.createAccessor(`gravehorn_idle_time_${index}`)
.setType("SCALAR")
.setArray(new Float32Array([0, 1]))
.setBuffer(buffer);
const output = document.createAccessor(`gravehorn_idle_value_${index}`)
.setType(sourceOutput.getType())
.setNormalized(sourceOutput.getNormalized())
.setArray(values)
.setBuffer(buffer);
const sampler = document.createAnimationSampler(`gravehorn_idle_sampler_${index}`)
.setInput(input)
.setOutput(output)
.setInterpolation("LINEAR");
const channel = document.createAnimationChannel(`gravehorn_idle_channel_${index}`)
.setSampler(sampler)
.setTargetNode(targetNode)
.setTargetPath(targetPath);
idle.addSampler(sampler).addChannel(channel);
}
}
async function runtimeBounds(assetPath) {
const data = await readFile(assetPath);
const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
globalThis.self ??= globalThis;
globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} });
const gltf = await new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).parseAsync(arrayBuffer, "");
gltf.scene.updateMatrixWorld(true);
const bounds = new Box3().setFromObject(gltf.scene);
return {
min: bounds.min.toArray(),
max: bounds.max.toArray(),
center: bounds.getCenter(new Vector3()).toArray(),
};
}
function validateStructure(document, { optimized }) {
const root = document.getRoot();
const material = root.listMaterials()[0];
const clips = sortedNames(root.listAnimations());
const textureMimeTypes = root.listTextures().map((texture) => texture.getMimeType());
const extensionNames = new Set(root.listExtensionsUsed().map((extension) => extension.extensionName));
if (JSON.stringify(clips) !== JSON.stringify([...requiredClips].sort())) {
throw new Error(`Gravehorn animation clips changed: ${clips.join(", ")}.`);
}
if (root.listMeshes().length !== 1 || root.listMaterials().length !== 1 || root.listTextures().length !== 3 || root.listSkins().length !== 1) {
throw new Error("Gravehorn must contain one mesh, one material, three textures, and one skin.");
}
if (root.listSkins()[0].listJoints().length !== 140) {
throw new Error(`Gravehorn joint count changed: ${root.listSkins()[0].listJoints().length}.`);
}
if (material.getDoubleSided()) {
throw new Error("Gravehorn material must keep backface culling enabled.");
}
if (!extensionNames.has("EXT_meshopt_compression")) {
throw new Error("Gravehorn is missing Meshopt compression.");
}
if (optimized && (textureMimeTypes.some((mimeType) => mimeType !== "image/ktx2") || !extensionNames.has("KHR_texture_basisu"))) {
throw new Error("Optimized Gravehorn asset must contain only KTX2 textures.");
}
if (!optimized && textureMimeTypes.some((mimeType) => mimeType === "image/ktx2")) {
throw new Error("Legacy Gravehorn fallback must retain standard textures.");
}
}
await access(sourcePath);
await MeshoptDecoder.ready;
await MeshoptEncoder.ready;
const io = await createGameAssetIO();
const document = await io.read(sourcePath);
const root = document.getRoot();
const scene = root.listScenes()[0].setName("gravehorn_triceratops");
const sceneRoot = scene.listChildren()[0];
const sourceBounds = await runtimeBounds(sourcePath);
const sourceTranslation = sceneRoot.getTranslation();
sceneRoot.setTranslation([
sourceTranslation[0] - sourceBounds.center[0],
sourceTranslation[1] - sourceBounds.min[1],
sourceTranslation[2] - sourceBounds.center[2],
]);
root.listMeshes()[0].setName("gravehorn_triceratops");
root.listMaterials()[0]
.setName("gravehorn_bone")
.setDoubleSided(false)
.setEmissiveFactor([0.1, 0.06, 0.02]);
addUprightIdle(document);
for (const animation of root.listAnimations()) {
if (!unusedClips.has(animation.getName())) continue;
for (const channel of animation.listChannels()) channel.dispose();
for (const sampler of animation.listSamplers()) sampler.dispose();
animation.dispose();
}
await document.transform(
resample({ tolerance: 1e-4 }),
dedup({ keepUniqueNames: true }),
prune({ keepLeaves: true, keepSolidTextures: true }),
meshopt({ encoder: MeshoptEncoder, level: "high" }),
);
await io.write(legacyPath, document);
const legacyDocument = await io.read(legacyPath);
validateStructure(legacyDocument, { optimized: false });
const legacyBounds = await runtimeBounds(legacyPath);
if (Math.abs(legacyBounds.min[1]) > 0.015 || Math.abs(legacyBounds.center[0]) > 0.015 || Math.abs(legacyBounds.center[2]) > 0.015) {
throw new Error(`Gravehorn pivot is not grounded and centered: minY=${legacyBounds.min[1]}, centerX=${legacyBounds.center[0]}, centerZ=${legacyBounds.center[2]}.`);
}
const optimizedDocument = await io.read(legacyPath);
await convertDocumentTexturesToKtx2(optimizedDocument, "iwt-gravehorn-");
await io.write(optimizedPath, optimizedDocument);
validateStructure(await io.read(optimizedPath), { optimized: true });
const sourceSize = (await stat(sourcePath)).size;
const legacySize = (await stat(legacyPath)).size;
const optimizedSize = (await stat(optimizedPath)).size;
console.log(`Built ${path.relative(repositoryRoot, legacyPath)} (${sourceSize} -> ${legacySize} bytes).`);
console.log(`Built ${path.relative(repositoryRoot, optimizedPath)} (${optimizedSize} bytes, KTX2/UASTC).`);
+70
View File
@@ -0,0 +1,70 @@
import { access } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourceRoot = path.join(repositoryRoot, "game_assets");
const sourceAssets = [
"models/claudecraft/chars/players/druid.glb",
"models/claudecraft/chars/players/knight.glb",
"models/claudecraft/chars/players/mage.glb",
"models/claudecraft/chars/players/ranger.glb",
"models/claudecraft/chars/players/rogue.glb",
"models/claudecraft/weapons/adv_dagger.glb",
"models/claudecraft/weapons/adv_druid_staff.glb",
"models/claudecraft/weapons/adv_sword_1handed.glb",
"models/claudecraft/weapons/adv_wand.glb",
"models/claudecraft/weapons/crossbow_2handed.glb",
"models/claudecraft/weapons/shield_badge.glb",
"models/claudecraft/weapons/spellbook_open.glb",
];
function optimizedPath(sourcePath) {
return sourcePath.replace(/\.glb$/, "-uastc.glb");
}
function sortedNames(properties) {
return properties.map((property) => property.getName()).sort();
}
const io = await createGameAssetIO();
for (const relativePath of sourceAssets) {
const inputPath = path.join(sourceRoot, relativePath);
const outputRelativePath = optimizedPath(relativePath);
const outputPath = path.join(sourceRoot, outputRelativePath);
await access(inputPath);
const document = await io.read(inputPath);
const sourceRootProperties = document.getRoot();
const expected = {
animations: sortedNames(sourceRootProperties.listAnimations()),
materials: sortedNames(sourceRootProperties.listMaterials()),
meshes: sortedNames(sourceRootProperties.listMeshes()),
nodes: sortedNames(sourceRootProperties.listNodes()),
scenes: sortedNames(sourceRootProperties.listScenes()),
textureCount: sourceRootProperties.listTextures().length,
};
await convertDocumentTexturesToKtx2(document, "iwt-ktx2-");
await io.write(outputPath, document);
const outputDocument = await io.read(outputPath);
const outputRoot = outputDocument.getRoot();
const actual = {
animations: sortedNames(outputRoot.listAnimations()),
materials: sortedNames(outputRoot.listMaterials()),
meshes: sortedNames(outputRoot.listMeshes()),
nodes: sortedNames(outputRoot.listNodes()),
scenes: sortedNames(outputRoot.listScenes()),
textureCount: outputRoot.listTextures().length,
};
if (
JSON.stringify(actual) !== JSON.stringify(expected)
|| outputRoot.listTextures().some((texture) => texture.getMimeType() !== "image/ktx2")
|| outputRoot.listExtensionsUsed().some((extension) => extension.extensionName === "EXT_texture_webp")
) {
throw new Error(`Generated asset failed round-trip validation: ${outputRelativePath}`);
}
console.log(`Built game_assets/${outputRelativePath}`);
}
+66
View File
@@ -0,0 +1,66 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import { createGameAssetIO } from "./lib/ktx2.mjs";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourcePath = path.join(repositoryRoot, "game_assets/models/claudecraft/chars/players/mage.glb");
const outputPath = path.join(repositoryRoot, "game_assets/textures/claudecraft/chars/players/priest-vestments.webp");
function clamp01(value) {
return Math.max(0, Math.min(1, value));
}
function luminance(red, green, blue) {
return (red * 0.2126 + green * 0.7152 + blue * 0.0722) / 255;
}
function paintSwatch(pixels, width, channels, rectangle, shadow, highlight) {
const samples = [];
for (let y = rectangle.y; y < rectangle.y + rectangle.height; y += 1) {
for (let x = rectangle.x; x < rectangle.x + rectangle.width; x += 1) {
const offset = (y * width + x) * channels;
if (pixels[offset + 3] === 0) continue;
samples.push(luminance(pixels[offset], pixels[offset + 1], pixels[offset + 2]));
}
}
const minimum = Math.min(...samples);
const maximum = Math.max(...samples);
const range = Math.max(0.001, maximum - minimum);
for (let y = rectangle.y; y < rectangle.y + rectangle.height; y += 1) {
for (let x = rectangle.x; x < rectangle.x + rectangle.width; x += 1) {
const offset = (y * width + x) * channels;
if (pixels[offset + 3] === 0) continue;
const sourceLuminance = luminance(pixels[offset], pixels[offset + 1], pixels[offset + 2]);
const mix = clamp01((sourceLuminance - minimum) / range);
for (let channel = 0; channel < 3; channel += 1) {
pixels[offset + channel] = Math.round(shadow[channel] + (highlight[channel] - shadow[channel]) * mix);
}
}
}
}
const io = await createGameAssetIO();
const document = await io.read(sourcePath);
const sourceTexture = document.getRoot().listTextures().find((texture) => texture.getName() === "mage_texture");
if (!sourceTexture?.getImage()) throw new Error("Mage source is missing mage_texture.");
const decoded = await sharp(sourceTexture.getImage()).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const { width, height, channels } = decoded.info;
if (width !== 512 || height !== 512 || channels !== 4) {
throw new Error(`Unexpected mage palette shape: ${width}x${height}x${channels}.`);
}
const pixels = new Uint8Array(decoded.data);
paintSwatch(pixels, width, channels, { x: 0, y: 128, width: 128, height: 128 }, [48, 58, 94], [244, 239, 211]);
paintSwatch(pixels, width, channels, { x: 128, y: 128, width: 64, height: 128 }, [105, 66, 18], [255, 218, 109]);
paintSwatch(pixels, width, channels, { x: 64, y: 256, width: 64, height: 128 }, [105, 66, 18], [255, 218, 109]);
paintSwatch(pixels, width, channels, { x: 128, y: 256, width: 64, height: 128 }, [37, 71, 101], [157, 215, 221]);
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, await sharp(pixels, { raw: { width, height, channels } })
.webp({ quality: 92, smartSubsample: true })
.toBuffer());
console.log(`Built ${path.relative(repositoryRoot, outputPath)}`);
+14
View File
@@ -0,0 +1,14 @@
import { mkdirSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
const dataDirectory = resolve(process.env.DATA_DIR ?? "data");
mkdirSync(dataDirectory, { recursive: true });
const database = new DatabaseSync(resolve(dataDirectory, "game.db"));
const schema = await readFile(new URL("../db/schema.sql", import.meta.url), "utf8");
database.exec(schema);
database.close();
console.log(`Database ready: ${resolve(dataDirectory, "game.db")}`);
+72
View File
@@ -0,0 +1,72 @@
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS, EXTTextureWebP, KHRTextureBasisu } from "@gltf-transform/extensions";
import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
import sharp from "sharp";
export const TOKTX_COMMAND = process.env.TOKTX ?? "toktx";
export async function createGameAssetIO() {
await MeshoptDecoder.ready;
await MeshoptEncoder.ready;
return new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({
"meshopt.decoder": MeshoptDecoder,
"meshopt.encoder": MeshoptEncoder,
});
}
function run(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: "inherit" });
child.once("error", reject);
child.once("exit", (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} exited with code ${code ?? "unknown"}.`));
});
});
}
export async function convertDocumentTexturesToKtx2(document, temporaryPrefix) {
const textures = document.getRoot().listTextures();
if (textures.length === 0) throw new Error("Asset contains no textures to convert.");
const temporaryDirectory = await mkdtemp(path.join(tmpdir(), temporaryPrefix));
try {
for (const [index, texture] of textures.entries()) {
const sourceImage = texture.getImage();
if (!sourceImage) throw new Error(`Texture ${texture.getName() || index} contains no image data.`);
const pngPath = path.join(temporaryDirectory, `texture-${index}.png`);
const ktx2Path = path.join(temporaryDirectory, `texture-${index}.ktx2`);
await writeFile(pngPath, await sharp(sourceImage).png().toBuffer());
await run(TOKTX_COMMAND, [
"--t2",
"--encode", "uastc",
"--uastc_quality", "4",
"--zcmp", "18",
"--threads", process.env.TOKTX_THREADS ?? "4",
"--genmipmap",
"--assign_oetf", "srgb",
"--assign_primaries", "bt709",
ktx2Path,
pngPath,
]);
texture
.setName(`${texture.getName() || `texture_${index}`}_uastc`)
.setMimeType("image/ktx2")
.setImage(new Uint8Array(await readFile(ktx2Path)));
}
document.getRoot().listExtensionsUsed()
.find((extension) => extension.extensionName === EXTTextureWebP.EXTENSION_NAME)
?.dispose();
document.createExtension(KHRTextureBasisu).setRequired(true);
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
+39 -20
View File
@@ -8,7 +8,6 @@ import hashlib
import json
import os
import re
import secrets
import shutil
import subprocess
import sys
@@ -339,17 +338,6 @@ def create_release(tag: str, commit: str, message: str, token: str) -> dict[str,
return result
def multipart_asset(path: Path, media_type: str) -> tuple[bytes, str]:
boundary = f"----iwanttoheal{secrets.token_hex(12)}"
prefix = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{path.name}"\r\n'
f"Content-Type: {media_type}\r\n\r\n"
).encode()
body = prefix + path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
return body, f"multipart/form-data; boundary={boundary}"
def upload_release_asset(
release_id: int,
path: Path,
@@ -357,14 +345,45 @@ def upload_release_asset(
media_type: str,
token: str,
) -> None:
body, content_type = multipart_asset(path, media_type)
gitea_request(
f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets"
f"?name={urllib.parse.quote(path.name)}",
token=token,
method="POST",
body=body,
content_type=content_type,
curl = shutil.which("curl")
if not curl:
raise SystemExit("curl is required to upload Gitea release assets")
url = (
f"{GITEA_API}/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets"
f"?name={urllib.parse.quote(path.name)}"
)
headers = (
"Accept: application/json\n"
f"Authorization: token {token}\n"
"Expect: 100-continue\n"
)
result = subprocess.run(
[
curl,
"--silent",
"--show-error",
"--fail-with-body",
"--connect-timeout",
"30",
"--max-time",
"300",
"--header",
"@-",
"--form",
f"attachment=@{path};type={media_type}",
url,
],
cwd=REPO_ROOT,
input=headers,
capture_output=True,
text=True,
check=False,
)
if result.returncode:
details = result.stdout.strip() or result.stderr.strip()
raise SystemExit(
f"Gitea release asset upload failed (curl {result.returncode}): {details}"
)
print(f"Release asset uploaded: {path.name}")
+15
View File
@@ -0,0 +1,15 @@
import { copyFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourceDirectory = path.join(repositoryRoot, "node_modules", "three", "examples", "jsm", "libs", "basis");
const outputDirectory = path.join(repositoryRoot, "public", "basis");
const files = ["basis_transcoder.js", "basis_transcoder.wasm"];
await mkdir(outputDirectory, { recursive: true });
for (const file of files) {
await copyFile(path.join(sourceDirectory, file), path.join(outputDirectory, file));
}
console.log(`Synced Three.js Basis transcoder ${files.join(", ")} -> public/basis`);
+856
View File
@@ -0,0 +1,856 @@
import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
import { mkdirSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
const MAX_JSON_BYTES = 1024 * 1024;
const AUTH_WINDOW_MS = 15 * 60 * 1000;
const AUTH_ATTEMPTS_PER_WINDOW = 20;
const authAttempts = new Map();
function apiError(message, status = 400) {
const error = new Error(message);
error.status = status;
return error;
}
function sendJson(response, status, body) {
response.statusCode = status;
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.setHeader("X-Content-Type-Options", "nosniff");
response.end(JSON.stringify(body));
}
function configuredCorsOrigins() {
return String(process.env.CORS_ORIGINS ?? "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
}
function setCorsHeaders(request, response) {
const origin = request.headers.origin;
if (!origin) return;
const configured = configuredCorsOrigins();
if (!configured.includes("*") && !configured.includes(origin)) return;
response.setHeader("Access-Control-Allow-Origin", origin);
response.setHeader("Access-Control-Allow-Headers", "Authorization,Content-Type");
response.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
response.setHeader("Access-Control-Max-Age", "86400");
response.setHeader("Vary", "Origin");
}
async function readJson(request) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > MAX_JSON_BYTES) throw apiError("Request body is too large.", 413);
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw apiError("Request body must be valid JSON.");
}
}
function canonicalUsername(value) {
return String(value ?? "").trim().toLocaleLowerCase();
}
function validateUsername(value) {
const username = String(value ?? "").trim();
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
throw apiError("Username must be 320 letters, numbers, or underscores.");
}
return username;
}
function validatePassword(value) {
const password = String(value ?? "");
if (password.length < 10 || password.length > 128) {
throw apiError("Password must be 10128 characters.");
}
return password;
}
function passwordDigest(password, salt) {
return scryptSync(password, salt, 64).toString("hex");
}
function verifyPassword(password, account) {
const actual = Buffer.from(passwordDigest(password, account.passwordSalt), "hex");
const expected = Buffer.from(account.passwordHash, "hex");
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
function tokenHash(token) {
return createHash("sha256").update(token).digest("hex");
}
function bearerToken(request) {
const authorization = String(request.headers.authorization ?? "");
return authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : "";
}
function createSession(database, accountId) {
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + SESSION_LIFETIME_MS).toISOString();
database.prepare(`
INSERT INTO sessions (account_id, token_hash, expires_at)
VALUES (?, ?, ?)
`).run(accountId, tokenHash(token), expiresAt);
return token;
}
function currentSession(database, request) {
const token = bearerToken(request);
if (!token) return null;
return database.prepare(`
SELECT accounts.id AS accountId, accounts.username
FROM sessions
JOIN accounts ON accounts.id = sessions.account_id
WHERE sessions.token_hash = ? AND sessions.expires_at > CURRENT_TIMESTAMP
`).get(tokenHash(token)) ?? null;
}
function requireSession(database, request) {
const session = currentSession(database, request);
if (!session) throw apiError("Sign in required.", 401);
return session;
}
function clientAddress(request) {
return request.socket?.remoteAddress ?? "unknown";
}
function enforceAuthRateLimit(request) {
const now = Date.now();
const key = clientAddress(request);
const existing = authAttempts.get(key);
const bucket = existing && now - existing.startedAt < AUTH_WINDOW_MS
? existing
: { startedAt: now, count: 0 };
bucket.count += 1;
authAttempts.set(key, bucket);
if (bucket.count > AUTH_ATTEMPTS_PER_WINDOW) {
throw apiError("Too many authentication attempts. Try again later.", 429);
}
}
function register(database, payload) {
const username = validateUsername(payload?.username);
const password = validatePassword(payload?.password);
const canonical = canonicalUsername(username);
if (database.prepare("SELECT id FROM accounts WHERE canonical_username = ?").get(canonical)) {
throw apiError("Account already exists.", 409);
}
const salt = randomBytes(16).toString("hex");
const result = database.prepare(`
INSERT INTO accounts (username, canonical_username, password_hash, password_salt)
VALUES (?, ?, ?, ?)
`).run(username, canonical, passwordDigest(password, salt), salt);
const accountId = Number(result.lastInsertRowid);
return { account: { id: accountId, username }, token: createSession(database, accountId) };
}
function login(database, payload) {
const canonical = canonicalUsername(payload?.username);
const password = String(payload?.password ?? "");
const account = database.prepare(`
SELECT id, username, password_hash AS passwordHash, password_salt AS passwordSalt
FROM accounts WHERE canonical_username = ?
`).get(canonical);
if (!account || !verifyPassword(password, account)) {
throw apiError("Username or password is incorrect.", 401);
}
return {
account: { id: account.id, username: account.username },
token: createSession(database, account.id),
};
}
function validateSlotId(value) {
const slotId = Number(value);
if (!Number.isInteger(slotId) || slotId < 1 || slotId > 3) throw apiError("Invalid save slot.");
return slotId;
}
function validateSave(value, slotId) {
const schemaVersion = Number(value?.schemaVersion);
if (!value || typeof value !== "object" || schemaVersion !== 5 && schemaVersion !== 6) {
throw apiError("Save snapshot is invalid.");
}
if (Number(value.slotId) !== slotId) throw apiError("Save slot does not match request.");
if (typeof value.hunterName !== "string" || !value.hunterName.trim()) {
throw apiError("Save snapshot has no hunter name.");
}
return { ...value, schemaVersion: 6 };
}
function normalizeNonNegativeInteger(value) {
const number = Math.floor(Number(value));
return Number.isFinite(number) ? Math.max(0, number) : 0;
}
function normalizeNonNegativeNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? Math.max(0, number) : 0;
}
function mergeLeaderboardHighWater(database, accountId, slotId, save) {
const blockbreaker = database.prepare(`
SELECT highest_bricks AS highestBricks, longest_seconds AS longestSeconds, highest_score AS highestScore
FROM blockbreaker_records WHERE account_id = ? AND slot_id = ?
`).get(accountId, slotId);
const aether = database.prepare(`
SELECT highest_score AS highestScore, wave_at_best AS waveAtBest, duration_at_best AS durationAtBest
FROM aether_assault_records WHERE account_id = ? AND slot_id = ?
`).get(accountId, slotId);
const stats = save.stats && typeof save.stats === "object" ? save.stats : {};
const candidateAether = {
score: normalizeNonNegativeInteger(stats.highestAetherAssaultScore),
wave: normalizeNonNegativeInteger(stats.highestAetherAssaultWaveAtBest),
duration: normalizeNonNegativeNumber(stats.longestAetherAssaultSecondsAtBest),
};
const storedAether = {
score: normalizeNonNegativeInteger(aether?.highestScore),
wave: normalizeNonNegativeInteger(aether?.waveAtBest),
duration: normalizeNonNegativeNumber(aether?.durationAtBest),
};
const aetherRecord = candidateAether.score > storedAether.score
|| candidateAether.score === storedAether.score && candidateAether.wave > storedAether.wave
|| candidateAether.score === storedAether.score && candidateAether.wave === storedAether.wave && candidateAether.duration > storedAether.duration
? candidateAether
: storedAether;
return {
...save,
schemaVersion: 6,
stats: {
...stats,
highestBlockbreakerBricks: Math.max(
normalizeNonNegativeInteger(stats.highestBlockbreakerBricks),
normalizeNonNegativeInteger(blockbreaker?.highestBricks),
),
longestBlockbreakerSeconds: Math.max(
normalizeNonNegativeNumber(stats.longestBlockbreakerSeconds),
normalizeNonNegativeNumber(blockbreaker?.longestSeconds),
),
highestBlockbreakerScore: Math.max(
normalizeNonNegativeInteger(stats.highestBlockbreakerScore),
normalizeNonNegativeInteger(blockbreaker?.highestScore),
),
highestAetherAssaultScore: aetherRecord.score,
highestAetherAssaultWaveAtBest: aetherRecord.wave,
longestAetherAssaultSecondsAtBest: aetherRecord.duration,
},
};
}
function syncLeaderboardStats(database, accountId, slotId, save) {
database.prepare("DELETE FROM boss_kill_records WHERE account_id = ? AND slot_id = ?").run(accountId, slotId);
const insertBoss = database.prepare(`
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);
const highestHockeyReturns = normalizeNonNegativeInteger(save.stats?.highestHockeyHealingReturns);
const hockeyDurationSeconds = normalizeNonNegativeNumber(save.stats?.longestHockeyHealingSecondsAtBest);
database.prepare(`
INSERT INTO hockey_healing_records (account_id, slot_id, highest_returns, duration_seconds, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
highest_returns = excluded.highest_returns,
duration_seconds = excluded.duration_seconds,
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, highestHockeyReturns, hockeyDurationSeconds);
const hockeyPvpWins = normalizeNonNegativeInteger(save.stats?.hockeyHealingPvpWins);
const hockeyPvpLosses = normalizeNonNegativeInteger(save.stats?.hockeyHealingPvpLosses);
const hockeyPvpBossKills = normalizeNonNegativeInteger(save.stats?.hockeyHealingPvpBossKills);
database.prepare(`
INSERT INTO hockey_pvp_records (account_id, slot_id, wins, losses, boss_kills, updated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
wins = excluded.wins,
losses = excluded.losses,
boss_kills = excluded.boss_kills,
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, hockeyPvpWins, hockeyPvpLosses, hockeyPvpBossKills);
const highestBlockbreakerBricks = normalizeNonNegativeInteger(save.stats?.highestBlockbreakerBricks);
const longestBlockbreakerSeconds = normalizeNonNegativeNumber(save.stats?.longestBlockbreakerSeconds);
const highestBlockbreakerScore = normalizeNonNegativeInteger(save.stats?.highestBlockbreakerScore);
database.prepare(`
INSERT INTO blockbreaker_records (
account_id, slot_id,
highest_bricks, bricks_achieved_at,
longest_seconds, time_achieved_at,
highest_score, score_achieved_at,
updated_at
) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, CURRENT_TIMESTAMP, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
bricks_achieved_at = CASE
WHEN excluded.highest_bricks > blockbreaker_records.highest_bricks THEN CURRENT_TIMESTAMP
ELSE blockbreaker_records.bricks_achieved_at
END,
highest_bricks = MAX(blockbreaker_records.highest_bricks, excluded.highest_bricks),
time_achieved_at = CASE
WHEN excluded.longest_seconds > blockbreaker_records.longest_seconds THEN CURRENT_TIMESTAMP
ELSE blockbreaker_records.time_achieved_at
END,
longest_seconds = MAX(blockbreaker_records.longest_seconds, excluded.longest_seconds),
score_achieved_at = CASE
WHEN excluded.highest_score > blockbreaker_records.highest_score THEN CURRENT_TIMESTAMP
ELSE blockbreaker_records.score_achieved_at
END,
highest_score = MAX(blockbreaker_records.highest_score, excluded.highest_score),
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, highestBlockbreakerBricks, longestBlockbreakerSeconds, highestBlockbreakerScore);
const highestAetherScore = normalizeNonNegativeInteger(save.stats?.highestAetherAssaultScore);
const aetherWaveAtBest = normalizeNonNegativeInteger(save.stats?.highestAetherAssaultWaveAtBest);
const aetherDurationAtBest = normalizeNonNegativeNumber(save.stats?.longestAetherAssaultSecondsAtBest);
database.prepare(`
INSERT INTO aether_assault_records (
account_id, slot_id, highest_score, wave_at_best, duration_at_best, score_achieved_at, updated_at
) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
highest_score = excluded.highest_score,
wave_at_best = excluded.wave_at_best,
duration_at_best = excluded.duration_at_best,
score_achieved_at = CASE
WHEN excluded.highest_score > aether_assault_records.highest_score
OR excluded.highest_score = aether_assault_records.highest_score
AND excluded.wave_at_best > aether_assault_records.wave_at_best
THEN CURRENT_TIMESTAMP
ELSE aether_assault_records.score_achieved_at
END,
updated_at = CURRENT_TIMESTAMP
WHERE excluded.highest_score > aether_assault_records.highest_score
OR excluded.highest_score = aether_assault_records.highest_score
AND excluded.wave_at_best > aether_assault_records.wave_at_best
OR excluded.highest_score = aether_assault_records.highest_score
AND excluded.wave_at_best = aether_assault_records.wave_at_best
AND excluded.duration_at_best > aether_assault_records.duration_at_best
`).run(accountId, slotId, highestAetherScore, aetherWaveAtBest, aetherDurationAtBest);
}
function writeSave(database, accountId, slotId, rawSave) {
const validatedSave = validateSave(rawSave, slotId);
database.exec("BEGIN IMMEDIATE");
try {
const save = mergeLeaderboardHighWater(database, accountId, slotId, validatedSave);
const serialized = JSON.stringify(save);
if (Buffer.byteLength(serialized) > MAX_JSON_BYTES) throw apiError("Save snapshot is too large.", 413);
database.prepare(`
INSERT INTO hunter_saves (account_id, slot_id, hunter_name, save_json, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
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");
return save;
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
}
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 {
const save = JSON.parse(row.saveJson);
return mergeLeaderboardHighWater(database, accountId, slotId, save);
} 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 {
const save = mergeLeaderboardHighWater(database, accountId, row.slotId, JSON.parse(row.saveJson));
return [{ slotId: row.slotId, save, updatedAt: row.updatedAt }];
}
catch { return []; }
});
}
function leaderboardEntry(row, valueKey, secondaryValueKey) {
return {
rank: row.rank,
username: row.username,
hunterName: row.hunterName,
slotId: row.slotId,
value: row[valueKey],
...(secondaryValueKey ? { secondaryValue: row[secondaryValueKey] } : {}),
};
}
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,
};
}
function hockeyHealingLeaderboard(database, accountId, slotId) {
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (ORDER BY records.highest_returns DESC, records.duration_seconds DESC) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.highest_returns AS highestReturns,
records.duration_seconds AS durationSeconds,
accounts.username,
saves.hunter_name AS hunterName,
records.updated_at AS updatedAt
FROM hockey_healing_records records
JOIN accounts ON accounts.id = records.account_id
JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id
WHERE records.highest_returns > 0 OR records.duration_seconds > 0
)
SELECT * FROM ranked
ORDER BY highestReturns DESC, durationSeconds DESC, updatedAt ASC, accountId ASC, slotId ASC
`).all();
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
return {
kind: "hockey-healing",
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestReturns", "durationSeconds")),
current: current ? leaderboardEntry(current, "highestReturns", "durationSeconds") : null,
};
}
function hockeyPvpLeaderboard(database, accountId, slotId, kind) {
const winsBoard = kind === "hockey-pvp-wins";
const order = winsBoard
? "records.wins DESC, records.losses ASC"
: "records.boss_kills DESC, records.wins DESC";
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (ORDER BY ${order}) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.wins,
records.losses,
records.boss_kills AS bossKills,
accounts.username,
saves.hunter_name AS hunterName,
records.updated_at AS updatedAt
FROM hockey_pvp_records records
JOIN accounts ON accounts.id = records.account_id
JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id
WHERE ${winsBoard ? "records.wins > 0 OR records.losses > 0" : "records.boss_kills > 0"}
)
SELECT * FROM ranked
ORDER BY ${winsBoard ? "wins DESC, losses ASC" : "bossKills DESC, wins DESC"}, updatedAt ASC, accountId ASC, slotId ASC
`).all();
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
const valueKey = winsBoard ? "wins" : "bossKills";
const secondaryKey = winsBoard ? "losses" : undefined;
return {
kind,
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, valueKey, secondaryKey)),
current: current ? leaderboardEntry(current, valueKey, secondaryKey) : null,
};
}
function blockbreakerLeaderboard(database, accountId, slotId, kind) {
const boards = {
"blockbreaker-bricks": { column: "highest_bricks", valueKey: "highestBricks", achieved: "bricks_achieved_at" },
"blockbreaker-time": { column: "longest_seconds", valueKey: "longestSeconds", achieved: "time_achieved_at" },
"blockbreaker-score": { column: "highest_score", valueKey: "highestScore", achieved: "score_achieved_at" },
};
const board = boards[kind];
if (!board) throw apiError("Invalid Blockbreaker leaderboard.");
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (ORDER BY records.${board.column} DESC) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.${board.column} AS ${board.valueKey},
records.${board.achieved} AS achievedAt,
accounts.username,
saves.hunter_name AS hunterName
FROM blockbreaker_records records
JOIN accounts ON accounts.id = records.account_id
JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id
WHERE records.${board.column} > 0
)
SELECT * FROM ranked
ORDER BY ${board.valueKey} DESC, achievedAt ASC, accountId ASC, slotId ASC
`).all();
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
return {
kind,
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, board.valueKey)),
current: current ? leaderboardEntry(current, board.valueKey) : null,
};
}
function aetherAssaultLeaderboard(database, accountId, slotId) {
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (
ORDER BY records.highest_score DESC, records.wave_at_best DESC,
records.score_achieved_at ASC, records.account_id ASC, records.slot_id ASC
) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.highest_score AS highestScore,
records.wave_at_best AS waveAtBest,
records.score_achieved_at AS achievedAt,
accounts.username,
saves.hunter_name AS hunterName
FROM aether_assault_records records
JOIN accounts ON accounts.id = records.account_id
JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id
WHERE records.highest_score > 0
)
SELECT * FROM ranked
ORDER BY highestScore DESC, waveAtBest DESC, achievedAt ASC, accountId ASC, slotId ASC
`).all();
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
return {
kind: "aether-assault",
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestScore", "waveAtBest")),
current: current ? leaderboardEntry(current, "highestScore", "waveAtBest") : null,
};
}
export function createGameApiHandler(options = {}) {
const dataDirectory = resolve(options.dataDirectory ?? process.env.DATA_DIR ?? "data");
mkdirSync(dataDirectory, { recursive: true });
const database = new DatabaseSync(resolve(dataDirectory, "game.db"));
database.exec(readFileSync(new URL("../db/schema.sql", import.meta.url), "utf8"));
const hockeyPvpTickets = new Map();
const hockeyPvpMatches = new Map();
function queueResult(ticket) {
const match = ticket.matchId ? hockeyPvpMatches.get(ticket.matchId) : null;
if (!match) return { ticketId: ticket.id, status: "waiting" };
const opponentSide = ticket.side === "host" ? "guest" : "host";
const opponent = match.players[opponentSide];
return {
ticketId: ticket.id,
status: "matched",
match: {
id: match.id,
seed: match.seed,
opponentName: opponent.hunterName,
role: ticket.side,
},
};
}
function joinHockeyPvpQueue(session, payload) {
const slotId = validateSlotId(payload?.slotId);
const hunterName = String(payload?.hunterName ?? "").trim().slice(0, 20);
if (!hunterName) throw apiError("Hunter name is required.");
const existing = [...hockeyPvpTickets.values()].find((ticket) =>
ticket.accountId === session.accountId && !ticket.matchId && !ticket.cancelled && !ticket.completed);
if (existing) return queueResult(existing);
const now = Date.now();
for (const ticket of hockeyPvpTickets.values()) {
if (!ticket.matchId && now - ticket.createdAt > 30_000) {
ticket.cancelled = true;
ticket.completed = true;
}
}
const opponent = [...hockeyPvpTickets.values()].find((ticket) =>
!ticket.matchId && !ticket.cancelled && !ticket.completed && ticket.accountId !== session.accountId);
const ticket = {
id: randomBytes(18).toString("base64url"),
accountId: session.accountId,
username: session.username,
slotId,
hunterName,
createdAt: now,
matchId: null,
side: null,
cancelled: false,
completed: false,
};
hockeyPvpTickets.set(ticket.id, ticket);
if (!opponent) return queueResult(ticket);
const matchId = randomBytes(18).toString("base64url");
const match = {
id: matchId,
seed: randomBytes(4).readUInt32BE(0) || 1,
createdAt: now,
players: { host: opponent, guest: ticket },
snapshots: { host: null, guest: null },
};
opponent.matchId = matchId;
opponent.side = "host";
ticket.matchId = matchId;
ticket.side = "guest";
hockeyPvpMatches.set(matchId, match);
return queueResult(ticket);
}
function requireHockeyPvpTicket(session, ticketId) {
const ticket = hockeyPvpTickets.get(ticketId);
if (!ticket || ticket.accountId !== session.accountId || ticket.cancelled) throw apiError("Queue ticket not found.", 404);
return ticket;
}
function requireHockeyPvpMatch(session, matchId) {
const match = hockeyPvpMatches.get(matchId);
if (!match) throw apiError("PVP match not found.", 404);
const side = match.players.host.accountId === session.accountId
? "host"
: match.players.guest.accountId === session.accountId
? "guest"
: null;
if (!side) throw apiError("PVP match access denied.", 403);
return { match, side };
}
async function handle(request, response, next) {
if (!request.url?.startsWith("/api/")) return next();
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/hockey-pvp/queue" && request.method === "POST") {
return sendJson(response, 200, joinHockeyPvpQueue(session, await readJson(request)));
}
const queueMatch = path.match(/^\/api\/hockey-pvp\/queue\/([A-Za-z0-9_-]+)$/);
if (queueMatch && request.method === "GET") {
return sendJson(response, 200, queueResult(requireHockeyPvpTicket(session, queueMatch[1])));
}
if (queueMatch && request.method === "DELETE") {
const ticket = requireHockeyPvpTicket(session, queueMatch[1]);
if (ticket.matchId) throw apiError("Matched queue cannot be cancelled.", 409);
ticket.cancelled = true;
ticket.completed = true;
return sendJson(response, 200, { ok: true });
}
const pvpStateMatch = path.match(/^\/api\/hockey-pvp\/matches\/([A-Za-z0-9_-]+)\/state$/);
if (pvpStateMatch && request.method === "PUT") {
const { match, side } = requireHockeyPvpMatch(session, pvpStateMatch[1]);
const payload = await readJson(request);
if (!payload?.snapshot || typeof payload.snapshot !== "object") throw apiError("PVP snapshot is invalid.");
match.snapshots[side] = payload.snapshot;
return sendJson(response, 200, {
opponentSnapshot: match.snapshots[side === "host" ? "guest" : "host"],
hostSnapshot: match.snapshots.host,
});
}
if (path === "/api/saves" && request.method === "GET") {
return sendJson(response, 200, { slots: listSaves(database, session.accountId) });
}
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));
}
if (path === "/api/leaderboards/hockey-healing" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, hockeyHealingLeaderboard(database, session.accountId, slotId));
}
if (path === "/api/leaderboards/hockey-pvp-wins" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, hockeyPvpLeaderboard(database, session.accountId, slotId, "hockey-pvp-wins"));
}
if (path === "/api/leaderboards/hockey-pvp-boss-kills" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, hockeyPvpLeaderboard(database, session.accountId, slotId, "hockey-pvp-boss-kills"));
}
if (path === "/api/leaderboards/blockbreaker-bricks" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, blockbreakerLeaderboard(database, session.accountId, slotId, "blockbreaker-bricks"));
}
if (path === "/api/leaderboards/blockbreaker-time" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, blockbreakerLeaderboard(database, session.accountId, slotId, "blockbreaker-time"));
}
if (path === "/api/leaderboards/blockbreaker-score" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, blockbreakerLeaderboard(database, session.accountId, slotId, "blockbreaker-score"));
}
if (path === "/api/leaderboards/aether-assault" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, aetherAssaultLeaderboard(database, session.accountId, slotId));
}
return sendJson(response, 404, { error: "API route not found." });
} catch (error) {
const status = Number(error?.status) || 500;
const message = status >= 500 ? "Server error." : error.message;
if (status >= 500) console.error(error);
return sendJson(response, status, { error: message });
}
}
return {
handle,
close: () => {
hockeyPvpTickets.clear();
hockeyPvpMatches.clear();
database.close();
},
};
}
+264
View File
@@ -0,0 +1,264 @@
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, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins = 0, hockeyHealingPvpLosses = 0, hockeyHealingPvpBossKills = 0, highestBlockbreakerBricks = 0, longestBlockbreakerSeconds = 0, highestBlockbreakerScore = 0, highestAetherAssaultScore = 0, highestAetherAssaultWaveAtBest = 0, longestAetherAssaultSecondsAtBest = 0) {
return {
schemaVersion: 6,
slotId,
hunterName,
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins, hockeyHealingPvpLosses, hockeyHealingPvpBossKills, highestBlockbreakerBricks, longestBlockbreakerSeconds, highestBlockbreakerScore, highestAetherAssaultScore, highestAetherAssaultWaveAtBest, longestAetherAssaultSecondsAtBest },
};
}
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 highestHockeyReturns = 30 - index * 4;
const hockeyDuration = 180 - index * 10;
const pvpWins = 30 - index * 4;
const pvpLosses = index + 1;
const pvpBossKills = 120 - index * 12;
const blockbreakerBricks = index < 2 ? 600 : 700 - index * 100;
const blockbreakerSeconds = 360 - index * 30;
const blockbreakerScore = 20_000 - index * 2_000;
const aetherScore = index < 2 ? 50_000 : 54_000 - index * 5_000;
const aetherWave = index < 2 ? 12 : 10 - index;
const aetherDuration = 300 - index * 20;
const upload = await json("/api/saves/1", {
method: "PUT",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound, highestEndlessKills, highestHockeyReturns, hockeyDuration, pvpWins, pvpLosses, pvpBossKills, blockbreakerBricks, blockbreakerSeconds, blockbreakerScore, aetherScore, aetherWave, aetherDuration) }),
});
assert.equal(upload.response.status, 200);
players.push({ token, kills, highestRound, highestEndlessKills, highestHockeyReturns, hockeyDuration, pvpWins, pvpLosses, pvpBossKills, blockbreakerBricks, blockbreakerSeconds, blockbreakerScore, aetherScore, aetherWave, aetherDuration });
}
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 hockeyBoard = await json("/api/leaderboards/hockey-healing?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(hockeyBoard.body.kind, "hockey-healing");
assert.equal(hockeyBoard.body.top.length, 5);
assert.equal(hockeyBoard.body.top[0].value, 30);
assert.equal(hockeyBoard.body.current.rank, 6);
assert.equal(hockeyBoard.body.current.value, current.highestHockeyReturns);
assert.equal(hockeyBoard.body.current.secondaryValue, current.hockeyDuration);
const pvpWinsBoard = await json("/api/leaderboards/hockey-pvp-wins?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(pvpWinsBoard.body.kind, "hockey-pvp-wins");
assert.equal(pvpWinsBoard.body.top[0].value, 30);
assert.equal(pvpWinsBoard.body.current.value, current.pvpWins);
assert.equal(pvpWinsBoard.body.current.secondaryValue, current.pvpLosses);
const pvpKillsBoard = await json("/api/leaderboards/hockey-pvp-boss-kills?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(pvpKillsBoard.body.kind, "hockey-pvp-boss-kills");
assert.equal(pvpKillsBoard.body.top[0].value, 120);
assert.equal(pvpKillsBoard.body.current.value, current.pvpBossKills);
const blockbreakerBricksBoard = await json("/api/leaderboards/blockbreaker-bricks?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(blockbreakerBricksBoard.body.kind, "blockbreaker-bricks");
assert.equal(blockbreakerBricksBoard.body.top.length, 5);
assert.equal(blockbreakerBricksBoard.body.top[0].value, 600);
assert.equal(blockbreakerBricksBoard.body.top[0].rank, 1);
assert.equal(blockbreakerBricksBoard.body.top[1].rank, 1);
assert.equal(blockbreakerBricksBoard.body.current.rank, 6);
assert.equal(blockbreakerBricksBoard.body.current.value, current.blockbreakerBricks);
const blockbreakerTimeBoard = await json("/api/leaderboards/blockbreaker-time?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(blockbreakerTimeBoard.body.kind, "blockbreaker-time");
assert.equal(blockbreakerTimeBoard.body.top[0].value, 360);
assert.equal(blockbreakerTimeBoard.body.current.value, current.blockbreakerSeconds);
const blockbreakerScoreBoard = await json("/api/leaderboards/blockbreaker-score?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(blockbreakerScoreBoard.body.kind, "blockbreaker-score");
assert.equal(blockbreakerScoreBoard.body.top[0].value, 20_000);
assert.equal(blockbreakerScoreBoard.body.current.value, current.blockbreakerScore);
const aetherBoard = await json("/api/leaderboards/aether-assault?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(aetherBoard.body.kind, "aether-assault");
assert.equal(aetherBoard.body.top.length, 5);
assert.equal(aetherBoard.body.top[0].value, 50_000);
assert.equal(aetherBoard.body.top[0].secondaryValue, 12);
assert.equal(aetherBoard.body.top[0].username, "hunter_0");
assert.equal(aetherBoard.body.top[0].rank, 1);
assert.equal(aetherBoard.body.top[1].value, 50_000);
assert.equal(aetherBoard.body.top[1].secondaryValue, 12);
assert.equal(aetherBoard.body.top[1].username, "hunter_1");
assert.equal(aetherBoard.body.top[1].rank, 2);
assert.equal(aetherBoard.body.current.rank, 6);
assert.equal(aetherBoard.body.current.value, current.aetherScore);
assert.equal(aetherBoard.body.current.secondaryValue, current.aetherWave);
const legacySnapshot = save(1, "Hero 5", current.kills, current.highestRound, current.highestEndlessKills, current.highestHockeyReturns, current.hockeyDuration, current.pvpWins, current.pvpLosses, current.pvpBossKills);
delete legacySnapshot.stats.highestBlockbreakerBricks;
delete legacySnapshot.stats.longestBlockbreakerSeconds;
delete legacySnapshot.stats.highestBlockbreakerScore;
delete legacySnapshot.stats.highestAetherAssaultScore;
delete legacySnapshot.stats.highestAetherAssaultWaveAtBest;
delete legacySnapshot.stats.longestAetherAssaultSecondsAtBest;
legacySnapshot.schemaVersion = 5;
const legacyUpload = await json("/api/saves/1", {
method: "PUT",
headers: { Authorization: `Bearer ${current.token}`, "Content-Type": "application/json" },
body: JSON.stringify({ save: legacySnapshot }),
});
assert.equal(legacyUpload.body.save.stats.highestBlockbreakerBricks, current.blockbreakerBricks);
assert.equal(legacyUpload.body.save.stats.longestBlockbreakerSeconds, current.blockbreakerSeconds);
assert.equal(legacyUpload.body.save.stats.highestBlockbreakerScore, current.blockbreakerScore);
assert.equal(legacyUpload.body.save.schemaVersion, 6);
assert.equal(legacyUpload.body.save.stats.highestAetherAssaultScore, current.aetherScore);
assert.equal(legacyUpload.body.save.stats.highestAetherAssaultWaveAtBest, current.aetherWave);
assert.equal(legacyUpload.body.save.stats.longestAetherAssaultSecondsAtBest, current.aetherDuration);
const download = await json("/api/saves/1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(download.body.save.hunterName, "Hero 5");
});
test("Healing Hockey PVP queue pairs players and relays match snapshots", async () => {
const registerPlayer = async (username) => {
const registration = await json("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password: `long-password-${username}` }),
});
return registration.body.token;
};
const alphaToken = await registerPlayer("pvp_alpha");
const betaToken = await registerPlayer("pvp_beta");
const alphaQueue = await json("/api/hockey-pvp/queue", {
method: "POST",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ slotId: 1, hunterName: "Alpha" }),
});
assert.equal(alphaQueue.body.status, "waiting");
const betaQueue = await json("/api/hockey-pvp/queue", {
method: "POST",
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ slotId: 1, hunterName: "Beta" }),
});
assert.equal(betaQueue.body.status, "matched");
assert.equal(betaQueue.body.match.role, "guest");
assert.equal(betaQueue.body.match.opponentName, "Alpha");
const alphaMatched = await json(`/api/hockey-pvp/queue/${alphaQueue.body.ticketId}`, {
headers: { Authorization: `Bearer ${alphaToken}` },
});
assert.equal(alphaMatched.body.status, "matched");
assert.equal(alphaMatched.body.match.role, "host");
assert.equal(alphaMatched.body.match.id, betaQueue.body.match.id);
assert.equal(alphaMatched.body.match.seed, betaQueue.body.match.seed);
const hostSnapshot = { sequence: 1, party: [], puck: { goalSequence: 0 } };
await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ snapshot: hostSnapshot }),
});
const guestExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ snapshot: { sequence: 1, party: [] } }),
});
assert.deepEqual(guestExchange.body.opponentSnapshot, hostSnapshot);
assert.deepEqual(guestExchange.body.hostSnapshot, hostSnapshot);
});
test("invalid credentials cannot access server saves", async () => {
const login = await json("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "hunter_0", password: "incorrect-password" }),
});
assert.equal(login.response.status, 401);
const saves = await json("/api/saves");
assert.equal(saves.response.status, 401);
});
+62
View File
@@ -0,0 +1,62 @@
import { createReadStream, existsSync, statSync } from "node:fs";
import { createServer } from "node:http";
import { extname, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { createGameApiHandler } from "./game-api.mjs";
const distPath = fileURLToPath(new URL("../dist", import.meta.url));
const indexPath = resolve(distPath, "index.html");
const host = process.env.HOST ?? "127.0.0.1";
const port = Number(process.env.PORT ?? 4173);
const contentTypes = {
".css": "text/css; charset=utf-8",
".glb": "model/gltf-binary",
".html": "text/html; charset=utf-8",
".ico": "image/x-icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
};
function sendFile(response, filePath) {
response.statusCode = 200;
response.setHeader("Content-Type", contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream");
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("Referrer-Policy", "same-origin");
response.setHeader("X-Frame-Options", "DENY");
createReadStream(filePath).pipe(response);
}
function serveStatic(request, response) {
let requestPath;
try { requestPath = decodeURIComponent(new URL(request.url, "http://localhost").pathname); }
catch { response.statusCode = 400; return response.end("Bad request"); }
const candidate = resolve(distPath, `.${requestPath}`);
const insideDist = candidate === distPath || candidate.startsWith(`${distPath}${sep}`);
if (insideDist && existsSync(candidate) && statSync(candidate).isFile()) return sendFile(response, candidate);
if (!existsSync(indexPath)) {
response.statusCode = 503;
return response.end("Build missing. Run pnpm build.");
}
return sendFile(response, indexPath);
}
const api = createGameApiHandler();
const server = createServer((request, response) => {
void api.handle(request, response, () => serveStatic(request, response));
});
server.listen(port, host, () => console.log(`I Want To Heal listening on http://${host}:${port}`));
function shutdown() {
server.close(() => {
api.close();
process.exit(0);
});
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
+143 -22
View File
@@ -3,15 +3,19 @@ import packageJson from "../package.json";
import { DualDisplayFrame } from "./components/DualDisplayFrame";
import { FrontEnd } from "./components/FrontEnd";
import { useActiveHunter, useFrontendStore } from "./frontend/store";
import { useGameStore } from "./game/store";
import { getHockeyPvpNetworkSnapshot, useGameStore } from "./game/store";
import type { BossId } from "./game/types";
import type { DifficultySlug } from "./game/progression/loot";
import { useActionBindings } from "./game/useGameLoop";
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
import { DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
import { startSaveSyncCoordinator } from "./frontend/saveSync";
import type { HockeyPvpMatchConfig } from "./game/hockeyHealingPvp";
import { onlineRepository } from "./frontend/onlineRepository";
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
const HealerModelGallery = lazy(() => import("./components/GameScene").then((module) => ({ default: module.HealerModelGallery })));
function GameLoadingScreen() {
return (
@@ -22,49 +26,127 @@ function GameLoadingScreen() {
);
}
export default function App() {
function MainApp() {
useForcedThorDisplays();
useAuthoritativeDualScreenSync();
const screen = useFrontendStore((state) => state.screen);
const accountId = useFrontendStore((state) => state.accountId);
const hunter = useActiveHunter();
const settings = useFrontendStore((state) => state.settings);
const navigate = useFrontendStore((state) => state.navigate);
const touchActiveSave = useFrontendStore((state) => state.touchActiveSave);
const updateActiveHealerInventory = useFrontendStore((state) => state.updateActiveHealerInventory);
const recordBossVictory = useFrontendStore((state) => state.recordBossVictory);
const recordRoguelikeDefeat = useFrontendStore((state) => state.recordRoguelikeDefeat);
const recordRogueTrialsEndlessDefeat = useFrontendStore((state) => state.recordRogueTrialsEndlessDefeat);
const recordHockeyHealingDefeat = useFrontendStore((state) => state.recordHockeyHealingDefeat);
const recordHockeyPvpResult = useFrontendStore((state) => state.recordHockeyPvpResult);
const recordHockeyPvpBossKill = useFrontendStore((state) => state.recordHockeyPvpBossKill);
const recordBlockbreakerDefeat = useFrontendStore((state) => state.recordBlockbreakerDefeat);
const recordAetherAssaultDefeat = useFrontendStore((state) => state.recordAetherAssaultDefeat);
const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards);
const rewardedBossInstances = useRef(new Set<string>());
const screenRef = useRef(screen);
screenRef.current = screen;
const leaveGame = useCallback(() => {
updateActiveHealerInventory(useGameStore.getState().inventory);
const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState();
const game = useGameStore.getState();
// RPG Roguelike equipment belongs only to its current run. Never leak it
// into the hunter's permanent inventory when leaving the expedition.
if (game.runMode !== "rpg-roguelike") updateActiveHealerInventory(game.inventory);
touchActiveSave();
navigate("home");
if (accountId && activeSlotId) void uploadSlot(activeSlotId);
}, [navigate, touchActiveSave, updateActiveHealerInventory]);
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug) => {
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug, hockeyPvpMatch?: HockeyPvpMatchConfig) => {
if (!hunter) return;
const progress = hunter.healers[hunter.activeClassId];
const runMode = useFrontendStore.getState().selectedMode === "roguelike-pve" ? "roguelike" : "encounter";
const launchDifficulty = runMode === "roguelike"
const selectedMode = useFrontendStore.getState().selectedMode;
const runMode = selectedMode === "roguelike-pve"
? "rpg-roguelike"
: selectedMode === "rogue-trials"
? "rogue-trials"
: selectedMode === "hockey-healing"
? "hockey-healing"
: selectedMode === "hockey-healing-pvp"
? "hockey-healing-pvp"
: selectedMode === "blockbreaker"
? "blockbreaker"
: selectedMode === "aether-assault"
? "aether-assault"
: "encounter";
const launchDifficulty = runMode !== "encounter"
? "initiate"
: requestedDifficultySlug ?? useFrontendStore.getState().selectedDifficultySlug;
rewardedBossInstances.current.clear();
clearRecentRewards();
useGameStore.getState().configureHealer(hunter.activeClassId, hunter.hunterName, progress.inventory, bossIds, runMode, hunter.gearProgress, launchDifficulty);
useGameStore.getState().configureHealer(
hunter.activeClassId,
hunter.hunterName,
progress.inventory,
bossIds,
runMode,
hunter.gearProgress,
launchDifficulty,
hockeyPvpMatch,
);
touchActiveSave();
navigate("game");
}, [clearRecentRewards, hunter, navigate, touchActiveSave]);
useEffect(() => {
const onDualScreenLaunch = (event: Event) => {
const detail = (event as CustomEvent<{ bossIds: readonly BossId[]; difficultySlug?: DifficultySlug } | readonly BossId[]>).detail;
if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug);
const detail = (event as CustomEvent<{ bossIds: readonly BossId[]; difficultySlug?: DifficultySlug; hockeyPvpMatch?: HockeyPvpMatchConfig } | readonly BossId[]>).detail;
if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug, detail.hockeyPvpMatch);
else launchGame(detail);
};
window.addEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
}, [launchGame]);
useEffect(() => {
window.addEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
return () => window.removeEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
}, [leaveGame]);
useEffect(() => {
if (!accountId) return;
return startSaveSyncCoordinator((slotId) => useFrontendStore.getState().uploadSlot(slotId));
}, [accountId]);
useEffect(() => {
if (screen !== "game") return;
let stopped = false;
let exchangeActive = false;
const exchange = async () => {
if (stopped || exchangeActive) return;
const state = useGameStore.getState();
if (state.runMode !== "hockey-healing-pvp" || !state.hockeyPvp.matchId || state.hockeyPvp.role === "cpu") return;
const snapshot = getHockeyPvpNetworkSnapshot();
if (!snapshot) return;
exchangeActive = true;
try {
const result = await onlineRepository.exchangeHockeyPvpState(state.hockeyPvp.matchId, snapshot);
if (!stopped && result.opponentSnapshot) {
useGameStore.getState().applyHockeyPvpRemoteSnapshot(
result.opponentSnapshot,
result.hostSnapshot?.puck,
);
}
} catch {
// Last authoritative snapshot remains playable through short network gaps.
} finally {
exchangeActive = false;
}
};
void exchange();
const timer = window.setInterval(() => { void exchange(); }, 120);
return () => {
stopped = true;
window.clearInterval(timer);
};
}, [screen]);
useActionBindings(screen === "game", leaveGame);
useEffect(() => {
@@ -75,44 +157,83 @@ export default function App() {
useEffect(() => {
return useGameStore.subscribe((state, previousState) => {
const startedFreshEncounter = state.phase === "briefing" && previousState.phase !== "briefing"
|| previousState.phase === "intermission" && state.phase === "combat";
if (state.phase === "briefing" || previousState.phase === "intermission" && state.phase === "combat") {
|| previousState.phase === "intermission" && state.phase === "combat"
|| previousState.phase === "victory" && state.phase === "combat" && state.endlessMode;
if (state.phase === "briefing"
|| previousState.phase === "intermission" && state.phase === "combat"
|| previousState.phase === "victory" && state.phase === "combat" && state.endlessMode) {
rewardedBossInstances.current.clear();
}
if (startedFreshEncounter) clearRecentRewards();
if (screenRef.current !== "game") return;
if (state.runMode === "roguelike" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordRoguelikeDefeat(state.round);
}
if (state.runMode === "rpg-roguelike"
&& (state.phase === "defeat" || state.phase === "victory")
&& state.phase !== previousState.phase) {
recordRoguelikeDefeat(Math.max(1, state.rpgRun?.bossesDefeated ?? 0));
}
if (state.runMode === "rogue-trials" && state.endlessMode && state.phase === "defeat" && previousState.phase !== "defeat") {
recordRogueTrialsEndlessDefeat(state.endlessBossKills);
}
if (state.runMode === "hockey-healing" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordHockeyHealingDefeat(state.hockey.returns, state.time);
}
if (state.runMode === "hockey-healing-pvp"
&& (state.phase === "victory" || state.phase === "defeat")
&& state.phase !== previousState.phase) {
recordHockeyPvpResult(state.phase === "victory");
}
if (state.runMode === "blockbreaker" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordBlockbreakerDefeat(state.blockbreaker.bricksBroken, state.time, state.blockbreaker.score);
}
if (state.runMode === "aether-assault" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordAetherAssaultDefeat(state.aetherAssault.score, state.aetherAssault.wave, state.time);
}
// RPG rewards are generated inside the run reducer. Permanent boss loot
// here would duplicate its chest and break run-only progression.
if (state.runMode === "rpg-roguelike") return;
const bossCount = 1 + state.additionalBosses.length;
if (state.boss.hp <= 0 && previousState.boss.hp > 0) {
const primaryInstanceId = `boss-0-${state.boss.id}`;
const primaryInstanceId = state.bossInstanceId;
if (!rewardedBossInstances.current.has(primaryInstanceId)) {
rewardedBossInstances.current.add(primaryInstanceId);
if (!state.endlessMode) rewardedBossInstances.current.add(primaryInstanceId);
const defeatedBefore = (state.round - 1) * bossCount;
const rewardDifficulty = state.runMode === "roguelike" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
recordBossVictory(state.boss.id, rewardDifficulty);
if (state.runMode === "hockey-healing-pvp") recordHockeyPvpBossKill();
}
}
for (let index = 0; index < state.additionalBosses.length; index += 1) {
const entry = state.additionalBosses[index];
const previous = previousState.additionalBosses[index];
const justDefeated = entry.boss.hp <= 0 && (!previous || previous.instanceId !== entry.instanceId || previous.boss.hp > 0);
if (!justDefeated || rewardedBossInstances.current.has(entry.instanceId)) continue;
rewardedBossInstances.current.add(entry.instanceId);
if (!justDefeated || !state.endlessMode && rewardedBossInstances.current.has(entry.instanceId)) continue;
if (!state.endlessMode) rewardedBossInstances.current.add(entry.instanceId);
const defeatedBefore = (state.round - 1) * bossCount + index + 1;
const rewardDifficulty = state.runMode === "roguelike" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
recordBossVictory(entry.boss.id, rewardDifficulty);
}
});
}, [clearRecentRewards, recordBossVictory]);
}, [clearRecentRewards, recordAetherAssaultDefeat, recordBlockbreakerDefeat, recordBossVictory, recordHockeyHealingDefeat, recordHockeyPvpBossKill, recordHockeyPvpResult, recordRoguelikeDefeat, recordRogueTrialsEndlessDefeat]);
return (
<main className="prototype-shell">
<header className="prototype-header">
<main className="app-shell">
<header className="app-header">
<div><span>THOR / DUAL DISPLAY</span><strong>I Want To Heal</strong></div>
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
</header>
{screen === "game"
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} bottom={<BottomScreen />} /></Suspense>
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<BottomScreen onExit={leaveGame} />} /></Suspense>
: <FrontEnd onLaunch={launchGame} />}
</main>
);
}
export default function App() {
if (import.meta.env.DEV && new URLSearchParams(window.location.search).get("preview") === "healer-models") {
return <Suspense fallback={null}><HealerModelGallery /></Suspense>;
}
return <MainApp />;
}
+16
View File
@@ -3,3 +3,19 @@
Only assets referenced by the game ship from this folder. They are copied from the ignored `game_assets` source library, retain their source-relative path, and are imported with Vite `new URL(...)` calls.
Use `pnpm assets:import <path-within-game_assets>` to add a source asset. Add `--replace` only when deliberately updating an existing runtime copy.
## KTX2/UASTC pilot
The tracked `*-uastc.glb` files are parallel optimized copies. Their original GLBs remain the rollback path and must not be replaced or deleted during this pilot.
KTX-Software `toktx` is an authoring dependency. Rebuild source copies with:
```sh
TOKTX=/path/to/toktx pnpm assets:build-ktx2
TOKTX=/path/to/toktx pnpm assets:build-dungeon-kit
TOKTX=/path/to/toktx pnpm assets:build-gravehorn
```
Import each generated path from `game_assets/` with `pnpm assets:import <path> --replace`. Builds and dev startup copy Three.js's matching Basis transcoder into the ignored `public/basis/` generated directory.
Force all original GLBs at runtime with `?legacyGameAssets=1`. Force them in an Android/browser build with `VITE_LEGACY_GAME_ASSETS=1 pnpm build`. `legacyDungeonAssets` and `VITE_LEGACY_DUNGEON_ASSETS=1` remain supported aliases.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
# Animated Triceratops Skeleton
- Creator: Zacxophone — https://sketchfab.com/Zacxophone
- Source: https://sketchfab.com/3d-models/animated-triceratops-skeleton-06cb55f941d94dc8b95ac46f92d89e7c
- License: CC0 1.0 Universal — https://creativecommons.org/publicdomain/zero/1.0/
Runtime files named `gravehorn-triceratops*.glb` are optimized derivatives of this model.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+510 -20
View File
@@ -1,15 +1,50 @@
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { Suspense, useEffect, useLayoutEffect, useMemo, useRef } from "react";
import { Component, Suspense, useEffect, useLayoutEffect, useMemo, useRef, type ReactNode } from "react";
import * as THREE from "three";
import { ARENA_CENTER, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "../game/arena";
import {
BLOCKBREAKER_BIOMES,
blockbreakerBiomeForSeed,
type BlockbreakerArenaBiome,
type BlockbreakerBiomeFixture,
} from "../game/blockbreakerBiomes";
import { bossRoomFor, type BossRoomDefinition, type BossRoomFloor } from "../game/bossRooms";
import {
HOCKEY_ARENA_CENTER_Z,
HOCKEY_ARENA_LENGTH,
HOCKEY_ARENA_MAX_X,
HOCKEY_ARENA_MAX_Z,
HOCKEY_ARENA_MIN_X,
HOCKEY_ARENA_MIN_Z,
HOCKEY_ARENA_WIDTH,
HOCKEY_GOAL_HALF_WIDTH,
HOCKEY_HEALER_GOAL_Z,
HOCKEY_MIDLINE_Z,
HOCKEY_NPC_GOAL_Z,
} from "../game/hockeyHealing";
import { useGameStore } from "../game/store";
import { LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider";
import {
HOCKEY_PVP_ARENA_MAX_X,
HOCKEY_PVP_ARENA_MAX_Z,
HOCKEY_PVP_ARENA_MIN_X,
HOCKEY_PVP_ARENA_MIN_Z,
HOCKEY_PVP_GOAL_HALF_WIDTH,
HOCKEY_PVP_GOAL_Z,
} from "../game/hockeyHealingPvp";
import { RpgRoomPortals } from "./rpgRoguelike/RpgRoomPortals";
const ROOM_CENTER_Z = ARENA_CENTER[1];
const KAYKIT_DUNGEON_PILLAR_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/pillar-decorated.glb", import.meta.url).href;
const KAYKIT_DUNGEON_WALL_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/wall-pillar.glb", import.meta.url).href;
const KAYKIT_DUNGEON_TORCH_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/torch-lit.glb", import.meta.url).href;
const KAYKIT_DUNGEON_KIT_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/dungeon-kit-uastc.glb", import.meta.url).href;
const DUNGEON_MESH_NAMES = {
pillar: "pillar_decorated",
wall: "wall_pillar",
torch: "torch_lit",
} as const;
type ArenaFixture = {
position: readonly [number, number, number];
@@ -106,21 +141,19 @@ function firstMesh(scene: THREE.Object3D) {
return mesh as THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
}
function DungeonAssetInstances({
url,
function DungeonMeshInstances({
mesh,
fixtures,
tint,
opacity = 1,
colors,
}: {
url: string;
mesh: THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
fixtures: readonly ArenaFixture[];
tint: string;
opacity?: number;
colors?: readonly THREE.Color[];
}) {
const gltf = useGLTF(url, false, true);
const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]);
const sourceMaterial = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
const material = useMemo(() => {
const next = sourceMaterial.clone();
@@ -156,23 +189,84 @@ function DungeonAssetInstances({
return <instancedMesh ref={instances} args={[mesh.geometry, material, fixtures.length]} castShadow receiveShadow />;
}
function ArenaArchitecture({ room }: { room: BossRoomDefinition }) {
const walls = useMemo(() => ARENA_WALL_SEGMENTS.map((fixture) => ({
function LegacyDungeonAssetInstances({
url,
...props
}: Omit<Parameters<typeof DungeonMeshInstances>[0], "mesh"> & { url: string }) {
const gltf = useGLTF(url, false, true);
const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]);
return <DungeonMeshInstances mesh={mesh} {...props} />;
}
function arenaWalls(room: BossRoomDefinition, portalOpenings = false) {
return ARENA_WALL_SEGMENTS.filter((_, index) => !portalOpenings || index !== 0 && index !== 8).map((fixture) => ({
...fixture,
scaleY: room.wallHeight / 4,
})), [room.wallHeight]);
}));
}
function LegacyArenaArchitecture({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
const walls = useMemo(() => arenaWalls(room, portalOpenings), [portalOpenings, room]);
return (
<group>
<DungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
<DungeonAssetInstances url={KAYKIT_DUNGEON_PILLAR_URL} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
<DungeonAssetInstances url={KAYKIT_DUNGEON_TORCH_URL} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_PILLAR_URL} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_TORCH_URL} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
</group>
);
}
function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
function namedMesh(scene: THREE.Object3D, name: string) {
const object = scene.getObjectByName(name);
if (object instanceof THREE.Mesh) {
return object as THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
}
throw new Error(`Dungeon kit is missing mesh ${name}.`);
}
function DungeonKitArchitecture({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
const gltf = useGameGLTF(KAYKIT_DUNGEON_KIT_URL);
const walls = useMemo(() => arenaWalls(room, portalOpenings), [portalOpenings, room]);
const meshes = useMemo(() => ({
pillar: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.pillar),
wall: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.wall),
torch: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.torch),
}), [gltf.scene]);
return (
<group>
<DungeonMeshInstances mesh={meshes.wall} fixtures={walls} tint={room.wallColor} opacity={0.54} />
<DungeonMeshInstances mesh={meshes.pillar} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
<DungeonMeshInstances mesh={meshes.torch} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
</group>
);
}
class DungeonAssetErrorBoundary extends Component<{
children: ReactNode;
fallback: ReactNode;
}, { failed: boolean }> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch(error: unknown) {
console.warn("Optimized dungeon asset failed; using legacy GLBs.", error);
}
render() {
return this.state.failed ? this.props.fallback : this.props.children;
}
}
function RoomWallFallback({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
const walls = useRef<THREE.Group>(null);
const previousCameraPosition = useRef<THREE.Vector3 | null>(null);
const fixtures = useMemo(
() => ARENA_WALL_SEGMENTS.filter((_, index) => !portalOpenings || index !== 0 && index !== 8),
[portalOpenings],
);
useFrame(({ camera }) => {
if (!walls.current) return;
@@ -190,7 +284,7 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
return (
<group ref={walls}>
{ARENA_WALL_SEGMENTS.map((fixture, index) => (
{fixtures.map((fixture, index) => (
<mesh
key={index}
position={[fixture.position[0], room.wallHeight / 2, fixture.position[2]]}
@@ -205,14 +299,30 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
);
}
function RoomWalls({ room }: { room: BossRoomDefinition }) {
function LegacyRoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
return (
<Suspense fallback={<RoomWallFallback room={room} />}>
<ArenaArchitecture room={room} />
<Suspense fallback={<RoomWallFallback room={room} portalOpenings={portalOpenings} />}>
<LegacyArenaArchitecture room={room} portalOpenings={portalOpenings} />
</Suspense>
);
}
function OptimizedRoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
return (
<DungeonAssetErrorBoundary fallback={<LegacyRoomWalls room={room} portalOpenings={portalOpenings} />}>
<Suspense fallback={<RoomWallFallback room={room} portalOpenings={portalOpenings} />}>
<DungeonKitArchitecture room={room} portalOpenings={portalOpenings} />
</Suspense>
</DungeonAssetErrorBoundary>
);
}
function RoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
return LEGACY_GAME_ASSETS_FORCED
? <LegacyRoomWalls room={room} portalOpenings={portalOpenings} />
: <OptimizedRoomWalls room={room} portalOpenings={portalOpenings} />;
}
function RoomMarks({ room }: { room: BossRoomDefinition }) {
const rays = useRef<THREE.InstancedMesh>(null);
const pattern = ROOM_PATTERNS[room.floor];
@@ -306,7 +416,7 @@ function RoomScenery({ room }: { room: BossRoomDefinition }) {
);
}
function RoomFloor({ room }: { room: BossRoomDefinition }) {
function RoomFloor({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
return (
<group>
<mesh position={[0, -0.45, ROOM_CENTER_Z]} receiveShadow>
@@ -323,14 +433,384 @@ function RoomFloor({ room }: { room: BossRoomDefinition }) {
</mesh>
<RoomMarks room={room} />
<RoomScenery room={room} />
<RoomWalls room={room} />
<RoomWalls room={room} portalOpenings={portalOpenings} />
</group>
);
}
function HockeyGoal({ z, color }: { z: number; color: string }) {
const goalWidth = HOCKEY_GOAL_HALF_WIDTH * 2;
return (
<group position={[0, 0, z]}>
<mesh position={[-HOCKEY_GOAL_HALF_WIDTH, 1.05, 0]} castShadow>
<boxGeometry args={[0.18, 2.1, 0.24]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={1.7} metalness={0.45} roughness={0.32} />
</mesh>
<mesh position={[HOCKEY_GOAL_HALF_WIDTH, 1.05, 0]} castShadow>
<boxGeometry args={[0.18, 2.1, 0.24]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={1.7} metalness={0.45} roughness={0.32} />
</mesh>
<mesh position={[0, 2.03, 0]} castShadow>
<boxGeometry args={[goalWidth, 0.16, 0.22]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={1.2} metalness={0.45} roughness={0.32} />
</mesh>
<mesh position={[0, 1.05, z === HOCKEY_HEALER_GOAL_Z ? 0.08 : -0.08]}>
<planeGeometry args={[goalWidth, 2]} />
<meshBasicMaterial color={color} transparent opacity={0.14} wireframe depthWrite={false} />
</mesh>
<pointLight color={color} intensity={2.4} distance={7} position={[0, 1.3, 0]} />
</group>
);
}
function HockeyHealingRoom() {
const goalSideWidth = (HOCKEY_ARENA_WIDTH - HOCKEY_GOAL_HALF_WIDTH * 2) * 0.5;
const leftGoalSideX = HOCKEY_ARENA_MIN_X + goalSideWidth * 0.5;
const rightGoalSideX = HOCKEY_ARENA_MAX_X - goalSideWidth * 0.5;
const wallMaterial = <meshStandardMaterial color="#172d36" roughness={0.58} metalness={0.48} />;
return (
<group>
<color attach="background" args={["#020b11"]} />
<fog attach="fog" args={["#061a24", 18, 46]} />
<hemisphereLight args={["#80dfff", "#061015", 1.08]} />
<directionalLight castShadow position={[6, 12, 8]} intensity={1.8} color="#d6f7ff" shadow-mapSize={[512, 512]} />
<pointLight color="#50dfff" intensity={2.2} distance={14} position={[0, 4, HOCKEY_MIDLINE_Z]} />
<mesh position={[0, -0.42, HOCKEY_ARENA_CENTER_Z]} receiveShadow>
<boxGeometry args={[HOCKEY_ARENA_WIDTH + 1.2, 0.8, HOCKEY_ARENA_LENGTH + 1.2]} />
<meshStandardMaterial color="#061117" roughness={0.9} />
</mesh>
<mesh position={[0, -0.01, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[HOCKEY_ARENA_WIDTH, HOCKEY_ARENA_LENGTH]} />
<meshStandardMaterial color="#102731" roughness={0.68} metalness={0.3} />
</mesh>
<mesh position={[0, 0.012, HOCKEY_MIDLINE_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH - 0.5, 0.09]} />
<meshBasicMaterial color="#64e7ff" transparent opacity={0.62} />
</mesh>
<mesh position={[0, 0.018, HOCKEY_MIDLINE_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[2.45, 2.55, 48]} />
<meshBasicMaterial color="#64e7ff" transparent opacity={0.44} />
</mesh>
<mesh position={[HOCKEY_ARENA_MIN_X - 0.15, 1.5, HOCKEY_ARENA_CENTER_Z]} castShadow receiveShadow>
<boxGeometry args={[0.3, 3, HOCKEY_ARENA_LENGTH + 0.6]} />
{wallMaterial}
</mesh>
<mesh position={[HOCKEY_ARENA_MAX_X + 0.15, 1.5, HOCKEY_ARENA_CENTER_Z]} castShadow receiveShadow>
<boxGeometry args={[0.3, 3, HOCKEY_ARENA_LENGTH + 0.6]} />
{wallMaterial}
</mesh>
{[HOCKEY_ARENA_MIN_Z - 0.15, HOCKEY_ARENA_MAX_Z + 0.15].flatMap((z) => [leftGoalSideX, rightGoalSideX].map((x) => (
<mesh key={`${x}-${z}`} position={[x, 1.5, z]} castShadow receiveShadow>
<boxGeometry args={[goalSideWidth, 3, 0.3]} />
<meshStandardMaterial color="#172d36" roughness={0.58} metalness={0.48} />
</mesh>
)))}
<HockeyGoal z={HOCKEY_NPC_GOAL_Z} color="#ff6e5c" />
<HockeyGoal z={HOCKEY_HEALER_GOAL_Z} color="#67e8ff" />
</group>
);
}
const BLOCKBREAKER_BIOME_FIXTURES = [
[-12.75, -12.5, 0.94],
[-12.75, -1, 1.08],
[-12.75, 10.5, 0.9],
[12.75, -12.5, 1.02],
[12.75, -1, 0.88],
[12.75, 10.5, 1.12],
] as const;
function BlockbreakerFixtureGeometry({ fixture }: { fixture: BlockbreakerBiomeFixture }) {
if (fixture === "crystal") return <octahedronGeometry args={[1.25, 0]} />;
if (fixture === "forge") return <cylinderGeometry args={[0.72, 1.18, 2.8, 6]} />;
if (fixture === "spire") return <coneGeometry args={[1.05, 3.7, 5]} />;
if (fixture === "monolith") return <boxGeometry args={[1.25, 3.5, 1.25]} />;
return <torusGeometry args={[1.08, 0.3, 8, 18]} />;
}
function BlockbreakerBiomeFixtures({ biome }: { biome: BlockbreakerArenaBiome }) {
const mesh = useRef<THREE.InstancedMesh>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
useLayoutEffect(() => {
if (!mesh.current) return;
BLOCKBREAKER_BIOME_FIXTURES.forEach(([x, z, scale], index) => {
transform.position.set(x, biome.fixture === "reactor" ? 1.45 : 1.75, z);
transform.rotation.set(
biome.fixture === "reactor" ? 0 : index % 2 === 0 ? -0.08 : 0.08,
index * 0.73,
biome.fixture === "reactor" ? 0 : index % 2 === 0 ? 0.06 : -0.06,
);
transform.scale.setScalar(scale);
transform.updateMatrix();
mesh.current!.setMatrixAt(index, transform.matrix);
});
mesh.current.instanceMatrix.needsUpdate = true;
mesh.current.computeBoundingSphere();
}, [biome.fixture, transform]);
return (
<instancedMesh ref={mesh} args={[undefined, undefined, BLOCKBREAKER_BIOME_FIXTURES.length]} castShadow receiveShadow>
<BlockbreakerFixtureGeometry fixture={biome.fixture} />
<meshStandardMaterial
color={biome.fixtureColor}
emissive={biome.fixtureEmissive}
emissiveIntensity={0.72}
roughness={biome.fixture === "monolith" ? 0.26 : 0.48}
metalness={biome.fixture === "forge" || biome.fixture === "reactor" ? 0.42 : 0.12}
/>
</instancedMesh>
);
}
function BrightArcadeRoom({
variant,
biome = BLOCKBREAKER_BIOMES[0],
}: {
variant: "blockbreaker" | "aether-assault";
biome?: BlockbreakerArenaBiome;
}) {
const roomWidth = HOCKEY_ARENA_WIDTH + 10;
const roomLength = HOCKEY_ARENA_LENGTH + 12;
const roomMinX = HOCKEY_ARENA_MIN_X - 5;
const roomMaxX = HOCKEY_ARENA_MAX_X + 5;
const roomMinZ = HOCKEY_ARENA_MIN_Z - 6;
const roomMaxZ = HOCKEY_ARENA_MAX_Z + 6;
const wallHeight = 4.8;
const wallMaterial = (
<meshStandardMaterial
color={biome.wall}
emissive={biome.wallEmissive}
emissiveIntensity={biome.wallEmissiveIntensity}
roughness={0.62}
metalness={0.12}
/>
);
return (
<group key={variant === "blockbreaker" ? biome.id : variant}>
<color attach="background" args={[biome.background]} />
<fog attach="fog" args={[biome.fog, 34, 72]} />
<ambientLight color={biome.ambient} intensity={biome.ambientIntensity} />
<hemisphereLight args={[biome.sky, biome.ground, biome.hemisphereIntensity]} />
<directionalLight
castShadow
position={[8, 15, 10]}
intensity={biome.keyLightIntensity}
color={biome.keyLight}
shadow-mapSize={[512, 512]}
/>
<pointLight color={biome.fillLightA} intensity={biome.fillLightIntensityA} distance={30} position={[-8, 7, 7]} />
<pointLight color={biome.fillLightB} intensity={biome.fillLightIntensityB} distance={30} position={[8, 7, -9]} />
<mesh position={[0, -0.48, HOCKEY_ARENA_CENTER_Z]} receiveShadow>
<boxGeometry args={[roomWidth + 1.2, 0.9, roomLength + 1.2]} />
<meshStandardMaterial color={biome.foundation} roughness={0.92} metalness={0.04} />
</mesh>
<mesh position={[0, -0.015, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[roomWidth, roomLength]} />
<meshStandardMaterial color={biome.floor} roughness={biome.floorRoughness} metalness={biome.floorMetalness} />
</mesh>
<mesh position={[0, 0.006, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH, HOCKEY_ARENA_LENGTH]} />
<meshStandardMaterial color={biome.playfield} transparent opacity={0.72} roughness={0.52} metalness={0.05} />
</mesh>
{[HOCKEY_ARENA_MIN_X, HOCKEY_ARENA_MAX_X].map((x) => (
<mesh key={`lane-x-${x}`} position={[x, 0.025, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[0.1, HOCKEY_ARENA_LENGTH]} />
<meshBasicMaterial color={biome.boundary} transparent opacity={0.72} toneMapped={false} />
</mesh>
))}
{[HOCKEY_ARENA_MIN_Z, HOCKEY_ARENA_MAX_Z].map((z) => (
<mesh key={`lane-z-${z}`} position={[0, 0.025, z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH, 0.1]} />
<meshBasicMaterial color={biome.boundary} transparent opacity={0.72} toneMapped={false} />
</mesh>
))}
{variant === "blockbreaker" ? (
<mesh position={[0, 0.028, HOCKEY_MIDLINE_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH, 0.12]} />
<meshBasicMaterial color={biome.midline} transparent opacity={0.86} toneMapped={false} />
</mesh>
) : (
<group>
{[-6.7, -3.35, 0, 3.35, 6.7].map((x) => (
<mesh key={`aether-lane-${x}`} position={[x, 0.029, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[0.055, HOCKEY_ARENA_LENGTH - 0.8]} />
<meshBasicMaterial color="#c6fbff" transparent opacity={x === 0 ? 0.58 : 0.3} toneMapped={false} />
</mesh>
))}
{[-10.7, -8.45, -6.2, -3.95].map((z) => (
<mesh key={`aether-rank-${z}`} position={[0, 0.03, z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH - 1, 0.045]} />
<meshBasicMaterial color="#fff2bf" transparent opacity={0.36} toneMapped={false} />
</mesh>
))}
</group>
)}
{[roomMinX - 0.18, roomMaxX + 0.18].map((x) => (
<mesh key={`room-x-${x}`} position={[x, wallHeight * 0.5, HOCKEY_ARENA_CENTER_Z]} castShadow receiveShadow>
<boxGeometry args={[0.36, wallHeight, roomLength + 0.7]} />
{wallMaterial}
</mesh>
))}
{[roomMinZ - 0.18, roomMaxZ + 0.18].map((z) => (
<mesh key={`room-z-${z}`} position={[0, wallHeight * 0.5, z]} castShadow receiveShadow>
<boxGeometry args={[roomWidth + 0.7, wallHeight, 0.36]} />
{wallMaterial}
</mesh>
))}
{[roomMinX - 0.36, roomMaxX + 0.36].map((x) => (
<mesh key={`light-x-${x}`} position={[x, 3.7, HOCKEY_ARENA_CENTER_Z]}>
<boxGeometry args={[0.12, 0.18, roomLength - 1]} />
<meshBasicMaterial color={biome.railA} toneMapped={false} />
</mesh>
))}
{[roomMinZ - 0.36, roomMaxZ + 0.36].map((z) => (
<mesh key={`light-z-${z}`} position={[0, 3.7, z]}>
<boxGeometry args={[roomWidth - 1, 0.18, 0.12]} />
<meshBasicMaterial color={biome.railB} toneMapped={false} />
</mesh>
))}
{variant === "blockbreaker" && <BlockbreakerBiomeFixtures biome={biome} />}
</group>
);
}
function HealingHockeyPvpRoom() {
const width = HOCKEY_PVP_ARENA_MAX_X - HOCKEY_PVP_ARENA_MIN_X;
const length = HOCKEY_PVP_ARENA_MAX_Z - HOCKEY_PVP_ARENA_MIN_Z;
const goalSideWidth = (width - HOCKEY_PVP_GOAL_HALF_WIDTH * 2) * 0.5;
const sideCenters = [
HOCKEY_PVP_ARENA_MIN_X + goalSideWidth * 0.5,
HOCKEY_PVP_ARENA_MAX_X - goalSideWidth * 0.5,
];
return (
<group>
<color attach="background" args={["#040912"]} />
<fog attach="fog" args={["#071522", 28, 68]} />
<hemisphereLight args={["#8deaff", "#130812", 1.1]} />
<directionalLight castShadow position={[7, 14, 10]} intensity={1.9} color="#d8fbff" shadow-mapSize={[512, 512]} />
<pointLight color="#62e7ff" intensity={2.5} distance={18} position={[0, 4, 13]} />
<pointLight color="#ff6f83" intensity={2.5} distance={18} position={[0, 4, -13]} />
<mesh position={[0, -0.42, 0]} receiveShadow>
<boxGeometry args={[width + 1.2, 0.8, length + 1.2]} />
<meshStandardMaterial color="#070d17" roughness={0.9} />
</mesh>
<mesh position={[0, -0.01, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[width, length]} />
<meshStandardMaterial color="#112632" roughness={0.7} metalness={0.32} />
</mesh>
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[width - 0.5, 0.11]} />
<meshBasicMaterial color="#f4d978" transparent opacity={0.7} />
</mesh>
{[-11, 11].map((z) => <mesh key={z} position={[0, 0.014, z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[width - 0.8, 0.07]} />
<meshBasicMaterial color={z > 0 ? "#64e7ff" : "#ff7688"} transparent opacity={0.38} />
</mesh>)}
{[HOCKEY_PVP_ARENA_MIN_X - 0.15, HOCKEY_PVP_ARENA_MAX_X + 0.15].map((x) => (
<mesh key={x} position={[x, 1.5, 0]} castShadow receiveShadow>
<boxGeometry args={[0.3, 3, length + 0.6]} />
<meshStandardMaterial color="#172b38" roughness={0.58} metalness={0.48} />
</mesh>
))}
{[-HOCKEY_PVP_ARENA_MAX_Z - 0.15, HOCKEY_PVP_ARENA_MAX_Z + 0.15].flatMap((z) => sideCenters.map((x) => (
<mesh key={`${x}-${z}`} position={[x, 1.5, z]} castShadow receiveShadow>
<boxGeometry args={[goalSideWidth, 3, 0.3]} />
<meshStandardMaterial color="#172b38" roughness={0.58} metalness={0.48} />
</mesh>
)))}
<HockeyGoal z={-HOCKEY_PVP_GOAL_Z} color="#ff647b" />
<HockeyGoal z={HOCKEY_PVP_GOAL_Z} color="#62e7ff" />
<HockeyPvpScoreboards />
</group>
);
}
function HockeyPvpScoreboards() {
const localGoals = useGameStore((state) => state.hockeyPvp.opponentGoalsConceded);
const opponentGoals = useGameStore((state) => state.hockeyPvp.localGoalsConceded);
const texture = useMemo(() => {
const canvas = document.createElement("canvas");
canvas.width = 512;
canvas.height = 192;
const next = new THREE.CanvasTexture(canvas);
next.colorSpace = THREE.SRGBColorSpace;
next.minFilter = THREE.LinearMipmapLinearFilter;
next.magFilter = THREE.LinearFilter;
return next;
}, []);
useEffect(() => {
const canvas = texture.image as HTMLCanvasElement;
const context = canvas.getContext("2d");
if (!context) return;
const gradient = context.createLinearGradient(0, 0, canvas.width, canvas.height);
gradient.addColorStop(0, "#061d28");
gradient.addColorStop(0.5, "#05080d");
gradient.addColorStop(1, "#2b0a17");
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
context.strokeStyle = "#89efff";
context.lineWidth = 5;
context.strokeRect(5, 5, canvas.width - 10, canvas.height - 10);
context.fillStyle = "#a8c4c9";
context.font = "700 20px Inter, sans-serif";
context.textAlign = "center";
context.fillText("HEALING HOCKEY", canvas.width / 2, 31);
context.fillStyle = "#74ecff";
context.font = "700 18px Inter, sans-serif";
context.fillText("YOU", 132, 58);
context.fillStyle = "#ff819f";
context.fillText("RIVAL", 380, 58);
context.font = "700 94px Impact, Inter, sans-serif";
context.fillStyle = "#eaffff";
context.fillText(String(Math.min(99, localGoals)).padStart(2, "0"), 132, 151);
context.fillStyle = "#ffedf4";
context.fillText(String(Math.min(99, opponentGoals)).padStart(2, "0"), 380, 151);
context.fillStyle = "#f3d87c";
context.font = "700 56px Inter, sans-serif";
context.fillText("", 256, 137);
texture.needsUpdate = true;
}, [localGoals, opponentGoals, texture]);
useEffect(() => () => texture.dispose(), [texture]);
return <>{([-1, 1] as const).map((side) => (
<group
key={side}
position={[side * 9.78, 4.05, 5.5]}
rotation={[0, side < 0 ? Math.PI / 2 : -Math.PI / 2, 0]}
>
<mesh castShadow>
<boxGeometry args={[7.6, 3.25, 0.24]} />
<meshStandardMaterial color="#101923" roughness={0.32} metalness={0.72} />
</mesh>
<mesh position={[0, 0, 0.126]}>
<planeGeometry args={[7.28, 2.93]} />
<meshBasicMaterial map={texture} toneMapped={false} />
</mesh>
</group>
))}</>;
}
/** Main-display room projection. Gameplay stays in the shared arena domain. */
export function BossRoom() {
const bossId = useGameStore((state) => state.boss.id);
const blockbreakerSeed = useGameStore((state) => state.blockbreaker.seed);
const hockeyMode = useGameStore((state) => state.activityMode === "hockey-healing");
const blockbreakerMode = useGameStore((state) => state.activityMode === "blockbreaker");
const aetherAssaultMode = useGameStore((state) => state.activityMode === "aether-assault");
const hockeyPvpMode = useGameStore((state) => state.activityMode === "hockey-healing-pvp");
const rpgPhase = useGameStore((state) => state.rpgRun?.phase ?? null);
const rpgBossRoom = useGameStore((state) => state.runMode === "rpg-roguelike" && state.activityMode === "boss");
if (hockeyPvpMode) return <HealingHockeyPvpRoom />;
if (aetherAssaultMode) return <BrightArcadeRoom variant="aether-assault" />;
if (blockbreakerMode) return <BrightArcadeRoom variant="blockbreaker" biome={blockbreakerBiomeForSeed(blockbreakerSeed)} />;
if (hockeyMode) return <HockeyHealingRoom />;
const room = bossRoomFor(bossId);
return (
<group key={room.id}>
@@ -340,11 +820,21 @@ export function BossRoom() {
<directionalLight castShadow position={[5, 10, 8]} intensity={2.1} color={room.accentSecondary} shadow-mapSize={[512, 512]} />
<pointLight color={room.accent} intensity={2.35} distance={8} position={[-6, 2.8, ROOM_CENTER_Z]} />
<pointLight color={room.accentSecondary} intensity={1.75} distance={7} position={[6, 2.4, ROOM_CENTER_Z - 1]} />
<RoomFloor room={room} />
<RoomFloor room={room} portalOpenings={rpgBossRoom} />
{rpgBossRoom && rpgPhase && (
<RpgRoomPortals
entryOpen
exitOpen={rpgPhase === "boss-cleared" || rpgPhase === "reward"}
accent={room.accent}
wallColor={room.wallColor}
/>
)}
</group>
);
}
if (LEGACY_GAME_ASSETS_FORCED) {
useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true);
useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true);
useGLTF.preload(KAYKIT_DUNGEON_TORCH_URL, false, true);
}
+59
View File
@@ -0,0 +1,59 @@
import { Canvas } from "@react-three/fiber";
import { Suspense, useMemo } from "react";
import * as THREE from "three";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { ALTERNATE_BOSS_CONFIG, bossVisualUrl } from "../game/bossVisuals";
import type { BossId } from "../game/types";
import { GameAssetProvider, LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider";
function PortraitModel({ bossId }: { bossId: BossId }) {
const gltf = useGameGLTF(bossVisualUrl(bossId, !LEGACY_GAME_ASSETS_FORCED));
const model = useMemo(() => {
const clone = cloneSkeleton(gltf.scene);
clone.updateMatrixWorld(true);
const bounds = new THREE.Box3().setFromObject(clone);
const center = bounds.getCenter(new THREE.Vector3());
const size = bounds.getSize(new THREE.Vector3());
const scale = 2.35 / Math.max(size.x, size.y, size.z, 0.001);
clone.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.castShadow = false;
object.receiveShadow = false;
}
});
return { clone, center, scale };
}, [gltf.scene]);
return (
<primitive
object={model.clone}
position={[-model.center.x * model.scale, -model.center.y * model.scale, -model.center.z * model.scale]}
rotation={[0, bossId === "bulldrome" ? 0.35 : ALTERNATE_BOSS_CONFIG[bossId].rotationOffset + 0.35, 0]}
scale={model.scale}
/>
);
}
export function BossTrophyPortrait({ bossId }: { bossId: BossId }) {
const boss = BOSS_DEFINITIONS[bossId];
return (
<div className="trophy-portrait" aria-label={`${boss.name} portrait`} role="img">
<Canvas
camera={{ position: [3.4, 2.35, 4.8], zoom: 70, near: 0.1, far: 30 }}
dpr={1}
frameloop="demand"
gl={{ alpha: true, antialias: true, powerPreference: "low-power" }}
orthographic
>
<ambientLight intensity={1.9} />
<directionalLight color="#fff3cf" intensity={3.2} position={[3, 5, 4]} />
<directionalLight color={boss.accent} intensity={2.1} position={[-4, 2, -2]} />
<GameAssetProvider>
<Suspense fallback={null}><PortraitModel bossId={bossId} /></Suspense>
</GameAssetProvider>
</Canvas>
<span aria-hidden="true">{boss.icon}</span>
</div>
);
}
+433 -58
View File
@@ -1,11 +1,39 @@
import { ABILITY_ORDER } from "../game/data";
import { HEALER_CLASSES } from "../game/healers";
import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { BARRIER_RADIUS, GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
import type { BottomTab, PartyMember } from "../game/types";
import { useFrontendStore } from "../frontend/store";
import { useActiveHunter, useFrontendStore } from "../frontend/store";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
import {
HOCKEY_ARENA_CENTER_Z,
HOCKEY_GOAL_HALF_WIDTH,
HOCKEY_HEALER_GOAL_Z,
HOCKEY_NPC_PADDLE_HALF_WIDTH,
HOCKEY_NPC_PADDLE_Z,
HOCKEY_NPC_GOAL_Z,
} from "../game/hockeyHealing";
import {
HOCKEY_PVP_GOAL_DAMAGE,
HOCKEY_PVP_GOAL_HALF_WIDTH,
HOCKEY_PVP_GOAL_Z,
HOCKEY_PVP_SIDE_OFFSET_Z,
} from "../game/hockeyHealingPvp";
import { bottomTabsFor } from "../game/bottomTabs";
import {
BLOCKBREAKER_BREACH_DAMAGE,
BLOCKBREAKER_BRICK_COLORS,
BLOCKBREAKER_DANGER_Z,
blockbreakerColumnX,
blockbreakerRowZ,
blockbreakerTimeMultiplier,
} from "../game/blockbreaker";
import { aetherShipColor } from "./aetherAssaultVisuals";
import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings";
import { RpgRunTacticalPanel } from "./rpgRoguelike/RpgRunTacticalPanel";
import { isBeaconOfLightTarget } from "../game/healerMechanics";
function RewardSummary() {
const rewards = useFrontendStore((state) => state.recentRewards);
@@ -29,23 +57,27 @@ function PartyFrame({ member }: { member: PartyMember }) {
const selected = useGameStore((state) => state.selectedMemberId === member.id);
const selectMember = useGameStore((state) => state.selectMember);
const time = useGameStore((state) => state.time);
const renewRemaining = Math.max(0, member.renewExpiresAt - time);
const healerMechanic = useGameStore((state) => state.healerMechanic);
const beaconed = isBeaconOfLightTarget(member.id, healerMechanic, time);
const activeHealingEffects = member.healingEffects.filter((effect) => effect.expiresAt > time).slice(0, 3);
const knockedRemaining = Math.max(0, member.knockedUntil - time);
const barrier = useGameStore((state) => state.barrier);
const tankAura = useGameStore((state) => state.partyCombat.tankAura);
const tankPosition = useGameStore((state) => state.partyPositions.brann);
const tankPosition = useGameStore((state) => state.partyPositions[state.partyCombat.tankAura.sourceId]);
const combatant = useGameStore((state) => member.id === "aelia" ? undefined : state.partyCombat.combatants[member.id]);
const position = useGameStore((state) => state.partyPositions[member.id]);
const protectedByBarrier = barrierProtects(position, barrier, time);
const linkedBySpirit = barrier.kind === "spirit-link" && healerFieldContains(position, barrier, time);
const protectedByTank = tankAuraProtects(position, tankPosition, tankAura, time);
const currentAction = combatant?.visualAction && combatant.visualAction.endsAt > time
? PARTY_ABILITY_NAMES[combatant.visualAction.abilityId]
: null;
return (
<button
className={`party-frame ${selected ? "is-selected" : ""} ${member.hp <= 0 ? "is-down" : ""}`}
className={`party-frame ${selected ? "is-selected" : ""} ${beaconed ? "is-beacon" : ""} ${member.hp <= 0 ? "is-down" : ""}`}
onClick={() => selectMember(member.id)}
aria-pressed={selected}
aria-label={`${member.name}, ${Math.ceil(member.hp)} health${beaconed ? ", Beacon of Light" : ""}`}
>
<span className="party-avatar" style={{ "--member-color": member.color } as React.CSSProperties}>{member.name[0]}</span>
<span className="party-data">
@@ -54,10 +86,16 @@ function PartyFrame({ member }: { member: PartyMember }) {
<small>{currentAction ?? member.className}</small>
</span>
<span className="effect-stack">
{beaconed && <i className="effect beacon-effect" title={`Beacon of Light: ${Math.max(0, healerMechanic.beaconExpiresAt - time).toFixed(1)} seconds`}></i>}
{member.absorb > 0 && <i className="effect shield-effect" title={`${Math.ceil(member.absorb)} absorption`}></i>}
{renewRemaining > 0 && <i className="effect renew-effect" title={`Renew: ${renewRemaining.toFixed(1)} seconds`}>{Math.ceil(renewRemaining)}</i>}
{activeHealingEffects.map((effect) => {
const label = effect.id === "renew" ? "R" : effect.id === "regrowth" ? "G" : effect.id === "rejuvenation" ? "J" : effect.id === "lifebloom" ? `L${effect.stacks}` : effect.id === "wild-growth" ? "W" : "T";
return <i key={effect.id} className="effect renew-effect" title={`${effect.id}: ${Math.max(0, effect.expiresAt - time).toFixed(1)} seconds`}>{label}</i>;
})}
{member.reactiveHeal && member.reactiveHeal.expiresAt > time && <i className="effect earth-shield-effect" title={`Earth Shield: ${member.reactiveHeal.charges} charges`}>{member.reactiveHeal.charges}</i>}
{member.debuffs.length > 0 && <i className="effect debuff-effect" title={`${member.debuffs[0].name} — Purify`}>!</i>}
{protectedByBarrier && <i className="effect barrier-effect" title="Barrier: 30% reduced damage">B</i>}
{linkedBySpirit && <i className="effect spirit-link-effect" title="Spirit Link: health equalized each second">S</i>}
{protectedByTank && <i className="effect tank-aura-effect" title="Bulwark March: 30% reduced damage">T</i>}
{knockedRemaining > 0 && <i className="effect knock-effect" title={`Knocked down: ${knockedRemaining.toFixed(1)} seconds`}>KD</i>}
</span>
@@ -77,25 +115,32 @@ function PartyList() {
function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number] }) {
const healerClassId = useGameStore((state) => state.healerClassId);
const ability = HEALER_CLASSES[healerClassId].abilities[abilityId];
const ability = useGameStore((state) => resolveSlottedAbility(state.abilityLoadout, abilityId));
const time = useGameStore((state) => state.time);
const cooldowns = useGameStore((state) => state.cooldowns);
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
const mana = useGameStore((state) => state.mana);
const phase = useGameStore((state) => state.phase);
const healerAlive = useGameStore((state) => state.party.some((member) => member.id === "aelia" && member.hp > 0));
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const activeCast = useGameStore((state) => state.activeCast);
const castAbility = useGameStore((state) => state.castAbility);
const runModifiers = useGameStore((state) => state.runModifiers);
const healerMechanic = useGameStore((state) => state.healerMechanic);
if (!ability) {
return <button className={`ability ability-${abilityId} is-empty`} disabled aria-label={`Empty ${abilityId}`}><span className="ability-key">{Number(abilityId.slice(-1))}</span><span className="ability-icon"></span><span className="ability-copy"><strong>Empty</strong><small>No spell drafted</small></span><span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span></button>;
}
const remaining = abilityRemaining(abilityId, time, cooldowns);
const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
const castTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
const baseCastTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
const castTime = ability.id === "shaman-healing-wave" && healerMechanic.resource > 0 ? baseCastTime * 0.5 : baseCastTime;
const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
const globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = abilityId === "purify" && selected.debuffs.length === 0;
const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0;
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
const disabled = phase !== "combat" || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const resourceCopy = `${manaCost ? `${manaCost} mana` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const resourceName = HEALER_CLASSES[healerClassId].resourceName.toLowerCase();
const resourceCopy = `${manaCost ? `${manaCost} ${resourceName}` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
return (
<button
@@ -106,10 +151,10 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
title={ability.description}
aria-label={`${ability.name}. ${ability.description}`}
>
<span className="ability-key">{ability.key}</span>
<span className="ability-key">{Number(abilityId.slice(-1))}</span>
<span className="ability-icon">{ability.icon}</span>
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
<span className="ability-pad">{ability.gamepad}</span>
<span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span>
{remaining > 0 && (
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / cooldownDuration) } as React.CSSProperties}>
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
@@ -129,17 +174,37 @@ function AbilityTray() {
const healer = HEALER_CLASSES[healerClassId];
const mana = useGameStore((state) => state.mana);
const maxMana = useGameStore((state) => state.maxMana);
const healerMechanic = useGameStore((state) => state.healerMechanic);
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const time = useGameStore((state) => state.time);
const boss = useGameStore((state) => state.boss);
const bossMotion = useGameStore((state) => state.bossMotion);
const additionalBosses = useGameStore((state) => state.additionalBosses);
const mechanic = upcomingEncounterMechanic({ boss, bossMotion, additionalBosses, time });
const activityMode = useGameStore((state) => state.activityMode);
const hockeyReturns = useGameStore((state) => state.hockey.returns);
const bossKills = useGameStore((state) => state.endlessBossKills);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyMode = activityMode === "hockey-healing";
const pvpMode = activityMode === "hockey-healing-pvp";
const blockbreakerMode = activityMode === "blockbreaker";
const aetherMode = activityMode === "aether-assault";
const duration = `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`;
const nextRowSeconds = Math.max(0, blockbreaker.nextRowAt - time);
return (
<div className="ability-column">
<div className="ability-meta">
<div className={`ability-meta ${hockeyMode || pvpMode || blockbreakerMode || aetherMode ? "is-hockey" : ""} ${pvpMode ? "is-pvp" : ""}`}>
<div className="target-chip"><span>Target</span><strong>{selected.name}</strong></div>
<div className="resource-stack">
<div className="mana-wrap"><span>{healer.resourceName}</span><b>{Math.ceil(mana)}</b><i><em style={{ width: `${(mana / maxMana) * 100}%` }} /></i></div>
{healer.secondaryResourceName && <div className="class-resource"><span>{healer.secondaryResourceName}</span><b>{healerMechanic.resource} / {healerMechanic.maxResource}</b></div>}
</div>
{hockeyMode && <div className="hockey-run-meta"><span>Returns</span><strong>{hockeyReturns}</strong><small>{duration} · {bossKills} KOs</small></div>}
{pvpMode && <div className="hockey-run-meta pvp-run-meta"><span>Goals · Bosses</span><strong>{hockeyPvp.opponentGoalsConceded}{hockeyPvp.localGoalsConceded}</strong><small>{bossKills}{hockeyPvp.opponentBossKills} · {hockeyPvp.opponentName}</small></div>}
{blockbreakerMode && <div className="hockey-run-meta blockbreaker-run-meta"><span>Score · Bricks</span><strong>{blockbreaker.score} · {blockbreaker.bricksBroken}</strong><small>{blockbreakerTimeMultiplier(time).toFixed(1)}× · next row {nextRowSeconds.toFixed(1)}s</small></div>}
{aetherMode && <div className="hockey-run-meta aether-run-meta"><span>Score · Wave</span><strong>{aetherAssault.score} · {aetherAssault.wave}</strong><small>{aetherAssault.multiplier.toFixed(2)}× · {aetherAssault.ships.length} ships</small></div>}
</div>
<div className="ability-grid">
{ABILITY_ORDER.map((abilityId) => <AbilityButton key={abilityId} abilityId={abilityId} />)}
@@ -163,14 +228,20 @@ function BriefingPanel() {
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
const bossNames = bosses.map((boss) => boss.name).join(" & ");
const activityMode = useGameStore((state) => state.activityMode);
const hockeyMode = activityMode === "hockey-healing";
const pvpMode = activityMode === "hockey-healing-pvp";
const blockbreakerMode = activityMode === "blockbreaker";
const aetherMode = activityMode === "aether-assault";
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
return (
<div className="briefing-panel">
<div className="briefing-class">
<div className="class-crest" style={{ color: healer.color }}>{healer.icon}</div>
<span>Chosen discipline</span>
<h2>{healer.specialization}</h2>
<p>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</p>
<button className="start-button" onClick={startEncounter}><span>Face {bossNames}</span><small>START / ENTER</small></button>
<p>{hockeyMode ? "Defend the wide goal while healing through two bosses. Left-stick direction sets every puck return; the moving enemy paddle tracks it and strikes it back. Each fallen boss awards loot and rolls its pet chance before replacement." : pvpMode ? `Face ${opponentName}. Both parties use normalized base gear and fight the same boss order. Every boss kill adds 5% global healing Dampening. Aim returns with left stick. A goal deals ${HOCKEY_PVP_GOAL_DAMAGE} damage to every member of the conceding party. Boss kills still award loot and pet chances.` : blockbreakerMode ? `Aim the puck into advancing five-column color rows while healing through two bosses. Orthogonal matching clusters break together. Rows start every 10 seconds and accelerate. Misses safely re-serve; each breach deals ${BLOCKBREAKER_BREACH_DAMAGE} damage to every party member.` : aetherMode ? "Move freely through the full runway while your arcane focus fires automatically. Heal with your normal kit. Dodge enemy volleys and diving ships; ship hits only threaten the healer. Bosses and rewards continue independently." : <>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</>}</p>
<button className="start-button" onClick={startEncounter}><span>{hockeyMode ? "Begin Hockey Healing" : pvpMode ? `Face ${opponentName}` : blockbreakerMode ? "Begin Blockbreaker" : aetherMode ? "Begin Aether Assault" : `Face ${bossNames}`}</span><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ENTER</small></button>
</div>
<div className="briefing-kit">
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
@@ -189,29 +260,62 @@ function BriefingPanel() {
);
}
function EndPanel() {
function EndPanel({ onExit }: { onExit?: () => void }) {
const hunter = useActiveHunter();
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
const activityMode = useGameStore((state) => state.activityMode);
const round = useGameStore((state) => state.round);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const endlessChoiceSelection = useGameStore((state) => state.endlessChoiceSelection);
const setEndlessChoiceSelection = useGameStore((state) => state.setEndlessChoiceSelection);
const startRogueTrialsEndless = useGameStore((state) => state.startRogueTrialsEndless);
const time = useGameStore((state) => state.time);
const party = useGameStore((state) => state.party);
const restart = useGameStore((state) => state.restart);
const startEncounter = useGameStore((state) => state.startEncounter);
const totalHp = party.reduce((sum, member) => sum + member.hp, 0);
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
const hockey = useGameStore((state) => state.hockey);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
const hockeyDefeat = phase === "defeat" && activityMode === "hockey-healing";
const blockbreakerDefeat = phase === "defeat" && activityMode === "blockbreaker";
const aetherDefeat = phase === "defeat" && activityMode === "aether-assault";
const pvpMatch = activityMode === "hockey-healing-pvp";
const endlessDefeat = phase === "defeat" && endlessMode && !hockeyDefeat && !blockbreakerDefeat && !aetherDefeat;
const endlessHighScore = hunter?.stats.highestRogueTrialsEndlessKills ?? 0;
const endlessHighScoreLabel = `${endlessHighScore} ${endlessHighScore === 1 ? "boss" : "bosses"}`;
return (
<div className={`end-panel end-${phase}`}>
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
<small>{phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
<h2>{phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
<small>{showEndlessChoice ? "ROGUE TRIALS CLEARED" : hockeyDefeat ? "HOCKEY HEALING COMPLETE" : blockbreakerDefeat ? "BLOCKBREAKER RUN COMPLETE" : aetherDefeat ? "AETHER ASSAULT COMPLETE" : pvpMatch ? phase === "victory" ? "PVP MATCH WON" : "PVP MATCH LOST" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
<h2>{showEndlessChoice ? "The trial can continue" : hockeyDefeat ? `${hockey.returns} pucks returned` : blockbreakerDefeat ? `${blockbreaker.score} points scored` : aetherDefeat ? `${aetherAssault.score} points scored` : pvpMatch ? phase === "victory" ? `${hockeyPvp.opponentName} fell first` : `${hockeyPvp.opponentName} wins` : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
<div className="result-stats">
<span><small>Duration</small><strong>{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}</strong></span>
<span><small>Party vitality</small><strong>{Math.round((totalHp / totalMax) * 100)}%</strong></span>
<span><small>Boss</small><strong>{phase === "victory" ? "Defeated" : "Standing"}</strong></span>
</div>
{phase === "victory" && <RewardSummary />}
<div className="end-actions">
<button onClick={() => { restart(); startEncounter(); }}>Run again</button>
<button className="secondary" onClick={restart}>Return to briefing</button>
<span><small>{hockeyDefeat ? "Puck returns" : blockbreakerDefeat ? "Bricks broken" : aetherDefeat ? "Wave reached" : pvpMatch ? "Goals" : "Party vitality"}</small><strong>{hockeyDefeat ? hockey.returns : blockbreakerDefeat ? blockbreaker.bricksBroken : aetherDefeat ? aetherAssault.wave : pvpMatch ? `${hockeyPvp.opponentGoalsConceded}${hockeyPvp.localGoalsConceded}` : `${Math.round((totalHp / totalMax) * 100)}%`}</strong></span>
<span><small>{hockeyDefeat || blockbreakerDefeat || aetherDefeat ? "Boss kills" : pvpMatch ? "Boss kills" : endlessDefeat ? "Endless kills" : "Boss"}</small><strong>{hockeyDefeat || blockbreakerDefeat || aetherDefeat || endlessDefeat ? endlessBossKills : pvpMatch ? `${endlessBossKills}${hockeyPvp.opponentBossKills}` : phase === "victory" ? "Defeated" : "Standing"}</strong></span>
</div>
{(phase === "victory" || hockeyDefeat || blockbreakerDefeat || aetherDefeat) && <RewardSummary />}
{showEndlessChoice ? <div className="end-actions endless-choice-actions">
<button
className={endlessChoiceSelection === "continue" ? "is-controller-selected" : ""}
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-selected" : ""}`}
onPointerEnter={() => setEndlessChoiceSelection("quit")}
onClick={onExit}
>Quit to Main Menu</button>
</div> : <div className="end-actions">
<button onClick={() => { if (pvpMatch) onExit?.(); else { restart(); startEncounter(); } }}>{pvpMatch ? "Find new opponent" : "Run again"}</button>
<button className="secondary" onClick={endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat || pvpMatch ? onExit : restart}>{endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat || pvpMatch ? "Return to main menu" : "Return to briefing"}</button>
</div>}
</div>
);
}
@@ -225,16 +329,16 @@ function IntermissionStatusPanel() {
<h2>Choose on top display</h2>
<p>Next encounter stays locked until one blessing is claimed.</p>
<RewardSummary />
<small>Use D-pad to choose · A to claim</small>
<small>Use D-pad to choose · {DEFAULT_CONTROLLER_GLYPHS.confirm} to claim</small>
</div>
);
}
function CombatPanel() {
function CombatPanel({ onExit }: { onExit?: () => void }) {
const phase = useGameStore((state) => state.phase);
if (phase === "briefing") return <BriefingPanel />;
if (phase === "intermission") return <IntermissionStatusPanel />;
if (phase === "victory" || phase === "defeat") return <EndPanel />;
if (phase === "victory" || phase === "defeat") return <EndPanel onExit={onExit} />;
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
}
@@ -247,10 +351,182 @@ function MapPanel() {
const time = useGameStore((state) => state.time);
const phase = useGameStore((state) => state.phase);
const bossId = useGameStore((state) => state.bossId);
const activityMode = useGameStore((state) => state.activityMode);
const hockey = useGameStore((state) => state.hockey);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyPvpOpponent = useGameStore((state) => state.hockeyPvpOpponent);
const bossDefinition = BOSS_DEFINITIONS[bossId];
const playerX = 120 + playerPosition[0] * 7;
const playerY = 143 + playerPosition[1] * 5.3;
const bossMotions = [bossMotion, ...additionalBosses.map((entry) => entry.motion)];
if (activityMode === "aether-assault") {
const mapX = (x: number) => 120 + x * 8.5;
const mapY = (z: number) => 140 + (z - HOCKEY_ARENA_CENTER_Z) * 8;
return (
<div className="map-panel hockey-map-panel aether-map-panel">
<div className="map-copy">
<span>Aether Assault</span>
<h2>Arcane Formation Runway</h2>
<p>Focus fire stays automatic. Move anywhere in the rink, heal freely, and evade red volleys plus amber dive warnings.</p>
<div className="map-legend"><i className="legend-party" /> Party <i className="legend-boss" /> Bosses <i className="legend-ship" /> Ships <i className="legend-shot" /> Shots</div>
</div>
<div className="map-canvas">
<svg viewBox="0 0 240 280" role="img" aria-label="Aether Assault tactical map">
<defs><linearGradient id="aether-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#18394a" /><stop offset="0.55" stopColor="#d9f3ef" /><stop offset="1" stopColor="#dff8f4" /></linearGradient></defs>
<rect className="map-room hockey-map-room" x="35" y="20" width="170" height="240" rx="3" fill="url(#aether-room)" />
<path className="map-ring aether-map-lanes" d="M63 20 V260 M91 20 V260 M120 20 V260 M149 20 V260 M177 20 V260" />
{aetherAssault.ships.map((ship, index) => {
const color = aetherShipColor(aetherAssault.seed, aetherAssault.wave, index, ship.kind);
return <g key={ship.id}>
{ship.phase === "diving" && <circle className="aether-map-warning" cx={mapX(ship.position[0])} cy={mapY(ship.position[1])} r="8" />}
<path className={`aether-map-ship is-${ship.kind}`} style={{ fill: color, filter: `drop-shadow(0 0 4px ${color})` }} d={`M${mapX(ship.position[0])} ${mapY(ship.position[1]) - 5} l6 9 h-12 z`} />
</g>;
})}
{aetherAssault.playerShots.map((shot) => <circle key={`player-shot-${shot.id}`} className="aether-map-player-shot" cx={mapX(shot.position[0])} cy={mapY(shot.position[1])} r="2" />)}
{aetherAssault.enemyShots.map((shot) => <circle key={`enemy-shot-${shot.id}`} className="aether-map-enemy-shot" cx={mapX(shot.position[0])} cy={mapY(shot.position[1])} r="2.5" />)}
{bossMotions.map((motion, index) => <circle key={`${motion.bossId}-${index}`} className="map-boss" cx={mapX(motion.position[0])} cy={mapY(motion.position[1])} r="7" />)}
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(partyPositions[memberId][1])} r="4" />)}
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="11" />
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="6" />
{barrier.expiresAt > time && <ellipse className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} rx={BARRIER_RADIUS * 8.5} ry={BARRIER_RADIUS * 8} />}
</svg>
<span className="map-state">{phase === "combat" ? `WAVE ${aetherAssault.wave} · ${aetherAssault.score} SCORE` : "FORMATION PREVIEW"}</span>
</div>
</div>
);
}
if (activityMode === "blockbreaker") {
const mapX = (x: number) => 120 + x * 8.5;
const mapY = (z: number) => 140 + (z - HOCKEY_ARENA_CENTER_Z) * 8;
const colors: Record<(typeof BLOCKBREAKER_BRICK_COLORS)[number], string> = {
cyan: "#36d9ef",
amber: "#f1b74f",
magenta: "#e85aa9",
lime: "#93db54",
};
return (
<div className="map-panel hockey-map-panel blockbreaker-map-panel">
<div className="map-copy">
<span>Blockbreaker</span>
<h2>Advancing Color Wall</h2>
<p>Match orthogonal colors. Crossing bricks disappear and deal {BLOCKBREAKER_BREACH_DAMAGE} partywide damage.</p>
<div className="map-legend"><i className="legend-party" /> Party <i className="legend-boss" /> Bosses <i className="legend-brick" /> Bricks <i className="legend-exit" /> Puck</div>
</div>
<div className="map-canvas">
<svg viewBox="0 0 240 280" role="img" aria-label="Blockbreaker tactical map">
<defs><linearGradient id="blockbreaker-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#251717" /><stop offset="0.5" stopColor="#10262d" /><stop offset="1" stopColor="#092d39" /></linearGradient></defs>
<rect className="map-room hockey-map-room" x="35" y="20" width="170" height="240" rx="3" fill="url(#blockbreaker-room)" />
<path className="map-ring" d="M35 140 H205" />
<path className="blockbreaker-map-danger" d={`M35 ${mapY(BLOCKBREAKER_DANGER_Z)} H205`} />
{blockbreaker.bricks.map((brick) => (
<rect
className="blockbreaker-map-brick"
key={brick.id}
x={mapX(blockbreakerColumnX(brick.column)) - 12.5}
y={mapY(blockbreakerRowZ(brick.row)) - 5}
width="25"
height="10"
rx="2"
fill={colors[brick.color]}
/>
))}
{bossMotions.map((motion, index) => <circle key={`${motion.bossId}-${index}`} className="map-boss" cx={mapX(motion.position[0])} cy={mapY(motion.position[1])} r="7" />)}
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="12" />
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="6" />
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(partyPositions[memberId][1])} r="4" />)}
<circle className="hockey-map-puck" cx={mapX(blockbreaker.puckPosition[0])} cy={mapY(blockbreaker.puckPosition[1])} r="5" />
{barrier.expiresAt > time && <ellipse className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} rx={BARRIER_RADIUS * 8.5} ry={BARRIER_RADIUS * 8} />}
</svg>
<span className="map-state">{phase === "combat" ? `${blockbreaker.bricks.length} BRICKS · ${blockbreaker.score} SCORE` : "WALL PREVIEW"}</span>
</div>
</div>
);
}
if (activityMode === "hockey-healing-pvp") {
const mapX = (x: number) => 120 + x * 8.2;
const mapY = (z: number) => 140 + z * 4.85;
const localWorldZ = (z: number) => z + HOCKEY_PVP_SIDE_OFFSET_Z;
const opponentWorldX = (x: number) => -x;
const opponentWorldZ = (z: number) => -z - HOCKEY_PVP_SIDE_OFFSET_Z;
return (
<div className="map-panel hockey-map-panel pvp-map-panel">
<div className="map-copy">
<span>Healing Hockey PVP</span>
<h2>You vs {hockeyPvp.opponentName}</h2>
<p>Matching boss order. Each goal hits all five allies for {HOCKEY_PVP_GOAL_DAMAGE} damage.</p>
<div className="map-legend"><i className="legend-party" /> Your party <i className="legend-boss" /> Bosses <i className="legend-opponent" /> Rival <i className="legend-exit" /> Puck</div>
</div>
<div className="map-canvas">
<svg viewBox="0 0 240 280" role="img" aria-label="Healing Hockey PVP tactical map">
<defs><linearGradient id="pvp-hockey-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#361819" /><stop offset="0.5" stopColor="#15262b" /><stop offset="1" stopColor="#0a3040" /></linearGradient></defs>
<rect className="map-room hockey-map-room" x="35" y="14" width="170" height="252" rx="3" fill="url(#pvp-hockey-room)" />
<path className="map-ring" d="M35 140 H205 M120 116 A24 24 0 1 0 120 164 A24 24 0 1 0 120 116" />
<path className="hockey-map-goal is-npc" d={`M${mapX(-HOCKEY_PVP_GOAL_HALF_WIDTH)} ${mapY(-HOCKEY_PVP_GOAL_Z)} H${mapX(HOCKEY_PVP_GOAL_HALF_WIDTH)}`} />
<path className="hockey-map-goal is-healer" d={`M${mapX(-HOCKEY_PVP_GOAL_HALF_WIDTH)} ${mapY(HOCKEY_PVP_GOAL_Z)} H${mapX(HOCKEY_PVP_GOAL_HALF_WIDTH)}`} />
<circle className="map-boss" cx={mapX(bossMotion.position[0])} cy={mapY(localWorldZ(bossMotion.position[1]))} r="7" />
<circle className="map-boss is-opponent" cx={mapX(opponentWorldX(hockeyPvpOpponent.bossMotion.position[0]))} cy={mapY(opponentWorldZ(hockeyPvpOpponent.bossMotion.position[1]))} r="7" />
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(localWorldZ(playerPosition[1]))} r="10" />
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(localWorldZ(playerPosition[1]))} r="5" />
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(localWorldZ(partyPositions[memberId][1]))} r="3.5" />)}
{(["aelia", "brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={`opponent-${memberId}`} className="map-ally map-opponent" cx={mapX(opponentWorldX(hockeyPvpOpponent.partyPositions[memberId][0]))} cy={mapY(opponentWorldZ(hockeyPvpOpponent.partyPositions[memberId][1]))} r="3.5" />)}
<circle className="hockey-map-puck" cx={mapX(hockeyPvp.puckPosition[0])} cy={mapY(hockeyPvp.puckPosition[1])} r="5" />
</svg>
<span className="map-state">{phase === "combat" ? `GOALS ${hockeyPvp.opponentGoalsConceded}${hockeyPvp.localGoalsConceded} · LIVE` : "VERSUS RINK"}</span>
</div>
</div>
);
}
if (activityMode === "hockey-healing") {
const mapX = (x: number) => 120 + x * 8.5;
const mapY = (z: number) => 140 + (z - HOCKEY_ARENA_CENTER_Z) * 8;
const npcGoalLeft = mapX(-HOCKEY_GOAL_HALF_WIDTH);
const npcGoalRight = mapX(HOCKEY_GOAL_HALF_WIDTH);
return (
<div className="map-panel hockey-map-panel">
<div className="map-copy">
<span>Hockey Healing</span>
<h2>Rectangular Boss Rink</h2>
<p>Party fights enemy half. Moving Pong paddle tracks each return and strikes it back toward healer.</p>
<div className="map-legend"><i className="legend-party" /> Party <i className="legend-boss" /> Boss <i className="legend-paddle" /> Paddle <i className="legend-exit" /> Puck</div>
</div>
<div className="map-canvas">
<svg viewBox="0 0 240 280" role="img" aria-label="Hockey Healing tactical map">
<defs>
<linearGradient id="hockey-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#251717" /><stop offset="0.5" stopColor="#10262d" /><stop offset="1" stopColor="#092d39" /></linearGradient>
</defs>
<rect className="map-room hockey-map-room" x="35" y="20" width="170" height="240" rx="3" fill="url(#hockey-room)" />
<path className="map-ring" d="M35 140 H205 M120 116 A24 24 0 1 0 120 164 A24 24 0 1 0 120 116" />
<path className="hockey-map-goal is-npc" d={`M${npcGoalLeft} ${mapY(HOCKEY_NPC_GOAL_Z)} H${npcGoalRight}`} />
<path className="hockey-map-goal is-healer" d={`M${npcGoalLeft} ${mapY(HOCKEY_HEALER_GOAL_Z)} H${npcGoalRight}`} />
<rect
className="hockey-map-paddle"
x={mapX(hockey.paddleX - HOCKEY_NPC_PADDLE_HALF_WIDTH)}
y={mapY(HOCKEY_NPC_PADDLE_Z) - 4}
width={HOCKEY_NPC_PADDLE_HALF_WIDTH * 2 * 8.5}
height="8"
rx="3"
/>
{bossMotions.map((motion, index) => (
<g key={`${motion.bossId}-${index}`}>
<circle className="map-boss" cx={mapX(motion.position[0])} cy={mapY(motion.position[1])} r="8" />
<path className="map-boss-arrow" d={`M${mapX(motion.position[0])} ${mapY(motion.position[1]) - 14} l6 9 h-12 z`} />
</g>
))}
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="12" />
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="6" />
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => (
<circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(partyPositions[memberId][1])} r="4" />
))}
<circle className="hockey-map-puck" cx={mapX(hockey.puckPosition[0])} cy={mapY(hockey.puckPosition[1])} r="5" />
{barrier.expiresAt > time && <ellipse className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} rx={BARRIER_RADIUS * 8.5} ry={BARRIER_RADIUS * 8} />}
</svg>
<span className="map-state">{phase === "combat" ? `${hockey.returns} RETURNS · LIVE` : "RINK PREVIEW"}</span>
</div>
</div>
);
}
return (
<div className="map-panel">
<div className="map-copy">
@@ -286,8 +562,8 @@ function MapPanel() {
className="map-barrier"
cx={120 + barrier.center[0] * 7}
cy={143 + barrier.center[1] * 5.3}
rx="21"
ry="15.9"
rx={BARRIER_RADIUS * 7}
ry={BARRIER_RADIUS * 5.3}
/>
)}
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => (
@@ -334,37 +610,136 @@ function PackPanel() {
);
}
const tabs: { id: BottomTab; label: string; icon: string; key: string }[] = [
{ id: "combat", label: "Heal", icon: "✦", key: "" },
{ id: "map", label: "Map", icon: "⌁", key: "M" },
{ id: "pack", label: "Pack", icon: "▧", key: "I" },
];
function PvpPanel() {
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
const opponentParty = useGameStore((state) => state.hockeyPvpOpponent.party);
const opponentGoalsConceded = useGameStore((state) => state.hockeyPvp.opponentGoalsConceded);
const localGoalsConceded = useGameStore((state) => state.hockeyPvp.localGoalsConceded);
const opponentBossKills = useGameStore((state) => state.hockeyPvp.opponentBossKills);
const living = opponentParty.filter((member) => member.hp > 0).length;
const currentHealth = opponentParty.reduce((total, member) => total + Math.max(0, member.hp), 0);
const maximumHealth = opponentParty.reduce((total, member) => total + member.maxHp, 0);
export function BottomScreen() {
const activeTab = useGameStore((state) => state.activeTab);
const setActiveTab = useGameStore((state) => state.setActiveTab);
const phase = useGameStore((state) => state.phase);
const paused = useGameStore((state) => state.paused);
return (
<section className="display bottom-display" aria-label="Tactical touch display">
<header className="lower-header">
<div className="lower-brand"><span>IH</span><strong>I Want To Heal</strong><small>{phase === "combat" ? "Encounter live" : "Field console"}</small></div>
<nav aria-label="Lower display sections">
{tabs.map((tab) => (
<button key={tab.id} className={activeTab === tab.id ? "is-active" : ""} onClick={() => setActiveTab(tab.id)}>
<i>{tab.icon}</i><span>{tab.label}</span>{tab.key && <small>{tab.key}</small>}
</button>
))}
</nav>
<div className="pvp-panel">
<header className="pvp-roster-header">
<span><small>Opponent party</small><strong>{opponentName}</strong></span>
<div><small>Goals</small><strong>{opponentGoalsConceded}{localGoalsConceded}</strong></div>
<div><small>Boss KOs</small><strong>{opponentBossKills}</strong></div>
<div><small>Standing</small><strong>{living} / {opponentParty.length}</strong></div>
</header>
<main className="lower-content">
{activeTab === "combat" && <CombatPanel />}
{activeTab === "map" && <MapPanel />}
{activeTab === "pack" && <PackPanel />}
</main>
<div className="pvp-party-list" aria-label={`${opponentName} party health`}>
<div className="section-label"><span>Rival health feed</span><small>{Math.ceil(currentHealth)} / {maximumHealth} total</small></div>
{opponentParty.map((member) => (
<article className={`pvp-party-frame ${member.hp <= 0 ? "is-down" : ""}`} key={member.id}>
<span className="party-avatar" style={{ "--member-color": member.color } as React.CSSProperties}>{member.name[0]}</span>
<span className="party-data">
<span className="party-name"><strong>{member.name}</strong><em>{Math.ceil(member.hp)} / {member.maxHp}</em></span>
<HealthBar member={member} />
<small>{member.className}</small>
</span>
<b>{member.hp <= 0 ? "DOWN" : `${Math.ceil((member.hp / member.maxHp) * 100)}%`}</b>
</article>
))}
</div>
</div>
);
}
const tabPresentation: Record<BottomTab, { label: string; icon: string; key: string }> = {
combat: { label: "Heal", icon: "✦", key: "" },
map: { label: "Map", icon: "⌁", key: "M" },
pack: { label: "Pack", icon: "▧", key: "I" },
pvp: { label: "PVP", icon: "⚔", key: "P" },
};
function RpgBottomDisplay({ run, focusedId, paused, onExit }: {
readonly run: NonNullable<ReturnType<typeof useGameStore.getState>["rpgRun"]>;
readonly focusedId: string | null;
readonly paused: boolean;
readonly onExit?: () => void;
}) {
const party = useGameStore((state) => state.party);
const selectedMemberId = useGameStore((state) => state.selectedMemberId);
const mana = useGameStore((state) => state.mana);
const maxMana = useGameStore((state) => state.maxMana);
const cooldowns = useGameStore((state) => state.cooldowns);
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
const time = useGameStore((state) => state.time);
const activeCast = useGameStore((state) => state.activeCast);
const spellResources = useGameStore((state) => state.rpgSpellResources);
const selectMember = useGameStore((state) => state.selectMember);
const castAbility = useGameStore((state) => state.castAbility);
const dispatchRpgAction = useGameStore((state) => state.dispatchRpgAction);
const setRpgFocusId = useGameStore((state) => state.setRpgFocusId);
const restart = useGameStore((state) => state.restart);
return (
<section className="display bottom-display rpg-bottom-display" aria-label="RPG Roguelike tactical display">
<RpgRunTacticalPanel
run={run}
focusedId={focusedId}
onFocusChange={setRpgFocusId}
onAction={dispatchRpgAction}
onRestartRun={restart}
onExitRun={onExit}
liveCombat={{
party,
selectedMemberId,
mana,
maxMana,
cooldowns,
globalCooldownUntil,
time,
activeCast,
spellResources,
onSelectMember: selectMember,
onCastAbility: castAbility,
}}
/>
{paused && (
<div className="lower-pause-overlay" aria-hidden="true">
<span>PAUSED</span><strong>Encounter suspended</strong><small>START / ESC resumes · selects menu action</small>
<span>PAUSED</span><strong>Expedition suspended</strong><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC resumes · selects menu action</small>
</div>
)}
</section>
);
}
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
const activeTab = useGameStore((state) => state.activeTab);
const setActiveTab = useGameStore((state) => state.setActiveTab);
const phase = useGameStore((state) => state.phase);
const paused = useGameStore((state) => state.paused);
const runMode = useGameStore((state) => state.runMode);
const activityMode = useGameStore((state) => state.activityMode);
const rpgRun = useGameStore((state) => state.rpgRun);
const rpgFocusId = useGameStore((state) => state.rpgFocusId);
const tabs = bottomTabsFor(runMode);
if (runMode === "rpg-roguelike" && rpgRun) {
return <RpgBottomDisplay run={rpgRun} focusedId={rpgFocusId} paused={paused} onExit={onExit} />;
}
return (
<section className="display bottom-display" aria-label="Tactical touch display">
<header className="lower-header">
<div className="lower-brand"><span>IH</span><strong>I Want To Heal</strong><small>{phase === "combat" ? "Encounter live" : "Field console"}</small></div>
<nav aria-label="Lower display sections" role="tablist">
{tabs.map((tabId) => {
const tab = tabPresentation[tabId];
return <button key={tabId} role="tab" aria-selected={activeTab === tabId} className={activeTab === tabId ? "is-active" : ""} onClick={() => setActiveTab(tabId)}>
<i>{tab.icon}</i><span>{tab.label}</span>{tab.key && <small>{tab.key}</small>}
</button>
})}
</nav>
</header>
<main className="lower-content">
{activeTab === "combat" && <CombatPanel onExit={onExit} />}
{activeTab === "map" && <MapPanel />}
{activeTab === "pack" && activityMode !== "hockey-healing-pvp" && <PackPanel />}
{activeTab === "pvp" && activityMode === "hockey-healing-pvp" && <PvpPanel />}
</main>
{paused && (
<div className="lower-pause-overlay" aria-hidden="true">
<span>PAUSED</span><strong>Encounter suspended</strong><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC resumes · selects menu action</small>
</div>
)}
</section>
+25 -10
View File
@@ -1,50 +1,65 @@
import { RUN_BUFFS, bossHealthMultiplier, effectiveRunBuffRank, formatRunBuffEffect } from "../game/roguelike";
import { useEffect, useState } from "react";
import { ROGUE_TRIALS_TRIO_ROUND, RUN_BUFFS, bossHealthMultiplier, effectiveRunBuffRank, formatRunBuffEffect } from "../game/roguelike";
import { HEALER_CLASSES } from "../game/healers";
import { useGameStore } from "../game/store";
import { isRunBuffInputLocked, useGameStore } from "../game/store";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
export function BuffDraftPanel({ className = "" }: { className?: string }) {
const round = useGameStore((state) => state.round);
const runMode = useGameStore((state) => state.runMode);
const healerClassId = useGameStore((state) => state.healerClassId);
const runBuffRanks = useGameStore((state) => state.runBuffRanks);
const passiveRunBuffId = useGameStore((state) => state.passiveRunBuffId);
const choices = useGameStore((state) => state.draftBuffIds);
const selected = useGameStore((state) => state.selectedRunBuffId);
const inputUnlockAt = useGameStore((state) => state.runBuffInputUnlockAt);
const setSelected = useGameStore((state) => state.setSelectedRunBuff);
const choose = useGameStore((state) => state.chooseRunBuff);
const continueRun = useGameStore((state) => state.continueRoguelikeRound);
const nextRound = round + 1;
const nextBossCount = runMode === "rogue-trials" && nextRound === ROGUE_TRIALS_TRIO_ROUND ? 3 : 2;
const abilities = HEALER_CLASSES[healerClassId].abilities;
const [inputLocked, setInputLocked] = useState(() => isRunBuffInputLocked(useGameStore.getState()));
useEffect(() => {
const remaining = inputUnlockAt - Date.now();
setInputLocked(remaining > 0);
if (remaining <= 0) return;
const timer = window.setTimeout(() => setInputLocked(false), remaining);
return () => window.clearTimeout(timer);
}, [inputUnlockAt]);
return (
<div className={`buff-draft ${className}`.trim()} role="dialog" aria-modal="true" aria-label={`Choose a buff for round ${nextRound}`}>
<div className={`buff-draft ${inputLocked ? "is-input-locked" : ""} ${className}`.trim()} role="dialog" aria-modal="true" aria-label={`Choose a buff for round ${nextRound}`} aria-busy={inputLocked}>
<header>
<span>Round {round} cleared</span>
<h2>Choose one blessing</h2>
<p>Claim required. Round {nextRound} begins with two new bosses at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.</p>
<p>Claim required. Round {nextRound} begins with {nextBossCount === 3 ? "an unseen trio" : "two new bosses"} at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.</p>
</header>
<div className={`buff-choice-grid choice-count-${choices.length}`}>
{choices.length > 0 ? choices.map((buffId) => {
const buff = RUN_BUFFS[buffId];
const rank = effectiveRunBuffRank(runBuffRanks, buffId, passiveRunBuffId);
const nextRank = Math.min(buff.maxRank, rank + 1);
const ability = abilities[buff.abilityId];
const ability = abilities[buff.abilitySlotId];
return (
<button
key={buffId}
className={selected === buffId ? "is-controller-focused" : ""}
className={selected === buffId ? "is-controller-selected" : ""}
style={{ "--buff-accent": buff.accent } as React.CSSProperties}
onFocus={() => setSelected(buffId)}
onPointerEnter={() => setSelected(buffId)}
onClick={() => choose(buffId)}
disabled={inputLocked}
aria-pressed={selected === buffId}
>
<i>{buff.icon}</i>
<span><small>{rank ? `Rank ${rank}${nextRank} / ${buff.maxRank}` : `New blessing · Rank 1 / ${buff.maxRank}`}</small><strong>{ability.shortName}: {buff.name}</strong></span>
<b>{formatRunBuffEffect(buffId, nextRank)}</b>
<b>{formatRunBuffEffect(buffId, nextRank, ability.shortName)}</b>
<p>{buff.detail}</p>
</button>
);
}) : (
<button className="buff-mastery-continue is-controller-focused" onClick={continueRun}>
<button className="buff-mastery-continue is-controller-selected" onClick={continueRun} disabled={inputLocked}>
<i></i>
<span><small>Full mastery</small><strong>Continue Without Buff</strong></span>
<b>All 18 blessings reached maximum rank.</b>
@@ -52,7 +67,7 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
</button>
)}
</div>
<footer>{choices.length > 0 && <><b> / </b> Choose <i /></>} <b>A / ENTER</b> {choices.length > 0 ? "Claim" : "Continue"}</footer>
<footer>{inputLocked ? <b>Choices ready in a moment</b> : <>{choices.length > 0 && <><b> / </b> Choose <i /></>} <b>{DEFAULT_CONTROLLER_GLYPHS.confirm} / ENTER</b> {choices.length > 0 ? "Claim" : "Continue"}</>}</footer>
</div>
);
}
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { CLAUDECRAFT_WEAPON_CATALOG } from "../game/weaponCatalog";
import { characterEquipmentAssetUrl, characterEquipmentAssetUrls } from "./CharacterEquipmentAssets";
describe("Claudecraft runtime weapon URLs", () => {
it("resolves every catalog entry to one emitted GLB URL", () => {
const urls = characterEquipmentAssetUrls();
expect(Object.keys(urls)).toHaveLength(55);
for (const definition of CLAUDECRAFT_WEAPON_CATALOG) {
expect(characterEquipmentAssetUrl(definition.id)).toMatch(/\.glb(?:\?|$)/);
}
});
});
@@ -0,0 +1,35 @@
import {
CLAUDECRAFT_WEAPON_CATALOG,
weaponDefinition,
type WeaponCatalogId,
} from "../game/weaponCatalog";
import { selectedGameAssetUrl } from "./GameAssetProvider";
const WEAPON_ASSET_PREFIX = "../assets/game/models/claudecraft/weapons/";
const WEAPON_ASSET_URLS = import.meta.glob<string>(
"../assets/game/models/claudecraft/weapons/*.glb",
{ eager: true, import: "default", query: "?url" },
);
function importedWeaponUrl(fileName: string) {
const url = WEAPON_ASSET_URLS[`${WEAPON_ASSET_PREFIX}${fileName}`];
if (!url) throw new Error(`Missing imported Claudecraft weapon asset: ${fileName}`);
return url;
}
/** Resolves one selected asset. Creating the URL table does not fetch or decode every GLB. */
export function characterEquipmentAssetUrl(modelId: WeaponCatalogId) {
const definition = weaponDefinition(modelId);
const sourceUrl = importedWeaponUrl(definition.sourceFile);
const optimizedUrl = definition.optimizedFile
? importedWeaponUrl(definition.optimizedFile)
: sourceUrl;
return selectedGameAssetUrl(sourceUrl, optimizedUrl);
}
export function characterEquipmentAssetUrls() {
return Object.fromEntries(CLAUDECRAFT_WEAPON_CATALOG.map((definition) => [
definition.id,
characterEquipmentAssetUrl(definition.id),
])) as Record<WeaponCatalogId, string>;
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
import { subscribeControllerToken } from "../input/controller";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
const dedicatedSurface = new URLSearchParams(window.location.search).get("display");
@@ -45,7 +46,7 @@ export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: Reac
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")}
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
>
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>SELECT / TAB</small>
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
</button>
</div>
);
+1168 -173
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { KTX2Loader } from "three-stdlib";
const BASIS_TRANSCODER_PATH = `${import.meta.env.BASE_URL}basis/`;
const query = typeof window === "undefined" ? null : new URLSearchParams(window.location.search);
export const LEGACY_GAME_ASSETS_FORCED = import.meta.env.VITE_LEGACY_GAME_ASSETS === "1"
|| import.meta.env.VITE_LEGACY_DUNGEON_ASSETS === "1"
|| query?.has("legacyGameAssets") === true
|| query?.has("legacyDungeonAssets") === true;
const Ktx2LoaderContext = createContext<KTX2Loader | null>(null);
export function selectedGameAssetUrl(legacyUrl: string, optimizedUrl: string) {
return LEGACY_GAME_ASSETS_FORCED ? legacyUrl : optimizedUrl;
}
export function GameAssetProvider({ children }: { children: ReactNode }) {
const gl = useThree((state) => state.gl);
const [ktx2Loader] = useState(() => LEGACY_GAME_ASSETS_FORCED
? null
: new KTX2Loader().setTranscoderPath(BASIS_TRANSCODER_PATH).detectSupport(gl));
useEffect(() => () => {
ktx2Loader?.dispose();
}, [ktx2Loader]);
return <Ktx2LoaderContext.Provider value={ktx2Loader}>{children}</Ktx2LoaderContext.Provider>;
}
export function useGameGLTF(url: string) {
const ktx2Loader = useContext(Ktx2LoaderContext);
return useGLTF(
url,
false,
true,
(loader) => {
if (ktx2Loader) loader.setKTX2Loader(ktx2Loader);
},
);
}

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