Update Healer Man server

This commit is contained in:
phenom committed 2026-09-27 18:46:30 -04:00
1 parent e284f4fc76
commit 15a5254380
52 files changed
+637 -80

No files matched your search

+7
View File
@@ -14,6 +14,13 @@ camera, input, physics, mob-rendering, or asset-loading code changes.
- The isolated model preview also loaded the same graph as one mesh layer. The - The isolated model preview also loaded the same graph as one mesh layer. The
combat test verified a fiend-owned damage event and 5% maximum-mana return combat test verified a fiend-owned damage event and 5% maximum-mana return
from the successful hit. Full dungeon entry was not part of this check. from the successful hit. Full dungeon entry was not part of this check.
- After moving model preparation into the Priest gameplay transition, the
preview waited for the same shared media cache before casting. In a fresh
Edge session, the graph and texture requests completed before the cast
(`15165.5 ms` and `15251.5 ms` versus cast at `15265.5 ms`); no page errors or
frame gap over 50 ms occurred after the cast. The source model remained visible
in `playtest-artifacts/shadowfiend/warmup-check.png`. This isolated preview does
not establish the full dungeon frame rate.
## 2026-09-26 RuneWaker dungeon minimap and M-key map ## 2026-09-26 RuneWaker dungeon minimap and M-key map
+2 -1
View File
@@ -14,7 +14,7 @@
"web:prepare": "node scripts/content/prepare-web-bootstrap.mjs", "web:prepare": "node scripts/content/prepare-web-bootstrap.mjs",
"android:prepare": "node scripts/content/prepare-android-bootstrap.mjs", "android:prepare": "node scripts/content/prepare-android-bootstrap.mjs",
"content:test": "node --test scripts/content/*.test.mjs", "content:test": "node --test scripts/content/*.test.mjs",
"effects:generate": "node scripts/combat-effects/generate-manifest.mjs --package-audio && node scripts/combat-effects/package-wow-original.mjs", "effects:generate": "node scripts/combat-effects/generate-manifest.mjs --package-audio && node scripts/combat-effects/package-wow-original.mjs && node scripts/combat-effects/package-ascension-meteor.mjs",
"effects:rom": "node scripts/combat-effects/generate-manifest.mjs --runewaker-only --package-audio", "effects:rom": "node scripts/combat-effects/generate-manifest.mjs --runewaker-only --package-audio",
"effects:rom:particles": "node scripts/combat-effects/package-rom-particles.mjs", "effects:rom:particles": "node scripts/combat-effects/package-rom-particles.mjs",
"effects:rom:audit": "node scripts/combat-effects/audit-rom-media.mjs --write", "effects:rom:audit": "node scripts/combat-effects/audit-rom-media.mjs --write",
@@ -22,6 +22,7 @@
"effects:rom:batch": "npm run effects:rom && npm run effects:rom:particles && npm run effects:rom && npm run effects:rom:audit && npm run effects:rom:plan", "effects:rom:batch": "npm run effects:rom && npm run effects:rom:particles && npm run effects:rom && npm run effects:rom:audit && npm run effects:rom:plan",
"effects:audit": "node scripts/combat-effects/package-wow-original.mjs --audit", "effects:audit": "node scripts/combat-effects/package-wow-original.mjs --audit",
"effects:wow": "node scripts/combat-effects/package-wow-original.mjs", "effects:wow": "node scripts/combat-effects/package-wow-original.mjs",
"effects:meteor": "node scripts/combat-effects/package-ascension-meteor.mjs",
"effects:priest": "npm run effects:wow", "effects:priest": "npm run effects:wow",
"effects:test": "node --test scripts/combat-effects/*.test.mjs", "effects:test": "node --test scripts/combat-effects/*.test.mjs",
"wow335:abilities": "node scripts/generate-wow335-abilities.mjs", "wow335:abilities": "node scripts/generate-wow335-abilities.mjs",
@@ -1 +1 @@
{"schemaVersion":1,"generatedAt":null,"sources":[{"kind":"wow335","path":"../wow335a/Data/enUS/patch-enUS.MPQ","sha256":"9f0088da35c5ed0b5106cd20e422611489e435b74043e520e02221e0ce1b1af1"},{"kind":"ascension","path":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","sha256":"2f77f18b91ffbae32a19568e4bc05ec78ee384b6ba8dd2a6e05f0e97abbb2dce"},{"kind":"runewaker","path":"../Runewaker/Resource/data","sha256":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9"}],"coverage":{"requested":6844,"authentic":5620,"fallback":1224,"fallbackKeys":["wow335:20647","wow335:2687","wow335:64380","wow335:20187","wow335:25781","wow335:20185","wow335:20186","wow335:5502","wow335:20184","wow335:53651","wow335:31898","wow335:31804","wow335:53733","wow335:53726","wow335:75","wow335:13481","wow335:781","wow335:34026","wow335:34754","wow335:47633","wow335:46584","wow335:26372","wow335:49278","wow335:49279","wow335:44450","wow335:34913","wow335:43043","wow335:43044","wow335:55342","wow335:29886","wow335:58889","wow335:5225","wow335:52610","ascension:705153","ascension:705154","ascension:560456","ascension:800193","ascension:561322","ascension:804138","ascension:805821","ascension:801767","ascension:707583","ascension:705160","ascension:705184","ascension:705185","ascension:804746","ascension:706346","ascension:706347","ascension:705238","ascension:92081","ascension:706354","ascension:92083","ascension:560906","ascension:707207","ascension:706326","ascension:706327","ascension:92082","ascension:705209","ascension:705210","ascension:705215","ascension:707721","ascension:705196","ascension:706804","ascension:706350","ascension:706351","ascension:705206","ascension:561314","ascension:705172","ascension:561321","ascension:705171","ascension:807861","ascension:800943","ascension:561394","ascension:705202","ascension:705241","ascension:705174","ascension:705170","ascension:705169","ascension:706353","ascension:560564","ascension:503408","ascension:503409","ascension:503410","ascension:503411","ascension:503412","ascension:503413","ascension:503414","ascension:705921","ascension:301297","ascension:572870","ascension:92085","ascension:705923","ascension:712396","ascension:802756","ascension:705906","ascension:705888","ascension:705852","ascension:681465","ascension:705889","ascension:705890","ascension:705873","ascension:806283","ascension:705849","ascension:705907","ascension:706368","ascension:705936","ascension:705937","ascension:705861","ascension:705909","ascension:503748","ascension:92084","ascension:705840","ascension:705841","ascension:92086","ascension:705911","ascension:705878","ascension:706538","ascension:300663","ascension:705903","ascension:705904","ascension:501220","ascension:501221","ascension:501222","ascension:501223","ascension:501224","ascension:501225","ascension:501226","ascension:501227","ascension:501228","ascension:705843","ascension:706482","ascension:501162","ascension:705848","ascension:705844","ascension:807296","ascension:707617","ascension:704500","ascension:560822","ascension:706266","ascension:707844","ascension:92089","ascension:681376","ascension:300465","ascension:92087","ascension:520251","ascension:705102","ascension:707842","ascension:520257","ascension:705108","ascension:804613","ascension:704607","ascension:705120","ascension:705151","ascension:92088","ascension:705105","ascension:560573","ascension:704360","ascension:301287","ascension:707513","ascension:704374","ascension:804609","ascension:300468","ascension:705128","ascension:805244","ascension:704613","ascension:524947","ascension:704612","ascension:807430","ascension:705113","ascension:705125","ascension:705133","ascension:705521","ascension:705522","ascension:705523","ascension:705498","ascension:707862","ascension:92091","ascension:680529","ascension:681484","ascension:806195","ascension:802011","ascension:705488","ascension:92094","ascension:705466","ascension:92093","ascension:705493","ascension:705454","ascension:789256","ascension:707230","ascension:706242","ascension:706243","ascension:705528","ascension:681092","ascension:804024","ascension:680253","ascension:705526","ascension:803638","ascension:705534","ascension:800165","ascension:520211","ascension:520212","ascension:520213","ascension:578297","ascension:574319","ascension:574320","ascension:807462","ascension:807887","ascension:705690","ascension:705691","ascension:92097","ascension:705675","ascension:707053","ascension:705720","ascension:705701","ascension:570722","ascension:92098","ascension:705647","ascension:300595","ascension:92096","ascension:705640","ascension:567542","ascension:705693","ascension:705641","ascension:705642","ascension:705656","ascension:705648","ascension:705649","ascension:707652","ascension:705651","ascension:705696","ascension:705697","ascension:705698","ascension:800699","ascension:807233","ascension:705659","ascension:705660","ascension:804040","ascension:705677","ascension:705689","ascension:567533","ascension:705Line truncated {"schemaVersion":1,"generatedAt":null,"sources":[{"kind":"wow335","path":"../wow335a/Data/enUS/patch-enUS.MPQ","sha256":"9f0088da35c5ed0b5106cd20e422611489e435b74043e520e02221e0ce1b1af1"},{"kind":"ascension","path":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","sha256":"2f77f18b91ffbae32a19568e4bc05ec78ee384b6ba8dd2a6e05f0e97abbb2dce"},{"kind":"runewaker","path":"../Runewaker/Resource/data","sha256":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9"}],"coverage":{"requested":6844,"authentic":5620,"fallback":1224,"fallbackKeys":["wow335:20647","wow335:2687","wow335:64380","wow335:20187","wow335:25781","wow335:20185","wow335:20186","wow335:5502","wow335:20184","wow335:53651","wow335:31898","wow335:31804","wow335:53733","wow335:53726","wow335:75","wow335:13481","wow335:781","wow335:34026","wow335:34754","wow335:47633","wow335:46584","wow335:26372","wow335:49278","wow335:49279","wow335:44450","wow335:34913","wow335:43043","wow335:43044","wow335:55342","wow335:29886","wow335:58889","wow335:5225","wow335:52610","ascension:705153","ascension:705154","ascension:560456","ascension:800193","ascension:561322","ascension:804138","ascension:805821","ascension:801767","ascension:707583","ascension:705160","ascension:705184","ascension:705185","ascension:804746","ascension:706346","ascension:706347","ascension:705238","ascension:92081","ascension:706354","ascension:92083","ascension:560906","ascension:707207","ascension:706326","ascension:706327","ascension:92082","ascension:705209","ascension:705210","ascension:705215","ascension:707721","ascension:705196","ascension:706804","ascension:706350","ascension:706351","ascension:705206","ascension:561314","ascension:705172","ascension:561321","ascension:705171","ascension:807861","ascension:800943","ascension:561394","ascension:705202","ascension:705241","ascension:705174","ascension:705170","ascension:705169","ascension:706353","ascension:560564","ascension:503408","ascension:503409","ascension:503410","ascension:503411","ascension:503412","ascension:503413","ascension:503414","ascension:705921","ascension:301297","ascension:572870","ascension:92085","ascension:705923","ascension:712396","ascension:802756","ascension:705906","ascension:705888","ascension:705852","ascension:681465","ascension:705889","ascension:705890","ascension:705873","ascension:806283","ascension:705849","ascension:705907","ascension:706368","ascension:705936","ascension:705937","ascension:705861","ascension:705909","ascension:503748","ascension:92084","ascension:705840","ascension:705841","ascension:92086","ascension:705911","ascension:705878","ascension:706538","ascension:300663","ascension:705903","ascension:705904","ascension:501220","ascension:501221","ascension:501222","ascension:501223","ascension:501224","ascension:501225","ascension:501226","ascension:501227","ascension:501228","ascension:705843","ascension:706482","ascension:501162","ascension:705848","ascension:705844","ascension:807296","ascension:707617","ascension:704500","ascension:560822","ascension:706266","ascension:707844","ascension:92089","ascension:681376","ascension:300465","ascension:92087","ascension:520251","ascension:705102","ascension:707842","ascension:520257","ascension:705108","ascension:804613","ascension:704607","ascension:705120","ascension:705151","ascension:92088","ascension:705105","ascension:560573","ascension:704360","ascension:301287","ascension:707513","ascension:704374","ascension:804609","ascension:300468","ascension:705128","ascension:805244","ascension:704613","ascension:524947","ascension:704612","ascension:807430","ascension:705113","ascension:705125","ascension:705133","ascension:705521","ascension:705522","ascension:705523","ascension:705498","ascension:707862","ascension:92091","ascension:680529","ascension:681484","ascension:806195","ascension:802011","ascension:705488","ascension:92094","ascension:705466","ascension:92093","ascension:705493","ascension:705454","ascension:789256","ascension:707230","ascension:706242","ascension:706243","ascension:705528","ascension:681092","ascension:804024","ascension:680253","ascension:705526","ascension:803638","ascension:705534","ascension:800165","ascension:520211","ascension:520212","ascension:520213","ascension:578297","ascension:574319","ascension:574320","ascension:807462","ascension:807887","ascension:705690","ascension:705691","ascension:92097","ascension:705675","ascension:707053","ascension:705720","ascension:705701","ascension:570722","ascension:92098","ascension:705647","ascension:300595","ascension:92096","ascension:705640","ascension:567542","ascension:705693","ascension:705641","ascension:705642","ascension:705656","ascension:705648","ascension:705649","ascension:707652","ascension:705651","ascension:705696","ascension:705697","ascension:705698","ascension:800699","ascension:807233","ascension:705659","ascension:705660","ascension:804040","ascension:705677","ascension:705689","ascension:567533","ascension:705Line truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"kind":"wow-m2-model","source":"wow335","sourceModel":"spells/cfx_mage_livingbomb_secondaryareaimpact.m2","sourceSha256":"4dfdd60cea023d79f123eb99ccf20c013133bbab3a3afb284d4d3fafd3b97c73","sourceSkinSha256":"26cfbb7a171348ba881cde500631f2ef3abc537a1b8ac2e4ffbac14e2a3a08af","animations":[{"index":0,"id":0,"variationIndex":0,"durationMs":667,"moveSpeed":0,"flags":32},{"index":1,"id":0,"variationIndex":1,"durationMs":667,"moveSpeed":0,"flags":32},{"index":2,"id":0,"variationIndex":2,"durationMs":666,"moveSpeed":0,"flags":32},{"index":3,"id":159,"variationIndex":0,"durationMs":1000,"moveSpeed":0,"flags":32}],"globalSequences":[],"bones":[{"parent":-1,"flags":8,"pivot":[0,0,0.5758171081542969],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":-1,"flags":512,"pivot":[0,0,0.29817116260528564],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":1,"globalSequence":-1,"sequences":[{"timestamps":[0],"values":[[0.707083346049379,0.707083346049379,0,0]]},{"timestamps":[0],"values":[[0.707083346049379,0.707083346049379,0,0]]},{"timestamps":[0],"values":[[0.707083346049379,0.707083346049379,0,0]]},{"timestamps":[0],"values":[[0.707083346049379,0.707083346049379,0,0]]}]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":-1,"flags":0,"pivot":[0,0,0.5555555820465088],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":-1,"flags":512,"pivot":[0,0,0.3558160662651062],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":1,"globalSequence":-1,"sequences":[{"timestamps":[0],"values":[[0.707083346049379,0,0,0.707083346049379]]},{"timestamps":[0],"values":[[0.707083346049379,0,0,0.707083346049379]]},{"timestamps":[],"values":[]},{"timestamps":[0],"values":[[0.707083346049379,0,0,0.707083346049379]]}]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":-1,"flags":512,"pivot":[0,0,0.3558160364627838],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":1,"globalSequence":-1,"sequences":[{"timestamps":[0],"values":[[0.707083346049379,0,0,0.707083346049379]]},{"timestamps":[0],"values":[[0.707083346049379,0,0,0.707083346049379]]},{"timestamps":[],"values":[]},{"timestamps":[0],"values":[[0.707083346049379,0,0,0.707083346049379]]}]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":-1,"flags":512,"pivot":[0,0,0.3558160364627838],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":1,"globalSequence":-1,"sequences":[{"timestamps":[0],"values":[[0.707083346049379,0,0,0.707083346049379]]},{"timestamps":[0],"values":[[0.707083346049379,0,0,0.707083346049379]]},{"timestamps":[],"values":[]},{"timestamps":[0],"values":[[0.707083346049379,0,0,0.707083346049379]]}]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":-1,"flags":512,"pivot":[0,0,0.3558160364627838],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":1,"globalSequence":-1,"sequences":[{"timestamps":[0],"values":[[0.707083346049379,0,0,0.707083346049379]]},{"timestamps":[0],"values":[[0.707083346049379,0,0,0.707083346049379]]},{"timestamps":[],"values":[]},{"timestamps":[0],"values":[[0.707083346049379,0,0,0.707083346049379]]}]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":-1,"flags":0,"pivot":[0,0,0],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}}],"textureTransforms":[{"translation":{"interpolation":1,"globalSequence":-1,"sequences":[{"timestamps":[0,667],"values":[[0,-0.949999988079071,0],[0,-0.5,0]]},{"timestamps":[0,667],"values":[[0.39377498626708984,-0.949999988079071,0],[0.39377498626708984,-0.5,0]]},{"timestamps":[0,666],"values":[[0.7875499725341797,-0.949999988079071,0],[0.7875499725341797,-0.5,0]]},{"timestamps":[0],"values":[[2.165762424468994,-0.5,0]]}]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":1,"globalSequence":-1,"sequences":[{"timestamps":[0],"values":[[1,1,0]]},{"timestamps":[0],"values":[[1,1,0]]},{"timestamps":[0],"values":[[1,1,0]]},{"timestamps":[0],"values":[[1,1,0]]}]}},{"translation":{"interpolation":1,"globalSequence":-1,"sequences":[{"timestamps":[0,133,267,400,667],"values":[[0,0.5,0],[0,0,0],[0,0,0],[0,-0.20000000298023224,0],[0,-0.19999998807907104,0]]},{"timestamps":[0,134,267,400,667],"values":[[0,0.5,0],[0,0,0],[0,0,0],[0,-0.20000000298023224,0],[0,-0.19999998807907104,0]]},{"timestaLine truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"kind":"wow-m2-model","source":"wow335","sourceModel":"spells/mage_meteor_state.m2","sourceSha256":"0ee1da7a0217d4d1afe40a277badd25dff671c5ef68c915a7520375dd363046c","sourceSkinSha256":"dfb2949f251e32605f62d175ae1bdbd072c11dd17c4cfd92d44bd6aad69373f3","animations":[{"index":0,"id":0,"variationIndex":0,"durationMs":1000,"moveSpeed":0,"flags":32},{"index":1,"id":158,"variationIndex":0,"durationMs":1000,"moveSpeed":0,"flags":32},{"index":2,"id":159,"variationIndex":0,"durationMs":1000,"moveSpeed":0,"flags":161},{"index":3,"id":157,"variationIndex":0,"durationMs":1000,"moveSpeed":0,"flags":225},{"index":4,"id":1,"variationIndex":0,"durationMs":1000,"moveSpeed":0,"flags":227}],"globalSequences":[8033,10000],"bones":[{"parent":-1,"flags":512,"pivot":[0,0,0.0636688768863678],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":1,"globalSequence":-1,"sequences":[{"timestamps":[0,33,100,167,233,1000],"values":[[1,1,1],[1.7415540218353271,1.741626501083374,1.7415902614593506],[2.407714605331421,2.4078147411346436,2.4077646732330322],[2.633957862854004,2.6340675354003906,2.6340126991271973],[2.6971755027770996,2.6972875595092773,2.6972315311431885],[2.6971755027770996,2.6972875595092773,2.6972315311431885]]},{"timestamps":[0],"values":[[2.6971755027770996,2.6972875595092773,2.6972315311431885]]},{"timestamps":[0],"values":[[2.6971755027770996,2.6972875595092773,2.6972315311431885]]},{"timestamps":[0],"values":[[2.6971755027770996,2.6972875595092773,2.6972315311431885]]},{"timestamps":[0],"values":[[2.6971755027770996,2.6972875595092773,2.6972315311431885]]}]}},{"parent":-1,"flags":512,"pivot":[0,0,0.0636688768863678],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":1,"globalSequence":-1,"sequences":[{"timestamps":[0,33,100,167,233,1000],"values":[[1,1,1],[1.7415541410446167,1.7416266202926636,1.7415903806686401],[2.407714605331421,2.4078147411346436,2.4077646732330322],[2.633957862854004,2.6340675354003906,2.6340126991271973],[2.6971755027770996,2.6972875595092773,2.6972315311431885],[2.6971755027770996,2.6972875595092773,2.6972315311431885]]},{"timestamps":[0],"values":[[2.6971755027770996,2.6972875595092773,2.6972315311431885]]},{"timestamps":[0],"values":[[2.6971755027770996,2.6972875595092773,2.6972315311431885]]},{"timestamps":[0],"values":[[2.6971755027770996,2.6972875595092773,2.6972315311431885]]},{"timestamps":[0],"values":[[2.6971755027770996,2.6972875595092773,2.6972315311431885]]}]}}],"textureTransforms":[{"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":1,"globalSequence":0,"sequences":[{"timestamps":[0,1900,3667,6067,8033],"values":[[0,0,0,1],[0,0,-0.6042442917823792,0.7967992424964905],[0,0,-0.984807014465332,0.1736523061990738],[0,0,-0.7071065902709961,-0.7071069478988647],[0,0,0,-1]]}]},"scale":{"interpolation":1,"globalSequence":-1,"sequences":[{"timestamps":[0],"values":[[0.5,0.5,0]]},{"timestamps":[0],"values":[[0.5,0.5,0]]},{"timestamps":[0],"values":[[0.5,0.5,0]]},{"timestamps":[0],"values":[[0.5,0.5,0]]},{"timestamps":[0],"values":[[0.5,0.5,0]]}]}}],"materials":[{"flags":21,"blendMode":4},{"flags":16,"blendMode":2}],"textures":[{"type":0,"flags":4,"filename":"spells\\6_0_fire_runes_01.blp","sourcePath":"spells/6_0_fire_runes_01.blp","sourceSha256":"6628f77252519872889e79fddf87c4a39401b66b59592c20f4c8bd9c9a2318d1","url":"/assets/game/combat-effects/original/wow335/all/textures/6628f77252519872889e.png"},{"type":0,"flags":3,"filename":"spells\\fire_sharp_mod2x_a.blp","sourcePath":"spells/fire_sharp_mod2x_a.blp","sourceSha256":"b9f38055a046d985e6c76dbbbbf30befa22d64c1ed995e721895e22e6abdae3a","url":"/assets/game/combat-effects/original/wow335/all/textures/b9f38055a046d985e6c7.png"},{"type":0,"flags":4,"filename":"spells\\flare05_dark.blp","sourcePath":"spells/flare05_dark.blp","sourceSha256":"6a2e7118b5d5bd60f1c38a5b9be4364546bce4f1fbe8369fc21b8f6772450573","url":"/assets/game/combat-effects/original/wow335/all/textures/6a2e7118b5d5bd60f1c3.png"}],"particleGraph":null,"ribbons":[],"children":{},"mesh":{"positions":[-1.3114432096481323,-1.3113887310028076,0.0636688768863678,1.3113887310028076,-1.3114432096481323,0.0636688768863678,-1.3113887310028076,1.3114432096481323,0.0636688768863678,1.3114432096481323,1.3113887310028076,0.0636688768863678,-4.6536760330200195,-4.653482913970947,0.0636688768863678,4.653482913970947,-4.6536760330200195,0.0636688768863678,-4.653482913970947,4.6536760330200195,0.0636688768863678,4.6536760330200195,4.653482913970947,0.0636688768863678],"normals":[0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1],"uvs":[0,0,0,1,1,0,1,1,0,0,0,1,1,0,1,1],"uvs2":[-0.9494181871414185,-0.9749999046325684,-0.9749999642372131,1.949418067932129,1.975000023841858,-0.949418306350708,1.9494184255599976,1.97499990463Line truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"kind":"wow-m2-model","source":"wow335","sourceModel":"spells/meteor_ball_missile.m2","sourceSha256":"c6eee5b2f3b985895073ebc2edfe06fc67aadf56eb4fe6e87f9041421dd8aba3","sourceSkinSha256":"8e8d7d96db24852f3376d2309e760c46332ab5ba8b733edc746ebab367254c6f","animations":[{"index":0,"id":0,"variationIndex":0,"durationMs":3333,"moveSpeed":0,"flags":32}],"globalSequences":[1167,200],"bones":[{"parent":-1,"flags":512,"pivot":[0,0,0],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":1,"globalSequence":0,"sequences":[{"timestamps":[0,146,292,438,583,729,875,1021,1167],"values":[[0,0,0,1],[0.22461622974333934,0.30915250099185154,0,0.924069948423719],[0.41514328440198983,0.5713980529190954,0,0.7078768272957549],[0.5426496169927061,0.7469100009155553,0,0.3841669972838527],[0.5877559739982299,0.8089846491897336,0,0.002197332682271798],[0.543656727805414,0.7482833338419752,0,-0.38013855403302105],[0.41700491348002566,0.5739310892056032,0,-0.7047639393292031],[0.2269966734824671,0.31244850001525926,0,-0.9223914304025391],[0.002563554795983764,0.0035401470992156743,0,-1]]}]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":-1,"flags":520,"pivot":[0,0,0],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":1,"globalSequence":1,"sequences":[{"timestamps":[0,67,133,200],"values":[[0.7874003052711487,0.7874020934104919,0.7874022722244263],[1,1,1],[0.884954571723938,0.8849560022354126,0.884956955909729],[0.7874003052711487,0.7874020934104919,0.7874022722244263]]}]}},{"parent":-1,"flags":520,"pivot":[0,0,0],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":1,"globalSequence":1,"sequences":[{"timestamps":[0,67,133,200],"values":[[0.7874017357826233,0.7874016165733337,0.7874017357826233],[1,1,1],[0.8849561214447021,0.8849557042121887,0.8849557638168335],[0.7874017357826233,0.7874016165733337,0.7874017357826233]]}]}},{"parent":-1,"flags":0,"pivot":[0.03611205518245697,-0.0274946428835392,-0.1563093513250351],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":-1,"flags":0,"pivot":[0.0361119881272316,-0.0274946428835392,-0.15630900859832764],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}}],"textureTransforms":[],"materials":[{"flags":1,"blendMode":0},{"flags":17,"blendMode":4},{"flags":17,"blendMode":4}],"textures":[{"type":0,"flags":0,"filename":"SPELLS\\INFERNALSKIN_RED.BLP","sourcePath":"spells/infernalskin_red.blp","sourceSha256":"89c9e63fcd7c5fe44e192c47cc949d9a9536a4fab7a0e67477cc5d032c24d889","url":"/assets/game/combat-effects/original/wow335/all/textures/89c9e63fcd7c5fe44e19.png"},{"type":0,"flags":0,"filename":"SPELLS\\DUST6COLORRED.BLP","sourcePath":"spells/dust6colorred.blp","sourceSha256":"b3b2cd43132ff22c26b9bc5be5fa18eca00cdd02fc7fc8d9330bc05219f3c543","url":"/assets/game/combat-effects/original/wow335/all/textures/b3b2cd43132ff22c26b9.png"},{"type":0,"flags":0,"filename":"WORLD\\GENERIC\\ACTIVEDOODADS\\SPELLPORTALS\\FLARE.BLP","sourcePath":"world/generic/activedoodads/spellportals/flare.blp","sourceSha256":"20ef5e726bf050a7fa082377d5ce72f09b7fc8532b256b8b8e31731f5c5b6c16","url":"/assets/game/combat-effects/original/wow335/all/textures/20ef5e726bf050a7fa08.png"},{"type":0,"flags":0,"filename":"WORLD\\EXPANSION01\\DOODADS\\HELLFIREPENINSULA\\DARKPORTAL\\SMOKEPUFF.BLP","sourcePath":"world/expansion01/doodads/hellfirepeninsula/darkportal/smokepuff.blp","sourceSha256":"20b5e1b9efe7dbb931ea53d44f734a60cdba0e74abadafb6d7f3cf67be39cfb9","url":"/assets/game/combat-effects/original/wow335/all/textures/20b5e1b9efe7dbb931ea.png"}],"particleGraph":{"schemaVersion":1,"kind":"wow-m2-particle-effect","source":"wow335","sourceModel":"spells/meteor_ball_missile.m2","normalizedSourceModel":"spells/meteor_ball_missile.m2","sourceSha256":"c6eee5b2f3b985895073ebc2edfe06fc67aadf56eb4fe6e87f9041421dd8aba3","durationMs":3333,"animations":[{"index":0,"id":0,"variationIndex":0,"durationMs":3333,"moveSpeed":0,"flags":32}],"textures":[{"type":0,"flags":0,"filename":"SPELLS\\INFERNALSKIN_RED.BLP","sourcePath":"spells/infernalskin_red.blp","sourceSha256":"89c9e63fcd7c5fe44e192c47cc949d9a9536a4fab7a0e67477cc5d032c24d889","url":"/assets/game/combat-effects/original/wow335/all/textures/89c9e63fcd7c5fe44e19.png"},{"type":0,"flags":0,"filename":"SPELLS\\DUST6COLORRED.BLP","sourcePath":"spells/dust6colorred.blp","sourceSha256":"b3b2cd43132ff22c26b9bc5be5fa18eca00cdd02fc7fc8d9330bc05219f3c543","url":"/assets/game/combat-effects/original/wow335/all/texLine truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"kind":"wow-m2-model","source":"wow335","sourceModel":"spells/xplosion_fire_impact.m2","sourceSha256":"efda9f81ea2ffdc61aecb342f4f1b55f401c5c1b4088ddde841ef71708de7dbf","sourceSkinSha256":null,"animations":[{"index":0,"id":0,"variationIndex":0,"durationMs":600,"moveSpeed":0,"flags":33}],"globalSequences":[],"bones":[{"parent":-1,"flags":0,"pivot":[0,0,0.7859585285186768],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":-1,"flags":0,"pivot":[0,0,0.7697693705558777],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":-1,"flags":0,"pivot":[0,0,0.770228385925293],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":-1,"flags":0,"pivot":[0.34697383642196655,0.1852230578660965,0.7697693705558777],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}},{"parent":-1,"flags":0,"pivot":[0.34697383642196655,0.1852230578660965,0.7697692513465881],"translation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"rotation":{"interpolation":0,"globalSequence":-1,"sequences":[]},"scale":{"interpolation":0,"globalSequence":-1,"sequences":[]}}],"textureTransforms":[],"materials":[],"textures":[{"type":0,"flags":0,"filename":"SPELLS\\T_VFX_FLARE05_A.BLP","sourcePath":"spells/t_vfx_flare05_a.blp","sourceSha256":"572699fe1566b04a0708dec45d433fff8d04a6b52a9081d8b712565590bf8e7b","url":"/assets/game/combat-effects/original/wow335/all/textures/572699fe1566b04a0708.png"},{"type":0,"flags":0,"filename":"CREATURE\\SNOWFLAKECREATURE\\T_VFX_FREEZE_C.BLP","sourcePath":"creature/snowflakecreature/t_vfx_freeze_c.blp","sourceSha256":"5fa35a6af3ed75243efa0c8de8d3188e181408354808af15a4a808cd15ffdce5","url":"/assets/game/combat-effects/original/wow335/all/textures/5fa35a6af3ed75243efa.png"},{"type":0,"flags":0,"filename":"SPELLS\\T_VFX_FLAR_BLUR_64.BLP","sourcePath":"spells/t_vfx_flar_blur_64.blp","sourceSha256":"ce09ebf4b23d22a7b53db389526e352020820c2da4352a51b79ccee87966e7f8","url":"/assets/game/combat-effects/original/wow335/all/textures/ce09ebf4b23d22a7b53d.png"},{"type":0,"flags":0,"filename":"SPELLS\\T_VFX_FIRE01_A64_BLANK4.BLP","sourcePath":"spells/t_vfx_fire01_a64_blank4.blp","sourceSha256":"be9c1183bb856f54b9cdf9af4c0798dd9082f6b781bd688526220bd67497bfc8","url":"/assets/game/combat-effects/original/wow335/all/textures/be9c1183bb856f54b9cd.png"},{"type":0,"flags":0,"filename":"SPELLS\\T_VFX_SMOKE_B64.BLP","sourcePath":"spells/t_vfx_smoke_b64.blp","sourceSha256":"fd644be0445176159833602c92a0f53c4aafc12a8302cfd30d262e34881f7b17","url":"/assets/game/combat-effects/original/wow335/all/textures/fd644be0445176159833.png"}],"particleGraph":{"schemaVersion":1,"kind":"wow-m2-particle-effect","source":"wow335","sourceModel":"spells/xplosion_fire_impact.m2","normalizedSourceModel":"spells/xplosion_fire_impact.m2","sourceSha256":"efda9f81ea2ffdc61aecb342f4f1b55f401c5c1b4088ddde841ef71708de7dbf","durationMs":600,"animations":[{"index":0,"id":0,"variationIndex":0,"durationMs":600,"moveSpeed":0,"flags":33}],"textures":[{"type":0,"flags":0,"filename":"SPELLS\\T_VFX_FLARE05_A.BLP","sourcePath":"spells/t_vfx_flare05_a.blp","sourceSha256":"572699fe1566b04a0708dec45d433fff8d04a6b52a9081d8b712565590bf8e7b","url":"/assets/game/combat-effects/original/wow335/all/textures/572699fe1566b04a0708.png"},{"type":0,"flags":0,"filename":"CREATURE\\SNOWFLAKECREATURE\\T_VFX_FREEZE_C.BLP","sourcePath":"creature/snowflakecreature/t_vfx_freeze_c.blp","sourceSha256":"5fa35a6af3ed75243efa0c8de8d3188e181408354808af15a4a808cd15ffdce5","url":"/assets/game/combat-effects/original/wow335/all/textures/5fa35a6af3ed75243efa.png"},{"type":0,"flags":0,"filename":"SPELLS\\T_VFX_FLAR_BLUR_64.BLP","sourcePath":"spells/t_vfx_flar_blur_64.blp","sourceSha256":"ce09ebf4b23d22a7b53db389526e352020820c2da4352a51b79ccee87966e7f8","url":"/assets/game/combat-effects/original/wow335/all/textures/ce09ebf4b23d22a7b53d.png"},{"type":0,"flags":0,"filename":"SPELLS\\T_VFX_FIRE01_A64_BLANK4.BLP","sourcePath":"spells/t_vfx_fire01_a64_blank4.blp","sourceSha256":"be9c1183bb856f54b9cdf9af4c0798dd9082f6b781bd688526220bd67497bfc8","url":"/assets/game/combat-effects/original/wow335/all/textures/be9c1183bb856f54b9cd.png"},{"type":0,"flags":0,"filename":"SPELLS\\T_VFX_SMOKE_B64.BLP","sourcePath":"spells/t_vfx_smoke_b64.blp","sourceSha256":"fd644be0445176159833602c92a0f53c4aafc12a8302cfd30d262e34881f7b17","url":"/assets/game/combat-effects/original/wow335/all/textures/fd644be044517615Line truncated
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

+6
View File
@@ -21,6 +21,12 @@ npm run effects:audit
npm run effects:test npm run effects:test
``` ```
`npm run effects:meteor` packages the Ascension Mage Meteor release and impact
models used by Falling Stars. It reads the additional Ascension models from the
extracted `patch-N` directory and attaches browser-ready graphs to spell
`ascension:500135`. The full `effects:generate` command runs this step after
the WoW package step so a regenerated manifest keeps the affix visuals.
`effects:generate` rebuilds the DBC/audio mappings before packaging all WoW `effects:generate` rebuilds the DBC/audio mappings before packaging all WoW
visuals. `effects:priest` remains a compatibility alias for `effects:wow` so it visuals. `effects:priest` remains a compatibility alias for `effects:wow` so it
cannot accidentally revert the main manifest to Priest-only coverage. The old cannot accidentally revert the main manifest to Priest-only coverage. The old
@@ -0,0 +1,36 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { packageWowOriginalEffects } from "./package-wow-original.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const manifestPath = path.join(projectRoot, "public/assets/game/combat-effects/manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const entryIndex = manifest.entries.findIndex((entry) => entry.key === "ascension:500135");
if (entryIndex < 0) throw new Error("Mage Meteor is absent from the combat effect manifest.");
const entry = manifest.entries[entryIndex];
const phases = {
release: entry.phases.release,
impact: entry.phases.impact,
};
const roots = ["patch-N", "common-2.mpq", "patch-3", "patch-2", "patch", "expansion", "lichking", "common"]
.map((name) => path.resolve(projectRoot, "../LadiksMPQEditor", name));
const packaged = packageWowOriginalEffects({ entries: [{ ...entry, source: "wow335", phases }], coverage: {} }, {
roots,
catalog: { classes: {} },
});
if (packaged.report.failures.length) {
throw new Error(`Meteor packaging failed: ${JSON.stringify(packaged.report.failures)}`);
}
const packagedPhases = packaged.manifest.entries[0].phases;
for (const phase of Object.keys(phases)) {
if (packagedPhases[phase].some((asset) => !asset.effectGraphUrl)) {
throw new Error(`Meteor ${phase} has an unrenderable model.`);
}
}
manifest.entries[entryIndex] = { ...entry, phases: { ...entry.phases, ...packagedPhases } };
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`);
console.log(JSON.stringify({ key: entry.key, phases: packagedPhases, report: packaged.report }, null, 2));
+1 -1
View File
@@ -55,7 +55,7 @@ function RuntimeCatalogGate({ children, preloadGameplay }: { readonly children:
void Promise.all([ void Promise.all([
preloadAbilityCatalogsForClasses(classIds), preloadAbilityCatalogsForClasses(classIds),
preloadGameplay && talentClassId ? preloadTalentGameplayResources(talentClassId) : Promise.resolve(), preloadGameplay && talentClassId ? preloadTalentGameplayResources(talentClassId) : Promise.resolve(),
preloadGameplay ? preloadSharedGameplayResources(originalMediaEnabled) : Promise.resolve(), preloadGameplay ? preloadSharedGameplayResources(originalMediaEnabled, character?.classId === "priest") : Promise.resolve(),
]).then(() => { ]).then(() => {
if (active) setLoadedKey(requestKey); if (active) setLoadedKey(requestKey);
}).catch(() => { }).catch(() => {
+6 -2
View File
@@ -7,12 +7,16 @@ import { preloadAbilityCatalogsForClasses } from "../game/abilityCatalog";
import { useCombatStore } from "../game/combatStore"; import { useCombatStore } from "../game/combatStore";
import { setMobPosition } from "../game/mobRuntimeRegistry"; import { setMobPosition } from "../game/mobRuntimeRegistry";
import { CombatSummonPopulation } from "../scene/CombatSummonPopulation"; import { CombatSummonPopulation } from "../scene/CombatSummonPopulation";
import { preloadShadowfiendMedia } from "../scene/shadowfiendMedia";
function Preview() { function Preview() {
const [status, setStatus] = useState("Casting Shadowfiend…"); const [status, setStatus] = useState("Preparing Shadowfiend media…");
const canvas = useRef<HTMLCanvasElement | null>(null); const canvas = useRef<HTMLCanvasElement | null>(null);
useEffect(() => { useEffect(() => {
void preloadAbilityCatalogsForClasses(["priest"]).then(() => { void Promise.all([
preloadAbilityCatalogsForClasses(["priest"]),
preloadShadowfiendMedia().catch(() => null),
]).then(() => {
const combat = useCombatStore.getState(); const combat = useCombatStore.getState();
combat.initializeCharacter({ classId: "priest", level: 80 }); combat.initializeCharacter({ classId: "priest", level: 80 });
combat.registerMob("shadowfiend-preview-target", { combat.registerMob("shadowfiend-preview-target", {
+4
View File
@@ -139,6 +139,10 @@ export interface CombatPresentationEvent {
readonly targetPosition?: CombatPresentationPosition; readonly targetPosition?: CombatPresentationPosition;
readonly radius?: number; readonly radius?: number;
readonly durationMs?: number; readonly durationMs?: number;
/** Scripted visual-only projectile travel; ordinary spell travel keeps its distance-based timing. */
readonly visualTravelDurationMs?: number;
/** Scripted visual-only source scale; ordinary spells keep the packaged scale. */
readonly visualScale?: number;
/** The cast resolved immediately, so its source cast visual may overlap release. */ /** The cast resolved immediately, so its source cast visual may overlap release. */
readonly instantCast?: boolean; readonly instantCast?: boolean;
} }
+15
View File
@@ -186,6 +186,21 @@ describe("Bite-Sized race mechanics", () => {
expect(kind === "ring" ? distance < 4 || distance > 7 : distance > effect.radius).toBe(true); expect(kind === "ring" ? distance < 4 || distance > 7 : distance > effect.radius).toBe(true);
}); });
it.each(["falling-stars", "stormcall"])("retains the %s strike visual without repeating damage", (movementId) => {
start("last-stand", movementId);
advanceDungeonRaceAffixes(20_000, []);
advanceDungeonRaceAffixes(22_000, []);
const healthAfterStrike = useCombatStore.getState().health;
const effect = useDungeonRaceAffixStore.getState().effects[0];
expect(effect.tickAt).toBe(22_000);
expect(effect.expiresAt).toBe(24_800);
advanceDungeonRaceAffixes(23_000, []);
expect(useCombatStore.getState().health).toBe(healthAfterStrike);
expect(useDungeonRaceAffixStore.getState().effects).toHaveLength(1);
advanceDungeonRaceAffixes(24_800, []);
expect(useDungeonRaceAffixStore.getState().effects).toHaveLength(0);
});
it("freezes a warning and the next hazard across an offline pause", () => { it("freezes a warning and the next hazard across an offline pause", () => {
start("volatile-remains"); start("volatile-remains");
deadTrash(trashIds[0]); deadTrash(trashIds[0]);
+5 -2
View File
@@ -137,8 +137,11 @@ function createMovementEffect(id: string, kind: string, now: number, targetRoll:
const target = actors[targetRoll % actors.length]; const target = actors[targetRoll % actors.length];
const base = { id, position: target.position, createdAt: now, resolvesAt: now + 2_000, const base = { id, position: target.position, createdAt: now, resolvesAt: now + 2_000,
expiresAt: now + 2_500, radius: 3 }; expiresAt: now + 2_500, radius: 3 };
if (kind === "falling-stars") return { ...base, kind: "star" }; // Retain resolved strikes long enough for their source impact animation to finish.
if (kind === "stormcall") return { ...base, kind: "storm", targetId: target.id }; // tickAt still makes damage a one-time event.
if (kind === "falling-stars") return { ...base, kind: "star", expiresAt: now + 4_800 };
if (kind === "stormcall") return { ...base, kind: "storm", targetId: target.id,
expiresAt: now + 4_800 };
if (kind === "arcane-pulse") return { ...base, kind: "ring", radius: 7 }; if (kind === "arcane-pulse") return { ...base, kind: "ring", radius: 7 };
return { ...base, kind: "predator", targetId: target.id, radius: 2, return { ...base, kind: "predator", targetId: target.id, radius: 2,
expiresAt: now + 8_000, tickAt: now + 3_000, expiresAt: now + 8_000, tickAt: now + 3_000,
+21 -9
View File
@@ -49,7 +49,8 @@ describe("RoM bite-sized finales", () => {
expect(dungeonContentPackId(id)).toBe(`dungeon:${source.id}`); expect(dungeonContentPackId(id)).toBe(`dungeon:${source.id}`);
expect(slice.assets).toBe(source.assets); expect(slice.assets).toBe(source.assets);
expect(slice.staticSpawns).toHaveLength(id === "bite-sized-barren-caves-anglo" ? 4 expect(slice.staticSpawns).toHaveLength(id === "bite-sized-barren-caves-anglo" ? 4
: id === "bite-sized-kawaks-tomb-linobia" || id === "bite-sized-hall-of-survivors-pursuer" ? 5 : id === "bite-sized-kawaks-tomb-linobia" ? 5
: id === "bite-sized-hall-of-survivors-pursuer" ? 7
: id === "bite-sized-hoto-teeth" ? 10 : 6); : id === "bite-sized-hoto-teeth" ? 10 : 6);
expect(slice.staticSpawns.every((spawn) => source.staticSpawns.some((candidate) => expect(slice.staticSpawns.every((spawn) => source.staticSpawns.some((candidate) =>
candidate.id === spawn.id && candidate.entityId === spawn.entityId))).toBe(true); candidate.id === spawn.id && candidate.entityId === spawn.entityId))).toBe(true);
@@ -120,17 +121,28 @@ describe("RoM bite-sized finales", () => {
} }
}, 30_000); }, 30_000);
it("stages both Hall packs and the finale in the twin-statue chamber", () => { it("stages three Hall packs from the balcony to the twin-statue finale", () => {
const slice = DUNGEON_REGISTRY["bite-sized-hall-of-survivors-pursuer"]; const slice = DUNGEON_REGISTRY["bite-sized-hall-of-survivors-pursuer"];
const balcony = slice.staticSpawns.filter((spawn) => spawn.id.includes("119628"));
const landing = slice.staticSpawns.filter((spawn) => spawn.id.includes("599423"));
const guardians = slice.staticSpawns.filter((spawn) => spawn.id.includes("59919")); const guardians = slice.staticSpawns.filter((spawn) => spawn.id.includes("59919"));
const hunters = slice.staticSpawns.filter((spawn) => spawn.id.includes("119628")); expect(balcony).toHaveLength(2);
expect(landing).toHaveLength(2);
expect(guardians).toHaveLength(3); expect(guardians).toHaveLength(3);
expect(hunters).toHaveLength(2); expect(slice.entrance.footPosition[1]).toBeGreaterThan(0);
// The western side room lies beyond x=-185; the statue chamber is to its east. expect(balcony.every((spawn) => spawn.position[1] > 0)).toBe(true);
expect([...slice.staticSpawns, slice.encounterSpawnGroups![0].spawns[0]].every( expect(landing.every((spawn) => spawn.position[1] > -7 && spawn.position[1] < -3)).toBe(true);
(spawn) => spawn.position[0] > -185 && spawn.position[0] < -158)).toBe(true); expect(guardians.every((spawn) => spawn.position[1] < -17)).toBe(true);
expect(Math.min(...hunters.map((spawn) => spawn.position[2])) expect(Math.max(...balcony.map((spawn) => spawn.position[0])))
- Math.max(...guardians.map((spawn) => spawn.position[2]))).toBeGreaterThan(10); .toBeLessThan(Math.min(...landing.map((spawn) => spawn.position[0])));
expect(Math.max(...landing.map((spawn) => spawn.position[0])))
.toBeLessThan(Math.min(...guardians.map((spawn) => spawn.position[0])));
const boss = slice.encounterSpawnGroups![0].spawns[0];
expect(boss.position[0]).toBeGreaterThan(-174);
expect(boss.position[0]).toBeLessThan(-166);
expect(boss.position[2]).toBeGreaterThan(15);
expect(boss.position[2]).toBeLessThan(25);
expect(boss.position[1]).toBeLessThan(-17);
}); });
it("fills Hoto with three readable source Ayam clusters and a cooler cavern palette", () => { it("fills Hoto with three readable source Ayam clusters and a cooler cavern palette", () => {
+18 -10
View File
@@ -190,23 +190,31 @@ const ROM_SLICES: readonly RoMSlice[] = [
title: "Hall of Survivors: Pursuer Ward", title: "Hall of Survivors: Pursuer Ward",
location: "Hall of Survivors · Pursuer Ward", location: "Hall of Survivors · Pursuer Ward",
entranceName: "Pursuer Ward", entranceName: "Pursuer Ward",
start: [-155, -17.55, 35], // Begin on the upper overlook before descending the connected east stair.
forward: [-1, 0, 0], start: [-220, 0.554, 40],
forward: [1, 0, 0],
finaleBossName: "Divine Punishment Pursuer", finaleBossName: "Divine Punishment Pursuer",
finalePosition: [-171, -17.55, 31], // Open floor on the axis between the two giant statues, facing the ornate doorway.
finalePosition: [-170, -17.55, 20],
mobSpawnIds: [ mobSpawnIds: [
"rw-hall-of-survivors-spawn-11962803", "rw-hall-of-survivors-spawn-11962817",
"rw-hall-of-survivors-spawn-5994236", "rw-hall-of-survivors-spawn-5994237",
"rw-hall-of-survivors-spawn-5991940", "rw-hall-of-survivors-spawn-5991941", "rw-hall-of-survivors-spawn-5991940", "rw-hall-of-survivors-spawn-5991941",
"rw-hall-of-survivors-spawn-5991939", "rw-hall-of-survivors-spawn-5991939",
"rw-hall-of-survivors-spawn-11962803", "rw-hall-of-survivors-spawn-11962817",
], ],
// The two Hunters originally stood in the western side room. Reposition them // Two Hunters guard the balcony, two Rune Swordsmen hold the stair landing,
// as a distinct pack at the far end of the open chamber with the statues. // and three Rune Guardians guard the lower room before the statues.
mobSpawnPositions: { mobSpawnPositions: {
"rw-hall-of-survivors-spawn-11962803": [-165, -17.55, 38], "rw-hall-of-survivors-spawn-11962803": [-218, 0.554, 52],
"rw-hall-of-survivors-spawn-11962817": [-175, -17.55, 38], "rw-hall-of-survivors-spawn-11962817": [-222, 0.554, 55],
"rw-hall-of-survivors-spawn-5994236": [-197, -4.2, 52],
"rw-hall-of-survivors-spawn-5994237": [-193, -4.2, 55],
"rw-hall-of-survivors-spawn-5991940": [-158, -17.55, 45],
"rw-hall-of-survivors-spawn-5991941": [-154, -17.55, 44],
"rw-hall-of-survivors-spawn-5991939": [-156, -17.55, 40],
}, },
summary: "Clear two packs around the ward's twin statues, then defeat one surprise Hall boss in the chamber.", summary: "Clear the overlook, stair landing, and lower ward packs, then defeat one surprise Hall boss between the twin statues.",
adaptation: "The Hunters and selected boss are moved out of the western side room into the statue chamber. Other Pursuer and Seeker bosses, earlier rooms, and combined source scripts are outside this short run.", adaptation: "The Hunters, Rune Swordsmen, Rune Guardians, and selected boss stage a descending three-pack route from the balcony into the twin-statue chamber. Other Pursuer and Seeker bosses, earlier rooms, and combined source scripts are outside this short run.",
}, },
]; ];
+7 -38
View File
@@ -1,21 +1,15 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useFrame } from "@react-three/fiber"; import { useFrame } from "@react-three/fiber";
import { DoubleSide, Group, SRGBColorSpace, Texture, TextureLoader } from "three"; import { DoubleSide, Group } from "three";
import { subscribeContentManager } from "../content/contentManager"; import { subscribeContentManager } from "../content/contentManager";
import { fetchCombatMediaAsset, combatMediaPackReady } from "../game/combatMediaPack"; import { combatMediaPackReady } from "../game/combatMediaPack";
import { preloadCombatEffectManifest, shadowfiendOriginalModelAsset } from "../game/combatPresentation";
import { type CombatSummon, useCombatStore } from "../game/combatStore"; import { type CombatSummon, useCombatStore } from "../game/combatStore";
import { getMobPosition } from "../game/mobRuntimeRegistry"; import { getMobPosition } from "../game/mobRuntimeRegistry";
import { OriginalWowModel } from "./OriginalWowModel"; import { OriginalWowModel } from "./OriginalWowModel";
import { parseOriginalWowModelGraph, type OriginalWowModelGraph } from "./originalWowModelRuntime"; import { getPreparedShadowfiendMedia, preloadShadowfiendMedia } from "./shadowfiendMedia";
const SHADOWFIEND_CREATURE_ID = 19668; const SHADOWFIEND_CREATURE_ID = 19668;
interface ShadowfiendMedia {
readonly graph: OriginalWowModelGraph;
readonly textures: ReadonlyMap<number, Texture>;
}
function ShadowfiendFallback() { function ShadowfiendFallback() {
return <group name="shadowfiend-fallback"> return <group name="shadowfiend-fallback">
<mesh position={[0, 0.66, 0]} scale={[0.56, 0.77, 0.46]}> <mesh position={[0, 0.66, 0]} scale={[0.56, 0.77, 0.46]}>
@@ -39,7 +33,7 @@ function ShadowfiendFallback() {
} }
function ShadowfiendMember({ summon, active }: { readonly summon: CombatSummon; readonly active: boolean }) { function ShadowfiendMember({ summon, active }: { readonly summon: CombatSummon; readonly active: boolean }) {
const [media, setMedia] = useState<ShadowfiendMedia | null>(null); const [media, setMedia] = useState(getPreparedShadowfiendMedia);
const mediaReady = useSyncExternalStore(subscribeContentManager, combatMediaPackReady, () => false); const mediaReady = useSyncExternalStore(subscribeContentManager, combatMediaPackReady, () => false);
const group = useRef<Group>(null); const group = useRef<Group>(null);
const playback = useRef({ elapsedMs: 0, decaying: false }); const playback = useRef({ elapsedMs: 0, decaying: false });
@@ -50,36 +44,11 @@ function ShadowfiendMember({ summon, active }: { readonly summon: CombatSummon;
return; return;
} }
let cancelled = false; let cancelled = false;
const ownedTextures: Texture[] = []; void preloadShadowfiendMedia().then((ready) => {
void (async () => { if (!cancelled) setMedia(ready);
await preloadCombatEffectManifest(); }).catch(() => { if (!cancelled) setMedia(null); });
const url = shadowfiendOriginalModelAsset()?.effectGraphUrl;
if (!url) return;
const response = await fetchCombatMediaAsset(url);
if (!response.ok) throw new Error(`Shadowfiend model request failed: ${response.status}`);
const graph = parseOriginalWowModelGraph(await response.json());
const textures = new Map<number, Texture>();
const loader = new TextureLoader();
for (const [index, source] of graph.textures.entries()) {
if (!source.url) continue;
const image = await fetchCombatMediaAsset(source.url);
if (!image.ok) throw new Error(`Shadowfiend texture request failed: ${image.status}`);
const objectUrl = URL.createObjectURL(await image.blob());
try {
const texture = await loader.loadAsync(objectUrl);
texture.colorSpace = SRGBColorSpace;
ownedTextures.push(texture);
textures.set(index, texture);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
if (cancelled) ownedTextures.forEach((texture) => texture.dispose());
else setMedia({ graph, textures });
})().catch(() => { if (cancelled) ownedTextures.forEach((texture) => texture.dispose()); });
return () => { return () => {
cancelled = true; cancelled = true;
ownedTextures.forEach((texture) => texture.dispose());
}; };
}, [mediaReady]); }, [mediaReady]);
+59 -11
View File
@@ -1,10 +1,15 @@
import { Html } from "@react-three/drei"; import { useEffect } from "react";
import { Billboard, Html } from "@react-three/drei";
import { DoubleSide } from "three"; import { DoubleSide } from "three";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
import { useCombatStore } from "../game/combatStore"; import { useCombatStore } from "../game/combatStore";
import { activeLastStand } from "../game/dungeonRaceAffixBuffs"; import { activeLastStand } from "../game/dungeonRaceAffixBuffs";
import { useDungeonRaceAffixStore, type RaceAffixEffect } from "../game/dungeonRaceAffixRuntime"; import { useDungeonRaceAffixStore, type RaceAffixEffect } from "../game/dungeonRaceAffixRuntime";
import { getMobPosition } from "../game/mobRuntimeRegistry"; import { getMobPosition } from "../game/mobRuntimeRegistry";
import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry"; import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry";
import { useGameStore } from "../game/store";
import { preloadRaceAffixImpactVisuals, RaceAffixLightningEffect, RaceAffixMeteorDescent,
RaceAffixSpellImpact } from "./RaceAffixLightningEffect";
const COLORS: Record<RaceAffixEffect["kind"], string> = { const COLORS: Record<RaceAffixEffect["kind"], string> = {
volatile: "#ff7b36", ash: "#e65327", orb: "#d073ff", boon: "#f5cc56", volatile: "#ff7b36", ash: "#e65327", orb: "#d073ff", boon: "#f5cc56",
@@ -24,19 +29,23 @@ function GroundMarker({ effect, now }: { readonly effect: RaceAffixEffect; reado
const position = visualPosition(effect, now); const position = visualPosition(effect, now);
const warning = now < effect.resolvesAt; const warning = now < effect.resolvesAt;
const isRing = effect.kind === "ring"; const isRing = effect.kind === "ring";
const isPredator = effect.kind === "predator";
const color = COLORS[effect.kind]; const color = COLORS[effect.kind];
const strikeResolved = (effect.kind === "star" || effect.kind === "storm")
&& effect.tickAt !== undefined && now >= effect.resolvesAt + 350;
return <group position={[position[0], position[1] + 0.09, position[2]]}> return <group position={[position[0], position[1] + 0.09, position[2]]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}> {!strikeResolved && <mesh rotation={[-Math.PI / 2, 0, 0]}>
{isRing ? <ringGeometry args={[4, 7, 64]} /> : <circleGeometry args={[effect.radius, 48]} />} {isRing ? <ringGeometry args={[4, 7, 64]} />
<meshBasicMaterial color={color} transparent opacity={warning ? 0.3 : 0.48} : isPredator ? <ringGeometry args={[effect.radius - 0.09, effect.radius, 48]} />
: <circleGeometry args={[effect.radius, 48]} />}
<meshBasicMaterial color={color} transparent opacity={isPredator ? 0.9 : warning ? 0.3 : 0.48}
side={DoubleSide} depthWrite={false} /> side={DoubleSide} depthWrite={false} />
</mesh>
{effect.kind === "star" && <mesh position={[0, warning
? 2.4 - Math.min(1, (now - effect.createdAt) / 2_000) * 1.9 : 0.45, 0]}>
<octahedronGeometry args={[warning ? 0.32 : 0.55, 0]} />
<meshBasicMaterial color="#fff2b0" transparent opacity={warning ? 0.95 : 0.65}
depthTest={false} />
</mesh>} </mesh>}
{isPredator && warning && <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.01, 0]}>
<circleGeometry args={[0.3, 24]} />
<meshBasicMaterial color={color} transparent opacity={0.8} depthWrite={false} />
</mesh>}
{isPredator && !warning && <PredatorEye now={now} />}
{warning && <Html center position={[0, 0.7, 0]} style={{ pointerEvents: "none" }}> {warning && <Html center position={[0, 0.7, 0]} style={{ pointerEvents: "none" }}>
<span style={{ color: "white", background: "#101015dc", border: `1px solid ${color}`, <span style={{ color: "white", background: "#101015dc", border: `1px solid ${color}`,
borderRadius: 6, padding: "2px 6px", fontWeight: 700, whiteSpace: "nowrap" }}> borderRadius: 6, padding: "2px 6px", fontWeight: 700, whiteSpace: "nowrap" }}>
@@ -46,10 +55,40 @@ function GroundMarker({ effect, now }: { readonly effect: RaceAffixEffect; reado
</group>; </group>;
} }
function PredatorEye({ now }: { readonly now: number }) {
const bob = Math.sin(now / 230) * 0.12;
const tilt = Math.sin(now / 510) * 0.08;
return <Billboard follow position={[0, 1.35 + bob, 0]} rotation={[0, 0, tilt]} name="predator-eye">
<mesh scale={[0.73, 0.52, 0.28]}>
<sphereGeometry args={[1, 20, 12]} />
<meshStandardMaterial color="#f2ddfa" emissive="#843ba0" emissiveIntensity={0.35} />
</mesh>
<mesh position={[0, 0, 0.27]} scale={[0.29, 0.29, 0.1]}>
<sphereGeometry args={[1, 20, 12]} />
<meshBasicMaterial color="#b050e4" />
</mesh>
<mesh position={[0, 0, 0.36]} scale={[0.14, 0.19, 0.05]}>
<sphereGeometry args={[1, 16, 12]} />
<meshBasicMaterial color="#26102f" />
</mesh>
<mesh position={[-0.085, 0.11, 0.4]} scale={[0.055, 0.07, 0.025]}>
<sphereGeometry args={[1, 12, 8]} />
<meshBasicMaterial color="#ffffff" />
</mesh>
</Billboard>;
}
export function DungeonRaceAffixEffects() { export function DungeonRaceAffixEffects() {
const effects = useDungeonRaceAffixStore((state) => state.effects); const effects = useDungeonRaceAffixStore((state) => state.effects);
const movementId = useDungeonRaceStore((state) => state.affixIds?.[1] ?? null);
const visualActive = useGameStore((state) => !state.paused && !state.mapOpen);
const now = Date.now(); const now = Date.now();
const buffs = activeLastStand(now); const buffs = activeLastStand(now);
useEffect(() => {
if (movementId === "falling-stars" || movementId === "stormcall") {
void preloadRaceAffixImpactVisuals();
}
}, [movementId]);
return <> return <>
{effects.map((effect) => effect.kind === "orb" ? ( {effects.map((effect) => effect.kind === "orb" ? (
<group key={effect.id} position={[effect.position[0], effect.position[1] + 1.2, effect.position[2]]}> <group key={effect.id} position={[effect.position[0], effect.position[1] + 1.2, effect.position[2]]}>
@@ -70,7 +109,16 @@ export function DungeonRaceAffixEffects() {
<span className="race-affix-world-countdown">Boon · {Math.max(1, Math.ceil((effect.expiresAt - now) / 1_000))}s</span> <span className="race-affix-world-countdown">Boon · {Math.max(1, Math.ceil((effect.expiresAt - now) / 1_000))}s</span>
</Html> </Html>
</group> </group>
) : <GroundMarker key={effect.id} effect={effect} now={now} />)} ) : <group key={effect.id}>
<GroundMarker effect={effect} now={now} />
{effect.kind === "star" && effect.tickAt === undefined &&
<RaceAffixMeteorDescent position={effect.position} active={visualActive} />}
{effect.kind === "storm" && effect.tickAt !== undefined &&
<RaceAffixLightningEffect position={effect.position} active={visualActive} />}
{effect.kind === "star" && effect.tickAt !== undefined &&
<RaceAffixSpellImpact position={effect.position} spellId={500135} source="ascension"
school="fire" name="Falling Stars" fallbackKind="meteor" active={visualActive} />}
</group>)}
{buffs.map(({ id, stacks }) => { {buffs.map(({ id, stacks }) => {
const position = getMobPosition(id); const position = getMobPosition(id);
return position ? <mesh key={id} position={[position[0], position[1] + 1.8, position[2]]}> return position ? <mesh key={id} position={[position[0], position[1] + 1.8, position[2]]}>
+21 -2
View File
@@ -25,6 +25,7 @@ import {
type CombatEffectPhaseAsset, type CombatEffectPhaseAsset,
type CombatPresentationEvent, type CombatPresentationEvent,
type CombatPresentationPhase, type CombatPresentationPhase,
type CombatPresentationSource,
type CombatVfxQuality, type CombatVfxQuality,
} from "../game/combatPresentation"; } from "../game/combatPresentation";
import { fetchCombatMediaAsset } from "../game/combatMediaPack"; import { fetchCombatMediaAsset } from "../game/combatMediaPack";
@@ -163,6 +164,7 @@ function graphTailMs(graph: OriginalCombatEffectGraph): number {
function placementHeight(model: string | undefined, event: CombatPresentationEvent): number { function placementHeight(model: string | undefined, event: CombatPresentationEvent): number {
const normalized = model?.toLowerCase() ?? ""; const normalized = model?.toLowerCase() ?? "";
if (event.abilityId === "race-affix-meteor-release") return 0.08;
if (normalized.endsWith(".ros") && /(?:_|\/)down(?:\.|_|$)/.test(normalized)) return 0.08; if (normalized.endsWith(".ros") && /(?:_|\/)down(?:\.|_|$)/.test(normalized)) return 0.08;
if (/(?:_|\/)head(?:\.|_|$)/.test(normalized)) return 1.72; if (/(?:_|\/)head(?:\.|_|$)/.test(normalized)) return 1.72;
if (/(?:_|\/)chest(?:\.|_|$)/.test(normalized)) return 1.12; if (/(?:_|\/)chest(?:\.|_|$)/.test(normalized)) return 1.12;
@@ -296,6 +298,18 @@ async function loadOriginalGraphAsset(asset: CombatEffectPhaseAsset): Promise<Lo
return { asset, graph, textures: new Map(loadedTextures), sourceTextures }; return { asset, graph, textures: new Map(loadedTextures), sourceTextures };
} }
/** Warm a scripted effect before its warning starts so its first cue is not delayed by asset loading. */
export async function preloadOriginalCombatEffectPhase(input: {
readonly source: Exclude<CombatPresentationSource, "fallback">;
readonly sourceSpellId: number;
readonly phase: CombatPresentationPhase;
readonly abilityId: string;
}): Promise<void> {
await preloadCombatEffectManifest();
const assets = combatOriginalPhaseAssets(input);
await Promise.allSettled(assets.map(loadOriginalGraphAsset));
}
function materialForParticle(particle: OriginalCombatEffectParticle, texture: Texture): ShaderMaterial { function materialForParticle(particle: OriginalCombatEffectParticle, texture: Texture): ShaderMaterial {
const additive = particle.blend === "no-alpha-add" const additive = particle.blend === "no-alpha-add"
|| particle.blend === "add" || particle.blend === "add"
@@ -727,7 +741,9 @@ function LoadedOriginalCombatEffect({
const projectile = (event.phase === "release" || (event.phase === "tick" && isPenancePulseVisual(event))) const projectile = (event.phase === "release" || (event.phase === "tick" && isPenancePulseVisual(event)))
&& event.delivery === "projectile" && event.delivery === "projectile"
&& event.targetPosition !== undefined; && event.targetPosition !== undefined;
const projectileDurationMs = projectile ? projectileTravelDurationMs(event) : 0; const projectileDurationMs = projectile
? Math.max(1, event.visualTravelDurationMs ?? projectileTravelDurationMs(event))
: 0;
const anchor = event.phase === "cast-start" || projectile const anchor = event.phase === "cast-start" || projectile
? event.origin ? event.origin
: event.targetPosition ?? event.origin; : event.targetPosition ?? event.origin;
@@ -800,7 +816,10 @@ function LoadedOriginalCombatEffect({
&& item.asset.model?.toLowerCase() === "model/fx/skill/ice/act_riptide_t_freeze_hit.ros" && item.asset.model?.toLowerCase() === "model/fx/skill/ice/act_riptide_t_freeze_hit.ros"
? risingTideImpactPlacement(event, combatEffectTargetVisualSize(event.targetActorId)) ? risingTideImpactPlacement(event, combatEffectTargetVisualSize(event.targetActorId))
: null; : null;
const scale = effectScale(item.asset) * (risingTideTarget?.scale ?? 1) * tuning.scale; const scriptedScale = typeof event.visualScale === "number" && Number.isFinite(event.visualScale)
? Math.max(0.01, event.visualScale) : 1;
const scale = effectScale(item.asset) * (risingTideTarget?.scale ?? 1) * tuning.scale
* scriptedScale;
return ( return (
<group <group
key={`${item.asset.effectGraphUrl}:${item.asset.model ?? "model"}`} key={`${item.asset.effectGraphUrl}:${item.asset.model ?? "model"}`}
+2
View File
@@ -24,6 +24,7 @@ import { cacheStaticMatrices, instanceRepeatedStaticMeshes } from "./staticScene
import { createStaticVisibilityCells, registerStaticVisibility } from "./staticSceneVisibility"; import { createStaticVisibilityCells, registerStaticVisibility } from "./staticSceneVisibility";
import { useGameGLTF } from "./useGameGLTF"; import { useGameGLTF } from "./useGameGLTF";
import { prepareDungeonMaterial } from "./productionDungeonMaterials"; import { prepareDungeonMaterial } from "./productionDungeonMaterials";
import { repairHallOfSurvivorsVisual } from "./hallOfSurvivorsVisual";
import { import {
DUNGEON_COLLISION_LOAD_BATCH_SIZE, DUNGEON_COLLISION_LOAD_BATCH_SIZE,
DUNGEON_VISUAL_LOAD_BATCH_SIZE, DUNGEON_VISUAL_LOAD_BATCH_SIZE,
@@ -53,6 +54,7 @@ function prepareVisual(root: Object3D, definition: DungeonDefinition) {
instanced.computeBoundingSphere(); instanced.computeBoundingSphere();
} }
}); });
repairHallOfSurvivorsVisual(clone, definition.id);
// Scholomance's compact visual has thousands of opaque repetitions. Grouping // Scholomance's compact visual has thousands of opaque repetitions. Grouping
// them across the level saves draw calls without expanding a large triangle set. // them across the level saves draw calls without expanding a large triangle set.
instanceRepeatedStaticMeshes(clone, 3, undefined, definition.id === "scholomance"); instanceRepeatedStaticMeshes(clone, 3, undefined, definition.id === "scholomance");
+190
View File
@@ -0,0 +1,190 @@
import { useMemo, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import { AdditiveBlending, CatmullRomCurve3, DoubleSide, Group, MeshBasicMaterial, Vector3 } from "three";
import { useShellStore } from "../app/shellStore";
import {
combatPresentationSessionId,
effectiveCombatVfxMode,
type CombatPresentationEvent,
type CombatPresentationPhase,
type CombatPresentationSource,
type CombatPresentationSchool,
} from "../game/combatPresentation";
import { OriginalCombatEffect, preloadOriginalCombatEffectPhase } from "./OriginalCombatEffect";
type GroundPosition = readonly [number, number, number];
type FallbackKind = "lightning" | "meteor";
export interface RaceAffixSpellImpactProps {
readonly position: GroundPosition;
readonly spellId: number;
readonly source?: Exclude<CombatPresentationSource, "fallback">;
readonly phase?: CombatPresentationPhase;
readonly school?: CombatPresentationSchool;
readonly name?: string;
readonly abilityId?: string;
readonly visualScale?: number;
readonly fallbackKind: FallbackKind;
/** Release visuals descend from this point to position. */
readonly projectileOrigin?: GroundPosition;
readonly travelDurationMs?: number;
readonly active?: boolean;
readonly safeGraphics?: boolean;
readonly onDone?: () => void;
}
const noop = () => {};
function GeneratedImpact({ kind, active, onDone, projectileOrigin, position, travelDurationMs }: {
readonly kind: FallbackKind;
readonly active: boolean;
readonly onDone: () => void;
readonly projectileOrigin?: GroundPosition;
readonly position: GroundPosition;
readonly travelDurationMs?: number;
}) {
const groupRef = useRef<Group>(null);
const bodyRef = useRef<Group>(null);
const coreRef = useRef<MeshBasicMaterial>(null);
const glowRef = useRef<MeshBasicMaterial>(null);
const ringRef = useRef<MeshBasicMaterial>(null);
const elapsedRef = useRef(0);
const doneRef = useRef(false);
const durationMs = travelDurationMs ?? (kind === "lightning" ? 650 : 800);
const bolt = useMemo(() => new CatmullRomCurve3([
new Vector3(-0.42, 7, 0.3), new Vector3(0.35, 5.8, -0.25),
new Vector3(-0.2, 4.5, 0.15), new Vector3(0.32, 3.3, -0.18),
new Vector3(-0.25, 2, 0.12), new Vector3(0.08, 0.1, 0),
]), []);
useFrame((_state, delta) => {
if (!active || doneRef.current) return;
elapsedRef.current += Math.max(0, Math.min(100, delta * 1_000));
const progress = Math.min(1, elapsedRef.current / durationMs);
if (groupRef.current) {
groupRef.current.scale.setScalar(kind === "meteor" && !projectileOrigin ? 0.55 + progress * 1.5 : 1);
if (kind === "lightning") groupRef.current.rotation.y += delta * 3;
}
if (projectileOrigin && bodyRef.current) {
bodyRef.current.position.set(
(projectileOrigin[0] - position[0]) * (1 - progress),
(projectileOrigin[1] - position[1]) * (1 - progress),
(projectileOrigin[2] - position[2]) * (1 - progress),
);
}
if (coreRef.current) coreRef.current.opacity = projectileOrigin
? Math.max(0, 1 - Math.max(0, progress - 0.9) * 10)
: Math.max(0, 1 - progress * progress);
if (glowRef.current) glowRef.current.opacity = Math.max(0, 0.45 * (1 - progress));
if (ringRef.current) ringRef.current.opacity = Math.max(0, 0.4 * (1 - progress));
if (progress < 1) return;
doneRef.current = true;
onDone();
});
return <group ref={groupRef} position={[0, 0.1, 0]}>
{kind === "lightning" ? <>
<mesh>
<tubeGeometry args={[bolt, 24, 0.13, 5, false]} />
<meshBasicMaterial ref={glowRef} color="#58bfff" transparent opacity={0.45}
depthWrite={false} blending={AdditiveBlending} toneMapped={false} />
</mesh>
<mesh>
<tubeGeometry args={[bolt, 24, 0.042, 5, false]} />
<meshBasicMaterial ref={coreRef} color="#efffff" transparent opacity={1}
depthWrite={false} blending={AdditiveBlending} toneMapped={false} />
</mesh>
</> : <group ref={bodyRef} position={projectileOrigin ? [
projectileOrigin[0] - position[0],
projectileOrigin[1] - position[1],
projectileOrigin[2] - position[2],
] : [0, 0, 0]}>
<mesh position={[0, 0.38, 0]}>
<sphereGeometry args={[0.62, 12, 8]} />
<meshBasicMaterial ref={coreRef} color="#ffd06a" transparent opacity={1}
depthWrite={false} depthTest={false} blending={AdditiveBlending} toneMapped={false} />
</mesh>
{projectileOrigin && <mesh position={[0, 0.85, 0]}>
<coneGeometry args={[0.38, 1.5, 10]} />
<meshBasicMaterial color="#ff8b32" transparent opacity={0.55}
depthWrite={false} depthTest={false} blending={AdditiveBlending} toneMapped={false} />
</mesh>}
</group>}
{!projectileOrigin && <mesh rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.18, 1.25, 40]} />
<meshBasicMaterial ref={ringRef}
color={kind === "lightning" ? "#5dd2ff" : "#ff9e39"} transparent opacity={0.4}
depthWrite={false} side={DoubleSide} blending={AdditiveBlending} toneMapped={false} />
</mesh>}
</group>;
}
/** One ground impact. The caller retains it long enough for the source animation to finish. */
export function RaceAffixSpellImpact({
position, spellId, source = "wow335", phase = "impact", school = "nature", name = "Race affix impact",
abilityId = `race-affix-${spellId}`, visualScale, fallbackKind, projectileOrigin, travelDurationMs,
active = true, safeGraphics = false, onDone = noop,
}: RaceAffixSpellImpactProps) {
const settings = useShellStore((state) => state.combatPresentationSettings);
const quality = safeGraphics && settings.vfxQuality === "high" ? "low" : settings.vfxQuality;
const resolvedVisualScale = visualScale ?? (spellId === 500135 && source === "ascension"
&& phase === "impact" ? 0.35 : undefined);
const event = useMemo<CombatPresentationEvent>(() => ({
id: spellId,
sessionId: combatPresentationSessionId(),
occurredAt: Date.now(),
phase,
actorGroup: "enemy",
sourceActorId: "race-affix",
abilityId,
sourceSpellId: spellId,
name,
source,
school,
delivery: projectileOrigin ? "projectile" : "area",
origin: projectileOrigin ?? [position[0], position[1], position[2]],
targetPosition: [position[0], position[1], position[2]],
...(resolvedVisualScale === undefined ? {} : { visualScale: resolvedVisualScale }),
...(projectileOrigin && travelDurationMs ? { visualTravelDurationMs: travelDurationMs } : {}),
}), [position[0], position[1], position[2], projectileOrigin?.[0], projectileOrigin?.[1],
projectileOrigin?.[2], spellId, source, phase, school, name, abilityId, resolvedVisualScale, travelDurationMs]);
if (quality === "off") return null;
if (!settings.vfxEnabled.enemy) return null;
const fallback = <group position={position}>
<GeneratedImpact kind={fallbackKind} active={active} onDone={onDone}
projectileOrigin={projectileOrigin} position={position} travelDurationMs={travelDurationMs} />
</group>;
return effectiveCombatVfxMode(settings) === "original" ? (
<OriginalCombatEffect event={event} quality={quality} active={active}
onDone={onDone} fallback={fallback} />
) : fallback;
}
export function RaceAffixLightningEffect(props: Omit<RaceAffixSpellImpactProps,
"spellId" | "source" | "phase" | "school" | "name" | "abilityId" | "fallbackKind"
| "visualScale">) {
return <RaceAffixSpellImpact {...props} spellId={42230} source="wow335" phase="impact"
school="nature" name="Stormcall" fallbackKind="lightning" visualScale={0.25} />;
}
export function RaceAffixMeteorDescent({ position, ...props }: Omit<RaceAffixSpellImpactProps,
"spellId" | "source" | "phase" | "school" | "name" | "abilityId" | "fallbackKind"
| "projectileOrigin" | "travelDurationMs" | "visualScale">) {
const origin: GroundPosition = [position[0], position[1] + 5, position[2]];
return <RaceAffixSpellImpact {...props} position={position} spellId={500135} source="ascension"
phase="release" school="fire" name="Falling Stars" abilityId="race-affix-meteor-release"
fallbackKind="meteor" projectileOrigin={origin} travelDurationMs={2_000}
visualScale={0.15} />;
}
export async function preloadRaceAffixImpactVisuals(): Promise<void> {
await Promise.all([
preloadOriginalCombatEffectPhase({ source: "wow335", sourceSpellId: 42230,
phase: "impact", abilityId: "race-affix-42230" }),
preloadOriginalCombatEffectPhase({ source: "ascension", sourceSpellId: 500135,
phase: "release", abilityId: "race-affix-meteor-release" }),
preloadOriginalCombatEffectPhase({ source: "ascension", sourceSpellId: 500135,
phase: "impact", abilityId: "race-affix-500135" }),
]);
}
+130
View File
@@ -0,0 +1,130 @@
import {
BufferAttribute,
Mesh,
MeshStandardMaterial,
Object3D,
Uint32BufferAttribute,
Vector3,
} from "three";
const HALL_SLICE_ID = "bite-sized-hall-of-survivors-pursuer";
const WALL_ATLAS_MATERIAL = "runewaker_material_60";
const CEILING_ATLAS_MATERIAL = "runewaker_material_44";
const LIGHT_BEAM_MATERIAL = "runewaker_material_154";
const a = new Vector3();
const b = new Vector3();
const c = new Vector3();
const ab = new Vector3();
const ac = new Vector3();
const center = new Vector3();
function readWorldVertex(mesh: Mesh, positions: BufferAttribute, index: number, into: Vector3): Vector3 {
return into.set(positions.getX(index), positions.getY(index), positions.getZ(index))
.applyMatrix4(mesh.matrixWorld);
}
function isCeilingGlare(): boolean {
// Pale decorative canopy faces hang down over the twin-statue sight line.
// This is off the playable balcony and stair route at z ~ 40-55.
return center.x > -190 && center.x < -150
&& center.z > 10 && center.z < 28
&& center.y > -10 && center.y < 3;
}
function needsStoneProjection(area: number, normalY: number): boolean {
if (center.x < -231 || center.x > -120 || center.z < 0 || center.z > 70
|| center.y < -19 || center.y > 10) return false;
const horizontal = Math.abs(normalY) > 0.78;
const wall = Math.abs(normalY) < 0.25;
// Large atlas polygons have too few texels for their world-space size and
// produce the long, muddy bands visible on the floor and surrounding walls.
return (horizontal && area > 2 && (center.y < -16 || center.y > -1))
|| (wall && area > 2);
}
function stoneProjectionMaterial(source: MeshStandardMaterial): MeshStandardMaterial {
const material = source.clone();
material.name = `${source.name}-hall-stone-projection`;
material.onBeforeCompile = (shader) => {
shader.vertexShader = shader.vertexShader
.replace("#include <common>", "#include <common>\nvarying vec3 vHallWorldPosition;\nvarying vec3 vHallWorldNormal;")
.replace("#include <worldpos_vertex>", `#include <worldpos_vertex>
vHallWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;
vHallWorldNormal = normalize(mat3(modelMatrix) * normal);`);
shader.fragmentShader = shader.fragmentShader
.replace("#include <common>", "#include <common>\nvarying vec3 vHallWorldPosition;\nvarying vec3 vHallWorldNormal;")
.replace("#include <map_fragment>", `#ifdef USE_MAP
vec3 hallNormal = abs(normalize(vHallWorldNormal));
vec2 hallWallPlane = hallNormal.x > hallNormal.z
? vec2(vHallWorldPosition.z, vHallWorldPosition.y)
: vec2(vHallWorldPosition.x, vHallWorldPosition.y);
vec2 hallWallRepeat = 1.0 - abs(fract(hallWallPlane / vec2(9.0, 7.0)) * 2.0 - 1.0);
vec2 hallFloorRepeat = 1.0 - abs(fract(vHallWorldPosition.xz / 9.0) * 2.0 - 1.0);
// Both patches come from the existing Hall atlas: stone blocks for
// walls and worn square slabs for the walking surfaces.
vec2 hallWallUv = vec2(0.46, 0.65) + hallWallRepeat * vec2(0.42, 0.32);
vec2 hallFloorUv = vec2(0.47, 0.41) + hallFloorRepeat * vec2(0.415, 0.20);
vec2 hallUv = hallNormal.y > 0.78 ? hallFloorUv : hallWallUv;
diffuseColor *= texture2D(map, hallUv);
#endif`);
};
material.customProgramCacheKey = () => "hall-stone-projection-v1";
material.needsUpdate = true;
return material;
}
/** Repairs only the three-pack Hall route, leaving the full source dungeon alone. */
export function repairHallOfSurvivorsVisual(root: Object3D, dungeonId: string): void {
if (dungeonId !== HALL_SLICE_ID) return;
root.updateMatrixWorld(true);
const overlays: { source: Mesh; repair: Mesh }[] = [];
root.traverse((node) => {
const mesh = node as Mesh;
if (!mesh.isMesh || !mesh.geometry.index || Array.isArray(mesh.material)) return;
const name = mesh.material.name;
if (name !== WALL_ATLAS_MATERIAL && name !== CEILING_ATLAS_MATERIAL
&& name !== LIGHT_BEAM_MATERIAL) return;
if (name === LIGHT_BEAM_MATERIAL) {
// This imported fake volumetric light is a large opaque-looking white
// mesh from the statue room. Lighting comes from separate actual lights.
mesh.visible = false;
return;
}
const positions = mesh.geometry.getAttribute("position") as BufferAttribute | undefined;
if (!positions) return;
const indices = mesh.geometry.index;
const kept: number[] = [];
const projected: number[] = [];
for (let offset = 0; offset < indices.count; offset += 3) {
const ia = indices.getX(offset);
const ib = indices.getX(offset + 1);
const ic = indices.getX(offset + 2);
readWorldVertex(mesh, positions, ia, a);
readWorldVertex(mesh, positions, ib, b);
readWorldVertex(mesh, positions, ic, c);
center.copy(a).add(b).add(c).divideScalar(3);
ab.subVectors(b, a);
ac.subVectors(c, a);
const cross = ab.cross(ac);
const twiceArea = cross.length();
if (name === CEILING_ATLAS_MATERIAL && isCeilingGlare()) continue;
const destination = name === WALL_ATLAS_MATERIAL
&& needsStoneProjection(twiceArea / 2, twiceArea > 0 ? cross.y / twiceArea : 0)
? projected : kept;
destination.push(ia, ib, ic);
}
if (kept.length === indices.count) return;
if (projected.length) {
const repair = mesh.clone(false);
repair.geometry = mesh.geometry.clone();
repair.geometry.setIndex(new Uint32BufferAttribute(projected, 1));
repair.material = stoneProjectionMaterial(mesh.material as MeshStandardMaterial);
repair.name = `${mesh.name}-stone-repair`;
overlays.push({ source: mesh, repair });
}
mesh.geometry = mesh.geometry.clone();
mesh.geometry.setIndex(new Uint32BufferAttribute(kept, 1));
});
for (const { source, repair } of overlays) source.parent?.add(repair);
}
+13 -1
View File
@@ -1,4 +1,4 @@
import { readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { Matrix4, Quaternion, Vector3 } from "three"; import { Matrix4, Quaternion, Vector3 } from "three";
@@ -12,6 +12,18 @@ const urls = [...new Set<string>(manifest.entries.filter((e: { source: string })
const graphs = urls.map((url) => JSON.parse(readFileSync(path.join("public", url), "utf8"))); const graphs = urls.map((url) => JSON.parse(readFileSync(path.join("public", url), "utf8")));
describe("all-class original WoW models", () => { describe("all-class original WoW models", () => {
it("loads the packaged Ascension Mage Meteor release and impact visuals", () => {
const entry = manifest.entries.find((candidate: { key: string }) => candidate.key === "ascension:500135");
expect(entry).toBeDefined();
for (const phase of ["release", "impact"] as const) {
for (const asset of entry.phases[phase]) {
expect(asset.effectGraphUrl).toMatch(/^\/assets\/game\/combat-effects\/original\/wow335\/all\/graphs\//);
const graph = parseOriginalWowModelGraph(JSON.parse(readFileSync(path.join("public", asset.effectGraphUrl), "utf8")));
expect(graph.mesh?.indices.length || graph.particleGraph?.runtime.readyParticleIndices.length).toBeGreaterThan(0);
for (const url of wowModelTextureUrls(graph)) expect(existsSync(path.join("public", url))).toBe(true);
}
}
});
it("loads every shipped model through the runtime parser and samples finite bone poses", () => { it("loads every shipped model through the runtime parser and samples finite bone poses", () => {
expect(graphs.length).toBeGreaterThanOrEqual(378); expect(graphs.length).toBeGreaterThanOrEqual(378);
for (const raw of graphs) { for (const raw of graphs) {
+58
View File
@@ -0,0 +1,58 @@
import { SRGBColorSpace, Texture, TextureLoader } from "three";
import { fetchCombatMediaAsset, combatMediaPackReady } from "../game/combatMediaPack";
import { preloadCombatEffectManifest, shadowfiendOriginalModelAsset } from "../game/combatPresentation";
import { parseOriginalWowModelGraph, type OriginalWowModelGraph } from "./originalWowModelRuntime";
export interface ShadowfiendMedia {
readonly graph: OriginalWowModelGraph;
readonly textures: ReadonlyMap<number, Texture>;
}
let shadowfiendMediaRequest: Promise<ShadowfiendMedia | null> | null = null;
let preparedShadowfiendMedia: ShadowfiendMedia | null = null;
export function getPreparedShadowfiendMedia(): ShadowfiendMedia | null {
return combatMediaPackReady() ? preparedShadowfiendMedia : null;
}
/** Prepare the immutable client model once, before a Priest can cast in gameplay. */
export function preloadShadowfiendMedia(): Promise<ShadowfiendMedia | null> {
if (!combatMediaPackReady()) return Promise.resolve(null);
if (shadowfiendMediaRequest) return shadowfiendMediaRequest;
shadowfiendMediaRequest = (async () => {
await preloadCombatEffectManifest();
const url = shadowfiendOriginalModelAsset()?.effectGraphUrl;
if (!url) return null;
const response = await fetchCombatMediaAsset(url);
if (!response.ok) throw new Error(`Shadowfiend model request failed: ${response.status}`);
const graph = parseOriginalWowModelGraph(await response.json());
const textures = new Map<number, Texture>();
const ownedTextures: Texture[] = [];
const loader = new TextureLoader();
try {
for (const [index, source] of graph.textures.entries()) {
if (!source.url) continue;
const image = await fetchCombatMediaAsset(source.url);
if (!image.ok) throw new Error(`Shadowfiend texture request failed: ${image.status}`);
const objectUrl = URL.createObjectURL(await image.blob());
try {
const texture = await loader.loadAsync(objectUrl);
texture.colorSpace = SRGBColorSpace;
ownedTextures.push(texture);
textures.set(index, texture);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
preparedShadowfiendMedia = { graph, textures };
return preparedShadowfiendMedia;
} catch (error) {
ownedTextures.forEach((texture) => texture.dispose());
throw error;
}
})().catch((error: unknown) => {
shadowfiendMediaRequest = null;
throw error;
});
return shadowfiendMediaRequest;
}
+8
View File
@@ -633,6 +633,12 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.pause-menu__character span, .pause-menu__context { color: var(--muted); font-size: 0.78rem; } .pause-menu__character span, .pause-menu__context { color: var(--muted); font-size: 0.78rem; }
.pause-menu__character span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pause-menu__character span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pause-menu__context { flex-basis: 100%; margin: 0; text-align: right; } .pause-menu__context { flex-basis: 100%; margin: 0; text-align: right; }
.pause-menu__affixes { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 10px; margin-top: 12px; padding: 9px 11px; border: 1px solid rgba(188, 207, 160, 0.15); border-radius: 9px; background: rgba(170, 194, 141, 0.035); }
.pause-menu__affixes-heading { color: var(--muted); font-size: 0.68rem; letter-spacing: 0.1em; text-transform: uppercase; }
.pause-menu__affixes-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; min-width: 0; }
.pause-menu__affixes-list p { display: grid; gap: 2px; min-width: 0; margin: 0; font-size: 0.72rem; line-height: 1.25; }
.pause-menu__affixes-list p strong { color: var(--ink); font-size: 0.75rem; }
.pause-menu__affixes-list p span { color: var(--muted); }
.pause-menu__resume { display: flex; justify-content: center; margin: 14px 0; } .pause-menu__resume { display: flex; justify-content: center; margin: 14px 0; }
.pause-menu__resume .button { width: min(320px, 100%); } .pause-menu__resume .button { width: min(320px, 100%); }
.pause-menu__groups { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } .pause-menu__groups { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
@@ -662,6 +668,8 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
} }
@media (max-width: 620px) { @media (max-width: 620px) {
.pause-menu__affixes { grid-template-columns: minmax(0, 1fr); gap: 6px; }
.pause-menu__affixes-list { grid-template-columns: minmax(0, 1fr); gap: 8px; }
.pause-menu__groups { grid-template-columns: minmax(0, 1fr); gap: 8px; } .pause-menu__groups { grid-template-columns: minmax(0, 1fr); gap: 8px; }
.pause-menu--hub .pause-menu__group { padding: 9px; } .pause-menu--hub .pause-menu__group { padding: 9px; }
.pause-menu--hub .pause-menu__character { margin-top: 0; } .pause-menu--hub .pause-menu__character { margin-top: 0; }
+12
View File
@@ -12,6 +12,7 @@ import { dungeonBossCards } from "../game/dungeonBossCards";
import { requireDungeonDefinition } from "../game/dungeonRegistry"; import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { DungeonBossCards } from "./DungeonBossCards"; import { DungeonBossCards } from "./DungeonBossCards";
import { useDungeonRaceStore } from "../app/dungeonRaceStore"; import { useDungeonRaceStore } from "../app/dungeonRaceStore";
import { dungeonRaceAffix } from "../game/dungeonRaceAffixes";
function PauseDialog() { function PauseDialog() {
const closeOverlay = useGameStore((state) => state.closeOverlay); const closeOverlay = useGameStore((state) => state.closeOverlay);
@@ -19,6 +20,8 @@ function PauseDialog() {
const reset = useGameStore((state) => state.resetAtEntrance); const reset = useGameStore((state) => state.resetAtEntrance);
const gameMode = useGameStore((state) => state.gameMode); const gameMode = useGameStore((state) => state.gameMode);
const activeDungeonId = useGameStore((state) => state.activeDungeonId); const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const raceAffixIds = useDungeonRaceStore((state) =>
state.inGame && state.dungeonId === activeDungeonId ? state.affixIds : null);
const activeDifficultyId = useGameStore((state) => state.activeDifficultyId); const activeDifficultyId = useGameStore((state) => state.activeDifficultyId);
const sessionRevision = useGameStore((state) => state.sessionRevision); const sessionRevision = useGameStore((state) => state.sessionRevision);
const bossTest = useGameStore((state) => state.bossTest); const bossTest = useGameStore((state) => state.bossTest);
@@ -57,6 +60,7 @@ function PauseDialog() {
const dungeonCards = useMemo(() => gameMode === "dungeon" const dungeonCards = useMemo(() => gameMode === "dungeon"
? dungeonBossCards(requireDungeonDefinition(activeDungeonId), activeDifficultyId, bossCards) ? dungeonBossCards(requireDungeonDefinition(activeDungeonId), activeDifficultyId, bossCards)
: [], [activeDungeonId, activeDifficultyId, bossCards, gameMode, sessionRevision]); : [], [activeDungeonId, activeDifficultyId, bossCards, gameMode, sessionRevision]);
const raceAffixes = raceAffixIds?.map((id) => dungeonRaceAffix(id)).filter((affix) => affix !== undefined) ?? [];
const actions = useMemo<MenuAction[]>(() => [ const actions = useMemo<MenuAction[]>(() => [
{ id: "pause-resume", run: resume, neighbors: { down: "pause-party", right: "pause-party", up: "pause-main-menu", left: "pause-main-menu" } }, { id: "pause-resume", run: resume, neighbors: { down: "pause-party", right: "pause-party", up: "pause-main-menu", left: "pause-main-menu" } },
@@ -111,6 +115,14 @@ function PauseDialog() {
</div> </div>
{bossTest && <p className="pause-menu__context">Boss Testing · {bossTest.name}</p>} {bossTest && <p className="pause-menu__context">Boss Testing · {bossTest.name}</p>}
</header> </header>
{raceAffixes.length > 0 && <section className="pause-menu__affixes" aria-label="Active race affixes">
<strong className="pause-menu__affixes-heading">Race affixes</strong>
<div className="pause-menu__affixes-list">
{raceAffixes.map((affix) => <p key={affix.id}>
<strong>{affix.name}</strong><span>{affix.description}</span>
</p>)}
</div>
</section>}
{gameMode === "dungeon" && <section className="pause-menu__bosses" aria-label="Boss cards in this dungeon"> {gameMode === "dungeon" && <section className="pause-menu__bosses" aria-label="Boss cards in this dungeon">
<div className="pause-menu__bosses-heading"><strong>Boss cards in this dungeon</strong> <div className="pause-menu__bosses-heading"><strong>Boss cards in this dungeon</strong>
<span>{dungeonCards.filter((card) => card.copies > 0).length} / {dungeonCards.length} collected</span></div> <span>{dungeonCards.filter((card) => card.copies > 0).length} / {dungeonCards.length} collected</span></div>
+1 -1
View File
@@ -46,7 +46,7 @@ describe("gameplay panel loading integration", () => {
}); });
it("warms the shared runtime and first-open panels before gameplay mounts", () => { it("warms the shared runtime and first-open panels before gameplay mounts", () => {
expect(appSource).toContain("preloadSharedGameplayResources(originalMediaEnabled)"); expect(appSource).toContain('preloadSharedGameplayResources(originalMediaEnabled, character?.classId === "priest")');
expect(appSource).toContain("getLoadedGameRuntime()"); expect(appSource).toContain("getLoadedGameRuntime()");
expect(gameplayResourcesSource).toContain('import("../GameRuntime")'); expect(gameplayResourcesSource).toContain('import("../GameRuntime")');
expect(gameplayResourcesSource).toContain('import("./PartyManagementPanel")'); expect(gameplayResourcesSource).toContain('import("./PartyManagementPanel")');
+10 -1
View File
@@ -62,7 +62,10 @@ export function getLoadedBossGuidePanel() {
return loadedBossGuidePanel; return loadedBossGuidePanel;
} }
export async function preloadSharedGameplayResources(originalMediaEnabled: boolean): Promise<void> { export async function preloadSharedGameplayResources(
originalMediaEnabled: boolean,
priestCharacter: boolean,
): Promise<void> {
await Promise.all([ await Promise.all([
loadGameRuntime(), loadGameRuntime(),
loadPartyManagementPanel(), loadPartyManagementPanel(),
@@ -73,4 +76,10 @@ export async function preloadSharedGameplayResources(originalMediaEnabled: boole
if (!await ensureCombatMediaPack()) return; if (!await ensureCombatMediaPack()) return;
const { preloadCombatEffectManifest } = await import("../game/combatPresentation"); const { preloadCombatEffectManifest } = await import("../game/combatPresentation");
await preloadCombatEffectManifest(); await preloadCombatEffectManifest();
if (priestCharacter) {
const { preloadShadowfiendMedia } = await import("../scene/shadowfiendMedia");
await preloadShadowfiendMedia().catch((error: unknown) => {
console.warn("Shadowfiend model could not be prepared before entering the expedition.", error);
});
}
} }