feedback1 WD: late views keep the form in the client, 重生 clears statuses, blocked 隐匿 drawn solid

- render/app.js: the views' unit info is built by the exported renderInfo(UnitInfo), which now keeps `form`;
  addInfo dropped it, so a view built from fieldMeta (a teammate's field watched later, 联防 observers, a
  reconnect) still drew the first form. Tested through renderInfo (fake PIXI) and the real enterBattle(meta)
  path in headless Chrome (opt-in).
- content/enemies.js: rebirthCleanse at every 重生 (reborn, husk, statue): operator-applied buffs and
  source-less statuses go, own talents / enemy-given buffs / field state stay (PRTS 特殊机制 §重生). A 逐火
  knocked out while feared is no longer an unblockable ember.
- snapshot.js: an enemy's stealth bit only while its 隐匿 is on (not blocked, not revealed).
- Docs: area damage reaching an unblocked 隐匿 enemy is documented as an engine deviation (PRTS 作战机制
  §AOE伤害判定), not as the rule.
This commit is contained in:
sganggs committed 2026-10-03 03:44:17 +08:00
1 parent 5c23eeb804
commit e1ce0316dd
10 files changed
+274 -49

No files matched your search

+4 -2
View File
@@ -204,8 +204,10 @@ The resolver's full precedence list is in the header of `tools/assets/anim-roles
The manifest roles describe an enemy's first form. Enemies whose skeleton holds another form's clip set get it from The manifest roles describe an enemy's first form. Enemies whose skeleton holds another form's clip set get it from
`public/js/render/units.js FORMS` (keyed by Spine id, switched by the sim's 'phase' fx kind or the `form` of its `public/js/render/units.js FORMS` (keyed by Spine id, switched by the sim's 'phase' fx kind or the `form` of its
'ember' / 'revive' / 'telegraph' / 'stone' fx — or, for a view built mid-battle, UnitInfo `form`; a `change` clip plays 'ember' / 'revive' / 'telegraph' / 'stone' fx — or, for a view built mid-battle, UnitInfo `form`, which
once first, an `end` clip is timed from the fx's `dur` to finish as that state ends): `render/app.js renderInfo` passes to the view; a `change` clip plays once first, an `end` clip is timed from the fx's
`dur` to finish as that state ends). A blocked or revealed 隐匿 enemy is drawn solid: the sim sends the stealth bit only
while its 隐匿 is on:
- 掠海漂移体's crawl (`Change`, then `*_02`); - 掠海漂移体's crawl (`Change`, then `*_02`);
- 转译基底·α's three forms (`A_Die_B` / `_C` / `_D`, 2 s each, then `B_*` 寻仇者, `C_*` 幽灵, `D_*` 特战术师); - 转译基底·α's three forms (`A_Die_B` / `_C` / `_D`, 2 s each, then `B_*` 寻仇者, `C_*` 幽灵, `D_*` 特战术师);
- the 深池逐火 embers (`Die`, then `Idle_2` / `Move_2` / `Die_2`; `Revive` ends as it stands up) and 假想敌:再生's puppet - the 深池逐火 embers (`Die`, then `Idle_2` / `Move_2` / `Die_2`; `Revive` ends as it stands up) and 假想敌:再生's puppet
+1 -1
View File
@@ -73,7 +73,7 @@
- **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**重装干员**的手动技能在受到伤害时释放;执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放。**自动触发**的技能按其本身规则,例如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。 - **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**重装干员**的手动技能在受到伤害时释放;执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放。**自动触发**的技能按其本身规则,例如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。
- **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。 - **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。
- **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。 - **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。
- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡时才能被选为攻击目标:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(范围伤害也能打到),10 秒内没打掉就满血站起来;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。 - **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡时才能被选为攻击目标:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(挡住时它不再半透明),10 秒内没打掉就满血站起来;敌人重生时身上的减速、恐惧等状态会被清空,被恐惧的逐火战士变成余烬后也能被挡住;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。
- 右上角 🔍▶▶ 可以查看本回合即将来袭的敌人(预览区),左上角 🔍 查看本局信息(禁用盟约、策略)。 - 右上角 🔍▶▶ 可以查看本回合即将来袭的敌人(预览区),左上角 🔍 查看本局信息(禁用盟约、策略)。
## 5. 联防(同盟) ## 5. 联防(同盟)
+12 -6
View File
@@ -229,17 +229,23 @@ a crowd of dying 萨卡兹枯朽战士 totals 50 / s, not 50 × n.
**Knock-outs that are not deaths** (`content/enemies.js`; player reports after 0.1.0): a `killed` ability that keeps the **Knock-outs that are not deaths** (`content/enemies.js`; player reports after 0.1.0): a `killed` ability that keeps the
enemy alive hides the knock-out from every later `kill` handler, the kill count, kill credit and the bounty — they all enemy alive hides the knock-out from every later `kill` handler, the kill count, kill credit and the bounty — they all
wait for the real death, the only one with a `die` event. 重生 / form changes: `reborn()` (first knock-out → second wait for the real death, the only one with a `die` event. Every 重生 (`reborn()`, `husk()`, `statue()`) clears what
operators put on the enemy — the buffs with an ally source and source-less catalogue statuses (PRTS 特殊机制 §重生
"清空自身身上除白名单外所有Buff"; `rebirthCleanse`) — and keeps [ASSUMED: the whitelist] its `persist` talents, what it or
another enemy gave it (锏's self-applied 抵抗, enemy auras) and source-less field state (terrain, airflow, element burst
locks): a 逐火 knocked out while feared is no unblockable ember. 重生 / form changes: `reborn()` (first knock-out → second
form; fx forms 'reborn' → 'form2'), `statue()` (守墓石像: melee only while blocked; first knock-out → 10 s unblockable, form; fx forms 'reborn' → 'form2'), `statue()` (守墓石像: melee only while blocked; first knock-out → 10 s unblockable,
immobile statue → a flyer with ranged arts attacks that skip flyers; forms 'stone' → 'fly'), `husk()` (talent immobile statue → a flyer with ranged arts attacks that skip flyers; forms 'stone' → 'fly'), `husk()` (talent
Revive[Trigger], every knock-out: 1 s 重生 — 无敌, 无法阻挡, immobile — then a hit-count husk that Revive[Trigger], every knock-out: 1 s 重生 — 无敌, 无法阻挡, immobile — then a hit-count husk that
walks its route on: the 深池逐火 余烬 / 火灰 are 隐匿 and disarmed, so only a blocked one can be targeted by operators (area walks its route on: the 深池逐火 余烬 / 火灰 are 隐匿 and disarmed, so only a blocked one can be targeted by operators (the
damage still reaches it); 假想敌:再生's 傀儡 is unblockable and, as it begins, shields the other enemies within 1.8; a husk engine's radius area damage — `enemiesInRadius`: profession splash, skill circles — still reaches an unblocked one, a
known deviation: officially an AoE judges only the enemies it can select, PRTS 作战机制 §AOE伤害判定); 假想敌:再生's 傀儡 is unblockable and, as it begins, shields the other enemies within 1.8; a husk
still standing after `Revive[Trigger].interval` s stands up again with full HP), still standing after `Revive[Trigger].interval` s stands up again with full HP),
转译基底·α (its original form cancels every damage instance, and an HP loss stops at 1 HP; the 4th physical / arts 转译基底·α (its original form cancels every damage instance, and an HP loss stops at 1 HP; the 4th physical / arts
instance or a block starts a 2 s change). Each form change goes through `setForm(b, e, form, fxKind, params)`: the instance or a block starts a 2 s change). Each form change goes through `setForm(b, e, form, fxKind, params)`: the
unit keeps it (`e.form`, published as UnitInfo `form`, so a view built mid-battle from `fieldMeta()` — a watched unit keeps it (`e.form`, published as UnitInfo `form`, so a view built mid-battle from `fieldMeta()` — a watched
teammate's field, 联防 observers, a reconnect — starts in it) and the fx announces it: the `kind` of a form 'phase' fx teammate's field, 联防 observers, a reconnect — starts in it: `render/app.js renderInfo` hands it to the view) and the
fx announces it: the `kind` of a form 'phase' fx
(crawl, translator_*), else the `form` of the 'ember' ('husk'), 'revive' ('revived' / 'form2' / 'fly'), 'telegraph' (crawl, translator_*), else the `form` of the 'ember' ('husk'), 'revive' ('revived' / 'form2' / 'fly'), 'telegraph'
('reborn') or 'stone' ('stone') fx — render/units.js FORMS. Barrier / charge 'phase' kinds are no forms. ('reborn') or 'stone' ('stone') fx — render/units.js FORMS. Barrier / charge 'phase' kinds are no forms.
@@ -423,7 +429,7 @@ it, so the two players of a pair field compete for one instance instead of multi
| `tremble` (战栗) | 被阻挡后无法进行普通攻击: no normal attack **while blocked** (abilities still fire) | – | | `tremble` (战栗) | 被阻挡后无法进行普通攻击: no normal attack **while blocked** (abilities still fire) | – |
| `palsy` (麻痹) | each stack cancels one enemy normal attack (max 3, lasts until consumed); refused by 麻痹免疫 (data `palsyImmune`) | stacks, default 1 | | `palsy` (麻痹) | each stack cancels one enemy normal attack (max 3, lasts until consumed); refused by 麻痹免疫 (data `palsyImmune`) | stacks, default 1 |
| `disarm` | no normal attacks | – | | `disarm` | no normal attacks | – |
| `stealth` / `reveal` | 隐匿: untargetable unless blocked (an ally: only the enemy it blocks attacks it) / cancels stealth | – | | `stealth` / `reveal` | 隐匿: untargetable unless blocked (an ally: only the enemy it blocks attacks it) / cancels stealth. An enemy's b.snap stealth bit is set only while its 隐匿 is on (not blocked, not revealed: drawn solid otherwise); radius splash (`enemiesInRadius`) ignores it [known deviation, PRTS 作战机制 §AOE伤害判定] | – |
| `camou` (迷彩) | an ally's camouflage (ba.camou "不阻挡时不成为敌方普通攻击的目标"): like `stealth` for enemy targeting (only the enemy it blocks attacks it) and on screen (b.snap stealth bit, `snapshot.js flagsOf`), but not 隐匿 for 隐匿-conditions (叙拉古, 家族徽章) and under its own buff keys. 忍冬 S3 (key `vulpis:camou`, until her next cast), 寒芒克洛丝 S1 | – | | `camou` (迷彩) | an ally's camouflage (ba.camou "不阻挡时不成为敌方普通攻击的目标"): like `stealth` for enemy targeting (only the enemy it blocks attacks it) and on screen (b.snap stealth bit, `snapshot.js flagsOf`), but not 隐匿 for 隐匿-conditions (叙拉古, 家族徽章) and under its own buff keys. 忍冬 S3 (key `vulpis:camou`, until her next cast), 寒芒克洛丝 S1 | – |
| `invulnerable` | ignores damage | – | | `invulnerable` | ignores damage | – |
| `levitate` (浮空) | stun + unblockable (unblocks enemies) + 失衡免疫 (`noDisplace`); an air unit meanwhile (`isFlying`: melee cannot hit it); **half duration on units with (current) massLevel > 3**; refused on data flyers (`motion` FLY) and units already levitated (PRTS 异常效果 "若单位数据上为飞行单位…或是持有浮空异常则Buff取消") — a 近地悬浮 enemy is WALK in its data, so it can be levitated; 浮空 is not one of the 近地悬浮 enemies' drop triggers | – | | `levitate` (浮空) | stun + unblockable (unblocks enemies) + 失衡免疫 (`noDisplace`); an air unit meanwhile (`isFlying`: melee cannot hit it); **half duration on units with (current) massLevel > 3**; refused on data flyers (`motion` FLY) and units already levitated (PRTS 异常效果 "若单位数据上为飞行单位…或是持有浮空异常则Buff取消") — a 近地悬浮 enemy is WALK in its data, so it can be levitated; 浮空 is not one of the 近地悬浮 enemies' drop triggers | – |
@@ -977,7 +983,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te
- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: an enemy's current model form — `content/enemies.js setForm` — so a view built mid-battle from `fieldMeta()` starts on that clip set) - `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: an enemy's current model form — `content/enemies.js setForm` — so a view built mid-battle from `fieldMeta()` starts on that clip set)
(`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping) (`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping)
(`spine`/`avatar` are asset ids from data). (`spine`/`avatar` are asset ids from data).
- flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512); - flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 (an enemy's only while not blocked / revealed) or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512);
anim: ANIM codes (idle 0, move 1, attack 2, skill 3, die 4, stun 5, deploy 6). anim: ANIM codes (idle 0, move 1, attack 2, skill 3, die 4, stun 5, deploy 6).
--- ---
+31 -16
View File
@@ -12,7 +12,9 @@
// new board piece flashes (fx.deploy); a merge's elite — on the tile of // new board piece flashes (fx.deploy); a merge's elite — on the tile of
// the deployed copy it replaced, or on its bench slot — gets the // the deployed copy it replaced, or on its bench slot — gets the
// promotion cue instead (render/promote.js, fx.promote) // promotion cue instead (render/promote.js, fx.promote)
// view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] } // view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] } — each
// unit through renderInfo(UnitInfo) (an enemy's `form`: a view built
// mid-battle starts in the current model form)
// view.pushSnapshot(snap); view.pushEvents(ev | { ev, gt }) b.snap / b.ev wire frames as received (game time in // view.pushSnapshot(snap); view.pushEvents(ev | { ev, gt }) b.snap / b.ev wire frames as received (game time in
// `gt`; a numeric `t` is accepted for raw Battle snapshots / recordings) // `gt`; a numeric `t` is accepted for raw Battle snapshots / recordings)
// — 100 ms interpolation buffer; b.snap `down` keeps knocked-out // — 100 ms interpolation buffer; b.snap `down` keeps knocked-out
@@ -243,6 +245,32 @@ export const FORCED_EXIT = 'forcedExit';
*/ */
export const showsDeathFx = (info, consumed = false, reason = null) => !consumed && reason !== FORCED_EXIT && info?.kind !== 'device'; export const showsDeathFx = (info, consumed = false, reason = null) => !consumed && reason !== FORCED_EXIT && info?.kind !== 'device';
/**
* The views' info of a battle unit from its UnitInfo (m.field / fieldMeta `units`, a 'spawn' event; snapshot.js
* unitInfo), sanitised; null for a malformed entry. `form` — an enemy's current model form (content/enemies.js setForm:
* 转译基底·α's forms, a 逐火 余烬, a leader after its 重生, 掠海漂移体's crawl) — makes a view built mid-battle (a teammate's
* field watched later, 联防 observers, a reconnect, server-run watchers: no fx of the change is replayed) start on that
* clip set (render/units.js FORMS); it used to be dropped here, so such views drew the first form (player report #5).
*/
export function renderInfo(u) {
if (!u || typeof u !== 'object' || (typeof u.id !== 'number' && typeof u.id !== 'string')) return null;
return {
id: u.id, uid: u.uid ?? null, kind: u.kind || 'enemy', side: u.side === 'ally' ? 'ally' : 'enemy', ownerId: u.ownerId ?? null,
defId: u.defId ?? null, name: u.name ?? '', tier: u.tier ?? 1, golden: !!u.golden, spine: u.spine ?? u.defId ?? null,
avatar: u.avatar ?? u.defId ?? null, x: Number(u.x) || 0, y: Number(u.y) || 0, facing: u.facing === -1 ? -1 : 1,
maxHp: Number(u.maxHp) || 1, boss: !!u.boss, motion: u.motion,
// deploy direction of allies (UnitInfo.dir, DESIGN §3): the model (Back for UP, mirrored for LEFT) and the
// ground wedge follow it; absent = unknown (legacy frames) → derived from `facing`, no wedge
dir: typeof u.dir === 'string' ? u.dir : undefined,
// an enemy's current model form (UnitInfo.form): the view starts in it (UnitView reads info.form)
form: typeof u.form === 'string' ? u.form : undefined,
// DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a
// tap hands them to the detail card (a teammate's unit shows its owner's skill / module)
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined,
moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined,
};
}
/** '2d' | '3d' | 'auto' board preference: `?board=` in the page URL (dev), else the view option. */ /** '2d' | '3d' | 'auto' board preference: `?board=` in the page URL (dev), else the view option. */
export function boardPreference(opt) { export function boardPreference(opt) {
let q = null; let q = null;
@@ -1283,21 +1311,8 @@ export async function createFieldView(host, options = {}) {
} }
function addInfo(u) { function addInfo(u) {
if (!u || typeof u !== 'object' || (typeof u.id !== 'number' && typeof u.id !== 'string')) return null; const info = renderInfo(u);
const info = { if (info) infos.set(info.id, info);
id: u.id, uid: u.uid ?? null, kind: u.kind || 'enemy', side: u.side === 'ally' ? 'ally' : 'enemy', ownerId: u.ownerId ?? null,
defId: u.defId ?? null, name: u.name ?? '', tier: u.tier ?? 1, golden: !!u.golden, spine: u.spine ?? u.defId ?? null,
avatar: u.avatar ?? u.defId ?? null, x: Number(u.x) || 0, y: Number(u.y) || 0, facing: u.facing === -1 ? -1 : 1,
maxHp: Number(u.maxHp) || 1, boss: !!u.boss, motion: u.motion,
// deploy direction of allies (UnitInfo.dir, DESIGN §3): the model (Back for UP, mirrored for LEFT) and the
// ground wedge follow it; absent = unknown (legacy frames) → derived from `facing`, no wedge
dir: typeof u.dir === 'string' ? u.dir : undefined,
// DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a
// tap hands them to the detail card (a teammate's unit shows its owner's skill / module)
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined,
moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined,
};
infos.set(info.id, info);
return info; return info;
} }
+1 -1
View File
@@ -27,7 +27,7 @@
// Enemy modes (sim fx 'phase' { id, kind } or another fx's `form` → `setForm(kind, fx)`): 掠海漂移体 dropping to 爬行模式 // Enemy modes (sim fx 'phase' { id, kind } or another fx's `form` → `setForm(kind, fx)`): 掠海漂移体 dropping to 爬行模式
// (user playtest #5 item 1) plays its skeleton's 'Change' clip once, then the crawl set (*_02); 转译基底's forms, the 逐火 // (user playtest #5 item 1) plays its skeleton's 'Change' clip once, then the crawl set (*_02); 转译基底's forms, the 逐火
// embers, 再生's puppet, the leaders' 重生 and 守墓石像 likewise (user report after 0.1.0) — FORMS. A view built later // embers, 再生's puppet, the leaders' 重生 and 守墓石像 likewise (user report after 0.1.0) — FORMS. A view built later
// (UnitInfo `form`, the sim's current form) starts in the mode. // (`info.form` = UnitInfo `form`, the sim's current form, through render/app.js renderInfo) starts in the mode.
// Element gauges (b.snap `elem` → sample `el` / `elFill` / `elUntil` / `elDur`), the official form (PRTS 元素: "模型 // Element gauges (b.snap `elem` → sample `el` / `elFill` / `elUntil` / `elDur`), the official form (PRTS 元素: "模型
// 下部会显示对应的元素图标,并以白条显示剩余的元素值"; enemies "小尺寸图标(不显示元素图标,仅根据元素种类改变背景色)"): a row // 下部会显示对应的元素图标,并以白条显示剩余的元素值"; enemies "小尺寸图标(不显示元素图标,仅根据元素种类改变背景色)"): a row
// right under the unit's own HP / SP bars and inside their span — the element's disc at the left (operators with its // right under the unit's own HP / SP bars and inside their span — the element's disc at the left (operators with its
+38 -11
View File
@@ -25,7 +25,9 @@
// (absorbs arts after RES); frontGuard() — "来自正面的伤害降低" (facing = walking direction or the bigger crowd); // (absorbs arts after RES); frontGuard() — "来自正面的伤害降低" (facing = walking direction or the bigger crowd);
// unbalanced() — 失衡 detection (displacement beyond the enemy's own speed; engine displace() has no hook); // unbalanced() — 失衡 detection (displacement beyond the enemy's own speed; engine displace() has no hook);
// husk() — "被击倒时暂时变为…,一段时间后重生" (Revive[Trigger]: every knock-out of the first form ⇒ 1 s 重生 ⇒ a walking // husk() — "被击倒时暂时变为…,一段时间后重生" (Revive[Trigger]: every knock-out of the first form ⇒ 1 s 重生 ⇒ a walking
// hit-count husk — 隐匿 逐火 embers, the unblockable 再生 puppet — until the real death or its revival); a data-unarmed // hit-count husk — 隐匿 逐火 embers, the unblockable 再生 puppet — until the real death or its revival); every 重生
// (reborn / husk / statue) clears the enemy's statuses and the buffs allies gave it (rebirthCleanse, PRTS 特殊机制
// §重生 "清空自身身上除白名单外所有Buff"); a data-unarmed
// enemy armed by a form (转译基底) sets `e.profile` noAttack / melee / dmgType / maxTargets and attacks through the // enemy armed by a form (转译基底) sets `e.profile` noAttack / melee / dmgType / maxTargets and attacks through the
// engine (ai.js enemyAttack); float() — 近地悬浮 (an air unit that // engine (ai.js enemyAttack); float() — 近地悬浮 (an air unit that
// keeps its ground path, Unit.isFlying; kitSyufo / kitParrot lose it when stunned). `e.profile.canTarget(ally)` = the // keeps its ground path, Unit.isFlying; kitSyufo / kitParrot lose it when stunned). `e.profile.canTarget(ally)` = the
@@ -43,7 +45,10 @@
// gauge whose burst is the official one (termDescription ba.dt.erosion: "永久降低100点防御力并受到800点物理伤害"). // gauge whose burst is the official one (termDescription ba.dt.erosion: "永久降低100点防御力并受到800点物理伤害").
// DOT — damage zones (污染秽蚀: true damage, one tick per second however many cover a unit; 毒雾, 燃烧区域), // DOT — damage zones (污染秽蚀: true damage, one tick per second however many cover a unit; 毒雾, 燃烧区域),
// bleeding (removed by healing), pulsing auras. // bleeding (removed by healing), pulsing auras.
// INVISIBLE — permanent `stealth` flag (engine: untargetable unless blocked or revealed; AoE still hits). // INVISIBLE — permanent `stealth` flag (engine: untargetable unless blocked or revealed). Radius area damage
// (Battle.enemiesInRadius: profession splash around a struck target, many skills' circles) still hits
// an unblocked one — a known deviation: officially an AoE only judges the enemies it can select (PRTS
// 作战机制 §AOE伤害判定; ba.invisible vs ba.camou); tile selectors (enemiesInKeys) already skip it.
// REFLECTION — 折射 (ba.refraction "生效时,法术抗性+70"): RES +refracting.magic_resistance while NOT silenced // REFLECTION — 折射 (ba.refraction "生效时,法术抗性+70"): RES +refracting.magic_resistance while NOT silenced
// (the ability line is SILENCE-flagged: silencing turns it off); 镜膜 also gets max HP +100 % while on. // (the ability line is SILENCE-flagged: silencing turns it off); 镜膜 also gets max HP +100 % while on.
// SPECIAL — mostly stats; prisoners, 穿刺手, 暴虐兵长, 镜卫, 动力装甲 … below. // SPECIAL — mostly stats; prisoners, 穿刺手, 暴虐兵长, 镜卫, 动力装甲 … below.
@@ -704,9 +709,26 @@ function freeAllPrisoners(b) {
for (const o of b.aliveEnemies()) for (const x of (o.mem.ab ? o.mem.ab.list : [])) if (x.liberate && !x.free) x.liberate(b, o, x); for (const o of b.aliveEnemies()) for (const x of (o.mem.ab ? o.mem.ab.list : [])) if (x.liberate && !x.free) x.liberate(b, o, x);
} }
/**
* 重生 "清空自身身上除白名单外所有Buff" (PRTS 特殊机制 §重生): at a 重生 (reborn(), husk(), statue()) the enemy loses every
* buff an operator / summon / device put on it and every source-less catalogue status (晕眩, 减速, 恐惧, 脆弱, 诱导 … —
* buffs.js STATUS). Kept [ASSUMED: the whitelist]: its talents and traits (`persist` buffs, and what it or another enemy
* gave it — 锏's 抵抗 is a self-applied status, enemy auras refresh every few tenths of a second anyway) and the field's
* state buffs without a source (terrain, airflow — re-applied by position — and element burst locks). Without it a 逐火
* knocked out while feared (叙拉古 / 妮芙: 恐惧 makes it unblockable) stayed unblockable — so, 隐匿, untargetable — as an
* ember until the fear ran out.
*/
function rebirthCleanse(b, e) {
for (const x of e.buffs.slice()) {
if (x.persist || (x.source && x.source.side === 'enemy')) continue;
if (x.source || x.status) b.removeBuff(e, x);
}
}
/** /**
* "首次被击倒后重生 / 进入第二形态": the first knock-out is hidden (kill credit and bounty wait for the real death) and starts * "首次被击倒后重生 / 进入第二形态": the first knock-out is hidden (kill credit and bounty wait for the real death) and starts
* a rebirth of `dur` s — invulnerable, untargetable, released by its blocker, inert (no move / attack / skill). * a rebirth of `dur` s — statuses cleared (rebirthCleanse), invulnerable, untargetable, released by its blocker, inert
* (no move / attack / skill).
* `onKo(b, e, a)` runs at once (self-destruct, freeing prisoners …), `during(b, e, a, elapsed)` every tick of it; then the * `onKo(b, e, a)` runs at once (self-destruct, freeing prisoners …), `during(b, e, a, elapsed)` every tick of it; then the
* enemy stands up with `hpRatio` of its max HP, `onReborn(b, e, a)` switches the form and `invincible` s of 无敌 follow. * enemy stands up with `hpRatio` of its max HP, `onReborn(b, e, a)` switches the form and `invincible` s of 无敌 follow.
* `a.state`: undefined → 'reborn' → 'form2'. The 'telegraph' fx of the knock-out carries `form: 'reborn'` and the 'revive' * `a.state`: undefined → 'reborn' → 'form2'. The 'telegraph' fx of the knock-out carries `form: 'reborn'` and the 'revive'
@@ -731,6 +753,7 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu
a.state = 'reborn'; a.state = 'reborn';
a.t0 = b.time; a.t0 = b.time;
a.noAtk = e.profile.noAttack; a.noAtk = e.profile.noAttack;
rebirthCleanse(b, e);
e.hp = Math.min(1, e.s.maxHp); e.hp = Math.min(1, e.s.maxHp);
if (onKo) safe(b, e, () => onKo(b, e, a)); if (onKo) safe(b, e, () => onKo(b, e, a));
if (!(dur > 0)) { finish(b, e, a); return true; } if (!(dur > 0)) { finish(b, e, a); return true; }
@@ -811,11 +834,12 @@ function artsBarrier(amount, { key = 'ab:artsBarrier', whileUp = null } = {}) {
/** /**
* "被击倒时暂时变为…,一段时间后重生" (talent Revive[Trigger]: 逐火 embers, 假想敌:再生's puppet). A knock-out of the first form is * "被击倒时暂时变为…,一段时间后重生" (talent Revive[Trigger]: 逐火 embers, 假想敌:再生's puppet). A knock-out of the first form is
* no kill (credit, bounty and the kill count wait for the real death): HUSK_REBIRTH s of 重生 first — invulnerable, * no kill (credit, bounty and the kill count wait for the real death): HUSK_REBIRTH s of 重生 first — statuses cleared
* untargetable, unblockable, immobile, 失衡免疫 (PRTS 深池逐火战士 天赋 "被击倒后重生,持续1s,随后变为怨恨的余烬,1s内不移动且持有 * (rebirthCleanse: a feared warrior is no unblockable ember), invulnerable, untargetable, unblockable, immobile, 失衡免疫
* 无敌+无法阻挡+失衡免疫"; PRTS 特殊机制 §重生) — then the husk until `delay` s after that: `hits` HP of 特殊生命值机制 (every * (PRTS 深池逐火战士 天赋 "被击倒后重生,持续1s,随后变为怨恨的余烬,1s内不移动且持有无敌+无法阻挡+失衡免疫"; PRTS 特殊机制 §重生) —
* damage instance removes 1 — PRTS 特殊机制 §特殊生命值机制; engine flag hitCount), no attack (缴械), walking its route on — * then the husk until `delay` s after that: `hits` HP of 特殊生命值机制 (every damage instance removes 1 — PRTS 特殊机制
* 隐匿 (`stealthy`: targetable only while blocked, targeting.js canTargetEnemy — the 余烬 must be blocked to be beaten) * §特殊生命值机制; engine flag hitCount), no attack (缴械), walking its route on — 隐匿 (`stealthy`: targetable only while
* blocked, targeting.js canTargetEnemy — the 余烬 must be blocked to be beaten; drawn solid while blocked)
* and / or unblockable (`unblock`: 再生's 傀儡 "不可被阻挡"). Killing the husk is the real death; a husk still standing * and / or unblockable (`unblock`: 再生's 傀儡 "不可被阻挡"). Killing the husk is the real death; a husk still standing
* after `delay` s stands up in its first form with full HP, and every later knock-out starts it again ("一次又一次地站起"). * after `delay` s stands up in its first form with full HP, and every later knock-out starts it again ("一次又一次地站起").
* `onHusk(b, e)` runs as the husk begins, after the 重生 ("进入此形态时": 再生's shields). * `onHusk(b, e)` runs as the husk begins, after the 重生 ("进入此形态时": 再生's shields).
@@ -848,6 +872,7 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke
a.rebornUntil = b.time + HUSK_REBIRTH; a.rebornUntil = b.time + HUSK_REBIRTH;
a.noAtk = e.profile.noAttack; a.noAtk = e.profile.noAttack;
a.max = a.max ?? e.base.maxHp; a.max = a.max ?? e.base.maxHp;
rebirthCleanse(b, e);
e.profile.noAttack = true; e.profile.noAttack = true;
e.lastAttackAt = -Infinity; // the snapshot shows no attack of the fallen warrior e.lastAttackAt = -Infinity; // the snapshot shows no attack of the fallen warrior
setHits(e, hits); setHits(e, hits);
@@ -864,9 +889,10 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke
} }
/** /**
* 守墓石像 (PRTS 守墓石像 天赋): 地面模式 — melee attacks only while blocked; the first defeat is an instant 重生 to 100 % HP * 守墓石像 (PRTS 守墓石像 天赋): 地面模式 — melee attacks only while blocked; the first defeat is an instant 重生 (statuses
* into 转换模式 for stone.duration s — unblockable, 自缚 (immobile), 失衡免疫, immune to 浮空, DEF +stone.def, RES * cleared: rebirthCleanse) to 100 % HP into 转换模式 for stone.duration s — unblockable, 自缚 (immobile), 失衡免疫, immune
* +stone.magic_resistance, no attack [ASSUMED: PRTS lists none] — then 飞行模式: a flyer (失衡免疫) whose ranged attacks (the * to 浮空, DEF +stone.def, RES +stone.magic_resistance, no attack [ASSUMED: PRTS lists none] — then 飞行模式: a flyer
* (失衡免疫) whose ranged attacks (the
* data's 1.6 radius) deal arts damage and never target flyers; the HP is not refilled again. fx forms 'stone' / 'fly' * data's 1.6 radius) deal arts damage and never target flyers; the HP is not refilled again. fx forms 'stone' / 'fly'
* (its Sleep and *_2 clips, render/units.js FORMS). <破碎支柱> (an event device) has no counterpart in this mode. * (its Sleep and *_2 clips, render/units.js FORMS). <破碎支柱> (an event device) has no counterpart in this mode.
*/ */
@@ -878,6 +904,7 @@ function statue(ab) {
if (a.done || !(dur > 0)) return false; if (a.done || !(dur > 0)) return false;
a.done = true; a.done = true;
a.noAtk = e.profile.noAttack; a.noAtk = e.profile.noAttack;
rebirthCleanse(b, e); // "进行重生。重生瞬间完成"
e.profile.noAttack = true; e.profile.noAttack = true;
e.hp = e.s.maxHp; e.hp = e.s.maxHp;
ab2.immune = new Set([...(ab2.immune || []), 'levitate']); ab2.immune = new Set([...(ab2.immune || []), 'levitate']);
+6 -3
View File
@@ -4,7 +4,8 @@
// UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, // UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?,
// form?, skillIndex?, moduleId? } (form = an enemy's current model form, content/enemies.js setForm) // form?, skillIndex?, moduleId? } (form = an enemy's current model form, content/enemies.js setForm)
// dir = 'UP'|'RIGHT'|'DOWN'|'LEFT' (allies: the deploy direction, sim/dir.js); facing = its horizontal sign (±1). // dir = 'UP'|'RIGHT'|'DOWN'|'LEFT' (allies: the deploy direction, sim/dir.js); facing = its horizontal sign (±1).
// flags bits & anim codes come from shared/constants.js (UF / ANIM). // flags bits & anim codes come from shared/constants.js (UF / ANIM); an enemy's stealth bit = its 隐匿 is on (not while it
// is blocked or revealed), an ally's = 隐匿 / 迷彩 whatever it blocks.
import { UF, ANIM } from '../../shared/constants.js'; import { UF, ANIM } from '../../shared/constants.js';
import { DIE_ANIM_TIME, ATTACK_ANIM_TIME, DEPLOY_ANIM_TIME } from './constants.js'; import { DIE_ANIM_TIME, ATTACK_ANIM_TIME, DEPLOY_ANIM_TIME } from './constants.js';
@@ -52,8 +53,10 @@ export function flagsOf(u) {
if (u.side === 'enemy' ? !!u.blockedBy : u.blocking.length > 0) bits |= UF.BLOCKED; if (u.side === 'enemy' ? !!u.blockedBy : u.blocking.length > 0) bits |= UF.BLOCKED;
if (f.stun && !f.freeze && !f.sleep) bits |= UF.STUNNED; if (f.stun && !f.freeze && !f.sleep) bits |= UF.STUNNED;
if (f.freeze) bits |= UF.FROZEN; if (f.freeze) bits |= UF.FROZEN;
// 隐匿 or 迷彩 (buffs.js camou): both shown the see-through way, blocking or not (targeting.js canTargetAlly) // 隐匿 or 迷彩 (buffs.js camou), shown the see-through way. An ally keeps it while blocking (targeting.js
if (f.stealth || f.camou) bits |= UF.STEALTH; // canTargetAlly); an enemy's 隐匿 is off while it is blocked or revealed (PRTS 作战机制 §隐匿 "在被阻挡时开关会被关掉从而
// 失去隐匿"; targeting.js canTargetEnemy): a blocked 逐火 余烬 is drawn solid while the team beats it
if (u.side === 'enemy' ? (f.stealth && !f.reveal && !u.blockedBy) || f.camou : f.stealth || f.camou) bits |= UF.STEALTH;
if (u.skill && u.skill.active && u.skill.kind !== 'passive') bits |= UF.SKILL; if (u.skill && u.skill.active && u.skill.kind !== 'passive') bits |= UF.SKILL;
if (u.s.shield > 0 || u.buffs.some((b) => b.shieldHits > 0)) bits |= UF.SHIELD; if (u.s.shield > 0 || u.buffs.some((b) => b.shieldHits > 0)) bits |= UF.SHIELD;
if (f.invulnerable) bits |= UF.INVULN; if (f.invulnerable) bits |= UF.INVULN;
+75 -1
View File
@@ -4,7 +4,10 @@
// #5 转译基底·α walks on its A_* clips, takes 4 physical hits without losing HP, plays its 2 s A_Die_B change and goes on // #5 转译基底·α walks on its A_* clips, takes 4 physical hits without losing HP, plays its 2 s A_Die_B change and goes on
// as 寻仇者 (B_*), dying on B_Die — it used to die in its first form, on B_Die ("加载变身动画然后就没了"); // as 寻仇者 (B_*), dying on B_Die — it used to die in its first form, on B_Die ("加载变身动画然后就没了");
// #8 a 深池逐火战士 knocked out in front of 百炼嘉维尔 plays 'Die' (its 1 s 重生), stands there as the 隐匿 ember on // #8 a 深池逐火战士 knocked out in front of 百炼嘉维尔 plays 'Die' (its 1 s 重生), stands there as the 隐匿 ember on
// Idle_2 (blocked, so its blocker beats it) and dies on Die_2 — it used to stay an untargetable ember forever. // Idle_2 (blocked, so its blocker beats it) and dies on Die_2 — it used to stay an untargetable ember forever;
// a view built mid-battle — the real `view.enterBattle(b.fieldMeta())` after a silent catch-up, as for a teammate's
// field watched later, 联防 observers or a reconnect — starts in the current forms (UnitInfo `form` → render/app.js
// renderInfo): it used to draw the warrior / the A model again (the look of report #5) and 掠海漂移体 hovering.
// //
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/feedback1-forms.browser.test.js // Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/feedback1-forms.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/feedback1-*.png. // Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/feedback1-*.png.
@@ -76,6 +79,48 @@ async function runInPage(page, port, s, enemyKey, secs, actSrc) {
}, s, enemyKey, secs, actSrc); }, s, enemyKey, secs, actSrc);
} }
/**
* The late view: `spec` runs silently in the page (events drained and dropped, like the client runner's catch-up) while
* `setupSrc(b, ally)` changes the enemies' forms; at `atSecs` the field view enters the battle from `b.fieldMeta()` and
* follows the feed. Returns { [name]: { clip, form, alive } } of the views of the enemies `setupSrc` returns by name.
*/
async function lateViewInPage(page, port, s, atSecs, setupSrc) {
await page.goto(`http://127.0.0.1:${port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
return page.evaluate(async (spec, atSecs, setupSrc) => {
const setup = new Function('b', 'ally', setupSrc);
const { loadBrowserSim } = await import('/js/battle/runner.js');
const { data } = await import('/js/data.js');
const { spec: S, ds } = await loadBrowserSim();
const b = S.createBattleFromSpec(spec, ds, { quiet: true });
const v = window.__demo.view;
v.setStage(data.lookup('stages', spec.stageId));
b.step();
const named = setup(b, b.allyUnits[0]);
while (b.time < atSecs) { b.step(); b.drainEvents(); }
const meta = b.fieldMeta();
v.enterBattle(meta);
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
const raf = () => new Promise((r) => requestAnimationFrame(() => r()));
const read = () => Object.fromEntries(Object.entries(named).map(([k, u]) => {
const view = v.debug.views.get(u.id);
return [k, { clip: view?.actor?.current ?? null, form: view?.form ?? null, spine: !!view?.spineReady, alive: u.alive, simForm: u.form }];
}));
for (let i = 0; i < 30; i++) {
b.step();
if (i % 3 === 2) {
const ev = b.drainEvents();
if (ev.length) v.pushEvents({ t: 'b.ev', fieldId: meta.fieldId, gt: b.time, ev });
v.pushSnapshot(b.snapshot());
await raf();
}
if (i === 15) await new Promise((r) => setTimeout(r, 2500)); // the Spine models load
}
return read();
}, s, atSecs, setupSrc);
}
describe('player reports after 0.1.0: the models follow the knock-out forms (headless Chrome, real sim)', { skip }, () => { describe('player reports after 0.1.0: the models follow the knock-out forms (headless Chrome, real sim)', { skip }, () => {
let srv, browser; let srv, browser;
before(async () => { before(async () => {
@@ -151,4 +196,33 @@ describe('player reports after 0.1.0: the models follow the knock-out forms (hea
await p.close(); await p.close();
} }
}); });
test('a view built mid-battle (enterBattle(fieldMeta) after a silent catch-up) starts in the current forms: 特战术师 D_*, 幽灵 C_*, the ember Idle_2 / Move_2, 掠海漂移体 *_02', async () => {
const { p, problems } = await page();
try {
const got = await lateViewInPage(p, srv.port, spec('enemy_1007_slime', 'late', 9, 8), 7.5, `
ally.base.atk = 0; ally.markDirty(); // 百炼嘉维尔 only blocks
const still = { mods: { speedMul: 0 } };
const shushi = b.spawnEnemy('enemy_10081_mpplai', { pos: [11, 9], ...still });
const youling = b.spawnEnemy('enemy_10081_mpplai', { pos: [9, 8.4], ...still }); // blocked ⇒ 幽灵
const ember = b.spawnEnemy('enemy_1288_duskls', { pos: [12, 9], ...still });
const drift = b.spawnEnemy('enemy_2025_syufo', { pos: [11, 6], ...still });
for (let i = 0; i < 4; i++) b.dealDamage(ally, shushi, { amount: 1, type: 'arts' });
b.kill(ember, ally);
b.applyStatus(drift, 'stun', { duration: 1, source: ally });
return { shushi, youling, ember, drift };
`);
await p.screenshot({ path: path.join(OUT, 'feedback1-lateview.png') });
assert.deepEqual(Object.fromEntries(Object.entries(got).map(([k, x]) => [k, x.simForm])),
{ shushi: 'translator_shushi', youling: 'translator_youling', ember: 'husk', drift: 'crawl' }, 'the sim\'s forms');
for (const [k, x] of Object.entries(got)) assert.ok(x.spine && x.alive && x.form === x.simForm, `${k}: a live Spine view in the sim's form (${JSON.stringify(x)})`);
assert.match(got.shushi.clip, /^D_/, `特战术师 (${got.shushi.clip})`);
assert.match(got.youling.clip, /^C_/, `幽灵 (${got.youling.clip})`);
assert.match(got.ember.clip, /^(Idle|Move)_2$/, `the ember (${got.ember.clip})`);
assert.match(got.drift.clip, /_02$/, `crawling (${got.drift.clip})`);
assert.deepEqual(problems, []);
} finally {
await p.close();
}
});
}); });
+28 -8
View File
@@ -8,6 +8,7 @@
// 'Die' (its 1 s 重生) and walks as the ember on Idle_2 / Move_2, dying on Die_2, and 'Revive' — timed from the 'ember' // 'Die' (its 1 s 重生) and walks as the ember on Idle_2 / Move_2, dying on Die_2, and 'Revive' — timed from the 'ember'
// fx's `dur` to end as it stands up — brings the warrior back (sim fx 'ember' / 'revive' { form: 'husk' | 'revived' }); // fx's `dur` to end as it stands up — brings the warrior back (sim fx 'ember' / 'revive' { form: 'husk' | 'revived' });
// 假想敌:再生's 傀儡 the same with A_Die / B_* / B_Revive; the leaders' 重生 close on their last clip as the 重生 ends. // 假想敌:再生's 傀儡 the same with A_Die / B_* / B_Revive; the leaders' 重生 close on their last clip as the 重生 ends.
// A view built mid-battle gets the current form from UnitInfo `form` through render/app.js renderInfo (enterBattle).
import { test, describe, before, after } from 'node:test'; import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -23,10 +24,11 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8')); const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
const SYUFO = 'enemy_2025_syufo'; const SYUFO = 'enemy_2025_syufo';
let fake, UnitView, FORMS; let fake, UnitView, FORMS, renderInfo;
before(async () => { before(async () => {
fake = installFakePixi(); fake = installFakePixi();
({ UnitView, FORMS } = await import('../../public/js/render/units.js')); ({ UnitView, FORMS } = await import('../../public/js/render/units.js'));
({ renderInfo } = await import('../../public/js/render/app.js'));
}); });
after(() => fake.restore()); after(() => fake.restore());
@@ -297,26 +299,32 @@ describe('the leaders\' 重生 and 守墓石像 (audit of the knock-out forms af
}); });
}); });
test('a view built mid-battle from the real sim\'s fieldMeta (fx dropped by a silent catch-up) starts in the current form: the changed 转译基底·α on D_Idle, the 逐火 ember on Idle_2, 锏 after its 重生 on B_Idle', async () => { test('a view built mid-battle from the real sim\'s fieldMeta (fx dropped by a silent catch-up), through render/app.js renderInfo — what enterBattle(meta) hands the views — starts in the current form: the changed 转译基底·α on D_Idle / C_Idle, the 逐火 ember on Idle_2, 锏 after its 重生 on B_Idle, 掠海漂移体 crawling', async () => {
const mage = chessRec({ id: 't_mage', profession: 'CASTER', stats: { atk: 50, blockCnt: 0 }, rangeGrid: [[0, 0]], skill: null }); const mage = chessRec({ id: 't_mage', profession: 'CASTER', stats: { atk: 50, blockCnt: 0 }, rangeGrid: [[0, 0]], skill: null });
const wall = chessRec({ id: 't_wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
const quiet = () => ({ trait: { noAttack: true } });
const h = makeBattle({ const h = makeBattle({
stageId: 'act2autochess_m01', content: 'full', seed: 7, autoFinish: false, timeLimit: 600, modeId: 'mode_multi_hard', round: 3, stageId: 'act2autochess_m01', content: 'full', seed: 7, autoFinish: false, timeLimit: 600, modeId: 'mode_multi_hard', round: 3,
defs: { chess: { t_mage: mage } }, units: [{ chessId: 't_mage', row: 12, col: 2 }], kits: { t_mage: () => ({ trait: { noAttack: true } }) }, defs: { chess: { t_mage: mage, t_wall: wall } }, units: [{ chessId: 't_mage', row: 12, col: 2 }, { chessId: 't_wall', row: 9, col: 4 }],
kits: { t_mage: quiet, t_wall: quiet },
}); });
h.step(); h.step();
const still = { mods: { speedMul: 0 } }; const still = { mods: { speedMul: 0 } };
const tr = h.b.spawnEnemy('enemy_10081_mpplai', { pos: [9, 8], ...still }); const tr = h.b.spawnEnemy('enemy_10081_mpplai', { pos: [10, 8], ...still });
const ember = h.b.spawnEnemy('enemy_1288_duskls', { pos: [10, 8], ...still }); const ghost = h.b.spawnEnemy('enemy_10081_mpplai', { pos: [9, 4.4], ...still }); // walks into the wall: blocked ⇒ 幽灵
const mace = h.b.spawnEnemy('enemy_1525_blkswb', { pos: [11, 8], ...still }); const ember = h.b.spawnEnemy('enemy_1288_duskls', { pos: [11, 8], ...still });
const mace = h.b.spawnEnemy('enemy_1525_blkswb', { pos: [12, 8], ...still });
const drift = h.b.spawnEnemy(SYUFO, { pos: [11, 10], ...still });
const m = h.unit('t_mage'); const m = h.unit('t_mage');
for (let i = 0; i < 4; i++) h.b.dealDamage(m, tr, { amount: 1, type: 'arts' }); // the 4th arts hit ⇒ 特战术师 for (let i = 0; i < 4; i++) h.b.dealDamage(m, tr, { amount: 1, type: 'arts' }); // the 4th arts hit ⇒ 特战术师
h.b.kill(ember, m); h.b.kill(ember, m);
h.b.kill(mace, m); h.b.kill(mace, m);
h.b.applyStatus(drift, 'stun', { duration: 1, source: m }); // 掠海漂移体 drops to 爬行模式
const until = h.b.time + 6; const until = h.b.time + 6;
while (h.b.time < until) { h.b.step(); h.b.drainEvents(); } while (h.b.time < until) { h.b.step(); h.b.drainEvents(); }
const meta = h.b.fieldMeta(); const meta = h.b.fieldMeta();
const view = async (u) => { const view = async (u) => {
const info = meta.units.find((x) => x.id === u.id); const info = renderInfo(meta.units.find((x) => x.id === u.id));
const ctx = fakeViewCtx(fake.P, { assets: store(info.spine), cam }); const ctx = fakeViewCtx(fake.P, { assets: store(info.spine), cam });
const v = new UnitView(ctx, info); const v = new UnitView(ctx, info);
await tick(); await tick(); await tick(); await tick();
@@ -324,8 +332,20 @@ test('a view built mid-battle from the real sim\'s fieldMeta (fx dropped by a si
return v; return v;
}; };
assert.equal(clip(await view(tr)), 'D_Idle', '特战术师, not the A model'); assert.equal(clip(await view(tr)), 'D_Idle', '特战术师, not the A model');
assert.equal(clip(await view(ember)), 'Idle_2', 'the ember, not the warrior'); assert.equal(clip(await view(ghost)), 'C_Idle', '幽灵 (blocked first), not the A model');
const e = await view(ember);
assert.equal(clip(e), 'Idle_2', 'the ember, not the warrior');
e.die();
assert.equal(clip(e), 'Die_2', 'and it dies on the ember\'s clip');
assert.equal(clip(await view(mace)), 'B_Idle', '锏\'s second form'); assert.equal(clip(await view(mace)), 'B_Idle', '锏\'s second form');
assert.equal(clip(await view(drift)), 'Idle_02', '掠海漂移体 crawls (DESIGN §19.1: a reload used to hover)');
});
test('render/app.js renderInfo keeps UnitInfo `form` (it used to drop it: views built from fieldMeta drew the first form)', () => {
assert.equal(renderInfo({ id: 3, kind: 'enemy', side: 'enemy', defId: 'enemy_1288_duskls', form: 'husk' }).form, 'husk');
assert.equal(renderInfo({ id: 3, kind: 'enemy', side: 'enemy', defId: 'enemy_1288_duskls' }).form, undefined);
assert.equal(renderInfo({ id: 3, form: 7 }).form, undefined, 'only a string');
assert.equal(renderInfo({ kind: 'enemy' }), null, 'no id: no info');
}); });
test('render/app.js hands the sim\'s fx \'phase\' kind — or the `form` of any fx, with the fx — to the view and keeps the mode on the unit info', () => { test('render/app.js hands the sim\'s fx \'phase\' kind — or the `form` of any fx, with the fx — to the view and keeps the mode on the unit info', () => {
+78
View File
@@ -492,3 +492,81 @@ test('every enemy starts with the same profile fields and `moving` / `form` (one
} }
assert.equal(shapes.size, 1, 'one key order for every enemy'); assert.equal(shapes.size, 1, 'one key order for every enemy');
}); });
// ---------------------------------------------------------------------------------------------------------------
// second review: a 重生 clears the statuses; a blocked 隐匿 enemy is drawn solid
describe('a 重生 clears what operators put on the enemy (PRTS 特殊机制 §重生 "清空自身身上除白名单外所有Buff")', () => {
test('a 深池逐火战士 knocked out while feared and slowed (叙拉古-style 恐惧 makes it unblockable) is an ember without them: blocked again, beaten by the gun — its own talents stay', () => {
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }, { chessId: 't_wall', row: 9, col: 7 }] });
h.step();
const e = put(h, 'enemy_1288_duskls', { mods: { speedMul: 1 } });
const gun = h.unit('t_gun');
assert.ok(h.runUntil(() => !!e.blockedBy, 20), 'blocked by the wall');
assert.ok(h.b.applyStatus(e, 'fear', { duration: 30, source: gun }), 'feared');
assert.ok(h.b.applyStatus(e, 'slow', { duration: 30, source: gun, value: 0.5 }), 'slowed');
h.b.addBuff(e, { key: 'test:opDebuff', duration: 30, mods: { defFlat: -50 }, source: gun }); // a skill's debuff
assert.ok(e.s.flags.fear && e.s.flags.unblockable && !e.blockedBy, 'the fear releases it');
h.b.kill(e, gun);
assert.ok(e.alive && e.form === 'husk', 'knocked out: its 重生');
for (const k of ['fear', 'slow', 'test:opDebuff']) assert.equal(e.findBuff(k), null, `${k} cleared by the 重生`);
assert.ok(e.findBuff('ab:ember') && e.findBuff('ab:hitCount'), 'the ember\'s own buffs');
assert.ok(h.eventsOf('status').some((ev) => ev[1] === e.id && ev[2] === 'fear' && ev[3] === 0), 'the fear icon goes');
h.run(HUSK_REBIRTH + 0.1);
assert.ok(!e.s.flags.fear && !e.s.flags.unblockable && e.s.flags.stealth, 'a 隐匿 ember that can be blocked');
assert.ok(h.runUntil(() => !e.alive, 8), 'blocked, it is beaten before it could stand up');
assert.equal(e.removeReason, 'killed');
assert.ok(h.b.time < 2 + HUSK_REBIRTH + tb('enemy_1288_duskls', 'Revive[Trigger].interval') + 10);
});
test('锏\'s 重生 clears an operator\'s slow and 脆弱 but keeps its 抵抗 (a self-applied talent status); 守墓石像\'s instant 重生 too', () => {
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS });
h.step();
const still = { mods: { speedMul: 0 } };
const mace = put(h, 'enemy_1525_blkswb', { pos: [10, 7], ...still });
const stone = put(h, 'enemy_1172_dugago', { pos: [11, 7], ...still });
const gun = h.unit('t_gun');
assert.ok(mace.findBuff('resist'), '锏 has its 抵抗');
for (const u of [mace, stone]) {
assert.ok(h.b.applyStatus(u, 'slow', { duration: 30, source: gun, value: 0.5 }));
assert.ok(h.b.applyStatus(u, 'fragile', { duration: 30, source: gun, value: 0.3 }));
h.b.kill(u, gun);
assert.ok(u.alive, 'a 重生, not a death');
assert.equal(u.findBuff('slow'), null, `${u.defId}: slow cleared`);
assert.equal(u.findBuff('fragile'), null, `${u.defId}: 脆弱 cleared`);
}
assert.ok(mace.findBuff('resist'), '锏 keeps its 抵抗');
assert.ok(stone.findBuff('ab:stone'), 'the statue form starts');
});
});
describe('a blocked 隐匿 enemy is drawn solid (b.snap stealth bit: PRTS 作战机制 §隐匿 "在被阻挡时开关会被关掉")', () => {
test('the 逐火 ember: see-through while it walks unblocked, solid while blocked or revealed; an ally keeps its 迷彩 / 隐匿 look while blocking', async () => {
const { flagsOf } = await import('../../server/sim/snapshot.js');
const { UF } = await import('../../shared/constants.js');
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 7 }] });
h.step();
const e = put(h, 'enemy_1288_duskls', { mods: { speedMul: 1 } });
h.run(0.5);
h.b.kill(e, null);
h.run(HUSK_REBIRTH + 0.1);
assert.ok(e.s.flags.stealth && !e.blockedBy);
assert.ok(flagsOf(e) & UF.STEALTH, 'unblocked: see-through');
const tuple = () => h.b.snapshot().units.find((t) => t[0] === e.id);
assert.ok(tuple()[7] & UF.STEALTH, 'in b.snap too');
assert.ok(h.runUntil(() => !!e.blockedBy, 10), 'walks into the wall');
assert.ok(e.s.flags.stealth, 'the sim keeps the 隐匿 flag (it returns once the block ends)');
assert.equal(flagsOf(e) & UF.STEALTH, 0, 'blocked: drawn solid');
assert.equal(tuple()[7] & UF.STEALTH, 0);
assert.ok(flagsOf(e) & UF.BLOCKED);
const g = put(h, 'enemy_1288_duskls', { pos: [11, 9], mods: { speedMul: 0 } });
h.b.kill(g, null);
h.run(HUSK_REBIRTH + 0.1);
assert.ok(flagsOf(g) & UF.STEALTH);
h.b.addBuff(g, { key: 'test:reveal', duration: 5, flags: { reveal: true } });
assert.equal(flagsOf(g) & UF.STEALTH, 0, 'revealed: drawn solid');
const wall = h.unit('t_wall');
h.b.addBuff(wall, { key: 'test:stealth', duration: 5, flags: { stealth: true } });
assert.ok(wall.blocking.length > 0 && flagsOf(wall) & UF.STEALTH, 'an ally keeps its 隐匿 look while blocking');
});
});