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:
1 parent
06be81f67e
commit
5c23eeb804
12 files changed
+318
-88
No files matched your search
+12
-3
@@ -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.
|
||||
|
||||
+1
-1
@@ -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. 联防(同盟)
|
||||
|
||||
+10
-6
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+35
-12
@@ -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;
|
||||
|
||||
+26
-15
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+75
-20
@@ -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\)/);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user