feedback2: merge WK — a ranged enemy stands for each attack's clip, then walks (GitHub #58); every exit leaves a 倒地干员 redeployed on that tile, per PRTS 卫戍协议/帮助 (GitHub #60)

This commit is contained in:
sganggs committed 2026-10-04 03:24:02 +08:00
commit 8ce9934efb
19 files changed
+610 -72

No files matched your search

+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","modelScale":0.963},"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","modelScale":1.1111},"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","staticBody":true,"modelScale":0.7407},"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","staticBody":true,"modelScale":0.8148},"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,"moveSLine 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","modelScale":0.963,"attackAnim":{"clip":"Attack","dur":1,"hit":0.6}},"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","modelScale":1.1111,"attackAnim":{"clip":"Attack","dur":1,"hit":0.167}},"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","staticBody":true,"modelScale":0.7407,"attackAnim":{"clip":"Attack","dur":1,"hit":0.267}},"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","staticBody":true,"modelScale":0.8148,"attackAnim":{"clip":"Attack","dur":1,"hit":0.267}},"enemy_1005_yokai_3":{"key":"enemy_1005_yokai_3","name":"威龙","level":0,"rank":"ELITE","handbookIndex":"D3","desc":"【飞行单位】大型无人机,拥有更好的耐久性能,可进行远程物理射击。","descRawLine truncated
+4
View File
@@ -189,6 +189,10 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
}
```
The enemies' attack clip lengths are also a data input: `tools/build-data.mjs` copies each enemy model's
`anims.attack.loop` length and first `hits` time into data/enemies.json `attackAnim` (the sim stands an unblocked
ranged enemy for that clip, GitHub #58; docs/DATA.md §9) — rebuild the data after a manifest change that touches them.
### The `Roles` object
```js
+2
View File
@@ -358,6 +358,8 @@ blackboards (transitively). Level = `randomEnemyAttributeDict[key].level` (0 for
| `hitArea` | `{"w":4.95,"h":2.95,"dx":0,"dy":1}` (`enemy_9013_acstmk`) | **huge units only** (巨型单位, 7 keys: 假想敌:胄 ×2, 假想敌:管 ×2 — the 隐秘核心 one `dx` 1 —, 盐风主教昆图斯, 阿利斯泰尔,帝国余晖, “萨米的意志”): the hit rectangle, `w` tiles along the columns × `h` along the rows, centred on the unit's position moved `dx` columns right / `dy` rows up (sim/body.js; user playtest #5). Not in the game tables (the collider lives in the prefab): `tools/build-data.mjs HIT_AREAS` by `prefabKey`, from PRTS "巨型单位:受击判定区域为长4.95、宽2.95的长方形,向上偏移1.0" and PRTS盟约记录 (the season's 阿利斯泰尔 / “萨米的意志” versions); absent = a point. All 7 are also 自缚 + 无法被阻挡 (PRTS 天赋 — not a data field either: content/bosses.js `SELF_BOUND`) |
| `staticBody` | `true` (`enemy_1005_yokai` 妖怪) | **静态刚体 only** (28 keys: every air unit of the mode except “炎佑” — 妖怪 ×3, 御4, 暴鸰, 法术大师 ×2, 寒霜, 帝国炮火先兆者 ×2, 枯朽之种, 枯朽萃聚使徒, 护障 ×2, 远眺, 愧悔魂灵圣杯, 假想敌:黑云, “斩胄之剑”, “破胄之锤”, 刺胄之弹, 未装配刀片, 防护背心, 冲击式施术单元, 节日气球, “萨科塔之翼 / 之眼 / 昂首” — plus the ground boss 盐风主教昆图斯): pushes and pulls never move it, the skills still hit it (sim `Battle._displaceable`; player report after 0.1.0). PRTS 特殊机制 静态刚体: "该单位的Unity刚体的刚体类型为部分静态(Kinematic)或静态(Static)…无法产生任何速度或移动…※是否为静态刚体与单位的行动方式无关". Not in the game tables (the rigidbody lives in the prefab): `tools/build-data.mjs STATIC_BODIES` by key, from the 天赋 "{{特殊机制|静态刚体}}" of each enemy's PRTS page (every enemy of the file, read 2026-10-03); absent = a dynamic body |
| `modelScale` | `0.5926` (`enemy_1005_yokai_3` 威龙) | official drawn size of the enemy's Spine model relative to the standard (user playtest #6): the battle prefab's transform scale down to its Spine renderer (Graphic × FaceSwitcher × Spine; SkeletonDataAsset.scale is 0.01 for every enemy skeleton) ÷ 0.27, the standard of 1454 of the client's 2147 enemy prefabs (2080 have exactly one Spine renderer) and of the operators' battle skins. The renderer multiplies `UNIT.modelScale` by it (render/units.js `enemyModelScale`). 125 keys (122 prefabs) carry one, from 0.5926 (威龙 0.16; 妖怪 0.7407, 寒霜 0.6667) to 2.2222 (青铜镜 / 青瓷茶器 0.6); absent = 1. Not in the game tables: `tools/build-data.mjs MODEL_SCALES` by `prefabKey`, read from the local client by `tools/local-extract/enemy_scales.py` |
| `attackAnim` | `{"clip":"Attack","dur":1,"hit":0.533}` (`enemy_1019_jshoot` 隐形弩手) | the enemy's attack clip — the one the client plays for its attacks (data/assets.json `anims.attack.loop` of its model, not an Idle stand-in) —, its length (s) and first strike frame (`hits`, the OnAttack event; absent when the clip has none). An unblocked ranged enemy stands for this clip at each attack (sim/ai.js `attackStand`, GitHub #58). 213 keys; absent = no attack clip known (御4, 寒霜, the 岁 relics …: the sim keeps `ATTACK_PAUSE`). Not in the game tables: `tools/build-data.mjs enemyAttackAnim` reads the committed asset manifest (tools/fetch-assets.mjs, from the Spine skeletons) |
| `attackMoves` | `true` | **「不停止移动」 attackers only**: the handbook ability text says it attacks on the move (“十字路口”量产型's 四向攻击) — it never stops to attack (sim/ai.js `attackStand`). No enemy of the mode has it (build-data `attacksOnTheMove`) |
## 10. `factions.json` — special enemies (特训敌人)
+2 -2
View File
@@ -18,7 +18,7 @@
| 选择策略 | 40 名策略(分队长)决定**初始目标生命值(20–45)**和一条永久规则。同盟:随机顺序轮流选,**每人 30 秒**,右上角和「决策中」显示同一个倒计时;AI 队友立即选完;队友已选的不能重复,每人可**跳过一次**(排到最后);超时自动选你**当前选中**的策略(已被队友选走则选华法琳)。独立:自由选择、不限时;同盟模拟只有你一名玩家时也不限时。选的时候可以点左侧决策顺序下方的「查看禁用盟约与干员」,再看一遍「确认本局信息」里的盟约(置灰、禁用人数)和本局禁用干员:只能查看,倒计时照常走,底部也显示轮到谁和剩余秒数;点「关闭」、点窗口外面或按 Esc 关闭,轮到下一位时会自动关闭。 |
| 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(首位 30 秒,其余 16 秒;只有你一名玩家时不限时),独立 3 选 1。卡片包括装备、资金、干员、盟约层数、悬赏等;**悬赏决策**为你的作战添加悬赏敌人,卡上用蓝字标出持续多久:「下场作战」/「接下来两场作战」的只出现一场或两场,之后不再出现;官方的「多轮悬赏」(原文写「之后的每场作战」)在这里也按「接下来两场作战」处理,只出现两场(如教鞭的法术大师A2·多轮战术特训)。各回合的悬赏决策按官方对局截图来出:第 3 回合是 6 张「接下来两场作战」的悬赏(3 张 1 资金、2 张 2 资金、1 张 3 资金);第 9 回合是各首领的「下场作战」悬赏和「源石虫·特训」;绝境 / 终极的第 11 回合多半是悬赏决策(也可能是机密商店或战术决策),其中的悬赏通常有 1 只特异巨兽,其余是各阵营的「下场战斗」悬赏。击倒悬赏敌人的人(联防中的队友也算)获得资金;右侧效果栏会写「还剩 N 场作战」。**机密商店**的 6 件装备都免费,和官方一样可能出现两件相同的装备(各算一张卡,可以分别被选走);第 11 回合(绝境 / 终极)的机密商店按官方:2 件 VI 阶、至少 1 件 V 阶和 1 枚盟约之币,标准 / 险境较早回合的机密商店各阶装备都可能出现。**战术决策**也和官方一样可能出现两张相同的卡(官方截图里出现过两张「补给」;各算一张卡,可以分别被选走,每张只生效一次,两人各选一张全队卡则效果叠加);第 11 回合(绝境 / 终极)的战术决策按官方截图只出盟誓、驰援、列装 / 财富 / 补给 / 整备 / 升华等增益卡,不出削弱敌人的卡和战场演变卡。每张卡左上是图标和名称,下面写着完整效果(与详情卡相同的官方描述,手机上也显示);已被选走的卡变暗,右上角显示选走它的玩家头像(悬停可看名字)。选卡要**点两次**:第一次只选中(金框 +「确认选择 · 再次点击」),再点同一张或点右上的「确认选择」才生效;点别的卡换选,按 Esc 或点空白处取消。 |
| 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加);独立模拟、以及只有你一名玩家(只带 AI 队友)的同盟模拟不限时。所有人「准备就绪」即提前开战,全员就绪前可取消。 |
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**从上到下、从左到右**的顺序自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在倒下的格子上**(倒地姿势、略微变灰),之后也在这一格再部署(与官方一样;倒在其他干员或召唤物初始位置上的,会回到自己的初始位置躺下,召唤物已经消失也一样);倒地干员所在的格子**不会被占用**,突袭盟约的再部署、援军、召唤物都会避开它。头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**从上到下、从左到右**的顺序自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在倒下的格子上**(倒地姿势、略微变灰),之后也在这一格再部署(与官方一样;倒在其他干员或召唤物初始位置上的,会回到自己的初始位置躺下,召唤物已经消失也一样);因自身效果强制退场的干员(史尔特尔「余烬」结束、耀骑士临光 2 技能结束等)同样倒在原地、在原地再部署;倒地干员所在的格子**不会被占用**,突袭盟约的再部署、援军、召唤物都会避开它。头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
| 联防 | 见第 5 节。 |
| 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。 |
@@ -74,7 +74,7 @@
- **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**重装干员**的手动技能在受到伤害时释放(例外:深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能按玩家反馈改为攻击范围内有敌人时就释放,余的二技能「厚礼上宾」改为周围两格(技能范围)内有敌人时就释放,这是本作有意与官方不同的地方;深巡一技能仍要受到伤害);执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放。**自动触发**的技能按其本身规则,例如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。
- **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。野鬃二技能沿她的朝向推开:正前方 45° 以内按上面的距离,身后、侧面或贴身的敌人只被轻推一下(约 0.4 格以内);琳琅诗怀雅三技能的金币则从她身边向外推开,落在她前方 2–4 格的范围(官方「范围2-4」)内的地面敌人和她阻挡的敌人身上。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。
- **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。
- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡(或被揭示)时才能被选为攻击目标,溅射、技能的圆形范围伤害也打不到没被挡住的隐匿敌人(和官方一样);阻挡解除后 3 秒内它仍然可以被选中(官方页面注明 0 秒或 1 秒的敌人按页面),之后才重新隐匿:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(挡住时它不再半透明;被阻挡时被击倒的,重生结束后约 2 秒内也能用远程或范围伤害补刀),10 秒内没打掉就满血站起来;敌人重生时身上的减速、恐惧等状态会被清空、技能冷却从头开始,被恐惧的逐火战士变成余烬后也能被挡住;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。
- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。没被阻挡的远程敌人每次攻击都会站定、播完攻击动作,两次攻击之间才继续前进。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡(或被揭示)时才能被选为攻击目标,溅射、技能的圆形范围伤害也打不到没被挡住的隐匿敌人(和官方一样);阻挡解除后 3 秒内它仍然可以被选中(官方页面注明 0 秒或 1 秒的敌人按页面),之后才重新隐匿:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(挡住时它不再半透明;被阻挡时被击倒的,重生结束后约 2 秒内也能用远程或范围伤害补刀),10 秒内没打掉就满血站起来;敌人重生时身上的减速、恐惧等状态会被清空、技能冷却从头开始,被恐惧的逐火战士变成余烬后也能被挡住;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。
- **策略「鸭爵」(“神秘顾客”)**:从第 5 回合起,你和队友的每场作战(**最终攻势和隐秘核心也算**,联防不另外替换)有 0–2 名地面敌人(不含领袖及其部件、悬赏敌人)被替换为特殊版本的鸭爵、高普尼克、流泪小子或圆仔,出现在被替换敌人的时间和路线上(两人同场的领袖战里,各自只替换走向自己保护目标点的敌人);击倒者获得 1 资金(联防中的队友也算),漏进保护目标点照常扣 1 点目标生命值。鸭爵和圆仔**不攻击、无法被阻挡**:鸭爵受到伤害后移动速度 +300%;圆仔始终面向我方干员较多的一侧,来自正面的物理 / 法术伤害 −80%。流泪小子隐匿(被阻挡时才能被选中),攻击使目标晕眩 3.5 秒;高普尼克免疫晕眩、冻结和浮空。
- 右上角 🔍▶▶ 可以查看本回合即将来袭的敌人(预览区),左上角 🔍 查看本局信息(禁用盟约、禁用干员、策略)。
+14 -6
View File
@@ -86,8 +86,11 @@ timer done, tile free, DP ≥ cost), 不屈's, 阿戈尔's — comes back there
killed) lies on its own home instead when that is free (else it stays [ASSUMED: one attempt]); x / y / tileR / tileC keep
where it fell for the `kill` / `death` handlers. "倒地干员所在地块视为可部署,但所有我方单位在此处的部署
行为将被阻止": `downOn(r, c)` — `_deploy` (redeploys, the 突袭 landing, summons), `spawnDevice` and `relocate` refuse that
tile and `isReservedTile` reports it, so every automatic picker skips it. A withdrawn operator that is not down (a
retreat) comes back on its home tile.
tile and `isReservedTile` reports it, so every automatic picker skips it. The rule covers every 退场 (GitHub #60): an
operator forced out by its own effects (`retreat` reason `'retreat'`: 史尔特尔's 余烬, 耀骑士临光 S2, 骑士戒律 + 竞技旗,
伊内丝 S3; `'merchant'`: a 商人 that cannot pay) lies down and comes back the same way — still no kill (its death reason is
not `'killed'`: no 被击倒 effects, 不屈, 阿戈尔 or knock-down count); only the 突袭 retreat (`'raid'`, redeployed at once on
its landing tile) and permanent removals leave nothing.
### 1.1 Coordinates (PlayerBattleInput units)
@@ -273,7 +276,11 @@ not [ASSUMED, §21.22]. Until 0.1.2 they took every ally in the area (GitHub iss
every ally there: the "无视无法选择 / 无视可选性" abilities above (and 远眺's death 暴露, 圆仔's facing count), 寒霜's aura
(PRTS names its 攻速下降 Debuff among the effects that "无视隐匿状态起作用") and map / tile / terrain effects (“墓碑”'s
halving — PRTS: a map effect —, 【国度】, a chimera's 源石污染区 aura [ASSUMED for the last two])
and pause `ATTACK_PAUSE` (0.35 s) after each unblocked attack; `fear`/`disarm` stop attacks; `dmgType 'none'` enemies
and, unblocked, stand for each attack's clip — `attackStand`: data/enemies.json `attackAnim`, through its wind-up
(cooldown ≤ the strike frame, a target in range) and the rest of the clip after the strike, shortened when the attacks
come quicker than the clip; `ATTACK_PAUSE` (0.35 s) after the strike when no clip is known; an `attackMoves`
(「不停止移动」) enemy never stops; a stun ends the stand; only the walking waits — a route WAIT keeps running and
DISAPPEAR / APPEAR legs still happen, hiding ends the stand; GitHub #58 — then walk on; `fear`/`disarm` stop attacks; `dmgType 'none'` enemies
never attack — unless content arms them through `enemy.profile` (`noAttack: false`, `melee`, `dmgType`, `maxTargets`:
转译基底·α's 寻仇者 / 特战术师 forms, which then attack like any enemy); `dmgType 'heal'` enemies heal the lowest-HP% enemy in their radius instead. A `noMove` enemy stands (not `moving`, drawn idle). Content can take over an
enemy's attack: `enemy.profile.deferHit` = the engine makes the attack (target, timing, the `'atk'` event) but deals no
@@ -743,7 +750,7 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
| `spawnEnemy(enemyKey, { routeIndex, route, pos, mods, tag, sourcePlayerId, ownerPlayerId, bounty, countInTotal, def })` | returns the enemy, or null past `MAX_ALIVE_ENEMIES`; `def` = inline record (enemies.json shape or normalised) for keys missing from data |
| `spawnDevice(key, row, col, { hp, obstacle, blockCnt, name, def, res, atk, bat, aspd })`, `setObstacle(r, c, on)` | obstacles re-path enemies; spawnDevice returns null outside the rect, on a living unit or on a knocked-out operator's tile |
| `isReservedTile(r, c)` | true when a living unit stands there or it is the rest tile (`restTile`) of an ally piece that has not deployed yet / waits to redeploy — the tile a knocked-out operator lies on, else the home tile — every automatic picker (the 突袭 landing tile, tactical points, summon / device tiles) must skip these (`findTacticalPoint` does) |
| `downOn(r, c, except?)`, `restTile(u)` | the knocked-out operator lying on a tile (no ally deploys / moves there: `_deploy`, `spawnDevice`, `relocate`); the tile a withdrawn ally comes back on — its `body` tile when knocked out, else its home (§1) |
| `downOn(r, c, except?)`, `restTile(u)` | the down operator (`isDown`: knocked out or forced out) lying on a tile (no ally deploys / moves there: `_deploy`, `spawnDevice`, `relocate`); the tile a withdrawn ally comes back on — its `body` tile when down, else its home (§1) |
| `addProjectile({ from, target | to:{x,y}, speed, onHit(ctx), visual, source, hitDead })` | homing; fizzles if the target dies unless `hitDead` |
| `allySelectable(ally, by)`, `alliesFor(by, ownerId?)` | may an ability of ally `by` select `ally` — never a 孤立 unit (炎佑, 从不混淆的方向) but `by` itself (PRTS 选择器: "若掩码中孤立为1,且选择器的阵营与目标为友好关系,则不可选中"); `allies()` without the 孤立 ones — content picks ally targets (buffs, auras, 全场 talents, heal picks) through these, `alliesInGrid` too |
| `unitsInGrid(unit, grid, {side, extend})`, `alliesInGrid(unit)`, `enemiesInRadius(x, y, r, centre?)`, `foesInRadius(x, y, r, centre?)`, `alliesInRadius(x, y, r, ownerId?)` | grid offsets are relative to facing RIGHT, rotated by `unit.dir`; enemies by their body (§2 hit areas: a huge enemy on every tile it occupies / within `r` of its rectangle; `centre` = splash around a target, a 中点判定 by position; `foesInRadius` = the enemies an operator's area effect can select — no untargetable enemy, none whose 隐匿 is on (`targeting.js enemyStealthed`: not blocked, not revealed, not within 3 s of its last block), PRTS 作战机制 §AOE伤害判定 — while enemy-side auras / heals and collisions keep `enemiesInRadius`) |
@@ -1141,8 +1148,9 @@ Unknown subprofessions fall back to the profession default (test `professions.te
- `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.
`down: [[id, respawnAt, respawnTime, state, row, col]]` (only when non-empty) = knocked-out operators waiting to redeploy
(`Battle.isDown(u)`: reason `'killed'` or `FORCED_EXIT` (§1.1 carryState `down`), deployed at least once, a finite
`down: [[id, respawnAt, respawnTime, state, row, col]]` (only when non-empty) = operators lying down waiting to redeploy
(`Battle.isDown(u)`: any removal reason but the 突袭 `'raid'` — `'killed'`, `FORCED_EXIT` (§1.1 carryState `down`), a forced
exit `'retreat'` / `'merchant'` (GitHub #60) —, not removed for good, deployed at least once, a finite
respawn timer; `state` = constants.js
`DOWN_STATE`: 0 counting, 1 timer done / DP short, 2 timer done / its tile taken — a safeguard: no ally deploys on a
knocked-out operator's tile, §1; `row, col` = the tile it lies on and comes back on, `unit.body`); `elem: [[id, element, fill,
+32 -12
View File
@@ -9,7 +9,10 @@
// attack(interval, once) one attack happened now (b.ev 'atk'): plays begin→loop, re-phases the loop
// so its OnAttack frame lands now, timeScale = loopDuration / interval;
// `once` (a one-off cast, style.js PROJ[kind].once — 暴鸰's bomb drop): the
// clip plays once at its own speed, then base
// clip plays once at its own speed, then base; `clipPerAttack` (enemies,
// GitHub #58): every attack plays the clip once — at its own speed, faster
// only when the attacks come quicker than the clip —, then base (Move while
// the sim walks it: it stands for that clip, server/sim/ai.js attackStand)
// setSkill(on) skill begin→loop while active (skill idle replaces idle), end on stop
// deploy() 'Start' once, then base
// die() die clip once (callers fade out afterwards); a skeleton without one holds its
@@ -20,8 +23,8 @@
// closing clip timed to end `in` s from now (a 重生's last clip ends with the
// 重生), landing in `roles`
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
// Attack mode lasts until ~1.4 attack intervals without a new attack (a `once` cast: to the end of its clip), then the
// end clip (if any) and base.
// Attack mode lasts until ~1.4 attack intervals without a new attack (a `once` cast and every attack of a
// `clipPerAttack` actor: to the end of its clip), then the end clip (if any) and base.
const clampN = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
@@ -98,6 +101,13 @@ export class SpineActor {
this.frozen = false;
this.dead = false;
this.interval = 1;
/**
* Enemies (render/units.js, GitHub #58): each attack plays the attack clip once — at its own speed, faster only when
* the attacks come quicker than the clip — and then the resting state; the sim stands the enemy for exactly that
* clip (server/sim/ai.js attackStand). Off (operators): the clip loops over the attack rhythm.
*/
this.clipPerAttack = false;
this.wound = false; // clipPerAttack: wound up for the coming attack (windUp → attack)
this._play(this._idleName(), true);
}
@@ -241,19 +251,23 @@ export class SpineActor {
if (this.dead || this.mode === 'stun' || this.mode === 'die' || this.mode === 'change' || !(lead >= 0)) return false;
const clip = this._attackClip();
if (!clip) return false;
if (this.mode === 'attack' && this.current === clip.loop) return false; // in rhythm: attack() re-phases
const single = once || this.clipPerAttack;
// in rhythm: attack() re-phases; a clip-per-attack actor starts each attack's clip anew unless already wound up for it
if (this.mode === 'attack' && this.current === clip.loop && (!this.clipPerAttack || once || this.wound)) return false;
const loopDur = this.dur(clip.loop);
// a one-off cast plays at the clip's own speed (one loop per clip length), whatever the attack rhythm
const iv = once ? loopDur : clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
const hit = this._hitTime(clip.loop, loopDur);
const plan = windUpPlan(loopDur, hit, iv, lead);
// clip per attack: its own speed, sped up only when the attacks come quicker than the clip
const plan = windUpPlan(loopDur, hit, this.clipPerAttack && !once ? Math.min(iv, loopDur) : iv, lead);
if (!(hit > 0) || lead * plan.ts > hit + 1e-6) return false;
if (!once) this.interval = iv;
this.mode = 'attack';
this.attackUntil = this.clock + lead + (once ? Math.max(0, loopDur - hit) / plan.ts : Math.max(0.45, iv * 1.4));
this._play(clip.loop, !once, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 });
this.attackUntil = this.clock + lead + (single ? Math.max(0, loopDur - hit) / plan.ts : Math.max(0.45, iv * 1.4));
this._play(clip.loop, !single, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 });
this.windTs = plan.ts;
this.windUntil = this.clock + lead;
this.wound = true;
return true;
}
@@ -267,17 +281,22 @@ export class SpineActor {
const clip = this._attackClip();
if (!clip) return;
const loopDur = this.dur(clip.loop);
const ts = once ? 1 : clampN(loopDur / this.interval, 0.35, 4);
const per = this.clipPerAttack && !once;
const single = once || per;
// clip per attack: its own speed (ts 1), faster only when the attacks come quicker than the clip
const ts = once ? 1 : per ? clampN(loopDur / Math.min(this.interval, loopDur), 1, 4) : clampN(loopDur / this.interval, 0.35, 4);
const hit = this._hitTime(clip.loop, loopDur);
const wasAttacking = this.mode === 'attack' && this.current === clip.loop;
// (a clip-per-attack actor still playing the previous attack's clip was not wound up for this one)
const wasAttacking = this.mode === 'attack' && this.current === clip.loop && (!per || this.wound);
this.mode = 'attack';
this.attackUntil = this.clock + (once ? Math.max(0, loopDur - hit) / ts : Math.max(0.45, this.interval * 1.4));
this.attackUntil = this.clock + (single ? Math.max(0, loopDur - hit) / ts : Math.max(0.45, this.interval * 1.4));
this.windUntil = null;
this.wound = false;
if (!wasAttacking) {
// not wound up (no look-ahead, e.g. a batch that arrived late): the sim already resolved the hit, so show
// the strike frame now
this._play(clip.loop, !once, { timeScale: ts, start: hit, mix: 0.06 });
} else if (once) {
this._play(clip.loop, !single, { timeScale: ts, start: hit, mix: 0.06 });
} else if (single) {
const e = this.spine.state.tracks[0];
if (e) e.timeScale = ts;
} else {
@@ -408,6 +427,7 @@ export class SpineActor {
case 'attack':
if (this.clock > this.attackUntil) {
this.mode = 'base';
this.wound = false;
const clip = this._attackClip() || this.roles.attack;
if (clip && this.has(clip.end)) { this._play(clip.end, false); this._queue(this._baseName(), true); }
else this._play(this._baseName(), true, { mix: 0.15 });
+2
View File
@@ -460,6 +460,8 @@ export class UnitView {
try {
actor = new SpineActor(data, entry);
actor.setSkillIndex(this.info.skillIndex);
// enemies play their attack clip once per attack, then walk on (GitHub #58: the sim stands them for that clip)
actor.clipPerAttack = this.isEnemy;
} catch (err) {
console.warn('[render] spine build failed', id, err?.message || err);
this._releaseEntry(entry);
+29 -19
View File
@@ -10,9 +10,10 @@
// the players of a shared field side by side), fires `deploy` (initial) for each unit, forces out the operators that
// enter already knocked out (`carryState.down`, 联防: constants.js FORCED_EXIT — down on their tile, redeploy timer
// running) and then fires `battleStart`.
// A knocked-out operator lies on its `body` tile — where it fell, or its own home when it fell on another board piece's
// home — and redeploys there; no ally deploys or moves onto that tile meanwhile (PRTS 卫戍协议/帮助 §作战阶段 单位部署;
// `_layBody`, `downOn`, `restTile`, `isReservedTile`; docs/SIM.md §1).
// An operator that left the field — knocked out, or forced out by its own effects (史尔特尔's 余烬 …, GitHub #60) — lies
// on its `body` tile — where it fell, or its own home when it fell on another board piece's home — and redeploys there;
// no ally deploys or moves onto that tile meanwhile (PRTS 卫戍协议/帮助 §作战阶段 单位部署; `isDown`, `_layBody`, `downOn`,
// `restTile`, `isReservedTile`; docs/SIM.md §1).
// Tick order: scheduled callbacks → spawns → DP → buffs → enemies (attack, move, block) → enemy index →
// allies (skill tick, attack) → projectiles → redeploys → boss sync → `tick` hook → release hooks of removed units →
// time += TICK → end checks. A forceEnd() requested mid-step ends the step after the current phase (docs/SIM.md §1.4).
@@ -815,7 +816,8 @@ export class Battle {
e.deploySeq = ++this._deploySeq;
e.deployedAt = this.time;
e.atkCd = 0;
e.pauseUntil = -Infinity;
e.pauseUntil = -Infinity; // content holds (暴鸰's drop)
e.atkStandUntil = -Infinity; // standing for its attack clip (ai.js attackStand)
// every enemy profile starts with the same fields (stable object shapes keep the hot loop's property reads fast);
// `dmgType` null = the data's (content may arm a data-unarmed enemy: ai.js enemyAttack)
e.profile = { noAttack: def.dmgType === 'none', maxTargets: 1, atkScale: 1, dmgType: null };
@@ -847,8 +849,8 @@ export class Battle {
// deployment / death / redeploy
/**
* Deploy `u` on its home tile, or on `tile` ([r, c]: a one-off landing tile — the home stays the board tile: a
* withdrawn operator comes back there, a knocked-out one on its body tile, restTile). `keepSp` = { sp, charges }
* Deploy `u` on its home tile, or on `tile` ([r, c]: a one-off landing tile — the home stays the board tile; an
* operator that left the field comes back on its body tile, restTile). `keepSp` = { sp, charges }
* restored right after the skill reset, before the `deploy` hook fires.
*/
_deploy(u, { initial = false, carry = null, tile = null, keepSp = null } = {}) {
@@ -916,7 +918,10 @@ export class Battle {
this._remove(unit, 'killed', killer);
}
/** Withdraw an ally without a kill (it may redeploy after its respawn time). */
/**
* Withdraw an ally without a kill (it may redeploy after its respawn time): an operator lies down where it stood and
* comes back there (isDown, GitHub #60) — unless `permanent`, or the 突袭 retreat ('raid') that redeploys it at once.
*/
retreat(unit, { reason = 'retreat', permanent = false } = {}) {
if (!unit || !unit.alive || unit.side !== 'ally') return;
this._remove(unit, reason, null, permanent);
@@ -1005,8 +1010,8 @@ export class Battle {
}
/**
* Immediately redeploy a dead (or retreated) ally on its rest tile (restTile: where a knocked-out operator lies, else
* its home tile). opts:
* Immediately redeploy a dead (or retreated) ally on its rest tile (restTile: where an operator that left the field
* lies, else its home tile). opts:
* free=true no DP cost (false: pays `base.cost`, refused when the player lacks the DP)
* tile=[r,c] land on this in-rect tile instead (the home stays the board tile); refused (false) when the tile is
* outside the rect, a living unit stands there or another knocked-out operator lies there — no fallback
@@ -1043,9 +1048,9 @@ export class Battle {
}
/**
* Automatic redeploys (DESIGN §5.5): a withdrawn operator whose timer is done comes back on its rest tile — a knocked-out
* one where it lies ("满足再部署条件时,移除场上的该倒地干员并自动部署至该位置", PRTS 卫戍协议/帮助) — when that tile is
* free and its player has the DP.
* Automatic redeploys (DESIGN §5.5): an operator that left the field and whose timer is done comes back on its rest
* tile — where it lies ("满足再部署条件时,移除场上的该倒地干员并自动部署至该位置", PRTS 卫戍协议/帮助) — when that tile
* is free and its player has the DP.
*/
_checkRedeploys() {
for (const u of this.allyUnits) {
@@ -2056,7 +2061,7 @@ export class Battle {
}
/**
* The tile a withdrawn ally comes back on (redeploy, _checkRedeploys): a knocked-out operator's body tile — where it
* The tile a withdrawn ally comes back on (redeploy, _checkRedeploys): a down operator's body tile (isDown) — where it
* fell, or its home (_layBody) — else its home tile.
*/
restTile(u) {
@@ -2262,7 +2267,7 @@ export class Battle {
/**
* Compact full snapshot of this field (DESIGN §8.2 b.snap), plus (only when non-empty):
* down: [[id, respawnAt, respawnTime, state, row, col]] — knocked-out operators waiting to redeploy (isDown): the
* down: [[id, respawnAt, respawnTime, state, row, col]] — operators that left the field waiting to redeploy (isDown): the
* game time their respawn timer ends, its length (s), constants.js DOWN_STATE and the tile they lie on (and
* come back on: _layBody — where they fell, or their home);
* elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView).
@@ -2299,13 +2304,18 @@ export class Battle {
}
/**
* A knocked-out operator waiting to redeploy on the tile it lies on (DESIGN §5.5: after its respawn time, when the
* tile is free and DP ≥ cost; _layBody, downOn): killed — or entering the battle knocked out (FORCED_EXIT, 联防) —
* not withdrawn, not removed for good, after it was deployed. The client keeps its model on that tile knocked down
* with a redeploy countdown (b.snap `down`, render/units.js); summons, devices and enemies simply leave.
* An operator lying on the field, waiting to redeploy on that tile (DESIGN §5.5: after its respawn time, when the tile
* is free and DP ≥ cost; _layBody, downOn). PRTS 卫戍协议/帮助 §作战阶段 单位部署: "干员退场后,将返回隐藏的待部署区,并原地
* 留下一个“倒地干员”以供查看信息,满足再部署条件时,移除场上的该倒地干员并自动部署至该位置。" — every 退场 (GitHub #60, the owner's
* decision of 2026-10-04): knocked out ('killed'), entering the battle knocked out (FORCED_EXIT, 联防) and forced out
* by its own effects ('retreat': 史尔特尔's 余烬, 耀骑士临光 S2, 骑士戒律 + 竞技旗, 伊内丝 S3; 'merchant': a 商人 that cannot
* pay) — except the 突袭 retreat ('raid', redeployed at once on its landing tile); not removed for good, after it was
* deployed. A forced exit stays no kill: its 'die' / `death` reason is not 'killed' (no 被击倒 effect, 不屈, 阿戈尔, no
* knock-down count). The client keeps its model on that tile 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.removeReason === FORCED_EXIT)
return !!u && u.side === 'ally' && u.kind === 'op' && !u.alive && !u.removed && u.removeReason !== 'raid'
&& u.deploySeq > 0 && Number.isFinite(u.respawnAt);
}
+74 -20
View File
@@ -16,10 +16,11 @@
// always target the enemies it blocks, in range or not, whatever its facing, and targets them first (acquireTargets,
// Battle.blockedTargets; user playtest #6: "阻挡了就一定要能打到"); a heal attack keeps selecting injured allies while
// its unit blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位").
// Unblocked ranged enemies attack allies within their radius and pause ATTACK_PAUSE seconds after each attack; the
// candidates pass the enemy's own rule (`e.profile.canTarget`) and are ordered blocker → taunt → latest deployed
// (targeting.js sortAllyTargets). An enemy's damage type is its data's unless content arms it (`e.profile.dmgType`:
// 转译基底's forms, whose data never attacks). Reaching the final leg's end = leak. A `fear` (恐惧) status suspends the route: the
// Unblocked ranged enemies attack allies within their radius and stand for each attack's clip — through its wind-up
// and until the clip ends — then walk on (attackStand, GitHub #58; ATTACK_PAUSE after the strike when no clip is known;
// 「不停止移动」 attackers never stop); the candidates pass the enemy's own rule (`e.profile.canTarget`) and are ordered
// blocker → taunt → latest deployed (targeting.js sortAllyTargets). An enemy's damage type is its data's unless content
// arms it (`e.profile.dmgType`: 转译基底's forms, whose data never attacks). Reaching the final leg's end = leak. A `fear` (恐惧) status suspends the route: the
// enemy runs between random checkpoints away from the fear's source (fear.js moveFeared; a self-inflicted fear
// flutters inside its own tile); an `attract` (诱导) status walks it to the status point instead (moveAttracted);
// both re-plan the route when released (恐惧 outranks 诱导).
@@ -449,9 +450,11 @@ export function updateEnemy(b, e, dt) {
// hidden (teleporting) enemies only advance wait legs
const stunned = e.s.flags.stun;
if (e.atkCd > 0 && !stunned && !e.hidden) e.atkCd = Math.max(0, e.atkCd - dt);
if (!e.hidden && !stunned) enemyAttack(b, e);
// true: an unblocked ranged enemy in the wind-up of its next attack with a target in range (it stands)
const winding = !e.hidden && !stunned && enemyAttack(b, e);
if (!e.alive) return;
if (stunned && !e.hidden) return;
// a stun / freeze / sleep cuts the attack clip short: no stand left once it ends [ASSUMED]
if (stunned && !e.hidden) { e.atkStandUntil = -Infinity; return; }
if (e.blockedBy) {
const bl = e.blockedBy;
// (unblockable/levitate/fear may also arrive through a plain addBuff, which does not unblock by itself; a
@@ -462,14 +465,18 @@ export function updateEnemy(b, e, dt) {
}
if (!e.hidden && b._checkBlock(e)) return;
if (b.time < e.pauseUntil) return;
// standing for an attack clip (attackStand, GitHub #58): only the walking waits — a checkpoint's WAIT keeps running
// and DISAPPEAR / APPEAR legs still happen (advanceRoute); drawn idle (the client plays the clip, then Move again);
// a 恐惧 runs at once (it cannot attack)
const standing = winding || (b.time < e.atkStandUntil && !e.s.flags.fear);
if (e.s.flags.noMove) { e.moving = false; return; } // standing (a 重生, a form change): drawn idle, not walking
// 恐惧 (ba.fear "无法被阻挡并四散逃跑"; PRTS 诱发移动: 恐惧 outranks 诱导): runs to random tiles of the fan away from
// its source — a self-inflicted fear flutters inside its own tile (fear.js); the route re-plans once it ends
if (e.s.flags.fear && !e.hidden) { moveFeared(b, e, dt); return; }
if (e.mem.fearMove) endFear(e);
// 诱导 (ba.attract "无法被阻挡并向目标位置移动"): walks to the attract point instead of following its route
if (e.s.flags.attract) { moveAttracted(b, e, dt); return; }
advanceRoute(b, e, dt, R);
if (e.s.flags.attract) { if (standing) e.moving = false; else moveAttracted(b, e, dt); return; }
advanceRoute(b, e, dt, R, standing);
}
/**
@@ -514,7 +521,11 @@ function moveAttracted(b, e, dt) {
if (moved && e.route) e.route.pts = null;
}
function advanceRoute(b, e, dt, R) {
/**
* One tick along the route legs. `standing` (an attack clip, attackStand): the time-based legs go on — a WAIT runs
* down, DISAPPEAR / APPEAR happen (leaving the field ends the clip) — and a MOVE leg holds (GitHub #58 review).
*/
function advanceRoute(b, e, dt, R, standing = false) {
let budget = dt;
let guard = 16;
while (budget > 1e-9 && guard-- > 0 && e.alive) {
@@ -531,6 +542,7 @@ function advanceRoute(b, e, dt, R) {
}
if (leg.t === 'disappear') {
b._setHidden(e, true);
e.atkStandUntil = -Infinity; standing = false; // off the field: its attack clip is over
R.legIdx++; R.pts = null;
continue;
}
@@ -541,6 +553,7 @@ function advanceRoute(b, e, dt, R) {
continue;
}
// move
if (standing) { e.moving = false; return; } // the walking waits for the attack clip
if (!R.pts || R.version !== b.grid.version) planLeg(b, e, leg);
const speed = e.s.moveSpeed * MOVE_SCALE;
if (speed <= 0) { e.moving = false; return; }
@@ -573,15 +586,48 @@ function advanceRoute(b, e, dt, R) {
}
}
/**
* How long an unblocked ranged enemy stands for one attack (GitHub #58 — the owner's decision of 2026-10-04 from
* first-hand memory of the official game: a ranged enemy stops for each attack's animation and walks on between
* attacks; the handbook names attacking on the move as a special ability, “十字路口”量产型's 「不停止移动的四向攻击」).
* [ASSUMED] the stand lasts exactly its attack clip — data/enemies.json `attackAnim` { dur, hit }: the clip the client
* plays for its attacks and its strike frame (tools/build-data.mjs, from the asset manifest) —, `hit` of it before
* the strike (the wind-up, while a target is in range) and the rest after it, both shortened when the attacks come
* quicker than the clip (it then plays faster: render/spine.js, the same rule) — no source gives a length. An enemy in
* another form (掠海漂移体's crawl, 转译基底's forms, 杰斯顿's second form) stands for its base clip [ASSUMED]; known
* mismatches: 扎罗's second form stands 1.433 s (A_Attack) though its B_Attack lasts 2.0 s (2.5 s interval: ~0.4 s of
* walking during the recovery), 转译基底's 特战术师 stands 2.0 s (B_Attack) though its D_Attack lasts 1.667 s — a per-form
* clip needs the client's FORMS mapping (render/units.js) in shared data.
* Returns the wind-up and rest (s) into `out`: an enemy with no attack clip known stands ATTACK_PAUSE after the strike
* (the old rule), a 「不停止移动」 one (`attackMoves`: data or content) never stops.
*/
export function attackStand(e, out = { wind: 0, rest: 0 }) {
out.wind = 0; out.rest = 0;
if (e.profile?.attackMoves ?? e.def?.attackMoves) return out;
const a = e.def?.attackAnim;
if (!a || !(a.dur > 0)) { out.rest = ATTACK_PAUSE; return out; }
const iv = e.s.interval;
const speed = iv > 0 && iv < a.dur ? a.dur / iv : 1;
const hit = Number.isFinite(a.hit) ? Math.min(a.dur, Math.max(0, a.hit)) : a.dur / 2;
out.wind = hit / speed;
out.rest = (a.dur - hit) / speed;
return out;
}
const STAND = { wind: 0, rest: 0 };
/**
* One tick of an enemy's attack: attacks when its cooldown is over and a target is in reach. Returns true while an
* unblocked ranged enemy is in the wind-up of its next attack (cooldown ≤ its clip's wind-up, attackStand) with a
* target in range: it stands (updateEnemy).
*/
function enemyAttack(b, e) {
const def = e.def;
if (e.profile && e.profile.noAttack) return;
if (e.profile && e.profile.noAttack) return false;
const dmgType = (e.profile && e.profile.dmgType) || def.dmgType;
if (dmgType === 'none' || e.s.atk <= 0) return;
if (e.s.flags.fear || e.s.flags.disarm) return;
if (e.s.flags.tremble && e.blockedBy) return; // 战栗: 被阻挡后无法进行普通攻击
if (e.atkCd > 0) return;
if (dmgType === 'heal') { enemyHeal(b, e, e.base.rangeRadius); return; }
if (dmgType === 'none' || e.s.atk <= 0) return false;
if (e.s.flags.fear || e.s.flags.disarm) return false;
if (e.s.flags.tremble && e.blockedBy) return false; // 战栗: 被阻挡后无法进行普通攻击
if (dmgType === 'heal') { if (e.atkCd <= 0) enemyHeal(b, e, e.base.rangeRadius); return false; }
// applyWay MELEE enemies only ever hit their blocker, even when their data carries a rangeRadius (粉碎攻坚手 2.5,
// 宿主士兵 2.5, 深池伙友卫队 1.4 … — that radius belongs to their abilities/splash, handled by content).
// Content may flip it with `e.profile.melee = false`.
@@ -592,6 +638,11 @@ function enemyAttack(b, e) {
// `e.profile.canTarget(ally)`: the enemy's own target rule (只攻击地面单位, 不会攻击飞行单位 …; content/enemies.js),
// applied to the candidates before the priority sort and the target count
const own = e.profile && typeof e.profile.canTarget === 'function' ? e.profile.canTarget : null;
if (e.atkCd > 0) {
// the wind-up of the next attack (GitHub #58): an unblocked ranged enemy stands once a target is in range
if (e.blockedBy || radius <= 0 || !(e.atkCd <= attackStand(e, STAND).wind + 1e-9)) return false;
return b.alliesInRadius(e.x, e.y, reach, null).some((a) => canTargetAlly(e, a, true) && (!own || own(a)));
}
let targets = [];
if (e.blockedBy) {
const bl = e.blockedBy;
@@ -604,15 +655,16 @@ function enemyAttack(b, e) {
}
if (own && targets.length) targets = targets.filter((a) => own(a));
if (targets.length > 1) sortAllyTargets(e, targets);
if (!targets.length) return;
if (!targets.length) return false;
// 麻痹 (ba.palsy): each stack interrupts one normal attack
const palsy = e.buffs.length ? e.findBuff('palsy') : null;
if (palsy) {
if (--palsy.stacks <= 0) b.removeBuff(e, palsy); else e.markDirty();
e.atkCd = e.s.interval;
if (!e.blockedBy && radius > 0) e.pauseUntil = b.time + ATTACK_PAUSE;
// the interrupted attack ends its clip: the old short stand after it (PRTS 异常效果 麻痹: 0.5 s 麻痹震颤 — not modelled)
if (!e.blockedBy && radius > 0 && !(e.profile?.attackMoves ?? def.attackMoves)) e.atkStandUntil = b.time + ATTACK_PAUSE;
b.fx('palsy', { x: e.x, y: e.y, id: e.id });
return;
return false;
}
const n = Math.max(1, Math.floor(e.profile?.maxTargets ?? 1) + Math.floor(e.s.maxTargets));
if (targets.length > n) targets = targets.slice(0, n);
@@ -620,7 +672,7 @@ function enemyAttack(b, e) {
const ctx = { attacker: e, targets, isSkill: false, profile: e.profile };
b.emit('beforeAttack', ctx);
targets = (ctx.targets || []).filter((t) => t && t.alive);
if (!targets.length || !e.alive) return;
if (!targets.length || !e.alive) return false;
}
e.lastAttackAt = b.time;
e.stats.attacks++;
@@ -644,7 +696,9 @@ function enemyAttack(b, e) {
}
if (b._hooks.attack) b.emit('attack', { attacker: e, targets, isSkill: false });
e.atkCd = e.s.interval;
if (!e.blockedBy && radius > 0) e.pauseUntil = b.time + ATTACK_PAUSE;
// stands for the rest of its attack clip (attackStand; the wind-up was stood before the strike)
if (!e.blockedBy && radius > 0) e.atkStandUntil = b.time + attackStand(e, STAND).rest;
return false;
}
/** Enemy healers (dmgType 'heal'): heal the lowest-HP% other enemy within their radius. */
+5 -1
View File
@@ -13,7 +13,11 @@ export const COLS = GEO.COLS;
/** tilesPerSecond = moveSpeed × MOVE_SCALE (DESIGN §3). */
export const MOVE_SCALE = 0.5;
/** Ranged enemies stop moving this long after each attack (DESIGN §5.5). */
/**
* An unblocked ranged enemy stands for each attack's clip (ai.js attackStand, GitHub #58); one whose model has no attack
* clip known (no `attackAnim` in data/enemies.json) stands this long after each attack instead, as do all after an
* attack 麻痹 interrupts (DESIGN §5.5).
*/
export const ATTACK_PAUSE = 0.35;
/**
* Collider radius of an allied unit (PRTS 作战机制 §碰撞体积与位置识别: "我方干员碰撞体积基本均为以0.25格为半径的圆形" —
+3 -2
View File
@@ -1742,8 +1742,9 @@ function kitRoar(ab) {
* ×boomb.move_speed, and it flies on. A stun / freeze / sleep before the release interrupts the cast (Boomb
* `_immuneStunWhenAffecting` 0, like the other enemy channels here): nothing leaves the drone, it keeps its bomb and casts
* again after the skill's cooldown (1 s, counted from the interrupt) once it is free and an operator is in range.
* [ASSUMED] no drop when the drone is dead at the release; the ATK at the release; the hover through the cast (the
* engine pauses an enemy for its attack clip, ATTACK_PAUSE; the drop is the drone's only attack-like cast); the
* [ASSUMED] no drop when the drone is dead at the release; the ATK at the release; the hover through the cast (its own
* hold, `pauseUntil`: the drone has no normal attack — `noAttack` —, so ai.js attackStand, the stand of a ranged enemy
* for its attack clip, never runs for it; the drop is its only attack-like cast); the
* bomb-less look from the release (the bomb leaves the drone on that frame of the Attack clip; at the cast end — 1 tick
* before the clip ends — the client would draw the bomb back for a frame); an interrupted cast does not use up the one
* trigger (`_maxTriggerTime` 1 — the bomb is still on the drone); a stun after the release does not stop the cast end.
+3 -1
View File
@@ -1117,7 +1117,9 @@ const KITS = {
// while 余烬 is unused). "触发本天赋后,获得禁疗与不死": 不死 = every later lethal blow is prevented (`mem.ember`);
// 禁疗 (异常效果 HEAL_FREE "无法成为治疗类能力的目标,且受到的治疗量变为0", an HP-regen attribute excepted) = flags
// noHeal (no heal pick, no heal from others) + healFree (her own heals too; S3's start heal "无视禁疗"), shown as
// the status 'healFree' until she leaves. "强制退出战场视为撤回干员": a retreat (Battle.retreat drops the buff).
// the status 'healFree' until she leaves. "强制退出战场视为撤回干员": a retreat (Battle.retreat drops the buff) — she
// lies down where she stood and redeploys there, like every operator that leaves the field (PRTS 卫戍协议/帮助
// "干员退场后…原地留下一个“倒地干员”…自动部署至该位置"; Battle.isDown, GitHub #60).
const wait = num(t1['surtr_t_2[withdraw].interval'], 8);
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.ember = false; }, { owner: unit });
battle.on('fatal', (c) => {
+10
View File
@@ -225,6 +225,14 @@ export function immuneSet(v, extra = null) {
return out;
}
/** data/enemies.json `attackAnim` → { dur, hit } (hit null = none: half the clip), or null when malformed / absent. */
function normAttackAnim(a) {
const dur = Number(a?.dur);
if (!(Number.isFinite(dur) && dur > 0)) return null;
const hit = Number(a.hit);
return { dur, hit: a.hit != null && Number.isFinite(hit) ? Math.min(dur, Math.max(0, hit)) : null };
}
/** Normalise an enemy record (research 05 shape or data/enemies.json shape) into an EnemyDef. */
export function normalizeEnemy(key, e) {
const st = e.stats ?? e;
@@ -262,6 +270,8 @@ export function normalizeEnemy(key, e) {
notCountInTotal: !!(e.notCountInTotal),
hitArea: normHitArea(e.hitArea), // huge units only (body.js); null = a point
staticBody: !!e.staticBody, // 静态刚体: pushes / pulls never move it (Battle._displaceable)
attackAnim: normAttackAnim(e.attackAnim), // its attack clip { dur, hit } (ai.js attackStand); null = none known
attackMoves: !!e.attackMoves, // 「不停止移动」: never stops to attack (ai.js attackStand)
tags: e.tags ?? [],
abilities: e.abilities ?? [],
skills: e.skills ?? [],
@@ -0,0 +1,102 @@
// test/render/feedback2-enemy-attack-clip.test.js — GitHub #58, client side: an enemy plays its attack clip once per
// attack at the clip's own speed (faster only when the attacks come quicker than the clip) and then its resting clip —
// Move while the sim walks it again (server/sim/ai.js attackStand stands it for exactly that clip). It used to stretch
// the clip over the whole attack interval and loop it for 1.4 intervals, so a ranged enemy seemed to slide while it
// attacked. Operators keep the clip looping over their attack rhythm. Headless fake PIXI (test/render/fakepixi.js).
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { ANIM } from '../../shared/constants.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
const JSHOOT = 'enemy_1019_jshoot'; // 隐形弩手: Attack 1.0 s, strike at 0.533 s; attacks every 2.7 s
let fake, SpineActor, UnitView;
before(async () => {
fake = installFakePixi();
({ SpineActor } = await import('../../public/js/render/spine.js'));
({ UnitView } = await import('../../public/js/render/units.js'));
});
after(() => fake.restore());
const entryOf = (id) => assets.enemies[id].spine;
const dataOf = (entry) => ({ animations: Object.keys(entry.animations).map((name) => ({ name })) });
function actor(id, perAttack) {
const entry = entryOf(id);
const a = new SpineActor(dataOf(entry), entry);
a.clipPerAttack = perAttack;
a.setBase('move');
return a;
}
const track = (a) => a.spine.state.tracks[0];
const run = (a, s, dt = 1 / 60) => { for (let t = 0; t < s - 1e-9; t += dt) a.update(dt); };
test('an enemy: wound up before the strike, the Attack clip plays once at its own speed, then Move again — no stretching over the interval', () => {
const a = actor(JSHOOT, true);
assert.equal(a.current, 'Move');
assert.ok(a.windUp(2.7, 0.2), 'winds up 0.2 s before the strike');
assert.equal(a.current, 'Attack');
assert.equal(track(a).loop, false, 'one clip per attack');
run(a, 0.2);
a.attack(2.7);
assert.equal(track(a).timeScale, 1, 'its own speed (the 2.7 s interval used to slow it to 0.37)');
run(a, 0.45);
assert.equal(a.current, 'Attack', 'the clip plays to its end (0.467 s after the strike)');
run(a, 0.05);
assert.equal(a.current, 'Move', 'then walks on its Move clip (it used to hold the attack for 1.4 intervals: 3.8 s)');
// the next attack starts the clip anew
run(a, 1.5);
assert.ok(a.windUp(2.7, 0.3));
assert.equal(a.current, 'Attack');
run(a, 0.3);
a.attack(2.7);
run(a, 0.5);
assert.equal(a.current, 'Move');
});
test('an enemy attacking quicker than its clip plays it faster, one clip per attack; an attack seen late starts on the strike frame', () => {
const a = actor(JSHOOT, true);
a.attack(0.5); // no wind-up seen: from the strike frame, at 1.0 / 0.5 = 2×
assert.equal(a.current, 'Attack');
assert.equal(track(a).loop, false);
assert.equal(track(a).timeScale, 2);
assert.ok(Math.abs(track(a).trackTime - 0.533) < 1e-9, 'on the strike frame');
run(a, 0.25);
assert.equal(a.current, 'Move', 'the rest of the clip at 2×: 0.23 s');
});
test('an operator keeps its attack loop stretched over the attack rhythm (unchanged)', () => {
const a = actor(JSHOOT, false);
a.attack(2.7);
assert.equal(track(a).loop, true);
assert.ok(Math.abs(track(a).timeScale - 1 / 2.7) < 1e-9);
run(a, 2);
assert.equal(a.current, 'Attack', 'still in attack mode 2 s later (1.4 × the interval)');
});
test('UnitView: enemy models play a clip per attack, operator models do not', async () => {
const entry = entryOf(JSHOOT);
const store = { picture: () => null, image: async () => null, spineEntry: () => entry, spine: { acquire: async () => dataOf(entry), release() {} } };
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
const tick = () => new Promise((r) => setImmediate(r));
const make = async (side) => {
const v = new UnitView(fakeViewCtx(fake.P, { assets: store, cam }), { id: side === 'enemy' ? 7 : 8, side, kind: side === 'enemy' ? 'enemy' : 'op', defId: JSHOOT, spine: JSHOOT, tier: 1, x: 8, y: 9, maxHp: 1000, facing: -1 });
await tick(); await tick();
assert.ok(v.actor, 'model built');
return v;
};
const e = await make('enemy');
assert.equal(e.actor.clipPerAttack, true);
e.sync({ x: 8, y: 9, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: ANIM.MOVE, vx: 0 }, 1);
e.onAttack(null, 1);
assert.equal(e.actor.current, 'Attack');
assert.equal(e.actor.spine.state.tracks[0].loop, false);
const o = await make('ally');
assert.equal(o.actor.clipPerAttack, false);
});
+8 -6
View File
@@ -50,23 +50,25 @@ test('a knocked-out operator is `down` until it redeploys: its timer, then waiti
assert.ok(h.eventsOf('deploy').some((e) => e[1] === a.id));
});
test('summons, devices, enemies and withdrawn / removed operators are never `down`', () => {
test('summons, devices, enemies, operators removed for good and the 突袭 retreat are never `down`; an operator forced out is (GitHub #60)', () => {
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 }],
defs: { chess: { t_a: op('t_a'), t_b: op('t_b'), t_c: op('t_c'), t_d: op('t_d') }, 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 }, { chessId: 't_d', row: 9, col: 6 }],
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.retreat(h.unit('t_d'), { reason: 'raid' }); // 突袭: redeployed at once on its landing tile (here: nowhere)
h.b.kill(h.enemy('enemy_dummy'));
assert.equal(h.snapshot().down, undefined);
// PRTS 卫戍协议/帮助 "干员退场后…原地留下一个“倒地干员”…": a forced exit (史尔特尔's 余烬 …) lies down like a knock-out
h.b.retreat(h.unit('t_a'), { reason: 'retreat' });
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');
assert.deepEqual(h.snapshot().down.map((d) => d[0]), [h.unit('t_a').id, h.unit('t_c').id], 'the forced-out and 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));
assert.ok(ids.has(h.unit('t_c').id) && ids.has(h.unit('t_a').id) && !ids.has(h.unit('t_b').id) && !ids.has(h.unit('t_d').id) && !ids.has(tok.id));
});
test('carryState.down (联防): deployed with everyone, forced out at once — down on its tile without the knock-out hooks — then back after its timer', () => {
+4 -2
View File
@@ -392,7 +392,8 @@ test('redeploy: { tile } lands on another tile (home unchanged; knocked out ther
approx(atDeploy, sp, 1e-9, 'SP restored before the deploy hook');
approx(u.hp, u.s.maxHp);
// plain redeploy: initSp; knocked out there later → the auto-redeploy comes back on that tile (PRTS 卫戍协议/帮助
// "…原地留下一个“倒地干员”…满足再部署条件时…自动部署至该位置"; player report F5 after 0.1.0); a retreat → home
// "…原地留下一个“倒地干员”…满足再部署条件时…自动部署至该位置"; player report F5 after 0.1.0); a retreat too (every 退场:
// GitHub #60)
h.b.retreat(u);
h.b.redeploy(u, { tile: [12, 8] });
approx(u.skill.sp, 2, 1e-9, 'without keepSp: initial SP');
@@ -402,8 +403,9 @@ test('redeploy: { tile } lands on another tile (home unchanged; knocked out ther
assert.ok(h.runUntil(() => u.alive, u.base.respawnTime + 1));
assert.deepEqual([u.tileR, u.tileC], [12, 8], 'auto-redeploy on the tile it lay on');
h.b.retreat(u);
assert.deepEqual(h.b.snapshot().down?.map((d) => d.slice(4)), [[12, 8]], 'a retreated operator lies where it stood');
assert.equal(h.b.redeploy(u), true);
assert.deepEqual([u.tileR, u.tileC], [10, 4], 'a retreated unit comes back on its home tile');
assert.deepEqual([u.tileR, u.tileC], [12, 8], 'and comes back there, not on its home tile (GitHub #60)');
// paid redeploy on a tile
h.b.retreat(u);
const dp = p.dp;
@@ -0,0 +1,176 @@
// test/sim/feedback2-enemy-attack-stand.test.js — GitHub #58 「1.1中敌方远程攻击单位在攻击时任会移动」: an unblocked ranged enemy
// stands for each attack's clip — through its wind-up before the strike and the rest of the clip after it — and walks
// on between attacks (the owner's decision of 2026-10-04 from first-hand memory; the clip = data/enemies.json
// `attackAnim`, tools/build-data.mjs from the asset manifest; server/sim/ai.js attackStand). It used to stop only
// ATTACK_PAUSE (0.35 s) after each strike while the client played the attack clip over the whole walk. An enemy with no
// clip known keeps the old 0.35 s (test/sim/combat.test.js); a 「不停止移动」 attacker never stops; a stun cuts the stand.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { makeBattle, enemyRec, chessRec } from '../helpers/battleHarness.js';
import { TICK, ATTACK_PAUSE } from '../../server/sim/constants.js';
import { attackStand } from '../../server/sim/ai.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const readJson = (f) => JSON.parse(readFileSync(path.join(ROOT, f), 'utf8'));
/** Positions per tick and attack times of the first enemy, `seconds` long. */
function trace(h, seconds) {
const log = [], atks = [];
h.b.on('attack', (c) => { if (c.attacker.side === 'enemy') atks.push(h.b.time); });
for (let i = 0; i < Math.round(seconds / TICK); i++) {
h.step();
const e = h.enemies()[0];
if (!e) break;
log.push({ t: h.b.time, x: e.x, y: e.y, moving: e.moving });
}
return { log, atks };
}
/** Distance walked between game times t0 and t1 (ticks after t0 up to t1). */
function walked(log, t0, t1) {
let d = 0;
for (let i = 1; i < log.length; i++) if (log[i].t > t0 + 1e-9 && log[i].t <= t1 + 1e-9) d += Math.hypot(log[i].x - log[i - 1].x, log[i].y - log[i - 1].y);
return d;
}
test('data: every enemy with a real attack clip carries it as attackAnim (the clip the client plays, its length and strike frame)', () => {
const enemies = readJson('data/enemies.json'), assets = readJson('data/assets.json');
let n = 0;
for (const [k, e] of Object.entries(enemies)) {
const sp = assets.enemies[e.spine || k]?.spine;
const a = sp?.anims?.attack;
const real = !!a && typeof a.loop === 'string' && a.via !== 'idle' && sp.animations[a.loop] > 0;
assert.equal(!!e.attackAnim, real, `${k} ${e.name}`);
if (!real) continue;
n++;
assert.equal(e.attackAnim.clip, a.loop, k);
assert.equal(e.attackAnim.dur, sp.animations[a.loop], k);
const h = sp.hits?.[a.loop];
if (Array.isArray(h) && h.length) assert.equal(e.attackAnim.hit, h[0], k);
assert.equal(e.attackMoves, undefined, `${k}: no enemy of the mode attacks on the move`);
}
assert.ok(n > 200, `${n} enemies`);
// the ranged attackers of the report
assert.deepEqual(enemies.enemy_1019_jshoot.attackAnim, { clip: 'Attack', dur: 1, hit: 0.533 });
assert.deepEqual(enemies.enemy_1168_dumage.attackAnim, { clip: 'Attack', dur: 1.2, hit: 0.633 });
});
test('隐形弩手 with an operator in range stands for its 1.0 s Attack clip at every attack (0.533 s wind-up + 0.467 s), then walks', () => {
const h = makeBattle({ units: [{ chessId: 'chess_char_3_01_a', row: 10, col: 6, dir: 'RIGHT' }], enemies: [{ key: 'enemy_1019_jshoot', time: 0, route: 0 }],
seed: 3, timeLimit: 40, autoFinish: false, hooks: ['attack'] });
const { log, atks } = trace(h, 16);
assert.ok(atks.length >= 4, `${atks.length} attacks`);
const e = h.enemy('enemy_1019_jshoot');
const iv = e.s.interval;
assert.ok(Math.abs(iv - 2.7) < 1e-9);
for (const t of atks.slice(1, 3)) {
// the wind-up (the strike frame at 0.533 s of the clip) and the rest of the clip: no step
assert.equal(walked(log, t - 0.533 + 2 * TICK, t), 0, `stands through the wind-up before the attack at ${t.toFixed(2)}`);
assert.equal(walked(log, t, t + 0.467 - TICK), 0, `stands for the rest of the clip after ${t.toFixed(2)}`);
assert.ok(!log.find((r) => Math.abs(r.t - (t + 0.3)) < TICK / 2).moving, 'drawn standing (not on the move clip)');
// then it walks until the next wind-up: 2.7 − 1.0 = 1.7 s at 0.9 × 0.5 tiles/s
const d = walked(log, t + 0.467 + 2 * TICK, t + iv - 0.533 - TICK);
assert.ok(d > 0.6 && d < 0.8, `walks between the attacks (${d.toFixed(2)} tiles)`);
}
});
test('the stand shortens with the clip when the attacks come quicker than the clip: an enemy attacking every 1 s with a 2 s clip never walks while it has a target', () => {
const quick = { ...enemyRec({ key: 'enemy_quick', hp: 1e9, atk: 1, range: 2.5, bat: 1, speed: 1 }), attackAnim: { clip: 'Attack', dur: 2, hit: 1 } };
const st = attackStand({ def: { attackAnim: { dur: 2, hit: 1 } }, s: { interval: 1 }, profile: {} });
assert.deepEqual(st, { wind: 0.5, rest: 0.5 });
const h = makeBattle({
defs: { chess: { t_a: chessRec({ id: 't_a', stats: { maxHp: 1e9, atk: 0 } }) }, enemies: { enemy_quick: quick } },
units: [{ chessId: 't_a', row: 10, col: 6 }], enemies: [{ key: 'enemy_quick', pos: [9, 8] }], content: 'none', autoFinish: false, timeLimit: 60, hooks: ['attack'],
});
const { log, atks } = trace(h, 6);
assert.ok(atks.length >= 5, `${atks.length} attacks`);
assert.equal(walked(log, atks[0], atks.at(-1)), 0, 'stands from the first attack on');
});
test('a 「不停止移动」 attacker (attackMoves) keeps walking through its attacks', () => {
const rec = (attackMoves) => ({ ...enemyRec({ key: 'enemy_mover', hp: 1e9, atk: 1, range: 2.5, bat: 2, speed: 1 }), attackAnim: { clip: 'Attack', dur: 1.5, hit: 0.5 }, ...(attackMoves ? { attackMoves: true } : {}) });
const run = (attackMoves) => {
const h = makeBattle({
defs: { chess: { t_a: chessRec({ id: 't_a', stats: { maxHp: 1e9, atk: 0 } }) }, enemies: { enemy_mover: rec(attackMoves) } },
units: [{ chessId: 't_a', row: 10, col: 6 }], enemies: [{ key: 'enemy_mover', time: 0, route: 0 }], content: 'none', autoFinish: false, timeLimit: 60, hooks: ['attack'],
});
return trace(h, 12);
};
const moving = run(true), standing = run(false);
assert.ok(moving.atks.length >= 3 && standing.atks.length >= 3);
for (const t of moving.atks.slice(1, 3)) assert.ok(walked(moving.log, t - 0.5, t + 1) > 0.6, `walks through the attack at ${t.toFixed(2)}`);
// (the first attack lands as the target comes into range, its cooldown long over: no wind-up to stand through)
for (const t of standing.atks.slice(1, 3)) assert.equal(walked(standing.log, t - 0.5 + 2 * TICK, t + 1 - TICK), 0, 'the same enemy without it stands');
});
test('no attack clip known: ATTACK_PAUSE after the strike and no wind-up stand; a stun cuts a stand short', () => {
assert.deepEqual(attackStand({ def: {}, s: { interval: 2 }, profile: {} }), { wind: 0, rest: ATTACK_PAUSE });
assert.deepEqual(attackStand({ def: { attackMoves: true, attackAnim: { dur: 2, hit: 1 } }, s: { interval: 2 }, profile: {} }), { wind: 0, rest: 0 });
const slow = { ...enemyRec({ key: 'enemy_slow', hp: 1e9, atk: 1, range: 2.5, bat: 5, speed: 1 }), attackAnim: { clip: 'Attack', dur: 3, hit: 0.5 } };
const h = makeBattle({
defs: { chess: { t_a: chessRec({ id: 't_a', stats: { maxHp: 1e9, atk: 0 } }) }, enemies: { enemy_slow: slow } },
units: [{ chessId: 't_a', row: 10, col: 6 }], enemies: [{ key: 'enemy_slow', pos: [9, 8] }], content: 'none', autoFinish: false, timeLimit: 60, hooks: ['attack'],
});
h.step();
const e = h.enemy('enemy_slow');
assert.ok(e.lastAttackAt >= 0, 'attacked at once');
assert.ok(Math.abs(e.atkStandUntil - (h.b.time - TICK + 2.5)) < 1e-6, 'stands the 2.5 s after the strike');
h.run(0.5);
const x0 = e.x;
h.b.applyStatus(e, 'stun', { duration: 0.5 });
h.run(0.6);
assert.equal(e.atkStandUntil, -Infinity, 'the stun ended the stand');
h.run(0.3);
assert.notEqual(e.x, x0, 'walks once the stun is over');
});
// review: a stand holds only the walking — a route's WAIT keeps running and DISAPPEAR / APPEAR still happen
// (data/waves.json has 122 WAIT and 99 DISAPPEAR checkpoints); it used to hold every leg, so a wait ran long by each
// clip and never ended for an enemy attacking quicker than its clip
function routeField(key, bat, dur, checkpoints, ally) {
const rec = { ...enemyRec({ key, hp: 1e9, atk: 1, range: 3, bat, speed: 1 }), attackAnim: { clip: 'Attack', dur, hit: dur / 2 } };
return makeBattle({
defs: { chess: { t_a: chessRec({ id: 't_a', stats: { maxHp: 1e9, atk: 0, def: 1e6 } }) }, enemies: { [key]: rec } },
units: ally ? [{ chessId: 't_a', row: 10, col: 7 }] : [], routes: [{ motion: 'WALK', start: [9, 8], end: [9, 2], checkpoints }],
enemies: [{ key, time: 0, route: 0 }], content: 'none', autoFinish: false, timeLimit: 60, hooks: ['attack'],
});
}
test('a ranged enemy with a target in range on a WAIT checkpoint leaves when the wait (and its current clip) is over, as without a target', () => {
const leaves = (bat, dur, ally) => {
const h = routeField('enemy_waiter', bat, dur, [{ type: 'WAIT', time: 3 }], ally);
let waited = null, moved = null, x0 = null;
for (let i = 0; i < Math.round(8 / TICK); i++) {
h.step();
const e = h.enemies()[0];
x0 ??= e.x;
if (waited == null && e.route.legIdx >= 1) waited = h.b.time;
if (moved == null && Math.abs(e.x - x0) > 1e-6) moved = h.b.time;
}
return { waited, moved, attacks: h.hooksOf('attack').length };
};
const free = leaves(2.7, 1, false), sniper = leaves(2.7, 1, true), quick = leaves(1, 2, true);
assert.equal(free.attacks, 0);
assert.ok(sniper.attacks >= 2 && quick.attacks >= 5);
assert.ok(Math.abs(sniper.waited - free.waited) < 1e-9 && Math.abs(quick.waited - free.waited) < 1e-9, `the wait ends at ${free.waited.toFixed(2)} s for all three`);
// it walks on once the attack clip running at the end of the wait is over (the attack at 2.7 s: until 3.2 s)
assert.ok(sniper.moved > free.moved && sniper.moved <= 3.2 + 2 * TICK, `leaves at ${sniper.moved?.toFixed(2)} s (no target: ${free.moved.toFixed(2)} s)`);
assert.equal(quick.moved, null, 'an enemy attacking quicker than its clip stands while the target stays in range');
});
test('DISAPPEAR / APPEAR legs proceed while an enemy stands for its attacks; leaving the field ends the stand', () => {
const h = routeField('enemy_ghost', 1, 2, [{ type: 'WAIT', time: 1 }, { type: 'DISAPPEAR' }, { type: 'WAIT', time: 1 }, { type: 'APPEAR', pos: [9, 4] }], true);
let hiddenAt = null, appeared = null;
for (let i = 0; i < Math.round(4 / TICK); i++) {
h.step();
const e = h.enemies()[0];
if (hiddenAt == null && e.hidden) { hiddenAt = h.b.time; assert.equal(e.atkStandUntil, -Infinity, 'no stand left once hidden'); }
if (appeared == null && hiddenAt != null && !e.hidden) appeared = { t: h.b.time, x: e.x, y: e.y };
}
assert.ok(h.hooksOf('attack').length >= 1, 'it attacked before leaving');
assert.ok(hiddenAt != null && Math.abs(hiddenAt - 1) < 2 * TICK, `disappears after its 1 s wait (${hiddenAt})`);
assert.ok(appeared && Math.abs(appeared.t - 2) < 2 * TICK && Math.abs(appeared.x - 4) < 0.05 && appeared.y === 9, `appears on (9,4) 1 s later (${JSON.stringify(appeared)})`);
});
+103
View File
@@ -0,0 +1,103 @@
// test/sim/feedback2-forced-exit-down.test.js — GitHub #60 「42突袭死后会消失」 (42 = 史尔特尔): every operator that leaves
// the field lies down on its tile and redeploys there — PRTS 卫戍协议/帮助 §作战阶段 单位部署: "干员退场后,将返回隐藏的待部署区,
// 并原地留下一个“倒地干员”以供查看信息,满足再部署条件时,移除场上的该倒地干员并自动部署至该位置。" (the owner's decision of
// 2026-10-04). A forced exit — 史尔特尔's 余烬 (8 s after the lethal blow), 耀骑士临光 S2's withdrawal, the 骑士戒律 + 竞技旗
// combo, 伊内丝 S3's first deployment, a 商人 that cannot pay — used to leave no body and no ring and to come back on the
// board tile (Battle.isDown took only 'killed' / FORCED_EXIT); it still is no kill. The 突袭 retreat ('raid') redeploys at
// once and leaves nothing; a real knock-out is unchanged.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { makeBattle, enemyRec } from '../helpers/battleHarness.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const chess = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
const SURTR = 'chess_char_5_07_a', NEARL2 = 'chess_char_6_17_a', INSIDE = 'chess_char_1_01_a';
const DUMMY = { enemy_far: enemyRec({ key: 'enemy_far', hp: 1e9, speed: 0, atk: 0 }) };
const approx = (a, b, msg, eps = 0.05) => assert.ok(Math.abs(a - b) <= eps, `${msg}: ${a} vs ${b}`);
const RAID = { raidShip: { count: 2, active: true, tier: 1, layers: 10 } };
function field(units, bonds = {}) {
return makeBattle({ defs: { enemies: DUMMY }, units, bonds, enemies: [{ key: 'enemy_far', pos: [12, 8] }], autoFinish: false, timeLimit: 300, hooks: ['death', 'kill', 'deploy'] });
}
const downOf = (h, u) => (h.b.snapshot().down || []).find((d) => d[0] === u.id) ?? null;
/** Lethal blow on 史尔特尔: 余烬 holds her at 1 HP, then forces her out 8 s later. */
function ember(h, u) {
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.ok(u.alive && u.hp >= 1, '余烬 holds her at 1 HP');
assert.ok(h.runUntil(() => !u.alive, 10), 'forced out');
assert.equal(u.removeReason, 'retreat');
}
test('史尔特尔 after 余烬 lies down on her tile (b.snap down, field meta) and redeploys there after 70 s — no kill', () => {
const h = field([{ chessId: SURTR, row: 10, col: 3, dir: 'RIGHT' }]);
const u = h.unit(SURTR);
h.run(1);
ember(h, u);
assert.ok(h.b.isDown(u));
const d = downOf(h, u);
assert.ok(d, 'the 倒地干员 and its redeploy ring');
assert.deepEqual(d.slice(4), [10, 3]);
approx(d[2], 70, 'her redeploy time');
assert.ok(h.b.fieldMeta().units.some((x) => x.id === u.id), 'a client joining late draws her down');
assert.equal(h.hooksOf('kill').filter((c) => c.victim === u).length, 0, 'no knock-out hooks');
assert.deepEqual(h.hooksOf('death').filter((c) => c.unit === u).map((c) => c.reason), ['retreat']);
assert.equal(h.b.result().perPlayer.p1.deaths, 0, 'no knock-down counted');
h.b.getPlayer(u.ownerId).dp = 99;
const out = u.deathAt;
assert.ok(h.runUntil(() => u.alive, 75));
approx(h.b.time - out, 70, 'back after her redeploy time', 0.1);
assert.deepEqual([u.tileR, u.tileC], [10, 3]);
assert.equal(downOf(h, u), null);
h.invariants();
});
test('史尔特尔 with 突袭: forced out after her jump she lies on the landing tile and comes back there, not on her board tile', () => {
const h = field([{ chessId: SURTR, row: 10, col: 3, dir: 'RIGHT' }], RAID);
const u = h.unit(SURTR);
assert.ok(h.runUntil(() => u.tileR !== u.homeR || u.tileC !== u.homeC, 30), 'jumped');
const at = [u.tileR, u.tileC];
ember(h, u);
assert.deepEqual(downOf(h, u)?.slice(4), at, 'down where she stood');
assert.ok(h.b.isReservedTile(at[0], at[1]), 'no ally lands on her body');
h.b.getPlayer(u.ownerId).dp = 99;
const n = h.hooksOf('deploy').filter((c) => c.unit === u).length;
assert.ok(h.runUntil(() => u.alive, 75));
assert.equal(h.hooksOf('deploy').filter((c) => c.unit === u).length, n + 1);
assert.deepEqual([u.tileR, u.tileC], at, 'redeployed on that tile (it used to be her board tile)');
assert.deepEqual([u.homeR, u.homeC], [10, 3], 'her home is unchanged');
});
test('耀骑士临光 S2: withdrawn at the skill end, she lies down on her tile with the ×1.25 redeploy ring and comes back there', () => {
const idx = chess[NEARL2].skills.findIndex((s) => (s.skillId ?? s.id) === 'skchr_nearl2_2');
assert.ok(idx >= 0);
const h = field([{ chessId: NEARL2, row: 10, col: 4, skillIndex: idx }]);
const u = h.unit(NEARL2);
h.step();
assert.ok(u.alive && u.findBuff('nearl2:night'), 'S2 on deploy');
assert.ok(h.runUntil(() => !u.alive, 40), 'withdraws when the skill ends');
assert.equal(u.removeReason, 'retreat');
const d = downOf(h, u);
assert.deepEqual(d?.slice(4), [10, 4]);
approx(d[2], u.base.respawnTime * 1.25, 'this redeploy ×1.25', 0.02);
assert.equal(h.hooksOf('kill').filter((c) => c.victim === u).length, 0);
h.b.getPlayer(u.ownerId).dp = 99;
assert.ok(h.runUntil(() => u.alive, d[2] + 2));
assert.deepEqual([u.tileR, u.tileC], [10, 4]);
});
test('a real knock-out is unchanged: down on its tile, the kill hooks and the knock-down count', () => {
const h = field([{ chessId: INSIDE, row: 10, col: 3, dir: 'RIGHT' }]);
const u = h.unit(INSIDE);
h.run(1);
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.ok(!u.alive);
assert.equal(u.removeReason, 'killed');
assert.deepEqual(downOf(h, u)?.slice(4), [10, 3]);
assert.equal(h.hooksOf('kill').filter((c) => c.victim === u).length, 1);
assert.equal(h.b.result().perPlayer.p1.deaths, 1);
});
+36
View File
@@ -337,9 +337,20 @@ async function loadContext() {
return {
act, ac, charTable, skillTable, rangeTable, uniequip, battleEquip, handbook, enemyDb,
levels, templateIds: [...templateIds].sort(naturalCmp), stageIds, enemyDataLevelId, research,
manifest: await loadManifest(),
};
}
/**
* The committed asset manifest data/assets.json (tools/fetch-assets.mjs; docs/ASSETS.md) — the enemies' attack clip
* lengths (enemyAttackAnim); null when absent or unreadable (no `attackAnim` then, with a warning).
*/
async function loadManifest() {
const abs = join(ROOT, 'data', 'assets.json');
if (!existsSync(abs)) { warn('data/assets.json not found; enemies get no attackAnim'); return null; }
try { return JSON.parse(await readFile(abs, 'utf8')); } catch (e) { warn(`data/assets.json unreadable: ${e.message}`); return null; }
}
// ===== shared game-object helpers ===============================================================
/** Range grid [[dRow,dCol]…] of a range_table id (facing right), or null. */
@@ -1777,6 +1788,28 @@ const MODEL_SCALES = new Map([
const MODEL_SCALE_BY_PREFAB = new Map();
for (const [v, list] of MODEL_SCALES) for (const k of list) MODEL_SCALE_BY_PREFAB.set(`enemy_${k}`, Math.round((v / MODEL_SCALE_STANDARD) * 1e4) / 1e4);
/**
* An enemy's attack clip → enemies.json `attackAnim` { clip, dur, hit } (GitHub #58: an unblocked ranged enemy stands for
* its attack clip, server/sim/ai.js attackStand): the clip the client plays for its attacks (the asset manifest's
* `anims.attack.loop` of the enemy's model — not an Idle stand-in, `via: 'idle'`), its length and its first strike
* frame (`hits`: the clip's OnAttack event; absent when it has none — the sim then takes half the clip). Read from the
* committed data/assets.json (written by tools/fetch-assets.mjs from the Spine skeletons; docs/ASSETS.md), so the sim
* never reads client files; an enemy whose model the manifest lacks gets none (the sim falls back to ATTACK_PAUSE).
*/
function enemyAttackAnim(manifest, spineId) {
const sp = manifest?.enemies?.[spineId]?.spine;
const a = sp?.anims?.attack;
if (!a || typeof a.loop !== 'string' || a.via === 'idle') return null;
const dur = sp.animations?.[a.loop];
if (!(typeof dur === 'number' && dur > 0)) return null;
const h = sp.hits?.[a.loop];
const hit = Array.isArray(h) && Number.isFinite(h[0]) ? Math.min(dur, Math.max(0, h[0])) : null;
return hit != null ? { clip: a.loop, dur, hit } : { clip: a.loop, dur };
}
/** The handbook's 「不停止移动」 attack (“十字路口”量产型's 四向攻击) → enemies.json `attackMoves`: it never stops to attack. */
const attacksOnTheMove = (abilities) => abilities.some((a) => a.text.includes('不停止移动'));
/**
* The 鸭爵 strategy's swapped-in enemies (`round_start_all_player_change_enemy_2` enemylist — the act2 versions, *_2)
* cost BAND_SWAP_LPR at the protection point, not the database's lifePointReduce 0 (the roguelike 宝藏 rule): PRTS
@@ -1867,6 +1900,7 @@ function buildEnemies(ctx) {
const descRaw = mv(data.description);
const hitArea = HIT_AREAS[mv(data.prefabKey) || key] || null;
const modelScale = MODEL_SCALE_BY_PREFAB.get(mv(data.prefabKey) || key) ?? null;
const attackAnim = enemyAttackAnim(ctx.manifest, mv(data.prefabKey) || key);
out[key] = {
key, name, level: wantLevel, rank: mv(data.levelType, 'NORMAL'), handbookIndex: hb?.enemyIndex || null,
desc: stripRich(descRaw), descRaw: richRaw(descRaw),
@@ -1889,6 +1923,8 @@ function buildEnemies(ctx) {
...(hitArea ? { hitArea: { ...hitArea } } : {}),
...(STATIC_BODIES.has(key) ? { staticBody: true } : {}),
...(modelScale != null && modelScale !== 1 ? { modelScale } : {}),
...(attackAnim ? { attackAnim } : {}),
...(attacksOnTheMove(abilities) ? { attackMoves: true } : {}),
};
}
for (const k of STATIC_BODIES) if (!out[k]) warn(`STATIC_BODIES: ${k} is not an enemy of the mode`);