feedback2 WK: an unblocked ranged enemy stands for each attack's clip (its wind-up and the rest of the clip), then walks on; build-data copies each enemy's attack clip into enemies.json attackAnim (GitHub #58)
This commit is contained in:
1 parent
50c9b5b1b6
commit
0623336f30
11 files changed
+254
-22
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","staticBody":true,"modelScale":0.7407},"enemy_1005_yokai_2":{"key":"enemy_1005_yokai_2","name":"妖怪MKII","level":0,"rank":"NORMAL","handbookIndex":"D2","desc":"【飞行单位】妖怪的改进版,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>妖怪的改进版,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":1550,"atk":220,"def":50,"res":0,"moveSpeed":0.9,"bat":3,"aspd":100,"rangeRadius":2,"rawRangeRadius":2,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":4000,"beFactor":0.7,"attrPower":2800,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai_2","spine":"enemy_1005_yokai_2","staticBody":true,"modelScale":0.8148},"enemy_1005_yokai_3":{"key":"enemy_1005_yokai_3","name":"威龙","level":0,"rank":"ELITE","handbookIndex":"D3","desc":"【飞行单位】大型无人机,拥有更好的耐久性能,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>大型无人机,拥有更好的耐久性能,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":30000,"atk":660,"def":170,"res":0,"moveSLine truncated
|
||||
{"enemy_1000_gopro_2":{"key":"enemy_1000_gopro_2","name":"猎狗pro","level":0,"rank":"NORMAL","handbookIndex":"O2","desc":"比一般猎狗更具有作战能力,行动速度很快。","descRaw":"比一般猎狗更具有作战能力,<@eb.key>行动速度很快</>。","applyWay":"MELEE","stats":{"maxHp":1700,"atk":260,"def":0,"res":20,"moveSpeed":1.9,"bat":1.4,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["infection"],"be":3060,"beFactor":1,"attrPower":3060,"isFlyEnemy":false,"tokenOnly":false,"acTypes":["SPECIAL"],"acType":"SPECIAL","templateSlot":"T","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1000_gopro_2","spine":"enemy_1000_gopro_2","modelScale":0.963,"attackAnim":{"clip":"Attack","dur":1,"hit":0.6}},"enemy_1001_bigbo":{"key":"enemy_1001_bigbo","name":"大鲍勃","level":0,"rank":"BOSS","handbookIndex":"11","desc":"整合运动的特殊近战人员,使用电锯进行攻击,兼具高耐久、攻击力和防御力。","descRaw":"整合运动的特殊近战人员,使用电锯进行攻击,兼具<@eb.danger>高耐久、攻击力和防御力</>。","applyWay":"MELEE","stats":{"maxHp":22000,"atk":1900,"def":800,"res":30,"moveSpeed":0.5,"bat":6,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":4,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":[],"be":33990,"beFactor":1,"attrPower":33990,"isFlyEnemy":false,"tokenOnly":false,"acTypes":[],"acType":null,"templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1001_bigbo","spine":"enemy_1001_bigbo","modelScale":1.1111,"attackAnim":{"clip":"Attack","dur":1,"hit":0.167}},"enemy_1005_yokai":{"key":"enemy_1005_yokai","name":"妖怪","level":0,"rank":"NORMAL","handbookIndex":"D1","desc":"【飞行单位】敌方人员操纵的无人机,不会进行攻击。","descRaw":"<@eb.key>【飞行单位】</>敌方人员操纵的无人机,不会进行攻击。","applyWay":"NONE","stats":{"maxHp":800,"atk":0,"def":50,"res":0,"moveSpeed":0.9,"bat":2.3,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"none","dmgTypes":["none"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":950,"beFactor":1,"attrPower":950,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":"NF","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai","spine":"enemy_1005_yokai","staticBody":true,"modelScale":0.7407,"attackAnim":{"clip":"Attack","dur":1,"hit":0.267}},"enemy_1005_yokai_2":{"key":"enemy_1005_yokai_2","name":"妖怪MKII","level":0,"rank":"NORMAL","handbookIndex":"D2","desc":"【飞行单位】妖怪的改进版,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>妖怪的改进版,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":1550,"atk":220,"def":50,"res":0,"moveSpeed":0.9,"bat":3,"aspd":100,"rangeRadius":2,"rawRangeRadius":2,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":4000,"beFactor":0.7,"attrPower":2800,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai_2","spine":"enemy_1005_yokai_2","staticBody":true,"modelScale":0.8148,"attackAnim":{"clip":"Attack","dur":1,"hit":0.267}},"enemy_1005_yokai_3":{"key":"enemy_1005_yokai_3","name":"威龙","level":0,"rank":"ELITE","handbookIndex":"D3","desc":"【飞行单位】大型无人机,拥有更好的耐久性能,可进行远程物理射击。","descRawLine truncated
|
||||
@@ -189,6 +189,10 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
|
||||
}
|
||||
```
|
||||
|
||||
The enemies' attack clip lengths are also a data input: `tools/build-data.mjs` copies each enemy model's
|
||||
`anims.attack.loop` length and first `hits` time into data/enemies.json `attackAnim` (the sim stands an unblocked
|
||||
ranged enemy for that clip, GitHub #58; docs/DATA.md §9) — rebuild the data after a manifest change that touches them.
|
||||
|
||||
### The `Roles` object
|
||||
|
||||
```js
|
||||
|
||||
@@ -358,6 +358,8 @@ blackboards (transitively). Level = `randomEnemyAttributeDict[key].level` (0 for
|
||||
| `hitArea` | `{"w":4.95,"h":2.95,"dx":0,"dy":1}` (`enemy_9013_acstmk`) | **huge units only** (巨型单位, 7 keys: 假想敌:胄 ×2, 假想敌:管 ×2 — the 隐秘核心 one `dx` 1 —, 盐风主教昆图斯, 阿利斯泰尔,帝国余晖, “萨米的意志”): the hit rectangle, `w` tiles along the columns × `h` along the rows, centred on the unit's position moved `dx` columns right / `dy` rows up (sim/body.js; user playtest #5). Not in the game tables (the collider lives in the prefab): `tools/build-data.mjs HIT_AREAS` by `prefabKey`, from PRTS "巨型单位:受击判定区域为长4.95、宽2.95的长方形,向上偏移1.0" and PRTS盟约记录 (the season's 阿利斯泰尔 / “萨米的意志” versions); absent = a point. All 7 are also 自缚 + 无法被阻挡 (PRTS 天赋 — not a data field either: content/bosses.js `SELF_BOUND`) |
|
||||
| `staticBody` | `true` (`enemy_1005_yokai` 妖怪) | **静态刚体 only** (28 keys: every air unit of the mode except “炎佑” — 妖怪 ×3, 御4, 暴鸰, 法术大师 ×2, 寒霜, 帝国炮火先兆者 ×2, 枯朽之种, 枯朽萃聚使徒, 护障 ×2, 远眺, 愧悔魂灵圣杯, 假想敌:黑云, “斩胄之剑”, “破胄之锤”, 刺胄之弹, 未装配刀片, 防护背心, 冲击式施术单元, 节日气球, “萨科塔之翼 / 之眼 / 昂首” — plus the ground boss 盐风主教昆图斯): pushes and pulls never move it, the skills still hit it (sim `Battle._displaceable`; player report after 0.1.0). PRTS 特殊机制 静态刚体: "该单位的Unity刚体的刚体类型为部分静态(Kinematic)或静态(Static)…无法产生任何速度或移动…※是否为静态刚体与单位的行动方式无关". Not in the game tables (the rigidbody lives in the prefab): `tools/build-data.mjs STATIC_BODIES` by key, from the 天赋 "{{特殊机制|静态刚体}}" of each enemy's PRTS page (every enemy of the file, read 2026-10-03); absent = a dynamic body |
|
||||
| `modelScale` | `0.5926` (`enemy_1005_yokai_3` 威龙) | official drawn size of the enemy's Spine model relative to the standard (user playtest #6): the battle prefab's transform scale down to its Spine renderer (Graphic × FaceSwitcher × Spine; SkeletonDataAsset.scale is 0.01 for every enemy skeleton) ÷ 0.27, the standard of 1454 of the client's 2147 enemy prefabs (2080 have exactly one Spine renderer) and of the operators' battle skins. The renderer multiplies `UNIT.modelScale` by it (render/units.js `enemyModelScale`). 125 keys (122 prefabs) carry one, from 0.5926 (威龙 0.16; 妖怪 0.7407, 寒霜 0.6667) to 2.2222 (青铜镜 / 青瓷茶器 0.6); absent = 1. Not in the game tables: `tools/build-data.mjs MODEL_SCALES` by `prefabKey`, read from the local client by `tools/local-extract/enemy_scales.py` |
|
||||
| `attackAnim` | `{"clip":"Attack","dur":1,"hit":0.533}` (`enemy_1019_jshoot` 隐形弩手) | the enemy's attack clip — the one the client plays for its attacks (data/assets.json `anims.attack.loop` of its model, not an Idle stand-in) —, its length (s) and first strike frame (`hits`, the OnAttack event; absent when the clip has none). An unblocked ranged enemy stands for this clip at each attack (sim/ai.js `attackStand`, GitHub #58). 213 keys; absent = no attack clip known (御4, 寒霜, the 岁 relics …: the sim keeps `ATTACK_PAUSE`). Not in the game tables: `tools/build-data.mjs enemyAttackAnim` reads the committed asset manifest (tools/fetch-assets.mjs, from the Spine skeletons) |
|
||||
| `attackMoves` | `true` | **「不停止移动」 attackers only**: the handbook ability text says it attacks on the move (“十字路口”量产型's 四向攻击) — it never stops to attack (sim/ai.js `attackStand`). No enemy of the mode has it (build-data `attacksOnTheMove`) |
|
||||
|
||||
## 10. `factions.json` — special enemies (特训敌人)
|
||||
|
||||
|
||||
+4
-1
@@ -273,7 +273,10 @@ not [ASSUMED, §21.22]. Until 0.1.2 they took every ally in the area (GitHub iss
|
||||
every ally there: the "无视无法选择 / 无视可选性" abilities above (and 远眺's death 暴露, 圆仔's facing count), 寒霜's aura
|
||||
(PRTS names its 攻速下降 Debuff among the effects that "无视隐匿状态起作用") and map / tile / terrain effects (“墓碑”'s
|
||||
halving — PRTS: a map effect —, 【国度】, a chimera's 源石污染区 aura [ASSUMED for the last two])
|
||||
and pause `ATTACK_PAUSE` (0.35 s) after each unblocked attack; `fear`/`disarm` stop attacks; `dmgType 'none'` enemies
|
||||
and, unblocked, stand for each attack's clip — `attackStand`: data/enemies.json `attackAnim`, through its wind-up
|
||||
(cooldown ≤ the strike frame, a target in range) and the rest of the clip after the strike, shortened when the attacks
|
||||
come quicker than the clip; `ATTACK_PAUSE` (0.35 s) after the strike when no clip is known; an `attackMoves`
|
||||
(「不停止移动」) enemy never stops; a stun ends the stand; GitHub #58 — then walk on; `fear`/`disarm` stop attacks; `dmgType 'none'` enemies
|
||||
never attack — unless content arms them through `enemy.profile` (`noAttack: false`, `melee`, `dmgType`, `maxTargets`:
|
||||
转译基底·α's 寻仇者 / 特战术师 forms, which then attack like any enemy); `dmgType 'heal'` enemies heal the lowest-HP% enemy in their radius instead. A `noMove` enemy stands (not `moving`, drawn idle). Content can take over an
|
||||
enemy's attack: `enemy.profile.deferHit` = the engine makes the attack (target, timing, the `'atk'` event) but deals no
|
||||
|
||||
@@ -815,7 +815,8 @@ export class Battle {
|
||||
e.deploySeq = ++this._deploySeq;
|
||||
e.deployedAt = this.time;
|
||||
e.atkCd = 0;
|
||||
e.pauseUntil = -Infinity;
|
||||
e.pauseUntil = -Infinity; // content holds (暴鸰's drop)
|
||||
e.atkStandUntil = -Infinity; // standing for its attack clip (ai.js attackStand)
|
||||
// every enemy profile starts with the same fields (stable object shapes keep the hot loop's property reads fast);
|
||||
// `dmgType` null = the data's (content may arm a data-unarmed enemy: ai.js enemyAttack)
|
||||
e.profile = { noAttack: def.dmgType === 'none', maxTargets: 1, atkScale: 1, dmgType: null };
|
||||
|
||||
+61
-17
@@ -16,10 +16,11 @@
|
||||
// always target the enemies it blocks, in range or not, whatever its facing, and targets them first (acquireTargets,
|
||||
// Battle.blockedTargets; user playtest #6: "阻挡了就一定要能打到"); a heal attack keeps selecting injured allies while
|
||||
// its unit blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位").
|
||||
// Unblocked ranged enemies attack allies within their radius and pause ATTACK_PAUSE seconds after each attack; the
|
||||
// candidates pass the enemy's own rule (`e.profile.canTarget`) and are ordered blocker → taunt → latest deployed
|
||||
// (targeting.js sortAllyTargets). An enemy's damage type is its data's unless content arms it (`e.profile.dmgType`:
|
||||
// 转译基底's forms, whose data never attacks). Reaching the final leg's end = leak. A `fear` (恐惧) status suspends the route: the
|
||||
// Unblocked ranged enemies attack allies within their radius and stand for each attack's clip — through its wind-up
|
||||
// and until the clip ends — then walk on (attackStand, GitHub #58; ATTACK_PAUSE after the strike when no clip is known;
|
||||
// 「不停止移动」 attackers never stop); the candidates pass the enemy's own rule (`e.profile.canTarget`) and are ordered
|
||||
// blocker → taunt → latest deployed (targeting.js sortAllyTargets). An enemy's damage type is its data's unless content
|
||||
// arms it (`e.profile.dmgType`: 转译基底's forms, whose data never attacks). Reaching the final leg's end = leak. A `fear` (恐惧) status suspends the route: the
|
||||
// enemy runs between random checkpoints away from the fear's source (fear.js moveFeared; a self-inflicted fear
|
||||
// flutters inside its own tile); an `attract` (诱导) status walks it to the status point instead (moveAttracted);
|
||||
// both re-plan the route when released (恐惧 outranks 诱导).
|
||||
@@ -449,9 +450,11 @@ export function updateEnemy(b, e, dt) {
|
||||
// hidden (teleporting) enemies only advance wait legs
|
||||
const stunned = e.s.flags.stun;
|
||||
if (e.atkCd > 0 && !stunned && !e.hidden) e.atkCd = Math.max(0, e.atkCd - dt);
|
||||
if (!e.hidden && !stunned) enemyAttack(b, e);
|
||||
// true: an unblocked ranged enemy in the wind-up of its next attack with a target in range (it stands)
|
||||
const winding = !e.hidden && !stunned && enemyAttack(b, e);
|
||||
if (!e.alive) return;
|
||||
if (stunned && !e.hidden) return;
|
||||
// a stun / freeze / sleep cuts the attack clip short: no stand left once it ends [ASSUMED]
|
||||
if (stunned && !e.hidden) { e.atkStandUntil = -Infinity; return; }
|
||||
if (e.blockedBy) {
|
||||
const bl = e.blockedBy;
|
||||
// (unblockable/levitate/fear may also arrive through a plain addBuff, which does not unblock by itself; a
|
||||
@@ -462,6 +465,9 @@ export function updateEnemy(b, e, dt) {
|
||||
}
|
||||
if (!e.hidden && b._checkBlock(e)) return;
|
||||
if (b.time < e.pauseUntil) return;
|
||||
// standing for an attack clip (attackStand, GitHub #58): drawn idle — the client plays the clip, then Move again; a
|
||||
// 恐惧 runs at once (it cannot attack)
|
||||
if (winding || (b.time < e.atkStandUntil && !e.s.flags.fear)) { e.moving = false; return; }
|
||||
if (e.s.flags.noMove) { e.moving = false; return; } // standing (a 重生, a form change): drawn idle, not walking
|
||||
// 恐惧 (ba.fear "无法被阻挡并四散逃跑"; PRTS 诱发移动: 恐惧 outranks 诱导): runs to random tiles of the fan away from
|
||||
// its source — a self-inflicted fear flutters inside its own tile (fear.js); the route re-plans once it ends
|
||||
@@ -573,15 +579,45 @@ function advanceRoute(b, e, dt, R) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long an unblocked ranged enemy stands for one attack (GitHub #58 — the owner's decision of 2026-10-04 from
|
||||
* first-hand memory of the official game: a ranged enemy stops for each attack's animation and walks on between
|
||||
* attacks; the handbook names attacking on the move as a special ability, “十字路口”量产型's 「不停止移动的四向攻击」).
|
||||
* [ASSUMED] the stand lasts exactly its attack clip — data/enemies.json `attackAnim` { dur, hit }: the clip the client
|
||||
* plays for its attacks and its strike frame (tools/build-data.mjs, from the asset manifest) —, `hit` of it before
|
||||
* the strike (the wind-up, while a target is in range) and the rest after it, both shortened when the attacks come
|
||||
* quicker than the clip (it then plays faster: render/spine.js, the same rule) — no source gives a length. An enemy in
|
||||
* another form (掠海漂移体's crawl, 转译基底's forms, 杰斯顿's second form) stands for its base clip [ASSUMED].
|
||||
* Returns the wind-up and rest (s) into `out`: an enemy with no attack clip known stands ATTACK_PAUSE after the strike
|
||||
* (the old rule), a 「不停止移动」 one (`attackMoves`: data or content) never stops.
|
||||
*/
|
||||
export function attackStand(e, out = { wind: 0, rest: 0 }) {
|
||||
out.wind = 0; out.rest = 0;
|
||||
if (e.profile?.attackMoves ?? e.def?.attackMoves) return out;
|
||||
const a = e.def?.attackAnim;
|
||||
if (!a || !(a.dur > 0)) { out.rest = ATTACK_PAUSE; return out; }
|
||||
const iv = e.s.interval;
|
||||
const speed = iv > 0 && iv < a.dur ? a.dur / iv : 1;
|
||||
const hit = Number.isFinite(a.hit) ? Math.min(a.dur, Math.max(0, a.hit)) : a.dur / 2;
|
||||
out.wind = hit / speed;
|
||||
out.rest = (a.dur - hit) / speed;
|
||||
return out;
|
||||
}
|
||||
const STAND = { wind: 0, rest: 0 };
|
||||
|
||||
/**
|
||||
* One tick of an enemy's attack: attacks when its cooldown is over and a target is in reach. Returns true while an
|
||||
* unblocked ranged enemy is in the wind-up of its next attack (cooldown ≤ its clip's wind-up, attackStand) with a
|
||||
* target in range: it stands (updateEnemy).
|
||||
*/
|
||||
function enemyAttack(b, e) {
|
||||
const def = e.def;
|
||||
if (e.profile && e.profile.noAttack) return;
|
||||
if (e.profile && e.profile.noAttack) return false;
|
||||
const dmgType = (e.profile && e.profile.dmgType) || def.dmgType;
|
||||
if (dmgType === 'none' || e.s.atk <= 0) return;
|
||||
if (e.s.flags.fear || e.s.flags.disarm) return;
|
||||
if (e.s.flags.tremble && e.blockedBy) return; // 战栗: 被阻挡后无法进行普通攻击
|
||||
if (e.atkCd > 0) return;
|
||||
if (dmgType === 'heal') { enemyHeal(b, e, e.base.rangeRadius); return; }
|
||||
if (dmgType === 'none' || e.s.atk <= 0) return false;
|
||||
if (e.s.flags.fear || e.s.flags.disarm) return false;
|
||||
if (e.s.flags.tremble && e.blockedBy) return false; // 战栗: 被阻挡后无法进行普通攻击
|
||||
if (dmgType === 'heal') { if (e.atkCd <= 0) enemyHeal(b, e, e.base.rangeRadius); return false; }
|
||||
// applyWay MELEE enemies only ever hit their blocker, even when their data carries a rangeRadius (粉碎攻坚手 2.5,
|
||||
// 宿主士兵 2.5, 深池伙友卫队 1.4 … — that radius belongs to their abilities/splash, handled by content).
|
||||
// Content may flip it with `e.profile.melee = false`.
|
||||
@@ -592,6 +628,11 @@ function enemyAttack(b, e) {
|
||||
// `e.profile.canTarget(ally)`: the enemy's own target rule (只攻击地面单位, 不会攻击飞行单位 …; content/enemies.js),
|
||||
// applied to the candidates before the priority sort and the target count
|
||||
const own = e.profile && typeof e.profile.canTarget === 'function' ? e.profile.canTarget : null;
|
||||
if (e.atkCd > 0) {
|
||||
// the wind-up of the next attack (GitHub #58): an unblocked ranged enemy stands once a target is in range
|
||||
if (e.blockedBy || radius <= 0 || !(e.atkCd <= attackStand(e, STAND).wind + 1e-9)) return false;
|
||||
return b.alliesInRadius(e.x, e.y, reach, null).some((a) => canTargetAlly(e, a, true) && (!own || own(a)));
|
||||
}
|
||||
let targets = [];
|
||||
if (e.blockedBy) {
|
||||
const bl = e.blockedBy;
|
||||
@@ -604,15 +645,16 @@ function enemyAttack(b, e) {
|
||||
}
|
||||
if (own && targets.length) targets = targets.filter((a) => own(a));
|
||||
if (targets.length > 1) sortAllyTargets(e, targets);
|
||||
if (!targets.length) return;
|
||||
if (!targets.length) return false;
|
||||
// 麻痹 (ba.palsy): each stack interrupts one normal attack
|
||||
const palsy = e.buffs.length ? e.findBuff('palsy') : null;
|
||||
if (palsy) {
|
||||
if (--palsy.stacks <= 0) b.removeBuff(e, palsy); else e.markDirty();
|
||||
e.atkCd = e.s.interval;
|
||||
if (!e.blockedBy && radius > 0) e.pauseUntil = b.time + ATTACK_PAUSE;
|
||||
// the interrupted attack ends its clip: the old short stand after it (PRTS 异常效果 麻痹: 0.5 s 麻痹震颤 — not modelled)
|
||||
if (!e.blockedBy && radius > 0 && !(e.profile?.attackMoves ?? def.attackMoves)) e.atkStandUntil = b.time + ATTACK_PAUSE;
|
||||
b.fx('palsy', { x: e.x, y: e.y, id: e.id });
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const n = Math.max(1, Math.floor(e.profile?.maxTargets ?? 1) + Math.floor(e.s.maxTargets));
|
||||
if (targets.length > n) targets = targets.slice(0, n);
|
||||
@@ -620,7 +662,7 @@ function enemyAttack(b, e) {
|
||||
const ctx = { attacker: e, targets, isSkill: false, profile: e.profile };
|
||||
b.emit('beforeAttack', ctx);
|
||||
targets = (ctx.targets || []).filter((t) => t && t.alive);
|
||||
if (!targets.length || !e.alive) return;
|
||||
if (!targets.length || !e.alive) return false;
|
||||
}
|
||||
e.lastAttackAt = b.time;
|
||||
e.stats.attacks++;
|
||||
@@ -644,7 +686,9 @@ function enemyAttack(b, e) {
|
||||
}
|
||||
if (b._hooks.attack) b.emit('attack', { attacker: e, targets, isSkill: false });
|
||||
e.atkCd = e.s.interval;
|
||||
if (!e.blockedBy && radius > 0) e.pauseUntil = b.time + ATTACK_PAUSE;
|
||||
// stands for the rest of its attack clip (attackStand; the wind-up was stood before the strike)
|
||||
if (!e.blockedBy && radius > 0) e.atkStandUntil = b.time + attackStand(e, STAND).rest;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Enemy healers (dmgType 'heal'): heal the lowest-HP% other enemy within their radius. */
|
||||
|
||||
@@ -13,7 +13,11 @@ export const COLS = GEO.COLS;
|
||||
|
||||
/** tilesPerSecond = moveSpeed × MOVE_SCALE (DESIGN §3). */
|
||||
export const MOVE_SCALE = 0.5;
|
||||
/** Ranged enemies stop moving this long after each attack (DESIGN §5.5). */
|
||||
/**
|
||||
* An unblocked ranged enemy stands for each attack's clip (ai.js attackStand, GitHub #58); one whose model has no attack
|
||||
* clip known (no `attackAnim` in data/enemies.json) stands this long after each attack instead, as do all after an
|
||||
* attack 麻痹 interrupts (DESIGN §5.5).
|
||||
*/
|
||||
export const ATTACK_PAUSE = 0.35;
|
||||
/**
|
||||
* Collider radius of an allied unit (PRTS 作战机制 §碰撞体积与位置识别: "我方干员碰撞体积基本均为以0.25格为半径的圆形" —
|
||||
|
||||
@@ -1743,7 +1743,7 @@ function kitRoar(ab) {
|
||||
* `_immuneStunWhenAffecting` 0, like the other enemy channels here): nothing leaves the drone, it keeps its bomb and casts
|
||||
* again after the skill's cooldown (1 s, counted from the interrupt) once it is free and an operator is in range.
|
||||
* [ASSUMED] no drop when the drone is dead at the release; the ATK at the release; the hover through the cast (the
|
||||
* engine pauses an enemy for its attack clip, ATTACK_PAUSE; the drop is the drone's only attack-like cast); the
|
||||
* engine stands a ranged enemy for its attack clip, ai.js attackStand; the drop is the drone's only attack-like cast); the
|
||||
* bomb-less look from the release (the bomb leaves the drone on that frame of the Attack clip; at the cast end — 1 tick
|
||||
* before the clip ends — the client would draw the bomb back for a frame); an interrupted cast does not use up the one
|
||||
* trigger (`_maxTriggerTime` 1 — the bomb is still on the drone); a stun after the release does not stop the cast end.
|
||||
|
||||
@@ -225,6 +225,14 @@ export function immuneSet(v, extra = null) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** data/enemies.json `attackAnim` → { dur, hit } (hit null = none: half the clip), or null when malformed / absent. */
|
||||
function normAttackAnim(a) {
|
||||
const dur = Number(a?.dur);
|
||||
if (!(Number.isFinite(dur) && dur > 0)) return null;
|
||||
const hit = Number(a.hit);
|
||||
return { dur, hit: a.hit != null && Number.isFinite(hit) ? Math.min(dur, Math.max(0, hit)) : null };
|
||||
}
|
||||
|
||||
/** Normalise an enemy record (research 05 shape or data/enemies.json shape) into an EnemyDef. */
|
||||
export function normalizeEnemy(key, e) {
|
||||
const st = e.stats ?? e;
|
||||
@@ -262,6 +270,8 @@ export function normalizeEnemy(key, e) {
|
||||
notCountInTotal: !!(e.notCountInTotal),
|
||||
hitArea: normHitArea(e.hitArea), // huge units only (body.js); null = a point
|
||||
staticBody: !!e.staticBody, // 静态刚体: pushes / pulls never move it (Battle._displaceable)
|
||||
attackAnim: normAttackAnim(e.attackAnim), // its attack clip { dur, hit } (ai.js attackStand); null = none known
|
||||
attackMoves: !!e.attackMoves, // 「不停止移动」: never stops to attack (ai.js attackStand)
|
||||
tags: e.tags ?? [],
|
||||
abilities: e.abilities ?? [],
|
||||
skills: e.skills ?? [],
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// test/sim/feedback2-enemy-attack-stand.test.js — GitHub #58 「1.1中敌方远程攻击单位在攻击时任会移动」: an unblocked ranged enemy
|
||||
// stands for each attack's clip — through its wind-up before the strike and the rest of the clip after it — and walks
|
||||
// on between attacks (the owner's decision of 2026-10-04 from first-hand memory; the clip = data/enemies.json
|
||||
// `attackAnim`, tools/build-data.mjs from the asset manifest; server/sim/ai.js attackStand). It used to stop only
|
||||
// ATTACK_PAUSE (0.35 s) after each strike while the client played the attack clip over the whole walk. An enemy with no
|
||||
// clip known keeps the old 0.35 s (test/sim/combat.test.js); a 「不停止移动」 attacker never stops; a stun cuts the stand.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { makeBattle, enemyRec, chessRec } from '../helpers/battleHarness.js';
|
||||
import { TICK, ATTACK_PAUSE } from '../../server/sim/constants.js';
|
||||
import { attackStand } from '../../server/sim/ai.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const readJson = (f) => JSON.parse(readFileSync(path.join(ROOT, f), 'utf8'));
|
||||
|
||||
/** Positions per tick and attack times of the first enemy, `seconds` long. */
|
||||
function trace(h, seconds) {
|
||||
const log = [], atks = [];
|
||||
h.b.on('attack', (c) => { if (c.attacker.side === 'enemy') atks.push(h.b.time); });
|
||||
for (let i = 0; i < Math.round(seconds / TICK); i++) {
|
||||
h.step();
|
||||
const e = h.enemies()[0];
|
||||
if (!e) break;
|
||||
log.push({ t: h.b.time, x: e.x, y: e.y, moving: e.moving });
|
||||
}
|
||||
return { log, atks };
|
||||
}
|
||||
/** Distance walked between game times t0 and t1 (ticks after t0 up to t1). */
|
||||
function walked(log, t0, t1) {
|
||||
let d = 0;
|
||||
for (let i = 1; i < log.length; i++) if (log[i].t > t0 + 1e-9 && log[i].t <= t1 + 1e-9) d += Math.hypot(log[i].x - log[i - 1].x, log[i].y - log[i - 1].y);
|
||||
return d;
|
||||
}
|
||||
|
||||
test('data: every enemy with a real attack clip carries it as attackAnim (the clip the client plays, its length and strike frame)', () => {
|
||||
const enemies = readJson('data/enemies.json'), assets = readJson('data/assets.json');
|
||||
let n = 0;
|
||||
for (const [k, e] of Object.entries(enemies)) {
|
||||
const sp = assets.enemies[e.spine || k]?.spine;
|
||||
const a = sp?.anims?.attack;
|
||||
const real = !!a && typeof a.loop === 'string' && a.via !== 'idle' && sp.animations[a.loop] > 0;
|
||||
assert.equal(!!e.attackAnim, real, `${k} ${e.name}`);
|
||||
if (!real) continue;
|
||||
n++;
|
||||
assert.equal(e.attackAnim.clip, a.loop, k);
|
||||
assert.equal(e.attackAnim.dur, sp.animations[a.loop], k);
|
||||
const h = sp.hits?.[a.loop];
|
||||
if (Array.isArray(h) && h.length) assert.equal(e.attackAnim.hit, h[0], k);
|
||||
assert.equal(e.attackMoves, undefined, `${k}: no enemy of the mode attacks on the move`);
|
||||
}
|
||||
assert.ok(n > 200, `${n} enemies`);
|
||||
// the ranged attackers of the report
|
||||
assert.deepEqual(enemies.enemy_1019_jshoot.attackAnim, { clip: 'Attack', dur: 1, hit: 0.533 });
|
||||
assert.deepEqual(enemies.enemy_1168_dumage.attackAnim, { clip: 'Attack', dur: 1.2, hit: 0.633 });
|
||||
});
|
||||
|
||||
test('隐形弩手 with an operator in range stands for its 1.0 s Attack clip at every attack (0.533 s wind-up + 0.467 s), then walks', () => {
|
||||
const h = makeBattle({ units: [{ chessId: 'chess_char_3_01_a', row: 10, col: 6, dir: 'RIGHT' }], enemies: [{ key: 'enemy_1019_jshoot', time: 0, route: 0 }],
|
||||
seed: 3, timeLimit: 40, autoFinish: false, hooks: ['attack'] });
|
||||
const { log, atks } = trace(h, 16);
|
||||
assert.ok(atks.length >= 4, `${atks.length} attacks`);
|
||||
const e = h.enemy('enemy_1019_jshoot');
|
||||
const iv = e.s.interval;
|
||||
assert.ok(Math.abs(iv - 2.7) < 1e-9);
|
||||
for (const t of atks.slice(1, 3)) {
|
||||
// the wind-up (the strike frame at 0.533 s of the clip) and the rest of the clip: no step
|
||||
assert.equal(walked(log, t - 0.533 + 2 * TICK, t), 0, `stands through the wind-up before the attack at ${t.toFixed(2)}`);
|
||||
assert.equal(walked(log, t, t + 0.467 - TICK), 0, `stands for the rest of the clip after ${t.toFixed(2)}`);
|
||||
assert.ok(!log.find((r) => Math.abs(r.t - (t + 0.3)) < TICK / 2).moving, 'drawn standing (not on the move clip)');
|
||||
// then it walks until the next wind-up: 2.7 − 1.0 = 1.7 s at 0.9 × 0.5 tiles/s
|
||||
const d = walked(log, t + 0.467 + 2 * TICK, t + iv - 0.533 - TICK);
|
||||
assert.ok(d > 0.6 && d < 0.8, `walks between the attacks (${d.toFixed(2)} tiles)`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the stand shortens with the clip when the attacks come quicker than the clip: an enemy attacking every 1 s with a 2 s clip never walks while it has a target', () => {
|
||||
const quick = { ...enemyRec({ key: 'enemy_quick', hp: 1e9, atk: 1, range: 2.5, bat: 1, speed: 1 }), attackAnim: { clip: 'Attack', dur: 2, hit: 1 } };
|
||||
const st = attackStand({ def: { attackAnim: { dur: 2, hit: 1 } }, s: { interval: 1 }, profile: {} });
|
||||
assert.deepEqual(st, { wind: 0.5, rest: 0.5 });
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_a: chessRec({ id: 't_a', stats: { maxHp: 1e9, atk: 0 } }) }, enemies: { enemy_quick: quick } },
|
||||
units: [{ chessId: 't_a', row: 10, col: 6 }], enemies: [{ key: 'enemy_quick', pos: [9, 8] }], content: 'none', autoFinish: false, timeLimit: 60, hooks: ['attack'],
|
||||
});
|
||||
const { log, atks } = trace(h, 6);
|
||||
assert.ok(atks.length >= 5, `${atks.length} attacks`);
|
||||
assert.equal(walked(log, atks[0], atks.at(-1)), 0, 'stands from the first attack on');
|
||||
});
|
||||
|
||||
test('a 「不停止移动」 attacker (attackMoves) keeps walking through its attacks', () => {
|
||||
const rec = (attackMoves) => ({ ...enemyRec({ key: 'enemy_mover', hp: 1e9, atk: 1, range: 2.5, bat: 2, speed: 1 }), attackAnim: { clip: 'Attack', dur: 1.5, hit: 0.5 }, ...(attackMoves ? { attackMoves: true } : {}) });
|
||||
const run = (attackMoves) => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_a: chessRec({ id: 't_a', stats: { maxHp: 1e9, atk: 0 } }) }, enemies: { enemy_mover: rec(attackMoves) } },
|
||||
units: [{ chessId: 't_a', row: 10, col: 6 }], enemies: [{ key: 'enemy_mover', time: 0, route: 0 }], content: 'none', autoFinish: false, timeLimit: 60, hooks: ['attack'],
|
||||
});
|
||||
return trace(h, 12);
|
||||
};
|
||||
const moving = run(true), standing = run(false);
|
||||
assert.ok(moving.atks.length >= 3 && standing.atks.length >= 3);
|
||||
for (const t of moving.atks.slice(1, 3)) assert.ok(walked(moving.log, t - 0.5, t + 1) > 0.6, `walks through the attack at ${t.toFixed(2)}`);
|
||||
// (the first attack lands as the target comes into range, its cooldown long over: no wind-up to stand through)
|
||||
for (const t of standing.atks.slice(1, 3)) assert.equal(walked(standing.log, t - 0.5 + 2 * TICK, t + 1 - TICK), 0, 'the same enemy without it stands');
|
||||
});
|
||||
|
||||
test('no attack clip known: ATTACK_PAUSE after the strike and no wind-up stand; a stun cuts a stand short', () => {
|
||||
assert.deepEqual(attackStand({ def: {}, s: { interval: 2 }, profile: {} }), { wind: 0, rest: ATTACK_PAUSE });
|
||||
assert.deepEqual(attackStand({ def: { attackMoves: true, attackAnim: { dur: 2, hit: 1 } }, s: { interval: 2 }, profile: {} }), { wind: 0, rest: 0 });
|
||||
const slow = { ...enemyRec({ key: 'enemy_slow', hp: 1e9, atk: 1, range: 2.5, bat: 5, speed: 1 }), attackAnim: { clip: 'Attack', dur: 3, hit: 0.5 } };
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_a: chessRec({ id: 't_a', stats: { maxHp: 1e9, atk: 0 } }) }, enemies: { enemy_slow: slow } },
|
||||
units: [{ chessId: 't_a', row: 10, col: 6 }], enemies: [{ key: 'enemy_slow', pos: [9, 8] }], content: 'none', autoFinish: false, timeLimit: 60, hooks: ['attack'],
|
||||
});
|
||||
h.step();
|
||||
const e = h.enemy('enemy_slow');
|
||||
assert.ok(e.lastAttackAt >= 0, 'attacked at once');
|
||||
assert.ok(Math.abs(e.atkStandUntil - (h.b.time - TICK + 2.5)) < 1e-6, 'stands the 2.5 s after the strike');
|
||||
h.run(0.5);
|
||||
const x0 = e.x;
|
||||
h.b.applyStatus(e, 'stun', { duration: 0.5 });
|
||||
h.run(0.6);
|
||||
assert.equal(e.atkStandUntil, -Infinity, 'the stun ended the stand');
|
||||
h.run(0.3);
|
||||
assert.notEqual(e.x, x0, 'walks once the stun is over');
|
||||
});
|
||||
@@ -337,9 +337,20 @@ async function loadContext() {
|
||||
return {
|
||||
act, ac, charTable, skillTable, rangeTable, uniequip, battleEquip, handbook, enemyDb,
|
||||
levels, templateIds: [...templateIds].sort(naturalCmp), stageIds, enemyDataLevelId, research,
|
||||
manifest: await loadManifest(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The committed asset manifest data/assets.json (tools/fetch-assets.mjs; docs/ASSETS.md) — the enemies' attack clip
|
||||
* lengths (enemyAttackAnim); null when absent or unreadable (no `attackAnim` then, with a warning).
|
||||
*/
|
||||
async function loadManifest() {
|
||||
const abs = join(ROOT, 'data', 'assets.json');
|
||||
if (!existsSync(abs)) { warn('data/assets.json not found; enemies get no attackAnim'); return null; }
|
||||
try { return JSON.parse(await readFile(abs, 'utf8')); } catch (e) { warn(`data/assets.json unreadable: ${e.message}`); return null; }
|
||||
}
|
||||
|
||||
// ===== shared game-object helpers ===============================================================
|
||||
|
||||
/** Range grid [[dRow,dCol]…] of a range_table id (facing right), or null. */
|
||||
@@ -1777,6 +1788,28 @@ const MODEL_SCALES = new Map([
|
||||
const MODEL_SCALE_BY_PREFAB = new Map();
|
||||
for (const [v, list] of MODEL_SCALES) for (const k of list) MODEL_SCALE_BY_PREFAB.set(`enemy_${k}`, Math.round((v / MODEL_SCALE_STANDARD) * 1e4) / 1e4);
|
||||
|
||||
/**
|
||||
* An enemy's attack clip → enemies.json `attackAnim` { clip, dur, hit } (GitHub #58: an unblocked ranged enemy stands for
|
||||
* its attack clip, server/sim/ai.js attackStand): the clip the client plays for its attacks (the asset manifest's
|
||||
* `anims.attack.loop` of the enemy's model — not an Idle stand-in, `via: 'idle'`), its length and its first strike
|
||||
* frame (`hits`: the clip's OnAttack event; absent when it has none — the sim then takes half the clip). Read from the
|
||||
* committed data/assets.json (written by tools/fetch-assets.mjs from the Spine skeletons; docs/ASSETS.md), so the sim
|
||||
* never reads client files; an enemy whose model the manifest lacks gets none (the sim falls back to ATTACK_PAUSE).
|
||||
*/
|
||||
function enemyAttackAnim(manifest, spineId) {
|
||||
const sp = manifest?.enemies?.[spineId]?.spine;
|
||||
const a = sp?.anims?.attack;
|
||||
if (!a || typeof a.loop !== 'string' || a.via === 'idle') return null;
|
||||
const dur = sp.animations?.[a.loop];
|
||||
if (!(typeof dur === 'number' && dur > 0)) return null;
|
||||
const h = sp.hits?.[a.loop];
|
||||
const hit = Array.isArray(h) && Number.isFinite(h[0]) ? Math.min(dur, Math.max(0, h[0])) : null;
|
||||
return hit != null ? { clip: a.loop, dur, hit } : { clip: a.loop, dur };
|
||||
}
|
||||
|
||||
/** The handbook's 「不停止移动」 attack (“十字路口”量产型's 四向攻击) → enemies.json `attackMoves`: it never stops to attack. */
|
||||
const attacksOnTheMove = (abilities) => abilities.some((a) => a.text.includes('不停止移动'));
|
||||
|
||||
/**
|
||||
* The 鸭爵 strategy's swapped-in enemies (`round_start_all_player_change_enemy_2` enemylist — the act2 versions, *_2)
|
||||
* cost BAND_SWAP_LPR at the protection point, not the database's lifePointReduce 0 (the roguelike 宝藏 rule): PRTS
|
||||
@@ -1867,6 +1900,7 @@ function buildEnemies(ctx) {
|
||||
const descRaw = mv(data.description);
|
||||
const hitArea = HIT_AREAS[mv(data.prefabKey) || key] || null;
|
||||
const modelScale = MODEL_SCALE_BY_PREFAB.get(mv(data.prefabKey) || key) ?? null;
|
||||
const attackAnim = enemyAttackAnim(ctx.manifest, mv(data.prefabKey) || key);
|
||||
out[key] = {
|
||||
key, name, level: wantLevel, rank: mv(data.levelType, 'NORMAL'), handbookIndex: hb?.enemyIndex || null,
|
||||
desc: stripRich(descRaw), descRaw: richRaw(descRaw),
|
||||
@@ -1889,6 +1923,8 @@ function buildEnemies(ctx) {
|
||||
...(hitArea ? { hitArea: { ...hitArea } } : {}),
|
||||
...(STATIC_BODIES.has(key) ? { staticBody: true } : {}),
|
||||
...(modelScale != null && modelScale !== 1 ? { modelScale } : {}),
|
||||
...(attackAnim ? { attackAnim } : {}),
|
||||
...(attacksOnTheMove(abilities) ? { attackMoves: true } : {}),
|
||||
};
|
||||
}
|
||||
for (const k of STATIC_BODIES) if (!out[k]) warn(`STATIC_BODIES: ${k} is not an enemy of the mode`);
|
||||
|
||||
Reference in new issue
Block a user