feedback1 WD: forms reach late views, stable enemy shapes, review fixes

- Every form change goes through content setForm(): the unit keeps it (e.form)
  and UnitInfo publishes it, so a view built mid-battle from fieldMeta (a
  watched teammate's field, 联防 observers, a reconnect) starts in the
  current form instead of the first-form model.
- Enemy profile (dmgType) and Unit moving / form are initialised at spawn:
  one object shape for the sim's hot loop (the boss-field bench is back at
  the base speed).
- 转译基底·α: an HP loss cannot knock out its original form (no death clip).
- 假想敌:再生: 1 s 重生 and walking 傀儡 cited from PRTS; shields within 1.8
  as the 再生状态 begins.
- Renderer: a 重生's closing clip (and the ember's stand-up clip) is timed
  from the fx dur to end with it, so the next form attacks on its own clips.
- Docs: ASSETS FORMS list, SIM form fx, PLAYING 隐匿 wording.
This commit is contained in:
sganggs committed 2026-10-03 03:02:46 +08:00
1 parent 06be81f67e
commit 5c23eeb804
12 files changed
+318 -88

No files matched your search

+12 -3
View File
@@ -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 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' fx; a `change` clip plays once first): 掠海漂移体's crawl (`Change`, `*_02`), 转译基底·α's three forms 'ember' / 'revive' / 'telegraph' / 'stone' fx — or, for a view built mid-battle, UnitInfo `form`; a `change` clip plays
(`A_Die_B` / `_C` / `_D`, 2 s each, then `B_*` 寻仇者, `C_*` 幽灵, `D_*` 特战术师), the 深池逐火 embers (`Die`, then once first, an `end` clip is timed from the fx's `dur` to finish as that state ends):
`Idle_2` / `Move_2` / `Die_2`; `Revive` back) and 假想敌:再生's puppet (`A_Die`, `B_*`, `B_Revive`). - 掠海漂移体'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: 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. - **Choosing the model:** Front when the unit faces right or down; Front mirrored when facing left; Back when facing up.
+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. 联防(同盟)
+10 -6
View File
@@ -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, 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 and beaten; 假想敌: walks its route on: the 深池逐火 余烬 / 火灰 are 隐匿 and disarmed, so only a blocked one can be targeted by operators (area
再生's 傀儡 is unblockable; a husk still standing after `Revive[Trigger].interval` s stands up again with full HP), damage still reaches it); 假想敌:再生's 傀儡 is unblockable and, as it begins, shields the other enemies within 1.8; a husk
转译基底·α (its original form cancels every damage instance; the 4th physical / arts instance or a block starts a 2 s still standing after `Revive[Trigger].interval` s stands up again with full HP),
change). The model's clip set follows a 'phase' fx kind or the `form` of the 'ember' / 'revive' fx (render/units.js 转译基底·α (its original form cancels every damage instance, and an HP loss stops at 1 HP; the 4th physical / arts
FORMS). 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 **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 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]`, `['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), `['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]`. `['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) (`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 — 隐匿 or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512);
+1 -1
View File
@@ -1429,7 +1429,7 @@ export async function createFieldView(host, options = {}) {
if (form !== undefined) { if (form !== undefined) {
const inf = infos.get(ex4.id); const inf = infos.get(ex4.id);
if (inf && (typeof form !== 'string' || FORMS[inf.spine || inf.defId]?.[form])) inf.form = typeof form === 'string' ? form : null; 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]); fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]);
break; break;
+35 -12
View File
@@ -12,8 +12,10 @@
// deploy() 'Start' once, then base // deploy() 'Start' once, then base
// die() die clip once (callers fade out afterwards) // die() die clip once (callers fade out afterwards)
// stunned (setBase('stun')) stun clip, or the current track frozen at timeScale 0 // 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 // 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) // (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) // 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. // 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 */ } try { this.spine.stateData.defaultMix = 0.12; } catch { /* ignore */ }
this.base = 'idle'; 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.skillOn = false;
this.attackUntil = 0; this.attackUntil = 0;
this.clock = 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 * 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 * 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 || {}; const anims = this.entry?.anims || {};
this.roles = roles ? { ...anims, ...roles } : anims; this.roles = roles ? { ...anims, ...roles } : anims;
this.endClip = null;
if (this.dead) return; if (this.dead) return;
if (change && this.has(change)) { if (end && this.has(end.clip) && end.in > 0) {
this.stunWanted = this.mode === 'stun'; this.endClip = end.clip;
this.frozen = false; this.endAt = this.clock + Math.max(0, end.in - this.dur(end.clip));
this.mode = 'change'; this.endRoles = end.roles || null;
this._play(change, false, { mix: 0.08 }); }
this.changeUntil = this.clock + this.dur(change); if (change && this.has(change)) this._change(change);
} else if (this.mode === 'base') this._play(this._baseName(), true); 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); 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); } 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); } 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) { update(dt) {
this.clock += 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) { 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) // a compressed wind-up reached its strike frame: back to the rhythm speed (attack() also does it)
this.windUntil = null; this.windUntil = null;
+26 -15
View File
@@ -24,8 +24,10 @@
// redeploy) plays the deploy clip and restores the normal look; `setDown(null)` on a dead view lets it fade out. // 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') // 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. // 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) // Enemy modes (sim fx 'phase' { id, kind } or another fx's `form` → `setForm(kind, fx)`): 掠海漂移体 dropping to 爬行模式
// plays its skeleton's 'Change' clip once, then the crawl set (*_02) — FORMS; a view built later keeps the mode. // (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 元素: "模型 // 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
@@ -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 * - 深池逐火战士 / 精锐战士 / 护卫 (#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; * 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; * - 假想敌:再生: 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 / * - 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 * _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. * - 守墓石像 (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. * 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, skill: attack ? Object.freeze({ begin: null, loop: attack, end: null, via: 'attack', index: 0, idle: null }) : null,
}); });
const EMBER = Object.freeze({ const EMBER = Object.freeze({
husk: Object.freeze({ change: 'Die', roles: clipSet('Idle_2', 'Move_2', 'Die_2') }), husk: Object.freeze({ change: 'Die', end: 'Revive', next: 'revived', roles: clipSet('Idle_2', 'Move_2', 'Die_2') }),
revived: Object.freeze({ change: 'Revive', roles: Object.freeze({}) }), 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({ 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 }) }), 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: end, roles: form2 }), form2: Object.freeze({ change: null, roles: form2 }),
}); });
const STATUE = Object.freeze({ 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 }) }), 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_1288_duskls_2: EMBER,
enemy_1292_duskld: EMBER, enemy_1292_duskld: EMBER,
enemy_9010_acpupp: Object.freeze({ enemy_9010_acpupp: Object.freeze({
husk: Object.freeze({ change: 'A_Die', roles: clipSet('B_Idle', 'B_Move', 'B_Die') }), husk: Object.freeze({ change: 'A_Die', end: 'B_Revive', next: 'revived', roles: clipSet('B_Idle', 'B_Move', 'B_Die') }),
revived: Object.freeze({ change: 'B_Revive', roles: Object.freeze({}) }), 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_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')), 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 * The unit changed mode (sim fx 'phase' { id, kind } or another fx's `form`; `fx` = that fx's extra): the mode's clip
* change clip; null goes back to the manifest clips; a kind this skeleton has no clip set for (an arts barrier, a * set (FORMS) after its change clip, and its closing clip (`end`, landing in the `next` form's clips) timed to end
* broken charge …) changes nothing. Kept for a model built later. * `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; const k = typeof kind === 'string' ? kind : null;
if (k === this.form) return; if (k === this.form) return;
if (k && !FORMS[this.info.spine || this.info.defId]?.[k]) return; if (k && !FORMS[this.info.spine || this.info.defId]?.[k]) return;
@@ -399,7 +408,9 @@ export class UnitView {
this.info.form = k; this.info.form = k;
const f = this._formSpec(); const f = this._formSpec();
if (!this.actor) return; 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); else if (had) this.actor.setForm(null);
} }
+3 -1
View File
@@ -811,7 +811,9 @@ export class Battle {
e.deployedAt = this.time; e.deployedAt = this.time;
e.atkCd = 0; e.atkCd = 0;
e.pauseUntil = -Infinity; 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 }; 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) { if (!e.route.legs.length) {
const end = this.grid.specialTiles('end')[0]; const end = this.grid.specialTiles('end')[0];
+52 -27
View File
@@ -53,10 +53,13 @@
// 封冻/供暖器, 赘生甲壳, 锁链, 血债账款, 晦明) have no counterpart in this mode and are listed where they apply. // 封冻/供暖器, 赘生甲壳, 锁链, 血债账款, 晦明) 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, // 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'/ // kind, tiles?, id?, form?} (delayed strikes, charges, 'reborn' with form 'reborn') · 'beam' {from, to, kind} ·
// 'stone' {id, form?} · 'blink' {id, fx, fy} · 'charge' {id, tx, ty} · 'expose' {id} · 'shieldBreak' {id} · 'liberate' {id} // 'summon' {id, key} · 'ember' {id, hits, dur, form: 'husk'} · 'revive' {id, kind?, form: 'form2' | 'revived' | 'fly'}
// · 'phase' {id, kind} (form / barrier changes) · 'lpLoss' {value, reason} · 'steal' · 'ignite'. A 'phase' kind, or // · 'stone' {id, dur, form: 'stone'} · 'blink' {id, fx, fy} · 'charge' {id, tx, ty} · 'expose' {id} · 'shieldBreak'
// the `form` of any other fx, is the model's clip set from then on (render/app.js → UnitView.setForm, units.js FORMS). // {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. // Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss.
import { TICK, MOVE_SCALE, ELEMENT } from '../constants.js'; 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 假想敌:淤困 "附着对象元素爆发时 /** 假想敌:淤困: element damage its host's burst spreads to the allies of the 4 tiles around (PRTS 假想敌:淤困 "附着对象元素爆发时
* …对附着对象及周围4格内的所有我方单位…造成1000同类型元素损伤"; the host itself is in its 爆发冷却). */ * …对附着对象及周围4格内的所有我方单位…造成1000同类型元素损伤"; the host itself is in its 爆发冷却). */
const PARASITE_SPREAD = 1000; const PARASITE_SPREAD = 1000;
/** 重生 of a Revive[Trigger] knock-out before its husk (PRTS 深池逐火战士 "被击倒后重生,持续1s"; the same talent template on /** 重生 of a Revive[Trigger] knock-out before its husk (PRTS 深池逐火战士 and 假想敌:再生 天赋: "被击倒后重生,持续1s,随后变为
* 假想敌:再生, whose PRTS page gives no duration — 1 s there too [ASSUMED]; both models' knock-out clips last 1 s). */ * 怨恨的余烬 / 再生状态,1s内不移动且持有无敌+无法阻挡+失衡免疫"; both models' knock-out clips last 1 s). */
export const HUSK_REBIRTH = 1; export const HUSK_REBIRTH = 1;
/** 转译基底·α: "变化过程持续2s" (PRTS 天赋; the model's A_Die_B / _C / _D change clips last 2 s); the original form's /** 转译基底·α: "变化过程持续2s" (PRTS 天赋; the model's A_Die_B / _C / _D change clips last 2 s); the original form's
* immunities ("免疫晕眩/沉睡/寒冷/冻结/浮空/恐惧", plus 失衡免疫). */ * immunities ("免疫晕眩/沉睡/寒冷/冻结/浮空/恐惧", plus 失衡免疫). */
@@ -89,8 +92,8 @@ const TANK_ZONE_RADIUS = 1.7;
const POLLUTION_INTERVAL = 1; const POLLUTION_INTERVAL = 1;
/** "数个目标" of 假想敌:骨刺 while stealthed [ASSUMED]. */ /** "数个目标" of 假想敌:骨刺 while stealthed [ASSUMED]. */
const ACBUNN_TARGETS = 3; const ACBUNN_TARGETS = 3;
/** 假想敌:再生 shield aura radius ("周围一定距离") [ASSUMED]. */ /** 假想敌:再生 shield aura radius (PRTS 假想敌:再生 天赋 "进入此形态时,使半径1.8范围内的其他敌方单位(无视其可选性)获得5层…护盾"). */
const ACPUPP_AURA_RADIUS = 2; const ACPUPP_AURA_RADIUS = 1.8;
/** 重弩突袭者 直击 reach along a row/column [ASSUMED]. */ /** 重弩突袭者 直击 reach along a row/column [ASSUMED]. */
const CROSS_REACH = 6; const CROSS_REACH = 6;
/** 暴鸰 投弹: the target's tile and its 8 neighbours (PRTS "对目标及其周围八格的我方单位造成100%物理伤害"). */ /** 暴鸰 投弹: 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); 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). */ /** Leader "扣除目标生命" effects: recorded for the match (hook 'lpLoss' + result.lpLoss). */
export function lpLoss(b, amount, reason, source = null) { export function lpLoss(b, amount, reason, source = null) {
if (!(amount > 0)) return; 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 (invincible > 0) b.addBuff(e, { key: `${key}:inv`, duration: invincible, visible: true, flags: { invulnerable: true } });
if (e.route) e.route.pts = null; if (e.route) e.route.pts = null;
e.atkCd = 0; 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 { return {
killed(c, b, e, a) { 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, onTick: during ? ({ battle }) => during(battle, e, a, battle.time - a.t0) : null,
onExpire: ({ battle }) => finish(battle, e, a), 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; 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) * 隐匿 (`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 * 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).
* User report after 0.1.0 (#8): the v2.5 ember stood still, stealthed AND unblockable — nobody could ever target it. * 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 * fx (setForm): 'ember' {id, hits, dur, form: 'husk'} at the knock-out, 'revive' {id, form: 'revived'} when it stands
* renderer switches the model's clip set: render/units.js FORMS). * 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' }) { function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, key = 'ab:ember' }) {
const revive = (b, e, a) => { 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`); b.removeBuff(e, `${key}:reborn`);
if (e.route) e.route.pts = null; if (e.route) e.route.pts = null;
e.atkCd = 0; e.atkCd = 0;
b.fx('revive', { x: e.x, y: e.y, id: e.id, form: 'revived' }); setForm(b, e, 'revived', 'revive');
}; };
return { return {
killed(c, b, e, a) { 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, 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 } }); 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; 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' }); setForm(b, e, 'husk', 'ember', { hits, dur: HUSK_REBIRTH + delay });
if (onHusk) onHusk(b, e); 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 }); b.after(HUSK_REBIRTH + delay, () => revive(b, e, a), { owner: e });
return true; return true;
}, },
@@ -865,7 +881,7 @@ function statue(ab) {
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']);
b.fx('stone', { x: e.x, y: e.y, id: e.id, dur, form: 'stone' }); setForm(b, e, 'stone', 'stone', { dur });
b.addBuff(e, { b.addBuff(e, {
key: 'ab:stone', duration: dur, visible: true, flags: { noMove: true, unblockable: true, noDisplace: true }, 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 }, 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 }); 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 } }); battle.addBuff(e, { key: 'ab:flight', persist: true, flags: { noDisplace: true } });
if (e.route) e.route.pts = null; 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; return true;
@@ -1094,7 +1110,7 @@ function kitSyufo(ab) {
setFloat(b, e, false); setFloat(b, e, false);
e.profile.melee = true; e.profile.melee = true;
b.applyStatus(e, 'stun', { duration: SYUFO_CRAWL_STUN, source: null }); 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 /** 假想敌:再生 (PRTS 假想敌:再生 天赋) · knock-out ⇒ 1 s 重生 ⇒ 再生状态: a 傀儡 of prop_max_hp (15) hits that is 不可阻挡 for
* model has a 傀儡 walk cycle (B_Move); an immobile unit would not need to be unblockable [ASSUMED from both] — plus * `interval` (15) s and walks on ("1s内不移动": only the 重生 stands; its model has a 傀儡 walk cycle, B_Move), back with full
* max_damage_block_cnt-hit shields on the enemies around it. */ * 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) { 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; 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({ return [husk({
hits, delay, stealthy: false, unblock: true, key: 'ab:regen', hits, delay, stealthy: false, unblock: true, key: 'ab:regen',
onHusk(b, e) { onHusk(b, e) {
// 被击倒后…使周围一定距离内其他敌人获得可以抵挡物理及法术伤害的护盾 // "进入此形态时,使半径1.8范围内的其他敌方单位(无视其可选性)获得5层吸收物理/法术伤害的护盾"
b.fx('telegraph', { x: e.x, y: e.y, r: ACPUPP_AURA_RADIUS, kind: 'regenShield', id: e.id }); 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); } 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 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). * 幽灵 (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, * 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 — * projectiles and the attack hooks are the normal ones. Before its change ends it cannot be killed at all (damage is
* 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 * cancelled; an HP loss stops at 1 HP) — user report after 0.1.0 (#5): its model never changed (no FORMS clip set), so
* form playing the 寻仇者's B_Die. fx 'phase' {id, kind: translator_fuchou | _shushi | _youling} starts the model's 2 s * 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, * 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 幽灵. * 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; if (P.form || !e2.alive) return;
P.form = form; P.form = form;
b.addBuff(e2, { key: 'ab:change', duration: TRANSLATOR_CHANGE, persist: true, flags: { noMove: true }, onExpire: ({ battle }) => finish(battle, e2) }); 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 [{ return [{
spawn(b, e2) { spawn(b, e2) {
@@ -2126,6 +2144,13 @@ function kitTranslator(ab, e) {
} }
c.dmg.cancel = true; 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'); }, blocked(c, b, e2) { change(b, e2, 'youling'); },
tick(b, e2) { tick(b, e2) {
// 寻仇者: "生命值低于50%时,攻击力+100%" // 寻仇者: "生命值低于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: 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_1288_duskls_2: kitEmber, // 深池逐火精锐战士 · same
enemy_1292_duskld: kitEmber, // 深池逐火护卫 · 10-hit 火灰 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 元素 // --- ELEMENT 元素
enemy_1148_dssbr: kitEp('neural', 'epdamage.attack@ep_damage_ratio'), // 底海滑动者 · neural on hit enemy_1148_dssbr: kitEp('neural', 'epdamage.attack@ep_damage_ratio'), // 底海滑动者 · neural on hit
+5 -1
View File
@@ -1,7 +1,8 @@
// server/sim/snapshot.js — compact serialization for clients (DESIGN §8.2). // 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 } // 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). // 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).
@@ -32,6 +33,9 @@ export function unitInfo(u) {
maxHp: Math.max(1, Math.round(u.s.maxHp)), maxHp: Math.max(1, Math.round(u.s.maxHp)),
motion: u.motion === 'FLY' ? 'FLY' : undefined, motion: u.motion === 'FLY' ? 'FLY' : undefined,
boss: u.isBoss ? true : 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, uid: u.uid ?? undefined,
// DESIGN §16: the equipped skill's index (the renderer / audio pick that skill's Spine clip and sound) // 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, skillIndex: u.side === 'ally' && Number.isInteger(d.skill?.index) ? d.skill.index : undefined,
+2
View File
@@ -81,6 +81,8 @@ export class Unit {
this.trait = {}; // profession runtime state this.trait = {}; // profession runtime state
this.stats = { dmg: 0, kills: 0, heal: 0, taken: 0, attacks: 0 }; this.stats = { dmg: 0, kills: 0, heal: 0, taken: 0, attacks: 0 };
this.hidden = false; // enemies inside a DISAPPEAR segment 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.anim = 0;
this.persist = { redeployMul: 1, freeRedeploys: 0 }; this.persist = { redeployMul: 1, freeRedeploys: 0 };
this.isBoss = false; this.isBoss = false;
+75 -20
View File
@@ -5,8 +5,9 @@
// after the change (render/app.js keeps the mode on the unit info). Headless fake PIXI (test/render/fakepixi.js). // 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 // 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 // 特战术师 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 // 'Die' (its 1 s 重生) and walks as the ember on Idle_2 / Move_2, dying on Die_2, and 'Revive' — timed from the 'ember'
// (sim fx 'ember' / 'revive' { form: 'husk' | 'revived' }); 假想敌:再生's 傀儡 the same with A_Die / B_* / B_Revive. // 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 { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -16,6 +17,7 @@ import { fileURLToPath } from 'node:url';
import { installFakePixi, fakeViewCtx } from './fakepixi.js'; import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js'; import { presetCamera } from '../../public/js/render/projection.js';
import { UF, ANIM } from '../../shared/constants.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 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'));
@@ -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)', () => { 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']) { 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; 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); for (const name of ['Die', 'Idle_2', 'Move_2', 'Die_2', 'Revive']) assert.ok(name in anims, name);
const v = await enemy(id); const v = await enemy(id);
v.sync(sample(0, ANIM.MOVE), 1); v.sync(sample(0, ANIM.MOVE), 1);
assert.equal(clip(v), 'Move'); 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 重生)'); assert.equal(clip(v), 'Die', 'the knock-out (its 1 s 重生)');
frames(v, 65); frames(v, 65);
assert.equal(clip(v), 'Move_2', 'the ember walks'); assert.equal(clip(v), 'Move_2', 'the ember walks');
v.sync(sample(UF.STEALTH, ANIM.IDLE), 2); v.sync(sample(UF.STEALTH, ANIM.IDLE), 2);
assert.equal(clip(v), 'Idle_2'); assert.equal(clip(v), 'Idle_2');
v.setForm('revived'); frames(v, Math.round((dur - anims.Revive - 65 / 60) * 60) + 3);
assert.equal(clip(v), 'Revive'); assert.equal(clip(v), 'Revive', 'the stand-up clip starts so that it ends with the husk');
frames(v, 80); 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'); assert.equal(clip(v), 'Idle', 'the warrior again');
v.onAttack(null, 4); v.onAttack(null, 6);
assert.equal(clip(v), 'Attack'); assert.equal(clip(v), 'Attack', 'it attacks at once');
v.setForm('husk'); v.setForm('husk', { dur });
frames(v, 65); frames(v, 65);
v.die(); v.die();
assert.equal(clip(v), 'Die_2', 'the ember dies on its own clip'); 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 () => { 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'); const v = await enemy('enemy_9010_acpupp');
v.setForm('husk'); v.setForm('husk', { dur: 3 });
assert.equal(clip(v), 'A_Die'); assert.equal(clip(v), 'A_Die');
frames(v, 65); frames(v, 65);
assert.equal(clip(v), 'B_Idle'); assert.equal(clip(v), 'B_Idle');
v.sync(sample(0, ANIM.MOVE), 2); v.sync(sample(0, ANIM.MOVE), 2);
assert.equal(clip(v), 'B_Move'); assert.equal(clip(v), 'B_Move');
frames(v, 60);
assert.equal(clip(v), 'B_Revive', 'its last second');
frames(v, 57);
v.setForm('revived'); v.setForm('revived');
assert.equal(clip(v), 'B_Revive'); frames(v, 6);
frames(v, 65);
assert.equal(clip(v), 'A_Move'); 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)', () => { 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); for (const name of [begin, hold, end, idle2, die2]) assert.ok(name in anims, name);
const v = await enemy(id); const v = await enemy(id);
v.sync(sample(UF.STUNNED, ANIM.STUN), 1); 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); assert.equal(clip(v), begin);
frames(v, Math.ceil(anims[begin] * 60) + 5); frames(v, Math.ceil(anims[begin] * 60) + 5);
assert.equal(clip(v), hold, 'held (its stun role) while the sim keeps it stunned'); 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'); v.setForm('form2');
assert.equal(clip(v), end); frames(v, 6);
frames(v, Math.ceil(anims[end] * 60) + 5); assert.equal(clip(v), idle2, 'the second form at once');
assert.equal(clip(v), idle2); v.onAttack(null, dur + 1.2);
assert.notEqual(clip(v), idle2, 'its first attack is drawn, not swallowed by a change clip');
v.die(); v.die();
assert.equal(clip(v), die2); 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'); 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\)/);
}); });
+96 -1
View File
@@ -11,6 +11,10 @@
// every 逐火 enemy of a round leaked. // every 逐火 enemy of a round leaked.
// The real-round tests build the battle like Match._normalOpts: an official wave (waves.js buildNormalWave, a 悬赏 card // 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. // 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 { test, describe } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -23,7 +27,7 @@ import { getDefaultSource } from '../../server/sim/simdata.js';
import { GEO } from '../../shared/constants.js'; import { GEO } from '../../shared/constants.js';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js'; import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { canTargetEnemy } from '../../server/sim/targeting.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 QUIET = { warn() {}, error() {}, info() {} };
const DATA = getData({ log: QUIET }); 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)); walked = Math.max(walked, Math.hypot(u.x - m.x, u.y - m.y));
} }
check(u.stats.attacks === m.attacks, 'an ember never attacks'); 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(); 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.equal(e.hp, n - 4);
assert.ok(e.alive); 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 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; let n = 0;
while (!b.finished && n++ < 30 * 300) { while (!b.finished && n++ < 30 * 300) {
b.step(); b.step();
tr = tr ?? b.enemies.find((e) => e.defId === TR) ?? null; 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()) { for (const ev of b.drainEvents()) {
if (!tr) continue; if (!tr) continue;
if (ev[0] === 'fx' && ev[1] === 'phase' && ev[4].id === tr.id) { phaseAt = b.time; kind = ev[4].kind; } 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.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(firstFormLoss, null, 'its first form loses no HP');
assert.equal(kind, 'translator_fuchou', '4 physical hits first ⇒ 寻仇者'); 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'); approx(formAt - phaseAt, TRANSLATOR_CHANGE, 0.02, 'the change lasts 2 s');
assert.ok(!earlyAttack, 'no attack before its change'); assert.ok(!earlyAttack, 'no attack before its change');
const r = b.result(); const r = b.result();
@@ -356,6 +381,24 @@ describe('#5 转译基底·α: damage is cancelled until its form change; only t
checkInvariants(h2.b); 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', () => { 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 }); 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(); 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); 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');
});