updating buff and debuff icons. separated COA classes from WoW classes on the character creation screen
This commit is contained in:
+83
-23
@@ -5,6 +5,9 @@ Healer Man uses the same local-Gitea deployment pattern as the previous
|
|||||||
filesystem, mount the runnable checkout into one Node container, and update it
|
filesystem, mount the runnable checkout into one Node container, and update it
|
||||||
with a local fast-forward pull plus an app restart.
|
with a local fast-forward pull plus an app restart.
|
||||||
|
|
||||||
|
For the repeatable Git, TrueNAS, signed-APK, and Obtainium release checklist,
|
||||||
|
follow [RELEASE_RUNBOOK.md](RELEASE_RUNBOOK.md).
|
||||||
|
|
||||||
The public URL remains `https://iwanttoheal.phenomrom.com`. The old game and
|
The public URL remains `https://iwanttoheal.phenomrom.com`. The old game and
|
||||||
Healer Man cannot both own host port `4173`; stop the old app before the final
|
Healer Man cannot both own host port `4173`; stop the old app before the final
|
||||||
cutover. Keep its checkout and database until the replacement is verified.
|
cutover. Keep its checkout and database until the replacement is verified.
|
||||||
@@ -14,7 +17,7 @@ cutover. Keep its checkout and database until the replacement is verified.
|
|||||||
The `healer-man` TrueNAS app is the browser host and online account server. One
|
The `healer-man` TrueNAS app is the browser host and online account server. One
|
||||||
Node 24 process serves the production Vite bundle and authenticated `/api`
|
Node 24 process serves the production Vite bundle and authenticated `/api`
|
||||||
routes on port `4173`. Its SQLite database stores accounts, hashed credentials,
|
routes on port `4173`. Its SQLite database stores accounts, hashed credentials,
|
||||||
sessions, and cloud rosters under `/app/data/game.db`.
|
sessions, and cloud rosters under `/app/runtime-data/game.db`.
|
||||||
|
|
||||||
The source checkout and persistent data are separate mounts. Replacing the
|
The source checkout and persistent data are separate mounts. Replacing the
|
||||||
container, rebuilding `dist`, or pulling source must not replace the database.
|
container, rebuilding `dist`, or pulling source must not replace the database.
|
||||||
@@ -63,18 +66,16 @@ clone alone cannot retrieve them.
|
|||||||
## First installation
|
## First installation
|
||||||
|
|
||||||
Create an empty Gitea repository named `phenom/healer-man`, then initialize and
|
Create an empty Gitea repository named `phenom/healer-man`, then initialize and
|
||||||
push the local `D:\Projects\HealerMan` project to its `main` branch.
|
push the local `F:\Projects\HealerMan` project to its `main` branch.
|
||||||
|
|
||||||
The current project directory is not yet a Git working tree. After creating the
|
The project is already connected to Gitea. Verify the checkout and both remote
|
||||||
empty Gitea repository, run this once from PowerShell on the development PC:
|
URLs from PowerShell with these exact commands:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Set-Location D:\Projects\HealerMan
|
Set-Location F:\Projects\HealerMan
|
||||||
git init -b main
|
git status --short --branch
|
||||||
git remote add origin https://git.whoagland.com/phenom/healer-man.git
|
git remote -v
|
||||||
git add .
|
git branch --show-current
|
||||||
git commit -m "Initial Healer Man import"
|
|
||||||
git push -u origin main
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If the repository slug differs, change both the remote URL here and every
|
If the repository slug differs, change both the remote URL here and every
|
||||||
@@ -129,7 +130,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost,https://localhost"
|
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost,https://localhost"
|
||||||
CONTENT_DIR: /app/content
|
CONTENT_DIR: /app/content
|
||||||
DATA_DIR: /app/data
|
DATA_DIR: /app/runtime-data
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||||
PORT: "4173"
|
PORT: "4173"
|
||||||
@@ -143,13 +144,15 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/usbssds/apps/healer-man/app:/app
|
- /mnt/usbssds/apps/healer-man/app:/app
|
||||||
- /mnt/usbssds/apps/healer-man/data:/app/data
|
- /mnt/usbssds/apps/healer-man/data:/app/runtime-data
|
||||||
- /mnt/usbssds/apps/healer-man/content:/app/content
|
- /mnt/usbssds/apps/healer-man/content:/app/content
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not remove either persistent mount. `/app/data` owns account/cloud-save data;
|
Do not remove either persistent mount. `/app/runtime-data` owns
|
||||||
`/app/content` owns downloadable manifests and immutable content objects. The
|
account/cloud-save data; `/app/content` owns downloadable manifests and
|
||||||
|
immutable content objects. Never mount a volume at `/app/data`, because that
|
||||||
|
would hide the checkout's required `/app/data/loot/schema.sql`. The
|
||||||
startup command installs locked dependencies, initializes the schema, publishes
|
startup command installs locked dependencies, initializes the schema, publishes
|
||||||
new or changed content objects, builds the full browser bundle, and starts the
|
new or changed content objects, builds the full browser bundle, and starts the
|
||||||
combined static/API/content server. Existing content objects are reused.
|
combined static/API/content server. Existing content objects are reused.
|
||||||
@@ -173,15 +176,64 @@ schema-compatible with Healer Man and must not be copied over the new one.
|
|||||||
|
|
||||||
## Update workflow
|
## Update workflow
|
||||||
|
|
||||||
Push `main` from the development PC. Then run on TrueNAS:
|
For a code update that includes a new APK, choose the next version first. The
|
||||||
|
example below upgrades `0.1.0` to `0.1.1`; replace `2` if it is not the next
|
||||||
|
unused Android `versionCode`. Then run these commands in **Windows PowerShell**
|
||||||
|
on the development PC. Stop if any test or build fails:
|
||||||
|
|
||||||
```sh
|
```powershell
|
||||||
git -C /mnt/usbssds/apps/healer-man/app pull --ff-only \
|
Set-Location F:\Projects\HealerMan
|
||||||
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/healer-man.git \
|
|
||||||
main
|
$VersionName = '0.1.1'
|
||||||
|
$VersionCode = 2
|
||||||
|
npm version $VersionName --no-git-tag-version
|
||||||
|
|
||||||
|
npm run content:test
|
||||||
|
npm run server:test
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
npm test -- --run src/avatar
|
||||||
|
npx vitest run src/game/inputManager.test.ts src/game/inputMath.test.ts
|
||||||
|
|
||||||
|
git status --short
|
||||||
|
git diff --check
|
||||||
|
git add -A
|
||||||
|
git status --short
|
||||||
|
git diff --cached --stat
|
||||||
|
git diff --cached --check
|
||||||
|
git commit -m "Release Healer Man $VersionName"
|
||||||
|
git push origin main
|
||||||
|
git rev-parse HEAD
|
||||||
```
|
```
|
||||||
|
|
||||||
Restart `healer-man` in the TrueNAS Apps UI afterward. Container startup
|
Before `git commit`, read the staged file list printed by `git status --short`.
|
||||||
|
Do not commit a `.env` file, password, API token, keystore, APK, database,
|
||||||
|
`content/`, `dist/`, `dist-android/`, or `.android-public/` output. The final
|
||||||
|
command prints the exact deployed commit ID.
|
||||||
|
|
||||||
|
Next, open the TrueNAS Apps UI and **stop** `healer-man`. Open a TrueNAS shell
|
||||||
|
and run these exact commands:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo mkdir -p /mnt/usbssds/apps/healer-man/backups
|
||||||
|
if sudo test -f /mnt/usbssds/apps/healer-man/data/game.db; then
|
||||||
|
sudo cp -p \
|
||||||
|
/mnt/usbssds/apps/healer-man/data/game.db \
|
||||||
|
/mnt/usbssds/apps/healer-man/backups/game-$(date +%Y%m%d-%H%M%S).db
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo git config --global --add safe.directory \
|
||||||
|
/mnt/usbssds/apps/healer-man/app
|
||||||
|
sudo git -C /mnt/usbssds/apps/healer-man/app pull --ff-only \
|
||||||
|
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/healer-man.git \
|
||||||
|
main
|
||||||
|
sudo chown -R truenas_admin:truenas_admin \
|
||||||
|
/mnt/usbssds/apps/healer-man/app
|
||||||
|
sudo git -C /mnt/usbssds/apps/healer-man/app rev-parse HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
The TrueNAS commit ID must match the one printed on the development PC. Start
|
||||||
|
`healer-man` in the TrueNAS Apps UI afterward. Container startup
|
||||||
publishes immutable content objects and replaces `manifest.json` only after all
|
publishes immutable content objects and replaces `manifest.json` only after all
|
||||||
objects exist, then rebuilds the browser client and starts the server. Android
|
objects exist, then rebuilds the browser client and starts the server. Android
|
||||||
devices discover the small new manifest and download only missing or changed
|
devices discover the small new manifest and download only missing or changed
|
||||||
@@ -189,6 +241,14 @@ files. Asset-only updates do not require a new APK. A failed fast-forward pull
|
|||||||
deliberately leaves the running checkout unchanged; resolve diverged history on
|
deliberately leaves the running checkout unchanged; resolve diverged history on
|
||||||
the development machine rather than forcing the TrueNAS checkout.
|
the development machine rather than forcing the TrueNAS checkout.
|
||||||
|
|
||||||
|
After the logs say the server is listening on port `4173`, run on TrueNAS:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -fI http://127.0.0.1:4173/
|
||||||
|
curl -fsS http://127.0.0.1:4173/api/health
|
||||||
|
curl -fsS http://127.0.0.1:4173/content/manifest.json | head -c 500
|
||||||
|
```
|
||||||
|
|
||||||
To publish content manually without restarting the app container, run inside a
|
To publish content manually without restarting the app container, run inside a
|
||||||
Node 24 environment that mounts the checkout and content volume:
|
Node 24 environment that mounts the checkout and content volume:
|
||||||
|
|
||||||
@@ -222,11 +282,11 @@ Install JDK 21 and Android SDK Platform 36 on the development PC and set
|
|||||||
`JAVA_HOME` plus `ANDROID_SDK_ROOT`. The build wrapper also recognizes the
|
`JAVA_HOME` plus `ANDROID_SDK_ROOT`. The build wrapper also recognizes the
|
||||||
project's existing portable JDK/SDK layout when present.
|
project's existing portable JDK/SDK layout when present.
|
||||||
|
|
||||||
For each Android version, push `main` first. Then set the permanent signing-key
|
For each Android version, complete the exact update workflow above first. Then
|
||||||
secrets and Gitea token in the development shell:
|
set the permanent signing-key secrets and Gitea token in PowerShell:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:ANDROID_KEYSTORE_FILE = 'D:\secure\healer-man-release.jks'
|
$env:ANDROID_KEYSTORE_FILE = 'F:\secure\healer-man-release.jks'
|
||||||
$env:ANDROID_KEYSTORE_PASSWORD = '...'
|
$env:ANDROID_KEYSTORE_PASSWORD = '...'
|
||||||
$env:ANDROID_KEY_ALIAS = 'healer-man'
|
$env:ANDROID_KEY_ALIAS = 'healer-man'
|
||||||
$env:ANDROID_KEY_PASSWORD = '...'
|
$env:ANDROID_KEY_PASSWORD = '...'
|
||||||
|
|||||||
@@ -4,6 +4,71 @@ This file is the durable record of runtime behavior, regressions, and the
|
|||||||
evidence required before calling a visual bug fixed. Update it whenever scene,
|
evidence required before calling a visual bug fixed. Update it whenever scene,
|
||||||
camera, input, physics, mob-rendering, or asset-loading code changes.
|
camera, input, physics, mob-rendering, or asset-loading code changes.
|
||||||
|
|
||||||
|
## 2026-08-16 responsive jump and natural teammate movement - COMPLETE
|
||||||
|
|
||||||
|
- Added the grounded single-jump path for Space and controller L3, including a
|
||||||
|
`7.2 m/s` launch, `100 ms` coyote time, `120 ms` input buffering, landing
|
||||||
|
lockout, upward-normal ground filtering, and blocked-input cancellation.
|
||||||
|
Player physics and the latest grounded navigation anchor are now reported
|
||||||
|
separately, so party breadcrumbs and recalls do not follow an airborne
|
||||||
|
player position.
|
||||||
|
- Player locomotion now presents rising, falling, and landing phases. The live
|
||||||
|
WoW human used its native jump pose while preserving the visible body and
|
||||||
|
equipped weapon; RuneWaker and fallback clip selection is covered by the
|
||||||
|
focused animation suite.
|
||||||
|
- Party members now derive stable movement personalities from member ID and
|
||||||
|
living-party rank. The runtime applies personalized trail distance, lateral
|
||||||
|
offset, comfort slack, speed, slow sway, stop/start hysteresis, combat
|
||||||
|
bearing, preferred range, and ally separation only when the candidate path
|
||||||
|
passes collision and floor-continuity checks. Breadcrumb centerline routing,
|
||||||
|
Stop, stuck recovery, role decisions, and blocked-route reporting remain the
|
||||||
|
fallback behavior.
|
||||||
|
- Focused verification passed 17 files / 117 tests:
|
||||||
|
`playerJump`, `inputManager`, `inputMath`, `store`, `combatAnimation`,
|
||||||
|
`partyMovement`, `partyNavigation`, `partyPathing`, `partyRuntime`, and the
|
||||||
|
complete `src/avatar` suite. The first full-suite attempts exposed a
|
||||||
|
pre-existing load-sensitive `30 s` timeout in the heavyweight RuneWaker
|
||||||
|
animation audit; the isolated test passed in `6.5 s`. Its two asset-audit
|
||||||
|
cases now use `60 s`, matching their actual full-suite workload more closely.
|
||||||
|
- Final `npm test`: **114 files / 656 tests passed**. Final `npm run build`:
|
||||||
|
**passed**, including TypeScript, 954 transformed Vite modules, and the KTX2
|
||||||
|
audit of 11,839 compressed textures with zero fallbacks.
|
||||||
|
- Production preview was exercised at `http://127.0.0.1:4174/`. At desktop
|
||||||
|
`1440 x 900`, Space produced a visible jump from rest and while moving; the
|
||||||
|
running capture shows the native raised-arm jump pose. Space pressed while
|
||||||
|
paused did not launch on resume. Five rapid Space presses during one airborne
|
||||||
|
cycle returned to a normal grounded pose rather than producing a double jump.
|
||||||
|
The entrance smoke route covered its sloped floor and low cavern roof without
|
||||||
|
visible clipping or a hidden avatar.
|
||||||
|
- In Defend, the companions settled at visibly different longitudinal and
|
||||||
|
lateral offsets. In Attack, tank, ranged, and melee actors held distinct
|
||||||
|
bearings; Brynn peeled to the right while the other roles retained separate
|
||||||
|
safe positions. Party health and combat activity continued updating. Stop and
|
||||||
|
Recall were then issued from the Thor companion display and the authoritative
|
||||||
|
command state updated correctly.
|
||||||
|
- The browser Thor preview displayed both the `960 x 540` main surface and
|
||||||
|
`620 x 540` companion surface. Entering as healer from the companion display,
|
||||||
|
Attack/Stop/Recall dispatch, Space jumping on the main display, and
|
||||||
|
right-mouse input with no pointer lock all worked. Runtime inspection found
|
||||||
|
one visible canvas, no loading screen, and no scene-error overlay.
|
||||||
|
- Console capture contains no errors. It contains two occurrences of the
|
||||||
|
project's existing third-party initialization deprecation warning, one per
|
||||||
|
desktop/Thor runtime load. The Vite preview host separately logged the known
|
||||||
|
optional `/content/manifest.json` proxy refusal because the auxiliary content
|
||||||
|
server on port 4173 was not running; the packaged dungeon still loaded and
|
||||||
|
played normally.
|
||||||
|
- Evidence is under `playtest-artifacts/2026-08-16-jump-party/`:
|
||||||
|
`desktop-running-jump-preview.png`, `desktop-varied-follow-preview.png`,
|
||||||
|
`desktop-varied-combat-preview.png`, `desktop-pause-jump-blocked-preview.png`,
|
||||||
|
`thor-jump-preview.png`, `thor-varied-combat-preview.png`,
|
||||||
|
`thor-repeated-jump-landing-preview.png`, and `preview-console.txt`.
|
||||||
|
- Coverage limits: no physical controller was connected, so L3 edge behavior
|
||||||
|
and removal of L3 clear-target are automated-test verified rather than
|
||||||
|
hardware verified. The entrance smoke route did not provide a deterministic
|
||||||
|
walk-off ledge or narrow doorway fixture; exact coyote timing and lateral
|
||||||
|
centerline fallback remain covered by their pure/navigation tests rather than
|
||||||
|
claimed as live edge-case evidence.
|
||||||
|
|
||||||
## 2026-08-01 all-dungeon v5 animation acceptance pass - COMPLETE
|
## 2026-08-01 all-dungeon v5 animation acceptance pass - COMPLETE
|
||||||
|
|
||||||
- The Blackhorn Silencer and Giant Assassin failures demonstrated that valid
|
- The Blackhorn Silencer and Giant Assassin failures demonstrated that valid
|
||||||
@@ -1182,3 +1247,25 @@ Vite's existing large-chunk advisory remains informational.
|
|||||||
- Evidence reviewed during the browser run: login, roster-preview, Downloads,
|
- Evidence reviewed during the browser run: login, roster-preview, Downloads,
|
||||||
and in-dungeon screenshots. Browser console contained no errors; one existing
|
and in-dungeon screenshots. Browser console contained no errors; one existing
|
||||||
third-party initialization deprecation warning was present.
|
third-party initialization deprecation warning was present.
|
||||||
|
|
||||||
|
# 2026-08-16 — CoA character-creation category
|
||||||
|
|
||||||
|
- Opened the offline roster and entered character creation in the live Vite
|
||||||
|
client. The header rendered distinct **WoW**, **RoM**, and **CoA** tabs without
|
||||||
|
clipping.
|
||||||
|
- The WoW tab showed only the eight standard WoW classes available to Human;
|
||||||
|
no custom CoA class appeared.
|
||||||
|
- The CoA tab reused the Azeroth race roster and showed the 16 custom CoA
|
||||||
|
classes available to Human, defaulting to Barbarian. The character body and
|
||||||
|
starter weapon silhouette remained visible while switching categories.
|
||||||
|
- In the production preview, the saved Human Priest rendered in the roster with
|
||||||
|
her full body and starter staff silhouette, then entered Wailing Caverns with
|
||||||
|
her body, staff, party, dungeon environment, and HUD visible.
|
||||||
|
- Browser diagnostics contained no errors.
|
||||||
|
- Evidence: `playtest-artifacts/character-create-wow-category-2026-08-16.png`
|
||||||
|
`playtest-artifacts/character-create-coa-category-2026-08-16.png`,
|
||||||
|
`playtest-artifacts/character-category-roster-2026-08-16.png`, and
|
||||||
|
`playtest-artifacts/character-category-dungeon-2026-08-16.png`.
|
||||||
|
- Verification: 71 focused catalog/store/repository/dungeon/ability/party tests
|
||||||
|
passed, all 28 avatar tests passed, and `npm run build` completed
|
||||||
|
successfully.
|
||||||
|
|||||||
@@ -129,12 +129,13 @@ an accidentally reintroduced PNG/JPEG world texture fails before deployment.
|
|||||||
## Controls
|
## Controls
|
||||||
|
|
||||||
- Move: WASD, arrow keys, or left stick
|
- Move: WASD, arrow keys, or left stick
|
||||||
|
- Jump: Space or L3
|
||||||
- Look: hold the right mouse button for mouse look, or use right stick
|
- Look: hold the right mouse button for mouse look, or use right stick
|
||||||
- Base abilities: keyboard 1–8; controller Cross, Square, Triangle, Circle,
|
- Base abilities: keyboard 1–8; controller Cross, Square, Triangle, Circle,
|
||||||
R1, R2, D-pad Left, and D-pad Right
|
R1, R2, D-pad Left, and D-pad Right
|
||||||
- Secondary abilities: hold Shift or L1 while pressing an ability control
|
- Secondary abilities: hold Shift or L1 while pressing an ability control
|
||||||
- Tertiary abilities: hold Alt or L2 while pressing an ability control
|
- Tertiary abilities: hold Alt or L2 while pressing an ability control
|
||||||
- Hostile target: Tab or R3; clear targets with L3
|
- Hostile target: Tab or R3
|
||||||
- Friendly target: click a party/player frame or use D-pad Up/Down to cycle
|
- Friendly target: click a party/player frame or use D-pad Up/Down to cycle
|
||||||
living allies and yourself
|
living allies and yourself
|
||||||
- Party orders: F1 Attack, F2 Defend, F3 Stop
|
- Party orders: F1 Attack, F2 Defend, F3 Stop
|
||||||
|
|||||||
@@ -0,0 +1,606 @@
|
|||||||
|
# Healer Man update and release runbook
|
||||||
|
|
||||||
|
Use this checklist whenever game changes need to be pushed to Gitea, deployed
|
||||||
|
to the TrueNAS server, and released as an Obtainium-compatible APK.
|
||||||
|
|
||||||
|
The examples below use Android version `0.1.1` and `versionCode` `2`. Replace
|
||||||
|
both with the next values for the release you are making. Every APK must use a
|
||||||
|
larger `versionCode` than every APK previously released.
|
||||||
|
|
||||||
|
## What requires a new APK?
|
||||||
|
|
||||||
|
Use the full procedure in this document when the update changes any of these:
|
||||||
|
|
||||||
|
- React, TypeScript, JavaScript, gameplay, UI, shaders, or asset-loading code.
|
||||||
|
- Capacitor or native Android code, permissions, or configuration.
|
||||||
|
- The downloadable-content manifest format or compatibility rules.
|
||||||
|
- Starter content that must work immediately after a fresh offline install.
|
||||||
|
|
||||||
|
For an asset-only update, such as a compatible GLB, texture, audio file, or data
|
||||||
|
file, use the shorter procedure at the end. Those updates can be published by
|
||||||
|
the server and downloaded by an already-installed APK without an Obtainium
|
||||||
|
release.
|
||||||
|
|
||||||
|
Single-player remains offline-capable. A fresh APK includes the runtime and
|
||||||
|
starter content. Additional content that a player has downloaded is retained
|
||||||
|
in Android app-private storage and can be used offline after download.
|
||||||
|
|
||||||
|
## Fixed locations
|
||||||
|
|
||||||
|
```text
|
||||||
|
Development checkout:
|
||||||
|
F:\Projects\HealerMan
|
||||||
|
|
||||||
|
Gitea repository:
|
||||||
|
https://git.whoagland.com/phenom/healer-man
|
||||||
|
|
||||||
|
Git push endpoint:
|
||||||
|
ssh://git@192.168.1.180:30009/phenom/healer-man.git
|
||||||
|
|
||||||
|
TrueNAS working checkout:
|
||||||
|
/mnt/usbssds/apps/healer-man/app
|
||||||
|
|
||||||
|
TrueNAS bare Gitea repository:
|
||||||
|
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/healer-man.git
|
||||||
|
|
||||||
|
Persistent database:
|
||||||
|
/mnt/usbssds/apps/healer-man/data/game.db
|
||||||
|
|
||||||
|
Published downloadable content:
|
||||||
|
/mnt/usbssds/apps/healer-man/content
|
||||||
|
|
||||||
|
Public game URL:
|
||||||
|
https://iwanttoheal.phenomrom.com
|
||||||
|
```
|
||||||
|
|
||||||
|
The commands in the Windows sections are PowerShell commands. The commands in
|
||||||
|
the TrueNAS sections are shell commands entered after signing in to TrueNAS.
|
||||||
|
|
||||||
|
## One-time release setup
|
||||||
|
|
||||||
|
These items only need to be configured once on the development PC.
|
||||||
|
|
||||||
|
### 1. Confirm Git uses LAN SSH for pushes
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location F:\Projects\HealerMan
|
||||||
|
git remote -v
|
||||||
|
ssh -T -p 30009 git@192.168.1.180
|
||||||
|
```
|
||||||
|
|
||||||
|
The SSH test should say that Gitea authenticated you as `phenom`. The current
|
||||||
|
repository is configured to fetch over HTTPS and push over LAN SSH.
|
||||||
|
|
||||||
|
If the push URL ever needs to be restored, run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git remote set-url --push origin `
|
||||||
|
ssh://git@192.168.1.180:30009/phenom/healer-man.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install the Android build requirements
|
||||||
|
|
||||||
|
Install JDK 21 and Android SDK Platform 36. Configure their actual paths, then
|
||||||
|
verify them:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:JAVA_HOME = 'C:\path\to\jdk-21'
|
||||||
|
$env:ANDROID_SDK_ROOT = "$env:LOCALAPPDATA\Android\Sdk"
|
||||||
|
& "$env:JAVA_HOME\bin\java.exe" -version
|
||||||
|
Test-Path "$env:ANDROID_SDK_ROOT\platforms\android-36\android.jar"
|
||||||
|
```
|
||||||
|
|
||||||
|
The last command must return `True`. The build wrapper can also use the portable
|
||||||
|
JDK and Android SDK locations described in `scripts/runAndroidGradle.ps1`.
|
||||||
|
|
||||||
|
### 3. Create and protect one permanent signing key
|
||||||
|
|
||||||
|
Skip this step if the permanent Healer Man release key already exists.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
New-Item -ItemType Directory -Force F:\secure
|
||||||
|
keytool -genkeypair -v `
|
||||||
|
-keystore F:\secure\healer-man-release.jks `
|
||||||
|
-alias healer-man `
|
||||||
|
-keyalg RSA `
|
||||||
|
-keysize 4096 `
|
||||||
|
-validity 10000
|
||||||
|
```
|
||||||
|
|
||||||
|
Back up the keystore and its passwords somewhere secure. Do not put them in the
|
||||||
|
repository. Android will not install a future update over the existing game if
|
||||||
|
it is signed with a different key.
|
||||||
|
|
||||||
|
### 4. Create a Gitea access token
|
||||||
|
|
||||||
|
In Gitea, create an access token for `phenom` that can create repository
|
||||||
|
releases and upload attachments to `phenom/healer-man`. Store it in a password
|
||||||
|
manager. Never add the token to a file in the repository or distribute it to
|
||||||
|
players.
|
||||||
|
|
||||||
|
Obtainium must be able to read the release without your Gitea credentials.
|
||||||
|
Either make `phenom/healer-man` publicly readable or publish the APK to a
|
||||||
|
separate public release-only repository. Gitea release visibility follows the
|
||||||
|
repository's access rules.
|
||||||
|
|
||||||
|
### 5. Confirm the TrueNAS app has the correct data mount
|
||||||
|
|
||||||
|
The persistent database must be mounted at `/app/runtime-data`. Do **not** mount
|
||||||
|
anything at `/app/data`, because `/app/data/loot/schema.sql` is source data that
|
||||||
|
the build needs. The complete known-good YAML is in the appendix.
|
||||||
|
|
||||||
|
## Full release: Git, server, and APK
|
||||||
|
|
||||||
|
### Step 1: Open the project and inspect every change
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location F:\Projects\HealerMan
|
||||||
|
git status --short --branch
|
||||||
|
git diff --stat
|
||||||
|
git diff
|
||||||
|
```
|
||||||
|
|
||||||
|
Check that every listed file belongs to this update. Do not use `git reset
|
||||||
|
--hard` or discard unfamiliar changes. Before continuing, make sure the project
|
||||||
|
contains no passwords, Gitea tokens, `.env` files, keystores, database files, or
|
||||||
|
other secrets.
|
||||||
|
|
||||||
|
### Step 2: Select the Android version
|
||||||
|
|
||||||
|
Choose both values before building:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$VersionName = '0.1.1'
|
||||||
|
$VersionCode = 2
|
||||||
|
```
|
||||||
|
|
||||||
|
- `VersionName` is the player-facing semantic version.
|
||||||
|
- `VersionCode` is Android's integer update counter. It must always increase.
|
||||||
|
- Record the last used `versionCode` in the Gitea release notes or your release
|
||||||
|
records so it is never accidentally reused.
|
||||||
|
|
||||||
|
Update `package.json` and `package-lock.json` to the same version:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm version $VersionName --no-git-tag-version
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not create a local Git tag with `npm version`; the release script creates the
|
||||||
|
Gitea tag after the signed APK builds successfully.
|
||||||
|
|
||||||
|
### Step 3: Run automated validation
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run content:test
|
||||||
|
npm run server:test
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
All four commands must succeed.
|
||||||
|
|
||||||
|
Apply these additional targeted checks whenever the matching area was edited:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Character, gear, appearance, weapon, or attachment changes:
|
||||||
|
npm test -- --run src/avatar
|
||||||
|
|
||||||
|
# Camera or desktop input changes:
|
||||||
|
npx vitest run src/game/inputManager.test.ts src/game/inputMath.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the game locally and verify:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- Check the roster preview and an in-dungeon character. Confirm that bodies,
|
||||||
|
weapons, and equipped silhouettes render, and record screenshot evidence in
|
||||||
|
`PLAYTEST_NOTES.md`.
|
||||||
|
- Hold and drag the right mouse button in the 3D scene. Confirm the camera moves,
|
||||||
|
releasing the button stops it, no browser capture prompt appears, and
|
||||||
|
`document.pointerLockElement` remains `null`.
|
||||||
|
- Exercise the gameplay, UI, and content changed by this release.
|
||||||
|
- Stop the development server with `Ctrl+C` when finished.
|
||||||
|
|
||||||
|
### Step 4: Review and commit the release
|
||||||
|
|
||||||
|
First check for whitespace errors and review the final file list:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git diff --check
|
||||||
|
git status --short
|
||||||
|
git diff --stat
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage the intended release:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git add -A
|
||||||
|
git status --short
|
||||||
|
git diff --cached --stat
|
||||||
|
git diff --cached --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not continue if the staged list contains a keystore, APK, `.env` file,
|
||||||
|
database, `content/`, `dist/`, `dist-android/`, or `.android-public/` output.
|
||||||
|
Unstage any accidental file with `git restore --staged -- <path>` without
|
||||||
|
deleting the local file.
|
||||||
|
|
||||||
|
Commit only after the staged set is correct:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git commit -m "Release Healer Man $VersionName"
|
||||||
|
$ReleaseCommit = git rev-parse HEAD
|
||||||
|
$ReleaseCommit
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the printed commit ID so it can be compared with TrueNAS.
|
||||||
|
|
||||||
|
### Step 5: Push `main` to Gitea
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git push origin main
|
||||||
|
git status --short --branch
|
||||||
|
git ls-remote origin refs/heads/main
|
||||||
|
```
|
||||||
|
|
||||||
|
The push must finish successfully and the remote `main` hash must equal
|
||||||
|
`$ReleaseCommit`. Open the repository in Gitea and confirm the new commit is at
|
||||||
|
the top of `main`.
|
||||||
|
|
||||||
|
If SSH authentication fails, test it again:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
ssh -T -p 30009 git@192.168.1.180
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not switch this large repository back to an HTTPS push just to work around an
|
||||||
|
error; its large initial pack previously exceeded the HTTPS proxy connection.
|
||||||
|
|
||||||
|
### Step 6: Stop and back up the TrueNAS app
|
||||||
|
|
||||||
|
In the TrueNAS Apps UI, stop the `healer-man` app. This prevents the running
|
||||||
|
container from reading files while its checkout changes.
|
||||||
|
|
||||||
|
Then sign in to a TrueNAS shell and create a point-in-time SQLite copy:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo mkdir -p /mnt/usbssds/apps/healer-man/backups
|
||||||
|
if sudo test -f /mnt/usbssds/apps/healer-man/data/game.db; then
|
||||||
|
sudo cp -p \
|
||||||
|
/mnt/usbssds/apps/healer-man/data/game.db \
|
||||||
|
/mnt/usbssds/apps/healer-man/backups/game-$(date +%Y%m%d-%H%M%S).db
|
||||||
|
fi
|
||||||
|
df -h /mnt/usbssds/apps/healer-man
|
||||||
|
```
|
||||||
|
|
||||||
|
A TrueNAS dataset snapshot is recommended in addition to the file copy,
|
||||||
|
especially before a database migration.
|
||||||
|
|
||||||
|
### Step 7: Pull the release into the TrueNAS working checkout
|
||||||
|
|
||||||
|
The safe-directory commands are normally one-time setup, but running them again
|
||||||
|
is harmless:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo git config --global --add safe.directory \
|
||||||
|
/mnt/usbssds/apps/healer-man/app
|
||||||
|
sudo git config --global --add safe.directory \
|
||||||
|
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/healer-man.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Pull from the local Gitea bare repository:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo git -C /mnt/usbssds/apps/healer-man/app pull --ff-only \
|
||||||
|
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/healer-man.git \
|
||||||
|
main
|
||||||
|
sudo chown -R truenas_admin:truenas_admin \
|
||||||
|
/mnt/usbssds/apps/healer-man/app
|
||||||
|
sudo git -C /mnt/usbssds/apps/healer-man/app rev-parse HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
The final commit ID must match `$ReleaseCommit` from the development PC. Always
|
||||||
|
use `sudo` for this pull: `truenas_admin` cannot read the bare Gitea repository
|
||||||
|
directly.
|
||||||
|
|
||||||
|
### Step 8: Start the TrueNAS app and watch startup
|
||||||
|
|
||||||
|
Start `healer-man` in the TrueNAS Apps UI, then open its logs. A successful
|
||||||
|
startup performs these actions in order:
|
||||||
|
|
||||||
|
1. Installs the locked dependencies.
|
||||||
|
2. Initializes `/app/runtime-data/game.db`.
|
||||||
|
3. Publishes the content manifest and immutable objects to `/app/content`.
|
||||||
|
4. Generates the loot catalog and builds the browser client.
|
||||||
|
5. Starts the combined web/API/content server on port `4173`.
|
||||||
|
|
||||||
|
The deprecation, funding, and `npm audit` lines are warnings; they do not by
|
||||||
|
themselves close the server. Continue until the logs show the server listening.
|
||||||
|
|
||||||
|
If the logs report this path, the YAML is still wrong:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ENOENT: no such file or directory, open '/app/data/loot/schema.sql'
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the database volume and `DATA_DIR` to `/app/runtime-data` using the YAML
|
||||||
|
in the appendix, then redeploy the app.
|
||||||
|
|
||||||
|
### Step 9: Verify the live server
|
||||||
|
|
||||||
|
From the TrueNAS shell:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -fI http://127.0.0.1:4173/
|
||||||
|
curl -fsS http://127.0.0.1:4173/api/health
|
||||||
|
curl -fsS http://127.0.0.1:4173/content/manifest.json | head -c 500
|
||||||
|
```
|
||||||
|
|
||||||
|
From the development PC or another LAN machine:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
curl.exe -fI https://iwanttoheal.phenomrom.com/
|
||||||
|
curl.exe -fsS https://iwanttoheal.phenomrom.com/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Also open the public URL in a browser. Confirm the game loads, login works, and
|
||||||
|
the changed gameplay is present. If this release includes new or changed assets,
|
||||||
|
confirm the client sees the new content manifest and can retrieve one of those
|
||||||
|
assets.
|
||||||
|
|
||||||
|
### Step 10: Set release secrets for this PowerShell session
|
||||||
|
|
||||||
|
Set the permanent keystore path and alias:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location F:\Projects\HealerMan
|
||||||
|
$env:ANDROID_KEYSTORE_FILE = 'F:\secure\healer-man-release.jks'
|
||||||
|
$env:ANDROID_KEY_ALIAS = 'healer-man'
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the passwords and token without displaying them or placing them in command
|
||||||
|
history:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:ANDROID_KEYSTORE_PASSWORD = [System.Net.NetworkCredential]::new(
|
||||||
|
'',
|
||||||
|
(Read-Host 'Keystore password' -AsSecureString)
|
||||||
|
).Password
|
||||||
|
$env:ANDROID_KEY_PASSWORD = [System.Net.NetworkCredential]::new(
|
||||||
|
'',
|
||||||
|
(Read-Host 'Key password' -AsSecureString)
|
||||||
|
).Password
|
||||||
|
$env:GITEA_TOKEN = [System.Net.NetworkCredential]::new(
|
||||||
|
'',
|
||||||
|
(Read-Host 'Gitea token' -AsSecureString)
|
||||||
|
).Password
|
||||||
|
```
|
||||||
|
|
||||||
|
If Java and the Android SDK are not configured permanently, set `JAVA_HOME` and
|
||||||
|
`ANDROID_SDK_ROOT` in this same shell now.
|
||||||
|
|
||||||
|
### Step 11: Build, sign, and publish the APK
|
||||||
|
|
||||||
|
Confirm the version variables still contain the intended values:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$VersionName
|
||||||
|
$VersionCode
|
||||||
|
(Get-Content package.json -Raw | ConvertFrom-Json).version
|
||||||
|
```
|
||||||
|
|
||||||
|
The package version must match `$VersionName`. Then run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass `
|
||||||
|
-File scripts/publish-gitea-release.ps1 `
|
||||||
|
-VersionName $VersionName `
|
||||||
|
-VersionCode $VersionCode `
|
||||||
|
-ReleaseNotes "Healer Man $VersionName client update; Android versionCode $VersionCode."
|
||||||
|
```
|
||||||
|
|
||||||
|
The script will:
|
||||||
|
|
||||||
|
1. Build the compact Android web bundle and synchronize Capacitor.
|
||||||
|
2. Build a signed release APK.
|
||||||
|
3. Name it `healer-man-VERSION-release.apk`.
|
||||||
|
4. Create a SHA-256 checksum file.
|
||||||
|
5. Create the Gitea tag and non-draft release.
|
||||||
|
6. Upload the APK and checksum as release attachments.
|
||||||
|
|
||||||
|
Do not interrupt it while Gradle or the upload is running.
|
||||||
|
|
||||||
|
### Step 12: Verify the APK and Obtainium release
|
||||||
|
|
||||||
|
Check the local artifact:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$Apk = "android\app\build\outputs\apk\release\healer-man-$VersionName-release.apk"
|
||||||
|
Get-Item $Apk | Select-Object FullName, Length, LastWriteTime
|
||||||
|
Get-FileHash $Apk -Algorithm SHA256
|
||||||
|
```
|
||||||
|
|
||||||
|
Open:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://git.whoagland.com/phenom/healer-man/releases
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm the release has both attachments:
|
||||||
|
|
||||||
|
```text
|
||||||
|
healer-man-VERSION-release.apk
|
||||||
|
healer-man-VERSION-release.apk.sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
On a test Android device:
|
||||||
|
|
||||||
|
1. Refresh Healer Man in Obtainium.
|
||||||
|
2. Confirm it detects the new version.
|
||||||
|
3. Install the update over the existing app; do not uninstall first.
|
||||||
|
4. Launch once online and allow changed content to download.
|
||||||
|
5. Disconnect Wi-Fi/mobile data and confirm single-player still starts and
|
||||||
|
previously downloaded content remains playable.
|
||||||
|
|
||||||
|
If Obtainium needs manual source settings, use the releases page above, select
|
||||||
|
the HTML source override, and filter APK links with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
healer-man-.*-release\.apk$
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 13: Clear secrets from the shell
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Remove-Item Env:ANDROID_KEYSTORE_PASSWORD -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item Env:ANDROID_KEY_PASSWORD -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item Env:GITEA_TOKEN -ErrorAction SilentlyContinue
|
||||||
|
```
|
||||||
|
|
||||||
|
The keystore path and alias are not passwords, but they may also be cleared if
|
||||||
|
desired.
|
||||||
|
|
||||||
|
## Short procedure for an asset-only update
|
||||||
|
|
||||||
|
Use this only when no APK-owned code, native behavior, starter content, or
|
||||||
|
content compatibility rules changed.
|
||||||
|
|
||||||
|
Do **not** bump the Android version. Run these exact PowerShell commands:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location F:\Projects\HealerMan
|
||||||
|
npm run content:test
|
||||||
|
npm run build
|
||||||
|
git status --short
|
||||||
|
git diff --check
|
||||||
|
git add -A
|
||||||
|
git status --short
|
||||||
|
git diff --cached --stat
|
||||||
|
git diff --cached --check
|
||||||
|
git commit -m "Update Healer Man assets"
|
||||||
|
git push origin main
|
||||||
|
git rev-parse HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the staged file list before committing and stop if a test, build, or Git
|
||||||
|
command fails. Next, stop `healer-man` in the TrueNAS Apps UI and run this exact
|
||||||
|
block in a TrueNAS shell:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo mkdir -p /mnt/usbssds/apps/healer-man/backups
|
||||||
|
if sudo test -f /mnt/usbssds/apps/healer-man/data/game.db; then
|
||||||
|
sudo cp -p \
|
||||||
|
/mnt/usbssds/apps/healer-man/data/game.db \
|
||||||
|
/mnt/usbssds/apps/healer-man/backups/game-$(date +%Y%m%d-%H%M%S).db
|
||||||
|
fi
|
||||||
|
sudo git -C /mnt/usbssds/apps/healer-man/app pull --ff-only \
|
||||||
|
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/healer-man.git \
|
||||||
|
main
|
||||||
|
sudo chown -R truenas_admin:truenas_admin \
|
||||||
|
/mnt/usbssds/apps/healer-man/app
|
||||||
|
sudo git -C /mnt/usbssds/apps/healer-man/app rev-parse HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
Start `healer-man` in the TrueNAS Apps UI. Startup runs `content:publish`, writes
|
||||||
|
new immutable objects, and atomically replaces `manifest.json` after the objects
|
||||||
|
exist. When the logs show the server listening, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -fI http://127.0.0.1:4173/
|
||||||
|
curl -fsS http://127.0.0.1:4173/api/health
|
||||||
|
curl -fsS http://127.0.0.1:4173/content/manifest.json | head -c 500
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally, launch a currently installed APK while online. Confirm it downloads the
|
||||||
|
changed content, then disconnect the device and test the content offline.
|
||||||
|
|
||||||
|
Changing or adding an asset changes the published manifest. Because content is
|
||||||
|
addressed by its hash, an unchanged asset is reused while a changed file becomes
|
||||||
|
a new immutable object. Old clients continue using their active verified
|
||||||
|
manifest until the new download is complete; a partially downloaded update is
|
||||||
|
not activated.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Do not rewrite or force-push shared `main` history. If a source/server release is
|
||||||
|
bad, revert it from the development PC:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location F:\Projects\HealerMan
|
||||||
|
git revert <bad-commit-id>
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Then stop the TrueNAS app, pull the new revert commit with the Step 7 command,
|
||||||
|
and start the app again. Restore a database backup only if the failure changed
|
||||||
|
or damaged persistent data.
|
||||||
|
|
||||||
|
An already-installed Android APK cannot be rolled back in place to a lower
|
||||||
|
`versionCode`. Publish a corrected APK with a new version name and a still-higher
|
||||||
|
`versionCode`.
|
||||||
|
|
||||||
|
## Common failures
|
||||||
|
|
||||||
|
- **TrueNAS says the bare repository is not a Git repository:** run the pull
|
||||||
|
with `sudo`. The normal `truenas_admin` account cannot read Gitea's repository
|
||||||
|
storage.
|
||||||
|
- **`publish-content.mjs` is missing:** the TrueNAS checkout is not at the pushed
|
||||||
|
commit. Compare `git rev-parse HEAD` on both systems and pull again.
|
||||||
|
- **`/app/data/loot/schema.sql` is missing:** a volume is masking source
|
||||||
|
`/app/data`. Apply the corrected YAML below.
|
||||||
|
- **Git HTTPS push fails after uploading a large pack:** keep the configured LAN
|
||||||
|
SSH push URL and test port `30009` authentication.
|
||||||
|
- **APK installs as a separate app or refuses to update:** verify the app ID is
|
||||||
|
still `com.phenomrom.healerman`, use the same signing key, and increase
|
||||||
|
`versionCode`.
|
||||||
|
- **Gitea APK upload returns an HTTP size error:** increase the Gitea/reverse
|
||||||
|
proxy release-upload limit or use Gitea's direct LAN web endpoint with the
|
||||||
|
script's `-GiteaBaseUrl` option. Do not assume `192.168.1.180:8080` is Gitea;
|
||||||
|
verify the actual Gitea web port first.
|
||||||
|
- **Release creation succeeds but attachment upload fails:** do not repeatedly
|
||||||
|
create the same tag. Remove the incomplete release/tag in Gitea or upload the
|
||||||
|
attachments to that release, then retry only after its state is understood.
|
||||||
|
|
||||||
|
## Appendix: known-good TrueNAS YAML
|
||||||
|
|
||||||
|
The TrueNAS app name is `healer-man`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
healerman:
|
||||||
|
image: node:24-bookworm-slim
|
||||||
|
command: >-
|
||||||
|
sh -lc "npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start"
|
||||||
|
environment:
|
||||||
|
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost,https://localhost"
|
||||||
|
CONTENT_DIR: /app/content
|
||||||
|
DATA_DIR: /app/runtime-data
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||||
|
PORT: "4173"
|
||||||
|
SESSION_TTL_DAYS: "30"
|
||||||
|
STATIC_DIR: /app/dist
|
||||||
|
TRUST_PROXY: "true"
|
||||||
|
VITE_API_BASE_URL: "https://iwanttoheal.phenomrom.com"
|
||||||
|
init: true
|
||||||
|
ports:
|
||||||
|
- "4173:4173"
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /mnt/usbssds/apps/healer-man/app:/app
|
||||||
|
- /mnt/usbssds/apps/healer-man/data:/app/runtime-data
|
||||||
|
- /mnt/usbssds/apps/healer-man/content:/app/content
|
||||||
|
working_dir: /app
|
||||||
|
```
|
||||||
|
|
||||||
|
The critical distinction is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Source loot schema: /app/data/loot/schema.sql
|
||||||
|
Persistent database: /app/runtime-data/game.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Never add a volume mounted at `/app/data`.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
"@capacitor/core": "8.4.1",
|
"@capacitor/core": "8.4.1",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|||||||
@@ -124,6 +124,23 @@ describe("local profile repository", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("migrates custom-class profiles into the CoA category", () => {
|
||||||
|
const storage = new MemoryStorage();
|
||||||
|
const offline = createOfflineSession();
|
||||||
|
const character = createCharacter({
|
||||||
|
ownerId: offline.ownerId,
|
||||||
|
name: "Conqueror",
|
||||||
|
categoryId: "wow",
|
||||||
|
raceId: "human",
|
||||||
|
classId: "barbarian",
|
||||||
|
gender: "female",
|
||||||
|
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
|
||||||
|
}, storage).character!;
|
||||||
|
|
||||||
|
expect(character.categoryId).toBe("coa");
|
||||||
|
expect(listCharacters(offline.ownerId, storage)[0]?.categoryId).toBe("coa");
|
||||||
|
});
|
||||||
|
|
||||||
it("persists controller ability bindings per character without changing progression", () => {
|
it("persists controller ability bindings per character without changing progression", () => {
|
||||||
const storage = new MemoryStorage();
|
const storage = new MemoryStorage();
|
||||||
const offline = createOfflineSession();
|
const offline = createOfflineSession();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CharacterProfile, PlayerSession } from "./types";
|
import type { CharacterProfile, PlayerSession } from "./types";
|
||||||
import {
|
import {
|
||||||
contentCategoryForRace,
|
contentCategoryForClass,
|
||||||
isRomClassId,
|
isRomClassId,
|
||||||
type RomClassId,
|
type RomClassId,
|
||||||
} from "./characterCatalog";
|
} from "./characterCatalog";
|
||||||
@@ -112,7 +112,6 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
|||||||
equipment?: unknown;
|
equipment?: unknown;
|
||||||
manastormProgress?: unknown;
|
manastormProgress?: unknown;
|
||||||
settings?: unknown;
|
settings?: unknown;
|
||||||
categoryId?: unknown;
|
|
||||||
secondaryClassId?: unknown;
|
secondaryClassId?: unknown;
|
||||||
actionLoadouts?: unknown;
|
actionLoadouts?: unknown;
|
||||||
};
|
};
|
||||||
@@ -132,9 +131,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
|||||||
.filter(([, abilityId]) => abilityId === null || typeof abilityId === "string")
|
.filter(([, abilityId]) => abilityId === null || typeof abilityId === "string")
|
||||||
.map(([bindingId, abilityId]) => [bindingId, abilityId as string | null]))
|
.map(([bindingId, abilityId]) => [bindingId, abilityId as string | null]))
|
||||||
: {};
|
: {};
|
||||||
const categoryId = legacy.categoryId === "rom" || legacy.categoryId === "wow"
|
const categoryId = contentCategoryForClass(value.classId);
|
||||||
? legacy.categoryId
|
|
||||||
: contentCategoryForRace(value.raceId);
|
|
||||||
const secondaryClassId = categoryId === "rom"
|
const secondaryClassId = categoryId === "rom"
|
||||||
&& isRomClassId(value.classId)
|
&& isRomClassId(value.classId)
|
||||||
&& typeof legacy.secondaryClassId === "string"
|
&& typeof legacy.secondaryClassId === "string"
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import {
|
|||||||
classIconUrl,
|
classIconUrl,
|
||||||
classesForRace,
|
classesForRace,
|
||||||
clampAppearance,
|
clampAppearance,
|
||||||
|
contentCategoryForClass,
|
||||||
normalizeCharacterName,
|
normalizeCharacterName,
|
||||||
randomAppearance,
|
randomAppearance,
|
||||||
supportedGenders,
|
supportedGenders,
|
||||||
totalRaceClassCombinations,
|
totalRaceClassCombinations,
|
||||||
validateCharacterName,
|
validateCharacterName,
|
||||||
} from "./characterCatalog";
|
} from "./characterCatalog";
|
||||||
|
import { CONTENT_CATEGORIES } from "./contentCategories";
|
||||||
|
|
||||||
describe("wow335a character catalog", () => {
|
describe("wow335a character catalog", () => {
|
||||||
it("limits normal play to the live-verified race roster", () => {
|
it("limits normal play to the live-verified race roster", () => {
|
||||||
@@ -34,9 +36,13 @@ describe("wow335a character catalog", () => {
|
|||||||
|
|
||||||
it("matches the extracted Ascension playable catalog", () => {
|
it("matches the extracted Ascension playable catalog", () => {
|
||||||
const wowRaces = RACES.filter((race) => race.categoryId !== "rom");
|
const wowRaces = RACES.filter((race) => race.categoryId !== "rom");
|
||||||
const wowClasses = CLASSES.filter((characterClass) => characterClass.categoryId !== "rom");
|
const azerothClasses = CLASSES.filter((characterClass) => characterClass.categoryId !== "rom");
|
||||||
|
const wowClasses = CLASSES.filter((characterClass) => contentCategoryForClass(characterClass.id) === "wow");
|
||||||
|
const coaClasses = CLASSES.filter((characterClass) => contentCategoryForClass(characterClass.id) === "coa");
|
||||||
expect(wowRaces).toHaveLength(27);
|
expect(wowRaces).toHaveLength(27);
|
||||||
expect(wowClasses).toHaveLength(31);
|
expect(azerothClasses).toHaveLength(31);
|
||||||
|
expect(wowClasses).toHaveLength(10);
|
||||||
|
expect(coaClasses).toHaveLength(21);
|
||||||
expect(COA_CLASS_IDS).toHaveLength(21);
|
expect(COA_CLASS_IDS).toHaveLength(21);
|
||||||
expect(wowRaces.reduce((total, race) => total + classesForRace(race.id).length, 0)).toBe(740);
|
expect(wowRaces.reduce((total, race) => total + classesForRace(race.id).length, 0)).toBe(740);
|
||||||
expect(totalRaceClassCombinations()).toBe(758);
|
expect(totalRaceClassCombinations()).toBe(758);
|
||||||
@@ -48,7 +54,7 @@ describe("wow335a character catalog", () => {
|
|||||||
"Forest Troll", "Taunka", "Northrend Skeleton", "Ice Troll", "Earthen",
|
"Forest Troll", "Taunka", "Northrend Skeleton", "Ice Troll", "Earthen",
|
||||||
"Human Cultist",
|
"Human Cultist",
|
||||||
]);
|
]);
|
||||||
expect(COA_CLASS_IDS.map((id) => wowClasses.find((entry) => entry.id === id)?.name)).toEqual([
|
expect(COA_CLASS_IDS.map((id) => coaClasses.find((entry) => entry.id === id)?.name)).toEqual([
|
||||||
"Barbarian", "Witch Doctor", "Felsworn", "Witch Hunter", "Stormbringer",
|
"Barbarian", "Witch Doctor", "Felsworn", "Witch Hunter", "Stormbringer",
|
||||||
"Knight of Xoroth", "Guardian", "Templar", "Bloodmage", "Ranger",
|
"Knight of Xoroth", "Guardian", "Templar", "Bloodmage", "Ranger",
|
||||||
"Chronomancer", "Necromancer", "Pyromancer", "Cultist", "Starcaller",
|
"Chronomancer", "Necromancer", "Pyromancer", "Cultist", "Starcaller",
|
||||||
@@ -56,16 +62,25 @@ describe("wow335a character catalog", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("separates default WoW, custom CoA, and RoM character creation categories", () => {
|
||||||
|
expect(CONTENT_CATEGORIES.map((category) => category.id)).toEqual(["wow", "rom", "coa"]);
|
||||||
|
expect(classesForRace("human", "wow").map((entry) => entry.id)).toContain("priest");
|
||||||
|
expect(classesForRace("human", "wow").map((entry) => entry.id)).not.toContain("barbarian");
|
||||||
|
expect(classesForRace("human", "coa").map((entry) => entry.id)).toContain("barbarian");
|
||||||
|
expect(classesForRace("human", "coa").map((entry) => entry.id)).not.toContain("priest");
|
||||||
|
expect(classesForRace("rom-human", "rom").map((entry) => entry.id)).toContain("rom-priest");
|
||||||
|
});
|
||||||
|
|
||||||
it("applies the race eligibility rows from CharBaseInfo.dbc", () => {
|
it("applies the race eligibility rows from CharBaseInfo.dbc", () => {
|
||||||
const wowClassCount = CLASSES.filter((characterClass) => characterClass.categoryId !== "rom").length;
|
expect(classesForRace("human", "coa").map((entry) => entry.id)).toContain("barbarian");
|
||||||
expect(classesForRace("human").map((entry) => entry.id)).toContain("barbarian");
|
expect(classesForRace("human", "coa").map((entry) => entry.id)).not.toContain("felsworn");
|
||||||
expect(classesForRace("human").map((entry) => entry.id)).not.toContain("felsworn");
|
expect(classesForRace("draenei", "coa").map((entry) => entry.id)).toContain("felsworn");
|
||||||
expect(classesForRace("draenei").map((entry) => entry.id)).toContain("felsworn");
|
expect(classesForRace("draenei", "coa").map((entry) => entry.id)).toContain("knight-of-xoroth");
|
||||||
expect(classesForRace("draenei").map((entry) => entry.id)).toContain("knight-of-xoroth");
|
expect(classesForRace("troll", "coa").map((entry) => entry.id)).toContain("venomancer");
|
||||||
expect(classesForRace("troll").map((entry) => entry.id)).toContain("venomancer");
|
expect(classesForRace("troll", "coa").map((entry) => entry.id)).not.toContain("knight-of-xoroth");
|
||||||
expect(classesForRace("troll").map((entry) => entry.id)).not.toContain("knight-of-xoroth");
|
expect(classesForRace("goblin", "wow")).toHaveLength(10);
|
||||||
expect(classesForRace("goblin")).toHaveLength(wowClassCount);
|
expect(classesForRace("goblin", "coa")).toHaveLength(21);
|
||||||
expect(classesForRace("human-cultist")).toHaveLength(wowClassCount);
|
expect(classesForRace("human-cultist")).toHaveLength(31);
|
||||||
expect(supportedGenders("vrykul")).toEqual(["male"]);
|
expect(supportedGenders("vrykul")).toEqual(["male"]);
|
||||||
expect(supportedGenders("earthen")).toEqual(["male", "female"]);
|
expect(supportedGenders("earthen")).toEqual(["male", "female"]);
|
||||||
});
|
});
|
||||||
|
|||||||
+41
-25
@@ -408,27 +408,27 @@ export const CLASSES: readonly ClassDefinition[] = [
|
|||||||
{ id: "warlock", dbcId: 9, name: "Warlock", role: "Damage", sigil: "WL", color: "#9482c9", description: "Fel caster commanding curses and summoned demons.", mode: "classic", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Affliction", "Demonology", "Destruction"], archetype: "warlock" },
|
{ id: "warlock", dbcId: 9, name: "Warlock", role: "Damage", sigil: "WL", color: "#9482c9", description: "Fel caster commanding curses and summoned demons.", mode: "classic", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Affliction", "Demonology", "Destruction"], archetype: "warlock" },
|
||||||
{ id: "druid", dbcId: 11, name: "Druid", role: "Tank / Healer / Damage", sigil: "DU", color: "#ff7d0a", description: "Shapeshifter drawing power from the wilds.", mode: "classic", armor: "leather", primaryStat: "agility", resourceSummary: "Mana", specializations: ["Balance", "Feral", "Restoration"], archetype: "druid" },
|
{ id: "druid", dbcId: 11, name: "Druid", role: "Tank / Healer / Damage", sigil: "DU", color: "#ff7d0a", description: "Shapeshifter drawing power from the wilds.", mode: "classic", armor: "leather", primaryStat: "agility", resourceSummary: "Mana", specializations: ["Balance", "Feral", "Restoration"], archetype: "druid" },
|
||||||
|
|
||||||
{ id: "barbarian", dbcId: 12, name: "Barbarian", role: "Damage / Support", sigil: "BA", color: "#8a3303", description: "A brutal melee combatant who cleaves foes and rallies ancestral power.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Energy", specializations: ["Brutality", "Headhunting", "Ancestry"], archetype: "rogue" },
|
{ id: "barbarian", categoryId: "coa", dbcId: 12, name: "Barbarian", role: "Damage / Support", sigil: "BA", color: "#8a3303", description: "A brutal melee combatant who cleaves foes and rallies ancestral power.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Energy", specializations: ["Brutality", "Headhunting", "Ancestry"], archetype: "rogue" },
|
||||||
{ id: "witch-doctor", dbcId: 13, name: "Witch Doctor", role: "Healer / Damage", sigil: "WD", color: "#6eff00", description: "A shadowhunter who mixes hexes, brews, wards, and restorative voodoo.", mode: "conquest", armor: "leather", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Shadowhunting", "Voodoo", "Brewing"], archetype: "shaman" },
|
{ id: "witch-doctor", categoryId: "coa", dbcId: 13, name: "Witch Doctor", role: "Healer / Damage", sigil: "WD", color: "#6eff00", description: "A shadowhunter who mixes hexes, brews, wards, and restorative voodoo.", mode: "conquest", armor: "leather", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Shadowhunting", "Voodoo", "Brewing"], archetype: "shaman" },
|
||||||
{ id: "felsworn", dbcId: 14, name: "Felsworn", role: "Damage / Tank", sigil: "FE", color: "#a330c9", description: "A demon-touched fighter who turns fel fury into relentless offense and defense.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Felfury", specializations: ["Slayer", "Infernal", "Tyrant"], archetype: "rogue" },
|
{ id: "felsworn", categoryId: "coa", dbcId: 14, name: "Felsworn", role: "Damage / Tank", sigil: "FE", color: "#a330c9", description: "A demon-touched fighter who turns fel fury into relentless offense and defense.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Felfury", specializations: ["Slayer", "Infernal", "Tyrant"], archetype: "rogue" },
|
||||||
{ id: "witch-hunter", dbcId: 15, name: "Witch Hunter", role: "Damage / Tank", sigil: "WH", color: "#abd473", description: "An agile inquisitor wielding blades, firearms, traps, and forbidden shadow.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Mana", specializations: ["Boltslinger", "Houndmaster", "Inquisition", "Black Knight"], archetype: "hunter" },
|
{ id: "witch-hunter", categoryId: "coa", dbcId: 15, name: "Witch Hunter", role: "Damage / Tank", sigil: "WH", color: "#abd473", description: "An agile inquisitor wielding blades, firearms, traps, and forbidden shadow.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Mana", specializations: ["Boltslinger", "Houndmaster", "Inquisition", "Black Knight"], archetype: "hunter" },
|
||||||
{ id: "stormbringer", dbcId: 16, name: "Stormbringer", role: "Damage / Support", sigil: "ST", color: "#0070de", description: "An elemental conduit who commands lightning, wind, and violent storms.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Static", specializations: ["Maelstrom", "Lightning", "Wind"], archetype: "shaman" },
|
{ id: "stormbringer", categoryId: "coa", dbcId: 16, name: "Stormbringer", role: "Damage / Support", sigil: "ST", color: "#0070de", description: "An elemental conduit who commands lightning, wind, and violent storms.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Static", specializations: ["Maelstrom", "Lightning", "Wind"], archetype: "shaman" },
|
||||||
{ id: "knight-of-xoroth", dbcId: 17, name: "Knight of Xoroth", role: "Tank / Damage", sigil: "KX", color: "#d64747", description: "A demonic plate fighter fueled by rage, deathfire, and hellish summons.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Rage / Deathfire", specializations: ["Hellfire", "Defiance", "War"], archetype: "death-knight" },
|
{ id: "knight-of-xoroth", categoryId: "coa", dbcId: 17, name: "Knight of Xoroth", role: "Tank / Damage", sigil: "KX", color: "#d64747", description: "A demonic plate fighter fueled by rage, deathfire, and hellish summons.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Rage / Deathfire", specializations: ["Hellfire", "Defiance", "War"], archetype: "death-knight" },
|
||||||
{ id: "guardian", dbcId: 18, name: "Guardian", role: "Tank / Damage / Support", sigil: "GU", color: "#c79c6e", description: "A sword-and-board leader who blocks attacks and inspires allies with banners.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Energy", specializations: ["Gladiator", "Inspiration", "Vanguard"], archetype: "warrior" },
|
{ id: "guardian", categoryId: "coa", dbcId: 18, name: "Guardian", role: "Tank / Damage / Support", sigil: "GU", color: "#c79c6e", description: "A sword-and-board leader who blocks attacks and inspires allies with banners.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Energy", specializations: ["Gladiator", "Inspiration", "Vanguard"], archetype: "warrior" },
|
||||||
{ id: "templar", dbcId: 19, name: "Templar", role: "Damage / Tank", sigil: "TE", color: "#00ff99", description: "A flowing holy martial artist who chains strikes into powerful combos.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Energy / Holy Runes", specializations: ["Zealot", "Oathkeeper", "Crusader"], archetype: "rogue" },
|
{ id: "templar", categoryId: "coa", dbcId: 19, name: "Templar", role: "Damage / Tank", sigil: "TE", color: "#00ff99", description: "A flowing holy martial artist who chains strikes into powerful combos.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Energy / Holy Runes", specializations: ["Zealot", "Oathkeeper", "Crusader"], archetype: "rogue" },
|
||||||
{ id: "bloodmage", dbcId: 20, name: "Bloodmage", role: "Damage / Healer / Tank", sigil: "BM", color: "#cc9900", description: "A sanguine spellblade who spends vitality and embraces the worgen curse.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Health", specializations: ["Sanguine", "Accursed", "Eternal", "Fleshweaver"], archetype: "druid" },
|
{ id: "bloodmage", categoryId: "coa", dbcId: 20, name: "Bloodmage", role: "Damage / Healer / Tank", sigil: "BM", color: "#cc9900", description: "A sanguine spellblade who spends vitality and embraces the worgen curse.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Health", specializations: ["Sanguine", "Accursed", "Eternal", "Fleshweaver"], archetype: "druid" },
|
||||||
{ id: "ranger", dbcId: 21, name: "Ranger", role: "Damage / Support", sigil: "RA", color: "#fff569", description: "A mobile skirmisher equally dangerous with bows or paired melee weapons.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Focus / Archery Points", specializations: ["Archery", "Brigand", "Farstrider"], archetype: "hunter" },
|
{ id: "ranger", categoryId: "coa", dbcId: 21, name: "Ranger", role: "Damage / Support", sigil: "RA", color: "#fff569", description: "A mobile skirmisher equally dangerous with bows or paired melee weapons.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Focus / Archery Points", specializations: ["Archery", "Brigand", "Farstrider"], archetype: "hunter" },
|
||||||
{ id: "chronomancer", dbcId: 22, name: "Chronomancer", role: "Healer / Damage", sigil: "CH", color: "#f2e699", description: "A rule-bending caster who reverses wounds and warps order, chaos, and time.", mode: "conquest", armor: "cloth", primaryStat: "spirit", resourceSummary: "Mana", specializations: ["Infinite", "Time", "Artificer"], archetype: "priest" },
|
{ id: "chronomancer", categoryId: "coa", dbcId: 22, name: "Chronomancer", role: "Healer / Damage", sigil: "CH", color: "#f2e699", description: "A rule-bending caster who reverses wounds and warps order, chaos, and time.", mode: "conquest", armor: "cloth", primaryStat: "spirit", resourceSummary: "Mana", specializations: ["Infinite", "Time", "Artificer"], archetype: "priest" },
|
||||||
{ id: "necromancer", dbcId: 23, name: "Necromancer", role: "Damage", sigil: "NE", color: "#8787ed", description: "A master of frost, plague, and permanent undead minions.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Runic Power", specializations: ["Death", "Rime", "Animation"], archetype: "warlock" },
|
{ id: "necromancer", categoryId: "coa", dbcId: 23, name: "Necromancer", role: "Damage", sigil: "NE", color: "#8787ed", description: "A master of frost, plague, and permanent undead minions.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Runic Power", specializations: ["Death", "Rime", "Animation"], archetype: "warlock" },
|
||||||
{ id: "pyromancer", dbcId: 24, name: "Pyromancer", role: "Damage / Support", sigil: "PY", color: "#ff300f", description: "A fire specialist who incinerates foes, cauterizes allies, and channels dragons.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Incineration", "Flameweaving", "Draconic"], archetype: "mage" },
|
{ id: "pyromancer", categoryId: "coa", dbcId: 24, name: "Pyromancer", role: "Damage / Support", sigil: "PY", color: "#ff300f", description: "A fire specialist who incinerates foes, cauterizes allies, and channels dragons.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Incineration", "Flameweaving", "Draconic"], archetype: "mage" },
|
||||||
{ id: "cultist", dbcId: 25, name: "Cultist", role: "Healer / Damage / Tank", sigil: "CU", color: "#e0c7ff", description: "A versatile servant of the Old Gods wielding eldritch spells and void blades.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Mana / Insanity", specializations: ["Godblade", "Corruption", "Heretic", "Dreadnought"], archetype: "paladin" },
|
{ id: "cultist", categoryId: "coa", dbcId: 25, name: "Cultist", role: "Healer / Damage / Tank", sigil: "CU", color: "#e0c7ff", description: "A versatile servant of the Old Gods wielding eldritch spells and void blades.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Mana / Insanity", specializations: ["Godblade", "Corruption", "Heretic", "Dreadnought"], archetype: "paladin" },
|
||||||
{ id: "starcaller", dbcId: 26, name: "Starcaller", role: "Tank / Damage / Healer", sigil: "SC", color: "#b5ffff", description: "An astral warrior who invokes Elune through blades, bows, and lunar magic.", mode: "conquest", armor: "plate", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Moon Guard", "Sentinel", "Moon Priest", "Warden"], archetype: "paladin" },
|
{ id: "starcaller", categoryId: "coa", dbcId: 26, name: "Starcaller", role: "Tank / Damage / Healer", sigil: "SC", color: "#b5ffff", description: "An astral warrior who invokes Elune through blades, bows, and lunar magic.", mode: "conquest", armor: "plate", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Moon Guard", "Sentinel", "Moon Priest", "Warden"], archetype: "paladin" },
|
||||||
{ id: "sun-cleric", dbcId: 27, name: "Sun Cleric", role: "Damage / Healer / Tank", sigil: "SU", color: "#f58cba", description: "A solar champion who heals with warmth and burns enemies with holy fire.", mode: "conquest", armor: "plate", primaryStat: "intellect", resourceSummary: "Mana / Rage / Solar Power", specializations: ["Piety", "Blessings", "Valkyrie", "Seraphim"], archetype: "paladin" },
|
{ id: "sun-cleric", categoryId: "coa", dbcId: 27, name: "Sun Cleric", role: "Damage / Healer / Tank", sigil: "SU", color: "#f58cba", description: "A solar champion who heals with warmth and burns enemies with holy fire.", mode: "conquest", armor: "plate", primaryStat: "intellect", resourceSummary: "Mana / Rage / Solar Power", specializations: ["Piety", "Blessings", "Valkyrie", "Seraphim"], archetype: "paladin" },
|
||||||
{ id: "tinker", dbcId: 28, name: "Tinker", role: "Damage / Healer", sigil: "TI", color: "#a3a3a3", description: "An inventor armed with guns, ammunition, turrets, gadgets, and mechanical allies.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Energy / Ammunition", specializations: ["Demolition", "Invention", "Mechanics"], archetype: "hunter" },
|
{ id: "tinker", categoryId: "coa", dbcId: 28, name: "Tinker", role: "Damage / Healer", sigil: "TI", color: "#a3a3a3", description: "An inventor armed with guns, ammunition, turrets, gadgets, and mechanical allies.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Energy / Ammunition", specializations: ["Demolition", "Invention", "Mechanics"], archetype: "hunter" },
|
||||||
{ id: "venomancer", dbcId: 29, name: "Venomancer", role: "Tank / Damage / Healer", sigil: "VE", color: "#ff7d0a", description: "A shapeshifting toxin master who spreads venom and hardens into insect forms.", mode: "conquest", armor: "mail", primaryStat: "intellect", resourceSummary: "Mana / Rage", specializations: ["Venom", "Stalking", "Fortitude", "Vizier"], archetype: "shaman" },
|
{ id: "venomancer", categoryId: "coa", dbcId: 29, name: "Venomancer", role: "Tank / Damage / Healer", sigil: "VE", color: "#ff7d0a", description: "A shapeshifting toxin master who spreads venom and hardens into insect forms.", mode: "conquest", armor: "mail", primaryStat: "intellect", resourceSummary: "Mana / Rage", specializations: ["Venom", "Stalking", "Fortitude", "Vizier"], archetype: "shaman" },
|
||||||
{ id: "reaper", dbcId: 30, name: "Reaper", role: "Damage / Tank", sigil: "RE", color: "#c41f3b", description: "A soul-harvesting warrior who moves like a spectre and dominates the dead.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Mana / Souls", specializations: ["Harvest", "Soul", "Domination"], archetype: "death-knight" },
|
{ id: "reaper", categoryId: "coa", dbcId: 30, name: "Reaper", role: "Damage / Tank", sigil: "RE", color: "#c41f3b", description: "A soul-harvesting warrior who moves like a spectre and dominates the dead.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Mana / Souls", specializations: ["Harvest", "Soul", "Domination"], archetype: "death-knight" },
|
||||||
{ id: "primalist", dbcId: 31, name: "Primalist", role: "Damage / Tank / Healer", sigil: "PR", color: "#0d2ed6", description: "A primal shapeshifter who calls beasts, stone, magma, and the Earthmother.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Rage / Mana", specializations: ["Primal", "Geomancy", "Life", "Mountain King"], archetype: "druid" },
|
{ id: "primalist", categoryId: "coa", dbcId: 31, name: "Primalist", role: "Damage / Tank / Healer", sigil: "PR", color: "#0d2ed6", description: "A primal shapeshifter who calls beasts, stone, magma, and the Earthmother.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Rage / Mana", specializations: ["Primal", "Geomancy", "Life", "Mountain King"], archetype: "druid" },
|
||||||
{ id: "runemaster", dbcId: 32, name: "Runemaster", role: "Damage / Tank", sigil: "RU", color: "#40c7eb", description: "A rune-inscribing battlemage who bends elements, portals, and spellblades.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Runes", specializations: ["Runic", "Arcane", "Riftblade"], archetype: "mage" },
|
{ id: "runemaster", categoryId: "coa", dbcId: 32, name: "Runemaster", role: "Damage / Tank", sigil: "RU", color: "#40c7eb", description: "A rune-inscribing battlemage who bends elements, portals, and spellblades.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Runes", specializations: ["Runic", "Arcane", "Riftblade"], archetype: "mage" },
|
||||||
|
|
||||||
{ id: "rom-warrior", categoryId: "rom", dbcId: 1, name: "Warrior", role: "Tank / Damage", sigil: "RW", color: "#b86b3f", description: "A hardened weapon fighter who converts incoming pressure into rage.", mode: "rom", armor: "plate", primaryStat: "strength", resourceSummary: "Rage", specializations: [], archetype: "warrior" },
|
{ id: "rom-warrior", categoryId: "rom", dbcId: 1, name: "Warrior", role: "Tank / Damage", sigil: "RW", color: "#b86b3f", description: "A hardened weapon fighter who converts incoming pressure into rage.", mode: "rom", armor: "plate", primaryStat: "strength", resourceSummary: "Rage", specializations: [], archetype: "warrior" },
|
||||||
{ id: "rom-scout", categoryId: "rom", dbcId: 2, name: "Scout", role: "Damage", sigil: "SC", color: "#8bae58", description: "A mobile ranged hunter who relies on focus, precision, and battlefield control.", mode: "rom", armor: "leather", primaryStat: "agility", resourceSummary: "Focus", specializations: [], archetype: "hunter" },
|
{ id: "rom-scout", categoryId: "rom", dbcId: 2, name: "Scout", role: "Damage", sigil: "SC", color: "#8bae58", description: "A mobile ranged hunter who relies on focus, precision, and battlefield control.", mode: "rom", armor: "leather", primaryStat: "agility", resourceSummary: "Focus", specializations: [], archetype: "hunter" },
|
||||||
@@ -595,21 +595,37 @@ export function isRomClassId(classId: ClassId): classId is RomClassId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function racesForCategory(categoryId: ContentCategoryId): readonly RaceDefinition[] {
|
export function racesForCategory(categoryId: ContentCategoryId): readonly RaceDefinition[] {
|
||||||
return PLAYABLE_RACES.filter((race) => (race.categoryId ?? "wow") === categoryId);
|
return PLAYABLE_RACES.filter((race) => {
|
||||||
|
const raceCategory = race.categoryId ?? "wow";
|
||||||
|
return raceCategory === categoryId || (categoryId === "coa" && raceCategory === "wow");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function classesForRace(raceId: RaceId): readonly ClassDefinition[] {
|
export function raceSupportsCategory(raceId: RaceId, categoryId: ContentCategoryId): boolean {
|
||||||
|
const raceCategory = contentCategoryForRace(raceId);
|
||||||
|
return raceCategory === categoryId || (categoryId === "coa" && raceCategory === "wow");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classesForRace(
|
||||||
|
raceId: RaceId,
|
||||||
|
categoryId?: ContentCategoryId,
|
||||||
|
): readonly ClassDefinition[] {
|
||||||
const race = raceById(raceId);
|
const race = raceById(raceId);
|
||||||
if (isRomRaceId(raceId)) {
|
if (isRomRaceId(raceId)) {
|
||||||
|
if (categoryId && categoryId !== "rom") return [];
|
||||||
const allowed = new Set(ROM_CLASSES_BY_RACE[raceId]);
|
const allowed = new Set(ROM_CLASSES_BY_RACE[raceId]);
|
||||||
return CLASSES.filter((characterClass) => allowed.has(characterClass.id as RomClassId));
|
return CLASSES.filter((characterClass) => allowed.has(characterClass.id as RomClassId));
|
||||||
}
|
}
|
||||||
|
if (categoryId === "rom") return [];
|
||||||
|
const classMatchesCategory = (characterClass: ClassDefinition) => (
|
||||||
|
categoryId ? contentCategoryForClass(characterClass.id) === categoryId : contentCategoryForClass(characterClass.id) !== "rom"
|
||||||
|
);
|
||||||
if (race.unrestrictedClasses) return CLASSES.filter((characterClass) => (
|
if (race.unrestrictedClasses) return CLASSES.filter((characterClass) => (
|
||||||
(characterClass.categoryId ?? "wow") === "wow"
|
classMatchesCategory(characterClass)
|
||||||
));
|
));
|
||||||
const raceDbcId = race.dbcId;
|
const raceDbcId = race.dbcId;
|
||||||
return CLASSES.filter((characterClass) => (
|
return CLASSES.filter((characterClass) => (
|
||||||
(characterClass.categoryId ?? "wow") === "wow"
|
classMatchesCategory(characterClass)
|
||||||
&& RACE_DBC_IDS_BY_CLASS[characterClass.id].includes(raceDbcId)
|
&& RACE_DBC_IDS_BY_CLASS[characterClass.id].includes(raceDbcId)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type ContentCategoryId = "wow" | "rom";
|
export type ContentCategoryId = "wow" | "rom" | "coa";
|
||||||
|
|
||||||
export interface ContentCategoryDefinition {
|
export interface ContentCategoryDefinition {
|
||||||
readonly id: ContentCategoryId;
|
readonly id: ContentCategoryId;
|
||||||
@@ -6,9 +6,13 @@ export interface ContentCategoryDefinition {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared by character creation and dungeon selection. */
|
/** Character-creation categories; other catalogs may expose a supported subset. */
|
||||||
export const CONTENT_CATEGORIES: readonly ContentCategoryDefinition[] = Object.freeze([
|
export const CONTENT_CATEGORIES: readonly ContentCategoryDefinition[] = Object.freeze([
|
||||||
{ id: "wow", label: "WoW", name: "World of Warcraft" },
|
{ id: "wow", label: "WoW", name: "World of Warcraft" },
|
||||||
{ id: "rom", label: "RoM", name: "Runes of Magic" },
|
{ id: "rom", label: "RoM", name: "Runes of Magic" },
|
||||||
|
{ id: "coa", label: "CoA", name: "Conquest of Azeroth" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export function contentCategoryById(id: ContentCategoryId | null | undefined): ContentCategoryDefinition {
|
||||||
|
return CONTENT_CATEGORIES.find((category) => category.id === id) ?? CONTENT_CATEGORIES[0];
|
||||||
|
}
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ describe("shell store", () => {
|
|||||||
expect(useShellStore.getState().draft.gender).toBe("male");
|
expect(useShellStore.getState().draft.gender).toBe("male");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resets the creator to a valid draft when switching between WoW and RoM", () => {
|
it("resets the creator to a valid draft when switching among WoW, RoM, and CoA", () => {
|
||||||
useShellStore.getState().openCreator();
|
useShellStore.getState().openCreator();
|
||||||
useShellStore.getState().updateDraft({ name: "Category Keeper", categoryId: "rom" });
|
useShellStore.getState().updateDraft({ name: "Category Keeper", categoryId: "rom" });
|
||||||
expect(useShellStore.getState().draft).toMatchObject({
|
expect(useShellStore.getState().draft).toMatchObject({
|
||||||
@@ -179,6 +179,16 @@ describe("shell store", () => {
|
|||||||
classId: "rom-champion",
|
classId: "rom-champion",
|
||||||
gender: "male",
|
gender: "male",
|
||||||
});
|
});
|
||||||
|
useShellStore.getState().updateDraft({ categoryId: "coa" });
|
||||||
|
expect(useShellStore.getState().draft).toMatchObject({
|
||||||
|
name: "Categorykeep",
|
||||||
|
categoryId: "coa",
|
||||||
|
raceId: "human",
|
||||||
|
classId: "barbarian",
|
||||||
|
gender: "female",
|
||||||
|
});
|
||||||
|
useShellStore.getState().updateDraft({ classId: "priest" });
|
||||||
|
expect(useShellStore.getState().draft.classId).toBe("barbarian");
|
||||||
useShellStore.getState().updateDraft({ categoryId: "wow" });
|
useShellStore.getState().updateDraft({ categoryId: "wow" });
|
||||||
expect(useShellStore.getState().draft).toMatchObject({
|
expect(useShellStore.getState().draft).toMatchObject({
|
||||||
name: "Categorykeep",
|
name: "Categorykeep",
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ import {
|
|||||||
clampAppearance,
|
clampAppearance,
|
||||||
classById,
|
classById,
|
||||||
classesForRace,
|
classesForRace,
|
||||||
contentCategoryForRace,
|
|
||||||
createDefaultAppearance,
|
createDefaultAppearance,
|
||||||
normalizeCharacterName,
|
normalizeCharacterName,
|
||||||
randomAppearance,
|
randomAppearance,
|
||||||
|
raceSupportsCategory,
|
||||||
racesForCategory,
|
racesForCategory,
|
||||||
supportedGenders,
|
supportedGenders,
|
||||||
validateCharacterName,
|
validateCharacterName,
|
||||||
@@ -100,7 +100,7 @@ function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
|
|||||||
name: "",
|
name: "",
|
||||||
categoryId,
|
categoryId,
|
||||||
raceId,
|
raceId,
|
||||||
classId: categoryId === "rom" ? "rom-priest" : "priest",
|
classId: categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest",
|
||||||
gender: "female",
|
gender: "female",
|
||||||
appearance: createDefaultAppearance(),
|
appearance: createDefaultAppearance(),
|
||||||
rotation: -12,
|
rotation: -12,
|
||||||
@@ -165,16 +165,16 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
|||||||
? { ...defaultDraft(patch.categoryId), name: state.draft.name, rotation: state.draft.rotation }
|
? { ...defaultDraft(patch.categoryId), name: state.draft.name, rotation: state.draft.rotation }
|
||||||
: { ...state.draft, ...patch };
|
: { ...state.draft, ...patch };
|
||||||
if (patch.name !== undefined) next.name = normalizeCharacterName(patch.name);
|
if (patch.name !== undefined) next.name = normalizeCharacterName(patch.name);
|
||||||
if (contentCategoryForRace(next.raceId) !== next.categoryId) {
|
if (!raceSupportsCategory(next.raceId, next.categoryId)) {
|
||||||
const targetRace = racesForCategory(next.categoryId)[0];
|
const targetRace = racesForCategory(next.categoryId)[0];
|
||||||
next.raceId = targetRace.id;
|
next.raceId = targetRace.id;
|
||||||
next.classId = classesForRace(targetRace.id)[0].id;
|
next.classId = classesForRace(targetRace.id, next.categoryId)[0].id;
|
||||||
next.gender = supportedGenders(targetRace.id)[0] ?? "male";
|
next.gender = supportedGenders(targetRace.id)[0] ?? "male";
|
||||||
next.appearance = createDefaultAppearance();
|
next.appearance = createDefaultAppearance();
|
||||||
}
|
}
|
||||||
const availableGenders = supportedGenders(next.raceId);
|
const availableGenders = supportedGenders(next.raceId);
|
||||||
if (!availableGenders.includes(next.gender)) next.gender = availableGenders[0] ?? "male";
|
if (!availableGenders.includes(next.gender)) next.gender = availableGenders[0] ?? "male";
|
||||||
const availableClasses = classesForRace(next.raceId);
|
const availableClasses = classesForRace(next.raceId, next.categoryId);
|
||||||
if (!availableClasses.some((definition) => definition.id === next.classId)) {
|
if (!availableClasses.some((definition) => definition.id === next.classId)) {
|
||||||
next.classId = availableClasses[0].id;
|
next.classId = availableClasses[0].id;
|
||||||
}
|
}
|
||||||
@@ -201,7 +201,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
|||||||
set({ notice: nameError });
|
set({ notice: nameError });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!classesForRace(draft.raceId).some((definition) => definition.id === draft.classId)) {
|
if (!classesForRace(draft.raceId, draft.categoryId).some((definition) => definition.id === draft.classId)) {
|
||||||
set({ notice: "That class is not available to the selected race." });
|
set({ notice: "That class is not available to the selected race." });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,9 +39,11 @@ import { resolveContentUrl } from "../content/contentManager";
|
|||||||
import {
|
import {
|
||||||
selectCharacterBaseClip,
|
selectCharacterBaseClip,
|
||||||
selectCharacterEventClip,
|
selectCharacterEventClip,
|
||||||
|
selectCharacterVerticalClip,
|
||||||
type CharacterCombatAnimationEvent,
|
type CharacterCombatAnimationEvent,
|
||||||
type CharacterBaseMotion,
|
type CharacterBaseMotion,
|
||||||
} from "../game/combatAnimation";
|
} from "../game/combatAnimation";
|
||||||
|
import type { CharacterVerticalMotion } from "../game/playerJump";
|
||||||
import { AvatarErrorBoundary } from "./AvatarErrorBoundary";
|
import { AvatarErrorBoundary } from "./AvatarErrorBoundary";
|
||||||
import {
|
import {
|
||||||
ATTACK_PRESENTATION_DURATION_SECONDS,
|
ATTACK_PRESENTATION_DURATION_SECONDS,
|
||||||
@@ -77,6 +79,7 @@ export interface CharacterModelProps {
|
|||||||
identity: AvatarIdentity | null | undefined;
|
identity: AvatarIdentity | null | undefined;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
movingRef?: MutableRefObject<boolean>;
|
movingRef?: MutableRefObject<boolean>;
|
||||||
|
verticalMotionRef?: MutableRefObject<CharacterVerticalMotion>;
|
||||||
attackPulseRef?: MutableRefObject<number>;
|
attackPulseRef?: MutableRefObject<number>;
|
||||||
animationEventRef?: MutableRefObject<CharacterCombatAnimationEvent | null>;
|
animationEventRef?: MutableRefObject<CharacterCombatAnimationEvent | null>;
|
||||||
onReady?: () => void;
|
onReady?: () => void;
|
||||||
@@ -88,6 +91,8 @@ export interface CharacterModelProps {
|
|||||||
|
|
||||||
const EMPTY_EQUIPMENT: readonly InventoryItem[] = Object.freeze([]);
|
const EMPTY_EQUIPMENT: readonly InventoryItem[] = Object.freeze([]);
|
||||||
|
|
||||||
|
type CharacterLocomotionMotion = CharacterBaseMotion | Exclude<CharacterVerticalMotion, "grounded">;
|
||||||
|
|
||||||
type MappedMaterial = Material & { map?: Texture | null; color?: Color };
|
type MappedMaterial = Material & { map?: Texture | null; color?: Color };
|
||||||
|
|
||||||
function configureMaterial(material: Material): void {
|
function configureMaterial(material: Material): void {
|
||||||
@@ -164,6 +169,7 @@ function LoadedCharacterModel({
|
|||||||
identity,
|
identity,
|
||||||
manifest,
|
manifest,
|
||||||
movingRef,
|
movingRef,
|
||||||
|
verticalMotionRef,
|
||||||
attackPulseRef,
|
attackPulseRef,
|
||||||
animationEventRef,
|
animationEventRef,
|
||||||
onReady,
|
onReady,
|
||||||
@@ -176,6 +182,7 @@ function LoadedCharacterModel({
|
|||||||
identity: AvatarIdentity;
|
identity: AvatarIdentity;
|
||||||
manifest: AvatarManifest;
|
manifest: AvatarManifest;
|
||||||
movingRef?: MutableRefObject<boolean>;
|
movingRef?: MutableRefObject<boolean>;
|
||||||
|
verticalMotionRef?: MutableRefObject<CharacterVerticalMotion>;
|
||||||
attackPulseRef?: MutableRefObject<number>;
|
attackPulseRef?: MutableRefObject<number>;
|
||||||
animationEventRef?: MutableRefObject<CharacterCombatAnimationEvent | null>;
|
animationEventRef?: MutableRefObject<CharacterCombatAnimationEvent | null>;
|
||||||
onReady?: () => void;
|
onReady?: () => void;
|
||||||
@@ -190,7 +197,7 @@ function LoadedCharacterModel({
|
|||||||
const overrideAction = useRef<AnimationAction | null>(null);
|
const overrideAction = useRef<AnimationAction | null>(null);
|
||||||
const overrideTerminal = useRef(false);
|
const overrideTerminal = useRef(false);
|
||||||
const overrideLooping = useRef(false);
|
const overrideLooping = useRef(false);
|
||||||
const currentMotion = useRef<CharacterBaseMotion | null>(null);
|
const currentMotion = useRef<CharacterLocomotionMotion | null>(null);
|
||||||
const observedAttackPulse = useRef(attackPulseRef?.current ?? 0);
|
const observedAttackPulse = useRef(attackPulseRef?.current ?? 0);
|
||||||
const equipmentAttackPulseRef = useRef(attackPulseRef?.current ?? 0);
|
const equipmentAttackPulseRef = useRef(attackPulseRef?.current ?? 0);
|
||||||
const observedAnimationRevision = useRef(animationEventRef?.current?.revision ?? 0);
|
const observedAnimationRevision = useRef(animationEventRef?.current?.revision ?? 0);
|
||||||
@@ -338,19 +345,27 @@ function LoadedCharacterModel({
|
|||||||
?? gltf.animations[0]
|
?? gltf.animations[0]
|
||||||
);
|
);
|
||||||
|
|
||||||
const switchMotion = (motion: CharacterBaseMotion) => {
|
const switchMotion = (motion: CharacterLocomotionMotion, moving: boolean) => {
|
||||||
if (overrideAction.current) return;
|
if (overrideAction.current) return;
|
||||||
if (currentMotion.current === motion) return;
|
if (currentMotion.current === motion) return;
|
||||||
const clip = selectClip(motion);
|
const verticalSelection = motion === "rising" || motion === "falling" || motion === "landing"
|
||||||
|
? selectCharacterVerticalClip(gltf.animations, motion, moving)
|
||||||
|
: null;
|
||||||
|
const fallbackMotion: CharacterBaseMotion = moving ? "run" : "idle";
|
||||||
|
const clip = verticalSelection?.clip ?? selectClip(
|
||||||
|
motion === "idle" || motion === "walk" || motion === "run" ? motion : fallbackMotion,
|
||||||
|
);
|
||||||
if (!clip) return;
|
if (!clip) return;
|
||||||
|
const loop = verticalSelection?.loop ?? true;
|
||||||
const next = mixer.clipAction(clip, scene);
|
const next = mixer.clipAction(clip, scene);
|
||||||
if (currentAction.current !== next) {
|
next.enabled = true;
|
||||||
next.enabled = true;
|
next.clampWhenFinished = !loop;
|
||||||
next.clampWhenFinished = false;
|
next.setLoop(loop ? LoopRepeat : LoopOnce, loop ? Number.POSITIVE_INFINITY : 1)
|
||||||
next.setLoop(LoopRepeat, Number.POSITIVE_INFINITY).reset().fadeIn(0.16).play();
|
.reset()
|
||||||
currentAction.current?.fadeOut(0.16);
|
.fadeIn(0.16)
|
||||||
currentAction.current = next;
|
.play();
|
||||||
}
|
if (currentAction.current !== next) currentAction.current?.fadeOut(0.16);
|
||||||
|
currentAction.current = next;
|
||||||
currentMotion.current = motion;
|
currentMotion.current = motion;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -415,7 +430,7 @@ function LoadedCharacterModel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
switchMotion("idle");
|
switchMotion("idle", false);
|
||||||
return () => {
|
return () => {
|
||||||
mixer.stopAllAction();
|
mixer.stopAllAction();
|
||||||
mixer.uncacheRoot(scene);
|
mixer.uncacheRoot(scene);
|
||||||
@@ -439,6 +454,10 @@ function LoadedCharacterModel({
|
|||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const moving = !preview && Boolean(movingRef?.current);
|
const moving = !preview && Boolean(movingRef?.current);
|
||||||
|
const verticalMotion = preview ? "grounded" : verticalMotionRef?.current ?? "grounded";
|
||||||
|
const locomotionMotion: CharacterLocomotionMotion = verticalMotion === "grounded"
|
||||||
|
? moving ? "run" : "idle"
|
||||||
|
: verticalMotion;
|
||||||
const nextAnimationEvent = animationEventRef?.current;
|
const nextAnimationEvent = animationEventRef?.current;
|
||||||
if (nextAnimationEvent && nextAnimationEvent.revision !== observedAnimationRevision.current) {
|
if (nextAnimationEvent && nextAnimationEvent.revision !== observedAnimationRevision.current) {
|
||||||
observedAnimationRevision.current = nextAnimationEvent.revision;
|
observedAnimationRevision.current = nextAnimationEvent.revision;
|
||||||
@@ -455,7 +474,7 @@ function LoadedCharacterModel({
|
|||||||
playAnimationEvent({ revision: nextAttackPulse, kind: "attack" });
|
playAnimationEvent({ revision: nextAttackPulse, kind: "attack" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switchMotion(moving ? "run" : "idle");
|
switchMotion(locomotionMotion, moving);
|
||||||
|
|
||||||
const safeDelta = Math.min(delta, 0.05);
|
const safeDelta = Math.min(delta, 0.05);
|
||||||
mixer.update(safeDelta);
|
mixer.update(safeDelta);
|
||||||
@@ -468,7 +487,7 @@ function LoadedCharacterModel({
|
|||||||
&& !overrideLooping.current
|
&& !overrideLooping.current
|
||||||
&& !overrideAction.current.isRunning()
|
&& !overrideAction.current.isRunning()
|
||||||
) clearOverride();
|
) clearOverride();
|
||||||
switchMotion(moving ? "run" : "idle");
|
switchMotion(locomotionMotion, moving);
|
||||||
const swing = attackSwingAt(attackElapsed.current);
|
const swing = attackSwingAt(attackElapsed.current);
|
||||||
attackElapsed.current = Math.min(
|
attackElapsed.current = Math.min(
|
||||||
ATTACK_PRESENTATION_DURATION_SECONDS,
|
ATTACK_PRESENTATION_DURATION_SECONDS,
|
||||||
@@ -514,6 +533,7 @@ export function CharacterModel({
|
|||||||
identity,
|
identity,
|
||||||
active = true,
|
active = true,
|
||||||
movingRef,
|
movingRef,
|
||||||
|
verticalMotionRef,
|
||||||
attackPulseRef,
|
attackPulseRef,
|
||||||
animationEventRef,
|
animationEventRef,
|
||||||
onReady,
|
onReady,
|
||||||
@@ -546,6 +566,7 @@ export function CharacterModel({
|
|||||||
identity={identity}
|
identity={identity}
|
||||||
manifest={manifest}
|
manifest={manifest}
|
||||||
movingRef={movingRef}
|
movingRef={movingRef}
|
||||||
|
verticalMotionRef={verticalMotionRef}
|
||||||
attackPulseRef={attackPulseRef}
|
attackPulseRef={attackPulseRef}
|
||||||
animationEventRef={animationEventRef}
|
animationEventRef={animationEventRef}
|
||||||
onReady={onReady}
|
onReady={onReady}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
parseWoWAnimationName,
|
parseWoWAnimationName,
|
||||||
selectCharacterBaseClip,
|
selectCharacterBaseClip,
|
||||||
selectCharacterEventClip,
|
selectCharacterEventClip,
|
||||||
|
selectCharacterVerticalClip,
|
||||||
selectWoWAnimationVariation,
|
selectWoWAnimationVariation,
|
||||||
} from "./combatAnimation";
|
} from "./combatAnimation";
|
||||||
|
|
||||||
@@ -20,6 +21,10 @@ const clips = [
|
|||||||
{ name: "Death (ID 1 variation 0)" },
|
{ name: "Death (ID 1 variation 0)" },
|
||||||
{ name: "AttackUnarmed (ID 16 variation 0)" },
|
{ name: "AttackUnarmed (ID 16 variation 0)" },
|
||||||
{ name: "AttackUnarmed (ID 16 variation 1)" },
|
{ name: "AttackUnarmed (ID 16 variation 1)" },
|
||||||
|
{ name: "JumpStart (ID 37 variation 0)" },
|
||||||
|
{ name: "Fall (ID 40 variation 0)" },
|
||||||
|
{ name: "JumpEnd (ID 39 variation 0)" },
|
||||||
|
{ name: "JumpLandRun (ID 133 variation 0)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("WoW character animation selection", () => {
|
describe("WoW character animation selection", () => {
|
||||||
@@ -45,6 +50,19 @@ describe("WoW character animation selection", () => {
|
|||||||
expect(clipsForAnimationIds(clips, [57, 17])).toEqual([clips[4], clips[3]]);
|
expect(clipsForAnimationIds(clips, [57, 17])).toEqual([clips[4], clips[3]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("selects native rising, falling, and landing locomotion", () => {
|
||||||
|
expect(selectCharacterVerticalClip(clips, "rising", false)).toMatchObject({
|
||||||
|
clip: clips[11],
|
||||||
|
loop: false,
|
||||||
|
});
|
||||||
|
expect(selectCharacterVerticalClip(clips, "falling", false)).toMatchObject({
|
||||||
|
clip: clips[12],
|
||||||
|
loop: true,
|
||||||
|
});
|
||||||
|
expect(selectCharacterVerticalClip(clips, "landing", false)?.clip).toBe(clips[13]);
|
||||||
|
expect(selectCharacterVerticalClip(clips, "landing", true)?.clip).toBe(clips[14]);
|
||||||
|
});
|
||||||
|
|
||||||
it("maps hard-cast lifecycle events to ready and release clips", () => {
|
it("maps hard-cast lifecycle events to ready and release clips", () => {
|
||||||
expect(selectCharacterEventClip(clips, "priest", {
|
expect(selectCharacterEventClip(clips, "priest", {
|
||||||
revision: 1,
|
revision: 1,
|
||||||
@@ -79,6 +97,9 @@ describe("WoW character animation selection", () => {
|
|||||||
{ name: "Action - buff01" },
|
{ name: "Action - buff01" },
|
||||||
{ name: "Wound - hurt" },
|
{ name: "Wound - hurt" },
|
||||||
{ name: "Death - death" },
|
{ name: "Death - death" },
|
||||||
|
{ name: "Action - jump_up" },
|
||||||
|
{ name: "Action - jump_down" },
|
||||||
|
{ name: "Action - jump_end_run" },
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(selectCharacterEventClip(runewakerClips, "rom-mage", {
|
expect(selectCharacterEventClip(runewakerClips, "rom-mage", {
|
||||||
@@ -114,5 +135,11 @@ describe("WoW character animation selection", () => {
|
|||||||
revision: 6,
|
revision: 6,
|
||||||
kind: "death",
|
kind: "death",
|
||||||
})).toMatchObject({ clip: runewakerClips[9], terminal: true, native: true });
|
})).toMatchObject({ clip: runewakerClips[9], terminal: true, native: true });
|
||||||
|
expect(selectCharacterVerticalClip(runewakerClips, "rising", false)?.clip)
|
||||||
|
.toBe(runewakerClips[10]);
|
||||||
|
expect(selectCharacterVerticalClip(runewakerClips, "falling", false)?.clip)
|
||||||
|
.toBe(runewakerClips[11]);
|
||||||
|
expect(selectCharacterVerticalClip(runewakerClips, "landing", true)?.clip)
|
||||||
|
.toBe(runewakerClips[12]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { baseClassFor, type BaseClassId, type ClassId } from "../app/characterCatalog";
|
import { baseClassFor, type BaseClassId, type ClassId } from "../app/characterCatalog";
|
||||||
import type { AbilityDefinition } from "./abilityCatalog";
|
import type { AbilityDefinition } from "./abilityCatalog";
|
||||||
import { abilityAnimationById } from "./abilityAnimationLookup";
|
import { abilityAnimationById } from "./abilityAnimationLookup";
|
||||||
|
import type { CharacterVerticalMotion } from "./playerJump";
|
||||||
|
|
||||||
export type CharacterCombatAnimationKind =
|
export type CharacterCombatAnimationKind =
|
||||||
| "ability-start"
|
| "ability-start"
|
||||||
@@ -112,6 +113,35 @@ export function selectCharacterBaseClip<TClip extends NamedAnimationClip>(
|
|||||||
return selectWoWAnimationVariation(clips, ids);
|
return selectWoWAnimationVariation(clips, ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selectCharacterVerticalClip<TClip extends NamedAnimationClip>(
|
||||||
|
clips: readonly TClip[],
|
||||||
|
motion: Exclude<CharacterVerticalMotion, "grounded">,
|
||||||
|
moving: boolean,
|
||||||
|
): CharacterClipSelection<TClip> | null {
|
||||||
|
const ids = motion === "rising"
|
||||||
|
? [37, 38, 40]
|
||||||
|
: motion === "falling"
|
||||||
|
? [40, 38, 37]
|
||||||
|
: moving
|
||||||
|
? [133, 39, 38]
|
||||||
|
: [39, 133, 38];
|
||||||
|
const runewakerNames = motion === "rising"
|
||||||
|
? ["Action - jump_up", "Action - jump_loop"]
|
||||||
|
: motion === "falling"
|
||||||
|
? ["Action - jump_down", "Action - jump_loop"]
|
||||||
|
: moving
|
||||||
|
? ["Action - jump_end_run", "Action - jump_end_back"]
|
||||||
|
: ["Action - jump_end_back", "Action - jump_end_run"];
|
||||||
|
const clip = selectWoWAnimationVariation(clips, ids)
|
||||||
|
?? selectRunewakerClipVariation(clips, runewakerNames);
|
||||||
|
return clip ? {
|
||||||
|
clip,
|
||||||
|
loop: motion === "falling",
|
||||||
|
terminal: false,
|
||||||
|
native: true,
|
||||||
|
} : null;
|
||||||
|
}
|
||||||
|
|
||||||
function isMeleeAbility(ability: AbilityDefinition | null, classId: ClassId): boolean {
|
function isMeleeAbility(ability: AbilityDefinition | null, classId: ClassId): boolean {
|
||||||
if (!ability) return MELEE_CLASSES.has(baseClassFor(classId));
|
if (!ability) return MELEE_CLASSES.has(baseClassFor(classId));
|
||||||
if (ability.range.max <= 4) return true;
|
if (ability.range.max <= 4) return true;
|
||||||
|
|||||||
@@ -38,7 +38,13 @@ describe("named aura combat runtime", () => {
|
|||||||
sourceId: PLAYER_AGGRO_ID,
|
sourceId: PLAYER_AGGRO_ID,
|
||||||
targetId: PLAYER_AGGRO_ID,
|
targetId: PLAYER_AGGRO_ID,
|
||||||
expiresAt: now + 1_800_000,
|
expiresAt: now + 1_800_000,
|
||||||
definition: { disposition: "buff", dispelCategory: "magic" },
|
definition: {
|
||||||
|
disposition: "buff",
|
||||||
|
dispelCategory: "magic",
|
||||||
|
icon: useCombatStore.getState().abilities.find((ability) => (
|
||||||
|
ability.id === "wow335-priest-power-word-fortitude"
|
||||||
|
))?.icon,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useCombatStore.getState().tick(0, now + 1_800_000);
|
useCombatStore.getState().tick(0, now + 1_800_000);
|
||||||
@@ -57,6 +63,11 @@ describe("named aura combat runtime", () => {
|
|||||||
.flatMap((aura) => aura.absorbs)
|
.flatMap((aura) => aura.absorbs)
|
||||||
.reduce((total, absorb) => total + absorb.remaining, 0);
|
.reduce((total, absorb) => total + absorb.remaining, 0);
|
||||||
expect(beforeAbsorb).toBeGreaterThan(0);
|
expect(beforeAbsorb).toBeGreaterThan(0);
|
||||||
|
expect(useCombatStore.getState().auras.find((aura) => aura.absorbs.length)?.definition.icon).toBe(
|
||||||
|
useCombatStore.getState().abilities.find((ability) => (
|
||||||
|
ability.id === "wow335-priest-power-word-shield"
|
||||||
|
))?.icon,
|
||||||
|
);
|
||||||
|
|
||||||
const healthDamage = useCombatStore.getState().damagePlayer(20, "shadow", 80);
|
const healthDamage = useCombatStore.getState().damagePlayer(20, "shadow", 80);
|
||||||
const afterAbsorb = useCombatStore.getState().auras
|
const afterAbsorb = useCombatStore.getState().auras
|
||||||
|
|||||||
@@ -133,6 +133,10 @@ export interface AuraDefinition {
|
|||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly disposition: AuraDisposition;
|
readonly disposition: AuraDisposition;
|
||||||
|
/** Spell artwork used by every generic aura HUD surface. */
|
||||||
|
readonly icon?: string;
|
||||||
|
/** Keep a mechanically active aura out of the generic HUD strip when another widget owns its display. */
|
||||||
|
readonly hideFromAuraStrip?: boolean;
|
||||||
/** Null means the aura persists until explicitly removed. */
|
/** Null means the aura persists until explicitly removed. */
|
||||||
readonly durationMs: number | null;
|
readonly durationMs: number | null;
|
||||||
readonly maxStacks?: number;
|
readonly maxStacks?: number;
|
||||||
|
|||||||
+19
-8
@@ -83,6 +83,7 @@ import {
|
|||||||
resolveResourceValue as resolveAuraResourceValue,
|
resolveResourceValue as resolveAuraResourceValue,
|
||||||
resolveStatValue as resolveAuraStatValue,
|
resolveStatValue as resolveAuraStatValue,
|
||||||
type ActiveAura,
|
type ActiveAura,
|
||||||
|
type AuraDefinition,
|
||||||
type ProcEvent,
|
type ProcEvent,
|
||||||
type TriggeredAuraProc,
|
type TriggeredAuraProc,
|
||||||
} from "./combatAuras";
|
} from "./combatAuras";
|
||||||
@@ -1464,6 +1465,18 @@ function aurasForEntity(auras: readonly ActiveAura[], entityId: string): ActiveA
|
|||||||
return auras.filter((aura) => aura.targetId === entityId);
|
return auras.filter((aura) => aura.targetId === entityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function auraDefinitionForAbility(
|
||||||
|
definition: AuraDefinition,
|
||||||
|
ability: AbilityDefinition,
|
||||||
|
durationMs = definition.durationMs,
|
||||||
|
): AuraDefinition {
|
||||||
|
return {
|
||||||
|
...definition,
|
||||||
|
icon: definition.icon ?? ability.icon,
|
||||||
|
durationMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): CharacterStats {
|
function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): CharacterStats {
|
||||||
const stat = (value: number, ...names: string[]) => names.reduce(
|
const stat = (value: number, ...names: string[]) => names.reduce(
|
||||||
(current, name) => resolveAuraStatValue(current, auras, name),
|
(current, name) => resolveAuraStatValue(current, auras, name),
|
||||||
@@ -1534,7 +1547,7 @@ function syncPassiveAuras(
|
|||||||
const ability = abilityAtLevel(catalogAbility, level, learnedRank);
|
const ability = abilityAtLevel(catalogAbility, level, learnedRank);
|
||||||
return ability.effects
|
return ability.effects
|
||||||
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
|
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
|
||||||
.map((effect) => effect.aura);
|
.map((effect) => auraDefinitionForAbility(effect.aura, ability));
|
||||||
});
|
});
|
||||||
const passiveIds = new Set(definitions.map((definition) => definition.id));
|
const passiveIds = new Set(definitions.map((definition) => definition.id));
|
||||||
let next = current.filter((aura) => (
|
let next = current.filter((aura) => (
|
||||||
@@ -2125,7 +2138,7 @@ function executeTriggeredSpellInWork(
|
|||||||
? sourceId
|
? sourceId
|
||||||
: targetId ?? sourceId;
|
: targetId ?? sourceId;
|
||||||
work.auras = [...applyCombatAura(work.auras, {
|
work.auras = [...applyCombatAura(work.auras, {
|
||||||
definition: effect.aura,
|
definition: auraDefinitionForAbility(effect.aura, ability),
|
||||||
sourceId,
|
sourceId,
|
||||||
targetId: recipient,
|
targetId: recipient,
|
||||||
now,
|
now,
|
||||||
@@ -3156,12 +3169,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
}, ability.id, now);
|
}, ability.id, now);
|
||||||
}
|
}
|
||||||
} else if (effect.kind === "apply-aura") {
|
} else if (effect.kind === "apply-aura") {
|
||||||
const auraDefinition = effect.aura.durationMs === null
|
const durationMs = effect.aura.durationMs === null
|
||||||
? effect.aura
|
? null
|
||||||
: {
|
: talentAdjustedDuration(effect.aura.durationMs, ability, modifiers);
|
||||||
...effect.aura,
|
const auraDefinition = auraDefinitionForAbility(effect.aura, ability, durationMs);
|
||||||
durationMs: talentAdjustedDuration(effect.aura.durationMs, ability, modifiers),
|
|
||||||
};
|
|
||||||
const recipients = effect.recipient === "caster"
|
const recipients = effect.recipient === "caster"
|
||||||
? [PLAYER_AURA_ENTITY_ID]
|
? [PLAYER_AURA_ENTITY_ID]
|
||||||
: ability.target === "hostile"
|
: ability.target === "hostile"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { DUNGEON_DEFINITIONS, dungeonCanEnter, type DungeonId } from "./dungeonRegistry";
|
import { DUNGEON_DEFINITIONS, dungeonCanEnter, type DungeonId } from "./dungeonRegistry";
|
||||||
import { CONTENT_CATEGORIES, type ContentCategoryId } from "../app/contentCategories";
|
import { contentCategoryById, type ContentCategoryId } from "../app/contentCategories";
|
||||||
|
|
||||||
export type DungeonCategoryId = ContentCategoryId;
|
const DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[];
|
||||||
|
export type DungeonCategoryId = (typeof DUNGEON_CATEGORY_IDS)[number];
|
||||||
|
|
||||||
export interface DungeonCategory {
|
export interface DungeonCategory {
|
||||||
readonly id: DungeonCategoryId;
|
readonly id: DungeonCategoryId;
|
||||||
@@ -10,11 +11,10 @@ export interface DungeonCategory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DUNGEON_CATEGORIES: readonly DungeonCategory[] = Object.freeze([
|
export const DUNGEON_CATEGORIES: readonly DungeonCategory[] = Object.freeze([
|
||||||
...CONTENT_CATEGORIES.map((category) => ({
|
...DUNGEON_CATEGORY_IDS.map((id) => {
|
||||||
id: category.id,
|
const category = contentCategoryById(id);
|
||||||
label: category.label,
|
return { id, label: category.label, fullName: category.name };
|
||||||
fullName: category.name,
|
}),
|
||||||
})),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export interface DungeonCatalogEntry {
|
export interface DungeonCatalogEntry {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
MAX_MOUSE_DELTA_PER_FRAME,
|
MAX_MOUSE_DELTA_PER_FRAME,
|
||||||
POINTER_LOCK_MOUSE_GRACE_MS,
|
POINTER_LOCK_MOUSE_GRACE_MS,
|
||||||
beginMouseLookDrag,
|
beginMouseLookDrag,
|
||||||
|
clearJumpRequest,
|
||||||
|
consumeJumpRequest,
|
||||||
consumeMouseLook,
|
consumeMouseLook,
|
||||||
controllerCommandForToken,
|
controllerCommandForToken,
|
||||||
createMouseLookBuffer,
|
createMouseLookBuffer,
|
||||||
@@ -19,6 +21,7 @@ import {
|
|||||||
isDesktopCameraLookPointer,
|
isDesktopCameraLookPointer,
|
||||||
keyboardAxesForKeys,
|
keyboardAxesForKeys,
|
||||||
keyboardCommandForCode,
|
keyboardCommandForCode,
|
||||||
|
queueJumpRequest,
|
||||||
sanitizeMouseDelta,
|
sanitizeMouseDelta,
|
||||||
type InputActions,
|
type InputActions,
|
||||||
} from "./inputManager";
|
} from "./inputManager";
|
||||||
@@ -54,6 +57,7 @@ function inputActionSpies(blocked = false): InputActions {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
endMouseLookDrag();
|
endMouseLookDrag();
|
||||||
|
clearJumpRequest();
|
||||||
resetControllerState();
|
resetControllerState();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
@@ -89,7 +93,7 @@ describe("gameplay input bindings", () => {
|
|||||||
it("maps Thor controls without stealing Select from display routing", () => {
|
it("maps Thor controls without stealing Select from display routing", () => {
|
||||||
expect(controllerCommandForToken("Button9")).toBe("pause");
|
expect(controllerCommandForToken("Button9")).toBe("pause");
|
||||||
expect(controllerCommandForToken("Button11")).toBe("target-next");
|
expect(controllerCommandForToken("Button11")).toBe("target-next");
|
||||||
expect(controllerCommandForToken("Button10")).toBe("target-clear");
|
expect(controllerCommandForToken("Button10")).toBe("jump");
|
||||||
expect(controllerCommandForToken("Button12")).toBe("party-previous");
|
expect(controllerCommandForToken("Button12")).toBe("party-previous");
|
||||||
expect(controllerCommandForToken("Button13")).toBe("party-next");
|
expect(controllerCommandForToken("Button13")).toBe("party-next");
|
||||||
expect(controllerCommandForToken("Button4")).toBeNull();
|
expect(controllerCommandForToken("Button4")).toBeNull();
|
||||||
@@ -145,6 +149,31 @@ describe("gameplay input bindings", () => {
|
|||||||
expect(keyboardCommandForCode("Digit8")).toBeNull();
|
expect(keyboardCommandForCode("Digit8")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("queues Space and L3 jump presses once and blocks them behind menus", () => {
|
||||||
|
expect(keyboardCommandForCode("Space")).toBe("jump");
|
||||||
|
queueJumpRequest(25);
|
||||||
|
expect(consumeJumpRequest()).toBe(25);
|
||||||
|
expect(consumeJumpRequest()).toBeNull();
|
||||||
|
|
||||||
|
stubInputEventTargets();
|
||||||
|
const nextActions = inputActionSpies();
|
||||||
|
const blocked = nextActions.gameplayActionsBlocked as ReturnType<typeof vi.fn>;
|
||||||
|
const dispose = installInput(nextActions);
|
||||||
|
|
||||||
|
emitControllerToken({ token: "Button10", repeat: false, pressed: true });
|
||||||
|
expect(consumeJumpRequest()).not.toBeNull();
|
||||||
|
|
||||||
|
emitControllerToken({ token: "Button10", repeat: true, pressed: true });
|
||||||
|
expect(consumeJumpRequest()).toBeNull();
|
||||||
|
|
||||||
|
blocked.mockReturnValue(true);
|
||||||
|
emitControllerToken({ token: "Button10", repeat: false, pressed: false });
|
||||||
|
emitControllerToken({ token: "Button10", repeat: false, pressed: true });
|
||||||
|
expect(consumeJumpRequest()).toBeNull();
|
||||||
|
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("uses WASD for movement and arrow keys for camera look", () => {
|
it("uses WASD for movement and arrow keys for camera look", () => {
|
||||||
expect(keyboardAxesForKeys(new Set(["KeyW", "KeyD"]))).toEqual({
|
expect(keyboardAxesForKeys(new Set(["KeyW", "KeyD"]))).toEqual({
|
||||||
moveX: 1,
|
moveX: 1,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface InputActions {
|
|||||||
|
|
||||||
const keys = new Set<string>();
|
const keys = new Set<string>();
|
||||||
let actions: InputActions | null = null;
|
let actions: InputActions | null = null;
|
||||||
|
let jumpRequestAt: number | null = null;
|
||||||
|
|
||||||
// Pointer-lock implementations can emit a short burst of stale, very large
|
// Pointer-lock implementations can emit a short burst of stale, very large
|
||||||
// movement events while the cursor is being recentered. Some Android WebViews have also been
|
// movement events while the cursor is being recentered. Some Android WebViews have also been
|
||||||
@@ -69,6 +70,20 @@ function monotonicNow(): number {
|
|||||||
return Date.now();
|
return Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function queueJumpRequest(nowMs = monotonicNow()): void {
|
||||||
|
if (Number.isFinite(nowMs)) jumpRequestAt = nowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeJumpRequest(): number | null {
|
||||||
|
const requestedAt = jumpRequestAt;
|
||||||
|
jumpRequestAt = null;
|
||||||
|
return requestedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearJumpRequest(): void {
|
||||||
|
jumpRequestAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
export function createMouseLookBuffer(now: () => number = monotonicNow) {
|
export function createMouseLookBuffer(now: () => number = monotonicNow) {
|
||||||
let x = 0;
|
let x = 0;
|
||||||
let y = 0;
|
let y = 0;
|
||||||
@@ -161,12 +176,13 @@ export type GameplayInputCommand =
|
|||||||
| "party-defend"
|
| "party-defend"
|
||||||
| "party-stop"
|
| "party-stop"
|
||||||
| "party-recall"
|
| "party-recall"
|
||||||
|
| "jump"
|
||||||
| "target-clear";
|
| "target-clear";
|
||||||
|
|
||||||
export function controllerCommandForToken(token: string): GameplayInputCommand | null {
|
export function controllerCommandForToken(token: string): GameplayInputCommand | null {
|
||||||
if (token === "Button9" || token === CONTROLLER_SYSTEM_BACK_TOKEN) return "pause";
|
if (token === "Button9" || token === CONTROLLER_SYSTEM_BACK_TOKEN) return "pause";
|
||||||
if (token === "Button11") return "target-next";
|
if (token === "Button11") return "target-next";
|
||||||
if (token === "Button10") return "target-clear";
|
if (token === "Button10") return "jump";
|
||||||
if (token === "Button12") return "party-previous";
|
if (token === "Button12") return "party-previous";
|
||||||
if (token === "Button13") return "party-next";
|
if (token === "Button13") return "party-next";
|
||||||
return null;
|
return null;
|
||||||
@@ -183,6 +199,7 @@ export function keyboardCommandForCode(code: string, shiftKey = false): Gameplay
|
|||||||
if (code === "F2") return "party-defend";
|
if (code === "F2") return "party-defend";
|
||||||
if (code === "F3") return "party-stop";
|
if (code === "F3") return "party-stop";
|
||||||
if (code === "F4") return "party-recall";
|
if (code === "F4") return "party-recall";
|
||||||
|
if (code === "Space") return "jump";
|
||||||
if (code === "Tab") return shiftKey ? "target-previous" : "target-next";
|
if (code === "Tab") return shiftKey ? "target-previous" : "target-next";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -202,6 +219,7 @@ function runCommand(command: GameplayInputCommand, nextActions: InputActions): v
|
|||||||
else if (command === "party-defend") nextActions.setPartyCommand("defend");
|
else if (command === "party-defend") nextActions.setPartyCommand("defend");
|
||||||
else if (command === "party-stop") nextActions.setPartyCommand("stop");
|
else if (command === "party-stop") nextActions.setPartyCommand("stop");
|
||||||
else if (command === "party-recall") nextActions.recallParty();
|
else if (command === "party-recall") nextActions.recallParty();
|
||||||
|
else if (command === "jump") queueJumpRequest();
|
||||||
else if (command === "target-clear") nextActions.clearTarget();
|
else if (command === "target-clear") nextActions.clearTarget();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,6 +321,7 @@ export function installInput(nextActions: InputActions): () => void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (nextActions.gameplayActionsBlocked()) return;
|
if (nextActions.gameplayActionsBlocked()) return;
|
||||||
|
if (command === "jump") event.preventDefault();
|
||||||
if (event.code === "Tab") event.preventDefault();
|
if (event.code === "Tab") event.preventDefault();
|
||||||
runCommand(command, nextActions);
|
runCommand(command, nextActions);
|
||||||
};
|
};
|
||||||
@@ -339,6 +358,7 @@ export function installInput(nextActions: InputActions): () => void {
|
|||||||
keyboardModifiers.clear();
|
keyboardModifiers.clear();
|
||||||
controllerL1 = false;
|
controllerL1 = false;
|
||||||
controllerL2 = false;
|
controllerL2 = false;
|
||||||
|
clearJumpRequest();
|
||||||
mouseLookDragActive = false;
|
mouseLookDragActive = false;
|
||||||
previousMousePosition = null;
|
previousMousePosition = null;
|
||||||
nextActions.setActionLayer("primary");
|
nextActions.setActionLayer("primary");
|
||||||
|
|||||||
@@ -8,10 +8,17 @@ import {
|
|||||||
partyCombatMovementGoal,
|
partyCombatMovementGoal,
|
||||||
partyFormationPosition,
|
partyFormationPosition,
|
||||||
partyMadeStuckProgress,
|
partyMadeStuckProgress,
|
||||||
|
partyMovementProfile,
|
||||||
|
partyNaturalFollowPosition,
|
||||||
|
partyPersonalizedCombatMovementGoal,
|
||||||
partyPresentationY,
|
partyPresentationY,
|
||||||
|
partySeparationGoal,
|
||||||
partyShouldRecallForStuck,
|
partyShouldRecallForStuck,
|
||||||
|
partyShouldMoveToComfortTarget,
|
||||||
|
personalizedPartyCombatRangeBand,
|
||||||
resetPartyBreadcrumbTrail,
|
resetPartyBreadcrumbTrail,
|
||||||
stepPartyPosition,
|
stepPartyPosition,
|
||||||
|
synchronizePartyBreadcrumbCursor,
|
||||||
} from "./partyMovement";
|
} from "./partyMovement";
|
||||||
|
|
||||||
describe("party world movement", () => {
|
describe("party world movement", () => {
|
||||||
@@ -38,6 +45,89 @@ describe("party world movement", () => {
|
|||||||
expect(out).toEqual([12, 2, 17]);
|
expect(out).toEqual([12, 2, 17]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds stable and distinct bounded movement personalities", () => {
|
||||||
|
const first = partyMovementProfile("member-a", 0);
|
||||||
|
const repeat = partyMovementProfile("member-a", 0);
|
||||||
|
const second = partyMovementProfile("member-b", 1);
|
||||||
|
expect(first).toEqual(repeat);
|
||||||
|
expect(second).not.toEqual(first);
|
||||||
|
for (const profile of [first, second]) {
|
||||||
|
expect(Math.abs(profile.followDistanceJitter)).toBeLessThanOrEqual(0.35);
|
||||||
|
expect(Math.abs(profile.lateralOffset)).toBeLessThanOrEqual(0.65);
|
||||||
|
expect(profile.comfortSlack).toBeGreaterThanOrEqual(0.35);
|
||||||
|
expect(profile.comfortSlack).toBeLessThanOrEqual(0.85);
|
||||||
|
expect(profile.speedMultiplier).toBeGreaterThanOrEqual(0.94);
|
||||||
|
expect(profile.speedMultiplier).toBeLessThanOrEqual(1.06);
|
||||||
|
expect(Math.abs(profile.combatAngleOffset)).toBeLessThanOrEqual(50 * Math.PI / 180);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places natural followers inside personal spacing and movement bands", () => {
|
||||||
|
const profile = partyMovementProfile("member-c", 2);
|
||||||
|
const out: [number, number, number] = [0, 0, 0];
|
||||||
|
partyNaturalFollowPosition([10, 2, 20], 0, 1, 3.75, profile, 5_000, out);
|
||||||
|
expect(out[1]).toBe(2);
|
||||||
|
expect(Math.abs(out[0] - 10)).toBeLessThanOrEqual(0.83);
|
||||||
|
expect(20 - out[2]).toBeGreaterThanOrEqual(3.4);
|
||||||
|
expect(20 - out[2]).toBeLessThanOrEqual(4.1);
|
||||||
|
expect(partyShouldMoveToComfortTarget(out, out, false, profile.comfortSlack)).toBe(false);
|
||||||
|
expect(partyShouldMoveToComfortTarget([0, 0, 0], out, false, profile.comfortSlack)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps personalized combat bands inside role-safe bounds", () => {
|
||||||
|
const profiles = [
|
||||||
|
partyMovementProfile("ranged-a", 1),
|
||||||
|
partyMovementProfile("ranged-b", 2),
|
||||||
|
];
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const ranged = personalizedPartyCombatRangeBand({
|
||||||
|
minimum: 10,
|
||||||
|
preferred: 14.5,
|
||||||
|
maximum: 18.1,
|
||||||
|
}, profile);
|
||||||
|
expect(ranged.minimum).toBeGreaterThanOrEqual(9);
|
||||||
|
expect(ranged.maximum).toBeLessThanOrEqual(18.1);
|
||||||
|
expect(ranged.preferred).toBeGreaterThan(ranged.minimum);
|
||||||
|
expect(ranged.preferred).toBeLessThan(ranged.maximum);
|
||||||
|
|
||||||
|
const melee = personalizedPartyCombatRangeBand({
|
||||||
|
minimum: 0,
|
||||||
|
preferred: 3.3,
|
||||||
|
maximum: 3.55,
|
||||||
|
}, profile);
|
||||||
|
expect(melee.minimum).toBe(0);
|
||||||
|
expect(melee.maximum).toBeLessThanOrEqual(3.55);
|
||||||
|
expect(melee.preferred).toBeLessThan(melee.maximum);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a stable combat bearing and separates overlapping allies", () => {
|
||||||
|
const out: [number, number, number] = [0, 0, 0];
|
||||||
|
expect(partyPersonalizedCombatMovementGoal(
|
||||||
|
[20, 0, 0],
|
||||||
|
[0, 0, 0],
|
||||||
|
true,
|
||||||
|
{ minimum: 10, preferred: 14, maximum: 18 },
|
||||||
|
Math.PI / 2,
|
||||||
|
false,
|
||||||
|
out,
|
||||||
|
)).toBe("approach");
|
||||||
|
expect(out[0]).toBeCloseTo(14);
|
||||||
|
expect(out[1]).toBe(0);
|
||||||
|
expect(out[2]).toBeCloseTo(0);
|
||||||
|
|
||||||
|
const separated: [number, number, number] = [0, 0, 0];
|
||||||
|
expect(partySeparationGoal(
|
||||||
|
[1, 0, 1],
|
||||||
|
[2, 0, 2],
|
||||||
|
[[1, 0, 1]],
|
||||||
|
Math.PI / 2,
|
||||||
|
separated,
|
||||||
|
)).toBe(true);
|
||||||
|
expect(separated[0]).toBeGreaterThan(2);
|
||||||
|
expect(separated[2]).toBeCloseTo(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("recalls only distant allies that should be moving and stopped making progress", () => {
|
it("recalls only distant allies that should be moving and stopped making progress", () => {
|
||||||
expect(partyMadeStuckProgress([0, 0, 0], [0.45, 4, 0])).toBe(true);
|
expect(partyMadeStuckProgress([0, 0, 0], [0.45, 4, 0])).toBe(true);
|
||||||
expect(partyMadeStuckProgress([0, 0, 0], [0.44, 0, 0])).toBe(false);
|
expect(partyMadeStuckProgress([0, 0, 0], [0.44, 0, 0])).toBe(false);
|
||||||
@@ -168,6 +258,14 @@ describe("party world movement", () => {
|
|||||||
expect(out).toEqual([20, 3, 4]);
|
expect(out).toEqual([20, 3, 4]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("synchronizes direct followers to the matching safe trail point", () => {
|
||||||
|
const trail = createPartyBreadcrumbTrail();
|
||||||
|
for (let x = 0; x <= 6; x += 1) appendPartyBreadcrumb(trail, [x, 0, 0], 0.1, 20);
|
||||||
|
const cursor = createPartyBreadcrumbCursor();
|
||||||
|
synchronizePartyBreadcrumbCursor(trail, cursor, 2);
|
||||||
|
expect(cursor.breadcrumbId).toBe(trail.points.find((point) => point.position[0] === 4)?.id);
|
||||||
|
});
|
||||||
|
|
||||||
it("lets a distant follower rejoin a trail before it is long enough for formation spacing", () => {
|
it("lets a distant follower rejoin a trail before it is long enough for formation spacing", () => {
|
||||||
const trail = createPartyBreadcrumbTrail();
|
const trail = createPartyBreadcrumbTrail();
|
||||||
const cursor = createPartyBreadcrumbCursor();
|
const cursor = createPartyBreadcrumbCursor();
|
||||||
|
|||||||
+221
-1
@@ -19,7 +19,72 @@ export interface PartyBreadcrumbCursor {
|
|||||||
breadcrumbId: number | null;
|
breadcrumbId: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat";
|
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat" | "reposition";
|
||||||
|
|
||||||
|
export interface PartyMovementProfile {
|
||||||
|
readonly followDistanceJitter: number;
|
||||||
|
readonly lateralOffset: number;
|
||||||
|
readonly comfortSlack: number;
|
||||||
|
readonly speedMultiplier: number;
|
||||||
|
readonly swayAmplitude: number;
|
||||||
|
readonly swayPeriodMs: number;
|
||||||
|
readonly swayPhase: number;
|
||||||
|
readonly combatAngleOffset: number;
|
||||||
|
readonly combatRangeBias: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartyCombatRangeLike {
|
||||||
|
readonly minimum: number;
|
||||||
|
readonly preferred: number;
|
||||||
|
readonly maximum: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableHash(value: string): number {
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
hash ^= value.charCodeAt(index);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashUnit(value: string): number {
|
||||||
|
return stableHash(value) / 0xffff_ffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, minimum: number, maximum: number): number {
|
||||||
|
return Math.min(maximum, Math.max(minimum, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable per-character preferences keep motion reproducible without looking cloned. */
|
||||||
|
export function partyMovementProfile(memberId: string, livingRank: number): PartyMovementProfile {
|
||||||
|
const rank = Math.max(0, Math.floor(Number.isFinite(livingRank) ? livingRank : 0));
|
||||||
|
const prefix = `${memberId}|${rank}`;
|
||||||
|
const lateralMagnitude = rank === 0
|
||||||
|
? hashUnit(`${prefix}|leader-lateral`) * 0.3
|
||||||
|
: 0.35 + hashUnit(`${prefix}|lateral`) * 0.3;
|
||||||
|
const lateralSign = rank === 0
|
||||||
|
? hashUnit(`${prefix}|leader-side`) < 0.5 ? -1 : 1
|
||||||
|
: rank % 2 === 1 ? -1 : 1;
|
||||||
|
const angleSlots = [0, -35, 35, -50, 50];
|
||||||
|
const angleDegrees = clamp(
|
||||||
|
(angleSlots[rank % angleSlots.length] ?? 0)
|
||||||
|
+ (hashUnit(`${prefix}|angle-jitter`) - 0.5) * 10,
|
||||||
|
-50,
|
||||||
|
50,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
followDistanceJitter: (hashUnit(`${prefix}|distance`) - 0.5) * 0.7,
|
||||||
|
lateralOffset: lateralSign * lateralMagnitude,
|
||||||
|
comfortSlack: 0.35 + hashUnit(`${prefix}|comfort`) * 0.5,
|
||||||
|
speedMultiplier: 0.94 + hashUnit(`${prefix}|speed`) * 0.12,
|
||||||
|
swayAmplitude: 0.08 + hashUnit(`${prefix}|sway-amplitude`) * 0.1,
|
||||||
|
swayPeriodMs: 6_000 + hashUnit(`${prefix}|sway-period`) * 4_000,
|
||||||
|
swayPhase: hashUnit(`${prefix}|sway-phase`) * Math.PI * 2,
|
||||||
|
combatAngleOffset: angleDegrees * Math.PI / 180,
|
||||||
|
combatRangeBias: hashUnit(`${prefix}|combat-range`) * 2 - 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const PARTY_STUCK_RECALL_DISTANCE = 12;
|
export const PARTY_STUCK_RECALL_DISTANCE = 12;
|
||||||
export const PARTY_STUCK_RECALL_DELAY_MS = 3_500;
|
export const PARTY_STUCK_RECALL_DELAY_MS = 3_500;
|
||||||
@@ -66,6 +131,25 @@ export function createPartyBreadcrumbCursor(): PartyBreadcrumbCursor {
|
|||||||
return { breadcrumbId: null };
|
return { breadcrumbId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function synchronizePartyBreadcrumbCursor(
|
||||||
|
trail: PartyBreadcrumbTrail,
|
||||||
|
cursor: PartyBreadcrumbCursor,
|
||||||
|
trailingDistance: number,
|
||||||
|
): void {
|
||||||
|
const latest = trail.points[trail.points.length - 1];
|
||||||
|
if (!latest) {
|
||||||
|
cursor.breadcrumbId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const maximumDistance = latest.distance - Math.max(0, trailingDistance);
|
||||||
|
let target = trail.points[0];
|
||||||
|
for (const point of trail.points) {
|
||||||
|
if (point.distance > maximumDistance + EPSILON) break;
|
||||||
|
target = point;
|
||||||
|
}
|
||||||
|
cursor.breadcrumbId = target.id;
|
||||||
|
}
|
||||||
|
|
||||||
export function resetPartyBreadcrumbTrail(
|
export function resetPartyBreadcrumbTrail(
|
||||||
trail: PartyBreadcrumbTrail,
|
trail: PartyBreadcrumbTrail,
|
||||||
position?: PartyWorldPosition,
|
position?: PartyWorldPosition,
|
||||||
@@ -271,6 +355,114 @@ export function partyFormationPosition(
|
|||||||
out[2] = anchor[2] + rightZ * lateral - normalizedForwardZ * trailing;
|
out[2] = anchor[2] + rightZ * lateral - normalizedForwardZ * trailing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function partyNaturalFollowPosition(
|
||||||
|
anchor: PartyWorldPosition,
|
||||||
|
forwardX: number,
|
||||||
|
forwardZ: number,
|
||||||
|
baseTrailingDistance: number,
|
||||||
|
profile: PartyMovementProfile,
|
||||||
|
nowMs: number,
|
||||||
|
out: MutablePartyWorldPosition,
|
||||||
|
): void {
|
||||||
|
const trailing = Math.max(0.9, baseTrailingDistance + profile.followDistanceJitter);
|
||||||
|
const safeNow = Number.isFinite(nowMs) ? nowMs : 0;
|
||||||
|
const sway = Math.sin(
|
||||||
|
safeNow / Math.max(1, profile.swayPeriodMs) * Math.PI * 2 + profile.swayPhase,
|
||||||
|
) * profile.swayAmplitude;
|
||||||
|
partyFormationPosition(
|
||||||
|
anchor,
|
||||||
|
forwardX,
|
||||||
|
forwardZ,
|
||||||
|
profile.lateralOffset + sway,
|
||||||
|
trailing,
|
||||||
|
out,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start outside a personal comfort radius, then finish close enough to avoid jitter. */
|
||||||
|
export function partyShouldMoveToComfortTarget(
|
||||||
|
current: PartyWorldPosition,
|
||||||
|
target: PartyWorldPosition,
|
||||||
|
wasMoving: boolean,
|
||||||
|
comfortSlack: number,
|
||||||
|
): boolean {
|
||||||
|
const distance = Math.hypot(
|
||||||
|
target[0] - current[0],
|
||||||
|
target[1] - current[1],
|
||||||
|
target[2] - current[2],
|
||||||
|
);
|
||||||
|
if (!Number.isFinite(distance)) return false;
|
||||||
|
const startDistance = Math.max(0.18, Number.isFinite(comfortSlack) ? comfortSlack : 0.35);
|
||||||
|
return distance > (wasMoving ? 0.18 : startDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalizedPartyCombatRangeBand(
|
||||||
|
base: PartyCombatRangeLike,
|
||||||
|
profile: PartyMovementProfile,
|
||||||
|
): PartyCombatRangeLike {
|
||||||
|
if (base.minimum > 0) {
|
||||||
|
const minimum = clamp(base.minimum + profile.combatRangeBias * 0.75, 9, 11);
|
||||||
|
const maximum = clamp(base.maximum + profile.combatRangeBias * 0.35, 17, 18.1);
|
||||||
|
return {
|
||||||
|
minimum,
|
||||||
|
preferred: clamp(
|
||||||
|
base.preferred + profile.combatRangeBias * 1.25,
|
||||||
|
minimum + 0.75,
|
||||||
|
maximum - 0.5,
|
||||||
|
),
|
||||||
|
maximum,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const maximum = Math.max(2.4, base.maximum - Math.abs(profile.combatRangeBias) * 0.2);
|
||||||
|
return {
|
||||||
|
minimum: 0,
|
||||||
|
preferred: clamp(
|
||||||
|
base.preferred + profile.combatRangeBias * 0.3,
|
||||||
|
2.35,
|
||||||
|
maximum - 0.12,
|
||||||
|
),
|
||||||
|
maximum,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a bounded local repulsion so allies do not occupy the same point. */
|
||||||
|
export function partySeparationGoal(
|
||||||
|
current: PartyWorldPosition,
|
||||||
|
desired: PartyWorldPosition,
|
||||||
|
peers: readonly PartyWorldPosition[],
|
||||||
|
fallbackAngle: number,
|
||||||
|
out: MutablePartyWorldPosition,
|
||||||
|
separationDistance = 0.85,
|
||||||
|
maximumCorrection = 0.35,
|
||||||
|
): boolean {
|
||||||
|
let offsetX = 0;
|
||||||
|
let offsetZ = 0;
|
||||||
|
const safeSeparation = Math.max(EPSILON, separationDistance);
|
||||||
|
for (const peer of peers) {
|
||||||
|
const dx = current[0] - peer[0];
|
||||||
|
const dz = current[2] - peer[2];
|
||||||
|
const distance = Math.hypot(dx, dz);
|
||||||
|
if (!Number.isFinite(distance) || distance >= safeSeparation) continue;
|
||||||
|
const weight = (safeSeparation - distance) / safeSeparation;
|
||||||
|
if (distance <= EPSILON) {
|
||||||
|
offsetX += Math.sin(fallbackAngle) * weight;
|
||||||
|
offsetZ += Math.cos(fallbackAngle) * weight;
|
||||||
|
} else {
|
||||||
|
offsetX += dx / distance * weight;
|
||||||
|
offsetZ += dz / distance * weight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const length = Math.hypot(offsetX, offsetZ);
|
||||||
|
out[0] = desired[0];
|
||||||
|
out[1] = desired[1];
|
||||||
|
out[2] = desired[2];
|
||||||
|
if (length <= EPSILON) return false;
|
||||||
|
const correction = Math.min(Math.max(0, maximumCorrection), length);
|
||||||
|
out[0] += offsetX / length * correction;
|
||||||
|
out[2] += offsetZ / length * correction;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chooses an individual combat destination. Melee only closes distance;
|
* Chooses an individual combat destination. Melee only closes distance;
|
||||||
* ranged attackers also back out when a target collapses their preferred band.
|
* ranged attackers also back out when a target collapses their preferred band.
|
||||||
@@ -323,3 +515,31 @@ export function partyCombatMovementGoal(
|
|||||||
out[2] = target[2] + awayZ / planarLength * preferred;
|
out[2] = target[2] + awayZ / planarLength * preferred;
|
||||||
return "retreat";
|
return "retreat";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function partyPersonalizedCombatMovementGoal(
|
||||||
|
current: PartyWorldPosition,
|
||||||
|
target: PartyWorldPosition,
|
||||||
|
targetVisible: boolean,
|
||||||
|
rangeBand: PartyCombatRangeLike,
|
||||||
|
preferredBearing: number,
|
||||||
|
forceReposition: boolean,
|
||||||
|
out: MutablePartyWorldPosition,
|
||||||
|
): PartyCombatMovementIntent {
|
||||||
|
const intent = partyCombatMovementGoal(
|
||||||
|
current,
|
||||||
|
target,
|
||||||
|
targetVisible,
|
||||||
|
rangeBand.minimum,
|
||||||
|
rangeBand.preferred,
|
||||||
|
rangeBand.maximum,
|
||||||
|
out,
|
||||||
|
Math.sin(preferredBearing),
|
||||||
|
Math.cos(preferredBearing),
|
||||||
|
);
|
||||||
|
if (!targetVisible || (intent === "hold" && !forceReposition)) return intent;
|
||||||
|
|
||||||
|
out[0] = target[0] + Math.sin(preferredBearing) * rangeBand.preferred;
|
||||||
|
out[1] = target[1];
|
||||||
|
out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred;
|
||||||
|
return intent === "hold" ? "reposition" : intent;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
PLAYER_GROUND_MIN_NORMAL_Y,
|
||||||
|
PLAYER_JUMP_BUFFER_MS,
|
||||||
|
PLAYER_JUMP_COYOTE_MS,
|
||||||
|
bufferPlayerJump,
|
||||||
|
cancelBufferedPlayerJump,
|
||||||
|
characterVerticalMotion,
|
||||||
|
createPlayerJumpTiming,
|
||||||
|
isWalkableGroundHit,
|
||||||
|
updatePlayerJumpTiming,
|
||||||
|
} from "./playerJump";
|
||||||
|
|
||||||
|
describe("player jump timing", () => {
|
||||||
|
it("accepts only a nearby upward-facing ground hit", () => {
|
||||||
|
expect(isWalkableGroundHit({
|
||||||
|
timeOfImpact: 0.9,
|
||||||
|
normal: { y: PLAYER_GROUND_MIN_NORMAL_Y },
|
||||||
|
}, 0.95)).toBe(true);
|
||||||
|
expect(isWalkableGroundHit({ timeOfImpact: 0.96, normal: { y: 1 } }, 0.95)).toBe(false);
|
||||||
|
expect(isWalkableGroundHit({ timeOfImpact: 0.4, normal: { y: 0.2 } }, 0.95)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buffers a press shortly before landing", () => {
|
||||||
|
const timing = createPlayerJumpTiming();
|
||||||
|
bufferPlayerJump(timing, 1_000);
|
||||||
|
expect(updatePlayerJumpTiming(timing, 1_000 + PLAYER_JUMP_BUFFER_MS - 1, true, -2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows coyote time after leaving a ledge", () => {
|
||||||
|
const timing = createPlayerJumpTiming(2_000);
|
||||||
|
bufferPlayerJump(timing, 2_000 + PLAYER_JUMP_COYOTE_MS - 1);
|
||||||
|
expect(updatePlayerJumpTiming(
|
||||||
|
timing,
|
||||||
|
2_000 + PLAYER_JUMP_COYOTE_MS - 1,
|
||||||
|
false,
|
||||||
|
-0.2,
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels buffered input while gameplay is blocked", () => {
|
||||||
|
const timing = createPlayerJumpTiming(2_500);
|
||||||
|
bufferPlayerJump(timing, 2_500);
|
||||||
|
cancelBufferedPlayerJump(timing);
|
||||||
|
expect(updatePlayerJumpTiming(timing, 2_500, true, 0)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not double jump until a real landing", () => {
|
||||||
|
const timing = createPlayerJumpTiming(3_000);
|
||||||
|
bufferPlayerJump(timing, 3_000);
|
||||||
|
expect(updatePlayerJumpTiming(timing, 3_000, true, 0)).toBe(true);
|
||||||
|
|
||||||
|
bufferPlayerJump(timing, 3_040);
|
||||||
|
expect(updatePlayerJumpTiming(timing, 3_040, true, 6.2)).toBe(false);
|
||||||
|
expect(updatePlayerJumpTiming(timing, 3_300, false, -2)).toBe(false);
|
||||||
|
bufferPlayerJump(timing, 3_550);
|
||||||
|
expect(updatePlayerJumpTiming(timing, 3_600, true, -1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports rising, falling, landing, and grounded presentation phases", () => {
|
||||||
|
expect(characterVerticalMotion(false, 2, 100, 0)).toBe("rising");
|
||||||
|
expect(characterVerticalMotion(false, -0.1, 100, 0)).toBe("falling");
|
||||||
|
expect(characterVerticalMotion(true, 0, 100, 150)).toBe("landing");
|
||||||
|
expect(characterVerticalMotion(true, 0, 150, 150)).toBe("grounded");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
export const PLAYER_JUMP_VELOCITY = 7.2;
|
||||||
|
export const PLAYER_JUMP_COYOTE_MS = 100;
|
||||||
|
export const PLAYER_JUMP_BUFFER_MS = 120;
|
||||||
|
export const PLAYER_JUMP_LANDING_UNLOCK_MS = 80;
|
||||||
|
export const PLAYER_LANDING_PRESENTATION_MS = 180;
|
||||||
|
export const PLAYER_GROUND_PROBE_DISTANCE = 0.14;
|
||||||
|
export const PLAYER_GROUND_MIN_NORMAL_Y = 0.55;
|
||||||
|
|
||||||
|
export type CharacterVerticalMotion = "grounded" | "rising" | "falling" | "landing";
|
||||||
|
|
||||||
|
export interface PlayerJumpTiming {
|
||||||
|
lastGroundedAtMs: number;
|
||||||
|
bufferedUntilMs: number;
|
||||||
|
jumpedAtMs: number;
|
||||||
|
lockedUntilLanding: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroundProbeHit {
|
||||||
|
readonly timeOfImpact: number;
|
||||||
|
readonly normal: { readonly y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlayerJumpTiming(nowMs = Number.NEGATIVE_INFINITY): PlayerJumpTiming {
|
||||||
|
return {
|
||||||
|
lastGroundedAtMs: nowMs,
|
||||||
|
bufferedUntilMs: Number.NEGATIVE_INFINITY,
|
||||||
|
jumpedAtMs: Number.NEGATIVE_INFINITY,
|
||||||
|
lockedUntilLanding: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bufferPlayerJump(timing: PlayerJumpTiming, requestedAtMs: number): void {
|
||||||
|
if (!Number.isFinite(requestedAtMs)) return;
|
||||||
|
timing.bufferedUntilMs = requestedAtMs + PLAYER_JUMP_BUFFER_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelBufferedPlayerJump(timing: PlayerJumpTiming): void {
|
||||||
|
timing.bufferedUntilMs = Number.NEGATIVE_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWalkableGroundHit(
|
||||||
|
hit: GroundProbeHit | null | undefined,
|
||||||
|
maximumTimeOfImpact: number,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(
|
||||||
|
hit
|
||||||
|
&& Number.isFinite(hit.timeOfImpact)
|
||||||
|
&& hit.timeOfImpact >= 0
|
||||||
|
&& hit.timeOfImpact <= maximumTimeOfImpact
|
||||||
|
&& Number.isFinite(hit.normal.y)
|
||||||
|
&& hit.normal.y >= PLAYER_GROUND_MIN_NORMAL_Y,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the timing latch and consumes a buffered jump exactly once. The
|
||||||
|
* upward-velocity guard prevents the ground probe from unlocking the latch on
|
||||||
|
* the first few frames after launch while the capsule is still near the floor.
|
||||||
|
*/
|
||||||
|
export function updatePlayerJumpTiming(
|
||||||
|
timing: PlayerJumpTiming,
|
||||||
|
nowMs: number,
|
||||||
|
grounded: boolean,
|
||||||
|
verticalVelocity: number,
|
||||||
|
): boolean {
|
||||||
|
if (!Number.isFinite(nowMs)) return false;
|
||||||
|
|
||||||
|
if (grounded && verticalVelocity <= 0.5) {
|
||||||
|
timing.lastGroundedAtMs = nowMs;
|
||||||
|
if (
|
||||||
|
timing.lockedUntilLanding
|
||||||
|
&& nowMs - timing.jumpedAtMs >= PLAYER_JUMP_LANDING_UNLOCK_MS
|
||||||
|
) timing.lockedUntilLanding = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffered = timing.bufferedUntilMs >= nowMs;
|
||||||
|
const insideCoyoteWindow = nowMs - timing.lastGroundedAtMs <= PLAYER_JUMP_COYOTE_MS;
|
||||||
|
if (timing.lockedUntilLanding || !buffered || !insideCoyoteWindow) return false;
|
||||||
|
|
||||||
|
timing.lockedUntilLanding = true;
|
||||||
|
timing.jumpedAtMs = nowMs;
|
||||||
|
timing.bufferedUntilMs = Number.NEGATIVE_INFINITY;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function characterVerticalMotion(
|
||||||
|
grounded: boolean,
|
||||||
|
verticalVelocity: number,
|
||||||
|
nowMs: number,
|
||||||
|
landingUntilMs: number,
|
||||||
|
): CharacterVerticalMotion {
|
||||||
|
if (grounded) return nowMs < landingUntilMs ? "landing" : "grounded";
|
||||||
|
return verticalVelocity > 0.15 ? "rising" : "falling";
|
||||||
|
}
|
||||||
@@ -147,7 +147,7 @@ describe("RuneWaker dungeon creature animation coverage", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 30_000);
|
}, 60_000);
|
||||||
|
|
||||||
it("ships every configured directly-convertible actor as native except documented pose/proxy cases", async () => {
|
it("ships every configured directly-convertible actor as native except documented pose/proxy cases", async () => {
|
||||||
const recipes = await Promise.all(
|
const recipes = await Promise.all(
|
||||||
@@ -305,7 +305,7 @@ describe("RuneWaker dungeon creature animation coverage", () => {
|
|||||||
poseOnly: 1,
|
poseOnly: 1,
|
||||||
proxy: 0,
|
proxy: 0,
|
||||||
});
|
});
|
||||||
}, 30_000);
|
}, 60_000);
|
||||||
|
|
||||||
it("packages every Pasper actor with a skin and original combat/locomotion clips", async () => {
|
it("packages every Pasper actor with a skin and original combat/locomotion clips", async () => {
|
||||||
const pasper = DUNGEON_DEFINITIONS.find((definition) => definition.id === "paspers-shrine");
|
const pasper = DUNGEON_DEFINITIONS.find((definition) => definition.id === "paspers-shrine");
|
||||||
|
|||||||
@@ -57,12 +57,43 @@ describe("game store", () => {
|
|||||||
expect(state.areaName).toContain("Entrance");
|
expect(state.areaName).toContain("Entrance");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a grounded navigation anchor while the physical player is airborne", () => {
|
||||||
|
useGameStore.getState().updatePlayerSnapshot({
|
||||||
|
position: [164, -73.66, 132],
|
||||||
|
yaw: -2,
|
||||||
|
grounded: true,
|
||||||
|
});
|
||||||
|
useGameStore.getState().updatePlayerSnapshot({
|
||||||
|
position: [166, -72.2, 133],
|
||||||
|
yaw: -2,
|
||||||
|
grounded: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useGameStore.getState()).toMatchObject({
|
||||||
|
playerPosition: [166, -72.2, 133],
|
||||||
|
playerNavigationPosition: [164, -73.66, 132],
|
||||||
|
playerGrounded: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useGameStore.getState().updatePlayerSnapshot({
|
||||||
|
position: [167, -73.66, 134],
|
||||||
|
yaw: -2,
|
||||||
|
grounded: true,
|
||||||
|
});
|
||||||
|
expect(useGameStore.getState()).toMatchObject({
|
||||||
|
playerNavigationPosition: [167, -73.66, 134],
|
||||||
|
playerGrounded: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("reset restores the authoritative transformed entrance", () => {
|
it("reset restores the authoritative transformed entrance", () => {
|
||||||
useGameStore.getState().setActionLayer("tertiary");
|
useGameStore.getState().setActionLayer("tertiary");
|
||||||
useGameStore.getState().updatePlayerSnapshot({ position: [0, 0, 0], yaw: 0, distanceDelta: 8 });
|
useGameStore.getState().updatePlayerSnapshot({ position: [0, 0, 0], yaw: 0, distanceDelta: 8 });
|
||||||
useGameStore.getState().resetAtEntrance();
|
useGameStore.getState().resetAtEntrance();
|
||||||
const state = useGameStore.getState();
|
const state = useGameStore.getState();
|
||||||
expect(state.playerPosition).toEqual(DUNGEON_MANIFEST.entrance.footPosition);
|
expect(state.playerPosition).toEqual(DUNGEON_MANIFEST.entrance.footPosition);
|
||||||
|
expect(state.playerNavigationPosition).toEqual(DUNGEON_MANIFEST.entrance.footPosition);
|
||||||
|
expect(state.playerGrounded).toBe(true);
|
||||||
expect(state.cameraYaw).toBe(DUNGEON_MANIFEST.entrance.yaw);
|
expect(state.cameraYaw).toBe(DUNGEON_MANIFEST.entrance.yaw);
|
||||||
expect(state.distanceTravelled).toBe(0);
|
expect(state.distanceTravelled).toBe(0);
|
||||||
expect(state.actionLayer).toBe("primary");
|
expect(state.actionLayer).toBe("primary");
|
||||||
|
|||||||
+14
-1
@@ -13,6 +13,7 @@ interface PlayerSnapshot {
|
|||||||
position: Vector3Tuple;
|
position: Vector3Tuple;
|
||||||
yaw: number;
|
yaw: number;
|
||||||
distanceDelta?: number;
|
distanceDelta?: number;
|
||||||
|
grounded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
@@ -31,6 +32,8 @@ export interface GameState {
|
|||||||
assetStatus: AssetStatus;
|
assetStatus: AssetStatus;
|
||||||
assetMessage: string | null;
|
assetMessage: string | null;
|
||||||
playerPosition: Vector3Tuple;
|
playerPosition: Vector3Tuple;
|
||||||
|
playerNavigationPosition: Vector3Tuple;
|
||||||
|
playerGrounded: boolean;
|
||||||
cameraYaw: number;
|
cameraYaw: number;
|
||||||
areaName: string;
|
areaName: string;
|
||||||
distanceTravelled: number;
|
distanceTravelled: number;
|
||||||
@@ -77,6 +80,8 @@ export const useGameStore = create<GameState>((set) => ({
|
|||||||
assetStatus: "checking",
|
assetStatus: "checking",
|
||||||
assetMessage: null,
|
assetMessage: null,
|
||||||
playerPosition: entrance.footPosition,
|
playerPosition: entrance.footPosition,
|
||||||
|
playerNavigationPosition: entrance.footPosition,
|
||||||
|
playerGrounded: true,
|
||||||
cameraYaw: entrance.yaw,
|
cameraYaw: entrance.yaw,
|
||||||
areaName: resolveAreaNameForDungeon(initialDungeon, entrance.footPosition),
|
areaName: resolveAreaNameForDungeon(initialDungeon, entrance.footPosition),
|
||||||
distanceTravelled: 0,
|
distanceTravelled: 0,
|
||||||
@@ -106,6 +111,8 @@ export const useGameStore = create<GameState>((set) => ({
|
|||||||
assetStatus: "checking",
|
assetStatus: "checking",
|
||||||
assetMessage: null,
|
assetMessage: null,
|
||||||
playerPosition: activeSpawn.footPosition,
|
playerPosition: activeSpawn.footPosition,
|
||||||
|
playerNavigationPosition: activeSpawn.footPosition,
|
||||||
|
playerGrounded: true,
|
||||||
cameraYaw: activeSpawn.yaw,
|
cameraYaw: activeSpawn.yaw,
|
||||||
areaName: resolveAreaNameForDungeon(definition, activeSpawn.footPosition),
|
areaName: resolveAreaNameForDungeon(definition, activeSpawn.footPosition),
|
||||||
distanceTravelled: 0,
|
distanceTravelled: 0,
|
||||||
@@ -175,10 +182,12 @@ export const useGameStore = create<GameState>((set) => ({
|
|||||||
if (isStaleLoadingWrite) return state;
|
if (isStaleLoadingWrite) return state;
|
||||||
return { assetStatus, assetMessage };
|
return { assetStatus, assetMessage };
|
||||||
}),
|
}),
|
||||||
updatePlayerSnapshot: ({ position, yaw, distanceDelta = 0 }) => set((state) => {
|
updatePlayerSnapshot: ({ position, yaw, distanceDelta = 0, grounded = true }) => set((state) => {
|
||||||
const definition = requireDungeonDefinition(state.activeDungeonId);
|
const definition = requireDungeonDefinition(state.activeDungeonId);
|
||||||
return {
|
return {
|
||||||
playerPosition: position,
|
playerPosition: position,
|
||||||
|
playerNavigationPosition: grounded ? position : state.playerNavigationPosition,
|
||||||
|
playerGrounded: grounded,
|
||||||
cameraYaw: yaw,
|
cameraYaw: yaw,
|
||||||
areaName: resolveAreaNameForDungeon(definition, position),
|
areaName: resolveAreaNameForDungeon(definition, position),
|
||||||
distanceTravelled: state.distanceTravelled + Math.max(0, distanceDelta),
|
distanceTravelled: state.distanceTravelled + Math.max(0, distanceDelta),
|
||||||
@@ -193,6 +202,8 @@ export const useGameStore = create<GameState>((set) => ({
|
|||||||
mapOpen: false,
|
mapOpen: false,
|
||||||
cameraLookActive: false,
|
cameraLookActive: false,
|
||||||
playerPosition: state.activeSpawn.footPosition,
|
playerPosition: state.activeSpawn.footPosition,
|
||||||
|
playerNavigationPosition: state.activeSpawn.footPosition,
|
||||||
|
playerGrounded: true,
|
||||||
cameraYaw: state.activeSpawn.yaw,
|
cameraYaw: state.activeSpawn.yaw,
|
||||||
areaName: resolveAreaNameForDungeon(definition, state.activeSpawn.footPosition),
|
areaName: resolveAreaNameForDungeon(definition, state.activeSpawn.footPosition),
|
||||||
distanceTravelled: 0,
|
distanceTravelled: 0,
|
||||||
@@ -213,6 +224,8 @@ export const useGameStore = create<GameState>((set) => ({
|
|||||||
mapOpen: false,
|
mapOpen: false,
|
||||||
cameraLookActive: false,
|
cameraLookActive: false,
|
||||||
playerPosition: activeEntrance.footPosition,
|
playerPosition: activeEntrance.footPosition,
|
||||||
|
playerNavigationPosition: activeEntrance.footPosition,
|
||||||
|
playerGrounded: true,
|
||||||
cameraYaw: activeEntrance.yaw,
|
cameraYaw: activeEntrance.yaw,
|
||||||
areaName: resolveAreaNameForDungeon(definition, activeEntrance.footPosition),
|
areaName: resolveAreaNameForDungeon(definition, activeEntrance.footPosition),
|
||||||
distanceTravelled: 0,
|
distanceTravelled: 0,
|
||||||
|
|||||||
@@ -77,6 +77,26 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
|||||||
expect(shield.effects.some((effect) => effect.kind === "shield")).toBe(false);
|
expect(shield.effects.some((effect) => effect.kind === "shield")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps periodic damage and healing auras out of the generic HUD strip", () => {
|
||||||
|
const pain = byId("wow335-priest-shadow-word-pain");
|
||||||
|
const painAura = effectsOfKind(pain, "apply-aura")
|
||||||
|
.find((effect) => effect.sourceAuraType === 3);
|
||||||
|
const renew = byId("wow335-priest-renew");
|
||||||
|
const renewAura = effectsOfKind(renew, "apply-aura")
|
||||||
|
.find((effect) => effect.sourceAuraType === 8);
|
||||||
|
|
||||||
|
expect(painAura?.aura).toMatchObject({
|
||||||
|
disposition: "debuff",
|
||||||
|
hideFromAuraStrip: true,
|
||||||
|
});
|
||||||
|
expect(renewAura?.aura).toMatchObject({
|
||||||
|
disposition: "buff",
|
||||||
|
hideFromAuraStrip: true,
|
||||||
|
});
|
||||||
|
expect(effectsOfKind(pain, "dot")).toHaveLength(1);
|
||||||
|
expect(effectsOfKind(renew, "hot")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("maps cleanse, purge, and dual-purpose dispel categories", () => {
|
it("maps cleanse, purge, and dual-purpose dispel categories", () => {
|
||||||
expect(effectsOfKind(byId("wow335-paladin-cleanse"), "dispel")).toEqual(expect.arrayContaining([
|
expect(effectsOfKind(byId("wow335-paladin-cleanse"), "dispel")).toEqual(expect.arrayContaining([
|
||||||
expect.objectContaining({ mode: "cleanse", relationship: "friendly", categories: ["magic"] }),
|
expect.objectContaining({ mode: "cleanse", relationship: "friendly", categories: ["magic"] }),
|
||||||
|
|||||||
@@ -367,6 +367,9 @@ function namedAuraForEffect(
|
|||||||
id: `wow335:${rank.spellId}:effect:${effectIndex}:aura:${effect.auraType}`,
|
id: `wow335:${rank.spellId}:effect:${effectIndex}:aura:${effect.auraType}`,
|
||||||
name: `${abilityName} (DBC ${rank.spellId}, Aura ${effect.auraType})`,
|
name: `${abilityName} (DBC ${rank.spellId}, Aura ${effect.auraType})`,
|
||||||
disposition: effectTargetsHostile(effect) ? "debuff" : "buff",
|
disposition: effectTargetsHostile(effect) ? "debuff" : "buff",
|
||||||
|
...([3, 8].includes(effect.auraType) && effect.periodMs > 0
|
||||||
|
? { hideFromAuraStrip: true }
|
||||||
|
: {}),
|
||||||
durationMs,
|
durationMs,
|
||||||
maxStacks: 1,
|
maxStacks: 1,
|
||||||
stackBehavior: "refresh",
|
stackBehavior: "refresh",
|
||||||
|
|||||||
+268
-77
@@ -31,14 +31,21 @@ import {
|
|||||||
createPartyBreadcrumbCursor,
|
createPartyBreadcrumbCursor,
|
||||||
createPartyBreadcrumbTrail,
|
createPartyBreadcrumbTrail,
|
||||||
nextPartyBreadcrumbFollowWaypoint,
|
nextPartyBreadcrumbFollowWaypoint,
|
||||||
partyCombatMovementGoal,
|
|
||||||
partyFormationPosition,
|
partyFormationPosition,
|
||||||
partyMadeStuckProgress,
|
partyMadeStuckProgress,
|
||||||
|
partyMovementProfile,
|
||||||
|
partyNaturalFollowPosition,
|
||||||
|
partyPersonalizedCombatMovementGoal,
|
||||||
partyPresentationY,
|
partyPresentationY,
|
||||||
|
partySeparationGoal,
|
||||||
partyShouldRecallForStuck,
|
partyShouldRecallForStuck,
|
||||||
|
partyShouldMoveToComfortTarget,
|
||||||
|
personalizedPartyCombatRangeBand,
|
||||||
resetPartyBreadcrumbTrail,
|
resetPartyBreadcrumbTrail,
|
||||||
stepPartyPosition,
|
stepPartyPosition,
|
||||||
|
synchronizePartyBreadcrumbCursor,
|
||||||
type PartyBreadcrumbCursor,
|
type PartyBreadcrumbCursor,
|
||||||
|
type PartyMovementProfile,
|
||||||
} from "../game/partyMovement";
|
} from "../game/partyMovement";
|
||||||
import {
|
import {
|
||||||
buildPartyNavigationGraph,
|
buildPartyNavigationGraph,
|
||||||
@@ -82,13 +89,7 @@ const PARTY_MAX_FLOOR_DELTA = 0.55;
|
|||||||
const PARTY_MAX_GROUND_CORRECTION = 0.85;
|
const PARTY_MAX_GROUND_CORRECTION = 0.85;
|
||||||
const EMPTY_NAVIGATION_LINKS = [] as const;
|
const EMPTY_NAVIGATION_LINKS = [] as const;
|
||||||
|
|
||||||
/** Lateral and trailing offsets behind the current squad anchor. */
|
const PARTY_PLAYER_TRAILING_DISTANCE: readonly number[] = [1.45, 2.6, 3.75, 4.9];
|
||||||
const PARTY_FORMATION: readonly (readonly [number, number])[] = [
|
|
||||||
[0, 1.45],
|
|
||||||
[0, 2.6],
|
|
||||||
[0, 3.75],
|
|
||||||
[0, 4.9],
|
|
||||||
];
|
|
||||||
const PARTY_ENTRANCE_FORWARD_DISTANCE: readonly number[] = [2.4, 1.8, 1.2, 0.6];
|
const PARTY_ENTRANCE_FORWARD_DISTANCE: readonly number[] = [2.4, 1.8, 1.2, 0.6];
|
||||||
|
|
||||||
const PARTY_TRAIL_DISTANCE: readonly number[] = [0, 1.15, 2.3, 3.45];
|
const PARTY_TRAIL_DISTANCE: readonly number[] = [0, 1.15, 2.3, 3.45];
|
||||||
@@ -106,6 +107,11 @@ interface PartyStuckProgress {
|
|||||||
lastProgressAt: number;
|
lastProgressAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PartyCombatAnchor {
|
||||||
|
readonly targetId: string;
|
||||||
|
readonly bearing: number;
|
||||||
|
}
|
||||||
|
|
||||||
type PartyAvatarMetricKey = `${RaceId}:${GenderId}`;
|
type PartyAvatarMetricKey = `${RaceId}:${GenderId}`;
|
||||||
|
|
||||||
/** Bind-pose bounds measured from the shipped wow335a rigs, in local meters. */
|
/** Bind-pose bounds measured from the shipped wow335a rigs, in local meters. */
|
||||||
@@ -418,6 +424,9 @@ function PartyActors({
|
|||||||
const lastCommandRef = useRef(usePartyStore.getState().command);
|
const lastCommandRef = useRef(usePartyStore.getState().command);
|
||||||
const lastRecallRevisionRef = useRef(usePartyStore.getState().recallRevision);
|
const lastRecallRevisionRef = useRef(usePartyStore.getState().recallRevision);
|
||||||
const stuckProgressRef = useRef(new Map<string, PartyStuckProgress>());
|
const stuckProgressRef = useRef(new Map<string, PartyStuckProgress>());
|
||||||
|
const movementProfilesRef = useRef(new Map<string, PartyMovementProfile>());
|
||||||
|
const directFollowMovingRef = useRef(new Map<string, boolean>());
|
||||||
|
const combatAnchorsRef = useRef(new Map<string, PartyCombatAnchor>());
|
||||||
const memberIdKey = members.map((member) => member.id).join("|");
|
const memberIdKey = members.map((member) => member.id).join("|");
|
||||||
const teleportPartyToPlayer = useCallback((player: PartyWorldPosition, now: number): void => {
|
const teleportPartyToPlayer = useCallback((player: PartyWorldPosition, now: number): void => {
|
||||||
const party = usePartyStore.getState();
|
const party = usePartyStore.getState();
|
||||||
@@ -448,6 +457,8 @@ function PartyActors({
|
|||||||
trailLeaderIdRef.current = firstLiving?.id ?? null;
|
trailLeaderIdRef.current = firstLiving?.id ?? null;
|
||||||
routeRef.current = emptyPartyRouteState();
|
routeRef.current = emptyPartyRouteState();
|
||||||
combatRoutesRef.current.clear();
|
combatRoutesRef.current.clear();
|
||||||
|
directFollowMovingRef.current.clear();
|
||||||
|
combatAnchorsRef.current.clear();
|
||||||
followerCursorsRef.current.clear();
|
followerCursorsRef.current.clear();
|
||||||
lastActiveTargetIdRef.current = party.activeMobId;
|
lastActiveTargetIdRef.current = party.activeMobId;
|
||||||
lastLeaderWaypointRef.current = null;
|
lastLeaderWaypointRef.current = null;
|
||||||
@@ -473,6 +484,17 @@ function PartyActors({
|
|||||||
for (const id of stuckProgressRef.current.keys()) {
|
for (const id of stuckProgressRef.current.keys()) {
|
||||||
if (!activeIds.has(id)) stuckProgressRef.current.delete(id);
|
if (!activeIds.has(id)) stuckProgressRef.current.delete(id);
|
||||||
}
|
}
|
||||||
|
for (const id of directFollowMovingRef.current.keys()) {
|
||||||
|
if (!activeIds.has(id)) directFollowMovingRef.current.delete(id);
|
||||||
|
}
|
||||||
|
for (const id of combatAnchorsRef.current.keys()) {
|
||||||
|
if (!activeIds.has(id)) combatAnchorsRef.current.delete(id);
|
||||||
|
}
|
||||||
|
for (const key of movementProfilesRef.current.keys()) {
|
||||||
|
if (![...activeIds].some((id) => key.startsWith(`${id}|`))) {
|
||||||
|
movementProfilesRef.current.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
const cleanups = members.map((member, index) => {
|
const cleanups = members.map((member, index) => {
|
||||||
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
||||||
positionsRef.current.set(member.id, position);
|
positionsRef.current.set(member.id, position);
|
||||||
@@ -534,6 +556,8 @@ function PartyActors({
|
|||||||
trailLeaderIdRef.current = firstLiving?.id ?? null;
|
trailLeaderIdRef.current = firstLiving?.id ?? null;
|
||||||
followerCursorsRef.current.clear();
|
followerCursorsRef.current.clear();
|
||||||
combatRoutesRef.current.clear();
|
combatRoutesRef.current.clear();
|
||||||
|
directFollowMovingRef.current.clear();
|
||||||
|
combatAnchorsRef.current.clear();
|
||||||
lastActiveTargetIdRef.current = null;
|
lastActiveTargetIdRef.current = null;
|
||||||
lastLeaderWaypointRef.current = null;
|
lastLeaderWaypointRef.current = null;
|
||||||
routeRef.current = emptyPartyRouteState();
|
routeRef.current = emptyPartyRouteState();
|
||||||
@@ -561,6 +585,8 @@ function PartyActors({
|
|||||||
if (leaderPosition) resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition);
|
if (leaderPosition) resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition);
|
||||||
followerCursorsRef.current.clear();
|
followerCursorsRef.current.clear();
|
||||||
stuckProgressRef.current.clear();
|
stuckProgressRef.current.clear();
|
||||||
|
directFollowMovingRef.current.clear();
|
||||||
|
combatAnchorsRef.current.clear();
|
||||||
}, [navigationGraph]);
|
}, [navigationGraph]);
|
||||||
|
|
||||||
// Time spent in a pause/menu must not count toward the stuck watchdog.
|
// Time spent in a pause/menu must not count toward the stuck watchdog.
|
||||||
@@ -575,24 +601,39 @@ function PartyActors({
|
|||||||
const party = usePartyStore.getState();
|
const party = usePartyStore.getState();
|
||||||
if (!party.members.length) return;
|
if (!party.members.length) return;
|
||||||
const game = useGameStore.getState();
|
const game = useGameStore.getState();
|
||||||
const player = game.playerPosition;
|
const playerNavigation = game.playerNavigationPosition;
|
||||||
appendPartyBreadcrumb(playerTrailRef.current, player);
|
appendPartyBreadcrumb(playerTrailRef.current, playerNavigation);
|
||||||
const routeNow = clock.elapsedTime * 1_000;
|
const routeNow = clock.elapsedTime * 1_000;
|
||||||
|
const playerTrail = playerTrailRef.current.points;
|
||||||
|
const latestPlayerTrailPoint = playerTrail[playerTrail.length - 1];
|
||||||
|
const previousPlayerTrailPoint = playerTrail[playerTrail.length - 2];
|
||||||
|
let playerForwardX = Math.sin(game.cameraYaw);
|
||||||
|
let playerForwardZ = Math.cos(game.cameraYaw);
|
||||||
|
if (latestPlayerTrailPoint && previousPlayerTrailPoint) {
|
||||||
|
const trailX = latestPlayerTrailPoint.position[0] - previousPlayerTrailPoint.position[0];
|
||||||
|
const trailZ = latestPlayerTrailPoint.position[2] - previousPlayerTrailPoint.position[2];
|
||||||
|
if (Math.hypot(trailX, trailZ) > 0.05) {
|
||||||
|
playerForwardX = trailX;
|
||||||
|
playerForwardZ = trailZ;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (lastRecallRevisionRef.current !== party.recallRevision) {
|
if (lastRecallRevisionRef.current !== party.recallRevision) {
|
||||||
lastRecallRevisionRef.current = party.recallRevision;
|
lastRecallRevisionRef.current = party.recallRevision;
|
||||||
teleportPartyToPlayer(player, routeNow);
|
teleportPartyToPlayer(playerNavigation, routeNow);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (lastCommandRef.current !== party.command) {
|
if (lastCommandRef.current !== party.command) {
|
||||||
lastCommandRef.current = party.command;
|
lastCommandRef.current = party.command;
|
||||||
routeRef.current = emptyPartyRouteState();
|
routeRef.current = emptyPartyRouteState();
|
||||||
combatRoutesRef.current.clear();
|
combatRoutesRef.current.clear();
|
||||||
|
directFollowMovingRef.current.clear();
|
||||||
|
combatAnchorsRef.current.clear();
|
||||||
followerCursorsRef.current.clear();
|
followerCursorsRef.current.clear();
|
||||||
const livingLeader = party.members.find((member) => member.health > 0);
|
const livingLeader = party.members.find((member) => member.health > 0);
|
||||||
const livingLeaderPosition = livingLeader ? positionsRef.current.get(livingLeader.id) : null;
|
const livingLeaderPosition = livingLeader ? positionsRef.current.get(livingLeader.id) : null;
|
||||||
resetPartyBreadcrumbTrail(
|
resetPartyBreadcrumbTrail(
|
||||||
leaderTrailRef.current,
|
leaderTrailRef.current,
|
||||||
livingLeaderPosition ?? player,
|
livingLeaderPosition ?? playerNavigation,
|
||||||
);
|
);
|
||||||
lastLeaderWaypointRef.current = null;
|
lastLeaderWaypointRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -602,9 +643,29 @@ function PartyActors({
|
|||||||
const desired: MutablePartyWorldPosition = [0, 0, 0];
|
const desired: MutablePartyWorldPosition = [0, 0, 0];
|
||||||
const nextPosition: MutablePartyWorldPosition = [0, 0, 0];
|
const nextPosition: MutablePartyWorldPosition = [0, 0, 0];
|
||||||
const leaderDestination: MutablePartyWorldPosition = [0, 0, 0];
|
const leaderDestination: MutablePartyWorldPosition = [0, 0, 0];
|
||||||
|
const naturalFollowTarget: MutablePartyWorldPosition = [0, 0, 0];
|
||||||
|
const separatedDesired: MutablePartyWorldPosition = [0, 0, 0];
|
||||||
const livingIndices = party.members
|
const livingIndices = party.members
|
||||||
.map((member, index) => member.health > 0 ? index : -1)
|
.map((member, index) => member.health > 0 ? index : -1)
|
||||||
.filter((index) => index >= 0);
|
.filter((index) => index >= 0);
|
||||||
|
const livingIds = new Set(livingIndices.map((index) => party.members[index].id));
|
||||||
|
const movementProfileFor = (memberId: string, memberIndex: number): PartyMovementProfile => {
|
||||||
|
const livingRank = Math.max(0, livingIndices.indexOf(memberIndex));
|
||||||
|
const key = `${memberId}|${livingRank}`;
|
||||||
|
let profile = movementProfilesRef.current.get(key);
|
||||||
|
if (!profile) {
|
||||||
|
profile = partyMovementProfile(memberId, livingRank);
|
||||||
|
movementProfilesRef.current.set(key, profile);
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
};
|
||||||
|
const peerPositionsFor = (memberId: string): PartyWorldPosition[] => {
|
||||||
|
const peers: PartyWorldPosition[] = [];
|
||||||
|
for (const [id, position] of positionsRef.current) {
|
||||||
|
if (id !== memberId && livingIds.has(id)) peers.push(position);
|
||||||
|
}
|
||||||
|
return peers;
|
||||||
|
};
|
||||||
let leaderHasWaypoint = false;
|
let leaderHasWaypoint = false;
|
||||||
let leaderMovementExpected = false;
|
let leaderMovementExpected = false;
|
||||||
let leaderFollowingPlayer = false;
|
let leaderFollowingPlayer = false;
|
||||||
@@ -623,7 +684,7 @@ function PartyActors({
|
|||||||
routeRef.current = emptyPartyRouteState();
|
routeRef.current = emptyPartyRouteState();
|
||||||
combatRoutesRef.current.clear();
|
combatRoutesRef.current.clear();
|
||||||
followerCursorsRef.current.clear();
|
followerCursorsRef.current.clear();
|
||||||
resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition ?? player);
|
resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition ?? playerNavigation);
|
||||||
lastLeaderWaypointRef.current = null;
|
lastLeaderWaypointRef.current = null;
|
||||||
}
|
}
|
||||||
if (leaderMember && leaderPosition && trailLeaderIdRef.current !== leaderMember.id) {
|
if (leaderMember && leaderPosition && trailLeaderIdRef.current !== leaderMember.id) {
|
||||||
@@ -631,6 +692,7 @@ function PartyActors({
|
|||||||
trailLeaderIdRef.current = leaderMember.id;
|
trailLeaderIdRef.current = leaderMember.id;
|
||||||
followerCursorsRef.current.clear();
|
followerCursorsRef.current.clear();
|
||||||
combatRoutesRef.current.clear();
|
combatRoutesRef.current.clear();
|
||||||
|
combatAnchorsRef.current.clear();
|
||||||
lastLeaderWaypointRef.current = null;
|
lastLeaderWaypointRef.current = null;
|
||||||
routeRef.current = emptyPartyRouteState();
|
routeRef.current = emptyPartyRouteState();
|
||||||
}
|
}
|
||||||
@@ -640,18 +702,32 @@ function PartyActors({
|
|||||||
let goalKey: string;
|
let goalKey: string;
|
||||||
let hasDestination = true;
|
let hasDestination = true;
|
||||||
if (targetPosition && leaderTargetId) {
|
if (targetPosition && leaderTargetId) {
|
||||||
const rangeBand = partyCombatRangeBand(leader);
|
const profile = movementProfileFor(leader.id, leaderIndex);
|
||||||
const actor = actorRefs.current[leaderIndex];
|
const rangeBand = personalizedPartyCombatRangeBand(
|
||||||
const movementIntent = partyCombatMovementGoal(
|
partyCombatRangeBand(leader),
|
||||||
|
profile,
|
||||||
|
);
|
||||||
|
let combatAnchor = combatAnchorsRef.current.get(leader.id);
|
||||||
|
if (!combatAnchor || combatAnchor.targetId !== leaderTargetId) {
|
||||||
|
combatAnchor = {
|
||||||
|
targetId: leaderTargetId,
|
||||||
|
bearing: Math.atan2(
|
||||||
|
leaderPosition[0] - targetPosition[0],
|
||||||
|
leaderPosition[2] - targetPosition[2],
|
||||||
|
) + profile.combatAngleOffset,
|
||||||
|
};
|
||||||
|
combatAnchorsRef.current.set(leader.id, combatAnchor);
|
||||||
|
}
|
||||||
|
const peers = peerPositionsFor(leader.id);
|
||||||
|
const crowded = peers.some((peer) => planarDistance(leaderPosition, peer) < 0.85);
|
||||||
|
const movementIntent = partyPersonalizedCombatMovementGoal(
|
||||||
leaderPosition,
|
leaderPosition,
|
||||||
targetPosition,
|
targetPosition,
|
||||||
hasStaticLineOfSight(leaderPosition, targetPosition),
|
hasStaticLineOfSight(leaderPosition, targetPosition),
|
||||||
rangeBand.minimum,
|
rangeBand,
|
||||||
rangeBand.preferred,
|
combatAnchor.bearing,
|
||||||
rangeBand.maximum,
|
crowded,
|
||||||
leaderDestination,
|
leaderDestination,
|
||||||
actor ? Math.sin(actor.rotation.y) : 0,
|
|
||||||
actor ? Math.cos(actor.rotation.y) : 1,
|
|
||||||
);
|
);
|
||||||
if (movementIntent === "hold") {
|
if (movementIntent === "hold") {
|
||||||
hasDestination = false;
|
hasDestination = false;
|
||||||
@@ -677,10 +753,9 @@ function PartyActors({
|
|||||||
goalKey = `leader:${leader.id}|objective:${objective.id}`;
|
goalKey = `leader:${leader.id}|objective:${objective.id}`;
|
||||||
} else {
|
} else {
|
||||||
leaderFollowingPlayer = true;
|
leaderFollowingPlayer = true;
|
||||||
leaderDestination[0] = player[0];
|
leaderDestination[0] = playerNavigation[0];
|
||||||
leaderDestination[1] = player[1];
|
leaderDestination[1] = playerNavigation[1];
|
||||||
leaderDestination[2] = player[2];
|
leaderDestination[2] = playerNavigation[2];
|
||||||
if (fullDistance(leaderPosition, player) <= PARTY_FORMATION[0][1]) hasDestination = false;
|
|
||||||
goalKey = `leader:${leader.id}|player`;
|
goalKey = `leader:${leader.id}|player`;
|
||||||
}
|
}
|
||||||
leaderMovementExpected = hasDestination
|
leaderMovementExpected = hasDestination
|
||||||
@@ -689,28 +764,68 @@ function PartyActors({
|
|||||||
if (leaderFollowingPlayer) {
|
if (leaderFollowingPlayer) {
|
||||||
routeRef.current = { ...emptyPartyRouteState(), goalKey };
|
routeRef.current = { ...emptyPartyRouteState(), goalKey };
|
||||||
const cursor = leaderPlayerCursorRef.current;
|
const cursor = leaderPlayerCursorRef.current;
|
||||||
const previousBreadcrumbId = cursor.breadcrumbId;
|
const profile = movementProfileFor(leader.id, leaderIndex);
|
||||||
const requiresValidatedRejoin = cursor.breadcrumbId === null
|
const baseTrailingDistance = PARTY_PLAYER_TRAILING_DISTANCE[0];
|
||||||
|| !playerTrailRef.current.points.some((point) => point.id === cursor.breadcrumbId);
|
const personalizedTrailingDistance = Math.max(
|
||||||
leaderHasWaypoint = nextPartyBreadcrumbFollowWaypoint(
|
0.9,
|
||||||
playerTrailRef.current,
|
baseTrailingDistance + profile.followDistanceJitter,
|
||||||
cursor,
|
|
||||||
leaderPosition,
|
|
||||||
PARTY_FORMATION[0][1],
|
|
||||||
BREADCRUMB_REACHED_DISTANCE,
|
|
||||||
desired,
|
|
||||||
);
|
);
|
||||||
if (
|
partyNaturalFollowPosition(
|
||||||
leaderHasWaypoint
|
playerNavigation,
|
||||||
&& requiresValidatedRejoin
|
playerForwardX,
|
||||||
&& !directSegmentAllowed(leaderPosition, desired)
|
playerForwardZ,
|
||||||
) {
|
baseTrailingDistance,
|
||||||
cursor.breadcrumbId = previousBreadcrumbId;
|
profile,
|
||||||
leaderHasWaypoint = false;
|
routeNow,
|
||||||
leaderPlayerTrailBlocked = true;
|
leaderDestination,
|
||||||
|
);
|
||||||
|
if (directSegmentAllowed(leaderPosition, leaderDestination)) {
|
||||||
|
const wasMoving = directFollowMovingRef.current.get(leader.id) ?? false;
|
||||||
|
leaderHasWaypoint = partyShouldMoveToComfortTarget(
|
||||||
|
leaderPosition,
|
||||||
|
leaderDestination,
|
||||||
|
wasMoving,
|
||||||
|
profile.comfortSlack,
|
||||||
|
);
|
||||||
|
directFollowMovingRef.current.set(leader.id, leaderHasWaypoint);
|
||||||
|
synchronizePartyBreadcrumbCursor(
|
||||||
|
playerTrailRef.current,
|
||||||
|
cursor,
|
||||||
|
personalizedTrailingDistance,
|
||||||
|
);
|
||||||
|
if (leaderHasWaypoint) {
|
||||||
|
desired[0] = leaderDestination[0];
|
||||||
|
desired[1] = leaderDestination[1];
|
||||||
|
desired[2] = leaderDestination[2];
|
||||||
|
}
|
||||||
|
leaderMovementExpected = leaderHasWaypoint;
|
||||||
|
} else {
|
||||||
|
directFollowMovingRef.current.set(leader.id, false);
|
||||||
|
const previousBreadcrumbId = cursor.breadcrumbId;
|
||||||
|
const requiresValidatedRejoin = cursor.breadcrumbId === null
|
||||||
|
|| !playerTrailRef.current.points.some((point) => point.id === cursor.breadcrumbId);
|
||||||
|
leaderHasWaypoint = nextPartyBreadcrumbFollowWaypoint(
|
||||||
|
playerTrailRef.current,
|
||||||
|
cursor,
|
||||||
|
leaderPosition,
|
||||||
|
personalizedTrailingDistance,
|
||||||
|
BREADCRUMB_REACHED_DISTANCE,
|
||||||
|
desired,
|
||||||
|
profile.comfortSlack,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
leaderHasWaypoint
|
||||||
|
&& requiresValidatedRejoin
|
||||||
|
&& !directSegmentAllowed(leaderPosition, desired)
|
||||||
|
) {
|
||||||
|
cursor.breadcrumbId = previousBreadcrumbId;
|
||||||
|
leaderHasWaypoint = false;
|
||||||
|
leaderPlayerTrailBlocked = true;
|
||||||
|
}
|
||||||
|
leaderMovementExpected = leaderHasWaypoint
|
||||||
|
|| planarDistance(leaderPosition, playerNavigation)
|
||||||
|
> personalizedTrailingDistance + profile.comfortSlack;
|
||||||
}
|
}
|
||||||
leaderMovementExpected = leaderHasWaypoint
|
|
||||||
|| planarDistance(leaderPosition, player) > PARTY_FORMATION[0][1] + 0.75;
|
|
||||||
} else if (!hasDestination || fullDistance(leaderPosition, leaderDestination) <= 0.18) {
|
} else if (!hasDestination || fullDistance(leaderPosition, leaderDestination) <= 0.18) {
|
||||||
if (
|
if (
|
||||||
routeRef.current.goalKey !== goalKey
|
routeRef.current.goalKey !== goalKey
|
||||||
@@ -860,17 +975,32 @@ function PartyActors({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (memberTargetPosition && memberTargetId) {
|
if (memberTargetPosition && memberTargetId) {
|
||||||
const rangeBand = partyCombatRangeBand(member);
|
const profile = movementProfileFor(member.id, index);
|
||||||
const movementIntent = partyCombatMovementGoal(
|
const rangeBand = personalizedPartyCombatRangeBand(
|
||||||
|
partyCombatRangeBand(member),
|
||||||
|
profile,
|
||||||
|
);
|
||||||
|
let combatAnchor = combatAnchorsRef.current.get(member.id);
|
||||||
|
if (!combatAnchor || combatAnchor.targetId !== memberTargetId) {
|
||||||
|
combatAnchor = {
|
||||||
|
targetId: memberTargetId,
|
||||||
|
bearing: Math.atan2(
|
||||||
|
current[0] - memberTargetPosition[0],
|
||||||
|
current[2] - memberTargetPosition[2],
|
||||||
|
) + profile.combatAngleOffset,
|
||||||
|
};
|
||||||
|
combatAnchorsRef.current.set(member.id, combatAnchor);
|
||||||
|
}
|
||||||
|
const peers = peerPositionsFor(member.id);
|
||||||
|
const crowded = peers.some((peer) => planarDistance(current, peer) < 0.85);
|
||||||
|
const movementIntent = partyPersonalizedCombatMovementGoal(
|
||||||
current,
|
current,
|
||||||
memberTargetPosition,
|
memberTargetPosition,
|
||||||
canSeeActiveTarget,
|
canSeeActiveTarget,
|
||||||
rangeBand.minimum,
|
rangeBand,
|
||||||
rangeBand.preferred,
|
combatAnchor.bearing,
|
||||||
rangeBand.maximum,
|
crowded,
|
||||||
desired,
|
desired,
|
||||||
Math.sin(actor.rotation.y),
|
|
||||||
Math.cos(actor.rotation.y),
|
|
||||||
);
|
);
|
||||||
movementExpected = movementIntent !== "hold";
|
movementExpected = movementIntent !== "hold";
|
||||||
if (movementIntent === "hold") {
|
if (movementIntent === "hold") {
|
||||||
@@ -933,30 +1063,71 @@ function PartyActors({
|
|||||||
cursor = createPartyBreadcrumbCursor();
|
cursor = createPartyBreadcrumbCursor();
|
||||||
followerCursorsRef.current.set(member.id, cursor);
|
followerCursorsRef.current.set(member.id, cursor);
|
||||||
}
|
}
|
||||||
const previousBreadcrumbId = cursor.breadcrumbId;
|
|
||||||
const requiresValidatedRejoin = cursor.breadcrumbId === null
|
|
||||||
|| !leaderTrailRef.current.points.some((point) => point.id === cursor!.breadcrumbId);
|
|
||||||
const livingRank = Math.max(1, livingIndices.indexOf(index));
|
const livingRank = Math.max(1, livingIndices.indexOf(index));
|
||||||
const trailingDistance = PARTY_TRAIL_DISTANCE[livingRank] ?? livingRank * 1.15;
|
const profile = movementProfileFor(member.id, index);
|
||||||
hasDesired = nextPartyBreadcrumbFollowWaypoint(
|
const playerTrailingDistance = PARTY_PLAYER_TRAILING_DISTANCE[livingRank]
|
||||||
leaderTrailRef.current,
|
?? 1.45 + livingRank * 1.15;
|
||||||
cursor,
|
const leaderTrailingDistance = Math.max(
|
||||||
current,
|
0.9,
|
||||||
trailingDistance,
|
(PARTY_TRAIL_DISTANCE[livingRank] ?? livingRank * 1.15)
|
||||||
BREADCRUMB_REACHED_DISTANCE,
|
+ profile.followDistanceJitter,
|
||||||
desired,
|
|
||||||
);
|
);
|
||||||
if (
|
partyNaturalFollowPosition(
|
||||||
hasDesired
|
playerNavigation,
|
||||||
&& requiresValidatedRejoin
|
playerForwardX,
|
||||||
&& !directSegmentAllowed(current, desired)
|
playerForwardZ,
|
||||||
) {
|
playerTrailingDistance,
|
||||||
cursor.breadcrumbId = previousBreadcrumbId;
|
profile,
|
||||||
hasDesired = false;
|
routeNow,
|
||||||
frameRouteBlocked = true;
|
naturalFollowTarget,
|
||||||
|
);
|
||||||
|
if (directSegmentAllowed(current, naturalFollowTarget)) {
|
||||||
|
const wasMoving = directFollowMovingRef.current.get(member.id) ?? false;
|
||||||
|
hasDesired = partyShouldMoveToComfortTarget(
|
||||||
|
current,
|
||||||
|
naturalFollowTarget,
|
||||||
|
wasMoving,
|
||||||
|
profile.comfortSlack,
|
||||||
|
);
|
||||||
|
directFollowMovingRef.current.set(member.id, hasDesired);
|
||||||
|
synchronizePartyBreadcrumbCursor(
|
||||||
|
leaderTrailRef.current,
|
||||||
|
cursor,
|
||||||
|
leaderTrailingDistance,
|
||||||
|
);
|
||||||
|
if (hasDesired) {
|
||||||
|
desired[0] = naturalFollowTarget[0];
|
||||||
|
desired[1] = naturalFollowTarget[1];
|
||||||
|
desired[2] = naturalFollowTarget[2];
|
||||||
|
}
|
||||||
|
movementExpected = hasDesired;
|
||||||
|
} else {
|
||||||
|
directFollowMovingRef.current.set(member.id, false);
|
||||||
|
const previousBreadcrumbId = cursor.breadcrumbId;
|
||||||
|
const requiresValidatedRejoin = cursor.breadcrumbId === null
|
||||||
|
|| !leaderTrailRef.current.points.some((point) => point.id === cursor!.breadcrumbId);
|
||||||
|
hasDesired = nextPartyBreadcrumbFollowWaypoint(
|
||||||
|
leaderTrailRef.current,
|
||||||
|
cursor,
|
||||||
|
current,
|
||||||
|
leaderTrailingDistance,
|
||||||
|
BREADCRUMB_REACHED_DISTANCE,
|
||||||
|
desired,
|
||||||
|
profile.comfortSlack,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
hasDesired
|
||||||
|
&& requiresValidatedRejoin
|
||||||
|
&& !directSegmentAllowed(current, desired)
|
||||||
|
) {
|
||||||
|
cursor.breadcrumbId = previousBreadcrumbId;
|
||||||
|
hasDesired = false;
|
||||||
|
frameRouteBlocked = true;
|
||||||
|
}
|
||||||
|
movementExpected = hasDesired
|
||||||
|
|| planarDistance(current, leaderPosition ?? playerNavigation)
|
||||||
|
> leaderTrailingDistance + profile.comfortSlack;
|
||||||
}
|
}
|
||||||
movementExpected = hasDesired
|
|
||||||
|| planarDistance(current, leaderPosition ?? player) > trailingDistance + 0.75;
|
|
||||||
}
|
}
|
||||||
if (memberTargetPosition) {
|
if (memberTargetPosition) {
|
||||||
faceX = memberTargetPosition[0] - current[0];
|
faceX = memberTargetPosition[0] - current[0];
|
||||||
@@ -967,9 +1138,29 @@ function PartyActors({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentPlanarDistance = planarDistance(current, player);
|
const movementProfile = movementProfileFor(member.id, index);
|
||||||
|
if (hasDesired) {
|
||||||
|
const peers = peerPositionsFor(member.id);
|
||||||
|
if (
|
||||||
|
partySeparationGoal(
|
||||||
|
current,
|
||||||
|
desired,
|
||||||
|
peers,
|
||||||
|
movementProfile.swayPhase,
|
||||||
|
separatedDesired,
|
||||||
|
)
|
||||||
|
&& directSegmentAllowed(current, separatedDesired)
|
||||||
|
) {
|
||||||
|
desired[0] = separatedDesired[0];
|
||||||
|
desired[1] = separatedDesired[1];
|
||||||
|
desired[2] = separatedDesired[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPlanarDistance = planarDistance(current, playerNavigation);
|
||||||
if (member.health > 0 && party.command !== "stop" && hasDesired) {
|
if (member.health > 0 && party.command !== "stop" && hasDesired) {
|
||||||
const speed = currentPlanarDistance > 8 ? PARTY_CATCH_UP_SPEED : PARTY_MOVE_SPEED;
|
const speed = (currentPlanarDistance > 8 ? PARTY_CATCH_UP_SPEED : PARTY_MOVE_SPEED)
|
||||||
|
* movementProfile.speedMultiplier;
|
||||||
const moved = stepPartyPosition(current, desired, speed * safeDelta, nextPosition);
|
const moved = stepPartyPosition(current, desired, speed * safeDelta, nextPosition);
|
||||||
// Route progress, not radial player distance, is authoritative. A
|
// Route progress, not radial player distance, is authoritative. A
|
||||||
// valid path may need to move away briefly around a U-bend.
|
// valid path may need to move away briefly around a U-bend.
|
||||||
@@ -1015,7 +1206,7 @@ function PartyActors({
|
|||||||
stuckProgress.lastProgressAt = routeNow;
|
stuckProgress.lastProgressAt = routeNow;
|
||||||
} else if (partyShouldRecallForStuck(
|
} else if (partyShouldRecallForStuck(
|
||||||
current,
|
current,
|
||||||
player,
|
playerNavigation,
|
||||||
movementExpected,
|
movementExpected,
|
||||||
routeNow,
|
routeNow,
|
||||||
stuckProgress.lastProgressAt,
|
stuckProgress.lastProgressAt,
|
||||||
@@ -1042,7 +1233,7 @@ function PartyActors({
|
|||||||
if (autoRecallRequested) {
|
if (autoRecallRequested) {
|
||||||
party.recallParty();
|
party.recallParty();
|
||||||
lastRecallRevisionRef.current = usePartyStore.getState().recallRevision;
|
lastRecallRevisionRef.current = usePartyStore.getState().recallRevision;
|
||||||
teleportPartyToPlayer(player, routeNow);
|
teleportPartyToPlayer(playerNavigation, routeNow);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
party.setRouteBlocked(frameRouteBlocked);
|
party.setRouteBlocked(frameRouteBlocked);
|
||||||
|
|||||||
+90
-9
@@ -13,7 +13,12 @@ import { useShellStore } from "../app/shellStore";
|
|||||||
import type { CharacterProfile } from "../app/types";
|
import type { CharacterProfile } from "../app/types";
|
||||||
import { CharacterModel } from "../avatar/CharacterModel";
|
import { CharacterModel } from "../avatar/CharacterModel";
|
||||||
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
|
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
|
||||||
import { consumeMouseLook, readInputSnapshot } from "../game/inputManager";
|
import {
|
||||||
|
clearJumpRequest,
|
||||||
|
consumeJumpRequest,
|
||||||
|
consumeMouseLook,
|
||||||
|
readInputSnapshot,
|
||||||
|
} from "../game/inputManager";
|
||||||
import { useCombatStore } from "../game/combatStore";
|
import { useCombatStore } from "../game/combatStore";
|
||||||
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
|
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +38,18 @@ import {
|
|||||||
isBelowDungeonRecoveryPlane,
|
isBelowDungeonRecoveryPlane,
|
||||||
isBelowManastormRecoveryPlane,
|
isBelowManastormRecoveryPlane,
|
||||||
} from "../game/playerRecovery";
|
} from "../game/playerRecovery";
|
||||||
|
import {
|
||||||
|
PLAYER_GROUND_PROBE_DISTANCE,
|
||||||
|
PLAYER_JUMP_VELOCITY,
|
||||||
|
PLAYER_LANDING_PRESENTATION_MS,
|
||||||
|
bufferPlayerJump,
|
||||||
|
cancelBufferedPlayerJump,
|
||||||
|
characterVerticalMotion,
|
||||||
|
createPlayerJumpTiming,
|
||||||
|
isWalkableGroundHit,
|
||||||
|
updatePlayerJumpTiming,
|
||||||
|
type CharacterVerticalMotion,
|
||||||
|
} from "../game/playerJump";
|
||||||
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
||||||
import { useGameStore } from "../game/store";
|
import { useGameStore } from "../game/store";
|
||||||
|
|
||||||
@@ -62,6 +79,7 @@ interface PlayerControllerProps extends PlayerRigRefs {
|
|||||||
identity: CharacterProfile | null;
|
identity: CharacterProfile | null;
|
||||||
equipmentItems: readonly InventoryItem[];
|
equipmentItems: readonly InventoryItem[];
|
||||||
movingRef: React.MutableRefObject<boolean>;
|
movingRef: React.MutableRefObject<boolean>;
|
||||||
|
verticalMotionRef: React.MutableRefObject<CharacterVerticalMotion>;
|
||||||
animationEventRef: React.MutableRefObject<CharacterCombatAnimationEvent | null>;
|
animationEventRef: React.MutableRefObject<CharacterCombatAnimationEvent | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +103,7 @@ function PlayerController({
|
|||||||
identity,
|
identity,
|
||||||
equipmentItems,
|
equipmentItems,
|
||||||
movingRef,
|
movingRef,
|
||||||
|
verticalMotionRef,
|
||||||
animationEventRef,
|
animationEventRef,
|
||||||
}: PlayerControllerProps) {
|
}: PlayerControllerProps) {
|
||||||
const paused = useGameStore((state) => state.paused);
|
const paused = useGameStore((state) => state.paused);
|
||||||
@@ -98,11 +117,24 @@ function PlayerController({
|
|||||||
const spawn = useGameStore((state) => state.activeSpawn);
|
const spawn = useGameStore((state) => state.activeSpawn);
|
||||||
const dungeon = requireDungeonDefinition(activeDungeonId);
|
const dungeon = requireDungeonDefinition(activeDungeonId);
|
||||||
const lastReported = useRef<[number, number, number]>([...spawn.footPosition]);
|
const lastReported = useRef<[number, number, number]>([...spawn.footPosition]);
|
||||||
|
const { rapier, world } = useRapier();
|
||||||
|
const groundRay = useMemo(
|
||||||
|
() => new rapier.Ray({ x: 0, y: 0, z: 0 }, { x: 0, y: -1, z: 0 }),
|
||||||
|
[rapier],
|
||||||
|
);
|
||||||
|
const jumpTimingRef = useRef(createPlayerJumpTiming());
|
||||||
|
const wasGroundedRef = useRef(true);
|
||||||
|
const landingUntilRef = useRef(Number.NEGATIVE_INFINITY);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bodyRef.current) placeAtActiveSpawn(bodyRef.current, orbitRef.current);
|
if (bodyRef.current) placeAtActiveSpawn(bodyRef.current, orbitRef.current);
|
||||||
lastReported.current = [...spawn.footPosition];
|
lastReported.current = [...spawn.footPosition];
|
||||||
}, [bodyRef, orbitRef, resetRevision, spawn]);
|
clearJumpRequest();
|
||||||
|
jumpTimingRef.current = createPlayerJumpTiming(performance.now());
|
||||||
|
wasGroundedRef.current = true;
|
||||||
|
landingUntilRef.current = Number.NEGATIVE_INFINITY;
|
||||||
|
verticalMotionRef.current = "grounded";
|
||||||
|
}, [bodyRef, orbitRef, resetRevision, spawn, verticalMotionRef]);
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
const body = bodyRef.current;
|
const body = bodyRef.current;
|
||||||
@@ -131,18 +163,68 @@ function PlayerController({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const velocity = body.linvel();
|
const velocity = body.linvel();
|
||||||
if (paused || mapOpen || companionOpen || controlledUntil > Date.now()) {
|
const translation = body.translation();
|
||||||
|
if (![translation.x, translation.y, translation.z].every(Number.isFinite)) {
|
||||||
|
placeAtActiveSpawn(body, orbit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowMs = performance.now();
|
||||||
|
groundRay.origin.x = translation.x;
|
||||||
|
groundRay.origin.y = translation.y;
|
||||||
|
groundRay.origin.z = translation.z;
|
||||||
|
const groundProbeLength = PLAYER_CENTER_HEIGHT + PLAYER_GROUND_PROBE_DISTANCE;
|
||||||
|
const groundHit = world.castRayAndGetNormal(
|
||||||
|
groundRay,
|
||||||
|
groundProbeLength,
|
||||||
|
true,
|
||||||
|
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
let grounded = velocity.y <= 0.5 && isWalkableGroundHit(groundHit, groundProbeLength);
|
||||||
|
const requestedAtMs = consumeJumpRequest();
|
||||||
|
const gameplayBlocked = paused || mapOpen || companionOpen || controlledUntil > Date.now();
|
||||||
|
if (gameplayBlocked) {
|
||||||
|
cancelBufferedPlayerJump(jumpTimingRef.current);
|
||||||
movingRef.current = false;
|
movingRef.current = false;
|
||||||
|
verticalMotionRef.current = characterVerticalMotion(
|
||||||
|
grounded,
|
||||||
|
velocity.y,
|
||||||
|
nowMs,
|
||||||
|
landingUntilRef.current,
|
||||||
|
);
|
||||||
body.setLinvel({ x: 0, y: velocity.y, z: 0 }, true);
|
body.setLinvel({ x: 0, y: velocity.y, z: 0 }, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (requestedAtMs !== null) bufferPlayerJump(jumpTimingRef.current, requestedAtMs);
|
||||||
|
|
||||||
|
const shouldJump = updatePlayerJumpTiming(
|
||||||
|
jumpTimingRef.current,
|
||||||
|
nowMs,
|
||||||
|
grounded,
|
||||||
|
velocity.y,
|
||||||
|
);
|
||||||
|
const verticalVelocity = shouldJump ? PLAYER_JUMP_VELOCITY : velocity.y;
|
||||||
|
if (shouldJump) grounded = false;
|
||||||
|
if (grounded && !wasGroundedRef.current) {
|
||||||
|
landingUntilRef.current = nowMs + PLAYER_LANDING_PRESENTATION_MS;
|
||||||
|
}
|
||||||
|
wasGroundedRef.current = grounded;
|
||||||
|
verticalMotionRef.current = characterVerticalMotion(
|
||||||
|
grounded,
|
||||||
|
verticalVelocity,
|
||||||
|
nowMs,
|
||||||
|
landingUntilRef.current,
|
||||||
|
);
|
||||||
|
|
||||||
const [worldX, worldZ] = cameraRelativeMovement(
|
const [worldX, worldZ] = cameraRelativeMovement(
|
||||||
snapshot.moveX,
|
snapshot.moveX,
|
||||||
snapshot.moveForward,
|
snapshot.moveForward,
|
||||||
orbit.yaw,
|
orbit.yaw,
|
||||||
);
|
);
|
||||||
body.setLinvel({ x: worldX * MOVE_SPEED, y: velocity.y, z: worldZ * MOVE_SPEED }, true);
|
body.setLinvel({ x: worldX * MOVE_SPEED, y: verticalVelocity, z: worldZ * MOVE_SPEED }, true);
|
||||||
|
|
||||||
const moving = Math.hypot(worldX, worldZ) > 0.05;
|
const moving = Math.hypot(worldX, worldZ) > 0.05;
|
||||||
movingRef.current = moving;
|
movingRef.current = moving;
|
||||||
@@ -152,11 +234,6 @@ function PlayerController({
|
|||||||
avatarRef.current.rotation.y += difference * (1 - Math.exp(-14 * delta));
|
avatarRef.current.rotation.y += difference * (1 - Math.exp(-14 * delta));
|
||||||
}
|
}
|
||||||
|
|
||||||
const translation = body.translation();
|
|
||||||
if (![translation.x, translation.y, translation.z].every(Number.isFinite)) {
|
|
||||||
placeAtActiveSpawn(body, orbit);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const activeManastormPackage = gameMode === "manastorm"
|
const activeManastormPackage = gameMode === "manastorm"
|
||||||
? activeManastormStageAssetPackage()
|
? activeManastormStageAssetPackage()
|
||||||
: null;
|
: null;
|
||||||
@@ -185,6 +262,7 @@ function PlayerController({
|
|||||||
position: footPosition,
|
position: footPosition,
|
||||||
yaw: orbit.yaw,
|
yaw: orbit.yaw,
|
||||||
distanceDelta,
|
distanceDelta,
|
||||||
|
grounded,
|
||||||
});
|
});
|
||||||
lastReported.current = footPosition;
|
lastReported.current = footPosition;
|
||||||
}
|
}
|
||||||
@@ -214,6 +292,7 @@ function PlayerController({
|
|||||||
equipment={equipmentItems}
|
equipment={equipmentItems}
|
||||||
active={!paused && !mapOpen && !companionOpen}
|
active={!paused && !mapOpen && !companionOpen}
|
||||||
movingRef={movingRef}
|
movingRef={movingRef}
|
||||||
|
verticalMotionRef={verticalMotionRef}
|
||||||
animationEventRef={animationEventRef}
|
animationEventRef={animationEventRef}
|
||||||
fallback={<ProceduralAvatar accent={classById(identity?.classId ?? "priest").color} />}
|
fallback={<ProceduralAvatar accent={classById(identity?.classId ?? "priest").color} />}
|
||||||
/>
|
/>
|
||||||
@@ -331,6 +410,7 @@ export function PlayerRig() {
|
|||||||
const bodyRef = useRef<RapierRigidBody>(null);
|
const bodyRef = useRef<RapierRigidBody>(null);
|
||||||
const avatarRef = useRef<Object3D>(null);
|
const avatarRef = useRef<Object3D>(null);
|
||||||
const movingRef = useRef(false);
|
const movingRef = useRef(false);
|
||||||
|
const verticalMotionRef = useRef<CharacterVerticalMotion>("grounded");
|
||||||
const animationEventRef = useRef<CharacterCombatAnimationEvent | null>(animationEvent);
|
const animationEventRef = useRef<CharacterCombatAnimationEvent | null>(animationEvent);
|
||||||
animationEventRef.current = animationEvent;
|
animationEventRef.current = animationEvent;
|
||||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||||
@@ -349,6 +429,7 @@ export function PlayerRig() {
|
|||||||
identity={activeCharacter}
|
identity={activeCharacter}
|
||||||
equipmentItems={equipmentItems}
|
equipmentItems={equipmentItems}
|
||||||
movingRef={movingRef}
|
movingRef={movingRef}
|
||||||
|
verticalMotionRef={verticalMotionRef}
|
||||||
animationEventRef={animationEventRef}
|
animationEventRef={animationEventRef}
|
||||||
/>
|
/>
|
||||||
<ThirdPersonCamera bodyRef={bodyRef} orbitRef={orbitRef} avatarRef={avatarRef} />
|
<ThirdPersonCamera bodyRef={bodyRef} orbitRef={orbitRef} avatarRef={avatarRef} />
|
||||||
|
|||||||
@@ -707,6 +707,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
|||||||
.aura-strip { display: flex; flex-wrap: wrap; align-items: center; gap: 3px; }
|
.aura-strip { display: flex; flex-wrap: wrap; align-items: center; gap: 3px; }
|
||||||
.aura { position: relative; display: inline-grid; width: 22px; height: 22px; place-items: center; overflow: hidden; border: 1px solid rgba(255,255,255,.35); border-radius: 3px; color: #fff; background: linear-gradient(145deg, #42669a, #18273e); box-shadow: 0 1px 3px rgba(0,0,0,.7); }
|
.aura { position: relative; display: inline-grid; width: 22px; height: 22px; place-items: center; overflow: hidden; border: 1px solid rgba(255,255,255,.35); border-radius: 3px; color: #fff; background: linear-gradient(145deg, #42669a, #18273e); box-shadow: 0 1px 3px rgba(0,0,0,.7); }
|
||||||
.aura--debuff { border-color: rgba(243,118,105,.75); background: linear-gradient(145deg, #8c3d45, #35191f); }
|
.aura--debuff { border-color: rgba(243,118,105,.75); background: linear-gradient(145deg, #8c3d45, #35191f); }
|
||||||
|
.aura__icon { width: 100%; height: 100%; object-fit: cover; }
|
||||||
.aura__initial { font: 700 11px/1 var(--font-ui); text-shadow: 0 1px 2px #000; }
|
.aura__initial { font: 700 11px/1 var(--font-ui); text-shadow: 0 1px 2px #000; }
|
||||||
.aura__timer { position: absolute; right: 1px; bottom: 0; font: 700 7px/8px var(--font-ui); text-shadow: 0 1px 2px #000; }
|
.aura__timer { position: absolute; right: 1px; bottom: 0; font: 700 7px/8px var(--font-ui); text-shadow: 0 1px 2px #000; }
|
||||||
.aura__stacks { position: absolute; right: 1px; top: 0; font: 700 8px/8px var(--font-ui); color: #fff4b8; text-shadow: 0 1px 2px #000; }
|
.aura__stacks { position: absolute; right: 1px; top: 0; font: 700 8px/8px var(--font-ui); color: #fff4b8; text-shadow: 0 1px 2px #000; }
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { AuraDefinition } from "../game/combatAuras";
|
||||||
|
import { auraDefinitionDisplaysInStrip, auraDefinitionIcon } from "./AuraStrip";
|
||||||
|
|
||||||
|
function aura(overrides: Partial<AuraDefinition> = {}): AuraDefinition {
|
||||||
|
return {
|
||||||
|
id: "test-aura",
|
||||||
|
name: "Test Aura",
|
||||||
|
disposition: "debuff",
|
||||||
|
durationMs: 10_000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("aura strip visibility", () => {
|
||||||
|
it("shows ordinary auras and suppresses periodic shadow auras with dedicated timers", () => {
|
||||||
|
expect(auraDefinitionDisplaysInStrip(aura())).toBe(true);
|
||||||
|
expect(auraDefinitionDisplaysInStrip(aura({ hideFromAuraStrip: true }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses spell artwork while retaining the initial fallback for iconless effects", () => {
|
||||||
|
expect(auraDefinitionIcon(aura({ icon: " /assets/ui/spells/test.png " }))).toBe(
|
||||||
|
"/assets/ui/spells/test.png",
|
||||||
|
);
|
||||||
|
expect(auraDefinitionIcon(aura())).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+14
-2
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { AuraDisposition } from "../game/combatAuras";
|
import type { AuraDefinition, AuraDisposition } from "../game/combatAuras";
|
||||||
import { useCombatStore } from "../game/combatStore";
|
import { useCombatStore } from "../game/combatStore";
|
||||||
import { formatTimedEffectTimer } from "../game/timedEffectDisplay";
|
import { formatTimedEffectTimer } from "../game/timedEffectDisplay";
|
||||||
|
|
||||||
@@ -9,6 +9,14 @@ interface AuraStripProps {
|
|||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function auraDefinitionDisplaysInStrip(definition: AuraDefinition): boolean {
|
||||||
|
return definition.hideFromAuraStrip !== true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auraDefinitionIcon(definition: AuraDefinition): string | null {
|
||||||
|
return definition.icon?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
export function AuraStrip({
|
export function AuraStrip({
|
||||||
targetId,
|
targetId,
|
||||||
dispositions = ["buff", "debuff"],
|
dispositions = ["buff", "debuff"],
|
||||||
@@ -19,6 +27,7 @@ export function AuraStrip({
|
|||||||
const auras = allAuras.filter((aura) => (
|
const auras = allAuras.filter((aura) => (
|
||||||
aura.targetId === targetId
|
aura.targetId === targetId
|
||||||
&& dispositions.includes(aura.definition.disposition)
|
&& dispositions.includes(aura.definition.disposition)
|
||||||
|
&& auraDefinitionDisplaysInStrip(aura.definition)
|
||||||
&& (aura.expiresAt === null || aura.expiresAt > now)
|
&& (aura.expiresAt === null || aura.expiresAt > now)
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -36,6 +45,7 @@ export function AuraStrip({
|
|||||||
const remaining = aura.expiresAt === null ? null : Math.max(0, aura.expiresAt - now);
|
const remaining = aura.expiresAt === null ? null : Math.max(0, aura.expiresAt - now);
|
||||||
const timer = remaining === null ? "" : formatTimedEffectTimer(remaining);
|
const timer = remaining === null ? "" : formatTimedEffectTimer(remaining);
|
||||||
const initial = aura.definition.name.trim().charAt(0).toUpperCase() || "*";
|
const initial = aura.definition.name.trim().charAt(0).toUpperCase() || "*";
|
||||||
|
const icon = auraDefinitionIcon(aura.definition);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={aura.instanceId}
|
key={aura.instanceId}
|
||||||
@@ -43,7 +53,9 @@ export function AuraStrip({
|
|||||||
aria-label={`${aura.definition.name}, ${aura.definition.disposition}${timer ? `, ${timer} remaining` : ""}`}
|
aria-label={`${aura.definition.name}, ${aura.definition.disposition}${timer ? `, ${timer} remaining` : ""}`}
|
||||||
title={`${aura.definition.name}${timer ? ` · ${timer} remaining` : ""}`}
|
title={`${aura.definition.name}${timer ? ` · ${timer} remaining` : ""}`}
|
||||||
>
|
>
|
||||||
<span className="aura__initial" aria-hidden="true">{initial}</span>
|
{icon
|
||||||
|
? <img className="aura__icon" src={icon} alt="" draggable={false} />
|
||||||
|
: <span className="aura__initial" aria-hidden="true">{initial}</span>}
|
||||||
{aura.stacks > 1 ? <b className="aura__stacks">{aura.stacks}</b> : null}
|
{aura.stacks > 1 ? <b className="aura__stacks">{aura.stacks}</b> : null}
|
||||||
{timer ? <b className="aura__timer" aria-hidden="true">{timer}</b> : null}
|
{timer ? <b className="aura__timer" aria-hidden="true">{timer}</b> : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -34,12 +34,15 @@ export function CharacterCreateScreen() {
|
|||||||
const race = raceById(draft.raceId);
|
const race = raceById(draft.raceId);
|
||||||
const characterClass = classById(draft.classId);
|
const characterClass = classById(draft.classId);
|
||||||
const availableRaces = useMemo(() => racesForCategory(draft.categoryId), [draft.categoryId]);
|
const availableRaces = useMemo(() => racesForCategory(draft.categoryId), [draft.categoryId]);
|
||||||
const availableClasses = useMemo(() => classesForRace(draft.raceId), [draft.raceId]);
|
const availableClasses = useMemo(
|
||||||
|
() => classesForRace(draft.raceId, draft.categoryId),
|
||||||
|
[draft.categoryId, draft.raceId],
|
||||||
|
);
|
||||||
const appearanceLabels = race.labels[draft.gender];
|
const appearanceLabels = race.labels[draft.gender];
|
||||||
const appearanceCounts = race.counts[draft.gender];
|
const appearanceCounts = race.counts[draft.gender];
|
||||||
|
|
||||||
const chooseRace = (raceId: RaceId) => {
|
const chooseRace = (raceId: RaceId) => {
|
||||||
const nextClasses = classesForRace(raceId);
|
const nextClasses = classesForRace(raceId, draft.categoryId);
|
||||||
const nextGenders = supportedGenders(raceId);
|
const nextGenders = supportedGenders(raceId);
|
||||||
const classId = nextClasses.some((definition) => definition.id === draft.classId)
|
const classId = nextClasses.some((definition) => definition.id === draft.classId)
|
||||||
? draft.classId
|
? draft.classId
|
||||||
@@ -154,7 +157,7 @@ export function CharacterCreateScreen() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<section className="creator-classes">
|
<section className="creator-classes">
|
||||||
<header><span>02</span><div><strong>Class</strong><small>{availableClasses.length} {draft.categoryId === "rom" ? "RuneWaker" : "WoW / Ascension"} classes available to {race.name}</small></div></header>
|
<header><span>02</span><div><strong>Class</strong><small>{availableClasses.length} {draft.categoryId === "rom" ? "RuneWaker" : draft.categoryId === "coa" ? "CoA" : "WoW"} classes available to {race.name}</small></div></header>
|
||||||
<div className="class-grid">
|
<div className="class-grid">
|
||||||
{availableClasses.map((definition) => (
|
{availableClasses.map((definition) => (
|
||||||
<ControllerButton
|
<ControllerButton
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useState, type CSSProperties } from "react";
|
import { useMemo, useState, type CSSProperties } from "react";
|
||||||
import { CLASSES, PLAYABLE_RACES, classById, FACTIONS, raceById } from "../app/characterCatalog";
|
import { CLASSES, PLAYABLE_RACES, classById, contentCategoryForClass, FACTIONS, raceById } from "../app/characterCatalog";
|
||||||
|
import { contentCategoryById } from "../app/contentCategories";
|
||||||
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
||||||
import { DualDisplayFrame } from "../components/DualDisplayFrame";
|
import { DualDisplayFrame } from "../components/DualDisplayFrame";
|
||||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||||
@@ -61,6 +62,7 @@ export function CharacterSelectScreen() {
|
|||||||
{characters.map((character) => {
|
{characters.map((character) => {
|
||||||
const raceDefinition = raceById(character.raceId);
|
const raceDefinition = raceById(character.raceId);
|
||||||
const classDefinition = classById(character.classId);
|
const classDefinition = classById(character.classId);
|
||||||
|
const categoryDefinition = contentCategoryById(character.categoryId ?? contentCategoryForClass(character.classId));
|
||||||
return (
|
return (
|
||||||
<ControllerButton
|
<ControllerButton
|
||||||
key={character.id}
|
key={character.id}
|
||||||
@@ -72,7 +74,7 @@ export function CharacterSelectScreen() {
|
|||||||
onClick={() => select(character.id)}
|
onClick={() => select(character.id)}
|
||||||
>
|
>
|
||||||
<span style={{ "--portrait-accent": raceDefinition.accent } as CSSProperties}>{raceDefinition.sigil}</span>
|
<span style={{ "--portrait-accent": raceDefinition.accent } as CSSProperties}>{raceDefinition.sigil}</span>
|
||||||
<i><strong>{character.name}</strong><small>{character.categoryId === "rom" ? "RoM" : "WoW"} / Level {character.level} {classDefinition.name}{character.secondaryClassId ? ` + ${classById(character.secondaryClassId).name}` : ""}</small></i>
|
<i><strong>{character.name}</strong><small>{categoryDefinition.label} / Level {character.level} {classDefinition.name}{character.secondaryClassId ? ` + ${classById(character.secondaryClassId).name}` : ""}</small></i>
|
||||||
<em>{formatLastPlayed(character.lastPlayedAt)}</em>
|
<em>{formatLastPlayed(character.lastPlayedAt)}</em>
|
||||||
</ControllerButton>
|
</ControllerButton>
|
||||||
);
|
);
|
||||||
@@ -110,7 +112,7 @@ export function CharacterSelectScreen() {
|
|||||||
<h2>{selected.location}</h2>
|
<h2>{selected.location}</h2>
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>Level</dt><dd>{selected.level}</dd></div>
|
<div><dt>Level</dt><dd>{selected.level}</dd></div>
|
||||||
<div><dt>Content</dt><dd>{selected.categoryId === "rom" ? "Runes of Magic" : "World of Warcraft"}</dd></div>
|
<div><dt>Content</dt><dd>{contentCategoryById(selected.categoryId ?? contentCategoryForClass(selected.classId)).name}</dd></div>
|
||||||
<div><dt>Faction</dt><dd>{FACTIONS[race.faction].name}</dd></div>
|
<div><dt>Faction</dt><dd>{FACTIONS[race.faction].name}</dd></div>
|
||||||
<div><dt>Class role</dt><dd>{characterClass.role}</dd></div>
|
<div><dt>Class role</dt><dd>{characterClass.role}</dd></div>
|
||||||
<div><dt>Next step</dt><dd>Choose a game mode</dd></div>
|
<div><dt>Next step</dt><dd>Choose a game mode</dd></div>
|
||||||
|
|||||||
@@ -78,10 +78,11 @@ function PauseDialog() {
|
|||||||
</div>
|
</div>
|
||||||
<dl className="controls-list">
|
<dl className="controls-list">
|
||||||
<div><dt>Move</dt><dd>WASD / Left stick</dd></div>
|
<div><dt>Move</dt><dd>WASD / Left stick</dd></div>
|
||||||
|
<div><dt>Jump</dt><dd>Space / L3</dd></div>
|
||||||
<div><dt>Look</dt><dd>Hold right mouse / Arrow keys / Right stick</dd></div>
|
<div><dt>Look</dt><dd>Hold right mouse / Arrow keys / Right stick</dd></div>
|
||||||
<div><dt>Abilities</dt><dd>1–8 / Face, R1, R2, D-pad left/right</dd></div>
|
<div><dt>Abilities</dt><dd>1–8 / Face, R1, R2, D-pad left/right</dd></div>
|
||||||
<div><dt>Ability layers</dt><dd>Shift / Alt / Hold L1 / Hold L2</dd></div>
|
<div><dt>Ability layers</dt><dd>Shift / Alt / Hold L1 / Hold L2</dd></div>
|
||||||
<div><dt>Targets</dt><dd>Tab / R3 enemy · D-pad up/down party/self · L3 clear</dd></div>
|
<div><dt>Targets</dt><dd>Tab / R3 enemy · D-pad up/down party/self</dd></div>
|
||||||
<div><dt>Party orders</dt><dd>F1 Attack / F2 Defend / F3 Stop / F4 Recall</dd></div>
|
<div><dt>Party orders</dt><dd>F1 Attack / F2 Defend / F3 Stop / F4 Recall</dd></div>
|
||||||
<div><dt>Spellbook / {skillMenuLabel}</dt><dd>P / N</dd></div>
|
<div><dt>Spellbook / {skillMenuLabel}</dt><dd>P / N</dd></div>
|
||||||
<div><dt>Inventory / Loot</dt><dd>I / E</dd></div>
|
<div><dt>Inventory / Loot</dt><dd>I / E</dd></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user