feedback1 integrate: WI flying enemies are static bodies (never displaced); 胄 parts, 守墓石像 and 喷气人 flight

# Conflicts:
#	data/enemies.json
#	server/sim/content/enemies.js
This commit is contained in:
sganggs committed 2026-10-03 06:36:35 +08:00
commit f55de593a3
12 files changed
+511 -27

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","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","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,"moveSpeed":0.5,"bat":3.8,"aspd":100,"rangLine 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},"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
+1
View File
@@ -350,6 +350,7 @@ blackboards (transitively). Level = `randomEnemyAttributeDict[key].level` (0 for
| `summons[]`, `inactiveIn[]`, `seasonOverride` | | spawned enemies; modes banning it; overridden fields |
| `iconId`, `spine` | `"enemy_1422_lrsldr"` | asset ids |
| `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` |
## 10. `factions.json` — special enemies (特训敌人)
+5 -5
View File
@@ -599,8 +599,8 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
| `addDp(playerId, n)`, `retreat(unit, {reason, permanent})`, `relocate(unit, r, c)` | |
| `redeploy(unit, { free=true, tile, keepSp })` | immediate (re)deployment of a dead/retreated ally (full HP, `deploy {initial:false}`); `free: false` pays `base.cost` DP (refused without it); `tile: [r, c]` lands on that tile once (home unchanged — later redeploys use the board tile; refused when off-rect or occupied, no fallback); `keepSp` keeps SP/charges (保留技力), restored before `deploy` fires — 突袭 raids, 阿戈尔 revive in place |
| `refreshRange(unit)`, `setExtraRange(unit, keys)`, `rangeChanged(unit)` | rebuild the ranges after changing `unit.rangeGrid` (流形 copies); extra targetable tiles (absolute keys; merged into every later rebuild until set again; `null` clears; never in `baseRangeKeys`): 蕾缪安 wanted, 维娜 S3 |
| `push(enemy, force, {from, dir, fixed, fixedAngle, inward, effect})`, `pull(enemy, force, {to, center, stop})`, `pullToFront(enemy, unit, force)`, `forceLevel(enemy, force)`, `pushDistance(enemy, force, {effect})` | the official 位移 (PRTS 游戏数据基础 §重量公式 / 推与拉; user playtest #6 item 14): 受力等级 = 力度 (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3 …) − current 重量等级 (massLevel, 失重 counts). Push distance per level (`constants.js PUSH_TILES`, PRTS 推与拉's 弹道 column): ≤ −3 → 0, −2 → 0.12, −1 → 0.44, 0 → 1.7, 1 → 2.14, 2 → 2.96, ≥ 3 → 3.53 tiles; `effect` = a 特效 push (`PUSH_TILES_EFFECT`: −2 → 0.085, −1 → 0.374, 0 → 1.562, 1 → 1.987, 2 → 2.773, ≥ 3 → 3.331 — 见行者 S1 / S2, `PUSH_EFFECT_SKILLS`; every other pusher uses the 弹道 column [ASSUMED]); radial (away from `from`) unless `dir` (directional: 推击手, 朝部署方向 — > 45° off or < 0.25 tile ⇒ radial and level −2; `fixed` waives both (圣聆初雪 S1), `fixedAngle` only the angle (见行者 S2)); `inward` = a push towards `from` (薄绿 S2), stopping at the 急停 radius. Pull: level ≥ 0 → to `to` / the 急停 radius around `center`, −1 → 35 % of the way, −2 → 0.03, ≤ −3 → 0; `pullToFront` aims at the 拉力起点 0.5 tile ahead of the unit with the 急停 radius 0.6708 around it (never moves an enemy the unit itself blocks — nor does a pull towards an ally's centre, e.g. the 流形 S3 pulse) |
| `displace(enemy, {x, y}, tiles)` | the raw mover behind push / pull: along passable tiles (by `motion`: FLY in the rect, else ground-passable), no weight rule; nothing for 失衡免疫 (`noDisplace`: 近地悬浮, 浮空) and leaders; unblocks + re-paths |
| `push(enemy, force, {from, dir, fixed, fixedAngle, inward, effect})`, `pull(enemy, force, {to, center, stop})`, `pullToFront(enemy, unit, force)`, `forceLevel(enemy, force)`, `pushDistance(enemy, force, {effect})` | the official 位移 (PRTS 游戏数据基础 §重量公式 / 推与拉; user playtest #6 item 14): 受力等级 = 力度 (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3 …) − current 重量等级 (massLevel, 失重 counts). Push distance per level (`constants.js PUSH_TILES`, PRTS 推与拉's 弹道 column): ≤ −3 → 0, −2 → 0.12, −1 → 0.44, 0 → 1.7, 1 → 2.14, 2 → 2.96, ≥ 3 → 3.53 tiles; `effect` = a 特效 push (`PUSH_TILES_EFFECT`: −2 → 0.085, −1 → 0.374, 0 → 1.562, 1 → 1.987, 2 → 2.773, ≥ 3 → 3.331 — 见行者 S1 / S2, `PUSH_EFFECT_SKILLS`; every other pusher uses the 弹道 column [ASSUMED]); radial (away from `from`) unless `dir` (directional: 推击手, 朝部署方向 — > 45° off or < 0.25 tile ⇒ radial and level −2; `fixed` waives both (圣聆初雪 S1), `fixedAngle` only the angle (见行者 S2)); `inward` = a push towards `from` (薄绿 S2), stopping at the 急停 radius. Pull: level ≥ 0 → to `to` / the 急停 radius around `center`, −1 → 35 % of the way, −2 → 0.03, ≤ −3 → 0; `pullToFront` aims at the 拉力起点 0.5 tile ahead of the unit with the 急停 radius 0.6708 around it (never moves an enemy the unit itself blocks — nor does a pull towards an ally's centre, e.g. the 流形 S3 pulse). A 静态刚体 (data `staticBody`: every air unit of the mode except “炎佑”, plus 昆图斯 — PRTS 特殊机制 "可以进入失衡状态…但物理层面上无法产生任何速度或移动", "与单位的行动方式无关") moves 0 from every source while the skills that reach it still hit it (薄绿 S2, 锏 S3, the 钩索师 …; player report after 0.1.0) |
| `displace(enemy, {x, y}, tiles)` | the raw mover behind push / pull: along passable tiles (by `motion`: FLY in the rect, else ground-passable), no weight rule; nothing for 失衡免疫 (`noDisplace`: 近地悬浮 incl. 喷气人's 飞行模式, 浮空, the 胄 parts, 守墓石像's statue and flight), 静态刚体 (`def.staticBody`) and leaders (`_displaceable`); unblocks + re-paths |
| `after(seconds, fn, {owner})`, `every(seconds, fn, {owner, immediate})` | return `{cancel()}`; fn(battle, sched) |
| `fx(kind, params)`, `rng()` (+ `rng.int/range/chance/pick/shuffle/weighted`) | never use Math.random |
| `forceAttack(unit, targets?, { noAmmo })` | an immediate attack with the current profile (hooks, attack SP, a running ammo skill's bullet); `noAmmo: true` = spends no bullet and emits no `ammoUsed` (圣约送葬人 extra attack). Returns true when it attacked |
@@ -1058,9 +1058,9 @@ bat, blockCnt, cost, respawnTime, spRecovery, tauntLevel, massLevel, hpRecoveryP
skillType, durationType, duration, spType, spCost, initSp, maxCharges, rangeGrid, trigger {rule, grid}, bb, description}`,
`talents [{name, description, bb, bbStr, rangeGrid, tokenKey}]`, `raw`. Enemy: `maxHp, atk, def, res, aspd, bat,
rangeRadius, moveSpeed, massLevel, lpr, motion, applyWay, dmgType, immune, tauntLevel, blockCnt, epResistance,
epDamageResistance, hpRecoveryPerSec, notCountInTotal, hitArea (huge units only, §2; else null), tags, abilities, skills,
talent, raw`. Tokens use
`variants[ownerChessId]` (owner-level stats). `skcom_withdraw` token skills are ignored.
epDamageResistance, hpRecoveryPerSec, notCountInTotal, hitArea (huge units only, §2; else null), staticBody (静态刚体:
never displaced, §6), tags, abilities, skills, talent, raw`. Tokens use `variants[ownerChessId]` (owner-level stats).
`skcom_withdraw` token skills are ignored.
Operator loadouts (DESIGN §16, DATA.md §2.2 / §14): `getChess(id, { skillIndex, moduleId })` = the def with the selected
skill (`def.skill`, its bb / SP / trigger) and, for elites, the module's stats / trait / talents; `def.loadout` = the
resolved loadout `{ skillIndex, moduleId, skillIsDefault, moduleIsDefault, isDefault }` (illegal choices fall back to the
+12 -4
View File
@@ -1875,16 +1875,24 @@ export class Battle {
return this.pull(e, force, { to: { x: unit.x + f[1] * PULL_ORIGIN, y: unit.y + f[0] * PULL_ORIGIN }, center: unit, stop: PULL_STOP_RADIUS });
}
/** Can `e` be displaced at all: a living enemy, not a leader part of the boss pool, not 失衡免疫 (近地悬浮, 浮空). */
/**
* Can `e` be displaced at all: a living enemy, not a leader part of the boss pool, not 失衡免疫 (flag `noDisplace`:
* 近地悬浮, 浮空, the 胄 parts …) and not a 静态刚体 (data `staticBody`). PRTS 特殊机制 静态刚体: such a unit "可以进入
* 失衡状态 … 但物理层面上无法产生任何速度或移动" — every air unit of the mode except “炎佑”, plus the boss 昆图斯 (build-data
* STATIC_BODIES; player report after 0.1.0, "飞机可以被薄绿的技能拉走"). A skill that reaches it still hits it (its targeting
* is the skill's own: 锏 S3, 薄绿, the 钩索师 …); only the movement is 0, so distance-based effects (drag damage, 见行者 S2's
* wall stun) come to nothing. [ASSUMED] the 0.1 s 失衡硬直 a 静态刚体 still gets is not modelled (no displacement models it).
*/
_displaceable(e) {
return !!(e && e.alive && e.side === 'enemy' && !e.isBoss && !e.s.flags.noDisplace);
return !!(e && e.alive && e.side === 'enemy' && !e.isBoss && !e.s.flags.noDisplace && !(e.def && e.def.staticBody));
}
/**
* Move an enemy `distance` tiles along `dir` = {x, y} (normalised internally) over passable tiles — the raw mover of
* push() / pull(), which apply the official 力度 − 重量 rules (content uses those; the old `force` option is gone).
* 失衡免疫 (flag `noDisplace`: 近地悬浮, 浮空 — PRTS 异常效果 "不会被位移影响") and leaders ⇒ no movement. The tiles it may
* cross follow its movement (`motion`): a hovering enemy walks the ground, so it stays on ground-passable tiles.
* 失衡免疫 (flag `noDisplace`: 近地悬浮, 浮空 — PRTS 异常效果 "不会被位移影响"), 静态刚体 (data `staticBody`) and leaders
* ⇒ no movement (_displaceable). The tiles it may cross follow its movement (`motion`): a hovering enemy walks the
* ground, so it stays on ground-passable tiles.
*/
displace(e, dir, distance) {
if (!this._displaceable(e) || !dir) return 0;
+8 -3
View File
@@ -25,7 +25,8 @@
// level's blink route, fly at the lowest-ATK operator (3×3 stun + DoT); 15 hits shoot it
// down (瘫痪: stun 10 s, ground unit, damage ×1.3). Either way a new 初始模式 copy with its HP
// replaces it at home. Every damage it takes costs 胄 as much (PART_TRANSFER, 无来源, 等量) —
// grounded per both texts, during the dive per the PRTS talent [ASSUMED].
// grounded per both texts, during the dive per the PRTS talent [ASSUMED]. The shell, 剑 and
// 锤 are 失衡免疫 + 静态刚体 (PRTS 天赋): no push or pull moves them.
// boss_2/9 假想敌:铳 unblockable; highest-DEF target in range; erosion; ASPD ramp (+80 × 5) on the same target
// (floor 20); 最终之罚 charge at the highest-DEF ground unit (disabled by the h07_02 override).
// boss_9: damage ×0.2 while springs live, 盲信之誓 links (100 phys/s on the lines), 末日布道 dash.
@@ -418,7 +419,10 @@ function fireShell(b, boss, target) {
function kitShell(ab, e) {
const stun = T(ab, 'killed.duration') ?? 0, dot = T(ab, 'killed.value') ?? 0;
return [{
spawn(b, e2) { setHits(e2, e2.def.maxHp); hitCount(b, e2, true); }, // 需要数次攻击击倒
spawn(b, e2) {
setHits(e2, e2.def.maxHp); hitCount(b, e2, true); // 需要数次攻击击倒
b.addBuff(e2, { key: 'boss:shellGuard', persist: true, flags: { noDisplace: true } }); // 失衡免疫 (PRTS 天赋; also 静态刚体)
},
tick(b, e2, a, dt) {
const s = e2.mem.ab.shell;
if (!s || a.done) return;
@@ -479,7 +483,8 @@ function kitBlade(ab, e, b, tpl) {
{
spawn(b2, e2) {
hover(b2, true);
b2.addBuff(e2, { key: 'boss:anchor', persist: true, flags: { noMove: true, unblockable: true } }); // 自缚 (moved by hand) · 不可阻挡
// 自缚 (moved by hand) · 不可阻挡 · 失衡免疫 (PRTS 天赋 "{{特殊机制|静态刚体}},不可阻挡、失衡免疫…" — data `staticBody` too)
b2.addBuff(e2, { key: 'boss:anchor', persist: true, flags: { noMove: true, unblockable: true, noDisplace: true } });
if (BLADE_ATK_SCALE[e2.defId]) e2.profile.atkScale = BLADE_ATK_SCALE[e2.defId];
},
before(c, b2, e2) { const l = targetsNear(b2, e2, e2.base.rangeRadius || 1.6, { ranged: false }); if (l.length) c.targets = l; }, // 范围物理伤害
+21 -9
View File
@@ -655,7 +655,8 @@ const refraction = (res, hpPct = 0) => ({
* splash, ground traps and terrain skip it; ranged attacks hit it) with 失衡免疫 (`noDisplace`, "初始模式:近地悬浮,失衡
* 免疫" on the enemies' PRTS pages) — its `motion` stays WALK, so it keeps the ground path (and 浮空 can still lift it:
* Battle.applyStatus refuses data flyers only). Losing the float is each
* enemy's own ability (PRTS; 缚地 "使部分近地悬浮敌人掉落" is "实为敌人自身的能力"): kitSyufo, kitParrot.
* enemy's own ability (PRTS; 缚地 "使部分近地悬浮敌人掉落" is "实为敌人自身的能力"): kitSyufo, kitParrot. 喷气人's 飞行模式 is
* the same state for 7 s (its 'ab:takeoff' buff carries the flags; kitLeaderMisc).
*/
const FLOAT_KEY = 'ab:float';
const setFloat = (b, e, on) => {
@@ -895,12 +896,14 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke
}
/**
* 守墓石像 (PRTS 守墓石像 天赋): 地面模式 — melee attacks only while blocked; the first defeat is an instant 重生 (statuses
* cleared: rebirthCleanse) to 100 % HP into 转换模式 for stone.duration s — unblockable, 自缚 (immobile), 失衡免疫, immune
* to 浮空, DEF +stone.def, RES +stone.magic_resistance, no attack [ASSUMED: PRTS lists none] — then 飞行模式: a flyer
* (失衡免疫) whose ranged attacks (the
* data's 1.6 radius) deal arts damage and never target flyers; the HP is not refilled again. fx forms 'stone' / 'fly'
* (its Sleep and *_2 clips, render/units.js FORMS). <破碎支柱> (an event device) has no counterpart in this mode.
* 守墓石像 / 愤怒的守墓石像 (PRTS 天赋): 地面模式 — melee attacks only while blocked; the first defeat is an instant 重生
* (statuses cleared: rebirthCleanse) to 100 % HP into 转换模式 for stone.duration s — "无法被阻挡,自缚,失衡免疫,免疫浮空。
* 防御力增加800,法术抗性增加30", no attack [ASSUMED: PRTS lists none] — then 飞行模式, "变为飞行单位,失衡免疫": a flyer
* no push or pull moves, whose ranged attacks (the data's 1.6 radius) deal arts damage and never target flyers; the HP is
* not refilled again. Its pages list no 静态刚体 (data `staticBody` stays off), so the 失衡免疫 of the flight is a persistent
* `noDisplace` buff. 浮空 is refused from the statue on (`ab.immune`; data flyers refuse it anyway). [ASSUMED] a 浮空 it
* already carries at the knock-out runs out. fx forms 'stone' / 'fly' (its Sleep and *_2 clips, render/units.js FORMS).
* <破碎支柱> (an event device) has no counterpart in this mode.
*/
function statue(ab) {
const dur = T(ab, 'stone.duration') ?? 0;
@@ -1790,9 +1793,18 @@ function kitLeaderMisc(key, ab, e) {
}, { cond: (b, e2) => cands(b, e2).length > 0 })];
}
case 'enemy_2004_balloon': {
// 喷气人 · 升空 when blocked → 飞行模式 for `duration` s: PRTS 喷气人 "近地悬浮,不可阻挡,失衡免疫,移动速度+50%,不进行
// 攻击" — an air unit meanwhile (flag `float`: melee cannot hit it; it keeps the ground path, as setFloat), never
// displaced; "切换为飞行模式后,1.5秒内移动速度最终降低90% … 结束后,1.333秒内不可阻挡,移动速度最终降低90%" (PRTS numbers:
// the blackboard has only the duration and the +50 %).
const s = ab.sk.TakeOff;
const BRAKE = { moveMul: 0.1 }, BRAKE_UP = 1.5, LANDING = 1.333;
return [skill(s, (b, e2) => {
b.addBuff(e2, { key: 'ab:takeoff', duration: s.bb.duration ?? 0, flags: { unblockable: true }, mods: { moveMul: 1 + (s.bb['balloon_s[fly].move_speed'] ?? 0) }, visible: true });
b.addBuff(e2, {
key: 'ab:takeoff', duration: s.bb.duration ?? 0, flags: { unblockable: true, float: true, noDisplace: true }, mods: { moveMul: 1 + (s.bb['balloon_s[fly].move_speed'] ?? 0) }, visible: true,
onExpire: ({ battle }) => { if (e2.alive) battle.addBuff(e2, { key: 'ab:landing', duration: LANDING, flags: { unblockable: true }, mods: BRAKE }); },
});
b.addBuff(e2, { key: 'ab:takeoffBrake', duration: BRAKE_UP, mods: BRAKE });
b.fx('telegraph', { x: e2.x, y: e2.y, r: 0.5, kind: 'takeoff', id: e2.id });
}, { cond: (b, e2) => !!e2.blockedBy })];
}
@@ -2576,7 +2588,7 @@ export const KITS = Object.freeze({
enemy_1513_dekght_2: (ab, e) => kitLeaderMisc('enemy_1513_dekght_2', ab, e), // 凋零骑士 · 2 targets; rage when 腐败骑士 dies
enemy_1539_reid: (ab, e) => kitLeaderMisc('enemy_1539_reid', ab, e), // “复仇者” · ATK up below half; revives once at 50 %
enemy_2003_rockman: (ab, e) => kitLeaderMisc('enemy_2003_rockman', ab, e), // 迷路的巨像 · long-stun boulder on a non-stunned unit
enemy_2004_balloon: (ab, e) => kitLeaderMisc('enemy_2004_balloon', ab, e), // 喷气人 · takes off (unblockable) when blocked
enemy_2004_balloon: (ab, e) => kitLeaderMisc('enemy_2004_balloon', ab, e), // 喷气人 · takes off when blocked: 7 s hovering, unblockable, 失衡免疫
enemy_2005_axetro: (ab, e) => kitLeaderMisc('enemy_2005_axetro', ab, e), // “遗弃者” · attack stacks, reset after 4 s idle
enemy_2008_flking: (ab, e) => kitLeaderMisc('enemy_2008_flking', ab, e), // “墓碑” · operators' ATK/DEF halved; periodic barrier
enemy_2048_smgrd: (ab, e) => kitLeaderMisc('enemy_2048_smgrd', ab, e), // “邪魔的利刃” · 国度 ASPD-down around it, ×2.5 inside
+4 -2
View File
@@ -710,7 +710,7 @@ const KITS = {
// ---- 3_07 见行者 · 推击手 (hidden) — S2 惊爆射击: push every enemy in the skill range forward + stun (longer when
// slammed into a wall, collided enemies stunned too; air units too [ASSUMED: "范围内所有敌人", no 对空 note on
// PRTS] — a 失衡免疫 enemy is not pushed but still stunned); 技巧射击: ignore DEF vs heavy enemies;
// PRTS] — a 失衡免疫 enemy or a 静态刚体 (the drones) is not pushed but still stunned); 技巧射击: ignore DEF vs heavy enemies;
// 精锐 module PUS-X: redeployed on a ranged tile ⇒ half the deployment cost back
chess_char_3_07_a: (bb, chess, def) => {
const d = defOf(chess, def);
@@ -818,7 +818,9 @@ const KITS = {
onHit({ battle, unit, target }) {
if (!target || !target.alive || target.side !== 'enemy') return;
// PRTS 备注: "此技能的“拖拽”机制实际为反方向(指向薄绿方向)的推开" — a radial push towards her by the
// official 力度 − 重量 push distance (小力 vs weight 1: 0.44 tiles), never past her (Battle.push inward)
// official 力度 − 重量 push distance (小力 vs weight 1: 0.44 tiles), never past her (Battle.push inward). Her
// attack reaches air units (阵法术师 "攻击时可对空"), but the drones of the mode are 静态刚体 (PRTS 特殊机制): hit,
// never dragged (player report after 0.1.0, "飞机可以被薄绿的技能拉走")
if (battle.push(target, pullForce, { from: unit, inward: true }) > 0) fx(battle, 'pull', target, { src: unit.id });
},
},
+3 -2
View File
@@ -53,7 +53,8 @@
// while they roam; 头狼 stage 2 "特殊能力失效" = silence; stage 3 = +1 drone (normal attacks hit once
// more, S3 releases one more drone). Base drone count 1.
// 6_19 锏 10 slashes every d_hit_interval, pulls every p_hit_interval, final blow (skill range) at the end;
// S3 slashes and pulls air units too (PRTS 备注 "可对空").
// S3 slashes and pulls air units too (PRTS 备注 "可对空"; a 静态刚体 — every drone of the mode — is hit
// but stays put: Battle._displaceable).
// 6_20 纯烬艾雅法拉 5 shots are padded by cycling targets when fewer injured allies exist.
//
// Operator loadouts (DESIGN §16): every visible chess also authors its selectable NON-default skills in `skills`
@@ -2939,7 +2940,7 @@ function blkkgt(bb, chess, def) {
// removes the skill's range
const skillKeys = (unit) => unit.rangeKeys || [];
// S3 hits and pulls air units too — PRTS 锏 S3 备注 "※可对空。不会拖拽自身中心半径0.6708范围内的敌人" (her attacks and S1 /
// S2 stay ground-only: "地面敌人")
// S2 stay ground-only: "地面敌人"); the air units of the mode are 静态刚体, so the pull leaves them in place (Battle.pull)
const victims = (battle, unit) => {
const c = battle.enemiesInKeys(skillKeys(unit), unit, { ...unit.profile, canHitFly: true, groundOnly: false });
sortEnemyTargets(battle, unit, c, null);
+1
View File
@@ -261,6 +261,7 @@ export function normalizeEnemy(key, e) {
hpRecoveryPerSec: num(st.hpRecoveryPerSec ?? extra.hpRecoveryPerSec ?? e.hpRecoveryPerSec, 0),
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)
tags: e.tags ?? [],
abilities: e.abilities ?? [],
skills: e.skills ?? [],
+429
View File
@@ -0,0 +1,429 @@
// Player report after the 0.1.0 release (second batch, B5): "飞机可以被薄绿的技能拉走(难道不能拉动吗?原版我没印象了,你去复核下)".
//
// Verdict: real. Officially no push or pull moves the air units of this mode:
// - PRTS 特殊机制 静态刚体: "该单位的Unity刚体的刚体类型为部分静态(Kinematic)或静态(Static)。使用该类刚体的单位可以进入失衡
// 状态并启用物理,但物理层面上无法产生任何速度或移动 … ※与失衡免疫不同 … ※是否为静态刚体与单位的行动方式无关" (example: 妖怪);
// PRTS 失衡位移机制: "这里的敌方单位也包括空中单位,但会因下述的判断而被取消位移".
// - every air unit of data/enemies.json but “炎佑” (weight 10) lists "{{特殊机制|静态刚体}}" in its PRTS 天赋 (妖怪 "{{特殊机制|
// 静态刚体}},不进行普通攻击", 寒霜, 暴鸰, 法术大师A1, 御4, 护障, 远眺 …), and so does the ground boss 盐风主教昆图斯 →
// enemies.json `staticBody` (tools/build-data.mjs STATIC_BODIES) → Battle._displaceable: 0 tiles from every source.
// - the skills keep their reach: 薄绿 (阵法术师 "攻击时可对空"), 锏 S3 ("※可对空"), the 钩索师 … still hit the drones.
// - 刺胄之弹 / “斩胄之剑” / “破胄之锤” are also 失衡免疫 (PRTS 天赋 "{{特殊机制|静态刚体}},…失衡免疫…").
// - 喷气人's 飞行模式 (PRTS 喷气人 "近地悬浮,不可阻挡,失衡免疫,移动速度+50%,不进行攻击") hovers like the two 近地悬浮 enemies.
// - 守墓石像 turns into a flyer at run time (data WALK, no 静态刚体): its statue ("无法被阻挡,自缚,失衡免疫,免疫浮空") and its
// flight ("变为飞行单位,失衡免疫") are 失衡免疫 (PRTS 守墓石像 / 愤怒的守墓石像 天赋).
// Unchanged: ground enemies keep the 力度 − 重量 tables (DESIGN §20.3); the hovering 吉兆飞鳞 / 掠海漂移体 are 失衡免疫 while they
// hover and displaceable once grounded (no 静态刚体 on their pages). [ASSUMED] the 0.1 s 失衡硬直 of a 静态刚体 is not modelled.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { hasGeneratedData, getDefaultSource, spawnsFromTemplate } from '../../server/sim/simdata.js';
import { PUSH_TILES, PULL_STOP_RADIUS } from '../../server/sim/constants.js';
const REAL = { skip: !hasGeneratedData() };
const approx = (a, b, eps, msg) => assert.ok(Math.abs(a - b) <= eps, `${msg ?? ''} ${a} ≈ ${b}`);
const STILL = { hpMul: 1e4, speedMul: 0, atkMul: 0 };
const MINT = 'chess_char_3_08_a';
const YOKAI = 'enemy_1005_yokai'; // 妖怪: FLY, weight 0, 静态刚体
const FROST = 'enemy_1042_frostd'; // 寒霜: FLY, weight 1, 静态刚体
const SLIME = 'enemy_1007_slime'; // 源石虫: ground, weight 0
const AGENT = 'enemy_1046_agent'; // 步兵: ground, weight 1
const PARROT = 'enemy_10045_parrot'; // 吉兆飞鳞: 近地悬浮, weight 0
const SYUFO = 'enemy_2025_syufo'; // 掠海漂移体: 近地悬浮, weight 4
const SHELL = 'enemy_9016_acstmr'; // 刺胄之弹: FLY, weight 2, 静态刚体 + 失衡免疫
const BALLOON = 'enemy_2004_balloon'; // 喷气人: ground, weight 3; 飞行模式 = 近地悬浮 + 失衡免疫
const LOON = 'enemy_9012_acloon'; // “炎佑”: FLY, weight 10, no 静态刚体 on PRTS
// synthetic bodies: the rule is the rigidbody, not the movement ("是否为静态刚体与单位的行动方式无关")
const DYN_FLY = 'enemy_test_dynfly'; // a FLY body that is not static (none in this mode but “炎佑”)
const STATIC_WALK = 'enemy_test_staticwalk';
const SYNTH = {
[DYN_FLY]: enemyRec({ key: DYN_FLY, hp: 1e8, speed: 0, mass: 0, motion: 'FLY' }),
[STATIC_WALK]: { ...enemyRec({ key: STATIC_WALK, hp: 1e8, speed: 0, mass: 0 }), staticBody: true },
};
/** 薄绿 on (10,3) facing RIGHT (default loadout: S2 聚能涡旋), one still enemy at `pos`; S2 ready. */
function mintVs(key, pos, { chessId = MINT, route = 0, defs = null } = {}) {
const h = makeBattle({
...(defs ? { defs: { enemies: defs } } : {}),
units: [{ chessId, row: 10, col: 3, dir: 'RIGHT', carryState: { sp: 1e3 } }],
enemies: [{ key, pos, route, mods: STILL }], autoFinish: false, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
});
return { h, u: h.unit(chessId) };
}
/** Distance the enemy moved on the first hit of `u` (the S2 drag rides on the hit) and the damage of that hit. */
function firstHitDrag(h, u, pos) {
const x0 = pos[1], y0 = pos[0];
const e = h.b.enemies[0];
const hits = () => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e);
assert.ok(h.runUntil(() => hits().length > 0, 8), 'she hits it');
assert.equal(hits().length, 1, 'one hit so far');
return { e, moved: Math.hypot(e.x - x0, e.y - y0), dmg: hits()[0].amount };
}
test('B5: the data — every air unit of the mode but “炎佑” is a 静态刚体 (+ 昆图斯); 薄绿 S2 is 小力 and the default skill', REAL, () => {
const ds = getDefaultSource();
for (const id of [MINT, 'chess_char_3_08_b']) {
const c = ds.rawChess(id);
assert.equal(c.skill.skillId, 'skchr_mint_2', `${id}: S2 is the default`);
assert.equal(c.skill.bb['attack@force'], 0, `${id}: 小力`);
}
const list = Object.values(ds.raw.enemies);
const fly = list.filter((e) => e.stats.motion === 'FLY');
assert.ok(fly.length >= 20, `the mode's air units (${fly.length})`);
// PRTS: each one's 天赋 "{{特殊机制|静态刚体}}" — except “炎佑” (none; weight 10 — no 力度 reaches it)
for (const e of fly) assert.equal(!!e.staticBody, e.key !== LOON, `${e.key} ${e.name}`);
assert.ok(ds.rawEnemy(LOON).stats.massLevel >= 10);
// the only static ground unit: 盐风主教昆图斯; the hovering enemies are dynamic bodies (their pages list none)
assert.deepEqual(list.filter((e) => e.staticBody && e.stats.motion !== 'FLY').map((e) => e.key), ['enemy_1521_dslily']);
for (const k of [PARROT, SYUFO, BALLOON, SLIME]) assert.ok(!ds.rawEnemy(k).staticBody, k);
assert.equal(ds.getEnemy(YOKAI).staticBody, true, 'normalised def');
assert.equal(ds.getEnemy(SLIME).staticBody, false);
});
test('B5 (the player\'s scenario): 薄绿 S2 hits the drones but never drags them; ground enemies of the same weight still come (normal + elite)', REAL, () => {
const pos = [10, 5]; // two tiles ahead: the edge of her x-1 range
for (const chessId of [MINT, 'chess_char_3_08_b']) {
const res = {};
for (const [key, route] of [[SLIME, 0], [YOKAI, 2], [AGENT, 0], [FROST, 2]]) {
const { h, u } = mintVs(key, pos, { chessId, route });
h.step();
assert.equal(u.skill.id, 'skchr_mint_2');
const { e, moved, dmg } = firstHitDrag(h, u, pos);
assert.ok(dmg > 0, `${key}: hit`);
assert.equal(e.isFlying, key === YOKAI || key === FROST);
res[key] = moved;
checkInvariants(h.b);
}
approx(res[YOKAI], 0, 1e-9, `${chessId}: 妖怪 (静态刚体) stays`);
approx(res[FROST], 0, 1e-9, `${chessId}: 寒霜 (静态刚体) stays`);
// 小力 (0) − weight 0 = 受力等级 0: 1.7 tiles, stopped at the 急停 radius 0.6708 from her centre; − weight 1 = −1: 0.44
approx(res[SLIME], Math.min(PUSH_TILES[0], 2 - PULL_STOP_RADIUS), 1e-6, `${chessId}: 源石虫`);
approx(res[AGENT], PUSH_TILES[-1], 1e-6, `${chessId}: 步兵`);
}
});
test('B5 (the player\'s scenario): on act2 m01 a round-7 drone flies past 薄绿 on its route, hit after hit', REAL, () => {
// round 7's FLY route 10 (… (11,8) → (11,6) → (10,6) → (10,5) …) passes her x-1 range on the melee tile (10,7)
const { routes } = spawnsFromTemplate(getDefaultSource().getWave('act1autochess_07'));
assert.equal(routes[10].motion, 'FLY');
const fly = (withMint) => {
const h = makeBattle({
stageId: 'act2autochess_m01', routes, timeLimit: 80, autoFinish: false, hooks: ['damaged', 'enemyLeak'], captureNoisy: true,
units: withMint ? [{ chessId: MINT, row: 10, col: 7, dir: 'LEFT', carryState: { sp: 1e3 } }] : [],
});
h.step();
const drone = h.spawn(YOKAI, { routeIndex: 10, mods: { hpMul: 1e4, atkMul: 0 } });
assert.ok(drone && drone.isFlying && drone.motion === 'FLY');
const path = [];
while (drone.alive && !drone.leaked && h.b.time < 70) { h.step(); path.push([drone.x, drone.y]); }
return { h, drone, path, hits: h.hooksOf('damaged').filter((c) => c.target === drone).length };
};
const ref = fly(false), got = fly(true);
assert.ok(got.hits >= 5, `S2 hits the drone (${got.hits} hits)`);
assert.equal(got.path.length, ref.path.length, 'it reaches the goal on the same tick');
let dev = 0;
for (let i = 0; i < ref.path.length; i++) dev = Math.max(dev, Math.hypot(got.path[i][0] - ref.path[i][0], got.path[i][1] - ref.path[i][1]));
approx(dev, 0, 1e-9, 'its flight is the undisturbed one');
checkInvariants(got.h.b);
});
test('B5: 静态刚体 is the body, not the movement — a dynamic flyer is dragged, a static walker is not; push / pull give 0 at any 力度', REAL, () => {
const pos = [10, 5];
const drag = (key) => { const { h, u } = mintVs(key, pos, { route: key === DYN_FLY ? 2 : 0, defs: SYNTH }); h.step(); return firstHitDrag(h, u, pos).moved; };
approx(drag(DYN_FLY), Math.min(PUSH_TILES[0], 2 - PULL_STOP_RADIUS), 1e-6, 'a non-static FLY body: the weight-0 table');
approx(drag(STATIC_WALK), 0, 1e-9, 'a static ground body stays');
const { h } = mintVs(YOKAI, pos, { route: 2 });
h.step();
const e = h.b.enemies[0];
for (const f of [0, 1, 3, 5]) {
assert.equal(h.b.pushDistance(e, f), 0, `pushDistance 力度 ${f}`);
assert.equal(h.b.push(e, f, { from: { x: 3, y: 10 } }), 0, `push 力度 ${f}`);
assert.equal(h.b.pull(e, f, { to: { x: 0, y: 0 }, stop: 0 }), 0, `pull 力度 ${f}`);
assert.equal(h.b.pullToFront(e, h.unit(MINT), f), 0, `pullToFront 力度 ${f}`);
}
approx(Math.hypot(e.x - 5, e.y - 10), 0, 1e-9, 'in place');
});
test('B5: hovering enemies are 失衡免疫 while they hover, dragged once grounded (PRTS 吉兆飞鳞 / 掠海漂移体)', REAL, () => {
const pos = [10, 5];
for (const key of [PARROT, SYUFO]) {
const { h, u } = mintVs(key, pos);
h.step();
const e = h.b.enemies[0];
assert.ok(e.isFlying && e.s.flags.noDisplace, `${key}: hovering, 失衡免疫`);
const { moved } = firstHitDrag(h, u, pos);
approx(moved, 0, 1e-9, `${key}: hit while hovering, not moved`);
}
// grounded (吉兆飞鳞 晕眩模式 after a stun: no 失衡免疫, weight 0) → the 小力 drag moves it to her 急停 radius
const { h, u } = mintVs(PARROT, pos);
h.step();
const e = h.b.enemies[0];
h.b.applyStatus(e, 'stun', { duration: 1 });
h.step();
assert.ok(!e.isFlying && !e.s.flags.noDisplace, '晕眩模式: on the ground');
const at = { x: e.x, y: e.y };
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u && c.target === e), 8));
approx(Math.hypot(e.x - at.x, e.y - at.y), Math.min(PUSH_TILES[0], Math.hypot(at.x - 3, at.y - 10) - PULL_STOP_RADIUS), 1e-6, 'dragged');
// 掠海漂移体 爬行模式: no 失衡免疫, but weight 4 — 小力 − 4 = −4 → no movement (the table), 大力 (3) − 4 = −1 → 0.44
const s = mintVs(SYUFO, pos).h;
s.step();
const z = s.b.enemies[0];
s.b.applyStatus(z, 'stun', { duration: 0.5 });
s.step();
assert.ok(!z.isFlying && !z.s.flags.noDisplace, '爬行模式: a ground unit');
assert.equal(s.b.pushDistance(z, 0), 0);
approx(s.b.push(z, 3, { from: { x: 3, y: 10 } }), PUSH_TILES[-1], 1e-9, '大力 vs weight 4');
});
// ---------------------------------------------------------------------------------------------------------------------
// Audit: every displacement source of the pool × target class. Official: a 静态刚体 is never moved (every air unit of the
// mode, the 胄 parts too); a hovering enemy is 失衡免疫; a ground enemy follows the 力度 − 重量 tables. Whether a skill
// REACHES an air unit is its own selection — an attack-borne push / pull follows the operator's attack (melee attacks never
// select an air unit; 钩索师 / 阵法术师 / casters "可对空", PRTS 分支特性信息/data), an area effect takes every enemy in it
// unless its PRTS note says ground only (圣聆初雪 S1 "仅对地面单位产生推力") — checked as "hit" on the static drone and as
// "moved" on a synthetic dynamic flyer (DYN_FLY: what a non-static air unit would do).
// [ASSUMED] (no PRTS note): 见行者 S2 "范围内所有敌人" and 歌蕾蒂娅 S3's tornado / 捕网 reach air units (阿消 S2's "可对空" is
// the analogue); 乌尔比安 S1's anchor 捕网 skips them (a melee crusher's throw; 雪雉's 捕网 "不对空").
const SOURCES = [
// name, chessId, skillIndex, ground, air: the push / pull reaches it, air: hit (default: as the push)
['野鬃 S2 夹枪冲锋', 'chess_char_1_19_a', 1, true, false],
['崖心 S1 锁链勾爪', 'chess_char_2_03_a', 0, true, true],
['崖心 S2 束缚链', 'chess_char_2_03_a', 1, true, true],
['见行者 S1 护身射击', 'chess_char_3_07_a', 0, true, false],
['见行者 S2 惊爆射击', 'chess_char_3_07_a', 1, true, true],
['薄绿 S2 聚能涡旋', 'chess_char_3_08_a', 1, true, true],
['莫斯提马 S3 序时之匙', 'chess_char_4_02_a', 2, true, true],
['歌蕾蒂娅 S1', 'chess_char_4_12_a', 0, true, true],
['歌蕾蒂娅 S2', 'chess_char_4_12_a', 1, true, true],
['歌蕾蒂娅 S3', 'chess_char_4_12_a', 2, true, true],
['百炼嘉维尔 S2 (4_23)', 'chess_char_4_23_a', 1, true, false],
['百炼嘉维尔 S2 (5_18)', 'chess_char_5_18_a', 1, true, false],
['乌尔比安 S1', 'chess_char_5_05_a', 0, true, false],
['山 S3 震地碎岩击', 'chess_char_5_17_a', 2, true, false],
['圣聆初雪 S1 铃音吹雪', 'chess_char_6_02_a', 0, true, false, true], // the push spares air units, the damage does not
['锏 S3 归于宁静', 'chess_char_6_19_a', 2, true, true],
];
const TARGETS = [['ground', SLIME, 0], ['static air', YOKAI, 2], ['dynamic air', DYN_FLY, 2], ['hover', PARROT, 0], ['胄 shell', SHELL, 2]];
const POS = [[10, 5], [10, 6], [10, 4], [11, 4]];
/**
* One still enemy, the source's skill ready, `secs` s: the largest displacement and whether the source hit / stunned it
* (positions tried in turn until it moves).
*/
function probe(chessId, skillIndex, key, route, secs = 10) {
let best = 0, reached = false;
for (const pos of POS) {
const h = makeBattle({
defs: { enemies: SYNTH },
units: [{ chessId, row: 10, col: 3, dir: 'RIGHT', skillIndex, carryState: { sp: 1e3 } }],
enemies: [{ key, pos, route, mods: STILL }], autoFinish: false, timeLimit: 60, hooks: ['damaged', 'statusApplied'], captureNoisy: true,
});
const e = () => h.b.enemies[0];
for (let i = 0; i < secs * 30; i++) {
h.step();
if (!e() || !e().alive) break;
best = Math.max(best, Math.hypot(e().x - pos[1], e().y - pos[0]));
}
const mine = (c) => c.target === e() && c.source && (c.source.defId === chessId || c.source.ownerUnit?.defId === chessId);
reached = reached || h.hooksOf('damaged').some(mine) || h.hooksOf('statusApplied').some(mine);
if (best > 0.01) break;
}
return { moved: best > 0.01, reached };
}
test('B5 audit: every displacement source × ground / static air / dynamic air / hovering / 胄 shell', REAL, () => {
const wrong = [];
for (const [name, id, si, ground, air, airHit = air] of SOURCES) {
for (const [cls, key, route] of TARGETS) {
const want = cls === 'ground' ? ground : cls === 'dynamic air' ? air : false;
const { moved, reached } = probe(id, si, key, route);
if (moved !== want) wrong.push(`${name} × ${cls}: ${moved ? 'moved' : 'stayed'}, official ${want ? 'moved' : 'stayed'}`);
if (cls === 'static air' && reached !== airHit) wrong.push(`${name} × ${cls}: ${reached ? 'hit' : 'not hit'}, official ${airHit ? 'hit' : 'not hit'}`);
}
}
assert.deepEqual(wrong, []);
});
test('B5 audit: 琳琅诗怀雅 S3\'s coins push ground enemies only (PRTS 备注 "地面敌方单位…弹道(不可对空)")', REAL, () => {
const id = 'chess_char_3_04_a';
const h = makeBattle({
units: [{ chessId: id, row: 10, col: 3, dir: 'RIGHT', skillIndex: 2 }], flags: { dpInit: 60, dpMax: 99 },
enemies: [{ key: SLIME, pos: [10, 4], mods: STILL }, { key: YOKAI, pos: [10, 5], route: 2, mods: STILL }], autoFinish: false, timeLimit: 30,
});
const u = h.unit(id);
h.step();
assert.equal(u.skill.id, 'skchr_swire2_3');
u.skill.gainSp(u.skill.spCost + 1, 'test');
assert.ok(h.runUntil(() => u.skill.active, 5), 'the toggle is on');
const [g, f] = h.b.enemies;
u.mem.coins = u.skill.bb.sp;
h.step(3);
assert.ok(!u.skill.active && u.mem.coins === 0, 'the coins are spent');
assert.ok(g.x > 4.3, `the ground enemy is pushed forward (${g.x})`);
approx(Math.hypot(f.x - 5, f.y - 10), 0, 1e-9, 'the drone is never paid');
});
test('B5 audit: 缪尔赛思 S3 — a melee 流形 copy\'s pulse reaches air units (PRTS 流形 "…可对空(包括近战流形)"), a static drone stays', REAL, () => {
// the playtest #6 流形 layout (test/sim/playtest6-combat.test.js): the copy at (9,6) takes a melee guard's form
const guard = chessRec({ id: 'test_guard_a', profession: 'WARRIOR', position: 'MELEE', stats: { atk: 500, blockCnt: 2, maxHp: 1e6, def: 0 }, skill: null });
for (const [key, route, pulled] of [[SLIME, 0, true], [DYN_FLY, 2, true], [YOKAI, 2, false]]) {
const h = makeBattle({
defs: { chess: { test_guard_a: guard }, enemies: SYNTH },
units: [
{ chessId: 'chess_char_6_11_a', row: 12, col: 3, skillIndex: 2 },
{ chessId: 'test_guard_a', row: 12, col: 6 },
{ kind: 'token', tokenId: 'token_10030_mlyss_wtrman', row: 9, col: 6, ownerUid: 1, dir: 'LEFT' },
],
enemies: [{ key: SLIME, time: 9, route: 0, mods: { hpMul: 1e4, atkMul: 0 } }], autoFinish: false, timeLimit: 60,
});
const tok = () => h.b.allyUnits.find((x) => x.defId === 'token_10030_mlyss_wtrman' && x.alive);
assert.ok(h.runUntil(() => tok()?.mem.mlyss && !tok().mem.mlyss.ranged, 12), 'a melee copy');
const u = h.unit('chess_char_6_11_a');
assert.equal(u.skill.id, 'skchr_mlyss_3');
const t = tok();
const e = h.spawn(key, { routeIndex: route, pos: [10, 7], mods: STILL });
assert.ok(u.skill.activate('test', { free: true }), 'S3 on');
h.step(2);
// 小力 − weight 0 = 0: all the way to the 急停 radius around the copy's centre (from √2 tiles)
if (pulled) approx(Math.hypot(e.x - t.x, e.y - t.y), PULL_STOP_RADIUS, 1e-6, `${key}: pulled to the copy`);
else approx(Math.hypot(e.x - 7, e.y - 10), 0, 1e-9, `${key}: a 静态刚体 stays`);
}
});
test('B5 audit: 溯光星源 S2 links (凝滞师, "可对空") pull a ground enemy to the target; a static drone stays linked in place', REAL, () => {
const id = 'chess_char_6_16_a';
// her range reaches col 5: the main target stands on (11,5), the linked enemy on (10,6) outside her range
for (const [key, route, pulled] of [[SLIME, 0, true], [DYN_FLY, 2, true], [YOKAI, 2, false]]) {
const h = makeBattle({
defs: { enemies: SYNTH },
units: [{ chessId: id, row: 10, col: 3, dir: 'RIGHT', skillIndex: 1, carryState: { sp: 1e3 } }],
enemies: [{ key: SLIME, pos: [11, 5], mods: STILL }, { key, pos: [10, 6], route, mods: STILL }], autoFinish: false, timeLimit: 30,
});
const u = h.unit(id);
assert.equal(u.skill.id, 'skchr_halo2_2');
h.run(6);
const [main, linked] = h.b.enemies;
approx(Math.hypot(main.x - 5, main.y - 11), 0, 1e-9, 'the main target stays');
// 小力 − weight 0 = 0: all the way, stopping 0.3 from the target (the kit's [ASSUMED] stop)
if (pulled) approx(Math.hypot(linked.x - 5, linked.y - 11), 0.3, 1e-6, `${key}: linked and pulled to the target`);
else approx(Math.hypot(linked.x - 6, linked.y - 10), 0, 1e-9, `${key}: a 静态刚体 stays`);
}
});
test('B5: 刺胄之弹 / 斩胄之剑 / 破胄之锤 are 静态刚体 + 失衡免疫 — a fired shell pulled every tick still lands on time', REAL, () => {
const pool = () => ({ hp: 1e7, maxHp: 1e7, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } });
const run = (tug) => {
const h = makeBattle({
kind: 'boss', sharedBoss: pool(), autoFinish: false, timeLimit: 120, hooks: ['statusApplied'],
defs: { chess: { t_hi: chessRec({ id: 't_hi', profession: 'TANK', stats: { atk: 900, maxHp: 1e7, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null }) } },
kits: { t_hi: () => ({ trait: { noAttack: true } }) },
units: [{ chessId: 't_hi', row: 10, col: 6 }],
});
h.step();
const boss = h.spawn('enemy_9013_acstmk', { pos: [3, 10], routeIndex: 0, mods: { speedMul: 0 }, tag: 'boss' });
boss.profile.noAttack = true;
const shell = () => h.b.enemies.find((x) => x.defId === SHELL);
assert.ok(h.runUntil(() => !!shell(), 60), 'the shell is fired');
const s = shell();
assert.ok(s.s.flags.noDisplace && s.def.staticBody && s.isFlying, '失衡免疫 + 静态刚体');
let moved = 0;
while (s.alive && h.b.time < 110) {
if (tug) {
moved += h.b.pull(s, 5, { to: { x: 0, y: 0 }, stop: 0 }) + h.b.push(s, 5, { from: h.unit('t_hi') }) + h.b.pullToFront(s, h.unit('t_hi'), 5);
}
h.step();
}
const stun = h.hooksOf('statusApplied').find((c) => c.target === h.unit('t_hi') && c.status === 'stun');
return { moved, landed: stun ? stun.t : null };
};
const ref = run(false), got = run(true);
assert.ok(ref.landed != null, 'it explodes on its target');
assert.equal(got.moved, 0, 'no pull or push moves it');
approx(got.landed, ref.landed, 1e-9, 'on time');
// the blades: 失衡免疫 in every mode (PRTS 天赋), 静态刚体 too
const h = makeBattle({ autoFinish: false, timeLimit: 30 });
h.step();
for (const key of ['enemy_9014_acstma', 'enemy_9015_acstmb']) {
const e = h.spawn(key, { pos: [10, 6], routeIndex: 0, mods: { speedMul: 0 } });
assert.ok(e.s.flags.noDisplace && e.def.staticBody, key);
e.motion = 'WALK'; // 【瘫痪】: a ground unit — still a 静态刚体 ("与单位的行动方式无关")
h.b.removeBuff(e, 'boss:anchor');
assert.equal(h.b.pull(e, 5, { to: { x: 2, y: 10 }, stop: 0 }), 0, `${key}: 瘫痪`);
}
});
test('喷气人 飞行模式 = 近地悬浮 + 不可阻挡 + 失衡免疫 (PRTS 喷气人): an air unit nothing moves; then 1.333 s unblockable on the ground', REAL, () => {
const ds = getDefaultSource();
const s = ds.rawEnemy(BALLOON).skills.find((k) => k.prefabKey === 'TakeOff');
const wall = chessRec({ id: 't_wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
const h = makeBattle({
defs: { chess: { t_wall: wall } }, kits: { t_wall: () => ({ trait: { noAttack: true } }) },
units: [{ chessId: 't_wall', row: 9, col: 5 }], autoFinish: false, timeLimit: 60,
});
h.step();
// nearly still (speed × 1e-3), so it stays on the wall's tile while the speed ratios stay measurable
const e = h.spawn(BALLOON, { pos: [9, 5], routeIndex: 0, mods: { speedMul: 1e-3, atkMul: 0 } });
const speed = () => e.s.moveSpeed / e.base.moveSpeed;
const fly = 1 + s.bb['balloon_s[fly].move_speed'];
assert.ok(h.runUntil(() => !!e.findBuff('ab:takeoff'), s.initCooldown + 1), 'takes off when blocked');
const t0 = h.b.time;
h.step();
assert.ok(e.isFlying && e.s.flags.unblockable && e.s.flags.noDisplace && !e.blockedBy, '近地悬浮, 不可阻挡, 失衡免疫');
assert.equal(e.motion, 'WALK', 'keeps the ground path');
// "切换为飞行模式后,1.5秒内移动速度最终降低90%"
approx(speed(), fly * 0.1, 1e-9, 'braking after the take-off');
// 较大力 (2) − weight 3 = −1 would move a ground 喷气人 35 % of the way
assert.equal(h.b.pull(e, 2, { to: { x: 9, y: 9 }, stop: 0 }), 0, 'not pulled');
assert.equal(h.b.push(e, 3, { from: { x: 3, y: 9 } }), 0, 'not pushed');
h.run(1.6);
approx(speed(), fly, 1e-9, '移动速度+50%');
// "飞行模式持续7秒;结束后,1.333秒内不可阻挡,移动速度最终降低90%"
h.runUntil(() => !e.findBuff('ab:takeoff'), s.bb.duration);
approx(h.b.time - t0, s.bb.duration, 0.05, '7 s of flight');
h.step();
assert.ok(!e.isFlying && !e.s.flags.noDisplace, 'landed: a ground unit');
assert.ok(e.s.flags.unblockable && !e.blockedBy, 'unblockable for a while');
approx(speed(), 0.1, 1e-9, 'braking after the landing');
approx(h.b.push(e, 3, { from: { x: 3, y: 9 } }), PUSH_TILES[0], 1e-9, '大力 − 3 = 0 → 1.7 tiles');
h.run(1.4);
assert.ok(!e.s.flags.unblockable, 'blockable again after 1.333 s');
approx(speed(), 1, 1e-9, 'its own speed');
});
test('守墓石像 (PRTS 天赋): the statue (无法被阻挡,自缚,失衡免疫,免疫浮空) and the flight (飞行单位,失衡免疫) — nothing moves it, even 失重', REAL, () => {
const wall = chessRec({ id: 't_wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
for (const key of ['enemy_1172_dugago', 'enemy_1172_dugago_2']) {
const h = makeBattle({
defs: { chess: { t_wall: wall } }, kits: { t_wall: () => ({ trait: { noAttack: true } }) },
units: [{ chessId: 't_wall', row: 10, col: 6 }], autoFinish: false, timeLimit: 60,
});
h.step();
const e = h.spawn(key, { pos: [10, 6], routeIndex: 0, mods: { speedMul: 0, atkMul: 0 } });
h.step();
assert.ok(e.blockedBy && !e.def.staticBody && e.s.massLevel === 4, `${key}: blocked, a dynamic body of weight 4`);
const dur = e.def.talent['stone.duration'];
h.b.kill(e, null);
h.step();
assert.ok(e.alive && e.findBuff('ab:stone'), `${key}: the statue`);
assert.ok(e.s.flags.unblockable && !e.blockedBy && e.s.flags.noMove && e.s.flags.noDisplace, `${key}: 无法被阻挡, 自缚, 失衡免疫`);
assert.equal(h.b.applyStatus(e, 'levitate', { duration: 2 }), false, `${key}: 免疫浮空`);
// 失重 (massFlat −1 → weight 3): 较大力 (2) − 3 = −1 would pull it 35 % of the way, 大力 (3) − 3 = 0 push it 1.7 tiles
h.b.addBuff(e, { key: 'test:weightless', duration: 60, mods: { massFlat: -1 } });
assert.equal(e.s.massLevel, 3);
const at = { x: e.x, y: e.y };
assert.equal(h.b.pull(e, 2, { to: { x: 2, y: 10 }, stop: 0 }), 0, `${key}: statue not pulled`);
assert.equal(h.b.push(e, 3, { from: { x: 4, y: 10 } }), 0, `${key}: statue not pushed`);
h.run(dur + 0.5);
assert.ok(e.alive && e.motion === 'FLY' && e.isFlying && !e.findBuff('ab:stone'), `${key}: reborn as a flyer`);
assert.ok(e.s.flags.noDisplace && !e.def.staticBody, `${key}: 失衡免疫 (not a 静态刚体)`);
assert.equal(h.b.pull(e, 2, { to: { x: 2, y: 10 }, stop: 0 }), 0, `${key}: flyer not pulled`);
assert.equal(h.b.push(e, 3, { from: { x: 4, y: 10 } }), 0, `${key}: flyer not pushed`);
assert.equal(h.b.pullToFront(e, h.unit('t_wall'), 5), 0, `${key}: flyer not hooked`);
approx(Math.hypot(e.x - at.x, e.y - at.y), 0, 1e-9, `${key}: in place`);
checkInvariants(h.b);
}
});
+2 -1
View File
@@ -410,7 +410,8 @@ test('#14 特效 pushes (PRTS 推与拉: one frame less of travel than 弹道 on
approx(e.x, 4 + PUSH_TILES_EFFECT[1], 1e-6, '特效 column');
});
test('#14 锏 S3 归于宁静 slashes and pulls air units too (PRTS 备注 "※可对空。不会拖拽自身中心半径0.6708范围内的敌人")', REAL, () => {
test('#14 锏 S3 归于宁静 slashes air units and pulls a non-static one (PRTS 备注 "※可对空。不会拖拽自身中心半径0.6708范围内的敌人")', REAL, () => {
// a synthetic dynamic flyer: the air units of the mode are all 静态刚体 — hit, never moved (test/sim/feedback1b-displacement.test.js)
const id = 'chess_char_6_19_a';
const h = makeBattle({
defs: { enemies: { enemy_fly: foe('enemy_fly', { speed: 0, mass: 0, motion: 'FLY' }), enemy_near: foe('enemy_near', { speed: 0, mass: 0 }) } },
+24
View File
@@ -1652,6 +1652,28 @@ const HIT_AREAS = Object.freeze({
enemy_9033_acdeer: { w: 4.95, h: 2.95, dx: 0, dy: 1 },
});
/**
* 静态刚体 (static rigidbody) enemies → enemies.json `staticBody: true`: pushes and pulls never move them (player report
* after 0.1.0, "飞机可以被薄绿的技能拉走"). PRTS 特殊机制 静态刚体: "该单位的Unity刚体的刚体类型为部分静态(Kinematic)或静态
* (Static)。使用该类刚体的单位可以进入失衡状态并启用物理,但物理层面上无法产生任何速度或移动 … ※与失衡免疫不同 … ※是否为静态
* 刚体与单位的行动方式无关" (example: 妖怪). Like HIT_AREAS the rigidbody lives in the prefab, not in the game tables: the
* keys whose PRTS page lists "{{特殊机制|静态刚体}}" in its 天赋 (the page of every enemy of data/enemies.json, read 2026-10-03) —
* every air unit of the mode except “炎佑” (its page lists none; weight 10, so no push or pull moves it anyway), plus
* the ground boss 盐风主教昆图斯.
*/
const STATIC_BODIES = Object.freeze(new Set([
'enemy_1005_yokai', 'enemy_1005_yokai_2', 'enemy_1005_yokai_3', // 妖怪 / 妖怪MKII / 威龙
'enemy_1017_defdrn', 'enemy_1040_bombd', 'enemy_1041_lazerd', 'enemy_1041_lazerd_2', 'enemy_1042_frostd', // 御4 / 暴鸰 / 法术大师A1 / A2 / 寒霜
'enemy_1112_emppnt', 'enemy_1112_emppnt_2', // 帝国炮火先兆者 / 帝国炮火中枢先兆者
'enemy_1269_nhfly', 'enemy_1321_wdarft', // 枯朽之种 / 枯朽萃聚使徒
'enemy_1355_mrfly', 'enemy_1355_mrfly_2', 'enemy_1407_hummbd', // 护障 / 护障·P / 远眺
'enemy_1430_lrrook', 'enemy_1521_dslily', // 愧悔魂灵圣杯 / 盐风主教昆图斯 (WALK)
'enemy_9009_acfort', 'enemy_9014_acstma', 'enemy_9015_acstmb', 'enemy_9016_acstmr', // 假想敌:黑云 / “斩胄之剑” / “破胄之锤” / 刺胄之弹
'enemy_10028_vtswd', 'enemy_10029_vtshld', 'enemy_10030_vtwand', // 未装配刀片 / 防护背心 / 冲击式施术单元
'enemy_10040_cnvbln', // 节日气球
'enemy_10083_hlbird', 'enemy_10084_hlegle', 'enemy_10085_hllevi_2', // “萨科塔之翼” / “萨科塔之眼” / “萨科塔昂首”
]));
/**
* Official drawn size of enemy models (user playtest #6 item 9: 威龙 far too large) → enemies.json `modelScale`.
* The official client scales every Spine model in its battle prefab (`dyn/battle/prefabs/enemies/<prefab>.prefab`,
@@ -1807,9 +1829,11 @@ function buildEnemies(ctx) {
seasonOverride: override ? Object.keys(definedFields(override)) : null,
iconId: key, spine: mv(data.prefabKey) || key,
...(hitArea ? { hitArea: { ...hitArea } } : {}),
...(STATIC_BODIES.has(key) ? { staticBody: true } : {}),
...(modelScale != null && modelScale !== 1 ? { modelScale } : {}),
};
}
for (const k of STATIC_BODIES) if (!out[k]) warn(`STATIC_BODIES: ${k} is not an enemy of the mode`);
return out;
}