feedback1b WI: static-body enemies are never pushed or pulled (B5: 薄绿 vs drones)
Player report after 0.1.0: "飞机可以被薄绿的技能拉走". Real. PRTS 特殊机制 静态刚体: such a unit "可以进入失衡状态…但物理层面上无法产生任何速度或移动", independent of its movement type. Every air unit of the mode except “炎佑” lists it in its PRTS 天赋, and so does the ground boss 盐风主教昆图斯. - build-data: STATIC_BODIES (from the enemies' PRTS pages, like HIT_AREAS) writes enemies.json `staticBody: true` on those 28 keys; data regenerated - Battle._displaceable: a static body moves 0 tiles from every push or pull; the skills that reach it still hit it (薄绿 S2, 锏 S3, the hookmasters) - 刺胄之弹 / 斩胄之剑 / 破胄之锤 also get 失衡免疫 (PRTS 天赋) - ground enemies keep the 力度 − 重量 tables; the hovering 吉兆飞鳞 / 掠海漂移体 stay 失衡免疫 while they hover and movable once grounded - tests: the player's scenarios (薄绿 S2 vs 妖怪 / 寒霜, the act2 m01 drone), the data list, and an audit of every displacement source against ground, static air, dynamic air, hovering and 胄-shell targets
This commit is contained in:
1 parent
7f134737ce
commit
f9f26cea52
11 files changed
+416
-18
No files matched your search
+1
-1
@@ -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
|
||||
@@ -348,6 +348,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
@@ -570,8 +570,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`: 近地悬浮, 浮空, the 胄 parts), 静态刚体 (`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 |
|
||||
@@ -1028,9 +1028,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
@@ -1873,16 +1873,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;
|
||||
|
||||
@@ -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; }, // 范围物理伤害
|
||||
|
||||
@@ -708,7 +708,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);
|
||||
@@ -816,7 +816,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 });
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
// 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 天赋 "{{特殊机制|静态刚体}},…失衡免疫…").
|
||||
// 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}: 瘫痪`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 }) } },
|
||||
|
||||
@@ -1628,6 +1628,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`,
|
||||
@@ -1764,9 +1786,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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user