Update Healer Man server
No files matched your search
@@ -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
|
||||
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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"web:prepare": "node scripts/content/prepare-web-bootstrap.mjs",
|
||||
"android:prepare": "node scripts/content/prepare-android-bootstrap.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:particles": "node scripts/combat-effects/package-rom-particles.mjs",
|
||||
"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:audit": "node scripts/combat-effects/package-wow-original.mjs --audit",
|
||||
"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:test": "node --test scripts/combat-effects/*.test.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
|
||||
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 840 B |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 349 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 79 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
@@ -21,6 +21,12 @@ npm run effects:audit
|
||||
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
|
||||
visuals. `effects:priest` remains a compatibility alias for `effects:wow` so it
|
||||
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));
|
||||
@@ -55,7 +55,7 @@ function RuntimeCatalogGate({ children, preloadGameplay }: { readonly children:
|
||||
void Promise.all([
|
||||
preloadAbilityCatalogsForClasses(classIds),
|
||||
preloadGameplay && talentClassId ? preloadTalentGameplayResources(talentClassId) : Promise.resolve(),
|
||||
preloadGameplay ? preloadSharedGameplayResources(originalMediaEnabled) : Promise.resolve(),
|
||||
preloadGameplay ? preloadSharedGameplayResources(originalMediaEnabled, character?.classId === "priest") : Promise.resolve(),
|
||||
]).then(() => {
|
||||
if (active) setLoadedKey(requestKey);
|
||||
}).catch(() => {
|
||||
|
||||
@@ -7,12 +7,16 @@ import { preloadAbilityCatalogsForClasses } from "../game/abilityCatalog";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { setMobPosition } from "../game/mobRuntimeRegistry";
|
||||
import { CombatSummonPopulation } from "../scene/CombatSummonPopulation";
|
||||
import { preloadShadowfiendMedia } from "../scene/shadowfiendMedia";
|
||||
|
||||
function Preview() {
|
||||
const [status, setStatus] = useState("Casting Shadowfiend…");
|
||||
const [status, setStatus] = useState("Preparing Shadowfiend media…");
|
||||
const canvas = useRef<HTMLCanvasElement | null>(null);
|
||||
useEffect(() => {
|
||||
void preloadAbilityCatalogsForClasses(["priest"]).then(() => {
|
||||
void Promise.all([
|
||||
preloadAbilityCatalogsForClasses(["priest"]),
|
||||
preloadShadowfiendMedia().catch(() => null),
|
||||
]).then(() => {
|
||||
const combat = useCombatStore.getState();
|
||||
combat.initializeCharacter({ classId: "priest", level: 80 });
|
||||
combat.registerMob("shadowfiend-preview-target", {
|
||||
|
||||
@@ -139,6 +139,10 @@ export interface CombatPresentationEvent {
|
||||
readonly targetPosition?: CombatPresentationPosition;
|
||||
readonly radius?: 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. */
|
||||
readonly instantCast?: boolean;
|
||||
}
|
||||
|
||||
@@ -186,6 +186,21 @@ describe("Bite-Sized race mechanics", () => {
|
||||
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", () => {
|
||||
start("volatile-remains");
|
||||
deadTrash(trashIds[0]);
|
||||
|
||||
@@ -137,8 +137,11 @@ function createMovementEffect(id: string, kind: string, now: number, targetRoll:
|
||||
const target = actors[targetRoll % actors.length];
|
||||
const base = { id, position: target.position, createdAt: now, resolvesAt: now + 2_000,
|
||||
expiresAt: now + 2_500, radius: 3 };
|
||||
if (kind === "falling-stars") return { ...base, kind: "star" };
|
||||
if (kind === "stormcall") return { ...base, kind: "storm", targetId: target.id };
|
||||
// Retain resolved strikes long enough for their source impact animation to finish.
|
||||
// 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 };
|
||||
return { ...base, kind: "predator", targetId: target.id, radius: 2,
|
||||
expiresAt: now + 8_000, tickAt: now + 3_000,
|
||||
|
||||
@@ -49,7 +49,8 @@ describe("RoM bite-sized finales", () => {
|
||||
expect(dungeonContentPackId(id)).toBe(`dungeon:${source.id}`);
|
||||
expect(slice.assets).toBe(source.assets);
|
||||
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);
|
||||
expect(slice.staticSpawns.every((spawn) => source.staticSpawns.some((candidate) =>
|
||||
candidate.id === spawn.id && candidate.entityId === spawn.entityId))).toBe(true);
|
||||
@@ -120,17 +121,28 @@ describe("RoM bite-sized finales", () => {
|
||||
}
|
||||
}, 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 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 hunters = slice.staticSpawns.filter((spawn) => spawn.id.includes("119628"));
|
||||
expect(balcony).toHaveLength(2);
|
||||
expect(landing).toHaveLength(2);
|
||||
expect(guardians).toHaveLength(3);
|
||||
expect(hunters).toHaveLength(2);
|
||||
// The western side room lies beyond x=-185; the statue chamber is to its east.
|
||||
expect([...slice.staticSpawns, slice.encounterSpawnGroups![0].spawns[0]].every(
|
||||
(spawn) => spawn.position[0] > -185 && spawn.position[0] < -158)).toBe(true);
|
||||
expect(Math.min(...hunters.map((spawn) => spawn.position[2]))
|
||||
- Math.max(...guardians.map((spawn) => spawn.position[2]))).toBeGreaterThan(10);
|
||||
expect(slice.entrance.footPosition[1]).toBeGreaterThan(0);
|
||||
expect(balcony.every((spawn) => spawn.position[1] > 0)).toBe(true);
|
||||
expect(landing.every((spawn) => spawn.position[1] > -7 && spawn.position[1] < -3)).toBe(true);
|
||||
expect(guardians.every((spawn) => spawn.position[1] < -17)).toBe(true);
|
||||
expect(Math.max(...balcony.map((spawn) => spawn.position[0])))
|
||||
.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", () => {
|
||||
|
||||
@@ -190,23 +190,31 @@ const ROM_SLICES: readonly RoMSlice[] = [
|
||||
title: "Hall of Survivors: Pursuer Ward",
|
||||
location: "Hall of Survivors · Pursuer Ward",
|
||||
entranceName: "Pursuer Ward",
|
||||
start: [-155, -17.55, 35],
|
||||
forward: [-1, 0, 0],
|
||||
// Begin on the upper overlook before descending the connected east stair.
|
||||
start: [-220, 0.554, 40],
|
||||
forward: [1, 0, 0],
|
||||
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: [
|
||||
"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-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
|
||||
// as a distinct pack at the far end of the open chamber with the statues.
|
||||
// Two Hunters guard the balcony, two Rune Swordsmen hold the stair landing,
|
||||
// and three Rune Guardians guard the lower room before the statues.
|
||||
mobSpawnPositions: {
|
||||
"rw-hall-of-survivors-spawn-11962803": [-165, -17.55, 38],
|
||||
"rw-hall-of-survivors-spawn-11962817": [-175, -17.55, 38],
|
||||
"rw-hall-of-survivors-spawn-11962803": [-218, 0.554, 52],
|
||||
"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.",
|
||||
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.",
|
||||
summary: "Clear the overlook, stair landing, and lower ward packs, then defeat one surprise Hall boss between the twin statues.",
|
||||
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.",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
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 { fetchCombatMediaAsset, combatMediaPackReady } from "../game/combatMediaPack";
|
||||
import { preloadCombatEffectManifest, shadowfiendOriginalModelAsset } from "../game/combatPresentation";
|
||||
import { combatMediaPackReady } from "../game/combatMediaPack";
|
||||
import { type CombatSummon, useCombatStore } from "../game/combatStore";
|
||||
import { getMobPosition } from "../game/mobRuntimeRegistry";
|
||||
import { OriginalWowModel } from "./OriginalWowModel";
|
||||
import { parseOriginalWowModelGraph, type OriginalWowModelGraph } from "./originalWowModelRuntime";
|
||||
import { getPreparedShadowfiendMedia, preloadShadowfiendMedia } from "./shadowfiendMedia";
|
||||
|
||||
const SHADOWFIEND_CREATURE_ID = 19668;
|
||||
|
||||
interface ShadowfiendMedia {
|
||||
readonly graph: OriginalWowModelGraph;
|
||||
readonly textures: ReadonlyMap<number, Texture>;
|
||||
}
|
||||
|
||||
function ShadowfiendFallback() {
|
||||
return <group name="shadowfiend-fallback">
|
||||
<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 }) {
|
||||
const [media, setMedia] = useState<ShadowfiendMedia | null>(null);
|
||||
const [media, setMedia] = useState(getPreparedShadowfiendMedia);
|
||||
const mediaReady = useSyncExternalStore(subscribeContentManager, combatMediaPackReady, () => false);
|
||||
const group = useRef<Group>(null);
|
||||
const playback = useRef({ elapsedMs: 0, decaying: false });
|
||||
@@ -50,36 +44,11 @@ function ShadowfiendMember({ summon, active }: { readonly summon: CombatSummon;
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const ownedTextures: Texture[] = [];
|
||||
void (async () => {
|
||||
await preloadCombatEffectManifest();
|
||||
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()); });
|
||||
void preloadShadowfiendMedia().then((ready) => {
|
||||
if (!cancelled) setMedia(ready);
|
||||
}).catch(() => { if (!cancelled) setMedia(null); });
|
||||
return () => {
|
||||
cancelled = true;
|
||||
ownedTextures.forEach((texture) => texture.dispose());
|
||||
};
|
||||
}, [mediaReady]);
|
||||
|
||||
|
||||
@@ -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 { useDungeonRaceStore } from "../app/dungeonRaceStore";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { activeLastStand } from "../game/dungeonRaceAffixBuffs";
|
||||
import { useDungeonRaceAffixStore, type RaceAffixEffect } from "../game/dungeonRaceAffixRuntime";
|
||||
import { getMobPosition } from "../game/mobRuntimeRegistry";
|
||||
import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { preloadRaceAffixImpactVisuals, RaceAffixLightningEffect, RaceAffixMeteorDescent,
|
||||
RaceAffixSpellImpact } from "./RaceAffixLightningEffect";
|
||||
|
||||
const COLORS: Record<RaceAffixEffect["kind"], string> = {
|
||||
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 warning = now < effect.resolvesAt;
|
||||
const isRing = effect.kind === "ring";
|
||||
const isPredator = effect.kind === "predator";
|
||||
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]]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
{isRing ? <ringGeometry args={[4, 7, 64]} /> : <circleGeometry args={[effect.radius, 48]} />}
|
||||
<meshBasicMaterial color={color} transparent opacity={warning ? 0.3 : 0.48}
|
||||
{!strikeResolved && <mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
{isRing ? <ringGeometry args={[4, 7, 64]} />
|
||||
: 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} />
|
||||
</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>}
|
||||
{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" }}>
|
||||
<span style={{ color: "white", background: "#101015dc", border: `1px solid ${color}`,
|
||||
borderRadius: 6, padding: "2px 6px", fontWeight: 700, whiteSpace: "nowrap" }}>
|
||||
@@ -46,10 +55,40 @@ function GroundMarker({ effect, now }: { readonly effect: RaceAffixEffect; reado
|
||||
</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() {
|
||||
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 buffs = activeLastStand(now);
|
||||
useEffect(() => {
|
||||
if (movementId === "falling-stars" || movementId === "stormcall") {
|
||||
void preloadRaceAffixImpactVisuals();
|
||||
}
|
||||
}, [movementId]);
|
||||
return <>
|
||||
{effects.map((effect) => effect.kind === "orb" ? (
|
||||
<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>
|
||||
</Html>
|
||||
</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 }) => {
|
||||
const position = getMobPosition(id);
|
||||
return position ? <mesh key={id} position={[position[0], position[1] + 1.8, position[2]]}>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type CombatEffectPhaseAsset,
|
||||
type CombatPresentationEvent,
|
||||
type CombatPresentationPhase,
|
||||
type CombatPresentationSource,
|
||||
type CombatVfxQuality,
|
||||
} from "../game/combatPresentation";
|
||||
import { fetchCombatMediaAsset } from "../game/combatMediaPack";
|
||||
@@ -163,6 +164,7 @@ function graphTailMs(graph: OriginalCombatEffectGraph): number {
|
||||
|
||||
function placementHeight(model: string | undefined, event: CombatPresentationEvent): number {
|
||||
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 (/(?:_|\/)head(?:\.|_|$)/.test(normalized)) return 1.72;
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const additive = particle.blend === "no-alpha-add"
|
||||
|| particle.blend === "add"
|
||||
@@ -727,7 +741,9 @@ function LoadedOriginalCombatEffect({
|
||||
const projectile = (event.phase === "release" || (event.phase === "tick" && isPenancePulseVisual(event)))
|
||||
&& event.delivery === "projectile"
|
||||
&& 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
|
||||
? 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"
|
||||
? risingTideImpactPlacement(event, combatEffectTargetVisualSize(event.targetActorId))
|
||||
: 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 (
|
||||
<group
|
||||
key={`${item.asset.effectGraphUrl}:${item.asset.model ?? "model"}`}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { cacheStaticMatrices, instanceRepeatedStaticMeshes } from "./staticScene
|
||||
import { createStaticVisibilityCells, registerStaticVisibility } from "./staticSceneVisibility";
|
||||
import { useGameGLTF } from "./useGameGLTF";
|
||||
import { prepareDungeonMaterial } from "./productionDungeonMaterials";
|
||||
import { repairHallOfSurvivorsVisual } from "./hallOfSurvivorsVisual";
|
||||
import {
|
||||
DUNGEON_COLLISION_LOAD_BATCH_SIZE,
|
||||
DUNGEON_VISUAL_LOAD_BATCH_SIZE,
|
||||
@@ -53,6 +54,7 @@ function prepareVisual(root: Object3D, definition: DungeonDefinition) {
|
||||
instanced.computeBoundingSphere();
|
||||
}
|
||||
});
|
||||
repairHallOfSurvivorsVisual(clone, definition.id);
|
||||
// Scholomance's compact visual has thousands of opaque repetitions. Grouping
|
||||
// them across the level saves draw calls without expanding a large triangle set.
|
||||
instanceRepeatedStaticMeshes(clone, 3, undefined, definition.id === "scholomance");
|
||||
|
||||
@@ -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" }),
|
||||
]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
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")));
|
||||
|
||||
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", () => {
|
||||
expect(graphs.length).toBeGreaterThanOrEqual(378);
|
||||
for (const raw of graphs) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.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 .button { width: min(320px, 100%); }
|
||||
.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) {
|
||||
.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--hub .pause-menu__group { padding: 9px; }
|
||||
.pause-menu--hub .pause-menu__character { margin-top: 0; }
|
||||
|
||||
@@ -12,6 +12,7 @@ import { dungeonBossCards } from "../game/dungeonBossCards";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { DungeonBossCards } from "./DungeonBossCards";
|
||||
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
|
||||
import { dungeonRaceAffix } from "../game/dungeonRaceAffixes";
|
||||
|
||||
function PauseDialog() {
|
||||
const closeOverlay = useGameStore((state) => state.closeOverlay);
|
||||
@@ -19,6 +20,8 @@ function PauseDialog() {
|
||||
const reset = useGameStore((state) => state.resetAtEntrance);
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
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 sessionRevision = useGameStore((state) => state.sessionRevision);
|
||||
const bossTest = useGameStore((state) => state.bossTest);
|
||||
@@ -57,6 +60,7 @@ function PauseDialog() {
|
||||
const dungeonCards = useMemo(() => gameMode === "dungeon"
|
||||
? dungeonBossCards(requireDungeonDefinition(activeDungeonId), activeDifficultyId, bossCards)
|
||||
: [], [activeDungeonId, activeDifficultyId, bossCards, gameMode, sessionRevision]);
|
||||
const raceAffixes = raceAffixIds?.map((id) => dungeonRaceAffix(id)).filter((affix) => affix !== undefined) ?? [];
|
||||
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ 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>
|
||||
{bossTest && <p className="pause-menu__context">Boss Testing · {bossTest.name}</p>}
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("gameplay panel loading integration", () => {
|
||||
});
|
||||
|
||||
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(gameplayResourcesSource).toContain('import("../GameRuntime")');
|
||||
expect(gameplayResourcesSource).toContain('import("./PartyManagementPanel")');
|
||||
|
||||
@@ -62,7 +62,10 @@ export function getLoadedBossGuidePanel() {
|
||||
return loadedBossGuidePanel;
|
||||
}
|
||||
|
||||
export async function preloadSharedGameplayResources(originalMediaEnabled: boolean): Promise<void> {
|
||||
export async function preloadSharedGameplayResources(
|
||||
originalMediaEnabled: boolean,
|
||||
priestCharacter: boolean,
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
loadGameRuntime(),
|
||||
loadPartyManagementPanel(),
|
||||
@@ -73,4 +76,10 @@ export async function preloadSharedGameplayResources(originalMediaEnabled: boole
|
||||
if (!await ensureCombatMediaPack()) return;
|
||||
const { preloadCombatEffectManifest } = await import("../game/combatPresentation");
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||