Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e36ca1a41 | ||
|
|
018e060cdd | ||
|
|
437e70fc58 | ||
|
|
7b522e3bc8 | ||
|
|
016a012c78 | ||
|
|
5045f17b94 | ||
|
|
ea5d4550a5 | ||
|
|
40aa23be46 | ||
|
|
638aef22b2 | ||
|
|
10ff124466 | ||
|
|
de7b59ce77 | ||
|
|
4b31480bec | ||
|
|
ed34c2503c | ||
|
|
8e48bb4fb6 | ||
|
|
fb41147197 | ||
|
|
aa9f5eff20 | ||
|
|
18c416d4d2 | ||
|
|
77fd434226 | ||
|
|
122f159b94 |
@@ -1,5 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
data/
|
||||||
|
backups/
|
||||||
game_assets/
|
game_assets/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
vite.config.js
|
vite.config.js
|
||||||
@@ -8,5 +10,7 @@ vite.config.d.ts
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
/git-token
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
/public/basis/
|
||||||
|
|||||||
+30
-4
@@ -4,6 +4,17 @@ This game uses the same proven local-Gitea pattern as `testgame`: clone from the
|
|||||||
Gitea bare repository on the TrueNAS filesystem, mount that working checkout
|
Gitea bare repository on the TrueNAS filesystem, mount that working checkout
|
||||||
into one Node container, and update it with a local Git pull plus app restart.
|
into one Node container, and update it with a local Git pull plus app restart.
|
||||||
|
|
||||||
|
## What the TrueNAS server does
|
||||||
|
|
||||||
|
The `iwanttoheal-mmo` TrueNAS app is the game's live online server. One Node
|
||||||
|
process serves the browser bundle and authenticated `/api` routes on port `4173`.
|
||||||
|
The reverse proxy exposes both at `https://iwanttoheal.phenomrom.com`.
|
||||||
|
|
||||||
|
SQLite persists accounts, sessions, cloud saves, boss-kill rankings, and
|
||||||
|
roguelike records under `/app/data/game.db`. The separate data mount survives
|
||||||
|
container replacement. TrueNAS Gitea remains the source repository used for
|
||||||
|
deployment; it is not the game database.
|
||||||
|
|
||||||
## Paths
|
## Paths
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -13,6 +24,9 @@ Local Gitea bare repository:
|
|||||||
Runnable working checkout:
|
Runnable working checkout:
|
||||||
/mnt/usbssds/apps/iwanttoheal-mmo/app
|
/mnt/usbssds/apps/iwanttoheal-mmo/app
|
||||||
|
|
||||||
|
Persistent game data:
|
||||||
|
/mnt/usbssds/apps/iwanttoheal-mmo/data
|
||||||
|
|
||||||
Public URL:
|
Public URL:
|
||||||
https://iwanttoheal.phenomrom.com
|
https://iwanttoheal.phenomrom.com
|
||||||
|
|
||||||
@@ -39,7 +53,7 @@ sudo find /mnt -type d -name "i-want-to-heal-mmo.git" -prune -print 2>/dev/null
|
|||||||
Clone entirely through the local filesystem:
|
Clone entirely through the local filesystem:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal-mmo
|
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal-mmo/{app,data}
|
||||||
sudo git config --global --add safe.directory \
|
sudo git config --global --add safe.directory \
|
||||||
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
|
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
|
||||||
sudo git clone \
|
sudo git clone \
|
||||||
@@ -72,8 +86,10 @@ services:
|
|||||||
iwanttoheal:
|
iwanttoheal:
|
||||||
image: node:24-bookworm-slim
|
image: node:24-bookworm-slim
|
||||||
command: >-
|
command: >-
|
||||||
sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run build && corepack pnpm start"
|
sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run db:init && corepack pnpm run build && corepack pnpm start"
|
||||||
environment:
|
environment:
|
||||||
|
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost"
|
||||||
|
DATA_DIR: /app/data
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: "4173"
|
PORT: "4173"
|
||||||
init: true
|
init: true
|
||||||
@@ -82,11 +98,21 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
|
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
|
||||||
|
- /mnt/usbssds/apps/iwanttoheal-mmo/data:/app/data
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
```
|
```
|
||||||
|
|
||||||
This game has no server database. Do not add `db:init`, `/app/data`, cookie,
|
Do not remove the `/app/data` mount or place the SQLite database inside the source
|
||||||
CORS, or proxy environment settings from the older game.
|
checkout. Back up `/mnt/usbssds/apps/iwanttoheal-mmo/data/game.db` before database
|
||||||
|
migrations or destructive maintenance.
|
||||||
|
|
||||||
|
From the app checkout, create a consistent SQLite backup with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
DATA_DIR=/mnt/usbssds/apps/iwanttoheal-mmo/data \
|
||||||
|
BACKUP_DIR=/mnt/usbssds/apps/iwanttoheal-mmo/backups \
|
||||||
|
corepack pnpm run db:backup
|
||||||
|
```
|
||||||
|
|
||||||
The separate volume protects the old app files and data. The YAML still maps
|
The separate volume protects the old app files and data. The YAML still maps
|
||||||
host port `4173`, so the old and MMO apps cannot run simultaneously while both
|
host port `4173`, so the old and MMO apps cannot run simultaneously while both
|
||||||
|
|||||||
@@ -2,16 +2,23 @@
|
|||||||
|
|
||||||
Playable low-poly third-person combat vertical slice for AYN Thor's dual displays.
|
Playable low-poly third-person combat vertical slice for AYN Thor's dual displays.
|
||||||
|
|
||||||
Offline-first frontend includes three timestamped save slots, optional account sync, local/online overwrite controls, Hunter Profile statistics, boss collection logs, Settings, and PvE/PvP mode entry points.
|
Offline-first frontend includes three timestamped save slots, TrueNAS accounts
|
||||||
|
and cloud saves, Hunter Profile statistics, boss and roguelike leaderboards,
|
||||||
|
boss collection logs, Settings, and PvE/PvP mode entry points. Offline saves
|
||||||
|
remain playable without an account and can be uploaded after sign-in.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
|
pnpm dev:api
|
||||||
|
# In a second terminal:
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
The development server listens on `0.0.0.0:4173`.
|
The Vite development server listens on `0.0.0.0:4173` and proxies `/api` to the
|
||||||
|
local production/API server on `127.0.0.1:4174`. Both processes use the same
|
||||||
|
client API contract as TrueNAS.
|
||||||
|
|
||||||
## Android / AYN Thor test APK
|
## Android / AYN Thor test APK
|
||||||
|
|
||||||
@@ -23,6 +30,10 @@ installable debug APK:
|
|||||||
pnpm android:apk
|
pnpm android:apk
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Build explicitly against the TrueNAS API with `pnpm android:apk:truenas`.
|
||||||
|
Native builds also default to `https://iwanttoheal.phenomrom.com` when no API
|
||||||
|
base override is supplied.
|
||||||
|
|
||||||
Output is written under `android/app/build/outputs/apk/debug/`. With Android
|
Output is written under `android/app/build/outputs/apk/debug/`. With Android
|
||||||
platform tools and a connected Thor, build and install it with:
|
platform tools and a connected Thor, build and install it with:
|
||||||
|
|
||||||
@@ -30,23 +41,41 @@ platform tools and a connected Thor, build and install it with:
|
|||||||
pnpm android:install
|
pnpm android:install
|
||||||
```
|
```
|
||||||
|
|
||||||
The first Android milestone uses the complete single-display fallback. Press
|
The Android host routes the main and tactical surfaces to separate physical
|
||||||
Select (or Tab with a keyboard) to switch between the main game surface and the
|
Thor displays while preserving one authoritative game state. If only one
|
||||||
620 × 540 tactical surface. Native routing to both physical Thor displays is the
|
display is available, Select (or Tab with a keyboard) opens the tactical surface
|
||||||
next milestone; it needs two Android display contexts backed by one shared game
|
over the main game view.
|
||||||
state rather than two independent WebViews.
|
|
||||||
|
PC and handheld browsers use the Thor top screen as a responsive, full-viewport
|
||||||
|
game surface. The compact ability strip keeps combat controls visible; Select
|
||||||
|
or Tab opens party, map, inventory, and other tactical detail. Use
|
||||||
|
`?layout=thor-preview` to restore the stacked dual-screen hardware mockup for
|
||||||
|
browser QA.
|
||||||
|
|
||||||
## TrueNAS deployment
|
## TrueNAS deployment
|
||||||
|
|
||||||
Complete first-install, local-Gitea clone, YAML, update, and verification steps:
|
Complete first-install, local-Gitea clone, YAML, update, and verification steps:
|
||||||
[DEPLOYMENT.md](DEPLOYMENT.md).
|
[DEPLOYMENT.md](DEPLOYMENT.md).
|
||||||
|
|
||||||
The production preview server uses the existing deployment address and port:
|
The live TrueNAS web server uses the existing deployment address and port:
|
||||||
|
|
||||||
- Public URL: `https://iwanttoheal.phenomrom.com`
|
- Public URL: `https://iwanttoheal.phenomrom.com`
|
||||||
- Host/container port: `4173`
|
- Host/container port: `4173`
|
||||||
- App directory: `/mnt/usbssds/apps/iwanttoheal-mmo/app`
|
- App directory: `/mnt/usbssds/apps/iwanttoheal-mmo/app`
|
||||||
|
|
||||||
|
### Server architecture
|
||||||
|
|
||||||
|
TrueNAS is the online production game server. Its Node process serves the browser
|
||||||
|
application and authenticated `/api` routes on port `4173`; the reverse proxy
|
||||||
|
exposes both at the public URL above. SQLite data persists at `/app/data/game.db`
|
||||||
|
through the separate TrueNAS data mount.
|
||||||
|
|
||||||
|
The server owns account credentials, 30-day sessions, three cloud-save slots per
|
||||||
|
account, boss-kill rankings, and roguelike highest-round rankings. Passwords use
|
||||||
|
scrypt with per-account salts; clients store only opaque session tokens. Local
|
||||||
|
saves remain available for offline play. Gitea is a separate TrueNAS service used
|
||||||
|
for source hosting and deployment.
|
||||||
|
|
||||||
Clone from the TrueNAS-local Gitea bare repository into the app directory, then
|
Clone from the TrueNAS-local Gitea bare repository into the app directory, then
|
||||||
deploy `compose.yaml`. The expected source path is:
|
deploy `compose.yaml`. The expected source path is:
|
||||||
|
|
||||||
@@ -54,18 +83,16 @@ deploy `compose.yaml`. The expected source path is:
|
|||||||
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
|
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
|
||||||
```
|
```
|
||||||
|
|
||||||
Compared with the old game configuration:
|
Current configuration:
|
||||||
|
|
||||||
- use `corepack pnpm install --frozen-lockfile`, not `npm ci`;
|
- use `corepack pnpm install --frozen-lockfile`, not `npm ci`;
|
||||||
- remove `npm run db:init` because this game has no server database;
|
- run `pnpm db:init` before starting the production server;
|
||||||
- remove `COOKIE_SECURE`, `CORS_ORIGINS`, and `TRUST_PROXY`; the static Vite
|
- mount `/mnt/usbssds/apps/iwanttoheal-mmo/data` at `/app/data`;
|
||||||
preview server does not consume them;
|
- keep `CORS_ORIGINS` configured for the public site and Capacitor host;
|
||||||
- remove `/app/data`; offline saves live in each player's browser/Android WebView
|
- keep the source checkout and persistent data in separate mounts.
|
||||||
storage, not on TrueNAS.
|
|
||||||
|
|
||||||
The existing reverse proxy can keep forwarding the public hostname to port
|
The existing reverse proxy forwards the public hostname to port `4173`, including
|
||||||
`4173`. Add server data and authentication environment variables only when an
|
all `/api` routes.
|
||||||
actual sync API is introduced.
|
|
||||||
|
|
||||||
## Publish updates to Gitea
|
## Publish updates to Gitea
|
||||||
|
|
||||||
@@ -75,8 +102,16 @@ The repository target is:
|
|||||||
https://git.whoagland.com/phenom/i-want-to-heal-mmo.git
|
https://git.whoagland.com/phenom/i-want-to-heal-mmo.git
|
||||||
```
|
```
|
||||||
|
|
||||||
The Mac publisher is configured with the Gitea release token. `GITEA_TOKEN` can
|
Create `git-token` in the repository root. Paste only the Gitea token into it:
|
||||||
optionally override it for one run. Publish from `main`:
|
|
||||||
|
```text
|
||||||
|
gitea_token_value_goes_here
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not add quotes or a `GITEA_TOKEN=` prefix. The exact `/git-token` path is
|
||||||
|
Git-ignored. Restrict local file access with `chmod 600 git-token`. The publisher
|
||||||
|
reads it automatically. `GITEA_TOKEN` remains available as an optional override.
|
||||||
|
Publish from `main` normally:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm publish:gitea -- --message "Describe the update"
|
pnpm publish:gitea -- --message "Describe the update"
|
||||||
@@ -109,18 +144,45 @@ outside the repository.
|
|||||||
- `WASD` / left stick: move
|
- `WASD` / left stick: move
|
||||||
- `Q` and `E` / D-pad: cycle party target
|
- `Q` and `E` / D-pad: cycle party target
|
||||||
- `1`–`6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
|
- `1`–`6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
|
||||||
- Gamepad: `X`, `Y`, `B`, `A`, `LB`, `RB` map to those abilities
|
- Gamepad: PlayStation `□`, `△`, `○`, `✕`, `L1`, `R1` map to those abilities
|
||||||
|
- `Select` / `Tab`: open or close the tactical interface on one-screen devices
|
||||||
- `M`: tactical map
|
- `M`: tactical map
|
||||||
- `I`: inventory and item tooltip
|
- `I`: inventory and item tooltip
|
||||||
- `Enter` / Start: begin or reset encounter
|
- `Enter` / `START`: begin or reset encounter
|
||||||
|
|
||||||
Touch controls on lower display support party targeting, ability casting, map, and inventory.
|
Touch controls on lower display support party targeting, ability casting, map, and inventory.
|
||||||
|
|
||||||
## Prototype scope
|
## 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
|
- Five-member AI party with Disc Priest healer
|
||||||
- Animated Druid healer, Knight tank, Ranger, Rogue, and Mage party models
|
- Animated Druid healer, Knight tank, Ranger, Rogue, and Mage party models
|
||||||
- One animated boss: Bulldrome, using the Bull model at 180% of its original prototype scale
|
- Animated bosses using canonical tracked game models
|
||||||
- Telegraph, charge, 0.75-second knockdown, and return-to-tank behavior
|
- Telegraph, charge, 0.75-second knockdown, and return-to-tank behavior
|
||||||
- Three-charge cycle into a five-second stack marker and 300-damage shared pounce
|
- Three-charge cycle into a five-second stack marker and 300-damage shared pounce
|
||||||
- Tank pressure, party-wide Cinder Nova, dispellable Ember Brand
|
- Tank pressure, party-wide Cinder Nova, dispellable Ember Brand
|
||||||
@@ -129,6 +191,6 @@ Touch controls on lower display support party targeting, ability casting, map, a
|
|||||||
- Android layout targets: approximately 960×540 CSS pixels top and 620×540 CSS pixels bottom
|
- Android layout targets: approximately 960×540 CSS pixels top and 620×540 CSS pixels bottom
|
||||||
- Lower-screen typography scales against its own container, never the main page viewport
|
- Lower-screen typography scales against its own container, never the main page viewport
|
||||||
|
|
||||||
Current Android build is an installable single-display test host. Shipping to both
|
The Android build uses distinct display contexts that project one authoritative
|
||||||
physical Thor displays still needs distinct Android display contexts that project
|
game state across both physical Thor displays. PC and Steam Deck use the same top
|
||||||
one authoritative game state.
|
surface with an adaptive tactical overlay.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import java.util.HashSet;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
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 {
|
public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||||
private static final float AXIS_DEAD_ZONE = 0.45f;
|
private static final float AXIS_DEAD_ZONE = 0.45f;
|
||||||
private static final long REPEAT_THROTTLE_MS = 55L;
|
private static final long REPEAT_THROTTLE_MS = 55L;
|
||||||
@@ -43,9 +43,6 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
|||||||
getWindow().setAttributes(attributes);
|
getWindow().setAttributes(attributes);
|
||||||
if (bridge != null && bridge.getWebView() != null) {
|
if (bridge != null && bridge.getWebView() != null) {
|
||||||
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
|
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||||
bridge.getWebView().setFocusable(true);
|
|
||||||
bridge.getWebView().setFocusableInTouchMode(true);
|
|
||||||
bridge.getWebView().requestFocus();
|
|
||||||
}
|
}
|
||||||
enterImmersiveMode();
|
enterImmersiveMode();
|
||||||
}
|
}
|
||||||
@@ -60,22 +57,6 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
|||||||
public void onResume() {
|
public void onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
enterImmersiveMode();
|
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
|
@Override
|
||||||
@@ -102,7 +83,9 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
|||||||
|
|
||||||
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
|
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
|
||||||
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
|
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
|
||||||
dispatchNativeControllerMotion(leftStickX, leftStickY);
|
float rightStickX = controllerAxisValue(event, MotionEvent.AXIS_Z, MotionEvent.AXIS_RX);
|
||||||
|
float rightStickY = controllerAxisValue(event, MotionEvent.AXIS_RZ, MotionEvent.AXIS_RY);
|
||||||
|
dispatchNativeControllerMotion(leftStickX, leftStickY, rightStickX, rightStickY);
|
||||||
|
|
||||||
Set<String> currentTokens = new HashSet<>();
|
Set<String> currentTokens = new HashSet<>();
|
||||||
addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_X), "Button14", "Button15");
|
addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_X), "Button14", "Button15");
|
||||||
@@ -150,29 +133,35 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
|||||||
if (value >= AXIS_DEAD_ZONE) tokens.add(positive);
|
if (value >= AXIS_DEAD_ZONE) tokens.add(positive);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private float controllerAxisValue(MotionEvent event, int primaryAxis, int fallbackAxis) {
|
||||||
|
InputDevice device = event.getDevice();
|
||||||
|
if (device != null && device.getMotionRange(primaryAxis) != null) {
|
||||||
|
return event.getAxisValue(primaryAxis);
|
||||||
|
}
|
||||||
|
return event.getAxisValue(fallbackAxis);
|
||||||
|
}
|
||||||
|
|
||||||
private void dispatchNativeControllerToken(String token, boolean repeat) {
|
private void dispatchNativeControllerToken(String token, boolean repeat) {
|
||||||
if (bridge == null || bridge.getWebView() == null) return;
|
if (bridge == null || bridge.getWebView() == null) return;
|
||||||
String script =
|
String script =
|
||||||
"window.dispatchEvent(new CustomEvent('iwt-native-controller',"
|
"window.dispatchEvent(new CustomEvent('iwt-native-controller',"
|
||||||
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
|
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
|
||||||
bridge.getWebView().post(() -> {
|
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null));
|
||||||
bridge.getWebView().requestFocus();
|
|
||||||
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;
|
if (bridge == null || bridge.getWebView() == null) return;
|
||||||
String script =
|
String script =
|
||||||
"window.dispatchEvent(new CustomEvent('iwt-native-controller-motion',"
|
"window.dispatchEvent(new CustomEvent('iwt-native-controller-motion',"
|
||||||
+ "{detail:{x:" + x + ",y:" + y + "}}));";
|
+ "{detail:{moveX:" + moveX + ",moveY:" + moveY
|
||||||
|
+ ",lookX:" + lookX + ",lookY:" + lookY + "}}));";
|
||||||
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null));
|
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void clearHeldControllerState() {
|
private void clearHeldControllerState() {
|
||||||
activeMotionTokens.clear();
|
activeMotionTokens.clear();
|
||||||
lastMotionDispatchAt.clear();
|
lastMotionDispatchAt.clear();
|
||||||
dispatchNativeControllerMotion(0.0f, 0.0f);
|
dispatchNativeControllerMotion(0.0f, 0.0f, 0.0f, 0.0f);
|
||||||
if (bridge == null || bridge.getWebView() == null) return;
|
if (bridge == null || bridge.getWebView() == null) return;
|
||||||
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(
|
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(
|
||||||
"window.dispatchEvent(new Event('iwt-native-controller-reset'));",
|
"window.dispatchEvent(new Event('iwt-native-controller-reset'));",
|
||||||
|
|||||||
+4
-1
@@ -2,8 +2,10 @@ services:
|
|||||||
iwanttoheal:
|
iwanttoheal:
|
||||||
image: node:24-bookworm-slim
|
image: node:24-bookworm-slim
|
||||||
command: >-
|
command: >-
|
||||||
sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run build && corepack pnpm start"
|
sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run db:init && corepack pnpm run build && corepack pnpm start"
|
||||||
environment:
|
environment:
|
||||||
|
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost"
|
||||||
|
DATA_DIR: /app/data
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: "4173"
|
PORT: "4173"
|
||||||
init: true
|
init: true
|
||||||
@@ -12,4 +14,5 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
|
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
|
||||||
|
- /mnt/usbssds/apps/iwanttoheal-mmo/data:/app/data
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
|
|||||||
+142
@@ -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
@@ -1,20 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "i-want-to-heal",
|
"name": "i-want-to-heal",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.5",
|
"version": "0.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"predev": "node scripts/sync_basis_transcoder.mjs",
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"dev:api": "HOST=127.0.0.1 PORT=4174 node server/production.mjs",
|
||||||
|
"db:backup": "node scripts/backup-db.mjs",
|
||||||
|
"db:init": "node scripts/init-db.mjs",
|
||||||
|
"prebuild": "node scripts/sync_basis_transcoder.mjs",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"android:sync": "pnpm run build && cap sync android",
|
"android:sync": "pnpm run build && cap sync android",
|
||||||
|
"android:sync:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com pnpm run android:sync",
|
||||||
"android:apk": "pnpm run android:sync && cd android && ./gradlew --no-daemon clean assembleDebug",
|
"android:apk": "pnpm run android:sync && cd android && ./gradlew --no-daemon clean assembleDebug",
|
||||||
|
"android:apk:truenas": "pnpm run android:sync:truenas && cd android && ./gradlew --no-daemon clean assembleDebug",
|
||||||
"android:install": "pnpm run android:apk && adb install -r android/app/build/outputs/apk/debug/*.apk",
|
"android:install": "pnpm run android:apk && adb install -r android/app/build/outputs/apk/debug/*.apk",
|
||||||
"start": "vite preview --host ${HOST:-0.0.0.0} --port ${PORT:-4173} --strictPort",
|
"start": "node server/production.mjs",
|
||||||
"publish:gitea": "python3 scripts/publish_gitea.py",
|
"publish:gitea": "python3 scripts/publish_gitea.py",
|
||||||
"test": "vitest run",
|
"test": "vitest run && node --test server/game-api.test.mjs",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
"assets:build-dungeon-kit": "node scripts/build_dungeon_kit.mjs",
|
||||||
|
"assets:build-gravehorn": "node scripts/build_gravehorn_triceratops.mjs",
|
||||||
|
"assets:build-ktx2": "node scripts/build_ktx2_game_assets.mjs",
|
||||||
|
"assets:build-priest-palette": "node scripts/build_priest_palette.mjs",
|
||||||
"assets:prune-party-animations": "node scripts/prune_party_animations.mjs --write",
|
"assets:prune-party-animations": "node scripts/prune_party_animations.mjs --write",
|
||||||
"assets:import": "node scripts/import-game-asset.mjs"
|
"assets:import": "node scripts/import-game-asset.mjs",
|
||||||
|
"assets:sync-basis-transcoder": "node scripts/sync_basis_transcoder.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
@@ -24,17 +36,20 @@
|
|||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"three": "^0.179.1",
|
"three": "^0.179.1",
|
||||||
|
"three-stdlib": "2.36.1",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "8.4.1",
|
"@capacitor/cli": "8.4.1",
|
||||||
"@gltf-transform/core": "^4.4.1",
|
"@gltf-transform/core": "^4.4.1",
|
||||||
"@gltf-transform/extensions": "^4.4.1",
|
"@gltf-transform/extensions": "^4.4.1",
|
||||||
|
"@gltf-transform/functions": "4.4.1",
|
||||||
"@types/react": "^19.1.10",
|
"@types/react": "^19.1.10",
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.1.7",
|
||||||
"@types/three": "^0.179.0",
|
"@types/three": "^0.179.0",
|
||||||
"@vitejs/plugin-react": "^5.0.2",
|
"@vitejs/plugin-react": "^5.0.2",
|
||||||
"meshoptimizer": "^1.2.0",
|
"meshoptimizer": "^1.2.0",
|
||||||
|
"sharp": "0.34.5",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^7.1.3",
|
"vite": "^7.1.3",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
|
|||||||
Generated
+379
@@ -29,6 +29,9 @@ importers:
|
|||||||
three:
|
three:
|
||||||
specifier: ^0.179.1
|
specifier: ^0.179.1
|
||||||
version: 0.179.1
|
version: 0.179.1
|
||||||
|
three-stdlib:
|
||||||
|
specifier: 2.36.1
|
||||||
|
version: 2.36.1(three@0.179.1)
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.8
|
specifier: ^5.0.8
|
||||||
version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
|
version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
|
||||||
@@ -42,6 +45,9 @@ importers:
|
|||||||
'@gltf-transform/extensions':
|
'@gltf-transform/extensions':
|
||||||
specifier: ^4.4.1
|
specifier: ^4.4.1
|
||||||
version: 4.4.1
|
version: 4.4.1
|
||||||
|
'@gltf-transform/functions':
|
||||||
|
specifier: 4.4.1
|
||||||
|
version: 4.4.1
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.1.10
|
specifier: ^19.1.10
|
||||||
version: 19.2.17
|
version: 19.2.17
|
||||||
@@ -57,6 +63,9 @@ importers:
|
|||||||
meshoptimizer:
|
meshoptimizer:
|
||||||
specifier: ^1.2.0
|
specifier: ^1.2.0
|
||||||
version: 1.2.0
|
version: 1.2.0
|
||||||
|
sharp:
|
||||||
|
specifier: 0.34.5
|
||||||
|
version: 0.34.5
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ~5.8.3
|
specifier: ~5.8.3
|
||||||
version: 5.8.3
|
version: 5.8.3
|
||||||
@@ -172,6 +181,9 @@ packages:
|
|||||||
'@dimforge/rapier3d-compat@0.12.0':
|
'@dimforge/rapier3d-compat@0.12.0':
|
||||||
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.11.2':
|
||||||
|
resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.28.1':
|
'@esbuild/aix-ppc64@0.28.1':
|
||||||
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -334,6 +346,162 @@ packages:
|
|||||||
'@gltf-transform/extensions@4.4.1':
|
'@gltf-transform/extensions@4.4.1':
|
||||||
resolution: {integrity: sha512-dZZ9D/NdpNeJUmQKExtISYtd3W6OxU4njk8UI3IKm6j97uVskYQ24BNi2YP40uUpdWPiREsS/DhjNhWAbGU/1A==}
|
resolution: {integrity: sha512-dZZ9D/NdpNeJUmQKExtISYtd3W6OxU4njk8UI3IKm6j97uVskYQ24BNi2YP40uUpdWPiREsS/DhjNhWAbGU/1A==}
|
||||||
|
|
||||||
|
'@gltf-transform/functions@4.4.1':
|
||||||
|
resolution: {integrity: sha512-CAU6hczuRz7NIEtpn7BARuwVEwRawwIcGqmoMCaEwA4XC+CSUi3xptXuf2zDG/5AftFO7IeQxXl/56rDsck0GQ==}
|
||||||
|
|
||||||
|
'@img/colour@1.1.0':
|
||||||
|
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||||
|
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||||
|
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.34.5':
|
||||||
|
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-ppc64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-riscv64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-s390x@0.34.5':
|
||||||
|
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-wasm32@0.34.5':
|
||||||
|
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [wasm32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-ia32@0.34.5':
|
||||||
|
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@ionic/cli-framework-output@2.2.8':
|
'@ionic/cli-framework-output@2.2.8':
|
||||||
resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==}
|
resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
@@ -601,6 +769,9 @@ packages:
|
|||||||
'@types/fs-extra@8.1.5':
|
'@types/fs-extra@8.1.5':
|
||||||
resolution: {integrity: sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==}
|
resolution: {integrity: sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==}
|
||||||
|
|
||||||
|
'@types/ndarray@1.0.14':
|
||||||
|
resolution: {integrity: sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg==}
|
||||||
|
|
||||||
'@types/node@26.1.1':
|
'@types/node@26.1.1':
|
||||||
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
|
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
|
||||||
|
|
||||||
@@ -791,6 +962,9 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
cwise-compiler@1.1.3:
|
||||||
|
resolution: {integrity: sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -811,6 +985,10 @@ packages:
|
|||||||
detect-gpu@5.0.70:
|
detect-gpu@5.0.70:
|
||||||
resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==}
|
resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==}
|
||||||
|
|
||||||
|
detect-libc@2.1.2:
|
||||||
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
draco3d@1.5.7:
|
draco3d@1.5.7:
|
||||||
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
|
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
|
||||||
|
|
||||||
@@ -908,6 +1086,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==}
|
resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==}
|
||||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||||
|
|
||||||
|
iota-array@1.0.0:
|
||||||
|
resolution: {integrity: sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==}
|
||||||
|
|
||||||
|
is-buffer@1.1.6:
|
||||||
|
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
|
||||||
|
|
||||||
is-docker@2.2.1:
|
is-docker@2.2.1:
|
||||||
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
|
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1020,6 +1204,18 @@ packages:
|
|||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
ndarray-lanczos@0.3.0:
|
||||||
|
resolution: {integrity: sha512-5kBmmG3Zvyj77qxIAC4QFLKuYdDIBJwCG+DukT6jQHNa1Ft74/hPH1z5mbQXeHBt8yvGPBGVrr3wEOdJPYYZYg==}
|
||||||
|
|
||||||
|
ndarray-ops@1.2.2:
|
||||||
|
resolution: {integrity: sha512-BppWAFRjMYF7N/r6Ie51q6D4fs0iiGmeXIACKY66fLpnwIui3Wc3CXiD/30mgLbDjPpSLrsqcp3Z62+IcHZsDw==}
|
||||||
|
|
||||||
|
ndarray-pixels@5.0.1:
|
||||||
|
resolution: {integrity: sha512-IBtrpefpqlI8SPDCGjXk4v5NV5z7r3JSuCbfuEEXaM0vrOJtNGgYUa4C3Lt5H+qWdYF4BCPVFsnXhNC7QvZwkw==}
|
||||||
|
|
||||||
|
ndarray@1.0.19:
|
||||||
|
resolution: {integrity: sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==}
|
||||||
|
|
||||||
node-releases@2.0.50:
|
node-releases@2.0.50:
|
||||||
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
|
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1139,6 +1335,10 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
sharp@0.34.5:
|
||||||
|
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1275,6 +1475,9 @@ packages:
|
|||||||
undici-types@8.3.0:
|
undici-types@8.3.0:
|
||||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||||
|
|
||||||
|
uniq@1.0.1:
|
||||||
|
resolution: {integrity: sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==}
|
||||||
|
|
||||||
universalify@2.0.1:
|
universalify@2.0.1:
|
||||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
@@ -1597,6 +1800,11 @@ snapshots:
|
|||||||
|
|
||||||
'@dimforge/rapier3d-compat@0.12.0': {}
|
'@dimforge/rapier3d-compat@0.12.0': {}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.11.2':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.28.1':
|
'@esbuild/aix-ppc64@0.28.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1684,6 +1892,111 @@ snapshots:
|
|||||||
'@gltf-transform/core': 4.4.1
|
'@gltf-transform/core': 4.4.1
|
||||||
ktx-parse: 1.1.0
|
ktx-parse: 1.1.0
|
||||||
|
|
||||||
|
'@gltf-transform/functions@4.4.1':
|
||||||
|
dependencies:
|
||||||
|
'@gltf-transform/core': 4.4.1
|
||||||
|
'@gltf-transform/extensions': 4.4.1
|
||||||
|
ktx-parse: 1.1.0
|
||||||
|
ndarray: 1.0.19
|
||||||
|
ndarray-lanczos: 0.3.0
|
||||||
|
ndarray-pixels: 5.0.1
|
||||||
|
|
||||||
|
'@img/colour@1.1.0': {}
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-ppc64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-riscv64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-s390x@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-x64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-wasm32@0.34.5':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/runtime': 1.11.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-arm64@0.34.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-ia32@0.34.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.34.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@ionic/cli-framework-output@2.2.8':
|
'@ionic/cli-framework-output@2.2.8':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ionic/utils-terminal': 2.3.5
|
'@ionic/utils-terminal': 2.3.5
|
||||||
@@ -1958,6 +2271,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 26.1.1
|
'@types/node': 26.1.1
|
||||||
|
|
||||||
|
'@types/ndarray@1.0.14': {}
|
||||||
|
|
||||||
'@types/node@26.1.1':
|
'@types/node@26.1.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 8.3.0
|
undici-types: 8.3.0
|
||||||
@@ -2146,6 +2461,10 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
cwise-compiler@1.1.3:
|
||||||
|
dependencies:
|
||||||
|
uniq: 1.0.1
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -2158,6 +2477,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
webgl-constants: 1.1.1
|
webgl-constants: 1.1.1
|
||||||
|
|
||||||
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
draco3d@1.5.7: {}
|
draco3d@1.5.7: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.389: {}
|
electron-to-chromium@1.5.389: {}
|
||||||
@@ -2259,6 +2580,10 @@ snapshots:
|
|||||||
|
|
||||||
ini@4.1.3: {}
|
ini@4.1.3: {}
|
||||||
|
|
||||||
|
iota-array@1.0.0: {}
|
||||||
|
|
||||||
|
is-buffer@1.1.6: {}
|
||||||
|
|
||||||
is-docker@2.2.1: {}
|
is-docker@2.2.1: {}
|
||||||
|
|
||||||
is-fullwidth-code-point@3.0.0: {}
|
is-fullwidth-code-point@3.0.0: {}
|
||||||
@@ -2357,6 +2682,27 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
ndarray-lanczos@0.3.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/ndarray': 1.0.14
|
||||||
|
ndarray: 1.0.19
|
||||||
|
|
||||||
|
ndarray-ops@1.2.2:
|
||||||
|
dependencies:
|
||||||
|
cwise-compiler: 1.1.3
|
||||||
|
|
||||||
|
ndarray-pixels@5.0.1:
|
||||||
|
dependencies:
|
||||||
|
'@types/ndarray': 1.0.14
|
||||||
|
ndarray: 1.0.19
|
||||||
|
ndarray-ops: 1.2.2
|
||||||
|
sharp: 0.34.5
|
||||||
|
|
||||||
|
ndarray@1.0.19:
|
||||||
|
dependencies:
|
||||||
|
iota-array: 1.0.0
|
||||||
|
is-buffer: 1.1.6
|
||||||
|
|
||||||
node-releases@2.0.50: {}
|
node-releases@2.0.50: {}
|
||||||
|
|
||||||
open@8.4.2:
|
open@8.4.2:
|
||||||
@@ -2481,6 +2827,37 @@ snapshots:
|
|||||||
|
|
||||||
semver@7.8.5: {}
|
semver@7.8.5: {}
|
||||||
|
|
||||||
|
sharp@0.34.5:
|
||||||
|
dependencies:
|
||||||
|
'@img/colour': 1.1.0
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
semver: 7.8.5
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-darwin-arm64': 0.34.5
|
||||||
|
'@img/sharp-darwin-x64': 0.34.5
|
||||||
|
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||||
|
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||||
|
'@img/sharp-linux-arm': 0.34.5
|
||||||
|
'@img/sharp-linux-arm64': 0.34.5
|
||||||
|
'@img/sharp-linux-ppc64': 0.34.5
|
||||||
|
'@img/sharp-linux-riscv64': 0.34.5
|
||||||
|
'@img/sharp-linux-s390x': 0.34.5
|
||||||
|
'@img/sharp-linux-x64': 0.34.5
|
||||||
|
'@img/sharp-linuxmusl-arm64': 0.34.5
|
||||||
|
'@img/sharp-linuxmusl-x64': 0.34.5
|
||||||
|
'@img/sharp-wasm32': 0.34.5
|
||||||
|
'@img/sharp-win32-arm64': 0.34.5
|
||||||
|
'@img/sharp-win32-ia32': 0.34.5
|
||||||
|
'@img/sharp-win32-x64': 0.34.5
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 3.0.0
|
shebang-regex: 3.0.0
|
||||||
@@ -2609,6 +2986,8 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@8.3.0: {}
|
undici-types@8.3.0: {}
|
||||||
|
|
||||||
|
uniq@1.0.1: {}
|
||||||
|
|
||||||
universalify@2.0.1: {}
|
universalify@2.0.1: {}
|
||||||
|
|
||||||
untildify@4.0.0: {}
|
untildify@4.0.0: {}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: true
|
esbuild: true
|
||||||
|
sharp: true
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
|
const dataDirectory = resolve(process.env.DATA_DIR ?? "data");
|
||||||
|
const backupDirectory = resolve(process.env.BACKUP_DIR ?? "backups");
|
||||||
|
const timestamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
|
||||||
|
const backupPath = resolve(backupDirectory, `game-${timestamp}.db`);
|
||||||
|
mkdirSync(backupDirectory, { recursive: true });
|
||||||
|
|
||||||
|
const database = new DatabaseSync(resolve(dataDirectory, "game.db"));
|
||||||
|
try {
|
||||||
|
database.exec(`VACUUM INTO '${backupPath.replaceAll("'", "''")}'`);
|
||||||
|
console.log(`SQLite backup created: ${backupPath}`);
|
||||||
|
} finally {
|
||||||
|
database.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
"""Build two original low-poly creature bosses as animated runtime GLBs.
|
||||||
|
|
||||||
|
Replaces weak chicken and frog visuals while keeping stable boss IDs in game data.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
/Applications/Blender.app/Contents/MacOS/Blender --background --factory-startup \
|
||||||
|
--python scripts/blender/build_replacement_creature_bosses.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
from build_iwt2_boss_trio import ( # noqa: E402
|
||||||
|
OUT_ROOT,
|
||||||
|
actions,
|
||||||
|
armature,
|
||||||
|
cone,
|
||||||
|
ellipsoid,
|
||||||
|
export_asset,
|
||||||
|
finish,
|
||||||
|
join_parts,
|
||||||
|
plate,
|
||||||
|
prepare_materials,
|
||||||
|
reset_scene,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_metadata(asset_id: str, concept: str) -> None:
|
||||||
|
metadata_path = OUT_ROOT / asset_id / f"{asset_id}.asset.json"
|
||||||
|
metadata = json.loads(metadata_path.read_text())
|
||||||
|
metadata["sourceConcept"] = concept
|
||||||
|
metadata["license"] = "Original project-owned asset"
|
||||||
|
metadata["runtime"]["forward"] = "-Y"
|
||||||
|
metadata["runtime"]["unit"] = "meters"
|
||||||
|
metadata_path.write_text(json.dumps(metadata, indent=2) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def build_brassbeak_basilisk() -> None:
|
||||||
|
"""Six-legged forge basilisk replacing Cluckhorn's chicken-cow model."""
|
||||||
|
asset_id = "brassbeak-basilisk"
|
||||||
|
reset_scene()
|
||||||
|
mats = prepare_materials({
|
||||||
|
"Scale": {"color": (0.035, 0.105, 0.12, 1), "metallic": 0.14, "roughness": 0.62},
|
||||||
|
"Underbelly": {"color": (0.12, 0.19, 0.18, 1), "metallic": 0.05, "roughness": 0.72},
|
||||||
|
"Copper": {"color": (0.45, 0.16, 0.055, 1), "metallic": 0.48, "roughness": 0.33},
|
||||||
|
"Brass": {"color": (0.78, 0.48, 0.09, 1), "metallic": 0.62, "roughness": 0.25},
|
||||||
|
"Blade": {"color": (0.50, 0.58, 0.55, 1), "metallic": 0.72, "roughness": 0.2},
|
||||||
|
"Furnace": {"color": (0.02, 0.82, 0.72, 1), "roughness": 0.18, "emission": (0.01, 0.72, 0.64, 1), "strength": 5.5},
|
||||||
|
})
|
||||||
|
specs = [
|
||||||
|
("Root", (0, 0, 0), (0, 0, 0.45), None),
|
||||||
|
("Body", (0, 0.06, 1.18), (0, 0.05, 2.05), "Root"),
|
||||||
|
("Head", (0, -1.0, 1.48), (0, -1.82, 1.38), "Body"),
|
||||||
|
("Jaw", (0, -1.42, 1.28), (0, -2.05, 1.12), "Head"),
|
||||||
|
("Wing.L", (-0.62, -0.08, 1.72), (-1.55, -0.28, 1.5), "Body"),
|
||||||
|
("Wing.R", (0.62, -0.08, 1.72), (1.55, -0.28, 1.5), "Body"),
|
||||||
|
("Leg.FL", (-0.62, -0.72, 1.12), (-0.88, -0.84, 0.24), "Body"),
|
||||||
|
("Leg.FR", (0.62, -0.72, 1.12), (0.88, -0.84, 0.24), "Body"),
|
||||||
|
("Leg.ML", (-0.78, 0.03, 1.06), (-1.0, 0.02, 0.22), "Body"),
|
||||||
|
("Leg.MR", (0.78, 0.03, 1.06), (1.0, 0.02, 0.22), "Body"),
|
||||||
|
("Leg.BL", (-0.66, 0.76, 1.12), (-0.9, 0.88, 0.24), "Body"),
|
||||||
|
("Leg.BR", (0.66, 0.76, 1.12), (0.9, 0.88, 0.24), "Body"),
|
||||||
|
("Tail.1", (0, 1.0, 1.3), (0, 1.85, 1.12), "Body"),
|
||||||
|
("Tail.2", (0, 1.8, 1.12), (0, 2.7, 0.92), "Tail.1"),
|
||||||
|
]
|
||||||
|
rig = armature("BrassbeakBasilisk", specs)
|
||||||
|
|
||||||
|
# Broad armored silhouette with glowing furnace seams.
|
||||||
|
ellipsoid("BasiliskBody", (0, 0.08, 1.36), (1.08, 1.43, 0.72), mats["Scale"], "Body", 2)
|
||||||
|
ellipsoid("FurnaceBelly", (0, -0.15, 1.08), (0.78, 1.08, 0.46), mats["Underbelly"], "Body", 2)
|
||||||
|
for index, y in enumerate((-0.7, -0.22, 0.28, 0.76)):
|
||||||
|
width = 0.82 + (0.12 if index in (1, 2) else 0)
|
||||||
|
plate(
|
||||||
|
f"BackPlate{index}", (0, y, 1.92 + 0.08 * math.sin(index)),
|
||||||
|
(width, 0.55, 0.27), (math.radians(84), 0, 0),
|
||||||
|
mats["Copper"] if index % 2 == 0 else mats["Brass"], "Body",
|
||||||
|
)
|
||||||
|
cone(
|
||||||
|
f"ChimneySpine{index}", (0, y, 2.02), (0, y + 0.03, 2.52 - index * 0.04),
|
||||||
|
0.13, 0, mats["Blade"], "Body", 5,
|
||||||
|
)
|
||||||
|
for side in (-1, 1):
|
||||||
|
cone(
|
||||||
|
f"FurnaceSeam{side:+d}", (side * 0.58, -0.76, 1.34), (side * 0.72, 0.72, 1.34),
|
||||||
|
0.035, 0.022, mats["Furnace"], "Body", 5,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hammerhead, brass beak, split jaw, and crown blades.
|
||||||
|
ellipsoid("HammerHead", (0, -1.28, 1.5), (0.78, 0.74, 0.56), mats["Copper"], "Head", 2)
|
||||||
|
ellipsoid("FaceMask", (0, -1.72, 1.5), (0.58, 0.34, 0.42), mats["Brass"], "Head", 1)
|
||||||
|
cone("UpperBeak", (0, -1.68, 1.51), (0, -2.58, 1.34), 0.42, 0.035, mats["Brass"], "Head", 6)
|
||||||
|
cone("LowerBeak", (0, -1.64, 1.3), (0, -2.32, 1.18), 0.3, 0.025, mats["Blade"], "Jaw", 6)
|
||||||
|
for side, suffix in ((-1, "L"), (1, "R")):
|
||||||
|
ellipsoid(f"Eye{suffix}", (side * 0.43, -1.72, 1.66), (0.09, 0.055, 0.09), mats["Furnace"], "Head", 1)
|
||||||
|
cone(
|
||||||
|
f"BrowHorn{suffix}", (side * 0.42, -1.38, 1.78), (side * 0.88, -1.7, 2.03),
|
||||||
|
0.13, 0, mats["Blade"], "Head", 5,
|
||||||
|
)
|
||||||
|
for index, (x, z) in enumerate(((-0.34, 2.0), (0, 2.14), (0.34, 2.0))):
|
||||||
|
cone(f"CrownBlade{index}", (x, -1.2, 1.8), (x * 1.35, -1.15, z + 0.54), 0.12, 0, mats["Brass"], "Head", 5)
|
||||||
|
|
||||||
|
# Blade-like vestigial wings make lateral cleaves readable from camera.
|
||||||
|
for side, suffix in ((-1, "L"), (1, "R")):
|
||||||
|
bone = f"Wing.{suffix}"
|
||||||
|
plate(
|
||||||
|
f"WingShield{suffix}", (side * 1.05, -0.08, 1.62), (0.7, 0.56, 0.13),
|
||||||
|
(math.radians(78), math.radians(side * 18), math.radians(side * 8)), mats["Copper"], bone,
|
||||||
|
)
|
||||||
|
cone(
|
||||||
|
f"WingBlade{suffix}", (side * 0.72, -0.24, 1.7), (side * 1.95, -0.58, 1.42),
|
||||||
|
0.19, 0.015, mats["Blade"], bone, 6,
|
||||||
|
)
|
||||||
|
cone(
|
||||||
|
f"WingGlow{suffix}", (side * 0.88, -0.3, 1.69), (side * 1.7, -0.52, 1.5),
|
||||||
|
0.05, 0.008, mats["Furnace"], bone, 5,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Six short piston legs: stable, strange, easy to read while scuttling.
|
||||||
|
leg_rows = (("F", -0.72), ("M", 0.03), ("B", 0.76))
|
||||||
|
for row_index, (row, y) in enumerate(leg_rows):
|
||||||
|
for side, suffix in ((-1, "L"), (1, "R")):
|
||||||
|
bone = f"Leg.{row}{suffix}"
|
||||||
|
hip_x = side * (0.64 if row != "M" else 0.78)
|
||||||
|
foot_x = side * (0.98 if row != "M" else 1.1)
|
||||||
|
ellipsoid(f"Hip{row}{suffix}", (hip_x, y, 1.05), (0.3, 0.34, 0.3), mats["Copper"], bone, 1)
|
||||||
|
cone(f"Shin{row}{suffix}", (hip_x, y, 1.0), (foot_x, y - 0.04, 0.28), 0.22, 0.14, mats["Scale"], bone, 6)
|
||||||
|
ellipsoid(f"Foot{row}{suffix}", (foot_x, y - 0.22, 0.2), (0.31, 0.48, 0.19), mats["Brass"], bone, 1)
|
||||||
|
for toe_index, toe_x in enumerate((-0.13, 0.13)):
|
||||||
|
cone(
|
||||||
|
f"Toe{row}{suffix}{toe_index}", (foot_x + toe_x, y - 0.42, 0.2),
|
||||||
|
(foot_x + toe_x * 1.4, y - 0.75, 0.1), 0.055, 0.004, mats["Blade"], bone, 5,
|
||||||
|
)
|
||||||
|
|
||||||
|
cone("TailCore1", (0, 0.95, 1.3), (0, 1.85, 1.1), 0.48, 0.3, mats["Scale"], "Tail.1", 7)
|
||||||
|
cone("TailCore2", (0, 1.78, 1.1), (0, 2.72, 0.88), 0.31, 0.07, mats["Copper"], "Tail.2", 7)
|
||||||
|
cone("TailBladeTop", (0, 2.45, 0.9), (0, 3.18, 1.45), 0.2, 0.015, mats["Blade"], "Tail.2", 5)
|
||||||
|
cone("TailBladeBottom", (0, 2.45, 0.9), (0, 3.15, 0.48), 0.18, 0.015, mats["Brass"], "Tail.2", 5)
|
||||||
|
ellipsoid("TailCoreGlow", (0, 2.54, 0.91), (0.14, 0.17, 0.14), mats["Furnace"], "Tail.2", 1)
|
||||||
|
|
||||||
|
body = join_parts("BrassbeakBasilisk", rig)
|
||||||
|
clips = actions(rig, brassbeak_actions())
|
||||||
|
export_asset(
|
||||||
|
asset_id, "Brassbeak Basilisk", rig, body, clips,
|
||||||
|
[
|
||||||
|
("Body", (0, 0, 1.25), (1.25, 1.55, 0.9)),
|
||||||
|
("Head", (0, -1.65, 1.42), (0.95, 1.05, 0.75)),
|
||||||
|
("Tail", (0, 2.08, 1.0), (0.55, 1.25, 0.78)),
|
||||||
|
],
|
||||||
|
(0, 0, 1.25), 8.8, "FurnaceBurst", 20,
|
||||||
|
)
|
||||||
|
add_metadata(asset_id, "Original six-legged forge basilisk designed for I Want to Heal")
|
||||||
|
|
||||||
|
|
||||||
|
def brassbeak_actions():
|
||||||
|
return [
|
||||||
|
("Idle", 60, True, [
|
||||||
|
{"frame": 1},
|
||||||
|
{"frame": 15, "locations": {"Root": (0, 0, 0.04)}, "rotations": {"Head": (3, 0, -3), "Jaw": (7, 0, 0), "Tail.2": (0, 0, 7), "Wing.L": (0, 0, -4), "Wing.R": (0, 0, 4)}},
|
||||||
|
{"frame": 30, "rotations": {"Head": (0, 0, 3), "Jaw": (0, 0, 0), "Tail.2": (0, 0, -7)}},
|
||||||
|
{"frame": 45, "locations": {"Root": (0, 0, 0.04)}, "rotations": {"Head": (3, 0, -3), "Jaw": (7, 0, 0), "Tail.2": (0, 0, 7), "Wing.L": (0, 0, -4), "Wing.R": (0, 0, 4)}},
|
||||||
|
{"frame": 60},
|
||||||
|
]),
|
||||||
|
("Scuttle", 30, True, [
|
||||||
|
{"frame": 1, "rotations": {"Leg.FL": (-18, 0, -5), "Leg.MR": (-18, 0, 4), "Leg.BL": (-18, 0, -4), "Leg.FR": (18, 0, 5), "Leg.ML": (18, 0, -4), "Leg.BR": (18, 0, 4), "Tail.2": (0, 0, -9)}},
|
||||||
|
{"frame": 8, "locations": {"Root": (0, 0, 0.08)}, "rotations": {"Body": (-3, 0, 0)}},
|
||||||
|
{"frame": 16, "rotations": {"Leg.FL": (18, 0, 5), "Leg.MR": (18, 0, -4), "Leg.BL": (18, 0, 4), "Leg.FR": (-18, 0, -5), "Leg.ML": (-18, 0, 4), "Leg.BR": (-18, 0, -4), "Tail.2": (0, 0, 9)}},
|
||||||
|
{"frame": 23, "locations": {"Root": (0, 0, 0.08)}, "rotations": {"Body": (3, 0, 0)}},
|
||||||
|
{"frame": 30, "rotations": {"Leg.FL": (-18, 0, -5), "Leg.MR": (-18, 0, 4), "Leg.BL": (-18, 0, -4), "Leg.FR": (18, 0, 5), "Leg.ML": (18, 0, -4), "Leg.BR": (18, 0, 4), "Tail.2": (0, 0, -9)}},
|
||||||
|
]),
|
||||||
|
("BeakRend", 34, False, [
|
||||||
|
{"frame": 1},
|
||||||
|
{"frame": 9, "locations": {"Root": (0, 0.12, -0.05)}, "rotations": {"Body": (-9, 0, 0), "Head": (-24, 0, 0), "Jaw": (24, 0, 0), "Wing.L": (0, -16, -10), "Wing.R": (0, 16, 10)}},
|
||||||
|
{"frame": 15, "locations": {"Root": (0, -0.2, 0.05)}, "rotations": {"Body": (15, 0, 0), "Head": (28, 0, 0), "Jaw": (-6, 0, 0)}},
|
||||||
|
{"frame": 23, "rotations": {"Head": (-8, 0, 0), "Jaw": (12, 0, 0)}},
|
||||||
|
{"frame": 34},
|
||||||
|
]),
|
||||||
|
("FurnaceBurst", 46, False, [
|
||||||
|
{"frame": 1},
|
||||||
|
{"frame": 12, "locations": {"Root": (0, 0, 0.1)}, "scales": {"Body": (0.94, 0.94, 0.94)}, "rotations": {"Wing.L": (-18, 18, -22), "Wing.R": (-18, -18, 22), "Head": (-12, 0, 0), "Jaw": (18, 0, 0), "Tail.1": (-12, 0, 0)}},
|
||||||
|
{"frame": 20, "locations": {"Root": (0, -0.08, 0.22)}, "scales": {"Body": (1.1, 1.1, 1.1)}, "rotations": {"Wing.L": (18, -62, -64), "Wing.R": (18, 62, 64), "Head": (20, 0, 0), "Jaw": (30, 0, 0), "Tail.1": (18, 0, 0), "Tail.2": (-22, 0, 0)}},
|
||||||
|
{"frame": 30, "scales": {"Body": (0.97, 0.97, 0.97)}, "rotations": {"Wing.L": (4, -18, -20), "Wing.R": (4, 18, 20), "Jaw": (4, 0, 0), "Tail.2": (8, 0, 0)}},
|
||||||
|
{"frame": 46},
|
||||||
|
]),
|
||||||
|
("Stagger", 28, False, [
|
||||||
|
{"frame": 1},
|
||||||
|
{"frame": 6, "locations": {"Root": (0.12, 0.12, -0.09)}, "rotations": {"Body": (-14, 0, 13), "Head": (22, 0, -12), "Wing.L": (24, 0, -18), "Wing.R": (-8, 0, 12)}},
|
||||||
|
{"frame": 15, "rotations": {"Body": (7, 0, -6), "Head": (-8, 0, 5)}},
|
||||||
|
{"frame": 28},
|
||||||
|
]),
|
||||||
|
("Death", 72, False, [
|
||||||
|
{"frame": 1},
|
||||||
|
{"frame": 20, "locations": {"Root": (0.15, 0.08, -0.25)}, "rotations": {"Root": (0, 25, 32), "Body": (18, 0, 12), "Head": (24, 0, -10), "Jaw": (20, 0, 0), "Wing.L": (32, 0, -26), "Wing.R": (16, 0, 20)}},
|
||||||
|
{"frame": 46, "locations": {"Root": (0.28, 0.08, -0.78)}, "rotations": {"Root": (0, 52, 82), "Body": (30, 0, 20), "Head": (42, 0, -20), "Leg.FL": (30, 0, 0), "Leg.ML": (-24, 0, 0), "Leg.BL": (20, 0, 0), "Tail.1": (-32, 0, 0), "Tail.2": (-25, 0, 0)}},
|
||||||
|
{"frame": 72, "locations": {"Root": (0.28, 0.08, -0.82)}, "rotations": {"Root": (0, 52, 82), "Body": (30, 0, 20), "Head": (44, 0, -20), "Leg.FL": (30, 0, 0), "Leg.ML": (-24, 0, 0), "Leg.BL": (20, 0, 0), "Tail.1": (-32, 0, 0), "Tail.2": (-25, 0, 0)}},
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_bogbell_myconid() -> None:
|
||||||
|
"""Bell-capped fungal brute replacing Mirelord's frog model."""
|
||||||
|
asset_id = "bogbell-myconid"
|
||||||
|
reset_scene()
|
||||||
|
mats = prepare_materials({
|
||||||
|
"Bark": {"color": (0.12, 0.18, 0.095, 1), "roughness": 0.88},
|
||||||
|
"Root": {"color": (0.25, 0.31, 0.16, 1), "roughness": 0.8},
|
||||||
|
"Cap": {"color": (0.29, 0.055, 0.31, 1), "roughness": 0.6},
|
||||||
|
"CapEdge": {"color": (0.52, 0.15, 0.42, 1), "roughness": 0.52},
|
||||||
|
"Gill": {"color": (0.62, 0.55, 0.31, 1), "roughness": 0.7},
|
||||||
|
"Spore": {"color": (0.48, 1.0, 0.32, 1), "roughness": 0.16, "emission": (0.22, 0.92, 0.16, 1), "strength": 4.8},
|
||||||
|
})
|
||||||
|
specs = [
|
||||||
|
("Root", (0, 0, 0), (0, 0, 0.5), None),
|
||||||
|
("Body", (0, 0, 1.15), (0, 0, 2.25), "Root"),
|
||||||
|
("Cap", (0, -0.05, 2.18), (0, -0.05, 3.18), "Body"),
|
||||||
|
("Arm.L", (-0.58, -0.15, 1.72), (-1.28, -0.62, 0.7), "Body"),
|
||||||
|
("Arm.R", (0.58, -0.15, 1.72), (1.28, -0.62, 0.7), "Body"),
|
||||||
|
("Leg.L", (-0.38, 0.08, 1.08), (-0.58, -0.1, 0.2), "Body"),
|
||||||
|
("Leg.R", (0.38, 0.08, 1.08), (0.58, -0.1, 0.2), "Body"),
|
||||||
|
("Tendril.L", (-0.42, 0.58, 1.45), (-1.05, 1.45, 0.82), "Body"),
|
||||||
|
("Tendril.R", (0.42, 0.58, 1.45), (1.05, 1.45, 0.82), "Body"),
|
||||||
|
]
|
||||||
|
rig = armature("BogbellMyconid", specs)
|
||||||
|
|
||||||
|
# Gnarled trunk and hanging bell cap.
|
||||||
|
ellipsoid("Trunk", (0, 0.05, 1.48), (0.82, 0.68, 1.05), mats["Bark"], "Body", 2)
|
||||||
|
ellipsoid("ChestKnot", (0, -0.5, 1.62), (0.58, 0.28, 0.62), mats["Root"], "Body", 1)
|
||||||
|
cone("NeckStalk", (0, -0.02, 1.95), (0, -0.04, 2.65), 0.48, 0.36, mats["Gill"], "Cap", 8)
|
||||||
|
plate("BellCap", (0, -0.04, 2.78), (1.55, 1.38, 0.55), (0, 0, 0), mats["Cap"], "Cap", 9)
|
||||||
|
ellipsoid("CapCrown", (0, 0.02, 3.04), (1.25, 1.08, 0.42), mats["CapEdge"], "Cap", 2)
|
||||||
|
plate("GillBell", (0, -0.03, 2.58), (1.28, 1.12, 0.28), (0, 0, math.radians(180)), mats["Gill"], "Cap", 9)
|
||||||
|
for index, angle in enumerate(range(0, 360, 45)):
|
||||||
|
radians = math.radians(angle)
|
||||||
|
x, y = math.cos(radians) * 1.02, math.sin(radians) * 0.87
|
||||||
|
cone(
|
||||||
|
f"CapHorn{index}", (x * 0.9, y * 0.9, 3.12), (x * 1.38, y * 1.35, 3.34 + 0.08 * (index % 2)),
|
||||||
|
0.11, 0, mats["CapEdge"], "Cap", 5,
|
||||||
|
)
|
||||||
|
for side, suffix in ((-1, "L"), (1, "R")):
|
||||||
|
ellipsoid(f"Eye{suffix}", (side * 0.29, -0.65, 2.18), (0.095, 0.055, 0.11), mats["Spore"], "Cap", 1)
|
||||||
|
cone(
|
||||||
|
f"FaceRoot{suffix}", (side * 0.25, -0.52, 2.03), (side * 0.42, -0.78, 1.72),
|
||||||
|
0.07, 0.015, mats["Root"], "Cap", 5,
|
||||||
|
)
|
||||||
|
ellipsoid("MouthHollow", (0, -0.68, 1.94), (0.22, 0.055, 0.13), mats["Cap"], "Cap", 1)
|
||||||
|
|
||||||
|
# Root arms end in broad knuckles for readable pummel animation.
|
||||||
|
for side, suffix in ((-1, "L"), (1, "R")):
|
||||||
|
bone = f"Arm.{suffix}"
|
||||||
|
cone(f"UpperArm{suffix}", (side * 0.55, -0.12, 1.78), (side * 1.04, -0.45, 1.05), 0.3, 0.22, mats["Bark"], bone, 7)
|
||||||
|
cone(f"Forearm{suffix}", (side * 1.02, -0.44, 1.06), (side * 1.34, -0.84, 0.58), 0.24, 0.18, mats["Root"], bone, 7)
|
||||||
|
ellipsoid(f"Knuckle{suffix}", (side * 1.38, -0.91, 0.48), (0.43, 0.38, 0.32), mats["Bark"], bone, 1)
|
||||||
|
for finger in (-0.16, 0, 0.16):
|
||||||
|
cone(
|
||||||
|
f"Finger{suffix}{finger}", (side * 1.36 + finger, -1.02, 0.43),
|
||||||
|
(side * 1.46 + finger, -1.35, 0.22), 0.065, 0.012, mats["Root"], bone, 5,
|
||||||
|
)
|
||||||
|
|
||||||
|
for side, suffix in ((-1, "L"), (1, "R")):
|
||||||
|
bone = f"Leg.{suffix}"
|
||||||
|
ellipsoid(f"Hip{suffix}", (side * 0.4, 0.08, 1.0), (0.42, 0.46, 0.5), mats["Bark"], bone, 1)
|
||||||
|
cone(f"RootLeg{suffix}", (side * 0.4, 0.06, 0.95), (side * 0.62, -0.12, 0.25), 0.34, 0.22, mats["Root"], bone, 7)
|
||||||
|
for toe_index, toe_x in enumerate((-0.22, 0, 0.22)):
|
||||||
|
cone(
|
||||||
|
f"RootToe{suffix}{toe_index}", (side * 0.62 + toe_x, -0.2, 0.25),
|
||||||
|
(side * 0.72 + toe_x * 1.25, -0.78 - abs(toe_x), 0.08), 0.095, 0.015, mats["Bark"], bone, 6,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rear tendrils drag through mire. Spore sacs pulse during eruption.
|
||||||
|
for side, suffix in ((-1, "L"), (1, "R")):
|
||||||
|
bone = f"Tendril.{suffix}"
|
||||||
|
cone(f"TendrilBase{suffix}", (side * 0.4, 0.5, 1.38), (side * 0.78, 1.22, 0.82), 0.22, 0.12, mats["Root"], bone, 7)
|
||||||
|
cone(f"TendrilTip{suffix}", (side * 0.76, 1.18, 0.84), (side * 1.28, 1.85, 0.3), 0.13, 0.018, mats["Bark"], bone, 6)
|
||||||
|
ellipsoid(f"SporeSac{suffix}", (side * 0.82, 0.72, 1.25), (0.24, 0.31, 0.3), mats["Spore"], bone, 1)
|
||||||
|
for index, (x, y, z, size) in enumerate(((-0.5, 0.45, 1.88, 0.16), (0.48, 0.5, 1.72, 0.2), (-0.28, 0.62, 1.35, 0.13))):
|
||||||
|
ellipsoid(f"BodySpore{index}", (x, y, z), (size, size * 0.82, size * 1.1), mats["Spore"], "Body", 1)
|
||||||
|
|
||||||
|
body = join_parts("BogbellMyconid", rig)
|
||||||
|
clips = actions(rig, bogbell_actions())
|
||||||
|
export_asset(
|
||||||
|
asset_id, "Bogbell Myconid", rig, body, clips,
|
||||||
|
[
|
||||||
|
("Body", (0, 0, 1.45), (1.05, 0.95, 1.35)),
|
||||||
|
("Cap", (0, 0, 2.82), (1.68, 1.48, 0.72)),
|
||||||
|
("Roots", (0, 0.38, 0.62), (1.58, 1.75, 0.72)),
|
||||||
|
],
|
||||||
|
(0, 0, 1.65), 8.5, "SporeEruption", 21,
|
||||||
|
)
|
||||||
|
add_metadata(asset_id, "Original bell-capped fungal mire creature designed for I Want to Heal")
|
||||||
|
|
||||||
|
|
||||||
|
def bogbell_actions():
|
||||||
|
return [
|
||||||
|
("Idle", 60, True, [
|
||||||
|
{"frame": 1},
|
||||||
|
{"frame": 15, "locations": {"Root": (0, 0, 0.04)}, "scales": {"Cap": (1.03, 1.03, 0.98)}, "rotations": {"Cap": (2, 0, -3), "Arm.L": (0, 0, -3), "Arm.R": (0, 0, 3), "Tendril.L": (0, 0, 7), "Tendril.R": (0, 0, -7)}},
|
||||||
|
{"frame": 30, "scales": {"Cap": (0.98, 0.98, 1.03)}, "rotations": {"Cap": (-1, 0, 3), "Tendril.L": (0, 0, -7), "Tendril.R": (0, 0, 7)}},
|
||||||
|
{"frame": 45, "locations": {"Root": (0, 0, 0.04)}, "scales": {"Cap": (1.03, 1.03, 0.98)}, "rotations": {"Cap": (2, 0, -3), "Arm.L": (0, 0, -3), "Arm.R": (0, 0, 3), "Tendril.L": (0, 0, 7), "Tendril.R": (0, 0, -7)}},
|
||||||
|
{"frame": 60},
|
||||||
|
]),
|
||||||
|
("BurrowRush", 32, True, [
|
||||||
|
{"frame": 1, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (10, 0, 0), "Arm.L": (26, 0, -12), "Arm.R": (26, 0, 12), "Leg.L": (-16, 0, 0), "Leg.R": (16, 0, 0), "Tendril.L": (-18, 0, -12), "Tendril.R": (-18, 0, 12)}},
|
||||||
|
{"frame": 9, "locations": {"Root": (0, 0, -0.26)}, "rotations": {"Cap": (-9, 0, 0), "Leg.L": (16, 0, 0), "Leg.R": (-16, 0, 0), "Tendril.L": (14, 0, 10), "Tendril.R": (14, 0, -10)}},
|
||||||
|
{"frame": 17, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (10, 0, 0), "Arm.L": (26, 0, -12), "Arm.R": (26, 0, 12), "Leg.L": (-16, 0, 0), "Leg.R": (16, 0, 0), "Tendril.L": (-18, 0, -12), "Tendril.R": (-18, 0, 12)}},
|
||||||
|
{"frame": 25, "locations": {"Root": (0, 0, -0.26)}, "rotations": {"Cap": (-9, 0, 0), "Leg.L": (16, 0, 0), "Leg.R": (-16, 0, 0), "Tendril.L": (14, 0, 10), "Tendril.R": (14, 0, -10)}},
|
||||||
|
{"frame": 32, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (10, 0, 0), "Arm.L": (26, 0, -12), "Arm.R": (26, 0, 12), "Leg.L": (-16, 0, 0), "Leg.R": (16, 0, 0), "Tendril.L": (-18, 0, -12), "Tendril.R": (-18, 0, 12)}},
|
||||||
|
]),
|
||||||
|
("RootPummel", 36, False, [
|
||||||
|
{"frame": 1},
|
||||||
|
{"frame": 10, "locations": {"Root": (0, 0.1, 0.06)}, "rotations": {"Body": (-12, 0, 0), "Cap": (-8, 0, 0), "Arm.L": (-42, 0, -30), "Arm.R": (-42, 0, 30)}},
|
||||||
|
{"frame": 17, "locations": {"Root": (0, -0.12, -0.14)}, "rotations": {"Body": (22, 0, 0), "Cap": (18, 0, 0), "Arm.L": (58, 0, 16), "Arm.R": (58, 0, -16)}},
|
||||||
|
{"frame": 25, "rotations": {"Body": (-5, 0, 0), "Arm.L": (12, 0, -6), "Arm.R": (12, 0, 6)}},
|
||||||
|
{"frame": 36},
|
||||||
|
]),
|
||||||
|
("SporeEruption", 48, False, [
|
||||||
|
{"frame": 1},
|
||||||
|
{"frame": 13, "locations": {"Root": (0, 0, -0.12)}, "scales": {"Cap": (0.88, 0.88, 1.16)}, "rotations": {"Body": (-13, 0, 0), "Cap": (-15, 0, 0), "Arm.L": (-26, 0, -28), "Arm.R": (-26, 0, 28), "Tendril.L": (-28, 0, -24), "Tendril.R": (-28, 0, 24)}},
|
||||||
|
{"frame": 21, "locations": {"Root": (0, -0.04, 0.24)}, "scales": {"Cap": (1.18, 1.18, 0.9), "Body": (1.08, 1.08, 1.08)}, "rotations": {"Body": (18, 0, 0), "Cap": (19, 0, 0), "Arm.L": (18, 0, 62), "Arm.R": (18, 0, -62), "Tendril.L": (32, 0, 48), "Tendril.R": (32, 0, -48)}},
|
||||||
|
{"frame": 32, "scales": {"Cap": (0.97, 0.97, 1.04), "Body": (0.97, 0.97, 0.97)}, "rotations": {"Cap": (-5, 0, 0), "Arm.L": (4, 0, 12), "Arm.R": (4, 0, -12)}},
|
||||||
|
{"frame": 48},
|
||||||
|
]),
|
||||||
|
("Stagger", 28, False, [
|
||||||
|
{"frame": 1},
|
||||||
|
{"frame": 6, "locations": {"Root": (0.14, 0.1, -0.08)}, "rotations": {"Body": (-15, 0, 13), "Cap": (24, 0, -18), "Arm.L": (20, 0, -18), "Arm.R": (-8, 0, 12)}},
|
||||||
|
{"frame": 15, "rotations": {"Body": (7, 0, -6), "Cap": (-8, 0, 7)}},
|
||||||
|
{"frame": 28},
|
||||||
|
]),
|
||||||
|
("Death", 74, False, [
|
||||||
|
{"frame": 1},
|
||||||
|
{"frame": 20, "locations": {"Root": (0.14, 0.1, -0.3)}, "rotations": {"Root": (0, 24, 30), "Body": (20, 0, 12), "Cap": (28, 0, -18), "Arm.L": (30, 0, -26), "Arm.R": (16, 0, 20), "Tendril.L": (-24, 0, -14), "Tendril.R": (-16, 0, 18)}},
|
||||||
|
{"frame": 48, "locations": {"Root": (0.28, 0.1, -0.86)}, "rotations": {"Root": (0, 54, 84), "Body": (34, 0, 24), "Cap": (48, 0, -30), "Arm.L": (52, 0, -42), "Arm.R": (28, 0, 34), "Leg.L": (24, 0, 0), "Leg.R": (-18, 0, 0), "Tendril.L": (-40, 0, -24), "Tendril.R": (-34, 0, 26)}},
|
||||||
|
{"frame": 74, "locations": {"Root": (0.28, 0.1, -0.9)}, "rotations": {"Root": (0, 54, 84), "Body": (34, 0, 24), "Cap": (50, 0, -30), "Arm.L": (52, 0, -42), "Arm.R": (28, 0, 34), "Leg.L": (24, 0, 0), "Leg.R": (-18, 0, 0), "Tendril.L": (-40, 0, -24), "Tendril.R": (-34, 0, 26)}},
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
bpy.context.preferences.filepaths.save_version = 0
|
||||||
|
OUT_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
build_brassbeak_basilisk()
|
||||||
|
build_bogbell_myconid()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { access } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
EXTMeshoptCompression,
|
||||||
|
} from "@gltf-transform/extensions";
|
||||||
|
import { dedup, mergeDocuments, prune, unpartition } from "@gltf-transform/functions";
|
||||||
|
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const sourceDirectory = path.join(
|
||||||
|
repositoryRoot,
|
||||||
|
"game_assets",
|
||||||
|
"models",
|
||||||
|
"original",
|
||||||
|
"environment",
|
||||||
|
"kaykit-dungeon",
|
||||||
|
);
|
||||||
|
const outputPath = path.join(sourceDirectory, "dungeon-kit-uastc.glb");
|
||||||
|
const sources = ["pillar-decorated.glb", "wall-pillar.glb", "torch-lit.glb"];
|
||||||
|
const expectedMeshNames = ["pillar_decorated", "wall_pillar", "torch_lit"];
|
||||||
|
await Promise.all(sources.map((source) => access(path.join(sourceDirectory, source))));
|
||||||
|
const io = await createGameAssetIO();
|
||||||
|
|
||||||
|
const documents = await Promise.all(sources.map((source) => io.read(path.join(sourceDirectory, source))));
|
||||||
|
const document = documents[0];
|
||||||
|
const destinationScene = document.getRoot().listScenes()[0].setName("dungeon_kit");
|
||||||
|
|
||||||
|
for (const sourceDocument of documents.slice(1)) {
|
||||||
|
const sourceScene = sourceDocument.getRoot().listScenes()[0];
|
||||||
|
const propertyMap = mergeDocuments(document, sourceDocument);
|
||||||
|
const copiedScene = propertyMap.get(sourceScene);
|
||||||
|
if (!copiedScene) throw new Error(`Could not merge scene from ${sourceScene.getName() || "unnamed source"}.`);
|
||||||
|
for (const child of [...copiedScene.listChildren()]) destinationScene.addChild(child);
|
||||||
|
copiedScene.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
await document.transform(
|
||||||
|
dedup({ keepUniqueNames: false }),
|
||||||
|
unpartition(),
|
||||||
|
prune({ keepSolidTextures: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = document.getRoot();
|
||||||
|
const meshNames = root.listMeshes().map((mesh) => mesh.getName()).sort();
|
||||||
|
const expectedSorted = [...expectedMeshNames].sort();
|
||||||
|
if (JSON.stringify(meshNames) !== JSON.stringify(expectedSorted)) {
|
||||||
|
throw new Error(`Dungeon kit mesh names changed: expected ${expectedSorted.join(", ")}; received ${meshNames.join(", ")}.`);
|
||||||
|
}
|
||||||
|
if (root.listTextures().length !== 1) {
|
||||||
|
throw new Error(`Dungeon kit must contain exactly one shared texture; received ${root.listTextures().length}.`);
|
||||||
|
}
|
||||||
|
if (root.listMaterials().length !== 1) {
|
||||||
|
throw new Error(`Dungeon kit must contain exactly one shared material; received ${root.listMaterials().length}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await convertDocumentTexturesToKtx2(document, "iwt-dungeon-kit-");
|
||||||
|
const meshoptExtension = root.listExtensionsUsed()
|
||||||
|
.find((extension) => extension.extensionName === EXTMeshoptCompression.EXTENSION_NAME)
|
||||||
|
?? document.createExtension(EXTMeshoptCompression);
|
||||||
|
meshoptExtension
|
||||||
|
.setRequired(true)
|
||||||
|
.setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.QUANTIZE });
|
||||||
|
|
||||||
|
await io.write(outputPath, document);
|
||||||
|
|
||||||
|
const outputDocument = await io.read(outputPath);
|
||||||
|
const outputRoot = outputDocument.getRoot();
|
||||||
|
const outputTexture = outputRoot.listTextures()[0];
|
||||||
|
if (
|
||||||
|
outputRoot.listMeshes().length !== 3
|
||||||
|
|| outputRoot.listMaterials().length !== 1
|
||||||
|
|| outputRoot.listTextures().length !== 1
|
||||||
|
|| outputTexture.getMimeType() !== "image/ktx2"
|
||||||
|
) {
|
||||||
|
throw new Error("Generated dungeon kit failed structural validation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Built ${path.relative(repositoryRoot, outputPath)}`);
|
||||||
|
console.log("Meshes: pillar_decorated, wall_pillar, torch_lit; shared textures: 1; encoding: KTX2/UASTC.");
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { access, readFile, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { Box3, Vector3 } from "three";
|
||||||
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||||
|
import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
|
||||||
|
import { dedup, meshopt, prune, resample } from "@gltf-transform/functions";
|
||||||
|
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const assetDirectory = path.join(repositoryRoot, "game_assets", "models", "sketchfab-opensource");
|
||||||
|
const sourcePath = path.join(assetDirectory, "animated_triceratops_skeleton.glb");
|
||||||
|
const legacyPath = path.join(assetDirectory, "gravehorn-triceratops.glb");
|
||||||
|
const optimizedPath = path.join(assetDirectory, "gravehorn-triceratops-uastc.glb");
|
||||||
|
const requiredClips = [
|
||||||
|
"Armature|RiseUp",
|
||||||
|
"Armature|Roar",
|
||||||
|
"Armature|Walk",
|
||||||
|
"Armature|Fall",
|
||||||
|
"Gravehorn|Idle",
|
||||||
|
];
|
||||||
|
const unusedClips = new Set(["Armature|IdleGround", "Armature|RoarToWalk"]);
|
||||||
|
|
||||||
|
function sortedNames(properties) {
|
||||||
|
return properties.map((property) => property.getName()).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUprightIdle(document) {
|
||||||
|
const walk = document.getRoot().listAnimations().find((animation) => animation.getName() === "Armature|Walk");
|
||||||
|
const buffer = document.getRoot().listBuffers()[0];
|
||||||
|
if (!walk || !buffer) throw new Error("Gravehorn source is missing its Walk animation or binary buffer.");
|
||||||
|
|
||||||
|
const idle = document.createAnimation("Gravehorn|Idle");
|
||||||
|
for (const [index, sourceChannel] of walk.listChannels().entries()) {
|
||||||
|
const sourceSampler = sourceChannel.getSampler();
|
||||||
|
const sourceOutput = sourceSampler?.getOutput();
|
||||||
|
const sourceValues = sourceOutput?.getArray();
|
||||||
|
const targetNode = sourceChannel.getTargetNode();
|
||||||
|
const targetPath = sourceChannel.getTargetPath();
|
||||||
|
if (!sourceSampler || !sourceOutput || !sourceValues || !targetNode || !targetPath) {
|
||||||
|
throw new Error(`Gravehorn Walk channel ${index} is incomplete.`);
|
||||||
|
}
|
||||||
|
if (sourceSampler.getInterpolation() === "CUBICSPLINE") {
|
||||||
|
throw new Error("Gravehorn upright idle builder does not support cubic animation tracks.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const elementSize = sourceOutput.getElementSize();
|
||||||
|
const values = new sourceValues.constructor(elementSize * 2);
|
||||||
|
values.set(sourceValues.subarray(0, elementSize), 0);
|
||||||
|
values.set(sourceValues.subarray(0, elementSize), elementSize);
|
||||||
|
const input = document.createAccessor(`gravehorn_idle_time_${index}`)
|
||||||
|
.setType("SCALAR")
|
||||||
|
.setArray(new Float32Array([0, 1]))
|
||||||
|
.setBuffer(buffer);
|
||||||
|
const output = document.createAccessor(`gravehorn_idle_value_${index}`)
|
||||||
|
.setType(sourceOutput.getType())
|
||||||
|
.setNormalized(sourceOutput.getNormalized())
|
||||||
|
.setArray(values)
|
||||||
|
.setBuffer(buffer);
|
||||||
|
const sampler = document.createAnimationSampler(`gravehorn_idle_sampler_${index}`)
|
||||||
|
.setInput(input)
|
||||||
|
.setOutput(output)
|
||||||
|
.setInterpolation("LINEAR");
|
||||||
|
const channel = document.createAnimationChannel(`gravehorn_idle_channel_${index}`)
|
||||||
|
.setSampler(sampler)
|
||||||
|
.setTargetNode(targetNode)
|
||||||
|
.setTargetPath(targetPath);
|
||||||
|
idle.addSampler(sampler).addChannel(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runtimeBounds(assetPath) {
|
||||||
|
const data = await readFile(assetPath);
|
||||||
|
const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
||||||
|
globalThis.self ??= globalThis;
|
||||||
|
globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} });
|
||||||
|
const gltf = await new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).parseAsync(arrayBuffer, "");
|
||||||
|
gltf.scene.updateMatrixWorld(true);
|
||||||
|
const bounds = new Box3().setFromObject(gltf.scene);
|
||||||
|
return {
|
||||||
|
min: bounds.min.toArray(),
|
||||||
|
max: bounds.max.toArray(),
|
||||||
|
center: bounds.getCenter(new Vector3()).toArray(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateStructure(document, { optimized }) {
|
||||||
|
const root = document.getRoot();
|
||||||
|
const material = root.listMaterials()[0];
|
||||||
|
const clips = sortedNames(root.listAnimations());
|
||||||
|
const textureMimeTypes = root.listTextures().map((texture) => texture.getMimeType());
|
||||||
|
const extensionNames = new Set(root.listExtensionsUsed().map((extension) => extension.extensionName));
|
||||||
|
|
||||||
|
if (JSON.stringify(clips) !== JSON.stringify([...requiredClips].sort())) {
|
||||||
|
throw new Error(`Gravehorn animation clips changed: ${clips.join(", ")}.`);
|
||||||
|
}
|
||||||
|
if (root.listMeshes().length !== 1 || root.listMaterials().length !== 1 || root.listTextures().length !== 3 || root.listSkins().length !== 1) {
|
||||||
|
throw new Error("Gravehorn must contain one mesh, one material, three textures, and one skin.");
|
||||||
|
}
|
||||||
|
if (root.listSkins()[0].listJoints().length !== 140) {
|
||||||
|
throw new Error(`Gravehorn joint count changed: ${root.listSkins()[0].listJoints().length}.`);
|
||||||
|
}
|
||||||
|
if (material.getDoubleSided()) {
|
||||||
|
throw new Error("Gravehorn material must keep backface culling enabled.");
|
||||||
|
}
|
||||||
|
if (!extensionNames.has("EXT_meshopt_compression")) {
|
||||||
|
throw new Error("Gravehorn is missing Meshopt compression.");
|
||||||
|
}
|
||||||
|
if (optimized && (textureMimeTypes.some((mimeType) => mimeType !== "image/ktx2") || !extensionNames.has("KHR_texture_basisu"))) {
|
||||||
|
throw new Error("Optimized Gravehorn asset must contain only KTX2 textures.");
|
||||||
|
}
|
||||||
|
if (!optimized && textureMimeTypes.some((mimeType) => mimeType === "image/ktx2")) {
|
||||||
|
throw new Error("Legacy Gravehorn fallback must retain standard textures.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await access(sourcePath);
|
||||||
|
await MeshoptDecoder.ready;
|
||||||
|
await MeshoptEncoder.ready;
|
||||||
|
const io = await createGameAssetIO();
|
||||||
|
const document = await io.read(sourcePath);
|
||||||
|
const root = document.getRoot();
|
||||||
|
const scene = root.listScenes()[0].setName("gravehorn_triceratops");
|
||||||
|
const sceneRoot = scene.listChildren()[0];
|
||||||
|
const sourceBounds = await runtimeBounds(sourcePath);
|
||||||
|
const sourceTranslation = sceneRoot.getTranslation();
|
||||||
|
|
||||||
|
sceneRoot.setTranslation([
|
||||||
|
sourceTranslation[0] - sourceBounds.center[0],
|
||||||
|
sourceTranslation[1] - sourceBounds.min[1],
|
||||||
|
sourceTranslation[2] - sourceBounds.center[2],
|
||||||
|
]);
|
||||||
|
root.listMeshes()[0].setName("gravehorn_triceratops");
|
||||||
|
root.listMaterials()[0]
|
||||||
|
.setName("gravehorn_bone")
|
||||||
|
.setDoubleSided(false)
|
||||||
|
.setEmissiveFactor([0.1, 0.06, 0.02]);
|
||||||
|
addUprightIdle(document);
|
||||||
|
for (const animation of root.listAnimations()) {
|
||||||
|
if (!unusedClips.has(animation.getName())) continue;
|
||||||
|
for (const channel of animation.listChannels()) channel.dispose();
|
||||||
|
for (const sampler of animation.listSamplers()) sampler.dispose();
|
||||||
|
animation.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
await document.transform(
|
||||||
|
resample({ tolerance: 1e-4 }),
|
||||||
|
dedup({ keepUniqueNames: true }),
|
||||||
|
prune({ keepLeaves: true, keepSolidTextures: true }),
|
||||||
|
meshopt({ encoder: MeshoptEncoder, level: "high" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await io.write(legacyPath, document);
|
||||||
|
const legacyDocument = await io.read(legacyPath);
|
||||||
|
validateStructure(legacyDocument, { optimized: false });
|
||||||
|
const legacyBounds = await runtimeBounds(legacyPath);
|
||||||
|
if (Math.abs(legacyBounds.min[1]) > 0.015 || Math.abs(legacyBounds.center[0]) > 0.015 || Math.abs(legacyBounds.center[2]) > 0.015) {
|
||||||
|
throw new Error(`Gravehorn pivot is not grounded and centered: minY=${legacyBounds.min[1]}, centerX=${legacyBounds.center[0]}, centerZ=${legacyBounds.center[2]}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const optimizedDocument = await io.read(legacyPath);
|
||||||
|
await convertDocumentTexturesToKtx2(optimizedDocument, "iwt-gravehorn-");
|
||||||
|
await io.write(optimizedPath, optimizedDocument);
|
||||||
|
validateStructure(await io.read(optimizedPath), { optimized: true });
|
||||||
|
|
||||||
|
const sourceSize = (await stat(sourcePath)).size;
|
||||||
|
const legacySize = (await stat(legacyPath)).size;
|
||||||
|
const optimizedSize = (await stat(optimizedPath)).size;
|
||||||
|
console.log(`Built ${path.relative(repositoryRoot, legacyPath)} (${sourceSize} -> ${legacySize} bytes).`);
|
||||||
|
console.log(`Built ${path.relative(repositoryRoot, optimizedPath)} (${optimizedSize} bytes, KTX2/UASTC).`);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { access } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const sourceRoot = path.join(repositoryRoot, "game_assets");
|
||||||
|
const sourceAssets = [
|
||||||
|
"models/claudecraft/chars/players/druid.glb",
|
||||||
|
"models/claudecraft/chars/players/knight.glb",
|
||||||
|
"models/claudecraft/chars/players/mage.glb",
|
||||||
|
"models/claudecraft/chars/players/ranger.glb",
|
||||||
|
"models/claudecraft/chars/players/rogue.glb",
|
||||||
|
"models/claudecraft/weapons/adv_dagger.glb",
|
||||||
|
"models/claudecraft/weapons/adv_druid_staff.glb",
|
||||||
|
"models/claudecraft/weapons/adv_sword_1handed.glb",
|
||||||
|
"models/claudecraft/weapons/adv_wand.glb",
|
||||||
|
"models/claudecraft/weapons/crossbow_2handed.glb",
|
||||||
|
"models/claudecraft/weapons/shield_badge.glb",
|
||||||
|
"models/claudecraft/weapons/spellbook_open.glb",
|
||||||
|
];
|
||||||
|
|
||||||
|
function optimizedPath(sourcePath) {
|
||||||
|
return sourcePath.replace(/\.glb$/, "-uastc.glb");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedNames(properties) {
|
||||||
|
return properties.map((property) => property.getName()).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const io = await createGameAssetIO();
|
||||||
|
for (const relativePath of sourceAssets) {
|
||||||
|
const inputPath = path.join(sourceRoot, relativePath);
|
||||||
|
const outputRelativePath = optimizedPath(relativePath);
|
||||||
|
const outputPath = path.join(sourceRoot, outputRelativePath);
|
||||||
|
await access(inputPath);
|
||||||
|
|
||||||
|
const document = await io.read(inputPath);
|
||||||
|
const sourceRootProperties = document.getRoot();
|
||||||
|
const expected = {
|
||||||
|
animations: sortedNames(sourceRootProperties.listAnimations()),
|
||||||
|
materials: sortedNames(sourceRootProperties.listMaterials()),
|
||||||
|
meshes: sortedNames(sourceRootProperties.listMeshes()),
|
||||||
|
nodes: sortedNames(sourceRootProperties.listNodes()),
|
||||||
|
scenes: sortedNames(sourceRootProperties.listScenes()),
|
||||||
|
textureCount: sourceRootProperties.listTextures().length,
|
||||||
|
};
|
||||||
|
|
||||||
|
await convertDocumentTexturesToKtx2(document, "iwt-ktx2-");
|
||||||
|
await io.write(outputPath, document);
|
||||||
|
|
||||||
|
const outputDocument = await io.read(outputPath);
|
||||||
|
const outputRoot = outputDocument.getRoot();
|
||||||
|
const actual = {
|
||||||
|
animations: sortedNames(outputRoot.listAnimations()),
|
||||||
|
materials: sortedNames(outputRoot.listMaterials()),
|
||||||
|
meshes: sortedNames(outputRoot.listMeshes()),
|
||||||
|
nodes: sortedNames(outputRoot.listNodes()),
|
||||||
|
scenes: sortedNames(outputRoot.listScenes()),
|
||||||
|
textureCount: outputRoot.listTextures().length,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
JSON.stringify(actual) !== JSON.stringify(expected)
|
||||||
|
|| outputRoot.listTextures().some((texture) => texture.getMimeType() !== "image/ktx2")
|
||||||
|
|| outputRoot.listExtensionsUsed().some((extension) => extension.extensionName === "EXT_texture_webp")
|
||||||
|
) {
|
||||||
|
throw new Error(`Generated asset failed round-trip validation: ${outputRelativePath}`);
|
||||||
|
}
|
||||||
|
console.log(`Built game_assets/${outputRelativePath}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)}`);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
|
const dataDirectory = resolve(process.env.DATA_DIR ?? "data");
|
||||||
|
mkdirSync(dataDirectory, { recursive: true });
|
||||||
|
|
||||||
|
const database = new DatabaseSync(resolve(dataDirectory, "game.db"));
|
||||||
|
const schema = await readFile(new URL("../db/schema.sql", import.meta.url), "utf8");
|
||||||
|
database.exec(schema);
|
||||||
|
database.close();
|
||||||
|
|
||||||
|
console.log(`Database ready: ${resolve(dataDirectory, "game.db")}`);
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { NodeIO } from "@gltf-transform/core";
|
||||||
|
import { ALL_EXTENSIONS, EXTTextureWebP, KHRTextureBasisu } from "@gltf-transform/extensions";
|
||||||
|
import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
export const TOKTX_COMMAND = process.env.TOKTX ?? "toktx";
|
||||||
|
|
||||||
|
export async function createGameAssetIO() {
|
||||||
|
await MeshoptDecoder.ready;
|
||||||
|
await MeshoptEncoder.ready;
|
||||||
|
return new NodeIO()
|
||||||
|
.registerExtensions(ALL_EXTENSIONS)
|
||||||
|
.registerDependencies({
|
||||||
|
"meshopt.decoder": MeshoptDecoder,
|
||||||
|
"meshopt.encoder": MeshoptEncoder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, { stdio: "inherit" });
|
||||||
|
child.once("error", reject);
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error(`${command} exited with code ${code ?? "unknown"}.`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertDocumentTexturesToKtx2(document, temporaryPrefix) {
|
||||||
|
const textures = document.getRoot().listTextures();
|
||||||
|
if (textures.length === 0) throw new Error("Asset contains no textures to convert.");
|
||||||
|
|
||||||
|
const temporaryDirectory = await mkdtemp(path.join(tmpdir(), temporaryPrefix));
|
||||||
|
try {
|
||||||
|
for (const [index, texture] of textures.entries()) {
|
||||||
|
const sourceImage = texture.getImage();
|
||||||
|
if (!sourceImage) throw new Error(`Texture ${texture.getName() || index} contains no image data.`);
|
||||||
|
|
||||||
|
const pngPath = path.join(temporaryDirectory, `texture-${index}.png`);
|
||||||
|
const ktx2Path = path.join(temporaryDirectory, `texture-${index}.ktx2`);
|
||||||
|
await writeFile(pngPath, await sharp(sourceImage).png().toBuffer());
|
||||||
|
await run(TOKTX_COMMAND, [
|
||||||
|
"--t2",
|
||||||
|
"--encode", "uastc",
|
||||||
|
"--uastc_quality", "4",
|
||||||
|
"--zcmp", "18",
|
||||||
|
"--threads", process.env.TOKTX_THREADS ?? "4",
|
||||||
|
"--genmipmap",
|
||||||
|
"--assign_oetf", "srgb",
|
||||||
|
"--assign_primaries", "bt709",
|
||||||
|
ktx2Path,
|
||||||
|
pngPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
texture
|
||||||
|
.setName(`${texture.getName() || `texture_${index}`}_uastc`)
|
||||||
|
.setMimeType("image/ktx2")
|
||||||
|
.setImage(new Uint8Array(await readFile(ktx2Path)));
|
||||||
|
}
|
||||||
|
document.getRoot().listExtensionsUsed()
|
||||||
|
.find((extension) => extension.extensionName === EXTTextureWebP.EXTENSION_NAME)
|
||||||
|
?.dispose();
|
||||||
|
document.createExtension(KHRTextureBasisu).setRequired(true);
|
||||||
|
} finally {
|
||||||
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
-26
@@ -8,10 +8,10 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -22,9 +22,9 @@ from pathlib import Path
|
|||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
ANDROID_ROOT = REPO_ROOT / "android"
|
ANDROID_ROOT = REPO_ROOT / "android"
|
||||||
PACKAGE_JSON = REPO_ROOT / "package.json"
|
PACKAGE_JSON = REPO_ROOT / "package.json"
|
||||||
|
GITEA_TOKEN_FILE = REPO_ROOT / "git-token"
|
||||||
GITEA_REMOTE = "https://git.whoagland.com/phenom/i-want-to-heal-mmo.git"
|
GITEA_REMOTE = "https://git.whoagland.com/phenom/i-want-to-heal-mmo.git"
|
||||||
GITEA_API = "https://git.whoagland.com/api/v1"
|
GITEA_API = "https://git.whoagland.com/api/v1"
|
||||||
GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
|
|
||||||
GITEA_OWNER = "phenom"
|
GITEA_OWNER = "phenom"
|
||||||
GITEA_REPO = "i-want-to-heal-mmo"
|
GITEA_REPO = "i-want-to-heal-mmo"
|
||||||
BRANCH = "main"
|
BRANCH = "main"
|
||||||
@@ -35,6 +35,15 @@ TRUENAS_GITEA_REPO = Path(
|
|||||||
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
|
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaAPIError(SystemExit):
|
||||||
|
def __init__(self, method: str, path: str, status: int, details: str) -> None:
|
||||||
|
self.method = method
|
||||||
|
self.path = path
|
||||||
|
self.status = status
|
||||||
|
self.details = details
|
||||||
|
super().__init__(f"Gitea API {method} {path} failed ({status}): {details}")
|
||||||
|
|
||||||
|
|
||||||
def run(
|
def run(
|
||||||
args: list[str],
|
args: list[str],
|
||||||
*,
|
*,
|
||||||
@@ -259,11 +268,13 @@ def ensure_tag(version: str, commit: str, message: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def gitea_token() -> str:
|
def gitea_token() -> str:
|
||||||
token = os.environ.get("GITEA_TOKEN", GITEA_TOKEN).strip()
|
token = os.environ.get("GITEA_TOKEN", "").strip()
|
||||||
|
if not token and GITEA_TOKEN_FILE.is_file():
|
||||||
|
token = GITEA_TOKEN_FILE.read_text().strip()
|
||||||
if not token:
|
if not token:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"GITEA_TOKEN is required to create the release and upload the APK. "
|
f"Gitea token is required. Paste it into {GITEA_TOKEN_FILE}, "
|
||||||
"Use --skip-release to push source/tag only."
|
"set GITEA_TOKEN, or use --skip-release."
|
||||||
)
|
)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
@@ -295,7 +306,7 @@ def gitea_request(
|
|||||||
details = error.read().decode(errors="replace")
|
details = error.read().decode(errors="replace")
|
||||||
if allow_not_found and error.code == 404:
|
if allow_not_found and error.code == 404:
|
||||||
return None
|
return None
|
||||||
raise SystemExit(f"Gitea API {method} {path} failed ({error.code}): {details}") from error
|
raise GiteaAPIError(method, path, error.code, details) from error
|
||||||
return json.loads(payload) if payload else None
|
return json.loads(payload) if payload else None
|
||||||
|
|
||||||
|
|
||||||
@@ -328,28 +339,36 @@ def create_release(tag: str, commit: str, message: str, token: str) -> dict[str,
|
|||||||
"prerelease": True,
|
"prerelease": True,
|
||||||
}
|
}
|
||||||
).encode()
|
).encode()
|
||||||
|
path = f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases"
|
||||||
|
result = None
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
result = gitea_request(
|
result = gitea_request(
|
||||||
f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases",
|
path,
|
||||||
token=token,
|
token=token,
|
||||||
method="POST",
|
method="POST",
|
||||||
body=payload,
|
body=payload,
|
||||||
)
|
)
|
||||||
|
break
|
||||||
|
except GiteaAPIError as error:
|
||||||
|
tag_sync_race = (
|
||||||
|
error.status == 500
|
||||||
|
and "UQE_release_n" in error.details
|
||||||
|
and "23505" in error.details
|
||||||
|
)
|
||||||
|
if not tag_sync_race or attempt == 2:
|
||||||
|
raise
|
||||||
|
delay = 0.5 * (attempt + 1)
|
||||||
|
print(
|
||||||
|
f"Gitea tag sync still settling for {tag}; "
|
||||||
|
f"retrying release in {delay:g}s."
|
||||||
|
)
|
||||||
|
time.sleep(delay)
|
||||||
if not isinstance(result, dict):
|
if not isinstance(result, dict):
|
||||||
raise SystemExit("Gitea returned an invalid release response")
|
raise SystemExit("Gitea returned an invalid release response")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def multipart_asset(path: Path, media_type: str) -> tuple[bytes, str]:
|
|
||||||
boundary = f"----iwanttoheal{secrets.token_hex(12)}"
|
|
||||||
prefix = (
|
|
||||||
f"--{boundary}\r\n"
|
|
||||||
f'Content-Disposition: form-data; name="attachment"; filename="{path.name}"\r\n'
|
|
||||||
f"Content-Type: {media_type}\r\n\r\n"
|
|
||||||
).encode()
|
|
||||||
body = prefix + path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
|
|
||||||
return body, f"multipart/form-data; boundary={boundary}"
|
|
||||||
|
|
||||||
|
|
||||||
def upload_release_asset(
|
def upload_release_asset(
|
||||||
release_id: int,
|
release_id: int,
|
||||||
path: Path,
|
path: Path,
|
||||||
@@ -357,14 +376,45 @@ def upload_release_asset(
|
|||||||
media_type: str,
|
media_type: str,
|
||||||
token: str,
|
token: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
body, content_type = multipart_asset(path, media_type)
|
curl = shutil.which("curl")
|
||||||
gitea_request(
|
if not curl:
|
||||||
f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets"
|
raise SystemExit("curl is required to upload Gitea release assets")
|
||||||
f"?name={urllib.parse.quote(path.name)}",
|
|
||||||
token=token,
|
url = (
|
||||||
method="POST",
|
f"{GITEA_API}/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets"
|
||||||
body=body,
|
f"?name={urllib.parse.quote(path.name)}"
|
||||||
content_type=content_type,
|
)
|
||||||
|
headers = (
|
||||||
|
"Accept: application/json\n"
|
||||||
|
f"Authorization: token {token}\n"
|
||||||
|
"Expect: 100-continue\n"
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
curl,
|
||||||
|
"--silent",
|
||||||
|
"--show-error",
|
||||||
|
"--fail-with-body",
|
||||||
|
"--connect-timeout",
|
||||||
|
"30",
|
||||||
|
"--max-time",
|
||||||
|
"300",
|
||||||
|
"--header",
|
||||||
|
"@-",
|
||||||
|
"--form",
|
||||||
|
f"attachment=@{path};type={media_type}",
|
||||||
|
url,
|
||||||
|
],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
input=headers,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
details = result.stdout.strip() or result.stderr.strip()
|
||||||
|
raise SystemExit(
|
||||||
|
f"Gitea release asset upload failed (curl {result.returncode}): {details}"
|
||||||
)
|
)
|
||||||
print(f"Release asset uploaded: {path.name}")
|
print(f"Release asset uploaded: {path.name}")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { copyFile, mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const sourceDirectory = path.join(repositoryRoot, "node_modules", "three", "examples", "jsm", "libs", "basis");
|
||||||
|
const outputDirectory = path.join(repositoryRoot, "public", "basis");
|
||||||
|
const files = ["basis_transcoder.js", "basis_transcoder.wasm"];
|
||||||
|
|
||||||
|
await mkdir(outputDirectory, { recursive: true });
|
||||||
|
for (const file of files) {
|
||||||
|
await copyFile(path.join(sourceDirectory, file), path.join(outputDirectory, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Synced Three.js Basis transcoder ${files.join(", ")} -> public/basis`);
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from scripts import publish_gitea
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaTokenTests(unittest.TestCase):
|
||||||
|
def test_reads_ignored_token_file(self) -> None:
|
||||||
|
token_file = MagicMock()
|
||||||
|
token_file.is_file.return_value = True
|
||||||
|
token_file.read_text.return_value = "local-token\n"
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {}, clear=True),
|
||||||
|
patch.object(publish_gitea, "GITEA_TOKEN_FILE", token_file),
|
||||||
|
):
|
||||||
|
self.assertEqual(publish_gitea.gitea_token(), "local-token")
|
||||||
|
|
||||||
|
def test_environment_token_overrides_file(self) -> None:
|
||||||
|
token_file = MagicMock()
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {"GITEA_TOKEN": "environment-token"}, clear=True),
|
||||||
|
patch.object(publish_gitea, "GITEA_TOKEN_FILE", token_file),
|
||||||
|
):
|
||||||
|
self.assertEqual(publish_gitea.gitea_token(), "environment-token")
|
||||||
|
token_file.is_file.assert_not_called()
|
||||||
|
|
||||||
|
def test_requires_token(self) -> None:
|
||||||
|
token_file = MagicMock()
|
||||||
|
token_file.is_file.return_value = False
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {}, clear=True),
|
||||||
|
patch.object(publish_gitea, "GITEA_TOKEN_FILE", token_file),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(SystemExit, "Gitea token is required"):
|
||||||
|
publish_gitea.gitea_token()
|
||||||
|
|
||||||
|
|
||||||
|
class CreateReleaseTests(unittest.TestCase):
|
||||||
|
def test_retries_postgres_tag_sync_collision(self) -> None:
|
||||||
|
collision = publish_gitea.GiteaAPIError(
|
||||||
|
"POST",
|
||||||
|
"/repos/phenom/i-want-to-heal-mmo/releases",
|
||||||
|
500,
|
||||||
|
'duplicate key violates "UQE_release_n" (23505)',
|
||||||
|
)
|
||||||
|
created = {"id": 15, "tag_name": "v0.1.15"}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(publish_gitea, "release_for_tag", return_value=None),
|
||||||
|
patch.object(
|
||||||
|
publish_gitea,
|
||||||
|
"gitea_request",
|
||||||
|
side_effect=[collision, created],
|
||||||
|
) as request,
|
||||||
|
patch.object(publish_gitea.time, "sleep") as sleep,
|
||||||
|
):
|
||||||
|
result = publish_gitea.create_release(
|
||||||
|
"v0.1.15",
|
||||||
|
"ea5d455",
|
||||||
|
"Release v0.1.15 2026-07-18",
|
||||||
|
"token",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result, created)
|
||||||
|
self.assertEqual(request.call_count, 2)
|
||||||
|
sleep.assert_called_once_with(0.5)
|
||||||
|
|
||||||
|
def test_does_not_retry_unrelated_api_error(self) -> None:
|
||||||
|
unauthorized = publish_gitea.GiteaAPIError(
|
||||||
|
"POST",
|
||||||
|
"/repos/phenom/i-want-to-heal-mmo/releases",
|
||||||
|
401,
|
||||||
|
"unauthorized",
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(publish_gitea, "release_for_tag", return_value=None),
|
||||||
|
patch.object(
|
||||||
|
publish_gitea,
|
||||||
|
"gitea_request",
|
||||||
|
side_effect=unauthorized,
|
||||||
|
) as request,
|
||||||
|
patch.object(publish_gitea.time, "sleep") as sleep,
|
||||||
|
):
|
||||||
|
with self.assertRaises(publish_gitea.GiteaAPIError):
|
||||||
|
publish_gitea.create_release(
|
||||||
|
"v0.1.16",
|
||||||
|
"commit",
|
||||||
|
"Release v0.1.16",
|
||||||
|
"token",
|
||||||
|
)
|
||||||
|
|
||||||
|
request.assert_called_once()
|
||||||
|
sleep.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+1634
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,817 @@
|
|||||||
|
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-"));
|
||||||
|
let roguelikePvpNowMs = 1_000_000;
|
||||||
|
const api = createGameApiHandler({ dataDirectory, roguelikePvpNow: () => roguelikePvpNowMs });
|
||||||
|
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: 7,
|
||||||
|
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, 7);
|
||||||
|
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");
|
||||||
|
assert.equal(betaQueue.body.match.generation, 1);
|
||||||
|
assert.ok(betaQueue.body.match.countdownEndsAtMs > Date.now());
|
||||||
|
|
||||||
|
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);
|
||||||
|
assert.equal(alphaMatched.body.match.generation, betaQueue.body.match.generation);
|
||||||
|
assert.equal(alphaMatched.body.match.countdownEndsAtMs, betaQueue.body.match.countdownEndsAtMs);
|
||||||
|
|
||||||
|
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({ generation: 1, 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({ generation: 1, snapshot: { sequence: 1, party: [] } }),
|
||||||
|
});
|
||||||
|
assert.deepEqual(guestExchange.body.opponentSnapshot, hostSnapshot);
|
||||||
|
assert.deepEqual(guestExchange.body.hostSnapshot, hostSnapshot);
|
||||||
|
|
||||||
|
const alphaRematchWaiting = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/rematch`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1 }),
|
||||||
|
});
|
||||||
|
assert.equal(alphaRematchWaiting.body.status, "waiting");
|
||||||
|
|
||||||
|
const betaRematchReady = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/rematch`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1 }),
|
||||||
|
});
|
||||||
|
assert.equal(betaRematchReady.body.status, "matched");
|
||||||
|
assert.equal(betaRematchReady.body.match.generation, 2);
|
||||||
|
assert.ok(betaRematchReady.body.match.countdownEndsAtMs > Date.now());
|
||||||
|
|
||||||
|
const alphaRematchReady = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/rematch`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1 }),
|
||||||
|
});
|
||||||
|
assert.equal(alphaRematchReady.body.status, "matched");
|
||||||
|
assert.equal(alphaRematchReady.body.match.generation, 2);
|
||||||
|
assert.equal(alphaRematchReady.body.match.seed, betaRematchReady.body.match.seed);
|
||||||
|
assert.equal(alphaRematchReady.body.match.countdownEndsAtMs, betaRematchReady.body.match.countdownEndsAtMs);
|
||||||
|
|
||||||
|
const staleExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1, snapshot: hostSnapshot }),
|
||||||
|
});
|
||||||
|
assert.equal(staleExchange.response.status, 409);
|
||||||
|
|
||||||
|
const freshExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 2, snapshot: hostSnapshot }),
|
||||||
|
});
|
||||||
|
assert.equal(freshExchange.response.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Roguelike PVP isolates matchmaking, validates progress, hides drafts, and handles rematch lifecycle", 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}` }),
|
||||||
|
});
|
||||||
|
assert.equal(registration.response.status, 201);
|
||||||
|
return registration.body.token;
|
||||||
|
};
|
||||||
|
const snapshot = (sequence, overrides = {}) => ({
|
||||||
|
sequence,
|
||||||
|
round: 1,
|
||||||
|
phase: "combat",
|
||||||
|
partyHp: [1, 0.9, 0.8, 0.7, 0.6],
|
||||||
|
bossHp: 350,
|
||||||
|
bossMaxHp: 500,
|
||||||
|
defeatedBosses: 0,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const alphaToken = await registerPlayer("rogue_pvp_alpha");
|
||||||
|
const betaToken = await registerPlayer("rogue_pvp_beta");
|
||||||
|
const invalidMode = await json("/api/roguelike-pvp/queue", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mode: "hockey-healing-pvp", slotId: 1, hunterName: "Alpha", healerClassId: "priest" }),
|
||||||
|
});
|
||||||
|
assert.equal(invalidMode.response.status, 400);
|
||||||
|
|
||||||
|
const alphaQueue = await json("/api/roguelike-pvp/queue", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mode: "roguelike-pvp", slotId: 1, hunterName: "Alpha", healerClassId: "druid" }),
|
||||||
|
});
|
||||||
|
assert.equal(alphaQueue.body.status, "waiting");
|
||||||
|
const betaQueue = await json("/api/roguelike-pvp/queue", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mode: "roguelike-pvp", slotId: 2, hunterName: "Beta", healerClassId: "shaman" }),
|
||||||
|
});
|
||||||
|
assert.equal(betaQueue.body.status, "matched");
|
||||||
|
assert.equal(betaQueue.body.match.mode, "roguelike-pvp");
|
||||||
|
assert.equal(betaQueue.body.match.role, "guest");
|
||||||
|
assert.equal(betaQueue.body.match.opponentName, "Alpha");
|
||||||
|
assert.equal(betaQueue.body.match.opponentHealerClassId, "druid");
|
||||||
|
assert.equal(betaQueue.body.match.countdownEndsAtMs, roguelikePvpNowMs + 5_000);
|
||||||
|
|
||||||
|
const alphaMatched = await json(`/api/roguelike-pvp/queue/${alphaQueue.body.ticketId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}` },
|
||||||
|
});
|
||||||
|
assert.equal(alphaMatched.body.match.id, betaQueue.body.match.id);
|
||||||
|
assert.equal(alphaMatched.body.match.role, "host");
|
||||||
|
assert.equal(alphaMatched.body.match.opponentHealerClassId, "shaman");
|
||||||
|
assert.equal(alphaMatched.body.match.countdownEndsAtMs, betaQueue.body.match.countdownEndsAtMs);
|
||||||
|
const matchId = alphaMatched.body.match.id;
|
||||||
|
|
||||||
|
const hostState = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1, snapshot: snapshot(1) }),
|
||||||
|
});
|
||||||
|
assert.equal(hostState.response.status, 200);
|
||||||
|
assert.equal(hostState.body.status, "active");
|
||||||
|
assert.equal(hostState.body.opponentSnapshot, null);
|
||||||
|
const guestState = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1, snapshot: snapshot(1, { bossHp: 280 }) }),
|
||||||
|
});
|
||||||
|
assert.deepEqual(guestState.body.opponentSnapshot, snapshot(1));
|
||||||
|
assert.deepEqual(guestState.body.hostSnapshot, snapshot(1));
|
||||||
|
|
||||||
|
const staleState = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1, snapshot: snapshot(1) }),
|
||||||
|
});
|
||||||
|
assert.equal(staleState.response.status, 409);
|
||||||
|
const invalidState = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1, snapshot: snapshot(2, { partyHp: [1, 1] }) }),
|
||||||
|
});
|
||||||
|
assert.equal(invalidState.response.status, 400);
|
||||||
|
|
||||||
|
const roundOneDraftSnapshot = (sequence) => snapshot(sequence, {
|
||||||
|
phase: "draft",
|
||||||
|
bossHp: 0,
|
||||||
|
defeatedBosses: 2,
|
||||||
|
});
|
||||||
|
const hostRoundOneDraftState = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1, snapshot: roundOneDraftSnapshot(2) }),
|
||||||
|
});
|
||||||
|
assert.equal(hostRoundOneDraftState.response.status, 200);
|
||||||
|
const guestRoundOneDraftState = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1, snapshot: roundOneDraftSnapshot(2) }),
|
||||||
|
});
|
||||||
|
assert.equal(guestRoundOneDraftState.response.status, 200);
|
||||||
|
|
||||||
|
const openedDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1/open`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1 }),
|
||||||
|
});
|
||||||
|
assert.equal(openedDraft.body.status, "waiting");
|
||||||
|
assert.equal(openedDraft.body.deadlineAtMs, roguelikePvpNowMs + 15_000);
|
||||||
|
assert.equal(openedDraft.body.buffChoices.length, 3);
|
||||||
|
assert.equal(openedDraft.body.curseChoices.length, 3);
|
||||||
|
|
||||||
|
const futureDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2/open`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1 }),
|
||||||
|
});
|
||||||
|
assert.equal(futureDraft.response.status, 409);
|
||||||
|
|
||||||
|
const nonexistentSelection = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
generation: 1,
|
||||||
|
selection: { buffId: "not-a-real-buff", curseId: openedDraft.body.curseChoices[0] },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(nonexistentSelection.response.status, 400);
|
||||||
|
|
||||||
|
const nonOfferedBuffId = [
|
||||||
|
"mend-echo", "mend-efficiency", "mend-cast-speed", "renew-spread",
|
||||||
|
"renew-duration", "renew-potency", "shield-echo", "shield-potency",
|
||||||
|
"shield-guard", "purify-renew", "purify-shield", "purify-chain",
|
||||||
|
"radiance-cooldown", "radiance-renew", "radiance-shield",
|
||||||
|
"barrier-cooldown", "barrier-duration", "barrier-regen",
|
||||||
|
].find((buffId) => !openedDraft.body.buffChoices.includes(buffId));
|
||||||
|
const nonOfferedSelection = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
generation: 1,
|
||||||
|
selection: { buffId: nonOfferedBuffId, curseId: openedDraft.body.curseChoices[0] },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(nonOfferedSelection.response.status, 400);
|
||||||
|
|
||||||
|
const alphaRoundOneSelection = {
|
||||||
|
buffId: openedDraft.body.buffChoices[0],
|
||||||
|
curseId: openedDraft.body.curseChoices[0],
|
||||||
|
};
|
||||||
|
const alphaDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
generation: 1,
|
||||||
|
selection: alphaRoundOneSelection,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(alphaDraft.body.status, "waiting");
|
||||||
|
assert.equal(alphaDraft.body.submitted, true);
|
||||||
|
assert.equal("selection" in alphaDraft.body, false);
|
||||||
|
assert.equal("opponentSelection" in alphaDraft.body, false);
|
||||||
|
|
||||||
|
const betaDraftPoll = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1?generation=1`, {
|
||||||
|
headers: { Authorization: `Bearer ${betaToken}` },
|
||||||
|
});
|
||||||
|
assert.equal(betaDraftPoll.body.opponentSubmitted, true);
|
||||||
|
assert.equal("opponentSelection" in betaDraftPoll.body, false);
|
||||||
|
|
||||||
|
const betaRoundOneSelection = {
|
||||||
|
buffId: betaDraftPoll.body.buffChoices[0],
|
||||||
|
curseId: betaDraftPoll.body.curseChoices[0],
|
||||||
|
};
|
||||||
|
|
||||||
|
const betaDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
generation: 1,
|
||||||
|
selection: betaRoundOneSelection,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(betaDraft.body.status, "revealed");
|
||||||
|
assert.deepEqual(betaDraft.body.selection, {
|
||||||
|
...betaRoundOneSelection,
|
||||||
|
autoPicked: false,
|
||||||
|
});
|
||||||
|
assert.deepEqual(betaDraft.body.opponentSelection, {
|
||||||
|
...alphaRoundOneSelection,
|
||||||
|
autoPicked: false,
|
||||||
|
});
|
||||||
|
const changedBuffId = alphaRoundOneSelection.buffId === "mend-echo" ? "mend-efficiency" : "mend-echo";
|
||||||
|
const changedDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
generation: 1,
|
||||||
|
selection: { buffId: changedBuffId, curseId: alphaRoundOneSelection.curseId },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(changedDraft.response.status, 409);
|
||||||
|
|
||||||
|
const roundTwoDraftSnapshot = (sequence) => snapshot(sequence, {
|
||||||
|
round: 2,
|
||||||
|
phase: "draft",
|
||||||
|
bossHp: 0,
|
||||||
|
defeatedBosses: 2,
|
||||||
|
});
|
||||||
|
await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1, snapshot: roundTwoDraftSnapshot(3) }),
|
||||||
|
});
|
||||||
|
await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1, snapshot: roundTwoDraftSnapshot(3) }),
|
||||||
|
});
|
||||||
|
const openedRoundTwoDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2/open`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1 }),
|
||||||
|
});
|
||||||
|
assert.equal(openedRoundTwoDraft.response.status, 200);
|
||||||
|
const betaRoundTwoDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2?generation=1`, {
|
||||||
|
headers: { Authorization: `Bearer ${betaToken}` },
|
||||||
|
});
|
||||||
|
roguelikePvpNowMs += 15_000;
|
||||||
|
const lateManualDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
generation: 1,
|
||||||
|
selection: {
|
||||||
|
buffId: openedRoundTwoDraft.body.buffChoices[0],
|
||||||
|
curseId: openedRoundTwoDraft.body.curseChoices[0],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(lateManualDraft.response.status, 409);
|
||||||
|
const alphaAutoDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
generation: 1,
|
||||||
|
selection: {
|
||||||
|
buffId: openedRoundTwoDraft.body.buffChoices[0],
|
||||||
|
curseId: openedRoundTwoDraft.body.curseChoices[0],
|
||||||
|
autoPicked: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(alphaAutoDraft.body.deadlineExpired, true);
|
||||||
|
const betaAutoDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
generation: 1,
|
||||||
|
selection: {
|
||||||
|
buffId: betaRoundTwoDraft.body.buffChoices[0],
|
||||||
|
curseId: betaRoundTwoDraft.body.curseChoices[0],
|
||||||
|
autoPicked: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(betaAutoDraft.body.status, "revealed");
|
||||||
|
assert.equal(betaAutoDraft.body.opponentSelection.autoPicked, true);
|
||||||
|
|
||||||
|
const clientAuthoredWin = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
generation: 1,
|
||||||
|
snapshot: snapshot(4, { round: 3, phase: "won" }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(clientAuthoredWin.response.status, 400);
|
||||||
|
|
||||||
|
const incompletePartyLoss = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
generation: 1,
|
||||||
|
snapshot: snapshot(4, { round: 3, phase: "lost", partyHp: [0, 0, 0, 0, 0.01] }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(incompletePartyLoss.response.status, 400);
|
||||||
|
|
||||||
|
const hostPartyWipe = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
generation: 1,
|
||||||
|
snapshot: snapshot(4, { round: 3, phase: "lost", partyHp: [0, 0, 0, 0, 0] }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(hostPartyWipe.body.status, "lost-by-forfeit");
|
||||||
|
assert.equal(hostPartyWipe.body.outcomeReason, "party-wipe");
|
||||||
|
|
||||||
|
// First accepted valid wipe is the stable simultaneous-wipe tie-break.
|
||||||
|
// A later opposing wipe cannot oscillate or reverse the frozen result.
|
||||||
|
const guestPartyWipeAfterOutcome = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
generation: 1,
|
||||||
|
snapshot: snapshot(4, { round: 3, phase: "lost", partyHp: [0, 0, 0, 0, 0] }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(guestPartyWipeAfterOutcome.body.status, "won-by-forfeit");
|
||||||
|
assert.equal(guestPartyWipeAfterOutcome.body.outcomeReason, "party-wipe");
|
||||||
|
|
||||||
|
const alphaRematch = await json(`/api/roguelike-pvp/matches/${matchId}/rematch`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1 }),
|
||||||
|
});
|
||||||
|
assert.equal(alphaRematch.body.status, "waiting");
|
||||||
|
const betaRematch = await json(`/api/roguelike-pvp/matches/${matchId}/rematch`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1 }),
|
||||||
|
});
|
||||||
|
assert.equal(betaRematch.body.status, "matched");
|
||||||
|
assert.equal(betaRematch.body.match.generation, 2);
|
||||||
|
assert.equal(betaRematch.body.match.countdownEndsAtMs, roguelikePvpNowMs + 5_000);
|
||||||
|
const alphaRematchReady = await json(`/api/roguelike-pvp/matches/${matchId}/rematch`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1 }),
|
||||||
|
});
|
||||||
|
assert.equal(alphaRematchReady.body.match.seed, betaRematch.body.match.seed);
|
||||||
|
|
||||||
|
const staleGeneration = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1, snapshot: snapshot(1) }),
|
||||||
|
});
|
||||||
|
assert.equal(staleGeneration.response.status, 409);
|
||||||
|
await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 2, snapshot: snapshot(1) }),
|
||||||
|
});
|
||||||
|
roguelikePvpNowMs += 15_001;
|
||||||
|
const hostForfeitWin = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 2, snapshot: snapshot(2) }),
|
||||||
|
});
|
||||||
|
assert.equal(hostForfeitWin.body.status, "won-by-forfeit");
|
||||||
|
assert.equal(hostForfeitWin.body.opponentConnection, "forfeited");
|
||||||
|
const guestForfeitLoss = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 2, snapshot: snapshot(1) }),
|
||||||
|
});
|
||||||
|
assert.equal(guestForfeitLoss.body.status, "lost-by-forfeit");
|
||||||
|
|
||||||
|
roguelikePvpNowMs += 10 * 60_000 + 1;
|
||||||
|
const expiredMatch = await json(`/api/roguelike-pvp/queue/${alphaQueue.body.ticketId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}` },
|
||||||
|
});
|
||||||
|
assert.equal(expiredMatch.response.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Roguelike PVP draft deadline deterministically resolves missing submissions", 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}` }),
|
||||||
|
});
|
||||||
|
assert.equal(registration.response.status, 201);
|
||||||
|
return registration.body.token;
|
||||||
|
};
|
||||||
|
const draftSnapshot = (sequence, round) => ({
|
||||||
|
sequence,
|
||||||
|
round,
|
||||||
|
phase: "draft",
|
||||||
|
partyHp: [1, 0.9, 0.8, 0.7, 0.6],
|
||||||
|
bossHp: 0,
|
||||||
|
bossMaxHp: 500,
|
||||||
|
defeatedBosses: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hostToken = await registerPlayer("rogue_deadline_host");
|
||||||
|
const guestToken = await registerPlayer("rogue_deadline_guest");
|
||||||
|
const hostQueue = await json("/api/roguelike-pvp/queue", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${hostToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mode: "roguelike-pvp", slotId: 1, hunterName: "Host", healerClassId: "priest" }),
|
||||||
|
});
|
||||||
|
const guestQueue = await json("/api/roguelike-pvp/queue", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${guestToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mode: "roguelike-pvp", slotId: 1, hunterName: "Guest", healerClassId: "druid" }),
|
||||||
|
});
|
||||||
|
assert.equal(hostQueue.body.status, "waiting");
|
||||||
|
assert.equal(guestQueue.body.status, "matched");
|
||||||
|
const matchId = guestQueue.body.match.id;
|
||||||
|
|
||||||
|
for (const [token, snapshot] of [
|
||||||
|
[hostToken, draftSnapshot(1, 1)],
|
||||||
|
[guestToken, draftSnapshot(1, 1)],
|
||||||
|
]) {
|
||||||
|
const state = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1, snapshot }),
|
||||||
|
});
|
||||||
|
assert.equal(state.response.status, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostRoundOne = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1/open`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${hostToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1 }),
|
||||||
|
});
|
||||||
|
const guestRoundOne = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1?generation=1`, {
|
||||||
|
headers: { Authorization: `Bearer ${guestToken}` },
|
||||||
|
});
|
||||||
|
roguelikePvpNowMs = hostRoundOne.body.deadlineAtMs;
|
||||||
|
|
||||||
|
const expiredHostPoll = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1?generation=1`, {
|
||||||
|
headers: { Authorization: `Bearer ${hostToken}` },
|
||||||
|
});
|
||||||
|
assert.equal(expiredHostPoll.body.status, "revealed");
|
||||||
|
assert.equal(expiredHostPoll.body.deadlineExpired, true);
|
||||||
|
assert.deepEqual(expiredHostPoll.body.selection, {
|
||||||
|
buffId: hostRoundOne.body.buffChoices[0],
|
||||||
|
curseId: hostRoundOne.body.curseChoices[0],
|
||||||
|
autoPicked: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(expiredHostPoll.body.opponentSelection, {
|
||||||
|
buffId: guestRoundOne.body.buffChoices[0],
|
||||||
|
curseId: guestRoundOne.body.curseChoices[0],
|
||||||
|
autoPicked: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutateServerPick = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${hostToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
generation: 1,
|
||||||
|
selection: {
|
||||||
|
buffId: hostRoundOne.body.buffChoices[1],
|
||||||
|
curseId: hostRoundOne.body.curseChoices[1],
|
||||||
|
autoPicked: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(mutateServerPick.response.status, 409);
|
||||||
|
|
||||||
|
const expiredGuestPoll = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1?generation=1`, {
|
||||||
|
headers: { Authorization: `Bearer ${guestToken}` },
|
||||||
|
});
|
||||||
|
assert.equal(expiredGuestPoll.body.status, "revealed");
|
||||||
|
|
||||||
|
for (const [token, snapshot] of [
|
||||||
|
[hostToken, draftSnapshot(2, 2)],
|
||||||
|
[guestToken, draftSnapshot(2, 2)],
|
||||||
|
]) {
|
||||||
|
const state = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1, snapshot }),
|
||||||
|
});
|
||||||
|
assert.equal(state.response.status, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostRoundTwo = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2/open`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${hostToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1 }),
|
||||||
|
});
|
||||||
|
const hostManualSelection = {
|
||||||
|
buffId: hostRoundTwo.body.buffChoices[1],
|
||||||
|
curseId: hostRoundTwo.body.curseChoices[1],
|
||||||
|
};
|
||||||
|
const hostSubmission = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${hostToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1, selection: hostManualSelection }),
|
||||||
|
});
|
||||||
|
assert.equal(hostSubmission.body.status, "waiting");
|
||||||
|
const guestRoundTwo = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2?generation=1`, {
|
||||||
|
headers: { Authorization: `Bearer ${guestToken}` },
|
||||||
|
});
|
||||||
|
roguelikePvpNowMs = hostRoundTwo.body.deadlineAtMs;
|
||||||
|
|
||||||
|
const guestAutoResolved = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2?generation=1`, {
|
||||||
|
headers: { Authorization: `Bearer ${guestToken}` },
|
||||||
|
});
|
||||||
|
assert.equal(guestAutoResolved.body.status, "revealed");
|
||||||
|
assert.deepEqual(guestAutoResolved.body.selection, {
|
||||||
|
buffId: guestRoundTwo.body.buffChoices[0],
|
||||||
|
curseId: guestRoundTwo.body.curseChoices[0],
|
||||||
|
autoPicked: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(guestAutoResolved.body.opponentSelection, {
|
||||||
|
...hostManualSelection,
|
||||||
|
autoPicked: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid credentials cannot access server saves", async () => {
|
||||||
|
const login = await json("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: "hunter_0", password: "incorrect-password" }),
|
||||||
|
});
|
||||||
|
assert.equal(login.response.status, 401);
|
||||||
|
const saves = await json("/api/saves");
|
||||||
|
assert.equal(saves.response.status, 401);
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { extname, resolve, sep } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createGameApiHandler } from "./game-api.mjs";
|
||||||
|
|
||||||
|
const distPath = fileURLToPath(new URL("../dist", import.meta.url));
|
||||||
|
const indexPath = resolve(distPath, "index.html");
|
||||||
|
const host = process.env.HOST ?? "127.0.0.1";
|
||||||
|
const port = Number(process.env.PORT ?? 4173);
|
||||||
|
const contentTypes = {
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".glb": "model/gltf-binary",
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".png": "image/png",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".webp": "image/webp",
|
||||||
|
};
|
||||||
|
|
||||||
|
function sendFile(response, filePath) {
|
||||||
|
response.statusCode = 200;
|
||||||
|
response.setHeader("Content-Type", contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream");
|
||||||
|
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
|
response.setHeader("Referrer-Policy", "same-origin");
|
||||||
|
response.setHeader("X-Frame-Options", "DENY");
|
||||||
|
createReadStream(filePath).pipe(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveStatic(request, response) {
|
||||||
|
let requestPath;
|
||||||
|
try { requestPath = decodeURIComponent(new URL(request.url, "http://localhost").pathname); }
|
||||||
|
catch { response.statusCode = 400; return response.end("Bad request"); }
|
||||||
|
const candidate = resolve(distPath, `.${requestPath}`);
|
||||||
|
const insideDist = candidate === distPath || candidate.startsWith(`${distPath}${sep}`);
|
||||||
|
if (insideDist && existsSync(candidate) && statSync(candidate).isFile()) return sendFile(response, candidate);
|
||||||
|
if (!existsSync(indexPath)) {
|
||||||
|
response.statusCode = 503;
|
||||||
|
return response.end("Build missing. Run pnpm build.");
|
||||||
|
}
|
||||||
|
return sendFile(response, indexPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = createGameApiHandler();
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
void api.handle(request, response, () => serveStatic(request, response));
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, host, () => console.log(`I Want To Heal listening on http://${host}:${port}`));
|
||||||
|
|
||||||
|
function shutdown() {
|
||||||
|
server.close(() => {
|
||||||
|
api.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
+475
-22
@@ -3,15 +3,24 @@ import packageJson from "../package.json";
|
|||||||
import { DualDisplayFrame } from "./components/DualDisplayFrame";
|
import { DualDisplayFrame } from "./components/DualDisplayFrame";
|
||||||
import { FrontEnd } from "./components/FrontEnd";
|
import { FrontEnd } from "./components/FrontEnd";
|
||||||
import { useActiveHunter, useFrontendStore } from "./frontend/store";
|
import { useActiveHunter, useFrontendStore } from "./frontend/store";
|
||||||
import { useGameStore } from "./game/store";
|
import { getHockeyPvpNetworkSnapshot, getRoguelikePvpNetworkSnapshot, useGameStore } from "./game/store";
|
||||||
import type { BossId } from "./game/types";
|
import type { BossId } from "./game/types";
|
||||||
import type { DifficultySlug } from "./game/progression/loot";
|
import type { DifficultySlug } from "./game/progression/loot";
|
||||||
import { useActionBindings } from "./game/useGameLoop";
|
import { useActionBindings } from "./game/useGameLoop";
|
||||||
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
||||||
import { DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
|
import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT, HOCKEY_PVP_POST_MATCH_EVENT } from "./platform/dualScreenSync";
|
||||||
|
import { networkAppearsOnline, startSaveSyncCoordinator } from "./frontend/saveSync";
|
||||||
|
import type { HockeyPvpMatchConfig } from "./game/hockeyHealingPvp";
|
||||||
|
import { HOCKEY_PVP_COUNTDOWN_MS, HOCKEY_PVP_QUEUE_TIMEOUT_MS, hockeyPvpBossAt } from "./game/hockeyHealingPvp";
|
||||||
|
import { onlineRepository, type RoguelikePvpWireSnapshot } from "./frontend/onlineRepository";
|
||||||
|
import { startHockeyPvpMatchmaking, startHockeyPvpRematch, type HockeyPvpMatchOperation } from "./frontend/hockeyPvpMatchmaking";
|
||||||
|
import { roguelikePvpBossesForRound, type RoguelikePvpRemoteSnapshot, type RoguelikePvpStatus } from "./game/roguelikePvp";
|
||||||
|
import type { RoguelikePvpMatchConfig } from "./frontend/roguelikePvpMatchmaking";
|
||||||
|
import { createClassInventory } from "./game/healers";
|
||||||
|
|
||||||
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
|
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
|
||||||
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
|
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
|
||||||
|
const HealerModelGallery = lazy(() => import("./components/GameScene").then((module) => ({ default: module.HealerModelGallery })));
|
||||||
|
|
||||||
function GameLoadingScreen() {
|
function GameLoadingScreen() {
|
||||||
return (
|
return (
|
||||||
@@ -22,49 +31,445 @@ function GameLoadingScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
function TacticalLoadingScreen() {
|
||||||
|
return <section className="display bottom-display game-loading is-lower"><span>IH</span><strong>Loading field console</strong><small>Gameplay remains active</small></section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoguelikePvpDraftPreview() {
|
||||||
|
useEffect(() => {
|
||||||
|
useGameStore.getState().configureHealer(
|
||||||
|
"paladin",
|
||||||
|
"Preview Healer",
|
||||||
|
createClassInventory("paladin"),
|
||||||
|
"bulldrome",
|
||||||
|
"roguelike-pvp",
|
||||||
|
undefined,
|
||||||
|
"initiate",
|
||||||
|
{
|
||||||
|
matchId: null,
|
||||||
|
seed: 2,
|
||||||
|
generation: 1,
|
||||||
|
opponentName: "Rival Chrona",
|
||||||
|
opponentHealerClassId: "chronomancer",
|
||||||
|
role: "cpu",
|
||||||
|
countdownEndsAtMs: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
useGameStore.getState().startEncounter();
|
||||||
|
useGameStore.setState((state) => ({
|
||||||
|
phase: "intermission",
|
||||||
|
activeTab: "combat",
|
||||||
|
roguelikePvp: {
|
||||||
|
...state.roguelikePvp,
|
||||||
|
status: "drafting",
|
||||||
|
round: 1,
|
||||||
|
buffChoices: ["mend-echo", "purify-chain", "barrier-duration"],
|
||||||
|
curseChoices: ["ability1-mana-cost", "ability3-cooldown", "ability6-mana-cost"],
|
||||||
|
selectedBuffId: "mend-echo",
|
||||||
|
selectedCurseId: "ability1-mana-cost",
|
||||||
|
draftStep: "buff",
|
||||||
|
draftDeadlineAtMs: Date.now() + 120_000,
|
||||||
|
localDraftLocked: false,
|
||||||
|
opponentDraftLocked: false,
|
||||||
|
opponentBossHp: 0,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <Suspense fallback={<TacticalLoadingScreen />}><BottomScreen onExit={() => undefined} /></Suspense>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roguelikePvpWirePhase(status: RoguelikePvpStatus): RoguelikePvpWireSnapshot["phase"] {
|
||||||
|
// Clients may concede with `lost`; the server adjudicates winners.
|
||||||
|
if (status === "lost") return status;
|
||||||
|
if (status === "won") return "combat";
|
||||||
|
if (status === "drafting") return "draft";
|
||||||
|
if (status === "countdown" || status === "inactive") return "countdown";
|
||||||
|
return "combat";
|
||||||
|
}
|
||||||
|
|
||||||
|
function roguelikePvpStatusFromWire(phase: RoguelikePvpWireSnapshot["phase"]): RoguelikePvpStatus {
|
||||||
|
if (phase === "draft") return "drafting";
|
||||||
|
return phase;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roguelikePvpRemoteFromWire(
|
||||||
|
snapshot: RoguelikePvpWireSnapshot,
|
||||||
|
seed: number,
|
||||||
|
): RoguelikePvpRemoteSnapshot {
|
||||||
|
const bossIds = roguelikePvpBossesForRound(seed, snapshot.round);
|
||||||
|
const partyHpPercent = snapshot.partyHp.reduce((total, value) => total + value, 0) / snapshot.partyHp.length * 100;
|
||||||
|
return {
|
||||||
|
sequence: snapshot.sequence,
|
||||||
|
time: 0,
|
||||||
|
status: roguelikePvpStatusFromWire(snapshot.phase),
|
||||||
|
progress: {
|
||||||
|
round: snapshot.round,
|
||||||
|
bossesDefeated: snapshot.defeatedBosses,
|
||||||
|
livingPartyMembers: snapshot.partyHp.filter((value) => value > 0).length,
|
||||||
|
partyHpPercent,
|
||||||
|
bosses: [{ id: bossIds[0], hp: snapshot.bossHp, maxHp: snapshot.bossMaxHp }],
|
||||||
|
},
|
||||||
|
buffRanks: {},
|
||||||
|
curseRanks: {},
|
||||||
|
draftSubmission: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function MainApp() {
|
||||||
useForcedThorDisplays();
|
useForcedThorDisplays();
|
||||||
useAuthoritativeDualScreenSync();
|
useAuthoritativeDualScreenSync();
|
||||||
const screen = useFrontendStore((state) => state.screen);
|
const screen = useFrontendStore((state) => state.screen);
|
||||||
|
const accountId = useFrontendStore((state) => state.accountId);
|
||||||
const hunter = useActiveHunter();
|
const hunter = useActiveHunter();
|
||||||
const settings = useFrontendStore((state) => state.settings);
|
const settings = useFrontendStore((state) => state.settings);
|
||||||
const navigate = useFrontendStore((state) => state.navigate);
|
const navigate = useFrontendStore((state) => state.navigate);
|
||||||
const touchActiveSave = useFrontendStore((state) => state.touchActiveSave);
|
const touchActiveSave = useFrontendStore((state) => state.touchActiveSave);
|
||||||
const updateActiveHealerInventory = useFrontendStore((state) => state.updateActiveHealerInventory);
|
const updateActiveHealerInventory = useFrontendStore((state) => state.updateActiveHealerInventory);
|
||||||
const recordBossVictory = useFrontendStore((state) => state.recordBossVictory);
|
const recordBossVictory = useFrontendStore((state) => state.recordBossVictory);
|
||||||
|
const recordRoguelikeDefeat = useFrontendStore((state) => state.recordRoguelikeDefeat);
|
||||||
|
const recordRogueTrialsEndlessDefeat = useFrontendStore((state) => state.recordRogueTrialsEndlessDefeat);
|
||||||
|
const recordRoguelikePvpResult = useFrontendStore((state) => state.recordRoguelikePvpResult);
|
||||||
|
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 clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards);
|
||||||
|
const gamePhase = useGameStore((state) => state.phase);
|
||||||
|
const gameRunMode = useGameStore((state) => state.runMode);
|
||||||
|
const hockeyPvpCountdownEndsAtMs = useGameStore((state) => state.hockeyPvp.countdownEndsAtMs);
|
||||||
|
const roguelikePvpCountdownEndsAtMs = useGameStore((state) => state.roguelikePvp.countdownEndsAtMs);
|
||||||
const rewardedBossInstances = useRef(new Set<string>());
|
const rewardedBossInstances = useRef(new Set<string>());
|
||||||
|
const hockeyPvpPostMatchOperation = useRef<HockeyPvpMatchOperation | null>(null);
|
||||||
const screenRef = useRef(screen);
|
const screenRef = useRef(screen);
|
||||||
screenRef.current = screen;
|
screenRef.current = screen;
|
||||||
const leaveGame = useCallback(() => {
|
const leaveGame = useCallback(() => {
|
||||||
updateActiveHealerInventory(useGameStore.getState().inventory);
|
hockeyPvpPostMatchOperation.current?.cancel();
|
||||||
|
hockeyPvpPostMatchOperation.current = null;
|
||||||
|
const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState();
|
||||||
|
const game = useGameStore.getState();
|
||||||
|
if (game.runMode === "roguelike-pvp"
|
||||||
|
&& (game.phase === "combat" || game.phase === "intermission")) {
|
||||||
|
game.resolveRoguelikePvpMatch(false);
|
||||||
|
}
|
||||||
|
// 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();
|
touchActiveSave();
|
||||||
navigate("home");
|
navigate("home");
|
||||||
|
if (accountId && activeSlotId) void uploadSlot(activeSlotId);
|
||||||
}, [navigate, touchActiveSave, updateActiveHealerInventory]);
|
}, [navigate, touchActiveSave, updateActiveHealerInventory]);
|
||||||
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug) => {
|
|
||||||
|
const launchHockeyPvpMatch = useCallback((match: HockeyPvpMatchConfig) => {
|
||||||
if (!hunter) return;
|
if (!hunter) return;
|
||||||
const progress = hunter.healers[hunter.activeClassId];
|
const progress = hunter.healers[hunter.activeClassId];
|
||||||
const runMode = useFrontendStore.getState().selectedMode === "roguelike-pve" ? "roguelike" : "encounter";
|
rewardedBossInstances.current.clear();
|
||||||
const launchDifficulty = runMode === "roguelike"
|
clearRecentRewards();
|
||||||
|
useGameStore.getState().configureHealer(
|
||||||
|
hunter.activeClassId,
|
||||||
|
hunter.hunterName,
|
||||||
|
progress.inventory,
|
||||||
|
[hockeyPvpBossAt(match.seed, 0)],
|
||||||
|
"hockey-healing-pvp",
|
||||||
|
hunter.gearProgress,
|
||||||
|
"initiate",
|
||||||
|
match,
|
||||||
|
);
|
||||||
|
touchActiveSave();
|
||||||
|
}, [clearRecentRewards, hunter, touchActiveSave]);
|
||||||
|
|
||||||
|
const handleHockeyPvpPostMatchAction = useCallback((action: "rematch" | "requeue") => {
|
||||||
|
const game = useGameStore.getState();
|
||||||
|
if (game.runMode !== "hockey-healing-pvp"
|
||||||
|
|| game.phase !== "victory" && game.phase !== "defeat"
|
||||||
|
|| !hunter) return;
|
||||||
|
game.setHockeyPvpPostMatchSelection(action);
|
||||||
|
hockeyPvpPostMatchOperation.current?.cancel();
|
||||||
|
hockeyPvpPostMatchOperation.current = null;
|
||||||
|
|
||||||
|
if (action === "rematch" && (game.hockeyPvp.role === "cpu" || !game.hockeyPvp.matchId)) {
|
||||||
|
launchHockeyPvpMatch({
|
||||||
|
matchId: null,
|
||||||
|
seed: Math.max(1, Math.floor(Math.random() * 0xffffffff)),
|
||||||
|
generation: game.hockeyPvp.generation + 1,
|
||||||
|
opponentName: game.hockeyPvp.opponentName,
|
||||||
|
role: "cpu",
|
||||||
|
countdownEndsAtMs: Date.now() + HOCKEY_PVP_COUNTDOWN_MS,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operation = action === "rematch"
|
||||||
|
? startHockeyPvpRematch({
|
||||||
|
matchId: game.hockeyPvp.matchId!,
|
||||||
|
generation: game.hockeyPvp.generation,
|
||||||
|
})
|
||||||
|
: startHockeyPvpMatchmaking({
|
||||||
|
slotId: hunter.slotId,
|
||||||
|
hunterName: hunter.hunterName,
|
||||||
|
online: Boolean(accountId && networkAppearsOnline()),
|
||||||
|
});
|
||||||
|
game.setHockeyPvpPostMatchStatus(
|
||||||
|
action === "rematch" ? "waiting-rematch" : "requeueing",
|
||||||
|
action === "requeue" ? Date.now() + HOCKEY_PVP_QUEUE_TIMEOUT_MS : 0,
|
||||||
|
);
|
||||||
|
hockeyPvpPostMatchOperation.current = operation;
|
||||||
|
void operation.result.then((match) => {
|
||||||
|
if (!match || hockeyPvpPostMatchOperation.current !== operation) return;
|
||||||
|
hockeyPvpPostMatchOperation.current = null;
|
||||||
|
launchHockeyPvpMatch(match);
|
||||||
|
});
|
||||||
|
}, [accountId, hunter, launchHockeyPvpMatch]);
|
||||||
|
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug, pvpMatch?: HockeyPvpMatchConfig | RoguelikePvpMatchConfig) => {
|
||||||
|
if (!hunter) return;
|
||||||
|
const progress = hunter.healers[hunter.activeClassId];
|
||||||
|
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 === "roguelike-pvp"
|
||||||
|
? "roguelike-pvp"
|
||||||
|
: selectedMode === "blockbreaker"
|
||||||
|
? "blockbreaker"
|
||||||
|
: selectedMode === "aether-assault"
|
||||||
|
? "aether-assault"
|
||||||
|
: "encounter";
|
||||||
|
const launchDifficulty = runMode !== "encounter"
|
||||||
? "initiate"
|
? "initiate"
|
||||||
: requestedDifficultySlug ?? useFrontendStore.getState().selectedDifficultySlug;
|
: requestedDifficultySlug ?? useFrontendStore.getState().selectedDifficultySlug;
|
||||||
rewardedBossInstances.current.clear();
|
rewardedBossInstances.current.clear();
|
||||||
clearRecentRewards();
|
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,
|
||||||
|
pvpMatch,
|
||||||
|
);
|
||||||
touchActiveSave();
|
touchActiveSave();
|
||||||
navigate("game");
|
navigate("game");
|
||||||
}, [clearRecentRewards, hunter, navigate, touchActiveSave]);
|
}, [clearRecentRewards, hunter, navigate, touchActiveSave]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onDualScreenLaunch = (event: Event) => {
|
const onDualScreenLaunch = (event: Event) => {
|
||||||
const detail = (event as CustomEvent<{ bossIds: readonly BossId[]; difficultySlug?: DifficultySlug } | readonly BossId[]>).detail;
|
const detail = (event as CustomEvent<{ bossIds: readonly BossId[]; difficultySlug?: DifficultySlug; pvpMatch?: HockeyPvpMatchConfig | RoguelikePvpMatchConfig; hockeyPvpMatch?: HockeyPvpMatchConfig } | readonly BossId[]>).detail;
|
||||||
if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug);
|
if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug, detail.pvpMatch ?? detail.hockeyPvpMatch);
|
||||||
else launchGame(detail);
|
else launchGame(detail);
|
||||||
};
|
};
|
||||||
window.addEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
|
window.addEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
|
||||||
return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
|
return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
|
||||||
}, [launchGame]);
|
}, [launchGame]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
|
||||||
|
return () => window.removeEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
|
||||||
|
}, [leaveGame]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPostMatchAction = (event: Event) => {
|
||||||
|
handleHockeyPvpPostMatchAction((event as CustomEvent<"rematch" | "requeue">).detail);
|
||||||
|
};
|
||||||
|
window.addEventListener(HOCKEY_PVP_POST_MATCH_EVENT, onPostMatchAction);
|
||||||
|
return () => window.removeEventListener(HOCKEY_PVP_POST_MATCH_EVENT, onPostMatchAction);
|
||||||
|
}, [handleHockeyPvpPostMatchAction]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (state.phase !== "briefing" && state.phase !== "combat") return;
|
||||||
|
const snapshot = getHockeyPvpNetworkSnapshot();
|
||||||
|
if (!snapshot) return;
|
||||||
|
exchangeActive = true;
|
||||||
|
try {
|
||||||
|
const result = await onlineRepository.exchangeHockeyPvpState(state.hockeyPvp.matchId, state.hockeyPvp.generation, 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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (screen !== "game") return;
|
||||||
|
let stopped = false;
|
||||||
|
let exchangeActive = false;
|
||||||
|
let terminalLossReported = false;
|
||||||
|
const exchange = async () => {
|
||||||
|
if (stopped || exchangeActive) return;
|
||||||
|
const state = useGameStore.getState();
|
||||||
|
const pvp = state.roguelikePvp;
|
||||||
|
if (state.runMode !== "roguelike-pvp" || !pvp.matchId || pvp.role === "cpu") return;
|
||||||
|
if (state.phase === "victory" || pvp.status === "won") return;
|
||||||
|
if ((state.phase === "defeat" || pvp.status === "lost") && terminalLossReported) return;
|
||||||
|
const snapshot = getRoguelikePvpNetworkSnapshot();
|
||||||
|
if (!snapshot) return;
|
||||||
|
const bossHp = snapshot.progress.bosses.reduce((total, boss) => total + boss.hp, 0);
|
||||||
|
const bossMaxHp = snapshot.progress.bosses.reduce((total, boss) => total + boss.maxHp, 0);
|
||||||
|
const partyHp = state.party.map((member) => Math.max(0, Math.min(1, member.hp / Math.max(1, member.maxHp)))) as RoguelikePvpWireSnapshot["partyHp"];
|
||||||
|
const wireSnapshot: RoguelikePvpWireSnapshot = {
|
||||||
|
sequence: snapshot.sequence,
|
||||||
|
round: snapshot.progress.round,
|
||||||
|
phase: roguelikePvpWirePhase(snapshot.status),
|
||||||
|
partyHp,
|
||||||
|
bossHp,
|
||||||
|
bossMaxHp,
|
||||||
|
defeatedBosses: snapshot.progress.bossesDefeated,
|
||||||
|
};
|
||||||
|
exchangeActive = true;
|
||||||
|
try {
|
||||||
|
const result = await onlineRepository.exchangeRoguelikePvpState(pvp.matchId, pvp.generation, wireSnapshot);
|
||||||
|
if (stopped) return;
|
||||||
|
if (wireSnapshot.phase === "lost") terminalLossReported = true;
|
||||||
|
const game = useGameStore.getState();
|
||||||
|
game.setRoguelikePvpConnectionStatus(result.opponentConnection === "connected" ? "online" : "disconnected");
|
||||||
|
if (result.status !== "active") {
|
||||||
|
terminalLossReported = true;
|
||||||
|
game.resolveRoguelikePvpMatch(result.status === "won-by-forfeit");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.opponentSnapshot) {
|
||||||
|
game.applyRoguelikePvpRemoteSnapshot(roguelikePvpRemoteFromWire(result.opponentSnapshot, pvp.seed));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!stopped) useGameStore.getState().setRoguelikePvpConnectionStatus("disconnected");
|
||||||
|
} finally {
|
||||||
|
exchangeActive = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void exchange();
|
||||||
|
const timer = window.setInterval(() => { void exchange(); }, 160);
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [screen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (screen !== "game") return;
|
||||||
|
let stopped = false;
|
||||||
|
let requestActive = false;
|
||||||
|
const openedRounds = new Set<number>();
|
||||||
|
const submittedRounds = new Set<number>();
|
||||||
|
const syncDraft = async () => {
|
||||||
|
if (stopped || requestActive) return;
|
||||||
|
const state = useGameStore.getState();
|
||||||
|
const pvp = state.roguelikePvp;
|
||||||
|
if (state.runMode !== "roguelike-pvp"
|
||||||
|
|| state.phase !== "intermission"
|
||||||
|
|| !pvp.matchId
|
||||||
|
|| pvp.role === "cpu") return;
|
||||||
|
requestActive = true;
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
if (!openedRounds.has(state.round)) {
|
||||||
|
result = await onlineRepository.openRoguelikePvpDraft(pvp.matchId, pvp.generation, state.round);
|
||||||
|
openedRounds.add(state.round);
|
||||||
|
} else if (pvp.localDraftLocked && !submittedRounds.has(state.round)) {
|
||||||
|
submittedRounds.add(state.round);
|
||||||
|
result = await onlineRepository.submitRoguelikePvpDraft(
|
||||||
|
pvp.matchId,
|
||||||
|
pvp.generation,
|
||||||
|
state.round,
|
||||||
|
{
|
||||||
|
buffId: pvp.selectedBuffId,
|
||||||
|
curseId: pvp.selectedCurseId,
|
||||||
|
autoPicked: pvp.draftDeadlineAtMs > 0 && Date.now() >= pvp.draftDeadlineAtMs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
result = await onlineRepository.pollRoguelikePvpDraft(pvp.matchId, pvp.generation, state.round);
|
||||||
|
}
|
||||||
|
if (stopped) return;
|
||||||
|
if (pvp.localDraftLocked) {
|
||||||
|
if (result.submitted) submittedRounds.add(state.round);
|
||||||
|
else submittedRounds.delete(state.round);
|
||||||
|
}
|
||||||
|
const game = useGameStore.getState();
|
||||||
|
game.syncRoguelikePvpDraft(result.deadlineAtMs, result.opponentSubmitted);
|
||||||
|
if (result.status === "revealed" && result.selection && result.opponentSelection) {
|
||||||
|
game.applyRoguelikePvpDraftReveal({
|
||||||
|
round: result.round,
|
||||||
|
local: {
|
||||||
|
round: result.round,
|
||||||
|
buffId: result.selection.buffId,
|
||||||
|
curseId: result.selection.curseId,
|
||||||
|
},
|
||||||
|
opponent: {
|
||||||
|
round: result.round,
|
||||||
|
buffId: result.opponentSelection.buffId,
|
||||||
|
curseId: result.opponentSelection.curseId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!stopped) useGameStore.getState().setRoguelikePvpConnectionStatus("disconnected");
|
||||||
|
} finally {
|
||||||
|
requestActive = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void syncDraft();
|
||||||
|
const timer = window.setInterval(() => { void syncDraft(); }, 180);
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [screen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (screen !== "game") return;
|
||||||
|
let timer: number | undefined;
|
||||||
|
const autoStart = () => {
|
||||||
|
const state = useGameStore.getState();
|
||||||
|
if (state.phase !== "briefing"
|
||||||
|
|| state.runMode !== "hockey-healing-pvp" && state.runMode !== "roguelike-pvp") return;
|
||||||
|
const countdownEndsAtMs = state.runMode === "roguelike-pvp"
|
||||||
|
? state.roguelikePvp.countdownEndsAtMs
|
||||||
|
: state.hockeyPvp.countdownEndsAtMs;
|
||||||
|
const remaining = countdownEndsAtMs - Date.now();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
state.startEncounter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = window.setTimeout(autoStart, remaining + 16);
|
||||||
|
};
|
||||||
|
autoStart();
|
||||||
|
return () => {
|
||||||
|
if (timer !== undefined) window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [gamePhase, gameRunMode, hockeyPvpCountdownEndsAtMs, roguelikePvpCountdownEndsAtMs, screen]);
|
||||||
|
|
||||||
useActionBindings(screen === "game", leaveGame);
|
useActionBindings(screen === "game", leaveGame);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -75,44 +480,92 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return useGameStore.subscribe((state, previousState) => {
|
return useGameStore.subscribe((state, previousState) => {
|
||||||
const startedFreshEncounter = state.phase === "briefing" && previousState.phase !== "briefing"
|
const startedFreshEncounter = state.phase === "briefing" && previousState.phase !== "briefing"
|
||||||
|| previousState.phase === "intermission" && state.phase === "combat";
|
|| previousState.phase === "intermission" && state.phase === "combat"
|
||||||
if (state.phase === "briefing" || previousState.phase === "intermission" && state.phase === "combat") {
|
|| previousState.phase === "victory" && state.phase === "combat" && state.endlessMode;
|
||||||
|
if (state.phase === "briefing"
|
||||||
|
|| previousState.phase === "intermission" && state.phase === "combat"
|
||||||
|
|| previousState.phase === "victory" && state.phase === "combat" && state.endlessMode) {
|
||||||
rewardedBossInstances.current.clear();
|
rewardedBossInstances.current.clear();
|
||||||
}
|
}
|
||||||
if (startedFreshEncounter) clearRecentRewards();
|
if (startedFreshEncounter) clearRecentRewards();
|
||||||
if (screenRef.current !== "game") return;
|
if (screenRef.current !== "game") return;
|
||||||
|
if (state.runMode === "roguelike" && state.phase === "defeat" && previousState.phase !== "defeat") {
|
||||||
|
recordRoguelikeDefeat(state.round);
|
||||||
|
}
|
||||||
|
if (state.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 === "roguelike-pvp"
|
||||||
|
&& (state.phase === "victory" || state.phase === "defeat")
|
||||||
|
&& state.phase !== previousState.phase) {
|
||||||
|
recordRoguelikePvpResult(state.phase === "victory", state.round);
|
||||||
|
}
|
||||||
|
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" || state.runMode === "roguelike-pvp") return;
|
||||||
const bossCount = 1 + state.additionalBosses.length;
|
const bossCount = 1 + state.additionalBosses.length;
|
||||||
if (state.boss.hp <= 0 && previousState.boss.hp > 0) {
|
if (state.boss.hp <= 0 && previousState.boss.hp > 0) {
|
||||||
const primaryInstanceId = `boss-0-${state.boss.id}`;
|
const primaryInstanceId = state.bossInstanceId;
|
||||||
if (!rewardedBossInstances.current.has(primaryInstanceId)) {
|
if (!rewardedBossInstances.current.has(primaryInstanceId)) {
|
||||||
rewardedBossInstances.current.add(primaryInstanceId);
|
if (!state.endlessMode) rewardedBossInstances.current.add(primaryInstanceId);
|
||||||
const defeatedBefore = (state.round - 1) * bossCount;
|
const defeatedBefore = (state.round - 1) * bossCount;
|
||||||
const rewardDifficulty = state.runMode === "roguelike" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
|
const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
|
||||||
recordBossVictory(state.boss.id, rewardDifficulty);
|
recordBossVictory(state.boss.id, rewardDifficulty);
|
||||||
|
if (state.runMode === "hockey-healing-pvp") recordHockeyPvpBossKill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let index = 0; index < state.additionalBosses.length; index += 1) {
|
for (let index = 0; index < state.additionalBosses.length; index += 1) {
|
||||||
const entry = state.additionalBosses[index];
|
const entry = state.additionalBosses[index];
|
||||||
const previous = previousState.additionalBosses[index];
|
const previous = previousState.additionalBosses[index];
|
||||||
const justDefeated = entry.boss.hp <= 0 && (!previous || previous.instanceId !== entry.instanceId || previous.boss.hp > 0);
|
const justDefeated = entry.boss.hp <= 0 && (!previous || previous.instanceId !== entry.instanceId || previous.boss.hp > 0);
|
||||||
if (!justDefeated || rewardedBossInstances.current.has(entry.instanceId)) continue;
|
if (!justDefeated || !state.endlessMode && rewardedBossInstances.current.has(entry.instanceId)) continue;
|
||||||
rewardedBossInstances.current.add(entry.instanceId);
|
if (!state.endlessMode) rewardedBossInstances.current.add(entry.instanceId);
|
||||||
const defeatedBefore = (state.round - 1) * bossCount + index + 1;
|
const defeatedBefore = (state.round - 1) * bossCount + index + 1;
|
||||||
const rewardDifficulty = state.runMode === "roguelike" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
|
const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
|
||||||
recordBossVictory(entry.boss.id, rewardDifficulty);
|
recordBossVictory(entry.boss.id, rewardDifficulty);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [clearRecentRewards, recordBossVictory]);
|
}, [clearRecentRewards, recordAetherAssaultDefeat, recordBlockbreakerDefeat, recordBossVictory, recordHockeyHealingDefeat, recordHockeyPvpBossKill, recordHockeyPvpResult, recordRoguelikeDefeat, recordRoguelikePvpResult, recordRogueTrialsEndlessDefeat]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="prototype-shell">
|
<main className="app-shell">
|
||||||
<header className="prototype-header">
|
<header className="app-header">
|
||||||
<div><span>THOR / DUAL DISPLAY</span><strong>I Want To Heal</strong></div>
|
<div><span>THOR / DUAL DISPLAY</span><strong>I Want To Heal</strong></div>
|
||||||
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
|
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
|
||||||
</header>
|
</header>
|
||||||
{screen === "game"
|
{screen === "game"
|
||||||
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} bottom={<BottomScreen />} /></Suspense>
|
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame contextLabel="Tactical" top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<Suspense fallback={<TacticalLoadingScreen />}><BottomScreen onExit={leaveGame} onHockeyPvpAction={handleHockeyPvpPostMatchAction} /></Suspense>} /></Suspense>
|
||||||
: <FrontEnd onLaunch={launchGame} />}
|
: <FrontEnd onLaunch={launchGame} />}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const preview = import.meta.env.DEV ? new URLSearchParams(window.location.search).get("preview") : null;
|
||||||
|
if (preview === "healer-models") {
|
||||||
|
return <Suspense fallback={null}><HealerModelGallery /></Suspense>;
|
||||||
|
}
|
||||||
|
if (preview === "roguelike-pvp-draft") {
|
||||||
|
return <RoguelikePvpDraftPreview />;
|
||||||
|
}
|
||||||
|
return <MainApp />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,3 +3,19 @@
|
|||||||
Only assets referenced by the game ship from this folder. They are copied from the ignored `game_assets` source library, retain their source-relative path, and are imported with Vite `new URL(...)` calls.
|
Only assets referenced by the game ship from this folder. They are copied from the ignored `game_assets` source library, retain their source-relative path, and are imported with Vite `new URL(...)` calls.
|
||||||
|
|
||||||
Use `pnpm assets:import <path-within-game_assets>` to add a source asset. Add `--replace` only when deliberately updating an existing runtime copy.
|
Use `pnpm assets:import <path-within-game_assets>` to add a source asset. Add `--replace` only when deliberately updating an existing runtime copy.
|
||||||
|
|
||||||
|
## KTX2/UASTC pilot
|
||||||
|
|
||||||
|
The tracked `*-uastc.glb` files are parallel optimized copies. Their original GLBs remain the rollback path and must not be replaced or deleted during this pilot.
|
||||||
|
|
||||||
|
KTX-Software `toktx` is an authoring dependency. Rebuild source copies with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
TOKTX=/path/to/toktx pnpm assets:build-ktx2
|
||||||
|
TOKTX=/path/to/toktx pnpm assets:build-dungeon-kit
|
||||||
|
TOKTX=/path/to/toktx pnpm assets:build-gravehorn
|
||||||
|
```
|
||||||
|
|
||||||
|
Import each generated path from `game_assets/` with `pnpm assets:import <path> --replace`. Builds and dev startup copy Three.js's matching Basis transcoder into the ignored `public/basis/` generated directory.
|
||||||
|
|
||||||
|
Force all original GLBs at runtime with `?legacyGameAssets=1`. Force them in an Android/browser build with `VITE_LEGACY_GAME_ASSETS=1 pnpm build`. `legacyDungeonAssets` and `VITE_LEGACY_DUNGEON_ASSETS=1` remain supported aliases.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
# Animated Triceratops Skeleton
|
||||||
|
|
||||||
|
- Creator: Zacxophone — https://sketchfab.com/Zacxophone
|
||||||
|
- Source: https://sketchfab.com/3d-models/animated-triceratops-skeleton-06cb55f941d94dc8b95ac46f92d89e7c
|
||||||
|
- License: CC0 1.0 Universal — https://creativecommons.org/publicdomain/zero/1.0/
|
||||||
|
|
||||||
|
Runtime files named `gravehorn-triceratops*.glb` are optimized derivatives of this model.
|
||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
@@ -0,0 +1,80 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings";
|
||||||
|
import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers";
|
||||||
|
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
|
||||||
|
import { compileRoguelikePvpCurses, roguelikePvpAbilityCooldown, roguelikePvpAbilityManaCost } from "../game/roguelikePvp";
|
||||||
|
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, useGameStore } from "../game/store";
|
||||||
|
import type { AbilitySlotId } from "../game/types";
|
||||||
|
|
||||||
|
export function AbilityButton({ abilityId, compact = false }: { abilityId: AbilitySlotId; compact?: boolean }) {
|
||||||
|
const healerClassId = useGameStore((state) => state.healerClassId);
|
||||||
|
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 runMode = useGameStore((state) => state.runMode);
|
||||||
|
const receivedCurseRanks = useGameStore((state) => state.roguelikePvp.receivedCurseRanks);
|
||||||
|
const healerMechanic = useGameStore((state) => state.healerMechanic);
|
||||||
|
const classes = `ability ability-${abilityId} ${compact ? "is-compact" : ""}`;
|
||||||
|
|
||||||
|
if (!ability) {
|
||||||
|
return (
|
||||||
|
<button className={`${classes} 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 compiledCurses = compileRoguelikePvpCurses(receivedCurseRanks);
|
||||||
|
const manaCost = runMode === "roguelike-pvp"
|
||||||
|
? roguelikePvpAbilityManaCost(abilityId, ability.mana, runModifiers, compiledCurses)
|
||||||
|
: runAbilityManaCost(abilityId, ability.mana, runModifiers);
|
||||||
|
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 = runMode === "roguelike-pvp"
|
||||||
|
? roguelikePvpAbilityCooldown(abilityId, ability.cooldown, runModifiers, compiledCurses)
|
||||||
|
: runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
|
||||||
|
const globalRemaining = Math.max(0, globalCooldownUntil - time);
|
||||||
|
const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0;
|
||||||
|
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
|
||||||
|
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
|
||||||
|
className={`${classes} ${remaining > 0 || globalRemaining > 0 ? "on-cooldown" : ""}`}
|
||||||
|
style={{ "--ability-color": ability.color } as CSSProperties}
|
||||||
|
onClick={() => castAbility(abilityId)}
|
||||||
|
disabled={disabled}
|
||||||
|
title={ability.description}
|
||||||
|
aria-label={`${ability.name}. ${ability.description}`}
|
||||||
|
>
|
||||||
|
<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_CONTROLLER_BINDINGS[abilityId].glyph}</span>
|
||||||
|
{remaining > 0 && (
|
||||||
|
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / cooldownDuration) } as CSSProperties}>
|
||||||
|
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{remaining <= 0 && globalRemaining > 0 && (
|
||||||
|
<span className="cooldown-mask global-cooldown" style={{ "--cooldown-progress": Math.min(1, globalRemaining / GLOBAL_COOLDOWN_SECONDS) } as CSSProperties}>
|
||||||
|
<b>{globalRemaining.toFixed(1)}</b>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
+510
-20
@@ -1,15 +1,50 @@
|
|||||||
import { useFrame } from "@react-three/fiber";
|
import { useFrame } from "@react-three/fiber";
|
||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import { Suspense, useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
import { Component, Suspense, useEffect, useLayoutEffect, useMemo, useRef, type ReactNode } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { ARENA_CENTER, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "../game/arena";
|
import { ARENA_CENTER, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "../game/arena";
|
||||||
|
import {
|
||||||
|
BLOCKBREAKER_BIOMES,
|
||||||
|
blockbreakerBiomeForSeed,
|
||||||
|
type BlockbreakerArenaBiome,
|
||||||
|
type BlockbreakerBiomeFixture,
|
||||||
|
} from "../game/blockbreakerBiomes";
|
||||||
import { bossRoomFor, type BossRoomDefinition, type BossRoomFloor } from "../game/bossRooms";
|
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 { 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 ROOM_CENTER_Z = ARENA_CENTER[1];
|
||||||
const KAYKIT_DUNGEON_PILLAR_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/pillar-decorated.glb", import.meta.url).href;
|
const KAYKIT_DUNGEON_PILLAR_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/pillar-decorated.glb", import.meta.url).href;
|
||||||
const KAYKIT_DUNGEON_WALL_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/wall-pillar.glb", import.meta.url).href;
|
const KAYKIT_DUNGEON_WALL_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/wall-pillar.glb", import.meta.url).href;
|
||||||
const KAYKIT_DUNGEON_TORCH_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/torch-lit.glb", import.meta.url).href;
|
const KAYKIT_DUNGEON_TORCH_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/torch-lit.glb", import.meta.url).href;
|
||||||
|
const KAYKIT_DUNGEON_KIT_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/dungeon-kit-uastc.glb", import.meta.url).href;
|
||||||
|
const DUNGEON_MESH_NAMES = {
|
||||||
|
pillar: "pillar_decorated",
|
||||||
|
wall: "wall_pillar",
|
||||||
|
torch: "torch_lit",
|
||||||
|
} as const;
|
||||||
|
|
||||||
type ArenaFixture = {
|
type ArenaFixture = {
|
||||||
position: readonly [number, number, number];
|
position: readonly [number, number, number];
|
||||||
@@ -106,21 +141,19 @@ function firstMesh(scene: THREE.Object3D) {
|
|||||||
return mesh as THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
|
return mesh as THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DungeonAssetInstances({
|
function DungeonMeshInstances({
|
||||||
url,
|
mesh,
|
||||||
fixtures,
|
fixtures,
|
||||||
tint,
|
tint,
|
||||||
opacity = 1,
|
opacity = 1,
|
||||||
colors,
|
colors,
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
mesh: THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
|
||||||
fixtures: readonly ArenaFixture[];
|
fixtures: readonly ArenaFixture[];
|
||||||
tint: string;
|
tint: string;
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
colors?: readonly THREE.Color[];
|
colors?: readonly THREE.Color[];
|
||||||
}) {
|
}) {
|
||||||
const gltf = useGLTF(url, false, true);
|
|
||||||
const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]);
|
|
||||||
const sourceMaterial = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
|
const sourceMaterial = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
|
||||||
const material = useMemo(() => {
|
const material = useMemo(() => {
|
||||||
const next = sourceMaterial.clone();
|
const next = sourceMaterial.clone();
|
||||||
@@ -156,23 +189,84 @@ function DungeonAssetInstances({
|
|||||||
return <instancedMesh ref={instances} args={[mesh.geometry, material, fixtures.length]} castShadow receiveShadow />;
|
return <instancedMesh ref={instances} args={[mesh.geometry, material, fixtures.length]} castShadow receiveShadow />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArenaArchitecture({ room }: { room: BossRoomDefinition }) {
|
function LegacyDungeonAssetInstances({
|
||||||
const walls = useMemo(() => ARENA_WALL_SEGMENTS.map((fixture) => ({
|
url,
|
||||||
|
...props
|
||||||
|
}: Omit<Parameters<typeof DungeonMeshInstances>[0], "mesh"> & { url: string }) {
|
||||||
|
const gltf = useGLTF(url, false, true);
|
||||||
|
const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]);
|
||||||
|
return <DungeonMeshInstances mesh={mesh} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arenaWalls(room: BossRoomDefinition, portalOpenings = false) {
|
||||||
|
return ARENA_WALL_SEGMENTS.filter((_, index) => !portalOpenings || index !== 0 && index !== 8).map((fixture) => ({
|
||||||
...fixture,
|
...fixture,
|
||||||
scaleY: room.wallHeight / 4,
|
scaleY: room.wallHeight / 4,
|
||||||
})), [room.wallHeight]);
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegacyArenaArchitecture({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||||||
|
const walls = useMemo(() => arenaWalls(room, portalOpenings), [portalOpenings, room]);
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
<DungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
|
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
|
||||||
<DungeonAssetInstances url={KAYKIT_DUNGEON_PILLAR_URL} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
|
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_PILLAR_URL} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
|
||||||
<DungeonAssetInstances url={KAYKIT_DUNGEON_TORCH_URL} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
|
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_TORCH_URL} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function 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 walls = useRef<THREE.Group>(null);
|
||||||
const previousCameraPosition = useRef<THREE.Vector3 | null>(null);
|
const previousCameraPosition = useRef<THREE.Vector3 | null>(null);
|
||||||
|
const fixtures = useMemo(
|
||||||
|
() => ARENA_WALL_SEGMENTS.filter((_, index) => !portalOpenings || index !== 0 && index !== 8),
|
||||||
|
[portalOpenings],
|
||||||
|
);
|
||||||
|
|
||||||
useFrame(({ camera }) => {
|
useFrame(({ camera }) => {
|
||||||
if (!walls.current) return;
|
if (!walls.current) return;
|
||||||
@@ -190,7 +284,7 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={walls}>
|
<group ref={walls}>
|
||||||
{ARENA_WALL_SEGMENTS.map((fixture, index) => (
|
{fixtures.map((fixture, index) => (
|
||||||
<mesh
|
<mesh
|
||||||
key={index}
|
key={index}
|
||||||
position={[fixture.position[0], room.wallHeight / 2, fixture.position[2]]}
|
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 (
|
return (
|
||||||
<Suspense fallback={<RoomWallFallback room={room} />}>
|
<Suspense fallback={<RoomWallFallback room={room} portalOpenings={portalOpenings} />}>
|
||||||
<ArenaArchitecture room={room} />
|
<LegacyArenaArchitecture room={room} portalOpenings={portalOpenings} />
|
||||||
</Suspense>
|
</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 }) {
|
function RoomMarks({ room }: { room: BossRoomDefinition }) {
|
||||||
const rays = useRef<THREE.InstancedMesh>(null);
|
const rays = useRef<THREE.InstancedMesh>(null);
|
||||||
const pattern = ROOM_PATTERNS[room.floor];
|
const pattern = ROOM_PATTERNS[room.floor];
|
||||||
@@ -306,7 +416,7 @@ function RoomScenery({ room }: { room: BossRoomDefinition }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoomFloor({ room }: { room: BossRoomDefinition }) {
|
function RoomFloor({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
<mesh position={[0, -0.45, ROOM_CENTER_Z]} receiveShadow>
|
<mesh position={[0, -0.45, ROOM_CENTER_Z]} receiveShadow>
|
||||||
@@ -323,14 +433,384 @@ function RoomFloor({ room }: { room: BossRoomDefinition }) {
|
|||||||
</mesh>
|
</mesh>
|
||||||
<RoomMarks room={room} />
|
<RoomMarks room={room} />
|
||||||
<RoomScenery room={room} />
|
<RoomScenery room={room} />
|
||||||
<RoomWalls room={room} />
|
<RoomWalls room={room} portalOpenings={portalOpenings} />
|
||||||
</group>
|
</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. */
|
/** Main-display room projection. Gameplay stays in the shared arena domain. */
|
||||||
export function BossRoom() {
|
export function BossRoom() {
|
||||||
const bossId = useGameStore((state) => state.boss.id);
|
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);
|
const room = bossRoomFor(bossId);
|
||||||
return (
|
return (
|
||||||
<group key={room.id}>
|
<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]} />
|
<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.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]} />
|
<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>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (LEGACY_GAME_ASSETS_FORCED) {
|
||||||
useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true);
|
useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true);
|
||||||
useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true);
|
useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true);
|
||||||
useGLTF.preload(KAYKIT_DUNGEON_TORCH_URL, false, true);
|
useGLTF.preload(KAYKIT_DUNGEON_TORCH_URL, false, true);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Canvas } from "@react-three/fiber";
|
||||||
|
import { Suspense, useMemo } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||||
|
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||||
|
import { ALTERNATE_BOSS_CONFIG, bossVisualUrl } from "../game/bossVisuals";
|
||||||
|
import type { BossId } from "../game/types";
|
||||||
|
import { GameAssetProvider, LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider";
|
||||||
|
|
||||||
|
function PortraitModel({ bossId }: { bossId: BossId }) {
|
||||||
|
const gltf = useGameGLTF(bossVisualUrl(bossId, !LEGACY_GAME_ASSETS_FORCED));
|
||||||
|
const model = useMemo(() => {
|
||||||
|
const clone = cloneSkeleton(gltf.scene);
|
||||||
|
clone.updateMatrixWorld(true);
|
||||||
|
const bounds = new THREE.Box3().setFromObject(clone);
|
||||||
|
const center = bounds.getCenter(new THREE.Vector3());
|
||||||
|
const size = bounds.getSize(new THREE.Vector3());
|
||||||
|
const scale = 2.35 / Math.max(size.x, size.y, size.z, 0.001);
|
||||||
|
clone.traverse((object) => {
|
||||||
|
if (object instanceof THREE.Mesh) {
|
||||||
|
object.castShadow = false;
|
||||||
|
object.receiveShadow = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { clone, center, scale };
|
||||||
|
}, [gltf.scene]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<primitive
|
||||||
|
object={model.clone}
|
||||||
|
position={[-model.center.x * model.scale, -model.center.y * model.scale, -model.center.z * model.scale]}
|
||||||
|
rotation={[0, bossId === "bulldrome" ? 0.35 : ALTERNATE_BOSS_CONFIG[bossId].rotationOffset + 0.35, 0]}
|
||||||
|
scale={model.scale}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BossTrophyPortrait({ bossId }: { bossId: BossId }) {
|
||||||
|
const boss = BOSS_DEFINITIONS[bossId];
|
||||||
|
return (
|
||||||
|
<div className="trophy-portrait" aria-label={`${boss.name} portrait`} role="img">
|
||||||
|
<Canvas
|
||||||
|
camera={{ position: [3.4, 2.35, 4.8], zoom: 70, near: 0.1, far: 30 }}
|
||||||
|
dpr={1}
|
||||||
|
frameloop="demand"
|
||||||
|
gl={{ alpha: true, antialias: true, powerPreference: "low-power" }}
|
||||||
|
orthographic
|
||||||
|
>
|
||||||
|
<ambientLight intensity={1.9} />
|
||||||
|
<directionalLight color="#fff3cf" intensity={3.2} position={[3, 5, 4]} />
|
||||||
|
<directionalLight color={boss.accent} intensity={2.1} position={[-4, 2, -2]} />
|
||||||
|
<GameAssetProvider>
|
||||||
|
<Suspense fallback={null}><PortraitModel bossId={bossId} /></Suspense>
|
||||||
|
</GameAssetProvider>
|
||||||
|
</Canvas>
|
||||||
|
<span aria-hidden="true">{boss.icon}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+600
-101
@@ -1,11 +1,153 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { ABILITY_ORDER } from "../game/data";
|
import { ABILITY_ORDER } from "../game/data";
|
||||||
import { HEALER_CLASSES } from "../game/healers";
|
import { HEALER_CLASSES } from "../game/healers";
|
||||||
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||||
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
import { BARRIER_RADIUS, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
||||||
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
|
|
||||||
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
|
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
|
||||||
import type { BottomTab, PartyMember } from "../game/types";
|
import type { BottomTab, PartyMember } from "../game/types";
|
||||||
import { useFrontendStore } from "../frontend/store";
|
import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
||||||
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
|
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,
|
||||||
|
cycleHockeyPvpPostMatchSelection,
|
||||||
|
type HockeyPvpPostMatchSelection,
|
||||||
|
} from "../game/hockeyHealingPvp";
|
||||||
|
import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown";
|
||||||
|
import { bottomTabsFor, cycleBottomTab } from "../game/bottomTabs";
|
||||||
|
import {
|
||||||
|
BLOCKBREAKER_BREACH_DAMAGE,
|
||||||
|
BLOCKBREAKER_BRICK_COLORS,
|
||||||
|
BLOCKBREAKER_DANGER_Z,
|
||||||
|
blockbreakerColumnX,
|
||||||
|
blockbreakerRowZ,
|
||||||
|
blockbreakerTimeMultiplier,
|
||||||
|
} from "../game/blockbreaker";
|
||||||
|
import { aetherShipColor } from "./aetherAssaultVisuals";
|
||||||
|
import { RpgRunTacticalPanel } from "./rpgRoguelike/RpgRunTacticalPanel";
|
||||||
|
import { isBeaconOfLightTarget } from "../game/healerMechanics";
|
||||||
|
import { AbilityButton } from "./AbilityButton";
|
||||||
|
import { getDisplaySurface, requestDisplaySurface, subscribeDisplaySurface } from "../platform/displayRouting";
|
||||||
|
import { isSingleScreenLayout } from "../platform/displayLayout";
|
||||||
|
import { subscribeControllerToken } from "../input/controller";
|
||||||
|
import {
|
||||||
|
RoguelikePvpDraftPanel,
|
||||||
|
RoguelikePvpTacticalPanel,
|
||||||
|
} from "./RoguelikePvpPanels";
|
||||||
|
|
||||||
|
function moveTacticalSelection(direction: 1 | -1) {
|
||||||
|
const store = useGameStore.getState();
|
||||||
|
if (store.activeTab === "combat") {
|
||||||
|
store.cycleMember(direction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (store.activeTab !== "pack" || store.inventory.length === 0) return;
|
||||||
|
const currentIndex = Math.max(0, store.inventory.findIndex((item) => item.id === store.selectedItemId));
|
||||||
|
const nextIndex = (currentIndex + direction + store.inventory.length) % store.inventory.length;
|
||||||
|
store.selectItem(store.inventory[nextIndex].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSingleScreenTacticalInput(
|
||||||
|
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void,
|
||||||
|
onExit?: () => void,
|
||||||
|
) {
|
||||||
|
const actionRef = useRef(onHockeyPvpAction);
|
||||||
|
const exitRef = useRef(onExit);
|
||||||
|
actionRef.current = onHockeyPvpAction;
|
||||||
|
exitRef.current = onExit;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSingleScreenLayout()) return;
|
||||||
|
let active = getDisplaySurface() === "bottom";
|
||||||
|
const unsubscribeSurface = subscribeDisplaySurface((surface) => { active = surface === "bottom"; });
|
||||||
|
const cycleTab = (direction: 1 | -1) => {
|
||||||
|
const store = useGameStore.getState();
|
||||||
|
if (direction === 1) store.setActiveTab(cycleBottomTab(store.activeTab, store.runMode));
|
||||||
|
else {
|
||||||
|
const tabs = bottomTabsFor(store.runMode);
|
||||||
|
const currentIndex = tabs.indexOf(store.activeTab);
|
||||||
|
store.setActiveTab(tabs[(currentIndex - 1 + tabs.length) % tabs.length]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const activatePhaseAction = () => {
|
||||||
|
const store = useGameStore.getState();
|
||||||
|
if (store.phase === "briefing") store.startEncounter();
|
||||||
|
else if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") {
|
||||||
|
if (store.hockeyPvp.postMatchSelection === "menu") exitRef.current?.();
|
||||||
|
else actionRef.current?.(store.hockeyPvp.postMatchSelection);
|
||||||
|
} else if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "roguelike-pvp") {
|
||||||
|
if (store.roguelikePvp.role === "cpu") store.restart();
|
||||||
|
else exitRef.current?.();
|
||||||
|
} else if (store.phase === "victory" || store.phase === "defeat") store.restart();
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (!active || event.repeat) return;
|
||||||
|
const store = useGameStore.getState();
|
||||||
|
if (store.paused
|
||||||
|
|| store.runMode === "rpg-roguelike"
|
||||||
|
|| store.phase === "intermission"
|
||||||
|
|| store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) return;
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") {
|
||||||
|
if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter", "escape"].includes(key)) event.preventDefault();
|
||||||
|
if (key === "arrowleft" || key === "arrowup") {
|
||||||
|
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, -1));
|
||||||
|
}
|
||||||
|
if (key === "arrowright" || key === "arrowdown") {
|
||||||
|
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, 1));
|
||||||
|
}
|
||||||
|
if (key === "enter") activatePhaseAction();
|
||||||
|
if (key === "escape") exitRef.current?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === "arrowleft") cycleTab(-1);
|
||||||
|
else if (key === "arrowright") cycleTab(1);
|
||||||
|
else if (key === "arrowup") moveTacticalSelection(-1);
|
||||||
|
else if (key === "arrowdown") moveTacticalSelection(1);
|
||||||
|
else if (key === "enter") activatePhaseAction();
|
||||||
|
else return;
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
|
||||||
|
if (!active || repeat && !["Button12", "Button13", "Button14", "Button15"].includes(token)) return;
|
||||||
|
const store = useGameStore.getState();
|
||||||
|
if (store.paused
|
||||||
|
|| store.runMode === "rpg-roguelike"
|
||||||
|
|| store.phase === "intermission"
|
||||||
|
|| store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) return;
|
||||||
|
if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") {
|
||||||
|
if (["Button12", "Button14"].includes(token)) {
|
||||||
|
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, -1));
|
||||||
|
} else if (["Button13", "Button15"].includes(token)) {
|
||||||
|
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, 1));
|
||||||
|
} else if (!repeat && token === "Button0") activatePhaseAction();
|
||||||
|
else if (!repeat && token === "Button1") exitRef.current?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (token === "Button14") cycleTab(-1);
|
||||||
|
else if (token === "Button15") cycleTab(1);
|
||||||
|
else if (token === "Button12") moveTacticalSelection(-1);
|
||||||
|
else if (token === "Button13") moveTacticalSelection(1);
|
||||||
|
else if (!repeat && token === "Button0") activatePhaseAction();
|
||||||
|
else if (!repeat && token === "Button1") requestDisplaySurface("top");
|
||||||
|
});
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
unsubscribeController();
|
||||||
|
unsubscribeSurface();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
function RewardSummary() {
|
function RewardSummary() {
|
||||||
const rewards = useFrontendStore((state) => state.recentRewards);
|
const rewards = useFrontendStore((state) => state.recentRewards);
|
||||||
@@ -29,23 +171,27 @@ function PartyFrame({ member }: { member: PartyMember }) {
|
|||||||
const selected = useGameStore((state) => state.selectedMemberId === member.id);
|
const selected = useGameStore((state) => state.selectedMemberId === member.id);
|
||||||
const selectMember = useGameStore((state) => state.selectMember);
|
const selectMember = useGameStore((state) => state.selectMember);
|
||||||
const time = useGameStore((state) => state.time);
|
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 knockedRemaining = Math.max(0, member.knockedUntil - time);
|
||||||
const barrier = useGameStore((state) => state.barrier);
|
const barrier = useGameStore((state) => state.barrier);
|
||||||
const tankAura = useGameStore((state) => state.partyCombat.tankAura);
|
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 combatant = useGameStore((state) => member.id === "aelia" ? undefined : state.partyCombat.combatants[member.id]);
|
||||||
const position = useGameStore((state) => state.partyPositions[member.id]);
|
const position = useGameStore((state) => state.partyPositions[member.id]);
|
||||||
const protectedByBarrier = barrierProtects(position, barrier, time);
|
const protectedByBarrier = barrierProtects(position, barrier, time);
|
||||||
|
const linkedBySpirit = barrier.kind === "spirit-link" && healerFieldContains(position, barrier, time);
|
||||||
const protectedByTank = tankAuraProtects(position, tankPosition, tankAura, time);
|
const protectedByTank = tankAuraProtects(position, tankPosition, tankAura, time);
|
||||||
const currentAction = combatant?.visualAction && combatant.visualAction.endsAt > time
|
const currentAction = combatant?.visualAction && combatant.visualAction.endsAt > time
|
||||||
? PARTY_ABILITY_NAMES[combatant.visualAction.abilityId]
|
? PARTY_ABILITY_NAMES[combatant.visualAction.abilityId]
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<button
|
<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)}
|
onClick={() => selectMember(member.id)}
|
||||||
aria-pressed={selected}
|
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-avatar" style={{ "--member-color": member.color } as React.CSSProperties}>{member.name[0]}</span>
|
||||||
<span className="party-data">
|
<span className="party-data">
|
||||||
@@ -54,10 +200,16 @@ function PartyFrame({ member }: { member: PartyMember }) {
|
|||||||
<small>{currentAction ?? member.className}</small>
|
<small>{currentAction ?? member.className}</small>
|
||||||
</span>
|
</span>
|
||||||
<span className="effect-stack">
|
<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>}
|
{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>}
|
{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>}
|
{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>}
|
{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>}
|
{knockedRemaining > 0 && <i className="effect knock-effect" title={`Knocked down: ${knockedRemaining.toFixed(1)} seconds`}>KD</i>}
|
||||||
</span>
|
</span>
|
||||||
@@ -75,71 +227,42 @@ function PartyList() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number] }) {
|
|
||||||
const healerClassId = useGameStore((state) => state.healerClassId);
|
|
||||||
const ability = HEALER_CLASSES[healerClassId].abilities[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 selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
|
|
||||||
const activeCast = useGameStore((state) => state.activeCast);
|
|
||||||
const castAbility = useGameStore((state) => state.castAbility);
|
|
||||||
const runModifiers = useGameStore((state) => state.runModifiers);
|
|
||||||
const remaining = abilityRemaining(abilityId, time, cooldowns);
|
|
||||||
const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
|
|
||||||
const castTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
|
|
||||||
const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
|
|
||||||
const globalRemaining = Math.max(0, globalCooldownUntil - time);
|
|
||||||
const noDispel = abilityId === "purify" && selected.debuffs.length === 0;
|
|
||||||
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
|
|
||||||
const disabled = phase !== "combat" || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
|
|
||||||
const resourceCopy = `${manaCost ? `${manaCost} mana` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`ability ability-${abilityId} ${remaining > 0 || globalRemaining > 0 ? "on-cooldown" : ""}`}
|
|
||||||
style={{ "--ability-color": ability.color } as React.CSSProperties}
|
|
||||||
onClick={() => castAbility(abilityId)}
|
|
||||||
disabled={disabled}
|
|
||||||
title={ability.description}
|
|
||||||
aria-label={`${ability.name}. ${ability.description}`}
|
|
||||||
>
|
|
||||||
<span className="ability-key">{ability.key}</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>
|
|
||||||
{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>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{remaining <= 0 && globalRemaining > 0 && (
|
|
||||||
<span className="cooldown-mask global-cooldown" style={{ "--cooldown-progress": Math.min(1, globalRemaining / GLOBAL_COOLDOWN_SECONDS) } as React.CSSProperties}>
|
|
||||||
<b>{globalRemaining.toFixed(1)}</b>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AbilityTray() {
|
function AbilityTray() {
|
||||||
const healerClassId = useGameStore((state) => state.healerClassId);
|
const healerClassId = useGameStore((state) => state.healerClassId);
|
||||||
const healer = HEALER_CLASSES[healerClassId];
|
const healer = HEALER_CLASSES[healerClassId];
|
||||||
const mana = useGameStore((state) => state.mana);
|
const mana = useGameStore((state) => state.mana);
|
||||||
const maxMana = useGameStore((state) => state.maxMana);
|
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 selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
|
||||||
const time = useGameStore((state) => state.time);
|
const time = useGameStore((state) => state.time);
|
||||||
const boss = useGameStore((state) => state.boss);
|
const boss = useGameStore((state) => state.boss);
|
||||||
const bossMotion = useGameStore((state) => state.bossMotion);
|
const bossMotion = useGameStore((state) => state.bossMotion);
|
||||||
const additionalBosses = useGameStore((state) => state.additionalBosses);
|
const additionalBosses = useGameStore((state) => state.additionalBosses);
|
||||||
const mechanic = upcomingEncounterMechanic({ boss, bossMotion, additionalBosses, time });
|
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 (
|
return (
|
||||||
<div className="ability-column">
|
<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="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>
|
<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>
|
||||||
<div className="ability-grid">
|
<div className="ability-grid">
|
||||||
{ABILITY_ORDER.map((abilityId) => <AbilityButton key={abilityId} abilityId={abilityId} />)}
|
{ABILITY_ORDER.map((abilityId) => <AbilityButton key={abilityId} abilityId={abilityId} />)}
|
||||||
@@ -163,14 +286,28 @@ function BriefingPanel() {
|
|||||||
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
|
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
|
||||||
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
|
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
|
||||||
const bossNames = bosses.map((boss) => boss.name).join(" & ");
|
const bossNames = bosses.map((boss) => boss.name).join(" & ");
|
||||||
|
const runMode = useGameStore((state) => state.runMode);
|
||||||
|
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);
|
||||||
|
const countdownEndsAtMs = useGameStore((state) => state.hockeyPvp.countdownEndsAtMs);
|
||||||
|
const pvpCountdownSeconds = useHockeyPvpCountdownSeconds(pvpMode, countdownEndsAtMs);
|
||||||
|
const roguelikePvp = useGameStore((state) => state.roguelikePvp);
|
||||||
|
const roguelikePvpMode = runMode === "roguelike-pvp";
|
||||||
|
const roguelikePvpCountdownSeconds = useHockeyPvpCountdownSeconds(roguelikePvpMode, roguelikePvp.countdownEndsAtMs);
|
||||||
|
const competitivePvpMode = pvpMode || roguelikePvpMode;
|
||||||
|
const competitivePvpCountdown = roguelikePvpMode ? roguelikePvpCountdownSeconds : pvpCountdownSeconds;
|
||||||
return (
|
return (
|
||||||
<div className="briefing-panel">
|
<div className="briefing-panel">
|
||||||
<div className="briefing-class">
|
<div className="briefing-class">
|
||||||
<div className="class-crest" style={{ color: healer.color }}>{healer.icon}</div>
|
<div className="class-crest" style={{ color: healer.color }}>{healer.icon}</div>
|
||||||
<span>Chosen discipline</span>
|
<span>Chosen discipline</span>
|
||||||
<h2>{healer.specialization}</h2>
|
<h2>{healer.specialization}</h2>
|
||||||
<p>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</p>
|
<p>{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.` : roguelikePvpMode ? `Face ${roguelikePvp.opponentName} through matching seeded encounters. After every clear, choose one blessing for yourself and secretly inflict one ability curse on your rival. Last five-person formation standing wins.` : 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>Face {bossNames}</span><small>START / ENTER</small></button>
|
<button className="start-button" onClick={startEncounter} disabled={competitivePvpMode}><span>{hockeyMode ? "Begin Hockey Healing" : competitivePvpMode ? competitivePvpCountdown > 0 ? `Match starts in ${competitivePvpCountdown} seconds` : "Match starting now" : blockbreakerMode ? "Begin Blockbreaker" : aetherMode ? "Begin Aether Assault" : `Face ${bossNames}`}</span><small>{competitivePvpMode ? "Automatic start" : `${DEFAULT_CONTROLLER_GLYPHS.start} / ENTER`}</small></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="briefing-kit">
|
<div className="briefing-kit">
|
||||||
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
|
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
|
||||||
@@ -189,29 +326,105 @@ function BriefingPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EndPanel() {
|
function EndPanel({
|
||||||
|
onExit,
|
||||||
|
onHockeyPvpAction,
|
||||||
|
}: {
|
||||||
|
onExit?: () => void;
|
||||||
|
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void;
|
||||||
|
}) {
|
||||||
|
const hunter = useActiveHunter();
|
||||||
const phase = useGameStore((state) => state.phase);
|
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 time = useGameStore((state) => state.time);
|
||||||
const party = useGameStore((state) => state.party);
|
const party = useGameStore((state) => state.party);
|
||||||
const restart = useGameStore((state) => state.restart);
|
const restart = useGameStore((state) => state.restart);
|
||||||
const startEncounter = useGameStore((state) => state.startEncounter);
|
const startEncounter = useGameStore((state) => state.startEncounter);
|
||||||
const totalHp = party.reduce((sum, member) => sum + member.hp, 0);
|
const totalHp = party.reduce((sum, member) => sum + member.hp, 0);
|
||||||
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
|
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
|
||||||
|
const hockey = useGameStore((state) => state.hockey);
|
||||||
|
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
|
||||||
|
const roguelikePvp = useGameStore((state) => state.roguelikePvp);
|
||||||
|
const setHockeyPvpPostMatchSelection = useGameStore((state) => state.setHockeyPvpPostMatchSelection);
|
||||||
|
const requeueSeconds = useHockeyPvpCountdownSeconds(
|
||||||
|
hockeyPvp.postMatchStatus === "requeueing",
|
||||||
|
hockeyPvp.postMatchQueueEndsAtMs,
|
||||||
|
);
|
||||||
|
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 roguelikePvpMatch = runMode === "roguelike-pvp";
|
||||||
|
const endlessDefeat = phase === "defeat" && endlessMode && !hockeyDefeat && !blockbreakerDefeat && !aetherDefeat && !pvpMatch && !roguelikePvpMatch;
|
||||||
|
const endlessHighScore = hunter?.stats.highestRogueTrialsEndlessKills ?? 0;
|
||||||
|
const endlessHighScoreLabel = `${endlessHighScore} ${endlessHighScore === 1 ? "boss" : "bosses"}`;
|
||||||
return (
|
return (
|
||||||
<div className={`end-panel end-${phase}`}>
|
<div className={`end-panel end-${phase} ${pvpMatch || roguelikePvpMatch ? "is-pvp" : ""}`}>
|
||||||
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
|
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
|
||||||
<small>{phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
|
<small>{showEndlessChoice ? "ROGUE TRIALS CLEARED" : hockeyDefeat ? "HOCKEY HEALING COMPLETE" : blockbreakerDefeat ? "BLOCKBREAKER RUN COMPLETE" : aetherDefeat ? "AETHER ASSAULT COMPLETE" : pvpMatch || roguelikePvpMatch ? phase === "victory" ? "PVP MATCH WON" : "PVP MATCH LOST" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
|
||||||
<h2>{phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
|
<h2>{showEndlessChoice ? "The trial can continue" : 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` : roguelikePvpMatch ? phase === "victory" ? `${roguelikePvp.opponentName}'s formation fell` : `${roguelikePvp.opponentName} wins the rift race` : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
|
||||||
<div className="result-stats">
|
<div className="result-stats">
|
||||||
<span><small>Duration</small><strong>{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}</strong></span>
|
<span><small>Duration</small><strong>{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}</strong></span>
|
||||||
<span><small>Party vitality</small><strong>{Math.round((totalHp / totalMax) * 100)}%</strong></span>
|
<span><small>{hockeyDefeat ? "Puck returns" : blockbreakerDefeat ? "Bricks broken" : aetherDefeat ? "Wave reached" : pvpMatch ? "Goals" : roguelikePvpMatch ? "Round reached" : "Party vitality"}</small><strong>{hockeyDefeat ? hockey.returns : blockbreakerDefeat ? blockbreaker.bricksBroken : aetherDefeat ? aetherAssault.wave : pvpMatch ? `${hockeyPvp.opponentGoalsConceded}–${hockeyPvp.localGoalsConceded}` : roguelikePvpMatch ? round : `${Math.round((totalHp / totalMax) * 100)}%`}</strong></span>
|
||||||
<span><small>Boss</small><strong>{phase === "victory" ? "Defeated" : "Standing"}</strong></span>
|
<span><small>{hockeyDefeat || blockbreakerDefeat || aetherDefeat ? "Boss kills" : pvpMatch ? "Boss kills" : roguelikePvpMatch ? "Active burdens" : endlessDefeat ? "Endless kills" : "Boss"}</small><strong>{hockeyDefeat || blockbreakerDefeat || aetherDefeat || endlessDefeat ? endlessBossKills : pvpMatch ? `${endlessBossKills}–${hockeyPvp.opponentBossKills}` : roguelikePvpMatch ? Object.values(roguelikePvp.receivedCurseRanks).filter((rank) => (rank ?? 0) > 0).length : phase === "victory" ? "Defeated" : "Standing"}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
{phase === "victory" && <RewardSummary />}
|
{!roguelikePvpMatch && (phase === "victory" || hockeyDefeat || blockbreakerDefeat || aetherDefeat) && <RewardSummary />}
|
||||||
<div className="end-actions">
|
{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> : roguelikePvpMatch ? <div className="end-actions pvp-end-actions">
|
||||||
|
{roguelikePvp.role === "cpu" && <button className="is-controller-selected" onClick={restart}><span>Run again</span><small>Same CPU rival</small></button>}
|
||||||
|
<button className={roguelikePvp.role === "cpu" ? "secondary" : "is-controller-selected"} onClick={onExit}>Main menu</button>
|
||||||
|
</div> : pvpMatch ? <>
|
||||||
|
<div className="pvp-post-match-status" role="status" aria-live="polite">
|
||||||
|
{hockeyPvp.postMatchStatus === "waiting-rematch"
|
||||||
|
? `Waiting for ${hockeyPvp.opponentName} to accept rematch…`
|
||||||
|
: hockeyPvp.postMatchStatus === "requeueing"
|
||||||
|
? `Searching queue · CPU fallback in ${requeueSeconds}s`
|
||||||
|
: "Choose rematch or enter queue for another opponent."}
|
||||||
|
</div>
|
||||||
|
<div className="end-actions pvp-end-actions">
|
||||||
|
<button
|
||||||
|
className={hockeyPvp.postMatchSelection === "rematch" ? "is-controller-selected" : ""}
|
||||||
|
disabled={hockeyPvp.postMatchStatus === "waiting-rematch"}
|
||||||
|
onPointerEnter={() => setHockeyPvpPostMatchSelection("rematch")}
|
||||||
|
onClick={() => onHockeyPvpAction?.("rematch")}
|
||||||
|
><span>{hockeyPvp.postMatchStatus === "waiting-rematch" ? "Rematch requested" : "Rematch"}</span><small>Same opponent</small></button>
|
||||||
|
<button
|
||||||
|
className={hockeyPvp.postMatchSelection === "requeue" ? "is-controller-selected" : ""}
|
||||||
|
disabled={hockeyPvp.postMatchStatus === "requeueing"}
|
||||||
|
onPointerEnter={() => setHockeyPvpPostMatchSelection("requeue")}
|
||||||
|
onClick={() => onHockeyPvpAction?.("requeue")}
|
||||||
|
><span>{hockeyPvp.postMatchStatus === "requeueing" ? `Queueing · ${requeueSeconds}s` : "Requeue"}</span><small>Find another rival</small></button>
|
||||||
|
<button
|
||||||
|
className={`secondary ${hockeyPvp.postMatchSelection === "menu" ? "is-controller-selected" : ""}`}
|
||||||
|
onPointerEnter={() => setHockeyPvpPostMatchSelection("menu")}
|
||||||
|
onClick={onExit}
|
||||||
|
>Main menu</button>
|
||||||
|
</div>
|
||||||
|
</> : <div className="end-actions">
|
||||||
<button onClick={() => { restart(); startEncounter(); }}>Run again</button>
|
<button onClick={() => { restart(); startEncounter(); }}>Run again</button>
|
||||||
<button className="secondary" onClick={restart}>Return to briefing</button>
|
<button className="secondary" onClick={endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat ? onExit : restart}>{endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat ? "Return to main menu" : "Return to briefing"}</button>
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -225,16 +438,22 @@ function IntermissionStatusPanel() {
|
|||||||
<h2>Choose on top display</h2>
|
<h2>Choose on top display</h2>
|
||||||
<p>Next encounter stays locked until one blessing is claimed.</p>
|
<p>Next encounter stays locked until one blessing is claimed.</p>
|
||||||
<RewardSummary />
|
<RewardSummary />
|
||||||
<small>Use D-pad to choose · A to claim</small>
|
<small>Use D-pad to choose · {DEFAULT_CONTROLLER_GLYPHS.confirm} to claim</small>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CombatPanel() {
|
function CombatPanel({ onExit, onHockeyPvpAction }: {
|
||||||
|
onExit?: () => void;
|
||||||
|
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void;
|
||||||
|
}) {
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
|
const runMode = useGameStore((state) => state.runMode);
|
||||||
if (phase === "briefing") return <BriefingPanel />;
|
if (phase === "briefing") return <BriefingPanel />;
|
||||||
if (phase === "intermission") return <IntermissionStatusPanel />;
|
if (phase === "intermission") return runMode === "roguelike-pvp"
|
||||||
if (phase === "victory" || phase === "defeat") return <EndPanel />;
|
? <RoguelikePvpDraftPanel />
|
||||||
|
: <IntermissionStatusPanel />;
|
||||||
|
if (phase === "victory" || phase === "defeat") return <EndPanel onExit={onExit} onHockeyPvpAction={onHockeyPvpAction} />;
|
||||||
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
|
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,10 +466,182 @@ function MapPanel() {
|
|||||||
const time = useGameStore((state) => state.time);
|
const time = useGameStore((state) => state.time);
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
const bossId = useGameStore((state) => state.bossId);
|
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 bossDefinition = BOSS_DEFINITIONS[bossId];
|
||||||
const playerX = 120 + playerPosition[0] * 7;
|
const playerX = 120 + playerPosition[0] * 7;
|
||||||
const playerY = 143 + playerPosition[1] * 5.3;
|
const playerY = 143 + playerPosition[1] * 5.3;
|
||||||
const bossMotions = [bossMotion, ...additionalBosses.map((entry) => entry.motion)];
|
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 (
|
return (
|
||||||
<div className="map-panel">
|
<div className="map-panel">
|
||||||
<div className="map-copy">
|
<div className="map-copy">
|
||||||
@@ -286,8 +677,8 @@ function MapPanel() {
|
|||||||
className="map-barrier"
|
className="map-barrier"
|
||||||
cx={120 + barrier.center[0] * 7}
|
cx={120 + barrier.center[0] * 7}
|
||||||
cy={143 + barrier.center[1] * 5.3}
|
cy={143 + barrier.center[1] * 5.3}
|
||||||
rx="21"
|
rx={BARRIER_RADIUS * 7}
|
||||||
ry="15.9"
|
ry={BARRIER_RADIUS * 5.3}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => (
|
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => (
|
||||||
@@ -334,37 +725,145 @@ function PackPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs: { id: BottomTab; label: string; icon: string; key: string }[] = [
|
function PvpPanel() {
|
||||||
{ id: "combat", label: "Heal", icon: "✦", key: "" },
|
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
|
||||||
{ id: "map", label: "Map", icon: "⌁", key: "M" },
|
const opponentParty = useGameStore((state) => state.hockeyPvpOpponent.party);
|
||||||
{ id: "pack", label: "Pack", icon: "▧", key: "I" },
|
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 (
|
return (
|
||||||
<section className="display bottom-display" aria-label="Tactical touch display">
|
<div className="pvp-panel">
|
||||||
<header className="lower-header">
|
<header className="pvp-roster-header">
|
||||||
<div className="lower-brand"><span>IH</span><strong>I Want To Heal</strong><small>{phase === "combat" ? "Encounter live" : "Field console"}</small></div>
|
<span><small>Opponent party</small><strong>{opponentName}</strong></span>
|
||||||
<nav aria-label="Lower display sections">
|
<div><small>Goals</small><strong>{opponentGoalsConceded}–{localGoalsConceded}</strong></div>
|
||||||
{tabs.map((tab) => (
|
<div><small>Boss KOs</small><strong>{opponentBossKills}</strong></div>
|
||||||
<button key={tab.id} className={activeTab === tab.id ? "is-active" : ""} onClick={() => setActiveTab(tab.id)}>
|
<div><small>Standing</small><strong>{living} / {opponentParty.length}</strong></div>
|
||||||
<i>{tab.icon}</i><span>{tab.label}</span>{tab.key && <small>{tab.key}</small>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</header>
|
</header>
|
||||||
<main className="lower-content">
|
<div className="pvp-party-list" aria-label={`${opponentName} party health`}>
|
||||||
{activeTab === "combat" && <CombatPanel />}
|
<div className="section-label"><span>Rival health feed</span><small>{Math.ceil(currentHealth)} / {maximumHealth} total</small></div>
|
||||||
{activeTab === "map" && <MapPanel />}
|
{opponentParty.map((member) => (
|
||||||
{activeTab === "pack" && <PackPanel />}
|
<article className={`pvp-party-frame ${member.hp <= 0 ? "is-down" : ""}`} key={member.id}>
|
||||||
</main>
|
<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 && (
|
{paused && (
|
||||||
<div className="lower-pause-overlay" aria-hidden="true">
|
<div className="lower-pause-overlay" aria-hidden="true">
|
||||||
<span>PAUSED</span><strong>Encounter suspended</strong><small>START / ESC resumes · ↑↓ selects menu action</small>
|
<span>PAUSED</span><strong>Expedition suspended</strong><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC resumes · ↑↓ selects menu action</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottomScreen({ onExit, onHockeyPvpAction }: {
|
||||||
|
onExit?: () => void;
|
||||||
|
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void;
|
||||||
|
} = {}) {
|
||||||
|
useSingleScreenTacticalInput(onHockeyPvpAction, onExit);
|
||||||
|
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">
|
||||||
|
{phase === "intermission" && runMode === "roguelike-pvp"
|
||||||
|
? <RoguelikePvpDraftPanel />
|
||||||
|
: <>
|
||||||
|
{activeTab === "combat" && <CombatPanel onExit={onExit} onHockeyPvpAction={onHockeyPvpAction} />}
|
||||||
|
{activeTab === "map" && <MapPanel />}
|
||||||
|
{activeTab === "pack" && activityMode !== "hockey-healing-pvp" && <PackPanel />}
|
||||||
|
{activeTab === "pvp" && activityMode === "hockey-healing-pvp" && <PvpPanel />}
|
||||||
|
{activeTab === "pvp" && runMode === "roguelike-pvp" && <RoguelikePvpTacticalPanel />}
|
||||||
|
</>}
|
||||||
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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 { 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 }) {
|
export function BuffDraftPanel({ className = "" }: { className?: string }) {
|
||||||
const round = useGameStore((state) => state.round);
|
const round = useGameStore((state) => state.round);
|
||||||
|
const runMode = useGameStore((state) => state.runMode);
|
||||||
const healerClassId = useGameStore((state) => state.healerClassId);
|
const healerClassId = useGameStore((state) => state.healerClassId);
|
||||||
const runBuffRanks = useGameStore((state) => state.runBuffRanks);
|
const runBuffRanks = useGameStore((state) => state.runBuffRanks);
|
||||||
const passiveRunBuffId = useGameStore((state) => state.passiveRunBuffId);
|
const passiveRunBuffId = useGameStore((state) => state.passiveRunBuffId);
|
||||||
const choices = useGameStore((state) => state.draftBuffIds);
|
const choices = useGameStore((state) => state.draftBuffIds);
|
||||||
const selected = useGameStore((state) => state.selectedRunBuffId);
|
const selected = useGameStore((state) => state.selectedRunBuffId);
|
||||||
|
const inputUnlockAt = useGameStore((state) => state.runBuffInputUnlockAt);
|
||||||
const setSelected = useGameStore((state) => state.setSelectedRunBuff);
|
const setSelected = useGameStore((state) => state.setSelectedRunBuff);
|
||||||
const choose = useGameStore((state) => state.chooseRunBuff);
|
const choose = useGameStore((state) => state.chooseRunBuff);
|
||||||
const continueRun = useGameStore((state) => state.continueRoguelikeRound);
|
const continueRun = useGameStore((state) => state.continueRoguelikeRound);
|
||||||
const nextRound = round + 1;
|
const nextRound = round + 1;
|
||||||
|
const nextBossCount = runMode === "rogue-trials" && nextRound === ROGUE_TRIALS_TRIO_ROUND ? 3 : 2;
|
||||||
const abilities = HEALER_CLASSES[healerClassId].abilities;
|
const abilities = HEALER_CLASSES[healerClassId].abilities;
|
||||||
|
const [inputLocked, setInputLocked] = useState(() => isRunBuffInputLocked(useGameStore.getState()));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const remaining = inputUnlockAt - Date.now();
|
||||||
|
setInputLocked(remaining > 0);
|
||||||
|
if (remaining <= 0) return;
|
||||||
|
const timer = window.setTimeout(() => setInputLocked(false), remaining);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [inputUnlockAt]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`buff-draft ${className}`.trim()} role="dialog" aria-modal="true" aria-label={`Choose a buff for round ${nextRound}`}>
|
<div className={`buff-draft ${inputLocked ? "is-input-locked" : ""} ${className}`.trim()} role="dialog" aria-modal="true" aria-label={`Choose a buff for round ${nextRound}`} aria-busy={inputLocked}>
|
||||||
<header>
|
<header>
|
||||||
<span>Round {round} cleared</span>
|
<span>Round {round} cleared</span>
|
||||||
<h2>Choose one blessing</h2>
|
<h2>Choose one blessing</h2>
|
||||||
<p>Claim required. Round {nextRound} begins with two new bosses at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.</p>
|
<p>Claim required. Round {nextRound} begins with {nextBossCount === 3 ? "an unseen trio" : "two new bosses"} at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.</p>
|
||||||
</header>
|
</header>
|
||||||
<div className={`buff-choice-grid choice-count-${choices.length}`}>
|
<div className={`buff-choice-grid choice-count-${choices.length}`}>
|
||||||
{choices.length > 0 ? choices.map((buffId) => {
|
{choices.length > 0 ? choices.map((buffId) => {
|
||||||
const buff = RUN_BUFFS[buffId];
|
const buff = RUN_BUFFS[buffId];
|
||||||
const rank = effectiveRunBuffRank(runBuffRanks, buffId, passiveRunBuffId);
|
const rank = effectiveRunBuffRank(runBuffRanks, buffId, passiveRunBuffId);
|
||||||
const nextRank = Math.min(buff.maxRank, rank + 1);
|
const nextRank = Math.min(buff.maxRank, rank + 1);
|
||||||
const ability = abilities[buff.abilityId];
|
const ability = abilities[buff.abilitySlotId];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={buffId}
|
key={buffId}
|
||||||
className={selected === buffId ? "is-controller-focused" : ""}
|
className={selected === buffId ? "is-controller-selected" : ""}
|
||||||
style={{ "--buff-accent": buff.accent } as React.CSSProperties}
|
style={{ "--buff-accent": buff.accent } as React.CSSProperties}
|
||||||
onFocus={() => setSelected(buffId)}
|
|
||||||
onPointerEnter={() => setSelected(buffId)}
|
onPointerEnter={() => setSelected(buffId)}
|
||||||
onClick={() => choose(buffId)}
|
onClick={() => choose(buffId)}
|
||||||
|
disabled={inputLocked}
|
||||||
aria-pressed={selected === buffId}
|
aria-pressed={selected === buffId}
|
||||||
>
|
>
|
||||||
<i>{buff.icon}</i>
|
<i>{buff.icon}</i>
|
||||||
<span><small>{rank ? `Rank ${rank} → ${nextRank} / ${buff.maxRank}` : `New blessing · Rank 1 / ${buff.maxRank}`}</small><strong>{ability.shortName}: {buff.name}</strong></span>
|
<span><small>{rank ? `Rank ${rank} → ${nextRank} / ${buff.maxRank}` : `New blessing · Rank 1 / ${buff.maxRank}`}</small><strong>{ability.shortName}: {buff.name}</strong></span>
|
||||||
<b>{formatRunBuffEffect(buffId, nextRank)}</b>
|
<b>{formatRunBuffEffect(buffId, nextRank, ability.shortName)}</b>
|
||||||
<p>{buff.detail}</p>
|
<p>{buff.detail}</p>
|
||||||
</button>
|
</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>
|
<i>✦</i>
|
||||||
<span><small>Full mastery</small><strong>Continue Without Buff</strong></span>
|
<span><small>Full mastery</small><strong>Continue Without Buff</strong></span>
|
||||||
<b>All 18 blessings reached maximum rank.</b>
|
<b>All 18 blessings reached maximum rank.</b>
|
||||||
@@ -52,7 +67,7 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</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>;
|
||||||
|
}
|
||||||
@@ -1,38 +1,80 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
|
import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
|
||||||
|
import { resolveDisplayLayout } from "../platform/displayLayout";
|
||||||
import { subscribeControllerToken } from "../input/controller";
|
import { subscribeControllerToken } from "../input/controller";
|
||||||
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
|
|
||||||
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
|
export function DualDisplayFrame({ top, bottom, contextLabel = "Context" }: { top: ReactNode; bottom: ReactNode; contextLabel?: string }) {
|
||||||
const dedicatedSurface = new URLSearchParams(window.location.search).get("display");
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const dedicatedSurface = params.get("display");
|
||||||
|
const layout = resolveDisplayLayout({ display: dedicatedSurface, layout: params.get("layout") });
|
||||||
const [activeSurface, setActiveSurface] = useState<DisplaySurface>(() => dedicatedSurface === "bottom" ? "bottom" : "top");
|
const [activeSurface, setActiveSurface] = useState<DisplaySurface>(() => dedicatedSurface === "bottom" ? "bottom" : "top");
|
||||||
const activeSurfaceRef = useRef(activeSurface);
|
const activeSurfaceRef = useRef(activeSurface);
|
||||||
activeSurfaceRef.current = activeSurface;
|
activeSurfaceRef.current = activeSurface;
|
||||||
|
const showSurface = useCallback((surface: DisplaySurface) => {
|
||||||
|
activeSurfaceRef.current = surface;
|
||||||
|
setActiveSurface(surface);
|
||||||
|
}, []);
|
||||||
|
const toggleSurface = useCallback(() => {
|
||||||
|
requestDisplaySurface(activeSurfaceRef.current === "top" ? "bottom" : "top");
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!document.documentElement.classList.contains("native-platform")) return;
|
|
||||||
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") return;
|
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") return;
|
||||||
const toggle = () => setActiveSurface((surface) => surface === "top" ? "bottom" : "top");
|
if (layout === "thor-preview") return;
|
||||||
const unsubscribeSurface = subscribeDisplaySurface(setActiveSurface);
|
requestDisplaySurface("top");
|
||||||
|
const unsubscribeSurface = subscribeDisplaySurface(showSurface);
|
||||||
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
|
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
|
||||||
if (token === "Button8" && !repeat) toggle();
|
if (token === "Button8" && !repeat) toggleSurface();
|
||||||
});
|
});
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key !== "Tab" || event.repeat) return;
|
if (event.repeat) return;
|
||||||
|
if (event.key === "Escape" && activeSurfaceRef.current === "bottom") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toggle();
|
requestDisplaySurface("top");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
event.preventDefault();
|
||||||
|
toggleSurface();
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", onKeyDown);
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
unsubscribeSurface();
|
unsubscribeSurface();
|
||||||
unsubscribeController();
|
unsubscribeController();
|
||||||
|
requestDisplaySurface("top");
|
||||||
};
|
};
|
||||||
}, [dedicatedSurface]);
|
}, [dedicatedSurface, layout, showSurface, toggleSurface]);
|
||||||
|
|
||||||
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") {
|
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") {
|
||||||
return <div className={`dedicated-display-surface dedicated-${dedicatedSurface}`}>{dedicatedSurface === "top" ? top : bottom}</div>;
|
return <div className={`dedicated-display-surface dedicated-${dedicatedSurface}`}>{dedicatedSurface === "top" ? top : bottom}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (layout === "single") {
|
||||||
|
const contextOpen = activeSurface === "bottom";
|
||||||
|
return (
|
||||||
|
<div className={`single-display-frame ${contextOpen ? "context-open" : ""}`}>
|
||||||
|
<div className="single-primary-surface">{top}</div>
|
||||||
|
{contextOpen && (
|
||||||
|
<div className="single-context-layer" role="dialog" aria-modal="true" aria-label={`${contextLabel} interface`}>
|
||||||
|
<button className="single-context-backdrop" onClick={() => requestDisplaySurface("top")} aria-label={`Close ${contextLabel.toLowerCase()} interface`} />
|
||||||
|
<div className="single-context-surface">{bottom}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="single-context-toggle"
|
||||||
|
onClick={toggleSurface}
|
||||||
|
aria-expanded={contextOpen}
|
||||||
|
aria-label={contextOpen ? "Return to main view" : `Open ${contextLabel.toLowerCase()} interface`}
|
||||||
|
>
|
||||||
|
<b>{contextOpen ? "Return" : contextLabel}</b>
|
||||||
|
<small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`device-frame active-${activeSurface}`}>
|
<div className={`device-frame active-${activeSurface}`}>
|
||||||
<div className="screen-label"><span>Main viewport</span><small>960 × 540 CSS · 1920 × 1080 · 120Hz</small></div>
|
<div className="screen-label"><span>Main viewport</span><small>960 × 540 CSS · 1920 × 1080 · 120Hz</small></div>
|
||||||
@@ -42,10 +84,10 @@ export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: Reac
|
|||||||
<div className={`surface-slot bottom-slot ${activeSurface === "bottom" ? "is-active" : ""}`}>{bottom}</div>
|
<div className={`surface-slot bottom-slot ${activeSurface === "bottom" ? "is-active" : ""}`}>{bottom}</div>
|
||||||
<button
|
<button
|
||||||
className="native-display-switch"
|
className="native-display-switch"
|
||||||
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")}
|
onClick={toggleSurface}
|
||||||
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
|
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
|
||||||
>
|
>
|
||||||
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>SELECT / TAB</small>
|
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user