forked from phenom/healer-man
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d060296ade | ||
|
|
c245a199b6 |
No files matched your search
@@ -0,0 +1,52 @@
|
||||
# Balance benchmarks
|
||||
|
||||
The balance benchmark suite provides repeatable evidence for tuning World of Warcraft, CoA, and Runes of Magic classes. It runs fixed builds through versioned damage, tanking, healing, movement, stacking, and resource-endurance scenarios. The goal is to reveal balance changes and outliers, not to name a universally best class.
|
||||
|
||||
## Default cohort
|
||||
|
||||
The default comparison cohort is level 60, matching CoA's authored cap while keeping the three content sources in one cohort. It uses the `coefficient-empty` preset: no equipment, so the first cohort isolates base kits, scaling coefficients, talents, and resources. Gear-normalized cohorts should be added separately rather than mixed with this one.
|
||||
|
||||
Every comparison must use the same gear preset, scenario version, baseline policy ID, duration scale, and seed set. A changed level, gear preset, scenario, policy, or app version creates a new cohort and should not be compared directly with an older one.
|
||||
|
||||
Run the suite with:
|
||||
|
||||
```sh
|
||||
npm run balance:benchmark
|
||||
```
|
||||
|
||||
Use `npm run balance:smoke` for a short end-to-end check. The full command accepts comma-separated `--class`, `--role`, `--scenario`, and `--seeds` filters, plus `--level`, `--duration-scale`, and `--output`. For example:
|
||||
|
||||
```sh
|
||||
npm run balance:benchmark -- --class priest,rom-priest --role healer --seeds 1,2,3
|
||||
```
|
||||
|
||||
The command is the intended stable entry point for the benchmark runner. Its output directory contains:
|
||||
|
||||
- `matrix.json`: raw run reports, build and scenario definitions, seed metadata, validation results, ability details, resources, and event counts.
|
||||
- `matrix.csv`: one row per scenario, build, and metric, with observed/null counts plus median, p10, and p90 values.
|
||||
- `summary.md`: a concise role-separated comparison for review and pull-request evidence.
|
||||
|
||||
Keep the JSON artifact when investigating a result. The CSV and Markdown are derived views and can be regenerated from it.
|
||||
|
||||
## Reading results
|
||||
|
||||
The reported center is the median across validated seeded runs. The p10–p90 interval shows run-to-run spread; a wide interval calls for more seeds or investigation before tuning. Invalid runs are counted but excluded from distributions. An early death or other scenario stop can be a valid outcome and should not be discarded merely because the full duration was not reached.
|
||||
|
||||
Interpret each role as a vector of outcomes, not a single ranking:
|
||||
|
||||
- Damage builds emphasize DPS and priority-target damage, with deaths and mechanics failures alongside throughput.
|
||||
- Tanks emphasize damage taken per second, mitigation, avoidance, threat control, minimum health, survival, and their damage contribution.
|
||||
- Healers emphasize healing per second, consumed absorbs, overheal, party health, deaths, and time to resource failure.
|
||||
- Movement and stack scenarios add movement interrupts and stack compliance to the relevant role vector.
|
||||
|
||||
No composite score is produced. Compare a build only with other builds in the same scenario version and cohort, and inspect the underlying metrics before making a balance change. A null time to resource failure means the resource was not exhausted during that run; it is not zero. Similarly, a null time to first death means no death occurred.
|
||||
|
||||
## Limitations
|
||||
|
||||
- The baseline policy is a deterministic, maintainable reference policy, not an optimal player rotation. Skilled play may outperform it, and policy changes can move results without a class-data change.
|
||||
- The initial `coefficient-empty` cohort does not model production gear breakpoints or itemization. Add explicit equal-budget gear presets for that work.
|
||||
- Runes of Magic coverage is primary-class-only initially. Secondary-class and dual-class interactions require their own explicitly versioned cohort when supported.
|
||||
- Results cover mechanics represented by the simulator and scenarios; they do not replace live encounter playtesting, usability review, or PvP testing.
|
||||
- Small seed sets can miss rare random outcomes. Treat p10 and p90 as descriptive bounds for the sampled runs, not guarantees.
|
||||
- Total metrics depend on scenario duration. Compare rates or fixed-duration totals only within the same scenario definition.
|
||||
- Gear-stat budgets must be equivalent, but equivalent budgets do not guarantee identical interactions with every class kit.
|
||||
@@ -1451,3 +1451,72 @@ Vite's existing large-chunk advisory remains informational.
|
||||
passed all 755 cases across 132 files (including the 28 avatar cases), and the
|
||||
TypeScript production build completed successfully with 983 modules
|
||||
transformed. The existing large-chunk advisory remains informational.
|
||||
|
||||
# 2026-08-28 — configurable render caps and idle-loop efficiency
|
||||
|
||||
- Opened the saved Human Priest roster after a fresh client reload. The full
|
||||
body and starter staff rendered correctly in the single character-preview
|
||||
canvas, which reported the persisted 30 FPS preview cap and manual frame loop.
|
||||
- Entered Wailing Caverns as healer and exercised the gameplay setting live.
|
||||
The performance probe settled at about 30.5 FPS for the 30 FPS option and 60
|
||||
FPS for the 60 FPS option; combat and fixed-step physics continued normally.
|
||||
- Pausing switched the dungeon canvas to demand rendering. Its performance
|
||||
counters remained byte-for-byte unchanged over 1.4 seconds, confirming that
|
||||
the local paused scene did not keep rendering in the background.
|
||||
- Returned gameplay rendering to the default 60 FPS and left the character
|
||||
preview at 30 FPS. Evidence reviewed during the browser run included the
|
||||
roster-preview and live dungeon screenshots. The final fresh session emitted
|
||||
no errors; the existing third-party initialization deprecation warning
|
||||
remained non-blocking.
|
||||
- Verification: the focused efficiency tests passed all 83 cases, all 28 avatar
|
||||
tests passed, the complete Vitest suite passed all 809 cases across 148 files,
|
||||
and the production build completed with 1,043 modules transformed. KTX2 and
|
||||
environment-performance audits remained green; the existing large-chunk
|
||||
advisory remains informational.
|
||||
|
||||
# 2026-08-30 — authored weapon grips and ready stances
|
||||
|
||||
- Reloaded the offline client and switched a Human female preview directly
|
||||
between Hunter, Warrior, and Priest. The bow, sword/shield, and staff changed
|
||||
immediately to their native HoldBow, Ready1H, and Ready2HL poses; no stale
|
||||
class pose or backward procedural axis remained.
|
||||
- Verified RuneWaker Human Warrior and Scout previews against their explicit
|
||||
hand/shield locator frames. The one-hand, shield, and bow silhouettes used the
|
||||
RuneWaker socket-axis adapter and remained proportionate to the character.
|
||||
Also checked the Ascension Human Ranger fallback with its bow visible in a
|
||||
supported ready pose.
|
||||
- The saved Human Priest roster preview showed the complete body and staff with
|
||||
both hands aligned to the shaft. In Wailing Caverns, the player and party
|
||||
equipment remained attached through live combat animation without a second
|
||||
local weapon swing or load-time placement jump.
|
||||
- Evidence: `playtest-artifacts/2026-08-30-weapon-grip/weapon-grip-roster.png`,
|
||||
`human-hunter-holdbow.png`, `human-warrior-ready1h.png`,
|
||||
`human-priest-ready2hl.png`, `runewaker-warrior-sockets.png`,
|
||||
`runewaker-scout-bow.png`, `ascension-ranger-bow.png`, and
|
||||
`weapon-grip-dungeon.png`.
|
||||
- Verification: all 41 avatar tests passed, the 37-test focused weapon/stance
|
||||
suite passed, and the production build completed with 1,047 modules
|
||||
transformed. Asset audits remained green with zero KTX2 fallbacks and zero
|
||||
environment-performance regressions; the existing large-chunk advisory
|
||||
remains informational.
|
||||
|
||||
# 2026-08-30 — bounded combat VFX bursts and silent audio default
|
||||
|
||||
- Created a fresh offline Human Priest and confirmed the in-game Options panel
|
||||
opened with **Master volume** at 0%. Gameplay and preview frame-rate controls,
|
||||
VFX quality, and per-actor VFX/audio controls remained available.
|
||||
- Entered Wailing Caverns as healer, sent the party into combat, and captured a
|
||||
live four-effect spell burst. High quality reported four active effect systems
|
||||
and zero combat point lights; additive particles, impacts, melee arcs, the HUD,
|
||||
and combat telegraphs remained visible.
|
||||
- The captured burst reported 60 FPS, a 16.7 ms average frame, a 16.8 ms slowest
|
||||
frame, 258 draw calls, and 353,526 triangles at 1.5 pixel ratio. A three-second
|
||||
combat sample reached five concurrent effects, stayed between 54.1 and 60 FPS,
|
||||
and returned to a 16.7 ms frame average.
|
||||
- A cold Vite dynamic import failed once while first opening character creation;
|
||||
reloading after compilation resolved it. The final dungeon run had no further
|
||||
errors; the existing third-party initialization deprecation warning remained.
|
||||
- Verification: the complete Vitest suite passed all 839 cases across 153 files,
|
||||
the production build completed with 1,047 modules transformed, KTX2 and
|
||||
environment-performance audits remained green, and the release-candidate
|
||||
check passed. The existing large-chunk advisory remains informational.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -27,6 +27,7 @@
|
||||
}
|
||||
],
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"exceptions": [],
|
||||
"expansion": "wrath",
|
||||
"extraction": {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/TempestKeepArcane",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/AuchindounDraenei",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
],
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"exceptions": [],
|
||||
"expansion": "wrath",
|
||||
"extraction": {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
],
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"exceptions": [],
|
||||
"expansion": "burning-crusade",
|
||||
"extraction": {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/Blackfathom",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [20, 30]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/BlackrockDepths",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [50, 60]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/BlackRockSpire",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [55, 60]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/HellfireDemon",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [58, 70]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/TempestKeepAtrium",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
],
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"exceptions": [],
|
||||
"expansion": "wrath",
|
||||
"extraction": {
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/DireMaul",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source-transform",
|
||||
"coordinateTransform": {"origin": [0, 0, 0], "yawDegrees": 180},
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [50, 60]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
],
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"exceptions": [],
|
||||
"expansion": "wrath",
|
||||
"extraction": {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
],
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"exceptions": [],
|
||||
"expansion": "burning-crusade",
|
||||
"extraction": {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
],
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"exceptions": [],
|
||||
"expansion": "wrath",
|
||||
"extraction": {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/GnomeragonInstance",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [25, 40]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
],
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"exceptions": [],
|
||||
"expansion": "wrath",
|
||||
"extraction": {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
],
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"exceptions": [],
|
||||
"expansion": "wrath",
|
||||
"extraction": {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
],
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"exceptions": [],
|
||||
"expansion": "wrath",
|
||||
"extraction": {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
],
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"exceptions": [],
|
||||
"expansion": "wrath",
|
||||
"extraction": {
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/HellfireRampart",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source-transform",
|
||||
"coordinateTransform": {"origin": [1262.4427, -60.8902, 1622.5397], "yawDegrees": 262.143},
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [58, 70]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/Sunwell5ManFix",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [70, 70]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/AuchindounEthereal",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/Mauradon",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [40, 55]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/TempestKeepFactory",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
],
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"exceptions": [],
|
||||
"expansion": "wrath",
|
||||
"extraction": {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/OrgrimmarInstance",
|
||||
"environmentMode": "global-wmo",
|
||||
"globalWmoAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [13, 25]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/RazorfenDowns",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source-transform",
|
||||
"coordinateTransform": {"origin": [-2402.2809, 62, 1208.3219], "yawDegrees": 0},
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [35, 50]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/RazorfenKraulInstance",
|
||||
"environmentMode": "global-wmo",
|
||||
"globalWmoSourceBasis": "flip-x",
|
||||
"coordinateAlignment": "source-transform",
|
||||
"coordinateTransform": {"origin": [-1952.1803, 87.684, 1587.8923], "yawDegrees": -133},
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [25, 40]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"clientBuild": "3.3.5a",
|
||||
"mapDirectory": "World/Maps/MonasteryInstances",
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"adtSourceBasis": "z-y-negative-x",
|
||||
"difficultyVariants": [
|
||||
{ "id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [26, 45] }
|
||||
],
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/SchoolofNecromancy",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source-transform",
|
||||
"coordinateTransform": {"origin": [-141.694, 148.0683, 105.6784], "yawDegrees": 1},
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [55, 60]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/AuchindounDemon",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/AuchindounShadow",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
"expansion": "classic",
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/Shadowfang",
|
||||
"environmentMode": "global-wmo",
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"adtSourceBasis": "z-y-negative-x",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [18, 30]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/Dungeon/LD_ShadowFang/LD_ShadowFang.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
|
||||
"extraction": {"adtTiles": "all-present"},
|
||||
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "tileSize": 256, "retainTileIntermediates": false, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
|
||||
"review": {"entrance": "required", "bossOrder": "required", "optionalEncounters": "required", "presentation": "required", "offMeshLinks": "required"},
|
||||
"exceptions": []
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/HellfireMilitary",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/CoilfangDraenei",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/CoilfangPumping",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/Stratholme",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source-transform",
|
||||
"coordinateTransform": {"origin": [-3449.6608, 144.3848, -3379.6966], "yawDegrees": 0},
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [55, 60]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/SunkenTemple",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [45, 55]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
],
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"exceptions": [],
|
||||
"expansion": "wrath",
|
||||
"extraction": {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
],
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"exceptions": [],
|
||||
"expansion": "wrath",
|
||||
"extraction": {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
],
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"exceptions": [],
|
||||
"expansion": "wrath",
|
||||
"extraction": {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/Uldaman",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [
|
||||
{
|
||||
"id": "normal",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/CoilfangMarsh",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
],
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"exceptions": [],
|
||||
"expansion": "wrath",
|
||||
"extraction": {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
],
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"exceptions": [],
|
||||
"expansion": "wrath",
|
||||
"extraction": {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
],
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"exceptions": [],
|
||||
"expansion": "wrath",
|
||||
"extraction": {
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/TanarisInstance",
|
||||
"environmentMode": "detect",
|
||||
"coordinateAlignment": "source",
|
||||
"adtSourceBasis": "z-y-negative-x",
|
||||
"difficultyVariants": [
|
||||
{ "id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [40, 54] }
|
||||
],
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.9",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
@@ -93,6 +93,8 @@
|
||||
"assets:ktx2:audit": "node scripts/asset-pipeline/audit-ktx2.mjs --visual-only --require-all",
|
||||
"assets:ktx2:refresh-metadata": "node scripts/asset-pipeline/refresh-ktx2-metadata.mjs",
|
||||
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
|
||||
"balance:benchmark": "vite-node scripts/combat-benchmark/run.ts",
|
||||
"balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2",
|
||||
"prebuild": "npm run loot:generate && npm run assets:ktx2:audit && npm run assets:performance:audit",
|
||||
"build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
|
||||
"build:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large.
Load diff
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.7",
|
||||
"versionCode": 1007
|
||||
"versionName": "0.1.9",
|
||||
"versionCode": 1009
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { CLASSES, type ClassId } from "../../src/app/characterCatalog";
|
||||
import {
|
||||
CANONICAL_BENCHMARK_LEVEL,
|
||||
loadCanonicalBenchmarkBuilds,
|
||||
} from "../../src/game/benchmark/benchmarkBuilds";
|
||||
import { BENCHMARK_POLICY_ID } from "../../src/game/benchmark/benchmarkController";
|
||||
import {
|
||||
renderBenchmarkMatrixMarkdown,
|
||||
serializeBenchmarkMatrixCsv,
|
||||
} from "../../src/game/benchmark/benchmarkReport";
|
||||
import { runCombatBenchmark } from "../../src/game/benchmark/benchmarkRunner";
|
||||
import { BENCHMARK_SCENARIOS } from "../../src/game/benchmark/benchmarkScenarios";
|
||||
import type { BenchmarkMatrixReport } from "../../src/game/benchmark/benchmarkTypes";
|
||||
import type { PartyRole } from "../../src/game/partyRoles";
|
||||
|
||||
interface CliOptions {
|
||||
readonly level: number;
|
||||
readonly classIds: readonly ClassId[] | null;
|
||||
readonly roles: readonly PartyRole[] | null;
|
||||
readonly scenarioIds: readonly string[] | null;
|
||||
readonly seeds: readonly number[];
|
||||
readonly durationScale: number;
|
||||
readonly output: string | null;
|
||||
}
|
||||
|
||||
const projectRoot = resolve(import.meta.dirname, "../..");
|
||||
|
||||
function argumentValue(arguments_: readonly string[], name: string): string | null {
|
||||
const index = arguments_.indexOf(name);
|
||||
if (index < 0) return null;
|
||||
const value = arguments_[index + 1];
|
||||
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function list(value: string | null): readonly string[] | null {
|
||||
if (value === null) return null;
|
||||
const values = value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
||||
if (!values.length) throw new Error("A comma-separated filter cannot be empty.");
|
||||
return values;
|
||||
}
|
||||
|
||||
function finiteNumber(value: string | null, fallback: number, name: string): number {
|
||||
if (value === null) return fallback;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) throw new Error(`${name} must be a finite number.`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseOptions(arguments_: readonly string[]): CliOptions {
|
||||
const knownClasses = new Set(CLASSES.map((definition) => definition.id));
|
||||
const classIds = list(argumentValue(arguments_, "--class"));
|
||||
if (classIds?.some((id) => !knownClasses.has(id as ClassId))) {
|
||||
throw new Error(`Unknown class filter: ${classIds.find((id) => !knownClasses.has(id as ClassId))}.`);
|
||||
}
|
||||
const roles = list(argumentValue(arguments_, "--role"));
|
||||
if (roles?.some((role) => !["tank", "healer", "damage"].includes(role))) {
|
||||
throw new Error(`Unknown role filter: ${roles.find((role) => !["tank", "healer", "damage"].includes(role))}.`);
|
||||
}
|
||||
const scenarioIds = list(argumentValue(arguments_, "--scenario"));
|
||||
const knownScenarios = new Set(BENCHMARK_SCENARIOS.map((scenario) => scenario.id));
|
||||
if (scenarioIds?.some((id) => !knownScenarios.has(id))) {
|
||||
throw new Error(`Unknown scenario filter: ${scenarioIds.find((id) => !knownScenarios.has(id))}.`);
|
||||
}
|
||||
const seeds = (list(argumentValue(arguments_, "--seeds")) ?? ["1", "2", "3"])
|
||||
.map((seed) => Number(seed));
|
||||
if (!seeds.length || seeds.some((seed) => !Number.isSafeInteger(seed))) {
|
||||
throw new Error("--seeds must contain comma-separated safe integers.");
|
||||
}
|
||||
const durationScale = finiteNumber(argumentValue(arguments_, "--duration-scale"), 1, "--duration-scale");
|
||||
if (durationScale <= 0) throw new Error("--duration-scale must be greater than zero.");
|
||||
return {
|
||||
level: Math.trunc(finiteNumber(argumentValue(arguments_, "--level"), CANONICAL_BENCHMARK_LEVEL, "--level")),
|
||||
classIds: classIds as readonly ClassId[] | null,
|
||||
roles: roles as readonly PartyRole[] | null,
|
||||
scenarioIds,
|
||||
seeds,
|
||||
durationScale,
|
||||
output: argumentValue(arguments_, "--output"),
|
||||
};
|
||||
}
|
||||
|
||||
function help(): string {
|
||||
return [
|
||||
"Healer Man balance benchmark",
|
||||
"",
|
||||
"Usage: npm run balance:benchmark -- [options]",
|
||||
"",
|
||||
"Options:",
|
||||
" --class mage,templar Limit primary classes.",
|
||||
" --role damage,healer Limit roles.",
|
||||
" --scenario id,id Limit scenario IDs.",
|
||||
" --level 60 Comparison level (default 60).",
|
||||
" --seeds 1,2,3 Deterministic seeds.",
|
||||
" --duration-scale 1 Scale scenario duration (smoke tests only below 1).",
|
||||
" --output path Output directory under the current path or absolute.",
|
||||
" --help Show this help.",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function timestampDirectory(iso: string): string {
|
||||
return iso.replaceAll(":", "-").replace(/\.\d{3}Z$/u, "Z");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const arguments_ = process.argv.slice(2);
|
||||
if (arguments_.includes("--help")) {
|
||||
process.stdout.write(help());
|
||||
return;
|
||||
}
|
||||
const options = parseOptions(arguments_);
|
||||
const generatedAt = new Date().toISOString();
|
||||
const packageJson = JSON.parse(readFileSync(resolve(projectRoot, "package.json"), "utf8")) as { version: string };
|
||||
const builds = (await loadCanonicalBenchmarkBuilds(options.level, options.classIds ?? undefined))
|
||||
.filter((build) => !options.roles || options.roles.includes(build.role));
|
||||
const scenarios = BENCHMARK_SCENARIOS.filter((scenario) => (
|
||||
!options.scenarioIds || options.scenarioIds.includes(scenario.id)
|
||||
));
|
||||
const pairs = builds.flatMap((build) => scenarios
|
||||
.filter((scenario) => scenario.roles.includes(build.role))
|
||||
.map((scenario) => ({ build, scenario })));
|
||||
if (!pairs.length) throw new Error("The selected filters produced no compatible build/scenario pairs.");
|
||||
|
||||
const totalRuns = pairs.length * options.seeds.length;
|
||||
const runs = [];
|
||||
let completed = 0;
|
||||
for (const pair of pairs) {
|
||||
for (const seed of options.seeds) {
|
||||
completed += 1;
|
||||
process.stdout.write(
|
||||
`[${completed}/${totalRuns}] ${pair.build.classId}:${pair.build.role} / ${pair.scenario.id} / seed ${seed}\n`,
|
||||
);
|
||||
const run = await runCombatBenchmark(pair.build, pair.scenario, {
|
||||
seed,
|
||||
durationScale: options.durationScale,
|
||||
});
|
||||
runs.push(run);
|
||||
if (!run.validation.valid) {
|
||||
process.stderr.write(` invalid: ${run.validation.reasons.join("; ")}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const report: BenchmarkMatrixReport = {
|
||||
schemaVersion: 1,
|
||||
generatedAt,
|
||||
appVersion: packageJson.version,
|
||||
cohort: {
|
||||
level: options.level,
|
||||
gearPresetId: builds[0]?.gearPresetId ?? "coefficient-empty",
|
||||
seeds: options.seeds,
|
||||
durationScale: options.durationScale,
|
||||
policyId: BENCHMARK_POLICY_ID,
|
||||
},
|
||||
scenarios,
|
||||
builds,
|
||||
runs,
|
||||
};
|
||||
const defaultOutput = resolve(projectRoot, "artifacts/combat-benchmarks", timestampDirectory(generatedAt));
|
||||
const output = options.output ? resolve(process.cwd(), options.output) : defaultOutput;
|
||||
mkdirSync(output, { recursive: true });
|
||||
writeFileSync(resolve(output, "matrix.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
writeFileSync(resolve(output, "matrix.csv"), serializeBenchmarkMatrixCsv(report), "utf8");
|
||||
writeFileSync(resolve(output, "summary.md"), renderBenchmarkMatrixMarkdown(report), "utf8");
|
||||
process.stdout.write(`Wrote ${runs.length} run(s) to ${output}\n`);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { NodeIO } from "@gltf-transform/core";
|
||||
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||
import { BufferAttribute, BufferGeometry, Vector3 } from "three";
|
||||
import { MeshBVH } from "three-mesh-bvh";
|
||||
import { MeshoptDecoder } from "meshoptimizer";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const campaign = await import("../../src/game/generated/dungeonCampaignCatalog.json", {
|
||||
with: { type: "json" },
|
||||
}).then((module) => module.default);
|
||||
const useCollision = process.argv.includes("--collision");
|
||||
const requestedSlugs = new Set(process.argv.slice(2).filter((argument) => argument !== "--collision"));
|
||||
const definitions = campaign.definitions.filter((definition) => (
|
||||
!requestedSlugs.size || requestedSlugs.has(definition.id)
|
||||
));
|
||||
|
||||
if (!definitions.length) {
|
||||
throw new Error("No matching dungeon definitions. Pass one or more dungeon slugs.");
|
||||
}
|
||||
|
||||
await MeshoptDecoder.ready;
|
||||
const io = new NodeIO()
|
||||
.registerExtensions(ALL_EXTENSIONS)
|
||||
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
|
||||
const point = new Vector3();
|
||||
|
||||
function normalizerFor(accessor, array) {
|
||||
if (!accessor.getNormalized()) return (value) => value;
|
||||
if (array instanceof Int16Array) return (value) => Math.max(value / 32_767, -1);
|
||||
if (array instanceof Uint16Array) return (value) => value / 65_535;
|
||||
if (array instanceof Int8Array) return (value) => Math.max(value / 127, -1);
|
||||
if (array instanceof Uint8Array) return (value) => value / 255;
|
||||
return (value) => value;
|
||||
}
|
||||
|
||||
async function geometryTrees(file) {
|
||||
const document = await io.read(file);
|
||||
const trees = [];
|
||||
for (const node of document.getRoot().listNodes()) {
|
||||
const mesh = node.getMesh();
|
||||
if (!mesh) continue;
|
||||
const matrix = node.getWorldMatrix();
|
||||
for (const primitive of mesh.listPrimitives()) {
|
||||
const accessor = primitive.getAttribute("POSITION");
|
||||
const source = accessor?.getArray();
|
||||
if (!accessor || !source) continue;
|
||||
const normalize = normalizerFor(accessor, source);
|
||||
const positions = new Float32Array(source.length);
|
||||
for (let offset = 0; offset < source.length; offset += 3) {
|
||||
const x = normalize(source[offset]);
|
||||
const y = normalize(source[offset + 1]);
|
||||
const z = normalize(source[offset + 2]);
|
||||
positions[offset] = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12];
|
||||
positions[offset + 1] = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13];
|
||||
positions[offset + 2] = matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14];
|
||||
}
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new BufferAttribute(positions, 3));
|
||||
const indices = primitive.getIndices()?.getArray();
|
||||
if (indices) geometry.setIndex(new BufferAttribute(indices, 1));
|
||||
trees.push(new MeshBVH(geometry));
|
||||
}
|
||||
}
|
||||
return trees;
|
||||
}
|
||||
|
||||
function nearestDistance(trees, position) {
|
||||
point.fromArray(position);
|
||||
let distance = Infinity;
|
||||
for (const tree of trees) {
|
||||
const result = tree.closestPointToPoint(point);
|
||||
distance = Math.min(distance, result?.distance ?? Infinity);
|
||||
}
|
||||
return distance;
|
||||
}
|
||||
|
||||
function percentile(sorted, proportion) {
|
||||
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * proportion))] ?? 0;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const definition of definitions) {
|
||||
const geometryUrls = useCollision
|
||||
? definition.assets.collision.map((chunk) => chunk.url)
|
||||
: [definition.assets.navigation.url];
|
||||
const trees = (await Promise.all(geometryUrls.map((url) => (
|
||||
geometryTrees(path.join(projectRoot, "public", url.replace(/^\//, "")))
|
||||
)))).flat();
|
||||
const samples = [
|
||||
{ id: "entrance", position: definition.entrance.footPosition },
|
||||
...definition.staticSpawns.map((spawn) => ({ id: spawn.id, position: spawn.position })),
|
||||
...definition.roamingPacks.flatMap((pack) => (
|
||||
pack.waypoints.map((position, index) => ({ id: `${pack.id}:${index}`, position }))
|
||||
)),
|
||||
];
|
||||
const distances = samples
|
||||
.map((sample) => ({ ...sample, distance: nearestDistance(trees, sample.position) }))
|
||||
.sort((left, right) => left.distance - right.distance);
|
||||
const values = distances.map((sample) => sample.distance);
|
||||
results.push({
|
||||
dungeonId: definition.id,
|
||||
geometry: useCollision ? "collision" : "navigation",
|
||||
coordinateAlignment: definition.provenance.coordinateAlignment,
|
||||
coordinateTransform: definition.provenance.coordinateTransform,
|
||||
samples: values.length,
|
||||
withinOneMeter: values.filter((distance) => distance <= 1).length,
|
||||
medianDistance: percentile(values, 0.5),
|
||||
p90Distance: percentile(values, 0.9),
|
||||
maximumDistance: values.at(-1) ?? 0,
|
||||
furthest: distances.slice(-5).reverse().map(({ id, position, distance }) => ({
|
||||
id,
|
||||
position,
|
||||
distance,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
@@ -175,6 +175,10 @@ function interpolate(left, right, alpha) {
|
||||
return left.map((value, axis) => rounded(value + (right[axis] - value) * alpha));
|
||||
}
|
||||
|
||||
function horizontalDistanceSquared(left, right) {
|
||||
return (left[0] - right[0]) ** 2 + (left[2] - right[2]) ** 2;
|
||||
}
|
||||
|
||||
function normalizedName(value) {
|
||||
return String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
@@ -201,7 +205,7 @@ const ADT_SOURCE_BASES = new Set([
|
||||
"z-y-negative-x",
|
||||
]);
|
||||
const GLOBAL_WMO_SOURCE_BASES = new Set(["draft", "flip-x"]);
|
||||
const GLOBAL_WMO_ALIGNMENTS = new Set(["player-anchor", "source"]);
|
||||
const COORDINATE_ALIGNMENTS = new Set(["player-anchor", "source", "source-transform"]);
|
||||
|
||||
function sourceBasisFor(recipe, environmentMode) {
|
||||
if (environmentMode !== "adt-hybrid") {
|
||||
@@ -222,14 +226,42 @@ function sourceBasisFor(recipe, environmentMode) {
|
||||
}
|
||||
|
||||
function coordinateAlignmentFor(recipe, environmentMode) {
|
||||
if (environmentMode !== "global-wmo") return "player-anchor";
|
||||
const alignment = recipe.globalWmoAlignment ?? "player-anchor";
|
||||
if (!GLOBAL_WMO_ALIGNMENTS.has(alignment)) {
|
||||
throw new Error(`${recipe.slug}: unsupported global WMO alignment ${alignment}.`);
|
||||
const legacyGlobalWmoAlignment = environmentMode === "global-wmo"
|
||||
? recipe.globalWmoAlignment
|
||||
: undefined;
|
||||
if (
|
||||
recipe.coordinateAlignment
|
||||
&& legacyGlobalWmoAlignment
|
||||
&& recipe.coordinateAlignment !== legacyGlobalWmoAlignment
|
||||
) {
|
||||
throw new Error(`${recipe.slug}: coordinate alignment overrides disagree.`);
|
||||
}
|
||||
const alignment = recipe.coordinateAlignment ?? legacyGlobalWmoAlignment ?? "player-anchor";
|
||||
if (!COORDINATE_ALIGNMENTS.has(alignment)) {
|
||||
throw new Error(`${recipe.slug}: unsupported coordinate alignment ${alignment}.`);
|
||||
}
|
||||
return alignment;
|
||||
}
|
||||
|
||||
function sourceTransformFor(recipe, alignment) {
|
||||
const transform = recipe.coordinateTransform;
|
||||
if (alignment !== "source-transform") {
|
||||
if (transform) {
|
||||
throw new Error(`${recipe.slug}: coordinateTransform requires source-transform alignment.`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!transform || !Array.isArray(transform.origin) || transform.origin.length !== 3) {
|
||||
throw new Error(`${recipe.slug}: source-transform alignment requires a three-value coordinateTransform.origin.`);
|
||||
}
|
||||
const origin = transform.origin.map(Number);
|
||||
const yawDegrees = Number(transform.yawDegrees ?? 0);
|
||||
if (!origin.every(Number.isFinite) || !Number.isFinite(yawDegrees)) {
|
||||
throw new Error(`${recipe.slug}: coordinateTransform values must be finite numbers.`);
|
||||
}
|
||||
return { origin, yawDegrees };
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime drafts retain the original pipeline convention. wow.export ADT
|
||||
* packages have an additional horizontal basis change baked into their GLBs.
|
||||
@@ -248,10 +280,39 @@ function sourceToPackageBasis(position, environmentMode, sourceBasis) {
|
||||
: point;
|
||||
}
|
||||
|
||||
function coordinateMapper(draft, environmentMode, playerAnchor, sourceBasis, alignment) {
|
||||
function rotateAroundY(position, yawDegrees) {
|
||||
const radians = yawDegrees * Math.PI / 180;
|
||||
const cosine = Math.cos(radians);
|
||||
const sine = Math.sin(radians);
|
||||
return vector([
|
||||
position[0] * cosine - position[2] * sine,
|
||||
position[1],
|
||||
position[0] * sine + position[2] * cosine,
|
||||
]);
|
||||
}
|
||||
|
||||
function coordinateMapper(
|
||||
draft,
|
||||
environmentMode,
|
||||
playerAnchor,
|
||||
sourceBasis,
|
||||
alignment,
|
||||
sourceTransform,
|
||||
) {
|
||||
if (alignment === "source") {
|
||||
return (position) => sourceToPackageBasis(position, environmentMode, sourceBasis);
|
||||
}
|
||||
if (alignment === "source-transform") {
|
||||
const basisOrigin = sourceToPackageBasis(
|
||||
sourceTransform.origin,
|
||||
environmentMode,
|
||||
sourceBasis,
|
||||
);
|
||||
return (position) => rotateAroundY(
|
||||
subtract(sourceToPackageBasis(position, environmentMode, sourceBasis), basisOrigin),
|
||||
sourceTransform.yawDegrees,
|
||||
);
|
||||
}
|
||||
const sourceEntrance = draft?.entrance
|
||||
?? draft?.spawns?.[0]?.position
|
||||
?? playerAnchor.position;
|
||||
@@ -778,6 +839,7 @@ async function compile() {
|
||||
const environmentMode = inferEnvironmentMode(recipe, assetPackage);
|
||||
const sourceBasis = sourceBasisFor(recipe, environmentMode);
|
||||
const coordinateAlignment = coordinateAlignmentFor(recipe, environmentMode);
|
||||
const sourceTransform = sourceTransformFor(recipe, coordinateAlignment);
|
||||
const playerAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "player");
|
||||
const trashAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "trash");
|
||||
const bossAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "boss");
|
||||
@@ -788,7 +850,7 @@ async function compile() {
|
||||
trashAnchor.position[0] - playerAnchor.position[0],
|
||||
trashAnchor.position[2] - playerAnchor.position[2],
|
||||
);
|
||||
const entranceYaw = Number.isFinite(playerAnchor.yaw)
|
||||
let entranceYaw = Number.isFinite(playerAnchor.yaw)
|
||||
? playerAnchor.yaw
|
||||
: inferredEntranceYaw;
|
||||
|
||||
@@ -809,6 +871,7 @@ async function compile() {
|
||||
playerAnchor,
|
||||
sourceBasis,
|
||||
coordinateAlignment,
|
||||
sourceTransform,
|
||||
);
|
||||
const entrancePosition = mapPosition(
|
||||
draft?.entrance
|
||||
@@ -896,16 +959,49 @@ async function compile() {
|
||||
const entry = Number(spawn.entityId.replace("entry-", ""));
|
||||
if (!bossCandidatesByEntry.has(entry)) bossCandidatesByEntry.set(entry, spawn);
|
||||
}
|
||||
const mappedNativeRoutePositions = coordinateAlignment !== "player-anchor"
|
||||
? (draft?.spawns ?? [])
|
||||
.map((spawn) => mapPosition(spawn.position))
|
||||
.filter((position) => position.every(Number.isFinite))
|
||||
.sort((left, right) => (
|
||||
horizontalDistanceSquared(left, entrancePosition)
|
||||
- horizontalDistanceSquared(right, entrancePosition)
|
||||
))
|
||||
: [];
|
||||
const entranceDirectionTarget = mappedNativeRoutePositions.find((position) => (
|
||||
Math.abs(position[1] - entrancePosition[1]) <= 3
|
||||
&& horizontalDistanceSquared(position, entrancePosition) >= 16
|
||||
));
|
||||
if (entranceDirectionTarget) {
|
||||
entranceYaw = Math.atan2(
|
||||
entranceDirectionTarget[0] - entrancePosition[0],
|
||||
entranceDirectionTarget[2] - entrancePosition[2],
|
||||
);
|
||||
}
|
||||
const entranceLevelRoutePositions = mappedNativeRoutePositions.filter(
|
||||
(position) => Math.abs(position[1] - entrancePosition[1]) <= 2.5,
|
||||
);
|
||||
const nativeRoutePositions = (
|
||||
bossCandidatesByEntry.size === 0
|
||||
&& entranceLevelRoutePositions.length >= encounters.length
|
||||
)
|
||||
? entranceLevelRoutePositions
|
||||
: mappedNativeRoutePositions;
|
||||
const usedBossSpawnIds = new Set();
|
||||
const bossObjectives = encounters.map((encounter, index) => {
|
||||
const candidate = bossCandidatesByEntry.get(Number(encounter.creatureId));
|
||||
const alpha = encounters.length <= 1 ? 1 : index / (encounters.length - 1);
|
||||
const routePosition = interpolate(trashAnchor.position, bossAnchor.position, 0.18 + alpha * 0.82);
|
||||
const routeAlpha = 0.18 + alpha * 0.82;
|
||||
const nativeRoutePosition = nativeRoutePositions.length
|
||||
? nativeRoutePositions[Math.round(routeAlpha * (nativeRoutePositions.length - 1))]
|
||||
: undefined;
|
||||
const routePosition = nativeRoutePosition
|
||||
?? interpolate(trashAnchor.position, bossAnchor.position, routeAlpha);
|
||||
const position = encounter.eventOnly
|
||||
? [-151.27, -102.82, 252.26]
|
||||
: candidate
|
||||
? mapPosition(candidate.position)
|
||||
: offsetPosition(routePosition, index % 5, 2.4);
|
||||
: nativeRoutePosition ?? offsetPosition(routePosition, index % 5, 2.4);
|
||||
const id = encounter.eventOnly
|
||||
? "mutanus-spawn"
|
||||
: candidate?.id ?? `encounter-${encounter.encounterId}`;
|
||||
@@ -1000,8 +1096,9 @@ async function compile() {
|
||||
|
||||
const allPoints = [
|
||||
entrancePosition,
|
||||
trashAnchor.position,
|
||||
bossAnchor.position,
|
||||
...(coordinateAlignment === "player-anchor" || syntheticTrash
|
||||
? [trashAnchor.position, bossAnchor.position]
|
||||
: []),
|
||||
...staticSpawns.map((spawn) => spawn.position),
|
||||
...roamingPacks.flatMap((pack) => pack.waypoints),
|
||||
];
|
||||
@@ -1032,6 +1129,8 @@ async function compile() {
|
||||
? ["Trash identities and placements are procedural; boss identities come from the installed Ascension encounter catalog."]
|
||||
: coordinateAlignment === "source"
|
||||
? [`Server positions use the packaged ${environmentMode} environment's native world coordinates.`]
|
||||
: coordinateAlignment === "source-transform"
|
||||
? [`Server positions use the packaged ${environmentMode} environment's authoritative WDT placement transform.`]
|
||||
: [`Server positions were aligned to the packaged ${environmentMode} environment at its reviewed player anchor.`]),
|
||||
];
|
||||
|
||||
@@ -1078,6 +1177,7 @@ async function compile() {
|
||||
serverGameObjects: draft?.gameObjects ?? [],
|
||||
provenance: {
|
||||
clientBuild: recipe.clientBuild,
|
||||
coordinateAlignment,
|
||||
clientArchiveHashes: assetPackage.source.checksums ?? {},
|
||||
databaseAdapter: databaseKind,
|
||||
databaseRevision: draft?.spawns?.length
|
||||
@@ -1090,6 +1190,8 @@ async function compile() {
|
||||
? sourceBasis === "z-y-negative-x"
|
||||
? "three(x,y,z)=(wow.y,wow.z,wow.x)"
|
||||
: "three(x,y,z)=(-wow.y,wow.z,wow.x)"
|
||||
: coordinateAlignment === "source-transform"
|
||||
? `three(x,y,z)=rotate-y(${sourceBasis === "flip-x" ? "(-draft.x,draft.y,draft.z)" : "draft(x,y,z)"}-[${sourceTransform.origin.join(",")}],${sourceTransform.yawDegrees}deg)`
|
||||
: coordinateAlignment === "source"
|
||||
? sourceBasis === "flip-x"
|
||||
? "three(x,y,z)=(-draft.x,draft.y,draft.z)"
|
||||
|
||||
@@ -61,10 +61,13 @@ import {
|
||||
import {
|
||||
EquipmentAttachments,
|
||||
equipmentAttachmentHand,
|
||||
resolveEquipmentAttachmentTransforms,
|
||||
} from "./equipmentAttachments";
|
||||
import {
|
||||
ApproximateStarterEquipment,
|
||||
StarterWeaponAttachments,
|
||||
characterWeaponStance,
|
||||
estimateAvatarWeaponScale,
|
||||
type AvatarHand,
|
||||
} from "./starterWeapons";
|
||||
|
||||
@@ -192,6 +195,14 @@ function LoadedCharacterModel({
|
||||
}) {
|
||||
const gltf = useGLTF(resolveContentUrl(entry.model));
|
||||
const scene = useMemo(() => cloneAvatarScene(gltf.scene), [gltf.scene]);
|
||||
const equipmentAttachmentTransforms = useMemo(
|
||||
() => resolveEquipmentAttachmentTransforms(scene, entry),
|
||||
[entry, scene],
|
||||
);
|
||||
const fallbackEquipmentScale = useMemo(
|
||||
() => estimateAvatarWeaponScale(scene),
|
||||
[scene],
|
||||
);
|
||||
const mixer = useMemo(() => new AnimationMixer(scene), [scene]);
|
||||
const currentAction = useRef<AnimationAction | null>(null);
|
||||
const overrideAction = useRef<AnimationAction | null>(null);
|
||||
@@ -199,7 +210,6 @@ function LoadedCharacterModel({
|
||||
const overrideLooping = useRef(false);
|
||||
const currentMotion = useRef<CharacterLocomotionMotion | null>(null);
|
||||
const observedAttackPulse = useRef(attackPulseRef?.current ?? 0);
|
||||
const equipmentAttackPulseRef = useRef(attackPulseRef?.current ?? 0);
|
||||
const observedAnimationRevision = useRef(animationEventRef?.current?.revision ?? 0);
|
||||
const attackElapsed = useRef(ATTACK_PRESENTATION_DURATION_SECONDS);
|
||||
const attackJoint = useMemo(
|
||||
@@ -233,6 +243,15 @@ function LoadedCharacterModel({
|
||||
}))] as AvatarHand[],
|
||||
[resolved.equipmentModels],
|
||||
);
|
||||
const weaponStance = useMemo(() => characterWeaponStance(
|
||||
identity.classId,
|
||||
equipment,
|
||||
resolved.equipmentModels.flatMap((itemModel) => (
|
||||
itemModel.attachment === "handLeft" || itemModel.attachment === "handRight"
|
||||
? [itemModel.itemId]
|
||||
: []
|
||||
)),
|
||||
), [equipment, identity.classId, resolved.equipmentModels]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -339,7 +358,7 @@ function LoadedCharacterModel({
|
||||
};
|
||||
|
||||
const selectClip = (motion: CharacterBaseMotion): AnimationClip | undefined => (
|
||||
selectCharacterBaseClip(gltf.animations, motion)
|
||||
selectCharacterBaseClip(gltf.animations, motion, weaponStance)
|
||||
?? namedClip(entry.animations[motion])
|
||||
?? namedClip(entry.animations.idle)
|
||||
?? gltf.animations[0]
|
||||
@@ -347,7 +366,6 @@ function LoadedCharacterModel({
|
||||
|
||||
const switchMotion = (motion: CharacterLocomotionMotion, moving: boolean) => {
|
||||
if (overrideAction.current) return;
|
||||
if (currentMotion.current === motion) return;
|
||||
const verticalSelection = motion === "rising" || motion === "falling" || motion === "landing"
|
||||
? selectCharacterVerticalClip(gltf.animations, motion, moving)
|
||||
: null;
|
||||
@@ -356,6 +374,7 @@ function LoadedCharacterModel({
|
||||
motion === "idle" || motion === "walk" || motion === "run" ? motion : fallbackMotion,
|
||||
);
|
||||
if (!clip) return;
|
||||
if (currentMotion.current === motion && currentAction.current?.getClip() === clip) return;
|
||||
const loop = verticalSelection?.loop ?? true;
|
||||
const next = mixer.clipAction(clip, scene);
|
||||
next.enabled = true;
|
||||
@@ -462,15 +481,11 @@ function LoadedCharacterModel({
|
||||
if (nextAnimationEvent && nextAnimationEvent.revision !== observedAnimationRevision.current) {
|
||||
observedAnimationRevision.current = nextAnimationEvent.revision;
|
||||
playAnimationEvent(nextAnimationEvent);
|
||||
if (nextAnimationEvent.kind === "attack" || nextAnimationEvent.kind === "ability-release") {
|
||||
equipmentAttackPulseRef.current = nextAnimationEvent.revision;
|
||||
}
|
||||
}
|
||||
const nextAttackPulse = attackPulseRef?.current ?? 0;
|
||||
if (nextAttackPulse !== observedAttackPulse.current) {
|
||||
observedAttackPulse.current = nextAttackPulse;
|
||||
if (nextAttackPulse > 0) {
|
||||
equipmentAttackPulseRef.current = nextAttackPulse;
|
||||
playAnimationEvent({ revision: nextAttackPulse, kind: "attack" });
|
||||
}
|
||||
}
|
||||
@@ -513,17 +528,16 @@ function LoadedCharacterModel({
|
||||
/>
|
||||
</group>
|
||||
<StarterWeaponAttachments
|
||||
attackPulseRef={equipmentAttackPulseRef}
|
||||
classId={identity.classId}
|
||||
entry={entry}
|
||||
occupiedHands={occupiedEquipmentHands}
|
||||
root={scene}
|
||||
/>
|
||||
<EquipmentAttachments
|
||||
attackPulseRef={equipmentAttackPulseRef}
|
||||
classId={identity.classId}
|
||||
entry={entry}
|
||||
fallbackScale={fallbackEquipmentScale}
|
||||
models={resolved.equipmentModels}
|
||||
root={scene}
|
||||
transforms={equipmentAttachmentTransforms}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Bone,
|
||||
BufferGeometry,
|
||||
Group,
|
||||
MeshBasicMaterial,
|
||||
Skeleton,
|
||||
SkinnedMesh,
|
||||
Vector3,
|
||||
} from "three";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findAvatarSkinBone,
|
||||
resolveAvatarAttachmentPoint,
|
||||
} from "./attachmentPoints";
|
||||
|
||||
const expectVectorClose = (actual: Vector3, expected: Vector3): void => {
|
||||
expect(actual.x).toBeCloseTo(expected.x, 6);
|
||||
expect(actual.y).toBeCloseTo(expected.y, 6);
|
||||
expect(actual.z).toBeCloseTo(expected.z, 6);
|
||||
};
|
||||
|
||||
function attachmentRig() {
|
||||
const world = new Group();
|
||||
world.position.set(4, -2, 7);
|
||||
world.rotation.set(0.2, -0.35, 0.1);
|
||||
world.scale.setScalar(1.7);
|
||||
|
||||
const root = new Group();
|
||||
root.position.set(-1, 3, 2);
|
||||
root.rotation.set(-0.1, 0.25, -0.15);
|
||||
root.scale.setScalar(0.8);
|
||||
world.add(root);
|
||||
|
||||
const internalRig = new Group();
|
||||
internalRig.scale.setScalar(0.1);
|
||||
root.add(internalRig);
|
||||
const unusedBone = new Bone();
|
||||
const attachmentBone = new Bone();
|
||||
attachmentBone.position.set(2, 4, -3);
|
||||
internalRig.add(unusedBone, attachmentBone);
|
||||
|
||||
const mesh = new SkinnedMesh(new BufferGeometry(), new MeshBasicMaterial());
|
||||
mesh.bind(new Skeleton([unusedBone, attachmentBone]));
|
||||
root.add(mesh);
|
||||
|
||||
return { attachmentBone, root, world };
|
||||
}
|
||||
|
||||
describe("avatar attachment-point transforms", () => {
|
||||
it("converts manifest positions into the selected skin bone", () => {
|
||||
const { attachmentBone, root, world } = attachmentRig();
|
||||
const point = { bone: 1, position: [0.45, 0.9, -0.2] as const };
|
||||
|
||||
const transform = resolveAvatarAttachmentPoint(root, point, null);
|
||||
expect(transform?.target).toBe(attachmentBone);
|
||||
expect(findAvatarSkinBone(root, 1)).toBe(attachmentBone);
|
||||
|
||||
world.updateWorldMatrix(true, true);
|
||||
const expectedWorldPosition = root.localToWorld(new Vector3(...point.position));
|
||||
const actualWorldPosition = attachmentBone.localToWorld(transform!.position.clone());
|
||||
expectVectorClose(actualWorldPosition, expectedWorldPosition);
|
||||
});
|
||||
|
||||
it("cancels internal locator scale without cancelling the avatar scale", () => {
|
||||
const { attachmentBone, root, world } = attachmentRig();
|
||||
const transform = resolveAvatarAttachmentPoint(root, undefined, attachmentBone)!;
|
||||
const portalChild = new Group();
|
||||
portalChild.scale.copy(transform.scale);
|
||||
attachmentBone.add(portalChild);
|
||||
|
||||
world.updateWorldMatrix(true, true);
|
||||
expectVectorClose(
|
||||
portalChild.getWorldScale(new Vector3()),
|
||||
root.getWorldScale(new Vector3()),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps authored hand joints at local zero after the skeleton animates", () => {
|
||||
const { attachmentBone, root } = attachmentRig();
|
||||
attachmentBone.position.set(-6, 8, 4);
|
||||
attachmentBone.rotation.set(0.7, -0.4, 0.2);
|
||||
|
||||
const transform = resolveAvatarAttachmentPoint(
|
||||
root,
|
||||
{ bone: 1, position: [0.45, 0.9, -0.2] },
|
||||
null,
|
||||
{ useTargetOrigin: true },
|
||||
);
|
||||
|
||||
expect(transform?.target).toBe(attachmentBone);
|
||||
expect(transform?.position.toArray()).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it("falls back safely when a manifest joint is unavailable", () => {
|
||||
const { root } = attachmentRig();
|
||||
const fallback = new Group();
|
||||
root.add(fallback);
|
||||
|
||||
const withMissingJoint = resolveAvatarAttachmentPoint(
|
||||
root,
|
||||
{ bone: 99, position: [0.1, 0.2, 0.3] },
|
||||
fallback,
|
||||
);
|
||||
expect(withMissingJoint?.target).toBe(fallback);
|
||||
|
||||
const withoutMetadata = resolveAvatarAttachmentPoint(root, undefined, fallback);
|
||||
expect(withoutMetadata?.target).toBe(fallback);
|
||||
expect(withoutMetadata?.position.toArray()).toEqual([0, 0, 0]);
|
||||
expect(resolveAvatarAttachmentPoint(root, undefined, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { SkinnedMesh, Vector3, type Object3D } from "three";
|
||||
import type { AvatarAttachmentPoint } from "./characterManifest";
|
||||
|
||||
export interface AvatarAttachmentTransform {
|
||||
readonly target: Object3D;
|
||||
readonly position: Vector3;
|
||||
readonly scale: Vector3;
|
||||
}
|
||||
|
||||
export interface AvatarAttachmentOptions {
|
||||
/** The manifest point is the authored origin of its referenced attachment joint. */
|
||||
readonly useTargetOrigin?: boolean;
|
||||
}
|
||||
|
||||
const safeScaleRatio = (rootScale: number, targetScale: number): number => (
|
||||
Number.isFinite(rootScale)
|
||||
&& Number.isFinite(targetScale)
|
||||
&& Math.abs(targetScale) > 1e-6
|
||||
? rootScale / targetScale
|
||||
: 1
|
||||
);
|
||||
|
||||
function attachmentScale(root: Object3D, target: Object3D): Vector3 {
|
||||
const rootScale = root.getWorldScale(new Vector3());
|
||||
const targetScale = target.getWorldScale(new Vector3());
|
||||
return new Vector3(
|
||||
safeScaleRatio(rootScale.x, targetScale.x),
|
||||
safeScaleRatio(rootScale.y, targetScale.y),
|
||||
safeScaleRatio(rootScale.z, targetScale.z),
|
||||
);
|
||||
}
|
||||
|
||||
export function findAvatarSkinBone(root: Object3D, boneIndex: number): Object3D | null {
|
||||
let bone: Object3D | null = null;
|
||||
root.traverse((node) => {
|
||||
if (bone) return;
|
||||
const skinnedMesh = node as SkinnedMesh;
|
||||
if (!skinnedMesh.isSkinnedMesh) return;
|
||||
bone = skinnedMesh.skeleton.bones[boneIndex] ?? null;
|
||||
});
|
||||
return bone;
|
||||
}
|
||||
|
||||
/** Converts a manifest point from avatar-local space into its target bone. */
|
||||
export function resolveAvatarAttachmentPoint(
|
||||
root: Object3D,
|
||||
point: AvatarAttachmentPoint | undefined,
|
||||
fallbackTarget: Object3D | null,
|
||||
options: AvatarAttachmentOptions = {},
|
||||
): AvatarAttachmentTransform | null {
|
||||
const skinBone = point ? findAvatarSkinBone(root, point.bone) : null;
|
||||
const target = skinBone ?? fallbackTarget;
|
||||
if (!target) return null;
|
||||
root.updateWorldMatrix(true, true);
|
||||
const scale = attachmentScale(root, target);
|
||||
if (!point || (skinBone && options.useTargetOrigin)) {
|
||||
return { target, position: new Vector3(), scale };
|
||||
}
|
||||
|
||||
const position = root.localToWorld(new Vector3(...point.position));
|
||||
target.worldToLocal(position);
|
||||
return { target, position, scale };
|
||||
}
|
||||
@@ -21,6 +21,15 @@ describe("avatar rendering integration invariants", () => {
|
||||
expect(characterModelSource).toContain("<EquipmentAttachments");
|
||||
});
|
||||
|
||||
it("uses manifest sockets while the loaded skeleton owns weapon motion", () => {
|
||||
const loadedStarterAttachment = characterModelSource.match(
|
||||
/<StarterWeaponAttachments[\s\S]*?\/>/,
|
||||
)?.[0] ?? "";
|
||||
expect(loadedStarterAttachment).toContain("entry={entry}");
|
||||
expect(loadedStarterAttachment).not.toContain("attackPulseRef=");
|
||||
expect(characterModelSource).not.toContain("equipmentAttackPulseRef");
|
||||
});
|
||||
|
||||
it("isolates each optional gear model behind its own loading and error boundary", () => {
|
||||
expect(equipmentAttachmentSource).toContain("<AvatarErrorBoundary");
|
||||
expect(equipmentAttachmentSource).toContain("<Suspense fallback={fallback}>");
|
||||
|
||||
@@ -35,7 +35,7 @@ const REQUIRED_NATIVE_IDS = [
|
||||
0, 1, 4, 5, 8, 9, 10,
|
||||
16, 17, 18, 19,
|
||||
25, 26, 27, 28,
|
||||
46,
|
||||
46, 109,
|
||||
51, 52, 53, 54,
|
||||
57, 58, 95, 124, 125,
|
||||
] as const;
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("resolveEquipmentAppearance", () => {
|
||||
|
||||
expect(resolved.models).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ itemId: 6472, attachment: "handRight" }),
|
||||
expect.objectContaining({ itemId: 6447, attachment: "handLeft" }),
|
||||
expect.objectContaining({ itemId: 6447, attachment: "shield" }),
|
||||
expect.objectContaining({ itemId: 5404, attachment: "shoulderLeft" }),
|
||||
expect.objectContaining({ itemId: 5404, attachment: "shoulderRight" }),
|
||||
]));
|
||||
@@ -39,6 +39,26 @@ describe("resolveEquipmentAppearance", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes every shield model through the dedicated shield attachment", () => {
|
||||
for (const itemId of [6447, 6630, 13245]) {
|
||||
expect(resolveEquipmentAppearance([item(itemId)], "male").models).toEqual([
|
||||
expect.objectContaining({ itemId, attachment: "shield" }),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares a stance-matched procedural fallback for every held model", () => {
|
||||
for (const itemId of [5243, 6447, 6448, 6469, 6472, 6630, 6631, 13245]) {
|
||||
const heldModels = resolveEquipmentAppearance([item(itemId)], "male").models
|
||||
.filter((model) => model.attachment !== "shoulderLeft" && model.attachment !== "shoulderRight");
|
||||
expect(heldModels).not.toHaveLength(0);
|
||||
for (const model of heldModels) {
|
||||
expect(model.fallbackKind).toBeTruthy();
|
||||
expect(model.weaponStance).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("uses gendered robe textures, equipment geosets, and the actual cape skin", () => {
|
||||
const resolved = resolveEquipmentAppearance([
|
||||
item(6465),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GenderId } from "../app/characterCatalog";
|
||||
import type { CharacterWeaponStance } from "../game/combatAnimation";
|
||||
import type { InventoryItem } from "../game/lootTypes";
|
||||
import type { TextureRegion } from "./characterManifest";
|
||||
|
||||
@@ -33,6 +34,8 @@ export interface EquipmentModelVisual {
|
||||
readonly name: string;
|
||||
readonly url: string;
|
||||
readonly attachment: EquipmentAttachmentName;
|
||||
readonly fallbackKind?: "bow" | "dagger" | "mace" | "shield" | "staff" | "sword";
|
||||
readonly weaponStance?: CharacterWeaponStance;
|
||||
}
|
||||
|
||||
interface GenderedTextureLayer {
|
||||
@@ -67,6 +70,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
name: "Firebelcher",
|
||||
url: model("firebelcher-5243"),
|
||||
attachment: "handRight",
|
||||
fallbackKind: "mace",
|
||||
weaponStance: "one-handed",
|
||||
}],
|
||||
},
|
||||
5404: {
|
||||
@@ -101,7 +106,9 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
models: [{
|
||||
name: "Worn Turtle Shell Shield",
|
||||
url: model("worn-turtle-shell-shield-6447"),
|
||||
attachment: "handLeft",
|
||||
attachment: "shield",
|
||||
fallbackKind: "shield",
|
||||
weaponStance: "one-handed",
|
||||
}],
|
||||
},
|
||||
6448: {
|
||||
@@ -109,6 +116,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
name: "Tail Spike",
|
||||
url: model("tail-spike-6448"),
|
||||
attachment: "handRight",
|
||||
fallbackKind: "dagger",
|
||||
weaponStance: "one-handed",
|
||||
}],
|
||||
},
|
||||
6449: {
|
||||
@@ -188,6 +197,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
name: "Venomstrike",
|
||||
url: model("venomstrike-6469"),
|
||||
attachment: "handLeft",
|
||||
fallbackKind: "bow",
|
||||
weaponStance: "bow",
|
||||
}],
|
||||
},
|
||||
6472: {
|
||||
@@ -195,6 +206,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
name: "Stinging Viper",
|
||||
url: model("stinging-viper-6472"),
|
||||
attachment: "handRight",
|
||||
fallbackKind: "mace",
|
||||
weaponStance: "one-handed",
|
||||
}],
|
||||
},
|
||||
6473: {
|
||||
@@ -237,7 +250,9 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
models: [{
|
||||
name: "Seedcloud Buckler",
|
||||
url: model("seedcloud-buckler-6630"),
|
||||
attachment: "handLeft",
|
||||
attachment: "shield",
|
||||
fallbackKind: "shield",
|
||||
weaponStance: "one-handed",
|
||||
}],
|
||||
},
|
||||
6631: {
|
||||
@@ -245,6 +260,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
name: "Living Root",
|
||||
url: model("living-root-6631"),
|
||||
attachment: "handRight",
|
||||
fallbackKind: "staff",
|
||||
weaponStance: "two-handed-left",
|
||||
}],
|
||||
},
|
||||
6632: {
|
||||
@@ -291,7 +308,9 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
models: [{
|
||||
name: "Kresh's Back",
|
||||
url: model("kreshs-back-13245"),
|
||||
attachment: "handLeft",
|
||||
attachment: "shield",
|
||||
fallbackKind: "shield",
|
||||
weaponStance: "one-handed",
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,31 +5,47 @@ import {
|
||||
Suspense,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
DoubleSide,
|
||||
Mesh,
|
||||
Object3D,
|
||||
SkinnedMesh,
|
||||
Vector3,
|
||||
type Material,
|
||||
} from "three";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import type { ClassId } from "../app/characterCatalog";
|
||||
import type { AvatarManifestEntry } from "./characterManifest";
|
||||
import type {
|
||||
EquipmentAttachmentName,
|
||||
EquipmentModelVisual,
|
||||
} from "./equipmentAppearance";
|
||||
import {
|
||||
resolveAvatarAttachmentPoint,
|
||||
type AvatarAttachmentTransform,
|
||||
} from "./attachmentPoints";
|
||||
import { AvatarErrorBoundary } from "./AvatarErrorBoundary";
|
||||
import {
|
||||
avatarWeaponSocketRotation,
|
||||
findAvatarHandBone,
|
||||
StarterWeaponAttachments,
|
||||
findAvatarShieldBone,
|
||||
StarterWeaponVisual,
|
||||
type AvatarHand,
|
||||
type StarterWeaponRotation,
|
||||
} from "./starterWeapons";
|
||||
|
||||
const EQUIPMENT_ATTACHMENT_NAMES: readonly EquipmentAttachmentName[] = Object.freeze([
|
||||
"handLeft",
|
||||
"handRight",
|
||||
"shield",
|
||||
"shoulderLeft",
|
||||
"shoulderRight",
|
||||
]);
|
||||
const NO_ROTATION: StarterWeaponRotation = Object.freeze([0, 0, 0]);
|
||||
|
||||
export type EquipmentAttachmentTransforms = Readonly<
|
||||
Partial<Record<EquipmentAttachmentName, AvatarAttachmentTransform>>
|
||||
>;
|
||||
|
||||
export function equipmentAttachmentHand(
|
||||
attachment: EquipmentAttachmentName,
|
||||
): AvatarHand | null {
|
||||
@@ -58,23 +74,14 @@ function cloneEquipmentScene(source: Object3D, name: string): Object3D {
|
||||
return scene;
|
||||
}
|
||||
|
||||
function findSkinBone(root: Object3D, boneIndex: number): Object3D | null {
|
||||
let bone: Object3D | null = null;
|
||||
root.traverse((node) => {
|
||||
if (bone) return;
|
||||
const skinnedMesh = node as SkinnedMesh;
|
||||
if (!skinnedMesh.isSkinnedMesh) return;
|
||||
bone = skinnedMesh.skeleton.bones[boneIndex] ?? null;
|
||||
});
|
||||
return bone;
|
||||
}
|
||||
|
||||
function fallbackTarget(
|
||||
root: Object3D,
|
||||
attachment: EquipmentAttachmentName,
|
||||
): Object3D | null {
|
||||
if (attachment === "handLeft" || attachment === "shield") {
|
||||
return findAvatarHandBone(root, "left");
|
||||
return attachment === "shield"
|
||||
? findAvatarShieldBone(root)
|
||||
: findAvatarHandBone(root, "left");
|
||||
}
|
||||
if (attachment === "handRight") return findAvatarHandBone(root, "right");
|
||||
return root.getObjectByName(
|
||||
@@ -82,43 +89,42 @@ function fallbackTarget(
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
function attachmentTransform(
|
||||
/** Snapshots all authored attachment offsets once, before animation begins. */
|
||||
export function resolveEquipmentAttachmentTransforms(
|
||||
root: Object3D,
|
||||
entry: AvatarManifestEntry,
|
||||
attachment: EquipmentAttachmentName,
|
||||
): { target: Object3D; position: Vector3 } | null {
|
||||
const point = entry.attachmentPoints?.[attachment];
|
||||
const target = point
|
||||
? findSkinBone(root, point.bone) ?? fallbackTarget(root, attachment)
|
||||
: fallbackTarget(root, attachment);
|
||||
if (!target) return null;
|
||||
if (!point) return { target, position: new Vector3() };
|
||||
|
||||
root.updateWorldMatrix(true, true);
|
||||
const position = root.localToWorld(new Vector3(...point.position));
|
||||
target.worldToLocal(position);
|
||||
return { target, position };
|
||||
): EquipmentAttachmentTransforms {
|
||||
const transforms: Partial<Record<EquipmentAttachmentName, AvatarAttachmentTransform>> = {};
|
||||
for (const attachment of EQUIPMENT_ATTACHMENT_NAMES) {
|
||||
const transform = resolveAvatarAttachmentPoint(
|
||||
root,
|
||||
entry.attachmentPoints?.[attachment],
|
||||
fallbackTarget(root, attachment),
|
||||
{
|
||||
useTargetOrigin: attachment === "handLeft"
|
||||
|| attachment === "handRight"
|
||||
|| attachment === "shield",
|
||||
},
|
||||
);
|
||||
if (transform) transforms[attachment] = transform;
|
||||
}
|
||||
return transforms;
|
||||
}
|
||||
|
||||
function AttachedEquipmentModel({
|
||||
entry,
|
||||
itemModel,
|
||||
root,
|
||||
transform,
|
||||
}: {
|
||||
readonly entry: AvatarManifestEntry;
|
||||
readonly itemModel: EquipmentModelVisual;
|
||||
readonly root: Object3D;
|
||||
readonly transform: AvatarAttachmentTransform;
|
||||
}): ReactNode {
|
||||
const gltf = useGLTF(resolveContentUrl(itemModel.url));
|
||||
const scene = useMemo(
|
||||
() => cloneEquipmentScene(gltf.scene, `${itemModel.itemId}-${itemModel.attachment}`),
|
||||
[gltf.scene, itemModel.attachment, itemModel.itemId],
|
||||
);
|
||||
const transform = useMemo(
|
||||
() => attachmentTransform(root, entry, itemModel.attachment),
|
||||
[entry, itemModel.attachment, root],
|
||||
);
|
||||
|
||||
useEffect(() => () => {
|
||||
const materials = new Set<Material>();
|
||||
scene.traverse((node) => {
|
||||
@@ -130,52 +136,109 @@ function AttachedEquipmentModel({
|
||||
materials.forEach((material) => material.dispose());
|
||||
}, [scene]);
|
||||
|
||||
if (!transform) return null;
|
||||
const socketRotation = itemModel.fallbackKind && itemModel.weaponStance
|
||||
? avatarWeaponSocketRotation(entry, itemModel.fallbackKind, itemModel.weaponStance)
|
||||
: NO_ROTATION;
|
||||
|
||||
return createPortal(
|
||||
<primitive object={scene} position={transform.position} />,
|
||||
<group
|
||||
position={transform.position}
|
||||
rotation={socketRotation}
|
||||
scale={transform.scale}
|
||||
>
|
||||
<primitive object={scene} />
|
||||
</group>,
|
||||
transform.target,
|
||||
);
|
||||
}
|
||||
|
||||
export function EquipmentAttachments({
|
||||
attackPulseRef,
|
||||
classId,
|
||||
function EquipmentModelFallback({
|
||||
entry,
|
||||
models,
|
||||
root,
|
||||
fallbackScale,
|
||||
itemModel,
|
||||
transform,
|
||||
}: {
|
||||
readonly attackPulseRef?: MutableRefObject<number>;
|
||||
readonly classId: ClassId;
|
||||
readonly entry: AvatarManifestEntry;
|
||||
readonly fallbackScale: number;
|
||||
readonly itemModel: EquipmentModelVisual;
|
||||
readonly transform: AvatarAttachmentTransform;
|
||||
}): ReactNode {
|
||||
const hand = equipmentAttachmentHand(itemModel.attachment);
|
||||
if (!hand || !itemModel.fallbackKind || !itemModel.weaponStance) return null;
|
||||
const socketRotation = avatarWeaponSocketRotation(
|
||||
entry,
|
||||
itemModel.fallbackKind,
|
||||
itemModel.weaponStance,
|
||||
);
|
||||
return createPortal(
|
||||
<group
|
||||
position={transform.position}
|
||||
rotation={socketRotation}
|
||||
scale={transform.scale}
|
||||
>
|
||||
<StarterWeaponVisual
|
||||
hand={hand}
|
||||
kind={itemModel.fallbackKind}
|
||||
name={`fallback-equipment-${itemModel.itemId}-${itemModel.attachment}`}
|
||||
scale={fallbackScale}
|
||||
/>
|
||||
</group>,
|
||||
transform.target,
|
||||
);
|
||||
}
|
||||
|
||||
function ResolvedEquipmentAttachment({
|
||||
entry,
|
||||
fallbackScale,
|
||||
itemModel,
|
||||
transform,
|
||||
}: {
|
||||
readonly entry: AvatarManifestEntry;
|
||||
readonly fallbackScale: number;
|
||||
readonly itemModel: EquipmentModelVisual;
|
||||
readonly transform: AvatarAttachmentTransform;
|
||||
}): ReactNode {
|
||||
const fallback = (
|
||||
<EquipmentModelFallback
|
||||
entry={entry}
|
||||
fallbackScale={fallbackScale}
|
||||
itemModel={itemModel}
|
||||
transform={transform}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<AvatarErrorBoundary
|
||||
fallback={fallback}
|
||||
resetKey={`${itemModel.itemId}:${itemModel.attachment}:${itemModel.url}`}
|
||||
>
|
||||
<Suspense fallback={fallback}>
|
||||
<AttachedEquipmentModel entry={entry} itemModel={itemModel} transform={transform} />
|
||||
</Suspense>
|
||||
</AvatarErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export function EquipmentAttachments({
|
||||
entry,
|
||||
fallbackScale,
|
||||
models,
|
||||
transforms,
|
||||
}: {
|
||||
readonly entry: AvatarManifestEntry;
|
||||
readonly fallbackScale: number;
|
||||
readonly models: readonly EquipmentModelVisual[];
|
||||
readonly root: Object3D;
|
||||
readonly transforms: EquipmentAttachmentTransforms;
|
||||
}): ReactNode {
|
||||
return models.map((itemModel) => {
|
||||
const hand = equipmentAttachmentHand(itemModel.attachment);
|
||||
const fallback = hand
|
||||
? (
|
||||
<StarterWeaponAttachments
|
||||
attackPulseRef={attackPulseRef}
|
||||
classId={classId}
|
||||
onlyHands={[hand]}
|
||||
root={root}
|
||||
/>
|
||||
)
|
||||
: null;
|
||||
return (
|
||||
<AvatarErrorBoundary
|
||||
const transform = transforms[itemModel.attachment];
|
||||
return transform ? (
|
||||
<ResolvedEquipmentAttachment
|
||||
key={`${itemModel.itemId}:${itemModel.attachment}`}
|
||||
fallback={fallback}
|
||||
resetKey={`${itemModel.itemId}:${itemModel.attachment}:${itemModel.url}`}
|
||||
>
|
||||
<Suspense fallback={fallback}>
|
||||
<AttachedEquipmentModel
|
||||
entry={entry}
|
||||
itemModel={itemModel}
|
||||
root={root}
|
||||
/>
|
||||
</Suspense>
|
||||
</AvatarErrorBoundary>
|
||||
);
|
||||
entry={entry}
|
||||
fallbackScale={fallbackScale}
|
||||
itemModel={itemModel}
|
||||
transform={transform}
|
||||
/>
|
||||
) : null;
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,19 @@ import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AvatarManifest } from "./characterManifest";
|
||||
|
||||
interface RunewakerGlbDocument {
|
||||
readonly animations?: readonly { readonly name?: string }[];
|
||||
readonly nodes?: readonly { readonly name?: string }[];
|
||||
}
|
||||
|
||||
function readGlbDocument(glb: Buffer): RunewakerGlbDocument {
|
||||
expect(glb.readUInt32LE(0)).toBe(0x46546c67);
|
||||
const jsonLength = glb.readUInt32LE(12);
|
||||
return JSON.parse(
|
||||
glb.subarray(20, 20 + jsonLength).toString("utf8").trimEnd(),
|
||||
) as RunewakerGlbDocument;
|
||||
}
|
||||
|
||||
const manifestFile = path.resolve("public/assets/characters/runewaker-v1/manifest.json");
|
||||
|
||||
describe("runewaker-v1 playable avatar pack", () => {
|
||||
@@ -32,6 +45,17 @@ describe("runewaker-v1 playable avatar pack", () => {
|
||||
});
|
||||
const model = path.resolve("public", entry.model.replace(/^\//, ""));
|
||||
expect((await stat(model)).size).toBe(entry.bytes);
|
||||
const document = readGlbDocument(await readFile(model));
|
||||
const animationNames = new Set(
|
||||
document.animations?.map((animation) => animation.name ?? "") ?? [],
|
||||
);
|
||||
for (const animationName of ["Idle - 1h_idle", "Idle - 2h_idle", "Idle - bow_idle"]) {
|
||||
expect(animationNames.has(animationName), `${entry.model} ${animationName}`).toBe(true);
|
||||
}
|
||||
const nodeNames = new Set(document.nodes?.map((node) => node.name ?? "") ?? []);
|
||||
for (const nodeName of ["p_left_hand", "p_right_hand", "p_hand_shield"]) {
|
||||
expect(nodeNames.has(nodeName), `${entry.model} ${nodeName}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,26 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Group, Object3D } from "three";
|
||||
import { Euler, Group, Object3D, Vector3 } from "three";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CLASSES, type ClassId } from "../app/characterCatalog";
|
||||
import {
|
||||
STARTER_WEAPON_LOADOUTS,
|
||||
approximateStarterWeaponGripRotation,
|
||||
avatarWeaponSocketRotation,
|
||||
characterWeaponStance,
|
||||
estimateAvatarWeaponScale,
|
||||
findAvatarHandBone,
|
||||
findAvatarHandBones,
|
||||
findAvatarShieldBone,
|
||||
sampleStarterWeaponStrike,
|
||||
starterWeaponItemsForAvailableHands,
|
||||
starterWeaponLoadout,
|
||||
starterWeaponModelRotation,
|
||||
starterWeaponStance,
|
||||
} from "./starterWeapons";
|
||||
import type { AvatarManifestEntry } from "./characterManifest";
|
||||
import { resolveAvatarAttachmentPoint } from "./attachmentPoints";
|
||||
import { equipmentAttachmentHand } from "./equipmentAttachments";
|
||||
|
||||
interface RawGlbNode {
|
||||
@@ -93,6 +101,89 @@ describe("starter weapon loadouts", () => {
|
||||
expect(equipmentAttachmentHand("shield")).toBe("left");
|
||||
expect(equipmentAttachmentHand("shoulderLeft")).toBeNull();
|
||||
});
|
||||
|
||||
it("selects the native ready stance that matches each loadout", () => {
|
||||
expect(starterWeaponStance("warrior")).toBe("one-handed");
|
||||
expect(starterWeaponStance("rogue")).toBe("one-handed");
|
||||
expect(starterWeaponStance("hunter")).toBe("bow");
|
||||
expect(starterWeaponStance("priest")).toBe("two-handed-left");
|
||||
expect(starterWeaponStance("death-knight")).toBe("two-handed");
|
||||
expect(starterWeaponStance("rom-priest")).toBe("two-handed-left");
|
||||
});
|
||||
|
||||
it("lets visible equipped weapons override the class starter stance", () => {
|
||||
expect(characterWeaponStance("priest", [
|
||||
{ inventorySlot: "one-hand", itemId: 6472, weaponType: "dagger" },
|
||||
], [6472])).toBe("one-handed");
|
||||
expect(characterWeaponStance("warrior", [
|
||||
{ inventorySlot: "two-hand", itemId: 6631, weaponType: "staff" },
|
||||
], [6631])).toBe("two-handed-left");
|
||||
expect(characterWeaponStance("warrior", [
|
||||
{ inventorySlot: "ranged", itemId: 6469, weaponType: "bow" },
|
||||
], [6469])).toBe("bow");
|
||||
expect(characterWeaponStance("priest", [
|
||||
{ inventorySlot: "one-hand", itemId: 9999, weaponType: "dagger" },
|
||||
], [])).toBe("two-handed-left");
|
||||
});
|
||||
|
||||
it("normalizes procedural equipment to the native WoW item axes", () => {
|
||||
const rotated = (
|
||||
kind: Parameters<typeof starterWeaponModelRotation>[0],
|
||||
direction: readonly [number, number, number],
|
||||
) => new Vector3(...direction).applyEuler(new Euler(...starterWeaponModelRotation(kind)));
|
||||
|
||||
expect(rotated("sword", [0, -1, 0]).distanceTo(new Vector3(1, 0, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(rotated("dagger", [0, -1, 0]).distanceTo(new Vector3(1, 0, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(rotated("mace", [0, -1, 0]).distanceTo(new Vector3(1, 0, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(rotated("staff", [0, 1, 0]).distanceTo(new Vector3(1, 0, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(rotated("bow", [0, 1, 0]).distanceTo(new Vector3(1, 0, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(rotated("bow", [0, 0, -1]).distanceTo(new Vector3(0, 1, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(starterWeaponModelRotation("shield")).toEqual([0, 0, 0]);
|
||||
|
||||
const fallbackDirection = (
|
||||
kind: Parameters<typeof starterWeaponModelRotation>[0],
|
||||
direction: readonly [number, number, number],
|
||||
) => rotated(kind, direction).applyEuler(
|
||||
new Euler(...approximateStarterWeaponGripRotation(kind)),
|
||||
);
|
||||
expect(fallbackDirection("sword", [0, -1, 0]).distanceTo(new Vector3(0, -1, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(fallbackDirection("staff", [0, 1, 0]).distanceTo(new Vector3(0, 1, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(fallbackDirection("bow", [0, 1, 0]).distanceTo(new Vector3(0, 1, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
});
|
||||
|
||||
it("adapts canonical weapons to RuneWaker prop-socket axes", () => {
|
||||
const entry = {
|
||||
rendererStrategy: { kind: "runewaker-modular" },
|
||||
} as AvatarManifestEntry;
|
||||
const socketDirection = (
|
||||
kind: Parameters<typeof avatarWeaponSocketRotation>[1],
|
||||
stance: Parameters<typeof avatarWeaponSocketRotation>[2],
|
||||
) => new Vector3(1, 0, 0).applyEuler(
|
||||
new Euler(...avatarWeaponSocketRotation(entry, kind, stance)),
|
||||
);
|
||||
|
||||
expect(socketDirection("sword", "one-handed").distanceTo(new Vector3(0, 0, 1)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(socketDirection("bow", "bow").distanceTo(new Vector3(0, 0, 1)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(socketDirection("staff", "two-handed-left").distanceTo(new Vector3(0, 1, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(socketDirection("sword", "two-handed").distanceTo(new Vector3(0, 1, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(socketDirection("shield", "one-handed").distanceTo(new Vector3(1, 0, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(avatarWeaponSocketRotation(undefined, "sword", "one-handed"))
|
||||
.toEqual([0, 0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("avatar hand-bone discovery", () => {
|
||||
@@ -123,6 +214,37 @@ describe("avatar hand-bone discovery", () => {
|
||||
expect(findAvatarHandBone(root, "right")).toBeNull();
|
||||
});
|
||||
|
||||
it("prioritizes explicit RuneWaker equipment locators over generic hand bones", () => {
|
||||
const root = new Group();
|
||||
const genericLeft = new Group();
|
||||
genericLeft.name = "Bip01 L Hand";
|
||||
const genericRight = new Group();
|
||||
genericRight.name = "Bip01 R Hand";
|
||||
const leftPivot = new Group();
|
||||
leftPivot.name = "p_left_hand";
|
||||
const rightPivot = new Group();
|
||||
rightPivot.name = "p_right_hand";
|
||||
const shieldPivot = new Group();
|
||||
shieldPivot.name = "p_hand_shield";
|
||||
root.add(genericLeft, genericRight, leftPivot, rightPivot, shieldPivot);
|
||||
|
||||
expect(findAvatarHandBone(root, "left")).toBe(leftPivot);
|
||||
expect(findAvatarHandBone(root, "right")).toBe(rightPivot);
|
||||
expect(findAvatarShieldBone(root)).toBe(shieldPivot);
|
||||
});
|
||||
|
||||
it("cancels inherited locator scale for portal children", () => {
|
||||
const root = new Group();
|
||||
const scaledRig = new Group();
|
||||
scaledRig.scale.setScalar(0.1);
|
||||
const pivot = new Group();
|
||||
root.add(scaledRig);
|
||||
scaledRig.add(pivot);
|
||||
|
||||
const transform = resolveAvatarAttachmentPoint(root, undefined, pivot);
|
||||
expect(transform?.scale.toArray()).toEqual([10, 10, 10]);
|
||||
});
|
||||
|
||||
it("estimates equipment scale in avatar-local space and ignores parent transforms", () => {
|
||||
const actor = new Group();
|
||||
actor.position.set(20, 8, -12);
|
||||
@@ -143,7 +265,7 @@ describe("avatar hand-bone discovery", () => {
|
||||
});
|
||||
|
||||
it("resolves both hands in every shipped race/gender character rig", () => {
|
||||
const manifestPaths = ["wow335a-v1", "ascension-v1"].map((version) => path.join(
|
||||
const manifestPaths = ["wow335a-v1", "ascension-v1", "runewaker-v1"].map((version) => path.join(
|
||||
projectDirectory,
|
||||
"public",
|
||||
"assets",
|
||||
@@ -154,7 +276,7 @@ describe("avatar hand-bone discovery", () => {
|
||||
const entries = manifestPaths.flatMap((manifestPath) => (
|
||||
Object.values((JSON.parse(readFileSync(manifestPath, "utf8")) as CharacterManifest).entries)
|
||||
));
|
||||
expect(entries).toHaveLength(45);
|
||||
expect(entries).toHaveLength(50);
|
||||
const resolvedLeftNames = new Set<string>();
|
||||
const resolvedRightNames = new Set<string>();
|
||||
const unresolved: string[] = [];
|
||||
@@ -172,6 +294,8 @@ describe("avatar hand-bone discovery", () => {
|
||||
// accidentally hardcodes one id cannot satisfy this diversity check.
|
||||
expect(resolvedLeftNames.size).toBeGreaterThan(8);
|
||||
expect(resolvedRightNames.size).toBeGreaterThan(8);
|
||||
expect(resolvedLeftNames).toContain("p_left_hand");
|
||||
expect(resolvedRightNames).toContain("p_right_hand");
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
|
||||
+228
-27
@@ -7,6 +7,10 @@ import {
|
||||
type BaseClassId,
|
||||
type ClassId,
|
||||
} from "../app/characterCatalog";
|
||||
import type { CharacterWeaponStance } from "../game/combatAnimation";
|
||||
import type { InventoryItem } from "../game/lootTypes";
|
||||
import { resolveAvatarAttachmentPoint } from "./attachmentPoints";
|
||||
import type { AvatarAttachmentPoint, AvatarManifestEntry } from "./characterManifest";
|
||||
|
||||
export type AvatarHand = "left" | "right";
|
||||
|
||||
@@ -28,8 +32,11 @@ export interface StarterWeaponItem {
|
||||
export interface StarterWeaponLoadout {
|
||||
readonly classId: ClassId;
|
||||
readonly items: readonly StarterWeaponItem[];
|
||||
readonly stance: StarterWeaponStance;
|
||||
}
|
||||
|
||||
export type StarterWeaponStance = CharacterWeaponStance;
|
||||
|
||||
const item = (
|
||||
id: string,
|
||||
kind: StarterWeaponKind,
|
||||
@@ -45,6 +52,7 @@ const item = (
|
||||
const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLoadout>> = Object.freeze({
|
||||
warrior: Object.freeze({
|
||||
classId: "warrior",
|
||||
stance: "one-handed",
|
||||
items: Object.freeze([
|
||||
item("warrior-sword", "sword", "right", 1.04),
|
||||
item("warrior-shield", "shield", "left", 0.96),
|
||||
@@ -52,6 +60,7 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
|
||||
}),
|
||||
paladin: Object.freeze({
|
||||
classId: "paladin",
|
||||
stance: "one-handed",
|
||||
items: Object.freeze([
|
||||
item("paladin-mace", "mace", "right", 1.04),
|
||||
item("paladin-shield", "shield", "left", 0.98),
|
||||
@@ -59,10 +68,12 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
|
||||
}),
|
||||
hunter: Object.freeze({
|
||||
classId: "hunter",
|
||||
stance: "bow",
|
||||
items: Object.freeze([item("hunter-bow", "bow", "left", 1.08)]),
|
||||
}),
|
||||
rogue: Object.freeze({
|
||||
classId: "rogue",
|
||||
stance: "one-handed",
|
||||
items: Object.freeze([
|
||||
item("rogue-main-dagger", "dagger", "right", 0.94),
|
||||
item("rogue-off-dagger", "dagger", "left", 0.94),
|
||||
@@ -70,14 +81,17 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
|
||||
}),
|
||||
priest: Object.freeze({
|
||||
classId: "priest",
|
||||
stance: "two-handed-left",
|
||||
items: Object.freeze([item("priest-staff", "staff", "right", 0.96)]),
|
||||
}),
|
||||
"death-knight": Object.freeze({
|
||||
classId: "death-knight",
|
||||
stance: "two-handed",
|
||||
items: Object.freeze([item("death-knight-runeblade", "sword", "right", 1.18)]),
|
||||
}),
|
||||
shaman: Object.freeze({
|
||||
classId: "shaman",
|
||||
stance: "one-handed",
|
||||
items: Object.freeze([
|
||||
item("shaman-mace", "mace", "right", 1),
|
||||
item("shaman-shield", "shield", "left", 0.9),
|
||||
@@ -85,14 +99,17 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
|
||||
}),
|
||||
mage: Object.freeze({
|
||||
classId: "mage",
|
||||
stance: "two-handed-left",
|
||||
items: Object.freeze([item("mage-staff", "staff", "right", 0.94)]),
|
||||
}),
|
||||
warlock: Object.freeze({
|
||||
classId: "warlock",
|
||||
stance: "two-handed-left",
|
||||
items: Object.freeze([item("warlock-staff", "staff", "right", 1)]),
|
||||
}),
|
||||
druid: Object.freeze({
|
||||
classId: "druid",
|
||||
stance: "two-handed-left",
|
||||
items: Object.freeze([item("druid-staff", "staff", "right", 1.02)]),
|
||||
}),
|
||||
});
|
||||
@@ -103,6 +120,7 @@ export const STARTER_WEAPON_LOADOUTS: Readonly<Record<ClassId, StarterWeaponLoad
|
||||
return [characterClass.id, Object.freeze({
|
||||
classId: characterClass.id,
|
||||
items: source.items,
|
||||
stance: source.stance,
|
||||
})];
|
||||
})) as unknown as Record<ClassId, StarterWeaponLoadout>,
|
||||
);
|
||||
@@ -111,6 +129,30 @@ export function starterWeaponLoadout(classId: ClassId): StarterWeaponLoadout {
|
||||
return STARTER_WEAPON_LOADOUTS[classId];
|
||||
}
|
||||
|
||||
export function starterWeaponStance(classId: ClassId): StarterWeaponStance {
|
||||
return starterWeaponLoadout(classId).stance;
|
||||
}
|
||||
|
||||
type VisibleWeaponItem = Pick<InventoryItem, "inventorySlot" | "itemId" | "weaponType">;
|
||||
|
||||
/** Selects a ready pose from the weapon models that are actually being rendered. */
|
||||
export function characterWeaponStance(
|
||||
classId: ClassId,
|
||||
equipment: readonly VisibleWeaponItem[] = [],
|
||||
visibleWeaponItemIds: readonly number[] = [],
|
||||
): StarterWeaponStance {
|
||||
const visibleIds = new Set(visibleWeaponItemIds);
|
||||
const visibleWeapons = equipment.filter((equipped) => (
|
||||
equipped.weaponType && visibleIds.has(equipped.itemId)
|
||||
));
|
||||
if (visibleWeapons.some((equipped) => equipped.weaponType === "bow")) return "bow";
|
||||
const weapon = visibleWeapons.find((equipped) => equipped.inventorySlot !== "ranged")
|
||||
?? visibleWeapons[0];
|
||||
if (!weapon) return starterWeaponStance(classId);
|
||||
if (weapon.weaponType === "staff") return "two-handed-left";
|
||||
return weapon.inventorySlot === "two-hand" ? "two-handed" : "one-handed";
|
||||
}
|
||||
|
||||
export function starterWeaponItemsForAvailableHands(
|
||||
classId: ClassId,
|
||||
occupiedHands: readonly AvatarHand[] = [],
|
||||
@@ -124,6 +166,13 @@ const FINGER_PARENT_PATTERN: Readonly<Record<AvatarHand, RegExp>> = Object.freez
|
||||
right: /(?:Index|Middle|Ring|Pinky)FingerR_p$/i,
|
||||
});
|
||||
|
||||
const EXPLICIT_HAND_PIVOT_PATTERN: Readonly<Record<AvatarHand, RegExp>> = Object.freeze({
|
||||
left: /^p_left_hand$/i,
|
||||
right: /^p_right_hand$/i,
|
||||
});
|
||||
|
||||
const EXPLICIT_SHIELD_PIVOT_PATTERN = /^p_hand_shield$/i;
|
||||
|
||||
const NAMED_HAND_PATTERN: Readonly<Record<AvatarHand, readonly RegExp[]>> = Object.freeze({
|
||||
left: Object.freeze([
|
||||
/(?:^|[_ .-])L Hand$/i,
|
||||
@@ -142,11 +191,19 @@ const NAMED_HAND_PATTERN: Readonly<Record<AvatarHand, readonly RegExp[]>> = Obje
|
||||
});
|
||||
|
||||
/**
|
||||
* Finds a playable-character hand without relying on exporter-specific numeric
|
||||
* bone ids. WoW's named finger pivot nodes survive every shipped race/gender
|
||||
* export, and their shared parent is the animated hand bone.
|
||||
* Finds a playable-character grip without relying on exporter-specific numeric
|
||||
* bone ids. RuneWaker's explicit link-frame locator wins when present. WoW's
|
||||
* named finger pivots otherwise identify their shared animated hand bone.
|
||||
*/
|
||||
export function findAvatarHandBone(root: Object3D, hand: AvatarHand): Object3D | null {
|
||||
let explicitPivot: Object3D | null = null;
|
||||
root.traverse((node) => {
|
||||
if (!explicitPivot && EXPLICIT_HAND_PIVOT_PATTERN[hand].test(node.name)) {
|
||||
explicitPivot = node;
|
||||
}
|
||||
});
|
||||
if (explicitPivot) return explicitPivot;
|
||||
|
||||
const votes = new Map<Object3D, number>();
|
||||
root.traverse((node) => {
|
||||
if (!FINGER_PARENT_PATTERN[hand].test(node.name) || !node.parent) return;
|
||||
@@ -171,6 +228,16 @@ export function findAvatarHandBone(root: Object3D, hand: AvatarHand): Object3D |
|
||||
return namedFallback;
|
||||
}
|
||||
|
||||
export function findAvatarShieldBone(root: Object3D): Object3D | null {
|
||||
let explicitPivot: Object3D | null = null;
|
||||
root.traverse((node) => {
|
||||
if (!explicitPivot && EXPLICIT_SHIELD_PIVOT_PATTERN.test(node.name)) {
|
||||
explicitPivot = node;
|
||||
}
|
||||
});
|
||||
return explicitPivot ?? findAvatarHandBone(root, "left");
|
||||
}
|
||||
|
||||
export interface AvatarHandBones {
|
||||
readonly left: Object3D | null;
|
||||
readonly right: Object3D | null;
|
||||
@@ -206,6 +273,67 @@ export function estimateAvatarWeaponScale(
|
||||
export type StarterWeaponRotation = readonly [x: number, y: number, z: number];
|
||||
|
||||
const ZERO_ROTATION: StarterWeaponRotation = Object.freeze([0, 0, 0]);
|
||||
const STARTER_WEAPON_MODEL_ROTATIONS: Readonly<
|
||||
Record<StarterWeaponKind, StarterWeaponRotation>
|
||||
> = Object.freeze({
|
||||
bow: Object.freeze([Math.PI / 2, 0, -Math.PI / 2] as const),
|
||||
dagger: Object.freeze([0, 0, Math.PI / 2] as const),
|
||||
mace: Object.freeze([0, 0, Math.PI / 2] as const),
|
||||
shield: ZERO_ROTATION,
|
||||
staff: Object.freeze([0, 0, -Math.PI / 2] as const),
|
||||
sword: Object.freeze([0, 0, Math.PI / 2] as const),
|
||||
});
|
||||
|
||||
const APPROXIMATE_WEAPON_GRIP_ROTATIONS: Readonly<
|
||||
Record<StarterWeaponKind, StarterWeaponRotation>
|
||||
> = Object.freeze({
|
||||
bow: Object.freeze([0, 0, Math.PI / 2] as const),
|
||||
dagger: Object.freeze([0, 0, -Math.PI / 2] as const),
|
||||
mace: Object.freeze([0, 0, -Math.PI / 2] as const),
|
||||
shield: ZERO_ROTATION,
|
||||
staff: Object.freeze([0, 0, Math.PI / 2] as const),
|
||||
sword: Object.freeze([0, 0, -Math.PI / 2] as const),
|
||||
});
|
||||
|
||||
const RUNEWAKER_ONE_HAND_SOCKET_ROTATION: StarterWeaponRotation = Object.freeze([
|
||||
0,
|
||||
-Math.PI / 2,
|
||||
0,
|
||||
]);
|
||||
const RUNEWAKER_TWO_HAND_SOCKET_ROTATION: StarterWeaponRotation = Object.freeze([
|
||||
0,
|
||||
0,
|
||||
Math.PI / 2,
|
||||
]);
|
||||
|
||||
/** Normalizes procedural geometry to the same local axes as native item GLBs. */
|
||||
export function starterWeaponModelRotation(
|
||||
kind: StarterWeaponKind,
|
||||
): StarterWeaponRotation {
|
||||
return STARTER_WEAPON_MODEL_ROTATIONS[kind];
|
||||
}
|
||||
|
||||
/** Synthetic hand pose used only while the animated character rig is absent. */
|
||||
export function approximateStarterWeaponGripRotation(
|
||||
kind: StarterWeaponKind,
|
||||
): StarterWeaponRotation {
|
||||
return APPROXIMATE_WEAPON_GRIP_ROTATIONS[kind];
|
||||
}
|
||||
|
||||
/** Adapts the canonical WoW +X item frame to a rig's authored prop socket. */
|
||||
export function avatarWeaponSocketRotation(
|
||||
entry: AvatarManifestEntry | undefined,
|
||||
kind: StarterWeaponKind,
|
||||
stance: StarterWeaponStance,
|
||||
): StarterWeaponRotation {
|
||||
if (entry?.rendererStrategy?.kind !== "runewaker-modular" || kind === "shield") {
|
||||
return ZERO_ROTATION;
|
||||
}
|
||||
return stance === "two-handed" || stance === "two-handed-left"
|
||||
? RUNEWAKER_TWO_HAND_SOCKET_ROTATION
|
||||
: RUNEWAKER_ONE_HAND_SOCKET_ROTATION;
|
||||
}
|
||||
|
||||
const STRIKE_DURATION_SECONDS = 0.54;
|
||||
|
||||
const clamp01 = (value: number): number => Math.max(0, Math.min(1, value));
|
||||
@@ -374,16 +502,16 @@ function BowVisual() {
|
||||
|
||||
function ShieldVisual() {
|
||||
return (
|
||||
<group name="starter-shield-geometry" position={[-0.08, 0, 0]}>
|
||||
<mesh rotation={[0, 0, Math.PI / 2]}>
|
||||
<group name="starter-shield-geometry" position={[0, 0, -0.08]}>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.38, 0.38, 0.07, 12]} />
|
||||
<meshStandardMaterial color="#6f5635" metalness={0.18} roughness={0.74} />
|
||||
</mesh>
|
||||
<mesh position={[-0.045, 0, 0]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<mesh position={[0, 0, -0.045]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.12, 0.12, 0.09, 10]} />
|
||||
<meshStandardMaterial color="#a6afb4" metalness={0.75} roughness={0.31} />
|
||||
</mesh>
|
||||
<mesh position={[-0.045, 0, 0]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<mesh position={[0, 0, -0.045]}>
|
||||
<torusGeometry args={[0.3, 0.035, 6, 16]} />
|
||||
<meshStandardMaterial color="#a6afb4" metalness={0.68} roughness={0.35} />
|
||||
</mesh>
|
||||
@@ -392,12 +520,22 @@ function ShieldVisual() {
|
||||
}
|
||||
|
||||
function WeaponGeometry({ kind }: { readonly kind: StarterWeaponKind }) {
|
||||
if (kind === "bow") return <BowVisual />;
|
||||
if (kind === "dagger") return <DaggerVisual />;
|
||||
if (kind === "mace") return <MaceVisual />;
|
||||
if (kind === "shield") return <ShieldVisual />;
|
||||
if (kind === "staff") return <StaffVisual />;
|
||||
return <SwordVisual />;
|
||||
const visual = kind === "bow"
|
||||
? <BowVisual />
|
||||
: kind === "dagger"
|
||||
? <DaggerVisual />
|
||||
: kind === "mace"
|
||||
? <MaceVisual />
|
||||
: kind === "shield"
|
||||
? <ShieldVisual />
|
||||
: kind === "staff"
|
||||
? <StaffVisual />
|
||||
: <SwordVisual />;
|
||||
return (
|
||||
<group rotation={starterWeaponModelRotation(kind)}>
|
||||
{visual}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export interface StarterWeaponVisualProps {
|
||||
@@ -449,35 +587,88 @@ export function StarterWeaponVisual({
|
||||
interface AttachedStarterWeaponProps {
|
||||
readonly attackPulse: number;
|
||||
readonly attackPulseRef?: MutableRefObject<number>;
|
||||
readonly attachmentScale: Vector3;
|
||||
readonly item: StarterWeaponItem;
|
||||
readonly position: Vector3;
|
||||
readonly scale: number;
|
||||
readonly socketRotation: StarterWeaponRotation;
|
||||
readonly target: Object3D;
|
||||
}
|
||||
|
||||
interface ResolvedStarterWeaponProps {
|
||||
readonly attackPulse: number;
|
||||
readonly attackPulseRef?: MutableRefObject<number>;
|
||||
readonly fallbackTarget: Object3D | null;
|
||||
readonly item: StarterWeaponItem;
|
||||
readonly point?: AvatarAttachmentPoint;
|
||||
readonly root: Object3D;
|
||||
readonly scale: number;
|
||||
readonly socketRotation: StarterWeaponRotation;
|
||||
}
|
||||
|
||||
function AttachedStarterWeapon({
|
||||
attackPulse,
|
||||
attackPulseRef,
|
||||
attachmentScale,
|
||||
item: equipped,
|
||||
position,
|
||||
scale,
|
||||
socketRotation,
|
||||
target,
|
||||
}: AttachedStarterWeaponProps) {
|
||||
return createPortal(
|
||||
<StarterWeaponVisual
|
||||
attackPulse={attackPulse}
|
||||
attackPulseRef={attackPulseRef}
|
||||
hand={equipped.hand}
|
||||
kind={equipped.kind}
|
||||
name={`starter-equipment-${equipped.id}`}
|
||||
scale={scale * equipped.scale}
|
||||
/>,
|
||||
<group position={position} rotation={socketRotation} scale={attachmentScale}>
|
||||
<StarterWeaponVisual
|
||||
attackPulse={attackPulse}
|
||||
attackPulseRef={attackPulseRef}
|
||||
hand={equipped.hand}
|
||||
kind={equipped.kind}
|
||||
name={`starter-equipment-${equipped.id}`}
|
||||
scale={scale * equipped.scale}
|
||||
/>
|
||||
</group>,
|
||||
target,
|
||||
);
|
||||
}
|
||||
|
||||
function ResolvedStarterWeapon({
|
||||
attackPulse,
|
||||
attackPulseRef,
|
||||
fallbackTarget,
|
||||
item: equipped,
|
||||
point,
|
||||
root,
|
||||
scale,
|
||||
socketRotation,
|
||||
}: ResolvedStarterWeaponProps): ReactNode {
|
||||
const transform = useMemo(
|
||||
() => resolveAvatarAttachmentPoint(
|
||||
root,
|
||||
point,
|
||||
fallbackTarget,
|
||||
{ useTargetOrigin: true },
|
||||
),
|
||||
[fallbackTarget, point, root],
|
||||
);
|
||||
return transform ? (
|
||||
<AttachedStarterWeapon
|
||||
attackPulse={attackPulse}
|
||||
attackPulseRef={attackPulseRef}
|
||||
attachmentScale={transform.scale}
|
||||
item={equipped}
|
||||
position={transform.position}
|
||||
scale={scale}
|
||||
socketRotation={socketRotation}
|
||||
target={transform.target}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export interface StarterWeaponAttachmentsProps {
|
||||
readonly attackPulse?: number;
|
||||
readonly attackPulseRef?: MutableRefObject<number>;
|
||||
readonly classId: ClassId;
|
||||
readonly entry?: AvatarManifestEntry;
|
||||
readonly occupiedHands?: readonly AvatarHand[];
|
||||
readonly onlyHands?: readonly AvatarHand[];
|
||||
readonly root: Object3D;
|
||||
@@ -493,6 +684,7 @@ export function StarterWeaponAttachments({
|
||||
attackPulse = 0,
|
||||
attackPulseRef,
|
||||
classId,
|
||||
entry,
|
||||
occupiedHands = [],
|
||||
onlyHands,
|
||||
root,
|
||||
@@ -504,20 +696,29 @@ export function StarterWeaponAttachments({
|
||||
[hands, root, scale],
|
||||
);
|
||||
const allowedHands = onlyHands ? new Set(onlyHands) : null;
|
||||
const stance = starterWeaponStance(classId);
|
||||
const items = starterWeaponItemsForAvailableHands(classId, occupiedHands)
|
||||
.filter((item) => !allowedHands || allowedHands.has(item.hand));
|
||||
return items.map((equipped) => {
|
||||
const target = hands[equipped.hand];
|
||||
return target ? (
|
||||
<AttachedStarterWeapon
|
||||
const attachmentName = equipped.kind === "shield"
|
||||
? "shield"
|
||||
: equipped.hand === "left" ? "handLeft" : "handRight";
|
||||
const fallbackTarget = equipped.kind === "shield"
|
||||
? findAvatarShieldBone(root)
|
||||
: hands[equipped.hand];
|
||||
return (
|
||||
<ResolvedStarterWeapon
|
||||
key={equipped.id}
|
||||
attackPulse={attackPulse}
|
||||
attackPulseRef={attackPulseRef}
|
||||
fallbackTarget={fallbackTarget}
|
||||
item={equipped}
|
||||
point={entry?.attachmentPoints?.[attachmentName]}
|
||||
root={root}
|
||||
scale={equipmentScale}
|
||||
target={target}
|
||||
socketRotation={avatarWeaponSocketRotation(entry, equipped.kind, stance)}
|
||||
/>
|
||||
) : null;
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -551,7 +752,7 @@ export function ApproximateStarterEquipment({
|
||||
<group
|
||||
key={equipped.id}
|
||||
position={handPositions[equipped.hand]}
|
||||
rotation={[0, -Math.PI / 2, 0]}
|
||||
rotation={approximateStarterWeaponGripRotation(equipped.kind)}
|
||||
>
|
||||
<StarterWeaponVisual
|
||||
attackPulse={attackPulse}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
|
||||
|
||||
describe("combatSimulationTimerShouldRun", () => {
|
||||
it("runs during active local gameplay", () => {
|
||||
expect(combatSimulationTimerShouldRun({
|
||||
sharedOnline: false,
|
||||
paused: false,
|
||||
companionOpen: false,
|
||||
documentHidden: false,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ paused: true, companionOpen: false, documentHidden: false },
|
||||
{ paused: false, companionOpen: true, documentHidden: false },
|
||||
{ paused: false, companionOpen: false, documentHidden: true },
|
||||
])("stops inactive local gameplay for %o", (state) => {
|
||||
expect(combatSimulationTimerShouldRun({
|
||||
sharedOnline: false,
|
||||
...state,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves shared-online simulation in every local lifecycle state", () => {
|
||||
expect(combatSimulationTimerShouldRun({
|
||||
sharedOnline: true,
|
||||
paused: true,
|
||||
companionOpen: true,
|
||||
documentHidden: true,
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
+84
-60
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { advanceDungeonPartyCombat } from "./partyRuntime";
|
||||
import { useGameStore } from "./store";
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
onlineSessionIsActive,
|
||||
onlineSessionOwnsWorldSimulation,
|
||||
} from "./onlineSessionRuntime";
|
||||
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
|
||||
|
||||
/**
|
||||
* Connects the pure combat runtime to the active character and persistent
|
||||
@@ -42,73 +44,55 @@ export function CombatBridge() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastKey = `${character.level}:${character.experience}:${JSON.stringify(character.talentRanks)}`;
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextKey = `${state.level}:${state.xp}:${JSON.stringify(state.talentRanks)}`;
|
||||
if (nextKey === lastKey) return;
|
||||
lastKey = nextKey;
|
||||
// Kills and talent changes are infrequent, so persist immediately. This
|
||||
// also guarantees a fast return to Character Select cannot outrun a
|
||||
// deferred save and lose the last level or talent point.
|
||||
useShellStore.getState().saveActiveProgression(state.level, state.xp, state.talentRanks);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
const characterId = character.id;
|
||||
const unsubscribe = useCombatStore.subscribe((state, previousState) => {
|
||||
// Save APIs intentionally target the active character. Avoid persisting a
|
||||
// final combat notification during the render-to-effect-cleanup window
|
||||
// after Character Select has already replaced the active character.
|
||||
const shell = useShellStore.getState();
|
||||
if (shell.activeCharacter?.id !== characterId) return;
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastKey = JSON.stringify(useCombatStore.getState().actionBindings);
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextKey = JSON.stringify(state.actionBindings);
|
||||
if (nextKey === lastKey) return;
|
||||
lastKey = nextKey;
|
||||
// Manastorm's local spell-selection substitute temporarily rewrites the
|
||||
// base controls. Never persist that run-only overlay to normal dungeons.
|
||||
if (useGameStore.getState().gameMode === "manastorm") return;
|
||||
useShellStore.getState().saveActiveActionBindings(state.actionBindings);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
// Persisted collections are replaced immutably by combat actions. A
|
||||
// reference/primitive comparison therefore detects the same changes as
|
||||
// serializing each collection, without doing five full-store callbacks
|
||||
// and JSON.stringify work for every 100 ms simulation notification.
|
||||
if (
|
||||
state.level !== previousState.level
|
||||
|| state.xp !== previousState.xp
|
||||
|| state.talentRanks !== previousState.talentRanks
|
||||
) {
|
||||
// Kills and talent changes are infrequent, so persist immediately. This
|
||||
// also guarantees a fast return to Character Select cannot outrun a
|
||||
// deferred save and lose the last level or talent point.
|
||||
shell.saveActiveProgression(state.level, state.xp, state.talentRanks);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastInventory = JSON.stringify(character.inventory);
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextInventory = JSON.stringify(state.inventory);
|
||||
if (nextInventory === lastInventory) return;
|
||||
lastInventory = nextInventory;
|
||||
useShellStore.getState().saveActiveInventory(state.inventory);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
if (state.actionBindings !== previousState.actionBindings) {
|
||||
// Manastorm's local spell-selection substitute temporarily rewrites the
|
||||
// base controls. Never persist that run-only overlay to normal dungeons.
|
||||
if (useGameStore.getState().gameMode !== "manastorm") {
|
||||
shell.saveActiveActionBindings(state.actionBindings);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastEquipment = JSON.stringify(character.equipment ?? {});
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextEquipment = JSON.stringify(state.equipment);
|
||||
if (nextEquipment === lastEquipment) return;
|
||||
lastEquipment = nextEquipment;
|
||||
useShellStore.getState().saveActiveEquipment(state.equipment);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastSettings = JSON.stringify(useCombatStore.getState().settings);
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextSettings = JSON.stringify(state.settings);
|
||||
if (nextSettings === lastSettings) return;
|
||||
lastSettings = nextSettings;
|
||||
useShellStore.getState().saveActiveSettings(state.settings);
|
||||
if (state.inventory !== previousState.inventory) {
|
||||
shell.saveActiveInventory(state.inventory);
|
||||
}
|
||||
if (state.equipment !== previousState.equipment) {
|
||||
shell.saveActiveEquipment(state.equipment);
|
||||
}
|
||||
if (state.settings !== previousState.settings) {
|
||||
shell.saveActiveSettings(state.settings);
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
let previous = performance.now();
|
||||
const timer = window.setInterval(() => {
|
||||
let timer: number | null = null;
|
||||
|
||||
const advance = () => {
|
||||
const now = performance.now();
|
||||
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
|
||||
previous = now;
|
||||
@@ -138,8 +122,48 @@ export function CombatBridge() {
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
return () => window.clearInterval(timer);
|
||||
};
|
||||
|
||||
const reconcileTimer = () => {
|
||||
const game = useGameStore.getState();
|
||||
const shouldRun = combatSimulationTimerShouldRun({
|
||||
sharedOnline: onlineSessionIsActive(),
|
||||
paused: game.paused,
|
||||
companionOpen: game.companionOpen,
|
||||
documentHidden: document.hidden,
|
||||
});
|
||||
if (shouldRun && timer === null) {
|
||||
// Reset the clock on resume so a long pause/background period cannot
|
||||
// leak into the first simulation delta.
|
||||
previous = performance.now();
|
||||
timer = window.setInterval(advance, 100);
|
||||
} else if (!shouldRun && timer !== null) {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribeGame = useGameStore.subscribe((state, previousState) => {
|
||||
if (
|
||||
state.paused !== previousState.paused
|
||||
|| state.companionOpen !== previousState.companionOpen
|
||||
) {
|
||||
reconcileTimer();
|
||||
}
|
||||
});
|
||||
// Online runtime configuration is intentionally module-local; the session
|
||||
// store notification gives the bridge a reactive point at which to
|
||||
// reconcile while preserving continuous shared-online simulation.
|
||||
const unsubscribeOnlineSession = useOnlineSessionStore.subscribe(reconcileTimer);
|
||||
document.addEventListener("visibilitychange", reconcileTimer);
|
||||
reconcileTimer();
|
||||
|
||||
return () => {
|
||||
if (timer !== null) window.clearInterval(timer);
|
||||
unsubscribeGame();
|
||||
unsubscribeOnlineSession();
|
||||
document.removeEventListener("visibilitychange", reconcileTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
|
||||
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user