Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash

- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays
  under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed
- #2 two-tap confirm on 机变 cards (like the shop)
- #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles)
- #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band
- #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from
- #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others)
- #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats)
- #8 official element gauges on units; burst cooldown (阻回) per PRTS
- #9 knocked-down operators stay on their tile with a redeploy countdown ring
- #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼)
- #11 赫默's drone only from her skill; placeable summons limited to talent tokens
- #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel
- #13 the occasional giant bond icon (loading texture scaled ×46) fixed
- docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
This commit is contained in:
sganggs committed 2026-09-30 05:35:45 +08:00
1 parent 0aba602ecf
commit d4f868cd19
107 files changed
+4510 -1653

No files matched your search

+9 -7
View File
@@ -20,7 +20,9 @@
- 联防、最终攻势(两人共享战场与领袖血池)、隐秘核心;同盟模拟断线 10 分钟内可重连(掉线期间按原阵容自动作战,也可「暂离」交给 AI 托管),独立模拟 24 小时内可回来继续。 - 联防、最终攻势(两人共享战场与领袖血池)、隐秘核心;同盟模拟断线 10 分钟内可重连(掉线期间按原阵容自动作战,也可「暂离」交给 AI 托管),独立模拟 24 小时内可回来继续。
- 真实 Spine 小人、官方 BGM / 音效、表情(6 套 × 6 个);可选的官方 3D 棋盘(需从本机客户端提取贴图)。 - 真实 Spine 小人、官方 BGM / 音效、表情(6 套 × 6 个);可选的官方 3D 棋盘(需从本机客户端提取贴图)。
- 作战特效:狙击曳光弹与枪口火光、法术 / 治疗 / 敌方光球、抛物线炮弹与爆炸、回环射手的回旋镖(飞出再飞回)、蕾缪安 3 技能的锁定与逐发炮击、近战斩击弧光、技能光柱与脚下光环;低画质只保留必要特效。 - 作战特效:狙击曳光弹与枪口火光、法术 / 治疗 / 敌方光球、抛物线炮弹与爆炸、回环射手的回旋镖(飞出再飞回)、蕾缪安 3 技能的锁定与逐发炮击、近战斩击弧光、技能光柱与脚下光环;低画质只保留必要特效。
- 漏怪时顶栏的目标生命值实时减少(结算时确定);点选、拖放和配发装备都以画面上实际画出的干员为准。 - 漏怪时顶栏的目标生命值实时减少(结算时确定);点选、拖放和配发装备都按地上的方格:点哪个方格就选中那个方格上的干员,拖动时模型始终在指针 / 手指下。
- 被击倒的干员留在原地并显示再部署倒计时圈,敌我单位显示官方样式的元素损伤图标(含爆发冷却);干员详情显示实时数值(作战中的当前数值 / 休整期的开战数值)。
- 只有一名玩家时(独立模拟,或同盟模拟只带 AI 队友)除作战外不计时;选策略每人 30 秒、只有一个倒计时;机变选卡和购买一样需要二次确认。
- **战斗在各玩家浏览器里模拟**(和官方一样),服务器只管经济与回合:一台低功耗 Windows 小主机即可开服。 - **战斗在各玩家浏览器里模拟**(和官方一样),服务器只管经济与回合:一台低功耗 Windows 小主机即可开服。
## 快速开始 ## 快速开始
@@ -76,12 +78,12 @@ npm start # http://localhost:3000
| 操作 | 方法 | | 操作 | 方法 |
|---|---| |---|---|
| 购买 / 升级调度中心 | 点一次选中,再点一次确认(`D` 升级) | | 购买 / 升级调度中心 / 机变选卡 | 点一次选中,再点一次确认(`D` 升级) |
| 部署 / 移动干员 | 从整备区拖到棋盘格 → 出现方向轮盘 → 往上/右/下/左滑动选择朝向后松手;松在中心或点「✕ 点击取消」取消。拖动中模型站在的格子就是落点(触屏时模型在手指上方) | | 部署 / 移动干员 | 从整备区拖到棋盘格 → 出现方向轮盘 → 往上/右/下/左滑动选择朝向后松手;松在中心或点「✕ 点击取消」取消。拖动时模型在指针 / 手指下,指针所在的格子就是落点 |
| 调整朝向 | 把干员拖回它自己的格子,再选方向 | | 调整朝向 | 把干员拖回它自己的格子,再选方向 |
| 出售 / 撤退 / 销毁装备 | 点击单位 → 底部按钮「出售 +1」「撤退」;整备区里的装备与法术只能「销毁」,已配发的装备锁定在干员身上(干员出售或合成精锐时退回整备区)。也可以把棋盘上的干员拖回整备区撤退 | | 出售 / 撤退 / 销毁装备 | 点击单位所在的格子 → 底部按钮「出售 +1」「撤退」;整备区里的装备与法术只能「销毁」,已配发的装备锁定在干员身上(干员出售或合成精锐时退回整备区)。也可以把棋盘上的干员拖回整备区撤退 |
| 装备 | 把装备拖到干员身上(以图标所在的干员为准,目标干员的格子会高亮;每人 2 件;满了会弹出替换窗口,选择要替换的一件——它会被销毁);法术拖到地块上并选方向 | | 装备 | 把装备拖到干员所在的格子上(配发给该格子上的干员,格子会高亮;每人 2 件;满了会弹出替换窗口,选择要替换的一件——它会被销毁);法术拖到地块上并选方向 |
| 查看详情 | 右键或长按单位 / 卡牌(干员的特质显示在最上方) | | 查看详情 | 右键或长按单位 / 卡牌(干员的特质显示在最上方;属性为实时数值,高于基础值为绿色、低于为红色) |
| 快捷键 | `R` 刷新 · `F` 冻结 · `D` 升级 · `Space` 准备就绪 · `Esc` 取消 / 关闭 | | 快捷键 | `R` 刷新 · `F` 冻结 · `D` 升级 · `Space` 准备就绪 · `Esc` 取消 / 关闭 |
| 方向轮盘键盘操作 | 方向键预览 · `Enter` 确认 · `Esc` 取消 | | 方向轮盘键盘操作 | 方向键预览 · `Enter` 确认 · `Esc` 取消 |
| 暂停(独立模拟) | 作战中(含最终攻势 / 隐秘核心)点顶栏的「暂停」或按 `Space`,再点「继续作战」(或 `Space`)继续;同盟模拟的作战不能暂停 | | 暂停(独立模拟) | 作战中(含最终攻势 / 隐秘核心)点顶栏的「暂停」或按 `Space`,再点「继续作战」(或 `Space`)继续;同盟模拟的作战不能暂停 |
@@ -111,7 +113,7 @@ npm start # http://localhost:3000
## 测试 ## 测试
```bash ```bash
node --test # 单元 + 集成测试(约 2400 项;缺少素材 / 浏览器的用例会自动跳过) node --test # 单元 + 集成测试(约 2500 项;缺少素材 / 浏览器的用例会自动跳过)
SP_E2E=1 node --test test/ui/mock.e2e.test.js # 需要本机 Chrome(CHROME_PATH 可指定路径) SP_E2E=1 node --test test/ui/mock.e2e.test.js # 需要本机 Chrome(CHROME_PATH 可指定路径)
SP_REAL_E2E=1 node --test test/ui/real.e2e.test.js # 需要 Chrome + 已下载素材 SP_REAL_E2E=1 node --test test/ui/real.e2e.test.js # 需要 Chrome + 已下载素材
``` ```
+1 -1
View File
@@ -1 +1 @@
{"version":1,"hash":"4135541dd0d3","generator":"tools/fetch-assets.mjs","stats":{"files":3927,"bytes":259646135,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":546,"sfxUnits":382},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"Skill_3_Idle"},"diLine truncated {"version":1,"hash":"18bc60d83db4","generator":"tools/fetch-assets.mjs","stats":{"files":3959,"bytes":259726913,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":546,"sfxUnits":382},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"Skill_3_Idle"},"diLine truncated
+1 -1
View File
@@ -1 +1 @@
{"events":{"artifact_paid_1":{"id":"artifact_paid_1","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_1_s":{"id":"artifact_paid_1_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_2":{"id":"artifact_paid_2","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_2_s":{"id":"artifact_paid_2_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_3":{"id":"artifact_paid_3","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_3_s":{"id":"artifact_paid_3_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_4":{"id":"artifact_paid_4","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_4_s":{"id":"artifact_paid_4_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_5":{"id":"artifact_paid_5","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_5_s":{"id":"artifact_paid_5_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"bossInitial_1":{"id":"bossInitial_1","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_1_s":{"id":"bossInitial_1_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_2":{"id":"bossInitial_2","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_2_s":{"id":"bossInitial_2_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_3":{"id":"bossInitial_3","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_3_s":{"id":"bossInitial_3_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_4":{"id":"bossInitial_4","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_4_s":{"id":"bossInitial_4_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"Line truncated {"events":{"artifact_paid_1":{"id":"artifact_paid_1","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_1_s":{"id":"artifact_paid_1_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_2":{"id":"artifact_paid_2","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_2_s":{"id":"artifact_paid_2_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_3":{"id":"artifact_paid_3","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_3_s":{"id":"artifact_paid_3_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_4":{"id":"artifact_paid_4","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_4_s":{"id":"artifact_paid_4_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_5":{"id":"artifact_paid_5","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_5_s":{"id":"artifact_paid_5_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"bossInitial_1":{"id":"bossInitial_1","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_1_s":{"id":"bossInitial_1_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_2":{"id":"bossInitial_2","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_2_s":{"id":"bossInitial_2_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_3":{"id":"bossInitial_3","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_3_s":{"id":"bossInitial_3_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_4":{"id":"bossInitial_4","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_4_s":{"id":"bossInitial_4_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"Line truncated
+1 -1
View File
@@ -1 +1 @@
{"season":"act2autochess","seasonName":"卫戍协议:盟约","modes":{"mode_training_1":{"modeId":"mode_training_1","name":"入门协议","code":"AC-TR-1","sortId":1,"type":"LOCAL","difficulty":"TRAINING","inScope":false,"color":"#4ed8af","iconId":"mode_training_icon","backgroundId":"background_training","desc":"用以熟悉协议内容的模式","effectDescList":["·仅可使用预置的调配干员","·无奖励","·无法用于解锁策略"],"unlockText":null,"specialPhaseTime":150,"activeBondIds":["yanShip","sargonShip","victoriaShip","kjeragShip","lateranoShip","egirShip","siracusaShip","kazimierzShip","preciShip","swiftShip","skillfulShip","arcaneShip","steadShip","deputShip","visiShip","miraShip","investShip","raidShip","indomShip","maniShip","emptyShip","soloShip","suntShip"],"inactiveBondIds":[],"inactiveEnemyKeys":[],"lastRound":4,"bossRound":4,"hiddenRound":null,"rounds":{"1":{"template":"act1autochess_tr01","bossTemplates":null,"combatTimeLimit":40,"levelMaxPlayTime":40,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"2":{"template":"act1autochess_tr02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"3":{"template":"act1autochess_tr02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":999,"prepTimeData":999,"isSpPrepare":true,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"4":{"template":null,"bossTemplates":{"boss_7":"act1autochess_tr04"},"combatTimeLimit":null,"levelMaxPlayTime":180,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":true,"isHidden":false,"bossOvertimeAfter":150}},"spRounds":[3],"combatTimeLimit":{"1":40,"2":45,"3":45,"4":null},"enemyScale":{"1":{"atk":1,"hp":1,"speed":1,"assumed":true},"2":{"atk":1,"hp":1,"speed":1,"assumed":true},"3":{"atk":1,"hp":1,"speed":1,"assumed":true},"4":{"atk":1,"hp":1,"speed":1,"assumed":true}},"bossHpScale":{"bloodPointKey":null,"coop":1,"aliveScaling":false,"unaffectedByEnemyScale":true},"upgradePrices":[4,4,6,9,12],"maxShopLevel":6,"shopSlots":{"1":{"chess":3,"item":0},"2":{"chess":3,"item":0},"3":{"chess":3,"item":0},"4":{"chess":3,"item":0},"5":{"chess":3,"item":1},"6":{"chess":3,"item":1}},"levelTagColors":{"1":"#434343","2":"#626654","3":"#445760","4":"#615B74","5":"#6C5E41","6":"#5d341e"},"stages":["act1autochess_m01"],"bossWeights":{"boss_7":5},"hiddenBossWeights":{}},"mode_single_funny":{"modeId":"mode_single_funny","name":"标准模拟","code":"AC-1","sortId":2,"type":"SINGLE","difficulty":"FUNNY","inScope":true,"color":"#f6a329","iconId":"mode_funny_icon","backgroundId":"background_funny","desc":"时长较短的模拟训练","effectDescList":["·可以快速完成作战","·常规奖励"],"unlockText":"通关【入门协议】后解锁","specialPhaseTime":150,"activeBondIds":["yanShip","sargonShip","victoriaShip","kjeragShip","siracusaShip","preciShip","swiftShip","steadShip","deputShip","visiShip","indomShip","maniShip","emptyShip"],"inactiveBondIds":["lateranoShip","egirShip","kazimierzShip","skillfulShip","arcaneShip","miraShip","investShip","raidShip","soloShip","suntShip"],"inactiveEnemyKeys":["enemy_1112_emppnt","enemy_1207_sfji","enemy_1275_dwlock_2","enemy_1234_dsubrl_2","enemy_10122_uacann_2","enemy_1272_nhtank_2","enemy_9008_acbunn","enemy_1174_duholy_2","enemy_1355_mrfly_2","enemy_1197_sfshu_2","enemy_10067_ftsjc","enemy_1270_nhstlk_2","enemy_1283_sgkill","enemy_1175_dushdo_2","enemy_1254_lypa_2","enemy_1425_lrcmra_2","enemy_1329_cbshld_2","enemy_1072_dlancer"],"lastRound":9,"bossRound":9,"hiddenRound":null,"rounds":{"1":{"template":"act1autochess_01","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"2":{"template":"act1autochess_02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"3":{"template":"act1autochess_03","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"4":{"template":"act1autochess_04","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"5":{"template":"act1autochess_05","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"6":{"template":"act1autochess_06","bossTemplates":null,"combatTimeLimit":70,"levelMaxPlayTime":70,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"7":{"template":"act1auLine truncated {"season":"act2autochess","seasonName":"卫戍协议:盟约","modes":{"mode_training_1":{"modeId":"mode_training_1","name":"入门协议","code":"AC-TR-1","sortId":1,"type":"LOCAL","difficulty":"TRAINING","inScope":false,"color":"#4ed8af","iconId":"mode_training_icon","backgroundId":"background_training","desc":"用以熟悉协议内容的模式","effectDescList":["·仅可使用预置的调配干员","·无奖励","·无法用于解锁策略"],"unlockText":null,"specialPhaseTime":150,"activeBondIds":["yanShip","sargonShip","victoriaShip","kjeragShip","lateranoShip","egirShip","siracusaShip","kazimierzShip","preciShip","swiftShip","skillfulShip","arcaneShip","steadShip","deputShip","visiShip","miraShip","investShip","raidShip","indomShip","maniShip","emptyShip","soloShip","suntShip"],"inactiveBondIds":[],"inactiveEnemyKeys":[],"lastRound":4,"bossRound":4,"hiddenRound":null,"rounds":{"1":{"template":"act1autochess_tr01","bossTemplates":null,"combatTimeLimit":40,"levelMaxPlayTime":40,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"2":{"template":"act1autochess_tr02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"3":{"template":"act1autochess_tr02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":999,"prepTimeData":999,"isSpPrepare":true,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"4":{"template":null,"bossTemplates":{"boss_7":"act1autochess_tr04"},"combatTimeLimit":null,"levelMaxPlayTime":180,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":true,"isHidden":false,"bossOvertimeAfter":150}},"spRounds":[3],"combatTimeLimit":{"1":40,"2":45,"3":45,"4":null},"enemyScale":{"1":{"atk":1,"hp":1,"speed":1,"assumed":true},"2":{"atk":1,"hp":1,"speed":1,"assumed":true},"3":{"atk":1,"hp":1,"speed":1,"assumed":true},"4":{"atk":1,"hp":1,"speed":1,"assumed":true}},"bossHpScale":{"bloodPointKey":null,"coop":1,"aliveScaling":false,"unaffectedByEnemyScale":true},"upgradePrices":[4,4,6,9,12],"maxShopLevel":6,"shopSlots":{"1":{"chess":3,"item":0},"2":{"chess":3,"item":0},"3":{"chess":3,"item":0},"4":{"chess":3,"item":0},"5":{"chess":3,"item":1},"6":{"chess":3,"item":1}},"levelTagColors":{"1":"#434343","2":"#626654","3":"#445760","4":"#615B74","5":"#6C5E41","6":"#5d341e"},"stages":["act1autochess_m01"],"bossWeights":{"boss_7":5},"hiddenBossWeights":{}},"mode_single_funny":{"modeId":"mode_single_funny","name":"标准模拟","code":"AC-1","sortId":2,"type":"SINGLE","difficulty":"FUNNY","inScope":true,"color":"#f6a329","iconId":"mode_funny_icon","backgroundId":"background_funny","desc":"时长较短的模拟训练","effectDescList":["·可以快速完成作战","·常规奖励"],"unlockText":"通关【入门协议】后解锁","specialPhaseTime":150,"activeBondIds":["yanShip","sargonShip","victoriaShip","kjeragShip","siracusaShip","preciShip","swiftShip","steadShip","deputShip","visiShip","indomShip","maniShip","emptyShip"],"inactiveBondIds":["lateranoShip","egirShip","kazimierzShip","skillfulShip","arcaneShip","miraShip","investShip","raidShip","soloShip","suntShip"],"inactiveEnemyKeys":["enemy_1112_emppnt","enemy_1207_sfji","enemy_1275_dwlock_2","enemy_1234_dsubrl_2","enemy_10122_uacann_2","enemy_1272_nhtank_2","enemy_9008_acbunn","enemy_1174_duholy_2","enemy_1355_mrfly_2","enemy_1197_sfshu_2","enemy_10067_ftsjc","enemy_1270_nhstlk_2","enemy_1283_sgkill","enemy_1175_dushdo_2","enemy_1254_lypa_2","enemy_1425_lrcmra_2","enemy_1329_cbshld_2","enemy_1072_dlancer"],"lastRound":9,"bossRound":9,"hiddenRound":null,"rounds":{"1":{"template":"act1autochess_01","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"2":{"template":"act1autochess_02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"3":{"template":"act1autochess_03","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"4":{"template":"act1autochess_04","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"5":{"template":"act1autochess_05","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"6":{"template":"act1autochess_06","bossTemplates":null,"combatTimeLimit":70,"levelMaxPlayTime":70,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"7":{"template":"act1auLine truncated
+1 -1
View File
@@ -1 +1 @@
{"enemy_1000_gopro_2":{"key":"enemy_1000_gopro_2","name":"猎狗pro","level":0,"rank":"NORMAL","handbookIndex":"O2","desc":"比一般猎狗更具有作战能力,行动速度很快。","descRaw":"比一般猎狗更具有作战能力,<@eb.key>行动速度很快</>。","applyWay":"MELEE","stats":{"maxHp":1700,"atk":260,"def":0,"res":20,"moveSpeed":1.9,"bat":1.4,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["infection"],"be":3060,"beFactor":1,"attrPower":3060,"isFlyEnemy":false,"tokenOnly":false,"acTypes":["SPECIAL"],"acType":"SPECIAL","templateSlot":"T","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1000_gopro_2","spine":"enemy_1000_gopro_2"},"enemy_1001_bigbo":{"key":"enemy_1001_bigbo","name":"大鲍勃","level":0,"rank":"BOSS","handbookIndex":"11","desc":"整合运动的特殊近战人员,使用电锯进行攻击,兼具高耐久、攻击力和防御力。","descRaw":"整合运动的特殊近战人员,使用电锯进行攻击,兼具<@eb.danger>高耐久、攻击力和防御力</>。","applyWay":"MELEE","stats":{"maxHp":22000,"atk":1900,"def":800,"res":30,"moveSpeed":0.5,"bat":6,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":4,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":[],"be":33990,"beFactor":1,"attrPower":33990,"isFlyEnemy":false,"tokenOnly":false,"acTypes":[],"acType":null,"templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1001_bigbo","spine":"enemy_1001_bigbo"},"enemy_1005_yokai":{"key":"enemy_1005_yokai","name":"妖怪","level":0,"rank":"NORMAL","handbookIndex":"D1","desc":"【飞行单位】敌方人员操纵的无人机,不会进行攻击。","descRaw":"<@eb.key>【飞行单位】</>敌方人员操纵的无人机,不会进行攻击。","applyWay":"NONE","stats":{"maxHp":800,"atk":0,"def":50,"res":0,"moveSpeed":0.9,"bat":2.3,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"none","dmgTypes":["none"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":950,"beFactor":1,"attrPower":950,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":"NF","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai","spine":"enemy_1005_yokai"},"enemy_1005_yokai_2":{"key":"enemy_1005_yokai_2","name":"妖怪MKII","level":0,"rank":"NORMAL","handbookIndex":"D2","desc":"【飞行单位】妖怪的改进版,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>妖怪的改进版,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":1550,"atk":220,"def":50,"res":0,"moveSpeed":0.9,"bat":3,"aspd":100,"rangeRadius":2,"rawRangeRadius":2,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":4000,"beFactor":0.7,"attrPower":2800,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai_2","spine":"enemy_1005_yokai_2"},"enemy_1005_yokai_3":{"key":"enemy_1005_yokai_3","name":"威龙","level":0,"rank":"ELITE","handbookIndex":"D3","desc":"【飞行单位】大型无人机,拥有更好的耐久性能,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>大型无人机,拥有更好的耐久性能,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":30000,"atk":660,"def":170,"res":0,"moveSpeed":0.5,"bat":3.8,"aspd":100,"rangeRadius":2,"rawRangeRadius":2,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerLine truncated {"enemy_1000_gopro_2":{"key":"enemy_1000_gopro_2","name":"猎狗pro","level":0,"rank":"NORMAL","handbookIndex":"O2","desc":"比一般猎狗更具有作战能力,行动速度很快。","descRaw":"比一般猎狗更具有作战能力,<@eb.key>行动速度很快</>。","applyWay":"MELEE","stats":{"maxHp":1700,"atk":260,"def":0,"res":20,"moveSpeed":1.9,"bat":1.4,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["infection"],"be":3060,"beFactor":1,"attrPower":3060,"isFlyEnemy":false,"tokenOnly":false,"acTypes":["SPECIAL"],"acType":"SPECIAL","templateSlot":"T","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1000_gopro_2","spine":"enemy_1000_gopro_2"},"enemy_1001_bigbo":{"key":"enemy_1001_bigbo","name":"大鲍勃","level":0,"rank":"BOSS","handbookIndex":"11","desc":"整合运动的特殊近战人员,使用电锯进行攻击,兼具高耐久、攻击力和防御力。","descRaw":"整合运动的特殊近战人员,使用电锯进行攻击,兼具<@eb.danger>高耐久、攻击力和防御力</>。","applyWay":"MELEE","stats":{"maxHp":22000,"atk":1900,"def":800,"res":30,"moveSpeed":0.5,"bat":6,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":4,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":[],"be":33990,"beFactor":1,"attrPower":33990,"isFlyEnemy":false,"tokenOnly":false,"acTypes":[],"acType":null,"templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1001_bigbo","spine":"enemy_1001_bigbo"},"enemy_1005_yokai":{"key":"enemy_1005_yokai","name":"妖怪","level":0,"rank":"NORMAL","handbookIndex":"D1","desc":"【飞行单位】敌方人员操纵的无人机,不会进行攻击。","descRaw":"<@eb.key>【飞行单位】</>敌方人员操纵的无人机,不会进行攻击。","applyWay":"NONE","stats":{"maxHp":800,"atk":0,"def":50,"res":0,"moveSpeed":0.9,"bat":2.3,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"none","dmgTypes":["none"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":950,"beFactor":1,"attrPower":950,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":"NF","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai","spine":"enemy_1005_yokai"},"enemy_1005_yokai_2":{"key":"enemy_1005_yokai_2","name":"妖怪MKII","level":0,"rank":"NORMAL","handbookIndex":"D2","desc":"【飞行单位】妖怪的改进版,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>妖怪的改进版,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":1550,"atk":220,"def":50,"res":0,"moveSpeed":0.9,"bat":3,"aspd":100,"rangeRadius":2,"rawRangeRadius":2,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":4000,"beFactor":0.7,"attrPower":2800,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai_2","spine":"enemy_1005_yokai_2"},"enemy_1005_yokai_3":{"key":"enemy_1005_yokai_3","name":"威龙","level":0,"rank":"ELITE","handbookIndex":"D3","desc":"【飞行单位】大型无人机,拥有更好的耐久性能,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>大型无人机,拥有更好的耐久性能,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":30000,"atk":660,"def":170,"res":0,"moveSpeed":0.5,"bat":3.8,"aspd":100,"rangeRadius":2,"rawRangeRadius":2,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerLine truncated
+1 -1
View File
@@ -1 +1 @@
{"chess_item_1_01_e_a":{"id":"chess_item_1_01_e_a","baseId":"chess_item_1_01_e_a","goldenId":"chess_item_1_01_e_b","isGolden":false,"trapId":"trap_1041_acarm041","iconId":"trap_1041_acarm041","identifier":267,"name":"维式重锤","itemType":"EQUIP","tier":1,"shopSortId":1,"price":1,"hideInShop":false,"mergeable":true,"upgradeNum":2,"upgradeChessId":"chess_item_1_01_e_b","duration":-1,"giveBondId":"victoriaShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acarm041","effectName":"维式重锤","desc":"攻击力+15%","descRaw":"攻击力<@ba.vup>+15%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"atk":0.15},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"atk":0.15,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"ATK% += atk (additive with other ATK% buffs)","rangeGrid":[[0,0]],"flavor":"一柄曾在维多利亚的工厂内铸造和平的重锤。\n“用同样的铁与火,去终结那场战争。”"},"chess_item_1_01_e_b":{"id":"chess_item_1_01_e_b","baseId":"chess_item_1_01_e_a","goldenId":"chess_item_1_01_e_b","isGolden":true,"trapId":"trap_1041_acarm041","iconId":"trap_1041_acarm041","identifier":326,"name":"维式重锤","itemType":"EQUIP","tier":1,"shopSortId":1,"price":5,"hideInShop":false,"mergeable":false,"upgradeNum":0,"upgradeChessId":null,"duration":0,"giveBondId":"victoriaShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acgarm041","effectName":"维式重锤","desc":"攻击力+25%","descRaw":"攻击力<@ba.vup>+25%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"atk":0.25},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"atk":0.25,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"ATK% += atk (additive with other ATK% buffs)","rangeGrid":[[0,0]],"flavor":"一柄曾在维多利亚的工厂内铸造和平的重锤。\n“用同样的铁与火,去终结那场战争。”"},"chess_item_1_02_e_a":{"id":"chess_item_1_02_e_a","baseId":"chess_item_1_02_e_a","goldenId":"chess_item_1_02_e_b","isGolden":false,"trapId":"trap_1047_acarm047","iconId":"trap_1047_acarm047","identifier":273,"name":"坚守盾牌","itemType":"EQUIP","tier":1,"shopSortId":2,"price":1,"hideInShop":false,"mergeable":true,"upgradeNum":2,"upgradeChessId":"chess_item_1_02_e_b","duration":-1,"giveBondId":"steadShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acarm047","effectName":"坚守盾牌","desc":"防御力+20%","descRaw":"防御力<@ba.vup>+20%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"def":0.2},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"def":0.2,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"DEF% += def","rangeGrid":[[0,0]],"flavor":"一面属于坚守之人的盾牌。\n“推击、拨挡、压制,主动防御的基础在于对盾的运用。”"},"chess_item_1_02_e_b":{"id":"chess_item_1_02_e_b","baseId":"chess_item_1_02_e_a","goldenId":"chess_item_1_02_e_b","isGolden":true,"trapId":"trap_1047_acarm047","iconId":"trap_1047_acarm047","identifier":332,"name":"坚守盾牌","itemType":"EQUIP","tier":1,"shopSortId":2,"price":5,"hideInShop":false,"mergeable":false,"upgradeNum":0,"upgradeChessId":null,"duration":0,"giveBondId":"steadShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acgarm047","effectName":"坚守盾牌","desc":"防御力+35%","descRaw":"防御力<@ba.vup>+35%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"def":0.35},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"def":0.35,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"DEF% += def","rangeGrid":[[0,0]],"flavor":"一面属于坚守之人的盾牌。\n“推击、拨挡、压制,主动防御的基础在于对盾的运用。”"},"chess_item_1_03_e_a":{"id":"chess_item_1_03_e_a","baseId":"chess_item_1_03_e_a","goldenId":"chess_item_1_03_e_b","isGolden":false,"trapId":"trap_1066_acarm066","iconId":"trap_1066_acarm066","identifier":292,"name":"盟约之币","itemType":"EQUIP","tier":1,"shopSortId":3,"price":1,"hideInShop":false,"mergeable":true,"upgradeNum":2,"upgradeChessId":"chess_item_1_03_e_b","duration":-1,"giveBondId":null,"givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acarm066","effectName":"盟约之币","desc":"装备时销毁,并获得1资金","descRaw":"装备时销毁,并获得<@ba.vup>1</>资金","buffs":[{"key":"equip_destory_gain_random_coin","countType":"NONE","bb":{"min":1,"max":1},"bbStr":{}}],"params":{"min":1,"max":1},"category":"ECONOMY","kind":"consume_on_equip","family":null,"implFormula":"On equip: destroy item, player.funds += randInt(min,max) (normal 1, golden 2). Does not use an equip slot.","rangeGrid":[[0,0]],"flavor":"“推动协议模拟Line truncated {"chess_item_1_01_e_a":{"id":"chess_item_1_01_e_a","baseId":"chess_item_1_01_e_a","goldenId":"chess_item_1_01_e_b","isGolden":false,"trapId":"trap_1041_acarm041","iconId":"trap_1041_acarm041","identifier":267,"name":"维式重锤","itemType":"EQUIP","tier":1,"shopSortId":1,"price":1,"hideInShop":false,"shopExcluded":false,"shopExcludedBy":null,"mergeable":true,"upgradeNum":2,"upgradeChessId":"chess_item_1_01_e_b","duration":-1,"giveBondId":"victoriaShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acarm041","effectName":"维式重锤","desc":"攻击力+15%","descRaw":"攻击力<@ba.vup>+15%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"atk":0.15},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"atk":0.15,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"ATK% += atk (additive with other ATK% buffs)","rangeGrid":[[0,0]],"flavor":"一柄曾在维多利亚的工厂内铸造和平的重锤。\n“用同样的铁与火,去终结那场战争。”"},"chess_item_1_01_e_b":{"id":"chess_item_1_01_e_b","baseId":"chess_item_1_01_e_a","goldenId":"chess_item_1_01_e_b","isGolden":true,"trapId":"trap_1041_acarm041","iconId":"trap_1041_acarm041","identifier":326,"name":"维式重锤","itemType":"EQUIP","tier":1,"shopSortId":1,"price":5,"hideInShop":false,"shopExcluded":false,"shopExcludedBy":null,"mergeable":false,"upgradeNum":0,"upgradeChessId":null,"duration":0,"giveBondId":"victoriaShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acgarm041","effectName":"维式重锤","desc":"攻击力+25%","descRaw":"攻击力<@ba.vup>+25%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"atk":0.25},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"atk":0.25,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"ATK% += atk (additive with other ATK% buffs)","rangeGrid":[[0,0]],"flavor":"一柄曾在维多利亚的工厂内铸造和平的重锤。\n“用同样的铁与火,去终结那场战争。”"},"chess_item_1_02_e_a":{"id":"chess_item_1_02_e_a","baseId":"chess_item_1_02_e_a","goldenId":"chess_item_1_02_e_b","isGolden":false,"trapId":"trap_1047_acarm047","iconId":"trap_1047_acarm047","identifier":273,"name":"坚守盾牌","itemType":"EQUIP","tier":1,"shopSortId":2,"price":1,"hideInShop":false,"shopExcluded":false,"shopExcludedBy":null,"mergeable":true,"upgradeNum":2,"upgradeChessId":"chess_item_1_02_e_b","duration":-1,"giveBondId":"steadShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acarm047","effectName":"坚守盾牌","desc":"防御力+20%","descRaw":"防御力<@ba.vup>+20%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"def":0.2},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"def":0.2,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"DEF% += def","rangeGrid":[[0,0]],"flavor":"一面属于坚守之人的盾牌。\n“推击、拨挡、压制,主动防御的基础在于对盾的运用。”"},"chess_item_1_02_e_b":{"id":"chess_item_1_02_e_b","baseId":"chess_item_1_02_e_a","goldenId":"chess_item_1_02_e_b","isGolden":true,"trapId":"trap_1047_acarm047","iconId":"trap_1047_acarm047","identifier":332,"name":"坚守盾牌","itemType":"EQUIP","tier":1,"shopSortId":2,"price":5,"hideInShop":false,"shopExcluded":false,"shopExcludedBy":null,"mergeable":false,"upgradeNum":0,"upgradeChessId":null,"duration":0,"giveBondId":"steadShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acgarm047","effectName":"坚守盾牌","desc":"防御力+35%","descRaw":"防御力<@ba.vup>+35%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"def":0.35},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"def":0.35,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"DEF% += def","rangeGrid":[[0,0]],"flavor":"一面属于坚守之人的盾牌。\n“推击、拨挡、压制,主动防御的基础在于对盾的运用。”"},"chess_item_1_03_e_a":{"id":"chess_item_1_03_e_a","baseId":"chess_item_1_03_e_a","goldenId":"chess_item_1_03_e_b","isGolden":false,"trapId":"trap_1066_acarm066","iconId":"trap_1066_acarm066","identifier":292,"name":"盟约之币","itemType":"EQUIP","tier":1,"shopSortId":3,"price":1,"hideInShop":false,"shopExcluded":false,"shopExcludedBy":null,"mergeable":true,"upgradeNum":2,"upgradeChessId":"chess_item_1_03_e_b","duration":-1,"giveBondId":null,"givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acarm066","effectName":"盟约之币","desc":"装备时销毁,并获得1资金","descRaw":"装备时销毁,并获得<@ba.vup>1</>资金","buffs":[{"key":"equip_destory_gain_random_coin","countType":"NONE","bb":{"min":1,"max":1},"bbStr":{}}],"params":{"min":1,"max":1},"category":"ECONOMY",Line truncated
+1 -1
View File
@@ -1 +1 @@
{"token_10000_silent_healrb":{"tokenId":"token_10000_silent_healrb","kind":"summon","name":"医疗探机","appellation":"Medical Drone","desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","profession":"TOKEN","subProfessionId":"notchar1","position":"ALL","displayType":"DEFAULT","placeable":true,"owners":["chess_char_2_02_a","chess_char_2_02_b"],"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"skill":{"skillId":"skcom_withdraw","bb":{}},"deployLimit":1,"count":1,"variants":{"chess_char_2_02_a":{"phase":1,"level":60,"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}}},"chess_char_2_02_b":{"phase":2,"level":55,"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}},"byModule":{"none":{"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"talents":[]}}}},"assets":{"avatar":"token_10000_silent_healrb","spine":"token_10000_silent_healrb"}},"token_10006_vodfox_doll":{"tokenId":"token_10006_vodfox_doll","kind":"summon","name":"诅咒娃娃","appellatiLine truncated {"token_10000_silent_healrb":{"tokenId":"token_10000_silent_healrb","kind":"summon","name":"医疗探机","appellation":"Medical Drone","desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","profession":"TOKEN","subProfessionId":"notchar1","position":"ALL","displayType":"DEFAULT","placeable":false,"owners":["chess_char_2_02_a","chess_char_2_02_b"],"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"skill":{"skillId":"skcom_withdraw","bb":{}},"deployLimit":1,"count":1,"variants":{"chess_char_2_02_a":{"phase":1,"level":60,"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}}},"chess_char_2_02_b":{"phase":2,"level":55,"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}},"byModule":{"none":{"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"talents":[]}}}},"assets":{"avatar":"token_10000_silent_healrb","spine":"token_10000_silent_healrb"}},"token_10006_vodfox_doll":{"tokenId":"token_10006_vodfox_doll","kind":"summon","name":"诅咒娃娃","appellatLine truncated
+4
View File
@@ -149,6 +149,10 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
units: { [charId|tokenId|enemyId]: { attack?, hit?, skill?, skills?: {[skillIndex]: url}, die?, born? } } units: { [charId|tokenId|enemyId]: { attack?, hit?, skill?, skills?: {[skillIndex]: url}, die?, born? } }
} }
}, },
// units' attack / hit (tools/assets/audio.mjs pickUnitSfx): operators get normal-mode banks only — the plain
// `attack` / `combat` ability first, never a bank holding a skill-mode file (`_d` / `_h` / `_s`; the normal attack's end
// in `_n`) — with their own projectile banks (ON_PROJECTILE_BORN / _HIT.projectile_chr_<name>) as fallbacks
// (DESIGN §18.4: 纯烬艾雅法拉's S3 impact used to be her `hit`); enemies and tokens take the first attack-like bank
fonts: { css: '/fonts/fonts.css', faces: { [name]: { family, weight, woff2?, original } } } fonts: { css: '/fonts/fonts.css', faces: { [name]: { family, weight, woff2?, original } } }
} }
``` ```
+8 -5
View File
@@ -119,7 +119,7 @@ Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAft
| `dp` | `{"init":10,"perSec":1,"max":99}` | | | `dp` | `{"init":10,"perSec":1,"max":99}` | |
| `unite` | `{"maxHelpers":2,"helperOrder":"unitsOnField>activeBond>undownedUnits; pair: unitsOnField>activeBond>activeLayers>undownedUnits, first = right field (PRTS 帮助)","layerGainsEnabled":false,"templates":{"1":"act1autochess_escaped_single","2":"act1autochess_escaped_multi"},…}` | 联防; `helperOrder` documents the rule `server/match/unite.js helperOrder` implements (research 08 §5; ties → seat) | | `unite` | `{"maxHelpers":2,"helperOrder":"unitsOnField>activeBond>undownedUnits; pair: unitsOnField>activeBond>activeLayers>undownedUnits, first = right field (PRTS 帮助)","layerGainsEnabled":false,"templates":{"1":"act1autochess_escaped_single","2":"act1autochess_escaped_multi"},…}` | 联防; `helperOrder` documents the rule `server/match/unite.js helperOrder` implements (research 08 §5; ties → seat) |
| `finalAssault` | `{"pairing":"seatOrderPairs","oddPlayerAlone":true,"movableBossPerAlivePlayerSide":true,"layerGainsEnabled":false}` | | | `finalAssault` | `{"pairing":"seatOrderPairs","oddPlayerAlone":true,"movableBossPerAlivePlayerSide":true,"layerGainsEnabled":false}` | |
| `timers` | `{"infoCheck":25,"infoCheckHint":5,"bandDraft":50,"bandDraftHint":15,"bandTurn":12,"battleCheck":3,"spFirst":30,"spTurn":16,"soloPrepTimeData":300,"soloSpTimeData":150,"chatCd":1,"chatBubble":3,"broadcastDelay":1,"enterSteps":[…]}` | seconds | | `timers` | `{"infoCheck":25,"infoCheckHint":5,"bandDraft":50,"bandDraftHint":15,"bandTurn":30,"battleCheck":3,"spFirst":30,"spTurn":16,"soloPrepTimeData":300,"soloSpTimeData":150,"chatCd":1,"chatBubble":3,"broadcastDelay":1,"enterSteps":[…]}` | seconds |
| `bans[difficulty]` | `{"core":3,"addon":4}` (FUNNY `{0,1}`) + `rule` | per-match disabled-bond draw (research 01 A2) | | `bans[difficulty]` | `{"core":3,"addon":4}` (FUNNY `{0,1}`) + `rule` | per-match disabled-bond draw (research 01 A2) |
| `bandDraft` | `{"skipsPerPlayer":1,"order":"random","duplicatesAllowed":true,"timeoutBandId":"band_bldsk"}` | | | `bandDraft` | `{"skipsPerPlayer":1,"order":"random","duplicatesAllowed":true,"timeoutBandId":"band_bldsk"}` | |
| `titles[]` | `{"id":"comment_1","picId":"comment_icon_1","name":"卫戍之星","stat":"bossDamage","rule":"max","onlyOnWin":true,"text":"…"}` | 评语 + [ASSUMED] criteria; `titleRule` explains assignment | | `titles[]` | `{"id":"comment_1","picId":"comment_icon_1","name":"卫戍之星","stat":"bossDamage","rule":"max","onlyOnWin":true,"text":"…"}` | 评语 + [ASSUMED] criteria; `titleRule` explains assignment |
@@ -272,6 +272,7 @@ everything needed to resolve a unit for `(chessId, skillIndex, moduleId)` (`simd
| `name` | `"维式重锤"` | | | `name` | `"维式重锤"` | |
| `itemType` | `"EQUIP"` | `EQUIP` (equipped, max 2) / `MAGIC` (Arts, dropped on a tile) | | `itemType` | `"EQUIP"` | `EQUIP` (equipped, max 2) / `MAGIC` (Arts, dropped on a tile) |
| `tier`, `price`, `hideInShop` | `1`, `1`, `false` | golden price 5 (never sold in shop) | | `tier`, `price`, `hideInShop` | `1`, `1`, `false` | golden price 5 (never sold in shop) |
| `shopExcluded`, `shopExcludedBy` | `false`, `null` / `true`, `"维多利亚盟约每25层 / 洛洛的定制品"` | effect-only items the shop never sells although the official table does not hide them (`tools/build-data.mjs SHOP_EXCLUDED_ITEMS`, sourced: the 4 special 维式重锤 — 战栗 / 坚固 / 加速 / 灼燃 — and 突变细胞; user playtest #4 item 5), normal and golden. `sim/simdata.js isShopItem` (non-golden EQUIP, not `hideInShop`, not `shopExcluded`) is the one predicate of every shop-item draw (the shop's item slot, 道具补给 / 机密商店 cards, `pool_equip_normal` / `_shop_1` / `_kathe` / `_narant`, `Match.rollItemId`): 51 of the 56 normal items; the item card shows `shopExcludedBy` as the source |
| `mergeable`, `upgradeNum`, `upgradeChessId` | `true`, `2`, `"chess_item_1_01_e_b"` | 2 copies → golden; `upgradeNum` 100 = never merges | | `mergeable`, `upgradeNum`, `upgradeChessId` | `true`, `2`, `"chess_item_1_01_e_b"` | 2 copies → golden; `upgradeNum` 100 = never merges |
| `duration` | `-1` | trapDuration | | `duration` | `-1` | trapDuration |
| `giveBondId`, `givePowerId`, `canGiveBond` | `"victoriaShip"`, `null`, `false` | 变形同构体 (`canGiveBond`) grants the `giveBondId` of the *other* equipped item | | `giveBondId`, `givePowerId`, `canGiveBond` | `"victoriaShip"`, `null`, `false` | 变形同构体 (`canGiveBond`) grants the `giveBondId` of the *other* equipped item |
@@ -315,7 +316,7 @@ Types: `BAND_INITIAL` 41, `BOND` 23, `EQUIP` 115, `ENEMY_GAIN` 129 (悬赏), `BU
| `cards.bounty[]` | `{"effectId":"enemyeffect_10_4","name":"悬赏·飞行I","tier":1,"coin":1,"payout":"kill","rounds":2,"multiRound":false,"enemyKey":"enemy_1005_yokai","count":1,"adds":[…]}` | `payout` `kill` (killer gets coins, 联防 helpers too) / `perfect` (chooser's own phase perfect); `rounds` = battles affected (99 = all remaining); `tier` from the I/II/III suffix (else coin value) | | `cards.bounty[]` | `{"effectId":"enemyeffect_10_4","name":"悬赏·飞行I","tier":1,"coin":1,"payout":"kill","rounds":2,"multiRound":false,"enemyKey":"enemy_1005_yokai","count":1,"adds":[…]}` | `payout` `kill` (killer gets coins, 联防 helpers too) / `perfect` (chooser's own phase perfect); `rounds` = battles affected (99 = all remaining); `tier` from the I/II/III suffix (else coin value) |
| `cards.tactic[]` | `{"effectId":"map_m01_1","name":"模拟战场演变·模式一","kind":"terrain","stageId":"act1autochess_m01","team":false}` | `kind` `ally` / `enemyDebuff` / `terrain` (only for the match stage); `team` = also given to teammates | | `cards.tactic[]` | `{"effectId":"map_m01_1","name":"模拟战场演变·模式一","kind":"terrain","stageId":"act1autochess_m01","team":false}` | `kind` `ally` / `enemyDebuff` / `terrain` (only for the match stage); `team` = also given to teammates |
| `schedule[modeId]` | `{"spRounds":[3,6,9],"rounds":{"3":{"families":[{"family":"bounty","weight":50},…],"cards":6,"supplyTiers":[1,4],"bountyTiers":[1,2],"events":{"bounty":[…],"supply":[…],"shop":[…],"tactic":[…]},"assumed":true}}}` | research 01 A4 defaults: pick a family by weight; `supplyTiers` = item tier window for 道具补给; 机密商店 draws any tier; `events` = official event ids usable as the phase header | | `schedule[modeId]` | `{"spRounds":[3,6,9],"rounds":{"3":{"families":[{"family":"bounty","weight":50},…],"cards":6,"supplyTiers":[1,4],"bountyTiers":[1,2],"events":{"bounty":[…],"supply":[…],"shop":[…],"tactic":[…]},"assumed":true}}}` | research 01 A4 defaults: pick a family by weight; `supplyTiers` = item tier window for 道具补给; 机密商店 draws any tier; `events` = official event ids usable as the phase header |
| `pools[poolId]` | `{"kind":"equip","items":[…]}` / `{"kind":"chess","tier":2,"rule":"shopEligible"}` | server-side reward pools referenced by effects (`pool_equip_*`, `pool_chess_shop_N_reward`, `pool_char_later`, …), all [ASSUMED]; `rule:"shopEligible"` = visible/non-hidden entries, `maxTier:"shopLevel"` = ≤ current shop level, `weighted` = `[[id, weight]]` | | `pools[poolId]` | `{"kind":"equip","items":[…]}` / `{"kind":"chess","tier":2,"rule":"shopEligible"}` | server-side reward pools referenced by effects (`pool_equip_*`, `pool_chess_shop_N_reward`, `pool_char_later`, …), all [ASSUMED]; `rule:"shopEligible"` = visible/non-hidden entries, `maxTier:"shopLevel"` = ≤ current shop level, `weighted` = `[[id, weight]]`. `pool_equip_vict` (维多利亚, every 25 layers: "获得一件带有随机特殊效果的维式重锤") and `pool_equip_rockr` (洛洛's 定制品; user playtest #4 — the research 04 §8 guess 有限加速器 / 激光发射器 / 护盾无人机 / 双模机械臂 / 蜂鸣器 is superseded) = the 4 special 维式重锤, uniform |
## 9. `enemies.json` — `{ [enemyKey]: Enemy }` (249 keys that can appear) ## 9. `enemies.json` — `{ [enemyKey]: Enemy }` (249 keys that can appear)
@@ -332,6 +333,7 @@ blackboards (transitively). Level = `randomEnemyAttributeDict[key].level` (0 for
| `abilities[]` | `{"text":"位于源石污染区内时,攻击造成法术伤害","textRaw":"…","format":"NORMAL"}` | handbook ability list | | `abilities[]` | `{"text":"位于源石污染区内时,攻击造成法术伤害","textRaw":"…","format":"NORMAL"}` | handbook ability list |
| `talents` | `{"bb":{"1.hp_ratio":0.2,"1.damage_scale":0.5},"bbStr":{}}` | talentBlackboard | | `talents` | `{"bb":{"1.hp_ratio":0.2,"1.damage_scale":0.5},"bbStr":{}}` | talentBlackboard |
| `skills[]` | `{"prefabKey":"2","priority":0,"cooldown":50,"initCooldown":0,"spCost":0,"bb":{"hp_ratio":0.02},"bbStr":{"branch_id":"boss_summon_enemy","enemy_key":"enemy_1005_yokai"}}` | `branch_id` → `waves[t].branches` | | `skills[]` | `{"prefabKey":"2","priority":0,"cooldown":50,"initCooldown":0,"spCost":0,"bb":{"hp_ratio":0.02},"bbStr":{"branch_id":"boss_summon_enemy","enemy_key":"enemy_1005_yokai"}}` | `branch_id` → `waves[t].branches` |
| `sp` | `{"type":"INCREASE_WHEN_ATTACK","maxSp":3,"initSp":0,"increment":1}` | enemy_database `spData` of the 22 enemies with SP skills (e.g. 假想敌:黑云 `enemy_9009_acfort`: `INCREASE_WITH_TIME`, `maxSp` 3 = its ammo cap); absent otherwise |
| `notCountInTotal`, `tags` | `false`, `["sarkaz"]` | | | `notCountInTotal`, `tags` | `false`, `["sarkaz"]` | |
| `be`, `beFactor`, `attrPower`, `isFlyEnemy` | `6620`, `1`, `6620`, `false` | `attrPower` = the official float32 attribute power `P = atk·5 + maxHp + def·3 + res·3` of the **enemy_database** record (season override NOT applied: 灼藤 / 元核孽生者 count with their database ATK) — the input of the replacement count (§10); `beFactor` = `randomEnemyAttributeDict.enemyBattleEffectivenessFactor`; `be` = round(P'/f) on the spawned stats (informative); `isFlyEnemy` = the official movement class | | `be`, `beFactor`, `attrPower`, `isFlyEnemy` | `6620`, `1`, `6620`, `false` | `attrPower` = the official float32 attribute power `P = atk·5 + maxHp + def·3 + res·3` of the **enemy_database** record (season override NOT applied: 灼藤 / 元核孽生者 count with their database ATK) — the input of the replacement count (§10); `beFactor` = `randomEnemyAttributeDict.enemyBattleEffectivenessFactor`; `be` = round(P'/f) on the spawned stats (informative); `isFlyEnemy` = the official movement class |
| `tokenOnly` | `false` | only ever spawned by other enemies (summons / blackboards / level refs) — a token for 联防 routing (research 08 §5) | | `tokenOnly` | `false` | only ever spawned by other enemies (summons / blackboards / level refs) — a token for 联防 routing (research 08 §5) |
@@ -442,7 +444,7 @@ Glyph legend (`rows`):
|---|---|---| |---|---|---|
| `tokenId`, `kind`, `name`, `appellation`, `desc`, `descRaw` | …, `"summon"`, `"狼群"` | | | `tokenId`, `kind`, `name`, `appellation`, `desc`, `descRaw` | …, `"summon"`, `"狼群"` | |
| `profession`, `subProfessionId`, `position` | `"TOKEN"`, `"notchar1"`, `"MELEE"` | | | `profession`, `subProfessionId`, `position` | `"TOKEN"`, `"notchar1"`, `"MELEE"` | |
| `displayType`, `placeable` | `"DEFAULT"`, `true` | `shopStateTokenDict`: DEFAULT = hand-placeable piece; HIDDEN = battle-only | | `displayType`, `placeable` | `"DEFAULT"`, `true` | `shopStateTokenDict` DEFAULT / HIDDEN (battle-only). `placeable` (a prep hand piece) = DEFAULT **and** a talent summon (海嗣, 狼群, 流形); skill summons (赫默's 医疗探机, 巫恋's 诅咒娃娃) are never hand pieces — their kits place them when the skill fires (user playtest #4 item 11) |
| `owners[]` | `["chess_char_3_19_a","chess_char_3_19_b"]` | | | `owners[]` | `["chess_char_3_19_a","chess_char_3_19_b"]` | |
| `stats`, `rangeGrid`, `dmgType`, `attackKind`, `projectile`, `canHitFly` | first owner's values | defaults | | `stats`, `rangeGrid`, `dmgType`, `attackKind`, `projectile`, `canHitFly` | first owner's values | defaults |
| `skill` | `{"skillId":"sktok_vigil_wolf_3","bb":{…}}` | default token skill (same slot as the owner's skill) | | `skill` | `{"skillId":"sktok_vigil_wolf_3","bb":{…}}` | default token skill (same slot as the owner's skill) |
@@ -452,7 +454,7 @@ Glyph legend (`rows`):
| `variants[o].byModule[m]` | `{"stats":{…},"immunities":{…},"trait":{…},"talents":[…]}` | golden owner with another module `m` or `'none'`: the token as that module makes it (module `tokenAttributeBlackboard`, `isToken` trait/talent parts) | | `variants[o].byModule[m]` | `{"stats":{…},"immunities":{…},"trait":{…},"talents":[…]}` | golden owner with another module `m` or `'none'`: the token as that module makes it (module `tokenAttributeBlackboard`, `isToken` trait/talent parts) |
| `assets` | `{"avatar":"token_10028_vigil_wolf","spine":"token_10028_vigil_wolf"}` | | | `assets` | `{"avatar":"token_10028_vigil_wolf","spine":"token_10028_vigil_wolf"}` | |
| mapChar extras | `phase`, `level`, `trait`, `talents`, `positions:[{"alias":"char_613_acmedc#2_multi_only","pos":[10,10],"dir":"RIGHT","multiOnly":true}]`, `source` | | | mapChar extras | `phase`, `level`, `trait`, `talents`, `positions:[{"alias":"char_613_acmedc#2_multi_only","pos":[10,10],"dir":"RIGHT","multiOnly":true}]`, `source` | |
| bondSummon extras | `bondId`, `motion`, `skills`, `talents`, `assets.isEnemyModel` | 炎佑 base template; ATK/HP are **replaced** at battle start by 30 % of the 炎 operators' sums (bonds.yanShip) | | bondSummon extras | `bondId`, `motion`, `skills`, `talents`, `assets.isEnemyModel` | 炎佑 base template (600 ATK / 12000 HP); at battle start 30 % of the 炎 operators' ATK / HP sums are **added** to it (PRTS "(最终加算)", bonds.yanShip; the 9-炎 ATK ×1.5 on the whole ATK [ASSUMED]) |
--- ---
@@ -489,7 +491,8 @@ Glyph legend (`rows`):
16. Research-only fields (null without `docs/research`): stage `name`, item `category/kind/family/implFormula/ 16. Research-only fields (null without `docs/research`): stage `name`, item `category/kind/family/implFormula/
requiresBondId/rangeGrid/flavor`, bond `spec`, E2 art availability (falls back to "char has an E2 phase"). requiresBondId/rangeGrid/flavor`, bond `spec`, E2 art availability (falls back to "char has an E2 phase").
17. [ASSUMED] content (flagged in data): 机变 family schedule and server pools, the solo leader pool factor 17. [ASSUMED] content (flagged in data): 机变 family schedule and server pools, the solo leader pool factor
(`bossHpScale.solo` 0.25), title criteria, income cap 12, per-turn band-draft timer 12 s. The special-enemy (`bossHpScale.solo` 0.25), title criteria, income cap 12, per-turn band-draft timer 30 s (`timers.bandTurn`, the
step's only countdown — user playtest #4), the shop-only item list (`SHOP_EXCLUDED_ITEMS`, from play). The special-enemy
generator, the co-op leader pool (`bloodPoint`, no alive-player scaling) and the 联防 timing are official. generator, the co-op leader pool (`bloodPoint`, no alive-player scaling) and the 联防 timing are official.
18. **Module parts flagged `isToken`** (伺夜, 浊心斯卡蒂, 缪尔赛思, 耀骑士临光 golden) upgrade the summon only; they are 18. **Module parts flagged `isToken`** (伺夜, 浊心斯卡蒂, 缪尔赛思, 耀骑士临光 golden) upgrade the summon only; they are
applied to `tokens.json` variants, never to the operator's talents/trait. applied to `tokens.json` variants, never to the operator's talents/trait.
+81 -30
View File
@@ -256,12 +256,12 @@ Engine helpers (content must use these, never touch internals): `battle.dealDama
### 5.5 Combat rules (engine) ### 5.5 Combat rules (engine)
- **Initial deployment** at t=0, free, order top→bottom then left→right (right-side boss player: right→left). Deploy effects fire (`deploy` with `initial: true`). Then `battleStart`. - **Initial deployment** at t=0, free, order top→bottom then left→right (right-side boss player: right→left). Deploy effects fire (`deploy` with `initial: true`). Then `battleStart`.
- **DP**: per player, start 10, +1/s, cap 99; used only for auto-redeploy (dead op: after `respawnTime`, if its tile is free and DP ≥ cost ⇒ redeploy with full HP, SP = initSp). - **DP**: per player, start 10, +1/s, cap 99; used only for auto-redeploy (dead op: after `respawnTime`, if its tile is free and DP ≥ cost ⇒ redeploy with full HP, SP = initSp). A knocked-out operator stays **down** on its tile from the knock-out until it redeploys (b.snap `down`, §18.3: counting / waiting for DP / its tile taken); summons, devices and enemies leave at once.
- **Blocking**: a ground op with free capacity blocks a ground, non-unblockable enemy whose position enters its tile (|dx|,|dy| ≤ 0.5). Enemy uses `blockCnt` capacity (default 1). Blocked enemies stop moving and attack their blocker if melee (or anyone in range if ranged, blocker first). Blocker death frees its enemies. - **Blocking**: a ground op with free capacity blocks a ground, non-unblockable enemy whose position enters its tile (|dx|,|dy| ≤ 0.5). Enemy uses `blockCnt` capacity (default 1). Blocked enemies stop moving and attack their blocker if melee (or anyone in range if ranged, blocker first). Blocker death frees its enemies.
- **Operator attacks**: every `interval` s if a valid target exists; targets = up to `maxTargets` (default 1) from `unitsInGrid(rangeGrid)`, priority: (1) units it blocks (melee), (2) profession/trait priorities (e.g. fly-first snipers, `trait` text parsed by build-data into `targetPriority`), (3) enemy with the **least remaining path distance** to its goal, ties → earliest spawned. Melee (no ranged trait) cannot hit FLY enemies. Stealthed enemies can't be targeted unless blocked or revealed. Ranged ops fire projectiles (speed ~12 tiles/s, instant for melee/arts-beam classes as data indicates); damage applies on impact. - **Operator attacks**: every `interval` s if a valid target exists; targets = up to `maxTargets` (default 1) from `unitsInGrid(rangeGrid)`, priority: (1) units it blocks (melee), (2) profession/trait priorities (e.g. fly-first snipers, `trait` text parsed by build-data into `targetPriority`), (3) enemy with the **least remaining path distance** to its goal, ties → earliest spawned. Melee (no ranged trait) cannot hit FLY enemies. Stealthed enemies can't be targeted unless blocked or revealed. Ranged ops fire projectiles (speed ~12 tiles/s, instant for melee/arts-beam classes as data indicates); damage applies on impact.
- **Healers** (`dmgType heal`): target the ally in range with the lowest HP% below 100%; do nothing otherwise (SP still regenerates). - **Healers** (`dmgType heal`): target the ally in range with the lowest HP% below 100%; do nothing otherwise (SP still regenerates).
- **Enemy AI**: follow the route (checkpoints; WALK paths recomputed on the grid when devices change); when blocked stop & fight; ranged enemies (`rangeRadius > 0`) attack ops within radius: priority blocker → highest taunt → **latest deployed**; they pause movement for `ATTACK_PAUSE = 0.35 s` per attack. FLY enemies ignore blocking and ground obstacles, follow their checkpoint route. - **Enemy AI**: follow the route (checkpoints; WALK paths recomputed on the grid when devices change); when blocked stop & fight; ranged enemies (`rangeRadius > 0`) attack ops within radius: priority blocker → highest taunt → **latest deployed**; they pause movement for `ATTACK_PAUSE = 0.35 s` per attack. FLY enemies ignore blocking and ground obstacles, follow their checkpoint route.
- **Damage**: phys `max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 0.05·A)`; arts `max(A × (1 − R'/100), 0.05·A)` with `R' = max(0, R×(1−resIgnorePct) − resIgnoreFlat)`; true = A. Then × `dmgDealtMul` (source) × `dmgTakenMul` & type-specific taken multipliers (target) × `dmg.mul`; shields absorb; dodge checks for phys/arts. Element damage fills a gauge (1000 on enemies, 1000 on ops); at full: burn ⇒ 1200 arts dmg + res −20 for 10 s (enemies); neural ⇒ 1000 true dmg + stun 5 s (ops); necrosis/apoptosis per research; gauge resets after the burst effect ends. - **Damage**: phys `max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 0.05·A)`; arts `max(A × (1 − R'/100), 0.05·A)` with `R' = max(0, R×(1−resIgnorePct) − resIgnoreFlat)`; true = A. Then × `dmgDealtMul` (source) × `dmgTakenMul` & type-specific taken multipliers (target) × `dmg.mul`; shields absorb; dodge checks for phys/arts. Element damage fills a gauge (1000; enemy leaders 2000) × `elemTakenMul` × (1 − 损伤抵抗/100), 损伤抵抗 = data `epResistance` (PRTS 元素). A full gauge bursts with the side-specific official effects (sim/constants.js `ELEMENT`; operators: 灼燃 1200 arts + RES −20, 神经 stun 10 s then 1000 true, 凋亡 15 s of 阻回 (`noSp`: no SP gain of any kind) + 静默 with −1 SP/s and 100 arts/s, 侵蚀 DEF −100 then 800 phys; enemies: the "·我方" terms). Every burst starts a **爆发冷却** = its duration (灼燃 / 神经 / 侵蚀 10 s — enemies' 侵蚀 8 s — 凋亡 15 s) during which NO element of the unit fills or can be recovered; when it ends every gauge of the unit resets (§18.3).
- **Leak**: an enemy reaching its goal is removed, counted as leaked for its field owner (`sourcePlayerId` for unite). LP accounting is done by the match, not the battle. - **Leak**: an enemy reaching its goal is removed, counted as leaked for its field owner (`sourcePlayerId` for unite). LP accounting is done by the match, not the battle.
- **End**: all spawns done and no enemies alive ⇒ `cleared`; `time ≥ timeLimit` ⇒ `timeout` (remaining enemies = leaked). Boss battles end when the shared boss pool reaches 0 (victory) or the match forces an end (team LP 0). - **End**: all spawns done and no enemies alive ⇒ `cleared`; `time ≥ timeLimit` ⇒ `timeout` (remaining enemies = leaked). Boss battles end when the shared boss pool reaches 0 (victory) or the match forces an end (team LP 0).
@@ -301,12 +301,13 @@ Profession traits (default behaviours implemented once in the engine, keyed by `
### 6.1 State machine (Match.js) ### 6.1 State machine (Match.js)
``` ```
LOBBY(room) → INFO_CHECK (co-op 25 s, solo untimed; all ready ⇒ skip; the operator loadout (§16) locks when it ends) → BAND_DRAFT (co-op: random order, 12 s/turn, 1 skip each, a strategy a teammate already took is refused (队友已选), timeout ⇒ band_bldsk; solo: free pick, no timer) → BATTLE_CHECK (3 s) LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed (§18.2); all ready ⇒ skip; the operator loadout (§16) locks when it ends) → BAND_DRAFT (co-op: random order, ONE countdown — `BAND_TURN_SECONDS` 30 s per turn = m.public.deadline, AI seats pick at once, 1 skip each, a strategy a teammate already took is refused (队友已选), timeout ⇒ the strategy the player highlights (`g.bandFocus`) while free, else band_bldsk, else the first free one; solo: free pick, no timer, no skip; a single human: untimed) → BATTLE_CHECK (3 s)
→ loop r = 1..lastRound: → loop r = 1..lastRound:
ROUND_START (income, upgrade price −1 (floor 0), temp NOT wiped — what overflowed after the last prep's deadline is shown in this ROUND_START (income, upgrade price −1 (floor 0), temp NOT wiped — what overflowed after the last prep's deadline is shown in this
prep (§6.2 temp overflow), unfrozen shop slots rerolled, frozen kept, <进入休整期时> effects) prep (§6.2 temp overflow), unfrozen shop slots rerolled, frozen kept, <进入休整期时> effects)
[SP_DRAFT] (if r ∈ spRounds: 机变 draft; co-op 30 s first picker / 16 s others; solo 3 cards untimed) [SP_DRAFT] (if r ∈ spRounds: 机变 draft; co-op 30 s first picker / 16 s others; solo 3 cards; solo / single human untimed;
PREP (co-op: timer from config, ends when all alive humans+bots ready; solo: untimed, ends on ready) a card is picked with two taps, §18.2)
PREP (co-op: timer from config, ends when all alive humans+bots ready; solo / single human: untimed, ends on ready)
PREP_END (<休整期结束时> effects; unfrozen shop cleared; temp overflow: Ready is blocked while temp is non-empty; at the deadline PREP_END (<休整期结束时> effects; unfrozen shop cleared; temp overflow: Ready is blocked while temp is non-empty; at the deadline
the temp pieces due at this prep are resolved (chess sold back, items / summon stacks destroyed) — pieces that arrived the temp pieces due at this prep are resolved (chess sold back, items / summon stacks destroyed) — pieces that arrived
after Ready or during the <休整期结束时> effects wait for the next prep (§6.2 tempDue)) after Ready or during the <休整期结束时> effects wait for the next prep (§6.2 tempDue))
@@ -393,15 +394,16 @@ S→C: `welcome {playerId, token, name, serverNow}` · `room.state {code, hostId
Platform additions (implemented): `welcome.{resumed, version}`, `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'shutdown'`; WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's `room.start` counts as the host being ready. After any (re)`hello` the server pushes `room.state` and, if a match runs, the match resends `m.public`/`m.private` (+`m.field`, or the field's `b.start` under client-side combat). **Reconnect window:** a dropped session stays resumable for 10 min (`net.js NET_DEFAULTS.reconnectWindowMs`; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its **solo** room's match runs stays resumable for the official `config.constants.singleReconnectTime` (86 400 s = 24 h; lobby option `soloReconnectWindowMs`; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become `match.onLeave` (co-op: 中途退出 = elimination; solo: the run ends `'abandoned'`). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, `start/handle/onDisconnect/onReconnect/onLeave/dispose`) is documented verbatim at the top of `server/match/Match.js` and is normative. `server/data.js` provides `getData()` + index getters (`getChess`, `getBond`, `getGarrison`, `getItem`, `getBand`, `getEffect`, `getEnemy`, `getWave`, `getStage`, `getBoss`, `getToken`, `getConfig`, `getMode`). Note: the skill trigger rule lives at `chess.skill.trigger.rule` in data. Platform additions (implemented): `welcome.{resumed, version}`, `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'shutdown'`; WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's `room.start` counts as the host being ready. After any (re)`hello` the server pushes `room.state` and, if a match runs, the match resends `m.public`/`m.private` (+`m.field`, or the field's `b.start` under client-side combat). **Reconnect window:** a dropped session stays resumable for 10 min (`net.js NET_DEFAULTS.reconnectWindowMs`; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its **solo** room's match runs stays resumable for the official `config.constants.singleReconnectTime` (86 400 s = 24 h; lobby option `soloReconnectWindowMs`; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become `match.onLeave` (co-op: 中途退出 = elimination; solo: the run ends `'abandoned'`). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, `start/handle/onDisconnect/onReconnect/onLeave/dispose`) is documented verbatim at the top of `server/match/Match.js` and is normative. `server/data.js` provides `getData()` + index getters (`getChess`, `getBond`, `getGarrison`, `getItem`, `getBand`, `getEffect`, `getEnemy`, `getWave`, `getStage`, `getBoss`, `getToken`, `getConfig`, `getMode`). Note: the skill trigger rule lives at `chess.skill.trigger.rule` in data.
### 8.2 Match ### 8.2 Match
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId}` · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.leave` C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId}` · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
S→C: S→C:
- `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks}, sp?:{family,cards,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — omitted when 0 and in every other phase (§17.5 live LP) */}] , fields:[{fieldId, kind, players:[playerId...]}] }` - `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — omitted when 0 and in every other phase (§17.5 live LP) */}] , fields:[{fieldId, kind, players:[playerId...]}] }`
- `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change. - `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change.
- `m.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo] }` - `m.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo] }` (the units on the field, knocked-out operators waiting to redeploy included — `Battle.fieldMeta`, §18.3)
- NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type). - NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type).
- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total }` 20 Hz - `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem? }` 20 Hz. Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken (sim/constants.js `DOWN_STATE`); `elem: [[id, element, fill 0..1, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0)
- `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]`, `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]` - `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]`, `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]`
- `m.toast {kind, text}` · `m.ticker {text}` · `m.emote {playerId, id}` · `m.result {...}` - `m.toast {kind, text}` · `m.ticker {text}` · `m.emote {playerId, id}` · `m.result {...}`
- `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, base: {…same}}` (shared/protocol.js `unitStatsEntry`) as its next battle starts (§18.5)
`UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId? }` (`dir` = the ally's deploy direction; the renderer picks the model (Back for UP, mirrored for LEFT) and the ground wedge from it; `skillIndex` = the ally's equipped skill (§16): the renderer plays that skill's Spine clip (`anims.skills[i]`) and the audio its own ON_SKILL_START sound; `moduleId` = an elite ally's equipped module (uniEquipId | 'none'); both also on the prep-scouting `m.field` units (`Match.prepFieldMeta`), so a teammate's unit shows its owner's loadout in the detail card). `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId? }` (`dir` = the ally's deploy direction; the renderer picks the model (Back for UP, mirrored for LEFT) and the ground wedge from it; `skillIndex` = the ally's equipped skill (§16): the renderer plays that skill's Spine clip (`anims.skills[i]`) and the audio its own ON_SKILL_START sound; `moduleId` = an elite ally's equipped module (uniEquipId | 'none'); both also on the prep-scouting `m.field` units (`Match.prepFieldMeta`), so a teammate's unit shows its owner's loadout in the detail card).
@@ -442,20 +444,19 @@ view.setPrep(privateState, { editable }) // render hand/temp/board pieces as
view.enterBattle(fieldMeta) // switch to battle rendering for m.field view.enterBattle(fieldMeta) // switch to battle rendering for m.field
view.pushSnapshot(snap) ; view.pushEvents(ev) // interpolated rendering (100 ms buffer) view.pushSnapshot(snap) ; view.pushEvents(ev) // interpolated rendering (100 ms buffer)
view.highlightTiles(tiles, style) // legal/illegal placement, range preview view.highlightTiles(tiles, style) // legal/illegal placement, range preview
view.on('pieceDragStart'|'pieceDrop'|'pieceDragEnd'|'pieceDragOver'|'pieceClick'|'pieceHover'|'tileHover', fn) view.on('pieceDragStart'|'pieceDrop'|'pieceDragEnd'|'pieceClick'|'pieceHover'|'tileHover', fn)
// pieceDrop: { uid, target: {area:'board',row,col}|{area:'hand',idx}|{area:'outside', clientX, clientY}, over } // pieceDrop: { uid, target: {area:'board',row,col}|{area:'hand',idx}|{area:'outside', clientX, clientY} }
// area 'outside' lets the DOM UI decide (e.g. dropped on the shop bar ⇒ sell) // area 'outside' lets the DOM UI decide: released off the grid or over DOM covering the canvas (the shop bar), mouse
// pieceDragOver { uid, over, clientX, clientY }: while an item is dragged, `over` = the uid of the own operator / // and touch alike — there is no drag-to-sell, the piece goes back
// summon under the dragged plate (null: none; render/pick.js), sent when it changes and BEFORE the drag asks // the drop target, its highlight and tileHover while dragging = the tile under the POINTER; an item dropped on a
// canPlace; pieceDrop / pieceDragEnd carry the final `over` (§17.2) // tile equips the unit standing on it (§18.1)
// tileHover while dragging = the drop target: the tile under the dragged ghost's feet (§17.2) view.pieceScreenRect(uid) // the drawn body's rect { left, top, right, bottom, width, height, x, y } (tooltips / overlays)
view.pieceScreenRect(uid) // for tooltips/overlays; `.shape` = the drawn body (render/pick.js PickBody)
view.tileScreen(row, col) // { x, y, s, poly } client px of a tile top (direction wheel geometry) view.tileScreen(row, col) // { x, y, s, poly } client px of a tile top (direction wheel geometry)
view.holdPiece(uid, {row,col}|null) // keep a dropped piece on a tile while its direction is chosen view.holdPiece(uid, {row,col}|null) // keep a dropped piece on a tile while its direction is chosen
view.setPieceDir(uid, dir) // show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir) view.setPieceDir(uid, dir) // show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir)
view.resize() ; view.destroy() view.resize() ; view.destroy()
``` ```
Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored, shadow sprite), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow), status icons; damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade; leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens. 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` (§17.2); the Spine store never hands out a skeleton whose unload is in flight (§17.1). Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored, shadow sprite), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow), status icons, the element icon right of the bars (§18.3); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens. 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1).
--- ---
@@ -463,11 +464,11 @@ Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highl
Look & feel: research 06 §11–§12 (near-black green-grey panels, mint `#4ed8af` accents, gold prices, bracket frames, hexagon badges, bond discs, 7-segment countdown). Fonts: Noto Sans SC (Google Fonts with local fallback), Bender/Novecento self-hosted for numerals and Latin micro-labels. Look & feel: research 06 §11–§12 (near-black green-grey panels, mint `#4ed8af` accents, gold prices, bracket frames, hexagon badges, bond discs, 7-segment countdown). Fonts: Noto Sans SC (Google Fonts with local fallback), Bender/Novecento self-hosted for numerals and Latin micro-labels.
Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立模拟 / 同盟模拟 → create or join; recent rooms), **Room** (4 seats, invite code + copy link, difficulty picker (host), add/remove AI, ready, start), **Briefing** (INFO_CHECK: stage, factions, disabled bonds, banned chess, boss silhouette, ready x/4), **Band draft** (grid of 40 bands with icon/name/LP/effect; turn order list; skip), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule/LP (live during the own battle, §17.5)/timer/ready; bond strip + popups; left team panel with LP/status/emote bubbles, click to watch; bottom shop bar (level card, 3–5 chess cards, item card, funds, refresh, freeze, collapse); detail panel on click/hover (header with bonds, then 特质 — the operator's own effect — right under it, 特性, stats, skill, module, equipped items, talents: §17.5); enemy preview drawer (next round enemies with counts, factions); 机变 overlay; merge reward overlay; phase banners; ticker; toasts; emote wheel; settings (volume, quality, show damage numbers)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room). Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立模拟 / 同盟模拟 → create or join; recent rooms), **Room** (4 seats, invite code + copy link, difficulty picker (host), add/remove AI, ready, start), **Briefing** (INFO_CHECK: stage, factions, disabled bonds, banned chess, boss silhouette, ready x/4), **Band draft** (grid of 40 bands with icon/name/LP/effect; turn order list; skip), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule/LP (live during the own battle, §17.5)/timer/ready; bond strip + popups; left team panel with LP/status/emote bubbles, click to watch; bottom shop bar (level card, 3–5 chess cards, item card, funds, refresh, freeze, collapse); detail panel on click/hover (header with bonds, then 特质 — the operator's own effect — right under it, 特性, stats (live: the battle's own numbers / the start-of-battle ones in prep, §18.5), skill, module, equipped items, talents: §17.5); enemy preview drawer (next round enemies with counts, factions); 机变 overlay (two taps, §18.2); merge reward overlay; phase banners; ticker; toasts; emote wheel; settings (volume, quality, show damage numbers)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room).
Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card and upgrade are two-tap (select → confirm); drag bench↔board — a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range; no drag-to-sell; drag item onto unit ⇒ equip (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail; `R` refresh, `F` freeze, `D` level-up, `Space` ready (in a solo battle: pause / resume, §14 Solo pause). All actions optimistic-free: UI waits for server state (latency is small). Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2); a press / drop goes by the tile under the pointer (§18.1); drag bench↔board — a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit's tile ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range; no drag-to-sell; drag an item onto a unit's tile ⇒ equip that unit (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail; `R` refresh, `F` freeze, `D` level-up, `Space` ready (in a solo battle: pause / resume, §14 Solo pause). All actions optimistic-free: UI waits for server state (latency is small).
Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled), boss music in R14/15. Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4), boss music in R14/15.
--- ---
@@ -539,7 +540,7 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer
- Waves: official generator (research 08 §2): 3 of 6 types, each owns exactly 3 of the 15 round slots, SPECIAL the rest (shuffled per match); per round one type + one weighted special entry (half by round); each template SPAWN **action** becomes `clamp(roundHalfEven(n·P(t)/P(new)), 1, 5)` units over the same window; placeholder actions of the other movement class are skipped. Stage per match from the mode's list (标准 = 战场#01 only, §6.5). No custom balance multipliers (`data/tuning.json` keeps only titles); co-op boss pool = `bloodPoint[difficulty]` unscaled. - Waves: official generator (research 08 §2): 3 of 6 types, each owns exactly 3 of the 15 round slots, SPECIAL the rest (shuffled per match); per round one type + one weighted special entry (half by round); each template SPAWN **action** becomes `clamp(roundHalfEven(n·P(t)/P(new)), 1, 5)` units over the same window; placeholder actions of the other movement class are skipped. Stage per match from the mode's list (标准 = 战场#01 only, §6.5). No custom balance multipliers (`data/tuning.json` keeps only titles); co-op boss pool = `bloodPoint[difficulty]` unscaled.
- Ground pathing: official SPFA-style 4-direction search from the goal with crates as cost-1000 tiles, then straight-line smoothing (research 08 §3.4). act1 m02 crates #001–#004 inactive in 下半. Remake refinement (user playtest #2): route lengths stay the official ones, but among equal-length routes the one with the fewest non-blockable (floor / gate) tiles wins and smoothing never cuts across floor the grid route does not walk — e.g. 战场#01's lower-gate enemies take the col-8 road, not the col-9 floor lane (server/sim/grid.js header; test/sim/pathing-blockable.test.js). - Ground pathing: official SPFA-style 4-direction search from the goal with crates as cost-1000 tiles, then straight-line smoothing (research 08 §3.4). act1 m02 crates #001–#004 inactive in 下半. Remake refinement (user playtest #2): route lengths stay the official ones, but among equal-length routes the one with the fewest non-blockable (floor / gate) tiles wins and smoothing never cuts across floor the grid route does not walk — e.g. 战场#01's lower-gate enemies take the col-8 road, not the col-9 floor lane (server/sim/grid.js header; test/sim/pathing-blockable.test.js).
- Observing: no watching while your own normal battle runs; after it ends (and in prep) tap a teammate → 前往查看 (local replica, fast-forwarded). ‹ › only inside 联防/FA fields; FA: own pair only; eliminated: free. - Observing: no watching while your own normal battle runs; after it ends (and in prep) tap a teammate → 前往查看 (local replica, fast-forwarded). ‹ › only inside 联防/FA fields; FA: own pair only; eliminated: free.
- Prep interactions: tap a unit → underframe with 出售 +1 (and 撤退 on board); items/Arts → 销毁; shop and upgrade are two-tap (select → confirm); strategy draft forbids a strategy already taken by a teammate (队友已选). - Prep interactions: tap a unit → underframe with 出售 +1 (and 撤退 on board); items/Arts → 销毁; shop and upgrade are two-tap (select → confirm; the remake's 机变 cards too, §18.2); strategy draft forbids a strategy already taken by a teammate (队友已选).
- Emotes: 6 themes × 6 battle emotes (research 09 §4.2), image-only bubbles beside the sender avatar, 1 s cooldown, 3 s bubble; `g.emote {id}` with the official emoji id. - Emotes: 6 themes × 6 battle emotes (research 09 §4.2), image-only bubbles beside the sender avatar, 1 s cooldown, 3 s bubble; `g.emote {id}` with the official emoji id.
--- ---
@@ -575,20 +576,15 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno
## 17. User playtest #3 (v2.2): models, picking, FX, rules, HUD ## 17. User playtest #3 (v2.2): models, picking, FX, rules, HUD
Nine reports from a real co-op session. Where each is handled: #1 lost operator models → §17.1; #2 the LP did not move while enemies entered the own blue gate → §17.5; #3 equipment pushed into the second bench row vanished after the battle → §6.2 temp overflow (server) + §17.5 (UI); #4 蕾缪安 S3 fired every shell at once → §17.4; #5 回环射手 attacked like a fast shooter → §17.4; #6 bullets / skill effects hardly visible → §17.3; #7 a click selected the unit one row lower (equipment too) → §17.2; #8 the operator's own effect (特质) sat at the bottom of the detail card → §17.5; #9 spinners next to texts → §17.5. Nine reports from a real co-op session. Where each is handled: #1 lost operator models → §17.1; #2 the LP did not move while enemies entered the own blue gate → §17.5; #3 equipment pushed into the second bench row vanished after the battle → §6.2 temp overflow (server) + §17.5 (UI); #4 蕾缪安 S3 fired every shell at once → §17.4; #5 回环射手 attacked like a fast shooter → §17.4; #6 bullets / skill effects hardly visible → §17.3; #7 a click selected the unit one row lower (equipment too) → §17.2 (superseded by §18.1); #8 the operator's own effect (特质) sat at the bottom of the detail card → §17.5; #9 spinners next to texts → §17.5.
### 17.1 Lost operator models (#1) — the Spine store (`public/js/assets.js RefLru`) ### 17.1 Lost operator models (#1) — the Spine store (`public/js/assets.js RefLru`)
Cause: `PIXI.Assets.unload` is asynchronous (the loader cache entry is dropped and the textures destroyed a microtask later), so a skeleton evicted and re-acquired in the same task came back as the doomed object and turned invisible for good (a tier chip without a body; the dead data stayed cached, so every later copy of that operator was invisible too). It happened at every battle → prep switch: `enterPrepMode → clearViews` made the reference count 0 for an instant, the LRU applied its "no scene on screen" budget (0 bytes) at that instant and dropped the bench models idle since the battle started, then `setPrep` took them back at once. Rules now: an `unload` may return a promise and a load of a key whose unload is still in flight waits for it (`unloadSpineData` returns one; `stats().spine.unloading`); the spine store runs eviction in one pass `SPINE_EVICT_DELAY_MS` (1 s) after a release (a scene switch takes its models back first); the quiet budget applies only after `SPINE_QUIET_DELAY_MS` (3 s) with nothing referenced (lobby / room). The count-cap path (60 skeletons) waits the same way. Tests: `test/render/assets.test.js` (fake `PIXI.Assets` with the same async unload), `test/render/models.browser.test.js` (models alive after long battles, drag / wheel stress). Cause: `PIXI.Assets.unload` is asynchronous (the loader cache entry is dropped and the textures destroyed a microtask later), so a skeleton evicted and re-acquired in the same task came back as the doomed object and turned invisible for good (a tier chip without a body; the dead data stayed cached, so every later copy of that operator was invisible too). It happened at every battle → prep switch: `enterPrepMode → clearViews` made the reference count 0 for an instant, the LRU applied its "no scene on screen" budget (0 bytes) at that instant and dropped the bench models idle since the battle started, then `setPrep` took them back at once. Rules now: an `unload` may return a promise and a load of a key whose unload is still in flight waits for it (`unloadSpineData` returns one; `stats().spine.unloading`); the spine store runs eviction in one pass `SPINE_EVICT_DELAY_MS` (1 s) after a release (a scene switch takes its models back first); the quiet budget applies only after `SPINE_QUIET_DELAY_MS` (3 s) with nothing referenced (lobby / room). The count-cap path (60 skeletons) waits the same way. Tests: `test/render/assets.test.js` (fake `PIXI.Assets` with the same async unload), `test/render/models.browser.test.js` (models alive after long battles, drag / wheel stress).
### 17.2 Picking and the drag target (#7) — `render/pick.js` ### 17.2 Picking and the drag target (#7) — superseded by §18.1
Cause: every path took the front-most 0.7 × 1.28-tile rectangle under the pointer and looked at the pressed tile only when no rectangle was hit; drawn chibis are 1.17–1.41 tiles tall (median 1.27 over the 138 operator skeletons) while the official cameras put rows only 0.84 (prep) – 0.91 (battle) tile apart on screen, so the front unit's rectangle covered the legs, feet and tile of the unit behind it. v2.2 answered "a click selected the unit one row lower" with body picking: every path took the drawn chibi (a head / body shape per unit, measured from its posed Spine model) and, where two bodies were close, a 1-px render probe of the pixel under the pointer; the dragged ghost stood on the pointer (on touch 0.6 tile above the finger) and an item went to the operator whose sprite was under the plate. User playtest #4 item 1 rejected it ("感觉你想复杂了,地上都画好了一个一个方格,点击对应方格就选中那个方格的人物就行"; the model moved up away from the finger): picking is by the tile under the pointer and the dragged model is held under the pointer (§18.1). The shapes, the probe, the touch lift, `pieceDragOver` and the DOM-side `pickPieceAt` / `equipRetarget` are gone.
- **One rule** for every "which unit is under the pointer" — prep press / drag start / right-click / long-press detail (`app.js pieceAt`), battle clicks and hover (`battleUnitAt`), the enemy preview pen (`penUnitAt`), the own operator under a dragged item (`pieceUnder`) and the DOM-side fallback (`ui/gameLogic.js pickPieceAt` over `view.pieceScreenRect(uid).shape`): `pickBody(bodies, x, y, groundTile, probe)`. A body (`UnitView.pickShape()`, px of one screen) is the drawn chibi — head ellipse + body box around its feet, its own height measured once from the posed Spine model (`getLocalBounds`), ±0.3 tile wide, facing, draw order, and the HUD it shows (tier chip / bars, an outline only; none while culled) — or the avatar diamond until the model is there, or an item plate.
- **Pixel probe first**: wherever it can change the answer (two or more bodies within their outline + `PROBE_MARGIN` 0.5 tile of the pointer, or one body the shape rule did not pick), `app.js drawnAt` renders each candidate view alone into a 1×1 RenderTexture from its last-frame transforms and reads the pixel back; the **front-most unit whose model draws the pixel under the pointer wins** — what the player sees (hair, arms, weapons included). Measured against the rendered pixels on the mock match: wrong unit 0.7 % of prep presses / 1.7 % in battle (anti-aliased edges), against 2.3 % / 11.1 % for the old rectangle; ≈ 0.7 ms per probed press. Every readback waits for the GPU, so **hover** picks (mouse moves over the field in prep and battle, item-drag moves — `render/drag.js` passes `hitPiece(x, y, hover)` / `dropPoint(e, piece, final)`) probe at most once per rendered frame (`app.js hoverProbeAt`; a second hover pick in the same frame uses the shape rule alone — it only moves a highlight); presses, releases and drops always probe.
- **Shape rule** where no unit draws (a gap, the bare tile) or the probe cannot tell (no readback): (1) a unit's face / torso core → the front-most such unit; (2) else the unit standing on the tile under the pointer; (3) else the nearest core among outline hits. Plain rects without a shape (the DOM fallback field) go to the rect whose centre is nearest relative to its size, ties → the front-most.
- **Drag target = where the ghost stands**: the dragged ghost stands on its drop point — the pointer for mouse / pen, `TOUCH_LIFT_TILES` = 0.6 tile above the finger for touch (so the finger never hides it) — and the drop target, `tileHover`, the target highlight and the direction wheel's tile are the tile under it (`render/drag.js dropPoint` hook). Whether DOM UI covers the drop is tested at the drop point too (a lifted touch ghost over the bench while the finger rests on the shop bar still lands). A dragged item plate is centred on the pointer; the operator under it (`pieceUnder`) is reported by `pieceDragOver` before the drag asks `canPlace`, lit as the target, and equipped on release (`ui/gameLogic.js equipRetarget`). Previously the ghost hung 0.45 tile below the pointer while the target was the tile under the pointer.
- Tests: `test/render/pick.test.js`, `drag.test.js`, `unitview.test.js`, `test/ui/gameLogic.test.js`; `test/render/models.browser.test.js` (pixel-truth accuracy in prep and battle, raised / stacked units, equipment drops, the ghost and wheel tile, the touch bench drop).
### 17.3 Battle FX (#6) — `render/fx.js`, `style.js PROJ`, `textures.js` ### 17.3 Battle FX (#6) — `render/fx.js`, `style.js PROJ`, `textures.js`
@@ -611,3 +607,58 @@ Cause: every path took the front-most 0.7 × 1.28-tile rectangle under the point
- **Detail card order (#8)** (`detailPanel.js CHESS_SECTIONS`): header (portrait, name, tier, class, bonds) → **特质** (the operator's own effect — garrison trigger chip such as 休整期结束时 + description, a compact gold block, visible without scrolling at 1920×1080, 1280×720, iPhone 14 and 640×360) → 特性 (class trait) → stats (a compact 4×2 grid) + attack range → 技能 → 模组 → 装备 → 天赋. On phones ≤ 460 px tall the portrait is shorter and the romanised name is hidden. - **Detail card order (#8)** (`detailPanel.js CHESS_SECTIONS`): header (portrait, name, tier, class, bonds) → **特质** (the operator's own effect — garrison trigger chip such as 休整期结束时 + description, a compact gold block, visible without scrolling at 1920×1080, 1280×720, iPhone 14 and 640×360) → 特性 (class trait) → stats (a compact 4×2 grid) + attack range → 技能 → 模组 → 装备 → 天赋. On phones ≤ 460 px tall the portrait is shorter and the romanised name is hidden.
- **Busy indicators and texts (#9)**: every text of the game (operators, skills, bonds, items, enemies, 特质 …) is static data, `/data/*.json` loaded once per page and never fetched during a match; the "spinners" the player saw were busy indicators of requests in flight next to texts — and the 特质 chip's type icon: it was picked by the trigger, so every <休整期开始时 / 结束时> 特质 showed the spoked 特异化 glyph (it reads like a loading spinner) before its text; the chip now shows the garrison's own official `eventTypeIcon` (`detailPanel.js garrisonTypeIconKey`: crossed swords 作战能力, gold 整备能力, chain link 持续 / 单次叠加, spokes only for 特异化). Now: no `cursor: wait / progress` anywhere; a loading `Button` keeps its icon and shows a thin sweeping bar along its bottom edge after 250 ms (no spinner ring); a 机变 card in flight shows a 选择中 strip until the pick appears in `m.public` (`choiceOverlay.js pickBusy`); the 作战结束,等待队友完成作战 hourglass is still; the hexagon `Spinner` is kept for genuine loading screens only (entering the match, the field view, connecting, guide images, the first 干员调配 load, which says the data loads once per page). `main.js warmGameData` downloads every in-match file (`gameComponents GAME_FILES`) in the background once the player is in a room, and the match screen waits for them; `data.js` retries a transient failure (network error, HTTP 5xx / 408 / 429) after 600 ms and 2 s (`RETRY_DELAYS_MS`) — a 404 or invalid JSON is final. - **Busy indicators and texts (#9)**: every text of the game (operators, skills, bonds, items, enemies, 特质 …) is static data, `/data/*.json` loaded once per page and never fetched during a match; the "spinners" the player saw were busy indicators of requests in flight next to texts — and the 特质 chip's type icon: it was picked by the trigger, so every <休整期开始时 / 结束时> 特质 showed the spoked 特异化 glyph (it reads like a loading spinner) before its text; the chip now shows the garrison's own official `eventTypeIcon` (`detailPanel.js garrisonTypeIconKey`: crossed swords 作战能力, gold 整备能力, chain link 持续 / 单次叠加, spokes only for 特异化). Now: no `cursor: wait / progress` anywhere; a loading `Button` keeps its icon and shows a thin sweeping bar along its bottom edge after 250 ms (no spinner ring); a 机变 card in flight shows a 选择中 strip until the pick appears in `m.public` (`choiceOverlay.js pickBusy`); the 作战结束,等待队友完成作战 hourglass is still; the hexagon `Spinner` is kept for genuine loading screens only (entering the match, the field view, connecting, guide images, the first 干员调配 load, which says the data loads once per page). `main.js warmGameData` downloads every in-match file (`gameComponents GAME_FILES`) in the background once the player is in a room, and the match screen waits for them; `data.js` retries a transient failure (network error, HTTP 5xx / 408 / 429) after 600 ms and 2 s (`RETRY_DELAYS_MS`) — a 404 or invalid JSON is final.
- Tests: `test/ui/playtest3.test.js`, `test/match/runner.test.js` (live leak counts), `test/match/temp.test.js` (§6.2), `test/docs-consistency.test.js`. - Tests: `test/ui/playtest3.test.js`, `test/match/runner.test.js` (live leak counts), `test/match/temp.test.js` (§6.2), `test/docs-consistency.test.js`.
---
## 18. User playtest #4 (v2.3): picking by tile, confirms, timers, battle state, content
Thirteen reports after playing v2.2. Where each is handled: #1 picking should simply follow the tiles and the dragged model moved away from the finger → §18.1; #2 a confirm step for 机变 picks → §18.2; #3 a countdown although playing alone → §18.2; #4 the strategy draft's 10 s turn out of step with the top-right countdown → §18.2; #5 the special 维式重锤 and 突变细胞 were sold in the shop → §18.6; #6 纯烬艾雅法拉's skill sound rang outside her skill → §18.4; #7 the operator card showed fixed numbers → §18.5; #8 no official element gauges → §18.3; #9 knocked-out operators vanished instead of lying on their tile with a redeploy ring → §18.3; #10 the enemy drones' behaviour → §18.6; #11 赫默's drone deployed at the start instead of by her skill → §18.6; #12 the 炎佑 dragon returned to a fixed point and its flame was no continuous jet → §18.6; #13 once in a while a huge piece of UI covered the screen for a second or two → §18.7.
### 18.1 Picking by tile, the model under the pointer (#1) — `render/pick.js`, `render/app.js`, `render/drag.js`
- **One rule**: the ground tile under the pointer decides (`render/projection.js pickTile`, raised tops first). Prep presses / clicks / right-click / long-press / drag starts / hover (`app.js pieceAt`) and the enemy preview pen (`penUnitAt`) take the unit standing on that tile (board, bench row 7, temp row 8; `pick.js pickOnTile`). An empty tile selects nothing; a press on a unit's head drawn over the tile behind it is a press on that tile; a raised tile in front hides the near 0.17–0.24 tile of the one behind it, and a press there is on the raised tile — what is drawn. Several units on one tile (the pen's clusters, a piece held for a moment): the nearest to the pointer's point on the tile, ties → the front-most.
- **Battle** (`app.js battleUnitAt` → `pickBattle`): an ally on the tile under the pointer — a knocked-out operator lying on its tile (§18.3) included. An enemy within `ENEMY_REACH` 0.6 tile [ASSUMED], using whichever is nearer: its ground position from the pointer's point on the ground, or its drawn body on screen (the upright line from its feet up to its head, in its px per tile) — so a press on a tall enemy's or the boss's torso or head picks it; a flying enemy uses its drawn body only. When an ally tile and an enemy both qualify the nearer (in tiles) wins; ties → the ally, then the front-most. Dead and dying units are not picked (a knocked-out operator waiting on its tile is).
- **Drag**: the dragged unit is held under the pointer, mouse and touch alike — its drawn (lifted) feet `DRAG_HOLD_TILES` = 0.45 tile (in its own px per tile) below the pointer [ASSUMED: the v2.1 offset], so the pointer rests on the chibi; an item plate is centred on the pointer. The drop target, its highlight, `tileHover` and the direction wheel's tile are the tile under the pointer. An item dropped on a tile equips the unit on that tile (`ui/gameLogic.js canPlace` / `dropIntent`; an empty tile says 请将装备拖拽至干员身上). A release over DOM covering the canvas (the shop bar) is `'outside'`: the piece goes back.
- **Touch**: the field canvas cancels `touchend` (`app.js onTouchEnd`, non-passive). A tap's compatibility mouse events and click came after `touchend`, hit-tested at the finger again — on the underframe the tap had just opened over the unit's tile (clamped under the top bar for rows 11–12 at 844×390), which pressed 撤退 / 出售.
- **Notched phones**: the shop bar keeps its place at the bottom edge instead of rising by the bottom safe-area inset (`css/devices.css .gm__hud > .shopbar`): raised by it the bar covered ~3/4 of bench pads 2–9 at 844×390 with the iPhone insets (23 % of those tiles left free, 71 % now — as without insets), and bench pieces are pressed and dropped on by their tile.
- Removed: the pixel probe (`drawnAt`, `hoverProbeAt`), body shapes (`pickShape`, `PROBE_MARGIN`, `BODY_H`), `TOUCH_LIFT_TILES`, the drag `dropPoint` hook, `pieceDragOver`, `ui/gameLogic.js pickPieceAt` / `equipRetarget`. `UnitView.bounds()` is a plain body rectangle again (tooltips and `pieceScreenRect` only).
- Tests: `test/render/pick.test.js`, `drag.test.js`, `unitview.test.js`, `test/ui/gameLogic.test.js`, `test/ui/playtest4.test.js` (shop bar); browser: `test/render/models.browser.test.js` (every tile of every board, high ground, bench and temp; battle allies and enemies incl. torso and head; the model under the pointer at every drag step; the wheel on the pointer's tile; equipment by tile; phone touch incl. the ghost click and the shop-bar release), `test/render/downelem.browser.test.js` (a knocked-out operator picked on its tile), `test/ui/playtest2.e2e.test.js` (the top-row tile under the bond strip; the notched phone's bench).
### 18.2 Confirms and timers (#2, #3, #4) — `ui/choiceOverlay.js`, `server/match/Match.js`, `screens/bandDraft.js`
- **机变 cards take two taps** (like the shop's cards): the stateful `ChoiceOverlay` wraps the pure `ChoiceView`. A first tap on a pickable card (bounty = extra enemies, 道具补给 / 机密商店 items, tactics) only selects it — gold frame, a 确认选择 · 再次点击 strip, the other cards dimmed, 确认选择 in the header; a second tap on it, or 确认选择, sends `g.choice`. Another card moves the selection; Esc or a press elsewhere drops it; so do the card being taken, the turn moving on and a pick in flight. Cards stay buttons (Tab / Enter). The other picks were already two-step (晋升奖励 in the shop bar, the strategy draft's 确认选择, 确认替换).
- **A single human is untimed** (`Match.loneHuman`: exactly one human seat at the start → `Match.soloUntimed`): 独立模拟 and a 同盟 room started alone or with AI teammates only publish no INFO_CHECK / BAND_DRAFT / SP_DRAFT / PREP deadline, and BATTLE_CHECK / ROUND_START / SETTLE run silently — the timers only ever made humans wait on each other. The co-op rules stay (shuffled draft order and skip, 6 机变 cards, 联防); pause stays solo-only; a disconnected lone human's match waits (the co-op reconnect window then ends it as abandoned). `Countdown` (`ui/components.js`) renders nothing without a deadline (it used to draw a `--` COUNTDOWN placeholder that only a CSS rule hid).
- **The strategy draft has ONE countdown**: every turn lasts `Match.BAND_TURN_SECONDS` = 30 s [ASSUMED] (× timerScale; `data/config.json timers.bandTurn` says the same — the official data only has the whole BAND_CHECK step, 50 s, now informational) and `m.public.deadline` IS the current turn's end (= `draft.turnDeadline`; `draft.turnSeconds` = the turn length, 0 untimed), so the step header and the picker's row show the same number. No step cap: the turns bound the step (≤ (seats + skips) × 30 s). AI seats pick at once. The draft screen reports its highlighted strategy (`g.bandFocus`); a turn that runs out takes it while it is allowed and free (`Match.timeoutBand`), else 华法琳 (`band_bldsk`), else the first free strategy (`defaultBand`, 队友已选 never). The selection starts on that default and the tip always names what a timeout gives ("每位博士有 30 秒,超时将自动选择当前选中的「X」"; untimed: "本局不限时"). `server/match/audit.js` checks it (deadline = the turn's, 30 s; nothing timed for a single human).
- Tests: `test/ui/playtest4.test.js`, `test/ui/bandDraft.test.js`, `test/match/draft.test.js`, `test/ui/gameLogic.test.js` (`phaseTotalSeconds` = the turn), `test/match/hardening.test.js` (the audit), `test/ui/mock.e2e.test.js` (two taps). Browser tests that need a timed co-op briefing now seat two humans (`test/ui/loadout.e2e.test.js`); a single human's 机变 is picked by the test itself (`test/ui/playtest2.real.e2e.test.js`), and the real-server drafts wait for BAND_DRAFT before picking (a client left in the briefing let its 30 s turns run out).
### 18.3 Knocked-out operators and element gauges (#9, #8) — `sim/Battle.js`, `sim/damage.js`, `render/interp.js`, `render/units.js`
- **Knocked out, not gone**: `Battle.snapshot()` lists `down: [[id, respawnAt, respawnTime, state]]` (§8.2) for operators knocked out (reason `'killed'`, not withdrawn), deployed at least once and with a finite respawn timer; `state` = sim/constants.js `DOWN_STATE` (0 counting, 1 timer done but DP < cost, 2 timer done but the home tile is taken — the official auto-redeploy rule, research 01 §4.3). `Battle.isDown(u)`; `fieldMeta()` lists them too, so a client joining mid-battle (reconnect, observer, replica) shows them. The renderer (`interp.js downAt`, `app.js syncBattle`, `units.js setDown`) keeps such an operator's view on its tile: its Spine Die clip plays once and holds its last frame (a collapsed / kneeling pose), lightly greyed (tint 0xb4b4b4, alpha 0.92), under a **redeploy ring** above the head — a dark disc with a mint arc filling as the timer runs and the seconds left (game s); timer done: a full pulsing amber ring "DP" (waiting for DP) or red "!" (its tile taken). The `deploy` event plays the Start clip and restores the normal look; a view created for an operator already down starts on the held pose; leaving the list without a redeploy fades it out. Summons, devices and enemies vanish as before. [ASSUMED] look: the official knocked-down / ring art is in no extracted autochess bundle.
- **Element gauges, official rules** (PRTS 元素 / 术语释义; `damage.js`): gain × `elemTakenMul` × (1 − 损伤抵抗 / 100), 损伤抵抗 = data `epResistance` (EP_RESISTANCE; it used to read `epDamageResistance` as a fraction). A burst's **爆发冷却** (= its duration: 灼燃 / 神经 / 侵蚀 10 s — enemies' 侵蚀 8 s — 凋亡 15 s; the operators' 侵蚀 had none) locks EVERY element of the unit — nothing fills or recovers (`reduceElement`) — and its end resets EVERY gauge (`burstLocked(unit)` takes no element). 神经 applies the stun (operators) / 3 麻痹 (enemies) before its damage, 侵蚀 its DEF cut before its damage; the operators' 凋亡 burst holds 阻回 (`noSp`: `skills.js gainSp` refuses SP of every kind — time, attack, hurt, granted) + 静默, −1 SP/s and 100 arts/s.
- **The gauge on screen** (official display: one icon, the "当前损伤元素"): `damage.js elementView(u, now)` → b.snap `elem` (§8.2): the fullest gauge, ties by the official element id (`constants.js ELEMENT_ORDER`: 神经 < 侵蚀 < 灼燃 < 凋亡); during a 爆发冷却 the bursting element, refilling over the cooldown. `interp.js` appends it to the unit's tuple; `units.js` draws it right of the HP / SP bars — operators: the element's disc with its glyph; enemies: a smaller plain disc of the element colour (official rule) — with a white ring for the remaining 元素值 (1 − fill). One procedural 512×576 atlas (`textures.js hudRings` / `ringArc`, 49 arc frames and the discs), sprites made on first use and hidden when idle. The burst's lock status (`burnBurst`, `neuralBurst` …) is not repeated in the status row while the element icon shows it (only a feed without `elem`, e.g. an older recording, shows it there). [ASSUMED] placement (the official draws it under the model, the remake's bars are above the head) and size (0.3 tile, enemies ×0.8).
- Recordings (`tools/record-battle.mjs`) carry both keys, so the render demo shows them. Tests: `test/sim/downelem.test.js`, `test/sim/combat.test.js`, `test/render/downring.test.js`, `test/render/interp.test.js`, `test/render/downelem.browser.test.js`.
### 18.4 Battle sounds (#6) — `public/js/audio.js`, `tools/assets/audio.mjs`
- Cause: every `'atk'` — heals included — made its source the target's "last attacker", and any later damage on that target (enemy projectiles, element fills, DoTs) played that unit's `hit`; 纯烬艾雅法拉 (who only heals) had her S3 impact (`p_imp_gtshpbrnch_s`, bank ON_ABILITY_HIT.attack.2) as `hit` in the manifest, so it rang all the time.
- Now only a hostile `'atk'` (from the other side) authors the target's next impact: it plays once, within 2.5 s real [ASSUMED], and only for phys / arts / true damage; heals and chain bounces never author one. An operator's skill-mode file (official names `_d` / `_h` / `_s`; the normal attack's end in `_n`) never plays for a normal attack (`normalAttackSfx`). At most 2 overlapping copies of one battle sound (`SfxLimiter`; official banks `maxSoundAllowed` 2 — a third copy is skipped, not the oldest stopped [ASSUMED]).
- Manifest: `tools/assets/audio.mjs pickUnitSfx(banks, { operator, projectile })` picks normal-mode banks for operators — the plain `attack` / `combat` ability first, never a bank with a skill-mode file — with the operator's own projectile banks (`ON_PROJECTILE_BORN` / `_HIT.projectile_chr_<name>`, `tools/assets/plan.mjs`) as fallbacks; enemies and tokens are unchanged. 56 operators' picks changed (e.g. agoat2 `hit` → `p_imp_gtshpbrnch_n`, nearl2 `attack` → `p_atk_goldspear_n`, whitw2 → `p_atk_whtolfdrksl_n`). Loop-bank skill sounds still play once (open).
- Tests: `test/ui/audio.test.js`, `test/assets.test.js`.
### 18.5 Live operator stats (#7) — `ui/detailPanel.js`, `battle/runner.js`, `Match.unitStats`
- **Battle**: `battleRunner.unitStats(id, fieldId)` reads the local sim's last computed stats (`unit._s`, never the lazy `unit.s`, so looking cannot shift the sim) and the detail card re-reads it 4× a second: current HP / max HP, ATK, DEF, RES, attack interval, block (enemies also move speed). `battleRunner.unitIdOf(uid, ownerId, fieldId?)` maps an own board piece to its battle unit, so a card opened in prep and left open into the battle turns live.
- **Prep**: C→S `g.unitStats {seq?}` → `m.unitStats {seq, round, units}` (§8.2): `Match.unitStats` builds a Battle from the player's battle input after the `onBattleStart` meta (dispatched with `ev.preview: true` and no `spawns` — handlers must not change the match then, docs/META.md), with the flags of the battle it previews (a normal round gains IN_BATTLE layers from its start; the Final Assault is previewed as a normal field without them), starts it (initial deployment + battleStart hooks), reads it and drops it — never stepped, so skills and timed effects are not in it [ASSUMED display]; cached per input (≈ 0.3–0.7 ms a build). `screens/game.js` asks 120 ms after any `m.private` / phase change while an own board unit's card is open and keeps the newest answer only.
- The card colours each value against the unit's base — green when it helps (a shorter interval is up), red when it hurts — with the difference beside it, the base in the tooltip and a 实时 / 开战时 tag. The shape is `shared/protocol.js unitStatsEntry`. Tests: `test/match/unitStats.test.js`, `test/match/runner.test.js`, `test/ui/playtest4.test.js`.
### 18.6 Shop-only items, drones, summons, 炎佑 (#5, #10, #11, #12) — `tools/build-data.mjs`, `sim/simdata.js`, `sim/content/*`
- **#5 not sold in the shop**: the official `trapShopChessDatas` has `hideInShop` false for all 56 normal items, so an explicit sourced list (`tools/build-data.mjs SHOP_EXCLUDED_ITEMS` → items.json `shopExcluded` / `shopExcludedBy`) marks the effect-only items: 战栗 / 坚固 / 加速 / 灼燃维式重锤 (the 维多利亚 bond every 25 layers, 洛洛's 定制品) and 突变细胞 (the 昆图斯 strategy 不稳定要素; user playtest #4 first-hand). `sim/simdata.js isShopItem` is the one predicate for every shop-item draw — the shop's item slot, 道具补给 / 机密商店 cards, `pool_equip_normal` / `_shop_1` / `_kathe` / `_narant`, `Match.rollItemId` — so 51 normal items are sold (the plain 维式重锤 still is). 洛洛's 定制品 (`pool_equip_rockr`) = the same 4 special hammers as `pool_equip_vict`, uniform [ASSUMED]. The item card says 调度中心不出售 · 获取途径:… (`detailPanel.js ItemDetail`). Other strategy-granted items stay on sale (no source says otherwise; 变形同构体 / 骑士储蓄罐 / 盟约之币 appear in an official 机密商店 screenshot).
- **#10 drones** (stats and ranges = enemy_database level 0, re-derived by `test/data.test.js`; behaviour per PRTS): 暴鸰 makes no normal attack — one bomb on its target and the 8 tiles around it (100 % ATK, splash ignores camouflage), then its speed ×2 (final); 帝国炮火先兆者 / 中枢先兆者 fire a shell per attack that lands 3 s later at the target's spot and hits every ally within 1.2 for 100 % of the ATK at launch, with no source (`content/enemies.js kitShell`; new `ai.js enemyAttack` hooks `profile.deferHit` — content resolves the hit in its `attack` handler — and `profile.shot`, the `'atk'` kind: `'mortar'` draws no projectile; fx `bombardShell`); 假想敌:黑云 grabs within `range_radius` × 2.5 (3.75) at most 3 flyers (nearest first [ASSUMED], 0.5 s delay [ASSUMED]), both held 4 s, ammo capped by enemies.json `sp.maxSp` (3; new `sp` from enemy_database spData), 全弹发射 = one 130 % hit per ammo on random allies anywhere on the field; “萨科塔之眼” never attacks flying allies; 萨科塔 self-fear speed final ×1.5. Unchanged (already right): 妖怪 / 妖怪MKII / 威龙 / 寒霜 / 法术大师A1·A2 / 御4 / 护障 / 护障·P / 远眺.
- **#11 skill summons are not prep pieces**: tokens.json `placeable` = `tokenDisplayType` DEFAULT **and** made by a talent (海嗣, 狼群, 流形). 赫默's 医疗探机 and 巫恋's 诅咒娃娃 appear only when the skill fires (their kits place them); `PlayerState.grantTokensFor` just grants the placeable ones.
- **#12 炎佑** (PRTS “炎佑” 级别0(卫戍协议), `content/tokens.js yanyouKit`): it flies after the highest-aggro targetable enemy of the whole field (operator order) and hovers 0.25 tile [ASSUMED] from it (a second 炎佑 of 9 炎 0.8 row beside the first); **with no enemy on the field it stays where it is** (user playtest #4; PRTS says it then tracks a random tile). 祛恶之焰 (模式乙): fires like a normal attack when its 15 s cooldown is full, locks that attack's first target and channels up to 20 s — each second (the first at once) 60 % ATK arts to every enemy within 1.0 of the target, no normal attacks meanwhile — ending when the target is gone or 炎佑 is silenced (inferred); the cooldown then runs again [ASSUMED]. Every damage it deals adds burn = 20 % ATK (flame ticks included, official 下半 notice); 元素脆弱 aura of radius 1.5 around itself; it ignores element damage; ATK / HP = the template 600 / 12000 **plus** 30 % of the 炎 operators' sums (PRTS 最终加算; the 9-炎 ×1.5 on the whole ATK [ASSUMED]). The flame is drawn as one continuous jet (`render/fx.js _flame`): the sim emits fx `yanyouFlame {x, y, id: dragon, target, r, n, dur: 1}` once per game second, and each tick re-aims and extends the dragon's jet — a stream of fire from the dragon onto its locked target with a burning disc of radius `r` following the target — which burns `dur` + 0.2 s and goes out when no tick comes or the dragon is gone.
- Tests: `test/content/playtest4_summons.test.js`, `enemies_bosses.test.js`, `tokens_devices.test.js`, `bonds_core.test.js`, `choices.test.js`, `test/data.test.js`, `test/match/pool.test.js`, `test/render/fxflame.test.js`, `test/ui/playtest4.test.js` (item card).
### 18.7 The occasional giant UI flash (#13) — `render/fx.js pop`
The first in-battle layer gain of each bond (b.ev `'layer'`) pops that bond's icon over the field (`FxSystem.pop`). The icon is `PIXI.Texture.from(url)` — a 1×1 placeholder until its image has loaded — and the pop sized the sprite from it (46 / 1): the bond icon was drawn ~5000 px across the whole screen for the pop's 1.4 s, once per bond per page (73 official garrisons gain layers in battle, e.g. 送葬人's). Now it is sized (46 px along its longer side) only once its texture is valid, hidden until then. Found by sampling long real-server sessions every 80 ms (DOM boxes > 25 % of the viewport, images drawn > 300 px, Pixi objects > 25 % or showing a whole atlas page, CSS scale ≥ 2); every other large object seen is a designed full-screen layer. Tests: `test/render/fxpop.test.js`, `test/render/flash.browser.test.js`.
+21 -12
View File
@@ -31,7 +31,7 @@ server/match/
## 1. Match flow ## 1. Match flow
``` ```
LOBBY → INFO_CHECK (co-op 25 s, solo untimed; all humans confirmed ⇒ next) → BAND_DRAFT → BATTLE_CHECK (3 s) LOBBY → INFO_CHECK (co-op 25 s; solo and single-human matches untimed; all humans confirmed ⇒ next) → BAND_DRAFT → BATTLE_CHECK (3 s)
→ for r = 1..lastRound (+ hidden): → for r = 1..lastRound (+ hidden):
ROUND_START (2 s) income + pending coins, upgrade price −1 (r > 1, floor 0), temp NOT wiped (what overflowed after ROUND_START (2 s) income + pending coins, upgrade price −1 (r > 1, floor 0), temp NOT wiped (what overflowed after
the last prep's deadline — battle-result grants, a SETTLE merge's elite, returned equipment — is the last prep's deadline — battle-result grants, a SETTLE merge's elite, returned equipment — is
@@ -40,7 +40,7 @@ LOBBY → INFO_CHECK (co-op 25 s, solo untimed; all humans confirmed ⇒ next)
shop reroll (frozen slots kept in place, then unfrozen), the round's wave generated (preview), shop reroll (frozen slots kept in place, then unfrozen), the round's wave generated (preview),
onRoundStart dispatch onRoundStart dispatch
[SP_DRAFT] r ∈ modes[m].spRounds (机变) [SP_DRAFT] r ∈ modes[m].spRounds (机变)
PREP onPrepStart; co-op timer rounds[r].prepTime, solo untimed; ends when every alive seat is ready PREP onPrepStart; co-op timer rounds[r].prepTime, solo / single human untimed; ends when every alive seat is ready
(prep end) onPrepEnd, the temp pieces due at this prep resolved (tempDue; what arrived after Ready or (prep end) onPrepEnd, the temp pieces due at this prep resolved (tempDue; what arrived after Ready or
during onPrepEnd waits for the next prep), reward offers expire, unfrozen shop cleared, funds lost during onPrepEnd waits for the next prep), reward offers expire, unfrozen shop cleared, funds lost
(band_cannot keeps them), boss round: Σ activated layers recorded for the hidden-core check (band_cannot keeps them), boss round: Σ activated layers recorded for the hidden-core check
@@ -54,28 +54,37 @@ LOBBY → INFO_CHECK (co-op 25 s, solo untimed; all humans confirmed ⇒ next)
| Timer (real s, × `opts.timerScale`) | Value | | Timer (real s, × `opts.timerScale`) | Value |
|---|---| |---|---|
| INFO_CHECK | `config.timers.infoCheck` 25 | | INFO_CHECK | `config.timers.infoCheck` 25 |
| band draft turn / whole step | `bandTurn` 12 / `bandDraft` 50 (co-op; solo untimed) | | band draft turn | `Match.BAND_TURN_SECONDS` 30 [ASSUMED] (= `timers.bandTurn`), the step's only countdown: `m.public.deadline` = the current turn's end, no step cap (`timers.bandDraft` 50 = the official whole step, informational) (co-op; solo / single human untimed) |
| BATTLE_CHECK | `battleCheck` 3 | | BATTLE_CHECK | `battleCheck` 3 |
| 机变 first / other pickers | `spFirst` 30 / `spTurn` 16 (co-op; solo untimed) | | 机变 first / other pickers | `spFirst` 30 / `spTurn` 16 (co-op; solo / single human untimed) |
| PREP | `modes[m].rounds[r].prepTime` (co-op; solo untimed) | | PREP | `modes[m].rounds[r].prepTime` (co-op; solo / single human untimed) |
| COMBAT / 联防 | `modes[m].rounds[r].combatTimeLimit` (= the level's `maxPlayTime`) real seconds = 2× that in game seconds | | COMBAT / 联防 | `modes[m].rounds[r].combatTimeLimit` (= the level's `maxPlayTime`) real seconds = 2× that in game seconds |
| 最终攻势 / 隐秘核心 | no hard stop: countdown `rounds[r].levelMaxPlayTime` 120 real s; overtime drain from `bossOvertimeAfter` 150 real s, 1 team LP per real s (§3) | | 最终攻势 / 隐秘核心 | no hard stop: countdown `rounds[r].levelMaxPlayTime` 120 real s; overtime drain from `bossOvertimeAfter` 150 real s, 1 team LP per real s (§3) |
| ROUND_START / after combat / SETTLE | 2 / 1.5 / 3 (presentation delays, `Match.DELAYS`) | | ROUND_START / after combat / SETTLE | 2 / 1.5 / 3 (presentation delays, `Match.DELAYS`) |
| bot action delay | 0.9 s (+0.35 s per seat) | | bot action delay | 0.9 s (+0.35 s per seat) |
A match with a single human seat at the start (独立模拟, or a 同盟 room started alone / with AI teammates only:
`Match.loneHuman` → `Match.soloUntimed`, user playtest #4 item 3) times nothing outside its battles: no INFO_CHECK /
band draft / 机变 / PREP deadline, and BATTLE_CHECK / ROUND_START / SETTLE run silently (deadline 0); the co-op rules
(draft order and skip, 6 机变 cards, 联防) stay.
`m.public.deadline` is the absolute end of the current timer (ms epoch, 0 = untimed; combat: estimated end at 2×; `m.public.deadline` is the absolute end of the current timer (ms epoch, 0 = untimed; combat: estimated end at 2×;
最终攻势 / 隐秘核心: the boss level's `levelMaxPlayTime` countdown, 120 real s — the battle goes on past it — with 最终攻势 / 隐秘核心: the boss level's `levelMaxPlayTime` countdown, 120 real s — the battle goes on past it — with
`m.public.overtimeAt` = when the overtime drain starts, 150 real s; both on the field clock). `m.public.overtimeAt` = when the overtime drain starts, 150 real s; both on the field clock).
### 1.1 Band draft ### 1.1 Band draft
Co-op: random order (all seats, bots included), one pick per turn, 12 s per turn (timeout ⇒ `bandDraft.timeoutBandId` Co-op: random order (all seats, bots included), one pick per turn, ONE countdown: `BAND_TURN_SECONDS` 30 s per turn,
华法琳), 50 s cap for the whole step (every unpicked seat gets 华法琳), one skip per player (`g.bandSkip`: the player moves to published as `m.public.deadline` (= `draft.turnDeadline`; `draft.turnSeconds` its length) — no step cap; AI seats pick
the end of the order; refused when nobody is left to pass to), duplicates allowed, band must list the mode type in at once. A turn that runs out takes the strategy the player highlights in the draft screen (`g.bandFocus {bandId?}`,
`modeTypeList`. Solo: free pick, no timer, no skip. Starting LP = `bands[id].totalHp`. `Match.timeoutBand`) while it is allowed and no teammate holds it, else `bandDraft.timeoutBandId` 华法琳, else the first
free strategy by sortId (`defaultBand`; a departing seat gets `defaultBand` too). One skip per player (`g.bandSkip`: the
player moves to the end of the order; refused when nobody is left to pass to); a strategy a teammate already took is
refused (队友已选); band must list the mode type in `modeTypeList`. A single human (co-op with AI teammates only):
untimed. Solo: free pick, no timer, no skip. Starting LP = `bands[id].totalHp`.
### 1.2 机变 (SP draft) ### 1.2 机变 (SP draft)
Family = weighted pick from `choices.schedule[modeId].rounds[r].families`; cards: co-op 6 shared (each player takes 1, Family = weighted pick from `choices.schedule[modeId].rounds[r].families`; cards: co-op 6 shared (each player takes 1,
random order, 30 s first / 16 s others, timeout ⇒ a random remaining card), solo 3 untimed. A 驰援 tactic card random order, 30 s first / 16 s others, timeout ⇒ a random remaining card), solo 3; solo and single-human drafts are
untimed. The UI picks a card with two taps (select → 确认选择, DESIGN §18.2). A 驰援 tactic card
(`single_special_choice_gain_bond_chess`) is only offered while its bond still has chess in this match's pool (`single_special_choice_gain_bond_chess`) is only offered while its bond still has chess in this match's pool
(`Match.bondInPool`; a bond whose every member is banned would grant nothing); more generally a 驰援 or 盟誓 (`Match.bondInPool`; a bond whose every member is banned would grant nothing); more generally a 驰援 or 盟誓
(`global_special_choice_bond_addlayer`) card is offered only while one of its bonds is live (`Match.bondLive`: not in (`global_special_choice_bond_addlayer`) card is offered only while one of its bonds is live (`Match.bondLive`: not in
@@ -197,7 +206,7 @@ Every handler method is `(ctx, ev)`; `ev` is shared by all handlers of one dispa
| `onMerge` | chess or item merge | `{ kind, piece, baseId|itemId, consumed:[uid] }` | | `onMerge` | chess or item merge | `{ kind, piece, baseId|itemId, consumed:[uid] }` |
| `onLevelUp` | shop level up | `{ level, price }` | | `onLevelUp` | shop level up | `{ level, price }` |
| `onSpend` | a payment's action is complete (buy / refresh / levelUp / reward / effect) | `{ amount, reason, total }` (`total` = funds spent this match) | | `onSpend` | a payment's action is complete (buy / refresh / levelUp / reward / effect) | `{ amount, reason, total }` (`total` = funds spent this match) |
| `onBattleStart` | a battle input is built (normal / unite / boss / hidden) | `{ input: PlayerBattleInput, kind, round }` — mutate/replace `ev.input` | | `onBattleStart` | a battle input is built (normal / unite / boss / hidden); also for the stats preview (`g.unitStats`, DESIGN §18.5) | `{ input: PlayerBattleInput, kind, round, spawns?, preview? }` — mutate/replace `ev.input`. With `ev.preview` true (no `ev.spawns`) a handler must NOT change match state or draw match rng: the preview only reads the input |
| `onBattleResult` | SETTLE (normal rounds) and after boss fields | `{ result, lpLoss, perfect, unite?|boss? }` | | `onBattleResult` | SETTLE (normal rounds) and after boss fields | `{ result, lpLoss, perfect, unite?|boss? }` |
| `onChoicePick` | a 机变 card was applied (the card's own `choice:` handler runs first, then every source observes) | `{ card, family, picker, forTeammate }` | | `onChoicePick` | a 机变 card was applied (the card's own `choice:` handler runs first, then every source observes) | `{ card, family, picker, forTeammate }` |
| `onEquip` | `g.equip` (item handler only) | `{ item, target, golden, consumed, keep, error }` — see §2.5 | | `onEquip` | `g.equip` (item handler only) | `{ item, target, golden, consumed, keep, error }` — see §2.5 |
@@ -429,7 +438,7 @@ who sent in its parent, ≤ the parents' data offspring count (磨砻 2, 烹泉
drawn set; `disabledBonds` = drawn ∪ the mode's static list), `hiddenBossId`, `bossRound`, `hiddenRound`, `spRound`, drawn set; `disabledBonds` = drawn ∪ the mode's static list), `hiddenBossId`, `bossRound`, `hiddenRound`, `spRound`,
`combatMode` (`'client'` | `'server'`), `fields[].progress { killed, total, done }` (teammates' progress UI), `paused` `combatMode` (`'client'` | `'server'`), `fields[].progress { killed, total, done }` (teammates' progress UI), `paused`
(solo pause, §1.3a), (solo pause, §1.3a),
`players[].autoplay`, and per phase: `draft { order, turn, picks, skipsLeft, turnDeadline, untimed }` (BAND_DRAFT), `players[].autoplay`, and per phase: `draft { order, turn, picks, skipsLeft, turnDeadline, turnSeconds, untimed }` (BAND_DRAFT),
`sp { family, name, desc, eventId, cards:[{ idx, kind:'bounty'|'item'|'tactic', id, name, desc, tier, coin?, payout?, `sp { family, name, desc, eventId, cards:[{ idx, kind:'bounty'|'item'|'tactic', id, name, desc, tier, coin?, payout?,
rounds?, enemyKey?, count?, price?, team?, tacticKind? }], order, turn, picks:{pid: idx}, taken:{idx: pid}, untimed }` rounds?, enemyKey?, count?, price?, team?, tacticKind? }], order, turn, picks:{pid: idx}, taken:{idx: pid}, untimed }`
(SP_DRAFT), `teamLp` / `bossHp {hp,max}` (Final Assault on), `overtimeAt` (最终攻势 / 隐秘核心: ms epoch when the (SP_DRAFT), `teamLp` / `bossHp {hp,max}` (Final Assault on), `overtimeAt` (最终攻势 / 隐秘核心: ms epoch when the
+12 -10
View File
@@ -7,18 +7,18 @@
## 1. 一局的流程 ## 1. 一局的流程
``` ```
确认本局信息(25 s)→ 选择策略(轮选)→ 协议启动 确认本局信息(同盟 25 s)→ 选择策略(轮选)→ 协议启动
→ 每回合:[机变] → 休整期 → 作战 → [联防] → 结算 → 每回合:[机变] → 休整期 → 作战 → [联防] → 结算
→ 第 14 回合:最终攻势 →(满足条件)第 15 回合:隐秘核心 → 结算称号 → 第 14 回合:最终攻势 →(满足条件)第 15 回合:隐秘核心 → 结算称号
``` ```
| 阶段 | 说明 | | 阶段 | 说明 |
|---|---| |---|---|
| 确认本局信息 | 本局地图(标准模拟固定为「战场#01」;险境从 8 张地图中随机,绝境 / 终极从不含战场#01 的 7 张中随机)、敌方派系、**被禁用的盟约及其干员**、领袖预告。所有人确认后继续;同盟模拟最多 25 秒,独立模拟不限时。 | | 确认本局信息 | 本局地图(标准模拟固定为「战场#01」;险境从 8 张地图中随机,绝境 / 终极从不含战场#01 的 7 张中随机)、敌方派系、**被禁用的盟约及其干员**、领袖预告。所有人确认后继续;同盟模拟最多 25 秒;只有你一名玩家时(独立模拟,或同盟模拟只带 AI 队友)不限时。 |
| 选择策略 | 40 名策略(分队长)决定**初始目标生命值(20–45)**和一条永久规则。同盟:随机顺序轮流选,每人 12 秒,队友已选的不能重复,每人可**跳过一次**(排到最后);超时自动选华法琳。独立:自由选择、不限时。 | | 选择策略 | 40 名策略(分队长)决定**初始目标生命值(20–45)**和一条永久规则。同盟:随机顺序轮流选,**每人 30 秒**,右上角和「决策中」显示同一个倒计时;AI 队友立即选完;队友已选的不能重复,每人可**跳过一次**(排到最后);超时自动选你**当前选中**的策略(已被队友选走则选华法琳)。独立:自由选择、不限时;同盟模拟只有你一名玩家时也不限时。 |
| 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(首位 30 秒,其余 16 秒),独立 3 选 1。卡片包括资金、干员、盟约层数、战术特训(悬赏敌人)等。 | | 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(首位 30 秒,其余 16 秒;只有你一名玩家时不限时),独立 3 选 1。卡片包括资金、干员、盟约层数、战术特训(悬赏敌人)等。选卡要**点两次**:第一次只选中(金框 +「确认选择 · 再次点击」),再点同一张或点右上的「确认选择」才生效;点别的卡换选,按 Esc 或点空白处取消。 |
| 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加),独立不限时。所有人「准备就绪」即提前开战,全员就绪前可取消。 | | 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加);独立模拟、以及只有你一名玩家(只带 AI 队友)的同盟模拟不限时。所有人「准备就绪」即提前开战,全员就绪前可取消。 |
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**从上到下、从左到右**的顺序自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。 | | 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**从上到下、从左到右**的顺序自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在原地**(倒地姿势、略微变灰),头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够,红色「!」表示原位被占。血条右侧的圆形图标是**元素损伤**:干员为带图标的彩色圆盘,敌人为小号纯色圆盘,白色圆环是剩余元素值;爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),期间所有元素都不会累积也无法被治疗,结束后清零。 |
| 联防 | 见第 5 节。 | | 联防 | 见第 5 节。 |
| 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。 | | 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。 |
@@ -43,7 +43,7 @@
| 招募价格(按阶级 I–VI) | 2 / 3 / 3 / 3 / 4 / 4 | | 招募价格(按阶级 I–VI) | 2 / 3 / 3 / 3 / 4 / 4 |
| 刷新 | 1 资金 | | 刷新 | 1 资金 |
| 出售干员 | 任何干员(含精锐)都只返还 **1** 资金 | | 出售干员 | 任何干员(含精锐)都只返还 **1** 资金 |
| 装备 / 法术 | 不能出售,整备区里的只能「销毁」(不返还)。已配发的装备锁定在干员身上:只能被第三件装备替换(弹出替换窗口,选择要替换的一件,它会被销毁),或在干员出售 / 合成精锐时退回整备区 | | 装备 / 法术 | 调度中心出售除特殊装备外的所有装备:4 把特殊维式重锤(战栗 / 坚固 / 加速 / 灼燃)只来自维多利亚盟约(每 25 层)和洛洛的定制品,突变细胞只来自昆图斯的策略。装备不能出售,整备区里的只能「销毁」(不返还)。已配发的装备锁定在干员身上:只能被第三件装备替换(弹出替换窗口,选择要替换的一件,它会被销毁),或在干员出售 / 合成精锐时退回整备区 |
| 冻结 | 免费,保留当前所有未买的格子到下一回合;冻结状态下仍可主动刷新 | | 冻结 | 免费,保留当前所有未买的格子到下一回合;冻结状态下仍可主动刷新 |
| 升级调度中心(1→2 … 5→6) | 险境及以上 5 / 8 / 11 / 12 / 13;同盟标准 5 / 8 / 10 / 11 / 11;独立标准 1 / 1 / 5 / 8 / 10。**每过一回合价格 −1**(最低 0),升级后按下一级的价格重新计算 | | 升级调度中心(1→2 … 5→6) | 险境及以上 5 / 8 / 11 / 12 / 13;同盟标准 5 / 8 / 10 / 11 / 11;独立标准 1 / 1 / 5 / 8 / 10。**每过一回合价格 −1**(最低 0),升级后按下一级的价格重新计算 |
| 其他收入 | 悬赏敌人、盟约「远见」「奇迹」、装备「骑士储蓄罐」、部分策略和特质 | | 其他收入 | 悬赏敌人、盟约「远见」「奇迹」、装备「骑士储蓄罐」、部分策略和特质 |
@@ -62,9 +62,10 @@
- 从整备区把干员拖到棋盘:近战位 / 远程位高亮为可放置格。松手后出现**方向轮盘**,向上 / 右 / 下 / 左滑动,棋盘上会用橙色条纹预览旋转后的攻击范围,松手确认;松在中心或点「✕ 点击取消」取消。 - 从整备区把干员拖到棋盘:近战位 / 远程位高亮为可放置格。松手后出现**方向轮盘**,向上 / 右 / 下 / 左滑动,棋盘上会用橙色条纹预览旋转后的攻击范围,松手确认;松在中心或点「✕ 点击取消」取消。
- 想只改朝向:把干员拖回它自己所在的格子再选方向。朝向会一直保留到下一回合。 - 想只改朝向:把干员拖回它自己所在的格子再选方向。朝向会一直保留到下一回合。
- 拖动时干员模型站在的格子就是落点(触屏时模型抬高到手指上方,不会被手指挡住);装备以拖动中的图标所在的干员为目标,目标干员的格子会高亮。 - **点选按地上的方格**:点哪个方格就选中站在那个方格上的单位,点空方格不选任何单位(点到后一格干员画出来的头部,算作点了后面那一格)。作战中友方同样按方格;敌人在格子之间走动,点它的身体或脚下附近(0.6 格内)即可,飞行敌人点它的身体。
- 点选以画面上实际画出的干员为准:点到谁的头发、手臂、武器或身体就选中谁;点在空地上时按所点的格子判定。 - 拖动时模型一直在指针 / 手指下(脚在指针下方约半格,鼠标和触屏一样),**指针所在的格子**就是落点并高亮;装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。
- 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。 - 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。
- 能在休整期摆放的召唤物只有天赋召唤物(海嗣、狼群、流形);赫默的医疗无人机、巫恋的诅咒娃娃这类技能召唤物只在技能发动时出现。
- 部署顺序是**从上到下、从左到右**,最后部署的干员嘲讽等级最高;部署费用不够时后面的干员会晚一点上场。 - 部署顺序是**从上到下、从左到右**,最后部署的干员嘲讽等级最高;部署费用不够时后面的干员会晚一点上场。
- 右上角 🔍▶▶ 可以查看本回合即将来袭的敌人(预览区),左上角 🔍 查看本局信息(禁用盟约、策略)。 - 右上角 🔍▶▶ 可以查看本回合即将来袭的敌人(预览区),左上角 🔍 查看本局信息(禁用盟约、策略)。
@@ -85,6 +86,7 @@
- **核心盟约**(8 个势力:炎、萨尔贡、维多利亚、谢拉格、拉特兰、阿戈尔、叙拉古、卡西米尔):3 名激活,6 名获得强力的额外效果。 - **核心盟约**(8 个势力:炎、萨尔贡、维多利亚、谢拉格、拉特兰、阿戈尔、叙拉古、卡西米尔):3 名激活,6 名获得强力的额外效果。
- **附加盟约**(精准、迅捷、灵巧、奥术、坚守、助力、远见、奇迹、投资人、突袭、不屈、调和、协防干员、独行、绝技):多为 2 / 3 名激活;「独行」需要场上**恰好 1 名**,「绝技」按场上的精锐数量计算,没有势力盟约的干员算「协防干员」。 - **附加盟约**(精准、迅捷、灵巧、奥术、坚守、助力、远见、奇迹、投资人、突袭、不屈、调和、协防干员、独行、绝技):多为 2 / 3 名激活;「独行」需要场上**恰好 1 名**,「绝技」按场上的精锐数量计算,没有势力盟约的干员算「协防干员」。
- **炎佑**:场上 6 名以上「炎」干员时召唤炎佑(9 名时两只)。它追踪全场仇恨最高的敌人并悬停在其上方,技能「祛恶之焰」锁定 1 个目标**持续喷火**(最长 20 秒,每秒对目标周围的敌人造成法术伤害并附带灼燃,喷火时不普攻,目标死亡或被沉默时结束);场上暂时没有敌人时停在原地等待。
- **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。 - **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。
- 每局会随机**禁用**若干盟约(标准模拟较少),它们的干员不会出现在商店;开局的「确认本局信息」会列出。 - 每局会随机**禁用**若干盟约(标准模拟较少),它们的干员不会出现在商店;开局的「确认本局信息」会列出。
- 两件同名装备会合成一件更强的装备;特定的装备组合可以为干员赋予额外的盟约效果。 - 两件同名装备会合成一件更强的装备;特定的装备组合可以为干员赋予额外的盟约效果。
+28 -14
View File
@@ -123,7 +123,10 @@ the enemy (untargetable, not in snapshots), `wait` pauses, `appear` teleports.
attack allies within the radius (a MELEE enemy only ever hits its blocker — data/enemies.json already zeroes their attack allies within the radius (a MELEE enemy only ever hits its blocker — data/enemies.json already zeroes their
`rangeRadius`, the engine enforces it for any source; content may set `enemy.profile.melee = false`) (blocker → highest taunt → latest deployed; stealthed and `untargetable` allies are skipped for ranged shots) `rangeRadius`, the engine enforces it for any source; content may set `enemy.profile.melee = false`) (blocker → highest taunt → latest deployed; stealthed and `untargetable` allies are skipped for ranged shots)
and pause `ATTACK_PAUSE` (0.35 s) after each unblocked attack; `fear`/`disarm` stop attacks; `dmgType 'none'` enemies and pause `ATTACK_PAUSE` (0.35 s) after each unblocked attack; `fear`/`disarm` stop attacks; `dmgType 'none'` enemies
never attack; `dmgType 'heal'` enemies heal the lowest-HP% enemy in their radius instead. Crates (stage devices with never attack; `dmgType 'heal'` enemies heal the lowest-HP% enemy in their radius instead. Content can take over an
enemy's attack: `enemy.profile.deferHit` = the engine makes the attack (target, timing, the `'atk'` event) but deals no
damage — the content's `attack` handler resolves it (帝国炮火先兆者's shells landing 3 s later, `content/enemies.js
kitShell`); `enemy.profile.shot` = the `'atk'` event's projectile kind (`'mortar'`: no projectile drawn). Crates (stage devices with
role `crate`, 100 HP) are ground obstacles; an enemy forced through one is blocked by it and destroys it. A device is role `crate`, 100 HP) are ground obstacles; an enemy forced through one is blocked by it and destroys it. A device is
present when data/stages.json says `active: true` (this wins over the level file's `hidden`: act1 m02's crates); present when data/stages.json says `active: true` (this wins over the level file's `hidden`: act1 m02's crates);
research stages without `active` use `!hidden`. Active platforms/mounds (射击台, act1 m03) [ASSUMED, DATA §15.11] are research stages without `active` use `!hidden`. Active platforms/mounds (射击台, act1 m03) [ASSUMED, DATA §15.11] are
@@ -274,22 +277,26 @@ a custom buff with `flags.sleep` also blocks nothing and is untargetable/invulne
**Element gauges** (`unit.elem = {burn, neural, apoptosis, erosion, necrosis}`; capacity `unit.gaugeMax` = 1000, enemy **Element gauges** (`unit.elem = {burn, neural, apoptosis, erosion, necrosis}`; capacity `unit.gaugeMax` = 1000, enemy
leaders (rank BOSS / boss units) 2000): deal `{ type:'element', element, amount }` (fires `elementHit` first; the gauge leaders (rank BOSS / boss units) 2000): deal `{ type:'element', element, amount }` (fires `elementHit` first; the gauge
gain is × `elemTakenMul` × (1 − target `epDamageResistance`)). A full gauge bursts with the official effects, which gain is × `elemTakenMul` × (1 − 损伤抵抗 / 100), 损伤抵抗 = the target's data `epResistance` — PRTS 元素 "受到的元素损伤 =
depend on the side hit (constants.js `ELEMENT`): 损伤值 × (1 − 损伤抵抗 × 0.01)"). A full gauge bursts with the official effects, which depend on the side hit
(constants.js `ELEMENT`):
| element | operator hit by enemies (ba.dt.*) | enemy hit by operators ("·我方" ba.dt.*2) | | element | operator hit by enemies (ba.dt.*) | enemy hit by operators ("·我方" ba.dt.*2) |
|---|---|---| |---|---|---|
| `burn` 灼燃 | 1200 arts + RES −20, 10 s lock | 7000 元素伤害 + RES −20, 10 s lock | | `burn` 灼燃 | 1200 arts + RES −20, 10 s lock | 7000 元素伤害 + RES −20, 10 s lock |
| `neural` 神经 | 1000 true + stun 10 s (10 s lock) | 6000 元素伤害 + 3 `palsy`, 10 s lock | | `neural` 神经 | stun 10 s, then 1000 true (10 s lock) | 3 `palsy`, then 6000 元素伤害, 10 s lock |
| `apoptosis` 凋亡 | 15 s: no skill activation, −1 SP/s, 100 arts/s | 15 s: 50 % weaken recovering over the burst, 800 元素伤害/s | | `apoptosis` 凋亡 | 15 s: 阻回 (`noSp`: no SP gain of any kind, skills.js) + 静默 (no skill activation), −1 SP/s, 100 arts/s | 15 s: 50 % weaken recovering over the burst, 800 元素伤害/s |
| `erosion` 侵蚀 | permanent DEF −100 (stacking `erosionDown`) then 800 phys; no lock | permanent DEF −120 then 5000 元素伤害, 8 s lock | | `erosion` 侵蚀 | permanent DEF −100 (stacking `erosionDown`) then 800 phys, 10 s lock | permanent DEF −120 then 5000 元素伤害, 8 s lock |
| `necrosis` (legacy spare gauge) | 12 s: 100 true/s, ATK −20 % | same | | `necrosis` (legacy spare gauge) | 12 s: 100 true/s, ATK −20 % | same |
The gauge stays full during the lock (`<el>Burst` buff) and resets when it ends. A burst that is still resolving counts The lock (`<el>Burst` buff, flag `burstLock`) is the official **爆发冷却**: while it runs NO element of the unit fills or
as locked too (`unit.burstPending[el]`, `burstLocked(unit, el)` in damage.js): the `elementBurst` hook fires before the can be recovered (`battle.reduceElement(unit, amount, el?)` removes nothing) and the bursting gauge shows full; when it
lock buff exists (and operator erosion has no lock), so same-element fills of that unit are refused until the burst has ends EVERY gauge of the unit resets to 0. A burst that is still resolving counts as locked too (`unit.burstPending[el]`,
resolved — an `elementBurst` handler that spreads the element to neighbours (淤困 parasite) cannot bounce it back into `burstLocked(unit)` in damage.js — it takes no element): the `elementBurst` hook fires before the lock buff exists, so
a second burst. `battle.reduceElement(unit, amount, el?)` skips locked gauges. fills of that unit are refused until the burst has resolved — an `elementBurst` handler that spreads the element to
neighbours (淤困 parasite) cannot bounce it back into a second burst. `elementView(u, now)` (damage.js) is the one gauge
a unit shows (b.snap `elem`, §9): the fullest one, ties by the official element id (`constants.js ELEMENT_ORDER`: 神经,
侵蚀, 灼燃, 凋亡, then the legacy `necrosis`); during a 爆发冷却 the bursting element with fill 1 and the cooldown's end.
**元素伤害** (element HP damage, e.g. "每秒受到…元素伤害") is the DamageInfo type `'elemental'` (+ optional `element` for the **元素伤害** (element HP damage, e.g. "每秒受到…元素伤害") is the DamageInfo type `'elemental'` (+ optional `element` for the
client colour): no DEF/RES/dodge, × source `dmgDealtMul` × target `dmgTakenMul` × `elemTakenMul`, shields absorb it. client colour): no DEF/RES/dodge, × source `dmgDealtMul` × target `dmgTakenMul` × `elemTakenMul`, shields absorb it.
@@ -407,7 +414,8 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
`NEVER` (alias `MANUAL`) — never auto-casts: the kit calls `unit.skill.activate(reason)` itself; `NEVER` (alias `MANUAL`) — never auto-casts: the kit calls `unit.skill.activate(reason)` itself;
`GDGLOW_SKILL_2`, `MLYSS_WTRMAN` and any unknown rule behave like DEFAULT. Units that never attack (bard, phalanx, `GDGLOW_SKILL_2`, `MLYSS_WTRMAN` and any unknown rule behave like DEFAULT. Units that never attack (bard, phalanx,
librator) check DEFAULT every tick. Silence blocks activation. `gainSp` is ignored while a duration/ammo/toggle skill librator) check DEFAULT every tick. Silence blocks activation. `gainSp` is ignored while a duration/ammo/toggle skill
runs (its bar shows the skill), whatever the reason. runs (its bar shows the skill), whatever the reason, and — any reason but `'init'` — while the unit has the `noSp`
flag (阻回: "停止并阻止任意形式的技力回复"; the operators' 凋亡 burst, §3).
- Kinds: `duration` (mods for `duration` s), `ammo` (mods until `ammo` attacks were made, optional duration cap), - Kinds: `duration` (mods for `duration` s), `ammo` (mods until `ammo` attacks were made, optional duration cap),
`instant` (onStart + optional one-shot attack override applied to the next attack), `charges` (instant with charges), `instant` (onStart + optional one-shot attack override applied to the next attack), `charges` (instant with charges),
`passive` (always on from deployment, no SP), `toggle` (stays on until death once activated). `passive` (always on from deployment, no SP), `toggle` (stays on until death once activated).
@@ -705,11 +713,17 @@ Unknown subprofessions fall back to the profession default (test `professions.te
## 9. Wire format (snapshot.js, DESIGN §8.2) ## 9. Wire format (snapshot.js, DESIGN §8.2)
- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss? }`. - `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem? }`.
`sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active. Units in DIE state stay 0.8 s. `sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active. Units in DIE state stay 0.8 s.
`down: [[id, respawnAt, respawnTime, state]]` (only when non-empty) = knocked-out operators waiting to redeploy
(`Battle.isDown(u)`: reason `'killed'`, deployed at least once, a finite respawn timer; `state` = constants.js
`DOWN_STATE`: 0 counting, 1 timer done / DP short, 2 timer done / own tile taken); `elem: [[id, element, fill,
cooldownEnd, cooldown]]` (only when non-empty) = `elementView` of every unit with a gauge or a running 爆发冷却 (§3).
`fieldMeta()` lists the knocked-out operators too (a client joining mid-battle shows them; DESIGN §18.3).
- `drainEvents()` tuples: `['spawn', UnitInfo]` (first appearance), `['deploy', id]` (every (re)deploy), `['atk', src, tgt, projKind]` - `drainEvents()` tuples: `['spawn', UnitInfo]` (first appearance), `['deploy', id]` (every (re)deploy), `['atk', src, tgt, projKind]`
(`none|arrow|bolt|bomb|lob|orb|drone|enemy|boomerang|chain|chainHeal`; a boomerang's way back has no event — the (`none|arrow|bolt|bomb|lob|orb|drone|enemy|boomerang|chain|chainHeal`; a boomerang's way back has no event — the
renderer flies it back to the thrower at `BOOMERANG_RETURN_SPEED`), `['dmg', tgt, amount, type]` (`phys|arts|true|burn|neural|necrosis|apoptosis`), renderer flies it back to the thrower at `BOOMERANG_RETURN_SPEED`; an enemy's `profile.shot` may name another kind,
e.g. `mortar` for 帝国炮火先兆者, which the renderer does not draw — its fx `bombardShell` is the shell), `['dmg', tgt, amount, type]` (`phys|arts|true|burn|neural|necrosis|apoptosis`),
`['heal', tgt, amount]`, `['skill', id, 1|0]`, `['die', id, reason]`, `['leak', id]`, `['status', id, key, 1|0]`, `['heal', tgt, amount]`, `['skill', id, 1|0]`, `['die', id, reason]`, `['leak', id]`, `['status', id, key, 1|0]`,
`['fx', kind, x, y, extra]`, `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`. `['fx', kind, x, y, extra]`, `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`.
- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, skillIndex? }` (`skillIndex`: an ally's equipped skill, DESIGN §16) - `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, skillIndex? }` (`skillIndex`: an ally's equipped skill, DESIGN §16)
+5
View File
@@ -37,6 +37,11 @@ input, textarea, select { -webkit-user-select: text; user-select: text; }
/* the selected unit's underframe (ui/underframe.js) is placed at the tile's CLIENT px (view.tileScreen) inside the HUD /* the selected unit's underframe (ui/underframe.js) is placed at the tile's CLIENT px (view.tileScreen) inside the HUD
layer: step back over the HUD's safe-area offset so it stays on its unit on a notched phone */ layer: step back over the HUD's safe-area offset so it stays on its unit on a notched phone */
.gm__hud > .uframe { margin-left: calc(-1 * var(--sa-l)); margin-top: calc(-1 * var(--sa-t)); } .gm__hud > .uframe { margin-left: calc(-1 * var(--sa-l)); margin-top: calc(-1 * var(--sa-t)); }
/* the shop bar keeps its place at the bottom edge (it may reach into the home-indicator strip): raised by the bottom
inset it covered ~3/4 of the bench pads 2–9 on a notched phone (844×390 with the iPhone insets: 23 % of those tiles
left free, 71 % now — as without insets), and a bench piece is pressed / dropped on by its tile (user playtest #4
item 1). Its right edge stays inside the inset (the notch / rounded corner). */
.gm__hud > .shopbar { bottom: calc(.2rem - var(--sa-b)); }
.gm > .spov { left: calc(2.8rem + var(--sa-l)); right: var(--sa-r); } .gm > .spov { left: calc(2.8rem + var(--sa-l)); right: var(--sa-r); }
/* every other screen is laid out inside the safe area (the app backdrop fills the notch strip) */ /* every other screen is laid out inside the safe area (the app backdrop fills the notch strip) */
.screen:not(.gm) { top: var(--sa-t); right: var(--sa-r); bottom: var(--sa-b); left: var(--sa-l); } .screen:not(.gm) { top: var(--sa-t); right: var(--sa-r); bottom: var(--sa-b); left: var(--sa-l); }
+30
View File
@@ -111,6 +111,19 @@
.dstat__row { display: flex; align-items: baseline; min-width: 0; white-space: nowrap; } .dstat__row { display: flex; align-items: baseline; min-width: 0; white-space: nowrap; }
.dstat__v { font-size: .17rem; line-height: 1.15; color: #fff; } .dstat__v { font-size: .17rem; line-height: 1.15; color: #fff; }
.dstat small { font-size: .11rem; color: var(--text-dim); margin-left: .04rem; } .dstat small { font-size: .11rem; color: var(--text-dim); margin-left: .04rem; }
/* live stats (user playtest #4 item 7): buffed values green, debuffed red (the official card), the difference beside
them; a 实时 (battle) / 开战时 (prep preview) tag over the grid's right edge */
.dstats.is-live, .dstats-wrap.is-live { margin-top: .14rem; }
.dstats.is-live, .dstats-wrap.is-live .dstats { position: relative; }
.dstat.is-up .dstat__v { color: #6dfa9c; text-shadow: 0 0 .08rem rgba(80, 240, 140, .35); }
.dstat.is-up small { color: #56e08a; }
.dstat.is-down .dstat__v { color: var(--red-premium); text-shadow: 0 0 .08rem rgba(255, 84, 84, .3); }
.dstat.is-down small { color: #ff8a80; }
.dstats__tag {
position: absolute; right: -1px; bottom: 100%; padding: .01rem .08rem; font-size: .11rem; font-weight: 700; letter-spacing: .1em;
color: var(--text-on-mint); background: var(--mint-500); pointer-events: auto; cursor: help;
}
.dstats__tag.is-battle { background: var(--orange); color: #1d0c00; }
.drange { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .06rem; padding: .06rem .1rem; background: #0f1311; border: 1px solid var(--line); min-width: 1.1rem; } .drange { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .06rem; padding: .06rem .1rem; background: #0f1311; border: 1px solid var(--line); min-width: 1.1rem; }
.rgrid { --rg: .14rem; display: grid; gap: 2px; } .rgrid { --rg: .14rem; display: grid; gap: 2px; }
.rgrid i { width: var(--rg); height: var(--rg); border: 1px solid rgba(255, 255, 255, .06); } .rgrid i { width: var(--rg); height: var(--rg); border: 1px solid rgba(255, 255, 255, .06); }
@@ -370,6 +383,23 @@
font-size: .14rem; font-weight: 700; letter-spacing: .2em; color: var(--mint-400); background: rgba(8, 11, 10, .85); font-size: .14rem; font-weight: 700; letter-spacing: .2em; color: var(--mint-400); background: rgba(8, 11, 10, .85);
} }
.spcard__busy::after { content: ''; position: absolute; left: 0; bottom: 0; width: 40%; height: 2px; background: var(--mint-400); animation: btn-busy 1.1s var(--ease-in-out) infinite; } .spcard__busy::after { content: ''; position: absolute; left: 0; bottom: 0; width: 40%; height: 2px; background: var(--mint-400); animation: btn-busy 1.1s var(--ease-in-out) infinite; }
/* two-tap pick (user playtest #4 item 2 — the shop's confirm language, game-shop.css .scard.is-armed): the selected card
lifts with a gold frame and a 确认选择 · 再次点击 strip; the others dim; the header shows 确认选择 */
.spcard.is-armed, .spcard.is-armed:hover {
z-index: 2; transform: translateY(-.1rem) scale(1.03); border-color: var(--gold);
background: linear-gradient(180deg, rgba(255, 198, 0, .16), rgba(255, 198, 0, .04));
box-shadow: 0 0 0 2px var(--gold), 0 .14rem .32rem rgba(0, 0, 0, .6), 0 0 .3rem rgba(255, 198, 0, .35);
}
.spov.has-armed .spcard.is-pickable:not(.is-armed) { filter: brightness(.7); }
.spov.has-armed .spcard.is-pickable:not(.is-armed):hover { filter: none; }
.spcard__confirm {
position: absolute; left: 0; right: 0; bottom: 0; z-index: 3; display: flex; flex-direction: column; align-items: center;
padding: .05rem 0 .05rem; background: linear-gradient(0deg, var(--gold), #e0a800); color: #1d1400; pointer-events: none;
animation: confirm-in var(--t-fast) var(--ease-out);
}
.spcard__confirm b { font-size: .18rem; font-weight: 900; letter-spacing: .22em; padding-left: .22em; }
.spcard__confirm small { font-size: .11rem; letter-spacing: .1em; opacity: .75; }
.spov__confirm { animation: pulse-gold 1.2s var(--ease-in-out) infinite; }
/* Grid image boxes: definite 100% tracks so `height: 100%` images resolve against the box (an auto track would /* Grid image boxes: definite 100% tracks so `height: 100%` images resolve against the box (an auto track would
let non-square art overflow its frame). */ let non-square art overflow its frame). */
+3 -4
View File
@@ -316,10 +316,9 @@
font-size: .8em; font-weight: 700; color: var(--text-on-mint); background: var(--mint-500); border-radius: 1em; font-size: .8em; font-weight: 700; color: var(--text-on-mint); background: var(--mint-500); border-radius: 1em;
} }
/* Untimed phases (user playtest #11: the official 独立模拟 shows no countdown outside combat — the server sends /* Untimed phases (user playtest #11 / #4 item 3: the official 独立模拟 shows no countdown outside combat — the server
deadline 0 for them, Match.soloUntimed): ui/components.js Countdown renders its "无倒计时" placeholder ("--" digits + sends deadline 0 for them, Match.soloUntimed): ui/components.js Countdown now renders nothing without a deadline;
COUNTDOWN label), which reads as a timer — it is not shown at all. (Owner of components.js: returning null there this rule stays as a safety net for the old "无倒计时" placeholder ("--" digits + COUNTDOWN label). */
for a missing deadline makes this rule a no-op.) */
.countdown[aria-label="无倒计时"] { display: none; } .countdown[aria-label="无倒计时"] { display: none; }
/* the app screens behind the overlay do not need to animate */ /* the app screens behind the overlay do not need to animate */
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"boss-m02","title":"最终攻势 · 卢西恩,“猩红血钻” · R14","kind":"boss","stageId":"act2autochess_m02","modeId":"mode_multi_normal","round":14,"wave":"act2autochess_h07_05","tick":0.03333333333333333,"snapEvery":3,"duration":75,"recordedAt":"2026-09-27","field":{"fieldId":"b1","kind":"boss","rect":{"r0":0,"r1":5,"c0":0,"c1":20},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":2,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":8,"y":2,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":2,"skillIndex":1,"moduleId":"uniequip_002_f12yin"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":7,"y":2,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":3,"skillIndex":2,"moduleId":"uniequip_002_surtr"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":3,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":4,"skillIndex":0,"moduleId":"uniequip_002_dusk"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":4,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_b","name":"烛煌","tier":5,"golden":true,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2_2","x":6,"y":5,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":6,"skillIndex":2,"moduleId":"uniequip_002_blaze2"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":5,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7,"skillIndex":2},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_6_05_a","name":"异客","tier":6,"golden":false,"spine":"char_472_pasngr","avatar":"char_472_pasngr","x":8,"y":5,"facing":1,"dir":"RIGHT","maxHp":1215,"uid":8,"skillIndex":2},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_03_b","name":"余","tier":6,"golden":true,"spine":"char_2026_yu","avatar":"char_2026_yu_2","x":11,"y":2,"facing":-1,"dir":"LEFT","maxHp":3430,"uid":101,"skillIndex":2,"moduleId":"uniequip_002_yu"},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_17_a","name":"耀骑士临光","tier":6,"golden":false,"spine":"char_1014_nearl2","avatar":"char_1014_nearl2","x":12,"y":2,"facing":-1,"dir":"LEFT","maxHp":2698,"uid":102,"skillIndex":2},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_19_b","name":"锏","tier":6,"golden":true,"spine":"char_4116_blkkgt","avatar":"char_4116_blkkgt_2","x":13,"y":2,"facing":-1,"dir":"LEFT","maxHp":2767,"uid":103,"skillIndex":2,"moduleId":"uniequip_002_blkkgt"},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_01_b","name":"蕾缪安","tier":6,"golden":true,"spine":"char_4193_lemuen","avatar":"char_4193_lemuen_2","x":16,"y":3,"facing":-1,"dir":"LEFT","maxHp":1470,"uid":104,"skillIndex":2,"moduleId":"uniequip_002_lemuen"},{"id":13,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_08_a","name":"焰影苇草","tier":6,"golden":false,"spine":"char_1020_reed2","avatar":"char_1020_reed2","x":15,"y":4,"facing":-1,"dir":"LEFT","maxHp":1361,"uid":105,"skillIndex":2},{"id":14,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_12_b","name":"迷迭香","tier":6,"golden":true,"spine":"char_391_rosmon","avatar":"char_391_rosmon_2","x":14,"y":5,"facing":-1,"dir":"LEFT","maxHp":2887,"uid":106,"skillIndex":1,"moduleId":"uniequip_002_rosmon"},{"id":15,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_14_a","name":"流明","tier":6,"golden":false,"spine":"char_4042_lumen","avatar":"char_4042_lumen","x":13,"y":5,"facing":-1,"dir":"LEFT","maxHp":1569,"uid":107,"skillIndex":2},{"id":16,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_20_a","name":"纯烬艾雅法拉","tier":6,"golden":false,"spine":"char_1016_agoat2","avatar":"char_1016_agoat2","x":12,"y":5,"facing":-1,"dir":"LEFT","maxHp":1237,"uid":108,"skillIndex":2},{"id":17,"kind":"enemy","side":"enemy","ownerId":"P1","defId":"enemy_2016_csphtm","name":"卢西恩,“猩红血钻”","tier":3,"golden":false,"spine":"enemy_2016_csphtm","avatar":"enemy_2016_csphtm","x":9.99,"y":4,"facing":1,"dir":"RIGHT","maxHp":195000,"boss":true}]},"frames":[{"snap":{"fieldId":"b1","t":0.1,"units":[[1,9,2,3003,Line truncated {"version":1,"name":"boss-m02","title":"最终攻势 · 卢西恩,“猩红血钻” · R14","kind":"boss","stageId":"act2autochess_m02","modeId":"mode_multi_normal","round":14,"wave":"act2autochess_h07_05","tick":0.03333333333333333,"snapEvery":3,"duration":75,"recordedAt":"2026-09-27","field":{"fieldId":"b1","kind":"boss","rect":{"r0":0,"r1":5,"c0":0,"c1":20},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":2,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":8,"y":2,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":2,"skillIndex":1,"moduleId":"uniequip_002_f12yin"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":7,"y":2,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":3,"skillIndex":2,"moduleId":"uniequip_002_surtr"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":3,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":4,"skillIndex":0,"moduleId":"uniequip_002_dusk"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":4,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_b","name":"烛煌","tier":5,"golden":true,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2_2","x":6,"y":5,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":6,"skillIndex":2,"moduleId":"uniequip_002_blaze2"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":5,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7,"skillIndex":2},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_6_05_a","name":"异客","tier":6,"golden":false,"spine":"char_472_pasngr","avatar":"char_472_pasngr","x":8,"y":5,"facing":1,"dir":"RIGHT","maxHp":1215,"uid":8,"skillIndex":2},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_03_b","name":"余","tier":6,"golden":true,"spine":"char_2026_yu","avatar":"char_2026_yu_2","x":11,"y":2,"facing":-1,"dir":"LEFT","maxHp":3430,"uid":101,"skillIndex":2,"moduleId":"uniequip_002_yu"},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_17_a","name":"耀骑士临光","tier":6,"golden":false,"spine":"char_1014_nearl2","avatar":"char_1014_nearl2","x":12,"y":2,"facing":-1,"dir":"LEFT","maxHp":2698,"uid":102,"skillIndex":2},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_19_b","name":"锏","tier":6,"golden":true,"spine":"char_4116_blkkgt","avatar":"char_4116_blkkgt_2","x":13,"y":2,"facing":-1,"dir":"LEFT","maxHp":2767,"uid":103,"skillIndex":2,"moduleId":"uniequip_002_blkkgt"},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_01_b","name":"蕾缪安","tier":6,"golden":true,"spine":"char_4193_lemuen","avatar":"char_4193_lemuen_2","x":16,"y":3,"facing":-1,"dir":"LEFT","maxHp":1470,"uid":104,"skillIndex":2,"moduleId":"uniequip_002_lemuen"},{"id":13,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_08_a","name":"焰影苇草","tier":6,"golden":false,"spine":"char_1020_reed2","avatar":"char_1020_reed2","x":15,"y":4,"facing":-1,"dir":"LEFT","maxHp":1361,"uid":105,"skillIndex":2},{"id":14,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_12_b","name":"迷迭香","tier":6,"golden":true,"spine":"char_391_rosmon","avatar":"char_391_rosmon_2","x":14,"y":5,"facing":-1,"dir":"LEFT","maxHp":2887,"uid":106,"skillIndex":1,"moduleId":"uniequip_002_rosmon"},{"id":15,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_14_a","name":"流明","tier":6,"golden":false,"spine":"char_4042_lumen","avatar":"char_4042_lumen","x":13,"y":5,"facing":-1,"dir":"LEFT","maxHp":1569,"uid":107,"skillIndex":2},{"id":16,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_20_a","name":"纯烬艾雅法拉","tier":6,"golden":false,"spine":"char_1016_agoat2","avatar":"char_1016_agoat2","x":12,"y":5,"facing":-1,"dir":"LEFT","maxHp":1237,"uid":108,"skillIndex":2},{"id":17,"kind":"enemy","side":"enemy","ownerId":"P1","defId":"enemy_2016_csphtm","name":"卢西恩,“猩红血钻”","tier":3,"golden":false,"spine":"enemy_2016_csphtm","avatar":"enemy_2016_csphtm","x":9.99,"y":4,"facing":1,"dir":"RIGHT","maxHp":195000,"boss":true}]},"frames":[{"snap":{"fieldId":"b1","t":0.1,"units":[[1,9,2,3003,Line truncated
+15 -15
View File
@@ -8,10 +8,10 @@
"frames": 550, "frames": 550,
"maxUnits": 30, "maxUnits": 30,
"file": "normal-m01.json", "file": "normal-m01.json",
"bytes": 417250, "bytes": 433457,
"result": { "result": {
"reason": "timeout", "reason": "timeout",
"killed": 14, "killed": 12,
"total": 32 "total": 32
} }
}, },
@@ -22,12 +22,12 @@
"stageId": "act2autochess_m02", "stageId": "act2autochess_m02",
"duration": 85, "duration": 85,
"frames": 850, "frames": 850,
"maxUnits": 24, "maxUnits": 26,
"file": "normal-m02.json", "file": "normal-m02.json",
"bytes": 558479, "bytes": 618614,
"result": { "result": {
"reason": "timeout", "reason": "timeout",
"killed": 15, "killed": 16,
"total": 35 "total": 35
} }
}, },
@@ -38,12 +38,12 @@
"stageId": "act2autochess_m03", "stageId": "act2autochess_m03",
"duration": 90, "duration": 90,
"frames": 900, "frames": 900,
"maxUnits": 31, "maxUnits": 33,
"file": "normal-m03.json", "file": "normal-m03.json",
"bytes": 720825, "bytes": 791453,
"result": { "result": {
"reason": "timeout", "reason": "timeout",
"killed": 11, "killed": 15,
"total": 46 "total": 46
} }
}, },
@@ -54,12 +54,12 @@
"stageId": "act2autochess_m04", "stageId": "act2autochess_m04",
"duration": 70, "duration": 70,
"frames": 700, "frames": 700,
"maxUnits": 33, "maxUnits": 35,
"file": "normal-m04.json", "file": "normal-m04.json",
"bytes": 560135, "bytes": 597582,
"result": { "result": {
"reason": "timeout", "reason": "timeout",
"killed": 18, "killed": 23,
"total": 39 "total": 39
} }
}, },
@@ -72,7 +72,7 @@
"frames": 843, "frames": 843,
"maxUnits": 30, "maxUnits": 30,
"file": "late-m03-r12.json", "file": "late-m03-r12.json",
"bytes": 571183, "bytes": 638858,
"result": { "result": {
"reason": "cleared", "reason": "cleared",
"killed": 12, "killed": 12,
@@ -88,7 +88,7 @@
"frames": 1100, "frames": 1100,
"maxUnits": 33, "maxUnits": 33,
"file": "late-m01-r13.json", "file": "late-m01-r13.json",
"bytes": 849895, "bytes": 904761,
"result": { "result": {
"reason": "timeout", "reason": "timeout",
"killed": 6, "killed": 6,
@@ -104,7 +104,7 @@
"frames": 600, "frames": 600,
"maxUnits": 36, "maxUnits": 36,
"file": "unite-m01.json", "file": "unite-m01.json",
"bytes": 620002, "bytes": 636264,
"result": { "result": {
"reason": "timeout", "reason": "timeout",
"killed": 17, "killed": 17,
@@ -120,7 +120,7 @@
"frames": 750, "frames": 750,
"maxUnits": 24, "maxUnits": 24,
"file": "boss-m02.json", "file": "boss-m02.json",
"bytes": 591355, "bytes": 714082,
"result": { "result": {
"reason": "forced", "reason": "forced",
"killed": 12, "killed": 12,
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"late-m01-r13","title":"常规作战 · 源石流发生装置 · R13(官方波次)","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_hard","round":13,"wave":"act1autochess_h06","tick":0.03333333333333333,"snapEvery":3,"duration":110,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_hsguma"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_b","name":"银灰","tier":4,"golden":true,"spine":"char_172_svrash","avatar":"char_172_svrash_2","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2569,"uid":2,"skillIndex":2,"moduleId":"uniequip_002_svrash"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_b","name":"斯卡蒂","tier":3,"golden":true,"spine":"char_263_skadi","avatar":"char_263_skadi_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":3853,"uid":3,"skillIndex":2,"moduleId":"uniequip_002_skadi"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":4,"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6,"skillIndex":1,"moduleId":"uniequip_002_kroos2"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_b","name":"远牙","tier":4,"golden":true,"spine":"char_430_fartth","avatar":"char_430_fartth_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1419,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_fartth"}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":3,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":1,"gate":"upper","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":11,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":32,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":39,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":55,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":62,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null}],"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3513,3513,0,0,0,6],[2,8,9,2569,2569,60.1,90,0,6],[3,7,9,3853,3853,60.1,90,0,6],[4,4,10,1695,1695,62.1,110,0,6],[5,4,11,1271,1271,70.1,100,0,6],[6,4,12,1561,1561,20.1,40,0,6],[7,5,12,1288,1288,20.1,55,0,6],[8,6,12,1419,1419,7.1,24,0,6]],"dp":10,"killed":0,"total":55},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6,"skillIndex":1,"moduleId":"uniequip_002_kroos2"}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"Line truncated {"version":1,"name":"late-m01-r13","title":"常规作战 · 源石流发生装置 · R13(官方波次)","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_hard","round":13,"wave":"act1autochess_h06","tick":0.03333333333333333,"snapEvery":3,"duration":110,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_hsguma"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_b","name":"银灰","tier":4,"golden":true,"spine":"char_172_svrash","avatar":"char_172_svrash_2","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2569,"uid":2,"skillIndex":2,"moduleId":"uniequip_002_svrash"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_b","name":"斯卡蒂","tier":3,"golden":true,"spine":"char_263_skadi","avatar":"char_263_skadi_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":3853,"uid":3,"skillIndex":2,"moduleId":"uniequip_002_skadi"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":4,"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6,"skillIndex":1,"moduleId":"uniequip_002_kroos2"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_b","name":"远牙","tier":4,"golden":true,"spine":"char_430_fartth","avatar":"char_430_fartth_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1419,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_fartth"}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":3,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":1,"gate":"upper","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":11,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":32,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":39,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":55,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":62,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null}],"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3513,3513,0,0,0,6],[2,8,9,2569,2569,60.1,90,0,6],[3,7,9,3853,3853,60.1,90,0,6],[4,4,10,1695,1695,62.1,110,0,6],[5,4,11,1271,1271,70.1,100,0,6],[6,4,12,1561,1561,20.1,40,0,6],[7,5,12,1288,1288,20.1,55,0,6],[8,6,12,1419,1419,7.1,24,0,6]],"dp":10,"killed":0,"total":55},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6,"skillIndex":1,"moduleId":"uniequip_002_kroos2"}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"Line truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"late-m03-r12","title":"常规作战 · 排气格栅 · R12(官方波次)","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":12,"wave":"act1autochess_h05","tick":0.03333333333333333,"snapEvery":3,"duration":84.23,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":2,"skillIndex":1,"moduleId":"uniequip_002_hsguma"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_f12yin"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":4,"skillIndex":2,"moduleId":"uniequip_002_surtr"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":5,"skillIndex":0,"moduleId":"uniequip_002_dusk"},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":6,"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_b","name":"铃兰","tier":5,"golden":true,"spine":"char_358_lisa","avatar":"char_358_lisa_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1480,"uid":7,"skillIndex":2,"moduleId":"uniequip_002_lisa"},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":3,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_horn"},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"upper","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":12,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":30,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"lower","t":31,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":35,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":51,"fly":false,"eLine truncated {"version":1,"name":"late-m03-r12","title":"常规作战 · 排气格栅 · R12(官方波次)","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":12,"wave":"act1autochess_h05","tick":0.03333333333333333,"snapEvery":3,"duration":84.23,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":2,"skillIndex":1,"moduleId":"uniequip_002_hsguma"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_f12yin"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":4,"skillIndex":2,"moduleId":"uniequip_002_surtr"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":5,"skillIndex":0,"moduleId":"uniequip_002_dusk"},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":6,"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_b","name":"铃兰","tier":5,"golden":true,"spine":"char_358_lisa","avatar":"char_358_lisa_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1480,"uid":7,"skillIndex":2,"moduleId":"uniequip_002_lisa"},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":3,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_horn"},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"upper","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":12,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":30,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"lower","t":31,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":35,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":51,"fly":false,"eLine truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"normal-m01","title":"常规作战 · 源石流发生装置 · R5","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":5,"wave":"act1autochess_05","tick":0.03333333333333333,"snapEvery":3,"duration":55,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1285,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2389,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_estell"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1260,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7,"skillIndex":2}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2216,2216,0.1,47,0,6],[2,8,9,1285,1285,23.1,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.1,24,0,6],[5,4,11,1260,1260,0.1,60,0,6],[6,4,12,1400,1400,0.1,26,0,6],[7,5,12,1472,1472,20.1,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7,"skillIndex":2}],["deploy",7],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2389,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_estell"}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1285,"uid":2,"skillIndex":1}],["deploy",2],["spawn",{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":1,"skillIndex":1}],["deploy",1],["status",5,"talent:angel_bless_ally",1],["fx","buff",4,11,{"id":5,"src":7,"talent":"angel_bless"}]]},{"snap":{"fieldId":"n:P1","t":0.2,"units":[[1,9,9,2216,2216,0.2,47,0,6],[2,8,9,1285,1285,23.2,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.2,24,0,6],[5,4,11,1260,1260,0.2,60,0,6],[6,4,12,1400,1400,0.2,26,0,6],[7,5,12,1472,1472,20.2,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[]},{"snap":{"fieldId":"n:P1","t":0.3,"units":[[1,9,9,2216,2216,0.3,47,0,6],[2,8,9,1285,1285,23.3,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.3,24,0,6],[5,4,11,1260,1260,0.3,60,0,6],[6,4,12,1400,1400,0.3,26,0,6],[7,5,12,1472,1472,20Line truncated {"version":1,"name":"normal-m01","title":"常规作战 · 源石流发生装置 · R5","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":5,"wave":"act1autochess_05","tick":0.03333333333333333,"snapEvery":3,"duration":55,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1285,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2389,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_estell"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1260,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7,"skillIndex":2}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2216,2216,0.1,47,0,6],[2,8,9,1285,1285,23.1,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.1,24,0,6],[5,4,11,1260,1260,0.1,60,0,6],[6,4,12,1400,1400,0.1,26,0,6],[7,5,12,1472,1472,20.1,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7,"skillIndex":2}],["deploy",7],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2389,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_estell"}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1285,"uid":2,"skillIndex":1}],["deploy",2],["spawn",{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":1,"skillIndex":1}],["deploy",1],["status",5,"talent:angel_bless_ally",1],["fx","buff",4,11,{"id":5,"src":7,"talent":"angel_bless"}]]},{"snap":{"fieldId":"n:P1","t":0.2,"units":[[1,9,9,2216,2216,0.2,47,0,6],[2,8,9,1285,1285,23.2,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.2,24,0,6],[5,4,11,1260,1260,0.2,60,0,6],[6,4,12,1400,1400,0.2,26,0,6],[7,5,12,1472,1472,20.2,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[]},{"snap":{"fieldId":"n:P1","t":0.3,"units":[[1,9,9,2216,2216,0.3,47,0,6],[2,8,9,1285,1285,23.3,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.3,24,0,6],[5,4,11,1260,1260,0.3,60,0,6],[6,4,12,1400,1400,0.3,26,0,6],[7,5,12,1472,1472,20Line truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"normal-m02","title":"常规作战 · 沼泽控制 · R8","kind":"normal","stageId":"act2autochess_m02","modeId":"mode_multi_hard","round":8,"wave":"act1autochess_h01","tick":0.03333333333333333,"snapEvery":3,"duration":85,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":2,"skillIndex":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_a","name":"斯卡蒂","tier":3,"golden":false,"spine":"char_263_skadi","avatar":"char_263_skadi","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2938,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":4,"skillIndex":2},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":8,"skillIndex":2},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,2022,2022,55.1,90,0,6],[3,7,9,2938,2938,55.1,90,0,6],[4,4,10,1428,1428,56.1,110,0,6],[5,5,11,1271,1271,70.1,100,0,6],[6,6,12,1246,1246,15.1,45,0,6],[7,7,12,1288,1288,20.1,55,0,6],[8,8,12,1217,1217,6.1,27,0,6],[9,9,11,100,100,0,0,0,6],[10,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":35},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1}],["deploy",7],["spawn",{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":8,"skillIndex":2}],["deploy",8],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":1Line truncated {"version":1,"name":"normal-m02","title":"常规作战 · 沼泽控制 · R8","kind":"normal","stageId":"act2autochess_m02","modeId":"mode_multi_hard","round":8,"wave":"act1autochess_h01","tick":0.03333333333333333,"snapEvery":3,"duration":85,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":2,"skillIndex":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_a","name":"斯卡蒂","tier":3,"golden":false,"spine":"char_263_skadi","avatar":"char_263_skadi","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2938,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":4,"skillIndex":2},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":8,"skillIndex":2},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,2022,2022,55.1,90,0,6],[3,7,9,2938,2938,55.1,90,0,6],[4,4,10,1428,1428,56.1,110,0,6],[5,5,11,1271,1271,70.1,100,0,6],[6,6,12,1246,1246,15.1,45,0,6],[7,7,12,1288,1288,20.1,55,0,6],[8,8,12,1217,1217,6.1,27,0,6],[9,9,11,100,100,0,0,0,6],[10,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":35},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1}],["deploy",7],["spawn",{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":8,"skillIndex":2}],["deploy",8],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":1Line truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"normal-m03","title":"常规作战 · 排气格栅 · R11","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":11,"wave":"act1autochess_h04","tick":0.03333333333333333,"snapEvery":3,"duration":90,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_a","name":"山","tier":5,"golden":false,"spine":"char_264_f12yin","avatar":"char_264_f12yin","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":2250,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_a","name":"史尔特尔","tier":5,"golden":false,"spine":"char_350_surtr","avatar":"char_350_surtr","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":0},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1206,"uid":6,"skillIndex":2},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7,"skillIndex":2},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_horn"},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3003,3003,0.1,8,0,6],[2,6,9,2250,2250,0.1,9,0,6],[3,5,9,2216,2216,0.1,9,0,6],[4,4,12,1404,1404,0.1,7,0,6],[5,5,12,1208,1208,20.1,45,0,6],[6,6,12,1206,1206,13.1,37,8,6],[7,7,12,1184,1184,43.1,70,0,6],[8,4,9,2775,2775,25.1,40,0,6],[9,3,12,100,100,0,0,0,6],[10,3,11,100,100,0,0,0,6],[11,9,11,100,100,0,0,0,6]],"dp":10,"killed":0,"total":46},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":0}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2}],["deploy",5],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"dir":"RILine truncated {"version":1,"name":"normal-m03","title":"常规作战 · 排气格栅 · R11","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":11,"wave":"act1autochess_h04","tick":0.03333333333333333,"snapEvery":3,"duration":90,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_a","name":"山","tier":5,"golden":false,"spine":"char_264_f12yin","avatar":"char_264_f12yin","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":2250,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_a","name":"史尔特尔","tier":5,"golden":false,"spine":"char_350_surtr","avatar":"char_350_surtr","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":0},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1206,"uid":6,"skillIndex":2},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7,"skillIndex":2},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_horn"},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3003,3003,0.1,8,0,6],[2,6,9,2250,2250,0.1,9,0,6],[3,5,9,2216,2216,0.1,9,0,6],[4,4,12,1404,1404,0.1,7,0,6],[5,5,12,1208,1208,20.1,45,0,6],[6,6,12,1206,1206,13.1,37,8,6],[7,7,12,1184,1184,43.1,70,0,6],[8,4,9,2775,2775,25.1,40,0,6],[9,3,12,100,100,0,0,0,6],[10,3,11,100,100,0,0,0,6],[11,9,11,100,100,0,0,0,6]],"dp":10,"killed":0,"total":46},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":0}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2}],["deploy",5],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"dir":"RILine truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"normal-m04","title":"常规作战 · 涨潮控制 · R6","kind":"normal","stageId":"act2autochess_m04","modeId":"mode_multi_normal","round":6,"wave":"act1autochess_06","tick":0.03333333333333333,"snapEvery":3,"duration":70,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m04","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_16_a","name":"蛇屠箱","tier":3,"golden":false,"spine":"char_150_snakek","avatar":"char_150_snakek","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2173,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulpis","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1481,"uid":2,"skillIndex":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":1911,"uid":3,"skillIndex":1},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7,"skillIndex":1},{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2173,2173,0.1,40,0,6],[2,8,9,1481,1481,6.1,21,0,6],[3,7,9,1911,1911,18.1,54,0,6],[4,3,12,1404,1404,0,19,0,6],[5,4,12,1364,1364,15,37,32,6],[6,5,12,1232,1232,20.1,60,0,6],[7,6,12,1251,1251,13.1,40,0,6],[8,9,11,100,100,0,0,0,6],[9,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":39},"ev":[["spawn",{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5,"skillIndex":2}],["deploy",5],["status",5,"talent:archet_shield",1],["fx","shield",4,12,{"id":5}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7,"skillIndex":1}],["deploy",7],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":1911,"uid":3,"skillIndex":1}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulLine truncated {"version":1,"name":"normal-m04","title":"常规作战 · 涨潮控制 · R6","kind":"normal","stageId":"act2autochess_m04","modeId":"mode_multi_normal","round":6,"wave":"act1autochess_06","tick":0.03333333333333333,"snapEvery":3,"duration":70,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m04","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_16_a","name":"蛇屠箱","tier":3,"golden":false,"spine":"char_150_snakek","avatar":"char_150_snakek","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2173,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulpis","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1481,"uid":2,"skillIndex":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":1911,"uid":3,"skillIndex":1},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7,"skillIndex":1},{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2173,2173,0.1,40,0,6],[2,8,9,1481,1481,6.1,21,0,6],[3,7,9,1911,1911,18.1,54,0,6],[4,3,12,1404,1404,0,19,0,6],[5,4,12,1364,1364,15,37,32,6],[6,5,12,1232,1232,20.1,60,0,6],[7,6,12,1251,1251,13.1,40,0,6],[8,9,11,100,100,0,0,0,6],[9,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":39},"ev":[["spawn",{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5,"skillIndex":2}],["deploy",5],["status",5,"talent:archet_shield",1],["fx","shield",4,12,{"id":5}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7,"skillIndex":1}],["deploy",7],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":1911,"uid":3,"skillIndex":1}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulLine truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"unite-m01","title":"联防 · 源石流发生装置 · R9","kind":"unite","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":9,"wave":"act1autochess_escaped_multi","tick":0.03333333333333333,"snapEvery":3,"duration":60,"recordedAt":"2026-09-27","field":{"fieldId":"u","kind":"unite","rect":{"r0":9,"r1":12,"c0":0,"c1":20},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1414,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":6,"skillIndex":2},{"id":7,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_11_a","name":"塞雷娅","tier":5,"golden":false,"spine":"char_202_demkni","avatar":"char_202_demkni","x":17,"y":9,"facing":1,"dir":"RIGHT","maxHp":2268,"uid":101,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_2_16_a","name":"拉普兰德","tier":2,"golden":false,"spine":"char_140_whitew","avatar":"char_140_whitew","x":16,"y":9,"facing":1,"dir":"RIGHT","maxHp":1791,"uid":102,"skillIndex":1},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":12,"y":10,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":103,"skillIndex":2},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":12,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":104,"skillIndex":1},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":12,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":105,"skillIndex":2},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":13,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":106,"skillIndex":2}]},"frames":[{"snap":{"fieldId":"u","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,1414,1414,23.1,40,0,6],[3,4,10,1472,1472,20.1,44,0,6],[4,4,11,1400,1400,0.1,26,0,6],[5,4,12,1145,1145,0.1,60,0,6],[6,7,9,2022,2022,55.1,90,0,6],[7,17,9,2268,2268,0.1,9,0,6],[8,16,9,1791,1791,0,27,0,6],[9,12,10,1428,1428,56.1,110,0,6],[10,12,11,1271,1271,70.1,100,0,6],[11,12,12,1217,1217,6.1,27,0,6],[12,13,12,1208,1208,20.1,45,0,6]],"dp":11,"killed":0,"total":40,"dps":{"P1":11,"P2":10}},"ev":[["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":3,"skillIndex":2}],["deploy",3],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":6,"skillIndex":2}],["deploy",6],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"cheLine truncated {"version":1,"name":"unite-m01","title":"联防 · 源石流发生装置 · R9","kind":"unite","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":9,"wave":"act1autochess_escaped_multi","tick":0.03333333333333333,"snapEvery":3,"duration":60,"recordedAt":"2026-09-27","field":{"fieldId":"u","kind":"unite","rect":{"r0":9,"r1":12,"c0":0,"c1":20},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1414,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":6,"skillIndex":2},{"id":7,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_11_a","name":"塞雷娅","tier":5,"golden":false,"spine":"char_202_demkni","avatar":"char_202_demkni","x":17,"y":9,"facing":1,"dir":"RIGHT","maxHp":2268,"uid":101,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_2_16_a","name":"拉普兰德","tier":2,"golden":false,"spine":"char_140_whitew","avatar":"char_140_whitew","x":16,"y":9,"facing":1,"dir":"RIGHT","maxHp":1791,"uid":102,"skillIndex":1},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":12,"y":10,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":103,"skillIndex":2},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":12,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":104,"skillIndex":1},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":12,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":105,"skillIndex":2},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":13,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":106,"skillIndex":2}]},"frames":[{"snap":{"fieldId":"u","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,1414,1414,23.1,40,0,6],[3,4,10,1472,1472,20.1,44,0,6],[4,4,11,1400,1400,0.1,26,0,6],[5,4,12,1145,1145,0.1,60,0,6],[6,7,9,2022,2022,55.1,90,0,6],[7,17,9,2268,2268,0.1,9,0,6],[8,16,9,1791,1791,0,27,0,6],[9,12,10,1428,1428,56.1,110,0,6],[10,12,11,1271,1271,70.1,100,0,6],[11,12,12,1217,1217,6.1,27,0,6],[12,13,12,1208,1208,20.1,45,0,6]],"dp":11,"killed":0,"total":40,"dps":{"P1":11,"P2":10}},"ev":[["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":3,"skillIndex":2}],["deploy",3],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":6,"skillIndex":2}],["deploy",6],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"cheLine truncated
+3 -2
View File
@@ -457,11 +457,12 @@ function fxScene(view, stageId) {
case 'dp': return { ...base, id: 3, n: 10 }; case 'dp': return { ...base, id: 3, n: 10 };
case 'element': return { ...base, id: 21, element: ['burn', 'neural', 'apoptosis'][i % 3] }; case 'element': return { ...base, id: 21, element: ['burn', 'neural', 'apoptosis'][i % 3] };
case 'shell': return { id: 2, r: 1.5, t: 0.3, i: 0 }; // 蕾缪安 (id = the shooter) shelling an enemy case 'shell': return { id: 2, r: 1.5, t: 0.3, i: 0 }; // 蕾缪安 (id = the shooter) shelling an enemy
case 'flame': return { id: 1, target: 20, r: 1, n: 1, dur: 1 }; // 炎佑's jet (id = the dragon) onto its target
default: return k === 'lock' ? { id: 21, src: 2 } : FX_KINDS[k].pt ? { id: 2, r: 1.5 } : base; default: return k === 'lock' ? { id: 21, src: 2 } : FX_KINDS[k].pt ? { id: 2, r: 1.5 } : base;
} }
}; };
// `pt` kinds name the shooter in `id` and happen at an enemy's spot // `pt` kinds name the shooter in `id` and happen at an enemy's spot; a 'flame' happens at its target
const posFor = (k, ex) => unitAt(FX_KINDS[k].pt ? [21, 23, 20][i % 3] : ex.id); const posFor = (k, ex) => unitAt(FX_KINDS[k].pt ? [21, 23, 20][i % 3] : FX_KINDS[k].a === 'flame' ? ex.target : ex.id);
// 蕾缪安 S3 as the sim plays it (sim/content/kits/tier6.js): a lock every 0.5 s, then after the skill one shell every // 蕾缪安 S3 as the sim plays it (sim/content/kits/tier6.js): a lock every 0.5 s, then after the skill one shell every
// 0.3 s on the locks in order, each landing 0.3 s later with its bombard — queued [game time, event] // 0.3 s on the locks in order, each landing 0.3 s later with its bombard — queued [game time, event]
const queue = []; const queue = [];
+57 -9
View File
@@ -11,8 +11,17 @@
// - Channels: master → { bgm, sfx } gains; volumes from settings (0..1) + mute. Tab hidden ⇒ suspend. // - Channels: master → { bgm, sfx } gains; volumes from settings (0..1) + mute. Tab hidden ⇒ suspend.
// - BGM: `intro` then `loop` (1 s crossfade); switching tracks fades out/in (0.8 s). The same loop URL // - BGM: `intro` then `loop` (1 s crossfade); switching tracks fades out/in (0.8 s). The same loop URL
// keeps playing across phases (prep and combat share a track). // keeps playing across phases (prep and combat share a track).
// - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds, // - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds, at most
// a per-unit cooldown and a per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable. // MAX_PER_URL overlapping copies of one sound (the official banks' maxSoundAllowed 2), a per-unit cooldown and a
// per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable.
// - Impact sounds (user playtest #4 item 6): a 'dmg' plays the `hit` sound of the unit whose hostile attack ('atk' on a
// unit of the other side) aimed at the target — once, within IMPACT_WINDOW_MS, and only for phys / arts / true damage.
// A heal "attack" ('atk' of a healer on an ally, chain heals) never makes the healer the author of the next damage
// on that ally (纯烬艾雅法拉's heals made every later hit on a healed ally ring her impact sound), element gauge fills
// and DoTs play none, and a chain bounce ('chain' / 'chainHeal': its first id is the previous target) plays no attack
// sound of that target. An operator's attack / hit sound that is a skill-mode file of its own (official names end in
// `_n` for the normal attack, `_d` / `_h` / `_s` for its skill modes — the manifest picked 纯烬艾雅法拉's S3 impact
// p_imp_gtshpbrnch_s as her `hit`) never plays for a normal attack (normalAttackSfx).
// - Deaths/deployments follow the official per-class defaults (unitSoundClass): only operators play the // - Deaths/deployments follow the official per-class defaults (unitSoundClass): only operators play the
// operator-knocked-down sound; summons use the token sounds; a summon used up by its own effect (fx `consumed`, // operator-knocked-down sound; summons use the token sounds; a summon used up by its own effect (fx `consumed`,
// 香槟炸弹) plays its impact sound instead of a death sound. // 香槟炸弹) plays its impact sound instead of a death sound.
@@ -26,9 +35,18 @@ import { PHASE } from '../../shared/constants.js';
const MAX_VOICES = 8; const MAX_VOICES = 8;
const UNIT_COOLDOWN_MS = 160; const UNIT_COOLDOWN_MS = 160;
const URL_GAP_MS = 45; const URL_GAP_MS = 45;
const MAX_PER_URL = 2;
const BUFFER_CACHE = 180; const BUFFER_CACHE = 180;
const XFADE_S = 1; const XFADE_S = 1;
const FADE_S = 0.8; const FADE_S = 0.8;
/** 'atk' projectile kinds whose first id is the previous bounce target (sim ai.js), not the attacker. */
const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
/** 'dmg' types that are an attack's impact (element gauge fills / 元素伤害 carry the element's name instead). */
const IMPACT_TYPES = new Set(['phys', 'arts', 'true']);
/** A 'dmg' later than this (real ms) after the attack aimed at the target is not that attack's impact. */
const IMPACT_WINDOW_MS = 2500;
/** Official operator sound files of a skill mode: `…_d` / `…_h` / `…_s` (+ digits) — the normal attack's end in `_n`. */
const SKILL_MODE_FILE = /_(d|h|s)\d*\.mp3$/i;
// ---- pure helpers (unit-tested) ----------------------------------------------------------------------- // ---- pure helpers (unit-tested) -----------------------------------------------------------------------
@@ -133,29 +151,45 @@ export function deploySfxUrl(manifest, info) {
return typeof url === 'string' ? url : null; return typeof url === 'string' ? url : null;
} }
/**
* Whether a unit's manifest `attack` / `hit` sound may play for its normal attacks: an operator's (`char_*`) sound file
* of one of its skill modes (`_d` / `_h` / `_s`, see header) may not. Enemy files use `_h` for heavy weapons (always
* allowed), and so may summons.
* @param {string} defId the unit's model id (sfx.units key)
* @param {string} url
*/
export function normalAttackSfx(defId, url) {
return typeof url === 'string' && !(typeof defId === 'string' && defId.startsWith('char_') && SKILL_MODE_FILE.test(url));
}
/** Concurrency + cooldown gate for battle SFX. Pure (time is passed in). */ /** Concurrency + cooldown gate for battle SFX. Pure (time is passed in). */
/** Gestures that may unlock audio: iOS Safari only accepts touchend / click / keydown; pointerdown covers the rest. */ /** Gestures that may unlock audio: iOS Safari only accepts touchend / click / keydown; pointerdown covers the rest. */
const UNLOCK_EVENTS = ['pointerdown', 'touchend', 'click', 'keydown']; const UNLOCK_EVENTS = ['pointerdown', 'touchend', 'click', 'keydown'];
export class SfxLimiter { export class SfxLimiter {
/** @param {{ maxVoices?: number, unitCooldownMs?: number, urlGapMs?: number }} [o] */ /** @param {{ maxVoices?: number, unitCooldownMs?: number, urlGapMs?: number, maxPerUrl?: number }} [o] */
constructor(o = {}) { constructor(o = {}) {
this.maxVoices = o.maxVoices ?? MAX_VOICES; this.maxVoices = o.maxVoices ?? MAX_VOICES;
this.unitCooldownMs = o.unitCooldownMs ?? UNIT_COOLDOWN_MS; this.unitCooldownMs = o.unitCooldownMs ?? UNIT_COOLDOWN_MS;
this.urlGapMs = o.urlGapMs ?? URL_GAP_MS; this.urlGapMs = o.urlGapMs ?? URL_GAP_MS;
// the official battle banks (attack, impact, heal, born, dead…) allow at most 2 overlapping copies of a sound
// (audio_data maxSoundAllowed 2): a heal / impact heard on every tick of a crowd never piles up
this.maxPerUrl = o.maxPerUrl ?? MAX_PER_URL;
this.active = 0; this.active = 0;
this.lastByUnit = new Map(); this.lastByUnit = new Map();
this.lastByUrl = new Map(); this.lastByUrl = new Map();
this.activeByUrl = new Map();
} }
/** /**
* Whether a sound may start now; records it when allowed (call `release()` when it ends). * Whether a sound may start now; records it when allowed (call `release(url)` when it ends).
* @param {number} now ms * @param {number} now ms
* @param {string|number|null} unitKey e.g. `${unitId}:atk` * @param {string|number|null} unitKey e.g. `${unitId}:atk`
* @param {string} url * @param {string} url
*/ */
tryAcquire(now, unitKey, url) { tryAcquire(now, unitKey, url) {
if (this.active >= this.maxVoices) return false; if (this.active >= this.maxVoices) return false;
if ((this.activeByUrl.get(url) || 0) >= this.maxPerUrl) return false;
if (unitKey != null) { if (unitKey != null) {
const t = this.lastByUnit.get(unitKey); const t = this.lastByUnit.get(unitKey);
if (t != null && now - t < this.unitCooldownMs) return false; if (t != null && now - t < this.unitCooldownMs) return false;
@@ -167,10 +201,16 @@ export class SfxLimiter {
if (this.lastByUnit.size > 600) this.lastByUnit.clear(); if (this.lastByUnit.size > 600) this.lastByUnit.clear();
if (this.lastByUrl.size > 400) this.lastByUrl.clear(); if (this.lastByUrl.size > 400) this.lastByUrl.clear();
this.active += 1; this.active += 1;
this.activeByUrl.set(url, (this.activeByUrl.get(url) || 0) + 1);
return true; return true;
} }
release() { this.active = Math.max(0, this.active - 1); } /** A sound started by tryAcquire ended. */
release(url) {
this.active = Math.max(0, this.active - 1);
const n = this.activeByUrl.get(url) || 0;
if (n <= 1) this.activeByUrl.delete(url); else this.activeByUrl.set(url, n - 1);
}
} }
// ---- manager ----------------------------------------------------------------------------------------------- // ---- manager -----------------------------------------------------------------------------------------------
@@ -195,7 +235,7 @@ export class AudioManager {
this.bgm = null; // { key, loopUrl, nodes: [{src, gain}], gain } this.bgm = null; // { key, loopUrl, nodes: [{src, gain}], gain }
this.bgmToken = 0; this.bgmToken = 0;
this.units = new Map(); // battle unit id → defId this.units = new Map(); // battle unit id → defId
this.lastAttacker = new Map(); // target id → attacker defId this.lastAttacker = new Map(); // target id → { def, at } of the hostile attack last aimed at it (its impact sound)
this.consumed = new Set(); // summons used up by their own effect (香槟炸弹 exploded): no death sound this.consumed = new Set(); // summons used up by their own effect (香槟炸弹 exploded): no death sound
this.installed = false; this.installed = false;
this._unlock = this._unlock.bind(this); this._unlock = this._unlock.bind(this);
@@ -454,7 +494,7 @@ export class AudioManager {
if (limited) { if (!this.limiter.tryAcquire(now, unitKey, url)) return; } if (limited) { if (!this.limiter.tryAcquire(now, unitKey, url)) return; }
else if (this.uiVoices >= 12) return; else if (this.uiVoices >= 12) return;
else this.uiVoices += 1; else this.uiVoices += 1;
const release = () => { if (limited) this.limiter.release(); else this.uiVoices = Math.max(0, this.uiVoices - 1); }; const release = () => { if (limited) this.limiter.release(url); else this.uiVoices = Math.max(0, this.uiVoices - 1); };
this._buffer(url).then((buf) => { this._buffer(url).then((buf) => {
if (!buf || !this.ctx) { release(); return; } if (!buf || !this.ctx) { release(); return; }
try { try {
@@ -507,6 +547,7 @@ export class AudioManager {
const own = kind === 'skill' && Number.isInteger(skillIndex) && u?.skills ? u.skills[skillIndex] : null; const own = kind === 'skill' && Number.isInteger(skillIndex) && u?.skills ? u.skills[skillIndex] : null;
const url = typeof own === 'string' ? own : u?.[kind]; const url = typeof own === 'string' ? own : u?.[kind];
if (typeof url !== 'string') return false; if (typeof url !== 'string') return false;
if ((kind === 'attack' || kind === 'hit') && !normalAttackSfx(defId, url)) return false;
this._play(url, { volume: kind === 'attack' || kind === 'hit' ? 0.55 : 0.8, limited: true, unitKey: `${unitId}:${kind}` }); this._play(url, { volume: kind === 'attack' || kind === 'hit' ? 0.55 : 0.8, limited: true, unitKey: `${unitId}:${kind}` });
return true; return true;
} catch { return false; } } catch { return false; }
@@ -542,18 +583,25 @@ export class AudioManager {
handleBattleEvents(ev) { handleBattleEvents(ev) {
if (!this.ctx || !Array.isArray(ev)) return; if (!this.ctx || !Array.isArray(ev)) return;
try { try {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
for (const e of ev) { for (const e of ev) {
if (!Array.isArray(e)) continue; if (!Array.isArray(e)) continue;
const kind = e[0]; const kind = e[0];
if (kind === 'spawn') { this._track(e[1]); continue; } if (kind === 'spawn') { this._track(e[1]); continue; }
if (kind === 'atk') { if (kind === 'atk') {
// a chain bounce: its first id is the previous target, whose attack sound this is not (see header)
if (CHAIN_KINDS.has(e[3])) { this.lastAttacker.delete(e[2]); continue; }
const src = this.units.get(e[1]); const src = this.units.get(e[1]);
if (!src) continue; if (!src) continue;
this.lastAttacker.set(e[2], src.def); // only a hostile attack authors the target's next impact (a heal — an ally aiming at an ally — never does)
const tgt = this.units.get(e[2]);
if (tgt && tgt.side !== src.side) this.lastAttacker.set(e[2], { def: src.def, at: now });
if (!this.unit(src.def, 'attack', e[1]) && src.side === 'enemy') this.battle('enemyHit', { unitKey: `${e[1]}:atk`, volume: 0.35 }); if (!this.unit(src.def, 'attack', e[1]) && src.side === 'enemy') this.battle('enemyHit', { unitKey: `${e[1]}:atk`, volume: 0.35 });
} else if (kind === 'dmg') { } else if (kind === 'dmg') {
const by = this.lastAttacker.get(e[1]); const by = this.lastAttacker.get(e[1]);
if (by) this.unit(by, 'hit', `h${e[1]}`); if (!by || !IMPACT_TYPES.has(e[3])) continue;
this.lastAttacker.delete(e[1]); // one impact per attack
if (now - by.at <= IMPACT_WINDOW_MS) this.unit(by.def, 'hit', `h${e[1]}`);
} else if (kind === 'heal') { } else if (kind === 'heal') {
this.battle('heal', { unitKey: `heal:${e[1]}`, volume: 0.35 }); this.battle('heal', { unitKey: `heal:${e[1]}`, volume: 0.35 });
} else if (kind === 'skill' && e[2]) { } else if (kind === 'skill' && e[2]) {
+33
View File
@@ -32,12 +32,19 @@
// battleRunner.on('snap' | 'ev' | 'field' | 'state', fn) → off // battleRunner.on('snap' | 'ev' | 'field' | 'state', fn) → off
// battleRunner.state() → { battleId, fieldId, kind, authoritative, watch, done, own, members, loading, paused, leaks } | null // battleRunner.state() → { battleId, fieldId, kind, authoritative, watch, done, own, members, loading, paused, leaks } | null
// battleRunner.stats() → { ticks, stepMs, avgTickMs, maxFrameMs, catchups, errors, battles } // battleRunner.stats() → { ticks, stepMs, avgTickMs, maxFrameMs, catchups, errors, battles }
// battleRunner.unitStats(unitId, fieldId?) → the live stats of a unit of the battle on screen (shared/protocol.js
// unitStatsEntry: current HP, effective max HP / ATK / DEF / RES / interval / block / move
// speed next to its base) | null — the detail card reads it a few times a second (user
// playtest #4 item 7). Read-only: it takes the stats the sim computed last (`unit._s`) and
// never makes the unit recompute them, so looking never changes the battle's floats.
// battleRunner.unitIdOf(uid, ownerId, fieldId?) → the id of an own board piece's unit in that battle | null
// //
// createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader // createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader
// (test/match/runner.test.js drives it under Node). // (test/match/runner.test.js drives it under Node).
import { net as appNet } from '../net.js'; import { net as appNet } from '../net.js';
import { store as appStore } from '../store.js'; import { store as appStore } from '../store.js';
import { unitStatsEntry } from '../../../shared/protocol.js';
const TICK = 1 / 30; const TICK = 1 / 30;
/** Fast-forward budget per frame (ticks) when far behind. */ /** Fast-forward budget per frame (ticks) when far behind. */
@@ -561,6 +568,32 @@ export function createBattleRunner(deps) {
stats() { stats() {
return { ...stats, avgTickMs: stats.ticks ? stats.stepMs / stats.ticks : 0, entries: entries.size, loadingSim: !!simP }; return { ...stats, avgTickMs: stats.ticks ? stats.stepMs / stats.ticks : 0, entries: entries.size, loadingSim: !!simP };
}, },
/**
* Live stats of unit `unitId` of the battle on screen (null: no such battle / unit, or `fieldId` names another
* field). Reads the sim's last computed stats (`unit._s`, falling back to the base) — never `unit.s`, whose lazy
* recompute would run earlier than the sim itself would run it.
* @param {number} unitId @param {string|null} [fieldId]
*/
unitStats(unitId, fieldId = null) {
const e = cur;
if (!e || !Number.isInteger(unitId) || (fieldId != null && e.fieldId !== fieldId)) return null;
let u = null;
try { u = typeof e.battle.unitById === 'function' ? e.battle.unitById(unitId) : null; } catch { u = null; }
if (!u) return null;
try { return unitStatsEntry(u, u._s || null); } catch { return null; }
},
/**
* The unit id of the board piece `uid` owned by `ownerId` in the battle on screen (an own operator's card opened in
* prep and left open into the battle turns live); null when that battle has no such unit.
* @param {number} uid @param {string} ownerId @param {string|null} [fieldId]
*/
unitIdOf(uid, ownerId, fieldId = null) {
const e = cur;
if (!e || !Number.isInteger(uid) || (fieldId != null && e.fieldId !== fieldId)) return null;
const list = Array.isArray(e.battle.allyUnits) ? e.battle.allyUnits : [];
const u = list.find((x) => x && x.uid === uid && x.ownerId === ownerId);
return u && Number.isInteger(u.id) ? u.id : null;
},
/** Re-show the current battle (the game screen remounted). */ /** Re-show the current battle (the game screen remounted). */
reshow() { if (cur) show(cur); }, reshow() { if (cur) show(cur); },
/** Test / debug hooks. */ /** Test / debug hooks. */
+103 -144
View File
@@ -8,28 +8,26 @@
// view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] } // view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] }
// view.pushSnapshot(snap); view.pushEvents(ev | { ev, gt }) b.snap / b.ev wire frames as received (game time in // view.pushSnapshot(snap); view.pushEvents(ev | { ev, gt }) b.snap / b.ev wire frames as received (game time in
// `gt`; a numeric `t` is accepted for raw Battle snapshots / recordings) // `gt`; a numeric `t` is accepted for raw Battle snapshots / recordings)
// — 100 ms interpolation buffer // — 100 ms interpolation buffer; b.snap `down` keeps knocked-out
// operators on the field under a redeploy ring and `elem` draws the
// element gauges (user playtest #4 items 8 / 9, render/units.js)
// view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat): // view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat):
// ~2-frame buffer at the battle's game speed // ~2-frame buffer at the battle's game speed
// view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'| // view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'|
// 'hover'|'target'|{color,fill,line,group}; highlightTiles(null) clears all // 'hover'|'target'|{color,fill,line,group}; highlightTiles(null) clears all
// view.on(name, fn) → unsubscribe ; view.off(name, fn) // view.on(name, fn) → unsubscribe ; view.off(name, fn)
// pieceDragStart { uid, piece, from } · pieceDrop { uid, piece, from, target, over } · pieceDragEnd {uid, dropped, over} // pieceDragStart { uid, piece, from } · pieceDrop { uid, piece, from, target } · pieceDragEnd {uid, dropped}
// pieceDragOver { uid, over, clientX, clientY } — while an item is dragged: `over` = uid of the own operator /
// summon under the dragged plate (null: none), sent when it changes, BEFORE the drag asks canPlace
// pieceClick { uid, piece, button, detail, clientX, clientY } (battle units: { unitId, uid, unit, … }) // pieceClick { uid, piece, button, detail, clientX, clientY } (battle units: { unitId, uid, unit, … })
// pieceDetail (right-click / long-press) · pieceHover { uid } | { uid: null } (battle: + unitId, unit) // pieceDetail (right-click / long-press) · pieceHover { uid } | { uid: null } (battle: + unitId, unit)
// tileHover { row, col, area, idx } | null (while dragging: the drop target, the tile under the ghost's feet) // tileHover { row, col, area, idx } | null (while dragging: the drop target — the tile under the pointer)
// view.pieceScreenRect(uid) → { left, top, right, bottom, width, height, x, y, shape } (client px) | null; // view.pieceScreenRect(uid) → { left, top, right, bottom, width, height, x, y } (client px: the drawn body) | null
// `shape` = the body for render/pick.js (ui/gameLogic.js pickPieceAt) // Picking (user playtest #4 item 1: the ground is drawn as tiles — a press on a tile is a press on the unit standing
// Picking (user playtest #3 item 7): every "which unit is under the pointer" — prep press / drag start / detail // there): every "which unit is under the pointer" — prep press / click / detail / drag start / hover (pieceAt), battle
// (pieceAt), battle clicks and hover (battleUnitAt), the pen (penUnitAt), the operator under a dragged item // clicks and hover (battleUnitAt), the pen (penUnitAt) — is render/pick.js over the tile under the pointer (groundTile:
// (pieceUnder) — is render/pick.js pickBody over the views' drawn bodies (UnitView.pickShape) and the tile under the // raised tops first); battle enemies, which walk between tiles, by their ground position (flying ones by their drawn
// pointer, with a pixel probe (drawnAt: a view rendered alone into a 1×1 target) wherever bodies overlap: the front-most // body). A dragged piece is held under the pointer — a unit with its drawn feet DRAG_HOLD_TILES below it (the pointer
// unit whose model draws the pixel under the pointer wins; elsewhere the face / torso of a unit is its own, then the // on its body), an item plate centred on it — and drops on the tile under the pointer (render/drag.js); an item
// unit standing on the pressed tile. A dragged // dropped on a unit's tile equips that unit.
// piece's ghost stands on its drop point — the pointer (mouse / pen) or TOUCH_LIFT_TILES above the finger (touch) —
// and the drop target is the tile under it (render/drag.js dropPoint).
// Direction step (ui/facingWheel.js, research 09 §1.2): // Direction step (ui/facingWheel.js, research 09 §1.2):
// view.tileScreen(row, col) → { x, y, s, poly: [[x,y]×4] } (client px: tile-top centre, px per tile, corners) | null // view.tileScreen(row, col) → { x, y, s, poly: [[x,y]×4] } (client px: tile-top centre, px per tile, corners) | null
// view.holdPiece(uid, {row,col} | null) keep a dropped prep piece standing on a tile while its direction is // view.holdPiece(uid, {row,col} | null) keep a dropped prep piece standing on a tile while its direction is
@@ -95,7 +93,7 @@ import { BoardScene } from './board3d/scene.js';
import { AREAS, areaFor, unionAreas } from './board3d/layout.js'; import { AREAS, areaFor, unionAreas } from './board3d/layout.js';
import { layoutPen, penSignature } from './pen.js'; import { layoutPen, penSignature } from './pen.js';
import { IDENTITY, bossPrepField, tilesToDisp } from './prepfield.js'; import { IDENTITY, bossPrepField, tilesToDisp } from './prepfield.js';
import { pickBody } from './pick.js'; import { pickOnTile, pickBattle } from './pick.js';
const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' }; const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' };
const PIECE_DIRS = new Set(['UP', 'RIGHT', 'DOWN', 'LEFT']); const PIECE_DIRS = new Set(['UP', 'RIGHT', 'DOWN', 'LEFT']);
@@ -112,20 +110,26 @@ const RANGE_GROUPS = new Set(['facing', 'range', 'rangeStand', 'select', 'sel',
/** atk projectile kinds whose first id is the previous bounce target (sim ai.js), not the attacker. */ /** atk projectile kinds whose first id is the previous bounce target (sim ai.js), not the attacker. */
const CHAIN_KINDS = new Set(['chain', 'chainHeal']); const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
const DROP_PENDING_MS = 1300; const DROP_PENDING_MS = 1300;
/** A touch drag holds its ghost — and so its drop point — this many tiles above the finger (neither is hidden). */ /**
export const TOUCH_LIFT_TILES = 0.6; * A dragged unit is held with its drawn feet this many tiles below the pointer — the pointer on its body, the model
* under the finger / mouse (user playtest #4 item 1: as in v2.1; mouse and touch alike). An item plate is centred on it.
*/
export const DRAG_HOLD_TILES = 0.45;
/** The display tile a view stands on (its drawn position). */ /**
const viewTile = (v) => ({ row: Math.round(v.y), col: Math.round(v.x) }); * A view as a render/pick.js unit, or null when it cannot be picked (gone, faded out, dead): standing on the display
/** A view's body for render/pick.js, or null when it cannot be picked (gone, faded out, dead). */ * tile it is drawn on, or — `walks` (battle enemies) — by its ground position and its drawn body (feet to head, on
function pickShapeOf(v, tile) { * screen; a flying one by its body alone).
if (!v || v.destroyed || typeof v.pickShape !== 'function' || v.alive === false) return null; */
function pickUnitOf(v, walks = false) {
// a knocked-out operator lying on its tile waiting to redeploy (b.snap `down`, user playtest #4 item 9) is on that tile
// too: a press there selects it; other dead / dying views are gone
if (!v || v.destroyed || (v.alive === false && !v.down)) return null;
if (Number.isFinite(v.alpha) && v.alpha < 0.05) return null; if (Number.isFinite(v.alpha) && v.alpha < 0.05) return null;
const b = v.pickShape(); const sc = v.screen;
if (!b) return null; const body = walks && sc && sc.s > 0 && !v.culled ? { x: sc.x, top: Number.isFinite(sc.top) ? sc.top : sc.y, feet: sc.y, s: sc.s } : null;
b.tile = tile === undefined ? viewTile(v) : tile; const tile = walks ? null : { row: Math.round(v.y), col: Math.round(v.x) };
b.ref = v; return { tile, x: v.x, y: v.y, fly: walks && !!v.flying, body, depth: v.root && !v.root.destroyed ? v.root.zIndex : 0, ref: v };
return b;
} }
let pixiPromise = null; let pixiPromise = null;
@@ -921,12 +925,12 @@ export async function createFieldView(host, options = {}) {
penList = null; penList = null;
} }
/** The pen enemy under a canvas point (render/pick.js; the figures idle on their pen tiles). */ /** The pen enemy under a canvas point: a figure on the tile under it (render/pick.js; ≤ 3 idle per pen tile). */
function penUnitAt(x, y) { function penUnitAt(x, y) {
if (penHidden) return null; if (penHidden) return null;
const bodies = []; const units = [];
for (const v of penViews.values()) { const b = pickShapeOf(v); if (b) bodies.push(b); } for (const v of penViews.values()) { const u = pickUnitOf(v); if (u) units.push(u); }
const hit = pickBody(bodies, x, y, groundTile(x, y), probeAt(x, y)); const hit = pickOnTile(units, groundTile(x, y));
return hit ? hit.ref : null; return hit ? hit.ref : null;
} }
@@ -937,100 +941,28 @@ export async function createFieldView(host, options = {}) {
// ---- drag & pointer ---------------------------------------------------------------------------------------- // ---- drag & pointer ----------------------------------------------------------------------------------------
/** The display tile under a canvas point (raised tops first), or null off the grid. */ /** The display tile under a canvas point (raised tops first) and the point on its top (world x, y), or null. */
function groundTile(x, y) { function groundTile(x, y) {
const t = pickTile(cam, x, y, heightAt, tiles.levels); const t = pickTile(cam, x, y, heightAt, tiles.levels);
return t ? { row: t.row, col: t.col } : null; return t ? { row: t.row, col: t.col, x: t.x, y: t.y } : null;
} }
// pixel probe of render/pick.js: one view's display tree rendered alone into a 1×1 target over the canvas pixel /** The prep piece under a canvas point: the one on the tile under it (render/pick.js; board, bench and temp rows). */
let probeRT = null; function pieceAt(x, y) {
let probeM = null;
/**
* Does view `v` draw an opaque pixel at canvas point (x, y) — as last rendered (its world transforms of the last
* frame: what the player sees)? null when it cannot tell (no WebGL readback, lost context…). Only asked where two or
* more bodies overlap (render/pick.js probeBodies), i.e. a few 1-px renders per press / hover move there.
*/
function drawnAt(v, x, y) {
const root = v && v.root;
if (!root || root.destroyed) return false;
if (!root.visible || !(root.worldAlpha > 0.02)) return false;
try {
const R = app.renderer;
if (!R || !R.extract || typeof R.extract.pixels !== 'function' || !P.RenderTexture || !P.Matrix) return null;
if (!probeRT) probeRT = P.RenderTexture.create({ width: 1, height: 1, resolution: 1 });
if (!probeM) probeM = new P.Matrix();
probeM.set(1, 0, 0, 1, 0.5 - x, 0.5 - y); // the pixel centred on the point
R.render(root, { renderTexture: probeRT, clear: true, transform: probeM, skipUpdateTransform: true });
const px = R.extract.pixels(probeRT);
return px && px.length >= 4 ? px[3] > 40 : null;
} catch { return null; }
}
const probeAt = (x, y) => (b) => drawnAt(b.ref, x, y);
// Hover picks (mouse moves over the field, item drags) probe at most once per frame: every readback waits for the
// GPU and a mouse reports up to 1000 moves per second, so a second hover pick in the same frame gets the shape rule
// alone (it only moves a highlight). Presses, releases and drops always probe.
let hoverProbeFrame = -1;
const hoverProbeAt = (x, y) => {
if (hoverProbeFrame === frameNo) return null;
hoverProbeFrame = frameNo;
return probeAt(x, y);
};
/** The prep piece entry under a canvas point (render/pick.js), among the entries `accept` lets through. */
function prepEntryAt(x, y, accept = null, hover = false) {
const bodies = [];
for (const e of prepPieces) {
if (accept && !accept(e)) continue;
const b = pickShapeOf(views.get(e.key));
if (b) { b.entry = e; bodies.push(b); }
}
const hit = pickBody(bodies, x, y, groundTile(x, y), hover ? hoverProbeAt(x, y) : probeAt(x, y));
return hit ? hit.entry : null;
}
function pieceAt(x, y, hover = false) {
if (mode !== 'prep') return null; if (mode !== 'prep') return null;
const best = prepEntryAt(x, y, null, hover); const units = [];
for (const e of prepPieces) {
const u = pickUnitOf(views.get(e.key));
if (u) { u.entry = e; units.push(u); }
}
const best = pickOnTile(units, groundTile(x, y))?.entry;
if (!best) return null; if (!best) return null;
return { uid: best.uid, kind: best.piece.kind, id: best.piece.id, area: best.area, idx: best.idx, row: best.row, col: best.col, piece: best.piece, draggable: true }; return { uid: best.uid, kind: best.piece.kind, id: best.piece.id, area: best.area, idx: best.idx, row: best.row, col: best.col, piece: best.piece, draggable: true };
} }
/** uid of the own operator / summon under a canvas point (not `exceptUid`): where a dragged item would go. */
function pieceUnder(x, y, exceptUid, hover = false) {
if (mode !== 'prep') return null;
const e = prepEntryAt(x, y, (en) => en.uid !== exceptUid && (en.piece.kind === 'chess' || en.piece.kind === 'token'), hover);
return e ? e.uid : null;
}
// the current drag's drop point and the own unit under it (items): { uid, over, x, y }
let dragHit = null;
/**
* Drop point of a dragged piece (render/drag.js dropPoint): the pointer, or TOUCH_LIFT_TILES above a finger. For an
* item it also finds the operator under the plate and tells the UI (pieceDragOver) before the drag asks canPlace
* (a move is a hover pick; the release (`final`) always probes).
*/
function dropPoint(e, piece, final = false) {
let x = e.x, y = e.y;
if (e.pointerType === 'touch') {
const g = cam.unproject(x, y, 0);
y -= TOUCH_LIFT_TILES * (g ? cam.scaleAt(g.x, g.y, 0) : cam.scale);
}
const uid = piece ? piece.uid : null;
const over = piece && piece.kind === 'item' ? pieceUnder(x, y, uid, !final) : null;
const changed = !dragHit || dragHit.uid !== uid || dragHit.over !== over;
dragHit = { uid, over, x, y };
if (changed && piece && piece.kind === 'item') {
const r = canvas.getBoundingClientRect();
emit('pieceDragOver', { uid, over, clientX: r.left + x, clientY: r.top + y });
}
return { x, y };
}
const drag = createDragController({ const drag = createDragController({
hitPiece: (x, y, hover) => pieceAt(x, y, !!hover), hitPiece: (x, y) => pieceAt(x, y),
pickTile: (x, y) => pickBoardTile(x, y), pickTile: (x, y) => pickBoardTile(x, y),
dropPoint: (e, piece, final) => dropPoint(e, piece, !!final),
isOverCanvas: (cx, cy) => { isOverCanvas: (cx, cy) => {
try { const el = document.elementFromPoint(cx, cy); return !el || el === canvas; } catch { return true; } try { const el = document.elementFromPoint(cx, cy); return !el || el === canvas; } catch { return true; }
}, },
@@ -1044,10 +976,8 @@ export async function createFieldView(host, options = {}) {
function onDragEvent(name, payload) { function onDragEvent(name, payload) {
const out = payload && payload.piece ? { ...payload, piece: publicPiece(payload.piece) } : payload; const out = payload && payload.piece ? { ...payload, piece: publicPiece(payload.piece) } : payload;
// the own unit under a dropped item (render/pick.js at the drop point; null for units / nothing there)
if ((name === 'pieceDrop' || name === 'pieceDragEnd') && out && typeof out === 'object') out.over = dragHit && dragHit.uid === payload.uid ? dragHit.over : null;
switch (name) { switch (name) {
case 'pieceDragStart': dragHit = null; startDragVisual(payload); break; case 'pieceDragStart': startDragVisual(payload); break;
case 'pieceDragMove': moveDragVisual(payload); return; // internal case 'pieceDragMove': moveDragVisual(payload); return; // internal
case 'pieceDrop': dropDragVisual(payload); break; case 'pieceDrop': dropDragVisual(payload); break;
case 'pieceDragEnd': if (!payload.dropped) endDragVisual(true); else clearHl('dragTarget'); break; case 'pieceDragEnd': if (!payload.dropped) endDragVisual(true); else clearHl('dragTarget'); break;
@@ -1111,11 +1041,18 @@ export async function createFieldView(host, options = {}) {
function moveDragVisual(p) { function moveDragVisual(p) {
if (!dragState) return; if (!dragState) return;
const v = dragState.view; const v = dragState.view;
// the ghost stands (an item: floats centred) on its drop point — the tile the drop goes to is the one under it // held under the pointer: a unit with its drawn (lifted) feet DRAG_HOLD_TILES of its own px per tile below it (the
const hx = Number.isFinite(p.hx) ? p.hx : p.x, hy = Number.isFinite(p.hy) ? p.hy : p.y; // scale at the pointer's ground, refined once at the feet), an item plate centred on it; the drop target
const t = pickTile(cam, hx, hy, heightAt, tiles.levels); // (p.target, highlighted below) is the tile under the pointer
const item = v instanceof ItemView;
const g = cam.unproject(p.x, p.y, 0);
const hold = item ? 0 : DRAG_HOLD_TILES;
const s0 = g ? cam.scaleAt(g.x, g.y, 0) : cam.scale;
const t = pickTile(cam, p.x, p.y + hold * s0, heightAt, tiles.levels);
const z = t ? heightAt(t.row, t.col) : 0; const z = t ? heightAt(t.row, t.col) : 0;
const w = cam.unproject(hx, hy, z); const up = item ? z : z + (v.lift || 0) + (v.hover || 0);
let w = cam.unproject(p.x, p.y + hold * s0, up);
if (w && hold) w = cam.unproject(p.x, p.y + hold * cam.scaleAt(w.x, w.y, up), up) || w;
if (w) { if (w) {
v.x = Math.max(-1, Math.min(21, w.x)); v.x = Math.max(-1, Math.min(21, w.x));
v.y = Math.max(-1, Math.min(19, w.y)); v.y = Math.max(-1, Math.min(19, w.y));
@@ -1167,15 +1104,15 @@ export async function createFieldView(host, options = {}) {
return { pointerId: e.pointerId, pointerType: e.pointerType, button: e.button, x: p.x, y: p.y, clientX: e.clientX, clientY: e.clientY }; return { pointerId: e.pointerId, pointerType: e.pointerType, button: e.button, x: p.x, y: p.y, clientX: e.clientX, clientY: e.clientY };
} }
/** The battle unit under a canvas point (render/pick.js; allies stand on their tiles, enemies walk: no tile). */ /** The battle unit under a canvas point (render/pick.js: an ally on the tile under it, an enemy near its ground). */
function battleUnitAt(x, y, hover = false) { function battleUnitAt(x, y) {
const bodies = []; const units = [];
for (const v of views.values()) { for (const v of views.values()) {
if (!v.info || v.info.kind === 'device') continue; if (!v.info || v.info.kind === 'device') continue;
const b = pickShapeOf(v, v.info.side === 'ally' ? viewTile(v) : null); const u = pickUnitOf(v, v.info.side !== 'ally');
if (b) bodies.push(b); if (u) units.push(u);
} }
const hit = pickBody(bodies, x, y, groundTile(x, y), hover ? hoverProbeAt(x, y) : probeAt(x, y)); const hit = pickBattle(units, groundTile(x, y), x, y);
return hit ? hit.ref : null; return hit ? hit.ref : null;
} }
@@ -1206,7 +1143,7 @@ export async function createFieldView(host, options = {}) {
const ev = evPayload(e); const ev = evPayload(e);
if (mode === 'battle') { if (mode === 'battle') {
if (e.pointerType === 'touch') return; if (e.pointerType === 'touch') return;
const v = battleUnitAt(ev.x, ev.y, true); const v = battleUnitAt(ev.x, ev.y);
if (v !== hoverUnit) { if (v !== hoverUnit) {
hoverUnit = v; hoverUnit = v;
const info = v ? infos.get(v.id) || v.info : null; const info = v ? infos.get(v.id) || v.info : null;
@@ -1220,12 +1157,18 @@ export async function createFieldView(host, options = {}) {
const onPointerCancel = (e) => { if (!destroyed) drag.pointerCancel(evPayload(e)); }; const onPointerCancel = (e) => { if (!destroyed) drag.pointerCancel(evPayload(e)); };
const onPointerLeave = (e) => { if (!destroyed && !drag.dragging) drag.pointerLeave(evPayload(e)); if (hoverUnit) { hoverUnit = null; emit('pieceHover', { uid: null, unitId: null }); } }; const onPointerLeave = (e) => { if (!destroyed && !drag.dragging) drag.pointerLeave(evPayload(e)); if (hoverUnit) { hoverUnit = null; emit('pieceHover', { uid: null, unitId: null }); } };
const onContext = (e) => e.preventDefault(); const onContext = (e) => e.preventDefault();
// A finger is handled through the pointer events above only. The compatibility mouse events + click of a tap come
// after touchend, hit-tested at the finger again — where the tap may just have opened DOM UI: a tap on a unit's tile
// selects it and its underframe opens over the tile (clamped under the top bar on a phone), and the click pressed
// 撤退 / 出售 (user playtest #4 item 1 on a phone). Cancelling touchend drops them.
const onTouchEnd = (e) => { if (e.cancelable) e.preventDefault(); };
canvas.addEventListener('pointerdown', onPointerDown); canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove); canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp); canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerCancel); canvas.addEventListener('pointercancel', onPointerCancel);
canvas.addEventListener('pointerleave', onPointerLeave); canvas.addEventListener('pointerleave', onPointerLeave);
canvas.addEventListener('contextmenu', onContext); canvas.addEventListener('contextmenu', onContext);
canvas.addEventListener('touchend', onTouchEnd, { passive: false });
// ---- battle --------------------------------------------------------------------------------------------- // ---- battle ---------------------------------------------------------------------------------------------
@@ -1436,6 +1379,7 @@ export async function createFieldView(host, options = {}) {
} }
let renderT0Battle = null; // game time of the first rendered battle frame (spawn puffs skip the initial wave) let renderT0Battle = null; // game time of the first rendered battle frame (spawn puffs skip the initial wave)
let downSeq = 0; // syncBattle pass counter: a view still marked down after a pass left the `down` list
function syncBattle(renderT) { function syncBattle(renderT) {
if (renderT0Battle == null) renderT0Battle = renderT; if (renderT0Battle == null) renderT0Battle = renderT;
interp.sample(renderT, sample); interp.sample(renderT, sample);
@@ -1448,13 +1392,36 @@ export async function createFieldView(host, options = {}) {
if (!v) v = battleView(id) || createUnknown(id, s); if (!v) v = battleView(id) || createUnknown(id, s);
if (!v) continue; if (!v) continue;
if (!v._seen) { v._seen = true; v.fadeIn = 0; } if (!v._seen) { v._seen = true; v.fadeIn = 0; }
if (v.alive || v.info?.kind === 'device') v.sync(s); if (v.alive || v.info?.kind === 'device') v.sync(s, renderT);
else if (v.dying > 0) { v.x = s.x; v.y = s.y; } else if (v.dying > 0) { v.x = s.x; v.y = s.y; }
else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s); } else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s, renderT); }
}
// knocked-out operators waiting to redeploy (b.snap `down`, user playtest #4 item 9): their view stays on the
// field knocked down under a redeploy ring (UnitView.setDown) — made on the spot for one already down when this
// field was entered; one that leaves the list without a redeploy fades out
const down = interp.downAt(renderT);
downSeq++;
if (down) {
for (const d of down) {
let v = views.get(d[0]);
let fresh = false;
if (!v) {
const info = infos.get(d[0]);
if (!info || info.side !== 'ally' || info.kind === 'device') continue;
gone.delete(d[0]);
v = battleView(d[0]);
fresh = true;
}
if (!v || typeof v.setDown !== 'function') continue;
v._seen = true;
v.setDown(d, renderT, fresh);
v._downSeq = downSeq;
}
} }
const now = performance.now(); const now = performance.now();
for (const [id, v] of views) { for (const [id, v] of views) {
if (sample.has(id)) continue; if (v.down && v._downSeq !== downSeq) v.setDown(null, renderT);
if (sample.has(id) || v.down) continue;
if (v._seen) { if (v._seen) {
// left the snapshot: removed (leaked / hidden / died and its DIE window passed) // left the snapshot: removed (leaked / hidden / died and its DIE window passed)
if (v.alive) { v.alive = false; v.dying = 0.25; v.dieDur = 0.25; } if (v.alive) { v.alive = false; v.dying = 0.25; v.dieDur = 0.25; }
@@ -1654,14 +1621,7 @@ export async function createFieldView(host, options = {}) {
const b = v.bounds(); const b = v.bounds();
const r = canvas.getBoundingClientRect(); const r = canvas.getBoundingClientRect();
const left = r.left + b.x, top = r.top + b.y; const left = r.left + b.x, top = r.top + b.y;
// the drawn body in client px (render/pick.js, ui/gameLogic.js pickPieceAt) return { left, top, right: left + b.width, bottom: top + b.height, width: b.width, height: b.height, x: left, y: top };
let shape = null;
if (typeof v.pickShape === 'function') {
const s = v.pickShape();
const hud = s.hud ? { x0: s.hud.x0 + r.left, y0: s.hud.y0 + r.top, x1: s.hud.x1 + r.left, y1: s.hud.y1 + r.top } : null;
shape = { ...s, x: s.x + r.left, y: s.y + r.top, hud };
}
return { left, top, right: left + b.width, bottom: top + b.height, width: b.width, height: b.height, x: left, y: top, shape };
}, },
/** /**
* Screen geometry of a BOARD-space tile (in the Final Assault prep: where that board tile is drawn on the boss * Screen geometry of a BOARD-space tile (in the Final Assault prep: where that board tile is drawn on the boss
@@ -1736,6 +1696,7 @@ export async function createFieldView(host, options = {}) {
canvas.removeEventListener('pointercancel', onPointerCancel); canvas.removeEventListener('pointercancel', onPointerCancel);
canvas.removeEventListener('pointerleave', onPointerLeave); canvas.removeEventListener('pointerleave', onPointerLeave);
canvas.removeEventListener('contextmenu', onContext); canvas.removeEventListener('contextmenu', onContext);
canvas.removeEventListener('touchend', onTouchEnd);
app.ticker.remove(frame); app.ticker.remove(frame);
app.ticker.remove(preRender); app.ticker.remove(preRender);
app.ticker.remove(postRender); app.ticker.remove(postRender);
@@ -1746,8 +1707,6 @@ export async function createFieldView(host, options = {}) {
try { fx.destroy(); } catch { /* ignore */ } try { fx.destroy(); } catch { /* ignore */ }
try { tiles.destroy(); } catch { /* ignore */ } try { tiles.destroy(); } catch { /* ignore */ }
try { impostors.destroy(); } catch { /* ignore */ } try { impostors.destroy(); } catch { /* ignore */ }
try { probeRT?.destroy(true); } catch { /* ignore */ }
probeRT = null;
listeners.clear(); listeners.clear();
try { board3d?.destroy(); } catch { /* ignore */ } try { board3d?.destroy(); } catch { /* ignore */ }
board3d = null; board3d = null;
@@ -1768,8 +1727,8 @@ export async function createFieldView(host, options = {}) {
/** Dev hooks (demo / tests). */ /** Dev hooks (demo / tests). */
debug: { debug: {
app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); }, app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); },
// picking (render/pick.js) at canvas px: the prep piece / battle view / pen view / own unit under a dragged item // picking (render/pick.js) at canvas px: the prep piece / battle view / pen view there, the ground tile under it
pick: { pieceAt, battleUnitAt, penUnitAt, pieceUnder, groundTile, drawnAt }, get dragHit() { return dragHit; }, pick: { pieceAt, battleUnitAt, penUnitAt, groundTile },
}, },
}; };
return view; return view;
+13 -30
View File
@@ -9,11 +9,10 @@
// target (temp slot, enemy lane, `canPlace` false, or the piece's own bench slot) cancels the drag without a // target (temp slot, enemy lane, `canPlace` false, or the piece's own bench slot) cancels the drag without a
// pieceDrop. A board piece released on its OWN tile is a drop (when `canPlace` allows it): the UI opens the // pieceDrop. A board piece released on its OWN tile is a drop (when `canPlace` allows it): the UI opens the
// direction wheel to re-orient it in place (research 09 §1.2 — "drag the unit onto its own tile and swipe"). // direction wheel to re-orient it in place (research 09 §1.2 — "drag the unit onto its own tile and swipe").
// While dragging, the target is the tile under the dragged ghost's feet: `dropPoint(e, piece)` (app.js) gives that // Everything is judged at the pointer (user playtest #4 item 1: the ground is drawn as tiles): a press / hover is on the
// canvas point — the pointer itself (mouse / pen), a little above the finger (touch) — and the ghost is drawn standing // piece standing on the tile under it (`hitPiece`, render/pick.js), and while dragging the target, `tileHover` and the
// on it, so what the player sees is where the piece lands (user playtest #3 item 7); whether DOM UI covers the drop is // legality are that tile — an item dropped on a unit's tile goes to that unit; app.js holds the dragged ghost under
// tested there too (a lifted touch ghost stands on the bench while the finger is on the shop bar below it). Hover and // the pointer. A release over DOM UI covering the canvas (the shop bar) is 'outside' (mouse and touch alike).
// presses use the pointer.
// //
// Events emitted through `emit(name, payload)`: // Events emitted through `emit(name, payload)`:
// pieceHover { uid, piece, clientX, clientY } | { uid: null } (mouse/pen hover changes) // pieceHover { uid, piece, clientX, clientY } | { uid: null } (mouse/pen hover changes)
@@ -21,8 +20,7 @@
// pieceClick { uid, piece, button, detail, clientX, clientY } (detail: right-click / long-press) // pieceClick { uid, piece, button, detail, clientX, clientY } (detail: right-click / long-press)
// pieceDetail { uid, piece, clientX, clientY } (right-click / long-press) // pieceDetail { uid, piece, clientX, clientY } (right-click / long-press)
// pieceDragStart { uid, piece, from } // pieceDragStart { uid, piece, from }
// pieceDragMove { uid, piece, x, y, hx, hy, clientX, clientY, target, legal } (for the ghost; high frequency; // pieceDragMove { uid, piece, x, y, clientX, clientY, target, legal } (for the ghost; high frequency)
// hx, hy = the drop point)
// pieceDrop { uid, piece, from, target } // pieceDrop { uid, piece, from, target }
// pieceDragEnd { uid, piece, dropped, cancelled } // pieceDragEnd { uid, piece, dropped, cancelled }
@@ -96,11 +94,8 @@ const DEFAULTS = { threshold: 6, touchThreshold: 10, longPressMs: 480 };
/** /**
* Pointer state machine. * Pointer state machine.
* @param {{ * @param {{
* hitPiece: (x, y, hover) => (object|null), // piece under canvas point (render/pick.js rule); piece.uid required; * hitPiece: (x, y) => (object|null), // piece on the tile under canvas point (render/pick.js); piece.uid required
* // hover = a mouse move (not a press): may skip the pixel probe
* pickTile: (x, y) => ({row, col}|null), // grid tile under canvas point * pickTile: (x, y) => ({row, col}|null), // grid tile under canvas point
* dropPoint?: (e, piece, final) => ({x, y}|null), // canvas point of the dragged ghost's feet (default: the pointer);
* // final = the release (a move may skip the pixel probe)
* isOverCanvas?: (clientX, clientY) => boolean, // false when DOM UI covers the point (default true) * isOverCanvas?: (clientX, clientY) => boolean, // false when DOM UI covers the point (default true)
* canPlace?: (piece, row, col, target) => boolean, * canPlace?: (piece, row, col, target) => boolean,
* emit: (name, payload) => void, * emit: (name, payload) => void,
@@ -121,14 +116,8 @@ export function createDragController(hooks) {
const emit = (name, payload) => { try { h.emit(name, payload); } catch (err) { console.error('[drag] listener failed', err); } }; const emit = (name, payload) => { try { h.emit(name, payload); } catch (err) { console.error('[drag] listener failed', err); } };
const draggable = (piece) => !!piece && piece.draggable !== false && piece.uid != null; const draggable = (piece) => !!piece && piece.draggable !== false && piece.uid != null;
const tileAt = (x, y) => { try { return h.pickTile(x, y); } catch { return null; } }; const tileAt = (x, y) => { try { return h.pickTile(x, y); } catch { return null; } };
const pieceAt = (x, y, hover = false) => { try { return h.hitPiece(x, y, hover); } catch { return null; } }; const pieceAt = (x, y) => { try { return h.hitPiece(x, y); } catch { return null; } };
const overCanvas = (cx, cy) => { try { return h.isOverCanvas ? h.isOverCanvas(cx, cy) !== false : true; } catch { return true; } }; const overCanvas = (cx, cy) => { try { return h.isOverCanvas ? h.isOverCanvas(cx, cy) !== false : true; } catch { return true; } };
/** The drop point of a dragged piece (its ghost's feet) for pointer event `e`. */
const dropAt = (e, piece, final = false) => {
let p = null;
try { p = typeof h.dropPoint === 'function' ? h.dropPoint(e, piece, final) : null; } catch { p = null; }
return p && Number.isFinite(p.x) && Number.isFinite(p.y) ? p : { x: e.x, y: e.y };
};
function setHoverTile(tile) { function setHoverTile(tile) {
const slot = tile ? tileSlot(tile) : null; const slot = tile ? tileSlot(tile) : null;
@@ -155,18 +144,13 @@ export function createDragController(hooks) {
moveDrag(e); moveDrag(e);
} }
/** Is the drop point (not the finger) on the canvas? A touch ghost is lifted over the board while the finger may be
* on the DOM below it (the shop bar under the bench on a phone): what counts is where the piece is drawn. */
const dropOverCanvas = (e, pt) => overCanvas(e.clientX + (pt.x - e.x), e.clientY + (pt.y - e.y));
function moveDrag(e) { function moveDrag(e) {
const pt = dropAt(e, st.piece); const tile = tileAt(e.x, e.y);
const tile = tileAt(pt.x, pt.y); const over = overCanvas(e.clientX, e.clientY);
const over = dropOverCanvas(e, pt);
const slot = over && tile ? tileSlot(tile) : null; const slot = over && tile ? tileSlot(tile) : null;
const legal = !!slot && slot.area !== 'temp' && (slot.area === 'board' || !sameSlot(slot, st.from)) && isLegal(canPlace, st.piece, slot); const legal = !!slot && slot.area !== 'temp' && (slot.area === 'board' || !sameSlot(slot, st.from)) && isLegal(canPlace, st.piece, slot);
setHoverTile(over ? tile : null); setHoverTile(over ? tile : null);
emit('pieceDragMove', { uid: st.piece.uid, piece: st.piece, x: e.x, y: e.y, hx: pt.x, hy: pt.y, clientX: e.clientX, clientY: e.clientY, target: slot, legal }); emit('pieceDragMove', { uid: st.piece.uid, piece: st.piece, x: e.x, y: e.y, clientX: e.clientX, clientY: e.clientY, target: slot, legal });
} }
function endDrag(e, cancelled) { function endDrag(e, cancelled) {
@@ -175,9 +159,8 @@ export function createDragController(hooks) {
if (!s) return; if (!s) return;
let dropped = false; let dropped = false;
if (!cancelled) { if (!cancelled) {
const pt = dropAt(e, s.piece, true); const tile = tileAt(e.x, e.y);
const tile = tileAt(pt.x, pt.y); const d = resolveDrop({ piece: s.piece, from: s.from, tile, overCanvas: overCanvas(e.clientX, e.clientY), clientX: e.clientX, clientY: e.clientY, canPlace });
const d = resolveDrop({ piece: s.piece, from: s.from, tile, overCanvas: dropOverCanvas(e, pt), clientX: e.clientX, clientY: e.clientY, canPlace });
if (d.kind === 'drop') { if (d.kind === 'drop') {
dropped = true; dropped = true;
emit('pieceDrop', { uid: s.piece.uid, piece: s.piece, from: s.from, target: d.target }); emit('pieceDrop', { uid: s.piece.uid, piece: s.piece, from: s.from, target: d.target });
@@ -226,7 +209,7 @@ export function createDragController(hooks) {
pointerMove(e) { pointerMove(e) {
if (!st) { if (!st) {
if (e.pointerType === 'touch') return; if (e.pointerType === 'touch') return;
setHoverPiece(pieceAt(e.x, e.y, true), e); setHoverPiece(pieceAt(e.x, e.y), e);
setHoverTile(tileAt(e.x, e.y)); setHoverTile(tileAt(e.x, e.y));
return; return;
} }
+154 -8
View File
@@ -103,6 +103,10 @@ const BOOM_SPIN = 22, BOOM_BOW = 0.35, BOOM_MAX_T = 6;
* S3: ammo grants / reloads lock far more than the 5 base shots, ≤ 33 shells). * S3: ammo grants / reloads lock far more than the 5 base shots, ≤ 33 shells).
*/ */
const LOCK_T = 5, LOCK_FADE = 0.2, MAX_LOCKS = 48; const LOCK_T = 5, LOCK_FADE = 0.2, MAX_LOCKS = 48;
// 炎佑 fire jet: real s a jet burns past its tick's `dur` (the next 1 game s tick re-aims and extends it before it fades:
// one continuous stream), its fade-out, the most jets at once, the fire colours
const FLAME_TAIL = 0.2, FLAME_FADE = 0.15, MAX_FLAMES = 8;
const FLAME_TINTS = Object.freeze([0xffd27a, 0xffa94d, 0xff8a3d, 0xff5a2a]);
/** Bombard shell: share of its flight spent rising from the shooter, and the height it climbs / falls from (tiles). */ /** Bombard shell: share of its flight spent rising from the shooter, and the height it climbs / falls from (tiles). */
const SHELL_RISE = 0.34, SHELL_UP = 5.5; const SHELL_RISE = 0.34, SHELL_UP = 5.5;
/** 蕾缪安 S3 shell (fx 'bombardShell'); `look` 'mortar' is its own flight (_stepMortar). */ /** 蕾缪安 S3 shell (fx 'bombardShell'); `look` 'mortar' is its own flight (_stepMortar). */
@@ -198,7 +202,9 @@ export const FX_KINDS = Object.freeze({
knack: { a: 'buff', c: 0xffd45a }, bloodBattle: { a: 'buff', c: 0xff4b3e }, sword: { a: 'buff', c: 0xdfe8ff }, equip: { a: 'buff', c: 0x4ed8af }, knack: { a: 'buff', c: 0xffd45a }, bloodBattle: { a: 'buff', c: 0xff4b3e }, sword: { a: 'buff', c: 0xdfe8ff }, equip: { a: 'buff', c: 0x4ed8af },
garrisonGrant: { a: 'buff', c: 0x4ed8af }, extraAttack: { a: 'buff', c: 0xffe066 }, soul: { a: 'buff', c: 0xb36bff }, jungleSoul: { a: 'buff', c: 0x7fd37a }, garrisonGrant: { a: 'buff', c: 0x4ed8af }, extraAttack: { a: 'buff', c: 0xffe066 }, soul: { a: 'buff', c: 0xb36bff }, jungleSoul: { a: 'buff', c: 0x7fd37a },
candle: { a: 'buff', c: 0xffb347 }, mote: { a: 'buff', c: 0xfff0a8 }, ember: { a: 'buff', c: 0xff7a33 }, ignite: { a: 'buff', c: 0xff6a2a }, candle: { a: 'buff', c: 0xffb347 }, mote: { a: 'buff', c: 0xfff0a8 }, ember: { a: 'buff', c: 0xff7a33 }, ignite: { a: 'buff', c: 0xff6a2a },
flame: { a: 'buff', c: 0xff6a2a }, yanyouFlame: { a: 'buff', c: 0xff8a3d }, grow: { a: 'buff', c: 0x7fd37a }, weightlessBuff: { a: 'buff', c: 0xcfe0ff }, flame: { a: 'buff', c: 0xff6a2a }, grow: { a: 'buff', c: 0x7fd37a }, weightlessBuff: { a: 'buff', c: 0xcfe0ff },
// 炎佑 祛恶之焰: a continuous jet from the dragon (`id`) onto its locked target (`target`) + a burning disc (_flame)
yanyouFlame: { a: 'flame', c: 0xff8a3d, r: 1 },
// states // states
takeoff: { a: 'lift', c: 0xcfe0ff }, levitate: { a: 'lift', c: 0xcfe0ff }, weightless: { a: 'lift', c: 0xcfe0ff }, takeoff: { a: 'lift', c: 0xcfe0ff }, levitate: { a: 'lift', c: 0xcfe0ff }, weightless: { a: 'lift', c: 0xcfe0ff },
sleep: { a: 'sleep', c: 0xa8b6ff }, crit: { a: 'crit', c: 0xffe066 }, sleep: { a: 'sleep', c: 0xa8b6ff }, crit: { a: 'crit', c: 0xffe066 },
@@ -310,6 +316,7 @@ export class FxSystem {
this.beams.blendMode = P.BLEND_MODES.ADD; this.beams.blendMode = P.BLEND_MODES.ADD;
ctx.layers.fxAdd.addChild(this.beams); ctx.layers.fxAdd.addChild(this.beams);
this.beamList = []; this.beamList = [];
this.flames = []; // fire jets (炎佑 祛恶之焰) { src, tgt, x, y, z, r, col, t, end, disc, edge, … }
this.nums = []; this.nums = [];
this._numPools = new Map(); // bitmap font → free number records this._numPools = new Map(); // bitmap font → free number records
this.auras = new Map(); // unit id → { sprite, ring, t } this.auras = new Map(); // unit id → { sprite, ring, t }
@@ -925,6 +932,128 @@ export class FxSystem {
this.beamList.length = w; this.beamList.length = w;
} }
// ---- fire jets (炎佑 祛恶之焰) ---------------------------------------------------------------------------------
/**
* fx 'yanyouFlame' (sim/content/tokens.js yanyouKit; user playtest #4 item 12 "一段持续时间的喷火"): the sim emits one
* event per game second of the channel; each keeps the jet of dragon `srcId` burning for `dur` real s + FLAME_TAIL
* onto its locked target `tgtId` (else the spot (x, y)), so the 1 s ticks join into ONE continuous stream, with a
* burning disc of radius `r` tiles following the target. One jet per dragon (a new tick re-aims and extends it).
*/
_flame(srcId, tgtId, x, y, r, col, dur) {
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
let F = srcId != null ? this.flames.find((f) => f.src === srcId) : null;
if (!F) {
const P = this.P;
const disc = new P.Sprite(this.tex.soft);
disc.anchor.set(0.5); disc.blendMode = P.BLEND_MODES.ADD; disc.alpha = 0;
const edge = new P.Sprite(this.tex.ring);
edge.anchor.set(0.5); edge.blendMode = P.BLEND_MODES.ADD; edge.alpha = 0;
F = { src: srcId, disc, edge, t: 0, end: 0, jet: 0, fire: 0, smoke: 0, seed: Math.random() * 100 };
this.flames.push(F);
if (this.flames.length > MAX_FLAMES) this._freeFlame(this.flames.shift());
}
F.tgt = tgtId; F.x = x; F.y = y; F.z = this._groundZ(x, y); F.r = r; F.col = col;
F.end = Math.max(F.end, F.t + dur + FLAME_TAIL);
F.disc.tint = F.edge.tint = col;
}
_freeFlame(F) { F.disc.destroy(); F.edge.destroy(); }
_updateFlames(dt) {
if (!this.flames.length) return;
const cam = this.ctx.cam();
const g = this.beams; // drawn after _updateBeams cleared it
const p = this._p, q = this._q, m = this._g;
const low = this.quality === 'low';
let w = 0;
for (const F of this.flames) {
F.t += dt;
const sv = this._viewOf(F.src);
// the channel is over (no tick kept it going) or the dragon is gone
if (F.t >= F.end || (F.src != null && this.ctx.view && (!sv || sv.alive === false))) { this._freeFlame(F); continue; }
const tv = this._viewOf(F.tgt);
if (tv && tv.alive !== false && Number.isFinite(tv.x) && Number.isFinite(tv.y)) { F.x = tv.x; F.y = tv.y; F.z = tv.z || 0; }
const fade = Math.min(1, F.t / 0.12, (F.end - F.t) / FLAME_FADE);
const flick = 0.82 + 0.18 * Math.sin(F.t * 23 + F.seed) * Math.sin(F.t * 7.3);
// burning disc on the ground around the target
this._onGround(F.disc, F.y, F.z); this._onGround(F.edge, F.y, F.z);
cam.project(F.x, F.y, F.z + 0.02, p);
cam.project(F.x, F.y + F.r, F.z + 0.02, q);
const cx = p.x, cy = p.y, s = p.s;
const rx = s * F.r, ry = Math.max(1, cy - q.y);
F.disc.position.set(cx, cy); F.disc.scale.set((rx * 2) / 128, (ry * 2) / 128); F.disc.alpha = 0.5 * fade * flick;
F.edge.position.set(cx, cy); F.edge.scale.set((rx * 2.05) / 128, (ry * 2.05) / 128); F.edge.alpha = 0.7 * fade * flick;
// flame tongues and embers rising from the disc
F.fire += dt * (low ? 14 : 34) * fade;
while (F.fire >= 1) {
F.fire -= 1;
if (!this._room()) { F.fire = 0; break; }
const a = Math.random() * Math.PI * 2, d = Math.sqrt(Math.random()) * 0.9;
const px = cx + Math.cos(a) * rx * d, py = cy + Math.sin(a) * ry * d;
const ember = Math.random() < 0.3;
const po = this._o();
po.tint = ember ? 0xffd27a : FLAME_TINTS[(Math.random() * FLAME_TINTS.length) | 0];
po.vx = (Math.random() - 0.5) * s * 0.3; po.vy = -s * (ember ? 1.6 : 0.9 + Math.random() * 0.6); po.drag = ember ? 0.5 : 1.5;
po.life = ember ? 0.7 : 0.35 + Math.random() * 0.25;
po.s0 = (s / 128) * (ember ? 0.06 : 0.28 + Math.random() * 0.12); po.s1 = (s / 128) * (ember ? 0.02 : 0.08);
po.a0 = 0.9; po.a1 = 0; po.fadeIn = 0.05;
this.particle(ember ? 'dot' : 'soft', px, py, po);
}
if (this.rich) {
F.smoke += dt * 5 * fade;
while (F.smoke >= 1) {
F.smoke -= 1;
if (!this._room()) { F.smoke = 0; break; }
const po = this._o();
po.add = false; po.tint = 0x3a3430; po.vx = (Math.random() - 0.5) * s * 0.2; po.vy = -s * 0.5; po.drag = 1;
po.life = 0.9; po.s0 = (s / 128) * 0.35; po.s1 = (s / 128) * 0.8; po.a0 = 0.32; po.a1 = 0; po.fadeIn = 0.15;
this.particle('smoke', cx + (Math.random() - 0.5) * rx, cy - s * 0.2, po);
}
}
// the jet: from the dragon's chest onto the target (its chest; the disc centre without a target view)
if (sv) {
this._chest(sv, m);
const mx = m.x, my = m.y;
if (tv && tv.alive !== false) this._chest(tv, q); else { q.x = cx; q.y = cy - s * 0.15; }
const tx = q.x, ty = q.y;
const segs = 6;
for (let pass = 0; pass < 3; pass++) {
const col = pass === 2 ? 0xfff2c0 : pass === 1 ? 0xffb347 : F.col;
const alpha = (pass === 0 ? 0.3 : pass === 1 ? 0.55 : 0.8) * fade * flick;
let x0 = mx, y0 = my;
for (let i = 1; i <= segs; i++) {
const f = i / segs;
// widens towards the target (a cone of fire), wobbling
const wd = s * (pass === 0 ? 0.1 + 0.22 * f : pass === 1 ? 0.05 + 0.1 * f : 0.02 + 0.03 * f);
const j = i < segs ? Math.sin(F.seed + i * 3.1 + F.t * 30) * s * 0.05 * f : 0;
const x1 = mx + (tx - mx) * f + j, y1 = my + (ty - my) * f - j * 0.5;
g.lineStyle(Math.max(1, wd), col, alpha);
g.moveTo(x0, y0); g.lineTo(x1, y1);
x0 = x1; y0 = y1;
}
}
// fire puffs streaming along it
F.jet += dt * (low ? 18 : 42) * fade;
const T = 0.2;
while (F.jet >= 1) {
F.jet -= 1;
if (!this._room()) { F.jet = 0; break; }
const a = Math.random() * Math.PI * 2, d = Math.random() * 0.5;
const gx = tx + Math.cos(a) * rx * d, gy = ty + Math.sin(a) * ry * d;
const po = this._o();
po.tint = FLAME_TINTS[(Math.random() * FLAME_TINTS.length) | 0];
po.vx = (gx - mx) / T; po.vy = (gy - my) / T;
po.life = T * (0.9 + Math.random() * 0.3);
po.s0 = (s / 128) * 0.1; po.s1 = (s / 128) * (0.38 + Math.random() * 0.2); po.a0 = 0.85; po.a1 = 0.15;
this.particle('soft', mx, my, po);
}
}
this.flames[w++] = F;
}
this.flames.length = w;
}
// ---- lock-on reticles (蕾缪安) ------------------------------------------------------------------------------- // ---- lock-on reticles (蕾缪安) -------------------------------------------------------------------------------
/** /**
@@ -1508,10 +1637,10 @@ export class FxSystem {
/** /**
* b.ev 'fx': every kind the sim / content emits has a visual (FX_KINDS archetypes: blast, shell, zone, telegraph, * b.ev 'fx': every kind the sim / content emits has a visual (FX_KINDS archetypes: blast, shell, zone, telegraph,
* heal, sp, shield, shatter, summon, vanish, blink, move, wave, mark, reticle, buff, lift, sleep, crit, dodge, * heal, sp, shield, shatter, summon, vanish, blink, move, wave, mark, reticle, buff, lift, sleep, crit, dodge,
* counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element); unknown kinds get a generic * counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame); unknown kinds get a
* sparkle. `extra` keys used: id (anchor unit — or the shooter of a `pt` kind), r | radius, dur | duration, t (shell * generic sparkle. `extra` keys used: id (anchor unit — or the shooter of a `pt` kind), r | radius, dur | duration,
* flight, game s), src / from / to / targets (unit ids), fx, fy / fromX, fromY / tx, ty (positions), element, n, * t (shell flight, game s), src / from / to / target / targets (unit ids), fx, fy / fromX, fromY / tx, ty (positions),
* scale, kind, tiles. * element, n, scale, kind, tiles.
*/ */
simFx(kind, x, y, extra) { simFx(kind, x, y, extra) {
const ex = extra && typeof extra === 'object' ? extra : {}; const ex = extra && typeof extra === 'object' ? extra : {};
@@ -1720,6 +1849,12 @@ export class FxSystem {
else this.strike(at.x, at.y, at.z, col); else this.strike(at.x, at.y, at.z, col);
break; break;
} }
case 'flame': {
// at the event's spot (the locked target), not snapped onto the dragon hovering 0.25 tile from it
const fx0 = Number(x), fy0 = Number(y);
this._flame(ex.id ?? ex.src ?? null, ex.target ?? ex.to ?? null, fx0, fy0, r, col, Math.max(0.2, dur || 0.5));
break;
}
case 'strike': case 'pillar': this.strike(at.x, at.y, at.z, col, spec.a === 'pillar'); break; case 'strike': case 'pillar': this.strike(at.x, at.y, at.z, col, spec.a === 'pillar'); break;
case 'volley': { case 'volley': {
const list = Array.isArray(ex.targets) ? ex.targets.slice(0, 12) : []; const list = Array.isArray(ex.targets) ? ex.targets.slice(0, 12) : [];
@@ -1938,9 +2073,17 @@ export class FxSystem {
glow.anchor.set(0.5); glow.tint = tint; glow.blendMode = P.BLEND_MODES.ADD; glow.scale.set(0.9); glow.anchor.set(0.5); glow.tint = tint; glow.blendMode = P.BLEND_MODES.ADD; glow.scale.set(0.9);
const sp = new P.Sprite(icon); const sp = new P.Sprite(icon);
sp.anchor.set(0.5); sp.anchor.set(0.5);
const k = 46 / Math.max(1, Math.max(icon.width, icon.height));
sp.scale.set(k);
sp.tint = tint; sp.tint = tint;
// 46 px along its longer side. A bond icon (app.js 'layer': PIXI.Texture.from(url)) is a 1×1 placeholder until
// its image has loaded: sized from that it was drawn 46× too big, ~5000 px over the whole screen for the pop's
// 1.4 s (user playtest #4 item 13) — so it is sized once its texture is valid, and hidden until then.
const fit = () => {
if (sp.destroyed) return;
sp.scale.set(46 / Math.max(1, icon.width, icon.height));
sp.visible = true;
};
if (icon.valid) fit();
else { sp.visible = false; icon.once('update', fit); }
c.addChild(glow, sp); c.addChild(glow, sp);
} }
if (label) { if (label) {
@@ -1989,6 +2132,8 @@ export class FxSystem {
this.pops.length = 0; this.pops.length = 0;
this.beamList.length = 0; this.beamList.length = 0;
this.beams.clear(); this.beams.clear();
for (const F of this.flames) this._freeFlame(F);
this.flames.length = 0;
this.vigT = 0; this.vigT = 0;
this.vignette.alpha = 0; this.vignette.alpha = 0;
for (const zn of this.zones) this._freeZone(zn); for (const zn of this.zones) this._freeZone(zn);
@@ -2006,6 +2151,7 @@ export class FxSystem {
this._updateProjs(dt); this._updateProjs(dt);
this._updateLocks(dt); this._updateLocks(dt);
this._updateBeams(dt); this._updateBeams(dt);
this._updateFlames(dt);
this._updateNums(dt); this._updateNums(dt);
this._updateRings(dt); this._updateRings(dt);
this._updateAuras(dt); this._updateAuras(dt);
@@ -2028,7 +2174,7 @@ export class FxSystem {
} }
get counts() { get counts() {
return { particles: this.parts.length, projectiles: this.projs.length, numbers: this.nums.length, rings: this.rings.length, auras: this.auras.size, locks: this.locks.length }; return { particles: this.parts.length, projectiles: this.projs.length, numbers: this.nums.length, rings: this.rings.length, auras: this.auras.size, locks: this.locks.length, flames: this.flames.length };
} }
destroy() { destroy() {
+44 -8
View File
@@ -18,9 +18,18 @@
// and handed out by `takeEvents()` once renderT passes the stamp. Stale cosmetic events (> `eventMaxLag` // and handed out by `takeEvents()` once renderT passes the stamp. Stale cosmetic events (> `eventMaxLag`
// game s behind) are dropped by the caller's choice (`isCosmeticEvent`); state events are always delivered. // game s behind) are dropped by the caller's choice (`isCosmeticEvent`); state events are always delivered.
// //
// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. // Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Two optional lists ride along
// (server/sim/Battle.js snapshot, user playtest #4 items 8 / 9):
// * `elem` [[id, element, fill, cooldownEnd, cooldown]] — the element gauge a unit shows: appended to that unit's
// normalised tuple (EL…EL_DUR) and handed out by sample() as `el`, `elFill`, `elUntil`, `elDur` (from the older
// snapshot, like flags);
// * `down` [[id, respawnAt, respawnTime, state]] — knocked-out operators waiting to redeploy (they are no longer in
// `units`): downAt(time) returns the list of the snapshot at `time`.
// Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT.
export const TUPLE = Object.freeze({ ID: 0, X: 1, Y: 2, HP: 3, MAXHP: 4, SP: 5, SPMAX: 6, FLAGS: 7, ANIM: 8 }); export const TUPLE = Object.freeze({ ID: 0, X: 1, Y: 2, HP: 3, MAXHP: 4, SP: 5, SPMAX: 6, FLAGS: 7, ANIM: 8, EL: 9, EL_FILL: 10, EL_UNTIL: 11, EL_DUR: 12 });
/** Element keys a snapshot `elem` entry may carry (server/sim/constants.js ELEMENT_ORDER). */
const ELEMENT_KEYS = new Set(['neural', 'erosion', 'burn', 'apoptosis', 'necrosis']);
const MAX_EVENTS = 6000; const MAX_EVENTS = 6000;
const finite = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d); const finite = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
@@ -42,8 +51,9 @@ export function frameTime(msg) {
} }
/** /**
* Validate & normalise a b.snap payload. Returns `{ t, units: Map<id, tuple>, raw }` or null when unusable. * Validate & normalise a b.snap payload. Returns `{ t, units: Map<id, tuple>, down: [[id, respawnAt, respawnTime,
* Tuples with a non-finite id/x/y are skipped; other numbers default to 0. * state]] | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default
* to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` entries are dropped.
*/ */
export function normalizeSnapshot(snap) { export function normalizeSnapshot(snap) {
if (!snap || typeof snap !== 'object') return null; if (!snap || typeof snap !== 'object') return null;
@@ -59,7 +69,21 @@ export function normalizeSnapshot(snap) {
if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) continue; if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) continue;
units.set(id, [id, x, y, finite(u[3]), finite(u[4]), finite(u[5]), finite(u[6]), finite(u[7]) | 0, finite(u[8]) | 0]); units.set(id, [id, x, y, finite(u[3]), finite(u[4]), finite(u[5]), finite(u[6]), finite(u[7]) | 0, finite(u[8]) | 0]);
} }
return { t, units, raw: snap }; if (Array.isArray(snap.elem)) {
for (const e of snap.elem) {
if (!Array.isArray(e) || !ELEMENT_KEYS.has(e[1])) continue;
const tu = units.get(e[0]);
if (tu && tu.length === 9) tu.push(e[1], clamp(finite(e[2]), 0, 1), finite(e[3]), Math.max(0, finite(e[4])));
}
}
let down = null;
if (Array.isArray(snap.down)) {
for (const d of snap.down) {
if (!Array.isArray(d) || !(typeof d[0] === 'number' || typeof d[0] === 'string')) continue;
(down || (down = [])).push([d[0], finite(d[1]), Math.max(0, finite(d[2])), finite(d[3]) | 0]);
}
}
return { t, units, down, raw: snap };
} }
export class SnapshotBuffer { export class SnapshotBuffer {
@@ -212,10 +236,21 @@ export class SnapshotBuffer {
return ans; return ans;
} }
/**
* The `down` list (knocked-out operators waiting to redeploy, see header) of the snapshot shown at `time` (default
* renderT) — the same snapshot sample() reads flags from — or null.
*/
downAt(time = this.renderT) {
const s = this.snaps;
if (!s.length || !Number.isFinite(time)) return null;
const ia = this._indexAt(time);
return s[ia < 0 ? 0 : ia].down || null;
}
/** /**
* Interpolated state at `time` (default renderT). Fills and returns `out` (a Map id → sample object reused * Interpolated state at `time` (default renderT). Fills and returns `out` (a Map id → sample object reused
* across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen }`). Samples of units no longer * across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen, el, elFill, elUntil, elDur }` —
* present are deleted from `out`. * `el` = the shown element gauge (null: none), see header). Samples of units no longer present are deleted from `out`.
*/ */
sample(time = this.renderT, out = new Map()) { sample(time = this.renderT, out = new Map()) {
const s = this.snaps; const s = this.snaps;
@@ -231,7 +266,7 @@ export class SnapshotBuffer {
const stamp = A.t; const stamp = A.t;
for (const [id, a] of A.units) { for (const [id, a] of A.units) {
let o = out.get(id); let o = out.get(id);
if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0 }; out.set(id, o); } if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0, el: null, elFill: 0, elUntil: 0, elDur: 0 }; out.set(id, o); }
const b = B ? B.units.get(id) : null; const b = B ? B.units.get(id) : null;
if (b) { if (b) {
const dx = b[1] - a[1], dy = b[2] - a[2]; const dx = b[1] - a[1], dy = b[2] - a[2];
@@ -264,6 +299,7 @@ export class SnapshotBuffer {
else if (!(o.hp > 0)) o.hp = 0; else if (!(o.hp > 0)) o.hp = 0;
o.flags = a[7]; o.flags = a[7];
o.anim = a[8]; o.anim = a[8];
if (a.length > 9) { o.el = a[9]; o.elFill = a[10]; o.elUntil = a[11]; o.elDur = a[12]; } else if (o.el !== null) { o.el = null; o.elFill = 0; o.elUntil = 0; o.elDur = 0; }
o.seen = stamp; o.seen = stamp;
} }
for (const id of out.keys()) if (!A.units.has(id)) out.delete(id); for (const id of out.keys()) if (!A.units.has(id)) out.delete(id);
+78 -207
View File
@@ -1,224 +1,95 @@
// render/pick.js — which unit the pointer is on (user playtest #3 item 7: "clicking a unit on the map often selects // render/pick.js — which unit the pointer is on (user playtest #4 item 1: "地上都画好了一个一个方格,点击对应方格就选中那个
// the one a row below it; dropping equipment too"). Pure screen-space geometry (no PIXI, no DOM), the ONE rule behind // 方格的人物就行" — the ground is drawn as tiles: a press on a tile selects the unit on that tile). Pure logic (no PIXI, no
// every picking path: prep press / drag start / right-click detail and equipment dropped on an operator // DOM): render/app.js hands in the units and the ground under the pointer (render/projection.js pickTile, raised tops
// (render/app.js pieceAt / pieceUnder, and ui/gameLogic.js pickPieceAt over view.pieceScreenRect shapes), battle // first, in the tiles the views are drawn on). The one rule behind every picking path — prep press / click / right-click
// clicks and hover (battleUnitAt) and the enemy preview pen (penUnitAt). // and long-press detail / drag start / hover (app.js pieceAt), battle clicks and hover (battleUnitAt), the enemy preview
// // pen (penUnitAt); a dragged piece (an item too) drops on the tile under the pointer (render/drag.js):
// Why "the front-most rectangle under the pointer" failed: a chibi is an upright billboard ~1.27 tiles tall (median // * prep pieces (board tiles, bench row 7, temp row 8), battle allies and the figures of the enemy pen stand on
// of the 138 official operators' Front / Back skeletons in their idle pose, p10–p90 1.17–1.41) standing on its tile, // tiles: the unit on the tile under the pointer. An empty tile selects nothing — a press on a unit's head where it is
// while the rows of the official 30° cameras are only ~0.84 (prep) – 0.91 (battle) of a tile apart on screen. The unit // drawn over the tile behind it is a press on that tile. Several on one tile (the pen's clusters of up to 3, a piece
// in front covers the legs / feet of the unit behind it and its head sits over that unit's tile; its 0.7-tile-wide // held for a moment on an occupied tile) → the one nearest to the pointer's point on the tile, ties → the front-most.
// rectangle also covered the ground and the legs showing beside its head (a head is only ~0.5 tile wide), and a // * battle enemies walk between tiles: the one within ENEMY_REACH tile of the pointer — its ground position from the
// pressed tile was only looked at when no rectangle at all was hit — so a press on the lower part of a unit, on its // pointer's point on the ground, or its drawn body (on screen: the upright line from its feet to its head, in its px
// feet or on its tile went to the unit in front of it: the one a row lower on screen. // per tile), whichever is nearer, so a press on a tall enemy's / a boss's body picks it too; a flying enemy (drawn in
// // the air, over whatever tile it crosses) by its drawn body alone.
// Body of a unit (all px on one screen; `x`, `y` = its feet, `s` = px per tile at the feet, `h` = model height in // * an ally's tile and an enemy both qualify → the nearer one (distance in tiles to the pointer's point), ties → the
// tiles, `w` = width factor (1 for operators; enemies scale with their size), `flip` = +1 facing right / −1 left). The // ally, then the front-most.
// measured opaque profile of the idle chibis is ~±0.3 tile wide through the body (the back side a little wider: hair, // (Replaces the drawn-body shapes and the 1-px render probe of v2.2, DESIGN §17.2: the player aims at the grid.)
// capes) and ~±0.2 at the crown; a taller model is taller hair / ears / hats — its face stays ~0.86 tile up:
// outline head ellipse (centre h − 0.33·w above the feet, radii 0.27·w × 0.33·w) ∪ body box (0.08 below the feet …
// the shoulders at min(0.62·h, 0.78·w); −0.34·w … +0.28·w facing right) ∪ the unit's HUD (tier chip /
// bars) — what is drawn
// core face (ellipse 0.21·w × 0.15·w centred min(0.86·w, h − 0.36·w) above the feet) ∪ torso / legs (±0.2·w, from
// 0.1 above the feet to the shoulders) — what a player aims at
// 'diamond' (the avatar diamond shown until the Spine model is there): the rhombus of side `d` tiles above the feet
// 'plate' (hand items): a square of half-size `r` tiles centred on (x, y)
// Rule (pickBody):
// 1. the pointer is on units' cores → the front-most of them (drawn on top: the face of the unit in front is its own;
// the torso of the unit behind is its own above that face)
// 2. else a unit stands on the tile under the pointer → that unit (its feet, its tile — also where the hair or a
// weapon of the unit in front hangs over it)
// 3. else the pointer is on outlines only (hair, hands, feet, weapon edges) → the unit whose core is nearest (px / its
// own scale); ties → the front-most
// The feet of a unit hidden behind the face of the unit in front are not pickable (nothing of them shows there); its
// tile beside that face and everything of it above that face are.
//
// Pixel probe (optional `probe(body)` → true / false / null, render/app.js drawnAt): the shapes above are a model of
// the drawn chibi, and where the bodies of two units overlap the model cannot tell whose arm, hair or weapon is drawn
// on top. Measured on the mock match against the rendered pixels (1920×1080, every opaque unit pixel sampled, the
// front-most drawn unit = the truth): the rule alone put 7 % of the prep presses (18 % in battle) on the wrong unit —
// mostly the unit BEHIND: the hair crown and the sides (arms, clothes, weapons) of the unit in front lie over the legs /
// tile of the one behind — against 2 % / 11 % for the old front-most rectangle; with the probe 0.7 % / 1.7 % (the rest:
// anti-aliased edges). So wherever the probe can change the answer (two or more bodies within their outline +
// PROBE_MARGIN of the pointer, or one the rule did not pick), the front-most body whose model really draws the pixel
// under the pointer wins — what the player sees. Only when none of them draws there (a gap, the bare tile) or the probe
// cannot tell (null) does the rule above decide.
/** Median model height of the official chibis (tiles above the feet), when the model's own is not known. */ /** Battle enemies are picked within this many tiles of the pointer's ground point or of their drawn body on screen. */
export const BODY_H = 1.27; export const ENEMY_REACH = 0.6;
/** Shape constants in tiles (see the header). */
export const BODY = Object.freeze({
headRx: 0.27, headRy: 0.33, faceRx: 0.21, faceRy: 0.15, face: 0.86, faceTop: 0.36, back: 0.34, front: 0.28,
below: 0.08, shoulder: 0.62, neck: 0.78, torso: 0.2, torsoFrom: 0.1, lean: 0.03,
});
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); const EPS = 1e-9;
const inRect = (r, px, py) => !!r && px >= r.x0 && px <= r.x1 && py >= r.y0 && py <= r.y1; const isTile = (t) => !!t && Number.isInteger(t.row) && Number.isInteger(t.col);
const inEllipse = (e, px, py) => ((px - e.cx) / e.rx) ** 2 + ((py - e.cy) / e.ry) ** 2 <= 1; const onTile = (u, t) => isTile(u.tile) && isTile(t) && u.tile.row === t.row && u.tile.col === t.col;
const rectDist = (r, px, py) => Math.hypot(Math.max(r.x0 - px, 0, px - r.x1), Math.max(r.y0 - py, 0, py - r.y1)); const depthOf = (u) => (u && Number.isFinite(u.depth) ? u.depth : 0);
function ellipseDist(e, px, py) { /** Tiles between a unit's ground position and the pointer's point on the ground (0 when either is unknown). */
const k = Math.hypot((px - e.cx) / e.rx, (py - e.cy) / e.ry); function groundDist(u, g) {
return k > 1 ? (k - 1) * Math.min(e.rx, e.ry) : 0; if (!g || !Number.isFinite(g.x) || !Number.isFinite(g.y) || !Number.isFinite(u.x) || !Number.isFinite(u.y)) return 0;
return Math.hypot(u.x - g.x, u.y - g.y);
} }
/** Rhombus |dx|/a + |dy|/a ≤ k around (cx, cy). */ /** Tiles (its px per tile) between screen point (px, py) and an enemy's drawn body — the line from its feet up to its head. */
const inRhombus = (d, px, py, k = 1) => Math.abs(px - d.cx) / d.a + Math.abs(py - d.cy) / d.a <= k; function bodyDist(b, px, py) {
if (!b || !(b.s > 0) || ![b.x, b.top, b.feet, px, py].every(Number.isFinite)) return Infinity;
/** const y = Math.max(Math.min(b.top, b.feet), Math.min(Math.max(b.top, b.feet), py));
* @typedef {{ x: number, y: number, s: number, h?: number, w?: number, flip?: number, kind?: 'chibi'|'diamond'|'plate', return Math.hypot(px - b.x, py - y) / b.s;
* d?: number, r?: number, depth?: number, hud?: { x0: number, y0: number, x1: number, y1: number } | null,
* tile?: { row: number, col: number } | null }} PickBody
* `depth`: draw order (larger = nearer the camera, drawn on top); `tile`: the tile it stands on (null: moving units)
*/
/**
* Screen geometry of a body: `{ outline: [parts], core: [parts] }`, parts `{ rect }` / `{ ellipse }` / `{ rhombus }`.
* @param {PickBody} b
*/
export function bodyParts(b) {
const s = b.s, x = b.x, y = b.y;
const hud = b.hud && [b.hud.x0, b.hud.y0, b.hud.x1, b.hud.y1].every(Number.isFinite) ? { rect: b.hud } : null;
if (b.kind === 'plate') {
const a = (Number(b.r) > 0 ? b.r : 0.31) * s;
return { outline: [{ rect: { x0: x - a, x1: x + a, y0: y - a, y1: y + a } }], core: [{ rect: { x0: x - a * 0.7, x1: x + a * 0.7, y0: y - a * 0.7, y1: y + a * 0.7 } }] };
}
if (b.kind === 'diamond') {
const half = ((Number(b.d) > 0 ? b.d : 0.78) / 2) * s;
const rh = { cx: x, cy: y - BODY.below * s - half, a: half };
return { outline: [{ rhombus: rh, k: 1 }, ...(hud ? [hud] : [])], core: [{ rhombus: rh, k: 0.7 }] };
}
const w = Number(b.w) > 0 ? b.w : 1;
const h = clamp(Number(b.h) > 0 ? b.h : BODY_H, 0.3, 4);
const flip = b.flip === -1 ? -1 : 1;
const cx = x - BODY.lean * flip * w * s;
const head = { cx, cy: y - (h - BODY.headRy * w) * s, rx: BODY.headRx * w * s, ry: BODY.headRy * w * s };
const face = { cx, cy: y - Math.min(BODY.face * w, h - BODY.faceTop * w) * s, rx: BODY.faceRx * w * s, ry: BODY.faceRy * w * s };
const top = y - Math.min(BODY.shoulder * h, BODY.neck * w) * s;
const box = { x0: x - (flip > 0 ? BODY.back : BODY.front) * w * s, x1: x + (flip > 0 ? BODY.front : BODY.back) * w * s, y0: top, y1: y + BODY.below * s };
const torso = { x0: cx - BODY.torso * w * s, x1: cx + BODY.torso * w * s, y0: top, y1: y - BODY.torsoFrom * Math.min(1, h) * s };
// the HUD (tier chip / bars) is drawn above every unit, but over the unit behind: an outline, never a core
return { outline: [{ ellipse: head }, { rect: box }, ...(hud ? [hud] : [])], core: [{ ellipse: face }, { rect: torso }] };
}
const partHit = (p, px, py) => (p.rect ? inRect(p.rect, px, py) : p.ellipse ? inEllipse(p.ellipse, px, py) : inRhombus(p.rhombus, px, py, p.k));
function partDist(p, px, py) {
if (p.rect) return rectDist(p.rect, px, py);
if (p.ellipse) return ellipseDist(p.ellipse, px, py);
const r = p.rhombus, k = (Math.abs(px - r.cx) + Math.abs(py - r.cy)) / r.a;
return k > p.k ? (k - p.k) * r.a * Math.SQRT1_2 : 0;
}
/** 2 = on the body's core, 1 = on its outline only, 0 = off it. */
export function hitBody(b, px, py) {
if (!b || !(b.s > 0) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return 0;
// cheap reject (battle hover tests every unit on every pointer move): far outside the body and its HUD
if (b.kind !== 'plate' && !(b.hud && inRect(b.hud, px, py))) {
const w = Number(b.w) > 0 ? b.w : 1, reach = Math.max(BODY.back * w, (Number(b.d) || 0) / 2) * b.s + 1;
const top = b.y - (b.kind === 'diamond' ? (Number(b.d) || 0.78) + BODY.below : clamp(Number(b.h) > 0 ? b.h : BODY_H, 0.3, 4)) * b.s - 1;
if (Math.abs(px - b.x) > reach || py < top || py > b.y + BODY.below * b.s + 1) return 0;
}
const parts = bodyParts(b);
if (parts.core.some((p) => partHit(p, px, py))) return 2;
return parts.outline.some((p) => partHit(p, px, py)) ? 1 : 0;
}
/** Distance (px) from a point to the body's core, divided by the body's scale (0 on the core). */
export function coreDistance(b, px, py) {
const parts = bodyParts(b);
let d = Infinity;
for (const p of parts.core) d = Math.min(d, partDist(p, px, py));
return d / b.s;
}
/** The screen bounding box of a body's outline (px): `{ x, y, width, height }`. */
export function bodyBounds(b) {
const parts = bodyParts(b);
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const p of [...parts.outline, ...parts.core]) {
if (p.rect) { x0 = Math.min(x0, p.rect.x0); x1 = Math.max(x1, p.rect.x1); y0 = Math.min(y0, p.rect.y0); y1 = Math.max(y1, p.rect.y1); }
else if (p.ellipse) { const e = p.ellipse; x0 = Math.min(x0, e.cx - e.rx); x1 = Math.max(x1, e.cx + e.rx); y0 = Math.min(y0, e.cy - e.ry); y1 = Math.max(y1, e.cy + e.ry); }
else { const r = p.rhombus, a = r.a * p.k; x0 = Math.min(x0, r.cx - a); x1 = Math.max(x1, r.cx + a); y0 = Math.min(y0, r.cy - a); y1 = Math.max(y1, r.cy + a); }
}
if (!Number.isFinite(x0)) return { x: b.x, y: b.y, width: 0, height: 0 };
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
}
const depthOf = (b) => (Number.isFinite(b.depth) ? b.depth : 0);
/** Margin (tiles) around a body's outline in which the pixel probe still looks (weapons, capes, hair, big enemies past
* the modelled outline; 0.25 left 4 % of the battle presses wrong, 0.5 1.7 %). */
export const PROBE_MARGIN = 0.5;
/** At most this many overlapping bodies are probed (front-most first). */
const PROBE_MAX = 6;
/** Bodies whose outline (+ PROBE_MARGIN) contains the point, front-most first. */
export function probeCandidates(bodies, px, py) {
const out = [];
for (const b of bodies) {
if (!b || !(b.s > 0) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) continue;
const r = bodyBounds({ ...b, hud: null }), m = PROBE_MARGIN * b.s;
if (px >= r.x - m && px <= r.x + r.width + m && py >= r.y - m && py <= r.y + r.height + m) out.push(b);
}
return out.sort((a, b) => depthOf(b) - depthOf(a));
} }
/** /**
* Pixel probe step of pickBody: the front-most body around the point whose model draws the pixel under it. Asked only * @typedef {{ row: number, col: number, x?: number, y?: number }} PickGround the tile under the pointer and the pointer's
* when it can change the answer: two or more bodies there, or one that the shape rule did not pick (`rule`: e.g. its * point on its top (world tiles: x = col, y = row), or null off the grid
* arm over the tile of a unit standing behind it). null when none of them draws there or the probe cannot tell for one * @typedef {{ tile?: { row: number, col: number } | null, x?: number, y?: number, depth?: number, fly?: boolean,
* in front of the first that does (then the shape rule decides). * body?: { x: number, top: number, feet: number, s: number } | null }} PickUnit
* `tile`: the tile it stands on (null: it walks — a battle enemy); `x`, `y`: its ground position (world tiles);
* `body`: an enemy's drawn body on screen (px: x, head `top`, `feet`; `s` px per tile there); `fly`: in the air — its
* ground position does not count; `depth`: draw order (larger = in front)
*/ */
function probeBodies(bodies, px, py, probe, rule) {
const cand = probeCandidates(bodies, px, py); /**
if (!cand.length || (cand.length === 1 && cand[0] === rule)) return null; * The unit standing on the tile under the pointer (several → the nearest to the pointer's point, ties → the front-most).
for (const b of cand.slice(0, PROBE_MAX)) { * @param {Array<PickUnit|null>} units
let d = null; * @param {PickGround|null} ground
try { d = probe(b); } catch { d = null; } * @returns {PickUnit|null} one of `units`
if (d === true) return b; */
if (d !== false) return null; export function pickOnTile(units, ground) {
} if (!Array.isArray(units) || !isTile(ground)) return null;
return null;
}
const onTile = (b, t) => !!t && !!b.tile && b.tile.row === t.row && b.tile.col === t.col;
function nearest(list, px, py) {
let best = null, bd = Infinity; let best = null, bd = Infinity;
for (const b of list) { for (const u of units) {
const d = coreDistance(b, px, py); if (!u || !onTile(u, ground)) continue;
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && depthOf(b) > depthOf(best))) { best = b; bd = d; } const d = groundDist(u, ground);
if (d < bd - EPS || (Math.abs(d - bd) <= EPS && depthOf(u) > depthOf(best))) { best = u; bd = d; }
} }
return best; return best;
} }
/** /**
* The body the pointer is on (see the header for the rule), or null. * Battle pick (see the header): allies on the tile under the pointer, enemies within ENEMY_REACH of it; the nearer wins.
* @param {Array<PickBody|null>} bodies * @param {Array<PickUnit|null>} units allies with `tile`, enemies with `tile: null` and their drawn `body`
* @param {number} px * @param {PickGround|null} ground
* @param {number} px pointer on screen (enemies' drawn bodies)
* @param {number} py * @param {number} py
* @param {{ row: number, col: number } | null} [ground] the tile under the pointer (same space as the bodies' `tile`) * @returns {PickUnit|null} one of `units`
* @param {((body: PickBody) => (boolean|null)) | null} [probe] does this body's model draw the pixel under the pointer
* (null: cannot tell)? See the header.
* @returns {PickBody|null} one of `bodies`
*/ */
export function pickBody(bodies, px, py, ground = null, probe = null) { export function pickBattle(units, ground, px, py) {
if (!Number.isFinite(px) || !Number.isFinite(py) || !Array.isArray(bodies)) return null; if (!Array.isArray(units)) return null;
const rule = ruleBody(bodies, px, py, ground); const onGround = !!ground && Number.isFinite(ground.x) && Number.isFinite(ground.y);
if (typeof probe === 'function') { let best = null, bd = Infinity;
const drawn = probeBodies(bodies, px, py, probe, rule); const better = (u, d) => {
if (drawn) return drawn; if (d < bd - EPS) return true;
if (Math.abs(d - bd) > EPS) return false;
if (!!u.tile !== !!best.tile) return !!u.tile; // a tie: the ally standing there
return depthOf(u) > depthOf(best);
};
for (const u of units) {
if (!u) continue;
let d = Infinity;
if (u.tile) { if (onTile(u, ground)) d = groundDist(u, ground); }
else {
d = bodyDist(u.body, px, py);
if (!u.fly && onGround && Number.isFinite(u.x) && Number.isFinite(u.y)) d = Math.min(d, Math.hypot(u.x - ground.x, u.y - ground.y));
if (!(d <= ENEMY_REACH)) continue;
}
if (d < Infinity && better(u, d)) { best = u; bd = d; }
} }
return rule; return best;
}
/** The shape rule of pickBody (the header's steps 1–3). */
function ruleBody(bodies, px, py, ground) {
let core = null;
const rims = [];
for (const b of bodies) {
const hit = hitBody(b, px, py);
if (hit === 2) { if (!core || depthOf(b) > depthOf(core)) core = b; }
else if (hit === 1) rims.push(b);
}
if (core) return core;
const standing = ground ? bodies.filter((b) => b && b.s > 0 && onTile(b, ground)) : [];
if (standing.length) return nearest(standing, px, py);
return rims.length ? nearest(rims, px, py) : null;
} }
+128
View File
@@ -6,6 +6,7 @@
// renderer swaps the texture without touching geometry, // renderer swaps the texture without touching geometry,
// * FX atlas (one base texture ⇒ particles batch / fit a ParticleContainer), // * FX atlas (one base texture ⇒ particles batch / fit a ParticleContainer),
// * status icons, tier chips, backdrop gradients, // * status icons, tier chips, backdrop gradients,
// * HUD rings: element gauge icons and the redeploy countdown ring (hudRings / ringArc),
// * avatar-in-rarity-diamond composites (cached per unit asset id) for the Spine fallback. // * avatar-in-rarity-diamond composites (cached per unit asset id) for the Spine fallback.
// Everything procedural is deterministic (seeded noise) and generated lazily on first use. Requires // Everything procedural is deterministic (seeded noise) and generated lazily on first use. Requires
// globalThis.PIXI and a DOM canvas at call time (never at import time). // globalThis.PIXI and a DOM canvas at call time (never at import time).
@@ -1156,3 +1157,130 @@ export function itemTexture(key, img, color) {
_items.set(k, tex); _items.set(k, tex);
return tex; return tex;
} }
// =============================================================================================================
// HUD rings (user playtest #4 items 8 and 9): the element gauge icon of a unit (PRTS 元素: "模型下部会显示对应的元素
// 图标,并以白条显示剩余的元素值" — operators: the element's disc with its glyph; enemies: "小尺寸图标(不显示元素图标,
// 仅根据元素种类改变背景色)") inside a white ring of the remaining 元素值, and the redeploy countdown ring above a
// knocked-out operator. One 512×576 atlas: RING_STEPS + 1 arc frames (white, clockwise from 12 o'clock) and the discs.
// Official element colours / glyphs after the client icons (图标 元素 sanity / water / fire / dark), drawn procedurally
// (the local-client extraction has no general battle HUD sprites).
/** Arc frames: `arcs[k]` covers k / RING_STEPS of the circle. */
export const RING_STEPS = 48;
/** Element disc colours (official icons: 神经 teal, 侵蚀 steel blue, 灼燃 rust, 凋亡 charcoal) and glyph colours. */
export const ELEMENT_RING = Object.freeze({
neural: Object.freeze({ disc: '#13806c', glyph: '#bfe6df', tint: 0x1fae93 }),
erosion: Object.freeze({ disc: '#2d5d86', glyph: '#cfdcea', tint: 0x4a8cc4 }),
burn: Object.freeze({ disc: '#8f3512', glyph: '#f0cdbd', tint: 0xe0632c }),
apoptosis: Object.freeze({ disc: '#363338', glyph: '#bdb8c2', tint: 0x9c8fb0 }),
necrosis: Object.freeze({ disc: '#363338', glyph: '#bdb8c2', tint: 0x9c8fb0 }),
});
const RING_KEYS = Object.keys(ELEMENT_RING);
const RC = 64; // cell px
const RING_R = 26, RING_W = 6.5, DISC_R = 21.5;
let _rings = null;
function drawElementGlyph(c, el, cx, cy, col) {
c.fillStyle = col; c.strokeStyle = col; c.lineCap = 'round'; c.lineJoin = 'round';
if (el === 'neural') { // concentric target
c.lineWidth = 2.4;
c.beginPath(); c.arc(cx, cy, 12, 0, Math.PI * 2); c.stroke();
c.beginPath(); c.arc(cx, cy, 7, 0, Math.PI * 2); c.stroke();
c.beginPath(); c.arc(cx, cy, 2.6, 0, Math.PI * 2); c.fill();
} else if (el === 'erosion') { // a big and a small water drop over a wave
const drop = (x, y, r) => {
c.beginPath(); c.moveTo(x, y - r * 2.1);
c.bezierCurveTo(x + r * 0.5, y - r * 1.2, x + r, y - r * 0.5, x + r, y + r * 0.1);
c.arc(x, y + r * 0.1, r, 0, Math.PI);
c.bezierCurveTo(x - r, y - r * 0.5, x - r * 0.5, y - r * 1.2, x, y - r * 2.1); c.fill();
};
drop(cx - 3, cy - 1, 5.6); drop(cx + 7.5, cy + 2, 3.1);
c.lineWidth = 2.2; c.beginPath(); c.moveTo(cx - 13, cy + 11); c.quadraticCurveTo(cx - 6.5, cy + 7, cx, cy + 11); c.quadraticCurveTo(cx + 6.5, cy + 15, cx + 13, cy + 11); c.stroke();
} else if (el === 'burn') { // flame
c.beginPath(); c.moveTo(cx, cy - 13); c.quadraticCurveTo(cx + 11, cy - 1, cx + 7, cy + 9); c.quadraticCurveTo(cx, cy + 14, cx - 7, cy + 9);
c.quadraticCurveTo(cx - 11, cy - 1, cx - 2, cy - 5); c.quadraticCurveTo(cx - 1, cy - 9, cx, cy - 13); c.fill();
c.globalCompositeOperation = 'destination-out';
c.beginPath(); c.moveTo(cx, cy - 1); c.quadraticCurveTo(cx + 5, cy + 5, cx + 2, cy + 9); c.quadraticCurveTo(cx - 2, cy + 11, cx - 4, cy + 7); c.quadraticCurveTo(cx - 4, cy + 3, cx, cy - 1); c.fill();
c.globalCompositeOperation = 'source-over';
} else { // 凋亡: an eye-like lozenge
c.lineWidth = 2.2;
c.beginPath(); c.moveTo(cx - 13, cy); c.lineTo(cx, cy - 8); c.lineTo(cx + 13, cy); c.lineTo(cx, cy + 8); c.closePath(); c.stroke();
c.beginPath(); c.moveTo(cx - 6, cy); c.lineTo(cx, cy - 3.5); c.lineTo(cx + 6, cy); c.lineTo(cx, cy + 3.5); c.closePath(); c.fill();
}
}
/**
* The HUD ring atlas: `{ arcs: Texture[RING_STEPS + 1], track, disc: { [element]: Texture }, discEnemy: { [element] },
* downDisc }` — every frame RC px square, centred on the ring centre (anchor 0.5). Built once, lazily.
*/
export function hudRings() {
if (_rings) return _rings;
const P = PIXI();
const W = 8 * RC, rowsArc = Math.ceil((RING_STEPS + 1) / 8), H = (rowsArc + 2) * RC;
const canvas = makeCanvas(W, H);
const c = canvas.getContext('2d');
const cell = (i) => [(i % 8) * RC, Math.floor(i / 8) * RC];
const frames = { arcs: [] };
// arcs: white with a thin dark edge (readable on light and dark ground)
for (let k = 0; k <= RING_STEPS; k++) {
const [x, y] = cell(k);
frames.arcs.push([x, y]);
if (!k) continue;
const cx = x + RC / 2, cy = y + RC / 2, a0 = -Math.PI / 2, a1 = a0 + (Math.PI * 2 * k) / RING_STEPS;
c.lineCap = k === RING_STEPS ? 'butt' : 'round';
c.strokeStyle = 'rgba(0,0,0,0.55)'; c.lineWidth = RING_W + 2.5;
c.beginPath(); c.arc(cx, cy, RING_R, a0, a1); c.stroke();
c.strokeStyle = '#ffffff'; c.lineWidth = RING_W;
c.beginPath(); c.arc(cx, cy, RING_R, a0, a1); c.stroke();
}
const row = rowsArc * RC;
// row A: ally discs (with glyph), the track ring, the dark disc of the redeploy ring
RING_KEYS.forEach((el, i) => {
const x = i * RC, cx = x + RC / 2, cy = row + RC / 2;
c.fillStyle = ELEMENT_RING[el].disc; c.beginPath(); c.arc(cx, cy, DISC_R, 0, Math.PI * 2); c.fill();
c.strokeStyle = 'rgba(0,0,0,0.5)'; c.lineWidth = 1.5; c.stroke();
c.save(); c.beginPath(); c.arc(cx, cy, DISC_R - 1, 0, Math.PI * 2); c.clip();
drawElementGlyph(c, el, cx, cy, ELEMENT_RING[el].glyph);
c.restore();
frames['a:' + el] = [x, row];
});
{
const x = RING_KEYS.length * RC, cx = x + RC / 2, cy = row + RC / 2;
c.strokeStyle = 'rgba(12,16,15,0.72)'; c.lineWidth = RING_W + 2.5; c.beginPath(); c.arc(cx, cy, RING_R, 0, Math.PI * 2); c.stroke();
c.strokeStyle = 'rgba(255,255,255,0.16)'; c.lineWidth = RING_W; c.beginPath(); c.arc(cx, cy, RING_R, 0, Math.PI * 2); c.stroke();
frames.track = [x, row];
const x2 = x + RC, cx2 = x2 + RC / 2;
const g = c.createRadialGradient(cx2, cy, 2, cx2, cy, RING_R + 3);
g.addColorStop(0, 'rgba(18,24,22,0.92)'); g.addColorStop(0.8, 'rgba(10,14,13,0.86)'); g.addColorStop(1, 'rgba(10,14,13,0)');
c.fillStyle = g; c.beginPath(); c.arc(cx2, cy, RING_R + 3, 0, Math.PI * 2); c.fill();
frames.downDisc = [x2, row];
}
// row B: enemy discs (element colour only)
RING_KEYS.forEach((el, i) => {
const x = i * RC, cx = x + RC / 2, cy = row + RC + RC / 2;
c.fillStyle = ELEMENT_RING[el].disc; c.beginPath(); c.arc(cx, cy, DISC_R, 0, Math.PI * 2); c.fill();
c.strokeStyle = 'rgba(0,0,0,0.5)'; c.lineWidth = 1.5; c.stroke();
frames['e:' + el] = [x, row + RC];
});
const base = P.BaseTexture.from(canvas, { mipmap: P.MIPMAP_MODES.ON });
const tex = ([x, y]) => new P.Texture(base, new P.Rectangle(x, y, RC, RC));
_rings = {
base, canvas, size: RC,
arcs: frames.arcs.map(tex),
track: tex(frames.track),
downDisc: tex(frames.downDisc),
disc: Object.fromEntries(RING_KEYS.map((el) => [el, tex(frames['a:' + el])])),
discEnemy: Object.fromEntries(RING_KEYS.map((el) => [el, tex(frames['e:' + el])])),
};
return _rings;
}
/** Arc frame of a fraction 0..1 (rounded to the nearest step; a non-zero fraction never shows an empty ring). */
export function ringArc(frac) {
const r = hudRings();
const f = frac > 1 ? 1 : frac > 0 ? frac : 0;
let k = Math.round(f * RING_STEPS);
if (k === 0 && f > 0) k = 1;
return r.arcs[k];
}
+165 -73
View File
@@ -14,6 +14,17 @@
// plane). `setDir(dir)` re-orients a live view (swapping Front ⇄ Back without a fallback flash). Enemies flip by the // plane). `setDir(dir)` re-orients a live view (swapping Front ⇄ Back without a fallback flash). Enemies flip by the
// sign of their horizontal velocity (with hysteresis). // sign of their horizontal velocity (with hysteresis).
// //
// Knocked-out operators (user playtest #4 item 9, b.snap `down`): `setDown([id, respawnAt, respawnTime, state])`
// keeps a dead operator on its tile in its knocked-down pose — the Spine Die clip played once and held on its last
// frame (the collapsed / kneeling pose with closed eyes), slightly greyed — with a redeploy ring above its head:
// a dark disc, a mint arc filling as the respawn timer runs and the seconds left; once the timer is done and it still
// waits, a full amber ring with "DP" (not enough DP) or a red ring with "!" (its tile is taken). `onDeploy` (the
// redeploy) plays the deploy clip and restores the normal look; `setDown(null)` on a dead view lets it fade out.
// Element gauges (item 8, b.snap `elem` → sample `el` / `elFill` / `elUntil` / `elDur`): the official element icon
// beside the bars (PRTS 元素: operators the element's disc with its glyph, enemies a smaller plain disc of the
// element's colour) inside a white ring of the remaining 元素值 (1 − fill); during a 爆发冷却 the ring refills over
// the cooldown. Both rings are sprites of one atlas (textures.js hudRings), created on first use and hidden when idle.
//
// ItemView renders hand items as a floating icon plate; DeviceView renders battle devices (crates) as 3D boxes // ItemView renders hand items as a floating icon plate; DeviceView renders battle devices (crates) as 3D boxes
// with an HP bar once damaged. // with an HP bar once damaged.
// //
@@ -23,17 +34,15 @@
// (`ctx.loadLevel()` 1–3, app.js) small / far units (< ~56 px per tile) animate every 2nd frame, and from level 2 on // (`ctx.loadLevel()` 1–3, app.js) small / far units (< ~56 px per tile) animate every 2nd frame, and from level 2 on
// every unit does. // every unit does.
// //
// Picking (user playtest #3 item 7): `pickShape()` describes the body as drawn — feet, px per tile, the model's own // Picking is by tile (render/pick.js, user playtest #4 item 1): views carry no hit shapes; `bounds()` is the drawn body's
// height (measured once from the posed skeleton's bounds; the median chibi until then), facing, draw order and the // screen rect for tooltips and overlays (view.pieceScreenRect). A dragged (lifted) item plate is drawn centred on its
// HUD it shows (tier chip / bars) — for the shared rule of render/pick.js; `bounds()` / `hitTest()` use the same shape. // ground point, i.e. on the pointer (render/app.js).
// A dragged (lifted) item plate is drawn centred on its ground point, i.e. on the pointer (render/app.js).
import { UF, ANIM } from '../../../shared/constants.js'; import { UF, ANIM } from '../../../shared/constants.js';
import { SpineActor } from './spine.js'; import { SpineActor } from './spine.js';
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture } from './textures.js'; import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc } from './textures.js';
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, statusIconKey } from './style.js'; import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, statusIconKey } from './style.js';
import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.js'; import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.js';
import { BODY_H, bodyBounds, hitBody } from './pick.js';
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const DIRS = ['UP', 'RIGHT', 'DOWN', 'LEFT']; const DIRS = ['UP', 'RIGHT', 'DOWN', 'LEFT'];
@@ -54,6 +63,17 @@ const RAISED_Z = 0.12;
/** Flying units hover this many tiles above the ground they cross. */ /** Flying units hover this many tiles above the ground they cross. */
export const FLY_HOVER = 0.32; export const FLY_HOVER = 0.32;
/** b.snap `down` entry states (server/sim/constants.js DOWN_STATE). */
export const DOWN_STATE = Object.freeze({ COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 });
/** Knocked-down look: model tint and alpha; redeploy ring colours per state; ring size (tiles) and height. */
export const DOWN_LOOK = Object.freeze({
tint: 0xb4b4b4, alpha: 0.92,
ring: Object.freeze({ [DOWN_STATE.COUNTING]: 0x4ed8af, [DOWN_STATE.WAIT_DP]: 0xffc600, [DOWN_STATE.WAIT_TILE]: 0xff4b3e }),
size: 0.42, height: 1.02,
});
/** Element ring diameter in tiles (operators; enemies ×0.8) and its pixel clamp. */
const EL_RING = 0.3;
/** /**
* Keep `obj` in the right layer: the surface container of block row round(y) when it lies on a raised top at * Keep `obj` in the right layer: the surface container of block row round(y) when it lies on a raised top at
* height `z` (drawn after that row's blocks), else `fallback`. Reparents only on change. * height `z` (drawn after that row's blocks), else `fallback`. Reparents only on change.
@@ -132,8 +152,13 @@ export class UnitView {
this.statuses = new Set(); this.statuses = new Set();
this.alive = true; this.alive = true;
this.dying = 0; // seconds left of the death fade (0 = not dying) this.dying = 0; // seconds left of the death fade (0 = not dying)
this.dieT = 0; this.dieT = 0; // seconds since die() (the Die clip's clock; a late Spine model catches up)
this.remove = false; // set when the death fade ended (owner removes the view) this.remove = false; // set when the death fade ended (owner removes the view)
this.down = null; // knocked out, waiting to redeploy: { until, total, state } (setDown) — no fade meanwhile
this.gameT = 0; // battle game time of the frame (render clock), for the rings' countdowns
this.el = null; this.elFill = 0; this.elUntil = 0; this.elDur = 0; // shown element gauge (sync)
this._elRing = null; // { track, disc, arc } sprites, built on first use
this._downRing = null; // { disc, track, arc, text } sprites, built on first use
this.alpha = 1; this.fadeIn = this.prep ? 1 : 0; this.alpha = 1; this.fadeIn = this.prep ? 1 : 0;
this.lunge = 0; this.lungeDir = { x: 1, y: 0 }; this.lunge = 0; this.lungeDir = { x: 1, y: 0 };
this.flash = 0; this.flash = 0;
@@ -143,9 +168,6 @@ export class UnitView {
this.shake = 0; this.shake = 0;
this.screen = { x: 0, y: 0, s: 1, top: 0 }; this.screen = { x: 0, y: 0, s: 1, top: 0 };
this.destroyed = false; this.destroyed = false;
this._bodyH = null; // model height (tiles above the feet), measured once per Spine model (pickShape)
this._hudBox = null; // screen rect of the HUD shown this frame (tier chip / bars), or null
this._flip = this.visFacing; // drawn facing (±1) of the model
// --- display objects // --- display objects
this.shadow = new P.Sprite(ctx.shadowTex || shadowTexture()); this.shadow = new P.Sprite(ctx.shadowTex || shadowTexture());
@@ -253,9 +275,12 @@ export class UnitView {
this.spineReady = true; this.spineReady = true;
this.swapT = swap ? 1 : 0; this.swapT = swap ? 1 : 0;
this.actor.spine.alpha = swap ? 1 : 0; this.actor.spine.alpha = swap ? 1 : 0;
// replay current state // replay current state (a dead model resumes its Die clip where it would be — a knocked-down one holds its end)
if (!this.alive) this.actor.die(); if (!this.alive) {
else { const d = this.actor.die();
const at = Math.min(d, this.dieT * (this.ctx.animRate?.() || 1));
if (at > 0) this.actor.update(at);
} else {
if (this.flags & UF.SKILL) this.actor.setSkill(true); if (this.flags & UF.SKILL) this.actor.setSkill(true);
this.actor.setBase(this._baseFromAnim()); this.actor.setBase(this._baseFromAnim());
} }
@@ -339,9 +364,13 @@ export class UnitView {
return 'idle'; return 'idle';
} }
/** Apply an interpolated sample (render/interp.js). */ /** Apply an interpolated sample (render/interp.js); `t` = the render clock's game time (ring countdowns). */
sync(s) { sync(s, t) {
if (!s) return; if (!s) return;
if (Number.isFinite(t)) this.gameT = t;
// the element gauge shown (b.snap `elem`): element, fill 0..1, cooldown end (game s) and length
this.el = typeof s.el === 'string' ? s.el : null;
this.elFill = this.el ? s.elFill || 0 : 0; this.elUntil = this.el ? s.elUntil || 0 : 0; this.elDur = this.el ? s.elDur || 0 : 0;
this.x = s.x; this.y = s.y; this.x = s.x; this.y = s.y;
this.flying = !!(s.flags & UF.FLYING) || this.info.motion === 'FLY'; this.flying = !!(s.flags & UF.FLYING) || this.info.motion === 'FLY';
// ground enemies only ever walk low tiles (a rounding step onto a block edge must not pop them up) // ground enemies only ever walk low tiles (a rounding step onto a block edge must not pop them up)
@@ -406,7 +435,6 @@ export class UnitView {
this.imp = null; this.imp = null;
} }
this._box = null; this._box = null;
this._bodyH = null; // the next model is measured again
const old = this.actor; const old = this.actor;
this.actor = null; this.actor = null;
this.spineReady = false; this.spineReady = false;
@@ -480,10 +508,38 @@ export class UnitView {
revive() { revive() {
this.alive = true; this.alive = true;
this.dying = 0; this.remove = false; this.alpha = 1; this.dying = 0; this.remove = false; this.alpha = 1;
this.down = null;
this.hp = this.maxHp; this.ghostHp = this.hp; this.hp = this.maxHp; this.ghostHp = this.hp;
if (this.actor) this.actor.revive(); if (this.actor) this.actor.revive();
} }
/**
* Knocked-out state (b.snap `down` entry `[id, respawnAt, respawnTime, state]`, render/interp.js downAt) or null;
* `t` = the render clock's game time. A living view is knocked down first (its Die clip plays; `instant`: a view made
* for a unit that is already down — a field joined mid-battle — starts on the held end of the clip). While down the
* view never fades: the Die clip's last frame stays on the tile under the redeploy ring. null on a view still down
* (it left for good) starts the fade; the redeploy itself comes through onDeploy / revive.
*/
setDown(d, t, instant = false) {
if (Number.isFinite(t)) this.gameT = t;
if (!d) {
if (!this.down) return;
this.down = null;
if (!this.alive) { this.dying = 0.55; this.dieDur = 0.55; }
return;
}
if (this.alive) {
this.die();
if (instant) { this.dieT = 30; if (this.actor) this.actor.update(30); }
}
if (this.zTarget == null) this.z = this.zTarget = groundZ(this.ctx, this.x, this.y); // never synced: its tile top
this.dying = 0; // no death fade while down
const dn = this.down || (this.down = { until: 0, total: 0, state: DOWN_STATE.COUNTING });
dn.until = Number(d[1]) || 0;
dn.total = Math.max(0, Number(d[2]) || 0);
dn.state = d[3] | 0;
}
// ---- per frame ------------------------------------------------------------------------------------------- // ---- per frame -------------------------------------------------------------------------------------------
/** @param {number} dt real seconds @param {any} cam camera @param {number} t real clock */ /** @param {number} dt real seconds @param {any} cam camera @param {number} t real clock */
@@ -501,7 +557,11 @@ export class UnitView {
// fades // fades
if (this.fadeIn < 1) this.fadeIn = Math.min(1, this.fadeIn + dt * 4); if (this.fadeIn < 1) this.fadeIn = Math.min(1, this.fadeIn + dt * 4);
let alpha = this.fadeIn; let alpha = this.fadeIn;
if (this.dying > 0) { if (this.down) {
// knocked down: the Die clip ends and holds its last frame (the actor keeps its clock), no fade
this.dieT += dt;
alpha *= DOWN_LOOK.alpha;
} else if (this.dying > 0) {
this.dieT += dt; this.dieT += dt;
this.dying -= dt; this.dying -= dt;
const tail = 0.55; const tail = 0.55;
@@ -524,7 +584,6 @@ export class UnitView {
// off-screen: nothing to animate or draw (bounds / hit-testing still follow `screen`) // off-screen: nothing to animate or draw (bounds / hit-testing still follow `screen`)
if (this._cull(bx, by, s, dt)) return; if (this._cull(bx, by, s, dt)) return;
const flip = this.isEnemy ? (ENEMY_MODEL_FACES_LEFT ? -this.visFacing : this.visFacing) : this.visFacing; const flip = this.isEnemy ? (ENEMY_MODEL_FACES_LEFT ? -this.visFacing : this.visFacing) : this.visFacing;
this._flip = flip;
// shadow (on a raised top it is drawn with that block row, else in the shadow layer under everything) // shadow (on a raised top it is drawn with that block row, else in the shadow layer under everything)
placeOnGround(this.ctx, this.shadow, this.ctx.layers.shadow, this.y, this.z); placeOnGround(this.ctx, this.shadow, this.ctx.layers.shadow, this.y, this.z);
@@ -543,7 +602,8 @@ export class UnitView {
const sc = s * UNIT.modelScale; const sc = s * UNIT.modelScale;
const flashK = this.flash > 0 ? this.flash : 0; const flashK = this.flash > 0 ? this.flash : 0;
let tint = 0xffffff; let tint = 0xffffff;
if (this.flags & UF.FROZEN) tint = 0x9fd4ff; if (this.down) tint = DOWN_LOOK.tint;
else if (this.flags & UF.FROZEN) tint = 0x9fd4ff;
else if (this.flags & UF.COLD) tint = 0xcfe6ff; else if (this.flags & UF.COLD) tint = 0xcfe6ff;
if (flashK > 0) tint = mixTint(tint, 0xff8a80, flashK * 0.8); if (flashK > 0) tint = mixTint(tint, 0xff8a80, flashK * 0.8);
let animDt = dt * (this.ctx.animRate?.() || 1); let animDt = dt * (this.ctx.animRate?.() || 1);
@@ -569,8 +629,6 @@ export class UnitView {
this.actor.update(animDt); this.actor.update(animDt);
if (this._tint !== tint) { this._tint = tint; this.actor.spine.tint = tint; } if (this._tint !== tint) { this._tint = tint; this.actor.spine.tint = tint; }
} }
// the model's own height for picking: once, from its first resting pose (not a deploy / skill clip)
if (this._bodyH == null && this.actor.clock > 0 && this.actor.mode === 'base') this._bodyH = this._measureBody();
} }
// the diamond is only needed while no model shows (a cross-fade keeps whatever diamond was already up) // the diamond is only needed while no model shows (a cross-fade keeps whatever diamond was already up)
if (!spineShown) this._ensurePicture(); if (!spineShown) this._ensurePicture();
@@ -579,7 +637,7 @@ export class UnitView {
const bob = this.alive ? Math.sin(t * 2.4 + this.bob) * s * 0.03 : 0; const bob = this.alive ? Math.sin(t * 2.4 + this.bob) * s * 0.03 : 0;
this.fallback.scale.set(size / 160); this.fallback.scale.set(size / 160);
this.fallback.position.set(0, -s * 0.08 + bob); this.fallback.position.set(0, -s * 0.08 + bob);
this.fallback.tint = this.flash > 0 ? mixTint(0xffffff, 0xff8a80, this.flash) : (this.flags & UF.FROZEN ? 0x9fd4ff : 0xffffff); this.fallback.tint = this.down ? DOWN_LOOK.tint : this.flash > 0 ? mixTint(0xffffff, 0xff8a80, this.flash) : (this.flags & UF.FROZEN ? 0x9fd4ff : 0xffffff);
if (!this.alive) this.fallback.alpha = Math.max(0, this.fallback.alpha); if (!this.alive) this.fallback.alpha = Math.max(0, this.fallback.alpha);
} }
this.flash = Math.max(0, this.flash - dt * 6); this.flash = Math.max(0, this.flash - dt * 6);
@@ -656,7 +714,8 @@ export class UnitView {
const showHp = showBars && (!this.isEnemy || damaged || this.isBoss); const showHp = showBars && (!this.isEnemy || damaged || this.isBoss);
const bw = clamp(s * (this.isBoss ? UNIT.bossBarWidth : UNIT.barWidth), 24, this.isBoss ? 260 : 96); const bw = clamp(s * (this.isBoss ? UNIT.bossBarWidth : UNIT.barWidth), 24, this.isBoss ? 260 : 96);
const bh = clamp(s * (this.isBoss ? 0.12 : 0.075), 3, this.isBoss ? 12 : 7); const bh = clamp(s * (this.isBoss ? 0.12 : 0.075), 3, this.isBoss ? 12 : 7);
this.hud.alpha = this.dying > 0 ? 0 : alpha; // a knocked-down operator's HUD is its redeploy ring alone, drawn at full strength over the greyed model
this.hud.alpha = this.down ? this.fadeIn : this.dying > 0 ? 0 : alpha;
let sx = this.shake > 0 ? Math.sin(t * 90) * this.shake * 10 : 0; let sx = this.shake > 0 ? Math.sin(t * 90) * this.shake * 10 : 0;
this.shake = Math.max(0, this.shake - dt); this.shake = Math.max(0, this.shake - dt);
const x0 = x - bw / 2 + sx; const x0 = x - bw / 2 + sx;
@@ -706,16 +765,6 @@ export class UnitView {
if (prep) this.chip.position.set(x, y - this.chip.height / 2 + 2); if (prep) this.chip.position.set(x, y - this.chip.height / 2 + 2);
else this.chip.position.set(x0 - this.chip.width / 2 - 1, cy + (showSp ? spH / 2 : 0)); else this.chip.position.set(x0 - this.chip.width / 2 - 1, cy + (showSp ? spH / 2 : 0));
} }
// the HUD's screen rect (drawn above every unit: a press on a unit's chip / bars is on that unit, render/pick.js)
let hx0 = Infinity, hy0 = Infinity, hx1 = -Infinity, hy1 = -Infinity;
const hudAdd = (ax, ay, bx2, by2) => { hx0 = Math.min(hx0, ax); hy0 = Math.min(hy0, ay); hx1 = Math.max(hx1, bx2); hy1 = Math.max(hy1, by2); };
if (showHp) hudAdd(x0 - 1, cy - bh / 2 - 1, x0 + bw + 1, cy + bh / 2 + 1);
if (showSp) hudAdd(x0 - 1, cy, x0 + bw + 1, this._spY + spH / 2 + 1);
if (this.chip && this.chip.visible) {
const cw = this.chip.width / 2, ch = this.chip.height / 2;
hudAdd(this.chip.position.x - cw, this.chip.position.y - ch, this.chip.position.x + cw, this.chip.position.y + ch);
}
this._hudBox = hx1 > hx0 && hy1 > hy0 && this.hud.alpha > 0.05 ? { x0: hx0, y0: hy0, x1: hx1, y1: hy1 } : null;
// status icons row above the bars // status icons row above the bars
const icons = this._iconKeys(); const icons = this._iconKeys();
const isz = clamp(s * 0.26, 12, 26); const isz = clamp(s * 0.26, 12, 26);
@@ -731,6 +780,9 @@ export class UnitView {
ic.width = ic.height = isz; ic.width = ic.height = isz;
ic.position.set(x - ((icons.length - 1) * (isz + 2)) / 2 + i * (isz + 2), iy); ic.position.set(x - ((icons.length - 1) * (isz + 2)) / 2 + i * (isz + 2), iy);
} }
// element gauge right of the bars (b.snap `elem`); redeploy ring above a knocked-down operator (b.snap `down`)
this._updateElementRing(showBars && !!this.el, x0 + bw, cy + (showSp ? spH / 2 : 0), s);
this._updateDownRing(!prep && !!this.down && !this.alive, x, this.screen.y - DOWN_LOOK.height * s, s, t);
// blocked marker at the feet (enemies held by a blocker) // blocked marker at the feet (enemies held by a blocker)
const blocked = !prep && this.alive && this.isEnemy && (this.flags & UF.BLOCKED); const blocked = !prep && this.alive && this.isEnemy && (this.flags & UF.BLOCKED);
this.blockIcon.visible = !!blocked; this.blockIcon.visible = !!blocked;
@@ -754,6 +806,72 @@ export class UnitView {
} }
} }
/**
* Element gauge icon (see header): the element's disc — operators with its glyph, enemies plain and smaller — in a
* white ring of the remaining 元素值 (1 − fill), which refills over a 爆发冷却; left edge at `xl`, centred on `cy`.
* Sprites of the hudRings atlas, built on the first gauge and hidden while there is none.
*/
_updateElementRing(show, xl, cy, s) {
let r = this._elRing;
if (!show) { if (r) r.root.visible = false; return; }
const tex = hudRings();
if (!r) {
const P = this.P;
const root = new P.Container();
const mk = (tx) => { const sp = new P.Sprite(tx); sp.anchor.set(0.5); root.addChild(sp); return sp; };
r = this._elRing = { root, track: mk(tex.track), disc: mk(tex.disc.burn), arc: mk(tex.arcs[0]) };
this.hud.addChild(root);
}
const cooling = this.elDur > 0 && this.elUntil > 0;
const frac = cooling ? clamp(1 - (this.elUntil - this.gameT) / this.elDur, 0, 1) : clamp(1 - this.elFill, 0, 1);
r.disc.texture = (this.isEnemy ? tex.discEnemy : tex.disc)[this.el] || tex.disc.burn;
r.arc.texture = ringArc(frac);
const d = clamp(s * EL_RING * (this.isEnemy ? 0.8 : 1), 13, 32);
r.root.scale.set(d / tex.size);
r.root.position.set(xl + d / 2 + 2, cy);
r.root.visible = true;
}
/**
* Redeploy ring above a knocked-down operator (see header), centred on (cx, cy): a mint arc filling as its respawn
* timer runs with the seconds left; a full pulsing amber ring with "DP" / red ring with "!" once it only waits.
*/
_updateDownRing(show, cx, cy, s, t) {
let r = this._downRing;
if (!show) { if (r) r.root.visible = false; return; }
const tex = hudRings();
if (!r) {
const P = this.P;
const root = new P.Container();
const mk = (tx) => { const sp = new P.Sprite(tx); sp.anchor.set(0.5); root.addChild(sp); return sp; };
const disc = mk(tex.downDisc), track = mk(tex.track), arc = mk(tex.arcs[0]);
const text = new P.Text('', { fontFamily: 'Bender, Oxanium, "Noto Sans SC", sans-serif', fontSize: 32, fontWeight: '700', fill: '#ffffff', stroke: '#0b0f0e', strokeThickness: 6 });
text.anchor.set(0.5);
root.addChild(text);
r = this._downRing = { root, disc, track, arc, text, label: null };
this.hud.addChild(root);
}
const dn = this.down;
const counting = dn.state === DOWN_STATE.COUNTING;
const left = Math.max(0, dn.until - this.gameT);
r.arc.texture = ringArc(counting ? (dn.total > 0 ? clamp(1 - left / dn.total, 0, 1) : 1) : 1);
const color = DOWN_LOOK.ring[dn.state] ?? DOWN_LOOK.ring[DOWN_STATE.COUNTING];
r.arc.tint = color;
const label = counting ? String(Math.ceil(left - 1e-6)) : dn.state === DOWN_STATE.WAIT_DP ? 'DP' : '!';
if (r.label !== label) {
r.label = label;
r.text.text = label;
r.text.style.fill = counting ? '#ffffff' : '#' + color.toString(16).padStart(6, '0');
}
const d = clamp(s * DOWN_LOOK.size, 22, 52);
const k = d / tex.size;
r.disc.scale.set(k); r.track.scale.set(k); r.arc.scale.set(k);
r.text.scale.set((d * (label.length > 2 ? 0.34 : 0.44)) / 32);
r.root.position.set(cx, cy);
r.root.alpha = counting ? 1 : 0.72 + 0.28 * Math.sin(t * 5);
r.root.visible = true;
}
// ---- impostor mode (crowded fields, clipped skeletons): the skeleton is rendered into a slot of the shared // ---- impostor mode (crowded fields, clipped skeletons): the skeleton is rendered into a slot of the shared
// impostor atlas (render/impostor.js) every `interval` frames (staggered per unit; every frame for clipped // impostor atlas (render/impostor.js) every `interval` frames (staggered per unit; every frame for clipped
// skeletons, whose stencil masks must stay out of the main pass) and shown as one sprite. Without a free atlas // skeletons, whose stencil masks must stay out of the main pass) and shown as one sprite. Without a free atlas
@@ -872,6 +990,9 @@ export class UnitView {
for (const k of this.statuses) { for (const k of this.statuses) {
if (out.length >= 4) break; if (out.length >= 4) break;
if (k === 'skill') continue; if (k === 'skill') continue;
// a burst's lock ('burnBurst', 'neuralBurst' … — the 爆发冷却) is shown by the element icon beside the bars
// (b.snap `elem`); only a feed without gauges (an older recording) shows it as a status
if (this.el && k.endsWith('Burst')) continue;
const icon = statusIconKey(k); const icon = statusIconKey(k);
// flag-driven states are authoritative (a stale 'stun' status must not outlive the flag) // flag-driven states are authoritative (a stale 'stun' status must not outlive the flag)
if (!icon || icon === 'stun' || icon === 'freeze' || icon === 'sleep' || icon === 'stealth' || icon === 'invuln') continue; if (!icon || icon === 'stun' || icon === 'freeze' || icon === 'sleep' || icon === 'stealth' || icon === 'invuln') continue;
@@ -881,39 +1002,13 @@ export class UnitView {
return out; return out;
} }
/** Model height (tiles above the feet) of the posed Spine model: its local bounds' top, clamped per kind. */ /** Canvas-space bounds (CSS px) of the body: 0.7 tile wide, from its head (UNIT.headroom; enemies: their model) down
_measureBody() { * to just below the feet — tooltips and overlays (view.pieceScreenRect), not picking (render/pick.js is by tile). */
let b = null;
try { b = this.actor.spine.getLocalBounds(); } catch { b = null; }
const top = b && Number.isFinite(b.y) && b.height > 1 ? -b.y * UNIT.modelScale : NaN;
const [lo, hi] = this.isEnemy ? (this.isBoss ? [0.6, 3.2] : [0.45, 2.4]) : [1.0, 1.55];
if (Number.isFinite(top) && top > 0.2) return clamp(top, lo, hi);
return this.isEnemy ? clamp(this._headTiles || BODY_H, lo, hi) : BODY_H;
}
/**
* The body as drawn, for render/pick.js (canvas CSS px): the Spine chibi (feet, px per tile, its height — measured,
* else the median chibi / the enemy's bounds —, width factor, facing, draw order, HUD) or the avatar diamond shown
* until the model is there.
* @returns {import('./pick.js').PickBody}
*/
pickShape() {
const sc = this.screen;
const depth = this.root && !this.root.destroyed ? this.root.zIndex : 0;
const hud = this.culled ? null : this._hudBox; // a culled unit's HUD box is where it was last drawn: stale
if (!(this.actor && this.spineReady)) return { kind: 'diamond', x: sc.x, y: sc.y, s: sc.s || 1, d: UNIT.diamond * (this.isBoss ? 1.5 : 1), depth, hud };
const h = this._bodyH || (this.isEnemy ? clamp(this._headTiles || BODY_H, 0.45, this.isBoss ? 3.2 : 2.4) : BODY_H);
return { kind: 'chibi', x: sc.x, y: sc.y, s: sc.s || 1, h, w: this.isEnemy ? clamp(h / 1.2, 0.75, 2.2) : 1, flip: this._flip === -1 ? -1 : 1, depth, hud };
}
/** Canvas-space bounds (CSS px) of the drawn body (the outline of pickShape, without the HUD). */
bounds() { bounds() {
return bodyBounds({ ...this.pickShape(), hud: null }); const s = this.screen.s || 1;
} const h = (this._headTiles || UNIT.headroom) * s;
const w = s * 0.7;
hitTest(x, y) { return { x: this.screen.x - w / 2, y: this.screen.y - h, width: w, height: h + s * 0.1 };
if (!this.alive || this.alpha < 0.05) return false;
return hitBody(this.pickShape(), x, y) > 0;
} }
setHover(on) { this.hovered = !!on; } setHover(on) { this.hovered = !!on; }
@@ -967,7 +1062,7 @@ function defaultInterval(ctx, info) {
/** /**
* Hand item: icon plate floating above its slot. While dragged (`lift` > 0) the plate is centred on its ground point — * Hand item: icon plate floating above its slot. While dragged (`lift` > 0) the plate is centred on its ground point —
* the pointer (render/app.js moveDragVisual) — so the item lands, and equips, where it is drawn. * the pointer (render/app.js moveDragVisual); it drops on (and equips the unit on) the tile under the pointer.
*/ */
export class ItemView { export class ItemView {
constructor(ctx, info) { constructor(ctx, info) {
@@ -1008,14 +1103,11 @@ export class ItemView {
this.shadow.alpha = 0.35; this.shadow.alpha = 0.35;
this.screen.top = lifted ? p.y - size / 2 : p.y - size; this.screen.top = lifted ? p.y - size / 2 : p.y - size;
} }
/** The plate on screen for render/pick.js: a square centred on the icon. */ /** Canvas-space bounds (CSS px) of the plate (centred on the pointer while dragged). */
pickShape() { bounds() {
const s = this.screen.s || 1; const w = (this.screen.s || 1) * 0.62;
const cy = this.lift > 0 ? this.screen.y : this.screen.y - s * 0.31; return { x: this.screen.x - w / 2, y: this.lift > 0 ? this.screen.y - w / 2 : this.screen.y - w, width: w, height: w };
return { kind: 'plate', x: this.screen.x, y: cy, s, r: 0.31, depth: this.root && !this.root.destroyed ? this.root.zIndex : 0, hud: null };
} }
bounds() { return bodyBounds(this.pickShape()); }
hitTest(x, y) { return hitBody(this.pickShape(), x, y) > 0; }
setHover() {} setHover() {}
destroy() { if (this.destroyed) return; this.destroyed = true; this.shadow.destroy(); this.root.destroy({ children: true }); } destroy() { if (this.destroyed) return; this.destroyed = true; this.shadow.destroy(); this.root.destroy({ children: true }); }
} }
+82 -24
View File
@@ -3,15 +3,19 @@
// for the mode type (icon, name, LP); a band a teammate already picked carries the picker's avatar and is marked // for the mode type (icon, name, LP); a band a teammate already picked carries the picker's avatar and is marked
// 队友已选 — it cannot be chosen again (research 09 §5, guidebook 策略与轮选; the server refuses it too); right = // 队友已选 — it cannot be chosen again (research 09 §5, guidebook 策略与轮选; the server refuses it too); right =
// detail pane (icon, 初始生命值, name, effect name + rich description) with 跳过 (co-op, once) and 确认选择. // detail pane (icon, 初始生命值, name, effect name + rich description) with 跳过 (co-op, once) and 确认选择.
// A timeout assigns 「华法琳」, or — when a teammate already holds it — the first free strategy (timeoutBand; the tip // One countdown (user playtest #4 item 4): every turn has the same clock (Match BAND_TURN_SECONDS, m.public.draft
// under the order list names the one I would get). Solo: free pick, no timer. // turnSeconds) and the step header counts it down — m.public.deadline IS the turn's end, the same number as the
// current picker's row. The highlighted band (the detail pane's) is what a turn that runs out takes: every change of it
// is reported (g.bandFocus) and the server assigns it while it is free, else 「华法琳」, else the first free strategy
// (timeoutBand). It starts on that default, so the tip under the order list always names what a timeout gives.
// Solo, and a co-op match with a single human (the server's soloUntimed: draft.untimed): no clock at all.
import { useEffect, useMemo, useState } from '../../vendor/hooks.module.js'; import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { html, Button, Icon, MicroLabel, useTicker, secondsLeft } from '../ui/components.js'; import { html, Button, Icon, MicroLabel, useTicker, secondsLeft } from '../ui/components.js';
import { useGameData, BandIcon, RichText, PlayerAvatar, LpTower, Sprite } from '../ui/gameComponents.js'; import { useGameData, BandIcon, RichText, PlayerAvatar, LpTower, Sprite } from '../ui/gameComponents.js';
import { StepHeader, ExitModal } from '../ui/matchChrome.js'; import { StepHeader, ExitModal } from '../ui/matchChrome.js';
import { actions } from '../ui/gameActions.js'; import { actions, act } from '../ui/gameActions.js';
import { normalizeDraft, sortedPlayers, phaseTotalSeconds } from '../ui/gameLogic.js'; import { normalizeDraft, sortedPlayers } from '../ui/gameLogic.js';
import { useStore } from '../store.js'; import { useStore } from '../store.js';
import { audio } from '../audio.js'; import { audio } from '../audio.js';
@@ -64,19 +68,62 @@ export function teammateBands(picks, myId) {
} }
/** /**
* The band the detail pane shows: the current one, else my pick, else the first band no teammate took. When it is my * The band the detail pane shows (= the highlighted band a turn that runs out takes): the current one, else my pick,
* turn and the shown band was taken meanwhile (队友已选), the first free one instead (confirm would be disabled). * else the band a timeout would give me (timeoutBand: 「华法琳」 while free, else the first free one). When it is my
* turn and the shown band was taken meanwhile (队友已选), that default instead (confirm would be disabled).
* @param {string|null} sel * @param {string|null} sel
* @param {{ bands: any[], taken: Map<string, any>, myPick: string|null, myTurn: boolean }} o * @param {{ bands: any[], taken: Map<string, any>, myPick: string|null, myTurn: boolean, defaultId?: string }} o
*/ */
export function draftSelection(sel, { bands, taken, myPick, myTurn }) { export function draftSelection(sel, { bands, taken, myPick, myTurn, defaultId = DEFAULT_TIMEOUT_BAND }) {
if (!Array.isArray(bands) || !bands.length) return sel; if (!Array.isArray(bands) || !bands.length) return sel;
const free = (bands.find((b) => !taken.has(b.bandId)) || bands[0]).bandId; const free = timeoutBand(bands, taken, defaultId) || bands[0].bandId;
if (!sel) return myPick || free; if (!sel) return myPick || free;
if (!myPick && myTurn && taken.has(sel)) return free; if (!myPick && myTurn && taken.has(sel)) return free;
return sel; return sel;
} }
/**
* The strategy a turn that runs out assigns me (server Match.timeoutBand): the highlighted band while it is one of
* the mode's and no teammate holds it, else timeoutBand. Null after my pick.
* @param {string|null} sel the highlighted band
* @param {{ bands: any[], taken: Map<string, any>, myPick?: string|null, defaultId?: string }} o
*/
export function autoPickBand(sel, { bands, taken, myPick = null, defaultId = DEFAULT_TIMEOUT_BAND }) {
if (myPick) return null;
const list = Array.isArray(bands) ? bands : [];
const has = (id) => !!(taken && typeof taken.has === 'function' && taken.has(id));
if (sel && !has(sel) && list.some((b) => b.bandId === sel)) return sel;
return timeoutBand(list, taken, defaultId);
}
/**
* The tip under the co-op draft order: the one skip, the turn clock and what a turn that runs out assigns me (the
* highlighted band while free — autoPickBand). Untimed drafts (a single human) name no clock.
* @param {{ timed: boolean, turnSeconds?: number|null, autoName?: string|null, selected?: boolean }} o
* selected: the auto pick is the highlighted band (not the default standing in for a band a teammate holds)
*/
export function draftTip({ timed, turnSeconds = null, autoName = null, selected = true }) {
const skip = '联合模拟在选择策略时可以进行一次跳过';
if (!timed) return `${skip};本局不限时`;
const clock = Number(turnSeconds) > 0 ? `每位博士有 ${Math.round(turnSeconds)} 秒` : '每位博士限时决策';
if (!autoName) return `${skip};${clock}`;
return `${skip};${clock},超时将自动选择${selected ? '当前选中的' : ''}「${autoName}」`;
}
/**
* The step header's countdown during the draft: the current turn's (m.public.deadline = draft.turnDeadline) with a
* turn's length as the gauge total — null when the draft is untimed.
* @param {any} pub m.public
* @returns {{ deadline: number, total: number|null } | null}
*/
export function draftClock(pub) {
const d = pub && typeof pub.draft === 'object' ? pub.draft : null;
if (!d || d.untimed) return null;
const deadline = Number(pub.deadline) > 0 ? Number(pub.deadline) : Number(d.turnDeadline) || 0;
if (!(deadline > 0)) return null;
return { deadline, total: Number(d.turnSeconds) > 0 ? Number(d.turnSeconds) : null };
}
/** BAND_DRAFT screen. */ /** BAND_DRAFT screen. */
export function BandDraftScreen() { export function BandDraftScreen() {
const pub = useStore((s) => s.match.public); const pub = useStore((s) => s.match.public);
@@ -106,21 +153,31 @@ export function BandDraftScreen() {
pickers.get(bid).push(p || { playerId: pid, name: '?' }); pickers.get(bid).push(p || { playerId: pid, name: '?' });
} }
// default selection: my pick, else the first band nobody else took; when my turn comes while the selected band has // default selection: my pick, else what a timeout gives me (华法琳 while free); when my turn comes while the selected
// been taken by a teammate meanwhile (队友已选), move the selection to the first free one // band has been taken by a teammate meanwhile (队友已选), move the selection back to that default
const defaultId = gd.config?.bandDraft?.timeoutBandId || DEFAULT_TIMEOUT_BAND;
const takenKey = [...taken.keys()].sort().join(','); const takenKey = [...taken.keys()].sort().join(',');
useEffect(() => { useEffect(() => {
const next = draftSelection(sel, { bands, taken, myPick, myTurn }); const next = draftSelection(sel, { bands, taken, myPick, myTurn, defaultId });
if (next !== sel) setSel(next); if (next !== sel) setSel(next);
}, [bands.length, myPick, myTurn, takenKey]); }, [bands.length, myPick, myTurn, takenKey]);
// "your turn" cue // "your turn" cue
useEffect(() => { if (myTurn && !solo) audio.sfx('yourTurn'); }, [myTurn]); useEffect(() => { if (myTurn && !solo) audio.sfx('yourTurn'); }, [myTurn]);
// what a timeout gives me (the server never assigns a strategy a teammate holds — Match.js defaultBand) // one countdown (user playtest #4 item 4): the current turn's — m.public.deadline, the same clock as the picker's row
const defaultId = gd.config?.bandDraft?.timeoutBandId || DEFAULT_TIMEOUT_BAND; const clock = solo ? null : draftClock(pub);
const autoId = myPick ? null : timeoutBand(bands, taken, defaultId); // the highlighted band is what a turn that runs out takes (Match.timeoutBand): report every change before my pick
const defaultName = gd.band(defaultId)?.name || '华法琳'; const timed = !solo && !!pub?.draft && !pub.draft.untimed;
const autoName = (autoId && gd.band(autoId)?.name) || defaultName; const focusSent = useRef(null);
useEffect(() => {
if (!timed || myPick || !sel || focusSent.current === sel) return;
focusSent.current = sel;
act('g.bandFocus', { bandId: sel }, { sfx: false, quiet: true });
}, [sel, timed, myPick]);
// what a timeout gives me: the highlighted band while free, else the default (never 队友已选 — Match.js timeoutBand)
const autoId = autoPickBand(sel, { bands, taken, myPick, defaultId });
const autoName = (autoId && gd.band(autoId)?.name) || gd.band(defaultId)?.name || '华法琳';
const band = sel ? gd.band(sel) : null; const band = sel ? gd.band(sel) : null;
const selTaken = !!band && taken.has(band.bandId); const selTaken = !!band && taken.has(band.bandId);
@@ -137,14 +194,15 @@ export function BandDraftScreen() {
setBusy(null); setBusy(null);
}; };
const turnName = players.find((p) => p.playerId === draft.turnPid)?.name; const turnName = players.find((p) => p.playerId === draft.turnPid)?.name;
const turnDeadline = Number(pub?.draft?.turnDeadline) || 0; useTicker(clock ? 250 : 0);
useTicker(!solo && turnDeadline > 0 ? 250 : 0); // the picker's row shows the step header's number (both read the one turn deadline)
const turnSecs = !solo && turnDeadline > 0 ? secondsLeft(turnDeadline) : null; const turnSecs = clock ? secondsLeft(clock.deadline) : null;
const turnLen = Number(pub?.draft?.turnSeconds) > 0 ? Math.round(pub.draft.turnSeconds) : null;
return html`<div class="screen draft"> return html`<div class="screen draft">
<div class="brief__bg" aria-hidden="true"></div> <div class="brief__bg" aria-hidden="true"></div>
<${StepHeader} step=${2} of=${2} title="选择策略" micro="STRATEGY // BAND CHECK" pub=${pub} <${StepHeader} step=${2} of=${2} title="选择策略" micro="STRATEGY // BAND CHECK" pub=${clock ? { ...pub, deadline: clock.deadline } : { ...pub, deadline: 0 }}
total=${phaseTotalSeconds(pub, gd.config)} onExit=${() => setExit(true)} /> total=${clock ? clock.total : null} onExit=${() => setExit(true)} />
<main class="draft__main"> <main class="draft__main">
<aside class="draft-order"> <aside class="draft-order">
<h3 class="brief-h"><span>${solo ? '独立模拟' : '决策顺序'}</span><${MicroLabel}>${solo ? 'FREE PICK' : 'RANDOM ORDER'}</${MicroLabel}></h3> <h3 class="brief-h"><span>${solo ? '独立模拟' : '决策顺序'}</span><${MicroLabel}>${solo ? 'FREE PICK' : 'RANDOM ORDER'}</${MicroLabel}></h3>
@@ -168,7 +226,7 @@ export function BandDraftScreen() {
</span> </span>
</div>`; </div>`;
})} })}
${!solo ? html`<p class="draft-order__tip">联合模拟在选择策略时可以进行一次跳过;超时将自动选择「${autoName}」${autoId && autoId !== defaultId ? `(「${defaultName}」已被队友选择)` : ''}</p>` : null} ${!solo ? html`<p class="draft-order__tip" data-testid="draft-tip">${draftTip({ timed, turnSeconds: turnLen, autoName: myPick ? null : autoName, selected: autoId === sel })}</p>` : null}
</aside> </aside>
<section class="draft-grid" role="listbox" aria-label="策略"> <section class="draft-grid" role="listbox" aria-label="策略">
+56 -79
View File
@@ -39,6 +39,9 @@
// state().leaks → ui/hud.js liveLp → top bar + own team row; teammates' rows from m.public players[].pendingLp); while // state().leaks → ui/hud.js liveLp → top bar + own team row; teammates' rows from m.public players[].pendingLp); while
// the temp overflow row (临时整备区) holds pieces it is framed and labelled on the board (ui/underframe.js // the temp overflow row (临时整备区) holds pieces it is framed and labelled on the board (ui/underframe.js
// TempRowNotice) and 准备就绪 / Space say why they are refused (item 3). // TempRowNotice) and 准备就绪 / Space say why they are refused (item 3).
// User playtest #4: the detail card shows live stats (item 7) — in battle the local sim's unit (battle/runner.js
// unitStats), in prep an own board unit's start-of-battle stats (g.unitStats → m.unitStats); 机变 cards take two taps
// (item 2, ui/choiceOverlay.js).
import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js'; import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { PHASE, GEO } from '../../../shared/constants.js'; import { PHASE, GEO } from '../../../shared/constants.js';
@@ -70,7 +73,7 @@ import { pieceTile } from '../render/drag.js';
import { import {
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent, phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers, snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, pickPieceAt, equipRetarget, dropFailureReason, countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
previewEnemyKey, prepCamera, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout, previewEnemyKey, prepCamera, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout,
} from '../ui/gameLogic.js'; } from '../ui/gameLogic.js';
import { toast } from '../ui/toasts.js'; import { toast } from '../ui/toasts.js';
@@ -312,8 +315,6 @@ function MatchScreen() {
}, [view, ownView, ownStage, baseStage]); }, [view, ownView, ownStage, baseStage]);
const staleFieldRef = useRef(null); const staleFieldRef = useRef(null);
const enteredFieldRef = useRef(null); const enteredFieldRef = useRef(null);
// drop target adjustment while dragging (equipment released over an operator's sprite → that operator)
const retargetRef = useRef((uid, target) => target);
const pressSel = useRef(null); // the selected piece when the current field press began const pressSel = useRef(null); // the selected piece when the current field press began
useEffect(() => { useEffect(() => {
if (!view) return; if (!view) return;
@@ -331,7 +332,7 @@ function MatchScreen() {
setHud(null); setHud(null);
} }
if (priv) { if (priv) {
view.setPrep(priv, { editable, canPlace: (uid, target) => canPlace(live.current.placeCtx, uid, retargetRef.current(uid, target)).ok }); view.setPrep(priv, { editable, canPlace: (uid, target) => canPlace(live.current.placeCtx, uid, target).ok });
// stored facings (m.private board `dir`); the piece in the direction step shows the wheel's preview instead // stored facings (m.private board `dir`); the piece in the direction step shows the wheel's preview instead
syncPieceDirs(view, priv, live.current.facing?.uid ?? null); syncPieceDirs(view, priv, live.current.facing?.uid ?? null);
} }
@@ -590,46 +591,10 @@ function MatchScreen() {
useEffect(() => { useEffect(() => {
if (!view) return undefined; if (!view) return undefined;
let moveOff = null; let moveOff = null;
// `over`: the render engine's own pick of the unit under the dragged item (pieceDragOver / pieceDrop `over`, the // the drop target is the tile under the pointer (render/drag.js; an item dropped on a unit's tile equips that unit —
// shared rule of render/pick.js at the plate) — undefined while the view does not report it (DOM fallback) // user playtest #4 item 1); `tile` = the last target tile (tileHover), `released` = the pointer went up (not a cancel)
const ptr = { x: NaN, y: NaN, released: false, tile: null, unit: null, over: undefined }; const ptr = { released: false, tile: null };
const insideBar = (x, y) => {
const el = barRef.current;
if (!el || !Number.isFinite(x)) return false;
const r = el.getBoundingClientRect();
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
};
const lookups = { getChess: gd.chess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => chessLoadout(rec, live.current.priv?.loadout ?? null, gd.chess)?.record }; const lookups = { getChess: gd.chess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => chessLoadout(rec, live.current.priv?.loadout ?? null, gd.chess)?.record };
// own operator / summon under the dragged item (not the dragged piece): the engine's report, else the pieces'
// screen shapes at the pointer (ui/gameLogic.js pickPieceAt — the same rule; the tile under the pointer decides
// where no face / torso is hit)
const unitUnderPointer = (uid) => {
if (ptr.over !== undefined) return ptr.over === uid ? null : ptr.over;
const L = live.current;
if (typeof view.pieceScreenRect !== 'function' || !L.placeCtx || !Number.isFinite(ptr.x)) return null;
const rects = [];
for (const en of L.placeCtx.pieces.values()) {
if (en.piece.uid === uid || (en.area !== 'board' && en.area !== 'hand') || (en.piece.kind !== 'chess' && en.piece.kind !== 'token')) continue;
let r = null;
try { r = view.pieceScreenRect(en.piece.uid); } catch { r = null; }
if (r) rects.push({ uid: en.piece.uid, left: r.left, top: r.top, right: r.right, bottom: r.bottom, shape: r.shape || null, tile: pieceTile(en) });
}
const at = ptr.tile && Number.isInteger(ptr.tile.row) ? { row: ptr.tile.row, col: ptr.tile.col } : null;
return pickPieceAt(rects, ptr.x, ptr.y, at);
};
const retarget = (uid, target) => {
const L = live.current;
if (L.placeCtx?.pieces.get(uid)?.piece.kind !== 'item' || insideBar(ptr.x, ptr.y)) return target;
return equipRetarget(L.placeCtx, uid, target, unitUnderPointer(uid));
};
retargetRef.current = retarget;
/** Light the tile of the operator a dragged item would equip (the drop target highlight follows the plate). */
const markItemTarget = (uid) => {
const t = retarget(uid, null);
const unit = t && t.area === 'board' ? [[t.row, t.col]] : t && t.area === 'hand' ? [[GEO.HAND_ROW, t.idx]] : [];
const key = JSON.stringify(unit);
if (key !== ptr.unit) { ptr.unit = key; view.highlightTiles(unit, 'target'); }
};
const runIntent = async (intent) => { const runIntent = async (intent) => {
const L = live.current; const L = live.current;
if (intent.confirmReplace) { if (intent.confirmReplace) {
@@ -654,7 +619,6 @@ function MatchScreen() {
}; };
const endDrag = () => { const endDrag = () => {
moveOff?.(); moveOff = null; moveOff?.(); moveOff = null;
ptr.over = undefined; // (a drop / drag end hands its own `over` in again)
setDrag(null); setDrag(null);
view.highlightTiles(null, null); view.highlightTiles(null, null);
}; };
@@ -680,36 +644,20 @@ function MatchScreen() {
const { legal } = boardTargets(L.placeCtx, entry.piece.uid); const { legal } = boardTargets(L.placeCtx, entry.piece.uid);
view.highlightTiles(null, null); view.highlightTiles(null, null);
view.highlightTiles(legal, 'legal'); view.highlightTiles(legal, 'legal');
ptr.released = false; ptr.tile = null; ptr.unit = null; ptr.over = undefined; ptr.released = false; ptr.tile = null;
const isItem = entry.piece.kind === 'item'; // capture phase: marked before the drag controller (canvas listener) ends the drag
// capture phase: the pointer position is current before the drag controller (canvas listener) asks canPlace const onUp = () => { ptr.released = true; };
const onMove = (ev) => {
ptr.x = ev.clientX; ptr.y = ev.clientY;
if (isItem && ptr.over === undefined) markItemTarget(entry.piece.uid); // the engine reports via pieceDragOver
};
const onUp = (ev) => { ptr.x = ev.clientX; ptr.y = ev.clientY; ptr.released = true; };
window.addEventListener('pointermove', onMove, { passive: true, capture: true });
window.addEventListener('pointerup', onUp, { passive: true, capture: true }); window.addEventListener('pointerup', onUp, { passive: true, capture: true });
moveOff = () => { moveOff = () => window.removeEventListener('pointerup', onUp, { capture: true });
window.removeEventListener('pointermove', onMove, { capture: true });
window.removeEventListener('pointerup', onUp, { capture: true });
};
}), }),
view.on('tileHover', (t) => { ptr.tile = t && typeof t === 'object' ? t : null; }), view.on('tileHover', (t) => { ptr.tile = t && typeof t === 'object' ? t : null; }),
// the render engine's pick of the unit under a dragged item (sent before the drag asks canPlace)
view.on('pieceDragOver', (e) => {
if (!e || !live.current.placeCtx?.pieces.get(e.uid)) return;
ptr.over = Number.isInteger(e.over) ? e.over : null;
markItemTarget(e.uid);
}),
view.on('pieceDrop', async (e) => { view.on('pieceDrop', async (e) => {
endDrag(); endDrag();
const L = live.current; const L = live.current;
if (!e || !L.editable) return; if (!e || !L.editable) return;
const entry = L.placeCtx?.pieces.get(e.uid); const entry = L.placeCtx?.pieces.get(e.uid);
if (!entry) return; if (!entry) return;
if ('over' in e) ptr.over = Number.isInteger(e.over) ? e.over : null; const t = e.target || {};
const t = retarget(e.uid, e.target || {});
if (t.area === 'outside') return; // no drag-to-sell (research 09 §5): the piece goes back if (t.area === 'outside') return; // no drag-to-sell (research 09 §5): the piece goes back
const res = canPlace(L.placeCtx, e.uid, t); const res = canPlace(L.placeCtx, e.uid, t);
if (needsFacing(L.placeCtx, e.uid, t, res)) { openFacing(entry, t); return; } if (needsFacing(L.placeCtx, e.uid, t, res)) { openFacing(entry, t); return; }
@@ -720,8 +668,9 @@ function MatchScreen() {
} }
await runIntent(intent); await runIntent(intent);
}), }),
view.on('pieceDragEnd', async (e) => { view.on('pieceDragEnd', (e) => {
// a cancelled drag (no pieceDrop) must not leave the sell zone / highlights behind // a cancelled drag (no pieceDrop) must not leave the highlights behind; a release on a tile that takes nothing
// (the drag controller found no legal target there) says why
const released = ptr.released; const released = ptr.released;
const tile = ptr.tile; const tile = ptr.tile;
endDrag(); endDrag();
@@ -732,17 +681,6 @@ function MatchScreen() {
const onOwnSlot = !!tile && ((src.area === 'board' && tile.area === 'board' && tile.row === src.row && tile.col === src.col) const onOwnSlot = !!tile && ((src.area === 'board' && tile.area === 'board' && tile.row === src.row && tile.col === src.col)
|| (src.area !== 'board' && tile.area === src.area && (tile.idx ?? tile.col) === src.idx)); || (src.area !== 'board' && tile.area === src.area && (tile.idx ?? tile.col) === src.idx));
if (onOwnSlot) return; // put back where it was if (onOwnSlot) return; // put back where it was
// released where the drag controller found no target: equipment over an operator's sprite still equips it,
// anything else says why it was refused
if ('over' in e) ptr.over = Number.isInteger(e.over) ? e.over : null;
const t = retarget(e.uid, null);
if (t) {
const res = canPlace(L.placeCtx, e.uid, t);
if (needsFacing(L.placeCtx, e.uid, t, res)) { openFacing(src, t); return; }
const intent = dropIntent(L.placeCtx, e.uid, t);
if (intent) { await runIntent(intent); return; }
if (res.code && res.code !== 'ALREADY') { refuse(res.reason); return; }
}
const reason = dropFailureReason(L.placeCtx, e.uid, tile); const reason = dropFailureReason(L.placeCtx, e.uid, tile);
if (reason) refuse(reason); if (reason) refuse(reason);
}), }),
@@ -923,6 +861,45 @@ function MatchScreen() {
return t ? { hp: t[3], max: t[4] } : null; return t ? { hp: t[3], max: t[4] } : null;
})(); })();
// ---- live stats of the detail card (user playtest #4 item 7) ------------------------------------------------------
// battle: the local sim's unit (battle/runner.js unitStats — a getter the panel re-reads 4× a second; any unit of the
// battle on screen: own, a teammate's, an enemy). Prep: an own board unit's stats at the start of its next battle,
// asked from the server (g.unitStats → m.unitStats; only the newest request's answer counts) whenever m.private or the
// phase changes while such a card is open — the last answer stays on show until the next one lands
const [unitStats, setUnitStats] = useState(null); // m.unitStats: { seq, round, units: Map<uid, entry> }
const statsSeqRef = useRef(0);
useEffect(() => net.on('m.unitStats', (msg) => {
if (!msg || msg.seq !== statsSeqRef.current || !Array.isArray(msg.units)) return;
setUnitStats({ seq: msg.seq, round: msg.round, units: new Map(msg.units.filter((u) => u && Number.isInteger(u.uid)).map((u) => [u.uid, u])) });
}), []);
const prepStatsUid = (phase === PHASE.PREP || phase === PHASE.SP_DRAFT || phase === PHASE.ROUND_START) && showPrep && alive
&& !!resolved?.piece && (resolved.type === 'chess' || resolved.type === 'token') && placeCtx.pieces.get(resolved.piece.uid)?.area === 'board'
? resolved.piece.uid : null;
useEffect(() => {
if (prepStatsUid == null) return undefined;
const t = setTimeout(() => {
statsSeqRef.current = (statsSeqRef.current % 1e9) + 1;
try { Promise.resolve(net.request('g.unitStats', { seq: statsSeqRef.current })).catch(() => {}); } catch { /* offline */ }
}, 120);
return () => clearTimeout(t);
}, [prepStatsUid, priv, phase]);
const liveStats = (() => {
if (prepStatsUid != null) {
const e = unitStats && unitStats.round === pub?.round ? unitStats.units.get(prepStatsUid) : null;
return e ? { ...e, src: 'prep' } : null;
}
// a battle unit's card, or an own board piece's card left open into the battle (its unit found by uid)
const id = resolved?.unitId;
const pieceUid = id == null && Number.isInteger(resolved?.piece?.uid) ? resolved.piece.uid : null;
if ((id == null && pieceUid == null) || !cc || !battleRunner || !field?.local || showPrep) return null;
const fid = field.fieldId;
return () => {
const uid = id ?? battleRunner.unitIdOf(pieceUid, myId, fid);
const e = uid != null ? battleRunner.unitStats(uid, fid) : null;
return e ? { ...e, src: 'battle' } : null;
};
})();
// ---- keyboard --------------------------------------------------------------------------------------------- // ---- keyboard ---------------------------------------------------------------------------------------------
useEffect(() => { useEffect(() => {
const onKey = async (e) => { const onKey = async (e) => {
@@ -1102,7 +1079,7 @@ function MatchScreen() {
onClose=${() => setBondOpen(null)} onMember=${(id) => setDetail({ kind: 'chess', id })} />` : null} onClose=${() => setBondOpen(null)} onMember=${(id) => setDetail({ kind: 'chess', id })} />` : null}
${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }} ${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }}
bonds=${stripBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} bonds=${stripBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats}
onBond=${(id) => { setBondOpen((b) => (b === id ? null : id)); audio.sfx('click', { volume: 0.4 }); }} />` : null} onBond=${(id) => { setBondOpen((b) => (b === id ? null : id)); audio.sfx('click', { volume: 0.4 }); }} />` : null}
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid} ${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
+97 -12
View File
@@ -1,11 +1,17 @@
// 机变 draft overlay (research 06 §4.4 / §11.5): family title | description, "倒计时结束后仍未选定将自动分配", // 机变 draft overlay (research 06 §4.4 / §11.5): family title | description, "倒计时结束后仍未选定将自动分配",
// whose turn ("当前轮到你决策" / "{name} 正在决策…") with countdown, pick order with ✓ / ⌛ / … / door, // whose turn ("当前轮到你决策" / "{name} 正在决策…") with countdown, pick order with ✓ / ⌛ / … / door,
// and a 3×2 grid (solo: 3 cards) of cards with icon, title, rich description, tier chip and the taker's // and a 3×2 grid (solo: 3 cards) of cards with icon, title, rich description, tier chip and the taker's
// avatar badge. Clicking an available card on your turn sends g.choice; while it is in flight the card shows a // avatar badge. Picking takes two taps, like buying in the shop (user playtest #4 item 2 — extra enemies, items and
// "选择中" strip with a sweeping bar (never a spinner over its text — user playtest #3 item 9), dropped as soon as the // tactics were picked by a slip of the finger; research 09 §5 EventOnFirstClick → EventOnConfirm): the first tap on an
// pick shows in m.public (spBusy itself resets when the request settles, ≤ 8 s, or the phase moves on). // available card selects it (it lifts with a gold frame and a 确认选择 · 再次点击 strip; the header shows 确认选择), a
// second tap on the same card — or 确认选择 — sends g.choice; a tap on another card moves the selection, a tap
// elsewhere or Esc drops it. Cards are buttons (Tab / Enter work the same way). While the pick is in flight the card
// shows a "选择中" strip with a sweeping bar (never a spinner over its text — user playtest #3 item 9), dropped as soon
// as the pick shows in m.public (spBusy itself resets when the request settles, ≤ 8 s, or the phase moves on).
// Untimed drafts (solo, a single-human match: sp.untimed) show no countdown and say so.
import { html, Icon, TierChip, Countdown, MicroLabel } from './components.js'; import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon, TierChip, Countdown, MicroLabel, Button } from './components.js';
import { Img, RichText, PlayerAvatar, GIcon } from './gameComponents.js'; import { Img, RichText, PlayerAvatar, GIcon } from './gameComponents.js';
import { itemIconUrl, enemyIconUrl, uiUrl } from './assetUrls.js'; import { itemIconUrl, enemyIconUrl, uiUrl } from './assetUrls.js';
import { richTextPlain } from './richText.js'; import { richTextPlain } from './richText.js';
@@ -59,9 +65,74 @@ export function pickBusy(busyIdx, card, mine) {
} }
/** /**
* Whether I may pick a card right now: my turn (solo: always), no pick of mine yet, the card free and no pick in flight.
* @param {any} sp normalizeSp(...) @param {{ idx: number, takenBy?: string|null } | null | undefined} card
* @param {{ myId: string, solo: boolean, busyIdx?: number|null }} o
*/
export function cardPickable(sp, card, { myId, solo, busyIdx = null }) {
if (!sp || !card) return false;
const myTurn = solo || sp.turnPid === myId;
return myTurn && sp.pickOf.get(myId) == null && !card.takenBy && busyIdx == null;
}
/**
* Two-tap step of a card tap (like the shop's first tap → confirm): tapping an available card selects it, tapping the
* selected card again confirms it. A card that cannot be picked changes nothing.
* @param {number|null} armed the selected card's idx
* @param {number} idx the tapped card
* @param {boolean} pickable cardPickable(...) of the tapped card
* @returns {{ armed: number|null, pick: number|null }} the new selection and the card to send (g.choice) or null
*/
export function spTap(armed, idx, pickable) {
if (!pickable || !Number.isInteger(idx)) return { armed, pick: null };
if (armed === idx) return { armed: null, pick: idx };
return { armed: idx, pick: null };
}
/**
* The selected card while it can still be picked (my turn, not taken, nothing in flight), else null — a selection
* never outlives the state that allowed it.
* @param {number|null} armed @param {any} sp @param {{ myId: string, solo: boolean, busyIdx?: number|null }} o
*/
export function armedCard(armed, sp, o) {
if (armed == null || !sp) return null;
const card = sp.cards.find((c) => c && c.idx === armed);
return cardPickable(sp, card, o) ? armed : null;
}
/**
* The overlay: keeps the two-tap selection and renders ChoiceView. `onPick(idx)` sends the confirmed card (g.choice).
* @param {{ pub:any, sp:any, myId:string, solo:boolean, onPick:(idx:number)=>void, busyIdx?:number|null, total?:number|null }} props * @param {{ pub:any, sp:any, myId:string, solo:boolean, onPick:(idx:number)=>void, busyIdx?:number|null, total?:number|null }} props
*/ */
export function ChoiceOverlay({ pub, sp, myId, solo, onPick, busyIdx = null, total = null }) { export function ChoiceOverlay(props) {
const { sp, myId, solo, busyIdx = null, onPick } = props;
const [sel, setSel] = useState(null);
const armed = armedCard(sel, sp, { myId, solo, busyIdx });
// a selection whose card cannot be picked any more (taken, the turn moved on, a pick in flight) is dropped
useEffect(() => { if (sel != null && armed == null) setSel(null); }, [sel, armed]);
useEffect(() => {
if (armed == null) return undefined;
const onKey = (e) => { if (e.key === 'Escape') setSel(null); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [armed]);
if (!sp) return null;
const tap = (idx) => {
const card = sp.cards.find((c) => c && c.idx === idx);
const r = spTap(armed, idx, cardPickable(sp, card, { myId, solo, busyIdx }));
setSel(r.armed);
if (r.pick != null) onPick(r.pick);
};
return html`<${ChoiceView} ...${props} armed=${armed} onTap=${tap}
onConfirm=${() => { if (armed != null) tap(armed); }} onDisarm=${() => setSel(null)} />`;
}
/**
* The overlay's view (pure: no hooks — test/ui renders it as a function).
* @param {{ pub:any, sp:any, myId:string, solo:boolean, busyIdx?:number|null, total?:number|null, armed?:number|null,
* onTap?:(idx:number)=>void, onConfirm?:()=>void, onDisarm?:()=>void }} props
*/
export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null, armed = null, onTap = () => {}, onConfirm = () => {}, onDisarm = () => {} }) {
if (!sp) return null; if (!sp) return null;
const fam = data.get('choices')?.families?.[sp.family] || null; const fam = data.get('choices')?.families?.[sp.family] || null;
const players = new Map(sortedPlayers(pub).map((p) => [p.playerId, p])); const players = new Map(sortedPlayers(pub).map((p) => [p.playerId, p]));
@@ -70,20 +141,32 @@ export function ChoiceOverlay({ pub, sp, myId, solo, onPick, busyIdx = null, tot
const turnName = players.get(sp.turnPid)?.name || '队友'; const turnName = players.get(sp.turnPid)?.name || '队友';
const special = /_s$/.test(String(sp.family || '')); const special = /_s$/.test(String(sp.family || ''));
const order = solo ? [] : sp.order; const order = solo ? [] : sp.order;
return html`<div class="spov" role="dialog" aria-label="机变阶段"> const timed = !solo && !sp.untimed;
const armedCardRec = armed != null ? sp.cards.find((c) => c && c.idx === armed) : null;
const armedName = armedCardRec ? resolveSpCard(armedCardRec, sp.family).name : null;
// a press anywhere but a card or the confirm button drops the selection
const onDown = (e) => {
if (armed == null) return;
const t = e.target;
if (t && typeof t.closest === 'function' && t.closest('.spcard, .spov__confirm')) return;
onDisarm();
};
return html`<div class=${cx('spov', armed != null && 'has-armed')} role="dialog" aria-label="机变阶段" onPointerDown=${onDown}>
<div class="spov__veil" aria-hidden="true"></div> <div class="spov__veil" aria-hidden="true"></div>
<div class="spov__inner"> <div class="spov__inner">
<header class="spov__head"> <header class="spov__head">
<div class="spov__titles"> <div class="spov__titles">
<${MicroLabel} tone="mint">CONTINGENCY // 机变阶段</${MicroLabel}> <${MicroLabel} tone="mint">CONTINGENCY // 机变阶段</${MicroLabel}>
<h2 class=${cx('spov__title', special && 'is-special')}>${sp.name || fam?.name || '机变'}<span class="spov__bar">|</span><span class="spov__desc"><${RichText} text=${sp.desc || fam?.desc || '选择一项'} /></span></h2> <h2 class=${cx('spov__title', special && 'is-special')}>${sp.name || fam?.name || '机变'}<span class="spov__bar">|</span><span class="spov__desc"><${RichText} text=${sp.desc || fam?.desc || '选择一项'} /></span></h2>
${!solo ? html`<p class="spov__sub">倒计时结束后仍未选定将自动分配</p>` : html`<p class="spov__sub">选择一项(无时间限制)</p>`} <p class="spov__sub">${timed ? '倒计时结束后仍未选定将自动分配' : '选择一项(无时间限制)'}${mine == null && myTurn ? ' · 点击卡牌选中,再次点击确认' : ''}</p>
</div> </div>
<div class="spov__turn"> <div class="spov__turn">
${mine != null ? html`<span class="spov__turntxt is-done"><${Icon} name="check" />已完成选择</span>` ${mine != null ? html`<span class="spov__turntxt is-done"><${Icon} name="check" />已完成选择</span>`
: myTurn ? html`<span class="spov__turntxt is-mine">当前轮到你决策</span>` : myTurn ? html`<span class="spov__turntxt is-mine">当前轮到你决策</span>`
: html`<span class="spov__turntxt">${turnName} 正在决策…<${Icon} name="hourglass" /></span>`} : html`<span class="spov__turntxt">${turnName} 正在决策…<${Icon} name="hourglass" /></span>`}
${!solo && !sp.untimed ? html`<${Countdown} deadline=${pub?.deadline} total=${total ?? undefined} size="sm" />` : null} ${armed != null ? html`<${Button} variant="primary" size="lg" icon="check" class="spov__confirm" data-testid="sp-confirm"
title=${armedName ? `确认选择「${armedName}」(再次点击卡牌亦可)` : '确认选择'} onClick=${onConfirm}>确认选择<//>` : null}
${timed ? html`<${Countdown} deadline=${pub?.deadline} total=${total ?? undefined} size="sm" />` : null}
</div> </div>
</header> </header>
${order.length ? html`<div class="spov__order" aria-label="决策顺序"> ${order.length ? html`<div class="spov__order" aria-label="决策顺序">
@@ -104,11 +187,12 @@ export function ChoiceOverlay({ pub, sp, myId, solo, onPick, busyIdx = null, tot
${sp.cards.map((card) => { ${sp.cards.map((card) => {
const r = resolveSpCard(card, sp.family); const r = resolveSpCard(card, sp.family);
const taker = card.takenBy ? players.get(card.takenBy) : null; const taker = card.takenBy ? players.get(card.takenBy) : null;
const can = myTurn && mine == null && !card.takenBy && busyIdx == null; const can = cardPickable(sp, card, { myId, solo, busyIdx });
const busy = pickBusy(busyIdx, card, mine); const busy = pickBusy(busyIdx, card, mine);
return html`<button key=${card.idx} type="button" class=${cx('spcard', `spcard--${r.kind}`, card.takenBy && 'is-taken', card.takenBy === myId && 'is-mine', can && 'is-pickable', busy && 'is-busy')} const isArmed = can && armed === card.idx;
aria-busy=${busy ? 'true' : undefined} return html`<button key=${card.idx} type="button" class=${cx('spcard', `spcard--${r.kind}`, card.takenBy && 'is-taken', card.takenBy === myId && 'is-mine', can && 'is-pickable', isArmed && 'is-armed', busy && 'is-busy')}
disabled=${!can} onClick=${() => can && onPick(card.idx)} aria-label=${r.name} title=${`${r.name}\n${richTextPlain(r.desc)}`}> aria-busy=${busy ? 'true' : undefined} aria-pressed=${can ? String(isArmed) : undefined}
disabled=${!can} onClick=${() => can && onTap(card.idx)} aria-label=${isArmed ? `${r.name},已选中,再次点击确认` : r.name} title=${`${r.name}\n${richTextPlain(r.desc)}`}>
<span class="spcard__glow" aria-hidden="true"></span> <span class="spcard__glow" aria-hidden="true"></span>
${r.tier ? html`<${TierChip} tier=${r.tier} size="md" class="spcard__tier" />` : null} ${r.tier ? html`<${TierChip} tier=${r.tier} size="md" class="spcard__tier" />` : null}
<span class="spcard__icon"><${Img} src=${r.icon} fallback=${html`<${GIcon} name=${r.kind === 'bounty' ? 'target' : 'bolt'} />`} /></span> <span class="spcard__icon"><${Img} src=${r.icon} fallback=${html`<${GIcon} name=${r.kind === 'bounty' ? 'target' : 'bolt'} />`} /></span>
@@ -121,6 +205,7 @@ export function ChoiceOverlay({ pub, sp, myId, solo, onPick, busyIdx = null, tot
</span> </span>
</span> </span>
${taker ? html`<span class="spcard__taker" title=${`${taker.name} 已选择`}><${PlayerAvatar} player=${taker} size="sm" /><span>${card.takenBy === myId ? '你' : taker.name}</span></span>` : null} ${taker ? html`<span class="spcard__taker" title=${`${taker.name} 已选择`}><${PlayerAvatar} player=${taker} size="sm" /><span>${card.takenBy === myId ? '你' : taker.name}</span></span>` : null}
${isArmed ? html`<span class="spcard__confirm" role="status"><b>确认选择</b><small>再次点击</small></span>` : null}
${busy ? html`<span class="spcard__busy" role="status">选择中</span>` : null} ${busy ? html`<span class="spcard__busy" role="status">选择中</span>` : null}
</button>`; </button>`;
})} })}
+5
View File
@@ -342,6 +342,9 @@ export function secondsLeft(deadline) {
/** /**
* 7-segment countdown with "COUNTDOWN" micro label and a 5-bar gauge; turns orange ≤ warnAt s. * 7-segment countdown with "COUNTDOWN" micro label and a 5-bar gauge; turns orange ≤ warnAt s.
* Untimed (no `seconds` and no deadline — the server sends deadline 0 for every phase outside the battles of a solo
* match, Match.soloUntimed): renders nothing at all. It used to draw a "--" placeholder with the COUNTDOWN label,
* which reads as a timer (user playtest #4 item 3; css/screens/loadout.css keeps hiding the old placeholder).
* @param {{ deadline?: number, seconds?: number, total?: number, warnAt?: number, label?: string, * @param {{ deadline?: number, seconds?: number, total?: number, warnAt?: number, label?: string,
* size?: 'sm'|'md'|'lg', gauge?: boolean, onExpire?: Function, class?: string }} props * size?: 'sm'|'md'|'lg', gauge?: boolean, onExpire?: Function, class?: string }} props
* deadline: server epoch ms (live); seconds: static value (overrides deadline); total: seconds for the gauge. * deadline: server epoch ms (live); seconds: static value (overrides deadline); total: seconds for the gauge.
@@ -364,6 +367,8 @@ export function Countdown({ deadline, seconds, total, warnAt = 10, label = 'COUN
} }
}); });
// untimed phase: no timer on screen (hooks above run unconditionally, so the component may toggle freely)
if (remain == null) return null;
return html`<div class=${cx('countdown', `countdown--${size}`, warn && 'is-warn', cls)} role="timer" aria-label=${remain == null ? '无倒计时' : `剩余${remain}秒`}> return html`<div class=${cx('countdown', `countdown--${size}`, warn && 'is-warn', cls)} role="timer" aria-label=${remain == null ? '无倒计时' : `剩余${remain}秒`}>
<div class="countdown__main"> <div class="countdown__main">
<${SevenSeg} text=${text} tone=${warn ? 'orange' : 'mint'} flicker=${warn && remain > 0} /> <${SevenSeg} text=${text} tone=${warn ? 'orange' : 'mint'} flicker=${warn && remain > 0} />
+126 -30
View File
@@ -10,8 +10,14 @@
// match (research 09 §5, ui/underframe.js): the panel's own 出售 / 销毁 buttons only render for callers that pass // match (research 09 §5, ui/underframe.js): the panel's own 出售 / 销毁 buttons only render for callers that pass
// `editable` + handlers. `side` 'right' docks the panel at the right edge (the game screen picks the side away from a // `editable` + handlers. `side` 'right' docks the panel at the right edge (the game screen picks the side away from a
// selected unit's underframe, gameLogic panelSide). // selected unit's underframe, gameLogic panelSide).
// Live stats (user playtest #4 item 7 — the card used to show the fixed record numbers): `live` = the unit's current
// stats (shared/protocol.js unitStatsEntry + `src`) — in battle the browser's own sim (battle/runner.js unitStats; a
// getter re-read 4× a second: current HP, max HP, ATK, DEF, RES, attack interval, block), in prep the stats the own
// board's units start their next battle with (m.unitStats: equipment, bonds / layers, 特质, band and 机变 effects). Each
// value is coloured against the unit's base like the official card — green when it helps (higher, or a shorter attack
// interval) with the difference beside it, red when it hurts — and a 实时 / 开战时 tag says which it is.
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog } from './components.js'; import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js'; import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier } from './gameLogic.js'; import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier } from './gameLogic.js';
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js'; import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
@@ -43,8 +49,64 @@ export function RangeGrid({ grid, class: cls }) {
return html`<div class=${cx('rgrid', cls)} style=${`grid-template-columns:repeat(${box.cols}, var(--rg))`} aria-label="攻击范围">${cells}</div>`; return html`<div class=${cx('rgrid', cls)} style=${`grid-template-columns:repeat(${box.cols}, var(--rg))`} aria-label="攻击范围">${cells}</div>`;
} }
function Stat({ k, v, sub }) { function Stat({ k, v, sub, tone = null, title }) {
return html`<div class="dstat"><span class="dstat__k">${k}</span><span class="dstat__row"><b class="dstat__v num">${v}</b>${sub ? html`<small>${sub}</small>` : null}</span></div>`; return html`<div class=${cx('dstat', tone && `is-${tone}`)} title=${title}><span class="dstat__k">${k}</span><span class="dstat__row"><b class="dstat__v num">${v}</b>${sub ? html`<small>${sub}</small>` : null}</span></div>`;
}
/** Tolerance below which a live stat counts as its base (display rounding). */
const STAT_EPS = { interval: 0.005, res: 0.05, moveSpeed: 0.005 };
/**
* How a live stat compares with the unit's base (the official card's colours): 'up' (green) when it helps — higher,
* or a shorter attack interval —, 'down' (red) when it hurts, null when equal or unknown.
* @param {string} key maxHp | atk | def | res | interval | blockCnt | moveSpeed
* @param {any} cur @param {any} base
* @returns {'up'|'down'|null}
*/
export function statTone(key, cur, base) {
if (!Number.isFinite(cur) || !Number.isFinite(base)) return null;
const d = cur - base;
if (Math.abs(d) < (STAT_EPS[key] ?? 0.5)) return null;
return (key === 'interval' ? d < 0 : d > 0) ? 'up' : 'down';
}
/**
* One stat of the card: the live value (when `live` has it) coloured against `live.base`, with the difference as the
* small text; else the record value.
* @param {any} live unitStatsEntry (+ src) or null
* @param {string} key unitStatsEntry key
* @param {any} fallback record value
* @param {(v: any) => any} [fmt]
* @returns {{ v: any, tone: 'up'|'down'|null, sub: string|null, title: string|undefined }}
*/
export function liveStat(live, key, fallback, fmt = fmtNum) {
const cur = live && Number.isFinite(live[key]) ? live[key] : null;
if (cur == null) return { v: fallback == null ? '—' : fmt(fallback), tone: null, sub: null, title: undefined };
const base = live.base && Number.isFinite(live.base[key]) ? live.base[key] : null;
const tone = statTone(key, cur, base);
let sub = null;
if (tone) {
const d = cur - base;
sub = key === 'interval' ? `${d > 0 ? '+' : '−'}${Math.abs(d).toFixed(2)}` : `${d > 0 ? '+' : '−'}${key === 'res' || key === 'moveSpeed' ? Math.round(Math.abs(d) * 10) / 10 : fmtNum(Math.abs(d))}`;
}
return { v: fmt(cur), tone, sub, title: base != null ? `基础 ${fmt(base)}` : undefined };
}
/** The tag of a live stats block: 实时 (battle) / 开战时 (the prep preview). */
function LiveTag({ live }) {
if (!live) return null;
const battle = live.src === 'battle';
return html`<span class=${cx('dstats__tag', battle && 'is-battle')} title=${battle ? '当前作战中的实时数值(绿色为增益,红色为减益)'
: '下一场作战开始时的数值:已计入装备、盟约层数、特质、策略与机变效果(不含技能与作战中的临时效果)'}>${battle ? '实时' : '开战时'}</span>`;
}
const fmtInterval = (v) => (Number.isFinite(v) && v > 0 ? `${v.toFixed(2)}s` : '—');
const fmtRes = (v) => (Number.isFinite(v) ? String(Math.round(v * 10) / 10) : '0');
/** HP bar of the card header: the live HP in battle, else the snapshot's. */
function hpOf(live, snapHp) {
if (live && live.src === 'battle' && Number.isFinite(live.hp) && Number.isFinite(live.maxHp)) return { hp: live.hp, max: live.maxHp };
return snapHp || null;
} }
function Section({ title, micro, children, class: cls }) { function Section({ title, micro, children, class: cls }) {
@@ -139,8 +201,9 @@ function GarrisonBlock({ garrison, m }) {
</section>`; </section>`;
} }
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond }) { export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond, live = null }) {
const m = data.get('assets'); const m = data.get('assets');
const hp = hpOf(live, snapHp);
const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id)); const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id));
const c = chess; const c = chess;
// stats / talents the unit fights with: the chosen module's (or none — statsBase) for an elite (DESIGN §16) // stats / talents the unit fights with: the chosen module's (or none — statsBase) for an elite (DESIGN §16)
@@ -177,21 +240,28 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
<span>${c.subProfessionName || ''}</span> <span>${c.subProfessionName || ''}</span>
<span class="dhead__pos">${c.position === 'MELEE' ? '近战位' : '远程位'}</span> <span class="dhead__pos">${c.position === 'MELEE' ? '近战位' : '远程位'}</span>
</div> </div>
${snapHp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (snapHp.hp / Math.max(1, snapHp.max)) * 100))}%`}></i><span class="num">${fmtNum(snapHp.hp)} / ${fmtNum(snapHp.max)}</span></div>` : null} ${hp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
<${BondChips} bondIds=${c.bonds} bonds=${bonds} onBond=${onBond} /> <${BondChips} bondIds=${c.bonds} bonds=${bonds} onBond=${onBond} />
</div> </div>
</div>`; </div>`;
blocks.garrison = garrison ? html`<${GarrisonBlock} key="garrison" garrison=${garrison} m=${m} />` : null; blocks.garrison = garrison ? html`<${GarrisonBlock} key="garrison" garrison=${garrison} m=${m} />` : null;
blocks.trait = c.trait?.desc ? html`<p key="trait" class="dtrait"><${Icon} name="info" /><${RichText} text=${traitText(c, golden, lo)} /></p>` : null; blocks.trait = c.trait?.desc ? html`<p key="trait" class="dtrait"><${Icon} name="info" /><${RichText} text=${traitText(c, golden, lo)} /></p>` : null;
// live (battle) / start-of-battle (prep) values against the base, else the record's (liveStat)
const st = {
maxHp: liveStat(live, 'maxHp', s.maxHp), atk: liveStat(live, 'atk', s.atk), def: liveStat(live, 'def', s.def),
res: liveStat(live, 'res', s.res ?? 0, fmtRes), interval: liveStat(live, 'interval', interval, fmtInterval),
blockCnt: liveStat(live, 'blockCnt', s.blockCnt, (v) => String(v)),
};
blocks.stats = html` blocks.stats = html`
<div key="stats" class="dstats-wrap"> <div key="stats" class=${cx('dstats-wrap', live && 'is-live')} data-live=${live ? live.src || 'prep' : undefined}>
<div class="dstats"> <div class="dstats">
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} /> <${LiveTag} live=${live} />
<${Stat} k="攻击" v=${fmtNum(s.atk)} /> <${Stat} k="生命上限" ...${st.maxHp} />
<${Stat} k="防御" v=${fmtNum(s.def)} /> <${Stat} k="攻击" ...${st.atk} />
<${Stat} k="法术抗性" v=${s.res ?? 0} /> <${Stat} k="防御" ...${st.def} />
<${Stat} k="攻击间隔" v=${interval ? `${interval.toFixed(2)}s` : '—'} /> <${Stat} k="法术抗性" ...${st.res} />
<${Stat} k="阻挡数" v=${s.blockCnt ?? '—'} /> <${Stat} k="攻击间隔" ...${st.interval} />
<${Stat} k="阻挡数" ...${st.blockCnt} />
<${Stat} k="部署费用" v=${s.cost ?? '—'} /> <${Stat} k="部署费用" v=${s.cost ?? '—'} />
<${Stat} k="再部署" v=${s.respawnTime != null ? `${s.respawnTime}s` : '—'} /> <${Stat} k="再部署" v=${s.respawnTime != null ? `${s.respawnTime}s` : '—'} />
</div> </div>
@@ -234,7 +304,11 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
return CHESS_SECTIONS.map((k) => blocks[k]).filter(Boolean); return CHESS_SECTIONS.map((k) => blocks[k]).filter(Boolean);
} }
function ItemDetail({ item, piece, editable, onDestroy }) { /**
* An item's card (hand / temp / shop / equipped). An effect-only item (items.json `shopExcluded`: the special 维式重锤,
* 突变细胞 — user playtest #4 item 5) says it is never sold and where it comes from (`shopExcludedBy`).
*/
export function ItemDetail({ item, piece, editable, onDestroy }) {
const m = data.get('assets'); const m = data.get('assets');
return html` return html`
<div class="dhead dhead--item"> <div class="dhead dhead--item">
@@ -250,16 +324,24 @@ function ItemDetail({ item, piece, editable, onDestroy }) {
${item.itemType === 'MAGIC' ${item.itemType === 'MAGIC'
? html`<p class="dhint"><${Icon} name="info" />将其拖拽至战场上的格子使用</p>` ? html`<p class="dhint"><${Icon} name="info" />将其拖拽至战场上的格子使用</p>`
: html`<p class="dhint"><${Icon} name="info" />拖拽至干员身上进行配发(每名干员最多 2 件,配发后无法取下)${item.mergeable ? ';2 件相同装备自动合成进阶装备' : ''}</p>`} : html`<p class="dhint"><${Icon} name="info" />拖拽至干员身上进行配发(每名干员最多 2 件,配发后无法取下)${item.mergeable ? ';2 件相同装备自动合成进阶装备' : ''}</p>`}
${item.shopExcluded ? html`<p class="dhint dhint--source"><${Icon} name="info" />调度中心不出售 · 获取途径:${item.shopExcludedBy || '效果获得'}</p>` : null}
${piece && editable ? html`<div class="dactions"><${Button} variant="danger" onClick=${() => onDestroy(piece, item)}>销毁道具<//></div>` : null}`; ${piece && editable ? html`<div class="dactions"><${Button} variant="danger" onClick=${() => onDestroy(piece, item)}>销毁道具<//></div>` : null}`;
} }
function EnemyDetail({ enemy, snapHp, count }) { function EnemyDetail({ enemy, snapHp, count, live = null }) {
const m = data.get('assets'); const m = data.get('assets');
const s = enemy.stats || {}; const s = enemy.stats || {};
const types = Array.isArray(enemy.acTypes) ? enemy.acTypes : enemy.acType ? [enemy.acType] : []; const types = Array.isArray(enemy.acTypes) ? enemy.acTypes : enemy.acType ? [enemy.acType] : [];
const factions = data.get('factions')?.types || {}; const factions = data.get('factions')?.types || {};
const imm = Object.entries(s.immunities || {}).filter(([, v]) => v).map(([k]) => ({ stun: '晕眩', silence: '沉默', sleep: '沉睡', frozen: '冻结', levitate: '浮空' }[k] || k)); const imm = Object.entries(s.immunities || {}).filter(([, v]) => v).map(([k]) => ({ stun: '晕眩', silence: '沉默', sleep: '沉睡', frozen: '冻结', levitate: '浮空' }[k] || k));
const interval = attackInterval(s.bat, s.aspd); const interval = attackInterval(s.bat, s.aspd);
const hp = hpOf(live, snapHp);
// a battle enemy: its live stats against its spawned ones (the round's multipliers included — unitStatsEntry base)
const st = {
maxHp: liveStat(live, 'maxHp', s.maxHp), atk: liveStat(live, 'atk', s.atk), def: liveStat(live, 'def', s.def),
res: liveStat(live, 'res', s.res ?? 0, fmtRes), moveSpeed: liveStat(live, 'moveSpeed', s.moveSpeed, (v) => String(Math.round(v * 100) / 100)),
interval: liveStat(live, 'interval', interval, (v) => (Number.isFinite(v) && v > 0 ? `${v.toFixed(1)}s` : '—')),
};
return html` return html`
<div class="dhead dhead--enemy"> <div class="dhead dhead--enemy">
<div class=${cx('dhead__icon', 'dhead__icon--enemy', enemy.rank === 'BOSS' && 'is-boss', enemy.rank === 'ELITE' && 'is-elite')}> <div class=${cx('dhead__icon', 'dhead__icon--enemy', enemy.rank === 'BOSS' && 'is-boss', enemy.rank === 'ELITE' && 'is-elite')}>
@@ -273,16 +355,17 @@ function EnemyDetail({ enemy, snapHp, count }) {
</div> </div>
<h3 class="dhead__name">${enemy.name}</h3> <h3 class="dhead__name">${enemy.name}</h3>
<div class="dfactions">${types.map((t) => html`<span key=${t} class="dfaction"><${Img} src=${factionIconUrl(m, factions[t]?.icon)} />${factions[t]?.name || t}</span>`)}</div> <div class="dfactions">${types.map((t) => html`<span key=${t} class="dfaction"><${Img} src=${factionIconUrl(m, factions[t]?.icon)} />${factions[t]?.name || t}</span>`)}</div>
${snapHp ? html`<div class="dhp dhp--enemy"><i style=${`width:${Math.max(0, Math.min(100, (snapHp.hp / Math.max(1, snapHp.max)) * 100))}%`}></i><span class="num">${fmtNum(snapHp.hp)} / ${fmtNum(snapHp.max)}</span></div>` : null} ${hp ? html`<div class="dhp dhp--enemy"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
</div> </div>
</div> </div>
<div class="dstats"> <div class=${cx('dstats', live && 'is-live')} data-live=${live ? live.src || 'battle' : undefined}>
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} /> <${LiveTag} live=${live} />
<${Stat} k="攻击" v=${fmtNum(s.atk)} sub=${DMG[s.dmgType] || ''} /> <${Stat} k="生命上限" ...${st.maxHp} />
<${Stat} k="防御" v=${fmtNum(s.def)} /> <${Stat} k="攻击" ...${st.atk} sub=${st.atk.sub || DMG[s.dmgType] || ''} />
<${Stat} k="法术抗性" v=${s.res ?? 0} /> <${Stat} k="防御" ...${st.def} />
<${Stat} k="移动速度" v=${s.moveSpeed ?? '—'} /> <${Stat} k="法术抗性" ...${st.res} />
<${Stat} k="攻击间隔" v=${interval ? `${interval.toFixed(1)}s` : '—'} /> <${Stat} k="移动速度" ...${st.moveSpeed} />
<${Stat} k="攻击间隔" ...${st.interval} />
<${Stat} k="攻击范围" v=${s.rangeRadius > 0 ? s.rangeRadius : '近战'} /> <${Stat} k="攻击范围" v=${s.rangeRadius > 0 ? s.rangeRadius : '近战'} />
<${Stat} k="目标价值" v=${s.lpr ?? 1} /> <${Stat} k="目标价值" v=${s.lpr ?? 1} />
</div> </div>
@@ -292,20 +375,27 @@ function EnemyDetail({ enemy, snapHp, count }) {
<//>` : enemy.descRaw || enemy.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${enemy.descRaw || enemy.desc} class="dtext" /><//>` : null}`; <//>` : enemy.descRaw || enemy.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${enemy.descRaw || enemy.desc} class="dtext" /><//>` : null}`;
} }
function TokenDetail({ token, piece }) { function TokenDetail({ token, piece, snapHp = null, live = null }) {
const m = data.get('assets'); const m = data.get('assets');
const s = token.stats || {}; const s = token.stats || {};
const hp = hpOf(live, snapHp);
const st = {
maxHp: liveStat(live, 'maxHp', s.maxHp), atk: liveStat(live, 'atk', s.atk), def: liveStat(live, 'def', s.def),
blockCnt: liveStat(live, 'blockCnt', s.blockCnt, (v) => String(v)),
};
return html` return html`
<div class="dhead dhead--item"> <div class="dhead dhead--item">
<div class="dhead__icon"><${Img} src=${tokenAvatarUrl(m, token.tokenId)} fallback=${html`<${GIcon} name="target" />`} /></div> <div class="dhead__icon"><${Img} src=${tokenAvatarUrl(m, token.tokenId)} fallback=${html`<${GIcon} name="target" />`} /></div>
<div class="dhead__info"> <div class="dhead__info">
<div class="dhead__chips"><span class="dtag-token">召唤物</span>${piece?.count > 1 ? html`<span class="dtag-kind num">×${piece.count}</span>` : null}</div> <div class="dhead__chips"><span class="dtag-token">召唤物</span>${piece?.count > 1 ? html`<span class="dtag-kind num">×${piece.count}</span>` : null}</div>
<h3 class="dhead__name">${token.name}</h3> <h3 class="dhead__name">${token.name}</h3>
${hp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
</div> </div>
</div> </div>
<div class="dstats"> <div class=${cx('dstats', live && 'is-live')} data-live=${live ? live.src || 'prep' : undefined}>
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} /><${Stat} k="攻击" v=${fmtNum(s.atk)} /> <${LiveTag} live=${live} />
<${Stat} k="防御" v=${fmtNum(s.def)} /><${Stat} k="阻挡数" v=${s.blockCnt ?? '—'} /> <${Stat} k="生命上限" ...${st.maxHp} /><${Stat} k="攻击" ...${st.atk} />
<${Stat} k="防御" ...${st.def} /><${Stat} k="阻挡数" ...${st.blockCnt} />
</div> </div>
${token.descRaw || token.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${token.descRaw || token.desc} class="dtext" /><//>` : null}`; ${token.descRaw || token.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${token.descRaw || token.desc} class="dtext" /><//>` : null}`;
} }
@@ -351,9 +441,15 @@ export function resolveDetail(target, pieces) {
* bonds: the owner's m.private.bonds (counts / tiers of the bond chips); loadout: m.private.loadout (DESIGN §16) for * bonds: the owner's m.private.bonds (counts / tiers of the bond chips); loadout: m.private.loadout (DESIGN §16) for
* the player's own operators and shop cards; a teammate's unit gets its owner's choice (gameLogic unitLoadout); null * the player's own operators and shop cards; a teammate's unit gets its owner's choice (gameLogic unitLoadout); null
* = the defaults * = the defaults
* live: the unit's live stats (unitStatsEntry + src 'battle' | 'prep') — an object, or a getter the panel re-reads 4×
* a second (the battle's own sim, battle/runner.js unitStats); null ⇒ the record's numbers
*/ */
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], loadout = null, onBond = null, side = 'left', shopOpen = false }) { export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], loadout = null, onBond = null, side = 'left', shopOpen = false, live = null }) {
const getter = typeof live === 'function' ? live : null;
useTicker(detail && getter ? 250 : 0);
if (!detail) return null; if (!detail) return null;
let liveNow = null;
try { liveNow = getter ? getter() : live && typeof live === 'object' ? live : null; } catch { liveNow = null; }
const sellIt = async (piece, chess) => { const sellIt = async (piece, chess) => {
const golden = piece.golden || chess?.isGolden; const golden = piece.golden || chess?.isGolden;
if (golden) { if (golden) {
@@ -371,10 +467,10 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
<button type="button" class="dpanel__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button> <button type="button" class="dpanel__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
<div class="dpanel__scroll"> <div class="dpanel__scroll">
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt} ${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
bonds=${bonds} loadout=${loadout} onBond=${onBond} />` : null} bonds=${bonds} loadout=${loadout} onBond=${onBond} live=${liveNow} />` : null}
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} />` : null} ${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} />` : null}
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} />` : null} ${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} />` : null} ${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} snapHp=${snapHp} live=${liveNow} />` : null}
</div> </div>
</aside>`; </aside>`;
} }
+2 -52
View File
@@ -18,7 +18,6 @@ import { GEO, PHASE, UF } from '../../../shared/constants.js';
import { resolveLoadout, loadoutOptions, MODULE_NONE } from '../../../shared/protocol.js'; import { resolveLoadout, loadoutOptions, MODULE_NONE } from '../../../shared/protocol.js';
import { resolveRecordLoadout, loadoutRecord } from '../../../shared/loadoutRecord.js'; import { resolveRecordLoadout, loadoutRecord } from '../../../shared/loadoutRecord.js';
import { layoutPen } from '../render/pen.js'; import { layoutPen } from '../render/pen.js';
import { pickBody } from '../render/pick.js';
import { bossLevelSeconds } from './matchStatus.js'; import { bossLevelSeconds } from './matchStatus.js';
// ---- small helpers ------------------------------------------------------------------------------- // ---- small helpers -------------------------------------------------------------------------------
@@ -148,7 +147,8 @@ export function phaseTotalSeconds(pub, config, myId = null) {
const num = (v) => (Number.isFinite(v) && v > 0 ? v : null); const num = (v) => (Number.isFinite(v) && v > 0 ? v : null);
switch (pub.phase) { switch (pub.phase) {
case PHASE.INFO_CHECK: return num(timers.infoCheck) ?? 25; case PHASE.INFO_CHECK: return num(timers.infoCheck) ?? 25;
case PHASE.BAND_DRAFT: return num(timers.bandTurn) ?? 12; // one countdown: the current turn's (m.public.draft.turnSeconds = Match.BAND_TURN_SECONDS; user playtest #4 item 4)
case PHASE.BAND_DRAFT: return num(pub.draft?.turnSeconds) ?? num(timers.bandTurn) ?? 30;
case PHASE.BATTLE_CHECK: return num(timers.battleCheck) ?? 3; case PHASE.BATTLE_CHECK: return num(timers.battleCheck) ?? 3;
case PHASE.SP_DRAFT: { case PHASE.SP_DRAFT: {
const sp = normalizeSp(pub.sp, pub.players); const sp = normalizeSp(pub.sp, pub.players);
@@ -871,56 +871,6 @@ export function dropIntent(ctx, uid, target) {
return { t: 'g.move', fields: { uid, to } }; return { t: 'g.move', fields: { uid, to } };
} }
/**
* The piece under a screen point (user playtest #3 item 7). Rects of the render engine carry the drawn body
* (`shape`, view.pieceScreenRect) and are picked by the shared rule of render/pick.js — the face / torso of a unit is
* its own; elsewhere the piece standing on the tile under the pointer (`tile`, same space as the rects' `tile`) wins.
* Plain rects (the DOM fallback) go to the one whose centre is nearest relative to its size (a unit standing in front
* no longer takes the whole overlap), ties → the front-most (largest bottom).
* @param {Array<{uid:number, left:number, top:number, right:number, bottom:number, shape?:object, tile?:{row:number,col:number}}|null>} rects
* @param {number} x
* @param {number} y
* @param {{row:number, col:number}|null} [tile] the tile under the point
* @returns {number|null} uid
*/
export function pickPieceAt(rects, x, y, tile = null) {
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
const list = (Array.isArray(rects) ? rects : []).filter((r) => isObj(r) && Number.isInteger(r.uid));
const shaped = list.filter((r) => isObj(r.shape) && r.shape.s > 0);
if (shaped.length && shaped.length === list.length) {
const hit = pickBody(shaped.map((r) => ({ ...r.shape, tile: r.tile || null, uid: r.uid })), x, y, tile);
return hit ? hit.uid : null;
}
let best = null, bestD = Infinity;
for (const r of list) {
if (x < r.left || x > r.right || y < r.top || y > r.bottom) continue;
const hw = Math.max(1, (r.right - r.left) / 2), hh = Math.max(1, (r.bottom - r.top) / 2);
const d = Math.hypot((x - (r.left + r.right) / 2) / hw, (y - (r.top + r.bottom) / 2) / hh);
if (d < bestD - 1e-9 || (Math.abs(d - bestD) <= 1e-9 && r.bottom > best.bottom)) { best = r; bestD = d; }
}
return best ? best.uid : null;
}
/**
* An equipment item released over an operator's sprite goes to that operator. The sprite stands about a tile taller
* than its own tile, so the tile under the pointer (the drop target of render/drag.js) is often the one behind it.
* @param {ReturnType<typeof placementContext>} ctx
* @param {number} uid the dragged piece
* @param {any} target the tile target (board / hand / outside / null)
* @param {number|null} hitUid the own unit piece whose sprite is under the pointer (pickPieceAt)
* @returns {any} the target to use: the hit unit's tile / slot, or `target` unchanged
*/
export function equipRetarget(ctx, uid, target, hitUid) {
const src = ctx?.pieces?.get(uid);
if (!src || src.piece.kind !== 'item' || !Number.isInteger(hitUid) || hitUid === uid) return target;
if (ctx.getItem(src.piece.id)?.itemType === 'MAGIC') return target; // Arts are used on the tile itself
const hit = ctx.pieces.get(hitUid);
if (!hit || (hit.piece.kind !== 'chess' && hit.piece.kind !== 'token')) return target;
if (hit.area === 'board') return { area: 'board', row: hit.row, col: hit.col };
if (hit.area === 'hand') return { area: 'hand', idx: hit.idx };
return target;
}
/** /**
* Why a drag released over the field was refused (null when there is nothing to say): the canPlace reason for a * Why a drag released over the field was refused (null when there is nothing to say): the canPlace reason for a
* board tile / hand slot, a generic line for other tiles of the own board rows (lanes, blocked tiles) and the * board tile / hand slot, a generic line for other tiles of the own board rows (lanes, blocked tiles) and the
+154 -27
View File
@@ -75,6 +75,10 @@
// Live LP (user playtest #3 item 2): during COMBAT / 联防 m.public players[].pendingLp = min(lpCapPerRound, the counted // Live LP (user playtest #3 item 2): during COMBAT / 联防 m.public players[].pendingLp = min(lpCapPerRound, the counted
// leaks of the player's own battle so far) (_pendingLpView; omitted when 0) — the teammates' rows of the team panel // leaks of the player's own battle so far) (_pendingLpView; omitted when 0) — the teammates' rows of the team panel
// show lp − pendingLp; the settlement lands with the SETTLE view, where it is gone. // show lp − pendingLp; the settlement lands with the SETTLE view, where it is gone.
// User playtest #4: a match with a single human (独立模拟, or a 同盟 room started alone / with AI teammates) times no
// phase outside its battles (soloUntimed); the co-op strategy draft has ONE countdown — BAND_TURN_SECONDS per turn,
// published as m.public.deadline — AI seats pick at once and a turn that runs out takes the highlighted strategy
// (g.bandFocus → timeoutBand); g.unitStats answers m.unitStats: the stats the board's units start their next battle with.
// opts.clientCombat default true (env SP_COMBAT=server → false: the legacy server-run + snapshot streaming mode) // opts.clientCombat default true (env SP_COMBAT=server → false: the legacy server-run + snapshot streaming mode)
// opts.verify 'off' | 'sample' | 'all' (env SP_VERIFY, default 'off'): re-simulate accepted client results // opts.verify 'off' | 'sample' | 'all' (env SP_VERIFY, default 'off'): re-simulate accepted client results
// ('sample': ~1 in 8, in a later callback, mismatches logged; 'all': before accepting — the // ('sample': ~1 in 8, in a later callback, mismatches logged; 'all': before accepting — the
@@ -108,7 +112,7 @@
// pairing and the boss pool; its own running normal battle is force-ended. The seat shows status 'left'. When no // pairing and the boss pool; its own running normal battle is force-ended. The seat shows status 'left'. When no
// human is left at all the match ends ('abandoned'); when nobody alive is left it ends as 'eliminated'. // human is left at all the match ends ('abandoned'); when nobody alive is left it ends as 'eliminated'.
import { C2S } from '../../shared/protocol.js'; import { C2S, unitStatsEntry } from '../../shared/protocol.js';
import { PHASE, ERR, EMOTES, EMOTE_COOLDOWN_MS, GEO, modeIdFor } from '../../shared/constants.js'; import { PHASE, ERR, EMOTES, EMOTE_COOLDOWN_MS, GEO, modeIdFor } from '../../shared/constants.js';
import { Battle } from '../sim/Battle.js'; import { Battle } from '../sim/Battle.js';
import { DataSource } from '../sim/simdata.js'; import { DataSource } from '../sim/simdata.js';
@@ -182,6 +186,14 @@ export const DELAYS = Object.freeze({
PUBLIC_THROTTLE: 100, PUBLIC_THROTTLE: 100,
}); });
/**
* Seconds of one turn of the co-op strategy draft (user playtest #4 item 4: the old 12 s per turn — research 06 §724,
* itself [ASSUMED] — inside the 50 s step was far too little and counted apart from the header's 50 s). [ASSUMED]: the
* official data only gives the whole BAND_CHECK step (autoChessData.enterStepList: 50 s, hint 15 s); the turn clock is
* the remake's. It is also the step's only countdown (m.public.deadline = draft.turnDeadline). × timerScale.
*/
export const BAND_TURN_SECONDS = 30;
const dsCache = new WeakMap(); const dsCache = new WeakMap();
function dataSourceFor(data) { function dataSourceFor(data) {
if (!data || typeof data !== 'object' || !data.chess) return undefined; if (!data || typeof data !== 'object' || !data.chess) return undefined;
@@ -270,6 +282,12 @@ export class Match {
} }
if (!this.players.size) throw new TypeError('Match: seats required'); if (!this.players.size) throw new TypeError('Match: seats required');
this.order = [...this.players.values()].sort((a, b) => a.seat - b.seat); this.order = [...this.players.values()].sort((a, b) => a.seat - b.seat);
/**
* Exactly one human seat at the start (独立模拟, or a 同盟 room started alone / with AI teammates only): nobody waits
* on anybody, so no phase outside a battle is timed — soloUntimed (user playtest #4 item 3). The mode's own rules
* (draft order and skip, 6 机变 cards, 联防 …) stay.
*/
this.loneHuman = this.order.filter((p) => !p.isBot).length === 1;
// per-match setup (DESIGN §6.5) // per-match setup (DESIGN §6.5)
const setup = setupMatchWaves(this.gd, this.rngSetup); const setup = setupMatchWaves(this.gd, this.rngSetup);
@@ -559,9 +577,11 @@ export class Match {
* Solo timers (research 01 §843 / 06 §3: 下半 独立模拟 has no time limit on 休整期 / 机变 — "休整期及机变阶段没有时间 * Solo timers (research 01 §843 / 06 §3: 下半 独立模拟 has no time limit on 休整期 / 机变 — "休整期及机变阶段没有时间
* 限制"; the strategy draft is free too): a solo match publishes a deadline ONLY for its battles. INFO_CHECK waits * 限制"; the strategy draft is free too): a solo match publishes a deadline ONLY for its battles. INFO_CHECK waits
* for 准备就绪 (co-op keeps the official 25 s guard), BAND_DRAFT / SP_DRAFT / PREP are untimed, and the fixed * for 准备就绪 (co-op keeps the official 25 s guard), BAND_DRAFT / SP_DRAFT / PREP are untimed, and the fixed
* presentation steps (BATTLE_CHECK, ROUND_START, SETTLE) run silently (no countdown). * presentation steps (BATTLE_CHECK, ROUND_START, SETTLE) run silently (no countdown). The same holds for any match
* with a single human (loneHuman: a 同盟 room started alone or with AI teammates only — user playtest #4 item 3):
* the timers only ever made humans wait on each other; AI seats act at once.
*/ */
get soloUntimed() { return this.isSolo; } get soloUntimed() { return this.isSolo || this.loneHuman; }
nextUid() { return ++this.uidSeq; } nextUid() { return ++this.uidSeq; }
@@ -772,7 +792,11 @@ export class Match {
if (this.bossPool) v.bossHp = { hp: Math.max(0, Math.round(this.bossPool.hp)), max: Math.round(this.bossPool.maxHp) }; if (this.bossPool) v.bossHp = { hp: Math.max(0, Math.round(this.bossPool.hp)), max: Math.round(this.bossPool.maxHp) };
if (this.phase === PHASE.BAND_DRAFT && this.draft) { if (this.phase === PHASE.BAND_DRAFT && this.draft) {
const d = this.draft; const d = this.draft;
v.draft = { order: d.order.slice(), turn: this.draftTurn(), picks: { ...d.picks }, skipsLeft: { ...d.skipsLeft }, turnDeadline: d.turnDeadline || 0, untimed: !!d.untimed }; // turnSeconds: the length of a turn (the countdown gauge's total; 0 when untimed) — deadline = turnDeadline
v.draft = {
order: d.order.slice(), turn: this.draftTurn(), picks: { ...d.picks }, skipsLeft: { ...d.skipsLeft }, turnDeadline: d.turnDeadline || 0,
turnSeconds: d.untimed ? 0 : this.bandTurnMs() / 1000, untimed: !!d.untimed,
};
} }
if (this.phase === PHASE.SP_DRAFT && this.sp) { if (this.phase === PHASE.SP_DRAFT && this.sp) {
const s = this.sp; const s = this.sp;
@@ -856,6 +880,8 @@ export class Match {
return OK; return OK;
case 'g.band': return this.pickBand(ps, msg.bandId); case 'g.band': return this.pickBand(ps, msg.bandId);
case 'g.bandSkip': return this.skipBand(ps); case 'g.bandSkip': return this.skipBand(ps);
// the strategy highlighted in the draft screen (what a timed-out turn takes, timeoutBand)
case 'g.bandFocus': return this.bandFocus(ps, msg.bandId ?? null);
case 'g.buy': return ps.buy(msg.slot); case 'g.buy': return ps.buy(msg.slot);
case 'g.refresh': return ps.refresh(); case 'g.refresh': return ps.refresh();
case 'g.freeze': return ps.freeze(); case 'g.freeze': return ps.freeze();
@@ -873,6 +899,8 @@ export class Match {
case 'g.watch': return this.watch(ps, msg.fieldId); case 'g.watch': return this.watch(ps, msg.fieldId);
case 'g.autoplay': return this.setAutoplay(ps, !!msg.on); case 'g.autoplay': return this.setAutoplay(ps, !!msg.on);
case 'g.pause': return this.setPause(ps, !!msg.on); case 'g.pause': return this.setPause(ps, !!msg.on);
// the stats the board's units start their next battle with (the detail card in prep, user playtest #4 item 7)
case 'g.unitStats': return this.unitStats(ps, msg.seq ?? null);
case 'g.leave': this.onLeave(ps.playerId); return OK; case 'g.leave': this.onLeave(ps.playerId); return OK;
case 'b.progress': return this._onProgress(ps, msg); case 'b.progress': return this._onProgress(ps, msg);
case 'b.result': return this._onResult(ps, msg); case 'b.result': return this._onResult(ps, msg);
@@ -924,6 +952,55 @@ export class Match {
return OK; return OK;
} }
/**
* g.unitStats { seq? } (user playtest #4 item 7: the detail card showed fixed record stats): the stats every unit of
* the player's board will fight with at the start of its next battle — equipment, bonds and their layers, 特质, the
* band and 机变 effects — computed exactly by the shared sim. The player's battle input after the onBattleStart meta
* handlers (`ev.preview: true`, no enemies — those handlers must not change the match for a preview) builds a Battle
* of the battle's options that is started (initial deployment + battleStart hooks), read and dropped: it is never
* stepped, so skills and timed effects do not show. Pushed to the player as `m.unitStats { seq, round, units }`
* (units: shared/protocol.js unitStatsEntry, board operators and summons by uid); cached per input (a build costs
* ≈ 0.3–0.7 ms). Prep phases only (ROUND_START, 机变, PREP); a battle's live stats come from the browser's own sim.
* @param {PlayerState} ps @param {number|null} seq echoed (the client keeps the newest answer)
*/
unitStats(ps, seq = null) {
if (!ps.alive) return fail(ERR.ELIMINATED);
if (this.phase !== PHASE.ROUND_START && this.phase !== PHASE.SP_DRAFT && this.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE);
const units = this._unitStatsOf(ps);
this.sendTo(ps.playerId, { t: 'm.unitStats', seq: Number.isInteger(seq) ? seq : null, round: this.round, units });
return OK;
}
/** The start-of-battle stats of a player's board units (see unitStats); [] when the preview battle cannot be built. */
_unitStatsOf(ps) {
const input = ps.battleInput({ side: 'L', colOffset: 0 });
const ev = { input, kind: 'normal', round: this.round, preview: true };
this.dispatch(ps, 'onBattleStart', ev);
const players = [ev.input && typeof ev.input === 'object' ? ev.input : input];
let key = null;
try { key = JSON.stringify([this.round, this.stageId, this.battleContent, players]); } catch { key = null; }
if (!this._unitStatsCache) this._unitStatsCache = new WeakMap(); // PlayerState → { key, units } (the last preview)
const cached = this._unitStatsCache.get(ps);
if (key && cached && cached.key === key) return cached.units;
const units = [];
// the flags of the battle it previews: a normal round gains IN_BATTLE layers from its start (a <战斗开始时> layer gain
// raises bond stats at t = 0 there too); the Final Assault / Hidden Core fight without (previewed as a normal field)
const bossRound = this.round === this.gd.bossRound || this.round === this.gd.hiddenRound;
const b = this.newBattle({
seed: deriveSeed(this.seed, `preview:${this.round}:${ps.seat}`), kind: 'normal', modeId: this.modeId, round: this.round,
stageId: this.stageId, rect: { ...GEO.NORMAL_RECT }, timeLimit: 60, players, spawns: [], routes: this.wave ? this.wave.routes : [],
sharedBoss: null, flags: { layerGainsEnabled: !bossRound, ...this.gd.dp }, fieldId: `n:${ps.playerId}`, recordEvents: false,
});
try {
if (typeof b.start === 'function') b.start();
for (const u of Array.isArray(b.allyUnits) ? b.allyUnits : []) {
if (u && Number.isInteger(u.uid) && (u.kind === 'op' || u.kind === 'token')) units.push(unitStatsEntry(u, u.s));
}
} catch (e) { this.reportError('unitStats', e); }
this._unitStatsCache.set(ps, { key, units });
return units;
}
/** /**
* Solo pause (official PauseUp / ResumeUp; DESIGN §14 "Solo pause"): g.pause { on } freezes the running battle — the * Solo pause (official PauseUp / ResumeUp; DESIGN §14 "Solo pause"): g.pause { on } freezes the running battle — the
* field clock (b.start `elapsed`, the boss budgets and overtime), the result deadline / server release timers, the * field clock (b.start `elapsed`, the boss budgets and overtime), the result deadline / server release timers, the
@@ -1022,20 +1099,26 @@ export class Match {
// =================================================================================================== // ===================================================================================================
// BAND_DRAFT // BAND_DRAFT
/**
* The strategy draft (user playtest #4 item 4): ONE countdown — every turn has the same clock, BAND_TURN_SECONDS, and
* m.public.deadline is the current turn's end (= draft.turnDeadline; the step header and the turn indicator show the
* same number). No separate step cap: the turns bound the step (≤ (seats + skips) × turn). AI seats pick at once. A
* turn that runs out takes the strategy the player has highlighted (g.bandFocus) while it is free, else the default
* (timeoutBand). Solo, and any single-human match (soloUntimed): untimed. Solo also keeps seat order and has no skip.
*/
enterBandDraft() { enterBandDraft() {
if (this.phase !== PHASE.INFO_CHECK) return; if (this.phase !== PHASE.INFO_CHECK) return;
this.phase = PHASE.BAND_DRAFT; this.phase = PHASE.BAND_DRAFT;
const order = this.order.map((p) => p.playerId); const order = this.order.map((p) => p.playerId);
const untimed = this.isSolo; if (!this.isSolo) this.rngDraft.shuffle(order);
if (!untimed) this.rngDraft.shuffle(order); const skips = this.isSolo ? 0 : this.gd.bandDraft.skipsPerPlayer;
const skips = this.gd.bandDraft.skipsPerPlayer; const untimed = this.soloUntimed;
this.draft = { order, idx: 0, picks: {}, skipsLeft: Object.fromEntries(order.map((pid) => [pid, untimed ? 0 : skips])), untimed, turnDeadline: 0 }; this.draft = {
if (!untimed) { order, idx: 0, picks: {}, skipsLeft: Object.fromEntries(order.map((pid) => [pid, skips])), untimed, turnDeadline: 0,
// overall step cap (50 s): everyone still unpicked gets the default /** playerId → the strategy highlighted in the draft screen (g.bandFocus) */
this.setDeadline(this.gd.timer('bandDraft'), () => this.finishBandDraft(true)); focus: new Map(),
} else { };
this.setDeadline(0); this.setDeadline(0);
}
this.startDraftTurn(); this.startDraftTurn();
this.markPublic(); this.markPublic();
} }
@@ -1046,30 +1129,44 @@ export class Match {
return d.order[d.idx] ?? null; return d.order[d.idx] ?? null;
} }
/** Real ms of one strategy-draft turn (BAND_TURN_SECONDS × timerScale). */
bandTurnMs() { return this.scaled(BAND_TURN_SECONDS * 1000); }
startDraftTurn() { startDraftTurn() {
const d = this.draft; const d = this.draft;
this.cancel(this._turnTimer); this.cancel(this._turnTimer);
this._turnTimer = null; this._turnTimer = null;
while (d.idx < d.order.length && d.picks[d.order[d.idx]]) d.idx++; while (d.idx < d.order.length && d.picks[d.order[d.idx]]) d.idx++;
if (d.idx >= d.order.length) { this.later(0, () => this.finishBandDraft(false)); return; } if (d.idx >= d.order.length) {
d.turnDeadline = 0;
this.deadline = 0;
this.later(0, () => this.finishBandDraft(false));
return;
}
const token = ++this._turnToken; const token = ++this._turnToken;
if (!d.untimed) { if (!d.untimed) {
const ms = this.scaled(this.gd.timer('bandTurn') * 1000); const ms = this.bandTurnMs();
d.turnDeadline = this.sched.now() + ms; d.turnDeadline = this.sched.now() + ms;
// the step's countdown IS the turn's (one number everywhere)
this.deadline = d.turnDeadline;
this._turnTimer = this.later(ms, () => { this._turnTimer = this.later(ms, () => {
if (this.phase !== PHASE.BAND_DRAFT || token !== this._turnToken) return; if (this.phase !== PHASE.BAND_DRAFT || token !== this._turnToken) return;
const pid = this.draftTurn(); const pid = this.draftTurn();
if (pid) this._applyBand(this.players.get(pid), this.defaultBand(pid)); if (pid) this._applyBand(this.players.get(pid), this.timeoutBand(pid));
}); });
} else {
d.turnDeadline = 0;
this.deadline = 0;
} }
const cur = this.players.get(this.draftTurn()); const cur = this.players.get(this.draftTurn());
if (cur && cur.botControlled) this.scheduleBandBot(); if (cur && cur.botControlled) this.scheduleBandBot();
this.markPublic(); this.markPublic();
} }
/** An AI seat's (or an AI 托管 seat's) turn: it picks at once (user playtest #4 item 4 — nobody waits on the AI). */
scheduleBandBot() { scheduleBandBot() {
const token = this._turnToken; const token = this._turnToken;
this.later(this.scaled(DELAYS.BOT_ACTION), () => { this.later(0, () => {
if (this.phase !== PHASE.BAND_DRAFT || token !== this._turnToken) return; if (this.phase !== PHASE.BAND_DRAFT || token !== this._turnToken) return;
const ps = this.players.get(this.draftTurn()); const ps = this.players.get(this.draftTurn());
if (!ps || !ps.botControlled) return; if (!ps || !ps.botControlled) return;
@@ -1084,7 +1181,7 @@ export class Match {
/** /**
* Whether `bandId` was already picked by another player of this draft. Research 09 §5 / DESIGN §14 corrections: * Whether `bandId` was already picked by another player of this draft. Research 09 §5 / DESIGN §14 corrections:
* the strategy draft marks a teammate's pick as 队友已选 and it cannot be chosen again (co-op). The automatic * the strategy draft marks a teammate's pick as 队友已选 and it cannot be chosen again (co-op). The automatic
* assignments — the 12 s turn timeout, the 50 s step cap and a departing seat — obey the same rule: see defaultBand. * assignments — a turn that runs out and a departing seat — obey the same rule: see timeoutBand / defaultBand.
*/ */
bandTaken(bandId, playerId) { bandTaken(bandId, playerId) {
const picks = this.draft?.picks || {}; const picks = this.draft?.picks || {};
@@ -1093,10 +1190,11 @@ export class Match {
} }
/** /**
* The strategy an automatic assignment gives `playerId` (12 s turn timeout, 50 s step cap, departing seat): the official * The strategy an automatic assignment gives `playerId` (a departing seat; a timed-out turn without a usable
* default 「华法琳」 (bandDraft.timeoutBandId) while no teammate holds it, else the first strategy of the mode (sortId * highlight, timeoutBand): the official default 「华法琳」 (bandDraft.timeoutBandId) while no teammate holds it, else the
* order, gd.bandIds) that nobody else picked — never a duplicate (队友已选; the client shows the same choice: * first strategy of the mode (sortId order, gd.bandIds) that nobody else picked — never a duplicate (队友已选; the
* public/js/screens/bandDraft.js timeoutBand). Solo drafts have no teammates, so it is always the default. * client shows the same choice: public/js/screens/bandDraft.js timeoutBand). Solo drafts have no teammates, so it is
* always the default.
* @param {string} playerId * @param {string} playerId
*/ */
defaultBand(playerId) { defaultBand(playerId) {
@@ -1105,6 +1203,33 @@ export class Match {
return this.gd.bandIds().find((b) => !this.bandTaken(b, playerId)) || def; return this.gd.bandIds().find((b) => !this.bandTaken(b, playerId)) || def;
} }
/**
* What a turn that runs out assigns (user playtest #4 item 4): the strategy the player has highlighted in the draft
* screen (g.bandFocus — the detail pane's band, the one 确认选择 would take) while it is allowed and no teammate holds
* it, else defaultBand.
* @param {string} playerId
*/
timeoutBand(playerId) {
const f = this.draft && this.draft.focus instanceof Map ? this.draft.focus.get(playerId) : null;
if (typeof f === 'string' && this.gd.bandAllowed(f) && !this.bandTaken(f, playerId)) return f;
return this.defaultBand(playerId);
}
/**
* g.bandFocus { bandId? }: the strategy the player highlights in the draft screen (any time before its pick; also
* while waiting for its turn). A missing / null bandId clears it. Only a timed-out turn reads it (timeoutBand).
*/
bandFocus(ps, bandId) {
if (this.phase !== PHASE.BAND_DRAFT || !this.draft) return fail(ERR.WRONG_PHASE);
const d = this.draft;
if (d.picks[ps.playerId]) return fail(ERR.ALREADY);
if (!(d.focus instanceof Map)) d.focus = new Map();
if (bandId == null) { d.focus.delete(ps.playerId); return OK; }
if (typeof bandId !== 'string' || !this.gd.bandAllowed(bandId)) return fail(ERR.BAD_TARGET);
d.focus.set(ps.playerId, bandId);
return OK;
}
pickBand(ps, bandId) { pickBand(ps, bandId) {
if (this.phase !== PHASE.BAND_DRAFT || !this.draft) return fail(ERR.WRONG_PHASE); if (this.phase !== PHASE.BAND_DRAFT || !this.draft) return fail(ERR.WRONG_PHASE);
if (this.draft.picks[ps.playerId]) return fail(ERR.ALREADY); if (this.draft.picks[ps.playerId]) return fail(ERR.ALREADY);
@@ -1131,7 +1256,7 @@ export class Match {
skipBand(ps) { skipBand(ps) {
if (this.phase !== PHASE.BAND_DRAFT || !this.draft) return fail(ERR.WRONG_PHASE); if (this.phase !== PHASE.BAND_DRAFT || !this.draft) return fail(ERR.WRONG_PHASE);
const d = this.draft; const d = this.draft;
if (d.untimed) return fail(ERR.WRONG_PHASE, 'no skip in solo'); if (this.isSolo) return fail(ERR.WRONG_PHASE, 'no skip in solo');
if (d.picks[ps.playerId]) return fail(ERR.ALREADY); if (d.picks[ps.playerId]) return fail(ERR.ALREADY);
if (this.draftTurn() !== ps.playerId) return fail(ERR.NOT_YOUR_TURN); if (this.draftTurn() !== ps.playerId) return fail(ERR.NOT_YOUR_TURN);
if (!(d.skipsLeft[ps.playerId] > 0)) return fail(ERR.ALREADY, 'no skip left'); if (!(d.skipsLeft[ps.playerId] > 0)) return fail(ERR.ALREADY, 'no skip left');
@@ -1218,8 +1343,9 @@ export class Match {
if (!draft || !alive.length) { this.enterPrep(); return; } if (!draft || !alive.length) { this.enterPrep(); return; }
this.phase = PHASE.SP_DRAFT; this.phase = PHASE.SP_DRAFT;
const order = alive.map((p) => p.playerId); const order = alive.map((p) => p.playerId);
const untimed = this.isSolo; if (!this.isSolo) this.rngDraft.shuffle(order);
if (!untimed) this.rngDraft.shuffle(order); // untimed: solo and any single-human match (soloUntimed); the co-op order / 6 cards stay
const untimed = this.soloUntimed;
this.sp = { ...draft, order, idx: 0, picks: {}, taken: {}, untimed, turnDeadline: 0 }; this.sp = { ...draft, order, idx: 0, picks: {}, taken: {}, untimed, turnDeadline: 0 };
this.setDeadline(0); this.setDeadline(0);
this.startSpTurn(); this.startSpTurn();
@@ -1404,7 +1530,8 @@ export class Match {
this.dispatch(ps, 'onPrepStart', { round: this.round }); this.dispatch(ps, 'onPrepStart', { round: this.round });
ps.recompute(); ps.recompute();
} }
const secs = this.isSolo ? null : this.gd.prepTime(this.round); // solo / single-human matches: untimed (soloUntimed); co-op: the round's prepTime
const secs = this.soloUntimed ? null : this.gd.prepTime(this.round);
this.setDeadline(secs, () => this.prepDeadline()); this.setDeadline(secs, () => this.prepDeadline());
let i = 0; let i = 0;
for (const ps of alive) if (ps.botControlled) this.scheduleBotPrep(ps, i++); for (const ps of alive) if (ps.botControlled) this.scheduleBotPrep(ps, i++);
+7 -12
View File
@@ -28,8 +28,9 @@
// absent; equipped items are otherwise locked: g.destroy refuses them), // absent; equipped items are otherwise locked: g.destroy refuses them),
// 2 identical normal items (hand/temp/equipped) merge into the golden item in the hand, items are never sold // 2 identical normal items (hand/temp/equipped) merge into the golden item in the hand, items are never sold
// (destroy for 0). consume-on-equip items resolve through the effect registry and never take a slot. // (destroy for 0). consume-on-equip items resolve through the effect registry and never take a slot.
// * Tokens: placing an owner with placeable summons (tokens.json displayType DEFAULT) sends one stack // * Tokens: placing an owner with placeable summons (tokens.json `placeable`: the talent summons 海嗣 / 狼群 / 流形)
// (deployLimit copies) to the hand; withdrawing/selling/merging the owner removes its tokens. // sends one stack (deployLimit copies) to the hand; withdrawing/selling/merging the owner removes its tokens. A
// skill's summon (赫默 医疗无人机, 巫恋 诅咒娃娃) is never a hand card: it appears in battle when the skill fires.
// * Facing (DESIGN §3, research 09 §1.2): every board piece has `dir` ∈ UP|RIGHT|DOWN|LEFT (server/sim/dir.js), set // * Facing (DESIGN §3, research 09 §1.2): every board piece has `dir` ∈ UP|RIGHT|DOWN|LEFT (server/sim/dir.js), set
// by g.move {…, dir} (absent ⇒ RIGHT) and kept across rounds. g.move onto the piece's OWN tile re-orients it in // by g.move {…, dir} (absent ⇒ RIGHT) and kept across rounds. g.move onto the piece's OWN tile re-orients it in
// place; a swap keeps the occupant's dir; pieces put on the board by effects (a merge elite taking a consumed // place; a swap keeps the occupant's dir; pieces put on the board by effects (a merge elite taking a consumed
@@ -39,8 +40,7 @@
// equal to the defaults dropped) is re-checked against this match's data (shared/protocol.js checkLoadout; a // equal to the defaults dropped) is re-checked against this match's data (shared/protocol.js checkLoadout; a
// mismatch falls back to the defaults) and kept frozen; bots always use the defaults. Match.setLoadout may replace // mismatch falls back to the defaults) and kept frozen; bots always use the defaults. Match.setLoadout may replace
// it during INFO_CHECK only. battleInput() resolves every chess unit to `skillIndex` + `moduleId` (resolveLoadout: // it during INFO_CHECK only. battleInput() resolves every chess unit to `skillIndex` + `moduleId` (resolveLoadout:
// normal chess → moduleId null, elite → uniEquipId | 'none'); m.private exposes `loadout`. A placeable summon of a // normal chess → moduleId null, elite → uniEquipId | 'none'); m.private exposes `loadout`.
// skill (赫默 S2 医疗无人机) is granted only with that skill equipped (grantTokensFor).
import { ERR, GEO, PHASE } from '../../shared/constants.js'; import { ERR, GEO, PHASE } from '../../shared/constants.js';
import { checkLoadout, resolveLoadout } from '../../shared/protocol.js'; import { checkLoadout, resolveLoadout } from '../../shared/protocol.js';
@@ -345,18 +345,13 @@ export class PlayerState {
} }
/** /**
* Owner placed on the board: send its placeable summons to the hand (one stack per token type). A summon that comes * Owner placed on the board: send its placeable summons to the hand (one stack per token type). Only talent summons
* from a skill (SkillRecord.overrideTokenKey, e.g. 赫默 S2 医疗无人机 "获得一个医疗无人机") is granted only while that * are placeable (gamedata.placeableTokens / tokens.json `placeable`); a summon a skill makes ("获得一个医疗无人机",
* skill is the one equipped under the player's loadout (DESIGN §16) — 赫默 on S1 has no drone. * 赫默 S2) appears in battle when that skill fires (user playtest #4: not on the field from the start).
*/ */
grantTokensFor(owner) { grantTokensFor(owner) {
const rec = this.gd.chess(owner.id);
const skills = rec && Array.isArray(rec.skills) ? rec.skills.filter(Boolean) : [];
const sel = skills.length ? this.loadoutFor(rec).skillIndex : null;
for (const { tokenId, count } of this.gd.placeableTokens(owner.id)) { for (const { tokenId, count } of this.gd.placeableTokens(owner.id)) {
if (this._hasTokensOf(owner.uid, tokenId)) continue; if (this._hasTokensOf(owner.uid, tokenId)) continue;
const bySkill = skills.filter((s) => s.overrideTokenKey === tokenId);
if (bySkill.length && !bySkill.some((s) => s.index === sel)) continue;
const t = this.newPiece('token', tokenId, { count, ownerUid: owner.uid }); const t = this.newPiece('token', tokenId, { count, ownerUid: owner.uid });
this.stow(t, { allowTemp: true }); this.stow(t, { allowTemp: true });
} }
+15 -6
View File
@@ -29,14 +29,18 @@
// after a win when hiddenEligible() holds // after a win when hiddenEligible() holds
// result each title ≤ once, ≤ 1 title per player, onlyOnWin titles only on a win, roundsPassed per player, // result each title ≤ once, ≤ 1 title per player, onlyOnWin titles only on a win, roundsPassed per player,
// Σ alive players' LP = the merged team LP after the Final Assault // Σ alive players' LP = the merged team LP after the Final Assault
// deadlines every timed phase's m.public deadline equals its configured duration × timerScale (a solo match // deadlines every timed phase's m.public deadline equals its configured duration × timerScale; the co-op
// runs BATTLE_CHECK / ROUND_START / SETTLE silently: deadline 0, Match.soloUntimed) // strategy draft has one countdown: the deadline is the current turn's (Match.BAND_TURN_SECONDS). A match
// with a single human (solo, or a 同盟 room with AI teammates only: Match.soloUntimed) times nothing
// outside its battles — no INFO_CHECK / draft / 机变 / prep deadline, BATTLE_CHECK / ROUND_START / SETTLE
// silent (deadline 0)
// Checks never throw into the match: an exception inside a check is itself recorded as a violation. // Checks never throw into the match: an exception inside a check is itself recorded as a violation.
import { PHASE } from '../../shared/constants.js'; import { PHASE } from '../../shared/constants.js';
import { collectViolations } from './invariants.js'; import { collectViolations } from './invariants.js';
import { pairPlayers, bossPoolHp, hiddenEligible } from './finalAssault.js'; import { pairPlayers, bossPoolHp, hiddenEligible } from './finalAssault.js';
import { helperOrder } from './unite.js'; import { helperOrder } from './unite.js';
import { BAND_TURN_SECONDS } from './Match.js';
/** /**
* @param {import('./Match.js').Match} m * @param {import('./Match.js').Match} m
@@ -236,7 +240,8 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
wrap(m, 'enterInfoCheck', function (orig) { wrap(m, 'enterInfoCheck', function (orig) {
const r = orig(); const r = orig();
runInvariants(); runInvariants();
if (m.phase === PHASE.INFO_CHECK && m.deadline) expectDeadline(gd.timer('infoCheck'), 'INFO_CHECK'); if (m.phase === PHASE.INFO_CHECK && m.soloUntimed) check('briefing', () => { if (m.deadline) fail('untimed briefing is timed'); });
else if (m.phase === PHASE.INFO_CHECK && m.deadline) expectDeadline(gd.timer('infoCheck'), 'INFO_CHECK');
return r; return r;
}); });
wrap(m, 'enterBandDraft', function (orig) { wrap(m, 'enterBandDraft', function (orig) {
@@ -247,7 +252,11 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
const d = m.draft; const d = m.draft;
const ids = m.order.map((p) => p.playerId).sort(); const ids = m.order.map((p) => p.playerId).sort();
if (JSON.stringify(d.order.slice().sort()) !== JSON.stringify(ids)) fail(`draft order ${d.order} != seats ${ids}`); if (JSON.stringify(d.order.slice().sort()) !== JSON.stringify(ids)) fail(`draft order ${d.order} != seats ${ids}`);
if (m.isSolo) { if (m.deadline) fail('solo band draft is timed'); } else expectDeadline(gd.timer('bandDraft'), 'BAND_DRAFT'); // one countdown (user playtest #4 item 4): the step's deadline IS the current turn's, BAND_TURN_SECONDS long
if (m.soloUntimed) { if (m.deadline || d.turnDeadline) fail('untimed band draft is timed'); } else {
if (m.deadline !== d.turnDeadline) fail(`BAND_DRAFT: deadline ${m.deadline} is not the turn's ${d.turnDeadline}`);
expectDeadline(BAND_TURN_SECONDS, 'BAND_DRAFT turn');
}
}); });
return r; return r;
}); });
@@ -280,7 +289,7 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
if (m.phase !== PHASE.SP_DRAFT || !m.sp) return; if (m.phase !== PHASE.SP_DRAFT || !m.sp) return;
const s = m.sp; const s = m.sp;
if (s.idx >= s.order.length) return; if (s.idx >= s.order.length) return;
if (m.isSolo) { if (m.deadline) fail('solo 机变 is timed'); } else expectDeadline(s.idx === 0 ? gd.timer('spFirst') : gd.timer('spTurn'), `SP_DRAFT turn ${s.idx}`); if (m.soloUntimed) { if (m.deadline) fail('untimed 机变 is timed'); } else expectDeadline(s.idx === 0 ? gd.timer('spFirst') : gd.timer('spTurn'), `SP_DRAFT turn ${s.idx}`);
}); });
return res; return res;
}); });
@@ -306,7 +315,7 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
runInvariants(); runInvariants();
check('prep', () => { check('prep', () => {
if (m.phase !== PHASE.PREP) return; if (m.phase !== PHASE.PREP) return;
if (m.isSolo) { if (m.deadline) fail('solo prep is timed'); } else if (!m._prepEndQueued) expectDeadline(gd.prepTime(m.round), 'PREP'); if (m.soloUntimed) { if (m.deadline) fail('untimed prep is timed'); } else if (!m._prepEndQueued) expectDeadline(gd.prepTime(m.round), 'PREP');
}); });
return res; return res;
}); });
+13 -4
View File
@@ -12,6 +12,7 @@
// that still carries them is ignored). // that still carries them is ignored).
import { getConfig, getMode } from '../data.js'; import { getConfig, getMode } from '../data.js';
import { isShopItem } from '../sim/simdata.js';
const own = (map, id) => (map && typeof map === 'object' && typeof id === 'string' && Object.hasOwn(map, id) && map[id] && typeof map[id] === 'object' ? map[id] : null); const own = (map, id) => (map && typeof map === 'object' && typeof id === 'string' && Object.hasOwn(map, id) && map[id] && typeof map[id] === 'object' ? map[id] : null);
const numOr = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d); const numOr = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
@@ -44,7 +45,7 @@ export const DEFAULTS = Object.freeze({
hiddenCore: { single: 350, multi: 1200, minTeamLpExclusive: 1, difficulties: ['NORMAL', 'HARD', 'ABYSS'] }, hiddenCore: { single: 350, multi: 1200, minTeamLpExclusive: 1, difficulties: ['NORMAL', 'HARD', 'ABYSS'] },
dp: { init: 10, perSec: 1, max: 99 }, dp: { init: 10, perSec: 1, max: 99 },
unite: { maxHelpers: 2, templates: { 1: 'act1autochess_escaped_single', 2: 'act1autochess_escaped_multi' } }, unite: { maxHelpers: 2, templates: { 1: 'act1autochess_escaped_single', 2: 'act1autochess_escaped_multi' } },
timers: { infoCheck: 25, bandDraft: 50, bandTurn: 12, battleCheck: 3, spFirst: 30, spTurn: 16 }, timers: { infoCheck: 25, bandDraft: 50, bandTurn: 30, battleCheck: 3, spFirst: 30, spTurn: 16 },
bans: { FUNNY: { core: 0, addon: 1 }, NORMAL: { core: 3, addon: 4 }, HARD: { core: 3, addon: 4 }, ABYSS: { core: 3, addon: 4 } }, bans: { FUNNY: { core: 0, addon: 1 }, NORMAL: { core: 3, addon: 4 }, HARD: { core: 3, addon: 4 }, ABYSS: { core: 3, addon: 4 } },
bandDraft: { skipsPerPlayer: 1, timeoutBandId: 'band_bldsk' }, bandDraft: { skipsPerPlayer: 1, timeoutBandId: 'band_bldsk' },
leftoverFundsKeptByBands: ['band_cannot'], leftoverFundsKeptByBands: ['band_cannot'],
@@ -76,10 +77,14 @@ export class GameData {
const c = chess[id]; const c = chess[id];
return c && c.visible && !c.isGolden && !c.isDiy && !c.isHidden && Number.isInteger(c.tier); return c && c.visible && !c.isGolden && !c.isDiy && !c.isHidden && Number.isInteger(c.tier);
}).sort(); }).sort();
/** shop-eligible normal EQUIP item ids by tier */ /**
* Shop item ids by tier (sim/simdata.js isShopItem: normal EQUIP, not hidden, not effect-only — the special
* 维式重锤 and 突变细胞 are never sold). Every "shop item" draw uses it: the shop item slot (pool.js), the 道具补给 /
* 机密商店 cards (choices.js) and the shop-eligible item pools (Match.rollItemId).
*/
this.shopItemsByTier = {}; this.shopItemsByTier = {};
for (const [id, it] of Object.entries(this._items)) { for (const [id, it] of Object.entries(this._items)) {
if (!it || it.isGolden || it.hideInShop || it.itemType !== 'EQUIP' || !Number.isInteger(it.tier)) continue; if (!isShopItem(it)) continue;
(this.shopItemsByTier[it.tier] ||= []).push(id); (this.shopItemsByTier[it.tier] ||= []).push(id);
} }
for (const k of Object.keys(this.shopItemsByTier)) this.shopItemsByTier[k].sort(); for (const k of Object.keys(this.shopItemsByTier)) this.shopItemsByTier[k].sort();
@@ -434,7 +439,11 @@ export class GameData {
return b && Number.isInteger(b.totalHp) && b.totalHp > 0 ? b.totalHp : this.defaultStartLp; return b && Number.isInteger(b.totalHp) && b.totalHp > 0 ? b.totalHp : this.defaultStartLp;
} }
/** Placeable (hand) tokens a chess sends to the hand when placed on the board: [{ tokenId, count }]. */ /**
* Placeable (hand) tokens a chess sends to the hand when placed on the board: [{ tokenId, count }] — its TALENT
* summons (tokens.json `placeable`: 海嗣 / 狼群 / 流形); a skill's summon (赫默's drone, 巫恋's doll) only appears in
* battle when the skill fires (tools/build-data.mjs buildTokens).
*/
placeableTokens(chessId) { placeableTokens(chessId) {
const c = this.chess(chessId); const c = this.chess(chessId);
if (!c || !Array.isArray(c.tokens)) return []; if (!c || !Array.isArray(c.tokens)) return [];
+47 -5
View File
@@ -18,13 +18,13 @@
// Robustness: every content callback and every step phase is wrapped; errors are logged once per key and the // Robustness: every content callback and every step phase is wrapped; errors are logged once per key and the
// battle continues. After MAX_INTERNAL_ERRORS the battle force-ends as a timeout. // battle continues. After MAX_INTERNAL_ERRORS the battle force-ends as a timeout.
import { TICK, ROWS, COLS, DP_DEFAULTS, MAX_BATTLE_TIME, MAX_INTERNAL_ERRORS, COLD_FREEZE_DURATION, OBSTACLE_DEVICES, EVENT_BUFFER_CAP, BOSS_ROW_OFFSET, MAX_HOOK_DEPTH, MAX_ALIVE_ENEMIES, LEVITATE_HALF_WEIGHT, RESIST_DEFAULT, RESIST_PALSY_DECAY } from './constants.js'; import { TICK, ROWS, COLS, DP_DEFAULTS, DOWN_STATE, MAX_BATTLE_TIME, MAX_INTERNAL_ERRORS, COLD_FREEZE_DURATION, OBSTACLE_DEVICES, EVENT_BUFFER_CAP, BOSS_ROW_OFFSET, MAX_HOOK_DEPTH, MAX_ALIVE_ENEMIES, LEVITATE_HALF_WEIGHT, RESIST_DEFAULT, RESIST_PALSY_DECAY } from './constants.js';
import { GEO } from '../../shared/constants.js'; import { GEO } from '../../shared/constants.js';
import { createRng } from './rng.js'; import { createRng } from './rng.js';
import { Grid } from './grid.js'; import { Grid } from './grid.js';
import { Unit } from './units.js'; import { Unit } from './units.js';
import { makeBuff, STATUS, RESIST_STATUSES } from './buffs.js'; import { makeBuff, STATUS, RESIST_STATUSES } from './buffs.js';
import { dealDamage as pipeDamage, heal as pipeHeal, applyHpLoss, makeDamageInfo, reduceElement, palsyBuff } from './damage.js'; import { dealDamage as pipeDamage, heal as pipeHeal, applyHpLoss, makeDamageInfo, reduceElement, palsyBuff, elementView } from './damage.js';
import { absoluteRangeKeys, canTargetEnemy } from './targeting.js'; import { absoluteRangeKeys, canTargetEnemy } from './targeting.js';
import { normDir, mirrorDir, localOrder, localBefore } from './dir.js'; import { normDir, mirrorDir, localOrder, localBefore } from './dir.js';
import { ProjectileSystem } from './projectiles.js'; import { ProjectileSystem } from './projectiles.js';
@@ -1824,7 +1824,12 @@ export class Battle {
return ev; return ev;
} }
/** Compact full snapshot of this field (DESIGN §8.2 b.snap). */ /**
* Compact full snapshot of this field (DESIGN §8.2 b.snap), plus (only when non-empty):
* down: [[id, respawnAt, respawnTime, state]] — knocked-out operators waiting to redeploy on their own tile
* (isDown): the game time their respawn timer ends, its length (s) and constants.js DOWN_STATE;
* elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView).
*/
snapshot() { snapshot() {
const snap = { const snap = {
fieldId: this.fieldId, fieldId: this.fieldId,
@@ -1839,14 +1844,51 @@ export class Battle {
for (const p of this.players) snap.dps[p.playerId] = Math.floor(p.dp); for (const p of this.players) snap.dps[p.playerId] = Math.floor(p.dp);
} }
if (this.sharedBoss) snap.boss = { hp: Math.max(0, Math.round(this.sharedBoss.hp)), max: Math.round(this.sharedBoss.maxHp) }; if (this.sharedBoss) snap.boss = { hp: Math.max(0, Math.round(this.sharedBoss.hp)), max: Math.round(this.sharedBoss.maxHp) };
const r2 = (v) => Math.round(v * 100) / 100;
let down = null;
for (const u of this.allyUnits) {
if (!this.isDown(u)) continue;
(down || (down = [])).push([u.id, r2(u.respawnAt), r2(Math.max(0, u.respawnAt - u.deathAt)), this._downState(u)]);
}
if (down) snap.down = down;
let elem = null;
for (const u of this.units) {
if (!u.alive || !u.deployed || u.hidden) continue;
const v = elementView(u, this.time);
if (v) (elem || (elem = [])).push([u.id, v[0], v[1], v[2], v[3]]);
}
if (elem) snap.elem = elem;
return snap; return snap;
} }
/** Field meta for m.field: { fieldId, kind, rect, stageId, units: UnitInfo[] } */ /**
* A knocked-out operator waiting to redeploy on its own tile (DESIGN §5.5: after its respawn time, when the tile is
* free and DP ≥ cost): killed — not withdrawn, not removed for good — after it was deployed. The client keeps its
* model on the field knocked down with a redeploy countdown (b.snap `down`, render/units.js); summons, devices and
* enemies simply leave.
*/
isDown(u) {
return !!u && u.side === 'ally' && u.kind === 'op' && !u.alive && !u.removed && u.removeReason === 'killed'
&& u.deploySeq > 0 && Number.isFinite(u.respawnAt);
}
/** constants.js DOWN_STATE of a down operator: its timer runs, or it waits for its tile / the DP (_checkRedeploys). */
_downState(u) {
if (this.time + 1e-9 < u.respawnAt) return DOWN_STATE.COUNTING;
const occ = this._occ[u.homeR * COLS + u.homeC];
if (occ && occ.alive && occ !== u) return DOWN_STATE.WAIT_TILE;
const ps = this.getPlayer(u.ownerId);
return !ps || ps.dp + 1e-9 < u.base.cost ? DOWN_STATE.WAIT_DP : DOWN_STATE.COUNTING;
}
/**
* Field meta for m.field: { fieldId, kind, rect, stageId, units: UnitInfo[] } — the units on the field, knocked-out
* operators waiting to redeploy included (a client joining mid-battle shows them down).
*/
fieldMeta() { fieldMeta() {
return { return {
fieldId: this.fieldId, kind: this.kind, rect: { ...this.rect }, stageId: this.stageId, fieldId: this.fieldId, kind: this.kind, rect: { ...this.rect }, stageId: this.stageId,
units: this.units.filter((u) => u.alive && u.deployed && !u.hidden).map(unitInfo), units: this.units.filter((u) => (u.alive && u.deployed && !u.hidden) || this.isDown(u)).map(unitInfo),
}; };
} }
+6 -1
View File
@@ -618,8 +618,13 @@ function enemyAttack(b, e) {
const attackId = ++b._attackSeq; const attackId = ++b._attackSeq;
const type = def.dmgType === 'heal' ? 'arts' : def.dmgType; const type = def.dmgType === 'heal' ? 'arts' : def.dmgType;
const rangedShot = radius > 0 && !(e.blockedBy && targets[0] === e.blockedBy && radius < 1); const rangedShot = radius > 0 && !(e.blockedBy && targets[0] === e.blockedBy && radius < 1);
// content-resolved attacks (`e.profile.deferHit`: 帝国炮火先兆者's shell landing 3 s later): the attack itself happens
// — the 'atk' event (kind `e.profile.shot`, drawn by the content's own fx), cooldown, pause, the 'attack' hook — and
// the content's 'attack' handler deals its damage
const deferred = !!(e.profile && e.profile.deferHit);
for (const t of targets) { for (const t of targets) {
b._ev(['atk', e.id, t.id, rangedShot ? 'enemy' : 'none']); b._ev(['atk', e.id, t.id, deferred ? (e.profile.shot || 'none') : rangedShot ? 'enemy' : 'none']);
if (deferred) continue;
const hit = (tt) => { const hit = (tt) => {
if (!tt || !tt.alive || !e.alive && !rangedShot) return; if (!tt || !tt.alive || !e.alive && !rangedShot) return;
b.dealDamage(e, tt, { amount: e.s.atk * (e.profile?.atkScale ?? 1), type, isAttack: true, attackId }); b.dealDamage(e, tt, { amount: e.s.atk * (e.profile?.atkScale ?? 1), type, isAttack: true, attackId });
+15 -1
View File
@@ -39,6 +39,9 @@ export const ELEMENTS = Object.freeze(['burn', 'neural', 'necrosis', 'apoptosis'
* `ally` = an operator/summon hit by enemy damage (ba.dt.burning / neural / apoptosis / erosion), * `ally` = an operator/summon hit by enemy damage (ba.dt.burning / neural / apoptosis / erosion),
* `enemy` = an enemy hit by operators (the "·我方" terms ba.dt.burning2 / neural2 / apoptosis2 / erosion2; * `enemy` = an enemy hit by operators (the "·我方" terms ba.dt.burning2 / neural2 / apoptosis2 / erosion2;
* `elemDamage` is 元素伤害: HP damage ignoring DEF/RES, × elemTakenMul). * `elemDamage` is 元素伤害: HP damage ignoring DEF/RES, × elemTakenMul).
* `duration` = the burst's 爆发冷却 (PRTS 元素 table "持续时间": 10 s, 凋亡 15 s, 侵蚀 on enemies 8 s — the operators'
* 侵蚀 burst has its 10 s cooldown too): while it runs NO element of the unit fills or recovers, and when it ends every
* gauge of the unit resets (damage.js).
* `necrosis` keeps the engine's earlier invented burst (no official counterpart) for legacy content. * `necrosis` keeps the engine's earlier invented burst (no official counterpart) for legacy content.
*/ */
export const ELEMENT = Object.freeze({ export const ELEMENT = Object.freeze({
@@ -58,11 +61,16 @@ export const ELEMENT = Object.freeze({
enemy: Object.freeze({ elemDps: 800, weaken: 0.5, duration: 15 }), enemy: Object.freeze({ elemDps: 800, weaken: 0.5, duration: 15 }),
}), }),
erosion: Object.freeze({ erosion: Object.freeze({
ally: Object.freeze({ damage: 800, type: 'phys', defDown: 100, duration: 0 }), ally: Object.freeze({ damage: 800, type: 'phys', defDown: 100, duration: 10 }),
enemy: Object.freeze({ elemDamage: 5000, defDown: 120, duration: 8 }), enemy: Object.freeze({ elemDamage: 5000, defDown: 120, duration: 8 }),
}), }),
necrosis: Object.freeze({ dps: 100, duration: 12, atkDownPct: 0.2 }), necrosis: Object.freeze({ dps: 100, duration: 12, atkDownPct: 0.2 }),
}); });
/**
* Official element order (PRTS 元素: SANITY 1 神经, WATER 2 侵蚀, FIRE 3 灼燃, DARK 4 凋亡; the legacy `necrosis` last):
* the tie-break of the "当前损伤元素" a unit shows — the fullest gauge, then the lower id (damage.js elementView).
*/
export const ELEMENT_ORDER = Object.freeze(['neural', 'erosion', 'burn', 'apoptosis', 'necrosis']);
/** 麻痹 (ba.palsy): each stack cancels one normal attack of an enemy; at most 3 stacks, lasts until consumed. */ /** 麻痹 (ba.palsy): each stack cancels one normal attack of an enemy; at most 3 stacks, lasts until consumed. */
export const PALSY_MAX = 3; export const PALSY_MAX = 3;
@@ -79,6 +87,12 @@ export const FREEZE_RES_DOWN = 15;
/** Default DP rules (DESIGN §5.5), overridable by Battle opts.flags. */ /** Default DP rules (DESIGN §5.5), overridable by Battle opts.flags. */
export const DP_DEFAULTS = Object.freeze({ dpInit: 10, dpPerSec: 1, dpMax: 99 }); export const DP_DEFAULTS = Object.freeze({ dpInit: 10, dpPerSec: 1, dpMax: 99 });
/**
* State of a knocked-out operator waiting to redeploy on its own tile (b.snap `down` entries, Battle.snapshot): its
* respawn timer runs (COUNTING), then it waits for the player's DP to reach its cost (WAIT_DP) or for its tile to be
* free (WAIT_TILE). render/units.js mirrors these codes.
*/
export const DOWN_STATE = Object.freeze({ COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 });
/** Safety cap for battles with an infinite time limit (boss rounds are force-ended by the match). */ /** Safety cap for battles with an infinite time limit (boss rounds are force-ended by the match). */
export const MAX_BATTLE_TIME = 3600; export const MAX_BATTLE_TIME = 3600;
+3 -2
View File
@@ -1,7 +1,8 @@
// server/sim/content/bonds/core.js — the 8 core bonds (核心盟约, research 02 §3.1–3.8), battle side + prep side. // server/sim/content/bonds/core.js — the 8 core bonds (核心盟约, research 02 §3.1–3.8), battle side + prep side.
// //
// 炎 yanShip members ATK ×(1 + base_atk + atk_per_stack·L); 6: one “炎佑” (30 % of the 炎 ATK / max HP sums // 炎 yanShip members ATK ×(1 + base_atk + atk_per_stack·L); 6: one “炎佑” (its template ATK / max HP + 30 %
// at battle start); 9: two 炎佑, ATK ×atk, damage taken ×(1 − damage_resistance) // of the 炎 ATK / max HP sums at battle start — PRTS "增加…(最终加算)"); 9: two 炎佑, ATK ×atk,
// damage taken ×(1 − damage_resistance)
// 萨尔贡 sargonShip member skill start → every member on the field gets an independent stack (ASPD +base_attack_speed // 萨尔贡 sargonShip member skill start → every member on the field gets an independent stack (ASPD +base_attack_speed
// for base_time + time_per_stack·L s, ≤ max_buff_stack_cnt); 6: each stack also ATK +base_atk // for base_time + time_per_stack·L s, ≤ max_buff_stack_cnt); 6: each stack also ATK +base_atk
// (additive, one atkMul); 6 + band_narant: instead lends the caster's equipment (tier ≤ // (additive, one atkMul); 6 + band_narant: instead lends the caster's equipment (tier ≤
+3 -4
View File
@@ -56,6 +56,7 @@
// Attribute bonuses are 直接乘算 (support/index.js convention): ×(1 + x) multipliers; flat values stay flat. // Attribute bonuses are 直接乘算 (support/index.js convention): ×(1 + x) multipliers; flat values stay flat.
import { gameData, num, buffsOf, passiveBuff, effectRecord } from './support/index.js'; import { gameData, num, buffsOf, passiveBuff, effectRecord } from './support/index.js';
import { isShopItem } from '../simdata.js';
// ===================================================================================================================== // =====================================================================================================================
// data helpers // data helpers
@@ -166,10 +167,8 @@ export function choiceCardIds(data = gameData()) {
for (const c of Array.isArray(cards.bounty) ? cards.bounty : []) if (c && c.effectId && !bounty.includes(c.effectId)) bounty.push(c.effectId); for (const c of Array.isArray(cards.bounty) ? cards.bounty : []) if (c && c.effectId && !bounty.includes(c.effectId)) bounty.push(c.effectId);
for (const c of Array.isArray(cards.tactic) ? cards.tactic : []) if (c && c.effectId && !tactic.includes(c.effectId)) tactic.push(c.effectId); for (const c of Array.isArray(cards.tactic) ? cards.tactic : []) if (c && c.effectId && !tactic.includes(c.effectId)) tactic.push(c.effectId);
const items = []; const items = [];
for (const [id, it] of Object.entries((data && data.items) || {})) { // the 道具补给 / 机密商店 cards draw shop items only (never the effect-only special 维式重锤 / 突变细胞)
if (!it || it.isGolden || it.hideInShop || it.itemType !== 'EQUIP' || !Number.isInteger(it.tier)) continue; for (const [id, it] of Object.entries((data && data.items) || {})) if (isShopItem(it)) items.push(id);
items.push(id);
}
return { bounty: bounty.sort(), tactic: tactic.sort(), items: items.sort() }; return { bounty: bounty.sort(), tactic: tactic.sort(), items: items.sort() };
} }
+101 -26
View File
@@ -69,8 +69,14 @@ const ACBUNN_TARGETS = 3;
const ACPUPP_AURA_RADIUS = 2; const ACPUPP_AURA_RADIUS = 2;
/** 重弩突袭者 直击 reach along a row/column [ASSUMED]. */ /** 重弩突袭者 直击 reach along a row/column [ASSUMED]. */
const CROSS_REACH = 6; const CROSS_REACH = 6;
/** 暴鸰 bomb splash radius [ASSUMED]. */ /** 暴鸰 投弹: the target's tile and its 8 neighbours (PRTS "对目标及其周围八格的我方单位造成100%物理伤害"). */
const BOMB_RADIUS = 1; const BOMB_REACH = 1;
/** 帝国炮火先兆者 shell (PRTS "普通攻击向目标所在位置发射一枚于3秒后命中的弹道,弹道对半径1.2范围内的所有我方单位造成攻击力
* 100%的无来源物理伤害 … ※弹道始终使用缓存攻击力"): flight time and blast radius. */
const SHELL_FLIGHT = 3, SHELL_RADIUS = 1.2;
/** 假想敌:黑云 抓取: the blackboard radius counts ×2.5 (PRTS "2.5倍可变半径": range_radius 1.5 → 3.75), at most 3 prey,
* "短暂延迟后" the 延迟吞噬 lands (delay [ASSUMED] 0.5 s); its SP (= 全弹发射 hits) caps at the data's spData.maxSp. */
const GRAB_RADIUS_SCALE = 2.5, GRAB_MAX_PREY = 3, GRAB_DELAY = 0.5;
/** 源石污染区 aura radius of an activated 奇美拉 [ASSUMED]. */ /** 源石污染区 aura radius of an activated 奇美拉 [ASSUMED]. */
const CHIMERA_AURA_RADIUS = 1; const CHIMERA_AURA_RADIUS = 1;
/** Bombardments per 自行炮 Cannon cast ("数次") [ASSUMED]. */ /** Bombardments per 自行炮 Cannon cast ("数次") [ASSUMED]. */
@@ -616,8 +622,9 @@ const selfFear = (ab) => ({
a.done = true; a.done = true;
const fear = T(ab, 'SelfFear.fear') ?? 0; const fear = T(ab, 'SelfFear.fear') ?? 0;
if (fear > 0) b.applyStatus(e, 'fear', { duration: fear, source: e }); if (fear > 0) b.applyStatus(e, 'fear', { duration: fear, source: e });
// PRTS “萨科塔之翼/之眼”: "在5s内移动速度最终提升至150%" — move_speed 1.5 is the final multiplier
const ms = T(ab, 'SelfFear.move_speed') ?? 0, sd = T(ab, 'SelfFear.speed_duration') ?? 0; const ms = T(ab, 'SelfFear.move_speed') ?? 0, sd = T(ab, 'SelfFear.speed_duration') ?? 0;
if (ms > 0 && sd > 0) b.addBuff(e, { key: 'ab:fearRun', duration: sd, mods: { moveMul: 1 + ms }, flags: { unblockable: true } }); if (ms > 0 && sd > 0) b.addBuff(e, { key: 'ab:fearRun', duration: sd, mods: { moveMul: ms }, flags: { unblockable: true } });
}, },
}); });
@@ -1226,25 +1233,50 @@ function kitBoneSpike() {
}]; }];
} }
/**
* 假想敌:黑云 (PRTS): 抓取 (KillOthers, cd 10 / icd 5) "仅半径3.75范围内存在未持有【延迟吞噬】的敌方飞行普通单位时可触发:
* 选择至多3名满足上述条件的目标,短暂延迟后对其施加4秒【延迟吞噬】:束缚,效果结束时令给予方+1SP,随后强制击杀受予方 ※技能
* 持续4秒,期间持有束缚" (the nearest prey first [ASSUMED]); 全弹发射 (FireWeapon, cd 10 / icd 5, SP cost 1) "仅全场范围内存在
* 我方单位时可触发:清空自身SP,进行一次多连击,每击选择全场范围内的1名随机我方单位,对其造成攻击力130%的物理伤害 ※多连击的
* 连击次数等于本技能消耗的SP数量" — SP (技力上限 spData.maxSp 3) only comes from the grabs.
*/
function kitBlackCloud(ab, e) { function kitBlackCloud(ab, e) {
const k = ab.sk.KillOthers, f = ab.sk.FireWeapon; const k = ab.sk.KillOthers, f = ab.sk.FireWeapon;
const maxAmmo = Math.max(1, num(e.def.raw && e.def.raw.sp && e.def.raw.sp.maxSp, 3));
const grabR = (k ? num(k.bb.range_radius, 1.5) : 1.5) * GRAB_RADIUS_SCALE;
const dur = k ? num(k.bb.duration, 4) : 4;
const isPrey = (b, e2, o) => o !== e2 && o.alive && !o.hidden && o.isFlying && o.def.rank === 'NORMAL' && !o.isBoss && !o.findBuff('ab:devoured');
const prey = (b, e2) => b.enemiesInRadius(e2.x, e2.y, grabR).filter((o) => isPrey(b, e2, o));
return [ return [
skill(k, (b, e2, a) => { skill(k, (b, e2) => {
const prey = b.enemiesInRadius(e2.x, e2.y, k.bb.range_radius ?? 1.5).filter((o) => o !== e2 && o.isFlying && o.def.rank === 'NORMAL' && !o.isBoss); const list = prey(b, e2).sort((p, q) => Math.hypot(p.x - e2.x, p.y - e2.y) - Math.hypot(q.x - e2.x, q.y - e2.y)).slice(0, GRAB_MAX_PREY);
for (const o of prey) { if (!list.length) return;
b.addBuff(o, { key: 'ab:devoured', duration: k.bb.duration ?? 0, flags: { bind: true, noMove: true, disarm: true } }); b.addBuff(e2, { key: 'ab:grabbing', duration: dur, flags: { bind: true, noMove: true } }); // 技能持续4秒,期间持有束缚
b.after(k.bb.duration ?? 0, () => { if (o.alive && e2.alive) { b.kill(o, null); e2.mem.ab.ammo = (e2.mem.ab.ammo ?? 0) + (k.bb.sp ?? 1); } }, { owner: e2 }); b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: list[0].id, kind: 'devour' });
} b.after(GRAB_DELAY, () => {
if (prey.length) b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: prey[0].id, kind: 'devour' }); if (!e2.alive) return;
}, { sil: true }), for (const o of list) {
if (!o.alive) continue;
b.addBuff(o, { key: 'ab:devoured', duration: dur, visible: true, flags: { bind: true, noMove: true, disarm: true } });
b.after(dur, () => {
if (!o.alive || !e2.alive) return;
e2.mem.ab.ammo = Math.min(maxAmmo, (e2.mem.ab.ammo ?? 0) + num(k.bb.sp, 1));
b.kill(o, null);
}, { owner: e2 });
}
}, { owner: e2 });
}, { sil: true, cond: (b, e2) => prey(b, e2).length > 0 }),
skill(f, (b, e2) => { skill(f, (b, e2) => {
const t = byPriority(e2, targetsNear(b, e2, f.bb.range_radius ?? e2.base.rangeRadius))[0]; const hits = Math.floor(e2.mem.ab.ammo ?? 0);
const ammo = e2.mem.ab.ammo ?? 0; if (!(hits > 0)) return;
if (!t || !(ammo > 0)) return;
e2.mem.ab.ammo = 0; e2.mem.ab.ammo = 0;
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'fireWeapon', n: ammo }); for (let i = 0; i < hits; i++) {
hurt(b, e2, t, e2.s.atk * (f.bb.atk_scale ?? 1) * ammo, 'phys'); const t = b.rng.pick(allTargets(b, e2));
}, { sil: true, cond: (b, e2) => (e2.mem.ab.ammo ?? 0) > 0 && targetsNear(b, e2, f.bb.range_radius ?? e2.base.rangeRadius).length > 0 }), if (!t) break;
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'fireWeapon' });
hurt(b, e2, t, e2.s.atk * num(f.bb.atk_scale, 1), 'phys');
}
}, { sil: true, cond: (b, e2) => (e2.mem.ab.ammo ?? 0) >= Math.max(1, num(f.sp, 1)) && allTargets(b, e2).length > 0 }),
]; ];
} }
@@ -1263,6 +1295,12 @@ function kitRegen(ab) {
function kitSteal(ab) { function kitSteal(ab) {
const n = T(ab, 'DamageOrBullet.attack@minus_bullet') ?? 1; const n = T(ab, 'DamageOrBullet.attack@minus_bullet') ?? 1;
return [selfFear(ab), { return [selfFear(ab), {
// PRTS “萨科塔之眼”: "不会攻击飞行单位" (the 炎佑 is one) — its next ground target in range instead
before(c, b, e) {
if (!c.targets || !c.targets.some((t) => t.isFlying)) return;
const l = targetsNear(b, e, e.base.rangeRadius).filter((t) => !t.isFlying);
c.targets = byPriority(e, l).slice(0, 1);
},
hitOut(c, b, e) { hitOut(c, b, e) {
if (!c.dmg.isAttack) return; if (!c.dmg.isAttack) return;
const t = c.target; const t = c.target;
@@ -1287,13 +1325,48 @@ function kitRoar(ab) {
}, { sil: true })]; }, { sil: true })];
} }
/**
* 暴鸰 (PRTS): "不进行普通攻击"; 投弹 (boomb, cooldown / initCooldown 1) "仅攻击范围内存在我方单位时可触发:对目标及其周围八格
* 的我方单位造成100%物理伤害(对主目标造成物理普通伤害,对溅射目标造成物理溅射伤害,伤害无视迷彩)技能结束后移速最终提升至200%
* ※此技能仅能触发一次,不可沉默" — one bomb on the ranged target (engine priority), then move speed ×boomb.move_speed.
*/
function kitBombd(ab) { function kitBombd(ab) {
const ms = (ab.sk.boomb && ab.sk.boomb.bb.move_speed) || 0; const s = ab.sk.boomb;
const ms = (s && s.bb.move_speed) || 0;
const reach = (e) => e.base.rangeRadius || 2;
return [
{ spawn(b, e) { e.profile.noAttack = true; } },
skill(s, (b, e, a) => {
const t = byPriority(e, targetsNear(b, e, reach(e)))[0];
if (!t) return;
a.cd = Infinity; a.left = Infinity; // 仅能触发一次
b.fx('explode', { x: t.x, y: t.y, r: BOMB_REACH + 0.5, kind: 'bomb', tiles: 'box', id: e.id });
hurt(b, e, t, e.s.atk, 'phys');
for (const u of alliesInTiles(b, t.tileR, t.tileC, 'box', BOMB_REACH)) if (u !== t) hurt(b, e, u, e.s.atk, 'phys', { tags: ['splash'] });
if (ms > 0) b.addBuff(e, { key: 'ab:bombRun', mods: { moveMul: ms }, persist: true }); // 移速最终提升至200%
}, { cond: (b, e) => targetsNear(b, e, reach(e)).length > 0 }),
];
}
/**
* 帝国炮火先兆者 / 中枢先兆者 (PRTS): every normal attack fires a shell at the target's position that lands SHELL_FLIGHT s
* later and deals 100 % of the ATK at launch as physical damage without a source to every ally within SHELL_RADIUS
* (it may miss the target that moved away; camouflage does not matter). The attack itself is the engine's (cooldown,
* pause, 'atk' event of kind 'mortar'); the damage is the shell's (ai.js `profile.deferHit`).
*/
function kitShell() {
return [{ return [{
dealt(c, b, e, a) { spawn(b, e) { e.profile.deferHit = true; e.profile.shot = 'mortar'; },
b.fx('explode', { x: c.target.x, y: c.target.y, r: BOMB_RADIUS, kind: 'bomb' }); attack(c, b, e) {
for (const u of b.alliesInRadius(c.target.x, c.target.y, BOMB_RADIUS)) if (u !== c.target) hurt(b, e, u, e.s.atk, 'phys'); const atk = e.s.atk * (e.profile.atkScale ?? 1);
if (!a.fast && ms > 0) { a.fast = true; b.addBuff(e, { key: 'ab:bombRun', mods: { moveMul: 1 + ms }, persist: true }); } // 投掷之后移动速度大幅度提升 for (const t of c.targets) {
const x = t.x, y = t.y;
b.fx('bombardShell', { x, y, id: e.id, r: SHELL_RADIUS, t: SHELL_FLIGHT });
b.after(SHELL_FLIGHT, () => {
b.fx('bombard', { x, y, r: SHELL_RADIUS, kind: 'emppnt' });
for (const u of b.alliesInRadius(x, y, SHELL_RADIUS)) hurt(b, null, u, atk, 'phys', { isSkill: false, tags: ['shell'] });
});
}
}, },
}]; }];
} }
@@ -2026,12 +2099,14 @@ export const KITS = Object.freeze({
enemy_1355_mrfly: (ab, e) => [enemyAura(e.base.rangeRadius || 2.5, 'ab:mrfly', { resFlat: T(ab, 'magdef_add.magic_resistance') ?? 0 })], // 护障 · RES aura enemy_1355_mrfly: (ab, e) => [enemyAura(e.base.rangeRadius || 2.5, 'ab:mrfly', { resFlat: T(ab, 'magdef_add.magic_resistance') ?? 0 })], // 护障 · RES aura
enemy_1355_mrfly_2: (ab, e) => [enemyAura(e.base.rangeRadius || 2.5, 'ab:mrfly', { resFlat: T(ab, 'magdef_add.magic_resistance') ?? 0 })], // 护障·P · RES aura enemy_1355_mrfly_2: (ab, e) => [enemyAura(e.base.rangeRadius || 2.5, 'ab:mrfly', { resFlat: T(ab, 'magdef_add.magic_resistance') ?? 0 })], // 护障·P · RES aura
enemy_1042_frostd: (ab) => [allyAura(T(ab, 'defup.range_radius') ?? 2.5, 'ab:frost', { aspd: (T(ab, 'atkSpeedDown.attack_speed') ?? 0) * 100 })], // 寒霜 · ASPD −50 aura on operators enemy_1042_frostd: (ab) => [allyAura(T(ab, 'defup.range_radius') ?? 2.5, 'ab:frost', { aspd: (T(ab, 'atkSpeedDown.attack_speed') ?? 0) * 100 })], // 寒霜 · ASPD −50 aura on operators
enemy_1040_bombd: kitBombd, // 暴鸰 · bomb splash; speeds up after throwing enemy_1040_bombd: kitBombd, // 暴鸰 · no normal attack: ONE bomb (target + 8 tiles), then ×2 speed
enemy_10083_hlbird: kitSelfFear, // “萨科塔之翼” · fear + flee below half HP enemy_10083_hlbird: kitSelfFear, // “萨科塔之翼” · fear + flee below half HP
enemy_10084_hlegle: kitSteal, // “萨科塔之眼” · fear below half; steals 1 ammo instead of hitting enemy_10084_hlegle: kitSteal, // “萨科塔之眼” · fear below half; steals 1 ammo instead of hitting
enemy_10085_hllevi_2: kitRoar, // “萨科塔昂首” · fear below half; 祈祷邀约 global ASPD −30 enemy_10085_hllevi_2: kitRoar, // “萨科塔昂首” · fear below half; 祈祷邀约 global ASPD −30
enemy_1407_hummbd: kitExposeOnDeath, // 远眺 · death: exposes operators around (damage taken ×1.2) enemy_1407_hummbd: kitExposeOnDeath, // 远眺 · death: exposes operators around (damage taken ×1.2)
enemy_9009_acfort: kitBlackCloud, // 假想敌:黑云 · devours normal flyers for ammo, fires it all at once enemy_9009_acfort: kitBlackCloud, // 假想敌:黑云 · devours ≤ 3 normal flyers for ammo, fires it all at random allies
enemy_1112_emppnt: kitShell, // 帝国炮火先兆者 · attacks are shells landing 3 s later (r 1.2)
enemy_1112_emppnt_2: kitShell, // 帝国炮火中枢先兆者 · same
enemy_1321_wdarft: (ab) => [skill(ab.sk.BornBugs, (b, e) => spawnChildren(b, e, 'enemy_1269_nhfly', BUGS_PER_CAST))], // 枯朽萃聚使徒 · spawns 枯朽之种 enemy_1321_wdarft: (ab) => [skill(ab.sk.BornBugs, (b, e) => spawnChildren(b, e, 'enemy_1269_nhfly', BUGS_PER_CAST))], // 枯朽萃聚使徒 · spawns 枯朽之种
enemy_1269_nhfly: () => [{ // 枯朽之种 · no normal attack: dives onto a nearby operator and self-destructs enemy_1269_nhfly: () => [{ // 枯朽之种 · no normal attack: dives onto a nearby operator and self-destructs
spawn(b, e) { e.profile.noAttack = true; }, spawn(b, e) { e.profile.noAttack = true; },
@@ -2288,8 +2363,8 @@ export const STATS_ONLY = Object.freeze({
enemy_1006_shield_2: 'heavy defender', enemy_1006_shield_3: 'heavy defender', enemy_1007_slime: 'plain', enemy_1006_shield_2: 'heavy defender', enemy_1006_shield_3: 'heavy defender', enemy_1007_slime: 'plain',
enemy_1010_demon: 'plain', enemy_1010_demon_2: 'plain', enemy_1041_lazerd: 'FLY arts (engine)', enemy_1041_lazerd_2: 'FLY arts (engine)', enemy_1010_demon: 'plain', enemy_1010_demon_2: 'plain', enemy_1041_lazerd: 'FLY arts (engine)', enemy_1041_lazerd_2: 'FLY arts (engine)',
enemy_1043_zomsbr: 'regen is data (hpRecoveryPerSec 80)', enemy_1061_zomshd: 'regen is data (hpRecoveryPerSec 200)', enemy_1043_zomsbr: 'regen is data (hpRecoveryPerSec 80)', enemy_1061_zomshd: 'regen is data (hpRecoveryPerSec 200)',
enemy_1046_agent: 'plain', enemy_1071_dftman: 'plain', enemy_1092_mdgint: 'plain (bounty)', enemy_1112_emppnt: 'FLY ranged (engine)', enemy_1046_agent: 'plain', enemy_1071_dftman: 'plain', enemy_1092_mdgint: 'plain (bounty)',
enemy_1112_emppnt_2: 'FLY ranged (engine)', enemy_1251_lysyta: 'R-series armour interaction absent', enemy_1251_lysyta_2: 'same', enemy_1251_lysyta: 'R-series armour interaction absent', enemy_1251_lysyta_2: 'same',
enemy_1252_lysytb_2: 'same', enemy_1254_lypa_2: 'no listed ability', enemy_1325_cbgpro: 'plain', enemy_1325_cbgpro_2: 'plain', enemy_1252_lysytb_2: 'same', enemy_1254_lypa_2: 'no listed ability', enemy_1325_cbgpro: 'plain', enemy_1325_cbgpro_2: 'plain',
enemy_1367_dseed: 'altar pulse absent (骸骨拷打者 leaves them inert, uncounted, in place)', enemy_1367_dseed: 'altar pulse absent (骸骨拷打者 leaves them inert, uncounted, in place)',
enemy_1381_winman: 'plain', enemy_1381_winman_2: 'plain', enemy_1387_winshd: '封冻/供暖器 zones absent', enemy_1415_mmkabi_2: 'chains absent', enemy_1381_winman: 'plain', enemy_1381_winman_2: 'plain', enemy_1387_winshd: '封冻/供暖器 zones absent', enemy_1415_mmkabi_2: 'chains absent',
+3 -3
View File
@@ -1149,7 +1149,7 @@ const KITS = {
}, },
// ---- 3_15 巫恋 · 削弱者 (hidden) — S2 诅咒娃娃: places the doll (auto-placed on the tile of the range covering most // ---- 3_15 巫恋 · 削弱者 (hidden) — S2 诅咒娃娃: places the doll (auto-placed on the tile of the range covering most
// enemies; replaces any doll of hers still standing, the prep-phase board piece included) whose 3×3 aura lowers // enemies; replaces any doll of hers still standing — the doll is never a prep hand card) whose 3×3 aura lowers
// enemy ATK/DEF, gone after 15 s; 溃败暗示: low-HP enemies fragile // enemy ATK/DEF, gone after 15 s; 溃败暗示: low-HP enemies fragile
chess_char_3_15_a: (bb, chess, def) => { chess_char_3_15_a: (bb, chess, def) => {
const d = defOf(chess, def); const d = defOf(chess, def);
@@ -1171,8 +1171,8 @@ const KITS = {
const life = num(tok?.skill?.duration, 0) > 0 ? tok.skill.duration : textNum(d.skill?.description, /(\d+)秒后自动销毁/, 15); const life = num(tok?.skill?.duration, 0) > 0 ? tok.skill.duration : textNum(d.skill?.description, /(\d+)秒后自动销毁/, 15);
const grid = tok?.rangeGrid ?? NINE; const grid = tok?.rangeGrid ?? NINE;
const reach = Math.max(1, ...grid.map((p) => Math.max(Math.abs(p[0]), Math.abs(p[1])))); const reach = Math.max(1, ...grid.map((p) => Math.max(Math.abs(p[0]), Math.abs(p[1]))));
// "最多可库存1个" / deploy limit 1: the new doll replaces any doll of hers still standing (the board piece // "最多可库存1个" / deploy limit 1: the new doll replaces any doll of hers still standing — withdrawn first
// placed in the prep phase included) — withdrawn first so its tile is available again // so its tile is available again
for (const t of tokensOf(battle, unit, tokenId)) if (t.alive) battle.retreat(t, { reason: 'expired', permanent: true }); for (const t of tokensOf(battle, unit, tokenId)) if (t.alive) battle.retreat(t, { reason: 'expired', permanent: true });
const foes = battle.aliveEnemies().filter((e) => !e.hidden); const foes = battle.aliveEnemies().filter((e) => !e.hidden);
let best = null, bs = -1, bd = Infinity; let best = null, bs = -1, bd = Infinity;
+105 -58
View File
@@ -35,7 +35,8 @@
// do not stack), refilled by shield_ratio_each_trigger /s when the target was not hit for `interval` // do not stack), refilled by shield_ratio_each_trigger /s when the target was not hit for `interval`
// s (always while 凯瑟琳's skill runs, owner bb overwrite_ratio) // s (always while 凯瑟琳's skill runs, owner bb overwrite_ratio)
// 投递坐标 / 风雪之眼 / 保护目标 inert pieces (no attack; 风雪之眼 untargetable) — their effects belong to the owner kit // 投递坐标 / 风雪之眼 / 保护目标 inert pieces (no attack; 风雪之眼 untargetable) — their effects belong to the owner kit
// 炎佑 (enemy_9012_acloon) flying ally AI (see spawnYanyou) // 炎佑 (enemy_9012_acloon) flying ally: flies after the highest-aggro enemy of the field and hovers over it, stays
// put when there is none; 3-target arts + burn on every hit, 元素脆弱 aura, 祛恶之焰 channel (yanyouKit)
// 预备干员-医疗 / Touch (band map characters) generic kit / 恳切福音 kit // 预备干员-医疗 / Touch (band map characters) generic kit / 恳切福音 kit
// Common rules: deploy limit per owner for tokens running these kits (data `deployLimit`; the oldest is withdrawn), // Common rules: deploy limit per owner for tokens running these kits (data `deployLimit`; the oldest is withdrawn),
// `summonKill` hook ({ token, owner, victim }) whenever a token kills, lifetimes never outlive a shorter // `summonKill` hook ({ token, owner, victim }) whenever a token kills, lifetimes never outlive a shorter
@@ -874,30 +875,64 @@ function yanyouFallbackDef(battle) {
return battle.data.rawEnemy?.(TOKEN_IDS.yanyou) ?? null; return battle.data.rawEnemy?.(TOKEN_IDS.yanyou) ?? null;
} }
/**
* 炎佑 (PRTS “炎佑” 级别0(卫戍协议), enemy_database enemy_9012_acloon; user playtest #4 item 12):
* * moves on its own — "持续追踪全场范围内仇恨值最高的敌方单位": it flies after the highest-aggro targetable enemy of
* the whole field (the operator order: taunt, least remaining path, earliest spawned; while 祛恶之焰 channels, its
* locked target) and hovers over it (YANYOU_STOP short of its position; a 2nd 炎佑 of 9 炎 hovers 0.8 row beside the
* first); with no enemy on the field it stays where it is (user playtest #4: "停留在最后在的位置,而不是往一个固定位置
* 返回"; PRTS says it then tracks a random tile — the user's first-hand account wins);
* * normal attack: rangeRadius 2.0, arts, 3 targets ("可同时攻击3个目标", talent 1.attack@max_target);
* * every damage it deals adds burn = ATK × 2.ep_damage_ratio (20 %: "自身造成伤害时,附加攻击力20%的灼燃损伤"; the
* official 下半 notice: "祛恶之焰附带的灼燃损伤调整为基于炎佑的攻击力") — normal attacks AND flame ticks;
* * 元素脆弱 aura: enemies within YANYOU_FRAGILE_RADIUS (1.5, PRTS "自身<固定半径>半径1.5范围内") take element damage
* × 2.damage_scale (1.2);
* * immune to element damage ("即将受到元素损伤前取消此元素损伤");
* * 祛恶之焰 [模式乙] (the 炎 bond's mode, Skill_2; cooldown / initCooldown 15, atk_scale 0.6, hit_duration 20,
* range_radius 1.0): "触发索敌和普通攻击相同:锁定1个目标持续施法,最多持续20秒,每秒对目标周围半径1.0范围内的所有
* 敌方单位造成攻击力60%的法术伤害(范围伤害为中点判定…对被锁定的目标也不例外)※施法期间受到沉默影响后,立即结束技能".
* It fires when a normal attack would (SP = the 15 s cooldown), locks that attack's first target, channels on it
* (no normal attacks meanwhile), deals the 1 s ticks (the first at once) and ends after 20 s, when the locked
* target is gone (dead, left, untargetable — "锁定1个目标…最多持续") or when silenced; the cooldown then runs again
* [ASSUMED: the cooldown counts from the end of the channel, like the engine's timed skills]. While stunned /
* frozen the channel keeps its lock but deals nothing.
*/
const YANYOU_STOP = 0.25; // [ASSUMED] how close it hovers to the tracked enemy (tiles)
const YANYOU_FRAGILE_RADIUS = 1.5; // PRTS "在场期间自身<!--固定半径-->半径1.5范围内的敌方单位受到20%的元素脆弱"
const YANYOU_AURA_EVERY = 0.2; // aura refresh period (s); the status lasts two periods
const YANYOU_PROFILE = Object.freeze({ canHitFly: true });
/** Highest-aggro targetable enemy of the whole field for `u` (operator order, targeting.js sortEnemyTargets). */
function topAggroEnemy(battle, u) {
let best = null, bk = null;
for (const e of battle.enemies) {
if (!canTargetEnemy(u, e, YANYOU_PROFILE)) continue;
const k = [-(e.s.taunt || 0), battle.remainingDistance(e), e.spawnSeq];
if (!bk || k[0] < bk[0] || (k[0] === bk[0] && (k[1] < bk[1] - 1e-9 || (Math.abs(k[1] - bk[1]) <= 1e-9 && k[2] < bk[2])))) { best = e; bk = k; }
}
return best;
}
function yanyouKit(bb, raw) { function yanyouKit(bb, raw) {
// enemy-shaped record: `talents` is one { bb } object, `skills` the enemy skill list // enemy-shaped record: `talents` is one { bb } object, `skills` the enemy skill list
const tal = raw?.talents && !Array.isArray(raw.talents) ? raw.talents.bb ?? {} : raw?.talent ?? {}; const tal = raw?.talents && !Array.isArray(raw.talents) ? raw.talents.bb ?? {} : raw?.talent ?? {};
const skills = Array.isArray(raw?.skills) ? raw.skills : []; const skills = Array.isArray(raw?.skills) ? raw.skills : [];
const sk1 = skills.find((s) => s.prefabKey === 'Skill') ?? skills[0] ?? {}; // the 炎 bond summons 模式乙 (Skill_2, PRTS: "由核心盟约-炎召唤时为模式乙"); 模式甲 (Skill) hits the target only
const sk2 = skills.find((s) => s.prefabKey === 'Skill_2') ?? sk1; const sk = skills.find((s) => s.prefabKey === 'Skill_2') ?? skills.find((s) => s.prefabKey === 'Skill') ?? skills[0] ?? {};
const maxTargets = Math.max(1, Math.floor(num(tal['1.attack@max_target'], 1))); const maxTargets = Math.max(1, Math.floor(num(tal['1.attack@max_target'], 1)));
const epRatio = num(tal['2.ep_damage_ratio'], 0); const epRatio = num(tal['2.ep_damage_ratio'], 0);
const fragR = num(tal['2.range_radius'], 0);
const fragMul = num(tal['2.damage_scale'], 1); const fragMul = num(tal['2.damage_scale'], 1);
const radius = num(raw?.stats?.rangeRadius, 0) > 0 ? num(raw.stats.rangeRadius, 0) : 2; const radius = num(raw?.stats?.rangeRadius, 0) > 0 ? num(raw.stats.rangeRadius, 0) : 2;
const moveSpeed = num(raw?.stats?.moveSpeed, 1); const moveSpeed = num(raw?.stats?.moveSpeed, 1);
const cd = num(sk1.cooldown, 0); const cd = num(sk.cooldown, 0);
const initCd = num(sk1.initCooldown, cd); const initCd = num(sk.initCooldown, cd);
const flameScale = num(sk2.bb?.atk_scale ?? sk1.bb?.atk_scale, 0); const flameScale = num(sk.bb?.atk_scale, 0);
const flameDur = num(sk2.bb?.hit_duration ?? sk1.bb?.hit_duration, 0); const flameDur = num(sk.bb?.hit_duration, 0);
const flameR = num(sk2.bb?.range_radius, 0); const flameR = num(sk.bb?.range_radius, 0);
const FLAME_PROFILE = { canHitFly: true }; /** The channel's locked target while it is still valid (alive, on the field, targetable), else null. */
const inReach = (u, e, pad = 0) => e && e.alive && !e.hidden && Math.hypot(e.x - u.x, e.y - u.y) <= radius + pad + 1e-9; const lockOf = (u) => {
const bestInReach = (battle, u) => { const t = u.mem.flame?.target;
const list = battle.enemies.filter((e) => inReach(u, e) && canTargetEnemy(u, e, FLAME_PROFILE)); return t && t.alive && canTargetEnemy(u, t, YANYOU_PROFILE) ? t : null;
if (!list.length) return null;
sortEnemyTargets(battle, u, list, null);
return list[0];
}; };
const refreshKeys = (u) => { const refreshKeys = (u) => {
// cached while it hovers in place — unless the engine rebuilt the range from the (dummy) grid meanwhile // cached while it hovers in place — unless the engine rebuilt the range from the (dummy) grid meanwhile
@@ -917,63 +952,73 @@ function yanyouKit(bb, raw) {
return { return {
skill: cd > 0 && flameDur > 0 ? { skill: cd > 0 && flameDur > 0 ? {
kind: 'duration', duration: flameDur, spType: 'time', spCost: cd, initSp: Math.max(0, cd - initCd), trigger: 'DEFAULT', kind: 'duration', duration: flameDur, spType: 'time', spCost: cd, initSp: Math.max(0, cd - initCd), trigger: 'DEFAULT',
onStart({ unit }) { unit.mem.flame = { target: null, acc: 1 }; }, attack: { noAttack: true }, // channelling: no normal attacks
onStart({ battle, unit }) {
// "触发索敌和普通攻击相同": the normal attack's first target
const list = battle.enemiesInKeys(unit.rangeKeys, unit, YANYOU_PROFILE);
sortEnemyTargets(battle, unit, list, null);
unit.mem.flame = { target: list[0] ?? null, acc: 1 };
if (!list[0]) unit.skill.end('noTarget');
},
onTick({ battle, unit, dt }) { onTick({ battle, unit, dt }) {
const f = unit.mem.flame || (unit.mem.flame = { target: null, acc: 1 }); const f = unit.mem.flame;
if (!f) return;
if (unit.s.flags.silence) { unit.skill.end('silence'); return; }
const t = lockOf(unit);
if (!t) { unit.skill.end('targetLost'); return; }
if (!unit.canAct) return; // stunned / frozen: the lock holds, no damage
f.acc += dt ?? battle.dt; f.acc += dt ?? battle.dt;
if (f.acc < 1 - 1e-9) return; if (f.acc < 1 - 1e-9) return;
f.acc -= 1; f.acc -= 1;
if (!inReach(unit, f.target, 0.5) || !canTargetEnemy(unit, f.target, FLAME_PROFILE)) f.target = bestInReach(battle, unit); // centre-point AoE around the locked target, searched again every second (the target itself included)
const t = f.target; const hit = flameR > 0 ? battle.enemiesInRadius(t.x, t.y, flameR) : [];
if (!t) { unit.skill.end('noTarget'); return; } if (!hit.includes(t)) hit.unshift(t);
// 祛恶之焰 (Skill_2 range_radius): the channelled target and every enemy within range_radius of it for (const e of hit) battle.dealDamage(unit, e, { amount: unit.s.atk * flameScale, type: 'arts', isSkill: true, tags: ['flame'] });
// (research 02 §3.1; the talent's 3-target cap is for normal attacks) battle.fx('yanyouFlame', { x: t.x, y: t.y, id: unit.id, target: t.id, r: flameR, n: hit.length, dur: 1 });
const others = flameR > 0 ? battle.enemiesInRadius(t.x, t.y, flameR).filter((e) => e !== t) : [];
for (const e of [t, ...others]) battle.dealDamage(unit, e, { amount: unit.s.atk * flameScale, type: 'arts', isSkill: true, tags: ['flame'] });
battle.fx('yanyouFlame', { x: t.x, y: t.y, id: unit.id, target: t.id, n: 1 + others.length });
}, },
onEnd({ unit }) { unit.mem.flame = null; }, onEnd({ unit }) { unit.mem.flame = null; },
} : null, } : null,
trait: { trait: { attack: 'ranged', dmgType: 'arts', projectile: 'bolt', canHitFly: true, maxTargets, heal: null, noAttack: false },
attack: 'ranged', dmgType: 'arts', projectile: 'bolt', canHitFly: true, maxTargets, heal: null, noAttack: false,
afterHit: (b, u, target, info) => {
// 元素脆弱 (same-name statuses keep the strongest); it lasts until shortly after its next attack [ASSUMED: no data]
if (fragR > 0 && fragMul > 1) {
for (const e of b.enemiesInRadius(info.x, info.y, fragR)) b.applyStatus(e, 'elemFragile', { duration: u.s.interval + 0.5, value: fragMul - 1, source: u });
}
if (epRatio > 0 && target && target.alive) b.dealDamage(u, target, { type: 'element', element: 'burn', amount: u.s.atk * epRatio, tags: ['yanyou'] });
},
},
install(battle, unit) { install(battle, unit) {
unit.motion = 'FLY'; unit.motion = 'FLY';
if (!(unit.base.spRecovery > 0)) unit.base.spRecovery = 1; // the cooldown ticks as time SP (data spRecovery 0) if (!(unit.base.spRecovery > 0)) unit.base.spRecovery = 1; // the cooldown ticks as time SP (data spRecovery 0)
unit.base.blockCnt = 0; unit.base.blockCnt = 0;
const speed = moveSpeed * MOVE_SCALE; const speed = moveSpeed * MOVE_SCALE;
onDeploy(battle, unit, () => { unit.mem.keysAt = null; refreshKeys(unit); }); onDeploy(battle, unit, () => { unit.mem.keysAt = null; refreshKeys(unit); });
// every damage it deals adds burn (not the burn itself, not element bursts)
if (epRatio > 0) {
battle.on('damaged', (ctx) => {
if (ctx.source !== unit || !ctx.target || ctx.target.side !== 'enemy' || !ctx.target.alive) return;
if (ctx.type === 'element' || ctx.type === 'elemental' || !(ctx.amount > 0)) return;
battle.dealDamage(unit, ctx.target, { type: 'element', element: 'burn', amount: unit.s.atk * epRatio, tags: ['yanyou'] });
}, { owner: unit });
}
// "即将受到元素损伤前取消此元素损伤"
battle.on('elementHit', (ctx) => { if (ctx.target === unit) ctx.dmg.cancel = true; }, { owner: unit });
let auraAcc = YANYOU_AURA_EVERY;
battle.on('tick', ({ dt }) => { battle.on('tick', ({ dt }) => {
if (!unit.alive || !unit.deployed) return; if (!unit.alive || !unit.deployed) return;
if (unit.canAct && !unit.s.flags.noMove && speed > 0) { if (unit.canAct && !unit.s.flags.noMove && speed > 0) {
// chase the leading enemy (or the flame target) and hover over the middle of the enemies around it, so const tgt = lockOf(unit) ?? topAggroEnemy(battle, unit);
// its multi-target attacks reach as many as possible
const tgt = unit.mem.flame?.target && unit.mem.flame.target.alive ? unit.mem.flame.target : mostAdvancedEnemy(battle);
let tx = unit.homeC, ty = unit.homeR;
if (tgt) { if (tgt) {
let sx = 0, sy = 0, n = 0; const dx = tgt.x - unit.x, dy = tgt.y + num(unit.mem.hoverDy, 0) - unit.y;
for (const e of battle.enemiesInRadius(tgt.x, tgt.y, radius)) { sx += e.x; sy += e.y; n++; } const d = Math.hypot(dx, dy);
tx = n ? sx / n : tgt.x; ty = n ? sy / n : tgt.y; if (d > YANYOU_STOP + 1e-6) {
ty += num(unit.mem.hoverDy, 0); // a 2nd 炎佑 hovers beside the first one const step = Math.min(d - YANYOU_STOP, speed * dt);
} const R = battle.rect;
const dx = tx - unit.x, dy = ty - unit.y; unit.x = Math.max(R.c0, Math.min(R.c1, unit.x + (dx / d) * step));
const d = Math.hypot(dx, dy); unit.y = Math.max(R.r0, Math.min(R.r1, unit.y + (dy / d) * step));
const stop = tgt ? 0.25 : 0; }
if (d > stop + 1e-6) {
const step = Math.min(d - stop, speed * dt);
const R = battle.rect;
unit.x = Math.max(R.c0, Math.min(R.c1, unit.x + (dx / d) * step));
unit.y = Math.max(R.r0, Math.min(R.r1, unit.y + (dy / d) * step));
} }
} }
refreshKeys(unit); refreshKeys(unit);
auraAcc += dt;
if (fragMul > 1 && auraAcc >= YANYOU_AURA_EVERY - 1e-9) {
auraAcc = 0;
for (const e of battle.enemiesInRadius(unit.x, unit.y, YANYOU_FRAGILE_RADIUS)) {
battle.applyStatus(e, 'elemFragile', { duration: 2 * YANYOU_AURA_EVERY, value: fragMul - 1, source: unit });
}
}
}, { owner: unit }); }, { owner: unit });
}, },
}; };
@@ -1003,8 +1048,9 @@ function airTile(battle, playerId, taken) {
/** /**
* 炎 bond summon: spawn `count` “炎佑” for `playerId` (flying ally that picks its own targets; bonds.js computes the * 炎 bond summon: spawn `count` “炎佑” for `playerId` (flying ally that picks its own targets; bonds.js computes the
* numbers). atk / hp replace the template stats (0.3 × 炎 sums at battle start), atkMul (9 炎: ×1.5) and * numbers). atk / hp (0.3 × the 炎 sums at battle start) are ADDED to the template stats (600 / 12000): PRTS "登场时使
* dmgTakenMul (9 炎: 0.1) are applied as a persistent buff. Returns the spawned units. * 自身攻击力、生命值增加召唤自身的玩家场地上的所有【炎】盟约干员攻击力、生命值的30%(最终加算)". atkMul (9 炎: ×1.5,
* [ASSUMED] on the whole ATK) and dmgTakenMul (9 炎: 0.1) are applied as a persistent buff. Returns the spawned units.
*/ */
export function spawnYanyou(battle, playerId, { atk, hp, atkMul = 1, dmgTakenMul = 1, count = 1 } = {}) { export function spawnYanyou(battle, playerId, { atk, hp, atkMul = 1, dmgTakenMul = 1, count = 1 } = {}) {
const rec = battle.data.rawToken?.(TOKEN_IDS.yanyou) ?? battle.data.rawEnemy?.(TOKEN_IDS.yanyou) ?? null; const rec = battle.data.rawToken?.(TOKEN_IDS.yanyou) ?? battle.data.rawEnemy?.(TOKEN_IDS.yanyou) ?? null;
@@ -1013,9 +1059,10 @@ export function spawnYanyou(battle, playerId, { atk, hp, atkMul = 1, dmgTakenMul
const n = Math.max(0, Math.min(Math.floor(num(count, 1)), Math.max(1, Math.floor(num(rec.deployLimit ?? rec.stats?.deployLimit, 2))))); const n = Math.max(0, Math.min(Math.floor(num(count, 1)), Math.max(1, Math.floor(num(rec.deployLimit ?? rec.stats?.deployLimit, 2)))));
const out = []; const out = [];
const taken = new Set(); const taken = new Set();
const base = rec.stats || {};
const stats = {}; const stats = {};
if (num(atk, -1) >= 0) stats.atk = num(atk, 0); if (num(atk, -1) >= 0) stats.atk = num(base.atk, 0) + num(atk, 0);
if (num(hp, 0) > 0) stats.maxHp = num(hp, 1); if (num(hp, 0) > 0) stats.maxHp = num(base.maxHp, 0) + num(hp, 1);
const ps = battle.getPlayer(playerId); const ps = battle.getPlayer(playerId);
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const tile = airTile(battle, playerId, taken); const tile = airTile(battle, playerId, taken);
@@ -1027,7 +1074,7 @@ export function spawnYanyou(battle, playerId, { atk, hp, atkMul = 1, dmgTakenMul
if (num(atkMul, 1) !== 1 && num(atkMul, 1) >= 0) mods.atkMul = num(atkMul, 1); if (num(atkMul, 1) !== 1 && num(atkMul, 1) >= 0) mods.atkMul = num(atkMul, 1);
if (num(dmgTakenMul, 1) !== 1 && num(dmgTakenMul, 1) >= 0) mods.dmgTakenMul = num(dmgTakenMul, 1); if (num(dmgTakenMul, 1) !== 1 && num(dmgTakenMul, 1) >= 0) mods.dmgTakenMul = num(dmgTakenMul, 1);
if (Object.keys(mods).length) battle.addBuff(u, { key: 'bond:yanyou', persist: true, allowDead: true, mods }); if (Object.keys(mods).length) battle.addBuff(u, { key: 'bond:yanyou', persist: true, allowDead: true, mods });
u.mem.hoverDy = out.length === 0 ? 0 : (out.length % 2 ? 0.8 : -0.8); u.mem.hoverDy = out.length === 0 ? 0 : (out.length % 2 ? 0.8 : -0.8); // the 2nd one hovers beside the first
u.hp = u.s.maxHp; u.hp = u.s.maxHp;
battle.fx('yanyouSummon', { x: u.x, y: u.y, id: u.id, playerId }); battle.fx('yanyouSummon', { x: u.x, y: u.y, id: u.id, playerId });
out.push(u); out.push(u);
+74 -27
View File
@@ -7,22 +7,25 @@
// → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill. // → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill.
// Phys: max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 5 %·A); Arts: max(A×(1 − R′/100), 5 %·A) with // Phys: max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 5 %·A); Arts: max(A×(1 − R′/100), 5 %·A) with
// R′ = max(0, R×(1−resIgnorePct) − resIgnoreFlat); True: A. // R′ = max(0, R×(1−resIgnorePct) − resIgnoreFlat); True: A.
// Element damage ('element' type + element) fills a gauge instead of HP (1000; enemy leaders 2000). A full gauge // Element damage ('element' type + element) fills a gauge instead of HP (1000; enemy leaders 2000), reduced by the
// bursts with the official term-table effects (constants.js ELEMENT), which differ by the side hit: // target's 损伤抵抗 (data `epResistance`, a percentage: PRTS 元素 "受到的元素损伤 = 损伤值 × (1 − 损伤抵抗 × 0.01)").
// operators (enemy damage): burn 1200 arts + RES −20 10 s · neural 1000 true + stun 10 s · apoptosis 15 s: no skill // A full gauge bursts with the official term-table effects (constants.js ELEMENT), which differ by the side hit:
// activation, −1 SP/s, 100 arts/s · erosion 800 phys + permanent DEF −100. // operators (enemy damage): burn 1200 arts + RES −20 10 s · neural stun 10 s, then 1000 true · apoptosis 15 s: 阻回
// enemies (operator damage, "·我方"): burn 7000 元素伤害 + RES −20 (10 s) · neural 6000 元素伤害 + 3 麻痹 (10 s) · // (noSp) + 静默 (no skill activation), −1 SP/s, 100 arts/s · erosion permanent DEF −100, then 800 phys (10 s).
// enemies (operator damage, "·我方"): burn 7000 元素伤害 + RES −20 (10 s) · neural 3 麻痹, then 6000 元素伤害 (10 s) ·
// apoptosis 15 s: 50 % weaken recovering over the burst + 800 元素伤害/s · erosion 5000 元素伤害 + permanent DEF −120 (8 s). // apoptosis 15 s: 50 % weaken recovering over the burst + 800 元素伤害/s · erosion 5000 元素伤害 + permanent DEF −120 (8 s).
// necrosis (legacy spare gauge): 12 s of 100 true dmg/s and ATK −20 %. // necrosis (legacy spare gauge): 12 s of 100 true dmg/s and ATK −20 %.
// The gauge stays locked (full) for the burst duration ("冷却") and resets to 0 when it ends. A burst that is still // 爆发冷却 (PRTS 元素, "阻回" after a burst): for the burst's duration (the `<el>Burst` buff) NO element of the unit can
// resolving (its `elementBurst` hook runs before the `<el>Burst` lock exists; erosion on operators has no lock at all) // fill or be recovered (reduceElement) — the bursting gauge shows full — and when it ends EVERY gauge of the unit
// already counts as locked (`unit.burstPending[el]`): same-element fills of that unit are refused until it has resolved, // resets to 0. A burst that is still resolving (its `elementBurst` hook runs before the `<el>Burst` lock exists) already
// so a hook that spreads the element back (淤困 parasite hosts next to each other) cannot re-burst it recursively. // counts as locked (`unit.burstPending[el]`), so a hook that spreads an element back (淤困 parasite hosts next to each
// other) cannot re-burst the unit recursively. The unit's shown gauge (the fullest, "当前损伤元素") and its cooldown
// travel in b.snap `elem` (elementView).
// 元素伤害 (HP damage of an element) is the DamageInfo type 'elemental' (+ optional `element` for display): no DEF/RES, // 元素伤害 (HP damage of an element) is the DamageInfo type 'elemental' (+ optional `element` for display): no DEF/RES,
// no dodge, × source dmgDealtMul × target dmgTakenMul × elemTakenMul. Sleeping units (沉睡: 无敌) take no damage // no dodge, × source dmgDealtMul × target dmgTakenMul × elemTakenMul. Sleeping units (沉睡: 无敌) take no damage
// unless the attacker's profile has `hitSleep` or the damage carries `ignoreSleep`. // unless the attacker's profile has `hitSleep` or the damage carries `ignoreSleep`.
import { MIN_DAMAGE_RATIO, ELEMENT, PALSY_MAX } from './constants.js'; import { MIN_DAMAGE_RATIO, ELEMENT, ELEMENT_ORDER, PALSY_MAX } from './constants.js';
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
@@ -201,9 +204,46 @@ export function applyHpLoss(battle, source, target, amount, dmg) {
return dealt; return dealt;
} }
/** Is `target`'s `el` gauge locked: its `<el>Burst` buff is up, or its burst is resolving right now (see header). */ /**
export function burstLocked(target, el) { * Are `target`'s element gauges locked (爆发冷却, see header): a burst's `<el>Burst` lock is up (any element — the lock
return !!(target.burstPending && target.burstPending[el]) || !!target.findBuff(el + 'Burst'); * holds every gauge of the unit), or a burst of the unit is resolving right now. The element argument of older callers
* is ignored.
*/
export function burstLocked(target) {
const p = target.burstPending;
if (p) for (const k in p) if (p[k]) return true;
return !!target.s.flags.burstLock;
}
/** The burst lock buff (爆发冷却) running on `u`, or null. */
function burstLockOf(u) {
let best = null;
for (const b of u.buffs) if (b.flags && b.flags.burstLock && (!best || b.timeLeft > best.timeLeft)) best = b;
return best;
}
/**
* The element gauge a unit shows (b.snap `elem`, render/units.js): null when every gauge is empty and no burst
* cooldown runs. Official display (PRTS 元素): one icon, the "当前损伤元素" — the fullest gauge (ties: the lower
* official element id, constants.js ELEMENT_ORDER) — with its remaining EP; during a 爆发冷却 the bursting element,
* refilling over the cooldown. Returns `[el, fill 0..1 (2 decimals), cooldownEnd (game s), cooldown (s)]`; the two
* cooldown numbers are 0 outside a cooldown. `now` = the battle time.
*/
export function elementView(u, now) {
const e = u.elem;
if (!e) return null;
const lock = u.s.flags.burstLock ? burstLockOf(u) : null;
if (lock) {
const el = lock.key.endsWith('Burst') ? lock.key.slice(0, -5) : 'burn';
const dur = Number.isFinite(lock.duration) && lock.duration > 0 ? lock.duration : 0;
const left = Number.isFinite(lock.timeLeft) ? Math.max(0, lock.timeLeft) : 0;
return [el in e ? el : 'burn', 1, Math.round((now + left) * 100) / 100, Math.round(dur * 100) / 100];
}
let best = null, bv = 0;
for (const k of ELEMENT_ORDER) { const v = e[k]; if (v > bv) { bv = v; best = k; } }
if (!best) return null;
const fill = Math.min(1, Math.max(0.01, Math.round((bv / u.gaugeMax) * 100) / 100));
return [best, fill, 0, 0];
} }
/** Element gauge accumulation + burst. Fires `elementHit` { source, target, dmg } first (mutable amount/mul, cancel). */ /** Element gauge accumulation + burst. Fires `elementHit` { source, target, dmg } first (mutable amount/mul, cancel). */
@@ -218,7 +258,8 @@ export function applyElement(battle, source, target, dmg) {
if (dmg.type !== 'element') return dealDamage(battle, source, target, dmg); // a handler converted it if (dmg.type !== 'element') return dealDamage(battle, source, target, dmg); // a handler converted it
} }
const max = target.gaugeMax; const max = target.gaugeMax;
let amt = dmg.amount * dmg.mul * target.s.elemTakenMul * (1 - clamp01(target.def?.epDamageResistance ?? 0)); // 损伤抵抗 (official EP_RESISTANCE = data epResistance, a percentage)
let amt = dmg.amount * dmg.mul * target.s.elemTakenMul * (1 - clamp01((target.def?.epResistance ?? 0) / 100));
if (!(amt > 0) || !Number.isFinite(amt)) return 0; if (!(amt > 0) || !Number.isFinite(amt)) return 0;
target.elem[el] = Math.min(max, target.elem[el] + amt); target.elem[el] = Math.min(max, target.elem[el] + amt);
battle._ev(['dmg', target.id, Math.round(amt), el]); battle._ev(['dmg', target.id, Math.round(amt), el]);
@@ -229,11 +270,12 @@ export function applyElement(battle, source, target, dmg) {
} }
/** /**
* A burst (see header): side-aware official effects; the `<el>Burst` buff locks the gauge for its duration. The unit * A burst (see header): side-aware official effects; the `<el>Burst` buff is the 爆发冷却 that locks every gauge of the
* is marked `burstPending[el]` while the burst resolves (hook, lock, hits), so nothing re-bursts it in the meantime. * unit for its duration. The unit is marked `burstPending[el]` while the burst resolves (hook, lock, hits), so nothing
* re-bursts it in the meantime.
*/ */
export function elementBurst(battle, source, target, el) { export function elementBurst(battle, source, target, el) {
if (!ELEMENT[el] || !target.alive || burstLocked(target, el)) return; if (!ELEMENT[el] || !target.alive || burstLocked(target)) return;
const pending = target.burstPending || (target.burstPending = {}); const pending = target.burstPending || (target.burstPending = {});
pending[el] = true; pending[el] = true;
try { resolveBurst(battle, source, target, el); } finally { pending[el] = false; } try { resolveBurst(battle, source, target, el); } finally { pending[el] = false; }
@@ -242,7 +284,8 @@ export function elementBurst(battle, source, target, el) {
function resolveBurst(battle, source, target, el) { function resolveBurst(battle, source, target, el) {
const cfg = ELEMENT[el]; const cfg = ELEMENT[el];
target.elem[el] = target.gaugeMax; target.elem[el] = target.gaugeMax;
const reset = () => { target.elem[el] = 0; }; // the cooldown's end restores every element of the unit ("随后单位所有种类的元素值恢复至最大值")
const reset = () => { for (const k of ELEMENT_ORDER) if (k in target.elem) target.elem[k] = 0; };
battle.fx('burst', { x: target.x, y: target.y, id: target.id, element: el }); battle.fx('burst', { x: target.x, y: target.y, id: target.id, element: el });
if (battle._hooks.elementBurst) battle.emit('elementBurst', { source, target, element: el }); if (battle._hooks.elementBurst) battle.emit('elementBurst', { source, target, element: el });
if (!target.alive) { reset(); return; } if (!target.alive) { reset(); return; }
@@ -265,13 +308,14 @@ function resolveBurst(battle, source, target, el) {
lock(c.duration, { mods: { resFlat: -c.resDown } }); lock(c.duration, { mods: { resFlat: -c.resDown } });
if (enemy) hit(c.elemDamage, 'elemental'); else hit(c.damage, c.type); if (enemy) hit(c.elemDamage, 'elemental'); else hit(c.damage, c.type);
} else if (el === 'neural') { } else if (el === 'neural') {
// PRTS 元素: the status first ("立刻获得等时长的眩晕" / "获得3层麻痹"), then the hit
lock(c.duration); lock(c.duration);
if (enemy) { if (enemy) {
hit(c.elemDamage, 'elemental'); battle.applyStatus(target, 'palsy', { value: c.palsy, source });
if (target.alive) battle.applyStatus(target, 'palsy', { value: c.palsy, source }); if (target.alive) hit(c.elemDamage, 'elemental');
} else { } else {
hit(c.damage, c.type); battle.applyStatus(target, 'stun', { duration: c.stun, source, force: true });
if (target.alive) battle.applyStatus(target, 'stun', { duration: c.stun, source, force: true }); if (target.alive) hit(c.damage, c.type);
} }
} else if (el === 'apoptosis') { } else if (el === 'apoptosis') {
if (enemy) { if (enemy) {
@@ -285,9 +329,9 @@ function resolveBurst(battle, source, target, el) {
}, },
}); });
} else { } else {
// 15 s: no skill activation, −1 SP per second, 100 arts damage per second // 15 s of 阻回 ("停止并阻止任意形式的技力回复": noSp) + 静默 (no skill activation), −1 SP and 100 arts damage per second
lock(c.duration, { lock(c.duration, {
flags: { silence: true }, interval: 1, flags: { silence: true, noSp: true }, interval: 1,
onTick: () => { onTick: () => {
const sk = target.skill; const sk = target.skill;
if (sk && !sk.noSkill && sk.kind !== 'passive' && !(sk.active && sk.isTimed) && sk.sp > 0) sk.sp = Math.max(0, sk.sp - c.spLossPerSec); if (sk && !sk.noSkill && sk.kind !== 'passive' && !(sk.active && sk.isTimed) && sk.sp > 0) sk.sp = Math.max(0, sk.sp - c.spLossPerSec);
@@ -296,7 +340,7 @@ function resolveBurst(battle, source, target, el) {
}); });
} }
} else if (el === 'erosion') { } else if (el === 'erosion') {
// the permanent DEF cut lands first, then the hit // the permanent DEF cut lands first, then the hit; both sides then have their cooldown (operators 10 s, enemies 8 s)
lock(c.duration); lock(c.duration);
battle.addBuff(target, { key: 'erosionDown', refresh: 'stack', stacks: 1, maxStacks: 1e6, mods: { defFlat: -c.defDown }, visible: true }); battle.addBuff(target, { key: 'erosionDown', refresh: 'stack', stacks: 1, maxStacks: 1e6, mods: { defFlat: -c.defDown }, visible: true });
if (enemy) hit(c.elemDamage, 'elemental'); else hit(c.damage, c.type); if (enemy) hit(c.elemDamage, 'elemental'); else hit(c.damage, c.type);
@@ -309,12 +353,15 @@ export function palsyBuff(n) {
return { key: 'palsy', refresh: 'stack', stacks, maxStacks: PALSY_MAX, visible: true, status: 'palsy' }; return { key: 'palsy', refresh: 'stack', stacks, maxStacks: PALSY_MAX, visible: true, status: 'palsy' };
} }
/** Reduce an element gauge (e.g. wandermedic "回复元素损伤"). Returns the amount removed. */ /**
* Reduce an element gauge (e.g. wandermedic "回复元素损伤"). Returns the amount removed. Nothing recovers during a
* burst cooldown ("爆发冷却状态下,单位所有类型的元素值均无法损失、无法被其他手段回复").
*/
export function reduceElement(target, amount, el = null) { export function reduceElement(target, amount, el = null) {
let removed = 0; let removed = 0;
if (!target || !target.elem || burstLocked(target)) return 0;
const els = el ? [el] : Object.keys(target.elem); const els = el ? [el] : Object.keys(target.elem);
for (const k of els) { for (const k of els) {
if (burstLocked(target, k)) continue;
const take = Math.min(target.elem[k], amount - removed); const take = Math.min(target.elem[k], amount - removed);
if (take > 0) { target.elem[k] -= take; removed += take; } if (take > 0) { target.elem[k] -= take; removed += take; }
if (removed >= amount) break; if (removed >= amount) break;
+10
View File
@@ -44,6 +44,16 @@ export function flattenBlackboard(bb) {
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d)); const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d));
/**
* Is an items.json record a shop item — what the 调度中心 item slot, the 道具补给 / 机密商店 cards and the "random
* item" pools (pool_equip_normal …) draw from: a normal EQUIP item with a tier, neither hidden by the official shop
* table (`hideInShop`: the Arts) nor one of the effect-only items build-data marks `shopExcluded` (the special
* 维式重锤 of 维多利亚 / 洛洛, 突变细胞 — tools/build-data.mjs SHOP_EXCLUDED_ITEMS).
*/
export function isShopItem(it) {
return !!it && typeof it === 'object' && !it.isGolden && !it.hideInShop && !it.shopExcluded && it.itemType === 'EQUIP' && Number.isInteger(it.tier);
}
function toArrayOfPairs(grid) { function toArrayOfPairs(grid) {
if (!Array.isArray(grid)) return null; if (!Array.isArray(grid)) return null;
const out = []; const out = [];
+4 -1
View File
@@ -1,7 +1,8 @@
// server/sim/skills.js — skill runtime: SP, charges, trigger rules, kinds, SkillSpec interpretation (DESIGN §5.6). // server/sim/skills.js — skill runtime: SP, charges, trigger rules, kinds, SkillSpec interpretation (DESIGN §5.6).
// //
// SP types: 'time' (+spRecovery/s), 'attack' (+1 per attack), 'hurt' (+1 per hit taken), 'none'. // SP types: 'time' (+spRecovery/s), 'attack' (+1 per attack), 'hurt' (+1 per hit taken), 'none'.
// No SP gain while a duration/ammo/toggle skill is active, while stunned, or while the unit has the noSp flag. // No SP gain while a duration/ammo/toggle skill is active, while stunned, or while the unit has the noSp flag (阻回: no SP
// gain of any kind — time, attack, hurt or granted).
// Charges (maxCharges > 1): SP fills to spCost → +1 charge (SP restarts) until charges == max (SP stays full). // Charges (maxCharges > 1): SP fills to spCost → +1 charge (SP restarts) until charges == max (SP stays full).
// Trigger rules: DEFAULT (ready + about to attack/heal + enemy / injured ally in the INITIAL range — or, checked every // Trigger rules: DEFAULT (ready + about to attack/heal + enemy / injured ally in the INITIAL range — or, checked every
// tick, an enemy inside one of the content trigger ranges added with addTriggerRange: 海嗣, 流形), // tick, an enemy inside one of the content trigger ranges added with addTriggerRange: 海嗣, 流形),
@@ -176,6 +177,8 @@ export class SkillRuntime {
gainSp(amount, reason = 'time', silent = false) { gainSp(amount, reason = 'time', silent = false) {
if (this.noSkill || this.kind === 'passive' || !(amount > 0)) return 0; if (this.noSkill || this.kind === 'passive' || !(amount > 0)) return 0;
if (this.active && this.isTimed && reason !== 'init') return 0; if (this.active && this.isTimed && reason !== 'init') return 0;
// 阻回 (the operators' 凋亡 burst, damage.js): "停止并阻止任意形式的技力回复" — no SP of any kind (time, attack, hurt, gifts)
if (reason !== 'init' && this.unit.s.flags.noSp) return 0;
let cost = this.spCost; let cost = this.spCost;
if (this.charges >= this.maxCharges && this.sp >= cost) return 0; if (this.charges >= this.maxCharges && this.sp >= cost) return 0;
let amt = amount; let amt = amount;
+51
View File
@@ -171,6 +171,49 @@ export function resolveLoadout(loadout, chess, getChess) {
return { skillIndex, moduleId }; return { skillIndex, moduleId };
} }
// ---- unit stats (user playtest #4 item 7): m.unitStats units and the browser battle's live stats ---------------------
const fin = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const round1 = (v) => Math.round(v * 10) / 10;
const round2 = (v) => Math.round(v * 100) / 100;
/** Attack interval (s) of a stats object: its own `interval`, else bat × 100 / aspd (null without an attack time). */
const intervalOf = (x) => {
if (Number.isFinite(x.interval) && x.interval > 0) return round2(x.interval);
const bat = fin(x.bat, 0);
const aspd = fin(x.aspd, 100) > 0 ? fin(x.aspd, 100) : 100;
return bat > 0 ? round2((bat * 100) / aspd) : null;
};
const statView = (x) => ({
maxHp: Math.round(fin(x.maxHp)), atk: Math.round(fin(x.atk)), def: Math.round(fin(x.def)), res: round1(fin(x.res)),
interval: intervalOf(x), blockCnt: Math.max(0, Math.round(fin(x.blockCnt))), moveSpeed: round2(fin(x.moveSpeed)),
});
/**
* The detail card's stats of a sim unit (server/sim/units.js Unit): its effective stats `s` (the aggregated `unit.s`,
* or the last ones the sim computed) next to its own `unit.base` (no buffs) — max HP, ATK, DEF, RES, attack interval
* (s), block, move speed — rounded for display (the sim keeps floats), plus the current HP. The shape of the
* `m.unitStats` units (Match.unitStats: what the board's units start their next battle with) and of the browser
* runner's live battle stats (public/js/battle/runner.js unitStats).
* @param {{ id?: number, uid?: number|null, defId?: string, hp?: number, alive?: boolean, base?: any } | null} u
* @param {any} [s] aggregated stats (missing ⇒ the base)
* @returns {{ id: number|null, uid: number|null, defId: string|null, hp: number, alive: boolean, maxHp: number, atk: number,
* def: number, res: number, interval: number|null, blockCnt: number, moveSpeed: number,
* base: { maxHp: number, atk: number, def: number, res: number, interval: number|null, blockCnt: number, moveSpeed: number } }}
*/
export function unitStatsEntry(u, s = null) {
const base = u && u.base && typeof u.base === 'object' ? u.base : {};
const cur = s && typeof s === 'object' ? s : base;
return {
id: Number.isInteger(u?.id) ? u.id : null,
uid: Number.isInteger(u?.uid) ? u.uid : null,
defId: typeof u?.defId === 'string' ? u.defId : null,
hp: Math.max(0, Math.round(fin(u?.hp))),
alive: u?.alive !== false,
...statView(cur),
base: statView(base),
};
}
/** Deploy directions (DESIGN §3, research 09 §1.2; the same list as server/sim/dir.js DIRS). */ /** Deploy directions (DESIGN §3, research 09 §1.2; the same list as server/sim/dir.js DIRS). */
export const DIRS = Object.freeze(['UP', 'RIGHT', 'DOWN', 'LEFT']); export const DIRS = Object.freeze(['UP', 'RIGHT', 'DOWN', 'LEFT']);
const isDir = (v) => DIRS.includes(v); const isDir = (v) => DIRS.includes(v);
@@ -202,6 +245,9 @@ export const C2S = {
'g.infoReady': {}, 'g.infoReady': {},
'g.band': { bandId: isId }, 'g.band': { bandId: isId },
'g.bandSkip': {}, 'g.bandSkip': {},
// the strategy highlighted in the draft screen (user playtest #4 item 4): a turn that runs out takes it while it is
// free (Match.timeoutBand); absent / null clears it
'g.bandFocus': { bandId: nullable(isId), $optional: ['bandId'] },
'g.buy': { slot: (v) => isInt(v, 0, 15) }, 'g.buy': { slot: (v) => isInt(v, 0, 15) },
'g.refresh': {}, 'g.refresh': {},
'g.freeze': {}, 'g.freeze': {},
@@ -223,6 +269,9 @@ export const C2S = {
// solo pause (official PauseUp / ResumeUp, DESIGN §14): freezes the running battle (field clock, deadlines, the // solo pause (official PauseUp / ResumeUp, DESIGN §14): freezes the running battle (field clock, deadlines, the
// browser's local runner) — solo matches only (co-op ⇒ WRONG_PHASE), only while a battle runs; m.public.paused // browser's local runner) — solo matches only (co-op ⇒ WRONG_PHASE), only while a battle runs; m.public.paused
'g.pause': { on: isBool }, 'g.pause': { on: isBool },
// the stats the own board's units start their next battle with (user playtest #4 item 7; prep phases): answered by
// the push m.unitStats { seq, round, units: [unitStatsEntry] }; `seq` is echoed so the client keeps the newest answer
'g.unitStats': { seq: (v) => isInt(v, 0, 2 ** 31), $optional: ['seq'] },
'g.leave': {}, 'g.leave': {},
// client-side combat (DESIGN §14): the authoritative client of a field reports its battle // client-side combat (DESIGN §14): the authoritative client of a field reports its battle
@@ -240,6 +289,8 @@ export const S2C = [
'welcome', 'ok', 'error', 'pong', 'welcome', 'ok', 'error', 'pong',
'room.state', 'room.closed', 'room.state', 'room.closed',
'm.public', 'm.private', 'm.field', 'm.toast', 'm.ticker', 'm.emote', 'm.result', 'm.public', 'm.private', 'm.field', 'm.toast', 'm.ticker', 'm.emote', 'm.result',
// m.unitStats { seq, round, units: [unitStatsEntry] } — the answer to g.unitStats (the requester only)
'm.unitStats',
// client-side combat (DESIGN §14): b.start { battleId, fieldId, kind, spec, authoritative, startAt, serverNow, elapsed, // client-side combat (DESIGN §14): b.start { battleId, fieldId, kind, spec, authoritative, startAt, serverNow, elapsed,
// speed, watch? } · b.pool { hp, max, teamLp, acked: { [fieldId]: cumulative boss damage counted } } · // speed, watch? } · b.pool { hp, max, teamLp, acked: { [fieldId]: cumulative boss damage counted } } ·
// b.end { battleId, fieldId, reason } // b.end { battleId, fieldId, reason }
+12
View File
@@ -319,6 +319,18 @@ describe('audio banks and plan id sets', () => {
assert.deepEqual(pickUnitSfx(banks), { hit: ['e/hit.mp3'] }); assert.deepEqual(pickUnitSfx(banks), { hit: ['e/hit.mp3'] });
}); });
test('operators: normal-mode sounds only, their own projectile banks as fallbacks (user playtest #4 item 6)', () => {
// 纯烬艾雅法拉: the numbered ability variant attack.2 is her S3 impact (_s) — never the normal hit
const banks = new Map([['ON_ABILITY_START.attack', ['p/p_atk_x_n.mp3']], ['ON_ABILITY_HIT.attack.2', ['p/p_imp_x_s.mp3']]]);
assert.deepEqual(pickUnitSfx(banks, { operator: true, projectile: { hit: ['p/p_imp_x_n.mp3'] } }), { attack: ['p/p_atk_x_n.mp3'], hit: ['p/p_imp_x_n.mp3'] });
assert.deepEqual(pickUnitSfx(banks), { attack: ['p/p_atk_x_n.mp3'], hit: ['p/p_imp_x_s.mp3'] }, 'enemies / tokens unchanged');
// a skill-mode plain bank (_d / _h / _s) is skipped; the plain ability wins over any numbered variant
const b2 = new Map([['ON_ABILITY_START.attack', ['p/p_atk_y_h.mp3']], ['ON_ABILITY_START.attack.0', ['p/p_atk_y_n.mp3']], ['ON_ABILITY_ON.attack', ['p/p_atk_y_n2.mp3']]]);
assert.deepEqual(pickUnitSfx(b2, { operator: true }).attack, ['p/p_atk_y_n2.mp3']);
assert.deepEqual(pickUnitSfx(new Map([['ON_ABILITY_START.attack.1', ['p/p_atk_z_s.mp3']]]), { operator: true }), {}, 'only skill-mode files: no attack sound');
assert.deepEqual(pickUnitSfx(undefined, { operator: true, projectile: { born: ['p/b.mp3'] } }), { attack: ['p/b.mp3'] }, 'no unit banks: the projectile bank');
});
test('enemy set covers 07 list, bosses and their summons', () => { test('enemy set covers 07 list, bosses and their summons', () => {
const assets07 = readJson('docs/research/07-assets.json'); const assets07 = readJson('docs/research/07-assets.json');
const ids = new Set(collectEnemyIds({ assets07, enemies05: readJson('docs/research/05-enemies.json'), maps05: readJson('docs/research/05-maps.json'), ops03: readJson('docs/research/03-operators.json') })); const ids = new Set(collectEnemyIds({ assets07, enemies05: readJson('docs/research/05-enemies.json'), maps05: readJson('docs/research/05-maps.json'), ops03: readJson('docs/research/03-operators.json') }));
+6 -3
View File
@@ -55,9 +55,12 @@ test('炎: members ATK ×(1+0.23+0.009L), 调和 enjoys it, non-members unchange
checkInvariants(h.b); checkInvariants(h.b);
}); });
test('炎 6 / 9: one / two 炎佑 with 30 % of the real 炎 sums; 9: ATK ×1.5, damage taken ×0.1', () => { test('炎 6 / 9: one / two 炎佑 with their template stats + 30 % of the real 炎 sums; 9: ATK ×1.5, damage taken ×0.1', () => {
assert.equal(yanyouShare(), 0.3); assert.equal(yanyouShare(), 0.3);
const bb = bondBb('yanShip'); const bb = bondBb('yanShip');
// PRTS: "登场时使自身攻击力、生命值增加…所有【炎】盟约干员攻击力、生命值的30%(最终加算)" — added to the template
const tpl = DATA.tokens.enemy_9012_acloon.stats;
assert.deepEqual([tpl.atk, tpl.maxHp], [600, 12000], 'enemy_9012_acloon template');
for (const n of [6, 9]) { for (const n of [6, 9]) {
const list = []; const list = [];
for (let i = 0; i < n - 1; i++) list.push([`y${i}_a`, ['yanShip']]); for (let i = 0; i < n - 1; i++) list.push([`y${i}_a`, ['yanShip']]);
@@ -72,8 +75,8 @@ test('炎 6 / 9: one / two 炎佑 with 30 % of the real 炎 sums; 9: ATK ×1.5,
const sumAtk = (n - 1) * 1000 * (1 + bb.base_atk); // 调和 excluded from the sums const sumAtk = (n - 1) * 1000 * (1 + bb.base_atk); // 调和 excluded from the sums
const sumHp = (n - 1) * 10000; const sumHp = (n - 1) * 10000;
for (const y of yy) { for (const y of yy) {
close(y.s.atk, sumAtk * 0.3 * (n === 9 ? bb.atk : 1), 1e-6, `炎佑 ATK @${n}`); close(y.s.atk, (tpl.atk + sumAtk * 0.3) * (n === 9 ? bb.atk : 1), 1e-6, `炎佑 ATK @${n}`);
close(y.s.maxHp, sumHp * 0.3, 1e-6, `炎佑 HP @${n}`); close(y.s.maxHp, tpl.maxHp + sumHp * 0.3, 1e-6, `炎佑 HP @${n}`);
close(y.s.dmgTakenMul, n === 9 ? 1 - bb.damage_resistance : 1, 1e-9, `炎佑 dmgTaken @${n}`); close(y.s.dmgTakenMul, n === 9 ? 1 - bb.damage_resistance : 1, 1e-9, `炎佑 dmgTaken @${n}`);
} }
checkInvariants(h.b); checkInvariants(h.b);
+4 -1
View File
@@ -79,7 +79,10 @@ test('coverage: every 悬赏 / 战术决策 effect and every 道具补给 / 机
const ids = choiceCardIds(DATA); const ids = choiceCardIds(DATA);
assert.equal(ids.bounty.length, 129, '129 ENEMY_GAIN effects'); assert.equal(ids.bounty.length, 129, '129 ENEMY_GAIN effects');
assert.equal(ids.tactic.length, 43, '43 BUFF_GAIN effects'); assert.equal(ids.tactic.length, 43, '43 BUFF_GAIN effects');
assert.equal(ids.items.length, 56, '56 shop-eligible normal EQUIP items'); // 56 normal EQUIP items − the 5 effect-only ones (4 special 维式重锤, 突变细胞: never sold, user playtest #4)
assert.equal(ids.items.length, 51, '51 shop items');
for (const id of ['chess_item_2_03_e_a', 'chess_item_3_09_e_a', 'chess_item_3_10_e_a', 'chess_item_4_09_e_a', 'chess_item_5_08_e_a']) assert.ok(!ids.items.includes(id), `${id} is never a supply card`);
assert.ok(ids.items.includes('chess_item_1_01_e_a'), 'the plain 维式重锤 is');
for (const id of [...ids.bounty, ...ids.tactic, ...ids.items]) { for (const id of [...ids.bounty, ...ids.tactic, ...ids.items]) {
const h = REG.get(`choice:${id}`); const h = REG.get(`choice:${id}`);
assert.ok(h && typeof h.onChoicePick === 'function', `choice:${id}`); assert.ok(h && typeof h.onChoicePick === 'function', `choice:${id}`);
+72 -21
View File
@@ -713,19 +713,22 @@ test(`${nm('enemy_1042_frostd')}: operators within ${tb('enemy_1042_frostd', 'de
assert.equal(h.unit('t_mage').s.aspd, 100); assert.equal(h.unit('t_mage').s.aspd, 100);
}); });
test(`${nm('enemy_1040_bombd')}: bombs splash around the target; speeds up after the first throw`, () => { test(`${nm('enemy_1040_bombd')}: no normal attack; ONE bomb on the target + its 8 tiles, then move speed ×${skb('enemy_1040_bombd', 'boomb').bb.move_speed}`, () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 10, col: 6 }] }); // PRTS 暴鸰: "不进行普通攻击" · 投弹 "对目标及其周围八格的我方单位造成100%物理伤害 … 技能结束后移速最终提升至200% ※此技能仅能触发一次"
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 10, col: 6 }, { chessId: 't_wall3', row: 12, col: 6 }] });
h.step(); h.step();
const e = put(h, 'enemy_1040_bombd', [10, 7], { route: 2 }); const e = put(h, 'enemy_1040_bombd', [10, 7], { route: 2 });
const v0 = E.enemy_1040_bombd.stats.moveSpeed; const s = skb('enemy_1040_bombd', 'boomb');
h.runUntil(() => e.stats.attacks >= 1, 10); h.run(s.initCooldown + 0.2);
h.run(0.5); const [w1, w2, w3] = ['t_wall', 't_wall2', 't_wall3'].map((id) => h.unit(id));
assert.ok(h.unit('t_wall').stats.taken > 0 && h.unit('t_wall2').stats.taken > 0); approx(w2.stats.taken, e.s.atk, 1e-6, 'the target (latest deployed in range): 100 % ATK');
assert.equal(e.base.moveSpeed, 0); approx(w1.stats.taken, e.s.atk, 1e-6, 'a tile next to it: splash 100 %');
approx(e.s.moveSpeed, 0); assert.equal(w3.stats.taken, 0, 'two rows away: outside the 3×3');
assert.ok(e.findBuff('ab:bombRun')); assert.equal(e.stats.attacks, 0, 'never a normal attack');
approx(e.findBuff('ab:bombRun').mods.moveMul, 1 + skb('enemy_1040_bombd', 'boomb').bb.move_speed); approx(e.findBuff('ab:bombRun').mods.moveMul, s.bb.move_speed, 1e-9, '移速最终提升至200%');
assert.ok(v0 > 0); h.run(20);
approx(w2.stats.taken + w1.stats.taken, 2 * e.s.atk, 1e-6, 'only once');
assert.equal(e.stats.attacks, 0);
}); });
for (const key of ['enemy_10083_hlbird', 'enemy_10084_hlegle', 'enemy_10085_hllevi_2']) { for (const key of ['enemy_10083_hlbird', 'enemy_10084_hlegle', 'enemy_10085_hllevi_2']) {
@@ -738,6 +741,7 @@ for (const key of ['enemy_10083_hlbird', 'enemy_10084_hlegle', 'enemy_10085_hlle
assert.equal(f.length, 1); assert.equal(f.length, 1);
approx(f[0].duration, tb(key, 'SelfFear.fear')); approx(f[0].duration, tb(key, 'SelfFear.fear'));
assert.ok(e.findBuff('ab:fearRun')); assert.ok(e.findBuff('ab:fearRun'));
approx(e.findBuff('ab:fearRun').mods.moveMul, tb(key, 'SelfFear.move_speed'), 1e-9, '移动速度最终提升至150%');
h.b.dealDamage(null, e, { amount: 1, type: 'true' }); h.b.dealDamage(null, e, { amount: 1, type: 'true' });
assert.equal(statuses(h, e.id, 'fear').length, 1, 'only once'); assert.equal(statuses(h, e.id, 'fear').length, 1, 'only once');
}); });
@@ -774,19 +778,66 @@ test(`${nm('enemy_1407_hummbd')}: death exposes nearby operators (damage taken
assert.equal(h.unit('t_wall2').s.dmgTakenMul, 1); assert.equal(h.unit('t_wall2').s.dmgTakenMul, 1);
}); });
test(`${nm('enemy_9009_acfort')}: devours nearby normal flyers for ammo, then fires it all (ATK×1.3 per round)`, () => { test(`${nm('enemy_9009_acfort')}: grabs ≤ 3 normal flyers within 2.5 × range_radius for ammo, then fires one ATK×1.3 hit per ammo at random allies`, () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }] }); const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 12, col: 2 }] });
h.step(); h.step();
const e = put(h, 'enemy_9009_acfort', [10, 7], { route: 2 }); const e = put(h, 'enemy_9009_acfort', [10, 7], { route: 2 });
e.profile.noAttack = true; e.profile.noAttack = true;
const y1 = put(h, 'enemy_1005_yokai', [10, 7], { route: 2 }); const k = skb('enemy_9009_acfort', 'KillOthers'), f = skb('enemy_9009_acfort', 'FireWeapon');
const y2 = put(h, 'enemy_1005_yokai', [11, 7], { route: 2 }); const R = k.bb.range_radius * 2.5; // PRTS: 半径3.75 (2.5倍可变半径)
const k = skb('enemy_9009_acfort', 'KillOthers'); assert.equal(R, 3.75);
h.run(k.initCooldown + k.bb.duration + 0.1); // four prey inside 3.75 (the nearest three are taken), one outside
assert.ok(!y1.alive && !y2.alive); const near = [[10, 7], [11, 7], [10, 9], [12, 10]].map((p) => put(h, 'enemy_1005_yokai', p, { route: 2 }));
assert.ok(h.unit('t_wall').stats.taken >= 2 * e.s.atk * skb('enemy_9009_acfort', 'FireWeapon').bb.atk_scale - 1e-6 || e.mem.ab.ammo === 2); const out = put(h, 'enemy_1005_yokai', [9, 2], { route: 2 });
h.run(skb('enemy_9009_acfort', 'FireWeapon').cooldown + 0.1); assert.ok(Math.hypot(out.x - e.x, out.y - e.y) > R && Math.hypot(near[3].x - e.x, near[3].y - e.y) <= R);
approx(h.unit('t_wall').stats.taken, 2 * e.s.atk * skb('enemy_9009_acfort', 'FireWeapon').bb.atk_scale); h.run(k.initCooldown + 0.1);
assert.ok(e.findBuff('ab:grabbing')?.flags.bind, '黑云 holds still while grabbing');
h.run(0.5 + k.bb.duration + 0.1);
assert.deepEqual(near.map((y) => y.alive), [false, false, false, true], 'the nearest three are devoured');
assert.ok(out.alive, 'out of reach');
const w1 = h.unit('t_wall'), w2 = h.unit('t_wall2');
// 全弹发射 fires as soon as it has ammo: 3 hits of ATK×1.3, each on a random ally of the whole field
assert.ok(h.runUntil(() => w1.stats.taken + w2.stats.taken > 0, f.cooldown + 1));
h.run(0.1);
approx(w1.stats.taken + w2.stats.taken, 3 * e.s.atk * f.bb.atk_scale, 1e-6, 'one hit per ammo');
assert.equal(e.mem.ab.ammo, 0, 'all SP spent');
checkInvariants(h.b);
});
test(`${nm('enemy_1112_emppnt')} / 中枢: every attack is a shell that lands 3 s later on the target's spot (all allies within 1.2)`, () => {
for (const key of ['enemy_1112_emppnt', 'enemy_1112_emppnt_2']) {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }, { chessId: 't_wall2', row: 11, col: 6 }, { chessId: 't_wall3', row: 12, col: 6 }], captureNoisy: true });
h.step();
const e = put(h, key, [10, 7], { route: 2 });
assert.ok(h.runUntil(() => e.stats.attacks >= 1, 10), 'attacks');
const [w1, w2, w3] = ['t_wall', 't_wall2', 't_wall3'].map((id) => h.unit(id));
const t0 = h.b.time;
assert.equal(w1.stats.taken + w2.stats.taken + w3.stats.taken, 0, 'nothing on launch');
assert.ok(h.events.some((ev) => ev[0] === 'atk' && ev[1] === e.id && ev[3] === 'mortar'), 'attack event without a projectile');
const shell = h.eventsOf('fx').find((ev) => ev[1] === 'bombardShell');
assert.ok(shell, 'the shell fx (3 s flight)');
h.run(2.8);
assert.equal(w1.stats.taken + w2.stats.taken + w3.stats.taken, 0, 'still in the air');
h.run(0.3);
const hit = [w1, w2, w3].filter((w) => w.stats.taken > 0);
// the target (latest deployed in range 2) is t_wall3 or t_wall2; the blast covers every wall within 1.2 of it
assert.ok(hit.length >= 2 && hit.every((w) => Math.abs(w.stats.taken - e.s.atk) < 1e-6), `${key}: ${hit.map((w) => w.defId)}`);
assert.ok(h.b.time - t0 >= 3 - 1e-6);
}
});
test(`${nm('enemy_10084_hlegle')}: never attacks a flying ally (不会攻击飞行单位) — a ground one in range instead`, () => {
// a flying ally (the 炎佑 is one) deployed after the wall, so the engine's order (latest deployed) would pick it
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }, { chessId: 't_wall2', row: 10, col: 8 }] });
h.step();
const w = h.unit('t_wall'), fly = h.unit('t_wall2');
fly.motion = 'FLY';
assert.ok(fly.isFlying && fly.deploySeq > w.deploySeq);
const e = put(h, 'enemy_10084_hlegle', [10, 7], { route: 2 });
assert.ok(h.runUntil(() => e.stats.attacks >= 1, 10));
h.run(0.5);
assert.equal(fly.stats.taken ?? 0, 0, 'the flyer is never hit');
assert.ok(w.stats.taken > 0, 'the ground wall is');
}); });
test(`${nm('enemy_1321_wdarft')} + ${nm('enemy_1269_nhfly')}: the apostle spawns seeds that dive onto operators and self-destruct`, () => { test(`${nm('enemy_1321_wdarft')} + ${nm('enemy_1269_nhfly')}: the apostle spawns seeds that dive onto operators and self-destruct`, () => {
+19 -13
View File
@@ -973,21 +973,27 @@ test('圣约送葬人 T1 受选之人: the extra attack consumes no ammo and nev
}); });
test('元素伤害 uses the engine elemental type: × elemTakenMul, never × trueTakenMul (烛煌 熔点引爆, 妮芙 失魂)', () => { test('元素伤害 uses the engine elemental type: × elemTakenMul, never × trueTakenMul (烛煌 熔点引爆, 妮芙 失魂)', () => {
const h = makeBattle({ // one battle per burst: after a burst the enemy's gauges stay locked for its 爆发冷却 (damage.js)
defs: { enemies: { enemy_dummy: dummy() } }, const arena = () => {
units: [{ chessId: 'chess_char_5_03_a', row: 9, col: 3 }, { chessId: 'chess_char_5_22_a', row: 11, col: 3 }], const h = makeBattle({
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 30, defs: { enemies: { enemy_dummy: dummy() } },
}); units: [{ chessId: 'chess_char_5_03_a', row: 9, col: 3 }, { chessId: 'chess_char_5_22_a', row: 11, col: 3 }],
const blaze = h.unit('chess_char_5_03_a'), nymph = h.unit('chess_char_5_22_a'); enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 30,
h.step(); });
const e = h.enemy('enemy_dummy'); h.step();
h.b.addBuff(e, { key: 't:true', mods: { trueTakenMul: 2 } }); const e = h.enemy('enemy_dummy');
h.b.addBuff(e, { key: 't:elem', mods: { elemTakenMul: 1.5 } }); h.b.addBuff(e, { key: 't:true', mods: { trueTakenMul: 2 } });
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 1000 }); h.b.addBuff(e, { key: 't:elem', mods: { elemTakenMul: 1.5 } });
const melt = tagged(h, 'blazeMelt', e); return { h, e, blaze: h.unit('chess_char_5_03_a'), nymph: h.unit('chess_char_5_22_a') };
};
const a = arena();
a.h.b.dealDamage(null, a.e, { type: 'element', element: 'burn', amount: 1000 });
const melt = tagged(a.h, 'blazeMelt', a.e);
assert.equal(melt.length, 1); assert.equal(melt.length, 1);
assert.equal(melt[0].type, 'elemental'); assert.equal(melt[0].type, 'elemental');
approx(melt[0].amount, blaze.s.atk * tal(blaze, 0).ep_damage_scale * 1.5, 1e-6, 'meltdown × elemTakenMul only'); approx(melt[0].amount, a.blaze.s.atk * tal(a.blaze, 0).ep_damage_scale * 1.5, 1e-6, 'meltdown × elemTakenMul only');
clean(a.h);
const { h, e, nymph } = arena();
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 1000 }); h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 1000 });
assert.ok(h.runUntil(() => tagged(h, 'nymphSoul', e).length > 0, 6)); assert.ok(h.runUntil(() => tagged(h, 'nymphSoul', e).length > 0, 6));
const soul = tagged(h, 'nymphSoul', e)[0]; const soul = tagged(h, 'nymphSoul', e)[0];
+156
View File
@@ -0,0 +1,156 @@
// Content tests for user playtest #4 items 11 and 12 (server/sim/content/tokens.js, kits/tier2.js):
// #11 赫默's 医疗无人机 appears only when her S2 fires — never on the field at battle start (tools/build-data.mjs:
// a skill's summon is not a prep hand card; test/match/loadout.test.js covers the prep side);
// #12 the 炎 bond's “炎佑” (PRTS “炎佑” 级别0(卫戍协议)): follows the highest-aggro enemy of the whole field and stays
// where it is without one (no fixed return point); 祛恶之焰 [模式乙] locks one target, channels up to 20 s (no normal
// attacks meanwhile), ends when the lock is lost or on silence; burn on every damage it deals; 元素脆弱 aura
// (radius 1.5); immune to element damage; bond stats = template + 30 % of the 炎 sums.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { hasGeneratedData } from '../../server/sim/simdata.js';
import { spawnYanyou, TOKEN_IDS } from '../../server/sim/content/tokens.js';
const REAL = { skip: !hasGeneratedData() };
const dummy = (o = {}) => enemyRec({ key: o.key ?? 'enemy_dummy', hp: 1e7, speed: 0, ...o });
const guard = (o = {}) => chessRec({ id: 'test_guard', profession: 'WARRIOR', skill: null, stats: { atk: 0, maxHp: 1e5, def: 0 }, ...o });
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
const flameRec = (h) => h.b.data.rawToken(TOKEN_IDS.yanyou).skills.find((s) => s.prefabKey === 'Skill_2');
// =====================================================================================================================
// #11 赫默
test('#11 赫默 (full kit, default S2): no drone at battle start; the 医疗探机 appears when 医疗无人机 fires', REAL, () => {
const h = makeBattle({ defs: { chess: { test_guard: guard() } }, units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 3 }, { chessId: 'test_guard', row: 10, col: 4 }], autoFinish: false, timeLimit: 90 });
h.step();
const hm = h.unit('chess_char_2_02_a');
assert.equal(hm.skill.id, 'skchr_silent_2');
const drones = () => h.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.healDrone && u.alive);
assert.equal(drones().length, 0, 'nothing but the operators at t = 0');
h.unit('test_guard').hp = 1000;
const cost = hm.skill.spCost;
h.run(cost - 2);
assert.equal(hm.skill.activations, 0);
assert.equal(drones().length, 0, 'no drone before the skill is ready');
assert.ok(h.runUntil(() => hm.skill.activations >= 1, 10), 'S2 fires once its SP is full (initSp 0)');
h.step();
assert.equal(drones().length, 1, 'the drone comes with the skill');
assert.equal(drones()[0].ownerUnit, hm);
checkInvariants(h.b);
});
// =====================================================================================================================
// #12 炎佑
test('#12 炎佑 stays where it is when no enemy is on the field (no fixed return point)', REAL, () => {
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [], enemies: [{ key: 'enemy_dummy', pos: [9, 4] }], autoFinish: false, timeLimit: 90 });
h.step();
const [y] = spawnYanyou(h.b, 'p1', { atk: 1000, hp: 5000 });
const home = { x: y.x, y: y.y };
const e = h.enemy('enemy_dummy');
assert.ok(h.runUntil(() => dist(y, e) <= 0.3, 30), 'flies over the enemy');
h.b.dealDamage(null, e, { amount: 1e9, type: 'true' });
assert.equal(e.alive, false);
h.step();
const at = { x: y.x, y: y.y };
assert.ok(Math.hypot(at.x - home.x, at.y - home.y) > 1, 'it had left its spawn point');
h.run(10);
assert.ok(Math.abs(y.x - at.x) < 1e-9 && Math.abs(y.y - at.y) < 1e-9, `hovers in place (${y.x.toFixed(2)},${y.y.toFixed(2)} vs ${at.x.toFixed(2)},${at.y.toFixed(2)})`);
// an empty field from the start: it never moves
const h2 = makeBattle({ units: [], autoFinish: false, timeLimit: 30 });
h2.step();
const [y2] = spawnYanyou(h2.b, 'p1', { atk: 1000, hp: 5000 });
const p0 = { x: y2.x, y: y2.y };
h2.run(10);
assert.deepEqual({ x: y2.x, y: y2.y }, p0);
checkInvariants(h.b);
});
test('#12 炎佑 tracks the highest-aggro enemy of the whole field (least remaining path), not the nearest one', REAL, () => {
// two standing enemies on the walk route to the goal (9,2): (9,4) is 2 tiles from it, (12,9) far away
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [], enemies: [{ key: 'enemy_dummy', pos: [12, 9] }, { key: 'enemy_dummy', pos: [9, 4] }], autoFinish: false, timeLimit: 90 });
h.step();
const [y] = spawnYanyou(h.b, 'p1', { atk: 0, hp: 5000 });
const [far, lead] = h.enemies();
assert.ok(h.b.remainingDistance(lead) < h.b.remainingDistance(far), 'fixture: (9,4) leads');
// start next to the far enemy: it still leaves it for the leader
y.x = far.x; y.y = far.y;
assert.ok(h.runUntil(() => dist(y, lead) <= 0.3, 40), 'flies over the leading enemy');
h.run(3);
assert.ok(dist(y, lead) <= 0.3, 'and keeps hovering over it');
});
test('#12 祛恶之焰 [模式乙]: locks the normal attack target, no normal attacks while channelling, ends when the target dies', REAL, () => {
const burn = [];
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy(), enemy_frail: dummy({ key: 'enemy_frail', hp: 1e7 }) } }, units: [],
enemies: [{ key: 'enemy_frail', pos: [10, 6] }, { key: 'enemy_dummy', pos: [10, 6] }], autoFinish: false, timeLimit: 120, captureNoisy: true, hooks: ['damaged'],
setup: (b) => b.on('damaged', (c) => { if (c.type === 'element' && c.dmg?.tags?.includes('yanyou')) burn.push({ t: b.time, id: c.target.id }); }),
});
h.step();
const [y] = spawnYanyou(h.b, 'p1', { atk: 0, hp: 50000 }); // ATK 600 (template): no burn burst in the test window
const sk = flameRec(h);
assert.deepEqual([sk.cooldown, sk.initCooldown, sk.bb.atk_scale, sk.bb.hit_duration, sk.bb.range_radius], [15, 15, 0.6, 20, 1], 'official Skill_2 blackboard');
assert.ok(h.runUntil(() => y.skill.active, 25), '祛恶之焰');
const t = y.mem.flame.target;
assert.ok(t && t.alive, 'locked target');
const attacks = y.stats.attacks;
for (const e of h.enemies()) e.elem.burn = 0; // the normal attacks' burn so far: keep the gauges unburst
const burnBefore = burn.length;
h.run(4.05);
assert.equal(y.stats.attacks, attacks, 'no normal attack while channelling');
assert.equal(y.mem.flame.target, t, 'the lock holds');
// 5 ticks (at once, then every second): both enemies (same tile) burn from every flame tick
assert.ok(burn.length - burnBefore >= 2 * 5, `burn from the flame ticks (${burn.length - burnBefore})`);
assert.ok(y.skill.active);
// the locked target dies → the channel ends at once (no retarget), long before its 20 s
h.b.dealDamage(null, t, { amount: 1e12, type: 'true' });
h.step();
assert.equal(y.skill.active, false, 'ends with its target');
assert.ok(h.b.time - y.skill.lastStart < sk.bb.hit_duration - 5);
assert.equal(y.mem.flame, null);
// normal attacks resume; the next flame needs the whole cooldown again
const t1 = h.b.time;
assert.ok(h.runUntil(() => y.stats.attacks > attacks, 5), 'attacks again');
assert.ok(h.runUntil(() => y.skill.activations >= 2, 20));
assert.ok(h.b.time - t1 >= sk.cooldown - 0.5, `cooldown after the channel (${(h.b.time - t1).toFixed(2)} s)`);
checkInvariants(h.b);
});
test('#12 祛恶之焰 lasts at most hit_duration on a surviving target, and silence ends it at once', REAL, () => {
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }], autoFinish: false, timeLimit: 120 });
h.step();
const [y] = spawnYanyou(h.b, 'p1', { atk: 100, hp: 50000 });
assert.ok(h.runUntil(() => y.skill.active, 25));
const t0 = y.skill.lastStart;
assert.ok(h.runUntil(() => !y.skill.active, 25));
const len = h.b.time - t0;
assert.ok(Math.abs(len - flameRec(h).bb.hit_duration) < 0.1, `20 s channel (${len.toFixed(2)})`);
assert.ok(h.runUntil(() => y.skill.active, 20), 'next flame');
h.b.applyStatus(y, 'silence', { duration: 3, source: null });
h.step();
assert.equal(y.skill.active, false, 'silenced: the channel ends');
});
test('#12 炎佑: 元素脆弱 aura within 1.5 of itself, immune to element damage, burn = 20 % ATK on normal hits', REAL, () => {
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy', pos: [10, 8] }], autoFinish: false, timeLimit: 60 });
h.step();
const [y] = spawnYanyou(h.b, 'p1', { atk: 400, hp: 5000 });
// pin it at (10, 4.8): the enemy at (10,6) is 1.2 away, the one at (10,8) 3.2
h.b.addBuff(y, { key: 'test:pin', persist: true, flags: { noMove: true } });
y.x = 4.8; y.y = 10;
h.run(0.5);
const [near, far] = h.enemies();
assert.ok(near.findBuff('elemFragile'), '1.2 away: 元素脆弱');
assert.ok(!far.findBuff('elemFragile'), '3.2 away: none');
assert.ok(Math.abs(near.s.elemTakenMul - 1.2) < 1e-9, 'element damage ×1.2');
// element damage on the dragon is cancelled
h.b.dealDamage(near, y, { type: 'element', element: 'burn', amount: 800 });
assert.equal(y.elem.burn, 0, 'no burn gauge on 炎佑');
// normal hits: burn = ATK × 0.2 (× the 1.2 fragility) per hit
const before = near.elem.burn;
assert.ok(h.runUntil(() => near.elem.burn > before, 5));
const tal = h.b.data.rawToken(TOKEN_IDS.yanyou).talents.bb;
assert.ok(Math.abs(near.elem.burn - before - y.s.atk * tal['2.ep_damage_ratio'] * tal['2.damage_scale']) < 1e-6, `burn per hit ${near.elem.burn - before}`);
checkInvariants(h.b);
});
+3 -3
View File
@@ -463,9 +463,9 @@ test('炎佑: spawnYanyou — flying ally, bond stats, 3 targets with burn + ele
assert.equal(ys.length, 2); assert.equal(ys.length, 2);
const y = ys[0]; const y = ys[0];
assert.equal(y.motion, 'FLY'); assert.equal(y.motion, 'FLY');
approx(y.s.atk, 1500, 1e-9); approx(y.s.atk, (600 + 1000) * 1.5, 1e-9, 'template 600 + the bond share, ×1.5');
assert.equal(y.s.maxHp, 6000); assert.equal(y.s.maxHp, 12000 + 6000);
assert.equal(y.hp, 6000); assert.equal(y.hp, 18000);
approx(y.s.dmgTakenMul, 0.1, 1e-9); approx(y.s.dmgTakenMul, 0.1, 1e-9);
assert.equal(y.s.blockCnt, 0); assert.equal(y.s.blockCnt, 0);
const x0 = y.x, y0 = y.y; const x0 = y.x, y0 = y.y;
+9 -3
View File
@@ -416,10 +416,16 @@ test('chess/tokens: talent tokens resolve and every token variant says where it
assert.deepEqual(tokens.token_10057_svash2_eagle1.variants.chess_char_5_14_a.sources, ['display']); assert.deepEqual(tokens.token_10057_svash2_eagle1.variants.chess_char_5_14_a.sources, ['display']);
// 夕's skill "cnt" is a charge count, not a token count. // 夕's skill "cnt" is a charge count, not a token count.
assert.equal(tokens.token_10015_dusk_drgn.variants.chess_char_5_12_a.count, null); assert.equal(tokens.token_10015_dusk_drgn.variants.chess_char_5_12_a.count, null);
// Every placeable (hand) token is produced by the chess's default skill or a talent. // Placeable (hand) tokens = the TALENT summons the shop state shows (tokenDisplayType DEFAULT); a skill's summon
for (const t of Object.values(tokens)) if (t.placeable) { // ("获得一个医疗无人机" 赫默, "获得一个诅咒娃娃" 巫恋) appears when the skill fires, never at battle start (user
for (const v of Object.values(t.variants)) assert.ok(v.sources.some((s) => s !== 'display'), `${t.tokenId}: placeable display-only token`); // playtest #4 item 11).
for (const t of Object.values(tokens)) {
if (t.kind !== 'summon') continue;
const talent = Object.values(t.variants).some((v) => v.sources.includes('talent') || Object.values(v.bySkill || {}).some((b) => b.sources.includes('talent')));
assert.equal(t.placeable, t.displayType === 'DEFAULT' && talent, `${t.tokenId} (${t.name}): placeable`);
} }
assert.deepEqual(Object.values(tokens).filter((t) => t.placeable).map((t) => t.name).sort(), ['斯卡蒂的海嗣', '流形', '狼群'].sort());
for (const id of ['token_10000_silent_healrb', 'token_10006_vodfox_doll']) assert.equal(tokens[id].placeable, false, `${id}: skill summon`);
assert.equal(tokens.enemy_9012_acloon.stats.deployLimit, tokens.enemy_9012_acloon.deployLimit); assert.equal(tokens.enemy_9012_acloon.stats.deployLimit, tokens.enemy_9012_acloon.deployLimit);
}); });
+64 -12
View File
@@ -5,8 +5,10 @@
// synthetic zero, reconnect windows (co-op 10 min, solo singleReconnectTime 24 h), g.equip replaceUid + equipped items // synthetic zero, reconnect windows (co-op 10 min, solo singleReconnectTime 24 h), g.equip replaceUid + equipped items
// locked, battleId unique per match, solo pause (g.pause / m.public.paused), road-over-floor lanes, module icons from // locked, battleId unique per match, solo pause (g.pause / m.public.paused), road-over-floor lanes, module icons from
// local art, 标准 = 战场#01 only; user playtest #3 (DESIGN §17): temp overflow kept until the first prep its player can // local art, 标准 = 战场#01 only; user playtest #3 (DESIGN §17): temp overflow kept until the first prep its player can
// act in (never wiped at the round start), the 回环射手 boomerang and 蕾缪安's shells one by one, the shared picking // act in (never wiped at the round start), the 回环射手 boomerang and 蕾缪安's shells one by one, the live LP, the detail
// rule / drag target, the live LP, the detail card order and the static game data. // card order and the static game data; user playtest #4 (DESIGN §18): picking by the tile under the pointer and the
// dragged model held under it, a single human untimed, the strategy draft's one countdown, 机变 two taps, knocked-out
// operators and the official element gauges, live stats, the shop-only items, skill summons, 炎佑.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
@@ -19,9 +21,10 @@ import { SOLO_RECONNECT_FALLBACK_SEC } from '../server/lobby.js';
import { moduleTypeIconUrl } from '../public/js/ui/assetUrls.js'; import { moduleTypeIconUrl } from '../public/js/ui/assetUrls.js';
import { validateC2S } from '../shared/protocol.js'; import { validateC2S } from '../shared/protocol.js';
import { ERR, PHASE } from '../shared/constants.js'; import { ERR, PHASE } from '../shared/constants.js';
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED } from '../server/sim/constants.js'; import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED, ELEMENT, ELEMENT_ORDER, DOWN_STATE } from '../server/sim/constants.js';
import { SUB } from '../server/sim/professions.js'; import { SUB } from '../server/sim/professions.js';
import { BODY_H, PROBE_MARGIN } from '../public/js/render/pick.js'; import { ENEMY_REACH } from '../public/js/render/pick.js';
import { BAND_TURN_SECONDS } from '../server/match/Match.js';
import { SPINE_EVICT_DELAY_MS, SPINE_QUIET_DELAY_MS } from '../public/js/assets.js'; import { SPINE_EVICT_DELAY_MS, SPINE_QUIET_DELAY_MS } from '../public/js/assets.js';
import { RETRY_DELAYS_MS } from '../public/js/data.js'; import { RETRY_DELAYS_MS } from '../public/js/data.js';
import { DATA, makeMatch } from './match/harness.js'; import { DATA, makeMatch } from './match/harness.js';
@@ -227,14 +230,8 @@ test('回环射手 boomerang and 蕾缪安 S3 shells (user playtest #3 items 4
assert.match(SIM, /S3 礼炮·强制追思/); assert.match(SIM, /S3 礼炮·强制追思/);
}); });
test('picking, drag target, lost models, live LP, detail card order, static game data (user playtest #3): code and DESIGN §17 agree', () => { test('lost models, live LP, detail card order, static game data (user playtest #3): code and DESIGN §17 agree', () => {
assert.equal(BODY_H, 1.27, 'median chibi height (tiles)'); assert.match(DESIGN, /### 17\.2 Picking and the drag target \(#7\) — superseded by §18\.1/);
assert.equal(PROBE_MARGIN, 0.5);
assert.match(DESIGN, /`PROBE_MARGIN` 0\.5 tile/);
const app = readFileSync(join(ROOT, 'public/js/render/app.js'), 'utf8');
assert.match(app, /export const TOUCH_LIFT_TILES = 0\.6;/);
assert.match(DESIGN, /`TOUCH_LIFT_TILES` = 0\.6 tile above the finger/);
assert.match(DESIGN, /render\/pick\.js/);
assert.equal(SPINE_EVICT_DELAY_MS, 1000); assert.equal(SPINE_EVICT_DELAY_MS, 1000);
assert.equal(SPINE_QUIET_DELAY_MS, 3000); assert.equal(SPINE_QUIET_DELAY_MS, 3000);
assert.match(DESIGN, /`SPINE_EVICT_DELAY_MS` \(1 s\)/); assert.match(DESIGN, /`SPINE_EVICT_DELAY_MS` \(1 s\)/);
@@ -251,3 +248,58 @@ test('picking, drag target, lost models, live LP, detail card order, static game
assert.match(PLAYING, /顶栏的目标生命值会\*\*立即\*\*显示扣除后的数值/); assert.match(PLAYING, /顶栏的目标生命值会\*\*立即\*\*显示扣除后的数值/);
assert.match(README, /漏怪时顶栏的目标生命值实时减少/); assert.match(README, /漏怪时顶栏的目标生命值实时减少/);
}); });
test('user playtest #4 (DESIGN §18): picking by tile, timers, 机变 two taps, down / element state, content — code and every doc agree', () => {
// #1 the tile under the pointer; the dragged model held under the pointer (no touch lift, probe or body shapes)
assert.equal(ENEMY_REACH, 0.6);
const app = readFileSync(join(ROOT, 'public/js/render/app.js'), 'utf8');
assert.match(app, /export const DRAG_HOLD_TILES = 0\.45;/);
assert.ok(!/TOUCH_LIFT_TILES|drawnAt|pickShape|pieceDragOver/.test(app), 'no touch lift, pixel probe or body shapes (user playtest #4 item 1)');
assert.match(DESIGN, /`DRAG_HOLD_TILES` = 0\.45 tile/);
assert.match(DESIGN, /`ENEMY_REACH` 0\.6 tile/);
assert.match(DESIGN, /render\/pick\.js/);
assert.ok(!/pieceDragOver'\|/.test(DESIGN), 'DESIGN §9: no pieceDragOver event');
assert.match(README, /按地上的方格/);
for (const [name, text] of [['README', README], ['PLAYING', PLAYING]]) {
assert.ok(!/画面上实际画出的干员/.test(text), `${name}: no body picking`);
assert.ok(!/模型抬高到手指上方|模型在手指上方/.test(text), `${name}: no touch lift`);
}
// #3 a single human is untimed outside battles (code + docs)
const lone = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 1, bots: 1, seed: 8, fake: true }).start();
assert.equal(lone.m.soloUntimed, true);
assert.equal(lone.m.publicView().deadline, 0, 'no briefing countdown');
lone.m.dispose();
assert.match(DESIGN, /solo and any single-human match untimed/);
assert.match(META, /solo \/ single human untimed/);
assert.match(PLAYING, /只有你一名玩家/);
// #4 one countdown: BAND_TURN_SECONDS per turn (code = data = docs)
assert.equal(BAND_TURN_SECONDS, 30);
assert.equal(DATA.config.timers.bandTurn, BAND_TURN_SECONDS);
assert.match(DESIGN, /`BAND_TURN_SECONDS` 30 s per turn = m\.public\.deadline/);
assert.match(META, /`Match\.BAND_TURN_SECONDS` 30/);
assert.match(PLAYING, /\*\*每人 30 秒\*\*/);
assert.ok(!/12 s\/turn/.test(DESIGN) && !/`bandTurn` 12/.test(META) && !/每人 12 秒/.test(PLAYING), 'the old 12 s turn is gone');
assert.match(META, /`ev\.preview` true/, 'META: onBattleStart handlers must not change the match for the stats preview');
// #2 机变 two taps
assert.match(PLAYING, /选卡要\*\*点两次\*\*/);
assert.match(README, /机变选卡/);
// #8 / #9 element gauges (爆发冷却) and knocked-out operators
assert.equal(ELEMENT.erosion.ally.duration, 10, 'operators\' 侵蚀 burst has its 10 s cooldown');
assert.deepEqual(ELEMENT_ORDER.slice(0, 4), ['neural', 'erosion', 'burn', 'apoptosis']);
assert.deepEqual({ ...DOWN_STATE }, { COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 });
assert.match(DESIGN, /\*\*爆发冷却\*\*/);
assert.match(DESIGN, /`down: \[\[id, respawnAt \(game s\), respawnTime \(s\), state\]\]`/);
assert.match(SIM, /`burstLocked\(unit\)` in damage\.js/);
assert.match(SIM, /损伤抵抗 = the target's data `epResistance`/);
assert.ok(!/800 phys; no lock/.test(SIM), 'SIM §3: operators\' 侵蚀 locks too');
assert.match(SIM, /`down: \[\[id, respawnAt, respawnTime, state\]\]`/);
// #5 / #11 / #12 data rules
const special = DATA.items.chess_item_4_09_e_a;
assert.ok(special.shopExcluded && special.shopExcludedBy, '灼燃维式重锤 is never sold');
assert.match(dataRow('shopExcluded`, `shopExcludedBy'), /isShopItem/);
assert.match(PLAYING, /突变细胞只来自昆图斯的策略/);
assert.equal(DATA.tokens.token_10000_silent_healrb.placeable, false, '赫默\'s drone is no hand piece');
assert.match(dataRow('displayType`, `placeable'), /DEFAULT \*\*and\*\* a talent summon/);
assert.ok(!/ATK\/HP are \*\*replaced\*\*/.test(DATA_MD), 'DATA: 炎佑 stats are added (最终加算)');
assert.match(DESIGN, /with no enemy on the field it stays where it is/);
});
+11 -1
View File
@@ -74,7 +74,12 @@ async function checkHud(c, where) {
if (r.count != null) assert.equal(r.boardChess, r.count, `${tag}: deployCount = chess on board`); if (r.count != null) assert.equal(r.boardChess, r.count, `${tag}: deployCount = chess on board`);
if (r.round > 0 && r.round <= r.lastRound) assert.equal(r.domRound, String(r.round), `${tag}: top bar round`); if (r.round > 0 && r.round <= r.lastRound) assert.equal(r.domRound, String(r.round), `${tag}: top bar round`);
assert.equal(r.teamRows, r.players, `${tag}: one team row per player`); assert.equal(r.teamRows, r.players, `${tag}: one team row per player`);
if (r.domLp != null && !['FINAL_ASSAULT', 'HIDDEN_CORE'].includes(r.phase)) assert.equal(r.domLp, String(r.lp), `${tag}: top bar LP`); if (r.domLp != null && ['COMBAT', 'UNITE'].includes(r.phase)) {
// live LP (DESIGN §17.5): while the own battle's enemies enter the blue gate the top bar already shows lp − the
// pending loss (≤ 10 a round), settled at SETTLE
const shown = Number(r.domLp);
assert.ok(shown <= r.lp && shown >= Math.max(0, r.lp - 10), `${tag}: top bar LP ${r.domLp} within the round's pending loss of ${r.lp}`);
} else if (r.domLp != null && !['FINAL_ASSAULT', 'HIDDEN_CORE'].includes(r.phase)) assert.equal(r.domLp, String(r.lp), `${tag}: top bar LP`);
if (r.shopShown) { if (r.shopShown) {
assert.equal(r.domFunds, String(r.funds), `${tag}: funds card`); assert.equal(r.domFunds, String(r.funds), `${tag}: funds card`);
assert.equal(r.domRemain, String(Math.max(0, r.cap - r.count)), `${tag}: 剩余可放置角色`); assert.equal(r.domRemain, String(Math.max(0, r.cap - r.count)), `${tag}: 剩余可放置角色`);
@@ -962,7 +967,9 @@ describe('browser E2E against the real server', { skip: !ENABLED && 'needs Chrom
for (let round = 1; round <= 3; round++) { for (let round = 1; round <= 3; round++) {
const s0 = await c.waitFor((s) => (s.phase === 'PREP' && s.round === round && !s.ready) || (s.phase === 'SP_DRAFT' && s.round === round), `prep ${round}`, 90000); const s0 = await c.waitFor((s) => (s.phase === 'PREP' && s.round === round && !s.ready) || (s.phase === 'SP_DRAFT' && s.round === round), `prep ${round}`, 90000);
if (s0.phase === 'SP_DRAFT') { if (s0.phase === 'SP_DRAFT') {
// two taps (user playtest #4 item 2): select, then confirm on the selected card
await c.click('.spcard.is-pickable'); await c.click('.spcard.is-pickable');
await c.click('.spcard.is-armed');
await c.waitFor((s) => s.phase === 'PREP' && !s.ready, `prep ${round} after 机变`, 30000); await c.waitFor((s) => s.phase === 'PREP' && !s.ready, `prep ${round} after 机变`, 30000);
} }
await sleep(1800); await sleep(1800);
@@ -1126,6 +1133,7 @@ describe('browser E2E against the real server', { skip: !ENABLED && 'needs Chrom
if (!seen.has(`sp-${c.label}-${s.round}`)) { seen.add(`sp-${c.label}-${s.round}`); await sleep(2000); await c.shot(`sp-r${s.round}`); } if (!seen.has(`sp-${c.label}-${s.round}`)) { seen.add(`sp-${c.label}-${s.round}`); await sleep(2000); await c.shot(`sp-r${s.round}`); }
if (s.sp?.turn === s.me && (await c.exists('.spcard.is-pickable'))) { if (s.sp?.turn === s.me && (await c.exists('.spcard.is-pickable'))) {
await c.click('.spcard.is-pickable'); await c.click('.spcard.is-pickable');
await c.click('.spcard.is-armed');
await c.waitFor((x) => x.sp?.turn !== x.me || x.phase !== 'SP_DRAFT', '机变 pick', 8000); await c.waitFor((x) => x.sp?.turn !== x.me || x.phase !== 'SP_DRAFT', '机变 pick', 8000);
did.sp += 1; did.sp += 1;
c.note(`机变 picked (round ${s.round})`); c.note(`机变 picked (round ${s.round})`);
@@ -1238,6 +1246,7 @@ describe('browser E2E against the real server', { skip: !ENABLED && 'needs Chrom
await sleep(2000); await sleep(2000);
await solo.shot(`sp-r${round}`); await solo.shot(`sp-r${round}`);
await solo.click('.spcard.is-pickable'); await solo.click('.spcard.is-pickable');
await solo.click('.spcard.is-armed');
await solo.waitFor((s) => s.phase === 'PREP' && !s.ready, `prep ${round} after 机变`, 30000); await solo.waitFor((s) => s.phase === 'PREP' && !s.ready, `prep ${round} after 机变`, 30000);
solo.note(`solo 机变 picked (round ${round})`); solo.note(`solo 机变 picked (round ${round})`);
} }
@@ -1314,6 +1323,7 @@ describe('browser E2E against the real server', { skip: !ENABLED && 'needs Chrom
await ready(c); await ready(c);
} else if (s.phase === 'SP_DRAFT' && (await c.exists('.spcard.is-pickable'))) { } else if (s.phase === 'SP_DRAFT' && (await c.exists('.spcard.is-pickable'))) {
await c.click('.spcard.is-pickable', null, { optional: true, timeout: 2000 }); await c.click('.spcard.is-pickable', null, { optional: true, timeout: 2000 });
await c.click('.spcard.is-armed', null, { optional: true, timeout: 2000 });
} else if ((s.phase === 'FINAL_ASSAULT' || s.phase === 'HIDDEN_CORE') && !bossShot) { } else if ((s.phase === 'FINAL_ASSAULT' || s.phase === 'HIDDEN_CORE') && !bossShot) {
await waitUnits(c, 8000); await waitUnits(c, 8000);
await sleep(1200); await sleep(1200);
+5 -5
View File
@@ -224,20 +224,20 @@ test('a terrain change by content (setDeviceActive at the prep end, no recompute
test('withdrawing a deployed summon into a full hand: HAND_FULL like any other card (no overflow into temp)', () => { test('withdrawing a deployed summon into a full hand: HAND_FULL like any other card (no overflow into temp)', () => {
const { m, ps } = prepMatch('act2autochess_m04'); const { m, ps } = prepMatch('act2autochess_m04');
// 赫默 → one 医疗探机 (deployLimit 1): once deployed no stack is left in the hand to join // 伺夜 → one 狼群 (deployLimit 1): once deployed no stack is left in the hand to join
const medic = give(m, ps, 'chess_char_2_02_a'); const medic = give(m, ps, 'chess_char_3_19_a');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: medic.uid, to: { area: 'board', row: 10, col: 5 } }), { ok: true }); assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: medic.uid, to: { area: 'board', row: 10, col: 5 } }), { ok: true });
const drone = ps.hand.find((p) => p && p.kind === 'token'); const drone = ps.hand.find((p) => p && p.kind === 'token');
assert.ok(drone && drone.count === 1, 'one drone'); assert.ok(drone && drone.count === 1, 'one 狼群');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'board', row: 9, col: 4 } }), { ok: true }); assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'board', row: 9, col: 4 } }), { ok: true });
// golden items never merge with each other: a plain full hand // golden items never merge with each other: a plain full hand
for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(m, ps, 'chess_item_1_02_e_b', 'hand', i); for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(m, ps, 'chess_item_1_02_e_b', 'hand', i);
assert.ok(ps.hand.every(Boolean) && ps.tempEmpty); assert.ok(ps.hand.every(Boolean) && ps.tempEmpty);
assert.equal(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'hand', idx: 0 } }).error, ERR.HAND_FULL, 'a new card for a full hand'); assert.equal(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'hand', idx: 0 } }).error, ERR.HAND_FULL, 'a new card for a full hand');
assert.equal(ps.board.get('9,4'), drone, 'the drone stays on the board'); assert.equal(ps.board.get('9,4'), drone, 'the 狼群 stays on the board');
assert.ok(ps.tempEmpty, 'nothing overflowed into temp (Ready stays possible)'); assert.ok(ps.tempEmpty, 'nothing overflowed into temp (Ready stays possible)');
assert.equal(m.handle('p_0', { t: 'g.move', uid: medic.uid, to: { area: 'hand', idx: 0 } }).error, ERR.HAND_FULL, 'the same as withdrawing an operator'); assert.equal(m.handle('p_0', { t: 'g.move', uid: medic.uid, to: { area: 'hand', idx: 0 } }).error, ERR.HAND_FULL, 'the same as withdrawing an operator');
// a free slot: the drone comes back // a free slot: the 狼群 comes back
ps.hand[3] = null; ps.hand[3] = null;
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'hand', idx: 0 } }), { ok: true }); assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'hand', idx: 0 } }), { ok: true });
assert.equal(ps.hand[3], drone); assert.equal(ps.hand[3], drone);
+4 -4
View File
@@ -175,13 +175,13 @@ test('联防: an operator dead at the end of the helper\'s own combat stays out
const m = h.m; const m = h.m;
h.toPrep(1); h.toPrep(1);
const helper = h.ps('p_1'); const helper = h.ps('p_1');
// 赫默 (placeable summon) is knocked out in p_1's own combat; a second operator survives it // 伺夜 (placeable talent summon 狼群) is knocked out in p_1's own combat; a second operator survives it
const t0 = legalTileFor(m, helper, 'chess_char_2_02_a'); const t0 = legalTileFor(m, helper, 'chess_char_3_19_a');
const hemo = give(m, helper, 'chess_char_2_02_a', 'board', t0); const hemo = give(m, helper, 'chess_char_3_19_a', 'board', t0);
const other = chessOfTier(1, (c) => c.position === 'RANGED').find((x) => m.pool.has(x)); const other = chessOfTier(1, (c) => c.position === 'RANGED').find((x) => m.pool.has(x));
const alive = give(m, helper, other, 'board', legalTileFor(m, helper, other)); const alive = give(m, helper, other, 'board', legalTileFor(m, helper, other));
const stack = helper.hand.find((p) => p && p.kind === 'token' && p.ownerUid === hemo.uid); const stack = helper.hand.find((p) => p && p.kind === 'token' && p.ownerUid === hemo.uid);
assert.ok(stack, '赫默 brought its summon stack'); assert.ok(stack, '伺夜 brought its summon stack');
let placed = null; let placed = null;
for (const [k] of helper.deployMap()) { for (const [k] of helper.deployMap()) {
const [r, c] = k.split(',').map(Number); const [r, c] = k.split(',').map(Number);
+69 -9
View File
@@ -1,8 +1,12 @@
// INFO_CHECK, band draft (order / skip / timeouts / 队友已选) and 机变 SP drafts (order / timers / auto-assign / effects). // INFO_CHECK, band draft (order / skip / one turn clock / timeouts / highlighted band / 队友已选) and 机变 SP drafts (order /
// timers / auto-assign / effects); a single human (solo or co-op with AI teammates) is never timed outside battles.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js'; import { ERR, PHASE } from '../../shared/constants.js';
import { DATA, makeMatch, checkInvariants } from './harness.js'; import { DATA, makeMatch, checkInvariants } from './harness.js';
import { BAND_TURN_SECONDS } from '../../server/match/Match.js';
const TURN_MS = BAND_TURN_SECONDS * 1000;
test('INFO_CHECK: ends when every human confirmed (bots/departed count as ready) or at the 25 s deadline', () => { test('INFO_CHECK: ends when every human confirmed (bots/departed count as ready) or at the 25 s deadline', () => {
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 1 }).start(); const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 1 }).start();
@@ -61,16 +65,27 @@ test('band draft (co-op): random order, one pick per turn, NOT_YOUR_TURN, one sk
m.dispose(); m.dispose();
}); });
test('band draft timers: 12 s per turn → 华法琳; the 50 s step cap assigns the rest; bots pick by themselves', () => { test('band draft timers (user playtest #4 item 4): one countdown of BAND_TURN_SECONDS per turn → the highlighted band or 华法琳; AI seats pick at once; no step cap', () => {
const h = makeMatch({ mode: 'coop', humans: 2, bots: 2, seed: 9 }).start(); const h = makeMatch({ mode: 'coop', humans: 2, bots: 2, seed: 9 }).start();
const m = h.m; const m = h.m;
m.handle('p_0', { t: 'g.infoReady' }); m.handle('p_0', { t: 'g.infoReady' });
m.handle('p_1', { t: 'g.infoReady' }); m.handle('p_1', { t: 'g.infoReady' });
h.sched.advance(1); h.sched.advance(1);
assert.equal(m.phase, PHASE.BAND_DRAFT); assert.equal(m.phase, PHASE.BAND_DRAFT);
// humans never act: each human turn times out after 12 s; bots pick within ~1 s // humans never act: each human turn times out after BAND_TURN_SECONDS; bots pick at once (no bot turn is timed out)
h.run(() => m.phase !== PHASE.BAND_DRAFT, { maxTime: 60000 }); const t0 = h.sched.now();
const seen = [];
h.onBroadcast.push((msg) => { if (msg.t === 'm.public' && msg.phase === PHASE.BAND_DRAFT && msg.draft) seen.push(msg); });
h.run(() => m.phase !== PHASE.BAND_DRAFT, { maxTime: 10 * TURN_MS });
assert.equal(m.phase, PHASE.BATTLE_CHECK); assert.equal(m.phase, PHASE.BATTLE_CHECK);
const took = h.sched.now() - t0;
assert.ok(took >= 2 * TURN_MS - 5 && took <= 2 * TURN_MS + 50, `two human turns of ${BAND_TURN_SECONDS} s each (${took} ms)`);
// every published draft view: the step's deadline IS the current turn's
assert.ok(seen.length > 0);
for (const pub of seen) {
assert.equal(pub.deadline, pub.draft.turnDeadline, 'one countdown');
assert.equal(pub.draft.turnSeconds, BAND_TURN_SECONDS);
}
// a timeout gives 华法琳 while no teammate holds it, else the first free strategy by sortId — never 队友已选 // a timeout gives 华法琳 while no teammate holds it, else the first free strategy by sortId — never 队友已选
// (Match.defaultBand): replay the assignments in draft order // (Match.defaultBand): replay the assignments in draft order
const taken = new Set(); const taken = new Set();
@@ -83,17 +98,22 @@ test('band draft timers: 12 s per turn → 华法琳; the 50 s step cap assigns
} }
assert.equal(new Set(Object.values(m.draft.picks)).size, 4, 'no duplicate strategies'); assert.equal(new Set(Object.values(m.draft.picks)).size, 4, 'no duplicate strategies');
m.dispose(); m.dispose();
// overall cap: with 4 humans × 12 s > 50 s the last one is assigned at the cap // 4 humans idle: 4 whole turns (the old 50 s step cap would have cut the last ones short)
const h2 = makeMatch({ mode: 'coop', humans: 4, seed: 9 }).start(); const h2 = makeMatch({ mode: 'coop', humans: 4, seed: 9 }).start();
for (const ps of h2.m.players.values()) h2.m.handle(ps.playerId, { t: 'g.infoReady' }); for (const ps of h2.m.players.values()) h2.m.handle(ps.playerId, { t: 'g.infoReady' });
h2.sched.advance(1); h2.sched.advance(1);
const t0 = h2.sched.now();
h2.run(() => h2.m.phase !== PHASE.BAND_DRAFT, { maxTime: 120000 });
assert.ok(h2.sched.now() - t0 <= 50000 + 5, `ended by the 50 s cap (${h2.sched.now() - t0} ms)`);
const first = h2.m.draft.order[0]; const first = h2.m.draft.order[0];
assert.equal(h2.ps(first).bandId, 'band_bldsk', 'the first timeout gets 华法琳'); assert.deepEqual(h2.m.handle(first, { t: 'g.bandFocus', bandId: 'band_amiya' }), { ok: true });
const s0 = h2.sched.now();
h2.run(() => h2.m.phase !== PHASE.BAND_DRAFT, { maxTime: 10 * TURN_MS });
const took2 = h2.sched.now() - s0;
assert.ok(took2 >= 4 * TURN_MS - 5 && took2 <= 4 * TURN_MS + 50, `four turns (${took2} ms)`);
assert.equal(h2.ps(first).bandId, 'band_amiya', 'the first timeout takes the highlighted strategy');
assert.equal(h2.ps(h2.m.draft.order[1]).bandId, 'band_bldsk', 'the next one the official default');
assert.equal(new Set([...h2.m.players.values()].map((ps) => ps.bandId)).size, 4, 'the rest get distinct free strategies'); assert.equal(new Set([...h2.m.players.values()].map((ps) => ps.bandId)).size, 4, 'the rest get distinct free strategies');
h2.m.dispose(); h2.m.dispose();
// data and code say the same: data/config.json timers.bandTurn (tools/build-data.mjs) = Match.BAND_TURN_SECONDS
assert.equal(DATA.config.timers.bandTurn, BAND_TURN_SECONDS);
}); });
test('band draft (solo): free pick, no timer, no skip; mode-restricted bands rejected', () => { test('band draft (solo): free pick, no timer, no skip; mode-restricted bands rejected', () => {
@@ -214,3 +234,43 @@ test('机变 tactic defaults: team cards reach teammates; layers / funds / free
}); });
const awaitImport = await import('../../server/match/choices.js'); const awaitImport = await import('../../server/match/choices.js');
test('a single human (a 同盟 room started alone or with AI teammates only) is never timed outside battles — user playtest #4 item 3', () => {
for (const bots of [0, 3]) {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots, seed: 12, fake: true, instant: false }).start();
const m = h.m;
assert.equal(m.soloUntimed, true);
assert.equal(m.isSolo, false, 'the co-op mode rules stay');
const seen = new Map(); // phase → deadlines published
const note = (pub) => { if (!seen.has(pub.phase)) seen.set(pub.phase, []); seen.get(pub.phase).push(pub.deadline); };
for (const msg of h.bc) if (msg.t === 'm.public') note(msg);
h.onBroadcast.push((msg) => { if (msg.t === 'm.public') note(msg); });
assert.equal(m.phase, PHASE.INFO_CHECK);
h.sched.advance(10 * 60_000);
assert.equal(m.phase, PHASE.INFO_CHECK, `briefing waits (${bots} AI)`);
m.handle('p_0', { t: 'g.infoReady' });
h.sched.advance(1);
h.run(() => m.draftTurn() === 'p_0', { maxTime: 1000 });
h.sched.advance(10 * 60_000);
assert.equal(m.phase, PHASE.BAND_DRAFT, 'the strategy draft waits');
assert.ok(h.drive(() => m.phase === PHASE.PREP && m.round === 2), 'reached PREP R2');
h.sched.advance(60 * 60_000);
assert.equal(m.phase, PHASE.PREP, 'prep waits');
assert.ok(h.drive(() => m.phase === PHASE.PREP && m.round === 4), 'past the R3 机变');
h.flushAll();
for (const ph of [PHASE.INFO_CHECK, PHASE.BAND_DRAFT, PHASE.BATTLE_CHECK, PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP, PHASE.SETTLE]) {
assert.ok(seen.has(ph), `saw ${ph}`);
assert.ok(seen.get(ph).every((d) => d === 0), `${ph}: no countdown with a single human (${bots} AI): ${seen.get(ph)}`);
}
assert.ok(seen.get(PHASE.COMBAT).some((d) => d > 0), 'the battles keep their clock');
m.dispose();
}
// two humans: the co-op timers stay
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 12 }).start();
assert.equal(h.m.soloUntimed, false);
const pub = h.lastBc('m.public');
assert.equal(pub.deadline - pub.serverNow, 25000, 'INFO_CHECK 25 s');
h.toPrep(1);
assert.ok(h.m.deadline > 0, 'co-op prep is timed');
h.m.dispose();
});
+4 -2
View File
@@ -411,8 +411,10 @@ test('audited FakeBattle matches (all phases incl. 联防, 机变, Final Assault
const h = makeMatch({ const h = makeMatch({
mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: n - 1, seed, fake: true, mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: n - 1, seed, fake: true,
script: (b) => (b.kind === 'boss' ? { bossDps: 5000 } : { leaks: Object.fromEntries(b.players.map((p, i) => [p, (b.round + i + seed) % 3])) }), script: (b) => (b.kind === 'boss' ? { bossDps: 5000 } : { leaks: Object.fromEntries(b.players.map((p, i) => [p, (b.round + i + seed) % 3])) }),
}).start(); });
h.autoHumans(); // AI-played before the start: a single human's briefing is untimed (Match.soloUntimed, user playtest #4 item 3) and
// waits for 准备就绪, which only an autoplaying seat gives by itself
h.autoHumans().start();
const audit = attachAudit(h.m); const audit = attachAudit(h.m);
for (const ps of h.m.players.values()) ps.lp = 200; for (const ps of h.m.players.values()) ps.lp = 200;
h.runToEnd({ maxSteps: 3e6 }); h.runToEnd({ maxSteps: 3e6 });
+14 -6
View File
@@ -256,7 +256,8 @@ test('solo: no deadline outside combat (INFO_CHECK, strategy draft, 机变, prep
}); });
test('co-op keeps its guards: INFO_CHECK 25 s, prep timer, transition deadlines', () => { test('co-op keeps its guards: INFO_CHECK 25 s, prep timer, transition deadlines', () => {
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 12 }).start(); // two humans: a single human is untimed like solo (Match.soloUntimed, user playtest #4 item 3)
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 12 }).start();
const m = h.m; const m = h.m;
const pub = h.lastBc('m.public'); const pub = h.lastBc('m.public');
assert.equal(pub.phase, PHASE.INFO_CHECK); assert.equal(pub.phase, PHASE.INFO_CHECK);
@@ -268,13 +269,15 @@ test('co-op keeps its guards: INFO_CHECK 25 s, prep timer, transition deadlines'
// ---- review fixes ----------------------------------------------------------------------------------------------------------- // ---- review fixes -----------------------------------------------------------------------------------------------------------
test('a skill summon comes only with that skill: 赫默 S2 (default) gets the 医疗无人机 token, 赫默 on S1 does not', async () => { test('a skill summon is never a hand card (user playtest #4): 赫默 on any skill gets no 医疗无人机 token in the prep phase', async () => {
const { give } = await import('./harness.js'); const { give } = await import('./harness.js');
const SILENCE = 'chess_char_2_02_a'; const SILENCE = 'chess_char_2_02_a';
const DRONE = 'token_10000_silent_healrb'; const DRONE = 'token_10000_silent_healrb';
assert.equal(chess(SILENCE).skills.find((s) => s.overrideTokenKey === DRONE)?.isDefault, true, 'fixture: the drone is the default S2 summon'); assert.equal(chess(SILENCE).skills.find((s) => s.overrideTokenKey === DRONE)?.isDefault, true, 'fixture: the drone is the default S2 summon');
const tokensIn = (ps) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter((p) => p && p.kind === 'token' && p.id === DRONE); // "获得一个医疗无人机": the drone appears in battle when S2 fires (test/content/tokens_devices.test.js), never before
for (const [skill, want] of [[null, 1], [0, 0], [1, 1]]) { const tokensIn = (ps, id = DRONE) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter((p) => p && p.kind === 'token' && p.id === id);
assert.equal(DATA.tokens[DRONE].placeable, false, 'tokens.json: a skill summon is not placeable');
for (const skill of [null, 0, 1]) {
const lo = skill == null ? null : checkLoadout({ [SILENCE]: { skill } }, chess).loadout; const lo = skill == null ? null : checkLoadout({ [SILENCE]: { skill } }, chess).loadout;
const h = makeMatch({ mode: 'solo', seats: seatsWith(lo), seed: 5 }).start(); const h = makeMatch({ mode: 'solo', seats: seatsWith(lo), seed: 5 }).start();
h.toPrep(1); h.toPrep(1);
@@ -283,11 +286,16 @@ test('a skill summon comes only with that skill: 赫默 S2 (default) gets the
ps.board.clear(); ps.board.clear();
ps.hand.fill(null); ps.hand.fill(null);
give(h.m, ps, SILENCE, 'board', [9, 3]); give(h.m, ps, SILENCE, 'board', [9, 3]);
assert.equal(tokensIn(ps).length, want, `normal 赫默, skill ${skill ?? 'default'}`); assert.equal(tokensIn(ps).length, 0, `normal 赫默, skill ${skill ?? 'default'}`);
ps.board.clear(); ps.board.clear();
ps.hand.fill(null); ps.hand.fill(null);
give(h.m, ps, chess(SILENCE).goldenId, 'board', [9, 4]); give(h.m, ps, chess(SILENCE).goldenId, 'board', [9, 4]);
assert.equal(tokensIn(ps).length, want, `elite 赫默, skill ${skill ?? 'default'}`); assert.equal(tokensIn(ps).length, 0, `elite 赫默, skill ${skill ?? 'default'}`);
// a talent summon still is a hand card: 伺夜 → 狼群
ps.board.clear();
ps.hand.fill(null);
give(h.m, ps, 'chess_char_3_19_a', 'board', [9, 5]);
assert.equal(tokensIn(ps, 'token_10028_vigil_wolf').length, 1, '伺夜 sends its 狼群 to the hand');
h.m.dispose(); h.m.dispose();
} }
}); });
+3 -2
View File
@@ -320,8 +320,9 @@ test('a merge completed during SETTLE (突变细胞) keeps its reward offer for
test('withdrawing a summoner with a full hand: its own summon stack frees the slot (onto the stack or anywhere)', () => { test('withdrawing a summoner with a full hand: its own summon stack frees the slot (onto the stack or anywhere)', () => {
for (const onto of ['stack', 'other']) { for (const onto of ['stack', 'other']) {
const { m, ps } = prep(24); const { m, ps } = prep(24);
const hemo = give(m, ps, 'chess_char_2_02_a'); // 伺夜: its talent summon 狼群 is a hand card (a skill's summon such as 赫默's drone never is)
const [r, c] = legalTileFor(m, ps, 'chess_char_2_02_a'); const hemo = give(m, ps, 'chess_char_3_19_a');
const [r, c] = legalTileFor(m, ps, 'chess_char_3_19_a');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: hemo.uid, to: { area: 'board', row: r, col: c } }), { ok: true }); assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: hemo.uid, to: { area: 'board', row: r, col: c } }), { ok: true });
const stackIdx = ps.hand.findIndex((p) => p && p.kind === 'token' && p.ownerUid === hemo.uid); const stackIdx = ps.hand.findIndex((p) => p && p.kind === 'token' && p.ownerUid === hemo.uid);
assert.ok(stackIdx >= 0, 'its summon stack went to the hand'); assert.ok(stackIdx >= 0, 'its summon stack went to the hand');
+42 -1
View File
@@ -88,12 +88,53 @@ test('item slot: tier ≤ level, shop-eligible normal equipment only', () => {
for (let i = 0; i < 300; i++) { for (let i = 0; i < 300; i++) {
const id = pool.rollItem(rng, level); const id = pool.rollItem(rng, level);
const it = DATA.items[id]; const it = DATA.items[id];
assert.ok(it && it.itemType === 'EQUIP' && !it.isGolden && !it.hideInShop, id); assert.ok(it && it.itemType === 'EQUIP' && !it.isGolden && !it.hideInShop && !it.shopExcluded, id);
assert.ok(it.tier <= level, `L${level} item tier ${it.tier}`); assert.ok(it.tier <= level, `L${level} item tier ${it.tier}`);
} }
} }
}); });
// user playtest #4 item 5: the special 维式重锤 (维多利亚 25-layer reward / 洛洛's 定制品) and 突变细胞 (strategy 昆图斯)
// are never sold although the official shop table lists them (tools/build-data.mjs SHOP_EXCLUDED_ITEMS)
const EFFECT_ONLY = ['chess_item_2_03_e_a', 'chess_item_3_09_e_a', 'chess_item_3_10_e_a', 'chess_item_4_09_e_a', 'chess_item_5_08_e_a'];
test('effect-only items are never shop items: not in shopItemsByTier, never in the item slot, the pools or the 机变 supply', () => {
for (const id of EFFECT_ONLY) {
assert.equal(DATA.items[id].shopExcluded, true, `${id} (${DATA.items[id].name}) marked`);
assert.equal(DATA.items[id].hideInShop, false, `${id}: the official table does not hide it`);
assert.ok(DATA.items[id.replace(/_a$/, '_b')].shopExcluded, `${id}: the golden too`);
}
assert.equal(DATA.items.chess_item_1_01_e_a.shopExcluded, false, 'the plain 维式重锤 is sold');
const gd = gdOf();
const listed = new Set(Object.values(gd.shopItemsByTier).flat());
assert.equal(listed.size, 51, '56 normal equipment − 5 effect-only');
for (const id of EFFECT_ONLY) assert.ok(!listed.has(id), `${id} not a shop item`);
assert.ok(listed.has('chess_item_1_01_e_a'));
// the shop item slot at every level
const pool = new SharedPool(gd);
const rng = createRng(4);
for (let level = 1; level <= 6; level++) for (let i = 0; i < 400; i++) assert.ok(!EFFECT_ONLY.includes(pool.rollItem(rng, level)));
// every shop-eligible pool (凯瑟琳 / 列装 / 定向投放 / 见者有份) and the plain "random item" roll of effects
const m = makeMatch({ mode: 'coop', seed: 3, fake: true }).m;
for (const pid of ['pool_equip_normal', 'pool_equip_shop_1', 'pool_equip_kathe', 'pool_equip_narant']) {
for (let i = 0; i < 300; i++) assert.ok(!EFFECT_ONLY.includes(m.rollItemId({ pool: pid, shopLevel: 6 })), pid);
}
for (let i = 0; i < 300; i++) assert.ok(!EFFECT_ONLY.includes(m.rollItemId({ maxTier: 6 })));
m.dispose();
});
test('维多利亚 25-layer reward and 洛洛的定制品: the 4 special 维式重锤 (and nothing else)', () => {
const special = ['chess_item_2_03_e_a', 'chess_item_3_09_e_a', 'chess_item_3_10_e_a', 'chess_item_4_09_e_a'];
const m = makeMatch({ mode: 'coop', seed: 3, fake: true }).m;
for (const pid of ['pool_equip_vict', 'pool_equip_rockr']) {
assert.deepEqual([...DATA.choices.pools[pid].items].sort(), special, pid);
const seen = new Set();
for (let i = 0; i < 200; i++) seen.add(m.rollPool(pid).id);
assert.deepEqual([...seen].sort(), special, `${pid}: every special hammer can come, nothing else`);
}
m.dispose();
});
test('per-match disabled bonds: 3 core + 4 add-on (NORMAL+), FUNNY static + 0 + 1; weight-0 never drawn; subset ban rule', () => { test('per-match disabled bonds: 3 core + 4 add-on (NORMAL+), FUNNY static + 0 + 1; weight-0 never drawn; subset ban rule', () => {
for (const [modeId, core, addon] of [['mode_multi_hard', 3, 4], ['mode_single_abyss', 3, 4], ['mode_multi_funny', 0, 1], ['mode_single_normal', 3, 4]]) { for (const [modeId, core, addon] of [['mode_multi_hard', 3, 4], ['mode_single_abyss', 3, 4], ['mode_multi_funny', 0, 1], ['mode_single_normal', 3, 4]]) {
const gd = new GameData(DATA, modeId); const gd = new GameData(DATA, modeId);
+50
View File
@@ -404,3 +404,53 @@ test('live leaks: a boss field is not counted (the merged team LP moves through
assert.deepEqual(r2.store.get().match.battle.leaks, {}, 'only normal fields'); assert.deepEqual(r2.store.get().match.battle.leaks, {}, 'only normal fields');
r2.runner.dispose(); r2.runner.dispose();
}); });
test('live unit stats (user playtest #4 item 7): unitStats(id) reads the battle on screen — the sim\'s last computed stats, never unit.s (no recompute from the UI)', async () => {
const start = realStart(7305);
const r = rig();
r.net.emit('b.start', start);
await r.settle();
const e = r.runner._entries.get(start.battleId);
r.advance(2000);
const ally = e.battle.allyUnits.find((u) => u.alive && u.deployed && u.kind === 'op');
assert.ok(ally, 'a deployed operator');
// a trap on the lazy getter: reading the live stats must not recompute them (it would move the sim's floats)
const s = ally._s;
Object.defineProperty(ally, 's', { configurable: true, get() { throw new Error('unit.s read by the UI'); } });
const got = r.runner.unitStats(ally.id);
delete ally.s;
assert.ok(got, 'the unit of the battle on screen');
assert.equal(got.id, ally.id);
assert.equal(got.uid, ally.uid);
assert.equal(got.defId, ally.defId);
assert.equal(got.atk, Math.round(s.atk));
assert.equal(got.maxHp, Math.round(s.maxHp));
assert.equal(got.def, Math.round(s.def));
assert.equal(got.interval, Math.round(s.interval * 100) / 100);
assert.equal(got.blockCnt, s.blockCnt);
assert.equal(got.hp, Math.round(ally.hp));
assert.equal(got.base.atk, Math.round(ally.base.atk));
assert.equal(got.base.maxHp, Math.round(ally.base.maxHp));
assert.equal(r.runner.unitStats(ally.id, start.fieldId)?.id, ally.id, 'on the named field');
assert.equal(r.runner.unitStats(ally.id, 'n:someone_else'), null, 'another field: nothing');
assert.equal(r.runner.unitStats(999999), null, 'unknown unit');
// an own board piece's card left open into the battle: its unit by uid and owner
assert.equal(r.runner.unitIdOf(ally.uid, ally.ownerId), ally.id);
assert.equal(r.runner.unitIdOf(ally.uid, ally.ownerId, start.fieldId), ally.id);
assert.equal(r.runner.unitIdOf(ally.uid, 'someone_else'), null, 'another player\'s piece');
assert.equal(r.runner.unitIdOf(ally.uid, ally.ownerId, 'n:someone_else'), null, 'another field');
assert.equal(r.runner.unitIdOf(null, ally.ownerId), null);
assert.equal(r.runner.unitStats('x'), null);
// enemies too, once one is out
for (let i = 0; i < 60 && !e.battle.enemies.some((x) => x.alive); i++) r.advance(500, 50);
const foe = e.battle.enemies.find((x) => x.alive);
if (foe) {
const fs = r.runner.unitStats(foe.id);
assert.ok(fs && fs.maxHp > 0 && fs.base.maxHp > 0, 'an enemy\'s live stats');
assert.equal(fs.moveSpeed, Math.round((foe._s || foe.base).moveSpeed * 100) / 100);
}
r.runner.clear();
assert.equal(r.runner.unitStats(ally.id), null, 'nothing on screen after the battles were dropped');
assert.equal(r.runner.unitIdOf(ally.uid, ally.ownerId), null);
r.runner.dispose();
});
+2 -1
View File
@@ -201,7 +201,8 @@ test('a bot clears the temp contents it finds at the start of its prep and readi
for (let i = 0; i < 12; i++) ctx.grantItem(PLAIN[i % PLAIN.length]); for (let i = 0; i < 12; i++) ctx.grantItem(PLAIN[i % PLAIN.length]);
}, },
}); });
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 1507, fake: true, registry: reg }).start(); // two humans: the prep stays timed (a single human is untimed, Match.soloUntimed — user playtest #4 item 3)
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, bots: 1, seed: 1507, fake: true, registry: reg }).start();
const audit = attachAudit(h.m); const audit = attachAudit(h.m);
const m = h.m; const m = h.m;
h.toPrep(2); h.toPrep(2);
+123
View File
@@ -0,0 +1,123 @@
// Live operator stats (user playtest #4 item 7 — the detail card showed the fixed record numbers): g.unitStats answers
// m.unitStats { seq, round, units } with the stats every unit of the player's board starts its next battle with — the
// real battle input (equipment, bonds / layers, 特质, band and 机变 effects, the onBattleStart meta) built into a Battle
// that is started and read, never stepped (server/match/Match.js unitStats); the shape is shared/protocol.js
// unitStatsEntry (also the browser runner's live battle stats, test/match/runner.test.js). The preview changes nothing
// of the match.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { validateC2S, unitStatsEntry, S2C } from '../../shared/protocol.js';
import { makeMatch, give, giveItem, chessOfTier, legalTileFor } from './harness.js';
/** 阿戈尔重刃: ATK +40 % (its own multiplier), attack speed −10 (data/items.json). */
const BLADE = 'chess_item_3_07_e_a';
/** 双模机械臂: ATK +30 % (a second, different item: both apply — two equal ones would merge into the hand). */
const ARM = 'chess_item_5_03_e_a';
/** Solo PREP R1 with one chess on an empty board carrying 阿戈尔重刃 (equipped through g.equip). */
function setup(seed = 21, opts = {}) {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed, ...opts }).start();
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
const id = chessOfTier(1, (c) => c.position === 'MELEE' && !m.gd.placeableTokens(c.chessId).length).find((x) => m.pool.has(x));
const carrier = give(m, ps, id, 'board', legalTileFor(m, ps, id));
const item = giveItem(m, ps, BLADE);
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: carrier.uid }), { ok: true });
return { h, m, ps, carrier };
}
test('protocol: g.unitStats { seq? } and g.bandFocus { bandId? } validate; m.unitStats is a known push', () => {
assert.equal(validateC2S({ t: 'g.unitStats' }), null);
assert.equal(validateC2S({ t: 'g.unitStats', seq: 12 }), null);
assert.notEqual(validateC2S({ t: 'g.unitStats', seq: -1 }), null);
assert.notEqual(validateC2S({ t: 'g.unitStats', seq: 1.5 }), null);
assert.equal(validateC2S({ t: 'g.bandFocus' }), null, 'no band = clear the highlight');
assert.equal(validateC2S({ t: 'g.bandFocus', bandId: null }), null);
assert.equal(validateC2S({ t: 'g.bandFocus', bandId: 'band_bldsk' }), null);
assert.notEqual(validateC2S({ t: 'g.bandFocus', bandId: 'a b' }), null);
assert.ok(S2C.includes('m.unitStats'));
});
test('unitStatsEntry: effective stats next to the base, rounded for display; the interval from bat / aspd when absent', () => {
const u = { id: 3, uid: 44, defId: 'chess_x', hp: 812.6, alive: true, base: { maxHp: 1000, atk: 300, def: 100, res: 10, aspd: 100, bat: 1.2, blockCnt: 2, moveSpeed: 0 } };
const e = unitStatsEntry(u, { maxHp: 1250.4, atk: 420.49, def: 90, res: 12.34, interval: 1.0833333, blockCnt: 3, moveSpeed: 0 });
assert.deepEqual(e, {
id: 3, uid: 44, defId: 'chess_x', hp: 813, alive: true,
maxHp: 1250, atk: 420, def: 90, res: 12.3, interval: 1.08, blockCnt: 3, moveSpeed: 0,
base: { maxHp: 1000, atk: 300, def: 100, res: 10, interval: 1.2, blockCnt: 2, moveSpeed: 0 },
});
const plain = unitStatsEntry(u);
assert.equal(plain.atk, 300, 'no aggregated stats: the base');
assert.equal(plain.interval, 1.2);
assert.equal(unitStatsEntry(null).maxHp, 0, 'never throws');
assert.equal(unitStatsEntry({ base: { bat: 0 } }).interval, null, 'no attack');
});
test('g.unitStats: the board\'s start-of-battle stats (equipment in: ATK ×1.4, a slower attack), the same numbers the battle starts with; seq echoed', () => {
const { h, m, ps, carrier } = setup();
assert.deepEqual(m.handle('p_0', { t: 'g.unitStats', seq: 7 }), { ok: true });
const msg = h.lastTo('p_0', 'm.unitStats');
assert.ok(msg, 'answered with a push');
assert.equal(msg.seq, 7);
assert.equal(msg.round, 1);
const boardUids = [...ps.board.values()].map((p) => p.uid).sort((a, b) => a - b);
assert.deepEqual(msg.units.map((u) => u.uid).sort((a, b) => a - b), boardUids, 'every board unit, by uid');
const u = msg.units.find((x) => x.uid === carrier.uid);
assert.ok(u.atk > u.base.atk, `ATK up (${u.base.atk} → ${u.atk})`);
assert.ok(Math.abs(u.atk - Math.round(u.base.atk * 1.4)) <= 1, `阿戈尔重刃 multiplies ATK by 1.4 (${u.atk})`);
assert.ok(u.interval > u.base.interval, `attack speed −10: a longer interval (${u.base.interval} → ${u.interval})`);
assert.equal(u.hp, u.maxHp, 'full HP at the start');
assert.equal(u.alive, true);
// exactly what the real battle of this board starts with (the same input, onBattleStart meta included)
const b = m.newBattle(m._normalOpts(ps));
b.start();
const bu = b.allyUnits.find((x) => x.uid === carrier.uid);
const { id: _a, ...want } = unitStatsEntry(bu, bu.s);
const { id: _b, ...have } = u;
assert.deepEqual(have, want);
// no seq: null; a second request with the same state answers the cached numbers
assert.deepEqual(m.handle('p_0', { t: 'g.unitStats' }), { ok: true });
assert.equal(h.lastTo('p_0', 'm.unitStats').seq, null);
assert.deepEqual(h.lastTo('p_0', 'm.unitStats').units, msg.units);
m.dispose();
});
test('g.unitStats follows the prep state: another item changes the numbers; prep phases only', () => {
const { h, m, ps, carrier } = setup(22);
m.handle('p_0', { t: 'g.unitStats', seq: 1 });
const before = h.lastTo('p_0', 'm.unitStats').units.find((x) => x.uid === carrier.uid);
const item = giveItem(m, ps, ARM);
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: carrier.uid }), { ok: true });
assert.deepEqual(carrier.items.map((x) => x.id).sort(), [ARM, BLADE].sort());
m.handle('p_0', { t: 'g.unitStats', seq: 2 });
const after = h.lastTo('p_0', 'm.unitStats').units.find((x) => x.uid === carrier.uid);
assert.ok(Math.abs(after.atk - Math.round(after.base.atk * 1.4 * 1.3)) <= 1, `each item its own multiplier: ATK ${before.atk} → ${after.atk}`);
// outside the prep phases: WRONG_PHASE, nothing pushed
const n = h.allTo('p_0', 'm.unitStats').length;
assert.ok(h.drive(() => m.phase === PHASE.COMBAT));
assert.equal(m.handle('p_0', { t: 'g.unitStats', seq: 3 }).error, ERR.WRONG_PHASE);
assert.equal(h.allTo('p_0', 'm.unitStats').length, n);
m.dispose();
});
test('the preview changes nothing of the match: two identical matches, one asking for the stats all along, stay identical', () => {
const run = (ask) => {
const { h, m, ps } = setup(23);
const snaps = [];
for (let r = 1; r <= 3; r++) {
if (ask) for (let k = 0; k < 3; k++) assert.deepEqual(m.handle('p_0', { t: 'g.unitStats', seq: k }), { ok: true });
snaps.push(JSON.stringify(ps.privateView()));
assert.ok(h.drive(() => m.phase === PHASE.PREP && m.round === r + 1), `PREP R${r + 1}`);
}
snaps.push(JSON.stringify(ps.privateView()), JSON.stringify({ ...m.publicView(), serverNow: 0, deadline: 0 }));
m.dispose();
return snaps;
};
assert.deepEqual(run(true), run(false));
});
+3 -2
View File
@@ -164,8 +164,9 @@ describe('render engine in headless Chrome', { skip }, () => {
const placed = await page.evaluate((uid) => window.__demo.scene.state.board.find((p) => p.uid === uid) || null, info.uid); const placed = await page.evaluate((uid) => window.__demo.scene.state.board.find((p) => p.uid === uid) || null, info.uid);
assert.ok(placed, 'piece moved to the board'); assert.ok(placed, 'piece moved to the board');
assert.deepEqual([placed.row, placed.col], [10, 5]); assert.deepEqual([placed.row, placed.col], [10, 5]);
const r2 = await page.evaluate(() => window.__demo.view.pieceScreenRect(window.__demo.scene.state.board[1].uid)); // picking is by tile (user playtest #4 item 1): right-click the unit's own tile
await page.mouse.click(r2.left + r2.width / 2, r2.top + r2.height / 2, { button: 'right' }); const r2 = await page.evaluate(() => { const p = window.__demo.scene.state.board[1]; return window.__demo.view.tileScreen(p.row, p.col); });
await page.mouse.click(r2.x, r2.y, { button: 'right' });
await wait(150); await wait(150);
const log = await page.evaluate(() => window.__demo.log.join('\n')); const log = await page.evaluate(() => window.__demo.log.join('\n'));
assert.match(log, /pieceClick .*"detail":true/); assert.match(log, /pieceClick .*"detail":true/);
+137
View File
@@ -0,0 +1,137 @@
// test/render/downelem.browser.test.js — user playtest #4 items 8 and 9 in headless Chrome through the render demo
// (public/dev/render-demo.html, real Spine models): frames fed to the field view like the client runner does (local
// feed) with b.snap `down` / `elem` — a knocked-out operator stays on its tile in its held Die pose under a redeploy
// ring counting down, then "DP", then comes back with its deploy clip (a press on its tile selects it meanwhile);
// operators and enemies show the official element icon beside their bars, refilling over a 爆发冷却; a summon and an
// enemy that die still vanish.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/downelem.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/downelem-*.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
/** Page side: a small field fed frame by frame (local feed) — `step(gt, units, extra, ev)` pushes one frame. */
function installFeed() {
const v = window.__demo.view;
const U = (id, side, spine, x, y, extra = {}) => ({ id, kind: side === 'enemy' ? 'enemy' : 'op', side, defId: spine, spine, avatar: spine, x, y, maxHp: 1000, facing: 1, dir: side === 'enemy' ? undefined : 'RIGHT', tier: 3, ...extra });
const units = [
U(1, 'ally', 'char_102_texas', 5, 10), U(2, 'ally', 'char_1016_agoat2', 3, 11), U(3, 'ally', 'token_10028_vigil_wolf', 4, 12, { kind: 'token' }),
U(4, 'enemy', 'enemy_1000_gopro_2', 8, 10), U(5, 'enemy', 'enemy_1000_gopro_2', 9, 11),
];
v.enterBattle({ fieldId: 'dn', kind: 'normal', rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, stageId: 'act2autochess_m01', units });
v.setCamera('normal', { rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
const tup = (u, hp = 1000, anim = 0) => [u.id, u.x, u.y, hp, 1000, 0, 0, 0, anim];
window.__feed = {
units,
step(gt, list, extra = {}, ev = null) {
if (ev && ev.length) v.pushEvents({ t: 'b.ev', fieldId: 'dn', gt, ev });
v.pushSnapshot({ t: 'b.snap', fieldId: 'dn', gt, units: list.map((x) => (Array.isArray(x) ? x : tup(x))), dp: 5, killed: 0, total: 5, ...extra });
},
tup,
state() {
const out = {};
for (const [id, x] of v.debug.views) {
out[id] = { alive: x.alive, down: x.down ? x.down.state : null, ring: !!x._downRing?.root?.visible, label: x._downRing?.text?.text ?? null,
el: x._elRing?.root?.visible ? x.el : null, spine: !!x.spineReady, dieClip: x.actor?.current ?? null, alpha: +(x.alpha ?? 0).toFixed(2) };
}
return out;
},
};
}
describe('knocked-out operators and element gauges in headless Chrome', { skip }, () => {
let srv, browser;
before(async () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const puppeteer = require('puppeteer-core');
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('down ring countdown → DP → redeploy; element icons; summons and enemies still vanish', async () => {
const page = await browser.newPage();
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
try {
await page.setViewport({ width: 1280, height: 720 });
// a recording scene, paused: it feeds nothing, the test drives the view
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
await page.evaluate(installFeed);
// phase 1 (gt 1–3): everyone stands; texas2's neighbour 艾雅法拉 carries a burn gauge, an enemy is in a neural cooldown
const run = (fn, from, to) => page.evaluate(async (src, from, to) => {
const f = window.__feed;
const body = new Function('f', 'gt', src);
for (let gt = from; gt <= to + 1e-9; gt += 1 / 30) { body(f, +gt.toFixed(4)); await new Promise((r) => requestAnimationFrame(() => r())); }
}, fn, from, to);
await run(`const U = f.units; f.step(gt, [U[0], U[1], U[2], U[3], U[4]], { elem: [[2, 'burn', 0.4, 0, 0], [4, 'neural', 1, 12, 10]] });`, 1, 3);
await new Promise((r) => setTimeout(r, 1500)); // Spine models load
await run(`const U = f.units; f.step(gt, [U[0], U[1], U[2], U[3], U[4]], { elem: [[2, 'burn', 0.4, 0, 0], [4, 'neural', 1, 12, 10]] });`, 3, 4);
let st = await page.evaluate(() => window.__feed.state());
assert.equal(st[2].el, 'burn', 'operator element icon');
assert.equal(st[4].el, 'neural', 'enemy element icon (cooldown)');
assert.equal(st[1].el, null);
// phase 2: texas (1) is knocked out, the wolf summon (3) and an enemy (5) die
await page.evaluate(() => {
const f = window.__feed, U = f.units;
f.step(4.05, [f.tup(U[0], 0, 4), U[1], f.tup(U[2], 0, 4), U[3], f.tup(U[4], 0, 4)], { down: [[1, 24.05, 20, 0]] }, [['die', 1, 'killed'], ['die', 3, 'killed'], ['die', 5, 'killed']]);
});
await run(`const U = f.units; f.step(gt, [U[1], U[3]], { down: [[1, 24.05, 20, 0]], elem: [[2, 'burn', 0.4, 0, 0]] });`, 4.1, 8);
await new Promise((r) => setTimeout(r, 1200)); // die clips and fades run out (the last frame stays)
await run(`const U = f.units; f.step(gt, [U[1], U[3]], { down: [[1, 24.05, 20, 0]], elem: [[2, 'burn', 0.4, 0, 0]] });`, 8, 9);
st = await page.evaluate(() => window.__feed.state());
await page.screenshot({ path: path.join(OUT, 'downelem-countdown.png') });
assert.equal(st[1].alive, false);
assert.equal(st[1].down, 0, 'knocked down');
assert.ok(st[1].ring, 'redeploy ring');
assert.equal(st[1].label, '16', 'seconds left');
assert.equal(st[1].dieClip, 'Die', 'held Die pose');
assert.ok(st[1].alpha > 0.8, `the model is drawn (${st[1].alpha})`);
assert.equal(st[3], undefined, 'the summon vanished');
assert.equal(st[5], undefined, 'the enemy vanished');
// a press on its tile selects it like any unit on a tile (picking by tile, user playtest #4 item 1)
const picked = await page.evaluate(() => {
const v = window.__demo.view, t = v.tileScreen(10, 5), r = v.debug.app.view.getBoundingClientRect();
return v.debug.pick.battleUnitAt(t.x - r.left, t.y - r.top)?.id ?? null;
});
assert.equal(picked, 1, 'the knocked-down operator is picked on its tile');
// phase 3: the timer is done but the DP is short
await run(`const U = f.units; f.step(gt, [U[1], U[3]], { down: [[1, 24.05, 20, 1]] });`, 24.1, 25);
st = await page.evaluate(() => window.__feed.state());
await page.screenshot({ path: path.join(OUT, 'downelem-waitdp.png') });
assert.equal(st[1].label, 'DP');
// phase 4: redeploy
await page.evaluate(() => { const f = window.__feed, U = f.units; f.step(25.05, [f.tup(U[0], 1000, 6), U[1], U[3]], {}, [['deploy', 1]]); });
await run(`const U = f.units; f.step(gt, [U[0], U[1], U[3]]);`, 25.1, 26);
st = await page.evaluate(() => window.__feed.state());
await page.screenshot({ path: path.join(OUT, 'downelem-redeployed.png') });
assert.equal(st[1].alive, true, 'back on its tile');
assert.equal(st[1].down, null);
assert.equal(st[1].ring, false);
assert.equal(st[2].el, null, 'the gauge left the snapshot: icon hidden');
assert.deepEqual(problems, []);
} finally {
await page.close();
}
});
});
+175
View File
@@ -0,0 +1,175 @@
// test/render/downring.test.js — user playtest #4 items 8 and 9, client side: render/interp.js carries b.snap `elem`
// (the element gauge a unit shows) and `down` (knocked-out operators waiting to redeploy); render/units.js keeps a
// knocked-out operator on its tile in its held Die pose under a redeploy ring (countdown → "DP" / "!" → redeploy)
// and draws the official element icon beside the bars (headless fake PIXI, test/render/fakepixi.js).
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { SnapshotBuffer, normalizeSnapshot, TUPLE } from '../../public/js/render/interp.js';
let fake, UnitView, DOWN_STATE, DOWN_LOOK, T;
before(async () => {
fake = installFakePixi();
({ UnitView, DOWN_STATE, DOWN_LOOK } = await import('../../public/js/render/units.js'));
T = await import('../../public/js/render/textures.js');
});
after(() => fake.restore());
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
const tuple = (id, x = 5, y = 10, hp = 1000) => [id, x, y, hp, 1000, 0, 0, 0, 0];
describe('interp: b.snap `elem` and `down`', () => {
test('`elem` entries are appended to their unit tuple and sampled as el / elFill / elUntil / elDur', () => {
const s = normalizeSnapshot({ t: 3, units: [tuple(1), tuple(2), tuple(3)], elem: [[1, 'burn', 0.4, 0, 0], [2, 'neural', 1, 12.5, 10], [9, 'burn', 0.5, 0, 0], [3, 'lava', 0.5, 0, 0], 'x'] });
assert.deepEqual(s.units.get(1).slice(TUPLE.EL), ['burn', 0.4, 0, 0]);
assert.deepEqual(s.units.get(2).slice(TUPLE.EL), ['neural', 1, 12.5, 10]);
assert.equal(s.units.get(3).length, 9, 'unknown element dropped');
assert.equal(s.down, null);
const buf = new SnapshotBuffer({ delay: 0 });
buf.push({ t: 1, units: [tuple(1)], elem: [[1, 'apoptosis', 0.2, 0, 0]] }, 0);
buf.push({ t: 1.1, units: [tuple(1)] }, 0.05);
const out = buf.sample(1.02);
assert.equal(out.get(1).el, 'apoptosis');
assert.equal(out.get(1).elFill, 0.2);
buf.sample(1.1, out);
assert.equal(out.get(1).el, null, 'cleared once the gauge is gone');
assert.equal(out.get(1).elFill, 0);
});
test('`down` is kept per snapshot; downAt(time) reads the snapshot shown at that time', () => {
const buf = new SnapshotBuffer({ delay: 0 });
buf.push({ t: 1, units: [] }, 0);
buf.push({ t: 1.1, units: [], down: [[7, 20.5, 18, 0], ['bad'], [8, 'x', -3, 1.7]] }, 0.05);
buf.push({ t: 1.2, units: [] }, 0.1);
assert.equal(buf.downAt(1.05), null);
assert.deepEqual(buf.downAt(1.15), [[7, 20.5, 18, 0], ['bad', 0, 0, 0], [8, 0, 0, 1]]);
assert.equal(buf.downAt(1.25), null);
});
});
function view(info = {}) {
const ctx = fakeViewCtx(fake.P, { cam });
return new UnitView(ctx, { id: 1, side: 'ally', kind: 'chess', defId: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT', ...info });
}
const frames = (v, n, dt = 1 / 60, gameT = null) => { for (let i = 0; i < n; i++) { if (gameT) v.gameT = gameT(i); v.update(dt, cam(), i * dt); } };
describe('knocked-down operator (UnitView.setDown)', () => {
test('stays on its tile greyed under a redeploy ring counting down, then "DP", then redeploys', () => {
const v = view();
frames(v, 3);
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 10);
assert.equal(v.alive, false, 'knocked down');
assert.ok(v.down);
frames(v, 400, 1 / 60, () => 10); // 6.7 s: a dying view would long be gone
assert.equal(v.remove, false, 'never removed while down');
assert.ok(v.alpha > 0.85, `drawn (alpha ${v.alpha})`);
const r = v._downRing;
assert.ok(r && r.root.visible, 'ring shown');
assert.equal(r.text.text, '20', 'seconds left (game s)');
assert.equal(r.arc.tint, DOWN_LOOK.ring[DOWN_STATE.COUNTING]);
assert.equal(r.arc.texture, T.ringArc(0), 'nothing elapsed yet');
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 25);
frames(v, 1, 1 / 60, () => 25);
assert.equal(r.text.text, '5');
assert.equal(r.arc.texture, T.ringArc(0.75), 'three quarters elapsed');
v.setDown([1, 30, 20, DOWN_STATE.WAIT_DP], 31);
frames(v, 1, 1 / 60, () => 31);
assert.equal(r.text.text, 'DP');
assert.equal(r.arc.tint, DOWN_LOOK.ring[DOWN_STATE.WAIT_DP]);
assert.equal(r.arc.texture, T.ringArc(1), 'full ring while it waits');
v.setDown([1, 30, 20, DOWN_STATE.WAIT_TILE], 32);
frames(v, 1);
assert.equal(r.text.text, '!');
const kids = v.hud.children.length;
frames(v, 30);
assert.equal(v.hud.children.length, kids, 'no per-frame allocation');
v.onDeploy(); // the redeploy (b.ev 'deploy')
assert.equal(v.alive, true);
assert.equal(v.down, null);
frames(v, 2);
assert.equal(r.root.visible, false, 'ring gone');
});
test('a view made for a unit already down starts on the held end of the Die clip', () => {
const v = view();
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 12, true);
assert.equal(v.alive, false);
assert.ok(v.dieT >= 30);
frames(v, 2, 1 / 60, () => 12);
assert.ok(v._downRing.root.visible);
assert.equal(v._downRing.text.text, '18');
});
test('leaving the `down` list without a redeploy fades the view out; a normal death still fades', () => {
const v = view();
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 10);
frames(v, 10);
v.setDown(null, 11);
frames(v, 60);
assert.equal(v.remove, true);
const w = view({ id: 2 });
frames(w, 2);
w.die();
frames(w, 200);
assert.equal(w.remove, true, 'a summon / enemy death is unchanged');
});
});
describe('element gauge ring', () => {
const sample = (o = {}) => ({ x: 5, y: 10, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, el: null, elFill: 0, elUntil: 0, elDur: 0, ...o });
test('operators: the element disc with its glyph in a white ring of the remaining 元素值', () => {
const v = view();
v.sync(sample(), 5);
frames(v, 2);
assert.equal(v._elRing, null, 'nothing built without a gauge');
v.sync(sample({ el: 'burn', elFill: 0.25 }), 5);
frames(v, 1);
const r = v._elRing;
assert.ok(r.root.visible);
assert.equal(r.disc.texture, T.hudRings().disc.burn);
assert.equal(r.arc.texture, T.ringArc(0.75), 'remaining EP = 1 − fill');
v.sync(sample({ el: 'neural', elFill: 0.6 }), 5);
frames(v, 1);
assert.equal(r.disc.texture, T.hudRings().disc.neural);
assert.equal(r.arc.texture, T.ringArc(0.4));
v.sync(sample(), 6);
frames(v, 1);
assert.equal(r.root.visible, false, 'hidden once the gauges are empty');
});
test('enemies: a smaller plain disc; during a 爆发冷却 the ring refills over the cooldown', () => {
const e = view({ id: 3, side: 'enemy', kind: 'enemy', defId: 'enemy_x', dir: undefined });
e.sync(sample({ el: 'apoptosis', elFill: 1, elUntil: 25, elDur: 15 }), 10);
frames(e, 1);
const r = e._elRing;
assert.equal(r.disc.texture, T.hudRings().discEnemy.apoptosis);
assert.equal(r.arc.texture, T.ringArc(0), 'empty at the burst');
e.sync(sample({ el: 'apoptosis', elFill: 1, elUntil: 25, elDur: 15 }), 17.5);
frames(e, 1);
assert.equal(r.arc.texture, T.ringArc(0.5), 'half refilled halfway through');
const op = view({ id: 4 });
op.sync(sample({ el: 'apoptosis', elFill: 0.5 }), 1);
frames(op, 1);
assert.ok(r.root.scale.x < op._elRing.root.scale.x, 'the enemy icon is smaller');
});
test('a burst lock status (爆发冷却) is not repeated in the status row while the element icon shows it', () => {
const e = view({ id: 5, side: 'enemy', kind: 'enemy', defId: 'enemy_x', dir: undefined });
e.onStatus('burnBurst', true);
e.onStatus('fragile', true);
assert.deepEqual([...e._iconKeys()], ['burn', 'fragile'], 'no gauge in the feed: the status shows the burst');
e.sync(sample({ el: 'burn', elFill: 1, elUntil: 20, elDur: 10 }), 12);
assert.deepEqual([...e._iconKeys()], ['fragile'], 'the element icon carries the 爆发冷却');
});
test('the atlas: one frame per step, every element in both styles', () => {
const R = T.hudRings();
assert.equal(R.arcs.length, T.RING_STEPS + 1);
for (const el of ['neural', 'erosion', 'burn', 'apoptosis', 'necrosis']) { assert.ok(R.disc[el]); assert.ok(R.discEnemy[el]); }
assert.equal(T.ringArc(0.001), R.arcs[1], 'a sliver never shows as empty');
assert.equal(T.ringArc(2), R.arcs[T.RING_STEPS]);
});
});
+42 -64
View File
@@ -77,7 +77,6 @@ function harness(opts = {}) {
const c = createDragController({ const c = createDragController({
hitPiece: (x, y) => pieces.find((p) => Math.abs(p.x - x) < 40 && Math.abs(p.y - y) < 40) || null, hitPiece: (x, y) => pieces.find((p) => Math.abs(p.x - x) < 40 && Math.abs(p.y - y) < 40) || null,
pickTile: (x, y) => (x < 0 || y < 0 || x >= 2100 ? null : { row: 12 - Math.floor(y / 100), col: Math.floor(x / 100) }), pickTile: (x, y) => (x < 0 || y < 0 || x >= 2100 ? null : { row: 12 - Math.floor(y / 100), col: Math.floor(x / 100) }),
dropPoint: opts.dropPoint,
isOverCanvas: (cx, cy) => !(opts.domCover && cy > 900), isOverCanvas: (cx, cy) => !(opts.domCover && cy > 900),
canPlace: opts.canPlace, canPlace: opts.canPlace,
emit: (name, p) => events.push([name, p]), emit: (name, p) => events.push([name, p]),
@@ -218,84 +217,63 @@ describe('drag controller', () => {
assert.equal(h.events.filter((e) => e[0] === 'pieceDrop').length, 0); assert.equal(h.events.filter((e) => e[0] === 'pieceDrop').length, 0);
}); });
// user playtest #3 item 7: the drop target is the tile under the dragged ghost's feet (render/app.js dropPoint: the // user playtest #4 item 1: everything is judged at the pointer — the tile under it is the target (tileHover, legality,
// pointer for a mouse, a little above the finger for touch) — the hover tile, legality, the ghost and the drop agree // the drop) for a mouse and a finger alike (v2.2 lifted a touch drag's target 0.6 tile above the finger)
test('dropPoint: target, tileHover, legality and the drop follow the ghost\'s feet; default = the pointer', () => { test('the target, tileHover, legality and the drop are the tile under the pointer (mouse and touch)', () => {
const calls = []; for (const pointerType of ['mouse', 'touch']) {
const h = harness({ const h = harness({ canPlace: (p, r) => r !== 11 });
dropPoint: (e, piece) => { calls.push([e.pointerType, piece.uid]); return e.pointerType === 'touch' ? { x: e.x, y: e.y - 100 } : null; }, h.c.setEditable(true);
canPlace: (p, r) => r !== 11, h.ev('pointerDown', 50, 550, { pointerType });
}); h.ev('pointerMove', 450, 350, { pointerType });
h.c.setEditable(true); let mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
// mouse: the hook returns nothing → the pointer itself assert.deepEqual([mv.x, mv.y, mv.target, mv.legal], [450, 350, { area: 'board', row: 9, col: 4 }, true], pointerType);
h.ev('pointerDown', 50, 550); assert.ok(h.events.some((e) => e[0] === 'tileHover' && e[1] && e[1].row === 9 && e[1].col === 4), `${pointerType}: tileHover = the target`);
h.ev('pointerMove', 350, 250); h.ev('pointerMove', 450, 150, { pointerType });
let mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1]; mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
assert.deepEqual([mv.hx, mv.hy, mv.target], [350, 250, { area: 'board', row: 10, col: 3 }]); assert.deepEqual([mv.target, mv.legal], [{ area: 'board', row: 11, col: 4 }, false], `${pointerType}: row 11 under the pointer is illegal`);
h.ev('pointerUp', 350, 250); h.ev('pointerMove', 450, 350, { pointerType });
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 10, col: 3 }); h.ev('pointerUp', 450, 350, { pointerType });
// touch: the ghost (and the target) one row above the finger assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 9, col: 4 }, pointerType);
h.events.length = 0; }
h.ev('pointerDown', 50, 550, { pointerType: 'touch' });
h.ev('pointerMove', 450, 350, { pointerType: 'touch' });
mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
assert.deepEqual([mv.x, mv.y, mv.hx, mv.hy], [450, 350, 450, 250], 'pointer and drop point both reported');
assert.deepEqual(mv.target, { area: 'board', row: 10, col: 4 });
assert.equal(mv.legal, true);
assert.ok(h.events.some((e) => e[0] === 'tileHover' && e[1] && e[1].row === 10 && e[1].col === 4), 'tileHover = the target');
h.ev('pointerMove', 450, 250, { pointerType: 'touch' });
assert.equal(h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1].legal, false, 'row 11 under the ghost: illegal');
h.ev('pointerUp', 450, 350, { pointerType: 'touch' });
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 10, col: 4 });
assert.ok(calls.every(([, uid]) => uid === 1) && calls.some(([t]) => t === 'touch'));
}); });
// the view may skip its pixel probe on hover picks (a readback per mouse move stalls on the GPU): the controller says test('presses and hover pick the piece at the pointer (the view\'s hitPiece: the piece on the tile there)', () => {
// which calls are hovers — mouse moves without a press, drag moves — and which must be exact: presses and releases const asked = [];
test('hitPiece / dropPoint tell hovers from presses and releases', () => {
const hits = [];
const drops = [];
const pieces = [{ uid: 1, area: 'hand', idx: 0, x: 50, y: 550 }]; const pieces = [{ uid: 1, area: 'hand', idx: 0, x: 50, y: 550 }];
const c = createDragController({ const c = createDragController({
hitPiece: (x, y, hover) => { hits.push(hover === true); return pieces.find((p) => Math.abs(p.x - x) < 40 && Math.abs(p.y - y) < 40) || null; }, hitPiece: (x, y) => { asked.push([x, y]); return pieces.find((p) => Math.abs(p.x - x) < 40 && Math.abs(p.y - y) < 40) || null; },
pickTile: (x, y) => ({ row: 12 - Math.floor(y / 100), col: Math.floor(x / 100) }), pickTile: (x, y) => ({ row: 12 - Math.floor(y / 100), col: Math.floor(x / 100) }),
dropPoint: (e, piece, final) => { drops.push(final === true); return null; },
emit: () => {}, emit: () => {},
}); });
const ev = (type, x, y) => c[type]({ pointerId: 1, pointerType: 'mouse', button: 0, x, y, clientX: x, clientY: y }); const ev = (type, x, y, pointerType = 'mouse') => c[type]({ pointerId: 1, pointerType, button: 0, x, y, clientX: x, clientY: y });
c.setEditable(true); c.setEditable(true);
ev('pointerMove', 50, 550); ev('pointerMove', 60, 560);
assert.deepEqual(hits, [true], 'a mouse move without a press is a hover'); ev('pointerDown', 55, 545, 'touch');
ev('pointerDown', 50, 550); assert.deepEqual(asked, [[60, 560], [55, 545]], 'hover and press ask at the pointer itself');
assert.deepEqual(hits, [true, false], 'a press probes'); ev('pointerMove', 355, 255, 'touch');
ev('pointerMove', 350, 250); ev('pointerUp', 355, 255, 'touch');
ev('pointerMove', 360, 250); assert.equal(asked.length, 2, 'a drag asks no hit-test');
ev('pointerUp', 360, 250);
assert.ok(drops.length >= 3);
assert.ok(drops.slice(0, -1).every((f) => f === false), 'drag moves are hovers');
assert.equal(drops[drops.length - 1], true, 'the release is final');
}); });
// review of item 7: on a phone the bench is the lowest canvas row and the shop bar (DOM) sits right under it — a // on a phone the bench is the lowest canvas row and the shop bar (DOM) sits right under it: a release with the finger
// touch ghost lifted onto the bench has the finger on the shop bar. DOM cover is judged at the drop point. // on the shop bar is 'outside' (the piece goes back); on the bench slot above it, it lands there
test('dropPoint: DOM cover is tested at the ghost\'s feet, not at the finger (touch onto the bench above the shop bar)', () => { test('DOM cover is judged at the pointer: a touch release over the shop bar is outside, on the bench it lands', () => {
const h = harness({ domCover: true, dropPoint: (e) => (e.pointerType === 'touch' ? { x: e.x, y: e.y - 400 } : null) }); const h = harness({ domCover: true });
h.c.setEditable(true); h.c.setEditable(true);
// board unit (10,4) dragged by touch; the finger ends on the covered strip (y > 900), its ghost on bench slot 2
h.ev('pointerDown', 450, 250, { pointerType: 'touch' }); h.ev('pointerDown', 450, 250, { pointerType: 'touch' });
h.ev('pointerMove', 250, 950, { pointerType: 'touch' }); h.ev('pointerMove', 250, 550, { pointerType: 'touch' });
const mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1]; let mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
assert.deepEqual(mv.target, { area: 'hand', idx: 2 }); assert.deepEqual([mv.target, mv.legal], [{ area: 'hand', idx: 2 }, true]);
assert.equal(mv.legal, true); h.ev('pointerUp', 250, 550, { pointerType: 'touch' });
h.ev('pointerUp', 250, 950, { pointerType: 'touch' });
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'hand', idx: 2 }); assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'hand', idx: 2 });
// the ghost itself under the DOM: outside (the piece goes back), as for a mouse released on the DOM
h.events.length = 0; h.events.length = 0;
h.ev('pointerDown', 450, 250, { pointerType: 'touch' }); h.ev('pointerDown', 450, 250, { pointerType: 'touch' });
h.ev('pointerMove', 250, 1350, { pointerType: 'touch' }); h.ev('pointerMove', 250, 950, { pointerType: 'touch' });
assert.equal(h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1].target, null); mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
h.ev('pointerUp', 250, 1350, { pointerType: 'touch' }); assert.equal(mv.target, null, 'the finger on the DOM: no target');
assert.equal(h.events.find((e) => e[0] === 'pieceDrop')[1].target.area, 'outside'); assert.ok(h.events.some((e) => e[0] === 'tileHover' && e[1] === null), 'no tile lit');
h.ev('pointerUp', 250, 950, { pointerType: 'touch' });
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'outside', clientX: 250, clientY: 950 });
}); });
test('a throwing listener or hit-test never breaks the controller', () => { test('a throwing listener or hit-test never breaks the controller', () => {
+2 -2
View File
@@ -120,9 +120,9 @@ describe('field view features in headless Chrome', { skip }, () => {
const clicks = []; const clicks = [];
const off = v.on('pieceClick', (e) => clicks.push(e)); const off = v.on('pieceClick', (e) => clicks.push(e));
const pv = [...v.debug.penViews.values()].find((p) => !p.culled); const pv = [...v.debug.penViews.values()].find((p) => !p.culled);
const b = pv.bounds();
const rect = v.debug.app.view.getBoundingClientRect(); const rect = v.debug.app.view.getBoundingClientRect();
out.click = { x: rect.left + b.x + b.width / 2, y: rect.top + b.y + b.height * 0.6, key: pv.info.enemyKey }; // picking is by tile (user playtest #4 item 1): a press at the figure's feet, on its own tile
out.click = { x: rect.left + pv.screen.x, y: rect.top + pv.screen.y, key: pv.info.enemyKey };
window.__clicks = clicks; window.__offClick = off; window.__clicks = clicks; window.__offClick = off;
return out; return out;
}); });
+83
View File
@@ -0,0 +1,83 @@
// test/render/flash.browser.test.js — user playtest #4 item 13 in headless Chrome ("屏幕很偶尔的情况下会有个巨大的ui的一部分
// 遮挡一两秒": once in a while a huge piece of UI covered the screen for a second or two).
// Found by sampling long real-server sessions every 80 ms (DOM boxes > 25 % of the viewport, images drawn > 300 px, Pixi
// objects > 25 % of the viewport or showing a whole atlas page, CSS animations scaling ≥ 2): the only unexpected hit
// was the bond-layer pop of render/fx.js — the first in-battle layer gain of each bond (b.ev 'layer') popped the bond's
// icon, a PIXI.Texture.from(url) that is 1×1 until its image has loaded, and pop() sized the sprite from that: 46× too
// big, ~5000 px over the whole screen for the pop's 1.4 s (test/render/fxpop.test.js has the unit test). Here the real
// mock battle (public/dev/game-mock.html) gets 'layer' events for bonds whose icons were never loaded on the page.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/flash.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/flash-*.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
describe('user playtest #4 item 13: no screen-sized UI flash (mock battle, headless Chrome)', { skip }, () => {
let srv, browser;
before(async () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const puppeteer = require('puppeteer-core');
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run', '--force-device-scale-factor=1'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
for (const [w, h] of [[1920, 1080], [1280, 720]]) {
test(`${w}×${h}: a bond layer gained in battle pops its icon at ~46 px — also the first time (icon still loading)`, async () => {
const page = await browser.newPage();
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
await page.setViewport({ width: w, height: h });
await page.goto(`http://127.0.0.1:${srv.port}/dev/game-mock.html?shot=1&render=engine&phase=COMBAT`, { waitUntil: 'networkidle0' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && globalThis.__SP_VIEW__?.raw?.mode === 'battle', { timeout: 30000 });
await sleep(1500);
const r = await page.evaluate(async () => {
const { net } = await import('/js/net.js');
const R = globalThis.__SP_VIEW__.raw, S = globalThis.__MOCK__.S();
const icons = (await import('/js/assets.js')).assets;
// bonds with an icon that no view has turned into a texture yet (the bond strip's <img>s do not count)
const bonds = ['yanShip', 'preciShip', 'abyssShip', 'kazimierzShip'].filter((b) => icons.bondIcon?.(b) && !PIXI.utils.TextureCache[icons.bondIcon(b)]);
const gt = (await new Promise((resolve) => { const off = net.on('b.snap', (m) => { off(); resolve(m.gt); }); })) + 0.05;
net._emit('b.ev', { t: 'b.ev', fieldId: S.battle.fieldRef.id, gt, ev: bonds.map((b, i) => ['layer', 'p1', b, i + 1]) });
const t0 = performance.now();
const icon = (p) => p.c.children.find((c) => c.texture && c.texture !== R.debug.fx.tex.glow);
let worst = 0, seen = 0;
// every frame of the pops' life (1.4 s): the icon sprite's drawn box
while (performance.now() - t0 < 1600) {
await new Promise((res) => requestAnimationFrame(res));
for (const p of R.debug.fx.pops) {
const sp = icon(p);
if (!sp || !sp.visible || !(sp.worldAlpha > 0.02)) continue;
const b = sp.getBounds();
seen = Math.max(seen, R.debug.fx.pops.length);
worst = Math.max(worst, b.width, b.height);
}
}
return { bonds, worst: Math.round(worst), seen, loaded: bonds.map((b) => !!PIXI.utils.TextureCache[icons.bondIcon(b)]?.valid) };
});
await page.screenshot({ path: path.join(OUT, `flash-bond-pop-${w}.png`) });
await page.close();
assert.ok(r.bonds.length >= 2 && r.seen >= 2, `premise: bond icons popped for the first time (${JSON.stringify(r)})`);
assert.ok(r.loaded.every(Boolean), `premise: their images loaded during the pop (${JSON.stringify(r)})`);
assert.ok(r.worst > 20 && r.worst < 80, `the icon is ~46 px (× the pop's scale-in), never screen-sized: ${r.worst} px`);
assert.deepEqual(problems, []);
});
}
});
+109
View File
@@ -0,0 +1,109 @@
// test/render/fxflame.test.js — 炎佑's 祛恶之焰 as a continuous jet of fire (render/fx.js _flame; user playtest #4 item 12
// "官方是一段持续时间的喷火"). The sim (server/sim/content/tokens.js yanyouKit) emits one fx 'yanyouFlame'
// {x, y, id: dragon, target, r, n, dur: 1} per game second of the channel; the renderer keeps ONE jet per dragon that each
// tick re-aims and extends, so the ticks join into a stream from the dragon onto its locked target with a burning disc
// of radius r around the target. It used to be the generic 'buff' sparkle once per second.
// Against the headless fake PIXI (test/render/fakepixi.js), like fxproj.test.js.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
let fake, FX;
before(async () => {
fake = installFakePixi();
FX = await import('../../public/js/render/fx.js');
});
after(() => fake.restore());
const DT = 1 / 120;
const cam = presetCamera('normal', { width: 1600, height: 900 });
const unit = (id, x, y, o = {}) => ({ id, x, y, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, info: { defId: 'x' }, onHit() {}, ...o });
function makeFx(views, ts = 2) {
const P = fake.P;
const ctx = fakeViewCtx(P);
const map = new Map(views.map((v) => [v.id, v]));
const fx = new FX.FxSystem({
P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality: 'high', damageNumbers: true },
timeScale: () => ts, loadLevel: () => 0, subProfOf: () => null, view: (id) => map.get(id) || null,
screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
});
return { fx, map };
}
const run = (fx, seconds) => { for (let t = 0; t < seconds - 1e-9; t += DT) fx.update(DT); };
/** One sim tick of the channel (the event tokens.js emits). */
const tick = (fx, dragon, target, r = 1) => fx.simFx('yanyouFlame', target.x, target.y, { x: target.x, y: target.y, id: dragon.id, target: target.id, r, n: 1, dur: 1 });
describe('炎佑 祛恶之焰: one continuous jet of fire (user playtest #4 item 12)', () => {
test('its own archetype (no longer the one-off buff sparkle)', () => {
assert.equal(FX.FX_KINDS.yanyouFlame.a, 'flame');
assert.equal(FX.fxSpec('yanyouFlame').a, 'flame');
});
test('the 1 s ticks join into one stream that burns while they come and ends after the last one', () => {
const dragon = unit(40, 6.2, 10.1, { hover: 0.32 }), foe = unit(20, 6, 10);
const { fx } = makeFx([dragon, foe]);
for (let s = 0; s < 6; s++) { // 6 game s of channel at the 2× clock: a tick every 0.5 real s
tick(fx, dragon, foe);
assert.equal(fx.flames.length, 1, `tick ${s}: still one jet`);
run(fx, 0.5);
assert.equal(fx.flames.length, 1, `between ticks ${s} and ${s + 1} the jet keeps burning`);
const F = fx.flames[0];
assert.ok(F.disc.alpha > 0.2 && F.edge.alpha > 0.2, 'the burning disc shows');
}
assert.ok(fx.counts.particles > 20, 'fire streams from the dragon and rises from the disc');
run(fx, 0.3);
assert.equal(fx.flames.length, 0, 'no tick came: the jet goes out within FLAME_TAIL');
});
test('the disc follows the locked target; a new tick re-aims the same jet', () => {
const dragon = unit(40, 3, 10, { hover: 0.32 }), a = unit(20, 3.2, 10), b = unit(21, 5, 11);
const { fx } = makeFx([dragon, a, b]);
tick(fx, dragon, a);
run(fx, 0.1);
const F = fx.flames[0];
const at0 = { x: F.disc.position.x, y: F.disc.position.y };
a.x = 4.2; // the target walks on: the disc goes with it
run(fx, 0.1);
assert.ok(F.disc.position.x > at0.x + 20, `disc follows the target (${at0.x} → ${F.disc.position.x})`);
tick(fx, dragon, b); // the next tick aims at another enemy: the same jet, re-aimed
run(fx, 0.1);
assert.equal(fx.flames.length, 1);
assert.equal(fx.flames[0], F);
assert.equal(F.tgt, 21);
assert.equal(F.r, 1);
});
test('the dragon gone (knocked out, left the field) puts its jet out at once; clear() drops every jet', () => {
const dragon = unit(40, 3, 10), foe = unit(20, 3.2, 10);
const { fx, map } = makeFx([dragon, foe]);
tick(fx, dragon, foe);
run(fx, 0.05);
assert.equal(fx.flames.length, 1);
map.delete(40);
run(fx, 0.05);
assert.equal(fx.flames.length, 0, 'no dragon, no fire');
map.set(40, dragon);
tick(fx, dragon, foe);
const disc = fx.flames[0].disc;
fx.clear();
assert.equal(fx.flames.length, 0);
assert.ok(disc.destroyed, 'its sprites are freed');
});
test('two dragons (9 炎) burn two jets; a target that died keeps the jet on its last spot until it fades', () => {
const d1 = unit(40, 3, 10), d2 = unit(41, 3, 10.8), foe = unit(20, 3.2, 10);
const { fx, map } = makeFx([d1, d2, foe]);
tick(fx, d1, foe); tick(fx, d2, foe);
assert.equal(fx.flames.length, 2);
run(fx, 0.1);
const x = fx.flames[0].x;
map.delete(20); // killed
run(fx, 0.1);
assert.equal(fx.flames[0].x, x, 'stays where the target was');
run(fx, 0.6);
assert.equal(fx.flames.length, 0);
});
});
+92
View File
@@ -0,0 +1,92 @@
// test/render/fxpop.test.js — user playtest #4 item 13 ("屏幕很偶尔的情况下会有个巨大的ui的一部分遮挡一两秒": once in a while a
// huge piece of UI covered the screen for a second or two). The first in-battle bond layer gain of each bond (b.ev
// 'layer' → render/app.js → FxSystem.pop) popped that bond's icon 46 times too big — ~5000 px, the whole screen, for
// the pop's 1.4 s. The icon is PIXI.Texture.from(url): a 1×1 placeholder until its image has loaded (then the real size
// and an 'update' event, Pixi 7.4), and pop() sized the sprite from the placeholder (46 / 1). Reproduced in headless
// Chrome (test/render/flash.browser.test.js); here against a fake PIXI with the same Texture semantics.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { FxSystem } from '../../public/js/render/fx.js';
class Emitter {
constructor() { this._on = new Map(); }
on(e, f, ctx) { if (!this._on.has(e)) this._on.set(e, []); this._on.get(e).push({ f, ctx, once: false }); return this; }
once(e, f, ctx) { if (!this._on.has(e)) this._on.set(e, []); this._on.get(e).push({ f, ctx, once: true }); return this; }
off(e, f) { const l = this._on.get(e); if (l) this._on.set(e, l.filter((x) => x.f !== f)); return this; }
emit(e, ...a) { const l = this._on.get(e) || []; this._on.set(e, l.filter((x) => !x.once)); for (const x of l) x.f.apply(x.ctx, a); return true; }
}
/** Pixi 7.4 Texture of an image: 1×1 and invalid until its base texture has loaded, then its size + 'update'. */
class Tex extends Emitter {
constructor(w, h, loaded = true) {
super();
this._size = [w, h];
this.baseTexture = { valid: loaded, width: loaded ? w : 1, height: loaded ? h : 1 };
this.orig = { width: loaded ? w : 1, height: loaded ? h : 1 };
this.valid = loaded;
}
get width() { return this.orig.width; }
get height() { return this.orig.height; }
load() {
[this.orig.width, this.orig.height] = this._size;
Object.assign(this.baseTexture, { valid: true, width: this._size[0], height: this._size[1] });
this.valid = true;
this.emit('update', this);
}
}
class Point { constructor(x = 1, y = x) { this.x = x; this.y = y; } set(x, y = x) { this.x = x; this.y = y; } }
class Sprite {
constructor(tex) { this.texture = tex; this.visible = true; this.scale = new Point(1); this.anchor = new Point(0); this.position = new Point(0); this.tint = 0xffffff; this.destroyed = false; }
destroy() { this.destroyed = true; }
}
class Container {
constructor() { this.children = []; this.position = new Point(0); this.scale = new Point(1); this.alpha = 1; this.destroyed = false; }
addChild(...c) { this.children.push(...c); return c[0]; }
destroy() { this.destroyed = true; for (const c of this.children) c.destroy?.(); }
}
class BitmapText { constructor(text) { this.text = text; this.anchor = new Point(0); this.position = new Point(0); this.tint = 0xffffff; } destroy() {} }
function fx() {
const f = Object.create(FxSystem.prototype);
Object.assign(f, {
P: { Sprite, Container, BitmapText, BLEND_MODES: { ADD: 1 } },
tex: { glow: new Tex(64, 64) }, pops: [],
ctx: { screenSize: () => ({ width: 1920, height: 1080 }), fieldTop: () => 200, layers: { screen: { addChild() {} } } },
});
return f;
}
/** Screen px of the icon sprite's longer side as drawn (0 while hidden). */
const drawn = (sp) => (sp.visible ? Math.max(sp.texture.width * Math.abs(sp.scale.x), sp.texture.height * Math.abs(sp.scale.y)) : 0);
const iconOf = (f, icon) => f.pops.at(-1).c.children.find((c) => c.texture === icon);
describe('FxSystem.pop: the bond icon is 46 px whenever its image loads (user playtest #4 item 13)', () => {
test('an icon still loading is sized when it has loaded — never from its 1×1 placeholder', () => {
const f = fx();
const icon = new Tex(110, 98, false); // bond/yanShip.png
f.pop(icon, '+2', 0xffffff, 0);
const sp = iconOf(f, icon);
assert.ok(drawn(sp) <= 46.5, `nothing big while loading (${drawn(sp)} px)`);
icon.load();
assert.ok(Math.abs(drawn(sp) - 46) < 0.5, `46 px once loaded (was 110 × 46 = 5060 px): ${drawn(sp)}`);
f._updatePops(0.5);
assert.ok(drawn(sp) * f.pops[0].c.scale.x < 60, 'with the pop\'s own scale-in');
});
test('an icon already loaded: 46 px along its longer side at once', () => {
const f = fx();
const icon = new Tex(102, 100);
f.pop(icon, '+1', 0xffffff, 1);
assert.ok(Math.abs(drawn(iconOf(f, icon)) - 46) < 0.5);
});
test('a pop that ended before its image loaded stays gone', () => {
const f = fx();
const icon = new Tex(110, 98, false);
f.pop(icon, '+3', 0xffffff, 0);
const sp = iconOf(f, icon);
f._updatePops(2);
assert.equal(f.pops.length, 0);
assert.doesNotThrow(() => icon.load());
assert.ok(sp.destroyed);
});
});
+257 -149
View File
@@ -1,16 +1,19 @@
// test/render/models.browser.test.js — user playtest #3 items 1 and 7 in headless Chrome, through the in-match mock // test/render/models.browser.test.js — user playtest #3 item 1 and user playtest #4 item 1 in headless Chrome, through the
// (public/dev/game-mock.html: the real game screen, a mock server behind net.request): // in-match mock (public/dev/game-mock.html: the real game screen, a mock server behind net.request):
// 1 operator models never go missing: after a battle that outlasts the Spine LRU grace, the battle → prep switch // #3.1 operator models never go missing: after a battle that outlasts the Spine LRU grace, the battle → prep switch
// (every battle view destroyed, the prep views built in the same task) used to unload the bench models and hand // (every battle view destroyed, the prep views built in the same task) used to unload the bench models and hand
// the doomed skeletons straight back (PIXI.Assets serves an unloading asset from its loader cache): invisible // the doomed skeletons straight back (PIXI.Assets serves an unloading asset from its loader cache): invisible
// chibis. Also a burst of rapid drags / swaps / direction-wheel previews and commits / cancels / re-drags. After // chibis. Also a burst of rapid drags / swaps / direction-wheel previews and commits / cancels / re-drags. After
// each: every prep piece has a view, visible, on its tile, drawing a live Spine model (textures alive) — and the // each: every prep piece has a view, visible, on its tile, drawing a live Spine model (textures alive) — and the
// canvas pixels over it change when it is hidden (it really draws something). // canvas pixels over it change when it is hidden (it really draws something).
// 7 picking: with an operator directly behind another (same column), presses on its head / torso / legs, on its // #4.1 picking by tile ("地上都画好了一个一个方格,点击对应方格就选中那个方格的人物就行"): a press anywhere on a tile — board
// feet where they show and on its tile beside the front one's head select IT; the front one's face selects the // (adjacent rows and columns, high ground), bench, temporary bench — selects the unit on it and an empty tile
// front one — in prep and in battle; equipment dropped on the body of the one behind equips it; a dragged unit's // nothing (a press on a unit's head, drawn over the tile behind it, is a press on that tile), in prep and in battle
// ghost stands on the pointer and the drop (direction wheel) is that tile. Every press on a drawn unit pixel (the // (allies by their tile, enemies by their ground position or drawn body); a dragged unit is held under the pointer — its drawn
// whole prep board and a battle) picks the front-most unit drawn there (render/pick.js pixel probe). // feet DRAG_HOLD_TILES below it, the model around it — for a mouse and a finger (phone, 844×390); the drop, its
// tileHover and the direction wheel are the tile under the pointer; equipment dropped on a tile equips the unit
// on it (not the one in front whose head is drawn there); on a phone a release on a bench slot lands there and one
// over the shop bar goes back.
// //
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/models.browser.test.js // Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/models.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/models-*.png. // Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/models-*.png.
@@ -169,7 +172,7 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
const units = (await page.evaluate(() => window.__t.pieces())).filter((p) => p.kind !== 'item'); const units = (await page.evaluate(() => window.__t.pieces())).filter((p) => p.kind !== 'item');
const p = units[Math.floor(rnd() * units.length)]; const p = units[Math.floor(rnd() * units.length)];
const [row, col] = rnd() < 0.8 ? tiles[Math.floor(rnd() * tiles.length)] : [7, Math.floor(rnd() * 10)]; const [row, col] = rnd() < 0.8 ? tiles[Math.floor(rnd() * tiles.length)] : [7, Math.floor(rnd() * 10)];
const from = await page.evaluate((u) => window.__t.at('p:' + u, 0.5), p.uid); const from = await page.evaluate((u) => window.__t.at('p:' + u, 0.1), p.uid); // on its own tile
const to = await page.evaluate((r, c) => globalThis.__SP_VIEW__.raw.tileScreen(r, c), row, col); const to = await page.evaluate((r, c) => globalThis.__SP_VIEW__.raw.tileScreen(r, c), row, col);
await drag(from, to); await drag(from, to);
await sleep(40 + rnd() * 80); await sleep(40 + rnd() * 80);
@@ -184,177 +187,282 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
assert.deepEqual(problems, []); assert.deepEqual(problems, []);
}); });
test('7: picking an operator standing directly behind another — prep and battle; equipment on the one behind', async () => { /** Client point on a board-space tile's top face: (u, v) ∈ [−1, 1] from its centre towards its left / right and near /
const { page, problems } = await open('phase=PREP'); * far edges (view.tileScreen poly: far-left, far-right, near-right, near-left). */
// col 4: row 9 (ground) · rows 10–12 (high ground) — a ranged operator behind a melee one, and two on the same height const tileSpot = (page, row, col, u = 0, v = 0) => page.evaluate((r, c, u, v) => {
const [a9, b10, c11] = [await uidAt(page, 9, 4), await uidAt(page, 10, 4), await uidAt(page, 11, 4)]; const t = globalThis.__SP_VIEW__.raw.tileScreen(r, c);
assert.ok(a9 && b10 && c11, 'mock premise: three operators in one column'); if (!t) return null;
const clicks = async (keyOf, idOf, cases) => { const [fl, fr, nr, nl] = t.poly, a = (u + 1) / 2, b = (v + 1) / 2;
await page.evaluate(() => { window.__picked = []; window.__offPick?.(); window.__offPick = globalThis.__SP_VIEW__.raw.on('pieceClick', (e) => window.__picked.push(e.uid)); }); const near = [nl[0] + (nr[0] - nl[0]) * a, nl[1] + (nr[1] - nl[1]) * a], far = [fl[0] + (fr[0] - fl[0]) * a, fl[1] + (fr[1] - fl[1]) * a];
for (const [label, key, up, dx, want] of cases) { return { x: near[0] + (far[0] - near[0]) * b, y: near[1] + (far[1] - near[1]) * b };
const pt = await page.evaluate((k, u, d) => window.__t.at(k, u, d), keyOf(key), up, dx); }, row, col, u, v);
await page.mouse.click(pt.x, pt.y); /** The render engine's own pick at a client point (render/pick.js through app.js pieceAt / battleUnitAt). */
await sleep(120); const pickAt = (page, pt, battle = false) => page.evaluate((x, y, b) => {
const got = await page.evaluate(() => window.__picked.splice(0)); const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect();
assert.deepEqual(got.slice(-1), [idOf(want)], `${label}`); if (!b) return R.debug.pick.pieceAt(x - cv.left, y - cv.top)?.uid ?? null;
await page.keyboard.press('Escape'); return R.debug.pick.battleUnitAt(x - cv.left, y - cv.top)?.id ?? null;
await sleep(60); }, pt.x, pt.y, battle);
const listenClicks = (page) => page.evaluate(() => {
window.__picked = [];
window.__offPick?.();
window.__offPick = globalThis.__SP_VIEW__.raw.on('pieceClick', (e) => window.__picked.push(e.unitId ?? e.uid ?? null));
});
const clickAt = async (page, pt) => {
await page.mouse.click(pt.x, pt.y);
await sleep(90);
const got = await page.evaluate(() => window.__picked.splice(0));
await page.keyboard.press('Escape');
await sleep(40);
return got;
};
// spots of a tile's top face that show on screen: its centre, 0.3 tile out to the sides and 0.4 to the far edge, 0.15
// towards the near edge (a raised tile in front hides the strip of this one just behind it: a 0.3-high block the
// nearest 0.17 tile, the 0.55 separator wall in front of the bench 0.22 — the press there is on the block's top)
const SPOTS = [[0, 0], [-0.6, 0], [0.6, 0], [0, -0.3], [0, 0.8], [-0.6, 0.6], [0.6, 0.6], [-0.6, -0.3], [0.6, -0.3]];
test('#4.1 prep: a press anywhere on a tile selects the unit on it — board, high ground, bench, temp; an empty tile nothing', async () => {
const { page, problems } = await open('phase=PREP&variant=temp');
const pieces = await page.evaluate(() => window.__t.pieces());
const on = (r, c) => pieces.find((p) => p.row === r && p.col === c)?.uid ?? null;
assert.ok(on(9, 3) && on(9, 4) && on(9, 5) && on(10, 4) && on(11, 4), 'premise: neighbours in a row (9, 3–5) and a column (9–11, 4; rows 10–12 high ground)');
assert.ok(pieces.filter((p) => p.row === 7).length >= 5 && pieces.filter((p) => p.row === 8).length >= 2, 'premise: bench and temp pieces');
await listenClicks(page);
for (const p of pieces) {
for (const [u, v] of SPOTS) {
const pt = await tileSpot(page, p.row, p.col, u, v);
assert.equal(await pickAt(page, pt), p.uid, `${p.kind} ${p.uid} on (${p.row},${p.col}) at (${u}, ${v})`);
}
// the whole press path (canvas pointer events → the drag controller → pieceClick) at the centre and a corner
for (const [u, v] of [[0, 0], [0.6, 0.6]]) {
const got = await clickAt(page, await tileSpot(page, p.row, p.col, u, v));
assert.deepEqual(got.slice(-1), [p.uid], `click on (${p.row},${p.col}) at (${u}, ${v})`);
} }
};
const cases = [
// [label, whose feet, tiles up, tiles aside, expected]
['raised B: face', b10, 0.86, 0, b10], ['raised B: torso', b10, 0.45, 0, b10], ['raised B: legs', b10, 0.22, 0, b10],
// B's feet where they show (straight under B's centre, A's hair crown is drawn over them: that pixel is A's)
['raised B: feet', b10, 0.05, -0.12, b10],
['raised B: its tile beside A\'s head', b10, 0, 0.42, b10],
['A (in front): face', a9, 0.86, 0, a9], ['A: torso', a9, 0.45, 0, a9], ['A: feet', a9, 0.05, 0, a9],
// (B's head and staff are drawn over the middle of C's lower torso and legs: those pixels are B's)
['same height C behind B: face', c11, 0.86, 0, c11], ['C: torso', c11, 0.62, 0, c11], ['C: legs beside B\'s head', c11, 0.4, -0.2, c11],
['C: its tile beside B\'s head', c11, 0, -0.42, c11], ['B in front of C: face', b10, 0.86, 0, b10],
];
await clicks((u) => 'p:' + u, (u) => u, cases);
// equipment: an item dropped on the torso of the one behind (C) equips C; one on A's face equips A
const items = await page.evaluate(() => globalThis.__MOCK__.S().priv.hand.filter((x) => x && x.kind === 'item').map((x) => x.uid));
for (const [target, up] of [[c11, 0.62], [a9, 0.86]]) {
const it = items.shift();
const from = await page.evaluate((u) => { const r = globalThis.__SP_VIEW__.pieceScreenRect(u); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }, it);
const to = await page.evaluate((u, k) => window.__t.at('p:' + u, k), target, up);
await page.mouse.move(from.x, from.y); await page.mouse.down();
for (let i = 1; i <= 10; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 10, from.y + ((to.y - from.y) * i) / 10); await sleep(16); }
await sleep(120);
assert.equal(await page.evaluate(() => globalThis.__SP_VIEW__.raw.debug.dragHit?.over ?? null), target, 'the plate is over the target (pieceDragOver)');
await page.mouse.up();
await sleep(600);
const eq = await page.evaluate((u) => globalThis.__MOCK__.S().priv.board.find((p) => p.uid === u).items.map((x) => x.uid), target);
assert.ok(eq.includes(it), `item ${it} equipped on ${target} (got ${eq})`);
} }
// a press on a unit's head, drawn over the tile behind it: the unit on that tile — or nothing when it is empty
const head = (uid) => page.evaluate((u) => window.__t.at('p:' + u, 1.0), uid);
assert.equal(await pickAt(page, await head(on(9, 4))), on(10, 4), 'the head of (9,4) is drawn over (10,4)');
assert.equal(on(10, 3), null, 'premise: (10,3) is a forbidden block');
assert.equal(await pickAt(page, await head(on(9, 3))), null, 'the head of (9,3) over the empty (10,3)');
assert.deepEqual(await clickAt(page, await head(on(9, 3))), [], 'no pieceClick');
// the nearest strip of (9,3) is hidden by the forbidden block (8,3) in front of it: a press there is on that block
const hidden = await tileSpot(page, 9, 3, 0, -0.9);
assert.deepEqual(await page.evaluate((x, y) => { const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect(), g = R.debug.pick.groundTile(x - cv.left, y - cv.top); return g && [g.row, g.col]; }, hidden.x, hidden.y), [8, 3]);
assert.equal(await pickAt(page, hidden), null);
await page.screenshot({ path: path.join(OUT, 'models-pick-prep.png') }); await page.screenshot({ path: path.join(OUT, 'models-pick-prep.png') });
// battle: the same presses on the battle views (ids → piece uids) await page.close();
assert.deepEqual(problems, []);
});
test('#4.1 battle: allies by the tile under the pointer, enemies by their ground position or drawn body', async () => {
const { page, problems } = await open('phase=PREP');
await setPhase(page, 'COMBAT'); await setPhase(page, 'COMBAT');
await sleep(2500); await sleep(6000); // several enemies spawned (one every 0.9 s)
// …at a moment of the battle when the units stand at rest: a press takes whatever is DRAWN under it (pixel probe), // freeze the mock battle (one last snapshot): the enemies stand still for the presses
// and the mock battle keeps every operator attacking (lunges, attack clips) and in random skill poses, which move
// the drawn face / torso / legs off the aimed points (then the unit in front or behind rightly takes the press —
// the pixel-truth test below covers moving poses). So the mock battle is stopped with one last snapshot that clears
// the skill flags, and the presses wait until every ally is back in its idle pose.
await page.evaluate(async () => { await page.evaluate(async () => {
const { net } = await import('/js/net.js'); const { net } = await import('/js/net.js');
const S = globalThis.__MOCK__.S(); const S = globalThis.__MOCK__.S();
const last = await new Promise((resolve) => { const off = net.on('b.snap', (m) => { off(); resolve(m); }); }); const last = await new Promise((resolve) => { const off = net.on('b.snap', (m) => { off(); resolve(m); }); });
clearInterval(S.battle.timer); clearInterval(S.battle.timer);
net._emit('b.snap', { ...last, gt: last.gt + 0.2, units: last.units.map((u) => { const c = u.slice(); c[7] = 0; return c; }) }); net._emit('b.snap', { ...last, gt: last.gt + 0.2 });
}); });
await page.waitForFunction(() => [...globalThis.__SP_VIEW__.raw.debug.views.values()] await sleep(800);
.filter((v) => v.info?.side === 'ally' && v.alive && v.actor) const units = await page.evaluate(() => {
.every((v) => v.actor.mode === 'base' && !v.actor.skillOn && !(v.lunge > 0)), { timeout: 15000, polling: 100 }); const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect(), out = [];
await sleep(300); for (const [id, v] of R.debug.views) {
const ids = await page.evaluate(() => { const out = {}; for (const [id, v] of globalThis.__SP_VIEW__.raw.debug.views) if (v.info?.uid != null) out[v.info.uid] = id; return out; }); if (!v.info || !v.alive || v.culled || v.info.kind === 'device') continue;
await clicks((u) => ids[u], (u) => u, cases.filter(([label]) => !label.includes('tile'))); const g = R.debug.cam.project(v.x, v.y, v.z);
out.push({ id, side: v.info.side, x: v.x, y: v.y, row: Math.round(v.y), col: Math.round(v.x), fly: !!v.flying, feet: { x: cv.left + g.x, y: cv.top + g.y },
body: { x: cv.left + v.screen.x, top: cv.top + v.screen.top, feet: cv.top + v.screen.y, s: v.screen.s } });
}
return out;
});
const allies = units.filter((u) => u.side === 'ally'), foes = units.filter((u) => u.side === 'enemy' && !u.fly);
assert.ok(allies.length >= 7 && foes.length >= 3, `premise: allies ${allies.length}, walking enemies ${foes.length}`);
await listenClicks(page);
const near = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
/** Tiles between a client point and an enemy's drawn body (the line from its feet up to its head). */
const bodyDist = (b, pt) => Math.hypot(pt.x - b.x, pt.y - Math.max(b.top, Math.min(b.feet, pt.y))) / b.s;
// the rule (render/pick.js), from the spot's own ground coordinates and client point: the ally on the tile, unless
// an enemy's ground position or drawn body is nearer (within 0.6 tile)
const expected = (a, g, pt) => {
let best = a, bd = near(a, g);
for (const f of foes) { const d = Math.min(near(f, g), bodyDist(f.body, pt)); if (d <= 0.6 && d < bd - 1e-6) { best = f; bd = d; } }
return best.id;
};
let byEnemy = 0;
for (const a of allies) {
for (const [u, v] of SPOTS) {
const pt = await tileSpot(page, a.row, a.col, u, v);
const g = await page.evaluate((x, y) => { const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect(); return R.debug.pick.groundTile(x - cv.left, y - cv.top); }, pt.x, pt.y);
const want = expected(a, g, pt);
if (want !== a.id) byEnemy++;
assert.equal(await pickAt(page, pt, true), want, `ally ${a.id} on (${a.row},${a.col}) at (${u}, ${v})`);
}
if (expected(a, { x: a.col, y: a.row }, await tileSpot(page, a.row, a.col)) === a.id) assert.deepEqual((await clickAt(page, await tileSpot(page, a.row, a.col))).slice(-1), [a.id], `click on ally ${a.id}`);
}
assert.ok(byEnemy < allies.length * SPOTS.length / 3, `most spots of the allies' tiles are the allies' (${byEnemy} went to an enemy standing nearer)`);
for (const f of foes) {
// at its feet, and on its drawn torso / head (a tall enemy's body is drawn over the ground a tile behind it)
for (const [k, pt] of [['feet', f.feet], ['torso', { x: f.body.x, y: (f.body.top + f.body.feet) / 2 }], ['head', { x: f.body.x, y: f.body.top + 0.1 * f.body.s }]]) {
const got = await pickAt(page, pt, true);
const other = units.find((u) => u.id === got);
assert.ok(got === f.id || (other && other.side === 'enemy' && near(other, f) < 0.3), `enemy ${f.id} at its ${k} (got ${got})`);
}
}
const f = foes.find((x) => !foes.some((y) => y !== x && near(x, y) < 0.3)) || foes[0];
const got = await clickAt(page, f.feet);
assert.ok(got.length === 1 && units.find((u) => u.id === got[0])?.side === 'enemy', `a click at an enemy's feet selects an enemy (${got})`);
await page.screenshot({ path: path.join(OUT, 'models-pick-battle.png') }); await page.screenshot({ path: path.join(OUT, 'models-pick-battle.png') });
await page.close(); await page.close();
assert.deepEqual(problems, []); assert.deepEqual(problems, []);
}); });
// review of item 7: the pick against what is really drawn. Every unit is rendered alone (its pixels = where its model /** The dragged view against the pointer (client px): its drawn feet, px per tile, and the Spine model's drawn bounds. */
// is opaque); the truth at a pixel is the front-most unit drawn there. Sampled over the whole prep board and a const ghostAt = (page, uid) => page.evaluate((u) => {
// battle: the shape rule alone got 7 % (battle 18 %) of these presses wrong, mostly giving the front unit's hair / const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect(), v = R.debug.views.get('p:' + u);
// arms to the unit behind; the pixel probe settles every overlap. const b = v.actor && v.spineReady && !v.imp ? v.actor.spine.getBounds() : null;
test('7: picks agree with the rendered pixels (prep and battle)', async () => { return { lift: v.lift, x: cv.left + v.screen.x, y: cv.top + v.screen.y, s: v.screen.s, model: b && { x0: cv.left + b.x, y0: cv.top + b.y, x1: cv.left + b.x + b.width, y1: cv.top + b.y + b.height } };
const { page, problems } = await open('phase=PREP'); }, uid);
const measure = (phase) => page.evaluate((ph) => { /** The model is under the pointer: its feet DRAG_HOLD_TILES (0.45) below it, the pointer inside its drawn body. */
const R = globalThis.__SP_VIEW__.raw, app = R.debug.app, rd = app.renderer, gl = rd.gl, res = rd.resolution; const assertHeld = (g, ptr, label) => {
const keyOf = new Map([...R.debug.views].map(([k, v]) => [v, k])); assert.ok(g.lift > 0, `${label}: lifted`);
const views = [...R.debug.views].filter(([k, v]) => v.pickShape && v.info && v.info.kind !== 'item' && v.info.kind !== 'device' assert.ok(Math.abs(g.x - ptr.x) < 1.5 && Math.abs(g.y - ptr.y - 0.45 * g.s) < 1.5, `${label}: feet 0.45 tile below the pointer (feet ${g.x.toFixed(1)},${g.y.toFixed(1)}, pointer ${ptr.x.toFixed(1)},${ptr.y.toFixed(1)}, s ${g.s.toFixed(1)})`);
&& v.alive && v.spineReady && !v.culled && (ph === 'PREP' ? k.startsWith('p:') : true)); if (g.model) {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; const m = g.model, k = (ptr.y - m.y0) / (m.y1 - m.y0);
for (const [, v] of views) { const s = v.screen.s; x0 = Math.min(x0, v.screen.x - s); x1 = Math.max(x1, v.screen.x + s); y0 = Math.min(y0, v.screen.y - 2 * s); y1 = Math.max(y1, v.screen.y + 0.3 * s); } assert.ok(ptr.x > m.x0 && ptr.x < m.x1 && k > 0.2 && k < 0.8, `${label}: the pointer on the drawn model (${JSON.stringify(m)}, k ${k.toFixed(2)})`);
x0 = Math.max(0, Math.floor(x0)); y0 = Math.max(0, Math.floor(y0)); }
x1 = Math.min(rd.view.width / res - 1, Math.ceil(x1)); y1 = Math.min(rd.view.height / res - 1, Math.ceil(y1)); };
const w = Math.floor((x1 - x0) * res), h = Math.floor((y1 - y0) * res), H = rd.view.height;
const all = [...R.debug.views.values()];
const saved = all.map((v) => [v, v.root?.visible, v.hud?.visible, v.shadow?.visible]);
for (const v of all) { if (v.hud) v.hud.visible = false; if (v.shadow) v.shadow.visible = false; if (v.root) v.root.visible = false; }
const read = () => { R.debug.ctx.impostors?.flush?.(); rd.render(app.stage); const px = new Uint8Array(w * h * 4); gl.readPixels(Math.floor(x0 * res), H - Math.floor(y0 * res) - h, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); return px; };
const bg = read();
const masks = [];
for (const [k, v] of views) {
v.root.visible = true; const a = read(); v.root.visible = false;
const m = new Uint8Array(w * h);
for (let i = 0; i < w * h; i++) { const j = i * 4; if (a[j + 3] > 40 && Math.abs(a[j] - bg[j]) + Math.abs(a[j + 1] - bg[j + 1]) + Math.abs(a[j + 2] - bg[j + 2]) + Math.abs(a[j + 3] - bg[j + 3]) > 40) m[i] = 1; }
masks.push({ k, m, z: v.root.zIndex });
}
for (const [v, a, b, c] of saved) { if (v.root) v.root.visible = a; if (v.hud) v.hud.visible = b; if (v.shadow) v.shadow.visible = c; }
const pick = (x, y) => { if (ph === 'PREP') { const p = R.debug.pick.pieceAt(x, y); return p ? 'p:' + p.uid : null; } const v = R.debug.pick.battleUnitAt(x, y); return v ? keyOf.get(v) : null; };
let n = 0, ok = 0, wrong = 0;
for (let py = 0; py < h; py += 4) for (let px = 0; px < w; px += 4) {
const i = (h - 1 - py) * w + px;
let truth = null, tz = -Infinity;
for (const mk of masks) if (mk.m[i] && mk.z > tz) { tz = mk.z; truth = mk.k; }
if (!truth) continue;
const got = pick(x0 + px / res, y0 + py / res);
n++;
if (got === truth) ok++; else if (got) wrong++;
}
return { units: views.length, n, ok: ok / n, wrong: wrong / n };
}, phase);
const prep = await measure('PREP');
assert.ok(prep.units >= 10 && prep.n > 3000, `premise: a full prep board (${JSON.stringify(prep)})`);
assert.ok(prep.ok > 0.95 && prep.wrong < 0.015, `prep: presses on a drawn unit pick it (${JSON.stringify(prep)})`);
await setPhase(page, 'COMBAT');
await sleep(3000);
const battle = await measure('COMBAT');
assert.ok(battle.units >= 10 && battle.n > 3000, `premise: a battle (${JSON.stringify(battle)})`);
assert.ok(battle.ok > 0.93 && battle.wrong < 0.03, `battle: presses on a drawn unit pick it (${JSON.stringify(battle)})`);
await page.close();
assert.deepEqual(problems, []);
});
test('7: a dragged unit stands on the pointer; the drop and its direction wheel are that tile', async () => { test('#4.1 drag (mouse): the unit is held under the pointer; target, tileHover and the direction wheel are the pointer\'s tile', async () => {
const { page, problems } = await open('phase=PREP'); const { page, problems } = await open('phase=PREP');
const hand = await page.evaluate(() => globalThis.__MOCK__.S().priv.hand.find((x) => x && x.kind === 'chess').uid); const hand = await page.evaluate(() => { const p = globalThis.__MOCK__.S().priv; const i = p.hand.findIndex((x) => x && x.kind === 'chess'); return { uid: p.hand[i].uid, idx: i }; });
const from = await page.evaluate((u) => window.__t.at('p:' + u, 0.5), hand); await page.evaluate(() => { window.__hover = []; globalThis.__SP_VIEW__.raw.on('tileHover', (t) => window.__hover.push(t && [t.row, t.col])); });
const to = await page.evaluate(() => globalThis.__SP_VIEW__.raw.tileScreen(9, 4)); // an occupied melee tile: a swap const from = await tileSpot(page, 7, hand.idx);
const to = await tileSpot(page, 9, 4, 0.3, 0.4); // an occupied melee tile (a swap), off its centre
await page.mouse.move(from.x, from.y); await page.mouse.down(); await page.mouse.move(from.x, from.y); await page.mouse.down();
for (let i = 1; i <= 10; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 10, from.y + ((to.y - from.y) * i) / 10); await sleep(16); } for (let i = 1; i <= 12; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 12, from.y + ((to.y - from.y) * i) / 12); await sleep(16); }
await sleep(150); await sleep(150);
const ghost = await page.evaluate((u) => { const v = globalThis.__SP_VIEW__.raw.debug.views.get('p:' + u); return { x: v.x, y: v.y, lift: v.lift }; }, hand); assertHeld(await ghostAt(page, hand.uid), to, 'mouse');
assert.ok(Math.abs(ghost.x - 4) < 0.06 && Math.abs(ghost.y - 9) < 0.06 && ghost.lift > 0, `the ghost stands on the pointer's tile (${ghost.x}, ${ghost.y})`); assert.deepEqual(await page.evaluate(() => window.__hover.at(-1)), [9, 4], 'tileHover = the tile under the pointer');
await page.screenshot({ path: path.join(OUT, 'models-drag-ghost.png') }); await page.screenshot({ path: path.join(OUT, 'models-drag-ghost.png') });
// the whole way: the model stays under the pointer (sampled at every step of a second drag leg)
const leg = await tileSpot(page, 10, 5);
for (let i = 1; i <= 6; i++) {
const pt = { x: to.x + ((leg.x - to.x) * i) / 6, y: to.y + ((leg.y - to.y) * i) / 6 };
await page.mouse.move(pt.x, pt.y);
await sleep(40);
assertHeld(await ghostAt(page, hand.uid), pt, `mouse step ${i}`);
}
await page.mouse.move(to.x, to.y);
await sleep(60);
await page.mouse.up(); await page.mouse.up();
await page.waitForSelector('.fwheel__dia', { timeout: 3000 }); await page.waitForSelector('.fwheel__dia', { timeout: 3000 });
const w = await page.evaluate(() => { const r = document.querySelector('.fwheel__dia').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }); const w = await page.evaluate(() => { const r = document.querySelector('.fwheel__dia').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
assert.ok(Math.hypot(w.x - to.x, w.y - to.y) < 2, 'the wheel opens on that tile'); const c = await tileSpot(page, 9, 4);
assert.ok(Math.hypot(w.x - c.x, w.y - c.y) < 2, `the wheel opens on the pointer's tile (${w.x},${w.y} vs ${c.x},${c.y})`);
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await page.close(); await page.close();
assert.deepEqual(problems, []); assert.deepEqual(problems, []);
}); });
// review of item 7: a touch drag holds its ghost (and drop point) 0.6 tile above the finger. On a phone the bench is test('#4.1 equipment dropped on a tile equips the unit on it (the one behind, a bench operator); an empty tile takes nothing', async () => {
// the lowest canvas row with the shop bar right under it, so putting a unit back on the bench has the finger on the const { page, problems } = await open('phase=PREP');
// shop bar: the drop is judged where the ghost is, not under the finger (it used to send the piece back). const S = () => page.evaluate(() => { const p = globalThis.__MOCK__.S().priv; return { board: p.board.map((x) => ({ uid: x.uid, row: x.row, col: x.col, items: x.items.map((i) => i.uid) })), hand: p.hand.map((x) => x && { uid: x.uid, kind: x.kind, items: (x.items || []).map((i) => i.uid) }) }; });
test('7: touch — a board unit dragged back onto a bench slot above the shop bar lands there (phone)', async () => { // three items in the hand (slots 0, 1, 4 are free / an item in the mock)
await page.evaluate(() => globalThis.__MOCK__.mutate((M) => {
const src = M.priv.hand.find((x) => x && x.kind === 'item');
for (const i of [0, 1]) if (!M.priv.hand[i]) M.priv.hand[i] = { ...src, uid: 900 + i };
}));
await sleep(800);
let st = await S();
const items = st.hand.map((x, i) => x && x.kind === 'item' && { uid: x.uid, idx: i }).filter(Boolean);
const target = st.board.find((b) => b.row === 10 && b.col === 4), front = st.board.find((b) => b.row === 9 && b.col === 4);
const benchIdx = st.hand.findIndex((x) => x && x.kind === 'chess');
assert.ok(items.length >= 3 && target && front && benchIdx >= 0, 'premise');
const dragTo = async (it, to) => {
const from = await tileSpot(page, 7, it.idx);
await page.mouse.move(from.x, from.y); await page.mouse.down();
for (let i = 1; i <= 10; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 10, from.y + ((to.y - from.y) * i) / 10); await sleep(16); }
await sleep(120);
await page.mouse.up();
await sleep(700);
};
// (10,4) behind (9,4): the tile of the one behind — near its front edge, where the front one's head is drawn
await dragTo(items[0], await tileSpot(page, 10, 4, 0, -0.6));
st = await S();
assert.ok(st.board.find((b) => b.uid === target.uid).items.includes(items[0].uid), 'equipped on the unit behind');
assert.ok(!st.board.find((b) => b.uid === front.uid).items.includes(items[0].uid), 'not on the one in front');
await dragTo(items[1], await tileSpot(page, 7, benchIdx, 0.4, 0.4));
st = await S();
assert.ok(st.hand[benchIdx].items.includes(items[1].uid), 'equipped on the bench operator');
// the empty high-ground tile behind (12,4)? use a free deploy tile: the item stays in the hand
const free = await page.evaluate(() => { const S = globalThis.__MOCK__.S(), used = new Set(S.priv.board.map((b) => `${b.row},${b.col}`)); return S.stage.deployTiles.normal.melee.find(([r, c]) => !used.has(`${r},${c}`)); });
await dragTo(items[2], await tileSpot(page, free[0], free[1]));
st = await S();
assert.ok(st.hand.some((x) => x && x.uid === items[2].uid), 'an empty tile takes nothing: the item is back in the hand');
await page.close();
assert.deepEqual(problems, []);
});
// a phone (touch, landscape 844×390): the finger holds the model like the mouse (no lift above the finger); on the
// bench (the lowest canvas row, the shop bar right under it) a release on the slot lands there, one on the shop bar
// goes back
test('#4.1 touch (phone): held under the finger, dropped on the tile under it; a release over the shop bar goes back', async () => {
const page = await browser.newPage(); const page = await browser.newPage();
const problems = []; const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)); page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
await page.setViewport({ width: 844, height: 390, deviceScaleFactor: 2, isMobile: true, hasTouch: true, isLandscape: true }); await page.setViewport({ width: 844, height: 390, deviceScaleFactor: 2, isMobile: true, hasTouch: true, isLandscape: true });
await page.goto(`http://127.0.0.1:${srv.port}/dev/game-mock.html?shot=1&render=engine&phase=PREP`, { waitUntil: 'networkidle0' }); await page.goto(`http://127.0.0.1:${srv.port}/dev/game-mock.html?shot=1&render=engine&phase=PREP`, { waitUntil: 'networkidle0' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__?.raw?.debug, { timeout: 30000 }); await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__?.raw?.debug, { timeout: 30000 });
await page.evaluate(installHelpers);
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.hand[5] = null; })); await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.hand[5] = null; }));
await sleep(2000); await sleep(2000);
const g = await page.evaluate(() => { const touchDrag = async (from, to, { hold = null } = {}) => {
const V = globalThis.__SP_VIEW__.raw, cv = V.debug.app.view.getBoundingClientRect(); await page.touchscreen.touchStart(from.x, from.y);
const u = globalThis.__MOCK__.S().priv.board[0], v = V.debug.views.get('p:' + u.uid), t = V.tileScreen(7, 5); for (let i = 1; i <= 12; i++) { await page.touchscreen.touchMove(from.x + ((to.x - from.x) * i) / 12, from.y + ((to.y - from.y) * i) / 12); await sleep(16); }
const finger = { x: t.x, y: t.y + 0.6 * t.s }; await sleep(100);
const el = document.elementFromPoint(finger.x, finger.y); if (hold) await hold();
return { uid: u.uid, from: { x: cv.left + v.screen.x, y: cv.top + v.screen.y - 0.5 * v.screen.s }, finger, onDom: !!el && el !== V.debug.app.view }; await page.touchscreen.touchEnd();
}); await sleep(800);
assert.ok(g.onDom, 'premise: the finger is on the DOM (shop bar) below the bench slot'); };
await page.touchscreen.touchStart(g.from.x, g.from.y); // a tap on a unit's tile selects it — and only that: the tap's compatibility click used to land on the underframe
for (let i = 1; i <= 12; i++) { await page.touchscreen.touchMove(g.from.x + ((g.finger.x - g.from.x) * i) / 12, g.from.y + ((g.finger.y - g.from.y) * i) / 12); await sleep(16); } // that had just opened over the tile (clamped under the top bar for rows 11–12) and pressed 撤退 (the unit left)
await sleep(100); for (const row of [9, 10, 11, 12]) {
await page.touchscreen.touchEnd(); const u = await page.evaluate((r) => { const b = globalThis.__MOCK__.S().priv.board.find((p) => p.row === r && p.col === 4); return b && { uid: b.uid, n: globalThis.__MOCK__.S().requests.length }; }, row);
await sleep(800); assert.ok(u, `premise: a unit on (${row},4)`);
assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().priv.hand[5]?.uid ?? null), g.uid, 'the unit is on bench slot 5'); const t = await tileSpot(page, row, 4);
await page.touchscreen.tap(t.x, t.y);
await sleep(600);
const s = await page.evaluate((u) => ({ onBoard: globalThis.__MOCK__.S().priv.board.some((p) => p.uid === u.uid), uframe: !!document.querySelector(`.uframe[data-uid="${u.uid}"]`), sent: globalThis.__MOCK__.S().requests.slice(u.n).map((r) => r[0]).filter((q) => q !== 'g.unitStats') }), u);
assert.deepEqual(s, { onBoard: true, uframe: true, sent: [] }, `a tap on (${row},4) selects its unit, nothing else`);
await page.keyboard.press('Escape');
await sleep(200);
}
// a bench operator onto the board: held under the finger, the wheel on the finger's tile
const hand = await page.evaluate(() => { const p = globalThis.__MOCK__.S().priv; const i = p.hand.findIndex((x) => x && x.kind === 'chess'); return { uid: p.hand[i].uid, idx: i }; });
const to = await tileSpot(page, 9, 4, -0.3, 0.3);
await touchDrag(await tileSpot(page, 7, hand.idx), to, { hold: async () => assertHeld(await ghostAt(page, hand.uid), to, 'touch') });
await page.waitForSelector('.fwheel__dia', { timeout: 3000 });
const w = await page.evaluate(() => { const r = document.querySelector('.fwheel__dia').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
const c = await tileSpot(page, 9, 4);
assert.ok(Math.hypot(w.x - c.x, w.y - c.y) < 2, 'the wheel opens on the finger\'s tile');
await page.evaluate(() => document.querySelector('.fwheel__cancel')?.click());
await sleep(500);
// a board unit back onto the free bench slot 5: the finger on the slot → lands; on the shop bar below it → back
const unit = await page.evaluate(() => { const u = globalThis.__MOCK__.S().priv.board[0]; return { uid: u.uid, row: u.row, col: u.col }; });
const slot = await tileSpot(page, 7, 5);
// the first point below the slot's centre that the shop bar covers (it overlaps the lower half of bench slots 4–9)
const bar = await page.evaluate((p) => {
const cv = globalThis.__SP_VIEW__.raw.debug.app.view;
for (let dy = 2; dy < 80; dy += 2) { const el = document.elementFromPoint(p.x, p.y + dy); if (el && el !== cv && el.closest('.shopbar')) return { x: p.x, y: p.y + dy + 3 }; }
return null;
}, slot);
assert.ok(bar, 'premise: the shop bar covers the canvas right below the bench slot');
await touchDrag(await tileSpot(page, unit.row, unit.col), bar);
assert.ok(await page.evaluate((u) => globalThis.__MOCK__.S().priv.board.some((b) => b.uid === u), unit.uid), 'released over the shop bar: back on the board');
await touchDrag(await tileSpot(page, unit.row, unit.col), slot);
assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().priv.hand[5]?.uid ?? null), unit.uid, 'released on the bench slot: it is there');
await page.screenshot({ path: path.join(OUT, 'models-touch-phone.png') });
await page.close(); await page.close();
assert.deepEqual(problems, []); assert.deepEqual(problems, []);
}); });
+130 -177
View File
@@ -1,206 +1,159 @@
// test/render/pick.test.js — render/pick.js, the one picking rule of every "which unit is under the pointer" path // test/render/pick.test.js — render/pick.js, the one picking rule of every "which unit is under the pointer" path (user
// (user playtest #3 item 7: "clicking an operator often selects the one a row below it; equipment too"), checked on the // playtest #4 item 1: "地上都画好了一个一个方格,点击对应方格就选中那个方格的人物就行" — a press on a tile selects the unit on
// geometry of the official cameras (render/projection.js presetCamera: prep / battle at 1920×1080 and 1280×720): a unit // that tile). Checked on the geometry of the official cameras (render/projection.js presetCamera + pickTile: prep and
// B standing directly behind a unit A (same column, adjacent rows; B on the same height or on a raised tile). // battle at 1920×1080 and 1280×720, raised tops first): units in adjacent rows and columns, on raised high ground, on
// the bench (row 7) and the temporary bench (row 8); and the battle rule for walking / flying enemies (ground position,
// drawn body).
import { test, describe } from 'node:test'; import { test, describe } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { presetCamera } from '../../public/js/render/projection.js'; import { presetCamera, pickTile } from '../../public/js/render/projection.js';
import { pickBody, hitBody, coreDistance, bodyBounds, bodyParts, BODY_H, probeCandidates, PROBE_MARGIN } from '../../public/js/render/pick.js'; import { pickOnTile, pickBattle, ENEMY_REACH } from '../../public/js/render/pick.js';
import { unitDepthKey } from '../../public/js/render/units.js'; import { unitDepthKey } from '../../public/js/render/units.js';
import { TILE_H } from '../../public/js/render/style.js';
const COL = 4; import { GEO } from '../../shared/constants.js';
/** A / B bodies on a camera: A at row 9 (height zA), B at row 10 (height zB), model heights hA / hB. */
function pair(kind, W, H, { zA = 0, zB = 0, hA = BODY_H, hB = BODY_H } = {}) {
const cam = presetCamera(kind, { width: W, height: H });
const body = (key, row, z, h) => { const p = cam.project(COL, row, z); return { key, x: p.x, y: p.y, s: p.s, h, flip: 1, depth: unitDepthKey(cam, COL, row), tile: { row, col: COL } }; };
const A = body('A', 9, zA, hA), B = body('B', 10, zB, hB);
const heights = new Map([[`9,${COL}`, zA], [`10,${COL}`, zB]]);
// the tile under a screen point, raised tops first (render/projection.js pickTile semantics)
const ground = (x, y) => {
for (const hz of [...new Set([zA, zB, 0])].sort((a, b) => b - a)) {
const g = cam.unproject(x, y, hz);
if (!g) continue;
const t = { row: Math.round(g.y), col: Math.round(g.x) };
if (hz === 0 || Math.abs((heights.get(`${t.row},${t.col}`) || 0) - hz) < 1e-6) return t;
}
return null;
};
/** Who a press `up` tiles above the feet of `u` (and `dx` tiles to the side) picks. */
const at = (u, up, dx = 0, bodies = [A, B]) => { const x = u.x + dx * u.s, y = u.y - up * u.s; return pickBody(bodies, x, y, ground(x, y))?.key ?? null; };
return { cam, A, B, at, rowGap: (A.y - B.y) / A.s };
}
const CAMS = [['prep', 1920, 1080], ['prep', 1280, 720], ['normal', 1920, 1080], ['normal', 1280, 720]]; const CAMS = [['prep', 1920, 1080], ['prep', 1280, 720], ['normal', 1920, 1080], ['normal', 1280, 720]];
describe('pickBody on the official cameras: B directly behind A', () => { /** A board on camera `kind`: tile heights (`raised` = [[r, c, z]…]), units standing on tiles, the ground under a point. */
function board(kind, W, H, raised = []) {
const cam = presetCamera(kind, { width: W, height: H });
const hz = new Map(raised.map(([r, c, z]) => [`${r},${c}`, z]));
const heightAt = (r, c) => hz.get(`${r},${c}`) || 0;
const levels = [...new Set([0, ...raised.map((x) => x[2])])];
const ground = (x, y) => { const t = pickTile(cam, x, y, heightAt, levels); return t && { row: t.row, col: t.col, x: t.x, y: t.y }; };
const unit = (key, row, col) => ({ key, tile: { row, col }, x: col, y: row, depth: unitDepthKey(cam, col, row) });
/** Screen point of a spot on the top face of tile (row, col): (u, v) ∈ [−0.5, 0.5] across / along the tile. */
const spot = (row, col, u = 0, v = 0) => cam.project(col + u, row + v, heightAt(row, col));
return { cam, heightAt, ground, unit, spot };
}
/** Spots inside a tile's top face: the centre and near its four edges and corners (0.4 of a tile out). */
const SPOTS = [[0, 0], [-0.4, 0], [0.4, 0], [0, -0.4], [0, 0.4], [-0.4, -0.4], [0.4, 0.4], [-0.4, 0.4], [0.4, -0.4]];
describe('pickOnTile: a press on a tile selects the unit on that tile (official cameras)', () => {
for (const [kind, W, H] of CAMS) { for (const [kind, W, H] of CAMS) {
test(`${kind} ${W}×${H}, same height: B's head / face / torso / legs / tile beside A are B's; A's face, torso and feet are A's`, () => { test(`${kind} ${W}×${H}: a 3×3 block of units, the back row on high ground — every spot of a tile is its unit's`, () => {
const { at, A, B, rowGap } = pair(kind, W, H); // rows 9–11 × cols 3–5; row 11 raised like the ranged high ground (TILE_H.wall)
assert.ok(rowGap > 0.8 && rowGap < 0.95, `rows ${rowGap.toFixed(2)} of a tile apart on screen (test premise)`); const B = board(kind, W, H, [[11, 3, TILE_H.wall], [11, 4, TILE_H.wall], [11, 5, TILE_H.wall]]);
assert.ok(BODY_H > rowGap, 'a chibi is taller than a row: A covers B\'s feet (test premise)'); const units = [];
for (const up of [1.2, 0.86, 0.6, 0.45, 0.3]) assert.equal(at(B, up), 'B', `B ${up} tile above its feet`); for (let r = 9; r <= 11; r++) for (let c = 3; c <= 5; c++) units.push(B.unit(`${r},${c}`, r, c));
for (const dx of [-0.4, 0.4]) assert.equal(at(B, 0, dx), 'B', `B's tile beside A's head (${dx})`); for (const u of units) {
for (const up of [0.86, 0.6, 0.3, 0.05, -0.1]) assert.equal(at(A, up), 'A', `A ${up} tile above its feet`); for (const [du, dv] of SPOTS) {
// the feet of B are drawn behind A's face: that press is A's (nothing of B shows there) const p = B.spot(u.tile.row, u.tile.col, du, dv);
assert.equal(at(B, 0), 'A'); assert.equal(pickOnTile(units, B.ground(p.x, p.y))?.key, u.key, `tile ${u.key} at (${du}, ${dv})`);
// A's hair crown over B's legs / tile goes to B; with nobody behind it is A's }
assert.equal(at(A, 1.2), 'B'); }
assert.equal(at(A, 1.2, 0, [A]), 'A');
assert.equal(at(A, 0.86, 0, [A]), 'A');
}); });
test(`${kind} ${W}×${H}, B on a raised tile (ranged high ground behind a melee lane): B's feet are B's too`, () => { test(`${kind} ${W}×${H}: a press on a unit's head (drawn over the tile behind) is a press on that tile`, () => {
const { at, B, A } = pair(kind, W, H, { zB: 0.42 }); const B = board(kind, W, H);
for (const up of [1.2, 0.86, 0.45, 0.2, 0]) assert.equal(at(B, up), 'B', `B ${up} tile above its feet`); const front = B.unit('front', 9, 4), behind = B.unit('behind', 10, 4);
for (const up of [0.86, 0.45, 0.05]) assert.equal(at(A, up), 'A', `A ${up} tile above its feet`); const p = B.spot(9, 4);
const head = { x: p.x, y: p.y - 1.0 * p.s }; // a chibi is ~1.27 tiles tall, rows only ~0.85 tile apart on screen
assert.deepEqual([B.ground(head.x, head.y).row, B.ground(head.x, head.y).col], [10, 4], 'premise: the head is over row 10');
assert.equal(pickOnTile([front, behind], B.ground(head.x, head.y)).key, 'behind');
assert.equal(pickOnTile([front], B.ground(head.x, head.y)), null, 'an empty tile selects nothing');
const feet = B.spot(10, 4);
assert.equal(pickOnTile([front, behind], B.ground(feet.x, feet.y)).key, 'behind', 'its own tile, where the front one\'s head is drawn');
}); });
} }
test('the old rule would have picked A for most of B\'s lower half (the playtest report)', () => { test('the bench (row 7) and the temporary bench (row 8): raised pads, one piece per slot', () => {
// the pre-fix hit box: 0.7 tile wide, 1.18 tiles above the feet + 0.1 below, front-most wins const pads = [];
const { A, B } = pair('prep', 1920, 1080); for (let c = 0; c < GEO.HAND_SIZE; c++) pads.push([GEO.HAND_ROW, c, TILE_H.bench]);
const box = (u, x, y) => Math.abs(x - u.x) <= 0.35 * u.s && y >= u.y - 1.18 * u.s && y <= u.y + 0.1 * u.s; for (let i = 0; i < GEO.TEMP_SIZE; i++) pads.push([GEO.TEMP_ROW, GEO.TEMP_C0 + i, TILE_H.bench]);
const old = (x, y) => (box(A, x, y) ? 'A' : box(B, x, y) ? 'B' : null); for (const [W, H] of [[1920, 1080], [1280, 720], [844, 390]]) {
let wrong = 0; const B = board('prep', W, H, pads);
for (const up of [0.3, 0.2, 0.1, 0]) for (const dx of [-0.3, 0, 0.3]) if (old(B.x + dx * B.s, B.y - up * B.s) === 'A') wrong++; const units = pads.map(([r, c]) => B.unit(`${r},${c}`, r, c));
assert.ok(wrong >= 6, `old rule: ${wrong}/12 presses on B's lower body / feet went to A`); // a board unit right in front of the temp row and behind nothing
units.push(B.unit('9,5', 9, 5));
for (const u of units) {
for (const [du, dv] of SPOTS) {
const p = B.spot(u.tile.row, u.tile.col, du * 0.9, dv * 0.9);
assert.equal(pickOnTile(units, B.ground(p.x, p.y))?.key, u.key, `${W}×${H} slot ${u.key} at (${du}, ${dv})`);
}
}
}
});
test('several on one tile (pen clusters, a held piece): the nearest to the pointer\'s point, ties the front-most', () => {
const t = { row: 15, col: 9 };
const a = { key: 'a', tile: t, x: 8.8, y: 15.15, depth: 1 }, b = { key: 'b', tile: t, x: 9.2, y: 15.15, depth: 1 }, c = { key: 'c', tile: t, x: 9, y: 14.8, depth: 2 };
assert.equal(pickOnTile([a, b, c], { ...t, x: 8.75, y: 15.2 }).key, 'a');
assert.equal(pickOnTile([a, b, c], { ...t, x: 9.3, y: 15.1 }).key, 'b');
assert.equal(pickOnTile([a, b, c], { ...t, x: 9, y: 14.7 }).key, 'c');
const d = { key: 'd', tile: t, x: 9, y: 15, depth: 5 }, e = { key: 'e', tile: t, x: 9, y: 15, depth: 3 };
assert.equal(pickOnTile([e, d], { ...t, x: 9.1, y: 15 }).key, 'd', 'same spot: the front-most');
assert.equal(pickOnTile([e, d], t).key, 'd', 'no point on the tile: the front-most');
});
test('bad input: nothing', () => {
const u = { tile: { row: 1, col: 1 }, x: 1, y: 1 };
assert.equal(pickOnTile([u], null), null);
assert.equal(pickOnTile([u], { row: 1.5, col: 1 }), null);
assert.equal(pickOnTile(null, { row: 1, col: 1 }), null);
assert.equal(pickOnTile([null, { x: 1, y: 1 }, { tile: null, x: 1, y: 1 }], { row: 1, col: 1 }), null);
assert.equal(pickOnTile([u], { row: 1, col: 2 }), null);
}); });
}); });
describe('pickBody rule details', () => { describe('pickBattle: allies by their tile, walking enemies by their ground position or drawn body, flying ones by their body', () => {
const cam = presetCamera('prep', { width: 1920, height: 1080 }); const ally = (key, row, col, depth = 0) => ({ key, tile: { row, col }, x: col, y: row, depth });
const at = (row, col, extra = {}) => { const p = cam.project(col, row, 0); return { x: p.x, y: p.y, s: p.s, h: BODY_H, depth: unitDepthKey(cam, col, row), tile: { row, col }, ...extra }; }; const foe = (key, x, y, depth = 0) => ({ key, tile: null, x, y, depth });
const at = (x, y) => ({ row: Math.round(y), col: Math.round(x), x, y });
test('off every body, the unit standing on the pressed tile is picked; an empty tile picks nothing', () => { test('an ally on the tile under the pointer; an empty tile with no enemy near: nothing', () => {
const U = { key: 'U', ...at(10, 6) }; const A = ally('A', 10, 4);
const tile = cam.project(6, 10, 0); assert.equal(pickBattle([A], at(4.3, 10.4), 0, 0).key, 'A');
// the tile's corner, well away from the body assert.equal(pickBattle([A], at(4.3, 10.6), 0, 0), null, 'the tile behind');
const cx = tile.x + 0.45 * tile.s, cy = tile.y + 0.3 * tile.s; assert.equal(pickBattle([A], null, 0, 0), null, 'off the grid');
assert.equal(hitBody(U, cx, cy), 0, 'premise: not on the body');
assert.equal(pickBody([U], cx, cy, { row: 10, col: 6 }), U);
assert.equal(pickBody([U], cx, cy, { row: 10, col: 7 }), null);
assert.equal(pickBody([U], cx, cy, null), null);
}); });
test('side by side (same row): each unit keeps its own body, weapon edges go by the tile', () => { test(`an enemy within ${ENEMY_REACH} tile of the pointer's ground point, the nearest of several`, () => {
const L = { key: 'L', ...at(10, 5) }, R = { key: 'R', ...at(10, 6) }; const e1 = foe('e1', 6.3, 12.1), e2 = foe('e2', 6.9, 12.0);
for (const u of [L, R]) for (const up of [0.9, 0.5, 0.15]) assert.equal(pickBody([L, R], u.x, u.y - up * u.s, u.tile), u); assert.equal(pickBattle([e1, e2], at(6.4, 12.2), 0, 0).key, 'e1');
const mid = (L.x + R.x) / 2; assert.equal(pickBattle([e1, e2], at(6.8, 11.9), 0, 0).key, 'e2');
assert.equal(pickBody([L, R], mid - 2, L.y - 0.3 * L.s, { row: 10, col: 5 }), L); assert.equal(pickBattle([e1], at(6.3 + ENEMY_REACH - 0.01, 12.1), 0, 0).key, 'e1');
assert.equal(pickBody([L, R], mid + 2, L.y - 0.3 * L.s, { row: 10, col: 6 }), R); assert.equal(pickBattle([e1], at(6.3 + ENEMY_REACH + 0.01, 12.1), 0, 0), null, 'too far');
}); });
test('a moving unit (no tile, e.g. an enemy walking in front) is picked by its body only, front-most on cores', () => { test('an ally\'s tile and an enemy both there (a blocked enemy at the edge of the blocker\'s tile): the nearer, ties the ally', () => {
const op = { key: 'op', ...at(10, 5) }; const A = ally('A', 10, 5), e = foe('e', 5.45, 10);
const foe = { key: 'foe', ...at(9, 5), tile: null, w: 1.1, h: 1.3 }; assert.equal(pickBattle([A, e], at(5.05, 10.05), 0, 0).key, 'A', 'the tile centre: the operator');
assert.equal(pickBody([op, foe], foe.x, foe.y - 0.5 * foe.s, { row: 9, col: 5 }), foe, 'its torso'); assert.equal(pickBattle([A, e], at(5.42, 10.02), 0, 0).key, 'e', 'on the enemy');
assert.equal(pickBody([op, foe], op.x, op.y - 0.6 * op.s, { row: 10, col: 5 }), op, 'the operator above it'); assert.equal(pickBattle([e, A], at(5.225, 10), 0, 0).key, 'A', 'halfway: the ally');
// off its body on the tile it walks over: nothing stands there assert.equal(pickBattle([A, e], at(5.8, 10), 0, 0).key, 'e', 'the next tile: only the enemy is near');
assert.equal(pickBody([foe], foe.x + 0.48 * foe.s, foe.y + 0.35 * foe.s, { row: 9, col: 5 }), null);
}); });
test('the HUD (tier chip / bars) is an outline: its own unit when nothing else is there, never over a torso', () => { test('a flying enemy: its drawn body (feet to head) within the reach × its px per tile, on screen', () => {
const B = { key: 'B', ...at(10, 5) }, C = { key: 'C', ...at(11, 5) }; const f = { key: 'f', tile: null, x: 7, y: 11, fly: true, body: { x: 500, top: 260, feet: 340, s: 80 } };
const chipY = B.y - 1.3 * B.s; const A = ally('A', 11, 7);
B.hud = { x0: B.x - 12, y0: chipY - 12, x1: B.x + 12, y1: chipY + 12 }; assert.equal(pickBattle([f], null, 510, 330).key, 'f', 'off any tile under it');
assert.equal(pickBody([B], B.x, chipY, null), B, 'alone: the chip is its unit\'s'); assert.equal(pickBattle([f], null, 505, 265).key, 'f', 'its head');
assert.equal(hitBody(C, B.x, chipY), 2, 'premise: C\'s legs are behind B\'s chip'); assert.equal(pickBattle([f], at(7, 11), 500 + ENEMY_REACH * 80 + 2, 300), null, 'the ground position does not count');
assert.equal(pickBody([B, C], B.x, chipY, { row: 11, col: 5 }), C); assert.equal(pickBattle([f], null, 500, 340 + ENEMY_REACH * 80 + 2), null, 'below its feet, beyond the reach');
assert.equal(pickBattle([f, A], at(7.4, 11.3), 505, 305).key, 'f', 'on the flyer, over an ally\'s tile');
assert.equal(pickBattle([f, A], at(7.02, 11), 500, 340 + 0.2 * 80).key, 'A', 'the ally\'s tile centre, the flyer 0.2 tile away: the ally is nearer');
}); });
test('model height, width factor and facing shape the outline', () => { test('a walking enemy: its drawn body counts too — a press on a tall enemy\'s / a boss\'s torso or head picks it', () => {
const tall = { ...at(10, 5), h: 1.45 }, short = { ...at(10, 5), h: 1.1 }; // a boss 2.4 tiles tall standing at (8, 12): its torso is drawn ~1.2 tiles above its feet, over the ground ~1 tile
const topOf = (b) => bodyBounds(b).y; // behind it — beyond the ground reach, on its body
assert.ok(topOf(tall) < topOf(short), 'taller model → higher outline'); const boss = { key: 'boss', tile: null, x: 8, y: 12, body: { x: 600, top: 600 - 2.4 * 90, feet: 600, s: 90 } };
assert.ok(Math.abs(topOf(tall) - (tall.y - 1.45 * tall.s)) < 1, 'the outline tops out at the model height'); assert.equal(pickBattle([boss], at(8.05, 13.1), 605, 600 - 1.2 * 90).key, 'boss', 'its torso');
const big = { ...at(10, 5), h: 2.4, w: 2 }; assert.equal(pickBattle([boss], at(8, 13.8), 598, 600 - 2.3 * 90).key, 'boss', 'its head');
assert.ok(bodyBounds(big).width > 1.9 * bodyBounds(short).width, 'enemies scale with their size'); assert.equal(pickBattle([boss], at(8.3, 12.2), 700, 610).key, 'boss', 'at its feet (the ground rule)');
const right = bodyParts({ ...at(10, 5), flip: 1 }).outline[1].rect, left = bodyParts({ ...at(10, 5), flip: -1 }).outline[1].rect; assert.equal(pickBattle([boss], at(8, 14.5), 600 + 0.7 * 90, 600 - 1.2 * 90), null, 'beside its body, beyond the reach');
const x = at(10, 5).x; // an ally on the tile its head is drawn over: the ally's tile centre stays the ally's, the head the enemy's
assert.ok(x - right.x0 > right.x1 - x && left.x1 - x > x - left.x0, 'the back side (hair, capes) is the wider one'); const e = { key: 'e', tile: null, x: 5, y: 10, body: { x: 400, top: 400 - 1.2 * 80, feet: 400, s: 80 } };
const A = ally('A', 11, 5);
assert.equal(pickBattle([A, e], at(5, 11), 400 + 0.3 * 80, 400 - 1.1 * 80).key, 'A', 'the ally\'s tile centre, 0.3 tile beside the head');
assert.equal(pickBattle([A, e], at(5, 10.6), 400, 400 - 1.0 * 80).key, 'e', 'on the enemy\'s head (over the near edge of that tile)');
}); });
test('avatar diamonds and item plates', () => { test('bad input: nothing', () => {
const d = { kind: 'diamond', ...at(10, 5), d: 0.78 }; assert.equal(pickBattle(null, at(1, 1), 0, 0), null);
const centre = d.y - (0.08 + 0.39) * d.s; assert.equal(pickBattle([null, foe('x', NaN, 1), { key: 'y', tile: null, x: 1, y: 1, fly: true, body: { x: 1, top: 0, feet: 1, s: 0 } }], at(1, 1), 1, 1), null);
assert.equal(hitBody(d, d.x, centre), 2);
assert.equal(hitBody(d, d.x + 0.33 * d.s, centre), 1, 'near its tip');
assert.equal(hitBody(d, d.x + 0.45 * d.s, centre), 0);
const plate = { kind: 'plate', x: 500, y: 300, s: 100, r: 0.31 };
assert.equal(hitBody(plate, 500, 300), 2);
assert.equal(hitBody(plate, 528, 300), 1);
assert.equal(hitBody(plate, 540, 300), 0);
assert.deepEqual(bodyBounds(plate), { x: 469, y: 269, width: 62, height: 62 });
assert.equal(coreDistance(plate, 500, 300), 0);
assert.ok(Math.abs(coreDistance(plate, 541.7, 300) - 0.2) < 1e-6, 'px to the core / px per tile');
});
test('junk input picks nothing', () => {
const U = { key: 'U', ...at(10, 5) };
assert.equal(pickBody([U], NaN, 3), null);
assert.equal(pickBody(null, 3, 3), null);
assert.equal(pickBody([null, { x: 1, y: 1, s: 0 }, { x: NaN, y: 1, s: 5 }], 1, 1, { row: 1, col: 1 }), null);
assert.equal(hitBody(null, 0, 0), 0);
});
});
// Review of item 7: measured against the rendered pixels of the mock match (every drawn unit pixel of the prep board at
// 1920×1080), the shape rule alone put ~8 % of the presses on the wrong unit — mostly the unit BEHIND: the hair crown
// and the sides (arms, clothes, weapons) of the unit in front lie over the legs / tile of the one behind. The pixel
// probe (render/app.js drawnAt) settles every overlap by what is really drawn on top.
describe('pickBody with the pixel probe', () => {
const { A, B, cam } = pair('prep', 1920, 1080);
const ground = (x, y) => { const g = cam.unproject(x, y, 0); return g ? { row: Math.round(g.y), col: Math.round(g.x) } : null; };
/** A probe that says which bodies draw the point (and records who was asked, in order). */
const probeOf = (drawing, calls = []) => (b) => { calls.push(b.key); return drawing.includes(b.key); };
test('A\'s hair crown drawn over B\'s torso: A (the shape rule alone says B)', () => {
const x = A.x, y = A.y - 1.2 * A.s;
assert.equal(pickBody([A, B], x, y, ground(x, y))?.key, 'B', 'premise: the rule gives it to B');
const calls = [];
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf(['A', 'B'], calls))?.key, 'A');
assert.deepEqual(calls, ['A'], 'front-most first; stops at the first that draws');
});
test('B\'s legs showing beside A\'s head: B', () => {
const x = B.x + 0.3 * B.s, y = B.y - 0.3 * B.s;
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf(['B']))?.key, 'B');
});
test('A\'s arm over the tile of B, outside B\'s outline: A (a single candidate the rule did not pick)', () => {
// a point on B's tile in front of B's feet, beside A's body: the rule picks B (standing on the pressed tile)
const t = cam.project(COL, 10, 0);
const x = t.x + 0.45 * t.s, y = t.y + 0.3 * t.s;
assert.equal(ground(x, y)?.row, 10, 'premise: B\'s tile');
assert.equal(pickBody([A, B], x, y, ground(x, y))?.key, 'B');
const cand = probeCandidates([A, B], x, y);
if (cand.length === 1) assert.equal(cand[0].key, 'A');
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf(['A']))?.key, 'A');
// nothing drawn there: the rule (B's tile)
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf([]))?.key, 'B');
});
test('the probe is only asked where it can change the answer; null / throwing → the shape rule', () => {
const lone = { ...A, key: 'L' };
const calls = [];
assert.equal(pickBody([lone], lone.x, lone.y - 0.5 * lone.s, null, probeOf([], calls))?.key, 'L');
assert.deepEqual(calls, [], 'one body that the rule picks: no probe');
assert.equal(pickBody([lone], lone.x + 5 * lone.s, lone.y, null, probeOf([], calls)), null);
assert.deepEqual(calls, [], 'no body around: no probe');
const x = A.x, y = A.y - 1.2 * A.s;
assert.equal(pickBody([A, B], x, y, ground(x, y), () => null)?.key, 'B', 'cannot tell → the rule');
assert.equal(pickBody([A, B], x, y, ground(x, y), () => { throw new Error('lost context'); })?.key, 'B');
// the one in front cannot tell: the rule decides (never a unit behind an unknown one)
assert.equal(pickBody([A, B], x, y, ground(x, y), (b) => (b.key === 'A' ? null : true))?.key, 'B');
assert.equal(pickBody([A, B], x, y, ground(x, y), (b) => (b.key === 'A' ? null : true)), pickBody([A, B], x, y, ground(x, y)));
});
test('probe candidates: bodies whose outline + PROBE_MARGIN holds the point, front-most first', () => {
const r = bodyBounds({ ...A, hud: null });
assert.deepEqual(probeCandidates([B, A], A.x, A.y - 0.9 * A.s).map((b) => b.key), ['A', 'B']);
assert.deepEqual(probeCandidates([A], r.x - (PROBE_MARGIN - 0.05) * A.s, A.y - 0.5 * A.s).map((b) => b.key), ['A']);
assert.deepEqual(probeCandidates([A], r.x - (PROBE_MARGIN + 0.05) * A.s, A.y - 0.5 * A.s), []);
assert.deepEqual(probeCandidates([null, { x: NaN, y: 0, s: 1 }], 0, 0), []);
}); });
}); });
+15 -60
View File
@@ -195,65 +195,22 @@ describe('field view teardown (app.js releaseGl)', () => {
}); });
}); });
// user playtest #3 item 7: the body as drawn, for render/pick.js (the shared picking rule) // user playtest #4 item 1: picking is by tile (render/pick.js); bounds() is the body's screen rect for tooltips / overlays
describe('pickShape / bounds / hitTest', () => { describe('bounds (view.pieceScreenRect)', () => {
test('the avatar diamond until the Spine model shows, then the chibi at its own measured height', async () => { test('a unit: 0.7 tile wide, from its head (UNIT.headroom) to just below its feet; an enemy by its model height', async () => {
const v = view({ defId: 'char_p1', avatar: 'char_p1' }, { prep: true }, store({ spine: true, imageDelay: 50 })); const v = view({ defId: 'char_p1', avatar: 'char_p1' }, { prep: true });
v.update(1 / 60, cam(), 0); v.update(1 / 60, cam(), 0);
assert.equal(v.pickShape().kind, 'diamond', 'no model yet'); const { x, y, s } = v.screen;
await tick(); await tick();
assert.ok(v.spineReady);
// the posed skeleton's bounds: 425.6 units above the feet = 1.33 tiles (UNIT.modelScale 1/320)
v.actor.spine.getLocalBounds = () => ({ x: -60, y: -425.6, width: 120, height: 430 });
v.update(1 / 60, cam(), 1 / 60);
const b = v.pickShape();
assert.equal(b.kind, 'chibi');
assert.ok(Math.abs(b.h - 1.33) < 1e-6, `measured height ${b.h}`);
assert.equal(b.w, 1, 'operators: the measured width profile');
assert.equal(b.x, v.screen.x); assert.equal(b.y, v.screen.y); assert.equal(b.s, v.screen.s);
assert.equal(b.depth, v.root.zIndex);
// measured once: a later pose (a raised weapon) does not move the outline
v.actor.spine.getLocalBounds = () => ({ x: -60, y: -700, width: 120, height: 700 });
v.update(1 / 60, cam(), 2 / 60);
assert.ok(Math.abs(v.pickShape().h - 1.33) < 1e-6);
// the outline tops out at the model; the tier chip (prep) is part of the pick shape, not of bounds()
const r = v.bounds(); const r = v.bounds();
assert.ok(Math.abs(r.y - (v.screen.y - 1.33 * v.screen.s)) < 1, 'bounds top = model top'); assert.ok(Math.abs(r.x - (x - 0.35 * s)) < 1e-6 && Math.abs(r.width - 0.7 * s) < 1e-6);
assert.ok(Math.abs(r.y + r.height - (v.screen.y + 0.08 * v.screen.s)) < 1, 'bounds bottom = just below the feet'); assert.ok(Math.abs(r.y - (y - 1.18 * s)) < 1e-6 && Math.abs(r.y + r.height - (y + 0.1 * s)) < 1e-6);
assert.ok(b.hud && b.hud.y1 < v.screen.y - v.screen.s, 'the chip is up at the head (UNIT.headroom)'); assert.equal(typeof v.pickShape, 'undefined', 'no hit shapes any more');
assert.ok(v.hitTest(v.screen.x, v.screen.y - 0.5 * v.screen.s), 'the torso');
assert.ok(!v.hitTest(v.screen.x + 0.6 * v.screen.s, v.screen.y - 0.5 * v.screen.s), 'beside it');
// a new model (Front ⇄ Back swap, _dropActor) is measured again
v._dropActor();
assert.equal(v._bodyH, null);
});
test('implausible skeleton bounds are clamped; enemies scale their width with their size', async () => {
const op = view({ defId: 'char_p2', avatar: 'char_p2' }, { prep: true }, store({ spine: true }));
await tick(); await tick();
op.actor.spine.getLocalBounds = () => ({ x: -500, y: -1200, width: 1000, height: 1200 }); // an effect in the idle pose
op.update(1 / 60, cam(), 0);
assert.equal(op.pickShape().h, 1.55, 'operators ≤ 1.55 tiles');
const foe = view({ side: 'enemy', kind: 'enemy', defId: 'enemy_big' }, {}, store({ spine: true })); const foe = view({ side: 'enemy', kind: 'enemy', defId: 'enemy_big' }, {}, store({ spine: true }));
await tick(); await tick(); await tick(); await tick();
foe.actor.spine.getLocalBounds = () => ({ x: -300, y: -640, width: 600, height: 640 }); foe.actor.entry.bounds = { height: 640 }; // setup-pose bounds: 2 tiles (UNIT.modelScale 1/320) × 0.92
foe.update(1 / 60, cam(), 0); foe.update(1 / 60, cam(), 0);
const b = foe.pickShape(); const fr = foe.bounds();
assert.ok(Math.abs(b.h - 2) < 1e-6); assert.ok(Math.abs(fr.y - (foe.screen.y - foe._headTiles * foe.screen.s)) < 1e-6 && foe._headTiles > 1.5, 'its own height');
assert.ok(Math.abs(b.w - 2 / 1.2) < 1e-6, 'width factor from the height');
assert.equal(b.hud, null, 'an undamaged enemy shows no bar');
});
test('a battle unit\'s bars are its HUD rect; a dead unit is not hit', async () => {
const v = view({ defId: 'char_p3', avatar: 'char_p3' }, {}, store({ spine: true }));
await tick(); await tick();
v.sync({ x: 5, y: 12, hp: 500, maxHp: 1000, sp: 3, spMax: 10, flags: 0, anim: 0, vx: 0 });
v.update(1 / 60, cam(), 0);
const hud = v.pickShape().hud;
assert.ok(hud && hud.x1 > hud.x0 && hud.y1 > hud.y0, 'HP / SP bars and chip');
assert.ok(hud.y1 < v.screen.y - v.screen.s, 'above the head');
v.die();
assert.equal(v.hitTest(v.screen.x, v.screen.y - 0.5 * v.screen.s), false);
}); });
test('item plates: floating above the slot; centred on the pointer while dragged (lifted)', async () => { test('item plates: floating above the slot; centred on the pointer while dragged (lifted)', async () => {
@@ -262,16 +219,14 @@ describe('pickShape / bounds / hitTest', () => {
const it = new ItemView(ctx, { id: 'p:9', uid: 9, defId: 'item_x', x: 3, y: 7 }); const it = new ItemView(ctx, { id: 'p:9', uid: 9, defId: 'item_x', x: 3, y: 7 });
it.setWorld(3, 7, 0.16); it.setWorld(3, 7, 0.16);
it.update(1 / 60, cam(), 0); it.update(1 / 60, cam(), 0);
let b = it.pickShape(); let r = it.bounds();
assert.equal(b.kind, 'plate'); assert.ok(Math.abs(r.y + r.height - it.screen.y) < 1e-6, 'resting: the plate above its anchor');
assert.ok(Math.abs(b.y - (it.screen.y - 0.31 * it.screen.s)) < 1e-6, 'resting: the plate above its anchor');
it.lift = 0.3; it.lift = 0.3;
it.update(1 / 60, cam(), 0); it.update(1 / 60, cam(), 0);
b = it.pickShape(); r = it.bounds();
const g = cam().project(3, 7, 0.16); const g = cam().project(3, 7, 0.16);
assert.ok(Math.abs(b.x - g.x) < 1e-6 && Math.abs(b.y - g.y) < 1e-6, 'dragged: centred on its ground point (the pointer)'); assert.ok(Math.abs(r.x + r.width / 2 - g.x) < 1e-6 && Math.abs(r.y + r.height / 2 - g.y) < 1e-6, 'dragged: centred on its ground point (the pointer)');
assert.equal(it.plate.anchor.y, 0.5); assert.equal(it.plate.anchor.y, 0.5);
assert.ok(it.hitTest(g.x, g.y));
}); });
}); });
+54 -8
View File
@@ -156,7 +156,7 @@ test('element bursts on enemies: apoptosis = 800 元素伤害/s + 50 % weaken re
assert.equal(e.s.def, 500 - 2 * ELEMENT.erosion.enemy.defDown, 'stacks, permanent'); assert.equal(e.s.def, 500 - 2 * ELEMENT.erosion.enemy.defDown, 'stacks, permanent');
}); });
test('element bursts on operators (enemy damage): burn 1200 arts, neural 1000 true + stun 10 s, apoptosis no skill / −1 SP/s / 100 arts/s, erosion 800 phys + DEF −100', () => { test('element bursts on operators (enemy damage): burn 1200 arts, neural stun 10 s then 1000 true, apoptosis 阻回 + no skill / −1 SP/s / 100 arts/s, erosion DEF −100 then 800 phys (10 s cooldown)', () => {
const op = chessRec({ id: 't_op', profession: 'WARRIOR', stats: { atk: 0, maxHp: 1e6, def: 300, res: 0, blockCnt: 0 }, skill: { spCost: 30, initSp: 20, duration: 5, bb: { atk: 0.5 } } }); const op = chessRec({ id: 't_op', profession: 'WARRIOR', stats: { atk: 0, maxHp: 1e6, def: 300, res: 0, blockCnt: 0 }, skill: { spCost: 30, initSp: 20, duration: 5, bb: { atk: 0.5 } } });
const h = makeBattle({ defs: { chess: { t_op: op } }, units: [{ chessId: 't_op', row: 9, col: 5 }], content: 'generic', autoFinish: false, timeLimit: 120 }); const h = makeBattle({ defs: { chess: { t_op: op } }, units: [{ chessId: 't_op', row: 9, col: 5 }], content: 'generic', autoFinish: false, timeLimit: 120 });
h.step(); h.step();
@@ -164,31 +164,74 @@ test('element bursts on operators (enemy damage): burn 1200 arts, neural 1000 tr
let hp = u.hp; let hp = u.hp;
h.b.dealDamage(null, u, { type: 'element', element: 'burn', amount: 1000 }); h.b.dealDamage(null, u, { type: 'element', element: 'burn', amount: 1000 });
approx(hp - u.hp, ELEMENT.burn.ally.damage); approx(hp - u.hp, ELEMENT.burn.ally.damage);
h.run(ELEMENT.burn.ally.duration + 0.1); // the burn cooldown holds every gauge
hp = u.hp; hp = u.hp;
let stunned = null;
h.b.on('damaged', (c) => { if (c.target === u && c.type === 'true' && stunned == null) stunned = !!u.s.flags.stun; });
h.b.dealDamage(null, u, { type: 'element', element: 'neural', amount: 1000 }); h.b.dealDamage(null, u, { type: 'element', element: 'neural', amount: 1000 });
approx(hp - u.hp, ELEMENT.neural.ally.damage); approx(hp - u.hp, ELEMENT.neural.ally.damage);
assert.ok(u.findBuff('stun')); assert.equal(stunned, true, 'PRTS: "立刻获得等时长的眩晕;随后受到1000点…真实伤害" — stunned before the hit');
approx(u.findBuff('stun').timeLeft, ELEMENT.neural.ally.stun); approx(u.findBuff('stun').timeLeft, ELEMENT.neural.ally.stun);
h.run(10.1); h.run(10.1);
const sp0 = u.skill.sp; const sp0 = u.skill.sp;
hp = u.hp; hp = u.hp;
h.b.dealDamage(null, u, { type: 'element', element: 'apoptosis', amount: 1000 }); h.b.dealDamage(null, u, { type: 'element', element: 'apoptosis', amount: 1000 });
assert.ok(u.s.flags.silence, 'no skill activation'); assert.ok(u.s.flags.silence, 'no skill activation');
assert.ok(u.s.flags.noSp, '阻回: no SP recovery');
h.run(3.02); h.run(3.02);
approx(hp - u.hp, 3 * ELEMENT.apoptosis.ally.dps); approx(hp - u.hp, 3 * ELEMENT.apoptosis.ally.dps);
approx(u.skill.sp, sp0 + 3 * u.s.spRecovery - 3 * ELEMENT.apoptosis.ally.spLossPerSec, 0.05); approx(u.skill.sp, sp0 - 3 * ELEMENT.apoptosis.ally.spLossPerSec, 0.05);
const spHeld = u.skill.sp;
assert.ok(spHeld < u.skill.spCost && !u.skill.active, 'room for SP (a 0 below is 阻回, not a full bar)');
assert.equal(u.skill.gainSp(5, 'talent'), 0, '阻回 ("停止并阻止任意形式的技力回复"): no granted SP');
assert.equal(u.skill.gainSp(1, 'hurt'), 0, '… nor SP from hits taken');
assert.equal(u.skill.gainSp(1, 'attack'), 0, '… nor from attacks');
assert.equal(u.skill.sp, spHeld);
h.run(12.1); h.run(12.1);
assert.ok(!u.s.flags.silence); assert.ok(!u.s.flags.silence && !u.s.flags.noSp);
hp = u.hp; hp = u.hp;
h.b.dealDamage(null, u, { type: 'element', element: 'erosion', amount: 1000 }); h.b.dealDamage(null, u, { type: 'element', element: 'erosion', amount: 1000 });
approx(hp - u.hp, ELEMENT.erosion.ally.damage - (300 - ELEMENT.erosion.ally.defDown), 1e-6, 'DEF cut first, then 800 phys'); approx(hp - u.hp, ELEMENT.erosion.ally.damage - (300 - ELEMENT.erosion.ally.defDown), 1e-6, 'DEF cut first, then 800 phys');
assert.equal(u.s.def, 300 - ELEMENT.erosion.ally.defDown); assert.equal(u.s.def, 300 - ELEMENT.erosion.ally.defDown);
assert.equal(u.elem.erosion, 0, 'no lock: accumulates again at once'); assert.equal(ELEMENT.erosion.ally.duration, 10, 'PRTS 元素: 侵蚀 爆发 10 s on operators');
assert.equal(u.elem.erosion, 1000, 'held full during its cooldown');
h.run(10.1);
assert.equal(u.elem.erosion, 0, 'reset when the cooldown ends');
h.b.dealDamage(null, u, { type: 'element', element: 'erosion', amount: 1000 });
assert.equal(u.findBuff('erosionDown').stacks, 2, 'the DEF cut stacks (permanent)');
});
test('爆发冷却 (PRTS 元素): during a burst no element fills or recovers; its end resets every gauge; 损伤抵抗 = epResistance %', () => {
const resRec = enemyRec({ key: 'enemy_res', hp: 1e6, speed: 0 });
resRec.stats.elementRes = 10;
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy(), enemy_res: resRec } },
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_res', pos: [10, 8] }], content: 'none', autoFinish: false,
});
h.step();
const e = h.enemy('enemy_dummy');
h.b.dealDamage(null, e, { type: 'element', element: 'neural', amount: 400 });
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 1000 });
assert.ok(e.findBuff('burnBurst') && e.s.flags.burstLock);
assert.equal(h.b.dealDamage(null, e, { type: 'element', element: 'neural', amount: 1000 }), 0, 'another element cannot fill during the cooldown');
approx(e.elem.neural, 400);
assert.equal(h.b.reduceElement(e, 300), 0, 'nor be recovered');
h.run(ELEMENT.burn.enemy.duration - 0.2);
assert.equal(e.elem.burn, 1000);
h.run(0.3);
assert.deepEqual([e.elem.burn, e.elem.neural, e.elem.apoptosis, e.elem.erosion], [0, 0, 0, 0], 'every gauge restored');
h.b.dealDamage(null, e, { type: 'element', element: 'neural', amount: 300 });
approx(e.elem.neural, 300, 1e-6, 'fills again after the cooldown');
// 损伤抵抗 (data elementRes → epResistance, a percentage): 受到的元素损伤 = 损伤值 × (1 − 损伤抵抗 × 0.01)
const r = h.enemy('enemy_res');
assert.equal(r.def.epResistance, 10);
h.b.dealDamage(null, r, { type: 'element', element: 'burn', amount: 500 });
approx(r.elem.burn, 450);
}); });
test('a resolving burst already locks its gauge: an elementBurst handler spreading the element back cannot re-burst (no ping-pong recursion)', () => { test('a resolving burst already locks its gauge: an elementBurst handler spreading the element back cannot re-burst (no ping-pong recursion)', () => {
// two operators that spread every burst to each other (the 淤困 parasite pattern) — burn has a lock, erosion on // two operators that spread every burst to each other (the 淤困 parasite pattern) — every burst has its cooldown
// operators has none (duration 0), apoptosis locks with a ticker: each must burst exactly once per fill // (erosion on operators too: 10 s), apoptosis locks with a ticker: each must burst exactly once per fill
const op = (id) => chessRec({ id, profession: 'WARRIOR', stats: { atk: 0, maxHp: 1e6, def: 300, res: 0, blockCnt: 0 }, skill: null }); const op = (id) => chessRec({ id, profession: 'WARRIOR', stats: { atk: 0, maxHp: 1e6, def: 300, res: 0, blockCnt: 0 }, skill: null });
for (const el of ['burn', 'erosion', 'apoptosis', 'neural']) { for (const el of ['burn', 'erosion', 'apoptosis', 'neural']) {
const h = makeBattle({ const h = makeBattle({
@@ -210,7 +253,10 @@ test('a resolving burst already locks its gauge: an elementBurst handler spreadi
if (el === 'burn') { approx(hpA - A.hp, ELEMENT.burn.ally.damage); approx(hpB - B.hp, ELEMENT.burn.ally.damage); } if (el === 'burn') { approx(hpA - A.hp, ELEMENT.burn.ally.damage); approx(hpB - B.hp, ELEMENT.burn.ally.damage); }
if (el === 'erosion') { if (el === 'erosion') {
for (const u of [A, B]) assert.equal(u.findBuff('erosionDown').stacks, 1, 'one permanent DEF cut'); for (const u of [A, B]) assert.equal(u.findBuff('erosionDown').stacks, 1, 'one permanent DEF cut');
assert.equal(A.elem.erosion, 0, 'no lock once resolved: accumulates again'); h.b.dealDamage(null, A, { type: 'element', element: 'erosion', amount: 1000 });
assert.equal(A.findBuff('erosionDown').stacks, 1, 'locked during its 10 s cooldown');
h.run(ELEMENT.erosion.ally.duration + 0.1);
assert.equal(A.elem.erosion, 0, 'reset once the cooldown ended');
h.b.dealDamage(null, A, { type: 'element', element: 'erosion', amount: 1000 }); h.b.dealDamage(null, A, { type: 'element', element: 'erosion', amount: 1000 });
assert.equal(A.findBuff('erosionDown').stacks, 2, 'a later fill bursts again'); assert.equal(A.findBuff('erosionDown').stacks, 2, 'a later fill bursts again');
} }
+103
View File
@@ -0,0 +1,103 @@
// test/sim/downelem.test.js — user playtest #4 items 8 and 9, sim side (server/sim/Battle.js snapshot / fieldMeta,
// damage.js elementView): b.snap `down` lists the knocked-out operators waiting to redeploy on their own tile (the
// client keeps them on the field knocked down with a redeploy countdown) and `elem` the element gauge each unit shows
// (PRTS 元素: the fullest gauge, its 爆发冷却) — entries, states, and what is left out.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
import { DOWN_STATE, ELEMENT, ELEMENT_ORDER } from '../../server/sim/constants.js';
import { elementView } from '../../server/sim/damage.js';
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
const op = (id, stats = {}) => chessRec({ id, profession: 'WARRIOR', stats: { atk: 0, maxHp: 1000, def: 0, blockCnt: 0, cost: 12, respawnTime: 6, ...stats }, skill: null });
const TOKEN = { name: '测试召唤物', stats: { maxHp: 100, atk: 0, def: 0, res: 0, blockCnt: 0, cost: 0, respawnTime: 0 } };
const downOf = (h, id) => (h.snapshot().down || []).find((d) => d[0] === id) || null;
test('a knocked-out operator is `down` until it redeploys: its timer, then waiting for its tile / the DP; then back', () => {
const h = makeBattle({
defs: { chess: { t_a: op('t_a'), t_b: op('t_b') } }, units: [{ chessId: 't_a', row: 9, col: 5 }, { chessId: 't_b', row: 10, col: 5 }],
content: 'none', autoFinish: false, timeLimit: 120, flags: { dpInit: 0, dpPerSec: 1, dpMax: 99 },
});
h.step();
const a = h.unit('t_a');
assert.equal(h.snapshot().down, undefined, 'no entry while everyone stands');
const t0 = h.b.time;
h.b.kill(a);
let d = downOf(h, a.id);
assert.ok(d, 'down right after the knock-out');
approx(d[1], Math.round((t0 + 6) * 100) / 100, 0.011);
approx(d[2], 6, 0.011);
assert.equal(d[3], DOWN_STATE.COUNTING);
assert.ok(h.b.fieldMeta().units.some((u) => u.id === a.id), 'a client joining now gets its UnitInfo');
h.run(6.5);
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_DP, 'timer done, 6.5 DP < cost 12');
const tok = h.b.spawnToken('p1', 'token_test', 9, 5, { def: TOKEN });
assert.ok(tok && tok.alive, 'a summon stands on its tile');
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_TILE);
h.run(7);
assert.ok(!a.alive, 'no redeploy while its tile is taken');
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_TILE);
h.b.retreat(tok, { reason: 'expired', permanent: true });
h.step();
assert.ok(a.alive && a.deployed, 'redeployed once the tile is free (DP 13.5 ≥ 12)');
assert.equal(downOf(h, a.id), null);
assert.equal(h.snapshot().down, undefined);
assert.ok(h.eventsOf('deploy').some((e) => e[1] === a.id));
});
test('summons, devices, enemies and withdrawn / removed operators are never `down`', () => {
const h = makeBattle({
defs: { chess: { t_a: op('t_a'), t_b: op('t_b'), t_c: op('t_c') }, enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 100, speed: 0 }) } },
units: [{ chessId: 't_a', row: 9, col: 5 }, { chessId: 't_b', row: 10, col: 5 }, { chessId: 't_c', row: 11, col: 5 }],
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false, timeLimit: 120,
});
h.step();
const tok = h.b.spawnToken('p1', 'token_test', 12, 5, { def: TOKEN });
h.b.kill(tok);
h.b.retreat(h.unit('t_a'), { reason: 'retreat' });
h.b.retreat(h.unit('t_b'), { reason: 'expired', permanent: true });
h.b.kill(h.enemy('enemy_dummy'));
assert.equal(h.snapshot().down, undefined);
h.b.kill(h.unit('t_c'));
assert.deepEqual(h.snapshot().down.map((d) => d[0]), [h.unit('t_c').id], 'only the knocked-out operator');
const ids = new Set(h.b.fieldMeta().units.map((u) => u.id));
assert.ok(ids.has(h.unit('t_c').id) && !ids.has(h.unit('t_a').id) && !ids.has(tok.id));
});
test('elementView: the fullest gauge (ties: the official element order), its cooldown while bursting, nothing when empty', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0 }), enemy_lead: enemyRec({ key: 'enemy_lead', hp: 1e6, speed: 0, rank: 'BOSS' }) } },
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_lead', pos: [10, 8] }], content: 'none', autoFinish: false,
});
h.step();
const e = h.enemy('enemy_dummy'), L = h.enemy('enemy_lead');
assert.equal(elementView(e, h.b.time), null);
assert.equal(h.snapshot().elem, undefined, 'no `elem` key when nothing shows');
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 300 });
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 450 });
assert.deepEqual(elementView(e, h.b.time), ['apoptosis', 0.45, 0, 0]);
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 150 });
assert.deepEqual(elementView(e, h.b.time), ['burn', 0.45, 0, 0], 'a tie goes to the lower official id (灼燃 3 < 凋亡 4)');
assert.deepEqual(ELEMENT_ORDER.slice(0, 4), ['neural', 'erosion', 'burn', 'apoptosis']);
h.b.dealDamage(null, L, { type: 'element', element: 'neural', amount: 500 });
assert.deepEqual(elementView(L, h.b.time), ['neural', 0.25, 0, 0], 'leaders hold 2000');
const snap = h.snapshot();
assert.deepEqual(snap.elem.map((x) => x[0]).sort(), [e.id, L.id].sort());
// burst: the bursting element, full, with the cooldown's end and length
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 600 });
const t = h.b.time;
const v = elementView(e, t);
assert.equal(v[0], 'apoptosis');
assert.equal(v[1], 1);
approx(v[2], Math.round((t + ELEMENT.apoptosis.enemy.duration) * 100) / 100, 0.011);
assert.equal(v[3], ELEMENT.apoptosis.enemy.duration);
h.run(5);
approx(elementView(e, h.b.time)[2], v[2], 0.05);
h.run(ELEMENT.apoptosis.enemy.duration - 4.9);
assert.equal(elementView(e, h.b.time), null, 'every gauge reset after the cooldown');
// a dead or hidden unit has no entry
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 100 });
h.b.kill(e);
assert.ok(!(h.snapshot().elem || []).some((x) => x[0] === e.id));
});
+95 -2
View File
@@ -6,7 +6,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { bgmKeyFor, resolveBgm, SfxLimiter, AudioManager } from '../../public/js/audio.js'; import { bgmKeyFor, resolveBgm, SfxLimiter, AudioManager, normalAttackSfx } from '../../public/js/audio.js';
import { PHASE } from '../../shared/constants.js'; import { PHASE } from '../../shared/constants.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
@@ -62,6 +62,16 @@ describe('SfxLimiter', () => {
assert.ok(l.tryAcquire(120, 'u1', 'z')); assert.ok(l.tryAcquire(120, 'u1', 'z'));
assert.ok(l.tryAcquire(121, null, 'w'), 'no unit key ⇒ only url gap'); assert.ok(l.tryAcquire(121, null, 'w'), 'no unit key ⇒ only url gap');
}); });
test('at most 2 overlapping copies of one sound (official banks: maxSoundAllowed 2)', () => {
const l = new SfxLimiter({ maxVoices: 99, unitCooldownMs: 0, urlGapMs: 0 });
assert.equal(l.maxPerUrl, 2);
assert.ok(l.tryAcquire(0, 'a', 'heal'));
assert.ok(l.tryAcquire(1, 'b', 'heal'));
assert.equal(l.tryAcquire(2, 'c', 'heal'), false, 'a third copy waits');
assert.ok(l.tryAcquire(2, 'c', 'other'), 'other sounds are not affected');
l.release('heal');
assert.ok(l.tryAcquire(3, 'c', 'heal'), 'one ended: room again');
});
}); });
// ---- fake Web Audio ------------------------------------------------------------------------------------- // ---- fake Web Audio -------------------------------------------------------------------------------------
@@ -130,7 +140,7 @@ describe('AudioManager', () => {
await new Promise((r) => setTimeout(r, 10)); await new Promise((r) => setTimeout(r, 10));
assert.equal(fw.made.started, before, 'prep → combat shares the loop'); assert.equal(fw.made.started, before, 'prep → combat shares the loop');
// battle events map to unit sounds (UnitInfo.spine = char id) // battle events map to unit sounds (UnitInfo.spine = char id)
const charId = Object.keys(manifest.audio.sfx.units).find((k) => manifest.audio.sfx.units[k].attack && k.startsWith('char_')); const charId = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('char_') && normalAttackSfx(k, manifest.audio.sfx.units[k].attack));
a.setFieldUnits([{ id: 1, side: 'ally', spine: charId }, { id: 2, side: 'enemy', spine: 'enemy_nope' }]); a.setFieldUnits([{ id: 1, side: 'ally', spine: charId }, { id: 2, side: 'enemy', spine: 'enemy_nope' }]);
a.handleBattleEvents([['atk', 1, 2, 'arrow'], ['dmg', 2, 100, 'phys'], ['die', 2], ['spawn', { id: 3, side: 'enemy', spine: 'x' }], ['bounty', 'p', 1]]); a.handleBattleEvents([['atk', 1, 2, 'arrow'], ['dmg', 2, 100, 'phys'], ['die', 2], ['spawn', { id: 3, side: 'enemy', spine: 'x' }], ['bounty', 'p', 1]]);
await new Promise((r) => setTimeout(r, 10)); await new Promise((r) => setTimeout(r, 10));
@@ -167,3 +177,86 @@ describe('AudioManager', () => {
} }
}); });
}); });
// user playtest #4 item 6: 纯烬艾雅法拉's skill sound rang outside her skill — her manifest `hit` is her S3 impact
// (p_imp_gtshpbrnch_s, the audio bank ON_ABILITY_HIT.attack.2) and every damage on an ally she had just healed was
// attributed to her ('atk' healer → ally), so ordinary enemy hits on healed allies played it.
describe('impact sounds (user playtest #4 item 6)', () => {
const AGOAT2 = 'char_1016_agoat2';
async function rig(units) {
const fw = fakeWindow();
const urls = [];
const origFetch = globalThis.fetch;
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
const a = new AudioManager({ win: fw.win, getManifest: () => manifest });
a.install();
fw.fire('pointerdown');
a.setFieldUnits(units);
const settle = () => new Promise((r) => setTimeout(r, 5));
return { a, urls, settle, restore: () => { globalThis.fetch = origFetch; } };
}
test('an operator never plays a skill-mode file (_d / _h / _s) as its normal attack or impact; enemies keep their _h', () => {
const u = manifest.audio.sfx.units;
// her impact: the S3 file (built before tools/assets/audio.mjs preferred normal-mode banks) is refused, the normal
// one (projectile_chr_agoat2: p_imp_gtshpbrnch_n) plays
assert.equal(normalAttackSfx(AGOAT2, '/assets/audio/sfx/player/p_imp/p_imp_gtshpbrnch_s.mp3'), false);
assert.equal(normalAttackSfx(AGOAT2, '/assets/audio/sfx/player/p_imp/p_imp_gtshpbrnch_n.mp3'), true);
if (u[AGOAT2].hit) assert.equal(normalAttackSfx(AGOAT2, u[AGOAT2].hit), !/_s\.mp3$/.test(u[AGOAT2].hit));
assert.equal(normalAttackSfx(AGOAT2, u[AGOAT2].attack), true, 'p_atk_gtshpbrnch_n');
assert.equal(normalAttackSfx('char_1014_nearl2', '/x/p_atk_goldspear_s.mp3'), false);
assert.equal(normalAttackSfx('char_1028_texas2', '/x/p_imp_reticentsword_h.mp3'), false);
assert.equal(normalAttackSfx('char_1045_svash2', '/x/p_atk_snwlprdg_n1.mp3'), true);
assert.equal(normalAttackSfx('enemy_1045_hammer', '/x/e_atk_bigaxe_h.mp3'), true, 'enemy _h = heavy weapon');
assert.equal(normalAttackSfx('char_x', null), false);
});
test('a heal never makes the healer the author of the next damage on the healed ally', async () => {
const enemyId = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('enemy_') && manifest.audio.sfx.units[k].hit);
const { a, urls, settle, restore } = await rig([
{ id: 1, side: 'ally', kind: 'chess', spine: AGOAT2 }, { id: 2, side: 'ally', kind: 'chess', spine: 'char_x' },
{ id: 3, side: 'enemy', kind: 'enemy', spine: enemyId },
]);
try {
const own = new Set(Object.values(manifest.audio.sfx.units[AGOAT2]).filter((x) => typeof x === 'string'));
a.handleBattleEvents([['atk', 1, 2, 'orb'], ['heal', 2, 300], ['dmg', 2, 120, 'phys'], ['dmg', 2, 80, 'arts']]);
await settle();
assert.ok(urls.includes(manifest.audio.sfx.units[AGOAT2].attack), 'her cast sound');
assert.ok(!urls.some((x) => x === manifest.audio.sfx.units[AGOAT2].hit), 'no impact sound of hers on the ally');
assert.ok(!urls.some((x) => own.has(x) && /_s\.mp3$/.test(x)), 'nothing of her S3');
// a hostile attack still authors its impact — once, and only for a real hit (not an element gauge fill)
a.handleBattleEvents([['atk', 3, 2, 'none'], ['dmg', 2, 900, 'burn']]);
await settle();
assert.ok(!urls.includes(manifest.audio.sfx.units[enemyId].hit), 'a gauge fill is no impact');
a.handleBattleEvents([['dmg', 2, 200, 'phys']]);
await settle();
assert.equal(urls.filter((x) => x === manifest.audio.sfx.units[enemyId].hit).length, 1, 'the impact');
a.limiter.lastByUnit.clear(); a.limiter.lastByUrl.clear();
a.handleBattleEvents([['dmg', 2, 50, 'phys']]);
await settle();
assert.equal(urls.filter((x) => x === manifest.audio.sfx.units[enemyId].hit).length, 1, 'a later tick is not the same attack\'s impact');
} finally { restore(); }
});
test('a chain bounce plays no attack sound of the previous target; a stale attack is no impact', async () => {
const enemyId = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('enemy_') && manifest.audio.sfx.units[k].attack && manifest.audio.sfx.units[k].hit);
const charId = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('char_') && normalAttackSfx(k, manifest.audio.sfx.units[k].hit) && manifest.audio.sfx.units[k].hit);
const { a, urls, settle, restore } = await rig([
{ id: 1, side: 'ally', kind: 'chess', spine: charId }, { id: 5, side: 'enemy', kind: 'enemy', spine: enemyId },
{ id: 6, side: 'enemy', kind: 'enemy', spine: enemyId },
]);
const perf = globalThis.performance;
let fakeNow = 1000;
globalThis.performance = { now: () => fakeNow };
try {
a.handleBattleEvents([['atk', 5, 6, 'chain']]);
await settle();
assert.ok(!urls.includes(manifest.audio.sfx.units[enemyId].attack), 'the bounce is not an enemy attack');
a.handleBattleEvents([['atk', 1, 5, 'arrow']]);
fakeNow += 4000;
a.handleBattleEvents([['dmg', 5, 100, 'phys']]);
await settle();
assert.ok(!urls.includes(manifest.audio.sfx.units[charId].hit), '4 s later: not that attack\'s impact');
} finally { globalThis.performance = perf; restore(); }
});
});
+111 -9
View File
@@ -1,14 +1,18 @@
// Strategy draft 队友已选 (research 09 §5 / §7, DESIGN §14 corrections): a strategy a teammate already picked cannot be // Strategy draft 队友已选 (research 09 §5 / §7, DESIGN §14 corrections): a strategy a teammate already picked cannot be
// chosen again — the server refuses it (server/match/Match.js pickBand → BAD_TARGET '队友已选'), bots re-draw, and the // chosen again — the server refuses it (server/match/Match.js pickBand → BAD_TARGET '队友已选'), bots re-draw, and the
// UI marks it (screens/bandDraft.js teammateBands). Automatic assignments (12 s turn timeout, 50 s cap, a departing // UI marks it (screens/bandDraft.js teammateBands). Automatic assignments (a turn that runs out, a departing seat) give
// seat) give the official default 「华法琳」 only while no teammate holds it, else the first free strategy // the official default 「华法琳」 only while no teammate holds it, else the first free strategy (Match.js defaultBand; the
// (Match.js defaultBand; the UI names it: bandDraft.js timeoutBand). // UI names it: bandDraft.js timeoutBand) — a timed-out turn takes the highlighted band first (g.bandFocus, Match.js
// timeoutBand; user playtest #4 item 4: one countdown, BAND_TURN_SECONDS per turn, no separate step cap).
import { test, describe } from 'node:test'; import { test, describe } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js'; import { ERR, PHASE } from '../../shared/constants.js';
import { makeMatch, DATA } from '../match/harness.js'; import { makeMatch, DATA } from '../match/harness.js';
import { teammateBands, timeoutBand, allowedBands } from '../../public/js/screens/bandDraft.js'; import { teammateBands, timeoutBand, allowedBands, autoPickBand, draftClock, draftTip, draftSelection } from '../../public/js/screens/bandDraft.js';
import { BAND_TURN_SECONDS } from '../../server/match/Match.js';
const TURN_MS = BAND_TURN_SECONDS * 1000;
function draftOf(o) { function draftOf(o) {
const h = makeMatch({ mode: 'coop', ...o }).start(); const h = makeMatch({ mode: 'coop', ...o }).start();
@@ -98,34 +102,66 @@ describe('server: automatic assignments never duplicate a teammate\'s strategy',
return ids; return ids;
}; };
test('12 s turn timeout: 华法琳 when free, else the first free strategy (sortId order)', () => { test('a turn that runs out (no highlight): 华法琳 when free, else the first free strategy (sortId order)', () => {
const h = draftOf({ humans: 3, seed: 5 }); const h = draftOf({ humans: 3, seed: 5 });
const m = h.m; const m = h.m;
const [first, second, third] = m.draft.order; const [first, second, third] = m.draft.order;
assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'band_bldsk' }), { ok: true }); assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'band_bldsk' }), { ok: true });
assert.equal(m.defaultBand(second), 'band_amiya', 'the next free strategy by sortId'); assert.equal(m.defaultBand(second), 'band_amiya', 'the next free strategy by sortId');
h.sched.advance(12001); h.sched.advance(TURN_MS - 1);
assert.equal(m.draft.picks[second], undefined, `the whole ${BAND_TURN_SECONDS} s`);
h.sched.advance(2);
assert.equal(m.draft.picks[second], 'band_amiya', 'timeout never assigns 队友已选 华法琳'); assert.equal(m.draft.picks[second], 'band_amiya', 'timeout never assigns 队友已选 华法琳');
assert.equal(h.ps(second).lp, DATA.bands.band_amiya.totalHp, 'the LP of the assigned strategy'); assert.equal(h.ps(second).lp, DATA.bands.band_amiya.totalHp, 'the LP of the assigned strategy');
assert.equal(m.draftTurn(), third); assert.equal(m.draftTurn(), third);
h.sched.advance(12001); h.sched.advance(TURN_MS + 1);
assert.equal(m.draft.picks[third], 'band_duyaoy'); assert.equal(m.draft.picks[third], 'band_duyaoy');
distinct(m); distinct(m);
m.dispose(); m.dispose();
}); });
test('a turn that runs out takes the highlighted strategy (g.bandFocus) while it is free', () => {
const h = draftOf({ humans: 3, seed: 5 });
const m = h.m;
const [first, second, third] = m.draft.order;
// highlights may come before the turn; the last one counts; null clears it
assert.deepEqual(m.handle(third, { t: 'g.bandFocus', bandId: 'band_lisa' }), { ok: true });
assert.deepEqual(m.handle(second, { t: 'g.bandFocus', bandId: 'band_amiya' }), { ok: true });
assert.deepEqual(m.handle(second, { t: 'g.bandFocus', bandId: 'band_sarkazb' }), { ok: true });
assert.equal(m.handle(second, { t: 'g.bandFocus', bandId: 'band_nope' }).error, ERR.BAD_TARGET);
assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'band_lisa' }), { ok: true });
assert.equal(m.handle(first, { t: 'g.bandFocus', bandId: 'band_amiya' }).error, ERR.ALREADY, 'nothing to highlight after the pick');
h.sched.advance(TURN_MS + 1);
assert.equal(m.draft.picks[second], 'band_sarkazb', 'the highlighted strategy');
assert.equal(h.ps(second).lp, DATA.bands.band_sarkazb.totalHp);
// the third player highlighted 华法琳's neighbour band_lisa — a teammate took it meanwhile: the default instead
assert.equal(m.timeoutBand(third), 'band_bldsk', '队友已选: never assigned');
h.sched.advance(TURN_MS + 1);
assert.equal(m.draft.picks[third], 'band_bldsk');
distinct(m);
m.dispose();
const h2 = draftOf({ humans: 2, seed: 5 });
const [a] = h2.m.draft.order;
assert.deepEqual(h2.m.handle(a, { t: 'g.bandFocus', bandId: 'band_amiya' }), { ok: true });
assert.deepEqual(h2.m.handle(a, { t: 'g.bandFocus' }), { ok: true }, 'cleared');
h2.sched.advance(TURN_MS + 1);
assert.equal(h2.m.draft.picks[a], 'band_bldsk', 'no highlight: the default');
assert.equal(h2.m.handle(a, { t: 'g.bandFocus', bandId: 'band_amiya' }).error, ERR.ALREADY);
h2.m.dispose();
});
test('the default stays 华法琳 while nobody holds it (also for a player whose teammates picked others)', () => { test('the default stays 华法琳 while nobody holds it (also for a player whose teammates picked others)', () => {
const h = draftOf({ humans: 2, seed: 5 }); const h = draftOf({ humans: 2, seed: 5 });
const m = h.m; const m = h.m;
const [first, second] = m.draft.order; const [first, second] = m.draft.order;
assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'band_amiya' }), { ok: true }); assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'band_amiya' }), { ok: true });
assert.equal(m.defaultBand(second), 'band_bldsk'); assert.equal(m.defaultBand(second), 'band_bldsk');
h.sched.advance(12001); h.sched.advance(TURN_MS + 1);
assert.equal(m.draft.picks[second], 'band_bldsk'); assert.equal(m.draft.picks[second], 'band_bldsk');
m.dispose(); m.dispose();
}); });
test('every human idle: the turn timeouts and the 50 s step cap assign distinct strategies', () => { test('every human idle: the turn timeouts assign distinct strategies (no separate step cap)', () => {
const h = draftOf({ humans: 4, seed: 9 }); const h = draftOf({ humans: 4, seed: 9 });
const m = h.m; const m = h.m;
h.run(() => m.phase !== PHASE.BAND_DRAFT, { maxTime: 120000 }); h.run(() => m.phase !== PHASE.BAND_DRAFT, { maxTime: 120000 });
@@ -195,3 +231,69 @@ describe('UI: timeoutBand (the tip names what a timeout gives me)', () => {
m.dispose(); m.dispose();
}); });
}); });
describe('UI: one countdown and the highlighted band (user playtest #4 item 4)', () => {
const bands = allowedBands([
{ bandId: 'band_amiya', sortId: 2 }, { bandId: 'band_bldsk', sortId: 1 }, { bandId: 'band_duyaoy', sortId: 3 },
], 'MULTI');
test('draftClock: the step header counts the current turn down (m.public.deadline = draft.turnDeadline); untimed ⇒ none', () => {
assert.deepEqual(draftClock({ deadline: 5000, draft: { turnDeadline: 5000, turnSeconds: 30 } }), { deadline: 5000, total: 30 });
assert.deepEqual(draftClock({ deadline: 0, draft: { turnDeadline: 7000, turnSeconds: 30 } }), { deadline: 7000, total: 30 }, 'the turn deadline stands in');
assert.equal(draftClock({ deadline: 0, draft: { turnDeadline: 0, untimed: true, turnSeconds: 0 } }), null);
assert.equal(draftClock({ deadline: 9000, draft: { untimed: true } }), null, 'an untimed draft shows no clock');
assert.equal(draftClock({ deadline: 0, draft: { turnDeadline: 0 } }), null);
assert.equal(draftClock(null), null);
assert.deepEqual(draftClock({ deadline: 4000, draft: {} }), { deadline: 4000, total: null });
});
test('the server publishes one clock: deadline = turnDeadline, turnSeconds = the turn length; AI seats pick at once', () => {
const h = draftOf({ humans: 2, bots: 2, seed: 9 });
const m = h.m;
h.sched.advance(1);
const turn = m.draftTurn();
assert.ok(turn && !turn.startsWith('ai_'), `a human's turn after the AI seats picked at once (${m.draft.order})`);
const pub = m.publicView();
assert.equal(pub.deadline, pub.draft.turnDeadline, 'one countdown');
assert.equal(pub.draft.turnSeconds, BAND_TURN_SECONDS);
assert.ok(Math.abs(pub.deadline - h.sched.now() - TURN_MS) <= 2, 'a whole turn left');
assert.deepEqual(draftClock(pub), { deadline: pub.deadline, total: BAND_TURN_SECONDS });
m.dispose();
});
test('autoPickBand: the highlighted band while free, else timeoutBand; nothing after my pick', () => {
assert.equal(autoPickBand('band_duyaoy', { bands, taken: new Map() }), 'band_duyaoy');
assert.equal(autoPickBand('band_duyaoy', { bands, taken: new Map([['band_duyaoy', ['a']]]) }), 'band_bldsk', '队友已选 ⇒ the default');
assert.equal(autoPickBand(null, { bands, taken: new Map() }), 'band_bldsk');
assert.equal(autoPickBand('band_unknown', { bands, taken: new Map() }), 'band_bldsk', 'not a band of the mode');
assert.equal(autoPickBand('band_amiya', { bands, taken: new Map([['band_bldsk', ['a']], ['band_amiya', ['b']]]) }), 'band_duyaoy');
assert.equal(autoPickBand('band_amiya', { bands, taken: new Map(), myPick: 'band_amiya' }), null);
});
test('the selection starts on what a timeout gives (华法琳 while free), so the tip names the highlighted band', () => {
assert.equal(draftSelection(null, { bands, taken: new Map(), myPick: null, myTurn: false }), 'band_bldsk');
assert.equal(draftSelection(null, { bands, taken: new Map([['band_bldsk', ['a']]]), myPick: null, myTurn: true }), 'band_amiya');
assert.equal(draftSelection('band_bldsk', { bands, taken: new Map([['band_bldsk', ['a']]]), myPick: null, myTurn: true }), 'band_amiya', 'taken on my turn ⇒ the default');
assert.match(draftTip({ timed: true, turnSeconds: 30, autoName: '华法琳' }), /每位博士有 30 秒,超时将自动选择当前选中的「华法琳」/);
assert.match(draftTip({ timed: true, turnSeconds: 30, autoName: '阿米娅', selected: false }), /超时将自动选择「阿米娅」$/);
assert.equal(draftTip({ timed: true, turnSeconds: 30, autoName: null }), '联合模拟在选择策略时可以进行一次跳过;每位博士有 30 秒');
assert.equal(draftTip({ timed: false, autoName: '华法琳' }), '联合模拟在选择策略时可以进行一次跳过;本局不限时');
});
test('a single human with AI teammates: the draft is untimed (soloUntimed), keeps the co-op order and its skip', () => {
const h = draftOf({ humans: 1, bots: 3, seed: 4 });
const m = h.m;
h.run(() => m.draftTurn() === 'p_0' || m.phase !== PHASE.BAND_DRAFT, { maxTime: 1000 });
assert.equal(m.draftTurn(), 'p_0');
const pub = m.publicView();
assert.equal(pub.deadline, 0);
assert.equal(pub.draft.untimed, true);
assert.equal(pub.draft.turnSeconds, 0);
assert.equal(draftClock(pub), null);
h.sched.advance(10 * 60_000);
assert.equal(m.phase, PHASE.BAND_DRAFT, 'waits for the player');
assert.equal(m.draftTurn(), 'p_0');
if (m.draft.order.length - m.draft.idx > 1) assert.deepEqual(m.handle('p_0', { t: 'g.bandSkip' }), { ok: true }, 'the co-op skip stays');
m.dispose();
});
});
+20 -62
View File
@@ -12,9 +12,8 @@ import {
bondMembers, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace, bondMembers, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace,
boardTargets, dropIntent, normalizeDraft, normalizeSp, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum, boardTargets, dropIntent, normalizeDraft, normalizeSp, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum,
rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId, rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId,
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, pickPieceAt, equipRetarget, activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason,
} from '../../public/js/ui/gameLogic.js'; } from '../../public/js/ui/gameLogic.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { pairPlayers } from '../../server/match/finalAssault.js'; import { pairPlayers } from '../../server/match/finalAssault.js';
import { PHASE, GEO } from '../../shared/constants.js'; import { PHASE, GEO } from '../../shared/constants.js';
@@ -101,6 +100,9 @@ describe('countdown', () => {
assert.equal(phaseTotalSeconds({ phase: PHASE.SP_DRAFT, sp: { cards: [], picks: {} }, players: [] }, config), config.timers.spFirst); assert.equal(phaseTotalSeconds({ phase: PHASE.SP_DRAFT, sp: { cards: [], picks: {} }, players: [] }, config), config.timers.spFirst);
assert.equal(phaseTotalSeconds({ phase: PHASE.SP_DRAFT, sp: { cards: [{}, {}], picks: { a: 0 } }, players: [{ playerId: 'a' }] }, config), config.timers.spTurn); assert.equal(phaseTotalSeconds({ phase: PHASE.SP_DRAFT, sp: { cards: [{}, {}], picks: { a: 0 } }, players: [{ playerId: 'a' }] }, config), config.timers.spTurn);
assert.equal(phaseTotalSeconds(null, config), null); assert.equal(phaseTotalSeconds(null, config), null);
// the strategy draft: one countdown, the turn's (m.public.draft.turnSeconds; user playtest #4 item 4)
assert.equal(phaseTotalSeconds({ phase: PHASE.BAND_DRAFT, draft: { turnSeconds: 30 } }, config), 30);
assert.equal(phaseTotalSeconds({ phase: PHASE.BAND_DRAFT }, config), config.timers.bandTurn);
}); });
}); });
@@ -494,65 +496,21 @@ describe('prepCamera (research 09 §1.2: the Final Assault prep on the own half
}); });
}); });
// user playtest #3 item 7: equipment dropped on the operator standing behind another selected the one in front // user playtest #4 item 1: a dragged item drops on the tile under the pointer (render/drag.js) and goes to the unit on
describe('pickPieceAt / equipRetarget (the unit under a dragged item)', () => { // that tile — the highlighted tile; nothing is retargeted to a sprite drawn over another tile any more
// two operators in one column on the official prep camera: uid 1 on row 9 (front), uid 2 on row 10 (behind, same describe('equipment dropped on a tile goes to the unit on it', () => {
// height); `shape` = the drawn body the render engine reports through view.pieceScreenRect test('the operator on the drop tile (board or bench), not the one whose head is drawn there; an empty tile says why', () => {
const cam = presetCamera('prep', { width: 1920, height: 1080 }); const front = { ...piece(RANGED), row: 9, col: 3 }, behind = { ...piece(RANGED), row: 10, col: 3 };
const unit = (uid, row, col) => { const onBench = piece(RANGED);
const p = cam.project(col, row, 0); const eq = item(EQUIP), art = item(MAGIC);
const shape = { kind: 'chibi', x: p.x, y: p.y, s: p.s, h: 1.27, flip: 1, depth: -cam.depthOf(col, row, 0) * 100 + col * 0.001 }; const ctx = ctxFor(privWith({ board: [front, behind], hand: [{ idx: 0, piece: eq }, { idx: 1, piece: art }, { idx: 4, piece: onBench }] }));
// the pre-fix rect (0.7 × 1.28 tiles) — plain rects are what the DOM fallback reports assert.deepEqual(dropIntent(ctx, eq.uid, { area: 'board', row: 9, col: 3 }).fields, { itemUid: eq.uid, targetUid: front.uid });
const rect = { left: p.x - 0.35 * p.s, right: p.x + 0.35 * p.s, top: p.y - 1.18 * p.s, bottom: p.y + 0.1 * p.s }; assert.deepEqual(dropIntent(ctx, eq.uid, { area: 'board', row: 10, col: 3 }).fields, { itemUid: eq.uid, targetUid: behind.uid });
return { uid, row, col, p, shape, rect }; assert.deepEqual(dropIntent(ctx, eq.uid, { area: 'hand', idx: 4 }).fields, { itemUid: eq.uid, targetUid: onBench.uid }, 'a bench operator');
}; // the empty tile behind `behind` (where its head is drawn) takes nothing — and the release says why
const A = unit(1, 9, 4), B = unit(2, 10, 4); assert.equal(dropIntent(ctx, eq.uid, { area: 'board', row: 11, col: 3 }), null);
const engine = [A, B].map((u) => ({ uid: u.uid, ...u.rect, shape: u.shape, tile: { row: u.row, col: u.col } })); assert.equal(dropFailureReason(ctx, eq.uid, { row: 11, col: 3, area: 'board' }), '请将装备拖拽至干员身上');
const plain = [A, B].map((u) => ({ uid: u.uid, ...u.rect })); // an Art is used on the tile under the pointer itself
const up = (u, k, dx = 0) => [u.p.x + dx * u.p.s, u.p.y - k * u.p.s]; assert.deepEqual(dropIntent(ctx, art.uid, { area: 'board', row: 11, col: 3 }), { t: 'g.art', fields: { itemUid: art.uid, row: 11, col: 3 } });
test('engine shapes: the face / torso of each is its own; the lower half of the unit behind is no longer the front one\'s', () => {
for (const k of [1.0, 0.6, 0.35]) assert.equal(pickPieceAt(engine, ...up(B, k), { row: 10, col: 4 }), 2, `B ${k} tile up`);
for (const k of [0.86, 0.5, 0.1]) assert.equal(pickPieceAt(engine, ...up(A, k), { row: 9, col: 4 }), 1, `A ${k} tile up`);
// B's tile showing beside A's head: B (the old front-most rect gave A)
const side = up(B, 0, 0.3);
assert.equal(pickPieceAt(engine, ...side, { row: 10, col: 4 }), 2);
assert.equal(pickPieceAt(plain, ...side), 1, 'plain rects: the overlap still favours the nearer centre');
// off both bodies: the tile under the point decides
assert.equal(pickPieceAt(engine, ...up(B, 0, 0.47), { row: 10, col: 4 }), 2);
assert.equal(pickPieceAt(engine, ...up(B, 0, 0.47), { row: 10, col: 5 }), null);
});
test('plain rects (DOM fallback): the rect whose centre is nearest relative to its size, ties → the front-most', () => {
const rects = [
{ uid: 1, left: 100, right: 160, top: 400, bottom: 520 }, // front (lower on screen)
{ uid: 2, left: 100, right: 160, top: 350, bottom: 470 }, // behind
];
assert.equal(pickPieceAt(rects, 130, 450), 1, 'nearer the front one\'s centre');
assert.equal(pickPieceAt(rects, 130, 430), 2, 'nearer the one behind (the front-most rule gave 1)');
assert.equal(pickPieceAt(rects, 130, 435), 1, 'equidistant: the front-most');
assert.equal(pickPieceAt(rects, 130, 360), 2);
assert.equal(pickPieceAt(rects, 10, 10), null);
assert.equal(pickPieceAt(rects, NaN, 10), null);
assert.equal(pickPieceAt(null, 1, 1), null);
assert.equal(pickPieceAt([{ uid: 'x', left: 0, right: 9, top: 0, bottom: 9 }, null], 5, 5), null);
});
test('equipRetarget sends the item to the unit the engine picked (hitUid), Arts never', () => {
const priv = {
alive: true, ready: false, funds: 10, deployCap: 8, deployCount: 2,
board: [piece(RANGED, { row: 9, col: 3 }), piece(RANGED, { row: 10, col: 3 })],
hand: [item(EQUIP), item(MAGIC), ...new Array(GEO.HAND_SIZE - 2).fill(null)],
temp: new Array(GEO.TEMP_SIZE).fill(null), shop: { slots: [] },
};
const ctx = placementContext({ priv, stage: STAGE, editable: true, getChess, getToken, getItem });
const [front, behind] = priv.board;
const [eq, art] = priv.hand;
const onFrontTile = { area: 'board', row: 9, col: 3 };
assert.deepEqual(equipRetarget(ctx, eq.uid, onFrontTile, behind.uid), { area: 'board', row: 10, col: 3 });
assert.deepEqual(dropIntent(ctx, eq.uid, equipRetarget(ctx, eq.uid, onFrontTile, behind.uid)).fields, { itemUid: eq.uid, targetUid: behind.uid });
assert.deepEqual(equipRetarget(ctx, art.uid, onFrontTile, behind.uid), onFrontTile, 'an Art is used on its tile');
assert.deepEqual(equipRetarget(ctx, eq.uid, onFrontTile, null), onFrontTile);
assert.deepEqual(equipRetarget(ctx, front.uid, onFrontTile, behind.uid), onFrontTile, 'units are not retargeted');
}); });
}); });
+3 -2
View File
@@ -256,9 +256,10 @@ describe('client leftovers — real server', { skip: !ENABLED && 'set SP_E2E=1 (
const itemsOf = () => c.page.evaluate((uid) => (globalThis.__SP__.store.get().match.private.board.find((p) => p.uid === uid)?.items || []).map((x) => ({ uid: x.uid, id: x.id })), unit.uid); const itemsOf = () => c.page.evaluate((uid) => (globalThis.__SP__.store.get().match.private.board.find((p) => p.uid === uid)?.items || []).map((x) => ({ uid: x.uid, id: x.id })), unit.uid);
const handItem = async (id) => (await c.handPieces('item')).find((p) => p.id === id) || null; const handItem = async (id) => (await c.handPieces('item')).find((p) => p.id === id) || null;
// an item goes to the unit on the tile it is dropped on (user playtest #4 item 1): the operator's own tile
const dropItem = async (it) => { const dropItem = async (it) => {
for (const at of [0.5, 0.35, 0.7]) { for (let i = 0; i < 2; i++) {
await c.drag(await c.piecePoint(it.uid, 0.5), await c.piecePoint(unit.uid, at)); await c.drag(await c.piecePoint(it.uid, 0.5), await c.tilePoint(tile.row, tile.col));
await sleep(500); await sleep(500);
if (await c.exists('.eqr') || !(await handItem(it.id))) return; if (await c.exists('.eqr') || !(await handItem(it.id))) return;
} }
+4 -1
View File
@@ -102,7 +102,10 @@ describe('user playtest #2 item 1 — loadout chosen in the UI fights in the loc
tag: !!document.querySelector('.dpanel .dskill__name .dtag-loadout'), tag: !!document.querySelector('.dpanel .dskill__name .dtag-loadout'),
module: document.querySelector('.dpanel .dmodule')?.textContent || '', module: document.querySelector('.dpanel .dmodule')?.textContent || '',
none: !!document.querySelector('.dpanel .dmodule.is-none'), none: !!document.querySelector('.dpanel .dmodule.is-none'),
stats: Object.fromEntries([...document.querySelectorAll('.dpanel .dstat')].map((el) => [el.querySelector('.dstat__k')?.textContent, el.querySelector('.dstat__v')?.textContent])), // the record (base) value: with live stats (user playtest #4 item 7: the start-of-battle preview) it is the
// cell's title "基础 N", else the value itself
stats: Object.fromEntries([...document.querySelectorAll('.dpanel .dstat')].map((el) => [el.querySelector('.dstat__k')?.textContent,
(el.getAttribute('title') || '').replace(/^基础 /, '') || el.querySelector('.dstat__v')?.textContent])),
trait: document.querySelector('.dpanel .dtrait')?.textContent || '', trait: document.querySelector('.dpanel .dtrait')?.textContent || '',
})), () => null); })), () => null);
if (detail) break; if (detail) break;
+11 -3
View File
@@ -142,11 +142,16 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
test('co-op briefing (review fix): the overlay shows the INFO_CHECK time left; an edit that misses the lock is told, not lost silently', async () => { test('co-op briefing (review fix): the overlay shows the INFO_CHECK time left; an edit that misses the lock is told, not lost silently', async () => {
const { ctx, page, problems } = await open(); const { ctx, page, problems } = await open();
await page.evaluate(() => globalThis.__SP__.net.request('room.create', { mode: 'coop', difficulty: 'NORMAL' })); await page.evaluate(() => globalThis.__SP__.net.request('room.create', { mode: 'coop', difficulty: 'NORMAL' }));
await waitSt(page, (s) => !!s.room, 'room'); const { room } = await waitSt(page, (s) => !!s.room, 'room');
// a second human joins: a single human is untimed like solo (Match.soloUntimed, user playtest #4 item 3)
const mate = await open();
await mate.page.evaluate((code) => globalThis.__SP__.net.request('room.join', { code }), room);
await waitSt(mate.page, (s) => s.room === room, 'the teammate in the room');
await mate.page.evaluate(() => globalThis.__SP__.net.request('room.ready', { ready: true }));
await page.evaluate(() => globalThis.__SP__.net.request('room.addBot', {})); await page.evaluate(() => globalThis.__SP__.net.request('room.addBot', {}));
await page.evaluate(() => globalThis.__SP__.net.request('room.start', {})); await page.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
const brief = await waitSt(page, (s) => s.phase === 'INFO_CHECK', 'briefing'); const brief = await waitSt(page, (s) => s.phase === 'INFO_CHECK', 'briefing');
assert.ok(brief.deadline > 0, 'co-op briefing is timed (25 s)'); assert.ok(brief.deadline > 0, 'co-op briefing (two humans) is timed (25 s)');
await clickSel(page, '.brief [data-testid="loadout-open"]'); await clickSel(page, '.brief [data-testid="loadout-open"]');
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 }); await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
// the briefing (and its countdown) is hidden under the overlay: the overlay carries the time left // the briefing (and its countdown) is hidden under the overlay: the overlay carries the time left
@@ -156,8 +161,9 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 }); await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
await page.click('.lo-card'); await page.click('.lo-card');
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true }); await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
// edit, and the briefing ends before the debounced send (every human ready: here the request stands in for the timer) // edit, and the briefing ends before the debounced send (every human ready: the requests stand in for the timer)
await page.click('.lo-detail .lo-skill[data-skill="0"]'); await page.click('.lo-detail .lo-skill[data-skill="0"]');
await mate.page.evaluate(() => globalThis.__SP__.net.request('g.infoReady', {}));
await page.evaluate(() => globalThis.__SP__.net.request('g.infoReady', {})); await page.evaluate(() => globalThis.__SP__.net.request('g.infoReady', {}));
await page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 5000 }); await page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 5000 });
await page.waitForFunction(() => [...document.querySelectorAll('.toast__text')].some((e) => /下一局生效/.test(e.textContent)), { timeout: 5000 }); await page.waitForFunction(() => [...document.querySelectorAll('.toast__text')].some((e) => /下一局生效/.test(e.textContent)), { timeout: 5000 });
@@ -166,6 +172,8 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout'))); const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')));
assert.deepEqual(stored.entries[INSIDE], { skill: 0 }, 'kept for the next match'); assert.deepEqual(stored.entries[INSIDE], { skill: 0 }, 'kept for the next match');
assert.deepEqual(problems, []); assert.deepEqual(problems, []);
assert.deepEqual(mate.problems, []);
await mate.ctx.close();
await ctx.close(); await ctx.close();
}); });
+31 -4
View File
@@ -245,11 +245,37 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
await page.close(); await page.close();
}); });
test('机变 pick, band pick, briefing ready', async () => { test('机变 pick (two taps, user playtest #4 item 2), band pick, briefing ready', async () => {
let { page, problems } = await open('phase=SP_DRAFT&variant=bounty'); let { page, problems } = await open('phase=SP_DRAFT&variant=bounty');
await page.click('.spcard.is-pickable'); await page.click('.spcard.is-pickable');
await sleep(200);
// the first tap only selects: the card lifts with its 确认选择 strip, the header offers 确认选择, nothing was sent
assert.ok(await page.$('.spcard.is-armed .spcard__confirm'), 'selected (armed)');
assert.ok(await page.$('.spov__confirm'), 'the header confirm');
assert.ok(!(await page.$('.spcard.is-mine')), 'not picked yet');
assert.ok(!(await page.evaluate(() => globalThis.__MOCK__.S().requests.some(([t]) => t === 'g.choice'))), 'no g.choice after one tap');
// another card moves the selection; Esc drops it
const pickable = await page.$$('.spcard.is-pickable');
await pickable[1].click();
await sleep(150);
assert.equal(await page.$$eval('.spcard.is-armed', (els) => els.length), 1, 'one selection');
await page.keyboard.press('Escape');
await sleep(150);
assert.ok(!(await page.$('.spcard.is-armed')), 'Esc drops the selection');
// select, then tap the same card again: picked
await page.click('.spcard.is-pickable');
await sleep(150);
await page.click('.spcard.is-armed');
await sleep(300); await sleep(300);
assert.ok(await page.$('.spcard.is-mine'), 'picked card marked'); assert.ok(await page.$('.spcard.is-mine'), 'picked card marked');
// the header's 确认选择 confirms as well
await page.close();
({ page, problems } = await open('phase=SP_DRAFT&variant=supply'));
await page.click('.spcard.is-pickable');
await sleep(150);
await page.click('.spov__confirm');
await sleep(300);
assert.ok(await page.$('.spcard.is-mine'), 'confirmed from the header');
assert.deepEqual(problems, []); assert.deepEqual(problems, []);
await page.close(); await page.close();
@@ -628,11 +654,12 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
await page.close(); await page.close();
}); });
test('engine: equipment released over an operator\'s upper body equips it', async (t) => { test('engine: equipment dropped on an operator\'s tile equips it — the tile under the pointer decides (user playtest #4 item 1)', async (t) => {
if (RENDER !== 'engine') { t.skip('SP_RENDER=fallback'); return; } if (RENDER !== 'engine') { t.skip('SP_RENDER=fallback'); return; }
const { page, problems } = await open('phase=PREP'); const { page, problems } = await open('phase=PREP');
await sleep(1500); // spine models await sleep(1500); // spine models
const at = (uid, fy) => page.evaluate((u, f) => { const r = globalThis.__SP_VIEW__?.pieceScreenRect(u); return r && { x: r.left + r.width / 2, y: r.top + r.height * f }; }, uid, fy); const at = (uid, fy) => page.evaluate((u, f) => { const r = globalThis.__SP_VIEW__?.pieceScreenRect(u); return r && { x: r.left + r.width / 2, y: r.top + r.height * f }; }, uid, fy);
const tileAt = (row, col) => page.evaluate((r, c) => { const x = globalThis.__SP_VIEW__?.raw?.tileScreen?.(r, c); return x && { x: x.x, y: x.y }; }, row, col);
const drag = async (from, to) => { const drag = async (from, to) => {
await page.mouse.move(from.x, from.y); await page.mouse.move(from.x, from.y);
await page.mouse.down(); await page.mouse.down();
@@ -646,9 +673,9 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
const items = s.hand.filter((x) => x && x.kind === 'item'); const items = s.hand.filter((x) => x && x.kind === 'item');
assert.ok(targets.length && items.length, 'mock board has an unequipped operator and a hand item'); assert.ok(targets.length && items.length, 'mock board has an unequipped operator and a hand item');
const unit = targets[0]; const unit = targets[0];
await drag(await at(items[0].uid, 0.5), await at(unit.uid, 0.25)); await drag(await at(items[0].uid, 0.5), await tileAt(unit.row, unit.col));
s = await mockState(page); s = await mockState(page);
assert.ok(s.board.find((x) => x.uid === unit.uid).items.some((i) => i.uid === items[0].uid), 'equipped from the head'); assert.ok(s.board.find((x) => x.uid === unit.uid).items.some((i) => i.uid === items[0].uid), 'equipped: dropped on its tile');
assert.deepEqual(problems, []); assert.deepEqual(problems, []);
await page.close(); await page.close();
}); });
+9 -11
View File
@@ -227,24 +227,22 @@ describe('user playtest #2 — UI fixes (mock harness, headless Chrome)', { skip
test('8: a top-row unit under the bond strip is tappable; on a notched phone the underframe stays on its unit', { skip: skipUnless('reach'), timeout: 5 * 60 * 1000 }, async () => { test('8: a top-row unit under the bond strip is tappable; on a notched phone the underframe stays on its unit', { skip: skipUnless('reach'), timeout: 5 * 60 * 1000 }, async () => {
for (const [name, w, h] of [['16x9', 1920, 1080], ['21x9', 2560, 1080], ['720p', 1280, 720], ['phone', 844, 390]]) { for (const [name, w, h] of [['16x9', 1920, 1080], ['21x9', 2560, 1080], ['720p', 1280, 720], ['phone', 844, 390]]) {
const { page, problems } = await open('phase=PREP', { w, h }); const { page, problems } = await open('phase=PREP', { w, h });
// the mock's row-12 unit (col 4, behind the row-11 one): its body stands under the bond strip at these sizes // the mock's row-12 unit (col 4, behind the row-11 one): its body reaches under the bond strip at these sizes; a
// press on its tile selects it (picking is by tile, user playtest #4 item 1), so the tile must be free of the strip
const top = await page.evaluate(() => { const top = await page.evaluate(() => {
const b = globalThis.__MOCK__.S().priv.board.filter((p) => p.row === 12).sort((a, c) => a.col - c.col)[0]; const b = globalThis.__MOCK__.S().priv.board.filter((p) => p.row === 12).sort((a, c) => a.col - c.col)[0];
const r = globalThis.__SP_VIEW__.pieceScreenRect(b.uid); const r = globalThis.__SP_VIEW__.pieceScreenRect(b.uid);
const t = globalThis.__SP_VIEW__.raw.tileScreen(b.row, b.col);
const strip = document.querySelector('.gm__bonds').getBoundingClientRect(); const strip = document.querySelector('.gm__bonds').getBoundingClientRect();
// points of the sprite's upper half (the lower half is behind the unit in front of it) const pts = [-0.3, 0, 0.3].map((dx) => ({ x: t.x + dx * t.s, y: t.y }));
const pts = [0.12, 0.25, 0.38].map((f) => ({ x: r.left + r.width / 2, y: r.top + r.height * f }));
const hits = pts.map((p) => { const el = document.elementFromPoint(p.x, p.y); return el.tagName === 'CANVAS' ? 'canvas' : el.closest('.bslot') ? 'disc' : `${el.tagName}.${el.className}`; }); const hits = pts.map((p) => { const el = document.elementFromPoint(p.x, p.y); return el.tagName === 'CANVAS' ? 'canvas' : el.closest('.bslot') ? 'disc' : `${el.tagName}.${el.className}`; });
return { uid: b.uid, pts, hits, underStrip: pts.some((p) => p.y >= strip.top && p.y <= strip.bottom) }; return { uid: b.uid, pts, hits, bodyUnderStrip: r.top < strip.bottom };
}); });
assert.ok(top.underStrip, `${name}: the row-12 unit stands under the bond strip (test premise)`); assert.ok(top.bodyUnderStrip, `${name}: the row-12 unit's body reaches under the bond strip (test premise)`);
// only the bond discs may take the pointer there — never the strip's empty box for (const hit of top.hits) assert.equal(hit, 'canvas', `${name}: the row-12 unit's tile is free of the bond strip`);
for (const hit of top.hits) assert.ok(hit === 'canvas' || hit === 'disc', `${name}: over the row-12 unit: ${hit}`); await page.mouse.click(top.pts[1].x, top.pts[1].y);
const free = top.pts[top.hits.indexOf('canvas')];
assert.ok(free, `${name}: some of the unit's body is free of bond discs (${top.hits})`);
await page.mouse.click(free.x, free.y);
await sleep(350); await sleep(350);
assert.ok(await page.$(`.uframe[data-uid="${top.uid}"]`), `${name}: a tap on the row-12 unit's body under the strip selects it`); assert.ok(await page.$(`.uframe[data-uid="${top.uid}"]`), `${name}: a tap on the row-12 unit's tile selects it`);
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await page.mouse.click(5, Math.round(h / 2)); await page.mouse.click(5, Math.round(h / 2));
await sleep(150); await sleep(150);
+21 -2
View File
@@ -185,6 +185,22 @@ function battleViewState(c) {
}); });
} }
/**
* Wait for a prep matching `pred`; a 机变 draft on the way is picked with its two taps (user playtest #4 item 2) —
* a single human's 机变 is untimed (Match.soloUntimed, item 3): nothing picks it for them any more.
*/
async function untilPrep(c, pred, what, timeout = 90000) {
const t0 = Date.now();
for (;;) {
const left = Math.max(2000, timeout - (Date.now() - t0));
const s = await c.waitFor((x) => (x.phase === 'PREP' && pred(x)) || (x.phase === 'SP_DRAFT' && x.sp?.turn === x.me), what, left);
if (s.phase === 'PREP') return s;
await c.click('.spcard.is-pickable', null, { optional: true, timeout: 4000 });
await c.click('.spcard.is-armed', null, { optional: true, timeout: 4000 });
await c.waitFor((x) => x.phase !== 'SP_DRAFT' || x.sp?.turn !== x.me, '机变 picked', 15000);
}
}
function assertNoPen(v, tag) { function assertNoPen(v, tag) {
assert.deepEqual(v.orphans, [], `${tag}: no stale unit sprite of another field`); assert.deepEqual(v.orphans, [], `${tag}: no stale unit sprite of another field`);
assert.equal(v.penVisible, 0, `${tag}: no pen figure`); assert.equal(v.penVisible, 0, `${tag}: no pen figure`);
@@ -216,7 +232,7 @@ describe('user playtest #2 item 6 — 前往查看 → 返回战场 in combat (r
} }
let observed = false; let observed = false;
for (let tries = 0; tries < 3 && !observed; tries++) { for (let tries = 0; tries < 3 && !observed; tries++) {
const s0 = await c.waitFor((s) => s.phase === 'PREP' && !s.ready, 'prep', 90000); const s0 = await untilPrep(c, (s) => !s.ready, 'prep');
await sleep(1500); await sleep(1500);
// the own field fights with everything the deploy cap allows: it clears its wave long before the idle AI's // the own field fights with everything the deploy cap allows: it clears its wave long before the idle AI's
// enemies have walked their whole path // enemies have walked their whole path
@@ -268,7 +284,7 @@ describe('user playtest #2 item 6 — 前往查看 → 返回战场 in combat (r
} }
assert.ok(observed, `observed a teammate's battle and came back (${c.log.join(' | ')})`); assert.ok(observed, `observed a teammate's battle and came back (${c.log.join(' | ')})`);
// and the next prep: the board without the pen, the pen only with 🔍▶▶ // and the next prep: the board without the pen, the pen only with 🔍▶▶
const s1 = await c.waitFor((s) => s.phase === 'PREP', 'next prep', 90000); const s1 = await untilPrep(c, () => true, 'next prep');
await sleep(1500); await sleep(1500);
assertNoPen(await battleViewState(c), `R${s1.round} prep after observing`); assertNoPen(await battleViewState(c), `R${s1.round} prep after observing`);
assert.deepEqual(problemsOf([c]), []); assert.deepEqual(problemsOf([c]), []);
@@ -344,6 +360,9 @@ describe('user playtest #2 item 10 — boss-round prep on the boss field (real s
await host.click('.room-bar__right button', '开始模拟', { timeout: 20000 }); await host.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000); for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
for (const c of [host, guest]) await c.click('.brief__foot .btn--primary', '准备就绪'); for (const c of [host, guest]) await c.click('.brief__foot .btn--primary', '准备就绪');
// the draft first (a client still in the briefing would count as done without picking: its turn then runs out —
// Match.BAND_TURN_SECONDS, 30 s each since user playtest #4 item 4)
for (const c of [host, guest]) await c.waitFor((s) => s.phase !== 'INFO_CHECK', 'band draft', 40000);
// band draft: whoever's turn it is picks a free strategy // band draft: whoever's turn it is picks a free strategy
const picked = new Set(); const picked = new Set();
const t0 = Date.now(); const t0 = Date.now();
+4 -3
View File
@@ -37,7 +37,7 @@ const { rowLp } = await import('../../public/js/ui/teamPanel.js');
const { LpTower, GAME_FILES } = await import('../../public/js/ui/gameComponents.js'); const { LpTower, GAME_FILES } = await import('../../public/js/ui/gameComponents.js');
const { tempRowFrame, tempRowRule } = await import('../../public/js/ui/underframe.js'); const { tempRowFrame, tempRowRule } = await import('../../public/js/ui/underframe.js');
const { ChessDetail, CHESS_SECTIONS, garrisonTypeIconKey } = await import('../../public/js/ui/detailPanel.js'); const { ChessDetail, CHESS_SECTIONS, garrisonTypeIconKey } = await import('../../public/js/ui/detailPanel.js');
const { ChoiceOverlay, pickBusy } = await import('../../public/js/ui/choiceOverlay.js'); const { ChoiceView, pickBusy } = await import('../../public/js/ui/choiceOverlay.js');
const { Button, Spinner } = await import('../../public/js/ui/components.js'); const { Button, Spinner } = await import('../../public/js/ui/components.js');
const { data, createDataStore, RETRY_DELAYS_MS } = await import('../../public/js/data.js'); const { data, createDataStore, RETRY_DELAYS_MS } = await import('../../public/js/data.js');
@@ -366,14 +366,15 @@ describe('9: busy indicators and data loading', () => {
assert.equal(pickBusy(0, { idx: 0 }, 0), false, 'index 0 too'); assert.equal(pickBusy(0, { idx: 0 }, 0), false, 'index 0 too');
assert.equal(pickBusy(2, { idx: 2, takenBy: 'p_1' }, null), false); assert.equal(pickBusy(2, { idx: 2, takenBy: 'p_1' }, null), false);
const sp = { family: 'tactic', name: '战术决策', desc: '', cards: [{ idx: 0, name: 'A', desc: 'a' }, { idx: 1, name: 'B', desc: 'b' }], order: ['me'], turnPid: 'me', pickOf: new Map(), untimed: true }; const sp = { family: 'tactic', name: '战术决策', desc: '', cards: [{ idx: 0, name: 'A', desc: 'a' }, { idx: 1, name: 'B', desc: 'b' }], order: ['me'], turnPid: 'me', pickOf: new Map(), untimed: true };
const v = ChoiceOverlay({ pub: { players: [{ playerId: 'me', seat: 0, name: 'Me' }] }, sp, myId: 'me', solo: true, onPick() {}, busyIdx: 1 }); // (ChoiceView: the overlay's pure view — ChoiceOverlay keeps the two-tap selection, user playtest #4 item 2)
const v = ChoiceView({ pub: { players: [{ playerId: 'me', seat: 0, name: 'Me' }] }, sp, myId: 'me', solo: true, busyIdx: 1 });
const nodes = [...walk(v)]; const nodes = [...walk(v)];
const busyCards = nodes.filter((n) => hasClass(n, 'spcard') && hasClass(n, 'is-busy')); const busyCards = nodes.filter((n) => hasClass(n, 'spcard') && hasClass(n, 'is-busy'));
assert.equal(busyCards.length, 1); assert.equal(busyCards.length, 1);
assert.equal(textOf(nodes.find((n) => hasClass(n, 'spcard__busy'))), '选择中'); assert.equal(textOf(nodes.find((n) => hasClass(n, 'spcard__busy'))), '选择中');
assert.ok(!nodes.some((n) => n.type === Spinner), 'no spinner over the card text'); assert.ok(!nodes.some((n) => n.type === Spinner), 'no spinner over the card text');
// the pick landed (m.public) while the request still waits for its reply // the pick landed (m.public) while the request still waits for its reply
const landed = ChoiceOverlay({ pub: { players: [] }, sp: { ...sp, pickOf: new Map([['me', 1]]), cards: [sp.cards[0], { ...sp.cards[1], takenBy: 'me' }] }, myId: 'me', solo: true, onPick() {}, busyIdx: 1 }); const landed = ChoiceView({ pub: { players: [] }, sp: { ...sp, pickOf: new Map([['me', 1]]), cards: [sp.cards[0], { ...sp.cards[1], takenBy: 'me' }] }, myId: 'me', solo: true, busyIdx: 1 });
assert.ok(![...walk(landed)].some((n) => hasClass(n, 'spcard__busy'))); assert.ok(![...walk(landed)].some((n) => hasClass(n, 'spcard__busy')));
}); });
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more