diff --git a/docs/ASSETS.md b/docs/ASSETS.md index 1702ac31..d63bef74 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -204,9 +204,18 @@ 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 `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' fx; a `change` clip plays once first): 掠海漂移体's crawl (`Change`, `*_02`), 转译基底·α'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` back) and 假想敌:再生's puppet (`A_Die`, `B_*`, `B_Revive`). +'ember' / 'revive' / 'telegraph' / 'stone' fx — or, for a view built mid-battle, UnitInfo `form`; a `change` clip plays +once first, an `end` clip is timed from the fx's `dur` to finish as that state ends): +- 掠海漂移体's crawl (`Change`, then `*_02`); +- 转译基底·α'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 + (`A_Die`, then `B_*`; `B_Revive`); +- the leaders' 重生: 锏 (`Revive1`, `Revive2` held, `Revive3`, then `B_*`), 扎罗 (`A_revive_1` / `_2` / `_3`, then `B_*`), + “复仇者” (`Revive_Begin` / `_Loop` / `_End`), 杰斯顿 (`C1_Die`, then `C2_*`); +- 守墓石像 (the statue on `Sleep` [ASSUMED by name], then the flyer's `*_2`). + +Not mapped (clip names ambiguous): “自在”, “巨大的丑东西”, 主角阵营角色 and “余音” (`*_A` / `*_B`: which of its two forms is A +is not known) keep their manifest clips. Other renderer rules from research 07 §5.4–5.5: - **Choosing the model:** Front when the unit faces right or down; Front mirrored when facing left; Back when facing up. diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 4e562a30..ae780089 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -73,7 +73,7 @@ - **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**重装干员**的手动技能在受到伤害时释放;执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放。**自动触发**的技能按其本身规则,例如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。 - **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 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. 联防(同盟) diff --git a/docs/SIM.md b/docs/SIM.md index 608b0771..d1fc8ef8 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -233,11 +233,15 @@ wait for the real death, the only one with a `die` event. 重生 / form changes: 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 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 and beaten; 假想敌: -再生's 傀儡 is unblockable; a husk still standing after `Revive[Trigger].interval` s stands up again with full HP), -转译基底·α (its original form cancels every damage instance; the 4th physical / arts instance or a block starts a 2 s -change). The model's clip set follows a 'phase' fx kind or the `form` of the 'ember' / 'revive' fx (render/units.js -FORMS). +walks its route on: the 深池逐火 余烬 / 火灰 are 隐匿 and disarmed, so only a blocked one can be targeted by operators (area +damage still reaches it); 假想敌:再生'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), +转译基底·α (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 +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 +(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. **Ownership** (`enemy.ownerId`, used for `killed/total` and leak attribution): `ownerPlayerId` if given, else the player whose half contains the spawn tile (cols ≥ 11 = right half / player with colOffset 8 or side R). A leak is @@ -970,7 +974,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te `['heal', tgt, amount]`, `['skill', id, 1|0]`, `['die', id, reason]`, `['leak', id]`, `['status', id, key, 1|0]`, `['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing), `['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`. -- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, skillIndex? }` (`skillIndex`: an ally's equipped skill, DESIGN §16) +- `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) (`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); diff --git a/public/js/render/app.js b/public/js/render/app.js index c71fd17d..d827a837 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -1429,7 +1429,7 @@ export async function createFieldView(host, options = {}) { if (form !== undefined) { const inf = infos.get(ex4.id); if (inf && (typeof form !== 'string' || FORMS[inf.spine || inf.defId]?.[form])) inf.form = typeof form === 'string' ? form : null; - views.get(ex4.id)?.setForm?.(form); + views.get(ex4.id)?.setForm?.(form, ex4); } fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]); break; diff --git a/public/js/render/spine.js b/public/js/render/spine.js index 9956d0a3..40dcf725 100644 --- a/public/js/render/spine.js +++ b/public/js/render/spine.js @@ -12,8 +12,10 @@ // deploy() 'Start' once, then base // die() die clip once (callers fade out afterwards) // stunned (setBase('stun')) stun clip, or the current track frozen at timeScale 0 -// setForm(roles, change) another clip set of the skeleton (an enemy's mode), after a change clip -// (no attack cuts the change clip short) +// setForm(roles, change, end) another clip set of the skeleton (an enemy's mode), after a change clip +// (no attack cuts the change clip short); `end` = { clip, in, roles? }: a +// closing clip timed to end `in` s from now (a 重生's last clip ends with the +// 重生), landing in `roles` // update(dt) advances the skeleton (autoUpdate is off: one clock for everything) // Attack mode lasts until ~1.4 attack intervals without a new attack, then the end clip (if any) and base. @@ -81,7 +83,10 @@ export class SpineActor { } try { this.spine.stateData.defaultMix = 0.12; } catch { /* ignore */ } this.base = 'idle'; - this.mode = 'base'; // base | attack | skillBegin | deploy | die | stun + this.mode = 'base'; // base | attack | skillBegin | deploy | die | stun | change + this.endClip = null; // setForm's closing clip, played as a change clip once the clock reaches endAt, + this.endAt = 0; // landing in endRoles (the next form's) when given + this.endRoles = null; this.skillOn = false; this.attackUntil = 0; this.clock = 0; @@ -119,22 +124,34 @@ export class SpineActor { /** * Another clip set of the same skeleton — an enemy's mode (render/units.js FORMS: 掠海漂移体's 爬行模式 plays its *_02 * clips): `roles` override the manifest roles (null = back to them); `change` = a transition clip played once first - * (also while stunned: the pose it ends in is the one a stun then holds). + * (also while stunned: the pose it ends in is the one a stun then holds). `end` = { clip, in, roles? } (game s): a + * closing clip played the same way so that it ends `in` s from now, landing in `roles` — a leader's 重生 ends on its + * last clip while the sim still holds it, and the next form starts on its own clips. */ - setForm(roles, change = null) { + setForm(roles, change = null, end = null) { const anims = this.entry?.anims || {}; this.roles = roles ? { ...anims, ...roles } : anims; + this.endClip = null; if (this.dead) return; - if (change && this.has(change)) { - this.stunWanted = this.mode === 'stun'; - this.frozen = false; - this.mode = 'change'; - this._play(change, false, { mix: 0.08 }); - this.changeUntil = this.clock + this.dur(change); - } else if (this.mode === 'base') this._play(this._baseName(), true); + if (end && this.has(end.clip) && end.in > 0) { + this.endClip = end.clip; + this.endAt = this.clock + Math.max(0, end.in - this.dur(end.clip)); + this.endRoles = end.roles || null; + } + if (change && this.has(change)) this._change(change); + else if (this.mode === 'base') this._play(this._baseName(), true); else if (this.mode === 'stun' && this.has(this.roles.stun?.loop)) this._play(this.roles.stun.loop, true); } + /** Play a form's transition clip once; attacks and the resting state wait for it (mode 'change'). */ + _change(clip) { + if (this.mode !== 'change') this.stunWanted = this.mode === 'stun'; + this.frozen = false; + this.mode = 'change'; + this._play(clip, false, { mix: 0.08 }); + this.changeUntil = this.clock + this.dur(clip); + } + has(name) { return !!name && this.names.has(name); } dur(name) { const d = this.durations[name]; return typeof d === 'number' && d > 0 ? d : this._durFromData(name); } @@ -339,6 +356,12 @@ export class SpineActor { update(dt) { this.clock += dt; + if (this.endClip && this.clock >= this.endAt) { + const clip = this.endClip; + this.endClip = null; + if (this.endRoles) this.roles = { ...(this.entry?.anims || {}), ...this.endRoles }; + if (!this.dead) this._change(clip); + } if (this.windUntil != null && this.clock >= this.windUntil) { // a compressed wind-up reached its strike frame: back to the rhythm speed (attack() also does it) this.windUntil = null; diff --git a/public/js/render/units.js b/public/js/render/units.js index 5e9c6dc1..27aa58bf 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -24,8 +24,10 @@ // redeploy) plays the deploy clip and restores the normal look; `setDown(null)` on a dead view lets it fade out. // An operator that enters a battle already knocked out (联防, user playtest #5 item 2: sim 'die' reason 'forcedExit') // goes down with `die(true)`: straight to the held end of the clip, no fall. -// Enemy modes (sim fx 'phase' { id, kind } → `setForm(kind)`): 掠海漂移体 dropping to 爬行模式 (user playtest #5 item 1) -// plays its skeleton's 'Change' clip once, then the crawl set (*_02) — FORMS; a view built later keeps the mode. +// 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 逐火 +// 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. // Element gauges (b.snap `elem` → sample `el` / `elFill` / `elUntil` / `elDur`), the official form (PRTS 元素: "模型 // 下部会显示对应的元素图标,并以白条显示剩余的元素值"; 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 @@ -109,9 +111,13 @@ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, g * - 深池逐火战士 / 精锐战士 / 护卫 (#8): knocked out → 'Die' (the 1 s 重生), the 余烬 on Idle_2 / Move_2 and its death on * Die_2; standing up again → 'Revive', then the warrior's manifest clips; * - 假想敌:再生: knocked out → A_Die, the 傀儡 on B_Idle / B_Move / B_Die; back → B_Revive, then the A_* manifest clips; + * (both: the stand-up clip (`end`) is timed from the 'ember' fx's `dur` to end as the husk stands up — the 'revive' + * fx then only lands in the manifest clips); * - the leaders' 重生 (sim reborn(): forms 'reborn' → 'form2'): 锏 Revive1, Revive2 held, Revive3, then B_*; 扎罗 A_revive_1 / * _2 / _3, then B_* (its double-hit attack); “复仇者” Revive_Begin / _Loop / _End; 杰斯顿 C1_Die (its 4 s 重生), then - * C2_* — their manifests mix the forms (锏 / 扎罗 died on B_Die from the A model, the look of report #5); + * C2_* — their manifests mix the forms (锏 / 扎罗 died on B_Die from the A model, the look of report #5). The closing + * clip (`end`) is timed from the 重生's `dur` (the 'telegraph' fx) to end with it, so the second form walks and + * attacks on its own clips at once (a view that missed the timing — built mid-重生 — skips the closing clip); * - 守墓石像 (forms 'stone' → 'fly'): the statue on Sleep [ASSUMED by name], then the flyer's *_2 clips. * A kind without a clip set of this skeleton (barriers, charges, …) changes nothing. 吉兆飞鳞's 晕眩模式 is its Stun clip. */ @@ -122,13 +128,14 @@ const clipSet = (idle, move, die, attack = null) => Object.freeze({ skill: attack ? Object.freeze({ begin: null, loop: attack, end: null, via: 'attack', index: 0, idle: null }) : null, }); const EMBER = Object.freeze({ - husk: Object.freeze({ change: 'Die', roles: clipSet('Idle_2', 'Move_2', 'Die_2') }), - revived: Object.freeze({ change: 'Revive', roles: Object.freeze({}) }), + husk: Object.freeze({ change: 'Die', end: 'Revive', next: 'revived', roles: clipSet('Idle_2', 'Move_2', 'Die_2') }), + revived: Object.freeze({ change: null, roles: Object.freeze({}) }), }); -/** A 重生 held on `hold` (also while the sim reports the rebirth's stun) after `begin`, then `end` and the second form. */ +/** A 重生 held on `hold` (also while the sim reports the rebirth's stun) after `begin`, closing on `end` as the 重生 ends, + * then the second form. */ const rebirth = (begin, hold, end, form2 = Object.freeze({})) => Object.freeze({ - reborn: Object.freeze({ change: begin, roles: Object.freeze({ idle: hold, deploy: hold, move: loop(hold), stun: loop(hold), attack: null, skill: null }) }), - form2: Object.freeze({ change: end, roles: form2 }), + reborn: Object.freeze({ change: begin, end, next: 'form2', roles: Object.freeze({ idle: hold, deploy: hold, move: loop(hold), stun: loop(hold), attack: null, skill: null }) }), + form2: Object.freeze({ change: null, roles: form2 }), }); const STATUE = Object.freeze({ stone: Object.freeze({ change: null, roles: Object.freeze({ idle: 'Sleep', deploy: 'Sleep', move: loop('Sleep'), stun: loop('Sleep'), attack: null, skill: null }) }), @@ -148,8 +155,8 @@ export const FORMS = Object.freeze({ enemy_1288_duskls_2: EMBER, enemy_1292_duskld: EMBER, enemy_9010_acpupp: Object.freeze({ - husk: Object.freeze({ change: 'A_Die', roles: clipSet('B_Idle', 'B_Move', 'B_Die') }), - revived: Object.freeze({ change: 'B_Revive', roles: Object.freeze({}) }), + husk: Object.freeze({ change: 'A_Die', end: 'B_Revive', next: 'revived', roles: clipSet('B_Idle', 'B_Move', 'B_Die') }), + revived: Object.freeze({ change: null, roles: Object.freeze({}) }), }), enemy_1525_blkswb: rebirth('Revive1', 'Revive2', 'Revive3', clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack')), enemy_1535_wlfmster: rebirth('A_revive_1', 'A_revive_2', 'A_revive_3', clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack')), @@ -386,11 +393,13 @@ export class UnitView { } /** - * The unit changed mode (sim fx 'phase' { id, kind } or another fx's `form`): the mode's clip set (FORMS) after its - * change clip; null goes back to the manifest clips; a kind this skeleton has no clip set for (an arts barrier, a - * broken charge …) changes nothing. Kept for a model built later. + * The unit changed mode (sim fx 'phase' { id, kind } or another fx's `form`; `fx` = that fx's extra): the mode's clip + * set (FORMS) after its change clip, and its closing clip (`end`, landing in the `next` form's clips) timed to end + * `fx.dur` game s later; null goes back + * to the manifest clips; a kind this skeleton has no clip set for (an arts barrier, a broken charge …) changes + * nothing. Kept for a model built later. */ - setForm(kind) { + setForm(kind, fx = null) { const k = typeof kind === 'string' ? kind : null; if (k === this.form) return; if (k && !FORMS[this.info.spine || this.info.defId]?.[k]) return; @@ -399,7 +408,9 @@ export class UnitView { this.info.form = k; const f = this._formSpec(); if (!this.actor) return; - if (f) this.actor.setForm(f.roles, f.change || null); + const dur = fx && Number(fx.dur); + const next = f && f.next ? FORMS[this.info.spine || this.info.defId]?.[f.next]?.roles || null : null; + if (f) this.actor.setForm(f.roles, f.change || null, f.end && dur > 0 ? { clip: f.end, in: dur, roles: next } : null); else if (had) this.actor.setForm(null); } diff --git a/server/sim/Battle.js b/server/sim/Battle.js index 68187bb4..e0315ce8 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -811,7 +811,9 @@ export class Battle { e.deployedAt = this.time; e.atkCd = 0; e.pauseUntil = -Infinity; - e.profile = { noAttack: def.dmgType === 'none', maxTargets: 1, atkScale: 1 }; + // 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 }; e.route = { legs: route ? compileRoute(route, this.rect) : [], legIdx: 0, pts: null, ptIdx: 0, suffix: null, version: -1, waitLeft: null }; if (!e.route.legs.length) { const end = this.grid.specialTiles('end')[0]; diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index c1347f23..75dfe8bf 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -53,10 +53,13 @@ // 封冻/供暖器, 赘生甲壳, 锁链, 血债账款, 晦明) have no counterpart in this mode and are listed where they apply. // // fx kinds emitted (battle.fx(kind, {x, y, …})): 'explode' {r, kind} · 'zone' {r, dur, kind} · 'telegraph' {r, dur, -// kind, tiles?} (delayed strikes, charges, 'reborn') · 'beam' {from, to, kind} · 'summon' {id, key} · 'ember'/'revive'/ -// 'stone' {id, form?} · 'blink' {id, fx, fy} · 'charge' {id, tx, ty} · 'expose' {id} · 'shieldBreak' {id} · 'liberate' {id} -// · 'phase' {id, kind} (form / barrier changes) · 'lpLoss' {value, reason} · 'steal' · 'ignite'. A 'phase' kind, or -// the `form` of any other fx, is the model's clip set from then on (render/app.js → UnitView.setForm, units.js FORMS). +// kind, tiles?, id?, form?} (delayed strikes, charges, 'reborn' with form 'reborn') · 'beam' {from, to, kind} · +// 'summon' {id, key} · 'ember' {id, hits, dur, form: 'husk'} · 'revive' {id, kind?, form: 'form2' | 'revived' | 'fly'} +// · 'stone' {id, dur, form: 'stone'} · 'blink' {id, fx, fy} · 'charge' {id, tx, ty} · 'expose' {id} · 'shieldBreak' +// {id} · 'liberate' {id} · 'phase' {id, kind, dur?} (form changes — crawl, translator_* — and barrier / charge states) +// · 'lpLoss' {value, reason} · 'steal' · 'ignite'. Forms go through setForm(): the unit keeps it (`e.form`, UnitInfo +// `form`) and the fx's `form` — a form 'phase' fx's `kind` — is the model's clip set from then on (render/app.js → +// UnitView.setForm, units.js FORMS). // Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss. import { TICK, MOVE_SCALE, ELEMENT } from '../constants.js'; @@ -73,8 +76,8 @@ export const EROSION_BURST = Object.freeze({ defDown: ELEMENT.erosion.ally.defDo /** 假想敌:淤困: element damage its host's burst spreads to the allies of the 4 tiles around (PRTS 假想敌:淤困 "附着对象元素爆发时 * …对附着对象及周围4格内的所有我方单位…造成1000同类型元素损伤"; the host itself is in its 爆发冷却). */ const PARASITE_SPREAD = 1000; -/** 重生 of a Revive[Trigger] knock-out before its husk (PRTS 深池逐火战士 "被击倒后重生,持续1s"; the same talent template on - * 假想敌:再生, whose PRTS page gives no duration — 1 s there too [ASSUMED]; both models' knock-out clips last 1 s). */ +/** 重生 of a Revive[Trigger] knock-out before its husk (PRTS 深池逐火战士 and 假想敌:再生 天赋: "被击倒后重生,持续1s,随后变为 + * 怨恨的余烬 / 再生状态,1s内不移动且持有无敌+无法阻挡+失衡免疫"; both models' knock-out clips last 1 s). */ export const HUSK_REBIRTH = 1; /** 转译基底·α: "变化过程持续2s" (PRTS 天赋; the model's A_Die_B / _C / _D change clips last 2 s); the original form's * immunities ("免疫晕眩/沉睡/寒冷/冻结/浮空/恐惧", plus 失衡免疫). */ @@ -89,8 +92,8 @@ const TANK_ZONE_RADIUS = 1.7; const POLLUTION_INTERVAL = 1; /** "数个目标" of 假想敌:骨刺 while stealthed [ASSUMED]. */ const ACBUNN_TARGETS = 3; -/** 假想敌:再生 shield aura radius ("周围一定距离") [ASSUMED]. */ -const ACPUPP_AURA_RADIUS = 2; +/** 假想敌:再生 shield aura radius (PRTS 假想敌:再生 天赋 "进入此形态时,使半径1.8范围内的其他敌方单位(无视其可选性)获得5层…护盾"). */ +const ACPUPP_AURA_RADIUS = 1.8; /** 重弩突袭者 直击 reach along a row/column [ASSUMED]. */ const CROSS_REACH = 6; /** 暴鸰 投弹: the target's tile and its 8 neighbours (PRTS "对目标及其周围八格的我方单位造成100%物理伤害"). */ @@ -461,6 +464,18 @@ export function hitCount(b, e, on, artsOnly = false) { } export const isHitCount = (e) => !!e.findBuff(HIT_COUNT_KEY); +/** + * The enemy's model takes another form (render/units.js FORMS: a clip set of its skeleton): kept on the unit + * (`e.form`, published by snapshot.js unitInfo — a view built mid-battle from fieldMeta(): a teammate's field watched + * later, 联防 observers, a reconnect — starts in it) and announced by the fx `fxKind` (+ id, x, y, `params`): a 'phase' + * fx carries the form as its `kind`, any other fx as `form`. Barrier / charge 'phase' kinds are no forms: they go + * through b.fx directly. + */ +export function setForm(b, e, form, fxKind = 'phase', params = null) { + e.form = form; + b.fx(fxKind, fxKind === 'phase' ? { ...params, x: e.x, y: e.y, id: e.id, kind: form } : { ...params, x: e.x, y: e.y, id: e.id, form }); +} + /** Leader "扣除目标生命" effects: recorded for the match (hook 'lpLoss' + result.lpLoss). */ export function lpLoss(b, amount, reason, source = null) { if (!(amount > 0)) return; @@ -708,7 +723,7 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu if (invincible > 0) b.addBuff(e, { key: `${key}:inv`, duration: invincible, visible: true, flags: { invulnerable: true } }); if (e.route) e.route.pts = null; e.atkCd = 0; - b.fx('revive', { x: e.x, y: e.y, id: e.id, kind: 'reborn', form: 'form2' }); + setForm(b, e, 'form2', 'revive', { kind: 'reborn' }); }; return { killed(c, b, e, a) { @@ -726,7 +741,7 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu onTick: during ? ({ battle }) => during(battle, e, a, battle.time - a.t0) : null, onExpire: ({ battle }) => finish(battle, e, a), }); - b.fx('telegraph', { x: e.x, y: e.y, r: 1, dur, kind: 'reborn', id: e.id, form: 'reborn' }); + setForm(b, e, 'reborn', 'telegraph', { r: 1, dur, kind: 'reborn' }); return true; }, }; @@ -803,9 +818,10 @@ function artsBarrier(amount, { key = 'ab:artsBarrier', whileUp = null } = {}) { * 隐匿 (`stealthy`: targetable only while blocked, targeting.js canTargetEnemy — the 余烬 must be blocked to be beaten) * 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 ("一次又一次地站起"). + * `onHusk(b, e)` runs as the husk begins, after the 重生 ("进入此形态时": 再生's shields). * User report after 0.1.0 (#8): the v2.5 ember stood still, stealthed AND unblockable — nobody could ever target it. - * fx: 'ember' {id, hits, dur, form: 'husk'} at the knock-out, 'revive' {id, form: 'revived'} when it stands up (the - * renderer switches the model's clip set: render/units.js FORMS). + * fx (setForm): 'ember' {id, hits, dur, form: 'husk'} at the knock-out, 'revive' {id, form: 'revived'} when it stands + * up (the renderer switches the model's clip set: render/units.js FORMS). */ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, key = 'ab:ember' }) { const revive = (b, e, a) => { @@ -821,7 +837,7 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke b.removeBuff(e, `${key}:reborn`); if (e.route) e.route.pts = null; e.atkCd = 0; - b.fx('revive', { x: e.x, y: e.y, id: e.id, form: 'revived' }); + setForm(b, e, 'revived', 'revive'); }; return { killed(c, b, e, a) { @@ -839,8 +855,8 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke b.addBuff(e, { key, visible: true, persist: true, flags: { disarm: true, ...(stealthy ? { stealth: true } : {}), ...(unblock ? { unblockable: true } : {}) } }); b.addBuff(e, { key: `${key}:reborn`, duration: HUSK_REBIRTH, flags: { invulnerable: true, untargetable: true, unblockable: true, noMove: true, noDisplace: true } }); if (e.route) e.route.pts = null; - b.fx('ember', { x: e.x, y: e.y, id: e.id, hits, dur: HUSK_REBIRTH + delay, form: 'husk' }); - if (onHusk) onHusk(b, e); + setForm(b, e, 'husk', 'ember', { hits, dur: HUSK_REBIRTH + delay }); + if (onHusk) b.after(HUSK_REBIRTH, () => { if (e.alive && a.state === 'husk') onHusk(b, e); }, { owner: e }); b.after(HUSK_REBIRTH + delay, () => revive(b, e, a), { owner: e }); return true; }, @@ -865,7 +881,7 @@ function statue(ab) { e.profile.noAttack = true; e.hp = e.s.maxHp; ab2.immune = new Set([...(ab2.immune || []), 'levitate']); - b.fx('stone', { x: e.x, y: e.y, id: e.id, dur, form: 'stone' }); + setForm(b, e, 'stone', 'stone', { dur }); b.addBuff(e, { key: 'ab:stone', duration: dur, visible: true, flags: { noMove: true, unblockable: true, noDisplace: true }, mods: { defFlat: T(ab, 'stone.def') ?? 0, resFlat: T(ab, 'stone.magic_resistance') ?? 0 }, @@ -875,7 +891,7 @@ function statue(ab) { Object.assign(e.profile, { noAttack: a.noAtk, melee: false, dmgType: 'arts', canTarget: (u) => !u.isFlying }); battle.addBuff(e, { key: 'ab:flight', persist: true, flags: { noDisplace: true } }); if (e.route) e.route.pts = null; - battle.fx('revive', { x: e.x, y: e.y, id: e.id, kind: 'fly', form: 'fly' }); + setForm(battle, e, 'fly', 'revive', { kind: 'fly' }); }, }); return true; @@ -1094,7 +1110,7 @@ function kitSyufo(ab) { setFloat(b, e, false); e.profile.melee = true; b.applyStatus(e, 'stun', { duration: SYUFO_CRAWL_STUN, source: null }); - b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'crawl' }); + setForm(b, e, 'crawl'); }, }]; } @@ -1436,15 +1452,16 @@ function kitBlackCloud(ab, e) { ]; } -/** 假想敌:再生 · knock-out ⇒ a 傀儡 of prop_max_hp hits for `interval` s that "不可被阻挡" (data description) and walks on — its - * model has a 傀儡 walk cycle (B_Move); an immobile unit would not need to be unblockable [ASSUMED from both] — plus - * max_damage_block_cnt-hit shields on the enemies around it. */ +/** 假想敌:再生 (PRTS 假想敌:再生 天赋) · knock-out ⇒ 1 s 重生 ⇒ 再生状态: a 傀儡 of prop_max_hp (15) hits that is 不可阻挡 for + * `interval` (15) s and walks on ("1s内不移动": only the 重生 stands; its model has a 傀儡 walk cycle, B_Move), back with full + * HP if still standing; entering it gives the other enemies within 1.8 (targetable or not) max_damage_block_cnt (5) + * hit shields. */ function kitRegen(ab) { const hits = T(ab, 'Revive[Trigger].prop_max_hp'), delay = T(ab, 'Revive[Trigger].interval'), block = T(ab, 'Aura.max_damage_block_cnt') ?? 0; return [husk({ hits, delay, stealthy: false, unblock: true, key: 'ab:regen', onHusk(b, e) { - // 被击倒后…使周围一定距离内其他敌人获得可以抵挡物理及法术伤害的护盾 + // "进入此形态时,使半径1.8范围内的其他敌方单位(无视其可选性)获得5层吸收物理/法术伤害的护盾" b.fx('telegraph', { x: e.x, y: e.y, r: ACPUPP_AURA_RADIUS, kind: 'regenShield', id: e.id }); for (const o of b.enemiesInRadius(e.x, e.y, ACPUPP_AURA_RADIUS)) if (o !== e && block > 0) { const oab = abOf(b, o); oab.hitShield = Math.max(oab.hitShield, block); } }, @@ -2078,9 +2095,10 @@ function kitWolfLord(ab) { * 特战术师 (form D): ranged only (the data's 2.4 radius), arts, 2 targets at once; * 幽灵 (form C): no stat change, unblockable, no attack (PRTS lists none; its model has no attack clip). * The armed forms attack through the engine's enemy attack (ai.js enemyAttack, `profile.dmgType`), so attack clips, - * projectiles and the attack hooks are the normal ones. Before its change ends it cannot be killed by damage at all — - * user report after 0.1.0 (#5): the v2.5 kit let damage through, so it died in its first form, its model still in that - * form playing the 寻仇者's B_Die. fx 'phase' {id, kind: translator_fuchou | _shushi | _youling} starts the model's 2 s + * projectiles and the attack hooks are the normal ones. Before its change ends it cannot be killed at all (damage is + * cancelled; an HP loss stops at 1 HP) — user report after 0.1.0 (#5): its model never changed (no FORMS clip set), so + * it died on the manifest's die clip, the 寻仇者's B_Die, from its first-form look; and the v2.5 kit let damage through, + * so some lineups killed it before either counter reached 4. fx 'phase' {id, kind: translator_fuchou | _shushi | _youling} starts the model's 2 s * change clip (render/units.js FORMS). Form letters: B / C / D follow the talents' order Fuchou / Youling / Shushi — C, * the only clip set without an attack, is the non-attacking 幽灵. */ @@ -2110,7 +2128,7 @@ function kitTranslator(ab, e) { if (P.form || !e2.alive) return; P.form = form; b.addBuff(e2, { key: 'ab:change', duration: TRANSLATOR_CHANGE, persist: true, flags: { noMove: true }, onExpire: ({ battle }) => finish(battle, e2) }); - b.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: `translator_${form}`, dur: TRANSLATOR_CHANGE }); + setForm(b, e2, `translator_${form}`, 'phase', { dur: TRANSLATOR_CHANGE }); }; return [{ spawn(b, e2) { @@ -2126,6 +2144,13 @@ function kitTranslator(ab, e) { } c.dmg.cancel = true; }, + // an HP loss (流失 — no 伤害 instance, so not cancelled: 隐德来希's 心烛 hand-over …) cannot knock it out before its + // change ends: its original form has no death clip of its own (A_Die_B / _C / _D are the changes) [ASSUMED floor 1 HP] + killed(c, b, e2) { + if (P.done) return false; + e2.hp = Math.max(1, e2.hp); + return true; + }, blocked(c, b, e2) { change(b, e2, 'youling'); }, tick(b, e2) { // 寻仇者: "生命值低于50%时,攻击力+100%" @@ -2219,7 +2244,7 @@ export const KITS = Object.freeze({ enemy_1288_duskls: kitEmber, // 深池逐火战士 · every KO: 1 s 重生 ⇒ walking 隐匿 5-hit ember (block it to hit it), back after 10 s enemy_1288_duskls_2: kitEmber, // 深池逐火精锐战士 · same enemy_1292_duskld: kitEmber, // 深池逐火护卫 · 10-hit 火灰 - enemy_9010_acpupp: kitRegen, // 假想敌:再生 · every KO: 1 s 重生 ⇒ walking unblockable 15-hit puppet (15 s) + 5-hit shields nearby + enemy_9010_acpupp: kitRegen, // 假想敌:再生 · every KO: 1 s 重生 ⇒ walking unblockable 15-hit puppet (15 s) + 5-hit shields within 1.8 // --- ELEMENT 元素 enemy_1148_dssbr: kitEp('neural', 'epdamage.attack@ep_damage_ratio'), // 底海滑动者 · neural on hit diff --git a/server/sim/snapshot.js b/server/sim/snapshot.js index 5525bfc3..00a8592c 100644 --- a/server/sim/snapshot.js +++ b/server/sim/snapshot.js @@ -1,7 +1,8 @@ // server/sim/snapshot.js — compact serialization for clients (DESIGN §8.2). // // b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total } -// UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId? } +// 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) // 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). @@ -32,6 +33,9 @@ export function unitInfo(u) { maxHp: Math.max(1, Math.round(u.s.maxHp)), motion: u.motion === 'FLY' ? 'FLY' : undefined, boss: u.isBoss ? true : undefined, + // an enemy's current model form (content/enemies.js setForm, render/units.js FORMS): a view built mid-battle + // (fieldMeta — a watched teammate's field, 联防 observers, a reconnect) starts on that clip set + form: typeof u.form === 'string' ? u.form : undefined, uid: u.uid ?? undefined, // DESIGN §16: the equipped skill's index (the renderer / audio pick that skill's Spine clip and sound) skillIndex: u.side === 'ally' && Number.isInteger(d.skill?.index) ? d.skill.index : undefined, diff --git a/server/sim/units.js b/server/sim/units.js index 59d43912..37f41f49 100644 --- a/server/sim/units.js +++ b/server/sim/units.js @@ -81,6 +81,8 @@ export class Unit { this.trait = {}; // profession runtime state this.stats = { dmg: 0, kills: 0, heal: 0, taken: 0, attacks: 0 }; this.hidden = false; // enemies inside a DISAPPEAR segment + this.moving = false; // enemies: walked this tick (drawn on the move clip; ai.js updateEnemy) + this.form = null; // enemies: the model's current form (content/enemies.js setForm → snapshot.js unitInfo) this.anim = 0; this.persist = { redeployMul: 1, freeRedeploys: 0 }; this.isBoss = false; diff --git a/test/render/forms.test.js b/test/render/forms.test.js index 7e4165fd..22c6f5a9 100644 --- a/test/render/forms.test.js +++ b/test/render/forms.test.js @@ -5,8 +5,9 @@ // after the change (render/app.js keeps the mode on the unit info). Headless fake PIXI (test/render/fakepixi.js). // Player reports after 0.1.0 (#5, #8): 转译基底·α changes on its 2 s A_Die_B / _C / _D clip into the 寻仇者 B_*, 幽灵 C_* or // 特战术师 D_* set (it used to stay on A_Idle / A_Move and die on B_Die — "加载变身动画然后就没了"); a knocked-out 逐火 plays -// 'Die' (its 1 s 重生) and walks as the ember on Idle_2 / Move_2, dying on Die_2, and 'Revive' brings the warrior back -// (sim fx 'ember' / 'revive' { form: 'husk' | 'revived' }); 假想敌:再生's 傀儡 the same with A_Die / B_* / B_Revive. +// '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' }); +// 假想敌:再生's 傀儡 the same with A_Die / B_* / B_Revive; the leaders' 重生 close on their last clip as the 重生 ends. import { test, describe, before, after } from 'node:test'; import assert from 'node:assert/strict'; @@ -16,6 +17,7 @@ import { fileURLToPath } from 'node:url'; import { installFakePixi, fakeViewCtx } from './fakepixi.js'; import { presetCamera } from '../../public/js/render/projection.js'; import { UF, ANIM } from '../../shared/constants.js'; +import { makeBattle, chessRec } from '../helpers/battleHarness.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8')); @@ -172,44 +174,60 @@ describe('转译基底·α forms (user report after 0.1.0, #5)', () => { describe('逐火 embers and the 再生 puppet (user report after 0.1.0, #8)', () => { for (const id of ['enemy_1288_duskls', 'enemy_1288_duskls_2', 'enemy_1292_duskld']) { - test(`${id}: knocked out ⇒ 'Die' once, the ember on Idle_2 / Move_2 / Die_2; revived ⇒ 'Revive', then the warrior's clips`, async () => { + test(`${id}: knocked out ⇒ 'Die' once, the ember on Idle_2 / Move_2 / Die_2; 'Revive' ends as it stands up, then the warrior's clips`, async () => { const anims = assets.enemies[id].spine.animations; for (const name of ['Die', 'Idle_2', 'Move_2', 'Die_2', 'Revive']) assert.ok(name in anims, name); const v = await enemy(id); v.sync(sample(0, ANIM.MOVE), 1); assert.equal(clip(v), 'Move'); - v.setForm('husk'); + const dur = 4; // the 'ember' fx: 1 s 重生 + the husk (10 s officially) + v.setForm('husk', { dur }); assert.equal(clip(v), 'Die', 'the knock-out (its 1 s 重生)'); frames(v, 65); assert.equal(clip(v), 'Move_2', 'the ember walks'); v.sync(sample(UF.STEALTH, ANIM.IDLE), 2); assert.equal(clip(v), 'Idle_2'); - v.setForm('revived'); - assert.equal(clip(v), 'Revive'); - frames(v, 80); + frames(v, Math.round((dur - anims.Revive - 65 / 60) * 60) + 3); + assert.equal(clip(v), 'Revive', 'the stand-up clip starts so that it ends with the husk'); + frames(v, Math.round(anims.Revive * 60) - 6); + v.setForm('revived'); // the sim's 'revive' fx + assert.equal(clip(v), 'Revive', 'plays out, not restarted'); + frames(v, 10); assert.equal(clip(v), 'Idle', 'the warrior again'); - v.onAttack(null, 4); - assert.equal(clip(v), 'Attack'); - v.setForm('husk'); + v.onAttack(null, 6); + assert.equal(clip(v), 'Attack', 'it attacks at once'); + v.setForm('husk', { dur }); frames(v, 65); v.die(); assert.equal(clip(v), 'Die_2', 'the ember dies on its own clip'); + frames(v, 300); + assert.equal(clip(v), 'Die_2', 'no stand-up clip after its death'); }); } test('假想敌:再生: A_Die, the 傀儡 on B_Idle / B_Move / B_Die; B_Revive back to the A_* clips', async () => { const v = await enemy('enemy_9010_acpupp'); - v.setForm('husk'); + v.setForm('husk', { dur: 3 }); assert.equal(clip(v), 'A_Die'); frames(v, 65); assert.equal(clip(v), 'B_Idle'); v.sync(sample(0, ANIM.MOVE), 2); assert.equal(clip(v), 'B_Move'); + frames(v, 60); + assert.equal(clip(v), 'B_Revive', 'its last second'); + frames(v, 57); v.setForm('revived'); - assert.equal(clip(v), 'B_Revive'); - frames(v, 65); + frames(v, 6); assert.equal(clip(v), 'A_Move'); }); + + test('a view built mid-husk (UnitInfo form, no timing) shows the husk; the revival lands in the manifest clips', async () => { + const v = await enemy('enemy_1288_duskls', { form: 'husk' }); + v.sync(sample(UF.STEALTH, ANIM.MOVE), 1); + assert.equal(clip(v), 'Move_2'); + v.setForm('revived'); + assert.equal(clip(v), 'Move', 'no stand-up clip without the timing'); + }); }); describe('the leaders\' 重生 and 守墓石像 (audit of the knock-out forms after report #5)', () => { @@ -224,15 +242,21 @@ describe('the leaders\' 重生 and 守墓石像 (audit of the knock-out forms af for (const name of [begin, hold, end, idle2, die2]) assert.ok(name in anims, name); const v = await enemy(id); v.sync(sample(UF.STUNNED, ANIM.STUN), 1); - v.setForm('reborn'); + const dur = 6; // the 'telegraph' fx's dur (Reborn.duration) + v.setForm('reborn', { dur }); assert.equal(clip(v), begin); frames(v, Math.ceil(anims[begin] * 60) + 5); assert.equal(clip(v), hold, 'held (its stun role) while the sim keeps it stunned'); - v.sync(sample(0, ANIM.IDLE), 6); + const at = Math.ceil(anims[begin] * 60) + 5; + frames(v, Math.round((dur - anims[end]) * 60) - at + 3); + assert.equal(clip(v), end, 'the closing clip starts so that it ends with the 重生'); + frames(v, Math.round(anims[end] * 60) - 6); + v.sync(sample(0, ANIM.IDLE), dur + 1); // the sim's 'revive' fx: the 重生 is over v.setForm('form2'); - assert.equal(clip(v), end); - frames(v, Math.ceil(anims[end] * 60) + 5); - assert.equal(clip(v), idle2); + frames(v, 6); + assert.equal(clip(v), idle2, 'the second form at once'); + v.onAttack(null, dur + 1.2); + assert.notEqual(clip(v), idle2, 'its first attack is drawn, not swallowed by a change clip'); v.die(); assert.equal(clip(v), die2); }); @@ -273,7 +297,38 @@ describe('the leaders\' 重生 and 守墓石像 (audit of the knock-out forms af }); }); -test('render/app.js hands the sim\'s fx \'phase\' kind — or the `form` of any fx — to the view and keeps the mode on the unit info', () => { +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 () => { + const mage = chessRec({ id: 't_mage', profession: 'CASTER', stats: { atk: 50, blockCnt: 0 }, rangeGrid: [[0, 0]], skill: null }); + const h = makeBattle({ + 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 } }) }, + }); + h.step(); + const still = { mods: { speedMul: 0 } }; + const tr = h.b.spawnEnemy('enemy_10081_mpplai', { pos: [9, 8], ...still }); + const ember = h.b.spawnEnemy('enemy_1288_duskls', { pos: [10, 8], ...still }); + const mace = h.b.spawnEnemy('enemy_1525_blkswb', { pos: [11, 8], ...still }); + 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 ⇒ 特战术师 + h.b.kill(ember, m); + h.b.kill(mace, m); + const until = h.b.time + 6; + while (h.b.time < until) { h.b.step(); h.b.drainEvents(); } + const meta = h.b.fieldMeta(); + const view = async (u) => { + const info = meta.units.find((x) => x.id === u.id); + const ctx = fakeViewCtx(fake.P, { assets: store(info.spine), cam }); + const v = new UnitView(ctx, info); + await tick(); await tick(); + v.sync(sample(), 1); + return v; + }; + 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(mace)), 'B_Idle', '锏\'s second form'); +}); + +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', () => { const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8'); - assert.match(src, /e\[1\] === 'phase' \? ex4\.kind : 'form' in ex4 \? ex4\.form[\s\S]{0,200}inf\.form = [\s\S]{0,120}setForm\?\.\(form\)/); + assert.match(src, /e\[1\] === 'phase' \? ex4\.kind : 'form' in ex4 \? ex4\.form[\s\S]{0,200}inf\.form = [\s\S]{0,120}setForm\?\.\(form, ex4\)/); }); diff --git a/test/sim/feedback1-transform.test.js b/test/sim/feedback1-transform.test.js index 64761e08..db003d52 100644 --- a/test/sim/feedback1-transform.test.js +++ b/test/sim/feedback1-transform.test.js @@ -11,6 +11,10 @@ // every 逐火 enemy of a round leaked. // The real-round tests build the battle like Match._normalOpts: an official wave (waves.js buildNormalWave, a 悬赏 card // through withBounties), the real stage 战场#01, real chess, full content, buildBattleSpec → createBattleFromSpec. +// Review follow-ups: a view built mid-battle (fieldMeta — a watched teammate's field, 联防 observers, a reconnect; the +// client runner's silent catch-up drops the fx) learns the current form from UnitInfo `form`; an HP loss (流失) cannot +// knock 转译基底·α out before its change; 假想敌:再生's shields come with its 再生状态, within 1.8 (PRTS 天赋); enemy +// profiles keep one object shape from spawn (the sim's hot loop). import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; @@ -23,7 +27,7 @@ import { getDefaultSource } from '../../server/sim/simdata.js'; import { GEO } from '../../shared/constants.js'; import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js'; import { canTargetEnemy } from '../../server/sim/targeting.js'; -import { HUSK_REBIRTH, TRANSLATOR_CHANGE } from '../../server/sim/content/enemies.js'; +import { HUSK_REBIRTH, TRANSLATOR_CHANGE, abOf } from '../../server/sim/content/enemies.js'; const QUIET = { warn() {}, error() {}, info() {} }; const DATA = getData({ log: QUIET }); @@ -128,6 +132,7 @@ describe('#8 深池逐火: a knock-out is a walking 隐匿 ember that a blocker walked = Math.max(walked, Math.hypot(u.x - m.x, u.y - m.y)); } check(u.stats.attacks === m.attacks, 'an ember never attacks'); + if (n % 30 === 0) check(b.fieldMeta().units.find((x) => x.id === u.id)?.form === 'husk', 'a view built now (fieldMeta) shows the ember'); } } const r = b.result(); @@ -209,6 +214,23 @@ describe('#8 深池逐火: a knock-out is a walking 隐匿 ember that a blocker assert.equal(e.hp, n - 4); assert.ok(e.alive); }); + + test('假想敌:再生 (PRTS 天赋 "被击倒后重生,持续1s,随后变为再生状态 … 进入此形态时,使半径1.8范围内的其他敌方单位…获得5层…护盾"): no shields during the 1 s 重生, then 5-hit shields within 1.8 only', () => { + const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS }); + h.step(); + const still = { mods: { speedMul: 0 } }; + const e = put(h, 'enemy_9010_acpupp', { pos: [10, 8], ...still }); + const near = put(h, 'enemy_1007_slime', { pos: [10, 6.3], ...still }); // 1.7 away + const far = put(h, 'enemy_1007_slime', { pos: [11.9, 8], ...still }); // 1.9 away + h.b.kill(e, h.unit('t_gun')); + assert.ok(e.alive && e.form === 'husk'); + h.run(HUSK_REBIRTH - 0.1); + assert.equal(abOf(h.b, near).hitShield ?? 0, 0, 'no shield while it is still in its 重生'); + h.run(0.2); + assert.equal(abOf(h.b, near).hitShield, tb('enemy_9010_acpupp', 'Aura.max_damage_block_cnt'), 'the 再生状态 begins: 5 hits of shield within 1.8'); + assert.equal(abOf(h.b, far).hitShield ?? 0, 0, 'nothing 1.9 away'); + assert.ok(e.s.flags.unblockable && !e.s.flags.noMove, '不可阻挡, free to move after the 1 s'); + }); }); // --------------------------------------------------------------------------------------------------------------- @@ -268,10 +290,12 @@ describe('#5 转译基底·α: damage is cancelled until its form change; only t ], }); let tr = null, phaseAt = null, kind = null, diedAt = null, formAt = null, maxBefore = null, firstFormLoss = null, earlyAttack = false; + let lateForm; // UnitInfo.form of the translator in fieldMeta() 1 s after its change began let n = 0; while (!b.finished && n++ < 30 * 300) { b.step(); tr = tr ?? b.enemies.find((e) => e.defId === TR) ?? null; + if (tr && tr.alive && phaseAt != null && lateForm === undefined && b.time >= phaseAt + 1) lateForm = b.fieldMeta().units.find((u) => u.id === tr.id)?.form ?? null; for (const ev of b.drainEvents()) { if (!tr) continue; if (ev[0] === 'fx' && ev[1] === 'phase' && ev[4].id === tr.id) { phaseAt = b.time; kind = ev[4].kind; } @@ -289,6 +313,7 @@ describe('#5 转译基底·α: damage is cancelled until its form change; only t assert.ok(phaseAt != null && diedAt > phaseAt + TRANSLATOR_CHANGE - 0.05, 'it changed form before it died — never killed in its first form'); assert.equal(firstFormLoss, null, 'its first form loses no HP'); assert.equal(kind, 'translator_fuchou', '4 physical hits first ⇒ 寻仇者'); + assert.equal(lateForm, 'translator_fuchou', 'a view built mid-battle (fieldMeta) draws the changing form, not the A model'); approx(formAt - phaseAt, TRANSLATOR_CHANGE, 0.02, 'the change lasts 2 s'); assert.ok(!earlyAttack, 'no attack before its change'); const r = b.result(); @@ -356,6 +381,24 @@ describe('#5 转译基底·α: damage is cancelled until its form change; only t checkInvariants(h2.b); }); + test('an HP loss (流失 — no damage instance to cancel, e.g. 隐德来希\'s 心烛 hand-over) cannot knock out its original form; once changed it can', () => { + const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS }); + h.step(); + const e = put(h, TR, { mods: { speedMul: 0 } }); + const g = h.unit('t_gun'); + h.b.loseHp(e, 1e9, { source: g }); + assert.ok(e.alive && e.hp >= 1, 'still standing in its first form'); + assert.equal(h.b.killed, 0); + assert.equal(h.eventsOf('die').filter((ev) => ev[1] === e.id).length, 0, 'no die event: its first form has no death clip'); + for (let i = 0; i < 4; i++) h.b.dealDamage(g, e, { amount: 1, type: 'phys' }); + h.b.loseHp(e, 1e9, { source: g }); + assert.ok(e.alive, 'not during its change either'); + h.run(TRANSLATOR_CHANGE + 0.1); + h.b.loseHp(e, 1e9, { source: g }); + assert.ok(!e.alive && e.removeReason === 'killed', 'the changed form dies'); + assert.equal(h.b.killed, 1); + }); + test('the 4th arts hit ⇒ 特战术师: its stats, ranged arts attacks on 2 targets at once', () => { const h = arena({ units: [{ chessId: 't_mage', row: 11, col: 6 }, { chessId: 't_gun', row: 10, col: 7 }], kits: QUIET_GUNS, hooks: ['damaged'], captureNoisy: true }); h.step(); @@ -397,3 +440,55 @@ describe('#5 转译基底·α: damage is cancelled until its form change; only t assert.equal(e.mem.ab.list.length, 1); }); }); + +// --------------------------------------------------------------------------------------------------------------- +// review follow-ups: the form reaches a view built mid-battle; stable enemy shapes + +describe('a view built mid-battle learns the form (UnitInfo `form` in fieldMeta)', () => { + const formOf = (h, e) => h.b.fieldMeta().units.find((u) => u.id === e.id)?.form; + test('spawned: no form; 转译基底·α changing / changed, a 逐火 ember and its revival, a leader\'s 重生 and second form, 守墓石像 — each fieldMeta carries the current one', () => { + const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }, { chessId: 't_mage', row: 11, col: 4 }], kits: QUIET_GUNS }); + h.step(); + const still = { mods: { speedMul: 0 } }; + const tr = put(h, TR, { pos: [9, 8], ...still }); + const ember = put(h, 'enemy_1288_duskls', { pos: [10, 8], ...still }); + const mace = put(h, 'enemy_1525_blkswb', { pos: [11, 8], ...still }); + const stone = put(h, 'enemy_1172_dugago', { pos: [12, 8], ...still }); + const spawned = h.eventsOf('spawn').find((ev) => ev[1].id === tr.id)[1]; + assert.equal(spawned.form, undefined, 'a fresh enemy has no form'); + assert.equal(formOf(h, tr), undefined); + const m = h.unit('t_mage'); + for (let i = 0; i < 4; i++) h.b.dealDamage(m, tr, { amount: 1, type: 'arts' }); + h.b.kill(ember, m); + h.b.kill(mace, m); + h.b.kill(stone, m); + // the client runner's silent catch-up: the fx are drained and dropped + h.b.step(); + h.b.drainEvents(); + assert.equal(formOf(h, tr), 'translator_shushi', 'changing'); + assert.equal(formOf(h, ember), 'husk'); + assert.equal(formOf(h, mace), 'reborn'); + assert.equal(formOf(h, stone), 'stone'); + h.b.kill(ember, null); // still in its 1 s 重生: nothing changes + while (h.b.time < 12.5) { h.b.step(); h.b.drainEvents(); } + assert.equal(formOf(h, tr), 'translator_shushi', 'changed'); + assert.equal(formOf(h, ember), 'revived', 'the warrior again'); + assert.equal(formOf(h, mace), 'form2', 'after its 重生'); + assert.equal(formOf(h, stone), 'fly'); + }); +}); + +test('every enemy starts with the same profile fields and `moving` / `form` (one object shape for the hot loop)', () => { + const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS }); + h.step(); + const keys = [TR, ...EMBERS, 'enemy_1007_slime', 'enemy_1172_dugago', 'enemy_1525_blkswb', 'enemy_9010_acpupp']; + const shapes = new Set(); + for (const key of keys) { + const e = put(h, key); + assert.equal(e.moving, false, `${key} moving`); + assert.equal(e.form, null, `${key} form`); + assert.ok('dmgType' in e.profile && e.profile.dmgType === null, `${key} profile.dmgType`); + shapes.add(Object.keys(e).join()); + } + assert.equal(shapes.size, 1, 'one key order for every enemy'); +});