feedback1 WD: late views keep the form in the client, 重生 clears statuses, blocked 隐匿 drawn solid
- render/app.js: the views' unit info is built by the exported renderInfo(UnitInfo), which now keeps `form`; addInfo dropped it, so a view built from fieldMeta (a teammate's field watched later, 联防 observers, a reconnect) still drew the first form. Tested through renderInfo (fake PIXI) and the real enterBattle(meta) path in headless Chrome (opt-in). - content/enemies.js: rebirthCleanse at every 重生 (reborn, husk, statue): operator-applied buffs and source-less statuses go, own talents / enemy-given buffs / field state stay (PRTS 特殊机制 §重生). A 逐火 knocked out while feared is no longer an unblockable ember. - snapshot.js: an enemy's stealth bit only while its 隐匿 is on (not blocked, not revealed). - Docs: area damage reaching an unblocked 隐匿 enemy is documented as an engine deviation (PRTS 作战机制 §AOE伤害判定), not as the rule.
This commit is contained in:
1 parent
5c23eeb804
commit
e1ce0316dd
10 files changed
+274
-49
No files matched your search
+4
-2
@@ -204,8 +204,10 @@ The resolver's full precedence list is in the header of `tools/assets/anim-roles
|
||||
|
||||
The manifest roles describe an enemy's first form. Enemies whose skeleton holds another form's clip set get it from
|
||||
`public/js/render/units.js FORMS` (keyed by Spine id, switched by the sim's 'phase' fx kind or the `form` of its
|
||||
'ember' / 'revive' / 'telegraph' / 'stone' fx — or, for a view built mid-battle, UnitInfo `form`; a `change` clip plays
|
||||
once first, an `end` clip is timed from the fx's `dur` to finish as that state ends):
|
||||
'ember' / 'revive' / 'telegraph' / 'stone' fx — 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). 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
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@
|
||||
- **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**重装干员**的手动技能在受到伤害时释放;执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放。**自动触发**的技能按其本身规则,例如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。
|
||||
- **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。
|
||||
- **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。
|
||||
- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡时才能被选为攻击目标:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(范围伤害也能打到),10 秒内没打掉就满血站起来;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。
|
||||
- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡时才能被选为攻击目标:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(挡住时它不再半透明),10 秒内没打掉就满血站起来;敌人重生时身上的减速、恐惧等状态会被清空,被恐惧的逐火战士变成余烬后也能被挡住;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。
|
||||
- 右上角 🔍▶▶ 可以查看本回合即将来袭的敌人(预览区),左上角 🔍 查看本局信息(禁用盟约、策略)。
|
||||
|
||||
## 5. 联防(同盟)
|
||||
|
||||
+12
-6
@@ -229,17 +229,23 @@ a crowd of dying 萨卡兹枯朽战士 totals 50 / s, not 50 × n.
|
||||
|
||||
**Knock-outs that are not deaths** (`content/enemies.js`; player reports after 0.1.0): a `killed` ability that keeps the
|
||||
enemy alive hides the knock-out from every later `kill` handler, the kill count, kill credit and the bounty — they all
|
||||
wait for the real death, the only one with a `die` event. 重生 / form changes: `reborn()` (first knock-out → second
|
||||
wait for the real death, the only one with a `die` event. Every 重生 (`reborn()`, `husk()`, `statue()`) clears what
|
||||
operators put on the enemy — the buffs with an ally source and source-less catalogue statuses (PRTS 特殊机制 §重生
|
||||
"清空自身身上除白名单外所有Buff"; `rebirthCleanse`) — and keeps [ASSUMED: the whitelist] its `persist` talents, what it or
|
||||
another enemy gave it (锏's self-applied 抵抗, enemy auras) and source-less field state (terrain, airflow, element burst
|
||||
locks): a 逐火 knocked out while feared is no unblockable ember. 重生 / form changes: `reborn()` (first knock-out → second
|
||||
form; fx forms 'reborn' → 'form2'), `statue()` (守墓石像: melee only while blocked; first knock-out → 10 s unblockable,
|
||||
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 (area
|
||||
damage still reaches it); 假想敌:再生's 傀儡 is unblockable and, as it begins, shields the other enemies within 1.8; a husk
|
||||
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) and the fx announces it: the `kind` of a form 'phase' fx
|
||||
teammate's field, 联防 observers, a reconnect — starts in it: `render/app.js renderInfo` hands it to the view) and the
|
||||
fx announces it: the `kind` of a form 'phase' fx
|
||||
(crawl, translator_*), else the `form` of the 'ember' ('husk'), 'revive' ('revived' / 'form2' / 'fly'), 'telegraph'
|
||||
('reborn') or 'stone' ('stone') fx — render/units.js FORMS. Barrier / charge 'phase' kinds are no forms.
|
||||
|
||||
@@ -423,7 +429,7 @@ it, so the two players of a pair field compete for one instance instead of multi
|
||||
| `tremble` (战栗) | 被阻挡后无法进行普通攻击: no normal attack **while blocked** (abilities still fire) | – |
|
||||
| `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 | – |
|
||||
@@ -977,7 +983,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te
|
||||
- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: an enemy's current model form — `content/enemies.js setForm` — so a view built mid-battle from `fieldMeta()` starts on that clip set)
|
||||
(`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).
|
||||
|
||||
---
|
||||
|
||||
+31
-16
@@ -12,7 +12,9 @@
|
||||
// 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
|
||||
@@ -243,6 +245,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 +1311,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
// Enemy modes (sim fx 'phase' { id, kind } or another fx's `form` → `setForm(kind, fx)`): 掠海漂移体 dropping to 爬行模式
|
||||
// (user playtest #5 item 1) plays its skeleton's 'Change' clip once, then the crawl set (*_02); 转译基底's forms, the 逐火
|
||||
// embers, 再生's puppet, the leaders' 重生 and 守墓石像 likewise (user report after 0.1.0) — FORMS. A view built later
|
||||
// (UnitInfo `form`, the sim's current form) starts in the mode.
|
||||
// (`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
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
// (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);
|
||||
// husk() — "被击倒时暂时变为…,一段时间后重生" (Revive[Trigger]: every knock-out of the first form ⇒ 1 s 重生 ⇒ a walking
|
||||
// hit-count husk — 隐匿 逐火 embers, the unblockable 再生 puppet — until the real death or its revival); a data-unarmed
|
||||
// hit-count husk — 隐匿 逐火 embers, the unblockable 再生 puppet — until the real death or its revival); every 重生
|
||||
// (reborn / husk / statue) clears the enemy's statuses and the buffs allies gave it (rebirthCleanse, PRTS 特殊机制
|
||||
// §重生 "清空自身身上除白名单外所有Buff"); a data-unarmed
|
||||
// enemy armed by a form (转译基底) sets `e.profile` noAttack / melee / dmgType / maxTargets and attacks through the
|
||||
// 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
|
||||
@@ -43,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.
|
||||
@@ -704,9 +709,26 @@ function freeAllPrisoners(b) {
|
||||
for (const o of b.aliveEnemies()) for (const x of (o.mem.ab ? o.mem.ab.list : [])) if (x.liberate && !x.free) x.liberate(b, o, x);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重生 "清空自身身上除白名单外所有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'. The 'telegraph' fx of the knock-out carries `form: 'reborn'` and the 'revive'
|
||||
@@ -731,6 +753,7 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu
|
||||
a.state = 'reborn';
|
||||
a.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; }
|
||||
@@ -811,11 +834,12 @@ function artsBarrier(amount, { key = 'ab:artsBarrier', whileUp = null } = {}) {
|
||||
|
||||
/**
|
||||
* "被击倒时暂时变为…,一段时间后重生" (talent Revive[Trigger]: 逐火 embers, 假想敌:再生's puppet). A knock-out of the first form is
|
||||
* no kill (credit, bounty and the kill count wait for the real death): HUSK_REBIRTH s of 重生 first — 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)
|
||||
* 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).
|
||||
@@ -848,6 +872,7 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke
|
||||
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);
|
||||
@@ -864,9 +889,10 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke
|
||||
}
|
||||
|
||||
/**
|
||||
* 守墓石像 (PRTS 守墓石像 天赋): 地面模式 — melee attacks only while blocked; the first defeat is an instant 重生 to 100 % HP
|
||||
* 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
|
||||
* 守墓石像 (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.
|
||||
*/
|
||||
@@ -878,6 +904,7 @@ function statue(ab) {
|
||||
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;
|
||||
ab2.immune = new Set([...(ab2.immune || []), 'levitate']);
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// 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';
|
||||
@@ -52,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;
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
// #5 转译基底·α walks on its A_* clips, takes 4 physical hits without losing HP, plays its 2 s A_Die_B change and goes on
|
||||
// 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.
|
||||
// Idle_2 (blocked, so its blocker beats it) and dies on Die_2 — it used to stay an untargetable ember forever;
|
||||
// a view built mid-battle — the real `view.enterBattle(b.fieldMeta())` after a silent catch-up, as for a teammate's
|
||||
// field watched later, 联防 observers or a reconnect — starts in the current forms (UnitInfo `form` → render/app.js
|
||||
// renderInfo): it used to draw the warrior / the A model again (the look of report #5) and 掠海漂移体 hovering.
|
||||
//
|
||||
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/feedback1-forms.browser.test.js
|
||||
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/feedback1-*.png.
|
||||
@@ -76,6 +79,48 @@ async function runInPage(page, port, s, enemyKey, secs, actSrc) {
|
||||
}, s, enemyKey, secs, actSrc);
|
||||
}
|
||||
|
||||
/**
|
||||
* The late view: `spec` runs silently in the page (events drained and dropped, like the client runner's catch-up) while
|
||||
* `setupSrc(b, ally)` changes the enemies' forms; at `atSecs` the field view enters the battle from `b.fieldMeta()` and
|
||||
* follows the feed. Returns { [name]: { clip, form, alive } } of the views of the enemies `setupSrc` returns by name.
|
||||
*/
|
||||
async function lateViewInPage(page, port, s, atSecs, setupSrc) {
|
||||
await page.goto(`http://127.0.0.1:${port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
|
||||
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
|
||||
return page.evaluate(async (spec, atSecs, setupSrc) => {
|
||||
const setup = new Function('b', 'ally', setupSrc);
|
||||
const { loadBrowserSim } = await import('/js/battle/runner.js');
|
||||
const { data } = await import('/js/data.js');
|
||||
const { spec: S, ds } = await loadBrowserSim();
|
||||
const b = S.createBattleFromSpec(spec, ds, { quiet: true });
|
||||
const v = window.__demo.view;
|
||||
v.setStage(data.lookup('stages', spec.stageId));
|
||||
b.step();
|
||||
const named = setup(b, b.allyUnits[0]);
|
||||
while (b.time < atSecs) { b.step(); b.drainEvents(); }
|
||||
const meta = b.fieldMeta();
|
||||
v.enterBattle(meta);
|
||||
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
|
||||
v.setLocalFeed({ on: true, speed: 2 });
|
||||
const raf = () => new Promise((r) => requestAnimationFrame(() => r()));
|
||||
const read = () => Object.fromEntries(Object.entries(named).map(([k, u]) => {
|
||||
const view = v.debug.views.get(u.id);
|
||||
return [k, { clip: view?.actor?.current ?? null, form: view?.form ?? null, spine: !!view?.spineReady, alive: u.alive, simForm: u.form }];
|
||||
}));
|
||||
for (let i = 0; i < 30; i++) {
|
||||
b.step();
|
||||
if (i % 3 === 2) {
|
||||
const ev = b.drainEvents();
|
||||
if (ev.length) v.pushEvents({ t: 'b.ev', fieldId: meta.fieldId, gt: b.time, ev });
|
||||
v.pushSnapshot(b.snapshot());
|
||||
await raf();
|
||||
}
|
||||
if (i === 15) await new Promise((r) => setTimeout(r, 2500)); // the Spine models load
|
||||
}
|
||||
return read();
|
||||
}, s, atSecs, setupSrc);
|
||||
}
|
||||
|
||||
describe('player reports after 0.1.0: the models follow the knock-out forms (headless Chrome, real sim)', { skip }, () => {
|
||||
let srv, browser;
|
||||
before(async () => {
|
||||
@@ -151,4 +196,33 @@ describe('player reports after 0.1.0: the models follow the knock-out forms (hea
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
// 'Die' (its 1 s 重生) and walks as the ember on Idle_2 / Move_2, dying on Die_2, and 'Revive' — timed from the 'ember'
|
||||
// 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';
|
||||
@@ -23,10 +24,11 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
|
||||
const 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());
|
||||
|
||||
@@ -297,26 +299,32 @@ describe('the leaders\' 重生 and 守墓石像 (audit of the knock-out forms af
|
||||
});
|
||||
});
|
||||
|
||||
test('a view built mid-battle from the real sim\'s fieldMeta (fx dropped by a silent catch-up) starts in the current form: the changed 转译基底·α on D_Idle, the 逐火 ember on Idle_2, 锏 after its 重生 on B_Idle', async () => {
|
||||
test('a view built mid-battle from the real sim\'s fieldMeta (fx dropped by a silent catch-up), through render/app.js renderInfo — what enterBattle(meta) hands the views — starts in the current form: the changed 转译基底·α on D_Idle / C_Idle, the 逐火 ember on Idle_2, 锏 after its 重生 on B_Idle, 掠海漂移体 crawling', async () => {
|
||||
const mage = chessRec({ id: 't_mage', profession: 'CASTER', stats: { atk: 50, blockCnt: 0 }, rangeGrid: [[0, 0]], skill: null });
|
||||
const 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 } }, units: [{ chessId: 't_mage', row: 12, col: 2 }], kits: { t_mage: () => ({ trait: { noAttack: true } }) },
|
||||
defs: { chess: { t_mage: mage, t_wall: wall } }, units: [{ chessId: 't_mage', row: 12, col: 2 }, { chessId: 't_wall', row: 9, col: 4 }],
|
||||
kits: { t_mage: quiet, t_wall: quiet },
|
||||
});
|
||||
h.step();
|
||||
const still = { mods: { speedMul: 0 } };
|
||||
const tr = h.b.spawnEnemy('enemy_10081_mpplai', { pos: [9, 8], ...still });
|
||||
const ember = h.b.spawnEnemy('enemy_1288_duskls', { pos: [10, 8], ...still });
|
||||
const mace = h.b.spawnEnemy('enemy_1525_blkswb', { pos: [11, 8], ...still });
|
||||
const 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 = meta.units.find((x) => x.id === u.id);
|
||||
const info = renderInfo(meta.units.find((x) => x.id === u.id));
|
||||
const ctx = fakeViewCtx(fake.P, { assets: store(info.spine), cam });
|
||||
const v = new UnitView(ctx, info);
|
||||
await tick(); await tick();
|
||||
@@ -324,8 +332,20 @@ test('a view built mid-battle from the real sim\'s fieldMeta (fx dropped by a si
|
||||
return v;
|
||||
};
|
||||
assert.equal(clip(await view(tr)), 'D_Idle', '特战术师, not the A model');
|
||||
assert.equal(clip(await view(ember)), 'Idle_2', 'the ember, not the warrior');
|
||||
assert.equal(clip(await view(ghost)), 'C_Idle', '幽灵 (blocked first), not the A model');
|
||||
const e = await view(ember);
|
||||
assert.equal(clip(e), 'Idle_2', 'the ember, not the warrior');
|
||||
e.die();
|
||||
assert.equal(clip(e), 'Die_2', 'and it dies on the ember\'s clip');
|
||||
assert.equal(clip(await view(mace)), 'B_Idle', '锏\'s second form');
|
||||
assert.equal(clip(await view(drift)), 'Idle_02', '掠海漂移体 crawls (DESIGN §19.1: a reload used to hover)');
|
||||
});
|
||||
|
||||
test('render/app.js renderInfo keeps UnitInfo `form` (it used to drop it: views built from fieldMeta drew the first form)', () => {
|
||||
assert.equal(renderInfo({ id: 3, kind: 'enemy', side: 'enemy', defId: 'enemy_1288_duskls', form: 'husk' }).form, 'husk');
|
||||
assert.equal(renderInfo({ id: 3, kind: 'enemy', side: 'enemy', defId: 'enemy_1288_duskls' }).form, undefined);
|
||||
assert.equal(renderInfo({ id: 3, form: 7 }).form, undefined, 'only a string');
|
||||
assert.equal(renderInfo({ kind: 'enemy' }), null, 'no id: no info');
|
||||
});
|
||||
|
||||
test('render/app.js hands the sim\'s fx \'phase\' kind — or the `form` of any fx, with the fx — to the view and keeps the mode on the unit info', () => {
|
||||
|
||||
@@ -492,3 +492,81 @@ test('every enemy starts with the same profile fields and `moving` / `form` (one
|
||||
}
|
||||
assert.equal(shapes.size, 1, 'one key order for every enemy');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 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