diff --git a/.gitignore b/.gitignore index 94cb61c..a1706ce 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ tools/*.mjs *.mjs *.cjs +!/mengsan/tools/test-hud-status.cjs +!/mengsan/tools/test-battle-hud.cjs +!/mengsan/tools/import-status-icons.cjs /.update_tmp/ /.planning/ diff --git a/mengsan/assets/cards/README.md b/mengsan/assets/cards/README.md index b9a0e61..03b5d48 100644 --- a/mengsan/assets/cards/README.md +++ b/mengsan/assets/cards/README.md @@ -1,6 +1,6 @@ # 梦三卡牌立绘规范 -2026-10-03:铁甲战士94张已全部接入:新增67、沿用27、暂缓0;另有衍生巨石。68张新增完整5:7水墨立绘使用 `mengsan_ic_*.png`。刘备专属牌组与空无色牌组已分目录和牌组类,详情见 [制作清单](../../../../../sts2-card-capture-20261003/generation/铁甲战士制作清单.md)、[全部立绘预览](../../../../../sts2-card-capture-20261003/generation/铁甲战士立绘预览.md) 及 [牌组结构](../../cards/packs/README.md)。尚未进行实机验收。 +2026-10-03:刘备94张已全部接入:新增67、沿用27、暂缓0;另有衍生巨石。68张新增完整5:7水墨立绘使用 `mengsan_ic_*.png`。刘备专属牌组与空无色牌组已分目录和牌组类,详情见 [制作清单](../../../../../sts2-card-capture-20261003/generation/刘备制作清单.md)、[全部立绘预览](../../../../../sts2-card-capture-20261003/generation/刘备立绘预览.md) 及 [牌组结构](../../cards/packs/README.md)。尚未进行实机验收。 用户于 2026-10-02 指定以截图 `C:\Users\Diuse\Downloads\Ai\49.png` 中“防御”为同系列基准。 diff --git a/mengsan/assets/relics/manifest.json b/mengsan/assets/relics/manifest.json index 7313e11..1cf05a3 100644 --- a/mengsan/assets/relics/manifest.json +++ b/mengsan/assets/relics/manifest.json @@ -3284,7 +3284,7 @@ } ], "notes": [ - "美味饼干使用铁甲战士版本;四个其他角色版本仍保留在来源图集中。", + "美味饼干使用刘备版本;四个其他角色版本仍保留在来源图集中。", "遗忘之魂映射 lost_soul.png;布质果实映射 looming_fruit_2.png。", "图标是用户提供的原游戏拆包图片,逐字节复制,未重绘。" ] diff --git a/mengsan/assets/status/aggression_power.png b/mengsan/assets/status/aggression_power.png new file mode 100644 index 0000000..07fc1fc Binary files /dev/null and b/mengsan/assets/status/aggression_power.png differ diff --git a/mengsan/assets/status/artifact_power.png b/mengsan/assets/status/artifact_power.png new file mode 100644 index 0000000..70a8b0d Binary files /dev/null and b/mengsan/assets/status/artifact_power.png differ diff --git a/mengsan/assets/status/automation_power.png b/mengsan/assets/status/automation_power.png new file mode 100644 index 0000000..9ccb8db Binary files /dev/null and b/mengsan/assets/status/automation_power.png differ diff --git a/mengsan/assets/status/barricade_power.png b/mengsan/assets/status/barricade_power.png new file mode 100644 index 0000000..76bd9d5 Binary files /dev/null and b/mengsan/assets/status/barricade_power.png differ diff --git a/mengsan/assets/status/beacon_of_hope_power.png b/mengsan/assets/status/beacon_of_hope_power.png new file mode 100644 index 0000000..51a3ac3 Binary files /dev/null and b/mengsan/assets/status/beacon_of_hope_power.png differ diff --git a/mengsan/assets/status/block_next_turn_power.png b/mengsan/assets/status/block_next_turn_power.png new file mode 100644 index 0000000..a20076a Binary files /dev/null and b/mengsan/assets/status/block_next_turn_power.png differ diff --git a/mengsan/assets/status/calamity_power.png b/mengsan/assets/status/calamity_power.png new file mode 100644 index 0000000..e716d14 Binary files /dev/null and b/mengsan/assets/status/calamity_power.png differ diff --git a/mengsan/assets/status/colossus_power.png b/mengsan/assets/status/colossus_power.png new file mode 100644 index 0000000..fc19ba7 Binary files /dev/null and b/mengsan/assets/status/colossus_power.png differ diff --git a/mengsan/assets/status/constrict_power.png b/mengsan/assets/status/constrict_power.png new file mode 100644 index 0000000..9b1f4a4 Binary files /dev/null and b/mengsan/assets/status/constrict_power.png differ diff --git a/mengsan/assets/status/corruption_power.png b/mengsan/assets/status/corruption_power.png new file mode 100644 index 0000000..cad52d1 Binary files /dev/null and b/mengsan/assets/status/corruption_power.png differ diff --git a/mengsan/assets/status/crimson_mantle_power.png b/mengsan/assets/status/crimson_mantle_power.png new file mode 100644 index 0000000..c294e7b Binary files /dev/null and b/mengsan/assets/status/crimson_mantle_power.png differ diff --git a/mengsan/assets/status/curious_power.png b/mengsan/assets/status/curious_power.png new file mode 100644 index 0000000..4b7a060 Binary files /dev/null and b/mengsan/assets/status/curious_power.png differ diff --git a/mengsan/assets/status/dark_embrace_power.png b/mengsan/assets/status/dark_embrace_power.png new file mode 100644 index 0000000..ff8dd8b Binary files /dev/null and b/mengsan/assets/status/dark_embrace_power.png differ diff --git a/mengsan/assets/status/demesne_power.png b/mengsan/assets/status/demesne_power.png new file mode 100644 index 0000000..c0f5440 Binary files /dev/null and b/mengsan/assets/status/demesne_power.png differ diff --git a/mengsan/assets/status/demon_form_power.png b/mengsan/assets/status/demon_form_power.png new file mode 100644 index 0000000..63c627e Binary files /dev/null and b/mengsan/assets/status/demon_form_power.png differ diff --git a/mengsan/assets/status/dexterity_power.png b/mengsan/assets/status/dexterity_power.png new file mode 100644 index 0000000..52b7632 Binary files /dev/null and b/mengsan/assets/status/dexterity_power.png differ diff --git a/mengsan/assets/status/draw_cards_next_turn_power.png b/mengsan/assets/status/draw_cards_next_turn_power.png new file mode 100644 index 0000000..86ac4ae Binary files /dev/null and b/mengsan/assets/status/draw_cards_next_turn_power.png differ diff --git a/mengsan/assets/status/energy_next_turn_power.png b/mengsan/assets/status/energy_next_turn_power.png new file mode 100644 index 0000000..4ed3633 Binary files /dev/null and b/mengsan/assets/status/energy_next_turn_power.png differ diff --git a/mengsan/assets/status/entropy_power.png b/mengsan/assets/status/entropy_power.png new file mode 100644 index 0000000..fa46d1c Binary files /dev/null and b/mengsan/assets/status/entropy_power.png differ diff --git a/mengsan/assets/status/fasten_power.png b/mengsan/assets/status/fasten_power.png new file mode 100644 index 0000000..3114d6b Binary files /dev/null and b/mengsan/assets/status/fasten_power.png differ diff --git a/mengsan/assets/status/feel_no_pain_power.png b/mengsan/assets/status/feel_no_pain_power.png new file mode 100644 index 0000000..3163e95 Binary files /dev/null and b/mengsan/assets/status/feel_no_pain_power.png differ diff --git a/mengsan/assets/status/flame_barrier_power.png b/mengsan/assets/status/flame_barrier_power.png new file mode 100644 index 0000000..c7594ca Binary files /dev/null and b/mengsan/assets/status/flame_barrier_power.png differ diff --git a/mengsan/assets/status/frail_power.png b/mengsan/assets/status/frail_power.png new file mode 100644 index 0000000..296b031 Binary files /dev/null and b/mengsan/assets/status/frail_power.png differ diff --git a/mengsan/assets/status/free_attack_power.png b/mengsan/assets/status/free_attack_power.png new file mode 100644 index 0000000..fd31236 Binary files /dev/null and b/mengsan/assets/status/free_attack_power.png differ diff --git a/mengsan/assets/status/hellraiser_power.png b/mengsan/assets/status/hellraiser_power.png new file mode 100644 index 0000000..2f33d96 Binary files /dev/null and b/mengsan/assets/status/hellraiser_power.png differ diff --git a/mengsan/assets/status/inferno_power.png b/mengsan/assets/status/inferno_power.png new file mode 100644 index 0000000..b499cf3 Binary files /dev/null and b/mengsan/assets/status/inferno_power.png differ diff --git a/mengsan/assets/status/infested_power.png b/mengsan/assets/status/infested_power.png new file mode 100644 index 0000000..ed9c798 Binary files /dev/null and b/mengsan/assets/status/infested_power.png differ diff --git a/mengsan/assets/status/intangible_power.png b/mengsan/assets/status/intangible_power.png new file mode 100644 index 0000000..47fce03 Binary files /dev/null and b/mengsan/assets/status/intangible_power.png differ diff --git a/mengsan/assets/status/juggernaut_power.png b/mengsan/assets/status/juggernaut_power.png new file mode 100644 index 0000000..1517c31 Binary files /dev/null and b/mengsan/assets/status/juggernaut_power.png differ diff --git a/mengsan/assets/status/juggling_power.png b/mengsan/assets/status/juggling_power.png new file mode 100644 index 0000000..4c9c341 Binary files /dev/null and b/mengsan/assets/status/juggling_power.png differ diff --git a/mengsan/assets/status/manifest.json b/mengsan/assets/status/manifest.json new file mode 100644 index 0000000..3e9d245 --- /dev/null +++ b/mengsan/assets/status/manifest.json @@ -0,0 +1,427 @@ +{ + "sourceVersion": "v0.111.0", + "source": "images/atlases/power_atlas.sprites", + "entries": [ + { + "name": "strength_power.png", + "bytes": 3435, + "width": 64, + "height": 64, + "sha256": "6584c67190bfd47255c0f333bfb7f2ab52720209d94ce9324a30b0a8e638d7ff" + }, + { + "name": "ringing_power.png", + "bytes": 5038, + "width": 64, + "height": 64, + "sha256": "c4a3d09f976788bca91d3a5011fc0f05c3f981eaa3482a33c31a0a661705f60c" + }, + { + "name": "slow_power.png", + "bytes": 5762, + "width": 64, + "height": 63, + "sha256": "b39d7af28b0fdcd773a44577a6635c8b23dc03bff6280de841dc831fe6c601b0" + }, + { + "name": "the_bomb_power.png", + "bytes": 4421, + "width": 64, + "height": 64, + "sha256": "862f05ed56d4d0ebd60afd777162d23fb4744991f6cace9e43640bee29f5db58" + }, + { + "name": "vigor_power.png", + "bytes": 5912, + "width": 64, + "height": 64, + "sha256": "551b7b93d139f30cf190887ea3eef197aa693a7a78e67aef48ef2b582b1532b3" + }, + { + "name": "draw_cards_next_turn_power.png", + "bytes": 3178, + "width": 58, + "height": 64, + "sha256": "c0a8c2781aff2e5c6bf73a2dd2938ef820a5fde651757954f5219113cf88ea22" + }, + { + "name": "energy_next_turn_power.png", + "bytes": 2896, + "width": 43, + "height": 64, + "sha256": "511314b05d5c223613b6d654816f7feb5607e7c40b0487207c5b1a75f5002f67" + }, + { + "name": "block_next_turn_power.png", + "bytes": 4275, + "width": 64, + "height": 64, + "sha256": "fd5c75f11f5719f9ffb52ceda3640cbc684ec178e783a3ceb2774548df1615f8" + }, + { + "name": "vulnerable_power.png", + "bytes": 3725, + "width": 64, + "height": 64, + "sha256": "1b9a9336dd7828cb1fb61b119ab765d3c713ce4e0e98bdbee7211724fe5c78b6" + }, + { + "name": "weak_power.png", + "bytes": 4738, + "width": 64, + "height": 64, + "sha256": "f8f4277c9fd848600a503b8d377bf9e83b4eaa06fa27f8f82ebefcfbd9714763" + }, + { + "name": "frail_power.png", + "bytes": 3945, + "width": 63, + "height": 64, + "sha256": "d772bf035190a650a9be735282c77ee9ab2276a775a9e7079710c8b09c4a4619" + }, + { + "name": "artifact_power.png", + "bytes": 5717, + "width": 64, + "height": 64, + "sha256": "2346d2425fd40cc5f8532df0f97afa9bbb782fa649a09bcb014fe6932e845f90" + }, + { + "name": "constrict_power.png", + "bytes": 4543, + "width": 64, + "height": 64, + "sha256": "5f29c6b3f8955e275d98f9ed47a893c1214a7bfb820ce1c696c9f16fddcbd8b4" + }, + { + "name": "tangled_power.png", + "bytes": 5754, + "width": 64, + "height": 64, + "sha256": "3d4d4686876ed0265f26696ab2d0ebde8366b0537994bf223bc388a0f104ec5e" + }, + { + "name": "shrink_power.png", + "bytes": 4258, + "width": 64, + "height": 64, + "sha256": "b19a0d045135bcc6cfa00f2fb11be78dba68f16ecc12f2ca8474caa9327c05dd" + }, + { + "name": "plow_power.png", + "bytes": 5865, + "width": 64, + "height": 64, + "sha256": "dc300ee907b06d300bcd01f2c2784cc06c761796206a090e1a94f0cecfa95a31" + }, + { + "name": "slippery_power.png", + "bytes": 4722, + "width": 64, + "height": 64, + "sha256": "06b4ac80cbf1f70c625be827047be79302f72c018cf8368bfb313a27744e568b" + }, + { + "name": "territorial_power.png", + "bytes": 3829, + "width": 64, + "height": 64, + "sha256": "cf1b1d401a6fd45dbb1f3eda64049edf8cdf7e91a55f3aaf7a10f138d54699dd" + }, + { + "name": "infested_power.png", + "bytes": 3554, + "width": 64, + "height": 50, + "sha256": "d6cd52278e0444a8f65fdc7d3c19b7ff9dc7067e2c1d21509f891238bae234f7" + }, + { + "name": "demon_form_power.png", + "bytes": 4728, + "width": 64, + "height": 64, + "sha256": "2e183f449fc07c30356d181a1b1a5814aad2ef077598642a35899f4d8cdc3bc3" + }, + { + "name": "demesne_power.png", + "bytes": 5817, + "width": 64, + "height": 63, + "sha256": "554fff2e62aaeaee7b1c9cc1c8283257139ce901a5191c39d143b757cd865e96" + }, + { + "name": "crimson_mantle_power.png", + "bytes": 4706, + "width": 64, + "height": 61, + "sha256": "7fdeb8f49fb6bfb3ef5f4ff52ec8bcad18bf8fd1e0357d9378ac657124cb8e23" + }, + { + "name": "inferno_power.png", + "bytes": 5996, + "width": 64, + "height": 64, + "sha256": "3775622f4ad2419ed897bb90e56d793fb383c1810bae514d3e9c17eaff53fe69" + }, + { + "name": "rupture_power.png", + "bytes": 3897, + "width": 64, + "height": 64, + "sha256": "101e3b473ec6d6b565aa94ed5946840f6c013f28e62f3f0961106c2c8cd54d95" + }, + { + "name": "vicious_power.png", + "bytes": 3247, + "width": 64, + "height": 62, + "sha256": "4b90144a9254bb155b1cce8180d91d919af99566b8841e2135eb86880ff0765a" + }, + { + "name": "rage_power.png", + "bytes": 4937, + "width": 64, + "height": 64, + "sha256": "a53721daa6996403e61e871aca8152ae50372c3255e509f1a6d4d99bbfd01d02" + }, + { + "name": "flame_barrier_power.png", + "bytes": 4786, + "width": 64, + "height": 64, + "sha256": "48a7543a8f87c8b360a3a23a67df23b51b4a54eb6f7747cc27c9f4a230610cdf" + }, + { + "name": "colossus_power.png", + "bytes": 3875, + "width": 64, + "height": 64, + "sha256": "cfe2be38931e40856d5388b6e3b8c86a7ee2de6688ab69294f348d383e1dfb7d" + }, + { + "name": "free_attack_power.png", + "bytes": 4223, + "width": 61, + "height": 64, + "sha256": "5bc861f0693c0b73e95be4a2faebd8d81441e1011004f7a0ff71ced6f913573b" + }, + { + "name": "no_draw_power.png", + "bytes": 4335, + "width": 58, + "height": 64, + "sha256": "7f8ac6cae6cc5611d7e8217619de9cc87dcef245e19bc70107bc299da790bed0" + }, + { + "name": "no_energy_gain_power.png", + "bytes": 6746, + "width": 64, + "height": 64, + "sha256": "c31490f89b758bd63564581218ede73d89438d833ece8d8da5a8f12f773a345d" + }, + { + "name": "one_two_punch_power.png", + "bytes": 3986, + "width": 64, + "height": 64, + "sha256": "458f05559a4efcd79bcbe0fb7e1d9aa4ae47d3a0c533f11e1a72ed3e67aec78b" + }, + { + "name": "plating_power.png", + "bytes": 5036, + "width": 64, + "height": 64, + "sha256": "10a27dd11231338120274c8b132ec82187b2cc00f81d0f7fa94e3637f4506b67" + }, + { + "name": "feel_no_pain_power.png", + "bytes": 4244, + "width": 64, + "height": 64, + "sha256": "92359b3a53e10600e864a502291be3b50cf00c1792dbf5203e8eaac09fab126e" + }, + { + "name": "dark_embrace_power.png", + "bytes": 5545, + "width": 64, + "height": 64, + "sha256": "f60e5524cf10b4da15f1b788a58e65e2765aa928988100d6fcb6012829520d99" + }, + { + "name": "juggling_power.png", + "bytes": 4512, + "width": 64, + "height": 64, + "sha256": "583f560f68a3b9df4ef60376b919ff51d85d862076f0ce09848644f3171d51af" + }, + { + "name": "stampede_power.png", + "bytes": 3357, + "width": 64, + "height": 64, + "sha256": "1aea977a121c451ef134581789f629dc61caba87b771e7bd684345baae573d03" + }, + { + "name": "aggression_power.png", + "bytes": 4524, + "width": 64, + "height": 64, + "sha256": "9ec47f0a5eb259c1cc131b82e6bb9e22343df7f64c7375db7580eb326b6a97f3" + }, + { + "name": "tank_power.png", + "bytes": 3191, + "width": 64, + "height": 64, + "sha256": "f8cfbc1bbf8832f4e83ffdf4172bfc37564c85d36a4a5c14d18c1ab60bd1b76e" + }, + { + "name": "hellraiser_power.png", + "bytes": 4296, + "width": 64, + "height": 64, + "sha256": "38ca8fc4c727fb936d057a78bfb9461a39b0266dd794d062e9d278505821efc6" + }, + { + "name": "unmovable_power.png", + "bytes": 4784, + "width": 64, + "height": 64, + "sha256": "f99e4f21fcfd6e57089d47e4bc4ed1cf1a813a759f76487c38852d66dc472a19" + }, + { + "name": "juggernaut_power.png", + "bytes": 5096, + "width": 57, + "height": 64, + "sha256": "c0c9382989b31f246678d1d33ecb3f17f3b75a97aa9a752dd1f6551733d76112" + }, + { + "name": "barricade_power.png", + "bytes": 2828, + "width": 64, + "height": 60, + "sha256": "1467ded14bfef8cdc15152e62a100ef7d3be85173ac5eea2f6960d4815d5c64c" + }, + { + "name": "corruption_power.png", + "bytes": 4822, + "width": 64, + "height": 64, + "sha256": "8228d730978170f60d9c429136a395b3de59b5c98726912812f708ec803836eb" + }, + { + "name": "dexterity_power.png", + "bytes": 3073, + "width": 63, + "height": 64, + "sha256": "6609f4d04f4613ba0001b6254a793ddd2c25a60e66a320ff31ef6724ef15f20e" + }, + { + "name": "no_block_power.png", + "bytes": 4592, + "width": 63, + "height": 64, + "sha256": "469f70ce5a86ea24751ef75461d20ec127014e16495ee23a86ca0255b4246d4b" + }, + { + "name": "intangible_power.png", + "bytes": 4398, + "width": 64, + "height": 64, + "sha256": "0368e806c412ca290796c58e1e1a96e6d84bd5dda67f39249433e688006eaf1f" + }, + { + "name": "the_gambit_power.png", + "bytes": 3051, + "width": 49, + "height": 64, + "sha256": "204284c28d48c60a8c73f7356bb3d5aa0fc8e4b44491cb1aec19717bb4f04784" + }, + { + "name": "retain_hand_power.png", + "bytes": 4225, + "width": 64, + "height": 64, + "sha256": "a8ee3da12b0774ffc9bba33d6da7695afdfc9b7904d31b3229df077489541784" + }, + { + "name": "panache_power.png", + "bytes": 3489, + "width": 55, + "height": 64, + "sha256": "61dd7bda14317085c4f6cb4ccecebc759b5468c5494d16fb1a2d78c5f3fd59e0" + }, + { + "name": "fasten_power.png", + "bytes": 4680, + "width": 64, + "height": 64, + "sha256": "13518361d51775876bf4cde87e88cc4a9e63a5a1d14ba49388eb12d57404bbe7" + }, + { + "name": "prep_time_power.png", + "bytes": 6445, + "width": 64, + "height": 63, + "sha256": "ce1698909dcb2e91bd67714c11f7c467ae083326b7f9f239751a4c202d50bf69" + }, + { + "name": "automation_power.png", + "bytes": 4452, + "width": 64, + "height": 64, + "sha256": "c7b5b35579dc1b2ff209e4be54a1982381b1096c640a3c21bb5c9e6aeb86a08f" + }, + { + "name": "curious_power.png", + "bytes": 4106, + "width": 57, + "height": 64, + "sha256": "77cb0e309fc148e2b2bb723fa5a227d4dc3c2503e22421b9d0e457fb3a85c7d7" + }, + { + "name": "rolling_boulder_power.png", + "bytes": 4422, + "width": 64, + "height": 64, + "sha256": "53c820c23f09cd07d2b0e3bd00493bdaef81fbd09d05bf2d15485c3c1ef7e4a0" + }, + { + "name": "nostalgia_power.png", + "bytes": 3856, + "width": 64, + "height": 64, + "sha256": "836674ba8a9e8d0ea2263e90944acdf7078dc1f6c835771650bfcf84825d84a4" + }, + { + "name": "entropy_power.png", + "bytes": 4849, + "width": 64, + "height": 64, + "sha256": "5042bd71bd07fd605cba2c39677dd21df704439ed5d0ad9610f1102d8ed782ad" + }, + { + "name": "beacon_of_hope_power.png", + "bytes": 5248, + "width": 64, + "height": 64, + "sha256": "0d33f9098425c3d740e699926809c0c7c9121cdd05e52508284a1e34d7b7bd8d" + }, + { + "name": "mayhem_power.png", + "bytes": 5287, + "width": 64, + "height": 64, + "sha256": "354b3fe4b63c064cc96302d46b27829f6d51fd37e703aec15d53ed53a14e2d51" + }, + { + "name": "calamity_power.png", + "bytes": 5331, + "width": 64, + "height": 64, + "sha256": "8d510b86daa72a8066729bc5d0a3943acf5c77903be69525f325b65607c7cdd7" + } + ], + "missing": [] +} diff --git a/mengsan/assets/status/mayhem_power.png b/mengsan/assets/status/mayhem_power.png new file mode 100644 index 0000000..34a3f10 Binary files /dev/null and b/mengsan/assets/status/mayhem_power.png differ diff --git a/mengsan/assets/status/no_block_power.png b/mengsan/assets/status/no_block_power.png new file mode 100644 index 0000000..7724489 Binary files /dev/null and b/mengsan/assets/status/no_block_power.png differ diff --git a/mengsan/assets/status/no_draw_power.png b/mengsan/assets/status/no_draw_power.png new file mode 100644 index 0000000..bc9a526 Binary files /dev/null and b/mengsan/assets/status/no_draw_power.png differ diff --git a/mengsan/assets/status/no_energy_gain_power.png b/mengsan/assets/status/no_energy_gain_power.png new file mode 100644 index 0000000..e3e68f2 Binary files /dev/null and b/mengsan/assets/status/no_energy_gain_power.png differ diff --git a/mengsan/assets/status/nostalgia_power.png b/mengsan/assets/status/nostalgia_power.png new file mode 100644 index 0000000..eb9eae7 Binary files /dev/null and b/mengsan/assets/status/nostalgia_power.png differ diff --git a/mengsan/assets/status/one_two_punch_power.png b/mengsan/assets/status/one_two_punch_power.png new file mode 100644 index 0000000..8d5e310 Binary files /dev/null and b/mengsan/assets/status/one_two_punch_power.png differ diff --git a/mengsan/assets/status/panache_power.png b/mengsan/assets/status/panache_power.png new file mode 100644 index 0000000..1c7bb49 Binary files /dev/null and b/mengsan/assets/status/panache_power.png differ diff --git a/mengsan/assets/status/plating_power.png b/mengsan/assets/status/plating_power.png new file mode 100644 index 0000000..0c846c2 Binary files /dev/null and b/mengsan/assets/status/plating_power.png differ diff --git a/mengsan/assets/status/plow_power.png b/mengsan/assets/status/plow_power.png new file mode 100644 index 0000000..7328393 Binary files /dev/null and b/mengsan/assets/status/plow_power.png differ diff --git a/mengsan/assets/status/prep_time_power.png b/mengsan/assets/status/prep_time_power.png new file mode 100644 index 0000000..ca93c4f Binary files /dev/null and b/mengsan/assets/status/prep_time_power.png differ diff --git a/mengsan/assets/status/rage_power.png b/mengsan/assets/status/rage_power.png new file mode 100644 index 0000000..aece28d Binary files /dev/null and b/mengsan/assets/status/rage_power.png differ diff --git a/mengsan/assets/status/retain_hand_power.png b/mengsan/assets/status/retain_hand_power.png new file mode 100644 index 0000000..cc1ca12 Binary files /dev/null and b/mengsan/assets/status/retain_hand_power.png differ diff --git a/mengsan/assets/status/ringing_power.png b/mengsan/assets/status/ringing_power.png new file mode 100644 index 0000000..09d16ea Binary files /dev/null and b/mengsan/assets/status/ringing_power.png differ diff --git a/mengsan/assets/status/rolling_boulder_power.png b/mengsan/assets/status/rolling_boulder_power.png new file mode 100644 index 0000000..cdb1d94 Binary files /dev/null and b/mengsan/assets/status/rolling_boulder_power.png differ diff --git a/mengsan/assets/status/rupture_power.png b/mengsan/assets/status/rupture_power.png new file mode 100644 index 0000000..bf013b4 Binary files /dev/null and b/mengsan/assets/status/rupture_power.png differ diff --git a/mengsan/assets/status/shrink_power.png b/mengsan/assets/status/shrink_power.png new file mode 100644 index 0000000..5f9eebc Binary files /dev/null and b/mengsan/assets/status/shrink_power.png differ diff --git a/mengsan/assets/status/slippery_power.png b/mengsan/assets/status/slippery_power.png new file mode 100644 index 0000000..5acc3b6 Binary files /dev/null and b/mengsan/assets/status/slippery_power.png differ diff --git a/mengsan/assets/status/slow_power.png b/mengsan/assets/status/slow_power.png new file mode 100644 index 0000000..19515a3 Binary files /dev/null and b/mengsan/assets/status/slow_power.png differ diff --git a/mengsan/assets/status/stampede_power.png b/mengsan/assets/status/stampede_power.png new file mode 100644 index 0000000..f457b6e Binary files /dev/null and b/mengsan/assets/status/stampede_power.png differ diff --git a/mengsan/assets/status/strength_power.png b/mengsan/assets/status/strength_power.png new file mode 100644 index 0000000..6280c84 Binary files /dev/null and b/mengsan/assets/status/strength_power.png differ diff --git a/mengsan/assets/status/tangled_power.png b/mengsan/assets/status/tangled_power.png new file mode 100644 index 0000000..dd016a3 Binary files /dev/null and b/mengsan/assets/status/tangled_power.png differ diff --git a/mengsan/assets/status/tank_power.png b/mengsan/assets/status/tank_power.png new file mode 100644 index 0000000..de1dc7d Binary files /dev/null and b/mengsan/assets/status/tank_power.png differ diff --git a/mengsan/assets/status/territorial_power.png b/mengsan/assets/status/territorial_power.png new file mode 100644 index 0000000..3d07163 Binary files /dev/null and b/mengsan/assets/status/territorial_power.png differ diff --git a/mengsan/assets/status/the_bomb_power.png b/mengsan/assets/status/the_bomb_power.png new file mode 100644 index 0000000..f9ecdfa Binary files /dev/null and b/mengsan/assets/status/the_bomb_power.png differ diff --git a/mengsan/assets/status/the_gambit_power.png b/mengsan/assets/status/the_gambit_power.png new file mode 100644 index 0000000..7b286bf Binary files /dev/null and b/mengsan/assets/status/the_gambit_power.png differ diff --git a/mengsan/assets/status/unmovable_power.png b/mengsan/assets/status/unmovable_power.png new file mode 100644 index 0000000..5214256 Binary files /dev/null and b/mengsan/assets/status/unmovable_power.png differ diff --git a/mengsan/assets/status/vicious_power.png b/mengsan/assets/status/vicious_power.png new file mode 100644 index 0000000..f2016c5 Binary files /dev/null and b/mengsan/assets/status/vicious_power.png differ diff --git a/mengsan/assets/status/vigor_power.png b/mengsan/assets/status/vigor_power.png new file mode 100644 index 0000000..eb0520f Binary files /dev/null and b/mengsan/assets/status/vigor_power.png differ diff --git a/mengsan/assets/status/vulnerable_power.png b/mengsan/assets/status/vulnerable_power.png new file mode 100644 index 0000000..42790a2 Binary files /dev/null and b/mengsan/assets/status/vulnerable_power.png differ diff --git a/mengsan/assets/status/weak_power.png b/mengsan/assets/status/weak_power.png new file mode 100644 index 0000000..c53e3b7 Binary files /dev/null and b/mengsan/assets/status/weak_power.png differ diff --git a/mengsan/battle/status-sources.js b/mengsan/battle/status-sources.js new file mode 100644 index 0000000..54b9a4a --- /dev/null +++ b/mengsan/battle/status-sources.js @@ -0,0 +1,26 @@ +// Display-only readers. Entries belong to a battle session, never to the run/save. +const sources = new WeakMap(); + +export function bindCombatStatusSource(player, session, id, read) { + if (!player || !session?.active || typeof read !== 'function') return; + let group = sources.get(player); + if (!group) sources.set(player, group = new Map()); + const entry = { session, read }; + group.set(id, entry); + session.ownResource(entry, () => { + if (group.get(id) !== entry) return; + group.delete(id); + if (!group.size && sources.get(player) === group) sources.delete(player); + }); +} + +export function readCombatStatusSources(player, session) { + if (!session?.active) return []; + const result = []; + for (const [id, entry] of sources.get(player) || []) { + if (entry.session !== session) continue; + const state = entry.read(); + if (state) result.push({ id, state }); + } + return result; +} diff --git a/mengsan/cards/packs/README.md b/mengsan/cards/packs/README.md index 896be5d..1195514 100644 --- a/mengsan/cards/packs/README.md +++ b/mengsan/cards/packs/README.md @@ -1,6 +1,6 @@ # 角色牌组 -当前铁甲战士主表 94 张:新增 67 张、沿用 27 张;另有衍生牌【巨石】。专属牌归刘备,基础打击/痛击分别沿用杀/重杀。所有正常获得入口沿用 `canAcquireCard` 的角色检查。杀、重杀保留原有通用牌归属,并纳入刘备的随机生成牌池。旧存档的独立打击/痛击转换为杀/重杀,保留实例ID、强化和词缀;待结算候选保留原ID以避免冲突。 +当前刘备主表 94 张:新增 67 张、沿用 27 张;另有衍生牌【巨石】。专属牌归刘备,基础打击/痛击分别沿用杀/重杀。所有正常获得入口沿用 `canAcquireCard` 的角色检查。杀、重杀保留原有通用牌归属,并纳入刘备的随机生成牌池。旧存档的独立打击/痛击转换为杀/重杀,保留实例ID、强化和词缀;待结算候选保留原ID以避免冲突。 | 目录/文件 | 职责 | | --- | --- | diff --git a/mengsan/cards/packs/liubei/advanced.js b/mengsan/cards/packs/liubei/advanced.js index 10aec3e..648b5ad 100644 --- a/mengsan/cards/packs/liubei/advanced.js +++ b/mengsan/cards/packs/liubei/advanced.js @@ -36,6 +36,8 @@ const originalOf = event => { return use?.mengsanIcRepeatOriginal_shuying || use?.cards?.find(c=>c.name===use.card?.name) || event.card; }; +import { bindCombatStatusSource } from "../../../battle/status-sources.js"; + export function createAdvancedIronclad({game,get,lib,status,getBattle,battleEnergy,random}) { const states=new WeakMap(), uses=new WeakMap(), hits=new WeakMap(), growth=new WeakMap(); const seenUses=new WeakSet(), seenBlocks=new WeakSet(), seenDraws=new WeakSet(); @@ -78,6 +80,7 @@ export function createAdvancedIronclad({game,get,lib,status,getBattle,battleEner s={player,token,powers:Object.create(null),turn:Object.create(null),queue:[],flushing:false, autoQueue:[],autoBusy:false,freeCards:new WeakSet(),negativeStrength:0,autoCount:0}; states.set(player,s); player.addSkill(MARK); + bindCombatStatusSource(player,token.session,"ironclad-advanced",()=>stateOf(player)); token.session.ownResource(s,()=>{if(states.get(player)===s){states.delete(player);player.removeSkill(MARK);s.queue.length=0;s.autoQueue.length=0;}}); bindIroncladHooks(player,token.session,{ valid:()=>valid(player,token), @@ -444,7 +447,7 @@ export function createAdvancedIronclad({game,get,lib,status,getBattle,battleEner if(r.attackDiscount)parts.push("本回合每打出一张攻击牌,费用减少1。"); if(r.draw)parts.push(`抽${r.draw}张牌。`); if(r.stopDraw)parts.push("本回合不能再抽牌。"); - if(r.generateAttack)parts.push("随机生成一张铁甲战士攻击牌至手牌,本回合费用为0。"); + if(r.generateAttack)parts.push("随机生成一张刘备攻击牌至手牌,本回合费用为0。"); if(r.exhaustEnergy)parts.push(`被消耗时获得${r.exhaustEnergy}费用。`); if(r.energyPerAttack)parts.push("每张攻击手牌获得1费用。"); if(r.stopEnergy)parts.push("本回合不能再获得费用。"); @@ -461,7 +464,7 @@ export function createAdvancedIronclad({game,get,lib,status,getBattle,battleEner if(r.topPlays)parts.push(`免费打出抽牌堆顶的X${r.xBonus?"+1":""}张牌。`); if(r.transformAttacks)parts.push(`所有攻击手牌变为巨石${r.boulderUpgrade?"+":""},仅本场战斗。`); if(r.doubleTap)parts.push(`本回合接下来的${r.doubleTap}张攻击牌额外打出一次。`); - if(r.stoke)parts.push(`消耗所有手牌,每张随机生成一张${r.generatedUpgrade?"已强化":""}铁甲战士或无色牌至手牌。`); + if(r.stoke)parts.push(`消耗所有手牌,每张随机生成一张${r.generatedUpgrade?"已强化":""}刘备或无色牌至手牌。`); if(r.retrieveAttack)parts.push("自身回合开始,从弃牌堆随机获得一张攻击牌并强化。"); if(r.bodyguard)parts.push("你承受敌人双倍伤害,敌人对其他友方的伤害减半。"); if(r.autoStrike)parts.push("抽到【杀】或名称含“打击”的攻击牌时,免费向随机敌人打出。"); diff --git a/mengsan/cards/packs/liubei/data.js b/mengsan/cards/packs/liubei/data.js index 668ef54..209010c 100644 --- a/mengsan/cards/packs/liubei/data.js +++ b/mengsan/cards/packs/liubei/data.js @@ -1,4 +1,4 @@ -// 2026-10-03 灰机Wiki铁甲战士详情页主表;所有值均为当前状态。 +// 2026-10-03 灰机Wiki卡牌详情页主表;本作品中归刘备专属牌组。 // 纯数据模块可被费用、升级、注册表及运行时共同导入,不依赖引擎。 import { ironcladAdvancedEntries } from "./advanced-data.js"; import { liubeiCardTraits } from "./traits.js"; diff --git a/mengsan/cards/packs/liubei/runtime.js b/mengsan/cards/packs/liubei/runtime.js index c5d56c9..2de49ab 100644 --- a/mengsan/cards/packs/liubei/runtime.js +++ b/mengsan/cards/packs/liubei/runtime.js @@ -4,6 +4,7 @@ import { canPayCard, cardCost, isActiveCardUse } from "../../../battle/combat-ru import { isAttackCard } from "../../../monsters/vine-tangled.js"; import { applyMengsanDebuff } from "../../../monsters/artifact-status.js"; import { createAdvancedIronclad } from "./advanced.js"; +import { bindCombatStatusSource } from "../../../battle/status-sources.js"; const POWER_SKILL = "mengsan_ic_powers_shuying"; const belongsTo = (event, root) => { @@ -42,12 +43,13 @@ export function createIroncladRuntime({ game, get, lib, status, getBattle, handE player.removeSkill(POWER_SKILL); }); states.set(player, s); + bindCombatStatusSource(player, token.session, "ironclad", () => stateOf(player)); player.addSkill(POWER_SKILL); return s; }; const add = (object, key, n) => { const next = (object[key] || 0) + n; - if (!Number.isFinite(next) || next < 0 || next > Number.MAX_SAFE_INTEGER) throw new RangeError("铁甲战士效果数值越界"); + if (!Number.isFinite(next) || next < 0 || next > Number.MAX_SAFE_INTEGER) throw new RangeError("刘备效果数值越界"); object[key] = next; }; const strength = (player, n, token) => { @@ -77,7 +79,7 @@ export function createIroncladRuntime({ game, get, lib, status, getBattle, handE }; const exhaustHands = async (player, event, token, filter = () => true, afterEach = null) => { const pile = pileOf(player, token); - if (typeof pile?.exhaustFromHand !== "function") throw new Error("铁甲战士缺少个人消耗接口"); + if (typeof pile?.exhaustFromHand !== "function") throw new Error("刘备缺少个人消耗接口"); const playing = new Set(event.cards || []); const cards = player.getCards("h").filter(c => !playing.has(c) && filter(c)); let count = 0; @@ -191,7 +193,7 @@ export function createIroncladRuntime({ game, get, lib, status, getBattle, handE if (r.hitsPerExhaust) hits = await exhaustHands(player, event, token); else if (r.vulnerableHits && target.storage?.mengsanVulnerable_shuying > 0) hits = r.vulnerableHits; const amount = (r.damage || 0) + (r.perExhaust ? r.perExhaust * exhaustCount(player, token) : 0); - if (!positive(amount) || !positive(hits)) throw new RangeError("铁甲战士攻击数值越界"); + if (!positive(amount) || !positive(hits)) throw new RangeError("刘备攻击数值越界"); if (amount > 0) for (let i = 0; i < hits && valid(player, token); i++) { const targets = r.all ? [...token.battle.players].filter(p => p !== player && p.isAlive() && player.isEnemyOf(p)) : [target]; for (const t of targets) { @@ -260,7 +262,7 @@ export function createIroncladRuntime({ game, get, lib, status, getBattle, handE mark: true, marktext: "战", intro: { content(storage, player) { const s = stateOf(player); - if (!s) return "本场战斗的铁甲战士效果"; + if (!s) return "本场战斗的刘备效果"; const names = { turnStrength: "每回合力量", turnEnergy: "每回合费用", turnBlock: "每回合格挡", turnLoseHp: "披风每回合失血", infernoLoseHp: "狱火每回合失血", lossDamage: "失血全体伤害", lossStrength: "失血获得力量", vulnerableBonus: "易伤倍率加成", attackBlock: "攻击获得格挡", retaliation: "反击伤害", colossus: "巨像减伤" }; return Object.entries({ ...s.powers, ...s.turn, ...s.defense }).map(([k, v]) => `${names[k] || k}:${v === true ? "50%" : k === "vulnerableBonus" ? `${v * 100}%` : v}`).join(""); } }, diff --git a/mengsan/cards/packs/shared/runtime.js b/mengsan/cards/packs/shared/runtime.js index 6889913..525dece 100644 --- a/mengsan/cards/packs/shared/runtime.js +++ b/mengsan/cards/packs/shared/runtime.js @@ -13,6 +13,7 @@ import { applyMengsanDebuff, consumeArtifact } from "../../../monsters/artifact- import { isStunned } from "../../../battle/stun-intent.js"; import { canPayCard, isActiveCardUse, authorizeFreeCardUse, takeXCardUse, cardCost } from "../../../battle/combat-rules.js"; import { grantGold } from "../../../relics/progression.js"; +import { bindCombatStatusSource } from "../../../battle/status-sources.js"; const BRIDGE="mengsan_shared_bridge_shuying", RULES="mengsan_shared_rules_shuying", END="mengsan_shared_end_shuying", LIMIT="mengsan_shared_limit_shuying", AFTER="mengsan_shared_after_shuying"; const safe=n=>Number.isSafeInteger(n) && n>=0; @@ -51,6 +52,7 @@ export function createSharedCardRuntime({game,get,lib,status,getBattle,battleEne s={player,token,powers:Object.create(null),turn:Object.create(null),freeTurn:new WeakSet(),freeBattle:new WeakSet(), nextTurn:[],bombs:[],negativeStrength:0,autoCount:0,drawCount:0,returns:[],maulBonus:0,vigor:0,queuedAllyEnergy:0}; states.set(player,s);player.addSkill(RULES); + bindCombatStatusSource(player,token.session,"shared",()=>stateOf(player)); token.session.ownResource(s,()=>{if(states.get(player)===s){states.delete(player);player.removeSkill(RULES);s.nextTurn.length=0;s.returns.length=0;}}); return s; } diff --git a/mengsan/docs/unified-battle-hud-20261021.md b/mengsan/docs/unified-battle-hud-20261021.md new file mode 100644 index 0000000..7f451b0 --- /dev/null +++ b/mengsan/docs/unified-battle-hud-20261021.md @@ -0,0 +1,98 @@ +# 梦三统一战斗 HUD + +> 当前范围以末尾“77.png/78.png”一节为准:仅替换玩家下方 HUD,采用完全透明布局;怪物、助战和战场出牌副本保持原生呈现。 + +用户已确认统一下方 HUD,并要求支持鼠标/触屏拖牌、多个敌人;不依赖十周年 UI,但兼容十周年 UI。本轮直接修改实际文件,保留其他已有修改,不创建修复副本、不提交或发布。 + +## 目标与边界 + +- 左侧真实玩家头像与生命/格挡/费用,中央状态条和原有真实手牌,右侧原生技能、确认、取消与结束控件。 +- 保持卡牌父容器、角色节点、控件回调及原生事件结算;不创建另一套技能执行或出牌结算。 +- 敌人与助战按现有席位身份动态布局;意图在上方,状态在下方,保留最多八席的原有校验。 +- 读取现有效果;不启用 Wiki 尚未实现的状态,不改变触发优先级、存档或数值。 +- 独立实例(炸弹、未来回合效果)分别显示,强度、回合、次数与阈值分别说明。 +- 十周年未启用时必须完整工作;启用时只适配其当前真实节点,不导入其接口或资源。 + +## 实施计划 + +1. [x] 建立会话隔离的只读状态来源;接入刘备基础/进阶、通用牌及遗物状态。先验证负值、独立实例及旧会话清理。 +2. [x] 新增 HUD 模块、样式、状态资源;保留原生手牌/控件父级,管理会话挂载与恢复。 +3. [x] 接入战斗入口与召唤刷新,适配原生和十周年控件、拖牌遮罩及多角色布局。 +4. [x] 用浏览器隔离场景检查原生/十周年样式、动态控件、拖牌、七个敌人、状态变化与重复卸载;结合源码核对并修复触屏误选、缓慢层数及遗物条裁切问题。 + +## 检查重点 + +技能进入/退出时控件重建;拖牌期间原生确认隐藏;触屏 click/touch 双触发;目标死亡/召唤后的真实命中区域;保存后换战的监听与样式清理。 + +静态、模块与浏览器隔离检查不等同于实际游戏验收。最终须在原生 UI 和实际十周年风格下分别验证完整对局。 + +## 进度 + +已检查既有修改;按本会话已确认方案直接实施。采用当前工作区,不另建工作树;不执行自动提交。 + +新增 `battle/status-sources.js` 管理会话级只读来源,`ui/hud/` 负责显示和布局。刘备基础/进阶与通用牌模块只增加显示读取注册;遗物控制器只增加状态快照,不更改结算。图标从本机 v0.111.0 切片复制60张,共270585字节,尺寸/字节/哈希见 `assets/status/manifest.json`。 + +原生 UI 与十周年三种实际 CSS 的隔离场景已覆盖1280×720、812×375、540×900、七个敌人、原节点父级保持、鼠标拖牌、触屏 PointerEvent 拖牌、十周年独立技能控件回调、状态详情和重复卸载。上述均为隔离检查,未运行完整游戏和真实安卓客户端。 + +检查结果:4项状态模型测试、270项浏览器隔离断言、13个模块语法检查通过;60张图标清单与本机切片一致。拖拽继续使用原有单目标/无目标出牌适配,复杂技能、多次选牌和特殊目标流程沿用原生选择控件。 + +显示顺序目前是稳定的状态定义/来源顺序,不宣称等同于原版的施加/触发顺序。未知原生标记继续保留;只有已由HUD展示的对应标记才隐藏。 + +## 70.png 反馈修正(2026-10-22) + +截图暴露了上一版隔离场景未覆盖的实际结构:十周年将 `ui.control.id` 改为 `dui-controls`,人物姓名/生命位于 `camp-wrap`、`hp-wrap` 内,人物尺寸及手机 `stayleft` 操作组还有高优先级规则。原来的直接子节点选择器未覆盖这些结构,造成皮肤边框、重复数值与操作按钮错位。 + +- 下方改为通透渐隐背景,仅左侧人物信息使用紧凑墨蓝面板;人物真实头像保持原节点,移除由 HUD 替代的姓名、生命和身份装饰显示,保留未知标记及装备交互。 +- 真实操作容器按引用定位,不依赖容器 ID;原生操作组和可选技能/全局技能列分别安排空间。覆盖手机 `stayleft` 横移与旧布局过渡,恢复手杀透明渐变字体的可见性,并保留技能选中/不可用提示。 +- 遗物条按内容收缩;无状态时隐藏空标题和敌人空状态框;真实手牌上移,减少桌面底边裁切。敌人按数量在可用战场空间内排布,独立显示姓名、生命与格挡。 +- 检查扩充为原生与三种十周年风格、四组尺寸(含1234×920),使用实际人物 CSS、嵌套人物结构、重命名按钮容器及手机样式,覆盖可选全局技能和技能文字可见性。 + +本次508项浏览器隔离断言、4项状态模型测试及修改模块语法/差异检查通过。已查看渲染截图;仍未在实际游戏进程或真实安卓客户端验收。游戏需重启以加载修改后的模块。 + +## 71.png 与原方案对照(2026-10-22) + +用户明确仍要求之前确认的统一下方HUD。上一轮实际做成浮动人物卡、手牌和操作列,虽修正了部分皮肤冲突,却偏离原示意中共用底座的视觉关系。本轮对照原 `mengsan-hud-options.html` 第一节恢复统一底座、三栏分隔、固定状态行与完整手牌,保留真实节点及事件;单个敌人使用更大尺寸,多敌人继续动态分行。 + +十周年 `playerDamagepop` 将字符串通知放在 `player.offsetTop + 80`,缩小后的头像会让“回合开始”等通知覆盖底部信息。本轮只为当前HUD中的原生 `.damage.skill-popup` 加显示类并把位置限制在底座上方,保留文字动画与原有自动移除,退出时恢复。 + +新增检查覆盖空状态行稳定占位、旋转手牌底边、单敌人尺寸、通知位置及卸载恢复;仍属浏览器隔离验证。最终视觉需以重新启动后的实际游戏截图确认,不以检查数量作为美观验收。 + +只读评审发现长遗物条会与首排意图相交,新增十遗物场景先复现失败,再为战场顶部独立预留空间。多单位布局将意图、头像、状态作为整体分配高度;短屏按每单位至少64像素确定列数,头像与标签都受单元格限宽。补查667×375/七名敌人和375×500/三名敌人边界,复核确认原遮挡及头像重叠问题已消除。最终871项浏览器隔离断言、修改模块语法与差异检查通过;仍未进行实机验收。 + +## 72.png 细节与遗物位置(2026-10-22) + +用户要求保留统一底座,修正头像被装备覆盖的问题,并将遗物移到屏幕中上方以避开左上回合数。装备原节点保持父级、回调和选取机制,移到头像下方的24像素紧凑横滚行;玩家装备放在底座底部,敌人装备与状态分别预留空间。额外适配十周年把同一装备根节点置于独立 `ui.equipSolts` 包装内的情况,不重建或搬移卡牌。遗物条改上方居中、限制最大宽度,过多时横向滚动。 + +测试加入真实装备CSS、五件装备首尾点击、装备与头像/状态互不遮挡、独立装备包装和卸载恢复,以及遗物居中和避开回合区域。先复现装备遮头像;独立包装的旧 `justify-content:flex-end` 使首件装备溢出且不可滚回,已显式改为起始排列。最终1087项浏览器隔离断言及修改模块语法/差异检查通过,未在实际游戏或安卓客户端验收。 + +## 73.png 现代风格、透明度与阵亡拥挤(2026-10-22) + +按用户要求使用 UI/UX 技能评审后,保留统一下方HUD,改为内缩8像素、24像素圆角、76%背景不透明度与10像素轻度模糊的底座。文字不整体降透明度;头像、状态、按钮、遗物条与详情面板采用一致的圆角层级,弱化分隔线和装备滚动条,扩大桌面栏间留白。原生控件按真实引用清除十周年高优先级的15像素侧边距,并包含按钮内边距/边框,修正右侧圆角裁切。 + +阵亡角色保留真实节点和引擎登记,只隐藏其战场呈现;复活时恢复,避免存留尸体覆盖重排后的角色。普通出牌副本在空间足够时缩成最近两张,独立放在战场下沿;小屏或空间不足时隐藏重复副本。新增 `ui/hud/played-cards.js` 仅修改可恢复的独立translate/scale,不覆盖原生transform、不移除卡牌、不更改结算或原生过渡完成回调;判定/比拼与固定、可选、抽牌/获得牌副本沿用原生呈现。 + +只读评审指出固定预留128像素会压缩812×600七角色的第二排信息,新增场景先复现,再改为优先保障角色及标注空间,余量足够时才显示最近出牌区。最终原生与十周年三种风格、七组尺寸共28组场景、1556项浏览器隔离断言通过,覆盖透明度、圆角、按钮边界、阵亡/复活、判定恢复、原生transform过渡完成、拖牌及退出恢复。已检查桌面阵亡与短屏渲染截图;模块语法及差异检查通过。上述不等于实际游戏或安卓客户端验收,需重启游戏确认最终效果。 + +## 标注图、75.png/76.png(2026-10-22) + +用户明确取消深蓝底框,要求完全透明;左下仅玩家立绘,不存在双武将,立绘需略大以容纳标记、装备。按标注将生命、格挡和费用移到立绘右侧竖排,状态移到立绘上方左侧矩阵;提示语放在手牌上方,确认、取消、托管和结束等原生控件在其下横排,原生技能与十周年可选技能控件在最右侧竖排。桌面玩家立绘由80×116放大为144×196,装备仍在原节点的下方横滚行;窄屏采用竖排人物信息。底座无背景色、边框、阴影或模糊,文字保持不透明并用文字阴影辅助辨识。 + +75.png 的右键说明被手牌遮住。新增 `ui/hud/overlays.js` 仅调整已有简单提示和原生 `.dialog.popped` 的位置,说明放到手牌和操作提示上方,并限制窗口内边界、允许长内容滚动。保留原有右键入口、内容节点、关闭遮罩和回调;不重建说明,不干预含卡牌/按钮的原生复杂选择框。针对原生绝对定位的内容包装增加局部流式布局,避免自动高度塌缩。 + +用户后续收窄范围:怪物界面沿用原版,只替换玩家下方 HUD。已去除本 HUD 的多单位布局、姓名/生命/装备/状态代理、死亡隐藏及最近出牌副本模块;敌人与助战不添加 HUD 类、不改内联位置、不替换节点,也不隐藏它们的原生标记。 + +76.png 奖励界面后方恢复旧布局,原因是 `requestBattleFinish` 立即卸载 HUD 和手牌适配。改为先冻结呈现、停交互和监听,保留玩家及扇形手牌外观;最终仍由原会话在奖励完成后统一释放,不改变战斗停止、结算、存档或奖励选择顺序。 + +本轮浏览器隔离检查使用实际玩家 HUD、手牌模块和原生/十周年三种风格 CSS,覆盖七组尺寸、状态矩阵溢出、玩家装备回调、怪物节点保持、原生技能独立竖排、鼠标与触屏拖牌、右键说明位置/内容/关闭、提示语与横排操作分离,以及停止后保留外观并最终恢复。最终28组场景、1455项浏览器隔离断言、4项状态模型测试以及修改模块语法和差异检查通过,已复核右键说明与透明底座的渲染截图;此类检查仍不等于实际游戏与安卓客户端验收。 + +## 77.png/78.png(2026-10-23) + +玩家信息列与立绘的横向间距缩至 8~16px;常规/横屏立绘下边距由 66px 改为 24px,数值列同步下移。装备使用原节点,在立绘下半部竖向叠放,窄尺寸可滚动;兼容十周年独立装备容器,不迁移节点或替换回调。 + +移除“当前状态/暂无状态”和“选择行动/等待行动”占位文字,无状态时隐藏状态区,有状态时自动显示。左侧状态矩阵贴近立绘上方;遗物移至右侧技能区上方,与状态区对应。窄竖屏遗物改为单行横向滚动,并作 16px 对齐补偿以避开原有怪物意图。技能区高度限于遗物下方。 + +后续按用户要求移除“技能与装备”按钮及其详情代理分支,删除自制操作按钮的纹理、字体、边框、颜色和禁用态覆盖,并移除已无引用的两张复制纹理。确定/回合结束使用当前已启用的十周年或官方原版控件和样式,保留真实节点、内容、点击回调及原有内边距;HUD 仅安排容器位置,允许原生尺寸向上撑开。信息列改为相对立绘顶部定位,避免移除按钮后数值列下沉。右侧技能区仍按原有 HUD 轨道适配,底部布局保持透明。 + +78.png 的原生标记说明层级低于立绘,原生打开函数也不拒绝重复点击。玩家标记捕获阶段仅阻止尚未完成打开或已有说明时的重复打开;由原生事件生成内容,随后将说明移到手牌操作区上方。说明与遮罩分别置于 90/89 层,取消位置和层级过渡,避免短暂遮挡。关闭仍走原生回调,关闭后可重新打开;不改怪物标记。 + +已通过原生及十周年三种风格、七种尺寸的 28 组浏览器隔离场景,共 1847 项断言;另有前一轮 4 项状态模型测试、当前修改模块语法检查和差异检查通过。检查覆盖装备在立绘内、信息间距、移除技能装备代理按钮、操作按钮原始节点/内容/外观/字体/内边距保持、原生确认和结束回调、按钮在手牌上方、遗物溢出访问、标记连续点击/层级/位置/关闭后重开。已复核透明布局与原生按钮、标记说明截图;拖牌在原生目标可见区域验证。尚未完成真实游戏或安卓客户端验收。 diff --git a/mengsan/mode.js b/mengsan/mode.js index 7eeed85..49bf1f1 100644 --- a/mengsan/mode.js +++ b/mengsan/mode.js @@ -73,6 +73,7 @@ import { isFogmog, isToothedEye, isFogmogActor, isEncounterEnemy, recordFogmogAction } from "./monsters/fogmog-intent.js"; import { mountEnergy } from "./ui/energy-display.js"; import { mountHandUI } from "./ui/hand/index.js"; +import { mountBattleHUD } from "./ui/hud/index.js"; import { finishDeathBlow, hasPendingDeathBlow, @@ -715,7 +716,12 @@ const prepareBattle = async (run, node, encounter, session, resources) => { } } activeBattle.pilesUI = mountBattlePiles(session, _status.mengsanBattle_shuying); - currentBattle.relicUI = mountRelics(session, run, currentBattle.relics); + currentBattle.owner = me; + currentBattle.hud = mountBattleHUD(me, currentBattle, { + game, ui, get, lib, _status, document, window, assetBase: STYLE_PATH, + log: message => game.log(message), + }); + currentBattle.relicUI = mountRelics(session, run, currentBattle.relics, { stripRoot: ui.arena }); activeBattle.gmUI = mountGMManager(session, me, activeBattle.personalPiles, _status.mengsanBattle_shuying, resources, game); for (let index = 0; index < plan.units.length; index++) await equipBattleUnit(participants[index], plan.units[index], resources); game.syncState(); @@ -841,7 +847,8 @@ const requestBattleFinish = outcome => { current.pilesUI?.dispose(); current.gmUI?.dispose(); current.relicUI?.dispose(); - current.handUI?.dispose(); + current.handUI?.freeze(); + current.hud?.freeze(); stopBattleTurn(current.session, _status.eventManager); if (_status.mengsanBattle_shuying) _status.mengsanBattle_shuying.resolving = true; current.finished(); @@ -920,6 +927,7 @@ const playBattle = async (run, node, encounter) => { if (current.engineFailed) { reportFlowError(current.engineError); session.requestStop(); + current.hud?.dispose(); current.handUI?.dispose(); document.body.classList.remove("mengsan-battle-ui-shuying"); const reason = current.engineError?.message || String(current.engineError); diff --git a/mengsan/relics/ASSETS.md b/mengsan/relics/ASSETS.md index 52e920b..a295867 100644 --- a/mengsan/relics/ASSETS.md +++ b/mengsan/relics/ASSETS.md @@ -13,7 +13,7 @@ | 遗忘之魂 | lost_soul.png | forgotten_soul.png | | 布质果实 | looming_fruit_2.png | looming_fruit.png | -美味饼干的另外四种角色图仍保留在源图集;当前梦三默认只映射刘备/铁甲战士。 +美味饼干的另外四种角色图仍保留在源图集;当前梦三默认只接入刘备对应版本。 束带是既有梦三道具,不属于Wiki的298件遗物,无对应图集图片,继续文字展示。 战斗遗物栏、背包、选遗物、商店、战利品清单使用这些图片;加载错误保留名称和描述。 diff --git a/mengsan/relics/IMPLEMENTATION-20261006.md b/mengsan/relics/IMPLEMENTATION-20261006.md index 2d0c17e..861a00a 100644 --- a/mengsan/relics/IMPLEMENTATION-20261006.md +++ b/mengsan/relics/IMPLEMENTATION-20261006.md @@ -10,7 +10,7 @@ - [遗物查找页](https://sts2.huijiwiki.com/wiki/模板:查找/遗物);[结构化数据页](https://sts2.huijiwiki.com/wiki/Data:Relic.tabx),快照修订号 `28579`。 - `relics/catalog.js` 保存池、原始稀有度、先古归属、图鉴顺序、中文名、可读描述、原始描述/原始 Wiki 标记、风味文字、前代及来源链接。 - 298 个唯一 Wiki ID;图鉴顺序完整覆盖 1—298。头环原始稀有度仍为“遗物”;先古归属与稀有度分别保存。 -- 遗物池:铁甲战士 9;静默猎手 9;储君 9;亡灵契约师 9;故障机器人 9;通用 253。 +- 遗物池:刘备 9;静默猎手 9;储君 9;亡灵契约师 9;故障机器人 9;通用 253。 - 稀有度:初始 10;普通 30;罕见 40;稀有 50;商店 30;先古之民 102;事件 35;遗物 1。 - 先古归属:无 196;涅奥 30;欧洛巴斯 10;佩尔 10;特兹卡塔拉 10;诺奴佩普 10;坦克斯 10;瓦库 10;达弗 12。 @@ -19,7 +19,7 @@ 来源为用户提供的 `assets/sts2/Godot_Atlas_Sprites_v0.111.0/images/atlases/relic_atlas.sprites/` 拆分 PNG 目录。运行时使用 `assets/relics/.png`,逐字节复制,不拉伸、不重新绘图。所有图片都在 480×672、300 KiB 以内。 298 张图片合计 **2,173,564 字节**。每张的源文件名、尺寸、透明通道、体积、SHA-256 见 [资源清单](../assets/relics/manifest.json)。 -特殊映射:美味饼干使用 `yummy_cookie_ironclad.png`;遗忘之魂使用 `lost_soul.png`;布质果实使用 `looming_fruit_2.png`。美味饼干的其他角色图仍在原图集,当前仅接入铁甲战士图。原梦三束带没有对应 STS2 原图,继续显示文字。 +特殊映射:美味饼干使用 `yummy_cookie_ironclad.png`;遗忘之魂使用 `lost_soul.png`;布质果实使用 `looming_fruit_2.png`。美味饼干的其他角色图仍在原图集,当前仅接入刘备图。原梦三束带没有对应 STS2 原图,继续显示文字。 战斗遗物栏、地图背包、遗物选择、商店和战利品清单已经接入图片;池/稀有度/先古归属可从遗物栏与背包查看。 ## 效果与获取接线 @@ -34,7 +34,7 @@ - 商店保留【杀】与回复生命,新增3个遗物货位、折扣与信使补货。遗物基价暂设120金币;不是 Wiki 公布的商品价格。购买后完成所有遗物选牌,再提交房间结果。 - 普通/罕见/稀有遗物池登记 **81** 项;可售遗物池登记 **91** 项,均在实际获取时继续过滤重复与角色池。 - 原有精英固定遗物奖励、第一章宝箱和初次剧情遗物选择自动使用扩展后的可用池。Boss 极品卡牌池保留原有专属稀有卡约定。 -- 新建刘备征程自动持有燃烧之血,符合铁甲战士初始池;既有存档不追补、不重复发放初始遗物。其他武将暂不指定初始遗物。 +- 新建刘备征程自动持有燃烧之血,符合刘备初始池;既有存档不追补、不重复发放初始遗物。其他武将暂不指定初始遗物。 - 先古/事件/初始遗物已提供独立奖励池;**尚未创建对应先古会面和事件剧情**,不会把这些条目混入普通掉落。后续节点可以引用下表池名或调用已有 `grantRelic`。 - 拾取操作在可序列化副本上计算,成功后替换运行状态;尚未实现的遗物/重复获取不会改金币、随机数或牌组。战后效果在现有结算候选里只计算一次,存档失败重试不重新发奖。 @@ -57,7 +57,7 @@ 1. **烹饪数值与流程**:网页只说明能够烹饪,目前采用最大生命+5。确定原作具体数值后可替换适配值。 2. **非战斗生命归零流程**:危险剪刀/芬芳蘑菇当前扣血保留至少1生命。需要明确是否允许地图中直接死亡,以及对应失败弹窗/存档规则。 3. **先古会面及事件入口**:请确定梦三在哪些章节、节点或剧情中接入各先古和事件遗物。已有数据分类、效果、专用奖励池,但尚无原作的完整会面事件。 -4. **角色池映射**:当前只把刘备映射为铁甲战士,其他四个 STS2 角色未绑定梦三武将;不擅自指定。 +4. **角色池映射**:当前仅接入刘备专属角色池,其他四个 STS2 角色未绑定梦三武将;不擅自指定。 5. **原作机制**:辉星、充能球、集中、奥斯提、灾厄、仆从、铸造、可使用药水,以及 Wiki 指定附魔,需要对应玩法接口;完整逐件缺口见下表。现有词缀不会冒充原作附魔。 6. **先古牌与击杀口径**:尘封魔典当前抽取现有先古事件牌;石之剑按精英战胜利次数计数,需要完整原作候选表及精英单位身份口径。 7. **实机验收**:需要用户回来在实际梦三运行以下场景;此次不在正在进行的征程上自动操纵或替换存档。 @@ -95,7 +95,7 @@ - **微型帐篷**:每个休息选项可各选择一次,直至选择离开;不允许无限重复同一选项。 - **切肉刀**:网页没有烹饪细则;暂定每次烹饪最大生命+5,待用户确认。 - **大蘑菇**:沿用梦三初始手牌4张,扣减2张;未改成STS2的基础手牌数量。 -- **长蛇戒指**:刘备仅映射铁甲战士池;其他角色池需配置player.relicPool才可获取。效果按前三个自身回合各摸2张接入。 +- **长蛇戒指**:刘备默认使用专属遗物池;其他角色池需配置player.relicPool才可获取。效果按前三个自身回合各摸2张接入。 - **符文金字塔**:梦三原本按手牌上限弃牌;这里让全部手牌不计入弃牌上限。虚无牌照常消耗。 - **松动羊毛剪**:选牌后扣除16点生命;当前地图流程保留至少1点生命,待补充非战斗死亡结算。 - **芳香蘑菇**:随机强化2张并扣除15点生命;当前地图流程保留至少1点生命,待补充非战斗死亡结算。 @@ -111,13 +111,13 @@ | 序号 | 遗物 / Wiki ID | 池 | 稀有度 | 先古归属 | 原文效果 | 状态 | 执行 / 缺口 | 来源图片 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| 1 | [燃烧之血](https://sts2.huijiwiki.com/wiki/%E7%87%83%E7%83%A7%E4%B9%8B%E8%A1%80)`burning_blood` | 铁甲战士 | 初始 | — | 在战斗结束时,回复6点生命。 | 已接入,待实机 | battleHeal=6 | `burning_blood.png`71×81;6202 B | +| 1 | [燃烧之血](https://sts2.huijiwiki.com/wiki/%E7%87%83%E7%83%A7%E4%B9%8B%E8%A1%80)`burning_blood` | 刘备 | 初始 | — | 在战斗结束时,回复6点生命。 | 已接入,待实机 | battleHeal=6 | `burning_blood.png`71×81;6202 B | | 2 | [蛇之戒指](https://sts2.huijiwiki.com/wiki/%E8%9B%87%E4%B9%8B%E6%88%92%E6%8C%87)`ring_of_the_snake` | 静默猎手 | 初始 | — | 在每场战斗开始时,额外抽2张牌。 | 已接入,待实机 | openingDraw=2 | `ring_of_the_snake.png`81×81;9868 B | | 3 | [天赋君权](https://sts2.huijiwiki.com/wiki/%E5%A4%A9%E8%B5%8B%E5%90%9B%E6%9D%83)`divine_right` | 储君 | 初始 | — | 在每场战斗开始时,获得[辉星][辉星][辉星]。 | 待补充,禁入奖励 | 辉星系统 | `divine_right.png`71×81;8739 B | | 4 | [缚魂命匣](https://sts2.huijiwiki.com/wiki/%E7%BC%9A%E9%AD%82%E5%91%BD%E5%8C%A3)`bound_phylactery` | 亡灵契约师 | 初始 | — | 在你的回合开始时,召唤1 | 待补充,禁入奖励 | 奥斯提召唤系统 | `bound_phylactery.png`77×82;8874 B | | 5 | [破损核心](https://sts2.huijiwiki.com/wiki/%E7%A0%B4%E6%8D%9F%E6%A0%B8%E5%BF%83)`cracked_core` | 故障机器人 | 初始 | — | 在每场战斗开始时,生成1个闪电充能球。 | 待补充,禁入奖励 | 充能球系统 | `cracked_core.png`79×78;8536 B | -| 6 | [黑暗之血](https://sts2.huijiwiki.com/wiki/%E9%BB%91%E6%9A%97%E4%B9%8B%E8%A1%80)`black_blood` | 铁甲战士 | 初始 | — | 在战斗结束时,回复12点生命。 | 已接入,待实机 | battleHeal=12;replaces="burning_blood" | `black_blood.png`66×79;6334 B | -| 7 | [长蛇戒指](https://sts2.huijiwiki.com/wiki/%E9%95%BF%E8%9B%87%E6%88%92%E6%8C%87)`ring_of_the_drake` | 静默猎手 | 初始 | — | 在战斗开始时的前3个回合,你额外抽2张牌。 | 梦三适配,待实机 | 刘备仅映射铁甲战士池;其他角色池需配置player.relicPool才可获取。效果按前三个自身回合各摸2张接入。 | `ring_of_the_drake.png`78×80;7525 B | +| 6 | [黑暗之血](https://sts2.huijiwiki.com/wiki/%E9%BB%91%E6%9A%97%E4%B9%8B%E8%A1%80)`black_blood` | 刘备 | 初始 | — | 在战斗结束时,回复12点生命。 | 已接入,待实机 | battleHeal=12;replaces="burning_blood" | `black_blood.png`66×79;6334 B | +| 7 | [长蛇戒指](https://sts2.huijiwiki.com/wiki/%E9%95%BF%E8%9B%87%E6%88%92%E6%8C%87)`ring_of_the_drake` | 静默猎手 | 初始 | — | 在战斗开始时的前3个回合,你额外抽2张牌。 | 梦三适配,待实机 | 刘备默认使用专属遗物池;其他角色池需配置player.relicPool才可获取。效果按前三个自身回合各摸2张接入。 | `ring_of_the_drake.png`78×80;7525 B | | 8 | [天命所归](https://sts2.huijiwiki.com/wiki/%E5%A4%A9%E5%91%BD%E6%89%80%E5%BD%92)`divine_destiny` | 储君 | 初始 | — | 在每场战斗开始时,获得[辉星][辉星][辉星][辉星][辉星][辉星]。 | 待补充,禁入奖励 | 辉星系统 | `divine_destiny.png`71×81;8401 B | | 9 | [无界命匣](https://sts2.huijiwiki.com/wiki/%E6%97%A0%E7%95%8C%E5%91%BD%E5%8C%A3)`phylactery_unbound` | 亡灵契约师 | 初始 | — | 在每场战斗开始时,召唤5。在你的回合开始时,召唤2。 | 待补充,禁入奖励 | 奥斯提召唤系统 | `phylactery_unbound.png`76×80;9269 B | | 10 | [注能核心](https://sts2.huijiwiki.com/wiki/%E6%B3%A8%E8%83%BD%E6%A0%B8%E5%BF%83)`infused_core` | 故障机器人 | 初始 | — | 在每场战斗开始时,生成3个闪电充能球。闪电充能球额外造成1点伤害。 | 待补充,禁入奖励 | 充能球系统 | `infused_core.png`80×78;10310 B | @@ -146,7 +146,7 @@ | 33 | [战纹涂料](https://sts2.huijiwiki.com/wiki/%E6%88%98%E7%BA%B9%E6%B6%82%E6%96%99)`war_paint` | 通用 | 普通 | — | 拾起时,随机升级2张技能牌。 | 已接入,待实机 | gainUpgrade=2;upgradeType="skill" | `war_paint.png`80×53;7563 B | | 34 | [准备背包](https://sts2.huijiwiki.com/wiki/%E5%87%86%E5%A4%87%E8%83%8C%E5%8C%85)`bag_of_preparation` | 通用 | 普通 | — | 在每场战斗开始时,额外抽2张牌。 | 已接入,待实机 | openingDraw=2 | `bag_of_preparation.png`81×74;8988 B | | 35 | [紫水晶茄子](https://sts2.huijiwiki.com/wiki/%E7%B4%AB%E6%B0%B4%E6%99%B6%E8%8C%84%E5%AD%90)`amethyst_aubergine` | 通用 | 普通 | — | 敌人额外掉落15金币。 | 已接入,待实机 | battleGold=15 | `amethyst_aubergine.png`81×77;7960 B | -| 36 | [红头骨](https://sts2.huijiwiki.com/wiki/%E7%BA%A2%E5%A4%B4%E9%AA%A8)`red_skull` | 铁甲战士 | 普通 | — | 当你的生命值低于或等于50%时,你额外获得3点力量 | 已接入,待实机 | lowHpStrength=3 | `red_skull.png`76×81;8046 B | +| 36 | [红头骨](https://sts2.huijiwiki.com/wiki/%E7%BA%A2%E5%A4%B4%E9%AA%A8)`red_skull` | 刘备 | 普通 | — | 当你的生命值低于或等于50%时,你额外获得3点力量 | 已接入,待实机 | lowHpStrength=3 | `red_skull.png`76×81;8046 B | | 37 | [异蛇头骨](https://sts2.huijiwiki.com/wiki/%E5%BC%82%E8%9B%87%E5%A4%B4%E9%AA%A8)`snecko_skull` | 静默猎手 | 普通 | — | 每当你给予敌人中毒时,所给予的中毒层数增加1层。 | 待补充,禁入奖励 | 玩家中毒牌与中毒系统 | `snecko_skull.png`80×73;6524 B | | 38 | [击剑指南](https://sts2.huijiwiki.com/wiki/%E5%87%BB%E5%89%91%E6%8C%87%E5%8D%97)`fencing_manual` | 储君 | 普通 | — | 在每场战斗开始时,铸造10。 | 待补充,禁入奖励 | 铸造系统 | `fencing_manual.png`59×80;6554 B | | 39 | [骨笛](https://sts2.huijiwiki.com/wiki/%E9%AA%A8%E7%AC%9B)`bone_flute` | 亡灵契约师 | 普通 | — | 每当奥斯提攻击时,获得2点格挡。 | 待补充,禁入奖励 | 奥斯提攻击系统 | `bone_flute.png`81×78;5418 B | @@ -181,8 +181,8 @@ | 68 | [招财异鱼](https://sts2.huijiwiki.com/wiki/%E6%8B%9B%E8%B4%A2%E5%BC%82%E9%B1%BC)`lucky_fysh` | 通用 | 罕见 | — | 每当你将一张卡牌加入你的牌组时,获得15金币。 | 已接入,待实机 | deckAddGold=15 | `lucky_fysh.png`81×83;9458 B | | 69 | [招架盾](https://sts2.huijiwiki.com/wiki/%E6%8B%9B%E6%9E%B6%E7%9B%BE)`parrying_shield` | 通用 | 罕见 | — | 如果你在回合结束时拥有至少10点格挡,则对随机敌人造成6点伤害。 | 已接入,待实机 | endBlockThreshold=10;endBlockDamage=6 | `parrying_shield.png`81×81;8633 B | | 70 | [烛台](https://sts2.huijiwiki.com/wiki/%E7%83%9B%E5%8F%B0)`candelabra` | 通用 | 罕见 | — | 在你的第2回合开始时,获得[能量][能量]。 | 已接入,待实机 | nthTurn=2;nthTurnEnergy=2 | `candelabra.png`81×81;5701 B | -| 71 | [纸蛙](https://sts2.huijiwiki.com/wiki/%E7%BA%B8%E8%9B%99)`paper_phrog` | 铁甲战士 | 罕见 | — | 有易伤状态的敌人受到的伤害增加75%而非50%。 | 梦三适配,待实机 | 依梦三易伤口径向上取整;作用于玩家发起的攻击,保留既有卡牌的额外易伤增幅。 | `paper_phrog.png`81×69;5557 B | -| 72 | [自成型黏土](https://sts2.huijiwiki.com/wiki/%E8%87%AA%E6%88%90%E5%9E%8B%E9%BB%8F%E5%9C%9F)`self_forming_clay` | 铁甲战士 | 罕见 | — | 每当你在战斗中失去生命,就在下回合获得3点格挡。 | 已接入,待实机 | hpLossNextBlock=3 | `self_forming_clay.png`81×80;7786 B | +| 71 | [纸蛙](https://sts2.huijiwiki.com/wiki/%E7%BA%B8%E8%9B%99)`paper_phrog` | 刘备 | 罕见 | — | 有易伤状态的敌人受到的伤害增加75%而非50%。 | 梦三适配,待实机 | 依梦三易伤口径向上取整;作用于玩家发起的攻击,保留既有卡牌的额外易伤增幅。 | `paper_phrog.png`81×69;5557 B | +| 72 | [自成型黏土](https://sts2.huijiwiki.com/wiki/%E8%87%AA%E6%88%90%E5%9E%8B%E9%BB%8F%E5%9C%9F)`self_forming_clay` | 刘备 | 罕见 | — | 每当你在战斗中失去生命,就在下回合获得3点格挡。 | 已接入,待实机 | hpLossNextBlock=3 | `self_forming_clay.png`81×80;7786 B | | 73 | [铜钹](https://sts2.huijiwiki.com/wiki/%E9%93%9C%E9%92%B9)`tingsha` | 静默猎手 | 罕见 | — | 你每在你的回合丢弃一张牌,就对一名随机敌人造成3点伤害。 | 待补充,禁入奖励 | 尚缺效果接口:discardDamage | `tingsha.png`77×72;6163 B | | 74 | [扭曲漏斗](https://sts2.huijiwiki.com/wiki/%E6%89%AD%E6%9B%B2%E6%BC%8F%E6%96%97)`twisted_funnel` | 静默猎手 | 罕见 | — | 在每场战斗开始时,给予所有敌人4层中毒。 | 待补充,禁入奖励 | 中毒系统 | `twisted_funnel.png`80×71;6585 B | | 75 | [星系尘埃](https://sts2.huijiwiki.com/wiki/%E6%98%9F%E7%B3%BB%E5%B0%98%E5%9F%83)`galactic_dust` | 储君 | 罕见 | — | 每消耗10点[辉星],就获得10格挡。 | 待补充,禁入奖励 | 辉星消费系统 | `galactic_dust.png`80×82;7036 B | @@ -226,9 +226,9 @@ | 113 | [钨合金棍](https://sts2.huijiwiki.com/wiki/%E9%92%A8%E5%90%88%E9%87%91%E6%A3%8D)`tungsten_rod` | 通用 | 稀有 | — | 你每次失去生命时,减少失去的生命值1点。 | 已接入,待实机 | reduceHpLoss=1 | `tungsten_rod.png`80×75;5669 B | | 114 | [蜥蜴尾巴](https://sts2.huijiwiki.com/wiki/%E8%9C%A5%E8%9C%B4%E5%B0%BE%E5%B7%B4)`lizard_tail` | 通用 | 稀有 | — | 当你的生命值将要降低至0或以下时,回复到最大生命值的50%(仅能起效一次)。 | 已接入,待实机 | deathHealFraction=0.5 | `lizard_tail.png`82×81;7566 B | | 115 | [转经轮](https://sts2.huijiwiki.com/wiki/%E8%BD%AC%E7%BB%8F%E8%BD%AE)`prayer_wheel` | 通用 | 稀有 | — | 普通敌人额外掉落一次卡牌奖励。 | 待补充,禁入奖励 | 独立的额外卡牌奖励组 | `prayer_wheel.png`57×81;6409 B | -| 116 | [卡戎之灰](https://sts2.huijiwiki.com/wiki/%E5%8D%A1%E6%88%8E%E4%B9%8B%E7%81%B0)`charons_ashes` | 铁甲战士 | 稀有 | — | 每当你消耗一张牌,对所有敌人造成3点伤害。 | 已接入,待实机 | exhaustDamage=3 | `charons_ashes.png`80×79;7056 B | -| 117 | [恶魔之舌](https://sts2.huijiwiki.com/wiki/%E6%81%B6%E9%AD%94%E4%B9%8B%E8%88%8C)`demon_tongue` | 铁甲战士 | 稀有 | — | 每回合第一次在回合内失去生命值时,回复等量的生命值。 | 待补充,禁入奖励 | 尚缺效果接口:firstTurnLossHeal | `demon_tongue.png`79×79;5413 B | -| 118 | [损毁头盔](https://sts2.huijiwiki.com/wiki/%E6%8D%9F%E6%AF%81%E5%A4%B4%E7%9B%94)`ruined_helmet` | 铁甲战士 | 稀有 | — | 你在每场战斗中第一次获得的力量值翻倍。 | 待补充,禁入奖励 | 尚缺效果接口:firstStrengthDouble | `ruined_helmet.png`67×78;6839 B | +| 116 | [卡戎之灰](https://sts2.huijiwiki.com/wiki/%E5%8D%A1%E6%88%8E%E4%B9%8B%E7%81%B0)`charons_ashes` | 刘备 | 稀有 | — | 每当你消耗一张牌,对所有敌人造成3点伤害。 | 已接入,待实机 | exhaustDamage=3 | `charons_ashes.png`80×79;7056 B | +| 117 | [恶魔之舌](https://sts2.huijiwiki.com/wiki/%E6%81%B6%E9%AD%94%E4%B9%8B%E8%88%8C)`demon_tongue` | 刘备 | 稀有 | — | 每回合第一次在回合内失去生命值时,回复等量的生命值。 | 待补充,禁入奖励 | 尚缺效果接口:firstTurnLossHeal | `demon_tongue.png`79×79;5413 B | +| 118 | [损毁头盔](https://sts2.huijiwiki.com/wiki/%E6%8D%9F%E6%AF%81%E5%A4%B4%E7%9B%94)`ruined_helmet` | 刘备 | 稀有 | — | 你在每场战斗中第一次获得的力量值翻倍。 | 待补充,禁入奖励 | 尚缺效果接口:firstStrengthDouble | `ruined_helmet.png`67×78;6839 B | | 119 | [螺线飞镖](https://sts2.huijiwiki.com/wiki/%E8%9E%BA%E7%BA%BF%E9%A3%9E%E9%95%96)`helical_dart` | 静默猎手 | 稀有 | — | 你每打出一张小刀,就在本回合获得1点敏捷。 | 待补充,禁入奖励 | 小刀牌与静默猎手牌组 | `helical_dart.png`79×80;6241 B | | 120 | [纸鹤](https://sts2.huijiwiki.com/wiki/%E7%BA%B8%E9%B9%A4)`paper_krane` | 静默猎手 | 稀有 | — | 有虚弱状态的敌人造成的伤害降低40%而非25%。 | 梦三适配,待实机 | 依梦三虚弱口径向下取整,敌人攻击倍率0.6。 | `paper_krane.png`76×78;5861 B | | 121 | [结实绷带](https://sts2.huijiwiki.com/wiki/%E7%BB%93%E5%AE%9E%E7%BB%B7%E5%B8%A6)`tough_bandages` | 静默猎手 | 稀有 | — | 你每在你的回合丢弃一张牌,就获得3点格挡。 | 待补充,禁入奖励 | 尚缺效果接口:discardBlock | `tough_bandages.png`80×78;7351 B | @@ -266,7 +266,7 @@ | 153 | [勇气投石索](https://sts2.huijiwiki.com/wiki/%E5%8B%87%E6%B0%94%E6%8A%95%E7%9F%B3%E7%B4%A2)`sling_of_courage` | 通用 | 商店 | — | 在与精英敌人战斗时,获得2点力量。 | 已接入,待实机 | eliteOpeningStrength=2 | `sling_of_courage.png`81×77;6202 B | | 154 | [幽灵种子](https://sts2.huijiwiki.com/wiki/%E5%B9%BD%E7%81%B5%E7%A7%8D%E5%AD%90)`ghost_seed` | 通用 | 商店 | — | 打击和防御获得虚无。 | 待补充,禁入奖励 | 尚缺效果接口:starterVoid | `ghost_seed.png`71×78;7167 B | | 155 | [羽翼护符](https://sts2.huijiwiki.com/wiki/%E7%BE%BD%E7%BF%BC%E6%8A%A4%E7%AC%A6)`wing_charm` | 通用 | 商店 | — | 每次卡牌奖励中,都会有随机一张牌被附魔:迅捷1。 | 待补充,禁入奖励 | 迅捷附魔 | `wing_charm.png`76×76;7790 B | -| 156 | [硫磺](https://sts2.huijiwiki.com/wiki/%E7%A1%AB%E7%A3%BA)`brimstone` | 铁甲战士 | 商店 | — | 在你的每个回合开始时,你获得2点力量,所有敌人获得1点力量。 | 待补充,禁入奖励 | 尚缺效果接口:turnStrength、enemyTurnStrength | `brimstone.png`79×72;7533 B | +| 156 | [硫磺](https://sts2.huijiwiki.com/wiki/%E7%A1%AB%E7%A3%BA)`brimstone` | 刘备 | 商店 | — | 在你的每个回合开始时,你获得2点力量,所有敌人获得1点力量。 | 待补充,禁入奖励 | 尚缺效果接口:turnStrength、enemyTurnStrength | `brimstone.png`79×72;7533 B | | 157 | [忍术卷轴](https://sts2.huijiwiki.com/wiki/%E5%BF%8D%E6%9C%AF%E5%8D%B7%E8%BD%B4)`ninja_scroll` | 静默猎手 | 商店 | — | 每场战斗开始时,将3张小刀加入你的手牌。 | 待补充,禁入奖励 | 小刀牌与静默猎手牌组 | `ninja_scroll.png`78×79;8733 B | | 158 | [维特鲁威仆从](https://sts2.huijiwiki.com/wiki/%E7%BB%B4%E7%89%B9%E9%B2%81%E5%A8%81%E4%BB%86%E4%BB%8E)`vitruvian_minion` | 储君 | 商店 | — | 名字中有“仆从”的卡牌造成双倍的伤害与格挡。 | 待补充,禁入奖励 | 仆从牌与储君牌组 | `vitruvian_minion.png`81×81;9816 B | | 159 | [不死符文](https://sts2.huijiwiki.com/wiki/%E4%B8%8D%E6%AD%BB%E7%AC%A6%E6%96%87)`undying_sigil` | 亡灵契约师 | 商店 | — | 当敌人的灾厄层数大于等于其生命值时,它造成的伤害降低50%。 | 待补充,禁入奖励 | 灾厄系统 | `undying_sigil.png`78×75;9913 B | diff --git a/mengsan/relics/battle.js b/mengsan/relics/battle.js index 13fedaa..88a1616 100644 --- a/mengsan/relics/battle.js +++ b/mengsan/relics/battle.js @@ -34,6 +34,7 @@ export function createRelicBattle(run,{active,draw,log,effect,owner,node={},enco const drawMore=async (relic,amount)=>{ if(!usable() || !(amount>0))return; if(!turns)openingHandBonus+=amount; log(relic,`摸${amount}张牌`); await draw(amount); }; const controller={ has, + displayState(){return {dexterity,plating,nextEnergy,nextBlock};}, retainsHand(){return has("runic_pyramid") || has("ringing_triangle") && turns===1;}, get openingHandBonus(){return openingHandBonus;}, get turns(){return turns;}, diff --git a/mengsan/relics/catalog.js b/mengsan/relics/catalog.js index 36ecf05..5b382a9 100644 --- a/mengsan/relics/catalog.js +++ b/mengsan/relics/catalog.js @@ -5,7 +5,7 @@ export const relicCatalog = Object.freeze([ "wikiId": "burning_blood", "name": "燃烧之血", "description": "在战斗结束时,回复6点生命。", - "pool": "铁甲战士", + "pool": "刘备", "tier": "初始", "ancient": null, "order": 1, @@ -90,7 +90,7 @@ export const relicCatalog = Object.freeze([ "wikiId": "black_blood", "name": "黑暗之血", "description": "在战斗结束时,回复12点生命。", - "pool": "铁甲战士", + "pool": "刘备", "tier": "初始", "ancient": null, "order": 6, @@ -600,7 +600,7 @@ export const relicCatalog = Object.freeze([ "wikiId": "red_skull", "name": "红头骨", "description": "当你的生命值低于或等于50%时,你额外获得3点力量", - "pool": "铁甲战士", + "pool": "刘备", "tier": "普通", "ancient": null, "order": 36, @@ -1195,7 +1195,7 @@ export const relicCatalog = Object.freeze([ "wikiId": "paper_phrog", "name": "纸蛙", "description": "有易伤状态的敌人受到的伤害增加75%而非50%。", - "pool": "铁甲战士", + "pool": "刘备", "tier": "罕见", "ancient": null, "order": 71, @@ -1212,7 +1212,7 @@ export const relicCatalog = Object.freeze([ "wikiId": "self_forming_clay", "name": "自成型黏土", "description": "每当你在战斗中失去生命,就在下回合获得3点格挡。", - "pool": "铁甲战士", + "pool": "刘备", "tier": "罕见", "ancient": null, "order": 72, @@ -1960,7 +1960,7 @@ export const relicCatalog = Object.freeze([ "wikiId": "charons_ashes", "name": "卡戎之灰", "description": "每当你消耗一张牌,对所有敌人造成3点伤害。", - "pool": "铁甲战士", + "pool": "刘备", "tier": "稀有", "ancient": null, "order": 116, @@ -1977,7 +1977,7 @@ export const relicCatalog = Object.freeze([ "wikiId": "demon_tongue", "name": "恶魔之舌", "description": "每回合第一次在回合内失去生命值时,回复等量的生命值。", - "pool": "铁甲战士", + "pool": "刘备", "tier": "稀有", "ancient": null, "order": 117, @@ -1994,7 +1994,7 @@ export const relicCatalog = Object.freeze([ "wikiId": "ruined_helmet", "name": "损毁头盔", "description": "你在每场战斗中第一次获得的力量值翻倍。", - "pool": "铁甲战士", + "pool": "刘备", "tier": "稀有", "ancient": null, "order": 118, @@ -2640,7 +2640,7 @@ export const relicCatalog = Object.freeze([ "wikiId": "brimstone", "name": "硫磺", "description": "在你的每个回合开始时,你获得2点力量,所有敌人获得1点力量。", - "pool": "铁甲战士", + "pool": "刘备", "tier": "商店", "ancient": null, "order": 156, diff --git a/mengsan/relics/definitions.js b/mengsan/relics/definitions.js index a4323dd..7272a2d 100644 --- a/mengsan/relics/definitions.js +++ b/mengsan/relics/definitions.js @@ -7,7 +7,7 @@ export const relicAdaptations = Object.freeze({ miniature_tent:"每个休息选项可各选择一次,直至选择离开;不允许无限重复同一选项。", meat_cleaver:"网页没有烹饪细则;暂定每次烹饪最大生命+5,待用户确认。", big_mushroom:"沿用梦三初始手牌4张,扣减2张;未改成STS2的基础手牌数量。", - ring_of_the_drake:"刘备仅映射铁甲战士池;其他角色池需配置player.relicPool才可获取。效果按前三个自身回合各摸2张接入。", + ring_of_the_drake:"刘备默认使用专属遗物池;其他角色池需配置player.relicPool才可获取。效果按前三个自身回合各摸2张接入。", runic_pyramid:"梦三原本按手牌上限弃牌;这里让全部手牌不计入弃牌上限。虚无牌照常消耗。", precarious_shears:"选牌后扣除16点生命;当前地图流程保留至少1点生命,待补充非战斗死亡结算。", fragrant_mushroom:"随机强化2张并扣除15点生命;当前地图流程保留至少1点生命,待补充非战斗死亡结算。", @@ -34,7 +34,7 @@ export const relicDefinitions = Object.freeze(Object.fromEntries(entries.map(ent const aliases = Object.freeze(Object.fromEntries(entries.flatMap(entry => [[entry.id, entry.id], [entry.wikiId, entry.id], [`mengsan_${entry.wikiId}_shuying`, entry.id]]))); export const getRelic = id => Object.hasOwn(aliases, id) ? relicDefinitions[aliases[id]] : null; export const heldRelics = run => [...new Set((run?.player?.items || []).map(id => getRelic(id)?.id).filter(Boolean))].map(getRelic); -export const relicPoolFor = run => run?.player?.relicPool || (run?.player?.character === "mengsan_liubei_shuying" ? "铁甲战士" : null); +export const relicPoolFor = run => run?.player?.relicPool || (run?.player?.character === "mengsan_liubei_shuying" ? "刘备" : null); export function canAcquireRelic(run, id, { allowPending = false } = {}) { const relic = getRelic(id); if (!run?.player || !relic || !allowPending && relic.implementation === "pending") return false; diff --git a/mengsan/tools/import-status-icons.cjs b/mengsan/tools/import-status-icons.cjs new file mode 100644 index 0000000..1126c33 --- /dev/null +++ b/mengsan/tools/import-status-icons.cjs @@ -0,0 +1,24 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { pathToFileURL } = require('node:url'); +(async () => { + const root = path.resolve(__dirname, '..'); + const { statusIconNames } = await import(pathToFileURL(path.join(root, 'ui/hud/status-model.js')).href); + const source = path.join(root, 'assets/sts2/Godot_Atlas_Sprites_v0.111.0/images/atlases/power_atlas.sprites'); + const output = path.join(root, 'assets/status'); + fs.mkdirSync(output, {recursive:true}); + const entries = [], missing = []; + for (const name of statusIconNames) { + if (!/^[a-z0-9_]+\.png$/.test(name)) throw new Error('Invalid icon name'); + if (!fs.existsSync(path.join(source,name))) { missing.push(name);continue; } + const bytes = fs.readFileSync(path.join(source,name)); + if (bytes.subarray(0,8).toString('hex')!=='89504e470d0a1a0a') throw new Error('Invalid PNG: '+name); + const target=path.join(output,name); + if(fs.existsSync(target)&&!fs.readFileSync(target).equals(bytes))throw new Error('Existing icon differs: '+name); + fs.writeFileSync(target,bytes); + entries.push({name,bytes:bytes.length,width:bytes.readUInt32BE(16),height:bytes.readUInt32BE(20),sha256:crypto.createHash('sha256').update(bytes).digest('hex')}); + } + fs.writeFileSync(path.join(output,'manifest.json'),JSON.stringify({sourceVersion:'v0.111.0',source:'images/atlases/power_atlas.sprites',entries,missing},null,2)+'\n'); + console.log(JSON.stringify({icons:entries.length,missing,bytes:entries.reduce((n,e)=>n+e.bytes,0)})); +})().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/mengsan/tools/import-sts2-relics.py b/mengsan/tools/import-sts2-relics.py index b894afc..7388552 100644 --- a/mengsan/tools/import-sts2-relics.py +++ b/mengsan/tools/import-sts2-relics.py @@ -17,6 +17,11 @@ IMAGE_ALIASES = { "forgotten_soul": "lost_soul.png", "looming_fruit": "looming_fruit_2.png", } +# Stable Wiki IDs keep the project's character name when importing raw snapshots. +LIUBEI_RELICS = frozenset({ + "burning_blood", "black_blood", "red_skull", "paper_phrog", + "self_forming_clay", "charons_ashes", "demon_tongue", "ruined_helmet", "brimstone", +}) def main(): @@ -43,7 +48,8 @@ def main(): entries.append({ "id": ALIASES.get(wiki_id, f"mengsan_{wiki_id}_shuying"), "wikiId": wiki_id, "name": row["name"], - "description": row["rendered_description"], "pool": row["pool"], + "description": row["rendered_description"], + "pool": "刘备" if wiki_id in LIUBEI_RELICS else row["pool"], "tier": row["tier"], "ancient": row["ancient"] or None, "order": int(row["compendium_order"]), "image": f"extension/术樱包/mengsan/assets/relics/{wiki_id}.png", @@ -63,7 +69,7 @@ def main(): "snapshot_date": "2026-10-04", "atlas_version": "v0.111.0", "data_source": source["data_source_url"], "data_revision": source["source_revision"], "assets": assets, - "notes": ["美味饼干使用铁甲战士版本;四个其他角色版本仍保留在来源图集中。", + "notes": ["美味饼干使用刘备版本;四个其他角色版本仍保留在来源图集中。", "遗忘之魂映射 lost_soul.png;布质果实映射 looming_fruit_2.png。", "图标是用户提供的原游戏拆包图片,逐字节复制,未重绘。"] }, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/mengsan/tools/test-battle-hud.cjs b/mengsan/tools/test-battle-hud.cjs new file mode 100644 index 0000000..6163b67 --- /dev/null +++ b/mengsan/tools/test-battle-hud.cjs @@ -0,0 +1,336 @@ +// Real browser DOM/CSS with engine services isolated; this is not a full-game test. +const assert = require('node:assert/strict'); +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const app = path.resolve(__dirname, '../../../..'); +const packages = process.env.CODEX_NODE_MODULES || 'C:/Users/Diuse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'; +const { chromium } = require(path.join(packages, 'playwright')); +const prefix = '/extension/' + encodeURIComponent('术樱包') + '/mengsan'; +// Native themes use different button sizes. Drag onto an exposed point on the +// original player, rather than assuming its center is clear in the dense fixture. +const exposedTarget = (page,index) => page.evaluate(index => { + const player=game.players[index],rect=player.getBoundingClientRect(); + for(const y of [.5,.15,.85,.05,.95])for(const x of [.5,.15,.85]) { + const px=rect.left+rect.width*x,py=rect.top+rect.height*y; + if(document.elementFromPoint(px,py)?.closest('.player')===player)return {x:px,y:py,width:0,height:0}; + } + throw new Error('Native target has no exposed drag point: '+index); +},index); +const fixture = `测试技能结束出牌`; +const server = http.createServer((req, res) => { + if (req.url === '/fixture') { res.setHeader('Content-Type','text/html;charset=utf-8');res.end(fixture);return; } + if(req.url==='/noname.js'){res.setHeader('Content-Type','text/javascript');res.end('export const _status={};');return;} + if(decodeURIComponent(new URL(req.url,'http://localhost').pathname)==='/extension/术樱包/diagnostics/index.js') { + res.setHeader('Content-Type','text/javascript');res.end('export const getDiagnostics=()=>({status:()=>({enabled:false}),scope:()=>({info(){},error(){}})});');return; + } + const target = path.resolve(app, '.' + decodeURIComponent(new URL(req.url, 'http://localhost').pathname)); + if (!target.startsWith(app + path.sep)) { res.writeHead(403);res.end();return; } + const types = { '.js':'text/javascript', '.css':'text/css', '.png':'image/png' }; + fs.readFile(target, (error, bytes) => { if(error){res.writeHead(404);res.end();return;}res.setHeader('Content-Type',types[path.extname(target)]||'text/plain');res.end(bytes); }); +}); +(async () => { + await new Promise(resolve => server.listen(0,'127.0.0.1',resolve)); + const origin = 'http://127.0.0.1:' + server.address().port; + const browser = await chromium.launch({channel:'msedge',headless:true}); + let checks = 0; + try { + for (const skin of process.env.HUD_TEST_SKIN?[process.env.HUD_TEST_SKIN]:['native','shousha','shizhounian','xinsha']) { + for (const viewport of [{width:1280,height:720},{width:1234,height:920},{width:812,height:600},{width:812,height:375},{width:667,height:375},{width:375,height:500},{width:540,height:900}]) { + const page = await browser.newPage({viewport,hasTouch:true}); + await page.goto(origin+'/fixture'); + await page.addStyleTag({content:'body{background:#161e29 url("/image/background/lanting_bg.jpg") center/cover no-repeat}'}); + await page.addStyleTag({url:origin+prefix+'/ui/style.css'}); + if(skin!=='native') { + for(const css of ['/extension/'+encodeURIComponent('十周年UI')+'/src/styles/layout.css','/extension/'+encodeURIComponent('十周年UI')+'/ui/styles/base.css','/extension/'+encodeURIComponent('十周年UI')+'/ui/styles/skill/'+skin+'.css','/extension/'+encodeURIComponent('十周年UI')+'/ui/styles/lbtn/'+skin+'.css'])await page.addStyleTag({url:origin+css}); + await page.addStyleTag({url:origin+'/extension/'+encodeURIComponent('十周年UI')+'/src/styles/player'+({shousha:2,shizhounian:1,xinsha:3}[skin])+'.css'}); + await page.addStyleTag({url:origin+'/extension/'+encodeURIComponent('十周年UI')+'/src/styles/equip.css'}); + } + await page.addStyleTag({url:origin+prefix+'/ui/hand/style.css'}); + const initial = await page.evaluate(async ({prefix,skin}) => { + const mod = await import(prefix+'/ui/hud/index.js').catch(error=>({error:String(error)})); + if(typeof mod.mountBattleHUD!=='function')return {module:false,error:mod.error}; + const releases=[];window.session={id:'hud-test',active:true,ownResource:(_,f)=>releases.push(f)}; + window.releaseAll=()=>releases.reverse().forEach(f=>f()); + const arena=document.getElementById('arena'), hand=document.getElementById('me'), control=document.getElementById('control'); + if(skin!=='native') { + // Match create.js and player-element.js, including the renamed container and nested wraps. + control.id='dui-controls';hand.classList.add('hand-wrap');arena.classList.add('decadeUI'); + arena.dataset.mode='identity';arena.dataset.outcropSkin='shizhounian';arena.dataset.playerHeight='default'; + if(window.innerHeight<=500||window.innerWidth<=600)arena.classList.add('dui-mobile'); + control.firstElementChild.classList.add('stayleft'); + } + document.body.classList.add('mengsan-hand-ui-shuying');control.firstElementChild.style.opacity='1'; + [...control.firstElementChild.children].forEach((node,index)=>{ + node.classList.add(index?'primary2':'primary'); + const label=index?'回合结束':'确定'; + if(skin==='native')node.textContent=label;else {const span=document.createElement('span');span.textContent=label;node.replaceChildren(span);} + node.addEventListener('click',()=>{window.nativeActionClicks=(window.nativeActionClicks||0)+1;}); + }); + const players=[]; + for(let i=0;i<8;i++) { + const player=document.createElement('div');player.className='player';player.dataset.position=String(i);player.playerid=String(i);player.name=i?'enemy'+i:'liubei';player.hp=40;player.maxHp=60;player.hujia=8; + player.storage={mengsanCamp_shuying:i?'enemy':'ally',mengsanStrength_shuying:i===1?3:0,mengsanVulnerable_shuying:i===2?2:0,mengsanEnergy_shuying:3,mengsanMaxEnergy_shuying:3}; + player.innerHTML=skin==='native' ? ''+(i?'敌人'+i:'刘备')+'40/60' : '回合开始汉'+(i?'敌人'+i:'刘备')+'40/608'; + if(i)player.insertAdjacentHTML('beforeend','攻击 8×2'); + player.node={avatar:player.querySelector('.avatar,.primary-avatar'),hp:player.querySelector('.hp'),hpWrap:player.querySelector('.hp-wrap'),campWrap:player.querySelector('.camp-wrap'),name:player.querySelector('.name,.avatar-name'),identity:player.querySelector('.identity'),framebg:player.querySelector('.framebg'),count:player.querySelector('.card-count'),marks:player.querySelector('.marks,.dui-marks')}; + player.node.avatar.style.backgroundImage='url("/image/character/'+(i?'caocao':'liubei')+'.jpg")'; + if(i){player.style.cssText=`left:${(i-1)%4*21+5}%;top:${68+Math.floor((i-1)/4)*96}px;width:68px;height:82px;transform:none`;} + const equips=document.createElement('div');equips.className='equips';player.node.equips=equips;player.appendChild(equips); + if(i===0||i===1)for(let j=0;j<(i===0?5:2);j++){ + const equip=document.createElement('div');equip.className='card equip'+(j+1);equip.innerHTML='♠2 '+(['八卦阵','的卢','青釭剑','赤兔','木牛'][j])+''; + equip.addEventListener('click',e=>{e.stopPropagation();window.equipClicks=(window.equipClicks||0)+1;});equips.appendChild(equip); + } + player.getCards=zone=>zone==='e'?[...equips.children]:[]; + player.marks={};player.hasSkill=()=>false;player.getSkills=()=>[];player.isAlive=()=>player.hp>0;arena.appendChild(player);players.push(player); + } + const me=players[0];window.game={me,players,dead:[]};window.battle={session,players:new Set(players),owner:me}; + const nativeMark=document.createElement('div');nativeMark.className='mark';nativeMark.textContent='义';me.node.marks.appendChild(nativeMark);window.testNativeMark=nativeMark; + nativeMark.addEventListener('click',event=>{ + event.stopPropagation();window.markIntroOpens=(window.markIntroOpens||0)+1; + const box=me.getBoundingClientRect(),tip=document.createElement('div');tip.className='dialog popped static'; + tip.style.cssText=`position:absolute;left:${box.right-20}px;top:${box.top-30}px;width:236px;height:160px;padding:16px;z-index:21`; + tip.innerHTML='桃园羁绊先天结识关羽、张飞。保留原生标记说明内容与关闭回调。'; + const layer=document.createElement('div');layer.className='poplayer';layer.style.cssText='position:absolute;inset:0'; + const close=()=>{tip.remove();layer.remove();window.markIntroClosed=true;};layer.addEventListener('click',close);tip._close=close; + ui.window.append(tip,layer);window.testMarkIntro=tip; + }); + window.hudEventState={currentPhase:me,event:{name:'chooseToUse',player:me,isMine:()=>true}}; + window.ui={arena,me:hand,mebg:document.getElementById('mebg'),control,window:document.getElementById('window'),handcards1Container:document.getElementById('handcards1'),handcards2Container:document.getElementById('handcards2'),selected:{cards:[],targets:[],buttons:[]},click:{}}; + if(skin==='xinsha') { + const host=document.createElement('div');host.className='equips-wrap';host.style.cssText='position:absolute;left:150px;top:0;width:130px;height:100%'; + host.innerHTML='装备槽';arena.insertBefore(host,hand);host.appendChild(me.node.equips);ui.equipSolts=host; + } + window.originalEquipParent=me.node.equips.parentNode; + me.node.handcards1=ui.handcards1Container.firstElementChild;me.node.handcards2=ui.handcards2Container.firstElementChild; + const get={translation:value=>value==='liubei'?'刘备':String(value).replace(/^enemy/,'敌人'),select:value=>value}; + const card=document.createElement('div');card.className='card selectable';card.textContent='杀';card.name='sha';me.node.handcards1.appendChild(card);window.card=card; + card.addEventListener('contextmenu',event=>{ + event.preventDefault();window.introOpens=(window.introOpens||0)+1; + const tip=document.createElement('div');tip.className='dialog popped static'; + const rect=card.getBoundingClientRect();tip.style.cssText=`position:absolute;left:${rect.left}px;top:${rect.top-30}px;width:236px;height:160px;z-index:21;padding:16px`; + tip.innerHTML='杀 · 卡牌说明对选定敌人造成伤害。说明保留原生内容与关闭操作。'; + const layer=document.createElement('div');layer.className='poplayer';layer.style.cssText='position:absolute;inset:0'; + layer.addEventListener('click',()=>{tip.remove();layer.remove();window.nativeIntroClosed=true;}); + ui.window.append(tip,layer);window.testIntro=tip; + }); + window.oldParents={hand:hand.parentNode,control:control.parentNode,card:card.parentNode}; + window.nativeActionGroup=control.firstElementChild;window.nativeActionButtons=[...nativeActionGroup.children]; + window.actionPaint=node=>{const s=getComputedStyle(node);return Object.fromEntries(['backgroundImage','backgroundColor','borderImageSource','borderTopColor','borderTopStyle','borderTopWidth','borderTopLeftRadius','color','fontFamily','fontSize','fontWeight','textShadow','boxShadow','filter','paddingTop','paddingRight','paddingBottom','paddingLeft'].map(key=>[key,s[key]]));}; + // Decade animates the initial class/theme change; compare settled native styles. + await new Promise(resolve=>setTimeout(resolve,650)); + window.originalActionPaint=[nativeActionGroup,...nativeActionButtons].map(actionPaint); + window.originalActionHTML=nativeActionGroup.innerHTML; + window.originalEnemyPresentation=players.slice(1).map(p=>({style:p.style.cssText,classes:p.className,html:p.innerHTML})); + window.hud=mod.mountBattleHUD(me,battle,{ui,game,get,lib:{skill:{},translate:{}},_status:hudEventState,document,window,assetBase:prefix}); + const {mountRelicStrip}=await import(prefix+'/ui/relic-strip.js'); + window.testRelics=[];window.testStrip=mountRelicStrip(arena,()=>testRelics,relic=>{window.lastRelicOpened=relic.id;},()=>{ + const icon=document.createElement('img');icon.src=prefix+'/assets/status/strength_power.png';return icon; + },document,window);releases.push(()=>testStrip.dispose()); + let submitted=0;game.check=()=>ui.selected.cards.length===1&&ui.selected.targets.length===1;game.uncheck=()=>{ui.selected.cards=[];ui.selected.targets=[];}; + ui.click.card=function(){ui.selected.cards=[this];players.slice(1).forEach(p=>p.classList.add('selectable'));};ui.click.target=function(){ui.selected.targets=[this];};ui.click.ok=()=>{submitted++;game.uncheck();}; + game.isSubmitted=()=>submitted;const event=hudEventState.event;event.selectTarget=[1,1];event.selectCard=[1,1]; + const {mountHandUI}=await import(prefix+'/ui/hand/index.js'); + window.testHandUI=await mountHandUI(me,session,{ui,game,get,lib:{card:{},translate:{}},_status:hudEventState,document,window,isActiveCardUse:()=>true,canPayCard:()=>true,cardCost:()=>1,styleURL:prefix+'/ui/hand/style.css'}); + window.testInput={owns:()=>false};hud.refresh();window.testFan={sync:()=>testHandUI.refresh()}; + return {module:true,parentKept:hand.parentNode===oldParents.hand&&control.parentNode===oldParents.control&&card.parentNode===oldParents.card,skin}; + },{prefix,skin}); + assert.equal(initial.module,true,initial.error||'HUD mount module must exist');checks++; + assert.equal(initial.parentKept,true);checks++; + await page.waitForTimeout(70); + await page.evaluate(()=>testFan.sync(true)); + const corners=await page.evaluate(()=>({portrait:parseFloat(getComputedStyle(game.me.node.avatar).borderTopLeftRadius)})); + assert.ok(corners.portrait>=12,'portrait retains rounded edges: '+JSON.stringify(corners));checks++; + const glass=await page.locator('.mengsan-hud-shuying').evaluate(n=>{const s=getComputedStyle(n);return {background:s.backgroundColor,opacity:s.opacity,blur:s.backdropFilter};}); + assert.equal(glass.background,'rgba(0, 0, 0, 0)','footer is completely transparent');checks++; + assert.equal(glass.blur,'none','footer has no background blur');checks++; + assert.equal(glass.opacity,'1','text and buttons retain full opacity');checks++; + const equipLayout=await page.evaluate(()=>{ + const p=game.me.getBoundingClientRect(),e=game.me.node.equips.getBoundingClientRect(),enemy=game.players[1],p2=enemy.getBoundingClientRect(),e2=enemy.node.equips.getBoundingClientRect(); + return {self:p.toJSON(),selfEquips:e.toJSON(),enemy:p2.toJSON(),enemyEquips:e2.toJSON()}; + }); + assert.ok(equipLayout.selfEquips.top>=equipLayout.self.top&&equipLayout.selfEquips.bottom<=equipLayout.self.bottom&&equipLayout.selfEquips.left>=equipLayout.self.left&&equipLayout.selfEquips.right<=equipLayout.self.right,'equipment overlays the enlarged portrait: '+JSON.stringify(equipLayout));checks++; + assert.ok(equipLayout.selfEquips.bottom<=viewport.height-8,'equipment stays inside the player portrait');checks++; + assert.equal(await page.evaluate(()=>getComputedStyle(game.me.node.equips).flexDirection),'column','equipment is stacked vertically over the portrait');checks++; + try { await page.locator('.mengsan-hud-self-equips-shuying>.card').first().click({timeout:3000}); } + catch(error) { console.log(await page.evaluate(()=>[game.me.node.equips.firstElementChild,game.me.node.equips,game.me.node.equips.parentNode,ui.arena].map(n=>({cls:n.className,rect:n.getBoundingClientRect().toJSON(),style:n.style.cssText,overflow:getComputedStyle(n).overflow,scroll:[n.scrollLeft,n.scrollTop]}))));throw error; } + assert.equal(await page.evaluate(()=>window.equipClicks),1,'real equipment callback is preserved');checks++; + assert.equal(await page.evaluate(()=>game.me.node.equips.parentNode===originalEquipParent),true,'optional separate equipment wrapper keeps its real parent');checks++; + await page.evaluate(()=>{const cards=game.me.node.equips.children;cards[4].classList.add('selectable');cards[4].scrollIntoView({block:'nearest',inline:'nearest'});}); + await page.locator('.mengsan-hud-self-equips-shuying>.card').last().click(); + assert.equal(await page.evaluate(()=>window.equipClicks),2,'last of five equipped cards remains reachable');checks++; + const layout=await page.evaluate(()=>{ + const rect=n=>{const r=n.getBoundingClientRect();return {x:r.x,y:r.y,right:r.right,bottom:r.bottom,width:r.width,height:r.height};}; + return {hud:rect(document.querySelector('.mengsan-hud-shuying')),hand:rect(ui.me),control:rect(ui.control),players:game.players.slice(1).map(rect),statusCount:document.querySelectorAll('.mengsan-unit-status-shuying').length, + statusRow:rect(document.querySelector('.mengsan-hud-statuses-shuying'))}; + }); + assert.equal(layout.statusCount,0,'enemy information is not replaced by the player HUD');checks++; + assert.ok(layout.control.bottom<=layout.hand.y-7,'horizontal actions sit above the hand area');checks++; + assert.equal(await page.evaluate(()=>game.players.slice(1).every((p,i)=>p.style.cssText===originalEnemyPresentation[i].style&&p.innerHTML===originalEnemyPresentation[i].html&&p.className===originalEnemyPresentation[i].classes)),true,'enemy and ally DOM, inline positions and classes stay native');checks++; + assert.equal(await page.evaluate(()=>{const p=game.me.getBoundingClientRect(),s=document.querySelector('.mengsan-hud-info-shuying').getBoundingClientRect();return p.right<=s.left||p.bottom<=s.top||p.left>=s.right||p.top>=s.bottom;}),true,'enlarged portrait clears the vertical statistics');checks++; + assert.equal(await page.evaluate(()=>{const p=game.me.getBoundingClientRect(),s=document.querySelector('.mengsan-hud-info-shuying').getBoundingClientRect();return ui.arena.dataset.mengsanHudLayout==='narrow'?p.bottom<=s.top&&s.top-p.bottom<32:s.left-p.right>=0&&s.left-p.right<=16;}),true,'statistics stay close to the portrait');checks++; + assert.equal(await page.locator('.mengsan-hud-center-shuying').isVisible(),false,'empty status area is hidden');checks++; + assert.equal(await page.locator('.mengsan-hud-shuying').innerText().then(t=>/当前状态|暂无状态|选择行动|等待行动/.test(t)),false,'status headings and action filler text are absent');checks++; + assert.deepEqual(await page.evaluate(()=>[nativeActionGroup,...nativeActionButtons].map(actionPaint)),await page.evaluate(()=>originalActionPaint),'native action group and button paint, typography and padding stay unchanged');checks++; + assert.equal(await page.evaluate(()=>ui.control.firstElementChild===nativeActionGroup&&nativeActionGroup.innerHTML===originalActionHTML&&nativeActionButtons.every((node,i)=>nativeActionGroup.children[i]===node)),true,'real native action nodes and contents are retained');checks++; + assert.equal(await page.locator('.mengsan-hud-inspect-shuying').count(),0,'skills and equipment proxy button is removed');checks++; + await page.locator('.mengsan-hud-controls-shuying>.control>div').first().click(); + await page.locator('.mengsan-hud-controls-shuying>.control>div').nth(1).click(); + assert.equal(await page.evaluate(()=>nativeActionClicks),2,'both real native confirm and end-turn callbacks remain usable');checks++; + assert.equal(await page.evaluate(()=>nativeActionButtons.every(n=>n.getBoundingClientRect().bottom<=ui.me.getBoundingClientRect().top-4)),true,'native button dimensions remain fully above the hand');checks++; + await page.evaluate(()=>{for(let i=0;i<6;i++)testNativeMark.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}));}); + await page.waitForTimeout(60); + await page.evaluate(()=>{for(let i=0;i<3;i++)testNativeMark.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}));}); + const markIntro=await page.evaluate(()=>({opens:markIntroOpens,count:ui.window.querySelectorAll('.dialog.popped').length,layers:ui.window.querySelectorAll('.poplayer').length,layerClass:ui.window.querySelector('.poplayer').className,layerStyle:ui.window.querySelector('.poplayer').style.cssText,box:testMarkIntro.getBoundingClientRect().toJSON(),portrait:game.me.getBoundingClientRect().toJSON(),info:document.querySelector('.mengsan-hud-info-shuying').getBoundingClientRect().toJSON(),z:getComputedStyle(testMarkIntro).zIndex,layerZ:getComputedStyle(ui.window.querySelector('.poplayer')).zIndex})); + assert.ok(markIntro.opens===1&&markIntro.count===1&&markIntro.layers===1,'rapid mark clicks keep one native popup and one backdrop: '+JSON.stringify(markIntro));checks++; + assert.ok(markIntro.box.bottom<=Math.min(markIntro.portrait.top,markIntro.info.top)&&markIntro.box.x>=8&&markIntro.box.right<=viewport.width-8,'mark details clear the player portrait and statistics: '+JSON.stringify(markIntro));checks++; + assert.ok(Number(markIntro.z)>Number(markIntro.layerZ)&&Number(markIntro.layerZ)>31,'native mark popup and backdrop draw above player and skills: '+JSON.stringify(markIntro));checks++; + if(viewport.width===1234){await page.waitForTimeout(350);await page.screenshot({path:path.join(process.env.HUD_ARTIFACTS||__dirname,'hud-mark-description-'+skin+'.png')});} + await page.locator('.poplayer').click({position:{x:4,y:4}}); + assert.equal(await page.evaluate(()=>markIntroClosed),true,'native mark close callback is preserved');checks++; + await page.evaluate(()=>testNativeMark.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}))); + await page.waitForTimeout(30); + assert.equal(await page.evaluate(()=>markIntroOpens),2,'mark details can reopen after closing');checks++; + await page.locator('.poplayer').click({position:{x:4,y:4}}); + assert.equal(await page.locator('.mengsan-hand-relics-shuying').isVisible(),false,'empty relic strip stays hidden');checks++; + const nativeSkills=await page.evaluate(()=>{ + const skills=document.createElement('div');skills.className='control';skills.innerHTML='原生技能甲原生技能乙'; + ui.control.appendChild(skills);ui.skills=skills;window.testNativeSkills=skills; + skills.firstElementChild.addEventListener('click',()=>window.nativeSkillUsed=true);hud.refresh(); + const boxes=[...skills.children].map(n=>n.getBoundingClientRect()); + const rail=skills.getBoundingClientRect();return {vertical:boxes[0].bottom<=boxes[1].top,right:rail.left>=ui.me.getBoundingClientRect().right,contained:boxes.every(r=>r.left>=rail.left-1&&r.right<=rail.right+1)}; + }); + assert.ok(nativeSkills.vertical&&nativeSkills.right&&nativeSkills.contained,'native skill references stay vertically on the right without moving their parent or clipping buttons: '+JSON.stringify(nativeSkills));checks++; + await page.getByText('原生技能甲',{exact:true}).click(); + assert.equal(await page.evaluate(()=>window.nativeSkillUsed),true,'native skill callbacks survive the separate rail');checks++; + await page.evaluate(()=>{testNativeSkills.remove();delete ui.skills;hud.refresh();}); + const relicGeometry=await page.evaluate(()=>{ + testRelics=[{id:'test',name:'遗物'}];testStrip.refresh(); + const strip=document.querySelector('.mengsan-hand-relics-shuying').getBoundingClientRect(),icon=document.querySelector('.mengsan-hand-relics-shuying button').getBoundingClientRect(); + return {strip:strip.toJSON(),icon:icon.toJSON()}; + }); + assert.ok(relicGeometry.icon.y>=relicGeometry.strip.y&&relicGeometry.icon.bottom<=relicGeometry.strip.bottom,'top relic icon fits without clipping');checks++; + assert.ok(relicGeometry.strip.width<=142,'relic area stays compact above the right skill rail');checks++; + assert.ok(relicGeometry.strip.x>=viewport.width-166,'relic area is on the right');checks++; + assert.ok(Math.abs(relicGeometry.strip.bottom-(viewport.height-Number.parseFloat(await page.locator('#arena').evaluate(n=>n.style.getPropertyValue('--mengsan-relic-dock-bottom')))))<2,'relic area aligns with the side dock, with a compact narrow-screen offset');checks++; + await page.evaluate(()=>{testRelics=Array.from({length:10},(_,i)=>({id:'r'+i,name:'遗物'+i}));testStrip.refresh();hud.refresh();}); + const battlefieldClear=await page.evaluate(()=>{ + const strip=document.querySelector('.mengsan-hand-relics-shuying').getBoundingClientRect(); + const intersects=(a,b)=>a.leftb.left&&a.topb.top; + return {clear:[...document.querySelectorAll('.player>.mengsan-intent-shuying')].every(n=>!intersects(n.getBoundingClientRect(),strip)),strip:strip.toJSON(),intents:[...document.querySelectorAll('.player>.mengsan-intent-shuying')].map(n=>n.getBoundingClientRect().toJSON())}; + }); + assert.equal(battlefieldClear.clear,true,'a full relic strip must not cover enemy intent: '+JSON.stringify(battlefieldClear));checks++; + await page.locator('.mengsan-hand-relics-shuying button').last().focus(); + await page.locator('.mengsan-hand-relics-shuying button').last().press('Enter'); + assert.equal(await page.evaluate(()=>lastRelicOpened),'r9','overflow relics retain keyboard access to details');checks++; + await page.evaluate(()=>{testRelics=[{id:'test',name:'遗物'}];testStrip.refresh();}); + const start=await page.locator('.handcards>.card').boundingBox(),target=await exposedTarget(page,3); + await page.mouse.move(start.x+start.width/2,start.y+30);await page.mouse.down(); + await page.mouse.move(target.x+target.width/2,target.y+target.height/2,{steps:8});await page.mouse.up(); + assert.equal(await page.evaluate(()=>game.isSubmitted()),1,'single target drag submits once');checks++; + if(skin!=='native') { + await page.evaluate(()=>{ + const skills=document.createElement('div');skills.className='skill-control';skills.innerHTML='主动技能';ui.arena.appendChild(skills);ui.skillControl=skills; + skills.querySelector('.skillitem').addEventListener('click',()=>{hudEventState.event.skill='test-skill';});hud.refresh(); + }); + await page.locator('.skillitem').click(); + assert.equal(await page.evaluate(()=>hudEventState.event.skill),'test-skill');checks++; + assert.notEqual(await page.locator('.skillitem').evaluate(n=>getComputedStyle(n).webkitTextFillColor),'rgba(0, 0, 0, 0)','skin gradient text remains readable after reskinning');checks++; + assert.equal(await page.evaluate(()=>ui.skillControl.getBoundingClientRect().left>=ui.me.getBoundingClientRect().right),true,'vertical skills stay to the right of the hand');checks++; + await page.evaluate(()=>{ + const globalSkills=document.createElement('div');globalSkills.className='gskill-control';globalSkills.innerHTML='仁德'; + ui.arena.appendChild(globalSkills);ui.gskillControl=globalSkills;hud.refresh(); + }); + const rails=await page.evaluate(()=>({skills:ui.skillControl.getBoundingClientRect().toJSON(),global:ui.gskillControl.getBoundingClientRect().toJSON(),native:ui.control.getBoundingClientRect().toJSON(),hud:document.querySelector('.mengsan-hud-shuying').getBoundingClientRect().toJSON()})); + assert.ok(rails.global.bottom<=rails.skills.top-4&&rails.global.top>=rails.hud.top,'optional global skill controls stay inside the HUD without overlap: '+JSON.stringify(rails));checks++; + await page.evaluate(()=>{delete hudEventState.event.skill;}); + assert.equal(await page.evaluate(()=>ui.arena.scrollLeft===0&&ui.arena.scrollTop===0),true,'skill controls must not scroll the arena');checks++; + } + const beforeTouch = await page.evaluate(()=>game.isSubmitted()); + const touchCard = await page.locator('.handcards>.card').boundingBox(); + await page.evaluate(({start,target})=>{ + const send=(host,type,x,y)=>host.dispatchEvent(new PointerEvent(type,{bubbles:true,cancelable:true,pointerId:17,pointerType:'touch',isPrimary:true,button:0,clientX:x,clientY:y})); + const x=start.x+start.width/2,y=start.y+25,tx=target.x+target.width/2,ty=target.y+target.height/2; + send(card,'pointerdown',x,y);send(window,'pointermove',tx,ty);send(window,'pointerup',tx,ty); + },{start:touchCard,target:await exposedTarget(page,2)}); + const afterTouch=await page.evaluate(()=>({submitted:game.isSubmitted(),selected:ui.selected.cards.length,targets:ui.selected.targets.length,eventSkill:hudEventState.event.skill,classes:card.className,owns:testInput.owns(),hand:ui.me.getBoundingClientRect().toJSON(),card:card.getBoundingClientRect().toJSON()})); + if(afterTouch.submitted!==beforeTouch+1){afterTouch.geometry=await page.evaluate(()=>['window','arena','me','control'].map(id=>{const n=id==='control'?ui.control:document.getElementById(id),s=getComputedStyle(n);return {id,rect:n.getBoundingClientRect().toJSON(),css:{transform:s.transform,zoom:s.zoom,left:s.left,top:s.top,width:s.width,height:s.height,translate:s.translate},inline:n.style.cssText};}));await page.screenshot({path:path.join(process.env.HUD_ARTIFACTS||__dirname,'hud-failure.png')});} + assert.equal(afterTouch.submitted,beforeTouch+1,'touch pointer drag submits once: '+JSON.stringify(afterTouch));checks++; + await page.evaluate(()=>{ + const group=document.createElement('div');group.className='control';group.style.opacity='1'; + const button=document.createElement('div');button.textContent='动态技能';button.addEventListener('click',()=>{window.dynamicSkillUsed=true;});group.appendChild(button);ui.control.replaceChildren(group);hud.refresh(); + }); + await page.getByText('动态技能',{exact:true}).click(); + assert.equal(await page.evaluate(()=>window.dynamicSkillUsed),true,'new native controls retain their callbacks');checks++; + await page.evaluate(()=>{game.players[7].hp=0;hud.refresh();}); + assert.equal(await page.evaluate(()=>game.players[7].style.cssText===originalEnemyPresentation[6].style&&!game.players[7].classList.contains('mengsan-hud-defeated-shuying')),true,'death keeps the original battlefield presentation');checks++; + await page.evaluate(()=>{game.players[7].hp=40;game.me.storage.mengsanVulnerable_shuying=3;hud.refresh();}); + assert.match(await page.locator('.mengsan-hud-statuses-shuying').innerText(),/3/);checks++; + await page.evaluate(()=>{for(const key of ['Weak','Frail','Artifact','Constrict','Tangled','Shrink','Plow','Slippery'])game.me.storage['mengsan'+key+'_shuying']=2;hud.refresh();}); + assert.equal(await page.evaluate(()=>{const row=document.querySelector('.mengsan-hud-statuses-shuying').getBoundingClientRect();return [...document.querySelectorAll('.mengsan-hud-statuses-shuying button')].every(n=>{const r=n.getBoundingClientRect();return r.bottom<=row.bottom+1&&r.right<=row.right+1;});}),true,'matrix overflow keeps the visible statuses and more button inside the grid');checks++; + await page.getByRole('button',{name:'查看全部状态',exact:true}).click(); + assert.ok(await page.getByRole('dialog').isVisible(),'overflow states remain available in the details');checks++; + await page.getByRole('button',{name:'关闭',exact:true}).click(); + assert.equal(await page.evaluate(()=>{const s=document.querySelector('.mengsan-hud-center-shuying').getBoundingClientRect(),p=game.me.getBoundingClientRect();return s.bottom<=p.top&&p.top-s.bottom<=13;}),true,'status chips automatically appear just above the portrait');checks++; + await page.locator('.mengsan-hud-statuses-shuying button').first().click(); + assert.ok(await page.locator('dialog.mengsan-hud-dialog-shuying').isVisible());checks++; + await page.getByRole('button',{name:'关闭',exact:true}).click(); + await page.locator('.mengsan-hud-statuses-shuying button').first().tap(); + assert.ok(await page.locator('dialog.mengsan-hud-dialog-shuying').isVisible(),'touch opens the player status details');checks++; + await page.getByRole('button',{name:'关闭',exact:true}).click(); + await page.evaluate(()=>game.uncheck()); + const prompt=await page.evaluate(()=>{ + const n=document.createElement('div');n.className='dialog';n.innerHTML='请弃置两张手牌';ui.arena.appendChild(n);ui.dialog=n;hudEventState.event.dialog=n;window.testPrompt=n;hud.refresh(); + return {r:n.getBoundingClientRect().toJSON(),control:ui.control.getBoundingClientRect().toJSON()}; + }); + assert.ok(prompt.r.bottom<=prompt.control.top-5,'native selection prompt stays above the horizontal action row');checks++; + await page.locator('.handcards>.card').first().click({button:'right'}); + await page.waitForTimeout(80); + const intro=await page.evaluate(()=>({opened:introOpens,box:testIntro.getBoundingClientRect().toJSON(),content:testIntro.querySelector('.content').getBoundingClientRect().toJSON(),hand:ui.me.getBoundingClientRect().toJSON(),prompt:testPrompt.getBoundingClientRect().toJSON(),z:Number(getComputedStyle(testIntro).zIndex),scroll:getComputedStyle(testIntro).overflowY})); + assert.equal(intro.opened,1,'right click still invokes the original card introduction');checks++; + assert.ok(intro.box.height>=40,'native absolute content wrapper does not collapse the description');checks++; + assert.ok(intro.content.height>20&&intro.content.top>=intro.box.top&&intro.content.top=8&&intro.box.right<=viewport.width-8,'description stays above hand and prompt, inside the viewport: '+JSON.stringify(intro));checks++; + assert.ok(intro.z>24&&intro.scroll==='auto','description draws above cards and scrolls when height is limited');checks++; + if(viewport.width===1234){await page.waitForTimeout(350);await page.screenshot({path:path.join(process.env.HUD_ARTIFACTS||__dirname,'hud-card-description-'+skin+'.png')});} + await page.locator('.poplayer').click({position:{x:4,y:4}}); + assert.equal(await page.evaluate(()=>window.nativeIntroClosed),true,'original popup backdrop still closes the description');checks++; + await page.evaluate(()=>{testPrompt.remove();delete ui.dialog;delete hudEventState.event.dialog;hud.refresh();}); + if(viewport.width===1234||skin==='native'&&viewport.width===812) { + await page.evaluate(()=>{ + for(let i=0;i<6;i++) { + const c=i===0?card:card.cloneNode(true);c.textContent=['杀','八卦阵','杀','桃','杀','无懈可击'][i];c.style.background='#e9dfc9 url("/image/card/'+(['sha','bagua','sha','tao','sha','wuxie'][i])+'.png") center/100% 100%'; + if(i)game.me.node.handcards1.appendChild(c); + } + if(ui.arena.clientWidth>1000){game.players.slice(4).forEach(p=>{p.hp=0;p.remove();battle.players.delete(p);});delete game.me.storage.mengsanVulnerable_shuying;} + testFan.sync(true);hud.refresh(); + }); + await page.waitForTimeout(180); + const fullCards=await page.locator('.handcards>.card').evaluateAll(nodes=>nodes.map(n=>n.getBoundingClientRect().toJSON())); + assert.ok(fullCards.every(r=>r.bottom<=viewport.height-8),'rotated hand cards remain fully above the bottom edge: '+JSON.stringify(fullCards));checks++; + await page.screenshot({path:path.join(process.env.HUD_ARTIFACTS||__dirname,'hud-transparent-'+skin+'-'+viewport.width+'x'+viewport.height+'.png')}); + } + const popup=await page.evaluate(()=>{ + const n=document.createElement('div');n.className='damage skill-popup';n.style.cssText=`position:absolute;left:${game.me.offsetLeft}px;top:${game.me.offsetTop+80}px;width:${game.me.offsetWidth}px;z-index:9999`; + for(const char of '回合开始'){const s=document.createElement('span');s.textContent=char;n.appendChild(s);}window.testPopup=n;window.testPopupTop=n.style.top;ui.arena.appendChild(n);hud.refresh(); + const rect=n.getBoundingClientRect(),footer=document.querySelector('.mengsan-hud-shuying').getBoundingClientRect(); + return {bottom:rect.bottom,footer:footer.top}; + }); + assert.ok(popup.bottom<=popup.footer-4,'skin notification stays above the command footer: '+JSON.stringify(popup));checks++; + await page.evaluate(()=>{testHandUI.freeze();hud.freeze();session.active=false;testHandUI.refresh();hud.refresh();}); + await page.waitForTimeout(240); + assert.equal(await page.evaluate(()=>document.querySelector('.mengsan-hud-shuying')!==null&&game.me.classList.contains('mengsan-hud-self-shuying')&&game.me.node.handcards1.classList.contains('mengsan-hand-layout-shuying')),true,'stopped battle retains player HUD and fan until reward cleanup');checks++; + await page.evaluate(()=>{hud.dispose();hud.dispose();}); + assert.equal(await page.evaluate(()=>game.me.node.equips.parentNode===originalEquipParent&&!game.me.node.equips.classList.contains('mengsan-hud-equips-shuying')&&!game.me.node.equips.style.getPropertyValue('top')),true,'equipment ownership and styles restore');checks++; + assert.equal(await page.evaluate(()=>!testPopup.classList.contains('mengsan-hud-notification-shuying')&&testPopup.style.top===testPopupTop),true,'notification placement is restored when HUD ends');checks++; + assert.equal(await page.locator('.mengsan-hud-shuying').count(),0);checks++; + assert.equal(await page.evaluate(()=>ui.me.parentNode===oldParents.hand&&ui.control.parentNode===oldParents.control&&!document.body.classList.contains('mengsan-hud-active-shuying')),true);checks++; + assert.equal(await page.evaluate(()=>game.players.slice(1).every((p,i)=>p.style.cssText===originalEnemyPresentation[i].style)),true,'native actor styles survive final cleanup');checks++; + await page.evaluate(()=>releaseAll()); + console.log(skin+' '+viewport.width+'x'+viewport.height+' passed'); + await page.close(); + } + } + console.log('HUD browser checks: '+checks); + } finally {await browser.close();await new Promise(resolve=>server.close(resolve));} +})().catch(error=>{console.error(error);server.close();process.exitCode=1;}); diff --git a/mengsan/tools/test-hud-status.cjs b/mengsan/tools/test-hud-status.cjs new file mode 100644 index 0000000..aacd2c6 --- /dev/null +++ b/mengsan/tools/test-hud-status.cjs @@ -0,0 +1,58 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { pathToFileURL } = require('node:url'); +const path = require('node:path'); +const moduleAt = name => import(pathToFileURL(path.join(__dirname, '..', name)).href) + .catch(error => { if (error.code === 'ERR_MODULE_NOT_FOUND') return {}; throw error; }); + +test('HUD reads strength, duration and charges without changing combat storage', async () => { + const { collectCombatStatuses } = await moduleAt('ui/hud/status-model.js'); + assert.equal(typeof collectCombatStatuses, 'function'); + const player = { storage: { mengsanStrength_shuying: 3, mengsanSetupStrength_shuying: 2, + mengsanVulnerable_shuying: 2, mengsanArtifact_shuying: 1, mengsanRinging_shuying: true, mengsanRingingUsed_shuying: 1 } }; + const before = structuredClone(player.storage); + const statuses = collectCombatStatuses(player, { session: { active: true } }); + assert.equal(statuses.find(s => s.id === 'strength').value, 5); + assert.equal(statuses.find(s => s.id === 'vulnerable').unit, '回合'); + assert.equal(statuses.find(s => s.id === 'artifact').unit, '次'); + assert.match(statuses.find(s => s.id === 'ringing').detail, /响应/); + assert.deepEqual(player.storage, before); +}); + +test('private effect snapshots show signed strength and separate countdown instances', async () => { + const { collectCombatStatuses } = await moduleAt('ui/hud/status-model.js'); + const { bindCombatStatusSource } = await moduleAt('battle/status-sources.js'); + assert.equal(typeof bindCombatStatusSource, 'function'); + const releases = [], session = { active: true, ownResource: (_, release) => releases.push(release) }; + const player = { storage: { mengsanStrength_shuying: 2 } }; + const state = { powers: { barricade: true, vulnerableDraw: 2 }, negativeStrength: 5, + bombs: [{ turns: 1, damage: 40 }, { turns: 3, damage: 50 }] }; + bindCombatStatusSource(player, session, 'shared', () => state); + const result = collectCombatStatuses(player, { session }); + assert.equal(result.find(s => s.id === 'strength').value, -3); + assert.equal(result.find(s => s.name === '凶恶').value, 2); + assert.deepEqual(result.filter(s => s.name === '炸弹').map(s => s.value), [1, 3]); + result.find(s => s.id === 'strength').value = 99; + assert.equal(player.storage.mengsanStrength_shuying, 2); + releases.forEach(release => release()); + assert.equal(collectCombatStatuses(player, { session }).filter(s => s.name === '炸弹').length, 0); +}); + +test('old session release does not remove a new battle status source', async () => { + const { bindCombatStatusSource, readCombatStatusSources } = await moduleAt('battle/status-sources.js'); + assert.equal(typeof bindCombatStatusSource, 'function'); + let releaseOld; + const old = { active: true, ownResource: (_, release) => { releaseOld = release; } }; + const next = { active: true, ownResource() {} }, player = {}; + bindCombatStatusSource(player, old, 'cards', () => ({ vigor: 2 })); + bindCombatStatusSource(player, next, 'cards', () => ({ vigor: 7 })); + releaseOld(); + assert.equal(readCombatStatusSources(player, next)[0].state.vigor, 7); + assert.equal(readCombatStatusSources(player, old).length, 0); +}); + +test('slow display uses layers as well as the current card counter', async () => { + const { collectCombatStatuses } = await moduleAt('ui/hud/status-model.js'); + const player={storage:{mengsanSlow_shuying:3,mengsanSlowCards_shuying:2},hasSkill:id=>id==='mengsan_slow_shuying'}; + assert.equal(collectCombatStatuses(player,{session:{active:true}}).find(s=>s.id==='slow').value,60); +}); diff --git a/mengsan/ui/hand/index.js b/mengsan/ui/hand/index.js index 27c050f..2810637 100644 --- a/mengsan/ui/hand/index.js +++ b/mengsan/ui/hand/index.js @@ -17,7 +17,7 @@ export async function mountHandUI(player, session, env) { const style = document.createElement("link"); style.rel = "stylesheet"; style.href = styleURL; - let disposed = false; + let disposed = false, frozen = false; let observer, resizeObserver, pointer = null, nativeInput = false; let finishStyleLoad = null, refreshTimer = null; let refreshFrame = null; @@ -58,7 +58,7 @@ export async function mountHandUI(player, session, env) { if (!enabled) nativeLayout(); } function refresh() { - if (disposed) return; + if (disposed || frozen) return; if (!session.active || !adapter.zones.some(zone => zone.isConnected)) { dispose(); return; @@ -74,7 +74,7 @@ export async function mountHandUI(player, session, env) { overlays.sync(adapter.cards()); } function scheduleRefresh() { - if (disposed || document.hidden || refreshFrame !== null) return; + if (disposed || frozen || document.hidden || refreshFrame !== null) return; const draw = () => { refreshFrame = null; if (!document.hidden) refresh(); @@ -98,6 +98,7 @@ export async function mountHandUI(player, session, env) { env.canPayCard?.(player, card) === false); } function beginInput(event) { + if(frozen)return; if (input?.owns()) return; const card = event.target.closest?.(".card"); if (event.button !== 0 || pointer || @@ -164,6 +165,18 @@ export async function mountHandUI(player, session, env) { } container.classList.add("scrollh"); } + function freeze() { + if(disposed || frozen)return; + frozen=true;window.clearTimeout(refreshTimer); + if(refreshFrame!==null){ + if(typeof window.requestAnimationFrame==='function')window.cancelAnimationFrame(refreshFrame); + else window.clearTimeout(refreshFrame); + refreshFrame=null; + } + observer?.disconnect();resizeObserver?.disconnect();restoreTouchScroll(); + input?.dispose();pointer=null;nativeInput=false; + fan.sync(layoutEnabled); + } function dispose() { if (disposed) return; disposed = true; @@ -204,7 +217,7 @@ export async function mountHandUI(player, session, env) { style.onerror = () => finish(new Error("梦三手牌样式加载失败")); document.head.appendChild(style); }); - if (!session.active || disposed) { dispose(); return { refresh, dispose }; } + if (!session.active || disposed) { dispose(); return { refresh, freeze, dispose }; } ownClass(adapter.root, "mengsan-hand-root-shuying"); ownClass(document.body, "mengsan-hand-ui-shuying"); for (const zone of adapter.zones) { @@ -244,7 +257,7 @@ export async function mountHandUI(player, session, env) { listen(document, "visibilitychange", scheduleRefresh); refresh(); diagnose(`已挂载手牌 UI,独立排列=${layoutEnabled}`); - return { refresh, dispose }; + return { refresh, freeze, dispose }; } catch (error) { log("必需模块挂载失败,停止进入战斗", error); dispose(); diff --git a/mengsan/ui/hand/input.js b/mengsan/ui/hand/input.js index 8c56373..c942dd4 100644 --- a/mengsan/ui/hand/input.js +++ b/mengsan/ui/hand/input.js @@ -5,7 +5,7 @@ export function createHandInput(player, session, adapter, fan, env, refresh, log const maskedControls = new Set(), submittedControls = new Set(); const maskClass = "mengsan-hand-controls-hidden-shuying"; function maskControls(hidden) { - const wanted = hidden ? new Set([ui.control, ui.confirm, gesture?.event.endButton]) : new Set(); + const wanted = hidden ? new Set([ui.control, ui.confirm, ui.skillControl, gesture?.event.endButton]) : new Set(); for (const node of maskedControls) { if (wanted.has(node)) continue; node.classList.remove(maskClass); maskedControls.delete(node); diff --git a/mengsan/ui/hud/index.js b/mengsan/ui/hud/index.js new file mode 100644 index 0000000..a0e8590 --- /dev/null +++ b/mengsan/ui/hud/index.js @@ -0,0 +1,279 @@ +import { collectCombatStatuses } from './status-model.js'; +import { createOverlayLayout } from './overlays.js'; + +// Native cards, players and controls stay under their original parents. +export function mountBattleHUD(player, battle, env) { + const { ui, get, _status, document, window, assetBase } = env; + const arena = ui.arena; + if (!arena || !ui.me || !ui.control || !battle.session.active) throw new Error('梦三 HUD 缺少原生界面容器'); + const handRoot = ui.me, controlRoot = ui.control; + const overlays = createOverlayLayout(arena, handRoot, ui, player); + const restores = [], styled = new WeakMap(), hiddenMarks = new Set(); + let disposed = false, frozen = false, frame = null, dialog = null, previousFocus = null, resize, observer; + const ownClass = (node, name) => { + if (!node || node.classList.contains(name)) return; + node.classList.add(name); restores.push(() => node.classList.remove(name)); + }; + const style = (node, key, value) => { + let values = styled.get(node); + if (!values) styled.set(node, values = new Map()); + if (!values.has(key)) { + const old = node.style.getPropertyValue(key), priority = node.style.getPropertyPriority(key); + const record = { last: null }; values.set(key, record); + restores.push(() => { + if (node.style.getPropertyValue(key) !== record.last) return; + if (old) node.style.setProperty(key, old, priority); else node.style.removeProperty(key); + }); + } + const record = values.get(key); + if (record.requested !== value || node.style.getPropertyValue(key) !== record.last) { + node.style.setProperty(key, value, 'important'); + record.requested = value; + // CSSOM rounds long decimal pixel values; compare the actual stored value on release. + record.last = node.style.getPropertyValue(key); + } + }; + const text = (node, value) => { if (node.textContent !== String(value)) node.textContent = String(value); }; + const create = (tag, className, parent, label) => { + const node = document.createElement(tag); node.className = className; + if (label != null) node.textContent = label; + if (tag === 'button') node.type = 'button'; + parent?.appendChild(node); return node; + }; + const plain = value => String(value || '').replace(/<[^>]*>/g, ''); + const nameOf = target => plain(get.translation(target.name || target.playerid || '角色')); + const root = create('section', 'mengsan-hud-shuying', arena); + root.setAttribute('aria-label', '梦三战斗操作区'); + const info = create('div', 'mengsan-hud-info-shuying', root); + const name = create('strong', 'mengsan-hud-name-shuying', info, nameOf(player)); + const hp = create('span', 'mengsan-hud-hp-shuying', info); + const healthTrack = create('div', 'mengsan-hud-health-track-shuying', info); + healthTrack.setAttribute('aria-hidden', 'true'); + const healthFill = create('span', '', healthTrack); + const block = create('span', 'mengsan-hud-block-shuying', info), energy = create('strong', 'mengsan-hud-energy-shuying', info); + const center = create('div', 'mengsan-hud-center-shuying', root); + const statuses = create('div', 'mengsan-hud-statuses-shuying', center); + statuses.setAttribute('aria-label', '玩家当前状态'); + const link = create('link', '', null); link.rel = 'stylesheet'; link.href = `${assetBase}/ui/hud/style.css`; + const closeDialog = () => { + if (!dialog) return; + const current = dialog; dialog = null; + if (current.open) current.close(); current.remove(); + if (previousFocus?.isConnected) previousFocus.focus(); + }; + const openDetails = target => { + if (disposed || !battle.session.active) return; + closeDialog(); previousFocus = document.activeElement; + dialog = create('dialog', 'mengsan-hud-dialog-shuying', document.body); + const current = dialog; + create('h2', '', dialog, `${nameOf(target)} · 当前状态`); + const close = create('button', 'mengsan-hud-dialog-close-shuying', dialog, '关闭'); close.addEventListener('click', closeDialog); + const entries = collectCombatStatuses(target, battle); + for (const entry of entries) { + const row = create('section', 'mengsan-hud-detail-row-shuying', dialog); + create('strong', '', row, `${entry.name}${entry.value == null ? '' : ` ${entry.value}${entry.unit ? ` ${entry.unit}` : ''}`}`); + create('p', '', row, entry.detail); + } + if (!entries.length) create('p', '', dialog, '暂无已登记状态。'); + // Unknown/native marks retain their engine intro and are never hidden by the HUD. + dialog.addEventListener('cancel', event => { event.preventDefault(); closeDialog(); }); + dialog.addEventListener('close', () => { if (dialog === current) closeDialog(); }); + for (const type of ['pointerdown', 'pointerup', 'click', 'touchstart', 'touchend', 'keydown']) dialog.addEventListener(type, event => event.stopPropagation()); + dialog.showModal(); close.focus(); + }; + let statusSignature = null; + function renderStatuses() { + const entries = collectCombatStatuses(player, battle); + const columns = layout === 'narrow' ? 1 : 2; + const rows = Math.max(1, Math.floor((statuses.clientHeight || 80) / 38)); + const maximum = Math.max(0, columns * rows - (entries.length > columns * rows ? 1 : 0)); + const signature = JSON.stringify([maximum, entries]); + if (signature !== statusSignature) { + statusSignature = signature; statuses.replaceChildren(); + center.hidden = entries.length === 0; + for (const entry of entries.slice(0, maximum)) { + const button = create('button', 'mengsan-hud-status-shuying', statuses); + button.setAttribute('aria-label', `${entry.name} ${entry.value ?? ''} ${entry.unit},查看状态详情`); + if (entry.icon) { + const image = create('img', '', button); image.src = `${assetBase}/assets/status/${entry.icon}`; image.alt = ''; + image.addEventListener('error', () => image.remove(), { once: true }); + } + create('span', '', button, entry.name); + if (entry.value != null) create('b', '', button, entry.value); + button.addEventListener('click', () => openDetails(player)); + } + if (entries.length > maximum) { + const more = create('button', 'mengsan-hud-status-shuying', statuses, `+${entries.length - maximum}`); + more.setAttribute('aria-label', '查看全部状态'); more.addEventListener('click', () => openDetails(player)); + } + } + const wanted = new Set(); + for (const target of [player]) { + for (const entry of collectCombatStatuses(target, battle)) { + for (const skill of entry.markSkills) { + const mark = target.marks?.[skill]; if (mark?.classList) wanted.add(mark); + } + } + } + for (const mark of hiddenMarks) if (!wanted.has(mark)) { mark.classList.remove('mengsan-hud-represented-mark-shuying'); hiddenMarks.delete(mark); } + for (const mark of wanted) if (!mark.classList.contains('mengsan-hud-represented-mark-shuying')) { mark.classList.add('mengsan-hud-represented-mark-shuying'); hiddenMarks.add(mark); } + } + let layout = null; + const hudHeightFor = () => layout === 'short' ? 188 : layout === 'narrow' ? 288 : 274; + const oldLayout = arena.getAttribute('data-mengsan-hud-layout'); + restores.push(() => { + if (arena.getAttribute('data-mengsan-hud-layout') !== layout) return; + if (oldLayout === null) arena.removeAttribute('data-mengsan-hud-layout'); else arena.setAttribute('data-mengsan-hud-layout', oldLayout); + }); + function skinActor(target) { + // Decade nests these in camp/hp wraps; direct-child .hp/.name selectors miss them. + for (const key of ['campWrap', 'hpWrap', 'hp', 'identity', 'framebg', 'count', 'name', 'name2', 'nameol']) { + ownClass(target.node?.[key], 'mengsan-hud-replaced-node-shuying'); + } + ownClass(target.node?.avatar, 'mengsan-hud-portrait-shuying'); + ownClass(target.node?.equips, 'mengsan-hud-equips-shuying'); + if (target === player) ownClass(target.node?.equips, 'mengsan-hud-self-equips-shuying'); + } + function syncActors() { + const width = arena.clientWidth, height = arena.clientHeight; + const narrow = width <= 600 && height >= 480; + const short = height <= 500 && !narrow; + layout = short ? 'short' : narrow ? 'narrow' : 'wide'; + if (arena.getAttribute('data-mengsan-hud-layout') !== layout) arena.setAttribute('data-mengsan-hud-layout', layout); + const hudHeight = hudHeightFor(); + style(arena, '--mengsan-hud-height', `${hudHeight}px`); + const large = width >= 1000; + style(arena, '--mengsan-hud-left', short ? '216px' : narrow ? '116px' : large ? '352px' : '292px'); + style(arena, '--mengsan-hud-right', short ? '132px' : narrow ? '116px' : '184px'); + style(arena, '--mengsan-profile-info-left', short ? '108px' : narrow ? '22px' : large ? '200px' : '160px'); + style(arena, '--mengsan-profile-width', short ? '172px' : narrow ? '76px' : large ? '200px' : '130px'); + skinActor(player); + // Inline important dimensions also beat skins with #arena > .player !important rules. + const selfBottom = narrow ? 180 : 24; + const selfHeight = short || narrow ? 104 : large ? 196 : 148; + const infoTop = narrow ? hudHeight - selfBottom + 16 : hudHeight - selfBottom - selfHeight + (short ? 0 : 20); + style(arena, '--mengsan-profile-info-top', `${infoTop}px`); + for (const [key, value] of Object.entries({left: short ? '24px' : narrow ? '22px' : '40px', right:'auto', top:'auto', + bottom:`${selfBottom}px`, width:short ? '76px' : narrow ? '76px' : large ? '144px' : '104px', + height:`${selfHeight}px`, transform:'none'})) style(player, key, value); + style(arena, '--mengsan-side-dock-bottom', `${selfBottom + selfHeight + 12}px`); + style(arena, '--mengsan-relic-dock-bottom', `${selfBottom + selfHeight + (narrow ? -4 : 12)}px`); + style(arena, '--mengsan-skill-ceiling', `${Math.min(hudHeight - 36, selfBottom + selfHeight)}px`); + style(arena, '--mengsan-status-height', short ? '52px' : narrow ? '84px' : '160px'); + } + function syncExternalEquipment() { + const equips = player.node?.equips; + if (!equips?.isConnected || equips.parentNode === player) return; + // An optional skin can keep the same real equipment nodes in a separate arena wrapper. + ownClass(ui.equipSolts, 'mengsan-hud-equip-host-shuying'); + const host = equips.offsetParent || arena, h = host.getBoundingClientRect(), a = arena.getBoundingClientRect(), p = player.getBoundingClientRect(); + const sx = h.width / host.offsetWidth || 1, sy = h.height / host.offsetHeight || sx; + const arenaScale = a.width / arena.clientWidth || 1; + const width = Math.max(0, p.width - 12 * arenaScale); + const equipHeight = Math.min(100 * arenaScale, p.height * .58); + for (const [key, value] of Object.entries({position:'absolute',left:`${(p.left + 6 * arenaScale - h.left) / sx}px`,right:'auto',bottom:'auto', + top:`${(p.bottom - 6 * arenaScale - equipHeight - h.top) / sy}px`,width:`${width / sx}px`,height:`${equipHeight / sy}px`,'max-height':`${equipHeight / sy}px`})) style(equips, key, value); + } + function placeControl(node, bottom, maxHeight) { + ownClass(node, 'mengsan-hud-external-control-shuying'); + const host = node.offsetParent || arena, a = arena.getBoundingClientRect(), h = host.getBoundingClientRect(); + const scale = h.width / host.offsetWidth || 1, arenaScale = a.width / arena.clientWidth || 1; + const rail = layout === 'wide' ? 142 : layout === 'short' ? 112 : 96; + for (const [key, value] of Object.entries({position:'absolute',left:'auto',top:'auto',transform:'none', + right:`${(h.right - a.right + (layout === 'wide' ? 24 : 16) * arenaScale) / scale}px`, bottom:`${(h.bottom - a.bottom + bottom * arenaScale) / scale}px`, + width:`${rail * arenaScale / scale}px`, 'max-height':`${maxHeight * arenaScale / scale}px`})) style(node, key, value); + } + function syncControls() { + // The actual action controls stay horizontal above the hand; skill references get their own rail. + const candidates = [...new Set([ui.skills, ui.skills2, ui.skills3, ui.skillControl, ui.gskillControl])] + .filter(node => node?.isConnected && node !== ui.control); + const external = candidates.filter(node => !candidates.some(other => other !== node && other.contains(node))); + const hudHeight = hudHeightFor(); + const rail = layout === 'wide' ? 142 : layout === 'short' ? 112 : 96; + const actionBottom = layout === 'narrow' ? hudHeight - 48 : hudHeight + 8; + const compactActions = arena.clientWidth < 1000; + for (const [key,value] of Object.entries({position:'absolute',left:compactActions?'16px':'var(--mengsan-hud-left)',right:compactActions?'16px':'var(--mengsan-hud-right)',top:'auto',bottom:`${actionBottom}px`,width:'auto','max-height':'none',transform:'none'})) style(ui.control,key,value); + for (const group of ui.control.children) if (group.classList.contains('control')) { + if (external.includes(group)) { + ownClass(group,'mengsan-hud-native-skills-shuying'); + for (const button of group.children) if (button.tagName === 'DIV') style(button, 'margin', '0px'); + continue; + } + for (const [key, value] of Object.entries({position:'relative',left:'auto',right:'auto',top:'auto',bottom:'auto',transform:'none'})) style(group, key, value); + } + let bottom = 20; + const ceiling = parseFloat(arena.style.getPropertyValue('--mengsan-skill-ceiling')); + external.forEach((node, index) => { + if (node === ui.skillControl || node === ui.gskillControl) ownClass(node, 'mengsan-hud-skin-skills-shuying'); + placeControl(node, bottom, Math.max(24, (ceiling - bottom - 6 * (external.length - index - 1)) / (external.length - index))); + bottom += node.offsetHeight + 6; + }); + for (const node of [ui.confirm,_status.event?.endButton]) { + if (!node?.isConnected || ui.control.contains(node)) continue; + ownClass(node,'mengsan-hud-external-action-shuying'); + const host=node.offsetParent||arena,a=arena.getBoundingClientRect(),h=host.getBoundingClientRect(); + const scale=h.width/host.offsetWidth||1,zoom=a.width/arena.clientWidth||1; + const center=(parseFloat(arena.style.getPropertyValue('--mengsan-hud-left'))+arena.clientWidth-parseFloat(arena.style.getPropertyValue('--mengsan-hud-right')))/2; + const side=node===ui.confirm?-1:1; + for(const [key,value] of Object.entries({position:'absolute',left:`${(a.left+center*zoom-h.left)/scale+side*80}px`,right:'auto',top:'auto',bottom:`${(h.bottom-a.bottom+actionBottom*zoom)/scale}px`,width:`${Math.min(rail,140)}px`,transform:'translateX(-50%)'}))style(node,key,value); + } + } + function syncNotifications() { + const maxTop = arena.clientHeight - hudHeightFor() - 48; + // Decade positions string popups at player.top + 80, which now intersects our footer. + for (const popup of arena.querySelectorAll(':scope > .damage.skill-popup')) { + if(!popup.classList.contains('mengsan-hud-notification-shuying') && + (Math.abs(popup.offsetLeft-player.offsetLeft)>1 || Math.abs(popup.offsetTop-player.offsetTop-80)>1))continue; + ownClass(popup, 'mengsan-hud-notification-shuying'); + if (popup.offsetTop > maxTop) style(popup, 'top', `${Math.max(0, maxTop)}px`); + const maxLeft = Math.max(0, arena.clientWidth - 160); + style(popup, 'left', `${Math.max(12, Math.min(maxLeft, popup.offsetLeft))}px`); + } + } + function refresh() { + if (disposed || frozen) return; + if (!battle.session.active || !arena.isConnected || !handRoot.isConnected || ui.me !== handRoot || ui.control !== controlRoot) { dispose(); return; } + text(name, nameOf(player)); text(hp, `生命 ${player.hp} / ${player.maxHp}`); text(block, `格挡 ${player.hujia || 0}`); + healthFill.style.width = `${Math.max(0, Math.min(100, player.maxHp > 0 ? player.hp / player.maxHp * 100 : 0))}%`; + text(energy, `费用 ${player.storage.mengsanEnergy_shuying ?? 0} / ${player.storage.mengsanMaxEnergy_shuying ?? 0}`); + syncActors(); syncExternalEquipment(); renderStatuses(); syncControls(); syncNotifications(); + overlays.refresh({ footerHeight:hudHeightFor(), status:_status }); + } + function schedule() { + if (disposed || frozen || document.hidden || frame !== null) return; + frame = window.requestAnimationFrame(() => { frame = null; refresh(); }); + } + const timer = window.setInterval(schedule, 200); + function freeze() { + if(disposed || frozen)return; + frozen=true;window.clearInterval(timer); + if(frame!==null){window.cancelAnimationFrame(frame);frame=null;} + observer?.disconnect();resize?.disconnect();closeDialog();overlays.dispose(); + ownClass(arena,'mengsan-hud-frozen-shuying'); + } + function dispose() { + if (disposed) return; disposed = true; + window.clearInterval(timer); if (frame !== null) window.cancelAnimationFrame(frame); + observer?.disconnect(); resize?.disconnect(); window.removeEventListener('resize', schedule); + document.removeEventListener('visibilitychange', schedule); + overlays.dispose(); + closeDialog(); + for (const mark of hiddenMarks) mark.classList.remove('mengsan-hud-represented-mark-shuying'); hiddenMarks.clear(); + for (const restore of restores.reverse()) restore(); + root.remove(); link.remove(); + } + ownClass(document.body, 'mengsan-hud-active-shuying'); ownClass(arena, 'mengsan-hud-arena-shuying'); + ownClass(ui.me, 'mengsan-hud-hand-shuying'); ownClass(ui.control, 'mengsan-hud-controls-shuying'); + ownClass(ui.mebg, 'mengsan-hud-mebg-shuying'); ownClass(player, 'mengsan-hud-self-shuying'); + // Panel events must not reach native selection/drag handlers. + for (const type of ['pointerdown', 'pointerup', 'click', 'touchstart', 'touchend', 'contextmenu']) root.addEventListener(type, event => event.stopPropagation()); + link.addEventListener('load', schedule); + link.addEventListener('error', () => { env.log?.('梦三 HUD 样式加载失败,保留原生布局'); dispose(); }); + document.head.appendChild(link); + observer = new window.MutationObserver(schedule); observer.observe(ui.control, { childList: true, subtree: true }); + observer.observe(arena, { childList: true }); + if(ui.window && ui.window!==arena) observer.observe(ui.window,{childList:true}); + if (window.ResizeObserver) { resize = new window.ResizeObserver(schedule); resize.observe(arena); } + window.addEventListener('resize', schedule); document.addEventListener('visibilitychange', schedule); + const api = { refresh, freeze, dispose }; battle.session.ownResource(api, dispose); refresh(); return api; +} diff --git a/mengsan/ui/hud/overlays.js b/mengsan/ui/hud/overlays.js new file mode 100644 index 0000000..4b62426 --- /dev/null +++ b/mengsan/ui/hud/overlays.js @@ -0,0 +1,100 @@ +// Position existing native prompts and popped descriptions; never recreate their content or handlers. +export function createOverlayLayout(arena, hand, ui, player) { + const records = new Map(); + const roots = new Set([ui.window, arena].filter(Boolean)); + const popped = () => new Set([...roots].flatMap(root => [...root.querySelectorAll(':scope > .dialog.popped')])); + const markPopups = new WeakSet(); + let activeMarkPopup = null, openingMark = false, disposed = false, options = null; + // Native mark intros do not reject repeated clicks themselves. Observe their real + // popup after the native handler, and block duplicate opens without replacing it. + const guardMark = event => { + const target = event.target.closest?.('.mark'); + if (!target || !player.contains(target)) return; + if (openingMark || activeMarkPopup?.isConnected) { + event.preventDefault(); event.stopImmediatePropagation(); return; + } + const before = popped(); + openingMark = true; + arena.ownerDocument.defaultView.queueMicrotask(() => { + openingMark = false; + if (disposed) return; + activeMarkPopup = [...popped()].find(node => !before.has(node)) || null; + if (activeMarkPopup) { markPopups.add(activeMarkPopup); if (options) api.refresh(options); } + }); + }; + const markEvents = ['click', 'touchend', 'contextmenu']; + for (const type of markEvents) player.addEventListener(type, guardMark, true); + function release(node, record) { + for (const [key, field] of record.styles) { + if (node.style.getPropertyValue(key) !== field.last) continue; + if (field.before) node.style.setProperty(key,field.before,field.priority); else node.style.removeProperty(key); + } + for (const [name, had] of record.classes) if (!had) node.classList.remove(name); + records.delete(node); + } + function mark(node, name) { + let record=records.get(node); + if(!record) {record={styles:new Map(),classes:new Map()};records.set(node,record);} + if(!record.classes.has(name))record.classes.set(name,node.classList.contains(name)); + node.classList.add(name);return record; + } + function style(node,key,value) { + const record=records.get(node); + if(!record.styles.has(key))record.styles.set(key,{before:node.style.getPropertyValue(key),priority:node.style.getPropertyPriority(key)}); + const field=record.styles.get(key); + if(field.requested!==value||node.style.getPropertyValue(key)!==field.last)node.style.setProperty(key,value,'important'); + field.requested=value;field.last=node.style.getPropertyValue(key); + } + function position(node,x,y,width,maxHeight) { + for(const [key,value] of Object.entries({position:'absolute',right:'auto',bottom:'auto',transform:'none',margin:'0px',width:`${width}px`,'max-height':`${maxHeight}px`}))style(node,key,value); + const host=node.offsetParent||arena.ownerDocument.documentElement,h=host.getBoundingClientRect(); + const sx=h.width/host.offsetWidth||1,sy=h.height/host.offsetHeight||sx; + style(node,'left',`${(x-h.left)/sx-host.clientLeft+host.scrollLeft}px`); + style(node,'top',`${(y-h.top)/sy-host.clientTop+host.scrollTop}px`); + } + const api = { + refresh(nextOptions) { + options = nextOptions; + const {footerHeight,status} = nextOptions; + const used=new Set(),a=arena.getBoundingClientRect(),zoom=a.width/arena.clientWidth||1; + const left=parseFloat(arena.style.getPropertyValue('--mengsan-hud-left'))||0; + const right=parseFloat(arena.style.getPropertyValue('--mengsan-hud-right'))||0; + const x=a.left+left*zoom,width=Math.max(100,arena.clientWidth-left-right); + const controlTop=ui.control.getBoundingClientRect().top; + const prompts=new Set(arena.querySelectorAll(':scope > .hand-tip,:scope > .hand-tip-info')); + const dialog=ui.dialog; + if(dialog?.isConnected && !dialog.classList.contains('popped') && !dialog.classList.contains('fullheight') && !dialog.forcebutton && !dialog.buttons?.length && !dialog.querySelector('.button,.card,.player,input,textarea,select') && status.event?.player===player)prompts.add(dialog); + // The event owns simple native prompt dialogs too, without a global game dependency. + const eventDialog=status.event?.dialog; + if(eventDialog?.isConnected && eventDialog===dialog && !eventDialog.buttons?.length && !eventDialog.forcebutton && !eventDialog.querySelector('.button,.card,.player,input,textarea,select'))prompts.add(eventDialog); + let promptBottom=Math.min(controlTop-10*zoom,a.bottom-(footerHeight+54)*zoom); + for(const node of prompts) { + if(node.hidden || node.style.display==='none')continue; + mark(node,'mengsan-hud-prompt-shuying');used.add(node); + position(node,x,0,width,arena.clientHeight<=500?34:64); + const height=node.getBoundingClientRect().height; + position(node,x,Math.max(a.top+50*zoom,promptBottom-height),width,arena.clientHeight<=500?34:64); + promptBottom=node.getBoundingClientRect().top-6*zoom; + } + const handBox=hand.getBoundingClientRect(); + const handTop=Math.min(handBox.top,...[...hand.querySelectorAll('.handcards>.card')].map(node=>node.getBoundingClientRect().top)); + const popups=popped(); + let hasPopup=false; + for(const node of popups) { + const rect=node.getBoundingClientRect(); + if(!markPopups.has(node) && !records.has(node) && !(rect.bottom>handTop && rect.lefthandBox.left))continue; + mark(node,'mengsan-hud-card-intro-shuying');used.add(node);hasPopup=true; + const popupWidth=Math.min(320,Math.max(180,rect.width/zoom),arena.clientWidth-24); + const maxHeight=Math.max(64,(promptBottom-a.top)/zoom-12); + const center=Math.max(a.left+12*zoom,Math.min(a.right-(popupWidth+12)*zoom,markPopups.has(node)?x:rect.left)); + position(node,center,a.top+12*zoom,popupWidth,maxHeight); + const height=node.getBoundingClientRect().height; + position(node,center,Math.max(a.top+12*zoom,promptBottom-height-8*zoom),popupWidth,maxHeight); + } + if(hasPopup)for(const root of roots)for(const node of root.querySelectorAll(':scope > .poplayer')) {mark(node,'mengsan-hud-intro-layer-shuying');style(node,'z-index','89');used.add(node);} + for(const [node,record] of records)if(!node.isConnected||!used.has(node))release(node,record); + }, + dispose(){disposed=true;for(const type of markEvents)player.removeEventListener(type,guardMark,true);for(const [node,record]of records)release(node,record);}, + }; + return api; +} diff --git a/mengsan/ui/hud/status-model.js b/mengsan/ui/hud/status-model.js new file mode 100644 index 0000000..370282a --- /dev/null +++ b/mengsan/ui/hud/status-model.js @@ -0,0 +1,119 @@ +import { readCombatStatusSources } from '../../battle/status-sources.js'; +import { slowPercent } from '../../monsters/effigy-slow.js'; + +// The descriptions describe Mengsan's current rules, not an imported Wiki engine. +const storageStatuses = [ + ['vulnerable', '易伤', 'mengsanVulnerable_shuying', '回合', '受到攻击伤害增加;剩余自身回合数,倍率按攻击来源及当前效果结算。', 'mengsan_vulnerable_shuying'], + ['weak', '虚弱', 'mengsanWeak_shuying', '回合', '攻击伤害减少25%;纸鹤对敌人采用40%减伤。自身回合结束减少。', 'mengsan_weak_shuying'], + ['frail', '脆弱', 'mengsanFrail_shuying', '回合', '获得格挡减少25%,向下取整;自身回合结束减少。', 'mengsan_frail_shuying'], + ['artifact', '人工制品', 'mengsanArtifact_shuying', '次', '每次抵消一次符合规则的负面状态施加,不抵消伤害。', 'mengsan_artifact_shuying'], + ['constrict', '紧缠', 'mengsanConstrict_shuying', '伤害', '自身回合结束受到等量非攻击伤害;来源死亡解除。', 'mengsan_constrict_shuying'], + ['tangled', '缠结', 'mengsanTangled_shuying', '回合', '攻击牌费用增加1,持续时间可叠加,加费不叠加。', 'mengsan_tangled_shuying'], + ['shrink', '缩小', 'mengsanShrink_shuying', '', '攻击伤害减少30%,不重复叠加;来源死亡解除。', 'mengsan_shrink_shuying'], + ['plow', '横冲直撞', 'mengsanPlow_shuying', '生命阈值', '受到伤害后生命不高于该阈值时击晕,清零力量并进入第二阶段。', 'mengsan_plow_shuying'], + ['slippery', '滑溜', 'mengsanSlippery_shuying', '次', '下一次实际生命损失限制为1,完全格挡不消耗。', 'mengsan_slippery_shuying'], + ['territorial', '领地意识', 'mengsanTerritorial_shuying', '力量', '自身回合结束获得等量力量。', 'mengsan_territorial_shuying'], + ['infested', '寄生物', 'mengsanInfested_shuying', '召唤数', '死亡后立即召唤扭动虫,必须消灭召唤单位才能获胜。', 'mengsan_infested_shuying'], +]; +// [display name, icon, value meaning, description]; only actual effect fields are read. +const effects = { + turnStrength: ['恶魔形态', 'demon_form', '力量', '自身回合开始获得力量。'], + turnEnergy: ['每回合费用', 'demesne', '费用', '自身回合开始额外获得费用。'], + turnBlock: ['绯红披风', 'crimson_mantle', '格挡', '自身回合开始获得格挡;失血部分另列。'], + turnLoseHp: ['披风失血', 'crimson_mantle', '生命', '自身回合开始失去生命。'], + infernoLoseHp: ['狱火失血', 'inferno', '生命', '自身回合开始失去生命。'], + lossDamage: ['狱火', 'inferno', '伤害', '自身回合失去生命时,对所有敌人造成伤害。'], + lossStrength: ['撕裂', 'rupture', '力量', '自身回合失去生命时获得力量。'], + vulnerableBonus: ['易伤增幅', 'vicious', '倍率加成', '对易伤敌人的伤害倍率额外加成。'], + attackBlock: ['狂怒', 'rage', '格挡', '本回合每打出一张攻击牌获得格挡。'], + retaliation: ['火焰屏障', 'flame_barrier', '伤害', '本回合受到攻击后反击。'], + colossus: ['巨像', 'colossus', '', '本回合受到易伤敌人的攻击伤害减少50%。'], + freeAttack: ['免费攻击', 'free_attack', '', '下一张符合规则的攻击牌免费。'], + noDraw: ['不可抽牌', 'no_draw', '', '本回合不能继续抽牌。'], + noEnergy: ['无法获得费用', 'no_energy_gain', '', '本回合不能再获得费用。'], + doubleTap: ['连环拳', 'one_two_punch', '张', '本回合接下来的攻击牌额外打出一次。'], + plating: ['覆甲', 'plating', '格挡', '回合末获得等量格挡;自身回合开始减少1层。'], + exhaustBlock: ['无惧疼痛', 'feel_no_pain', '格挡', '卡牌消耗后获得格挡。'], + exhaustDraw: ['黑暗之拥', 'dark_embrace', '张', '卡牌消耗后抽牌。'], + vulnerableDraw: ['凶恶', 'vicious', '张', '每次成功施加易伤后抽牌。'], + juggle: ['杂耍', 'juggling', '', '自身回合第三张攻击牌复制至手牌。'], + endAttack: ['惊逃', 'stampede', '张', '回合结束随机免费打出攻击手牌。'], + retrieveAttack: ['好勇斗狠', 'aggression', '张', '自身回合开始从弃牌堆获得攻击牌并强化。'], + bodyguard: ['肉盾', 'tank', '', '自身承受敌人双倍伤害,其他友方受到敌人的伤害减半。'], + autoStrike: ['地狱狂徒', 'hellraiser', '', '抽到杀或名称含打击的攻击牌时自动打出。'], + firstBlock: ['不动', 'unmovable', '张', '每个自身回合首批获得格挡的卡牌,格挡翻倍。'], + blockDamage: ['势不可当', 'juggernaut', '伤害', '获得格挡时对随机敌人造成伤害。'], + barricade: ['壁垒', 'barricade', '', '自身回合开始保留格挡。'], + corruption: ['腐化', 'corruption', '', '技能牌费用变为0,打出后消耗。'], + dexterity: ['敏捷', 'dexterity', '格挡', '影响从卡牌中获得的格挡。'], + noCardBlock: ['不可格挡', 'no_block', '回合', '无法从卡牌中获得格挡。'], + intangible: ['无实体', 'intangible', '回合', '按当前规则限制伤害和生命损失。'], + gambit: ['孤注一掷', 'the_gambit', '', '受到未被格挡的攻击伤害时死亡。'], + retain: ['保留手牌', 'retain_hand', '', '本回合保留手牌。'], + panache: ['神气制胜', 'panache', '伤害', '自身回合每打出5张牌,对所有敌人造成伤害。'], + defendBonus: ['勒紧', 'fasten', '格挡', '防御牌额外获得格挡。'], + turnVigor: ['准备时间', 'prep_time', '活力', '自身回合开始获得活力。'], + automation: ['自动化', 'automation', '费用', '抽牌达到计数要求后获得费用。'], + powerDiscount: ['好奇', 'curious', '费用', '能力牌费用降低。'], + boulder: ['滚石', 'rolling_boulder', '伤害', '回合开始对所有敌人造成伤害,随后伤害增加5。'], + nostalgia: ['怀旧', 'nostalgia', '', '每回合首张攻击或技能牌放回抽牌堆顶。'], + entropy: ['熵', 'entropy', '', '自身回合开始变化手牌。'], + beacon_of_hope: ['希望灯塔', 'beacon_of_hope', '', '获得格挡时向其他友方提供格挡。'], + mayhem: ['乱战', 'mayhem', '', '自身回合开始自动打出抽牌堆顶的牌。'], + calamity: ['劫难', 'calamity', '', '打出攻击牌时生成随机攻击牌。'], +}; +const activeValue = value => value === true || typeof value === 'number' && Number.isFinite(value) && value !== 0; +const number = value => typeof value === 'number' && Number.isFinite(value) ? value : 0; +const make = (id, name, icon, value, unit, detail, markSkills = []) => ({ id, name, + icon: icon ? `${icon}_power.png` : null, value: value === true ? null : value, unit, detail, markSkills }); + +export const statusIconNames = [...new Set(['strength', 'ringing', 'slow', 'the_bomb', 'vigor', 'draw_cards_next_turn', 'energy_next_turn', 'block_next_turn', + ...storageStatuses.map(row => row[0]), ...Object.values(effects).map(row => row[1])])].map(id => `${id}_power.png`); + +export function collectCombatStatuses(player, battle) { + if (!battle?.session?.active) return []; + const storage = player.storage || {}, result = [], sources = readCombatStatusSources(player, battle.session); + const penalty = sources.reduce((n, entry) => n + number(entry.state.negativeStrength), 0); + const permanent = number(storage.mengsanStrength_shuying), temporary = number(storage.mengsanSetupStrength_shuying); + if (permanent || temporary || penalty) result.push(make('strength', '力量', 'strength', permanent + temporary - penalty, '伤害', + `常驻力量 ${permanent};临时力量 ${temporary};本回合减益 ${penalty}。不同临时来源按各自原规则到期。`, + ['mengsan_raider_strength_shuying', 'mengsan_setup_strength_shuying'])); + for (const [id, name, key, unit, detail, skill] of storageStatuses) { + if (activeValue(storage[key])) result.push(make(id, name, id, storage[key], unit, detail, [skill])); + } + if (storage.mengsanRinging_shuying || player.hasSkill?.('mengsan_ringing_shuying')) result.push(make('ringing', '昏眩', 'ringing', + `${Math.min(1, number(storage.mengsanRingingUsed_shuying))}/1`, '主动出牌', '本次自身回合至多主动使用1张牌,不限制响应;回合结束解除。', ['mengsan_ringing_shuying'])); + if (player.hasSkill?.('mengsan_slow_shuying')) result.push(make('slow', '缓慢', 'slow', slowPercent(player), '%', + `缓慢 ${number(storage.mengsanSlow_shuying)} 层;友方回合每打出一张牌,受到的攻击牌伤害增加层数×10%;换回合计数重置。`, ['mengsan_slow_shuying'])); + for (const { id, state } of sources) { + for (const zone of ['powers', 'turn', 'defense']) { + for (const [key, value] of Object.entries(state[zone] || {})) { + const definition = effects[key]; + if (!definition || !activeValue(value)) continue; + const [name, icon, unit, detail] = definition; + result.push(make(`${id}:${zone}:${key}`, name, icon, key === 'vulnerableBonus' ? `${Math.round(value * 100)}%` : value, unit, detail, + id === 'ironclad' ? ['mengsan_ic_powers_shuying'] : [])); + } + } + if (activeValue(state.vigor)) result.push(make(`${id}:vigor`, '活力', 'vigor', state.vigor, '伤害', '下一张攻击牌获得额外伤害。')); + (state.bombs || []).forEach((bomb, index) => result.push(make(`${id}:bomb:${index}`, '炸弹', 'the_bomb', bomb.turns, '回合', + `此实例倒计时结束后对所有敌人造成${bomb.damage}点伤害;各炸弹独立结算。`))); + (state.nextTurn || []).forEach((next, index) => { + for (const [key, name, icon, unit] of [['draw', '下回合抽牌', 'draw_cards_next_turn', '张'], ['energy', '下回合费用', 'energy_next_turn', '费用'], ['block', '下回合格挡', 'block_next_turn', '格挡']]) { + if (activeValue(next[key])) result.push(make(`${id}:next:${index}:${key}`, name, icon, next[key], unit, + `自身回合开始获得;${next.remaining ? `剩余${next.remaining}次。` : '此实例结算一次。'}`)); + } + }); + } + if (battle.relics?.displayState && battle.owner === player) { + const relic = battle.relics.displayState(); + for (const [key, name, icon, unit, detail] of [ + ['dexterity', '遗物敏捷', 'dexterity', '格挡', '遗物提供的卡牌格挡修正。'], + ['plating', '遗物覆甲', 'plating', '格挡', '遗物提供的回合末格挡。'], + ['nextEnergy', '下回合费用', 'energy_next_turn', '费用', '遗物将在下个自身回合提供费用。'], + ['nextBlock', '下回合格挡', 'block_next_turn', '格挡', '遗物将在下个自身回合提供格挡。']]) { + if (activeValue(relic[key])) result.push(make(`relic:${key}`, name, icon, relic[key], unit, detail)); + } + } + return result; +} diff --git a/mengsan/ui/hud/style.css b/mengsan/ui/hud/style.css new file mode 100644 index 0000000..b5d1048 --- /dev/null +++ b/mengsan/ui/hud/style.css @@ -0,0 +1,128 @@ +/* Presentation only: native players, cards and controls keep their handlers. */ +.mengsan-hud-arena-shuying { --mengsan-hud-paper:#edf2f7;--mengsan-hud-gold:#dcc18e;--mengsan-hud-raised:transparent;--mengsan-hud-stroke:rgba(240,240,240,.35);overflow:clip!important; } +.mengsan-hud-hand-shuying,.mengsan-hud-controls-shuying,.mengsan-hud-external-control-shuying,.mengsan-hud-self-shuying,.mengsan-hud-actor-shuying { transition:none!important; } +.mengsan-hud-native-skills-shuying,.mengsan-hud-native-skills-shuying>div,.mengsan-hud-skin-skills-shuying>.enable,.mengsan-hud-skin-skills-shuying>.trigger,.mengsan-hud-skin-skills-shuying .skillitem { transition:none!important; } +.mengsan-hud-shuying { position:absolute;left:0;right:0;bottom:0;height:var(--mengsan-hud-height,274px);z-index:20;box-sizing:border-box;pointer-events:none;color:var(--mengsan-hud-paper);background:transparent;border:0;border-radius:0;backdrop-filter:none;box-shadow:none;text-shadow:0 1px 3px #000,0 0 6px #000c;font:13px/1.4 "Microsoft YaHei",sans-serif; } +/* Transparent layout only: no footer slab, tint, outline or blur. */ +.mengsan-hud-shuying::before,.mengsan-hud-shuying::after { display:none; } +.mengsan-hud-shuying *,.mengsan-hud-dialog-shuying * { box-sizing:border-box; } +.mengsan-hud-shuying div,.mengsan-hud-shuying span,.mengsan-hud-shuying strong,.mengsan-hud-shuying button,.mengsan-hud-dialog-shuying section,.mengsan-hud-dialog-shuying p { position:relative;display:block;float:none; } +.mengsan-hud-shuying [hidden],.mengsan-unit-status-shuying[hidden] { display:none!important; } +.mengsan-hud-shuying .mengsan-hud-info-shuying,.mengsan-hud-shuying .mengsan-hud-center-shuying { position:absolute; } +.mengsan-hud-shuying button,.mengsan-unit-status-shuying,.mengsan-hud-dialog-shuying button { color:var(--mengsan-hud-paper,#edf2f7);background:var(--mengsan-hud-raised,#28374ad6);border:1px solid var(--mengsan-hud-stroke,#dce8f42e);border-radius:12px;font:inherit;cursor:pointer;touch-action:manipulation;pointer-events:auto; } +.mengsan-hud-shuying button:hover,.mengsan-unit-status-shuying:hover { border-color:#dcc18e;background:#ffffff12; } +.mengsan-hud-shuying button:focus-visible,.mengsan-unit-status-shuying:focus-visible,.mengsan-hud-dialog-shuying button:focus-visible { outline:2px solid #f4d894;outline-offset:2px; } +.mengsan-hud-shuying .mengsan-hud-info-shuying { left:var(--mengsan-profile-info-left);top:var(--mengsan-profile-info-top);bottom:auto;width:100px;display:flex;flex-direction:column;gap:12px; } +.mengsan-hud-name-shuying { font:600 18px/1.2 "Microsoft YaHei",sans-serif;letter-spacing:.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; } +.mengsan-hud-hp-shuying,.mengsan-hud-block-shuying { font-size:12px;color:#e2dfd4;font-variant-numeric:tabular-nums;white-space:nowrap; } +.mengsan-hud-health-track-shuying { width:100%;height:4px;margin:0 0 2px;background:#ffffff1a;border-radius:4px;overflow:hidden; } +.mengsan-hud-health-track-shuying>span { height:100%;background:#9bb78d; } +.mengsan-hud-energy-shuying { color:#eacb87;font-size:16px;font-variant-numeric:tabular-nums;white-space:nowrap; } +.mengsan-hud-center-shuying { left:40px;right:auto;top:auto;bottom:var(--mengsan-side-dock-bottom);width:var(--mengsan-profile-width);height:var(--mengsan-status-height); } +.mengsan-hud-shuying .mengsan-hud-statuses-shuying { display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-auto-rows:32px;align-content:end;gap:6px;overflow:hidden;white-space:nowrap;height:100%; } +.mengsan-hud-shuying .mengsan-hud-status-shuying { display:flex;align-items:center;gap:4px;max-width:100%;min-width:0;min-height:32px;padding:3px 6px;background:transparent;border-radius:10px; } +.mengsan-hud-status-shuying span { overflow:hidden;text-overflow:ellipsis;font-size:12px; } +.mengsan-hud-status-shuying b { font-variant-numeric:tabular-nums;font-size:13px; } +.mengsan-hud-status-shuying img { width:22px;height:22px;object-fit:contain; } +.mengsan-hud-hand-shuying { position:absolute!important;left:var(--mengsan-hud-left)!important;right:var(--mengsan-hud-right)!important;bottom:68px!important;top:auto!important;width:calc(100% - var(--mengsan-hud-left) - var(--mengsan-hud-right))!important;height:calc(var(--mengsan-hud-height,274px) - 126px)!important;z-index:24!important;background:none!important;border:0!important;transform:none!important;pointer-events:none; } +.mengsan-hud-hand-shuying .handcards>.card { pointer-events:auto; } +.mengsan-hud-mebg-shuying,.mengsan-hud-hand-shuying>.fakeme.avatar { display:none!important; } +/* These explicit references cover Decade's nested camp-wrap and hp-wrap as well. */ +.mengsan-hud-active-shuying .mengsan-hud-replaced-node-shuying { display:none!important; } +#arena.mengsan-hud-arena-shuying>.player.mengsan-hud-self-shuying { box-sizing:border-box!important;background:#172131!important;border:1px solid #dce8f44d!important;border-radius:16px!important;box-shadow:0 6px 20px #0005!important;min-width:0!important;min-height:0!important;max-width:none!important;max-height:none!important; } +#arena.mengsan-hud-arena-shuying>.player.mengsan-hud-self-shuying { z-index:25!important; } +#arena.mengsan-hud-arena-shuying>.player.mengsan-hud-self-shuying::before,#arena.mengsan-hud-arena-shuying>.player.mengsan-hud-self-shuying::after { display:none!important; } +#arena.mengsan-hud-arena-shuying>.player.selectable.mengsan-hud-self-shuying { outline:2px solid #e8d198;outline-offset:3px; } +#arena.mengsan-hud-arena-shuying>.player.selected.mengsan-hud-self-shuying,#arena.mengsan-hud-arena-shuying>.player.target.mengsan-hud-self-shuying { outline:3px solid #f29b73;outline-offset:3px; } +#arena.mengsan-hud-arena-shuying .player>.mengsan-hud-portrait-shuying,#arena.mengsan-hud-arena-shuying .player.mengsan-hud-self-shuying>.dynamic-wrap { position:absolute!important;inset:0!important;width:100%!important;height:100%!important;transform:none!important;clip-path:inset(0 round 14px)!important;-webkit-clip-path:inset(0 round 14px)!important;border-radius:14px!important;background-size:cover!important;background-position:center 15%!important; } +.mengsan-hud-portrait-shuying>.action { display:none!important; } +.mengsan-hud-self-shuying>.mengsan-energy-shuying,.mengsan-hud-actor-shuying>.mengsan-energy-shuying { display:none!important; } +.mengsan-hud-represented-mark-shuying { display:none!important; } +/* Container IDs and combo-control differ. Bind to owned classes and keep all callbacks. */ +/* Only arrange the real native control groups; their theme owns all button paint. */ +body.mengsan-hud-active-shuying #arena>.mengsan-hud-controls-shuying { height:auto!important;display:flex!important;flex-direction:row!important;flex-wrap:wrap!important;align-items:center!important;align-content:flex-end!important;justify-content:center!important;gap:8px;overflow:visible!important;margin:0!important;padding:0!important;z-index:30!important;pointer-events:none; } +body.mengsan-hud-active-shuying #arena>.mengsan-hud-controls-shuying>.control { position:relative!important;left:auto!important;right:auto!important;top:auto!important;bottom:auto!important;transform:none!important;display:flex!important;flex-direction:row!important;flex-wrap:wrap!important;flex:none!important;align-items:center!important;justify-content:center!important;width:auto!important;max-width:100%!important;min-width:0!important;height:auto!important;margin:0!important;overflow:visible!important;pointer-events:none; } +body.mengsan-hud-active-shuying #arena>.mengsan-hud-controls-shuying>.control>div { pointer-events:auto; } +.mengsan-hud-external-control-shuying { z-index:31!important;overflow:auto!important;box-sizing:border-box; } +.mengsan-hud-skin-skills-shuying { height:auto!important;max-width:none!important;display:flex!important;flex-direction:column!important;gap:4px; } +.mengsan-hud-skin-skills-shuying>.enable,.mengsan-hud-skin-skills-shuying>.trigger { position:relative!important;display:flex!important;flex-wrap:nowrap!important;flex-direction:column!important;align-items:stretch!important;justify-content:center!important;gap:8px;width:100%!important;height:auto!important;margin:0!important;padding:0!important; } +.mengsan-hud-skin-skills-shuying .skillitem { position:relative!important;float:none!important;box-sizing:border-box!important;width:auto!important;min-width:40px!important;max-width:100%!important;height:auto!important;min-height:28px!important;margin:0!important;padding:4px 8px!important;background-size:100% 100%!important;border:0!important;border-radius:10px!important;font:13px/20px "Microsoft YaHei",sans-serif!important;white-space:normal!important; } +.mengsan-hud-skin-skills-shuying>.enable>.skillitem { width:100%!important;min-height:34px!important;text-align:center; } +.mengsan-hud-skin-skills-shuying .skillitem { -webkit-text-fill-color:currentColor!important;-webkit-text-stroke:0!important;background-clip:border-box!important;-webkit-background-clip:border-box!important; } +.mengsan-hud-skin-skills-shuying .skillitem::before,.mengsan-hud-skin-skills-shuying .skillitem-child { display:none!important; } +.mengsan-hud-skin-skills-shuying .skillitem:is(.select,.selected) { outline:1px solid #e6cb93;outline-offset:-2px; } +.mengsan-hud-skin-skills-shuying>.enable>.skillitem:not(.usable):not(.select),.mengsan-hud-skin-skills-shuying .skillitem.shixiao { opacity:.55; } +.mengsan-hud-skin-skills-shuying>.trigger>.skillitem { color:#c2c7c8!important;font-size:12px!important; } +.mengsan-hud-active-shuying .mengsan-hand-relics-shuying { position:absolute!important;left:auto!important;right:24px!important;transform:none!important;top:auto!important;bottom:var(--mengsan-relic-dock-bottom)!important;width:128px!important;max-width:128px!important;height:auto;max-height:var(--mengsan-status-height);padding:2px;box-sizing:border-box;display:flex;flex-direction:row;flex-wrap:wrap;justify-content:flex-end;gap:6px;overflow-x:hidden;overflow-y:auto;z-index:22;touch-action:pan-y;background:transparent!important;border:0;border-radius:14px;scrollbar-width:thin;scrollbar-color:#dce8f440 transparent; } +.mengsan-hud-active-shuying .mengsan-hand-relics-shuying[hidden] { display:none!important; } +.mengsan-hud-active-shuying .mengsan-hand-relics-shuying>button { flex:none;width:32px;min-height:32px;height:32px;border-color:var(--mengsan-hud-stroke);border-radius:10px;background:transparent; } +.mengsan-hud-active-shuying .mengsan-hand-relics-shuying img { width:28px;height:28px; } +.mengsan-hud-active-shuying .mengsan-hud-hand-shuying .mengsan-hand-scroll-shuying { left:0!important;width:100%!important; } +.mengsan-hud-dialog-shuying { position:fixed;inset:0;margin:auto;width:min(620px,calc(100vw - 24px));max-height:82vh;padding:24px;color:#edf2f7;background:#172131ed;border:1px solid #dce8f42e;border-radius:20px;backdrop-filter:blur(10px);overflow:auto;z-index:150;font:15px/1.6 "Microsoft YaHei",sans-serif; } +.mengsan-hud-dialog-shuying::backdrop { background:#0009; } +.mengsan-hud-dialog-shuying h2 { margin:0 70px 14px 0;font-size:19px; } +.mengsan-hud-dialog-close-shuying { position:absolute;right:14px;top:14px;padding:5px 10px;min-height:36px; } +.mengsan-hud-detail-row-shuying { border-top:1px solid #cbb58a40;padding:10px 0; } +.mengsan-hud-detail-row-shuying p { margin:4px 0; } +[data-mengsan-hud-layout="short"] .mengsan-hud-shuying .mengsan-hud-info-shuying { left:108px;width:90px;gap:4px; } +[data-mengsan-hud-layout="short"] .mengsan-hud-hp-shuying,[data-mengsan-hud-layout="short"] .mengsan-hud-block-shuying { font-size:11px; } +[data-mengsan-hud-layout="short"] .mengsan-hud-name-shuying { font-size:15px; } +[data-mengsan-hud-layout="short"] .mengsan-hud-energy-shuying { font-size:14px; } +[data-mengsan-hud-layout="short"] .mengsan-hud-hand-shuying { bottom:64px!important;height:calc(var(--mengsan-hud-height) - 122px)!important; } +[data-mengsan-hud-layout="short"] .mengsan-hud-center-shuying { top:auto;left:24px; } +[data-mengsan-hud-layout="short"] .mengsan-hud-shuying .mengsan-hud-status-shuying { min-height:26px;padding:2px 4px; } +[data-mengsan-hud-layout="short"] .mengsan-hud-status-shuying img { width:18px;height:18px; } +[data-mengsan-hud-layout="narrow"] .mengsan-hud-shuying .mengsan-hud-info-shuying { left:22px;width:76px;font-size:12px;gap:5px; } +[data-mengsan-hud-layout="narrow"] .mengsan-hud-hand-shuying { bottom:68px!important; } +[data-mengsan-hud-layout="narrow"] .mengsan-hud-name-shuying { font-size:16px; } +[data-mengsan-hud-layout="narrow"] .mengsan-hud-status-shuying span { display:none; } +#arena.mengsan-hud-arena-shuying>.mengsan-hud-notification-shuying { width:148px!important;max-width:148px!important;min-height:30px;height:30px!important;transition:none!important;font:600 16px/30px "Microsoft YaHei",sans-serif!important;white-space:nowrap; } +#arena.mengsan-hud-arena-shuying>.mengsan-hud-notification-shuying>span { font-size:16px!important; } +/* Stack real equipment nodes over the lower portrait, like Decade's player equips. */ +body.mengsan-hud-active-shuying #arena .mengsan-hud-equips-shuying { + position:absolute!important;left:6px!important;right:auto!important;top:auto!important;bottom:6px!important; + width:calc(100% - 12px)!important;min-width:0!important;height:auto!important;max-height:min(100px,58%)!important;box-sizing:border-box!important; + display:flex!important;flex-direction:column!important;align-items:stretch!important;justify-content:flex-start!important;gap:2px;overflow-x:hidden!important;overflow-y:auto!important; + padding:0!important;margin:0!important;transform:none!important;transition:none!important;background:none!important;border:0!important;box-shadow:none!important; + pointer-events:auto!important;touch-action:pan-y;scrollbar-width:thin;scrollbar-color:#dce8f440 transparent;z-index:72!important; +} +body.mengsan-hud-active-shuying #arena .mengsan-hud-equips-shuying:empty { display:none!important; } +body.mengsan-hud-active-shuying #arena .mengsan-hud-equip-host-shuying { opacity:1!important;visibility:visible!important;overflow:visible!important;pointer-events:none!important;z-index:26!important; } +body.mengsan-hud-active-shuying #arena .mengsan-hud-equip-host-shuying>.equips-back { display:none!important; } +body.mengsan-hud-active-shuying #arena .mengsan-hud-equips-shuying>.card { + position:relative!important;inset:auto!important;display:flex!important;flex:none!important;align-items:center!important;justify-content:center!important; + width:100%!important;min-width:0!important;max-width:100%!important;height:18px!important;margin:0!important;padding:0 2px!important;box-sizing:border-box!important; + transform:none!important;transition:none!important;overflow:hidden!important;border:1px solid #5f372d!important;border-radius:3px!important; + background:rgba(228,207,166,.88)!important;box-shadow:none!important;color:#181818!important;pointer-events:auto!important; +} +body.mengsan-hud-active-shuying #arena .mengsan-hud-equips-shuying>.card>.name2 { + position:relative!important;inset:auto!important;display:block!important;max-width:100%!important;width:auto!important;height:auto!important; + overflow:hidden!important;text-overflow:ellipsis!important;white-space:nowrap!important;color:#181818!important;font:11px/16px "Microsoft YaHei",sans-serif!important; + text-shadow:none!important;pointer-events:none!important; +} +body.mengsan-hud-active-shuying #arena .mengsan-hud-equips-shuying>.card>.name2>span { position:static!important;display:inline!important;font:inherit!important; } +body.mengsan-hud-active-shuying #arena .mengsan-hud-equips-shuying>.card>:not(.name2) { display:none!important; } +body.mengsan-hud-active-shuying #arena .mengsan-hud-equips-shuying>.card.selectable { border-color:#e4c98e!important;box-shadow:inset 0 0 0 1px #e4c98e!important; } +body.mengsan-hud-active-shuying #arena .mengsan-hud-equips-shuying>.card.selected { background:#49624b!important;border-color:#e4c98e!important; } + +body.mengsan-hud-active-shuying #arena .mengsan-hud-equips-shuying>.card:is([data-suit="heart"],[data-suit="diamond"])>.name2 { color:#8b0000!important; } +body.mengsan-hud-active-shuying #arena .mengsan-hud-equips-shuying::-webkit-scrollbar { width:3px; } +body.mengsan-hud-active-shuying #arena .mengsan-hud-equips-shuying::-webkit-scrollbar-thumb { background:#dce8f440;border-radius:3px; } +[data-mengsan-hud-layout="short"] .mengsan-hand-relics-shuying { right:16px!important;width:112px!important;max-width:112px!important; } +[data-mengsan-hud-layout="narrow"] .mengsan-hand-relics-shuying { right:16px!important;width:96px!important;max-width:96px!important;max-height:36px;flex-wrap:nowrap;justify-content:flex-start;overflow-x:auto;overflow-y:hidden;touch-action:pan-x; } +body.mengsan-hud-active-shuying #arena>.mengsan-hud-controls-shuying>.mengsan-hud-native-skills-shuying { flex-direction:column!important;flex-wrap:nowrap!important;align-items:stretch!important;max-width:none!important;overflow:auto!important; } +body.mengsan-hud-active-shuying #arena .mengsan-hud-native-skills-shuying>div { width:100%!important;min-width:0!important;box-sizing:border-box!important;padding:8px 10px!important;min-height:40px!important;font:14px/20px "Microsoft YaHei",sans-serif!important;white-space:normal!important; } +[data-mengsan-hud-layout="narrow"] .mengsan-hud-center-shuying { left:22px; } +[data-mengsan-hud-layout="short"] .mengsan-hud-shuying .mengsan-hud-statuses-shuying { height:100%; } +[data-mengsan-hud-layout="narrow"] .mengsan-hud-shuying .mengsan-hud-statuses-shuying { grid-template-columns:1fr; } +.mengsan-hud-prompt-shuying { box-sizing:border-box!important;height:auto!important;min-height:0!important;overflow:auto!important;padding:2px 6px!important;background:none!important;border:0!important;box-shadow:none!important;text-align:center!important;z-index:32!important;color:#f6edcb!important;text-shadow:0 1px 3px #000,0 0 6px #000!important; } +body.mengsan-hud-active-shuying .mengsan-hud-card-intro-shuying { box-sizing:border-box!important;z-index:90!important;height:auto!important;min-height:0!important;overflow:auto!important;border-radius:14px!important;background:#202020f5!important;box-shadow:0 8px 24px #0007!important;color:#f4f4f4!important;transition:none!important; } +body.mengsan-hud-active-shuying #window>.mengsan-hud-card-intro-shuying { z-index:90!important; } +body.mengsan-hud-active-shuying #window>.mengsan-hud-intro-layer-shuying { z-index:89!important; } +.mengsan-hud-card-intro-shuying>.content-container,.mengsan-hud-prompt-shuying>.content-container { position:relative!important;inset:auto!important;height:auto!important;max-height:none!important;overflow:visible!important; } +.mengsan-hud-card-intro-shuying>.content-container>.content,.mengsan-hud-prompt-shuying>.content-container>.content { position:relative!important;inset:auto!important;height:auto!important; } +.mengsan-hud-card-intro-shuying .content>div,.mengsan-hud-prompt-shuying .content>div { position:relative!important; } +body.mengsan-hud-active-shuying .mengsan-hud-intro-layer-shuying { z-index:89!important;transition:none!important; } +.mengsan-hud-frozen-shuying .mengsan-hud-hand-shuying,.mengsan-hud-frozen-shuying .mengsan-hud-self-shuying,.mengsan-hud-frozen-shuying .mengsan-hud-shuying { pointer-events:none!important; } +.mengsan-hud-frozen-shuying .mengsan-hud-hand-shuying *,.mengsan-hud-frozen-shuying .mengsan-hud-self-shuying *,.mengsan-hud-frozen-shuying .mengsan-hud-shuying * { pointer-events:none!important; } +.mengsan-hud-frozen-shuying .mengsan-hud-controls-shuying,.mengsan-hud-frozen-shuying .mengsan-hud-external-control-shuying,.mengsan-hud-frozen-shuying .hand-tip,.mengsan-hud-frozen-shuying .hand-tip-info { visibility:hidden!important; } diff --git a/mengsan/ui/relics.js b/mengsan/ui/relics.js index 68b865f..2b03663 100644 --- a/mengsan/ui/relics.js +++ b/mengsan/ui/relics.js @@ -3,7 +3,7 @@ import { heldRelics } from "../relics/definitions.js"; import { createRelicIcon } from "./relic-icon.js"; import { mountRelicStrip } from "./relic-strip.js"; -export function mountRelics(session, run, battleRelics) { +export function mountRelics(session, run, battleRelics, { stripRoot = ui.me } = {}) { let dialog = null, list = null, previousFocus = null, disposed = false; let relics = heldRelics(run), selected = null, strip = null; const button = document.createElement("button"); @@ -80,7 +80,7 @@ export function mountRelics(session, run, battleRelics) { catch (error) { close(); throw error; } }; button.addEventListener("click", () => open()); - if (ui.me) strip = mountRelicStrip(ui.me, () => heldRelics(run), + if (stripRoot) strip = mountRelicStrip(stripRoot, () => heldRelics(run), open, createRelicIcon, document, window); const dispose = () => { if (disposed) return; diff --git a/mengsan/ui/style.css b/mengsan/ui/style.css index 992ae61..13f37dd 100644 --- a/mengsan/ui/style.css +++ b/mengsan/ui/style.css @@ -1031,15 +1031,15 @@ dialog.mengsan-victory-dialog-shuying::backdrop, dialog.mengsan-card-choice-dial .mengsan-victory-intro-shuying p { max-width: 17em; margin: 0; color: var(--reward-muted); font-size: 15px; line-height: 1.8; } .mengsan-victory-options-shuying { position: relative !important; grid-column: 2; grid-row: 1; display: flex !important; flex-direction: column !important; - gap: 12px; max-height: min(66dvh,560px); padding: 24px; overflow-y: auto; + gap: 8px; max-height: min(66dvh,560px); padding: 16px; overflow-y: auto; border: 1px solid rgba(199,164,119,.65); border-radius: 7px; background: linear-gradient(145deg,#1e3435,#111f24 68%); box-shadow: inset 0 0 0 5px rgba(9,19,22,.8),0 22px 50px #0008; } .mengsan-victory-eyebrow-shuying { color: var(--reward-bronze); font: 700 13px/1.4 "Microsoft YaHei",sans-serif; letter-spacing: .16em; margin: 0 0 5px; } button.mengsan-victory-option-shuying { - position: relative !important; display: flex !important; align-items: center; gap: 18px; - width: 100%; min-height: 94px; padding: 17px 18px; border: 1px solid #60857a; + position: relative !important; display: flex !important; align-items: center; gap: 12px; flex-shrink: 0; + width: 100%; min-height: 64px; padding: 9px 12px; border: 1px solid #60857a; border-left: 4px solid var(--reward-bronze); border-radius: 3px; color: var(--reward-paper); background: linear-gradient(110deg,#254b49,#1a3238); text-align: left; cursor: pointer; transition: background .18s ease, border-color .18s ease, box-shadow .18s ease; @@ -1053,11 +1053,11 @@ button.mengsan-card-choice-tile-shuying:focus-visible, button.mengsan-card-choic outline: 3px solid #d9e9bb; outline-offset: 4px; } button.mengsan-victory-option-shuying:disabled { opacity: .6; cursor: wait; } -.mengsan-victory-option-index-shuying { flex: 0 0 44px; color: var(--reward-bronze); font: 700 24px/1 "KaiTi",serif; } -.mengsan-victory-option-copy-shuying { display: flex !important; flex: 1; min-width: 0; flex-direction: column; gap: 4px; } -.mengsan-victory-option-copy-shuying strong { color: #fff1d5; font: 700 23px/1.2 "KaiTi",serif; } -.mengsan-victory-option-copy-shuying small { color: #d3e0d4; font: 14px/1.5 "Microsoft YaHei",sans-serif; } -.mengsan-victory-option-arrow-shuying { flex: 0 0 28px; color: var(--reward-bronze); font: 34px/1 "KaiTi",serif; text-align: center; } +.mengsan-victory-option-index-shuying { flex: 0 0 32px; color: var(--reward-bronze); font: 700 20px/1 "KaiTi",serif; } +.mengsan-victory-option-copy-shuying { display: flex !important; flex: 1; min-width: 0; flex-direction: column; gap: 3px; } +.mengsan-victory-option-copy-shuying strong { color: #fff1d5; font: 700 20px/1.2 "KaiTi",serif; } +.mengsan-victory-option-copy-shuying small { color: #d3e0d4; font: 13px/1.4 "Microsoft YaHei",sans-serif; } +.mengsan-victory-option-arrow-shuying { flex: 0 0 22px; color: var(--reward-bronze); font: 28px/1 "KaiTi",serif; text-align: center; } button.mengsan-victory-skip-shuying { grid-column: 2; grid-row: 2; justify-self: end; min-height: 48px; margin-top: 16px; padding: 8px 4px; border: 0; border-bottom: 1px solid var(--reward-muted); color: var(--reward-muted); @@ -1121,7 +1121,7 @@ button.mengsan-card-choice-skip-shuying:hover { color: #fff1d5; border-color: #d .mengsan-victory-seal-shuying { display: none !important; } .mengsan-victory-intro-shuying::after { display: none; } .mengsan-victory-intro-shuying h2 { margin-top: 8px; } - .mengsan-victory-options-shuying { grid-column: 1; grid-row: 2; max-height: none; padding: 18px; } + .mengsan-victory-options-shuying { grid-column: 1; grid-row: 2; max-height: none; padding: 12px; } button.mengsan-victory-skip-shuying { grid-column: 1; grid-row: 3; } .mengsan-card-choice-stage-shuying { justify-content: flex-start; } .mengsan-card-choice-grid-shuying { grid-template-columns: repeat(3,minmax(0,1fr)); width: min(620px,100%); } @@ -1131,7 +1131,7 @@ button.mengsan-card-choice-skip-shuying:hover { color: #fff1d5; border-color: #d button.mengsan-card-choice-tile-shuying { min-height: 292px; } .mengsan-card-choice-art-shuying { height: 105px; } .mengsan-victory-option-index-shuying { flex-basis: 30px; font-size: 20px; } - button.mengsan-victory-option-shuying { gap: 10px; padding: 13px; } + button.mengsan-victory-option-shuying { gap: 8px; padding: 8px 10px; } } @media (max-width: 350px) { .mengsan-card-choice-grid-shuying { grid-template-columns: minmax(0,210px); } } @media (prefers-reduced-motion: reduce) {