feedback1 integrate: WD 转译基底 transforms, 逐火 embers beatable, knock-out forms kept by the client
# Conflicts: # docs/PLAYING.md
This commit is contained in:
commit
3328dedea2
19 files changed
+1850
-188
No files matched your search
@@ -202,6 +202,24 @@ A skill clip may also come from directional-only animations when a model has no
|
||||
|
||||
The resolver's full precedence list is in the header of `tools/assets/anim-roles.mjs`.
|
||||
|
||||
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 `form` of the sim's 'phase' / 'ember' / 'revive'
|
||||
/ 'telegraph' / 'stone' fx — `shared/protocol.js fxForm`; the client runner keeps these fx through catch-up frames and
|
||||
hidden tabs — or, for a view built mid-battle, UnitInfo `form`, which `render/app.js renderInfo` passes to the view; a
|
||||
`change` clip plays once first, an `end` clip is timed from the fx's `dur` to finish as that state ends, keeping the
|
||||
current form's death clip until the next form's fx). A blocked or revealed 隐匿 enemy is drawn solid: the sim sends the
|
||||
stealth bit only while its 隐匿 is on:
|
||||
- 掠海漂移体's crawl (`Change`, then `*_02`);
|
||||
- 转译基底·α's 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.
|
||||
- **Attack speed:** set the attack `timeScale` to `duration / attackInterval`.
|
||||
|
||||
+1
-1
@@ -74,7 +74,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 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。
|
||||
- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡时才能被选为攻击目标:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(挡住时它不再半透明),10 秒内没打掉就满血站起来;敌人重生时身上的减速、恐惧等状态会被清空,被恐惧的逐火战士变成余烬后也能被挡住;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。
|
||||
- **策略「鸭爵」(“神秘顾客”)**:从第 5 回合起,你和队友的每场作战(**最终攻势和隐秘核心也算**,联防不另外替换)有 0–2 名地面敌人(不含领袖及其部件、悬赏敌人)被替换为特殊版本的鸭爵、高普尼克、流泪小子或圆仔,出现在被替换敌人的时间和路线上(两人同场的领袖战里,各自只替换走向自己保护目标点的敌人);击倒者获得 1 资金(联防中的队友也算),漏进保护目标点照常扣 1 点目标生命值。鸭爵和圆仔**不攻击、无法被阻挡**:鸭爵受到伤害后移动速度 +300%;圆仔始终面向我方干员较多的一侧,来自正面的物理 / 法术伤害 −80%。流泪小子隐匿(被阻挡时才能被选中),攻击使目标晕眩 3.5 秒;高普尼克免疫晕眩、冻结和浮空。
|
||||
- 右上角 🔍▶▶ 可以查看本回合即将来袭的敌人(预览区),左上角 🔍 查看本局信息(禁用盟约、策略)。
|
||||
|
||||
|
||||
+32
-5
@@ -209,7 +209,8 @@ a stealthed ally (隐匿, 排气格栅) only for the enemy it blocks — our ope
|
||||
"不阻挡时不成为敌方普通攻击的目标") likewise (PRTS 异常效果: neither anomaly is "阻挡时解除") [ASSUMED: enemy skills and
|
||||
splash selectors skip it too]
|
||||
and pause `ATTACK_PAUSE` (0.35 s) after each unblocked attack; `fear`/`disarm` stop attacks; `dmgType 'none'` enemies
|
||||
never attack; `dmgType 'heal'` enemies heal the lowest-HP% enemy in their radius instead. Content can take over an
|
||||
never attack — unless content arms them through `enemy.profile` (`noAttack: false`, `melee`, `dmgType`, `maxTargets`:
|
||||
转译基底·α's 寻仇者 / 特战术师 forms, which then attack like any enemy); `dmgType 'heal'` enemies heal the lowest-HP% enemy in their radius instead. A `noMove` enemy stands (not `moving`, drawn idle). Content can take over an
|
||||
enemy's attack: `enemy.profile.deferHit` = the engine makes the attack (target, timing, the `'atk'` event) but deals no
|
||||
damage — the content's `attack` handler resolves it (帝国炮火先兆者's shells landing 3 s later, `content/enemies.js
|
||||
kitShell`); `enemy.profile.shot` = the `'atk'` event's projectile kind (`'mortar'`: no projectile drawn). Crates (stage devices with
|
||||
@@ -226,6 +227,31 @@ excepted). 【污染秽蚀】 (萨卡兹枯朽战车's 秽蚀轰击,
|
||||
同名效果不叠加", user playtest #6): a unit covered by several zones takes one tick per second (`unit.mem.pollutedAt`), so
|
||||
a crowd of dying 萨卡兹枯朽战士 totals 50 / s, not 50 × n.
|
||||
|
||||
**Knock-outs that are not deaths** (`content/enemies.js`; player reports after 0.1.0): a `killed` ability that keeps the
|
||||
enemy alive hides the knock-out from every later `kill` handler, the kill count, kill credit and the bounty — they all
|
||||
wait for the real death, the only one with a `die` event. Every 重生 (`reborn()`, `husk()`, `statue()`) clears what
|
||||
operators put on the enemy — the buffs with an ally source and source-less catalogue statuses (PRTS 特殊机制 §重生
|
||||
"清空自身身上除白名单外所有Buff"; `rebirthCleanse`) — and keeps [ASSUMED: the whitelist] its `persist` talents, what it or
|
||||
another enemy gave it (锏's self-applied 抵抗, enemy auras) and source-less field state (terrain, airflow, element burst
|
||||
locks): a 逐火 knocked out while feared is no unblockable ember. 重生 / form changes: `reborn()` (first knock-out → second
|
||||
form; fx forms 'reborn' → 'form2'), `statue()` (守墓石像: melee only while blocked; first knock-out → 10 s unblockable,
|
||||
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 by operators (the
|
||||
engine's radius area damage — `enemiesInRadius`: profession splash, skill circles — still reaches an unblocked one, a
|
||||
known deviation: officially an AoE judges only the enemies it can select, PRTS 作战机制 §AOE伤害判定); 假想敌:再生's 傀儡 is unblockable and, as it begins, shields the other enemies within 1.8; a husk
|
||||
still standing after `Revive[Trigger].interval` s stands up again with full HP),
|
||||
转译基底·α (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: `render/app.js renderInfo` hands it to the view) and the
|
||||
fx announces it as its `form`: a 'phase' fx (crawl, translator_* — also its `kind`), 'ember' ('husk'), 'revive'
|
||||
('revived' / 'form2' / 'fly'), 'telegraph' ('reborn') or 'stone' ('stone') — render/units.js FORMS. Barrier / charge
|
||||
'phase' kinds carry no `form` and are no forms. A form is state, not decoration (b.snap tuples carry none): the client
|
||||
keeps every fx with a `form` (`shared/protocol.js fxForm`) where it drops other events — the runner's catch-up frames
|
||||
and its hidden-tab backlog (`battle/runner.js keepsState`), the game screen's events buffered before a field is entered
|
||||
(`screens/game.js`); dropping them was report #5's look again after a stall or a background tab.
|
||||
|
||||
**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
|
||||
recorded for the player whose half contains the goal it reached, with `sourcePlayerId` preserved for unite LP.
|
||||
@@ -406,7 +432,7 @@ it, so the two players of a pair field compete for one instance instead of multi
|
||||
| `tremble` (战栗) | 被阻挡后无法进行普通攻击: no normal attack **while blocked** (abilities still fire) | – |
|
||||
| `palsy` (麻痹) | each stack cancels one enemy normal attack (max 3, lasts until consumed); refused by 麻痹免疫 (data `palsyImmune`) | stacks, default 1 |
|
||||
| `disarm` | no normal attacks | – |
|
||||
| `stealth` / `reveal` | 隐匿: untargetable unless blocked (an ally: only the enemy it blocks attacks it) / cancels stealth | – |
|
||||
| `stealth` / `reveal` | 隐匿: untargetable unless blocked (an ally: only the enemy it blocks attacks it) / cancels stealth. An enemy's b.snap stealth bit is set only while its 隐匿 is on (not blocked, not revealed: drawn solid otherwise); radius splash (`enemiesInRadius`) ignores it [known deviation, PRTS 作战机制 §AOE伤害判定] | – |
|
||||
| `camou` (迷彩) | an ally's camouflage (ba.camou "不阻挡时不成为敌方普通攻击的目标"): like `stealth` for enemy targeting (only the enemy it blocks attacks it) and on screen (b.snap stealth bit, `snapshot.js flagsOf`), but not 隐匿 for 隐匿-conditions (叙拉古, 家族徽章) and under its own buff keys. 忍冬 S3 (key `vulpis:camou`, until her next cast), 寒芒克洛丝 S1 | – |
|
||||
| `invulnerable` | ignores damage | – |
|
||||
| `levitate` (浮空) | stun + unblockable (unblocks enemies) + 失衡免疫 (`noDisplace`); an air unit meanwhile (`isFlying`: melee cannot hit it); **half duration on units with (current) massLevel > 3**; refused on data flyers (`motion` FLY) and units already levitated (PRTS 异常效果 "若单位数据上为飞行单位…或是持有浮空异常则Buff取消") — a 近地悬浮 enemy is WALK in its data, so it can be levitated; 浮空 is not one of the 近地悬浮 enemies' drop triggers | – |
|
||||
@@ -955,12 +981,13 @@ Unknown subprofessions fall back to the profession default (test `professions.te
|
||||
renderer flies it back to the thrower at `BOOMERANG_RETURN_SPEED`; an enemy's `profile.shot` may name another kind,
|
||||
e.g. `mortar` for 帝国炮火先兆者, which the renderer does not draw — its fx `bombardShell` is the shell), `['dmg', tgt, amount, type]` (`phys|arts|true|burn|neural|necrosis|apoptosis`),
|
||||
`['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;
|
||||
`extra.form` = the enemy's model form from then on, `content/enemies.js setForm` / `shared/protocol.js fxForm`),
|
||||
`['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);
|
||||
- flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 (an enemy's only while not blocked / revealed) or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512);
|
||||
anim: ANIM codes (idle 0, move 1, attack 2, skill 3, die 4, stun 5, deploy 6).
|
||||
|
||||
---
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
// and b.result (compactResult) at the end; applies b.pool (LocalBossPool.sync) and b.end (forceEnd / takeover),
|
||||
// * keeps an authoritative battle running while the tab is hidden (a 250 ms interval pump; browsers throttle it to
|
||||
// ~1 Hz, the bounded fast-forward absorbs that); the server deadline + takeover cover anything worse.
|
||||
// A frame that fast-forwards (catch-up) passes on only the state-bearing events (keepsState: spawns, deaths, deploys,
|
||||
// statuses, skills, leaks and the fx that change an enemy's model form — shared/protocol.js fxForm); while the tab is
|
||||
// hidden the battle on screen keeps the same events (≤ HELD_MAX, else the view re-enters from the field meta) and the
|
||||
// first frame back delivers them, so the view still knows every unit and every enemy's current form (player report #5
|
||||
// after 0.1.0: a 转译基底·α that changed form during a stall or in a background tab died in its first-form model).
|
||||
// Display replicas (a teammate's field after the own battle, 联防 observers, the partner of a boss pair) run the same
|
||||
// spec fast-forwarded to the server's clock (`elapsed`) and never report.
|
||||
// A b.result lost with the socket (the request failed DISCONNECTED / OFFLINE, or timed out twice) is kept and sent
|
||||
@@ -53,7 +58,7 @@
|
||||
|
||||
import { net as appNet } from '../net.js';
|
||||
import { store as appStore } from '../store.js';
|
||||
import { unitStatsEntry } from '../../../shared/protocol.js';
|
||||
import { unitStatsEntry, fxForm } from '../../../shared/protocol.js';
|
||||
|
||||
const TICK = 1 / 30;
|
||||
/** Fast-forward budget per frame (ticks) when far behind. */
|
||||
@@ -62,6 +67,13 @@ export const CATCHUP_TICKS = 240;
|
||||
const PREPARE_SLICE = 600;
|
||||
const MAX_ENTRIES = 4;
|
||||
const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill', 'leak']);
|
||||
/**
|
||||
* The b.ev tuples a catch-up frame or the hidden-tab backlog keeps: the state-bearing kinds, and every fx that sets an
|
||||
* enemy's model form (shared/protocol.js fxForm — dropped, the view kept the old model: player report #5 after 0.1.0).
|
||||
*/
|
||||
export const keepsState = (x) => Array.isArray(x) && (STATE_EV.has(x[0]) || fxForm(x) !== undefined);
|
||||
/** Hidden-tab backlog cap (tuples) of the battle on screen; beyond it the view re-enters from the field meta. */
|
||||
export const HELD_MAX = 3000;
|
||||
/** Data files the simulation reads (DataSource + content/support gameData()). */
|
||||
export const SIM_DATA_FILES = Object.freeze(['chess', 'enemies', 'tokens', 'stages', 'waves', 'bonds', 'items', 'garrisons', 'bands', 'effects']);
|
||||
|
||||
@@ -304,16 +316,33 @@ export function createBattleRunner(deps) {
|
||||
}
|
||||
|
||||
function emitFrame(e, catchingUp) {
|
||||
// the hidden-tab backlog overflowed: the view starts again from the field meta (UnitInfo carries every unit's form)
|
||||
if (e.stale) { show(e); return; }
|
||||
let ev = [];
|
||||
try { ev = e.battle.drainEvents() || []; } catch { ev = []; }
|
||||
const gt = Number(e.battle.time) || 0;
|
||||
if (ev.length) {
|
||||
const list = catchingUp ? ev.filter((x) => Array.isArray(x) && STATE_EV.has(x[0])) : ev;
|
||||
const held = e.held;
|
||||
if (held.length) e.held = [];
|
||||
if (ev.length || held.length) {
|
||||
const list = held.concat(catchingUp ? ev.filter(keepsState) : ev);
|
||||
if (list.length) emit('ev', { t: 'b.ev', fieldId: e.fieldId, gt, ev: list });
|
||||
}
|
||||
try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Events of a step that is not rendered (hidden tab): the battle on screen keeps its state-bearing ones for the next
|
||||
* rendered frame (keepsState, ≤ HELD_MAX — beyond that it is re-entered from the field meta); any other battle starts
|
||||
* from its field meta when shown (show()), so its events go.
|
||||
*/
|
||||
function hold(e) {
|
||||
let ev = [];
|
||||
try { ev = e.battle.drainEvents() || []; } catch { ev = []; }
|
||||
if (e !== cur || e.stale) return;
|
||||
for (const x of ev) if (keepsState(x)) e.held.push(x);
|
||||
if (e.held.length > HELD_MAX) { e.held = []; e.stale = true; }
|
||||
}
|
||||
|
||||
function progress(e, force = false) {
|
||||
if (!e.authoritative || e.resultSent || !net) return;
|
||||
const t = now();
|
||||
@@ -420,7 +449,7 @@ export function createBattleRunner(deps) {
|
||||
const dt = stepEntry(e, n);
|
||||
if (dt > stats.maxFrameMs) stats.maxFrameMs = dt;
|
||||
if (render) emitFrame(e, catchingUp);
|
||||
else { try { e.battle.drainEvents(); } catch { /* ignore */ } }
|
||||
else hold(e);
|
||||
noteLeaks(e);
|
||||
progress(e);
|
||||
if (e.battle.finished) finished(e);
|
||||
@@ -456,6 +485,8 @@ export function createBattleRunner(deps) {
|
||||
/** Put an entry on screen: field meta into the store (the game screen enters it), then its current frame. */
|
||||
function show(e) {
|
||||
cur = e;
|
||||
e.held = [];
|
||||
e.stale = false;
|
||||
let meta = null;
|
||||
try { meta = e.battle.fieldMeta(); } catch { meta = { units: [] }; }
|
||||
const field = {
|
||||
@@ -538,6 +569,9 @@ export function createBattleRunner(deps) {
|
||||
leaks: 0, leakMark: '', left: null,
|
||||
// live bond layers grown in this battle { [playerId]: { [bondId]: n } } and their total gain (noteLayers)
|
||||
live: null, layerSum: 0,
|
||||
// state-bearing events of the steps run while the tab was hidden (hold(); delivered by the next rendered frame),
|
||||
// and whether that backlog overflowed (the next frame re-enters the view from the field meta)
|
||||
held: [], stale: false,
|
||||
};
|
||||
if (lastPool && battle.sharedBoss && typeof battle.sharedBoss.sync === 'function') {
|
||||
battle.sharedBoss.sync(lastPool.hp, lastPool.acked ? lastPool.acked[e.fieldId] : undefined);
|
||||
|
||||
+48
-25
@@ -12,15 +12,19 @@
|
||||
// new board piece flashes (fx.deploy); a merge's elite — on the tile of
|
||||
// the deployed copy it replaced, or on its bench slot — gets the
|
||||
// promotion cue instead (render/promote.js, fx.promote)
|
||||
// view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] }
|
||||
// view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] } — each
|
||||
// unit through renderInfo(UnitInfo) (an enemy's `form`: a view built
|
||||
// mid-battle starts in the current model form)
|
||||
// view.pushSnapshot(snap); view.pushEvents(ev | { ev, gt }) b.snap / b.ev wire frames as received (game time in
|
||||
// `gt`; a numeric `t` is accepted for raw Battle snapshots / recordings)
|
||||
// — 100 ms interpolation buffer; b.snap `down` keeps knocked-out
|
||||
// operators on the field under a redeploy ring and `elem` draws the
|
||||
// element gauges (user playtest #4 items 8 / 9, render/units.js); a
|
||||
// 'die' with reason FORCED_EXIT (an operator entering 联防 knocked out,
|
||||
// user playtest #5 item 2) goes straight to the held pose, no burst
|
||||
// view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat):
|
||||
// user playtest #5 item 2) goes straight to the held pose, no burst; an
|
||||
// fx with a `form` (shared/protocol.js fxForm) switches the enemy's
|
||||
// model to that clip set (render/units.js FORMS)
|
||||
// view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat):
|
||||
// ~2-frame buffer at the battle's game speed
|
||||
// view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'|
|
||||
// 'hover'|'target'|{color,fill,line,group}; highlightTiles(null) clears all
|
||||
@@ -87,10 +91,11 @@
|
||||
// `data` is the client data store (public/js/data.js: lookup(file, id)) or plain { chess, tokens, items, enemies } maps.
|
||||
|
||||
import { GEO, ANIM, UF } from '../../../shared/constants.js';
|
||||
import { fxForm } from '../../../shared/protocol.js';
|
||||
import { Camera, presetCamera, lerpCamera, easeInOutCubic, pickTile, normRect } from './projection.js';
|
||||
import { SnapshotBuffer, frameTime } from './interp.js';
|
||||
import { TileField } from './tiles.js';
|
||||
import { UnitView, ItemView, DeviceView } from './units.js';
|
||||
import { UnitView, ItemView, DeviceView, FORMS } from './units.js';
|
||||
import { FxSystem, ensureDamageFonts } from './fx.js';
|
||||
import { createDragController, pieceTile } from './drag.js';
|
||||
import { backdropTextures, shadowTexture, refreshTierChips, silhouetteTexture } from './textures.js';
|
||||
@@ -243,6 +248,32 @@ export const FORCED_EXIT = 'forcedExit';
|
||||
*/
|
||||
export const showsDeathFx = (info, consumed = false, reason = null) => !consumed && reason !== FORCED_EXIT && info?.kind !== 'device';
|
||||
|
||||
/**
|
||||
* The views' info of a battle unit from its UnitInfo (m.field / fieldMeta `units`, a 'spawn' event; snapshot.js
|
||||
* unitInfo), sanitised; null for a malformed entry. `form` — an enemy's current model form (content/enemies.js setForm:
|
||||
* 转译基底·α's forms, a 逐火 余烬, a leader after its 重生, 掠海漂移体's crawl) — makes a view built mid-battle (a teammate's
|
||||
* field watched later, 联防 observers, a reconnect, server-run watchers: no fx of the change is replayed) start on that
|
||||
* clip set (render/units.js FORMS); it used to be dropped here, so such views drew the first form (player report #5).
|
||||
*/
|
||||
export function renderInfo(u) {
|
||||
if (!u || typeof u !== 'object' || (typeof u.id !== 'number' && typeof u.id !== 'string')) return null;
|
||||
return {
|
||||
id: u.id, uid: u.uid ?? null, kind: u.kind || 'enemy', side: u.side === 'ally' ? 'ally' : 'enemy', ownerId: u.ownerId ?? null,
|
||||
defId: u.defId ?? null, name: u.name ?? '', tier: u.tier ?? 1, golden: !!u.golden, spine: u.spine ?? u.defId ?? null,
|
||||
avatar: u.avatar ?? u.defId ?? null, x: Number(u.x) || 0, y: Number(u.y) || 0, facing: u.facing === -1 ? -1 : 1,
|
||||
maxHp: Number(u.maxHp) || 1, boss: !!u.boss, motion: u.motion,
|
||||
// deploy direction of allies (UnitInfo.dir, DESIGN §3): the model (Back for UP, mirrored for LEFT) and the
|
||||
// ground wedge follow it; absent = unknown (legacy frames) → derived from `facing`, no wedge
|
||||
dir: typeof u.dir === 'string' ? u.dir : undefined,
|
||||
// an enemy's current model form (UnitInfo.form): the view starts in it (UnitView reads info.form)
|
||||
form: typeof u.form === 'string' ? u.form : undefined,
|
||||
// DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a
|
||||
// tap hands them to the detail card (a teammate's unit shows its owner's skill / module)
|
||||
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined,
|
||||
moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** '2d' | '3d' | 'auto' board preference: `?board=` in the page URL (dev), else the view option. */
|
||||
export function boardPreference(opt) {
|
||||
let q = null;
|
||||
@@ -1283,21 +1314,8 @@ export async function createFieldView(host, options = {}) {
|
||||
}
|
||||
|
||||
function addInfo(u) {
|
||||
if (!u || typeof u !== 'object' || (typeof u.id !== 'number' && typeof u.id !== 'string')) return null;
|
||||
const info = {
|
||||
id: u.id, uid: u.uid ?? null, kind: u.kind || 'enemy', side: u.side === 'ally' ? 'ally' : 'enemy', ownerId: u.ownerId ?? null,
|
||||
defId: u.defId ?? null, name: u.name ?? '', tier: u.tier ?? 1, golden: !!u.golden, spine: u.spine ?? u.defId ?? null,
|
||||
avatar: u.avatar ?? u.defId ?? null, x: Number(u.x) || 0, y: Number(u.y) || 0, facing: u.facing === -1 ? -1 : 1,
|
||||
maxHp: Number(u.maxHp) || 1, boss: !!u.boss, motion: u.motion,
|
||||
// deploy direction of allies (UnitInfo.dir, DESIGN §3): the model (Back for UP, mirrored for LEFT) and the
|
||||
// ground wedge follow it; absent = unknown (legacy frames) → derived from `facing`, no wedge
|
||||
dir: typeof u.dir === 'string' ? u.dir : undefined,
|
||||
// DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a
|
||||
// tap hands them to the detail card (a teammate's unit shows its owner's skill / module)
|
||||
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined,
|
||||
moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined,
|
||||
};
|
||||
infos.set(info.id, info);
|
||||
const info = renderInfo(u);
|
||||
if (info) infos.set(info.id, info);
|
||||
return info;
|
||||
}
|
||||
|
||||
@@ -1415,20 +1433,25 @@ export async function createFieldView(host, options = {}) {
|
||||
break;
|
||||
}
|
||||
case 'status': { const v = views.get(e[1]); if (v) v.onStatus?.(e[2], !!e[3]); break; }
|
||||
case 'fx':
|
||||
case 'fx': {
|
||||
if (e[4] && typeof e[4] === 'object' && e[4].consumed && e[4].id != null) {
|
||||
consumedIds.add(e[4].id);
|
||||
if (consumedIds.size > 200) consumedIds.delete(consumedIds.values().next().value);
|
||||
}
|
||||
// an enemy's mode change (掠海漂移体 → 爬行模式, user playtest #5 item 1): its view switches clip set
|
||||
// (UnitView.setForm); the info keeps it for a view built later
|
||||
if (e[1] === 'phase' && e[4] && typeof e[4] === 'object' && e[4].id != null) {
|
||||
// an enemy's mode change — the `form` of a sim setForm fx (shared/protocol.js fxForm: 掠海漂移体 → 爬行模式, user
|
||||
// playtest #5 item 1; 转译基底's forms, a 逐火 ember and its revival, the leaders' 重生, 守墓石像 — user report after
|
||||
// 0.1.0) — switches the view's clip set (UnitView.setForm; a kind without a clip set of that skeleton changes
|
||||
// nothing); the info keeps it for a view built later. The client runner never drops these fx (catch-up frames,
|
||||
// hidden-tab backlog), nor does the game screen's pre-entry buffer.
|
||||
const form = fxForm(e);
|
||||
if (form !== undefined) {
|
||||
const inf = infos.get(e[4].id);
|
||||
if (inf) inf.form = typeof e[4].kind === 'string' ? e[4].kind : null;
|
||||
views.get(e[4].id)?.setForm?.(e[4].kind);
|
||||
if (inf && (form === null || FORMS[inf.spine || inf.defId]?.[form])) inf.form = form;
|
||||
views.get(e[4].id)?.setForm?.(form, e[4]);
|
||||
}
|
||||
fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]);
|
||||
break;
|
||||
}
|
||||
case 'layer': {
|
||||
const bondId = e[2], n = Number(e[3]) || 0;
|
||||
if (!(n > 0) || typeof bondId !== 'string') break;
|
||||
|
||||
+37
-13
@@ -12,7 +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
|
||||
// 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.
|
||||
|
||||
@@ -80,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;
|
||||
@@ -118,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); }
|
||||
|
||||
@@ -212,7 +230,7 @@ export class SpineActor {
|
||||
* false when it is still too early (call again next frame) or there is nothing to wind up.
|
||||
*/
|
||||
windUp(interval, lead) {
|
||||
if (this.dead || this.mode === 'stun' || this.mode === 'die' || !(lead >= 0)) return false;
|
||||
if (this.dead || this.mode === 'stun' || this.mode === 'die' || this.mode === 'change' || !(lead >= 0)) return false;
|
||||
const clip = this._attackClip();
|
||||
if (!clip) return false;
|
||||
if (this.mode === 'attack' && this.current === clip.loop) return false; // in rhythm: attack() re-phases
|
||||
@@ -232,7 +250,7 @@ export class SpineActor {
|
||||
|
||||
/** An attack happened now. `interval` = seconds between attacks (game time already scaled to real). */
|
||||
attack(interval) {
|
||||
if (this.dead || this.mode === 'stun' || this.mode === 'die') return;
|
||||
if (this.dead || this.mode === 'stun' || this.mode === 'die' || this.mode === 'change') return; // a form change plays out
|
||||
this.interval = clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
|
||||
const clip = this._attackClip();
|
||||
if (!clip) return;
|
||||
@@ -338,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;
|
||||
|
||||
+77
-18
@@ -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
|
||||
// (`info.form` = UnitInfo `form`, the sim's current form, through render/app.js renderInfo) starts in the mode.
|
||||
// Element gauges (b.snap `elem` → sample `el` / `elFill` / `elUntil` / `elDur`), the official form (PRTS 元素: "模型
|
||||
// 下部会显示对应的元素图标,并以白条显示剩余的元素值"; 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
|
||||
@@ -100,22 +102,72 @@ export const DOWN_LOOK = Object.freeze({
|
||||
export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, gap: 1, pulse: 0.45, pulseHz: 1.5 });
|
||||
|
||||
/**
|
||||
* Enemy modes drawn with another clip set of the same skeleton (sim fx 'phase' kind → UnitView.setForm), per Spine
|
||||
* id: 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change'. The mode's
|
||||
* roles override the manifest's (data/assets.json anims); 吉兆飞鳞's 晕眩模式 is its Stun clip already.
|
||||
* Enemy modes drawn with another clip set of the same skeleton (sim fx 'phase' kind, or the `form` of another sim fx
|
||||
* → UnitView.setForm), per Spine id; the mode's roles override the manifest's (data/assets.json anims), `change` plays
|
||||
* once first:
|
||||
* - 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change';
|
||||
* - 转译基底·α (user report after 0.1.0, #5): its 2 s change clip A_Die_B / _C / _D, then 寻仇者 B_*, 幽灵 C_* (no attack
|
||||
* clip: it never attacks) or 特战术师 D_* — its manifest roles are the original form's A_Idle / A_Move;
|
||||
* - 深池逐火战士 / 精锐战士 / 护卫 (#8): knocked out → 'Die' (the 1 s 重生), the 余烬 on Idle_2 / Move_2 and its death on
|
||||
* Die_2 (also while 'Revive' plays: the ember can still be beaten); 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). 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.
|
||||
*/
|
||||
const loop = (name, via = null) => Object.freeze(via ? { begin: null, loop: name, end: null, via } : { begin: null, loop: name, end: null });
|
||||
const clipSet = (idle, move, die, attack = null) => Object.freeze({
|
||||
idle, deploy: idle, die, move: loop(move),
|
||||
attack: attack ? loop(attack, 'attackAny') : null,
|
||||
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', 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`, 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, 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 }) }),
|
||||
fly: Object.freeze({ change: null, roles: clipSet('Idle_2', 'Move_2', 'Die_2', 'Attack_2') }),
|
||||
});
|
||||
const JAKILL2 = clipSet('C2_Idle', 'C2_Move', 'C2_Die', 'C2_Attack');
|
||||
export const FORMS = Object.freeze({
|
||||
enemy_2025_syufo: Object.freeze({
|
||||
crawl: Object.freeze({
|
||||
change: 'Change',
|
||||
roles: Object.freeze({
|
||||
idle: 'Idle_02', deploy: 'Idle_02', die: 'Die_02',
|
||||
move: Object.freeze({ begin: null, loop: 'Move_02', end: null }),
|
||||
attack: Object.freeze({ begin: null, loop: 'Attack_02', end: null, via: 'attackAny' }),
|
||||
skill: Object.freeze({ begin: null, loop: 'Attack_02', end: null, via: 'attack', index: 0, idle: null }),
|
||||
}),
|
||||
}),
|
||||
crawl: Object.freeze({ change: 'Change', roles: clipSet('Idle_02', 'Move_02', 'Die_02', 'Attack_02') }),
|
||||
}),
|
||||
enemy_10081_mpplai: Object.freeze({
|
||||
translator_fuchou: Object.freeze({ change: 'A_Die_B', roles: clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack') }),
|
||||
translator_youling: Object.freeze({ change: 'A_Die_C', roles: clipSet('C_Idle', 'C_Move', 'C_Die') }),
|
||||
translator_shushi: Object.freeze({ change: 'A_Die_D', roles: clipSet('D_Idle', 'D_Move', 'D_Die', 'D_Attack') }),
|
||||
}),
|
||||
enemy_1288_duskls: EMBER,
|
||||
enemy_1288_duskls_2: EMBER,
|
||||
enemy_1292_duskld: EMBER,
|
||||
enemy_9010_acpupp: 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')),
|
||||
enemy_1539_reid: rebirth('Revive_Begin', 'Revive_Loop', 'Revive_End'),
|
||||
enemy_1516_jakill: Object.freeze({
|
||||
reborn: Object.freeze({ change: 'C1_Die', roles: JAKILL2 }),
|
||||
form2: Object.freeze({ change: null, roles: JAKILL2 }),
|
||||
}),
|
||||
enemy_1172_dugago: STATUE,
|
||||
enemy_1172_dugago_2: STATUE,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -342,18 +394,25 @@ export class UnitView {
|
||||
}
|
||||
|
||||
/**
|
||||
* The unit changed mode (sim fx 'phase' { id, kind }): the mode's clip set (FORMS) after its change clip; a kind with
|
||||
* no clip set of its own goes back to the manifest clips. Kept for a model built later.
|
||||
* The unit changed mode (the `form` of a sim fx — shared/protocol.js fxForm; `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 — the unit is still in this mode while it plays (an ember can be beaten in its last second),
|
||||
* so this mode's death clip stays until the next mode's fx; 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;
|
||||
const had = !!this._formSpec();
|
||||
this.form = k;
|
||||
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);
|
||||
let next = f && f.next ? FORMS[this.info.spine || this.info.defId]?.[f.next]?.roles || null : null;
|
||||
if (next && typeof f.roles.die === 'string') next = { ...next, die: f.roles.die };
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { PHASE, GEO } from '../../../shared/constants.js';
|
||||
import { fxForm } from '../../../shared/protocol.js';
|
||||
import { html, Spinner, PhaseBanner, Icon, Button, MicroLabel, confirmDialog, useTicker } from '../ui/components.js';
|
||||
import { useGameData, GIcon } from '../ui/gameComponents.js';
|
||||
import { useFieldView } from '../ui/fieldHost.js';
|
||||
@@ -109,6 +110,9 @@ const SEL_RANGE = Object.freeze({ group: 'selRange', color: 0xff9c33, fill: 0.3,
|
||||
/** The tile an armed merge-completing card's elite will take (its own group; gold like the promotion cue, render/fx.js). */
|
||||
const MERGE_HL = Object.freeze({ group: 'mergeTile', color: 0xffd45a, fill: 0.34, line: 1 });
|
||||
const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill']);
|
||||
/** Event tuples replayed when a field is entered late: the state-bearing kinds and the fx that change an enemy's model
|
||||
* form (shared/protocol.js fxForm — the field meta's UnitInfo `form` predates them). */
|
||||
const keepEarly = (e) => Array.isArray(e) && (STATE_EV.has(e[0]) || fxForm(e) !== undefined);
|
||||
|
||||
/** Router for the in-match screens. */
|
||||
export function GameScreen() {
|
||||
@@ -446,7 +450,7 @@ function MatchScreen() {
|
||||
if (typeof msg.fieldId !== 'string') return;
|
||||
const buf = evBufRef.current.get(msg.fieldId) || [];
|
||||
// only state-bearing tuples are replayed later (see the enter effect)
|
||||
for (const e of msg.ev) if (Array.isArray(e) && STATE_EV.has(e[0])) buf.push(e);
|
||||
for (const e of msg.ev) if (keepEarly(e)) buf.push(e);
|
||||
if (buf.length > 1500) buf.splice(0, buf.length - 1500);
|
||||
evBufRef.current.set(msg.fieldId, buf);
|
||||
if (evBufRef.current.size > 8) evBufRef.current.delete(evBufRef.current.keys().next().value);
|
||||
|
||||
@@ -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];
|
||||
|
||||
+7
-5
@@ -17,7 +17,8 @@
|
||||
// user playtest #6: "阻挡了就一定要能打到").
|
||||
// Unblocked ranged enemies attack allies within their radius and pause ATTACK_PAUSE seconds after each attack; the
|
||||
// candidates pass the enemy's own rule (`e.profile.canTarget`) and are ordered blocker → taunt → latest deployed
|
||||
// (targeting.js sortAllyTargets). Reaching the final leg's end = leak. A `fear` (恐惧) status suspends the route: the
|
||||
// (targeting.js sortAllyTargets). An enemy's damage type is its data's unless content arms it (`e.profile.dmgType`:
|
||||
// 转译基底's forms, whose data never attacks). Reaching the final leg's end = leak. A `fear` (恐惧) status suspends the route: the
|
||||
// enemy runs between random checkpoints away from the fear's source (fear.js moveFeared; a self-inflicted fear
|
||||
// flutters inside its own tile); an `attract` (诱导) status walks it to the status point instead (moveAttracted);
|
||||
// both re-plan the route when released (恐惧 outranks 诱导).
|
||||
@@ -456,7 +457,7 @@ export function updateEnemy(b, e, dt) {
|
||||
}
|
||||
if (!e.hidden && b._checkBlock(e)) return;
|
||||
if (b.time < e.pauseUntil) return;
|
||||
if (e.s.flags.noMove) return;
|
||||
if (e.s.flags.noMove) { e.moving = false; return; } // standing (a 重生, a form change): drawn idle, not walking
|
||||
// 恐惧 (ba.fear "无法被阻挡并四散逃跑"; PRTS 诱发移动: 恐惧 outranks 诱导): runs to random tiles of the fan away from
|
||||
// its source — a self-inflicted fear flutters inside its own tile (fear.js); the route re-plans once it ends
|
||||
if (e.s.flags.fear && !e.hidden) { moveFeared(b, e, dt); return; }
|
||||
@@ -570,11 +571,12 @@ function advanceRoute(b, e, dt, R) {
|
||||
function enemyAttack(b, e) {
|
||||
const def = e.def;
|
||||
if (e.profile && e.profile.noAttack) return;
|
||||
if (def.dmgType === 'none' || e.s.atk <= 0) return;
|
||||
const dmgType = (e.profile && e.profile.dmgType) || def.dmgType;
|
||||
if (dmgType === 'none' || e.s.atk <= 0) return;
|
||||
if (e.s.flags.fear || e.s.flags.disarm) return;
|
||||
if (e.s.flags.tremble && e.blockedBy) return; // 战栗: 被阻挡后无法进行普通攻击
|
||||
if (e.atkCd > 0) return;
|
||||
if (def.dmgType === 'heal') { enemyHeal(b, e, e.base.rangeRadius); return; }
|
||||
if (dmgType === 'heal') { enemyHeal(b, e, e.base.rangeRadius); return; }
|
||||
// applyWay MELEE enemies only ever hit their blocker, even when their data carries a rangeRadius (粉碎攻坚手 2.5,
|
||||
// 宿主士兵 2.5, 深池伙友卫队 1.4 … — that radius belongs to their abilities/splash, handled by content).
|
||||
// Content may flip it with `e.profile.melee = false`.
|
||||
@@ -618,7 +620,7 @@ function enemyAttack(b, e) {
|
||||
e.lastAttackAt = b.time;
|
||||
e.stats.attacks++;
|
||||
const attackId = ++b._attackSeq;
|
||||
const type = def.dmgType === 'heal' ? 'arts' : def.dmgType;
|
||||
const type = dmgType === 'heal' ? 'arts' : dmgType;
|
||||
const rangedShot = radius > 0 && !(e.blockedBy && targets[0] === e.blockedBy && radius < 1);
|
||||
// content-resolved attacks (`e.profile.deferHit`: 帝国炮火先兆者's shell landing 3 s later): the attack itself happens
|
||||
// — the 'atk' event (kind `e.profile.shot`, drawn by the content's own fx), cooldown, pause, the 'attack' hook — and
|
||||
|
||||
+214
-96
@@ -24,7 +24,12 @@
|
||||
// rebirth of Reborn.duration s, untargetable and inert, then the new form); artsBarrier() — "吸收法术伤害的屏障"
|
||||
// (absorbs arts after RES); frontGuard() — "来自正面的伤害降低" (facing = walking direction or the bigger crowd);
|
||||
// unbalanced() — 失衡 detection (displacement beyond the enemy's own speed; engine displace() has no hook);
|
||||
// attackLoop() — normal attacks for data-unarmed enemies once a form arms them; float() — 近地悬浮 (an air unit that
|
||||
// husk() — "被击倒时暂时变为…,一段时间后重生" (Revive[Trigger]: every knock-out of the first form ⇒ 1 s 重生 ⇒ a walking
|
||||
// hit-count husk — 隐匿 逐火 embers, the unblockable 再生 puppet — until the real death or its revival); every 重生
|
||||
// (reborn / husk / statue) clears the enemy's statuses and the buffs allies gave it (rebirthCleanse, PRTS 特殊机制
|
||||
// §重生 "清空自身身上除白名单外所有Buff"); a data-unarmed
|
||||
// enemy armed by a form (转译基底) sets `e.profile` noAttack / melee / dmgType / maxTargets and attacks through the
|
||||
// engine (ai.js enemyAttack); float() — 近地悬浮 (an air unit that
|
||||
// keeps its ground path, Unit.isFlying; kitSyufo / kitParrot lose it when stunned). `e.profile.canTarget(ally)` = the
|
||||
// enemy's own target rule (只攻击地面单位 …), applied by the engine to the candidates before its priority order; a
|
||||
// special priority (优先攻击防御力最高的… / 生命上限最高的…) sorts by its key, ties by taunt then latest deployed
|
||||
@@ -40,7 +45,10 @@
|
||||
// gauge whose burst is the official one (termDescription ba.dt.erosion: "永久降低100点防御力并受到800点物理伤害").
|
||||
// DOT — damage zones (污染秽蚀: true damage, one tick per second however many cover a unit; 毒雾, 燃烧区域),
|
||||
// bleeding (removed by healing), pulsing auras.
|
||||
// INVISIBLE — permanent `stealth` flag (engine: untargetable unless blocked or revealed; AoE still hits).
|
||||
// INVISIBLE — permanent `stealth` flag (engine: untargetable unless blocked or revealed). Radius area damage
|
||||
// (Battle.enemiesInRadius: profession splash around a struck target, many skills' circles) still hits
|
||||
// an unblocked one — a known deviation: officially an AoE only judges the enemies it can select (PRTS
|
||||
// 作战机制 §AOE伤害判定; ba.invisible vs ba.camou); tile selectors (enemiesInKeys) already skip it.
|
||||
// REFLECTION — 折射 (ba.refraction "生效时,法术抗性+70"): RES +refracting.magic_resistance while NOT silenced
|
||||
// (the ability line is SILENCE-flagged: silencing turns it off); 镜膜 also gets max HP +100 % while on.
|
||||
// SPECIAL — mostly stats; prisoners, 穿刺手, 暴虐兵长, 镜卫, 动力装甲 … below.
|
||||
@@ -50,12 +58,16 @@
|
||||
// 封冻/供暖器, 赘生甲壳, 锁链, 血债账款, 晦明) 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} · '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'.
|
||||
// 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?} (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` is the model's clip set from then on (render/app.js → UnitView.setForm, units.js
|
||||
// FORMS); the client keeps every fx with a `form` through catch-ups and hidden tabs (shared/protocol.js fxForm).
|
||||
// Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss.
|
||||
|
||||
import { TICK, MOVE_SCALE, ELEMENT, ATTACK_PAUSE } from '../constants.js';
|
||||
import { TICK, MOVE_SCALE, ELEMENT } from '../constants.js';
|
||||
import { canTargetAlly, sortAllyTargets, aggroCmp } from '../targeting.js';
|
||||
import { mitigate } from '../damage.js';
|
||||
|
||||
@@ -69,6 +81,13 @@ 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 深池逐火战士 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 失衡免疫). */
|
||||
export const TRANSLATOR_CHANGE = 2;
|
||||
const TRANSLATOR_IMMUNE = Object.freeze(['stun', 'sleep', 'cold', 'freeze', 'levitate', 'fear']);
|
||||
/** Death-explosion radius when the enemy has no official radius [ASSUMED]. */
|
||||
const BOOM_RADIUS = 1.25;
|
||||
/** 萨卡兹枯朽战车 秽蚀轰击: radius of the 【污染秽蚀】 it leaves at its target (PRTS 萨卡兹枯朽战车 技能 "在目标位置生成半径1.7,
|
||||
@@ -78,8 +97,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%物理伤害"). */
|
||||
@@ -450,6 +469,19 @@ 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`) carrying it
|
||||
* as `form` (a 'phase' fx also as its `kind`). The `form` key marks the fx as state (shared/protocol.js fxForm): the
|
||||
* client never drops it, not in a catch-up frame nor while its tab is hidden. Barrier / charge 'phase' kinds are no
|
||||
* forms: they go through b.fx directly, without `form`.
|
||||
*/
|
||||
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, 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;
|
||||
@@ -683,12 +715,30 @@ function freeAllPrisoners(b) {
|
||||
for (const o of b.aliveEnemies()) for (const x of (o.mem.ab ? o.mem.ab.list : [])) if (x.liberate && !x.free) x.liberate(b, o, x);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重生 "清空自身身上除白名单外所有Buff" (PRTS 特殊机制 §重生): at a 重生 (reborn(), husk(), statue()) the enemy loses every
|
||||
* buff an operator / summon / device put on it and every source-less catalogue status (晕眩, 减速, 恐惧, 脆弱, 诱导 … —
|
||||
* buffs.js STATUS). Kept [ASSUMED: the whitelist]: its talents and traits (`persist` buffs, and what it or another enemy
|
||||
* gave it — 锏's 抵抗 is a self-applied status, enemy auras refresh every few tenths of a second anyway) and the field's
|
||||
* state buffs without a source (terrain, airflow — re-applied by position — and element burst locks). Without it a 逐火
|
||||
* knocked out while feared (叙拉古 / 妮芙: 恐惧 makes it unblockable) stayed unblockable — so, 隐匿, untargetable — as an
|
||||
* ember until the fear ran out.
|
||||
*/
|
||||
function rebirthCleanse(b, e) {
|
||||
for (const x of e.buffs.slice()) {
|
||||
if (x.persist || (x.source && x.source.side === 'enemy')) continue;
|
||||
if (x.source || x.status) b.removeBuff(e, x);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "首次被击倒后重生 / 进入第二形态": the first knock-out is hidden (kill credit and bounty wait for the real death) and starts
|
||||
* a rebirth of `dur` s — invulnerable, untargetable, released by its blocker, inert (no move / attack / skill).
|
||||
* a rebirth of `dur` s — statuses cleared (rebirthCleanse), invulnerable, untargetable, released by its blocker, inert
|
||||
* (no move / attack / skill).
|
||||
* `onKo(b, e, a)` runs at once (self-destruct, freeing prisoners …), `during(b, e, a, elapsed)` every tick of it; then the
|
||||
* enemy stands up with `hpRatio` of its max HP, `onReborn(b, e, a)` switches the form and `invincible` s of 无敌 follow.
|
||||
* `a.state`: undefined → 'reborn' → 'form2'.
|
||||
* `a.state`: undefined → 'reborn' → 'form2'. The 'telegraph' fx of the knock-out carries `form: 'reborn'` and the 'revive'
|
||||
* fx `form: 'form2'`: models with those clip sets (render/units.js FORMS — 锏, 扎罗, “复仇者”, 杰斯顿) play them.
|
||||
*/
|
||||
function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = null, onReborn = null, key = 'ab:reborn' } = {}) {
|
||||
const finish = (b, e, a) => {
|
||||
@@ -701,7 +751,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' });
|
||||
setForm(b, e, 'form2', 'revive', { kind: 'reborn' });
|
||||
};
|
||||
return {
|
||||
killed(c, b, e, a) {
|
||||
@@ -709,6 +759,7 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu
|
||||
a.state = 'reborn';
|
||||
a.t0 = b.time;
|
||||
a.noAtk = e.profile.noAttack;
|
||||
rebirthCleanse(b, e);
|
||||
e.hp = Math.min(1, e.s.maxHp);
|
||||
if (onKo) safe(b, e, () => onKo(b, e, a));
|
||||
if (!(dur > 0)) { finish(b, e, a); return true; }
|
||||
@@ -719,39 +770,12 @@ 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 });
|
||||
setForm(b, e, 'reborn', 'telegraph', { r: 1, dur, kind: 'reborn' });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A normal-attack loop for enemies whose data never attacks (dmgType none) once content arms them (转译基底 forms):
|
||||
* the blocker first, else (ranged) the engine priority inside `radius`; `hits` instances of ATK per attack.
|
||||
* Disabled until `a.on`.
|
||||
*/
|
||||
function attackLoop({ type = 'phys', melee = true, radius = 0, hits = 1 } = {}) {
|
||||
return {
|
||||
spawn(b, e, a) { a.cd = 0; a.on = false; },
|
||||
tick(b, e, a, dt) {
|
||||
if (!a.on || !e.alive || e.hidden) return;
|
||||
const f = e.s.flags;
|
||||
if (f.stun) return;
|
||||
if (a.cd > 0) { a.cd -= dt; if (a.cd > 1e-9) return; }
|
||||
if (f.disarm || f.fear || (f.tremble && e.blockedBy)) return;
|
||||
let t = e.blockedBy && e.blockedBy.alive ? e.blockedBy : null;
|
||||
if (!t && !melee && radius > 0) t = byPriority(e, targetsNear(b, e, radius))[0] ?? null;
|
||||
if (!t) return;
|
||||
a.cd = e.s.interval;
|
||||
e.lastAttackAt = b.time;
|
||||
e.stats.attacks++;
|
||||
if (!e.blockedBy) e.pauseUntil = b.time + ATTACK_PAUSE;
|
||||
if (!melee && t !== e.blockedBy) b.fx('beam', { x: e.x, y: e.y, from: e.id, to: t.id, kind: 'enemyShot' });
|
||||
for (let i = 0; i < hits && t.alive; i++) b.dealDamage(e, t, { amount: e.s.atk, type, isAttack: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 正面减伤: phys/arts damage from allies in front of the enemy (`a.facing` side) × (1 − cut); `face` updates a.facing. */
|
||||
function frontGuard(cut, face) {
|
||||
return {
|
||||
@@ -814,60 +838,93 @@ function artsBarrier(amount, { key = 'ab:artsBarrier', whileUp = null } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Revive as a stealthed 频次 husk (逐火 embers, 假想敌:再生). */
|
||||
function husk({ hits, delay, stealthy = true, onHusk = null, key = 'ab:ember' }) {
|
||||
/**
|
||||
* "被击倒时暂时变为…,一段时间后重生" (talent Revive[Trigger]: 逐火 embers, 假想敌:再生's puppet). A knock-out of the first form is
|
||||
* no kill (credit, bounty and the kill count wait for the real death): HUSK_REBIRTH s of 重生 first — statuses cleared
|
||||
* (rebirthCleanse: a feared warrior is no unblockable ember), invulnerable, untargetable, unblockable, immobile, 失衡免疫
|
||||
* (PRTS 深池逐火战士 天赋 "被击倒后重生,持续1s,随后变为怨恨的余烬,1s内不移动且持有无敌+无法阻挡+失衡免疫"; PRTS 特殊机制 §重生) —
|
||||
* then the husk until `delay` s after that: `hits` HP of 特殊生命值机制 (every damage instance removes 1 — PRTS 特殊机制
|
||||
* §特殊生命值机制; engine flag hitCount), no attack (缴械), walking its route on — 隐匿 (`stealthy`: targetable only while
|
||||
* blocked, targeting.js canTargetEnemy — the 余烬 must be blocked to be beaten; drawn solid while blocked)
|
||||
* and / or unblockable (`unblock`: 再生's 傀儡 "不可被阻挡"). Killing the husk is the real death; a husk still standing
|
||||
* 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 (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) => {
|
||||
if (!e.alive || a.state !== 'husk') return;
|
||||
a.state = null;
|
||||
hitCount(b, e, false);
|
||||
e.base.maxHp = a.max;
|
||||
e.markDirty();
|
||||
void e.s;
|
||||
e.hp = e.s.maxHp;
|
||||
e.profile.noAttack = a.noAtk;
|
||||
b.removeBuff(e, key);
|
||||
b.removeBuff(e, `${key}:reborn`);
|
||||
if (e.route) e.route.pts = null;
|
||||
e.atkCd = 0;
|
||||
setForm(b, e, 'revived', 'revive');
|
||||
};
|
||||
return {
|
||||
killed(c, b, e, a, ab) {
|
||||
if (a.state === 'husk' || !(hits > 0) || !(delay > 0)) return false;
|
||||
killed(c, b, e, a) {
|
||||
// an HP loss (流失 bypasses 无敌) during the 1 s 重生 cannot cut it short
|
||||
if (a.state === 'husk' && b.time < a.rebornUntil) { e.hp = e.s.maxHp; return true; }
|
||||
if (a.state === 'husk' || !(hits > 0) || !(delay > 0)) return false; // the husk's knock-out is the real death
|
||||
a.state = 'husk';
|
||||
a.rebornUntil = b.time + HUSK_REBIRTH;
|
||||
a.noAtk = e.profile.noAttack;
|
||||
a.max = a.max ?? e.base.maxHp;
|
||||
rebirthCleanse(b, e);
|
||||
e.profile.noAttack = true;
|
||||
e.lastAttackAt = -Infinity; // the snapshot shows no attack of the fallen warrior
|
||||
setHits(e, hits);
|
||||
hitCount(b, e, true);
|
||||
b.addBuff(e, { key, visible: true, flags: { noMove: true, unblockable: true, ...(stealthy ? { stealth: true } : {}) } });
|
||||
b.fx('ember', { x: e.x, y: e.y, id: e.id, hits, dur: delay });
|
||||
if (onHusk) onHusk(b, e);
|
||||
b.after(delay, () => {
|
||||
if (!e.alive || a.state !== 'husk') return;
|
||||
a.state = null;
|
||||
hitCount(b, e, false);
|
||||
e.base.maxHp = a.max;
|
||||
e.markDirty();
|
||||
void e.s;
|
||||
e.hp = e.s.maxHp;
|
||||
e.profile.noAttack = a.noAtk;
|
||||
b.removeBuff(e, key);
|
||||
if (e.route) e.route.pts = null;
|
||||
b.fx('revive', { x: e.x, y: e.y, id: e.id });
|
||||
}, { owner: e });
|
||||
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;
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 守墓石像: first knock-out → statue (DEF/RES up, immobile) → reborn as a flyer. */
|
||||
/**
|
||||
* 守墓石像 (PRTS 守墓石像 天赋): 地面模式 — melee attacks only while blocked; the first defeat is an instant 重生 (statuses
|
||||
* cleared: rebirthCleanse) to 100 % HP into 转换模式 for stone.duration s — unblockable, 自缚 (immobile), 失衡免疫, immune
|
||||
* to 浮空, DEF +stone.def, RES +stone.magic_resistance, no attack [ASSUMED: PRTS lists none] — then 飞行模式: a flyer
|
||||
* (失衡免疫) whose ranged attacks (the
|
||||
* data's 1.6 radius) deal arts damage and never target flyers; the HP is not refilled again. fx forms 'stone' / 'fly'
|
||||
* (its Sleep and *_2 clips, render/units.js FORMS). <破碎支柱> (an event device) has no counterpart in this mode.
|
||||
*/
|
||||
function statue(ab) {
|
||||
const dur = T(ab, 'stone.duration') ?? 0;
|
||||
return {
|
||||
killed(c, b, e, a) {
|
||||
spawn(b, e) { e.profile.melee = true; },
|
||||
killed(c, b, e, a, ab2) {
|
||||
if (a.done || !(dur > 0)) return false;
|
||||
a.done = true;
|
||||
a.noAtk = e.profile.noAttack;
|
||||
rebirthCleanse(b, e); // "进行重生。重生瞬间完成"
|
||||
e.profile.noAttack = true;
|
||||
e.hp = e.s.maxHp;
|
||||
b.fx('stone', { x: e.x, y: e.y, id: e.id, dur });
|
||||
ab2.immune = new Set([...(ab2.immune || []), 'levitate']);
|
||||
setForm(b, e, 'stone', 'stone', { dur });
|
||||
b.addBuff(e, {
|
||||
key: 'ab:stone', duration: dur, visible: true, flags: { noMove: 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 },
|
||||
onExpire: ({ battle }) => {
|
||||
if (!e.alive) return;
|
||||
e.motion = 'FLY';
|
||||
e.profile.noAttack = a.noAtk;
|
||||
e.hp = e.s.maxHp;
|
||||
battle.addBuff(e, { key: 'ab:liftoff', duration: 0.2, flags: { unblockable: true } }); // releases its blocker
|
||||
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' });
|
||||
setForm(battle, e, 'fly', 'revive', { kind: 'fly' });
|
||||
},
|
||||
});
|
||||
return true;
|
||||
@@ -953,6 +1010,9 @@ const kitPrisoner = (freeAll = false) => (ab) => [prisoner(ab, { freeAll })];
|
||||
const kitDeathSpawn = (extra = []) => (ab) => [deathSpawn(ab.tS['DeadSpawn.enemy_key'], T(ab, 'DeadSpawn.cnt') ?? 0), ...extra];
|
||||
const kitStun3 = (ab) => [nthAttackStatus(nthOf(ab.sk.stuncombat), 'stun', (ab.sk.stuncombat && ab.sk.stuncombat.bb.stun) || 0, true)];
|
||||
const kitSelfFear = (ab) => [selfFear(ab)];
|
||||
/** 深池逐火战士 / 精锐战士 / 护卫: knock-out ⇒ 1 s 重生 ⇒ a walking, 隐匿, disarmed 余烬 / 火灰 of prop_max_hp hits for `interval` s
|
||||
* (PRTS 深池逐火战士 天赋: "基础最大生命值临时变为5…具有特殊生命值机制,不进行攻击,获得隐匿、缴械,10s后若未被击倒则变回战士形态并恢复所有
|
||||
* 生命"); blocking it lifts the 隐匿, so its blocker (and every operator in range) can beat it. */
|
||||
const kitEmber = (ab) => [husk({ hits: T(ab, 'Revive[Trigger].prop_max_hp'), delay: T(ab, 'Revive[Trigger].interval') })];
|
||||
const kitPolluted = (ab) => [{
|
||||
death(c, b, e) {
|
||||
@@ -1083,7 +1143,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');
|
||||
},
|
||||
}];
|
||||
}
|
||||
@@ -1425,12 +1485,16 @@ function kitBlackCloud(ab, e) {
|
||||
];
|
||||
}
|
||||
|
||||
/** 假想敌:再生 (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, key: 'ab:regen',
|
||||
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); }
|
||||
},
|
||||
@@ -2053,32 +2117,86 @@ function kitWolfLord(ab) {
|
||||
})];
|
||||
}
|
||||
|
||||
/** 转译基底·α · never attacks at first; 4 physical hits ⇒ 复仇 form (melee, heavy), 4 arts hits ⇒ 术士 form (ranged arts),
|
||||
* blocked first ⇒ 幽灵 form (no stat change; unblockable [ASSUMED]). Stats from Mode_*_Passive. */
|
||||
/**
|
||||
* 转译基底·α (PRTS 转译基底·α 天赋; enemy_database talents Passive / Mode_*_Passive / Mode_Fuchou_Anger; skills ChangeToB/C/D):
|
||||
* 原始形态 — no attack, every damage instance is cancelled ("受到伤害时取消此伤害"), 失衡免疫, immune to 晕眩/沉睡/寒冷/冻结/浮空/
|
||||
* 恐惧. The 4th physical damage instance taken ⇒ 寻仇者, the 4th arts one ⇒ 特战术师, being blocked ⇒ 幽灵 — once ("仅可变化一次");
|
||||
* the change takes TRANSLATOR_CHANGE (2) s, standing still and still in the original form [ASSUMED: immobile, damage
|
||||
* still cancelled]; then the form's flat stat changes apply (move speed relative to the data's 1.0, attack interval
|
||||
* +N s, 重量等级 +N):
|
||||
* 寻仇者 (form B): melee only, physical; ATK +100 % while HP < 50 % (Mode_Fuchou_Anger.atk);
|
||||
* 特战术师 (form D): ranged only (the data's 2.4 radius), arts, 2 targets at once — flyers included [ASSUMED: the
|
||||
* translator's text "仅进行远程攻击,同时攻击2个目标,造成法术伤害" names no exception; the standalone PRTS 特战术师's talent
|
||||
* "不会攻击飞行单位" may carry over — then `canTarget: (u) => !u.isFlying` in finish()];
|
||||
* 幽灵 (form C): no stat change, unblockable, no attack (PRTS 转译基底·α "幽灵形态 无属性变化;无法被阻挡" — no attack line,
|
||||
* unlike the armed forms — and the linked PRTS 幽灵 天赋 "不进行普通攻击,无法被阻挡", 攻击方式 不攻击).
|
||||
* 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 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 幽灵 (skills ChangeToB / C / D).
|
||||
*/
|
||||
function kitTranslator(ab, e) {
|
||||
const t = (k) => T(ab, k) ?? 0;
|
||||
const P = { phys: 0, arts: 0, form: null };
|
||||
const mods = (pre) => ({ hpFlat: t(`${pre}.max_hp`), atkFlat: t(`${pre}.atk`), defFlat: t(`${pre}.def`), resFlat: t(`${pre}.magic_resistance`),
|
||||
moveMul: Math.max(0, 1 + t(`${pre}.move_speed`)), batPct: t(`${pre}.base_attack_time`) / (e.base.bat || 1) });
|
||||
const melee = attackLoop({ type: 'phys', melee: true });
|
||||
const caster = attackLoop({ type: 'arts', melee: false, radius: e.base.rangeRadius || (e.def.raw && e.def.raw.stats && e.def.raw.stats.rawRangeRadius) || 2.4 });
|
||||
const change = (b, e2, form) => {
|
||||
if (P.form) return;
|
||||
P.form = form;
|
||||
const pre = form === 'fuchou' ? 'Mode_Fuchou_Passive' : form === 'shushi' ? 'Mode_Shushi_Passive' : 'Mode_Youling_Passive';
|
||||
b.addBuff(e2, { key: 'ab:form', persist: true, visible: true, mods: mods(pre), flags: form === 'youling' ? { unblockable: true } : null });
|
||||
if (form === 'fuchou') melee.on = true;
|
||||
if (form === 'shushi') caster.on = true;
|
||||
b.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: `translator_${form}` });
|
||||
const P = { phys: 0, arts: 0, form: null, done: false, anger: false };
|
||||
const PRE = { fuchou: 'Mode_Fuchou_Passive', shushi: 'Mode_Shushi_Passive', youling: 'Mode_Youling_Passive' };
|
||||
const mods = (pre) => ({
|
||||
hpFlat: t(`${pre}.max_hp`), atkFlat: t(`${pre}.atk`), defFlat: t(`${pre}.def`), resFlat: t(`${pre}.magic_resistance`),
|
||||
moveMul: Math.max(0, 1 + t(`${pre}.move_speed`) / (e.def.moveSpeed || 1)), batPct: t(`${pre}.base_attack_time`) / (e.base.bat || 1),
|
||||
massFlat: t(`${pre}.mass_level`),
|
||||
});
|
||||
const finish = (b, e2) => {
|
||||
if (!e2.alive || P.done) return;
|
||||
P.done = true;
|
||||
b.removeBuff(e2, 'ab:origin');
|
||||
ab.immune = null;
|
||||
b.addBuff(e2, { key: 'ab:form', persist: true, visible: true, mods: mods(PRE[P.form]), flags: P.form === 'youling' ? { unblockable: true } : null });
|
||||
if (P.form !== 'youling') {
|
||||
const melee = P.form === 'fuchou';
|
||||
Object.assign(e2.profile, { noAttack: false, melee, dmgType: melee ? 'phys' : 'arts', maxTargets: melee ? 1 : 2 });
|
||||
e2.atkCd = 0;
|
||||
}
|
||||
if (e2.route) e2.route.pts = null;
|
||||
};
|
||||
return [melee, caster, {
|
||||
taken(c, b, e2) {
|
||||
const s = c.source;
|
||||
if (P.form || !s || s.side !== 'ally') return;
|
||||
if (c.dmg.type === 'phys' && ++P.phys >= (t('Passive.phy_max_count') || 4)) change(b, e2, 'fuchou');
|
||||
else if (c.dmg.type === 'arts' && ++P.arts >= (t('Passive.magic_max_count') || 4)) change(b, e2, 'shushi');
|
||||
const change = (b, e2, form) => {
|
||||
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) });
|
||||
setForm(b, e2, `translator_${form}`, 'phase', { dur: TRANSLATOR_CHANGE });
|
||||
};
|
||||
return [{
|
||||
spawn(b, e2) {
|
||||
b.addBuff(e2, { key: 'ab:origin', persist: true, flags: { noDisplace: true } });
|
||||
ab.immune = new Set(TRANSLATOR_IMMUNE);
|
||||
},
|
||||
hitIn(c, b, e2) {
|
||||
if (P.done) return;
|
||||
const s = c.source || c.credit, ty = c.dmg.type;
|
||||
if (!P.form && s && s.side === 'ally') {
|
||||
if (ty === 'phys' && ++P.phys >= (t('Passive.phy_max_count') || 4)) change(b, e2, 'fuchou');
|
||||
else if (ty === 'arts' && ++P.arts >= (t('Passive.magic_max_count') || 4)) change(b, e2, 'shushi');
|
||||
}
|
||||
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%"
|
||||
if (P.form !== 'fuchou' || !P.done) return;
|
||||
const on = e2.hpRatio < 0.5;
|
||||
if (on === P.anger) return;
|
||||
P.anger = on;
|
||||
if (on) b.addBuff(e2, { key: 'ab:anger', persist: true, visible: true, mods: { atkPct: t('Mode_Fuchou_Anger.atk') } });
|
||||
else b.removeBuff(e2, 'ab:anger');
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -2159,10 +2277,10 @@ export const KITS = Object.freeze({
|
||||
enemy_1209_sfden_2: kitInvisShield, // 堂皇 · same (铁灯盘)
|
||||
enemy_1203_sfhu: kitTeapot, // 烹泉 · death: 4 青瓷茶器 + ASPD-down arts blast zone
|
||||
enemy_1203_sfhu_2: kitTeapot, // 沏虹 · death: 4 彩瓷茶器 + blast zone
|
||||
enemy_1288_duskls: kitEmber, // 深池逐火战士 · on KO: stealthed 5-hit ember, revives 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_1292_duskld: kitEmber, // 深池逐火护卫 · 10-hit ember
|
||||
enemy_9010_acpupp: kitRegen, // 假想敌:再生 · 15-hit regen husk (15 s) + 5-hit shields on nearby enemies
|
||||
enemy_1292_duskld: kitEmber, // 深池逐火护卫 · 10-hit 火灰
|
||||
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
|
||||
@@ -2231,7 +2349,7 @@ export const KITS = Object.freeze({
|
||||
enemy_1170_dushld_2: kitRefraction, // 深池重甲卫士队长 · refraction
|
||||
enemy_1169_duphlx: kitPhalanx, // 深池方阵步兵 · refraction + DEF +200 per nearby same unit
|
||||
enemy_1169_duphlx_2: kitPhalanx, // 深池方阵指挥官 · same
|
||||
enemy_1172_dugago: (ab) => [...kitRefraction(ab), statue(ab)], // 守墓石像 · refraction; 1st KO → statue 10 s → flyer
|
||||
enemy_1172_dugago: (ab) => [...kitRefraction(ab), statue(ab)], // 守墓石像 · refraction; melee when blocked; 1st KO → unblockable statue 10 s → arts flyer
|
||||
enemy_1172_dugago_2: (ab) => [...kitRefraction(ab), statue(ab)], // 愤怒的守墓石像 · same
|
||||
enemy_1174_duholy: kitHolyGuard, // 深池伙友卫队 · refraction; ASPD-down field next to 影刃 (taunt from data)
|
||||
enemy_1174_duholy_2: kitHolyGuard, // 深池伙友卫队精英 · same
|
||||
@@ -2471,7 +2589,7 @@ export const KITS = Object.freeze({
|
||||
enemy_1517_xi: kitXi, // “自在” · cross arts, 破桎而出 barrier blast ⇒ form 2 (double hits, 2 crosses)
|
||||
enemy_1525_blkswb: kitMace, // 锏 · 抵抗, DEF pen, 速杀 blink, AoE ⇒ form 2 (stealth, double hits, SP clear)
|
||||
enemy_1535_wlfmster: kitWolfLord, // 扎罗 · −30 % damage, 溶血骇惧 ⇒ 远古威慑 rebirth ⇒ ranged double hits
|
||||
enemy_10081_mpplai: kitTranslator, // 转译基底·α · 4 phys / 4 arts hits or being blocked ⇒ one of three forms
|
||||
enemy_10081_mpplai: kitTranslator, // 转译基底·α · damage cancelled; 4th phys / 4th arts hit or blocked ⇒ 2 s change ⇒ 寻仇者 / 特战术师 / 幽灵
|
||||
enemy_10144_xdelk_2: kitElk, // 乌顶巨角卢鲁 · 角力对决 charge on its blocker (push fails ⇒ damage + stun)
|
||||
// 圆仔 · PRTS 天赋 "无法攻击/被阻挡;受到来源于正面的物理和法术伤害-80%;自身始终朝向我方干员数量最多的方向" (no attack: applyWay
|
||||
// NONE; its 倒走 / 【炫耀】 is "仅用于演出,无额外效果"): unblockable, faces the bigger crowd, front damage −80 %
|
||||
|
||||
+11
-4
@@ -1,9 +1,11 @@
|
||||
// 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).
|
||||
// flags bits & anim codes come from shared/constants.js (UF / ANIM); an enemy's stealth bit = its 隐匿 is on (not while it
|
||||
// is blocked or revealed), an ally's = 隐匿 / 迷彩 whatever it blocks.
|
||||
|
||||
import { UF, ANIM } from '../../shared/constants.js';
|
||||
import { DIE_ANIM_TIME, ATTACK_ANIM_TIME, DEPLOY_ANIM_TIME } from './constants.js';
|
||||
@@ -32,6 +34,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,
|
||||
@@ -48,8 +53,10 @@ export function flagsOf(u) {
|
||||
if (u.side === 'enemy' ? !!u.blockedBy : u.blocking.length > 0) bits |= UF.BLOCKED;
|
||||
if (f.stun && !f.freeze && !f.sleep) bits |= UF.STUNNED;
|
||||
if (f.freeze) bits |= UF.FROZEN;
|
||||
// 隐匿 or 迷彩 (buffs.js camou): both shown the see-through way, blocking or not (targeting.js canTargetAlly)
|
||||
if (f.stealth || f.camou) bits |= UF.STEALTH;
|
||||
// 隐匿 or 迷彩 (buffs.js camou), shown the see-through way. An ally keeps it while blocking (targeting.js
|
||||
// canTargetAlly); an enemy's 隐匿 is off while it is blocked or revealed (PRTS 作战机制 §隐匿 "在被阻挡时开关会被关掉从而
|
||||
// 失去隐匿"; targeting.js canTargetEnemy): a blocked 逐火 余烬 is drawn solid while the team beats it
|
||||
if (u.side === 'enemy' ? (f.stealth && !f.reveal && !u.blockedBy) || f.camou : f.stealth || f.camou) bits |= UF.STEALTH;
|
||||
if (u.skill && u.skill.active && u.skill.kind !== 'passive') bits |= UF.SKILL;
|
||||
if (u.s.shield > 0 || u.buffs.some((b) => b.shieldHits > 0)) bits |= UF.SHIELD;
|
||||
if (f.invulnerable) bits |= UF.INVULN;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -326,3 +326,17 @@ export const EV = Object.freeze({
|
||||
SPAWN: 'spawn', ATK: 'atk', DMG: 'dmg', HEAL: 'heal', SKILL: 'skill', DIE: 'die', LEAK: 'leak',
|
||||
STATUS: 'status', FX: 'fx', LAYER: 'layer', BOUNTY: 'bounty', DEPLOY: 'deploy',
|
||||
});
|
||||
|
||||
/**
|
||||
* The model form a `b.ev` 'fx' tuple ['fx', kind, x, y, extra] puts its unit in: `extra.form` (sim content/enemies.js
|
||||
* setForm — 转译基底·α's forms, a 逐火 余烬 and its revival, a leader's 重生, 守墓石像's modes, 掠海漂移体's crawl; a string is
|
||||
* that clip set, null the base one), undefined for any other tuple. A form is state, not decoration: a view that misses
|
||||
* the fx keeps drawing the old model (player report #5 after 0.1.0), so the client's catch-up frames, its hidden-tab
|
||||
* backlog (battle/runner.js) and the events buffered before a field is entered (screens/game.js) keep these tuples.
|
||||
*/
|
||||
export function fxForm(ev) {
|
||||
if (!Array.isArray(ev) || ev[0] !== EV.FX) return undefined;
|
||||
const x = ev[4];
|
||||
if (!x || typeof x !== 'object' || x.id == null || !Object.hasOwn(x, 'form')) return undefined;
|
||||
return typeof x.form === 'string' ? x.form : null;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.m
|
||||
const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8'));
|
||||
const { KITS, STATS_ONLY, EROSION, EROSION_BURST } = enemiesMod;
|
||||
const { BOSS_KITS, PART_TRANSFER, BLADE_TRANSFER, DRONE_LINK_BASE, droneLinkBase } = bossesMod;
|
||||
const { HUSK_REBIRTH, TRANSLATOR_CHANGE } = enemiesMod;
|
||||
|
||||
const BIG = [];
|
||||
for (let dr = -4; dr <= 4; dr++) for (let dc = -12; dc <= 12; dc++) BIG.push([dr, dc]);
|
||||
@@ -213,7 +214,7 @@ for (const key of ['enemy_1200_msfjin', 'enemy_1204_msfhu', 'enemy_1288_duskls']
|
||||
h.step();
|
||||
const e = put(h, key, [10, 7]);
|
||||
const g = h.unit('t_mage');
|
||||
if (/duskls/.test(key)) { killed(h, e, g); assert.ok(e.alive, 'ember'); }
|
||||
if (/duskls/.test(key)) { killed(h, e, g); assert.ok(e.alive, 'ember'); h.run(HUSK_REBIRTH + 0.05); }
|
||||
const hits = e.s.maxHp;
|
||||
assert.ok(hits > 1 && hits <= 30);
|
||||
let n = 0;
|
||||
@@ -307,7 +308,7 @@ for (const key of ['enemy_1203_sfhu', 'enemy_1203_sfhu_2']) {
|
||||
}
|
||||
|
||||
for (const key of ['enemy_1288_duskls', 'enemy_1288_duskls_2', 'enemy_1292_duskld']) {
|
||||
test(`${nm(key)}: knocked out ⇒ stealthed ember needing ${tb(key, 'Revive[Trigger].prop_max_hp')} hits; revives at full HP after ${tb(key, 'Revive[Trigger].interval')} s`, () => {
|
||||
test(`${nm(key)}: knocked out ⇒ ${HUSK_REBIRTH} s 重生, then a walking, disarmed, 隐匿 ember needing ${tb(key, 'Revive[Trigger].prop_max_hp')} hits; full HP again ${tb(key, 'Revive[Trigger].interval')} s later`, () => {
|
||||
const h = arena({ units: [{ chessId: 't_gun', row: 12, col: 3 }] });
|
||||
h.step();
|
||||
const e = put(h, key, [10, 7]);
|
||||
@@ -315,19 +316,23 @@ for (const key of ['enemy_1288_duskls', 'enemy_1288_duskls_2', 'enemy_1292_duskl
|
||||
killed(h, e, h.unit('t_gun'));
|
||||
assert.ok(e.alive);
|
||||
assert.equal(e.s.maxHp, tb(key, 'Revive[Trigger].prop_max_hp'));
|
||||
assert.ok(e.s.flags.stealth && e.profile.noAttack);
|
||||
h.run(tb(key, 'Revive[Trigger].interval') + 0.1);
|
||||
assert.ok(e.s.flags.invulnerable && e.s.flags.unblockable && e.s.flags.noMove, '重生');
|
||||
h.run(HUSK_REBIRTH + 0.05);
|
||||
assert.ok(e.s.flags.stealth && e.s.flags.disarm && e.profile.noAttack, '余烬: 隐匿, 缴械');
|
||||
assert.ok(!e.s.flags.unblockable && !e.s.flags.noMove, 'blockable, walks');
|
||||
h.run(tb(key, 'Revive[Trigger].interval'));
|
||||
assert.equal(e.s.maxHp, max);
|
||||
assert.equal(e.hp, max);
|
||||
assert.ok(!e.s.flags.stealth);
|
||||
// an ember hit N times dies for good
|
||||
killed(h, e, null);
|
||||
h.run(HUSK_REBIRTH + 0.05);
|
||||
for (let i = 0; i < tb(key, 'Revive[Trigger].prop_max_hp'); i++) h.b.dealDamage(null, e, { amount: 1, type: 'arts' });
|
||||
assert.ok(!e.alive);
|
||||
});
|
||||
}
|
||||
|
||||
test(`${nm('enemy_9010_acpupp')}: knocked out ⇒ 15-hit regeneration husk that revives, and nearby enemies get a 5-hit shield`, () => {
|
||||
test(`${nm('enemy_9010_acpupp')}: knocked out ⇒ an unblockable 15-hit 傀儡 that walks on and revives, and nearby enemies get a 5-hit shield`, () => {
|
||||
const h = arena();
|
||||
h.step();
|
||||
const e = put(h, 'enemy_9010_acpupp', [10, 7]);
|
||||
@@ -335,6 +340,8 @@ test(`${nm('enemy_9010_acpupp')}: knocked out ⇒ 15-hit regeneration husk that
|
||||
killed(h, e, null);
|
||||
assert.ok(e.alive);
|
||||
assert.equal(e.s.maxHp, tb('enemy_9010_acpupp', 'Revive[Trigger].prop_max_hp'));
|
||||
h.run(HUSK_REBIRTH + 0.05);
|
||||
assert.ok(e.s.flags.unblockable && e.s.flags.disarm && !e.s.flags.stealth && !e.s.flags.noMove, '傀儡: 不可被阻挡, not stealthed');
|
||||
const hp = o.hp;
|
||||
for (let i = 0; i < tb('enemy_9010_acpupp', 'Aura.max_damage_block_cnt'); i++) h.b.dealDamage(null, o, { amount: 100, type: 'phys' });
|
||||
assert.equal(o.hp, hp, 'shield negates the hits');
|
||||
@@ -342,6 +349,7 @@ test(`${nm('enemy_9010_acpupp')}: knocked out ⇒ 15-hit regeneration husk that
|
||||
assert.ok(o.hp < hp);
|
||||
h.run(tb('enemy_9010_acpupp', 'Revive[Trigger].interval') + 0.1);
|
||||
assert.equal(e.hp, E.enemy_9010_acpupp.stats.maxHp);
|
||||
assert.ok(!e.s.flags.unblockable);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
@@ -640,17 +648,33 @@ for (const key of ['enemy_1169_duphlx', 'enemy_1169_duphlx_2']) {
|
||||
}
|
||||
|
||||
for (const key of ['enemy_1172_dugago', 'enemy_1172_dugago_2']) {
|
||||
test(`${nm(key)}: first knock-out ⇒ statue (DEF +${tb(key, 'stone.def')}) for ${tb(key, 'stone.duration')} s, then reborn as a flyer`, () => {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 7 }] });
|
||||
test(`${nm(key)}: melee only while blocked; first knock-out ⇒ unblockable statue (DEF +${tb(key, 'stone.def')}, 失衡 / 浮空 immune) for ${tb(key, 'stone.duration')} s, then a flyer with ranged arts attacks that skip flyers (PRTS 天赋)`, () => {
|
||||
// 地面模式: an operator in its 1.6 radius but not blocking it is never attacked
|
||||
const h0 = arena({ units: [{ chessId: 't_gun', row: 11, col: 7 }] });
|
||||
h0.step();
|
||||
put(h0, key, [10, 7]);
|
||||
h0.run(6);
|
||||
assert.equal(h0.unit('t_gun').stats.taken, 0, 'no ranged attack in its ground mode');
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 7 }, { chessId: 't_gun', row: 11, col: 7 }], kits: { t_gun: NOATK }, hooks: ['damaged'], captureNoisy: true });
|
||||
h.step();
|
||||
const e = put(h, key, [10, 7]);
|
||||
h.step();
|
||||
killed(h, e, null);
|
||||
assert.ok(e.alive);
|
||||
assert.equal(e.hp, e.s.maxHp, 'an instant 重生 to full HP');
|
||||
assert.equal(e.s.def, E[key].stats.def + tb(key, 'stone.def'));
|
||||
h.step();
|
||||
assert.ok(e.s.flags.unblockable && e.s.flags.noDisplace && !e.blockedBy, '转换模式: 无法被阻挡, 失衡免疫');
|
||||
assert.equal(h.b.applyStatus(e, 'levitate', { duration: 2 }), false, '免疫浮空');
|
||||
h.b.dealDamage(h.unit('t_gun'), e, { amount: e.s.maxHp * 0.3, type: 'true' });
|
||||
const hp = e.hp;
|
||||
h.run(tb(key, 'stone.duration') + 0.5);
|
||||
assert.equal(e.motion, 'FLY');
|
||||
assert.ok(!e.blockedBy);
|
||||
assert.equal(e.hp, hp, 'no second refill when it takes off');
|
||||
assert.ok(h.runUntil(() => e.stats.attacks > 0, 10), '飞行模式 attacks at range');
|
||||
assert.ok(h.unit('t_gun').stats.taken + h.unit('t_wall').stats.taken > 0, 'an operator in its radius');
|
||||
assert.ok(h.hooksOf('damaged').filter((c) => c.source === e && c.dmg.isAttack).every((c) => c.dmg.type === 'arts'), 'arts damage');
|
||||
killed(h, e, null);
|
||||
assert.ok(!e.alive, 'second knock-out is final');
|
||||
});
|
||||
@@ -1849,30 +1873,34 @@ test(`${nm('enemy_1535_wlfmster')}: −${tb('enemy_1535_wlfmster', 'Passive.dama
|
||||
assert.equal(h.b.applyStatus(e, 'stun', { duration: 1 }), true, 'stunnable in the second form');
|
||||
});
|
||||
|
||||
test(`${nm('enemy_10081_mpplai')}: no attack until 4 physical hits (复仇: melee), 4 arts hits (术士: ranged arts) or being blocked (幽灵: unblockable)`, () => {
|
||||
test(`${nm('enemy_10081_mpplai')}: original form cancels every damage instance; 4th physical hit ⇒ 寻仇者 (melee), 4th arts hit ⇒ 特战术师 (ranged arts), blocked ⇒ 幽灵 (unblockable) — each after a ${TRANSLATOR_CHANGE} s change`, () => {
|
||||
const mk = () => { const h = arena({ units: [{ chessId: 't_gun', row: 12, col: 3 }, { chessId: 't_mage', row: 12, col: 4 }], kits: { t_gun: NOATK, t_mage: NOATK } }); h.step(); return h; };
|
||||
let h = mk();
|
||||
let e = put(h, 'enemy_10081_mpplai', [10, 7]);
|
||||
for (let i = 0; i < 4; i++) h.b.dealDamage(h.unit('t_gun'), e, { amount: 10, type: 'phys' });
|
||||
const hp = e.hp;
|
||||
for (let i = 0; i < 4; i++) assert.equal(h.b.dealDamage(h.unit('t_gun'), e, { amount: 1e6, type: 'phys' }), 0);
|
||||
assert.equal(e.hp, hp, 'no damage lands in the original form');
|
||||
h.run(TRANSLATOR_CHANGE + 0.05);
|
||||
approx(e.s.atk, E.enemy_10081_mpplai.stats.atk + tb('enemy_10081_mpplai', 'Mode_Fuchou_Passive.atk'));
|
||||
approx(e.s.maxHp, E.enemy_10081_mpplai.stats.maxHp + tb('enemy_10081_mpplai', 'Mode_Fuchou_Passive.max_hp'));
|
||||
const h2 = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }] });
|
||||
h2.step();
|
||||
const f = put(h2, 'enemy_10081_mpplai', [10, 7]);
|
||||
for (let i = 0; i < 4; i++) h2.b.dealDamage(null, f, { amount: 1, type: 'arts' });
|
||||
assert.equal(f.mem.ab.list.length, 3);
|
||||
// arts hits from nobody (terrain) do not count
|
||||
h2.run(TRANSLATOR_CHANGE + 0.05);
|
||||
assert.equal(f.findBuff('ab:form'), null);
|
||||
h = mk();
|
||||
e = put(h, 'enemy_10081_mpplai', [11, 5]);
|
||||
for (let i = 0; i < 4; i++) h.b.dealDamage(h.unit('t_mage'), e, { amount: 10, type: 'arts' });
|
||||
h.run(TRANSLATOR_CHANGE + 0.05);
|
||||
approx(e.s.res, E.enemy_10081_mpplai.stats.res + tb('enemy_10081_mpplai', 'Mode_Shushi_Passive.magic_resistance'));
|
||||
h.run(3);
|
||||
assert.ok(e.stats.attacks > 0 && h.unit('t_gun').stats.taken + h.unit('t_mage').stats.taken > 0, '术士 attacks in range');
|
||||
assert.ok(e.stats.attacks > 0 && h.unit('t_gun').stats.taken + h.unit('t_mage').stats.taken > 0, '特战术师 attacks in range');
|
||||
const h3 = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
|
||||
h3.step();
|
||||
const g = put(h3, 'enemy_10081_mpplai', [9, 5]);
|
||||
h3.run(0.5);
|
||||
h3.run(TRANSLATOR_CHANGE + 0.3);
|
||||
assert.ok(g.findBuff('ab:form') && g.s.flags.unblockable, '幽灵');
|
||||
assert.equal(g.stats.attacks, 0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// test/match/feedback1-runner-forms.test.js — player report #5 after 0.1.0 ("转译基底不会变身…加载变身动画然后就没了"), the
|
||||
// client runner's part (public/js/battle/runner.js under Node: fake socket, manual clock and animation frames, the real
|
||||
// sim on the real stage 战场#01). A form change reaches the view only as an fx with a `form` (sim content/enemies.js
|
||||
// setForm; shared/protocol.js fxForm) — b.snap tuples carry no form. The runner used to drop those fx in a catch-up
|
||||
// frame (a stall of more than ≈0.53 s real at 2×: only spawn / die / deploy / status / skill / leak passed) and every
|
||||
// event while the tab was hidden, so the view kept the first form and the 转译基底·α died on the 寻仇者's B_Die (the
|
||||
// review's headless-Chrome repro). Now a catch-up keeps them (keepsState), a hidden tab holds the battle on screen's
|
||||
// state-bearing events for the first frame back (≤ HELD_MAX, else the view re-enters from the field meta), and the game
|
||||
// screen's pre-entry buffer keeps them too (screens/game.js keepEarly, same predicate).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createBattleRunner, keepsState, HELD_MAX } from '../../public/js/battle/runner.js';
|
||||
import { createStore, initialState } from '../../public/js/store.js';
|
||||
import * as specMod from '../../server/sim/spec.js';
|
||||
import { DataSource } from '../../server/sim/simdata.js';
|
||||
import { validateC2S, fxForm } from '../../shared/protocol.js';
|
||||
import { DATA } from './harness.js';
|
||||
|
||||
const DS = new DataSource(DATA, null);
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const TR = 'enemy_10081_mpplai';
|
||||
|
||||
function fakeNet() {
|
||||
const handlers = new Map();
|
||||
const n = {
|
||||
sent: [],
|
||||
on(t, fn) { if (!handlers.has(t)) handlers.set(t, new Set()); handlers.get(t).add(fn); return () => handlers.get(t).delete(fn); },
|
||||
emit(t, msg) { for (const fn of handlers.get(t) || []) fn({ t, ...msg }); },
|
||||
send(t, fields) { const msg = { ...fields, t }; assert.equal(validateC2S(msg), null, `invalid ${t}`); n.sent.push(msg); return true; },
|
||||
request(t, fields) { const msg = { ...fields, t, rid: 1 }; assert.equal(validateC2S(msg), null, `invalid ${t}`); n.sent.push(msg); return Promise.resolve({ t: 'ok' }); },
|
||||
};
|
||||
return n;
|
||||
}
|
||||
|
||||
function rig({ hidden = false } = {}) {
|
||||
let t = 1000;
|
||||
const frames = [];
|
||||
const intervals = [];
|
||||
const docListeners = [];
|
||||
const doc = { hidden, addEventListener(type, fn) { if (type === 'visibilitychange') docListeners.push(fn); } };
|
||||
const net = fakeNet();
|
||||
const store = createStore(initialState);
|
||||
const runner = createBattleRunner({
|
||||
net, store, doc,
|
||||
now: () => t,
|
||||
raf: (fn) => { frames.push(fn); return frames.length; },
|
||||
caf: () => {},
|
||||
setInterval: (fn) => { intervals.push(fn); return intervals.length; },
|
||||
clearInterval: () => {},
|
||||
loadSim: async () => ({ spec: specMod, ds: DS }),
|
||||
logger: { error() {}, warn() {}, info() {}, debug() {} },
|
||||
});
|
||||
const feed = { evs: [], fields: [], snaps: 0 };
|
||||
runner.on('ev', (e) => feed.evs.push(e));
|
||||
runner.on('field', (f) => feed.fields.push(f));
|
||||
runner.on('snap', () => { feed.snaps++; });
|
||||
return {
|
||||
runner, net, doc, feed,
|
||||
/** advance the clock by `ms` in animation frames of `step` ms (pump intervals while hidden) */
|
||||
advance(ms, step = 1000 / 60) {
|
||||
const end = t + ms;
|
||||
while (t < end) {
|
||||
t = Math.min(end, t + step);
|
||||
if (doc.hidden) { for (const fn of intervals) fn(); continue; }
|
||||
const q = frames.splice(0);
|
||||
for (const fn of q) fn(t);
|
||||
}
|
||||
},
|
||||
show() { doc.hidden = false; for (const fn of docListeners) fn(); },
|
||||
async settle() { for (let i = 0; i < 50; i++) { await new Promise((res) => setImmediate(res)); const q = frames.splice(0); for (const fn of q) fn(t); } },
|
||||
};
|
||||
}
|
||||
|
||||
/** 战场#01: 百炼嘉维尔 blocks the lower lane on (9,8); one 转译基底·α walks it (9,10) → (9,2) — blocked, it turns 幽灵. */
|
||||
const start = (authoritative = false) => ({
|
||||
battleId: 'fb1.runner', fieldId: 'n:P1', kind: 'normal', speed: 2, elapsed: 0, authoritative, watch: !authoritative,
|
||||
spec: specMod.buildBattleSpec({
|
||||
battleId: 'fb1.runner', fieldId: 'n:P1', kind: 'normal', seed: 5, modeId: 'mode_multi_hard', round: 3, stageId: 'act2autochess_m01', timeLimit: 400,
|
||||
players: [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: 'chess_char_4_23_b', row: 9, col: 8, dir: 'RIGHT' }], bonds: {}, playerEffects: [] }],
|
||||
spawns: [{ time: 0, enemyKey: TR, routeIndex: 0, count: 1, mods: { hpMul: 1, atkMul: 0.01 } }],
|
||||
routes: [{ motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] }], flags: {},
|
||||
}),
|
||||
});
|
||||
|
||||
const all = (feed) => feed.evs.flatMap((x) => x.ev);
|
||||
const formsOf = (list, id) => list.filter((x) => fxForm(x) !== undefined && x[4].id === id).map(fxForm);
|
||||
const trOf = (e) => e.battle.units.find((u) => u.defId === TR);
|
||||
|
||||
test('fxForm / keepsState: a setForm fx (any fx kind with a `form`) is state; a barrier \'phase\', hit sparks and damage numbers are not', () => {
|
||||
assert.equal(fxForm(['fx', 'phase', 1, 2, { id: 3, kind: 'translator_youling', form: 'translator_youling', dur: 2 }]), 'translator_youling');
|
||||
assert.equal(fxForm(['fx', 'ember', 1, 2, { id: 3, hits: 5, dur: 11, form: 'husk' }]), 'husk');
|
||||
assert.equal(fxForm(['fx', 'revive', 1, 2, { id: 3, form: null }]), null, 'null = back to the base clip set');
|
||||
assert.equal(fxForm(['fx', 'phase', 1, 2, { id: 3, kind: 'artsBarrier', value: 300 }]), undefined, 'a barrier phase is no form');
|
||||
assert.equal(fxForm(['fx', 'ember', 1, 2, { form: 'husk' }]), undefined, 'no unit');
|
||||
assert.equal(fxForm(['dmg', 3, 100]), undefined);
|
||||
assert.ok(keepsState(['fx', 'revive', 1, 2, { id: 3, form: 'revived' }]));
|
||||
assert.ok(keepsState(['spawn', { id: 3 }]) && keepsState(['die', 3, 'killed']));
|
||||
assert.ok(!keepsState(['fx', 'explode', 1, 2, { r: 1 }]) && !keepsState(['atk', 1, 3]) && !keepsState(['dmg', 3, 100]));
|
||||
});
|
||||
|
||||
test('the smooth feed carries 转译基底·α\'s form fx (blocked ⇒ 幽灵)', async () => {
|
||||
const r = rig();
|
||||
r.net.emit('b.start', start());
|
||||
await r.settle();
|
||||
const e = r.runner._entries.get('fb1.runner');
|
||||
for (let i = 0; i < 12 && trOf(e)?.form == null; i++) r.advance(1000);
|
||||
r.advance(1500);
|
||||
const tr = trOf(e);
|
||||
assert.equal(tr?.form, 'translator_youling', 'fixture: blocked by 百炼嘉维尔 it turns 幽灵');
|
||||
assert.equal(r.runner.stats().catchups, 0, 'no catch-up');
|
||||
assert.deepEqual(formsOf(all(r.feed), tr.id), ['translator_youling']);
|
||||
r.runner.dispose();
|
||||
});
|
||||
|
||||
test('janky frames (0.7 s each: every frame a catch-up) keep the form fx — hit sparks and damage numbers still go', async () => {
|
||||
const r = rig();
|
||||
r.net.emit('b.start', start());
|
||||
await r.settle();
|
||||
const e = r.runner._entries.get('fb1.runner');
|
||||
for (let i = 0; i < 40 && !e.done && !(trOf(e)?.form && e.battle.time > 12); i++) r.advance(700, 700);
|
||||
const tr = trOf(e);
|
||||
assert.equal(tr?.form, 'translator_youling');
|
||||
assert.ok(r.runner.stats().catchups > 5, `catch-up frames (${r.runner.stats().catchups})`);
|
||||
const list = all(r.feed);
|
||||
assert.deepEqual(formsOf(list, tr.id), ['translator_youling'], 'the view learns the 幽灵 form (it used to keep the A model)');
|
||||
assert.ok(!list.some((x) => x[0] === 'atk' || x[0] === 'dmg'), 'catch-up frames still drop the decoration');
|
||||
const spawnAt = list.findIndex((x) => x[0] === 'spawn' && x[1].id === tr.id);
|
||||
const formAt = list.findIndex((x) => fxForm(x) !== undefined && x[4].id === tr.id);
|
||||
const dieAt = list.findIndex((x) => x[0] === 'die' && x[1] === tr.id);
|
||||
assert.ok(spawnAt >= 0 && formAt > spawnAt && (dieAt < 0 || dieAt > formAt), `in order: spawn ${spawnAt}, form ${formAt}, die ${dieAt}`);
|
||||
r.runner.dispose();
|
||||
});
|
||||
|
||||
test('a hidden tab across the change: nothing rendered meanwhile, the first frame back delivers the held spawn and form fx', async () => {
|
||||
const r = rig({ hidden: true });
|
||||
r.net.emit('b.start', start(true));
|
||||
await r.settle();
|
||||
const e = r.runner._entries.get('fb1.runner');
|
||||
assert.equal(r.feed.fields.length, 1, 'shown (the field meta) before the first tick');
|
||||
for (let i = 0; i < 12 && trOf(e)?.form == null; i++) r.advance(1000, 250);
|
||||
r.advance(1500, 250);
|
||||
const tr = trOf(e);
|
||||
assert.equal(tr?.form, 'translator_youling', 'pumped through the change while hidden');
|
||||
assert.equal(r.feed.evs.length, 0, 'nothing rendered while hidden');
|
||||
assert.ok(e.held.length > 0 && e.held.every(keepsState), 'the battle on screen holds its state-bearing events');
|
||||
r.show();
|
||||
r.advance(1000 / 60);
|
||||
const first = r.feed.evs[0]?.ev || [];
|
||||
assert.ok(first.some((x) => x[0] === 'spawn' && x[1].id === tr.id), 'the translator\'s spawn (it used to come back as an unknown view)');
|
||||
assert.deepEqual(formsOf(first, tr.id), ['translator_youling'], 'and its form');
|
||||
assert.equal(e.held.length, 0);
|
||||
assert.equal(r.feed.fields.length, 1, 'no re-entry needed');
|
||||
r.runner.dispose();
|
||||
});
|
||||
|
||||
test('a hidden-tab backlog beyond HELD_MAX: the first frame back re-enters the view from the field meta (UnitInfo `form`)', async () => {
|
||||
const r = rig({ hidden: true });
|
||||
r.net.emit('b.start', start(true));
|
||||
await r.settle();
|
||||
const e = r.runner._entries.get('fb1.runner');
|
||||
for (let i = 0; i < 12 && trOf(e)?.form == null; i++) r.advance(1000, 250);
|
||||
const tr = trOf(e);
|
||||
assert.ok(tr?.form, 'changing');
|
||||
e.held.push(...Array.from({ length: HELD_MAX }, () => ['status', tr.id, 'stun', false]));
|
||||
r.advance(500, 250);
|
||||
assert.ok(e.stale && e.held.length === 0, 'overflow: dropped, marked stale');
|
||||
r.show();
|
||||
r.advance(1000 / 60);
|
||||
assert.equal(r.feed.fields.length, 2, 're-entered');
|
||||
const meta = r.feed.fields[1];
|
||||
assert.equal(meta.units.find((u) => u.id === tr.id)?.form, 'translator_youling', 'the field meta carries the form');
|
||||
assert.equal(e.stale, false);
|
||||
r.advance(1000 / 60);
|
||||
assert.ok(!r.feed.evs.some((x) => x.ev.some((y) => y[0] === 'status' && y[2] === 'stun')), 'the dropped backlog never arrives');
|
||||
r.runner.dispose();
|
||||
});
|
||||
|
||||
test('screens/game.js buffers the form fx with the state-bearing events it replays when a field is entered late', () => {
|
||||
const src = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8');
|
||||
assert.match(src, /const keepEarly = \(e\) => Array\.isArray\(e\) && \(STATE_EV\.has\(e\[0\]\) \|\| fxForm\(e\) !== undefined\);/);
|
||||
assert.match(src, /for \(const e of msg\.ev\) if \(keepEarly\(e\)\) buf\.push\(e\);/);
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
// test/render/feedback1-forms.browser.test.js — player reports after 0.1.0 (#5, #8) in headless Chrome: the real sim runs
|
||||
// in the page (the client runner's loadBrowserSim) and feeds the render demo's field view on 战场#01 (render/app.js fx
|
||||
// 'phase' / `form` → render/units.js setForm / FORMS):
|
||||
// #5 转译基底·α walks on its A_* clips, takes 4 physical hits without losing HP, plays its 2 s A_Die_B change and goes on
|
||||
// as 寻仇者 (B_*), dying on B_Die — it used to die in its first form, on B_Die ("加载变身动画然后就没了");
|
||||
// #8 a 深池逐火战士 knocked out in front of 百炼嘉维尔 plays 'Die' (its 1 s 重生), stands there as the 隐匿 ember on
|
||||
// Idle_2 (blocked, so its blocker beats it) and dies on Die_2 — it used to stay an untargetable ember forever;
|
||||
// a view built mid-battle — the real `view.enterBattle(b.fieldMeta())` after a silent catch-up, as for a teammate's
|
||||
// field watched later, 联防 observers or a reconnect — starts in the current forms (UnitInfo `form` → render/app.js
|
||||
// renderInfo): it used to draw the warrior / the A model again (the look of report #5) and 掠海漂移体 hovering;
|
||||
// the real client runner (battle/runner.js createBattleRunner) feeding the view the way screens/game.js does, with
|
||||
// janky 0.7 s frames (every frame a catch-up) across the change of a 转译基底·α that walks into 百炼嘉维尔's block:
|
||||
// the view turns 幽灵 (C_*) — the catch-up filter used to drop the form fx, so it stayed on A_Move and died on B_Die.
|
||||
//
|
||||
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/feedback1-forms.browser.test.js
|
||||
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/feedback1-*.png.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { buildBattleSpec } from '../../server/sim/spec.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const OUT = path.join(ROOT, 'test/e2e/out');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
|
||||
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
|
||||
/** The view plays the feed a little behind the sim (its interpolation buffer): game seconds allowed after an event. */
|
||||
const LAG = 0.4;
|
||||
|
||||
/** 百炼嘉维尔 facing the lower gate on (9,8) (`row`, `col`: elsewhere); one enemy walking the lower lane (9,10) → (9,2). */
|
||||
const spec = (enemyKey, id, row = 9, col = 8) => buildBattleSpec({
|
||||
battleId: `e2e.${id}`, fieldId: 'n:P1', kind: 'normal', seed: 5, modeId: 'mode_multi_hard', round: 3, stageId: 'act2autochess_m01', timeLimit: 400,
|
||||
players: [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: 'chess_char_4_23_b', row, col, dir: 'RIGHT' }], bonds: {}, playerEffects: [] }],
|
||||
spawns: [{ time: 0, enemyKey, routeIndex: 0, count: 1, mods: { hpMul: 1, atkMul: 0.01 } }],
|
||||
routes: [{ motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] }], flags: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Run `spec` in the page for `secs` game seconds; `act(b, e, ally)` runs every tick (the test's own interventions).
|
||||
* Returns samples { t, clip, form, alive, hp, stealth, blocked } of the enemy's view every 6 ticks.
|
||||
*/
|
||||
async function runInPage(page, port, s, enemyKey, secs, actSrc) {
|
||||
await page.goto(`http://127.0.0.1:${port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
|
||||
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
|
||||
return page.evaluate(async (spec, key, secs, actSrc) => {
|
||||
const act = new Function('b', 'e', 'ally', 'state', actSrc);
|
||||
const { loadBrowserSim } = await import('/js/battle/runner.js');
|
||||
const { data } = await import('/js/data.js');
|
||||
const { spec: S, ds } = await loadBrowserSim();
|
||||
const b = S.createBattleFromSpec(spec, ds, { quiet: true });
|
||||
const v = window.__demo.view;
|
||||
v.setStage(data.lookup('stages', spec.stageId));
|
||||
b.step();
|
||||
const meta = b.fieldMeta();
|
||||
v.enterBattle(meta);
|
||||
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
|
||||
v.setLocalFeed({ on: true, speed: 2 });
|
||||
const raf = () => new Promise((r) => requestAnimationFrame(() => r()));
|
||||
const out = [];
|
||||
const state = {};
|
||||
let e = null;
|
||||
for (let i = 0; i < 30 * secs; i++) {
|
||||
if (!b.finished) b.step(); // (the view goes on after the end: the death clip)
|
||||
const t = Math.max(b.time, (i + 1) / 30);
|
||||
e = e ?? b.units.find((x) => x.side === 'enemy' && x.defId === key) ?? null;
|
||||
const ally = b.allyUnits[0];
|
||||
if (e) act(b, e, ally, state);
|
||||
if (i % 3 === 2) {
|
||||
const ev = b.drainEvents();
|
||||
if (ev.length) v.pushEvents({ t: 'b.ev', fieldId: meta.fieldId, gt: b.time, ev });
|
||||
v.pushSnapshot(b.snapshot());
|
||||
await raf();
|
||||
if (i === 30 * 2) await new Promise((r) => setTimeout(r, 2500)); // the Spine models load
|
||||
const view = e && v.debug.views.get(e.id);
|
||||
if (e && i % 6 === 5) out.push({ t: +t.toFixed(2), clip: view?.actor?.current ?? null, form: view?.form ?? null, spine: !!view?.spineReady, alive: e.alive, hp: e.hp, max: e.s.maxHp, stealth: !!e.s.flags.stealth, blocked: !!e.blockedBy });
|
||||
}
|
||||
}
|
||||
return { out, state };
|
||||
}, s, enemyKey, secs, actSrc);
|
||||
}
|
||||
|
||||
/**
|
||||
* The late view: `spec` runs silently in the page (events drained and dropped, like the client runner's catch-up) while
|
||||
* `setupSrc(b, ally)` changes the enemies' forms; at `atSecs` the field view enters the battle from `b.fieldMeta()` and
|
||||
* follows the feed. Returns { [name]: { clip, form, alive } } of the views of the enemies `setupSrc` returns by name.
|
||||
*/
|
||||
async function lateViewInPage(page, port, s, atSecs, setupSrc) {
|
||||
await page.goto(`http://127.0.0.1:${port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
|
||||
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
|
||||
return page.evaluate(async (spec, atSecs, setupSrc) => {
|
||||
const setup = new Function('b', 'ally', setupSrc);
|
||||
const { loadBrowserSim } = await import('/js/battle/runner.js');
|
||||
const { data } = await import('/js/data.js');
|
||||
const { spec: S, ds } = await loadBrowserSim();
|
||||
const b = S.createBattleFromSpec(spec, ds, { quiet: true });
|
||||
const v = window.__demo.view;
|
||||
v.setStage(data.lookup('stages', spec.stageId));
|
||||
b.step();
|
||||
const named = setup(b, b.allyUnits[0]);
|
||||
while (b.time < atSecs) { b.step(); b.drainEvents(); }
|
||||
const meta = b.fieldMeta();
|
||||
v.enterBattle(meta);
|
||||
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
|
||||
v.setLocalFeed({ on: true, speed: 2 });
|
||||
const raf = () => new Promise((r) => requestAnimationFrame(() => r()));
|
||||
const read = () => Object.fromEntries(Object.entries(named).map(([k, u]) => {
|
||||
const view = v.debug.views.get(u.id);
|
||||
return [k, { clip: view?.actor?.current ?? null, form: view?.form ?? null, spine: !!view?.spineReady, alive: u.alive, simForm: u.form }];
|
||||
}));
|
||||
for (let i = 0; i < 30; i++) {
|
||||
b.step();
|
||||
if (i % 3 === 2) {
|
||||
const ev = b.drainEvents();
|
||||
if (ev.length) v.pushEvents({ t: 'b.ev', fieldId: meta.fieldId, gt: b.time, ev });
|
||||
v.pushSnapshot(b.snapshot());
|
||||
await raf();
|
||||
}
|
||||
if (i === 15) await new Promise((r) => setTimeout(r, 2500)); // the Spine models load
|
||||
}
|
||||
return read();
|
||||
}, s, atSecs, setupSrc);
|
||||
}
|
||||
|
||||
/**
|
||||
* The real client runner in the page (fake socket / store, its default sim loader and clock) feeds the demo's view like
|
||||
* screens/game.js (field → enterBattle, snap → pushSnapshot, ev → pushEvents); its animation frames come every `jankMs`
|
||||
* real ms for `secs` real s. Returns samples { t, simForm, form, clip, spine, alive } of the 转译基底·α's view and the
|
||||
* runner's catch-up count.
|
||||
*/
|
||||
async function runnerInPage(page, port, s, jankMs, secs) {
|
||||
await page.goto(`http://127.0.0.1:${port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
|
||||
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
|
||||
return page.evaluate(async (spec, jankMs, secs) => {
|
||||
const { createBattleRunner } = await import('/js/battle/runner.js');
|
||||
const { data } = await import('/js/data.js');
|
||||
const v = window.__demo.view;
|
||||
v.setStage(data.lookup('stages', spec.stageId));
|
||||
const handlers = new Map();
|
||||
const net = {
|
||||
on(t, fn) { if (!handlers.has(t)) handlers.set(t, new Set()); handlers.get(t).add(fn); return () => handlers.get(t).delete(fn); },
|
||||
send() { return true; }, request() { return Promise.resolve({ t: 'ok' }); },
|
||||
};
|
||||
const queue = [];
|
||||
const runner = createBattleRunner({ net, store: { patch() {} }, doc: { hidden: false, addEventListener() {} }, raf: (fn) => { queue.push(fn); return queue.length; }, caf() {} });
|
||||
runner.on('field', (f) => { v.enterBattle(f); v.setCamera('normal', { rect: f.rect, side: 'L', instant: true }); v.setLocalFeed({ on: true, speed: f.speed }); });
|
||||
runner.on('snap', (x) => v.pushSnapshot(x));
|
||||
runner.on('ev', (m) => v.pushEvents(m));
|
||||
for (const fn of handlers.get('b.start') || []) fn({ t: 'b.start', battleId: spec.battleId, fieldId: spec.fieldId, kind: 'normal', spec, authoritative: false, watch: true, speed: 2, elapsed: 0 });
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
for (let i = 0; i < 100 && !runner._entries.size; i++) await sleep(50);
|
||||
const e = [...runner._entries.values()][0];
|
||||
const out = [];
|
||||
const end = performance.now() + secs * 1000;
|
||||
while (performance.now() < end) {
|
||||
await sleep(jankMs);
|
||||
for (const fn of queue.splice(0)) fn(performance.now());
|
||||
const tr = e.battle.units.find((u) => u.defId === 'enemy_10081_mpplai');
|
||||
const view = tr && v.debug.views.get(tr.id);
|
||||
if (tr) out.push({ t: +e.battle.time.toFixed(2), simForm: tr.form, form: view?.form ?? null, clip: view?.actor?.current ?? null, spine: !!view?.spineReady, alive: tr.alive });
|
||||
}
|
||||
const catchups = runner.stats().catchups;
|
||||
runner.dispose();
|
||||
return { out, catchups };
|
||||
}, s, jankMs, secs);
|
||||
}
|
||||
|
||||
describe('player reports after 0.1.0: the models follow the knock-out forms (headless Chrome, real sim)', { skip }, () => {
|
||||
let srv, browser;
|
||||
before(async () => {
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] });
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
async function page() {
|
||||
const p = await browser.newPage();
|
||||
const problems = [];
|
||||
p.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
p.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
p.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
|
||||
await p.setViewport({ width: 1280, height: 720 });
|
||||
return { p, problems };
|
||||
}
|
||||
|
||||
test('#5 转译基底·α: A_* while 4 physical hits do nothing, A_Die_B, then 寻仇者 B_*; it dies on B_Die', async () => {
|
||||
const { p, problems } = await page();
|
||||
try {
|
||||
// the operator stands off its lane (no block); at 3 s: 4 physical hits from it (the 4th starts the change); at 9 s:
|
||||
// knocked out for good
|
||||
const log = await runInPage(p, srv.port, spec('enemy_10081_mpplai', 'translator', 11, 4), 'enemy_10081_mpplai', 12, `
|
||||
if (!state.hit && b.time >= 3) { state.hit = b.time; state.hp = e.hp; for (let i = 0; i < 4; i++) b.dealDamage(ally, e, { amount: 1e6, type: 'phys' }); state.after = e.hp; }
|
||||
if (!state.kill && b.time >= 9 && e.alive) { state.kill = b.time; b.kill(e, ally); }
|
||||
`);
|
||||
await p.screenshot({ path: path.join(OUT, 'feedback1-translator.png') });
|
||||
const { out, state } = log;
|
||||
assert.equal(state.after, state.hp, 'the 4 hits took no HP');
|
||||
const pre = out.filter((x) => x.spine && x.t < state.hit);
|
||||
assert.ok(pre.length > 0 && pre.every((x) => /^A_/.test(x.clip)), `first form: A_* (${[...new Set(pre.map((x) => x.clip))]})`);
|
||||
const change = out.filter((x) => x.t > state.hit + 2 * LAG && x.t < state.hit + 1.8); // (a loaded machine: the view lags more)
|
||||
assert.ok(change.length > 0 && change.every((x) => x.clip === 'A_Die_B' && x.alive), `the change clip (${[...new Set(change.map((x) => x.clip))]})`);
|
||||
const form = out.filter((x) => x.t > state.hit + 2 + LAG && x.t < state.kill);
|
||||
assert.ok(form.length > 0 && form.every((x) => /^B_/.test(x.clip) && x.form === 'translator_fuchou'), `寻仇者 clips (${[...new Set(form.map((x) => x.clip))]})`);
|
||||
const dead = out.filter((x) => x.t > state.kill + LAG);
|
||||
assert.ok(dead.length > 0 && dead.every((x) => !x.alive && x.clip === 'B_Die'), `dies on B_Die (${[...new Set(dead.map((x) => x.clip))]})`);
|
||||
assert.deepEqual(problems, []);
|
||||
} finally {
|
||||
await p.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('#8 深池逐火战士 knocked out in front of 百炼嘉维尔: Die (重生), the 隐匿 ember on Idle_2, beaten, Die_2', async () => {
|
||||
const { p, problems } = await page();
|
||||
try {
|
||||
const log = await runInPage(p, srv.port, spec('enemy_1288_duskls', 'ember'), 'enemy_1288_duskls', 16, `
|
||||
if (!state.ko && e.blockedBy && b.time >= 6) { state.ko = b.time; b.kill(e, ally); }
|
||||
if (state.ko && !state.dead && !e.alive) state.dead = b.time;
|
||||
`);
|
||||
await p.screenshot({ path: path.join(OUT, 'feedback1-ember.png') });
|
||||
const { out, state } = log;
|
||||
assert.ok(state.ko, 'blocked and knocked out');
|
||||
const pre = out.filter((x) => x.spine && x.t < state.ko);
|
||||
assert.ok(pre.length > 0 && pre.every((x) => !/_2$/.test(x.clip)), `the warrior's clips (${[...new Set(pre.map((x) => x.clip))]})`);
|
||||
const reb = out.filter((x) => x.t > state.ko + LAG && x.t < state.ko + 0.9);
|
||||
assert.ok(reb.length > 0 && reb.every((x) => x.clip === 'Die' && x.alive), `重生: 'Die' (${[...new Set(reb.map((x) => x.clip))]})`);
|
||||
assert.ok(state.dead > state.ko + 1, `the blocker beats the ember (dead at ${state.dead})`);
|
||||
const ember = out.filter((x) => x.t > state.ko + 1 + LAG && x.t < state.dead);
|
||||
assert.ok(ember.length > 0 && ember.every((x) => x.clip === 'Idle_2' && x.stealth && x.max === 5), `the 隐匿 ember on Idle_2 (${[...new Set(ember.map((x) => x.clip))]})`);
|
||||
assert.ok(ember.some((x) => x.hp < 5), 'its hit counter runs down');
|
||||
const dead = out.filter((x) => x.t > state.dead + LAG);
|
||||
assert.ok(dead.length > 0 && dead.every((x) => x.clip === 'Die_2'), `dies on Die_2 (${[...new Set(dead.map((x) => x.clip))]})`);
|
||||
assert.deepEqual(problems, []);
|
||||
} finally {
|
||||
await p.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('a view built mid-battle (enterBattle(fieldMeta) after a silent catch-up) starts in the current forms: 特战术师 D_*, 幽灵 C_*, the ember Idle_2 / Move_2, 掠海漂移体 *_02', async () => {
|
||||
const { p, problems } = await page();
|
||||
try {
|
||||
const got = await lateViewInPage(p, srv.port, spec('enemy_1007_slime', 'late', 9, 8), 7.5, `
|
||||
ally.base.atk = 0; ally.markDirty(); // 百炼嘉维尔 only blocks
|
||||
const still = { mods: { speedMul: 0 } };
|
||||
const shushi = b.spawnEnemy('enemy_10081_mpplai', { pos: [11, 9], ...still });
|
||||
const youling = b.spawnEnemy('enemy_10081_mpplai', { pos: [9, 8.4], ...still }); // blocked ⇒ 幽灵
|
||||
const ember = b.spawnEnemy('enemy_1288_duskls', { pos: [12, 9], ...still });
|
||||
const drift = b.spawnEnemy('enemy_2025_syufo', { pos: [11, 6], ...still });
|
||||
for (let i = 0; i < 4; i++) b.dealDamage(ally, shushi, { amount: 1, type: 'arts' });
|
||||
b.kill(ember, ally);
|
||||
b.applyStatus(drift, 'stun', { duration: 1, source: ally });
|
||||
return { shushi, youling, ember, drift };
|
||||
`);
|
||||
await p.screenshot({ path: path.join(OUT, 'feedback1-lateview.png') });
|
||||
assert.deepEqual(Object.fromEntries(Object.entries(got).map(([k, x]) => [k, x.simForm])),
|
||||
{ shushi: 'translator_shushi', youling: 'translator_youling', ember: 'husk', drift: 'crawl' }, 'the sim\'s forms');
|
||||
for (const [k, x] of Object.entries(got)) assert.ok(x.spine && x.alive && x.form === x.simForm, `${k}: a live Spine view in the sim's form (${JSON.stringify(x)})`);
|
||||
assert.match(got.shushi.clip, /^D_/, `特战术师 (${got.shushi.clip})`);
|
||||
assert.match(got.youling.clip, /^C_/, `幽灵 (${got.youling.clip})`);
|
||||
assert.match(got.ember.clip, /^(Idle|Move)_2$/, `the ember (${got.ember.clip})`);
|
||||
assert.match(got.drift.clip, /_02$/, `crawling (${got.drift.clip})`);
|
||||
assert.deepEqual(problems, []);
|
||||
} finally {
|
||||
await p.close();
|
||||
}
|
||||
});
|
||||
test('the real client runner with janky frames (every frame a catch-up): 转译基底·α blocked by 百炼嘉维尔 turns 幽灵 in the view (C_*), never back to A_Move / B_Die', async () => {
|
||||
const { p, problems } = await page();
|
||||
try {
|
||||
const { out, catchups } = await runnerInPage(p, srv.port, spec('enemy_10081_mpplai', 'runner', 9, 7), 700, 9);
|
||||
await p.screenshot({ path: path.join(OUT, 'feedback1-runner.png') });
|
||||
assert.ok(catchups >= 5, `catch-up frames (${catchups})`);
|
||||
const changed = out.find((x) => x.simForm === 'translator_youling');
|
||||
assert.ok(changed, `the sim's 转译基底·α turned 幽灵 (${JSON.stringify(out.slice(-3))})`);
|
||||
const after = out.filter((x) => x.spine && x.t > changed.t + 2 + LAG);
|
||||
assert.ok(after.length > 0, 'samples after the change');
|
||||
assert.ok(after.every((x) => x.form === 'translator_youling' && /^C_/.test(x.clip)), `幽灵's clips (${[...new Set(after.map((x) => `${x.form}:${x.clip}`))]})`);
|
||||
assert.ok(!out.some((x) => x.spine && /^B_/.test(x.clip)), 'never the 寻仇者\'s clips (B_Die was the look of report #5)');
|
||||
assert.deepEqual(problems, []);
|
||||
} finally {
|
||||
await p.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
+254
-3
@@ -3,6 +3,12 @@
|
||||
// melee operators block and hit it, so its model must stop hovering: render/units.js FORMS switches the view to the
|
||||
// skeleton's crawl clips (*_02) after its 'Change' clip (render/spine.js SpineActor.setForm), also for a view built
|
||||
// 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' — 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.
|
||||
// A view built mid-battle gets the current form from UnitInfo `form` through render/app.js renderInfo (enterBattle).
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -12,15 +18,17 @@ 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'));
|
||||
const SYUFO = 'enemy_2025_syufo';
|
||||
|
||||
let fake, UnitView, FORMS;
|
||||
let fake, UnitView, FORMS, renderInfo;
|
||||
before(async () => {
|
||||
fake = installFakePixi();
|
||||
({ UnitView, FORMS } = await import('../../public/js/render/units.js'));
|
||||
({ renderInfo } = await import('../../public/js/render/app.js'));
|
||||
});
|
||||
after(() => fake.restore());
|
||||
|
||||
@@ -116,7 +124,250 @@ describe('掠海漂移体 爬行模式 (FORMS)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('render/app.js hands the sim\'s fx \'phase\' to the view and keeps the mode on the unit info', () => {
|
||||
/** A real-manifest enemy view (any skeleton). */
|
||||
async function enemy(id, info = {}) {
|
||||
const ctx = fakeViewCtx(fake.P, { assets: store(id), cam });
|
||||
const v = new UnitView(ctx, { id: 9, side: 'enemy', kind: 'enemy', defId: id, spine: id, tier: 2, x: 8, y: 9, maxHp: 1000, facing: -1, ...info });
|
||||
await tick(); await tick();
|
||||
assert.ok(v.actor, 'Spine model built');
|
||||
return v;
|
||||
}
|
||||
|
||||
describe('转译基底·α forms (user report after 0.1.0, #5)', () => {
|
||||
const TR = 'enemy_10081_mpplai';
|
||||
test('every clip of the three forms exists in the skeleton; the change clips last the official 2 s; the manifest keeps the original form', () => {
|
||||
const anims = assets.enemies[TR].spine.animations;
|
||||
for (const [kind, f] of Object.entries(FORMS[TR])) {
|
||||
for (const name of [f.change, f.roles.idle, f.roles.die, f.roles.move.loop]) assert.ok(name in anims, `${kind}: ${name}`);
|
||||
assert.equal(anims[f.change], 2, `${kind}: ${f.change}`);
|
||||
}
|
||||
assert.equal(FORMS[TR].translator_youling.roles.attack, null, '幽灵 never attacks');
|
||||
assert.equal(assets.enemies[TR].spine.anims.idle, 'A_Idle');
|
||||
assert.equal(assets.enemies[TR].spine.anims.move.loop, 'A_Move');
|
||||
});
|
||||
|
||||
for (const [kind, set, attack] of [['translator_fuchou', 'B', 'B_Attack'], ['translator_shushi', 'D', 'D_Attack'], ['translator_youling', 'C', null]]) {
|
||||
test(`${kind}: the original form walks on A_*, the change clip plays out (2 s, moving or not), then the ${set}_* set`, async () => {
|
||||
const v = await enemy(TR);
|
||||
v.sync(sample(0, ANIM.MOVE), 1);
|
||||
assert.equal(clip(v), 'A_Move');
|
||||
v.setForm(kind);
|
||||
assert.equal(clip(v), FORMS[TR][kind].change);
|
||||
frames(v, 60);
|
||||
v.sync(sample(0, ANIM.MOVE), 2);
|
||||
assert.equal(clip(v), FORMS[TR][kind].change, 'not cut short');
|
||||
frames(v, 70);
|
||||
assert.equal(clip(v), `${set}_Move`);
|
||||
v.sync(sample(0, ANIM.IDLE), 3);
|
||||
assert.equal(clip(v), `${set}_Idle`);
|
||||
v.onAttack(null, 3.2);
|
||||
assert.equal(clip(v), attack ?? `${set}_Idle`, attack ? 'its attack clip' : 'no attack clip');
|
||||
v.die();
|
||||
assert.equal(clip(v), `${set}_Die`);
|
||||
});
|
||||
}
|
||||
|
||||
test('a view built after the change starts on the form\'s clips', async () => {
|
||||
const v = await enemy(TR, { form: 'translator_shushi' });
|
||||
v.sync(sample(), 1);
|
||||
assert.equal(clip(v), 'D_Idle');
|
||||
});
|
||||
});
|
||||
|
||||
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; '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');
|
||||
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');
|
||||
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, 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', { 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');
|
||||
frames(v, 6);
|
||||
assert.equal(clip(v), 'A_Move');
|
||||
});
|
||||
|
||||
test('beaten in its last second (the stand-up clip already playing, still a husk in the sim): the ember dies on Die_2, 再生\'s 傀儡 on B_Die — the warrior\'s / A_* death clip only after the revive fx', async () => {
|
||||
for (const [id, end, die, die0] of [['enemy_1288_duskls', 'Revive', 'Die_2', 'Die'], ['enemy_9010_acpupp', 'B_Revive', 'B_Die', 'A_Die']]) {
|
||||
const anims = assets.enemies[id].spine.animations;
|
||||
const v = await enemy(id);
|
||||
v.setForm('husk', { dur: 4 });
|
||||
frames(v, Math.round((4 - anims[end]) * 60) + 6);
|
||||
assert.equal(clip(v), end, `${id}: the closing clip plays`);
|
||||
v.die();
|
||||
assert.equal(clip(v), die, `${id}: the husk's death clip (review of WD)`);
|
||||
const w = await enemy(id);
|
||||
w.setForm('husk', { dur: 4 });
|
||||
frames(w, 4 * 60 + 6);
|
||||
w.setForm('revived');
|
||||
frames(w, 6);
|
||||
w.die();
|
||||
assert.equal(clip(w), die0, `${id}: stood up, it dies on its first form's clip`);
|
||||
}
|
||||
});
|
||||
|
||||
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)', () => {
|
||||
const REBIRTH = {
|
||||
enemy_1525_blkswb: ['Revive1', 'Revive2', 'Revive3', 'B_Idle', 'B_Die'],
|
||||
enemy_1535_wlfmster: ['A_revive_1', 'A_revive_2', 'A_revive_3', 'B_Idle', 'B_Die'],
|
||||
enemy_1539_reid: ['Revive_Begin', 'Revive_Loop', 'Revive_End', 'Idle', 'Die'],
|
||||
};
|
||||
for (const [id, [begin, hold, end, idle2, die2]] of Object.entries(REBIRTH)) {
|
||||
test(`${id}: knock-out ⇒ ${begin}, ${hold} held through the 重生's stun, ${end}, then ${idle2}; dies on ${die2}`, async () => {
|
||||
const anims = assets.enemies[id].spine.animations;
|
||||
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);
|
||||
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');
|
||||
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');
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
test('enemy_1516_jakill 杰斯顿: C1_Die is its 4 s 重生, then the killer form C2_*', async () => {
|
||||
const v = await enemy('enemy_1516_jakill');
|
||||
v.setForm('reborn');
|
||||
assert.equal(clip(v), 'C1_Die');
|
||||
frames(v, 245);
|
||||
v.setForm('form2');
|
||||
v.sync(sample(0, ANIM.MOVE), 5);
|
||||
assert.equal(clip(v), 'C2_Move');
|
||||
v.onAttack(null, 5.5);
|
||||
assert.equal(clip(v), 'C2_Attack');
|
||||
});
|
||||
|
||||
test('enemy_1172_dugago 守墓石像: the statue on Sleep, the flyer on *_2', async () => {
|
||||
const v = await enemy('enemy_1172_dugago');
|
||||
v.setForm('stone');
|
||||
v.sync(sample(), 1);
|
||||
assert.equal(clip(v), 'Sleep');
|
||||
v.setForm('fly');
|
||||
v.sync(sample(UF.FLYING, ANIM.MOVE), 2);
|
||||
assert.equal(clip(v), 'Move_2');
|
||||
v.die();
|
||||
assert.equal(clip(v), 'Die_2');
|
||||
});
|
||||
|
||||
test('a kind without a clip set of that skeleton (an arts barrier, a broken charge) keeps the current form', async () => {
|
||||
const v = await enemy('enemy_10081_mpplai');
|
||||
v.setForm('translator_fuchou');
|
||||
frames(v, 130);
|
||||
v.setForm('artsBarrier');
|
||||
assert.equal(v.form, 'translator_fuchou');
|
||||
v.sync(sample(), 3);
|
||||
assert.equal(clip(v), 'B_Idle');
|
||||
});
|
||||
});
|
||||
|
||||
test('a view built mid-battle from the real sim\'s fieldMeta (fx dropped by a silent catch-up), through render/app.js renderInfo — what enterBattle(meta) hands the views — starts in the current form: the changed 转译基底·α on D_Idle / C_Idle, the 逐火 ember on Idle_2, 锏 after its 重生 on B_Idle, 掠海漂移体 crawling', async () => {
|
||||
const mage = chessRec({ id: 't_mage', profession: 'CASTER', stats: { atk: 50, blockCnt: 0 }, rangeGrid: [[0, 0]], skill: null });
|
||||
const wall = chessRec({ id: 't_wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
|
||||
const quiet = () => ({ trait: { noAttack: true } });
|
||||
const h = makeBattle({
|
||||
stageId: 'act2autochess_m01', content: 'full', seed: 7, autoFinish: false, timeLimit: 600, modeId: 'mode_multi_hard', round: 3,
|
||||
defs: { chess: { t_mage: mage, t_wall: wall } }, units: [{ chessId: 't_mage', row: 12, col: 2 }, { chessId: 't_wall', row: 9, col: 4 }],
|
||||
kits: { t_mage: quiet, t_wall: quiet },
|
||||
});
|
||||
h.step();
|
||||
const still = { mods: { speedMul: 0 } };
|
||||
const tr = h.b.spawnEnemy('enemy_10081_mpplai', { pos: [10, 8], ...still });
|
||||
const ghost = h.b.spawnEnemy('enemy_10081_mpplai', { pos: [9, 4.4], ...still }); // walks into the wall: blocked ⇒ 幽灵
|
||||
const ember = h.b.spawnEnemy('enemy_1288_duskls', { pos: [11, 8], ...still });
|
||||
const mace = h.b.spawnEnemy('enemy_1525_blkswb', { pos: [12, 8], ...still });
|
||||
const drift = h.b.spawnEnemy(SYUFO, { pos: [11, 10], ...still });
|
||||
const m = h.unit('t_mage');
|
||||
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);
|
||||
h.b.applyStatus(drift, 'stun', { duration: 1, source: m }); // 掠海漂移体 drops to 爬行模式
|
||||
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 = renderInfo(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(ghost)), 'C_Idle', '幽灵 (blocked first), not the A model');
|
||||
const e = await view(ember);
|
||||
assert.equal(clip(e), 'Idle_2', 'the ember, not the warrior');
|
||||
e.die();
|
||||
assert.equal(clip(e), 'Die_2', 'and it dies on the ember\'s clip');
|
||||
assert.equal(clip(await view(mace)), 'B_Idle', '锏\'s second form');
|
||||
assert.equal(clip(await view(drift)), 'Idle_02', '掠海漂移体 crawls (DESIGN §19.1: a reload used to hover)');
|
||||
});
|
||||
|
||||
test('render/app.js renderInfo keeps UnitInfo `form` (it used to drop it: views built from fieldMeta drew the first form)', () => {
|
||||
assert.equal(renderInfo({ id: 3, kind: 'enemy', side: 'enemy', defId: 'enemy_1288_duskls', form: 'husk' }).form, 'husk');
|
||||
assert.equal(renderInfo({ id: 3, kind: 'enemy', side: 'enemy', defId: 'enemy_1288_duskls' }).form, undefined);
|
||||
assert.equal(renderInfo({ id: 3, form: 7 }).form, undefined, 'only a string');
|
||||
assert.equal(renderInfo({ kind: 'enemy' }), null, 'no id: no info');
|
||||
});
|
||||
|
||||
test('render/app.js hands the `form` of a sim fx (shared/protocol.js fxForm) 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'[\s\S]{0,300}inf\.form = [\s\S]{0,200}setForm\?\.\(e\[4\]\.kind\)/);
|
||||
assert.match(src, /const form = fxForm\(e\);[\s\S]{0,200}inf\.form = form[\s\S]{0,120}setForm\?\.\(form, e\[4\]\)/);
|
||||
});
|
||||
@@ -0,0 +1,572 @@
|
||||
// test/sim/feedback1-transform.test.js — player reports after the public 0.1.0 (workstream WD: enemies that change form
|
||||
// when "killed"), real enemy data in the real sim:
|
||||
// #5 转译基底·α "不会变身,原血量打死就加载变身动画然后就没了": its original form cancels every damage instance (PRTS 转译基底·α
|
||||
// 天赋 "受到伤害时取消此伤害"); the 4th physical / 4th arts instance or being blocked starts a 2 s change into 寻仇者 /
|
||||
// 特战术师 / 幽灵 (Mode_*_Passive) — only the changed form can die. v0.1.0 let damage through: a sniper + caster lineup
|
||||
// killed it in its first form without any change, and its model (still in that form) played the 寻仇者's B_Die.
|
||||
// #8 深池逐火战士 / 精锐战士 / 护卫 "被击倒后变的怨恨的余烬无法被击杀": every knock-out ⇒ 1 s 重生 (无敌 + 无法阻挡, immobile) ⇒
|
||||
// a 隐匿, disarmed ember of 5 (护卫 10) hits that walks its route on and can be blocked — blocked it is targetable and
|
||||
// dies for good after that many damage instances; still standing after 10 s it is the warrior again with full HP
|
||||
// (PRTS 深池逐火战士 天赋). v0.1.0's ember stood still, stealthed AND unblockable: nobody could ever target it, so
|
||||
// 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';
|
||||
import { getData } from '../../server/data.js';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { buildNormalWave, withBounties } from '../../server/match/waves.js';
|
||||
import { createRng } from '../../server/sim/rng.js';
|
||||
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
|
||||
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, abOf } from '../../server/sim/content/enemies.js';
|
||||
|
||||
const QUIET = { warn() {}, error() {}, info() {} };
|
||||
const DATA = getData({ log: QUIET });
|
||||
const E = DATA.enemies;
|
||||
const MODE = 'mode_multi_hard';
|
||||
const STAGE = 'act2autochess_m01';
|
||||
const gd = new GameData(DATA, MODE);
|
||||
const tb = (key, k) => E[key].talents.bb[k];
|
||||
const TR = 'enemy_10081_mpplai';
|
||||
const EMBERS = ['enemy_1288_duskls', 'enemy_1288_duskls_2', 'enemy_1292_duskld'];
|
||||
const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
|
||||
|
||||
/** A player's normal field as Match._normalOpts builds it (real wave, real stage, full content). */
|
||||
function realBattle({ round, wave, spawns, units, seed = 77 }) {
|
||||
const spec = buildBattleSpec({
|
||||
battleId: 'fb1.wd', fieldId: 'n:p1', kind: 'normal', seed, modeId: MODE, round, stageId: STAGE, rect: { ...GEO.NORMAL_RECT },
|
||||
timeLimit: wave.timeLimit, players: [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units, bonds: {}, playerEffects: [] }],
|
||||
spawns: (spawns ?? wave.spawns).map((s) => ({ ...s, ownerPlayerId: 'p1' })), routes: wave.routes,
|
||||
flags: { layerGainsEnabled: true, ...gd.dp }, enemyOverrides: wave.overrides, waveId: wave.templateId,
|
||||
});
|
||||
return createBattleFromSpec(spec, getDefaultSource(), { quiet: true });
|
||||
}
|
||||
const chess = (uid, chessId, row, col, dir = 'RIGHT') => ({ uid, kind: 'chess', chessId, row, col, dir });
|
||||
|
||||
// synthetic operators for the rule tests (real enemies, real stage and routes): a blocker that never attacks, an
|
||||
// instant long-range phys gun and arts mage that reach the whole field
|
||||
const BIG = [];
|
||||
for (let dr = -4; dr <= 4; dr++) for (let dc = -12; dc <= 12; dc++) BIG.push([dr, dc]);
|
||||
const CHESS = {
|
||||
t_wall: chessRec({ id: 't_wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, def: 0, res: 0, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null }),
|
||||
t_gun: chessRec({ id: 't_gun', profession: 'SNIPER', projectile: 'none', stats: { atk: 50, maxHp: 1e7, bat: 1, blockCnt: 0 }, rangeGrid: BIG, skill: null }),
|
||||
t_mage: chessRec({ id: 't_mage', profession: 'CASTER', projectile: 'none', stats: { atk: 50, maxHp: 1e7, bat: 1, blockCnt: 0 }, rangeGrid: BIG, skill: null }),
|
||||
};
|
||||
const NOATK = () => ({ trait: { noAttack: true } });
|
||||
/** Real stage + the real routes of an official wave template (route 0 = 战场#01's lower lane (9,10) → (9,2)). */
|
||||
const ROUTES = DATA.waves.act1autochess_03.routes;
|
||||
function arena(o = {}) {
|
||||
const { kits = {}, ...rest } = o;
|
||||
return makeBattle({
|
||||
stageId: STAGE, routes: ROUTES, content: 'full', seed: 7, autoFinish: false, timeLimit: 600, modeId: MODE, round: 3,
|
||||
defs: { chess: CHESS }, kits: { t_wall: NOATK, ...kits }, ...rest,
|
||||
});
|
||||
}
|
||||
const put = (h, key, o = {}) => h.spawn(key, { routeIndex: 0, ...o });
|
||||
/** Synthetic attackers that never attack (the test deals every hit itself). */
|
||||
const QUIET_GUNS = { t_gun: NOATK, t_mage: NOATK };
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// #8 逐火 embers
|
||||
|
||||
describe('#8 深池逐火: a knock-out is a walking 隐匿 ember that a blocker can beat', () => {
|
||||
test('a real TIMES round (R3, 深池逐火精锐战士 pick) on 战场#01: every knocked-out 逐火 becomes an ember, the blocked embers die for good — cleared, nobody leaks, each enemy dies once', () => {
|
||||
let wave = null;
|
||||
for (let seed = 1; seed < 500 && !wave; seed++) {
|
||||
const w = buildNormalWave(gd, createRng(seed), ['TIMES', 'INVISIBLE', 'FLY'], 3);
|
||||
if (w.pick && w.pick.key === 'enemy_1288_duskls_2') wave = w;
|
||||
}
|
||||
assert.ok(wave, 'fixture: an official R3 wave with the 深池逐火 TIMES entry');
|
||||
assert.ok(wave.spawns.some((s) => EMBERS.includes(s.enemyKey)), 'the round sends 逐火 enemies');
|
||||
const b = realBattle({
|
||||
round: 3, wave, units: [
|
||||
chess(1, 'chess_char_4_23_b', 9, 8), // 百炼嘉维尔 blocks the lower lane
|
||||
chess(2, 'chess_char_4_20_b', 10, 4), // 远牙
|
||||
chess(3, 'chess_char_4_02_b', 11, 4), // 莫斯提马
|
||||
chess(4, 'chess_char_4_17_b', 12, 6), // 星熊 blocks the upper lane's way down
|
||||
],
|
||||
});
|
||||
const ember = new Map(); // id → { u, at, x, y, attacks } of its current ember
|
||||
let walked = 0; // the longest walk of an ember past its 重生
|
||||
const deaths = new Map();
|
||||
const bad = new Set(); // rule details that did not hold (asserted after the outcome)
|
||||
const check = (ok, what) => { if (!ok) bad.add(what); };
|
||||
let n = 0;
|
||||
while (!b.finished && n++ < 30 * 300) {
|
||||
b.step();
|
||||
for (const ev of b.drainEvents()) {
|
||||
if (ev[0] === 'fx' && ev[1] === 'ember') {
|
||||
const u = b.units.find((x) => x.id === ev[4].id);
|
||||
check(u && u.alive, 'a knock-out never removes the unit');
|
||||
check(ev[4].form === 'husk', 'the ember fx carries the clip set of the model (form: husk)');
|
||||
ember.set(u.id, { u, at: b.time, x: u.x, y: u.y, attacks: u.stats.attacks });
|
||||
}
|
||||
if (ev[0] === 'die') {
|
||||
const u = b.units.find((x) => x.id === ev[1]);
|
||||
if (u && u.side === 'enemy') {
|
||||
deaths.set(u.id, (deaths.get(u.id) ?? 0) + 1);
|
||||
check(ev[2] === 'killed', 'enemies only die by being killed');
|
||||
check(ember.has(u.id) && u.mem.ab.list[0].state === 'husk', 'a 逐火 dies only as an ember');
|
||||
check(u.s.maxHp === tb(u.defId, 'Revive[Trigger].prop_max_hp'), 'killed through its hit counter');
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [, m] of ember) {
|
||||
const u = m.u;
|
||||
if (!u.alive || u.mem.ab.list[0].state !== 'husk') continue;
|
||||
if (b.time < m.at + HUSK_REBIRTH - 1e-6) {
|
||||
// (its blocker lets go at the next enemy update)
|
||||
check(u.s.flags.invulnerable && u.s.flags.unblockable && (b.time - m.at < 0.05 || !u.blockedBy), '重生: 无敌 + 无法阻挡');
|
||||
check(u.x === m.x && u.y === m.y, '重生: immobile');
|
||||
} else {
|
||||
check(u.s.flags.stealth && u.s.flags.disarm && !u.s.flags.unblockable, '余烬: 隐匿 + 缴械, blockable');
|
||||
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();
|
||||
const dusk = b.units.filter((u) => u.side === 'enemy' && EMBERS.includes(u.defId));
|
||||
assert.ok(dusk.length >= 5);
|
||||
assert.deepEqual({ reason: r.reason, leaked: r.perPlayer.p1.leaked.length, killed: r.perPlayer.p1.killed },
|
||||
{ reason: 'cleared', leaked: 0, killed: r.perPlayer.p1.total }, 'the round is cleared: every enemy killed, no 逐火 walks out');
|
||||
for (const u of dusk) assert.equal(deaths.get(u.id), 1, `${u.defId} #${u.id} dies exactly once`);
|
||||
assert.equal(ember.size, dusk.length, 'every 逐火 went through its ember');
|
||||
assert.deepEqual([...bad], [], 'ember rules');
|
||||
assert.ok(walked > 1, `an ember whose blocker fell walks on (${walked.toFixed(2)} tiles)`);
|
||||
});
|
||||
|
||||
for (const key of EMBERS) {
|
||||
const hits = tb(key, 'Revive[Trigger].prop_max_hp'), delay = tb(key, 'Revive[Trigger].interval');
|
||||
test(`${key} ${E[key].name}: 1 s 重生, then a walking 隐匿 ember of ${hits} hits — unblocked no ranged unit can target it, blocked it dies after ${hits} instances; ${delay} s later it would stand up again`, () => {
|
||||
// unblocked: the gun cannot target it while it walks on; it revives at full HP, and the next knock-out repeats
|
||||
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS });
|
||||
h.step();
|
||||
const e = put(h, key, { mods: { speedMul: 1 } });
|
||||
const gun = h.unit('t_gun');
|
||||
h.run(2);
|
||||
const full = e.s.maxHp;
|
||||
const kills = () => h.hooksOf('kill').filter((c) => c.victim === e).length;
|
||||
h.b.kill(e, h.unit('t_gun'));
|
||||
assert.ok(e.alive, 'not removed');
|
||||
assert.equal(kills(), 0, 'a knock-out is no kill for anyone (credit, bounty, bond kill triggers)');
|
||||
assert.equal(h.b.killed, 0, 'and no kill count');
|
||||
assert.equal(e.s.maxHp, hits);
|
||||
assert.equal(e.hp, hits);
|
||||
const x0 = e.x;
|
||||
assert.equal(h.b.dealDamage(h.unit('t_gun'), e, { amount: 1e6, type: 'true' }), 0, '重生 is invulnerable');
|
||||
h.run(HUSK_REBIRTH - 0.05);
|
||||
approx(e.x, x0, 1e-9, 'immobile during the 重生');
|
||||
h.run(0.1);
|
||||
assert.ok(e.s.flags.stealth && e.s.flags.disarm && e.profile.noAttack && !e.s.flags.unblockable);
|
||||
assert.equal(canTargetEnemy(gun, e, gun.profile), false, 'unblocked and 隐匿: no ranged operator can target it');
|
||||
h.run(3);
|
||||
assert.ok(Math.abs(e.x - x0) > 0.5, 'walks its route on');
|
||||
assert.equal(e.hp, hits);
|
||||
h.run(delay - 3.15);
|
||||
assert.ok(e.s.flags.stealth, `still an ember just before ${delay} s`);
|
||||
h.run(0.2);
|
||||
assert.equal(e.s.maxHp, full, 'the warrior again');
|
||||
assert.equal(e.hp, full, 'with full HP');
|
||||
assert.ok(!e.s.flags.stealth && !e.s.flags.disarm && !e.profile.noAttack);
|
||||
assert.ok(h.eventsOf('fx').some((ev) => ev[1] === 'revive' && ev[4].id === e.id && ev[4].form === 'revived'));
|
||||
h.b.kill(e, null);
|
||||
assert.ok(e.alive && e.s.maxHp === hits, 'every knock-out of the warrior starts it again');
|
||||
|
||||
// blocked: the blocker lifts its 隐匿, so the gun hits it: `hits` instances end it for good
|
||||
const h2 = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }, { chessId: 't_wall', row: 9, col: 7 }] });
|
||||
h2.step();
|
||||
const f = put(h2, key, { mods: { speedMul: 1 } });
|
||||
assert.ok(h2.runUntil(() => !!f.blockedBy, 20), 'blocked');
|
||||
h2.b.kill(f, null);
|
||||
h2.run(HUSK_REBIRTH + 0.1);
|
||||
assert.ok(f.blockedBy, 'blocked again once the 重生 is over');
|
||||
assert.ok(canTargetEnemy(h2.unit('t_gun'), f, h2.unit('t_gun').profile), 'blocked: targetable in spite of its 隐匿');
|
||||
assert.ok(h2.runUntil(() => !f.alive, 3 * hits), `beaten by ${hits} gun shots`);
|
||||
assert.equal(f.removeReason, 'killed');
|
||||
assert.equal(h2.hooksOf('kill').filter((c) => c.victim === f).length, 1, 'one kill: the real death');
|
||||
assert.equal(h2.b.killed, 1);
|
||||
assert.equal(h2.eventsOf('die').filter((ev) => ev[1] === f.id).length, 1, 'one die event');
|
||||
checkInvariants(h2.b);
|
||||
});
|
||||
}
|
||||
|
||||
test('an ember counts every damage instance as one hit — physical, arts, true, 元素伤害 (AoE / DoT ticks alike); element 损伤 does not', () => {
|
||||
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS });
|
||||
h.step();
|
||||
const e = put(h, 'enemy_1292_duskld', { mods: { speedMul: 0 } });
|
||||
const g = h.unit('t_gun');
|
||||
h.b.kill(e, g);
|
||||
h.run(HUSK_REBIRTH + 0.05);
|
||||
const n = e.hp;
|
||||
for (const type of ['phys', 'arts', 'true', 'elemental']) h.b.dealDamage(g, e, { amount: 1e6, type, tags: ['aoe'] });
|
||||
h.b.dealDamage(g, e, { type: 'element', element: 'burn', amount: 300 });
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// audit: every other knock-out form of the mode ends in a targetable, killable unit and tells the model its clip set
|
||||
|
||||
describe('audit: the leaders\' 重生 and 守墓石像 — no vanishing, killable afterwards, the model follows', () => {
|
||||
const CASES = [
|
||||
['enemy_1525_blkswb', 'Reborn.duration', 'Reborn.invincible', 'reborn', 'form2'],
|
||||
['enemy_1535_wlfmster', 'Reborn.duration', 'Passive2.invincible_time', 'reborn', 'form2'],
|
||||
['enemy_1539_reid', 'Reborn.duration', 'Reborn.invincible', 'reborn', 'form2'],
|
||||
['enemy_1516_jakill', 'reborn.duration', null, 'reborn', 'form2'],
|
||||
['enemy_1517_xi', 'reborn.duration', 'reborn.invincible', 'reborn', 'form2'],
|
||||
['enemy_1512_mcmstr', 'reborn.duration', null, 'reborn', 'form2'],
|
||||
['enemy_10098_crhro', 'reborn.duration', 'reborn.invincible', 'reborn', 'form2'],
|
||||
['enemy_1172_dugago', 'stone.duration', null, 'stone', 'fly'],
|
||||
['enemy_1172_dugago_2', 'stone.duration', null, 'stone', 'fly'],
|
||||
];
|
||||
for (const [key, durKey, invKey, f1, f2] of CASES) {
|
||||
test(`${key} ${E[key].name}: knock-out ⇒ form '${f1}' (no die event, no kill), then '${f2}' — targetable and killed for good by the next knock-out`, () => {
|
||||
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS });
|
||||
h.step();
|
||||
const e = put(h, key, { pos: [10, 7], mods: { speedMul: 0 } });
|
||||
const gun = h.unit('t_gun');
|
||||
h.b.kill(e, gun);
|
||||
assert.ok(e.alive, 'not removed');
|
||||
const fxOf = (form) => h.eventsOf('fx').some((ev) => ev[4] && ev[4].id === e.id && ev[4].form === form);
|
||||
assert.ok(fxOf(f1), `fx form '${f1}' at the knock-out`);
|
||||
assert.equal(h.eventsOf('die').filter((ev) => ev[1] === e.id).length, 0, 'no die event');
|
||||
assert.equal(h.b.killed, 0, 'no kill');
|
||||
h.run((tb(key, durKey) ?? 0) + (invKey ? tb(key, invKey) ?? 0 : 0) + 0.3);
|
||||
assert.ok(e.alive && fxOf(f2), `fx form '${f2}' when it stands up`);
|
||||
assert.ok(!e.s.flags.untargetable && !e.s.flags.invulnerable, 'targetable and vulnerable again');
|
||||
if (!e.s.flags.stealth && !e.isFlying) assert.ok(canTargetEnemy(gun, e, gun.profile), 'a ranged operator can target it');
|
||||
h.b.kill(e, gun);
|
||||
assert.ok(!e.alive && e.removeReason === 'killed', 'the second knock-out is its death');
|
||||
assert.equal(h.b.killed, 1);
|
||||
assert.equal(h.eventsOf('die').filter((ev) => ev[1] === e.id).length, 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// #5 转译基底·α
|
||||
|
||||
describe('#5 转译基底·α: damage is cancelled until its form change; only the changed form can die', () => {
|
||||
test('悬赏·特异I in a real round (R4) on 战场#01, a sniper + caster + late blocker: no HP lost before the change, the 4th physical hit ⇒ 2 s change ⇒ 寻仇者, then killed for the bounty coin', () => {
|
||||
const wave = buildNormalWave(gd, createRng(5), ['TIMES', 'INVISIBLE', 'FLY'], 4);
|
||||
const card = DATA.choices.cards.bounty.find((c) => c.effectId === 'enemyeffect_16_12');
|
||||
assert.equal(card.enemyKey, TR, 'fixture: the 悬赏 card adds a 转译基底·α');
|
||||
const spawns = withBounties(gd, 4, wave, [{ id: 'b1', card }], 'p1');
|
||||
assert.equal(spawns.filter((s) => s.enemyKey === TR && s.tag === 'bounty').length, 1);
|
||||
const b = realBattle({
|
||||
round: 4, wave, spawns, units: [
|
||||
chess(1, 'chess_char_4_23_b', 9, 3), // 百炼嘉维尔 near the goal
|
||||
chess(2, 'chess_char_6_01_b', 10, 4), // 蕾缪安 (physical)
|
||||
chess(3, 'chess_char_4_02_b', 11, 4), // 莫斯提马 (arts)
|
||||
],
|
||||
});
|
||||
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; }
|
||||
if (ev[0] === 'die' && ev[1] === tr.id) { diedAt = b.time; assert.equal(ev[2], 'killed'); }
|
||||
if (ev[0] === 'atk' && ev[1] === tr.id && formAt == null) earlyAttack = true;
|
||||
}
|
||||
if (!tr || !tr.alive) continue;
|
||||
if (formAt == null && tr.findBuff('ab:form')) formAt = b.time;
|
||||
if (formAt == null) {
|
||||
maxBefore = maxBefore ?? tr.s.maxHp;
|
||||
if (tr.hp !== maxBefore && firstFormLoss == null) firstFormLoss = { t: +b.time.toFixed(2), hp: Math.round(tr.hp), max: Math.round(maxBefore) };
|
||||
}
|
||||
}
|
||||
assert.ok(diedAt != null, 'killed');
|
||||
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();
|
||||
assert.equal(r.perPlayer.p1.coins, card.coin, 'the bounty coin is paid to the player');
|
||||
assert.equal(r.perPlayer.p1.killed, r.perPlayer.p1.total);
|
||||
assert.equal(r.perPlayer.p1.leaked.length, 0);
|
||||
});
|
||||
|
||||
test('original form: every damage instance cancelled; 3 physical + 3 arts change nothing; the 4th physical ⇒ a 2 s change (immobile, still cancelled, immune) ⇒ 寻仇者 stats, melee physical attacks through the engine, ATK +100 % below half HP', () => {
|
||||
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }, { chessId: 't_mage', row: 11, col: 4 }], kits: QUIET_GUNS });
|
||||
h.step();
|
||||
const e = put(h, TR, { mods: { speedMul: 1 } });
|
||||
const g = h.unit('t_gun'), m = h.unit('t_mage');
|
||||
const base = { hp: e.s.maxHp, atk: e.s.atk, def: e.s.def, res: e.s.res, move: e.s.moveSpeed, bat: e.base.bat, mass: e.s.massLevel };
|
||||
for (let i = 0; i < 3; i++) {
|
||||
assert.equal(h.b.dealDamage(g, e, { amount: 1e6, type: 'phys' }), 0);
|
||||
assert.equal(h.b.dealDamage(m, e, { amount: 1e6, type: 'arts' }), 0);
|
||||
}
|
||||
assert.equal(h.b.dealDamage(g, e, { amount: 1e6, type: 'true' }), 0, 'true damage too');
|
||||
assert.equal(e.hp, base.hp);
|
||||
assert.ok(!e.findBuff('ab:change'), '3 + 3 hits: no change yet');
|
||||
for (const s of ['stun', 'sleep', 'cold', 'freeze', 'levitate', 'fear']) assert.equal(h.b.applyStatus(e, s, { duration: 3, source: g }), false, `immune to ${s}`);
|
||||
assert.ok(e.s.flags.noDisplace, '失衡免疫');
|
||||
h.b.dealDamage(g, e, { amount: 10, type: 'phys' });
|
||||
assert.ok(e.findBuff('ab:change'), 'the 4th physical hit starts the change');
|
||||
assert.ok(h.eventsOf('fx').some((ev) => ev[1] === 'phase' && ev[4].id === e.id && ev[4].kind === 'translator_fuchou'));
|
||||
const x0 = e.x;
|
||||
h.run(TRANSLATOR_CHANGE - 0.1);
|
||||
approx(e.x, x0, 1e-9, 'stands still while changing');
|
||||
assert.equal(h.b.dealDamage(g, e, { amount: 1e6, type: 'phys' }), 0, 'still cancelled while changing');
|
||||
assert.ok(!e.findBuff('ab:form'));
|
||||
h.run(0.15);
|
||||
const t = (k) => tb(TR, `Mode_Fuchou_Passive.${k}`);
|
||||
approx(e.s.maxHp, base.hp + t('max_hp'));
|
||||
approx(e.hp, e.s.maxHp, 1e-6, 'full HP');
|
||||
approx(e.s.atk, base.atk + t('atk'));
|
||||
approx(e.s.def, base.def + t('def'));
|
||||
approx(e.s.res, base.res + t('magic_resistance'));
|
||||
approx(e.s.moveSpeed, base.move * (1 + t('move_speed')));
|
||||
approx(e.s.interval, base.bat + t('base_attack_time'));
|
||||
assert.equal(e.s.massLevel, base.mass + t('mass_level'));
|
||||
assert.ok(!e.s.flags.noDisplace, 'the original form\'s immunities are gone');
|
||||
assert.ok(h.b.applyStatus(e, 'stun', { duration: 0.5, source: g }));
|
||||
assert.ok(h.b.dealDamage(g, e, { amount: 100, type: 'true' }) > 0, 'damage lands now');
|
||||
// the 4th arts hit / a block no longer change anything
|
||||
for (let i = 0; i < 4; i++) h.b.dealDamage(m, e, { amount: 1, type: 'arts' });
|
||||
assert.equal(e.findBuff('ab:form').mods.resFlat, t('magic_resistance'), 'one change only');
|
||||
// melee physical attacks on its blocker, through the engine ('atk' event, the attack hooks)
|
||||
const h2 = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }, { chessId: 't_wall', row: 9, col: 7 }], kits: QUIET_GUNS, hooks: ['damaged'], captureNoisy: true });
|
||||
h2.step();
|
||||
const f = put(h2, TR, { mods: { speedMul: 1 } });
|
||||
for (let i = 0; i < 4; i++) h2.b.dealDamage(h2.unit('t_gun'), f, { amount: 1, type: 'phys' });
|
||||
assert.ok(h2.runUntil(() => f.stats.attacks >= 1, 20));
|
||||
const w = h2.unit('t_wall');
|
||||
assert.ok(h2.eventsOf('atk').some((ev) => ev[1] === f.id && ev[2] === w.id && ev[3] === 'none'), 'a melee attack on its blocker');
|
||||
const hit = h2.hooksOf('damaged').find((c) => c.source === f);
|
||||
assert.equal(hit.dmg.type, 'phys');
|
||||
const atk = f.s.atk;
|
||||
h2.b.dealDamage(h2.unit('t_gun'), f, { amount: f.s.maxHp * 0.6, type: 'true' });
|
||||
h2.step();
|
||||
approx(f.s.atk, atk * (1 + tb(TR, 'Mode_Fuchou_Anger.atk')), 1e-6, 'ATK +100 % below half HP');
|
||||
h2.b.heal(null, f, f.s.maxHp);
|
||||
h2.step();
|
||||
approx(f.s.atk, atk, 1e-6, 'and back above half');
|
||||
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();
|
||||
const e = put(h, TR, { pos: [9, 7], mods: { speedMul: 0 } });
|
||||
const base = { hp: e.s.maxHp, res: e.s.res, bat: e.base.bat };
|
||||
for (let i = 0; i < 4; i++) h.b.dealDamage(h.unit('t_mage'), e, { amount: 1, type: 'arts' });
|
||||
assert.ok(h.eventsOf('fx').some((ev) => ev[1] === 'phase' && ev[4].id === e.id && ev[4].kind === 'translator_shushi'));
|
||||
h.run(TRANSLATOR_CHANGE + 0.1);
|
||||
const t = (k) => tb(TR, `Mode_Shushi_Passive.${k}`);
|
||||
approx(e.s.maxHp, base.hp + t('max_hp'));
|
||||
approx(e.s.res, base.res + t('magic_resistance'));
|
||||
approx(e.s.interval, base.bat + t('base_attack_time'));
|
||||
assert.ok(h.runUntil(() => e.stats.attacks >= 1, 10));
|
||||
assert.equal(h.eventsOf('atk').filter((ev) => ev[1] === e.id).length, 2, 'one attack, two shots');
|
||||
h.run(0.6); // the shots land
|
||||
const hits = h.hooksOf('damaged').filter((c) => c.source === e);
|
||||
assert.equal(new Set(hits.map((c) => c.target.id)).size, 2, 'two targets at once');
|
||||
assert.ok(hits.every((c) => c.dmg.type === 'arts'));
|
||||
});
|
||||
|
||||
test('blocked first ⇒ 幽灵: the 2 s change held by its blocker, then unblockable, walks on, never attacks, takes damage', () => {
|
||||
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }, { chessId: 't_wall', row: 9, col: 7 }], kits: QUIET_GUNS });
|
||||
h.step();
|
||||
const e = put(h, TR, { mods: { speedMul: 1 } });
|
||||
assert.ok(h.runUntil(() => !!e.findBuff('ab:change'), 20), 'the block starts the change');
|
||||
assert.ok(h.eventsOf('fx').some((ev) => ev[1] === 'phase' && ev[4].id === e.id && ev[4].kind === 'translator_youling'));
|
||||
assert.ok(e.blockedBy);
|
||||
const hp = e.hp;
|
||||
h.run(TRANSLATOR_CHANGE - 0.1);
|
||||
assert.equal(h.b.dealDamage(h.unit('t_gun'), e, { amount: 1e6, type: 'phys' }), 0, 'damage cancelled while changing');
|
||||
assert.equal(e.hp, hp);
|
||||
h.run(0.3);
|
||||
assert.ok(e.s.flags.unblockable && !e.blockedBy, '幽灵: unblockable, its blocker lets go');
|
||||
const x = e.x;
|
||||
h.run(3);
|
||||
assert.ok(Math.hypot(e.x - x, e.y - 9) > 0.5, 'walks on');
|
||||
assert.ok(h.b.dealDamage(h.unit('t_gun'), e, { amount: 100, type: 'phys' }) > 0, 'damage lands now');
|
||||
assert.equal(e.stats.attacks, 0, '幽灵 never attacks');
|
||||
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');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// second review: a 重生 clears the statuses; a blocked 隐匿 enemy is drawn solid
|
||||
|
||||
describe('a 重生 clears what operators put on the enemy (PRTS 特殊机制 §重生 "清空自身身上除白名单外所有Buff")', () => {
|
||||
test('a 深池逐火战士 knocked out while feared and slowed (叙拉古-style 恐惧 makes it unblockable) is an ember without them: blocked again, beaten by the gun — its own talents stay', () => {
|
||||
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }, { chessId: 't_wall', row: 9, col: 7 }] });
|
||||
h.step();
|
||||
const e = put(h, 'enemy_1288_duskls', { mods: { speedMul: 1 } });
|
||||
const gun = h.unit('t_gun');
|
||||
assert.ok(h.runUntil(() => !!e.blockedBy, 20), 'blocked by the wall');
|
||||
assert.ok(h.b.applyStatus(e, 'fear', { duration: 30, source: gun }), 'feared');
|
||||
assert.ok(h.b.applyStatus(e, 'slow', { duration: 30, source: gun, value: 0.5 }), 'slowed');
|
||||
h.b.addBuff(e, { key: 'test:opDebuff', duration: 30, mods: { defFlat: -50 }, source: gun }); // a skill's debuff
|
||||
assert.ok(e.s.flags.fear && e.s.flags.unblockable && !e.blockedBy, 'the fear releases it');
|
||||
h.b.kill(e, gun);
|
||||
assert.ok(e.alive && e.form === 'husk', 'knocked out: its 重生');
|
||||
for (const k of ['fear', 'slow', 'test:opDebuff']) assert.equal(e.findBuff(k), null, `${k} cleared by the 重生`);
|
||||
assert.ok(e.findBuff('ab:ember') && e.findBuff('ab:hitCount'), 'the ember\'s own buffs');
|
||||
assert.ok(h.eventsOf('status').some((ev) => ev[1] === e.id && ev[2] === 'fear' && ev[3] === 0), 'the fear icon goes');
|
||||
h.run(HUSK_REBIRTH + 0.1);
|
||||
assert.ok(!e.s.flags.fear && !e.s.flags.unblockable && e.s.flags.stealth, 'a 隐匿 ember that can be blocked');
|
||||
assert.ok(h.runUntil(() => !e.alive, 8), 'blocked, it is beaten before it could stand up');
|
||||
assert.equal(e.removeReason, 'killed');
|
||||
assert.ok(h.b.time < 2 + HUSK_REBIRTH + tb('enemy_1288_duskls', 'Revive[Trigger].interval') + 10);
|
||||
});
|
||||
|
||||
test('锏\'s 重生 clears an operator\'s slow and 脆弱 but keeps its 抵抗 (a self-applied talent status); 守墓石像\'s instant 重生 too', () => {
|
||||
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS });
|
||||
h.step();
|
||||
const still = { mods: { speedMul: 0 } };
|
||||
const mace = put(h, 'enemy_1525_blkswb', { pos: [10, 7], ...still });
|
||||
const stone = put(h, 'enemy_1172_dugago', { pos: [11, 7], ...still });
|
||||
const gun = h.unit('t_gun');
|
||||
assert.ok(mace.findBuff('resist'), '锏 has its 抵抗');
|
||||
for (const u of [mace, stone]) {
|
||||
assert.ok(h.b.applyStatus(u, 'slow', { duration: 30, source: gun, value: 0.5 }));
|
||||
assert.ok(h.b.applyStatus(u, 'fragile', { duration: 30, source: gun, value: 0.3 }));
|
||||
h.b.kill(u, gun);
|
||||
assert.ok(u.alive, 'a 重生, not a death');
|
||||
assert.equal(u.findBuff('slow'), null, `${u.defId}: slow cleared`);
|
||||
assert.equal(u.findBuff('fragile'), null, `${u.defId}: 脆弱 cleared`);
|
||||
}
|
||||
assert.ok(mace.findBuff('resist'), '锏 keeps its 抵抗');
|
||||
assert.ok(stone.findBuff('ab:stone'), 'the statue form starts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a blocked 隐匿 enemy is drawn solid (b.snap stealth bit: PRTS 作战机制 §隐匿 "在被阻挡时开关会被关掉")', () => {
|
||||
test('the 逐火 ember: see-through while it walks unblocked, solid while blocked or revealed; an ally keeps its 迷彩 / 隐匿 look while blocking', async () => {
|
||||
const { flagsOf } = await import('../../server/sim/snapshot.js');
|
||||
const { UF } = await import('../../shared/constants.js');
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 7 }] });
|
||||
h.step();
|
||||
const e = put(h, 'enemy_1288_duskls', { mods: { speedMul: 1 } });
|
||||
h.run(0.5);
|
||||
h.b.kill(e, null);
|
||||
h.run(HUSK_REBIRTH + 0.1);
|
||||
assert.ok(e.s.flags.stealth && !e.blockedBy);
|
||||
assert.ok(flagsOf(e) & UF.STEALTH, 'unblocked: see-through');
|
||||
const tuple = () => h.b.snapshot().units.find((t) => t[0] === e.id);
|
||||
assert.ok(tuple()[7] & UF.STEALTH, 'in b.snap too');
|
||||
assert.ok(h.runUntil(() => !!e.blockedBy, 10), 'walks into the wall');
|
||||
assert.ok(e.s.flags.stealth, 'the sim keeps the 隐匿 flag (it returns once the block ends)');
|
||||
assert.equal(flagsOf(e) & UF.STEALTH, 0, 'blocked: drawn solid');
|
||||
assert.equal(tuple()[7] & UF.STEALTH, 0);
|
||||
assert.ok(flagsOf(e) & UF.BLOCKED);
|
||||
const g = put(h, 'enemy_1288_duskls', { pos: [11, 9], mods: { speedMul: 0 } });
|
||||
h.b.kill(g, null);
|
||||
h.run(HUSK_REBIRTH + 0.1);
|
||||
assert.ok(flagsOf(g) & UF.STEALTH);
|
||||
h.b.addBuff(g, { key: 'test:reveal', duration: 5, flags: { reveal: true } });
|
||||
assert.equal(flagsOf(g) & UF.STEALTH, 0, 'revealed: drawn solid');
|
||||
const wall = h.unit('t_wall');
|
||||
h.b.addBuff(wall, { key: 'test:stealth', duration: 5, flags: { stealth: true } });
|
||||
assert.ok(wall.blocking.length > 0 && flagsOf(wall) & UF.STEALTH, 'an ally keeps its 隐匿 look while blocking');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user