From 92282878d3368d918b527bff5bf28e33b00e5bea Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 01:52:40 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feedback1=20WD:=20=E9=80=90=E7=81=AB=20embe?= =?UTF-8?q?rs=20can=20be=20beaten,=20=E8=BD=AC=E8=AF=91=E5=9F=BA=E5=BA=95?= =?UTF-8?q?=20changes=20form=20before=20it=20can=20die?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Player reports after 0.1.0: - #8 深池逐火战士 / 精锐战士 / 护卫: every knock-out is a 1 s 重生 (invulnerable, unblockable, immobile), then a walking, disarmed, 隐匿 hit-count ember that a blocker can target and beat; it stands up with full HP after 10 s (PRTS talent text). The old ember stood still, stealthed and unblockable, so nobody could ever target it and every 逐火 enemy leaked. 假想敌:再生's puppet walks on and stays unblockable. - #5 转译基底·α: its original form cancels every damage instance; the 4th physical / arts hit or a block starts a 2 s change into 寻仇者 / 特战术师 / 幽灵 (Mode_*_Passive, 寻仇者 rage below half HP, 特战术师 two targets). The armed forms attack through the engine (profile.dmgType). It used to die in its first form. - Render: units.js FORMS switch the models' clip sets (A_Die_B/C/D then B/C/D, Die then Idle_2/Move_2/Die_2, Revive), driven by the 'phase' kind or the fx `form`; a noMove enemy is drawn standing. --- docs/ASSETS.md | 6 + docs/SIM.md | 13 +- public/js/render/app.js | 15 +- public/js/render/units.js | 45 +++- server/sim/ai.js | 12 +- server/sim/content/enemies.js | 205 +++++++++------ test/content/enemies_bosses.test.js | 32 ++- test/render/forms.test.js | 100 +++++++- test/sim/feedback1-transform.test.js | 360 +++++++++++++++++++++++++++ 9 files changed, 676 insertions(+), 112 deletions(-) create mode 100644 test/sim/feedback1-transform.test.js diff --git a/docs/ASSETS.md b/docs/ASSETS.md index bbad32e8..1702ac31 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -202,6 +202,12 @@ 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 sim's 'phase' fx kind or the `form` of its +'ember' / 'revive' fx; a `change` clip plays once first): 掠海漂移体's crawl (`Change`, `*_02`), 转译基底·α's three forms +(`A_Die_B` / `_C` / `_D`, 2 s each, then `B_*` 寻仇者, `C_*` 幽灵, `D_*` 特战术师), the 深池逐火 embers (`Die`, then +`Idle_2` / `Move_2` / `Die_2`; `Revive` back) and 假想敌:再生's puppet (`A_Die`, `B_*`, `B_Revive`). + 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`. diff --git a/docs/SIM.md b/docs/SIM.md index 3894bb29..cb23f25d 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -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,16 @@ 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. 重生 / form changes: `reborn()` (first knock-out → second +form), `husk()` (talent Revive[Trigger], every knock-out: 1 s 重生 — 无敌, 无法阻挡, immobile — then a hit-count husk that +walks its route on: the 深池逐火 余烬 / 火灰 are 隐匿 and disarmed, so only a blocked one can be targeted and beaten; 假想敌: +再生's 傀儡 is unblockable; a husk still standing after `Revive[Trigger].interval` s stands up again with full HP), +转译基底·α (its original form cancels every damage instance; the 4th physical / arts instance or a block starts a 2 s +change). The model's clip set follows a 'phase' fx kind or the `form` of the 'ember' / 'revive' fx (render/units.js +FORMS). + **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. diff --git a/public/js/render/app.js b/public/js/render/app.js index 7d87e9a2..67847013 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -1420,12 +1420,15 @@ export async function createFieldView(host, options = {}) { 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) { - 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); + // an enemy's mode change (掠海漂移体 → 爬行模式, user playtest #5 item 1; 转译基底's forms, a 逐火 ember and its + // revival — user report after 0.1.0): a 'phase' kind, or the `form` any other fx carries, switches the view's + // clip set (UnitView.setForm); the info keeps it for a view built later + const ex4 = e[4] && typeof e[4] === 'object' && e[4].id != null ? e[4] : null; + const form = !ex4 ? undefined : e[1] === 'phase' ? ex4.kind : 'form' in ex4 ? ex4.form : undefined; + if (form !== undefined) { + const inf = infos.get(ex4.id); + if (inf) inf.form = typeof form === 'string' ? form : null; + views.get(ex4.id)?.setForm?.(form); } fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]); break; diff --git a/public/js/render/units.js b/public/js/render/units.js index c2dcccb5..d698a863 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -100,21 +100,42 @@ 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; 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. + * 吉兆飞鳞's 晕眩模式 is its Stun clip already. */ +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', roles: clipSet('Idle_2', 'Move_2', 'Die_2') }), + revived: Object.freeze({ change: 'Revive', roles: Object.freeze({}) }), +}); 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', roles: clipSet('B_Idle', 'B_Move', 'B_Die') }), + revived: Object.freeze({ change: 'B_Revive', roles: Object.freeze({}) }), }), }); diff --git a/server/sim/ai.js b/server/sim/ai.js index da6089ac..373d95b1 100644 --- a/server/sim/ai.js +++ b/server/sim/ai.js @@ -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 diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index 6b4bc47a..285da740 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -24,7 +24,10 @@ // 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); 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 @@ -51,11 +54,12 @@ // // 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'. +// 'stone' {id, form?} · 'blink' {id, fx, fy} · 'charge' {id, tx, ty} · 'expose' {id} · 'shieldBreak' {id} · 'liberate' {id} +// · 'phase' {id, kind} (form / barrier changes) · 'lpLoss' {value, reason} · 'steal' · 'ignite'. A 'phase' kind, or +// the `form` of any other fx, is the model's clip set from then on (render/app.js → UnitView.setForm, units.js FORMS). // 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 +73,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 深池逐火战士 "被击倒后重生,持续1s"; the same talent template on + * 假想敌:再生, whose PRTS page gives no duration — 1 s there too [ASSUMED]; both models' knock-out clips last 1 s). */ +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, @@ -720,33 +731,6 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu }; } -/** - * 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 { @@ -809,33 +793,53 @@ 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 — 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) + * 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 ("一次又一次地站起"). + * User report after 0.1.0 (#8): the v2.5 ember stood still, stealthed AND unblockable — nobody could ever target it. + * fx: 'ember' {id, hits, dur, form: 'husk'} at the knock-out, 'revive' {id, form: 'revived'} when it stands up (the + * renderer switches the model's clip set: render/units.js FORMS). + */ +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; + b.fx('revive', { x: e.x, y: e.y, id: e.id, form: 'revived' }); + }; 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; e.profile.noAttack = true; 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 }); + b.addBuff(e, { key, visible: true, persist: true, flags: { disarm: true, ...(stealthy ? { stealth: true } : {}), ...(unblock ? { unblockable: true } : {}) } }); + b.addBuff(e, { key: `${key}:reborn`, duration: HUSK_REBIRTH, flags: { invulnerable: true, untargetable: true, unblockable: true, noMove: true, noDisplace: true } }); + if (e.route) e.route.pts = null; + b.fx('ember', { x: e.x, y: e.y, id: e.id, hits, dur: HUSK_REBIRTH + delay, form: 'husk' }); if (onHusk) onHusk(b, e); - 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.after(HUSK_REBIRTH + delay, () => revive(b, e, a), { owner: e }); return true; }, }; @@ -948,6 +952,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) { @@ -1420,10 +1427,13 @@ function kitBlackCloud(ab, e) { ]; } +/** 假想敌:再生 · knock-out ⇒ a 傀儡 of prop_max_hp hits for `interval` s that "不可被阻挡" (data description) and walks on — its + * model has a 傀儡 walk cycle (B_Move); an immobile unit would not need to be unblockable [ASSUMED from both] — plus + * max_damage_block_cnt-hit shields on the enemies around it. */ 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) { // 被击倒后…使周围一定距离内其他敌人获得可以抵挡物理及法术伤害的护盾 b.fx('telegraph', { x: e.x, y: e.y, r: ACPUPP_AURA_RADIUS, kind: 'regenShield', id: e.id }); @@ -2048,32 +2058,75 @@ 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; + * 幽灵 (form C): no stat change, unblockable, no attack (PRTS lists none; its model has no attack clip). + * The armed forms attack through the engine's enemy attack (ai.js enemyAttack, `profile.dmgType`), so attack clips, + * projectiles and the attack hooks are the normal ones. Before its change ends it cannot be killed by damage at all — + * user report after 0.1.0 (#5): the v2.5 kit let damage through, so it died in its first form, its model still in that + * form playing the 寻仇者's B_Die. fx 'phase' {id, kind: translator_fuchou | _shushi | _youling} starts the model's 2 s + * 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 幽灵. + */ 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) }); + b.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: `translator_${form}`, 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; }, 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'); + }, }]; } @@ -2154,10 +2207,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 nearby // --- ELEMENT 元素 enemy_1148_dssbr: kitEp('neural', 'epdamage.attack@ep_damage_ratio'), // 底海滑动者 · neural on hit @@ -2466,7 +2519,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) enemy_2085_skzjxd: (ab) => [frontGuard(T(ab, 'Weakness.damage_resistance') ?? 0, faceCrowd)], // 圆仔 · faces the bigger crowd, front damage −80 % enemy_2085_skzjxd_2: (ab) => [frontGuard(T(ab, 'Weakness.damage_resistance') ?? 0, faceCrowd)], // 圆仔 · same diff --git a/test/content/enemies_bosses.test.js b/test/content/enemies_bosses.test.js index 36ef0d71..fd0257e4 100644 --- a/test/content/enemies_bosses.test.js +++ b/test/content/enemies_bosses.test.js @@ -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); }); // --------------------------------------------------------------------------------------------------------------- @@ -1831,30 +1839,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); }); diff --git a/test/render/forms.test.js b/test/render/forms.test.js index 35af0d74..a1bbbcb0 100644 --- a/test/render/forms.test.js +++ b/test/render/forms.test.js @@ -3,6 +3,10 @@ // 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' brings the warrior back +// (sim fx 'ember' / 'revive' { form: 'husk' | 'revived' }); 假想敌:再生's 傀儡 the same with A_Die / B_* / B_Revive. import { test, describe, before, after } from 'node:test'; import assert from 'node:assert/strict'; @@ -116,7 +120,99 @@ 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; revived ⇒ 'Revive', then the warrior's clips`, async () => { + const anims = assets.enemies[id].spine.animations; + for (const name of ['Die', 'Idle_2', 'Move_2', 'Die_2', 'Revive']) assert.ok(name in anims, name); + const v = await enemy(id); + v.sync(sample(0, ANIM.MOVE), 1); + assert.equal(clip(v), 'Move'); + v.setForm('husk'); + assert.equal(clip(v), 'Die', 'the knock-out (its 1 s 重生)'); + frames(v, 65); + assert.equal(clip(v), 'Move_2', 'the ember walks'); + v.sync(sample(UF.STEALTH, ANIM.IDLE), 2); + assert.equal(clip(v), 'Idle_2'); + v.setForm('revived'); + assert.equal(clip(v), 'Revive'); + frames(v, 80); + assert.equal(clip(v), 'Idle', 'the warrior again'); + v.onAttack(null, 4); + assert.equal(clip(v), 'Attack'); + v.setForm('husk'); + frames(v, 65); + v.die(); + assert.equal(clip(v), 'Die_2', 'the ember dies on its own clip'); + }); + } + + 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'); + 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'); + v.setForm('revived'); + assert.equal(clip(v), 'B_Revive'); + frames(v, 65); + assert.equal(clip(v), 'A_Move'); + }); +}); + +test('render/app.js hands the sim\'s fx \'phase\' kind — or the `form` of any fx — to the view and keeps the mode on the unit info', () => { 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, /e\[1\] === 'phase' \? ex4\.kind : 'form' in ex4 \? ex4\.form[\s\S]{0,200}inf\.form = [\s\S]{0,120}setForm\?\.\(form\)/); }); diff --git a/test/sim/feedback1-transform.test.js b/test/sim/feedback1-transform.test.js new file mode 100644 index 00000000..35db61ef --- /dev/null +++ b/test/sim/feedback1-transform.test.js @@ -0,0 +1,360 @@ +// 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. + +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 } 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'); + } + } + 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); + }); +}); + +// --------------------------------------------------------------------------------------------------------------- +// #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 n = 0; + while (!b.finished && n++ < 30 * 300) { + b.step(); + tr = tr ?? b.enemies.find((e) => e.defId === TR) ?? 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 ⇒ 寻仇者'); + 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('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); + }); +}); From 73e8bdcd87f15c60d6ab5694d5bb78bb6951baa6 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 02:01:35 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feedback1=20WD:=20knock-out=20forms=20audit?= =?UTF-8?q?=20=E2=80=94=20leaders'=20=E9=87=8D=E7=94=9F=20clips,=20?= =?UTF-8?q?=E5=AE=88=E5=A2=93=E7=9F=B3=E5=83=8F=20modes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reborn() tells the model its forms ('reborn' then 'form2' on the existing fx): 锏 and 扎罗 hold their revive clips and switch to their B_* set (both used to stay on the A model and die on B_Die), “复仇者” plays Revive_Begin/Loop/End, 杰斯顿 its C1_Die then the C2_* killer form. - 守墓石像 per its PRTS talent: melee only while blocked; the first defeat is an instant 重生 into a 10 s unblockable, immobile, 失衡/浮空-immune statue (Sleep clip), then a flyer with ranged arts attacks that skip flyers, without a second HP refill. - A form kind the skeleton has no clip set for (arts barrier, broken charge) no longer resets a model's form. --- docs/SIM.md | 4 +- public/js/render/app.js | 9 ++-- public/js/render/units.js | 33 +++++++++++++-- server/sim/content/enemies.js | 32 +++++++++------ test/content/enemies_bosses.test.js | 20 ++++++++- test/render/forms.test.js | 61 ++++++++++++++++++++++++++++ test/sim/feedback1-transform.test.js | 39 ++++++++++++++++++ 7 files changed, 175 insertions(+), 23 deletions(-) diff --git a/docs/SIM.md b/docs/SIM.md index cb23f25d..608b0771 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -230,7 +230,9 @@ 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 -form), `husk()` (talent Revive[Trigger], every knock-out: 1 s 重生 — 无敌, 无法阻挡, immobile — then a hit-count husk that +form; fx forms 'reborn' → 'form2'), `statue()` (守墓石像: melee only while blocked; first knock-out → 10 s unblockable, +immobile statue → a flyer with ranged arts attacks that skip flyers; forms 'stone' → 'fly'), `husk()` (talent +Revive[Trigger], every knock-out: 1 s 重生 — 无敌, 无法阻挡, immobile — then a hit-count husk that walks its route on: the 深池逐火 余烬 / 火灰 are 隐匿 and disarmed, so only a blocked one can be targeted and beaten; 假想敌: 再生's 傀儡 is unblockable; a husk still standing after `Revive[Trigger].interval` s stands up again with full HP), 转译基底·α (its original form cancels every damage instance; the 4th physical / arts instance or a block starts a 2 s diff --git a/public/js/render/app.js b/public/js/render/app.js index 67847013..c71fd17d 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -90,7 +90,7 @@ import { GEO, ANIM, UF } from '../../../shared/constants.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'; @@ -1421,13 +1421,14 @@ export async function createFieldView(host, options = {}) { if (consumedIds.size > 200) consumedIds.delete(consumedIds.values().next().value); } // an enemy's mode change (掠海漂移体 → 爬行模式, user playtest #5 item 1; 转译基底's forms, a 逐火 ember and its - // revival — user report after 0.1.0): a 'phase' kind, or the `form` any other fx carries, switches the view's - // clip set (UnitView.setForm); the info keeps it for a view built later + // revival, the leaders' 重生 — user report after 0.1.0): a 'phase' kind, or the `form` any other fx carries, + // 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 const ex4 = e[4] && typeof e[4] === 'object' && e[4].id != null ? e[4] : null; const form = !ex4 ? undefined : e[1] === 'phase' ? ex4.kind : 'form' in ex4 ? ex4.form : undefined; if (form !== undefined) { const inf = infos.get(ex4.id); - if (inf) inf.form = typeof form === 'string' ? form : null; + if (inf && (typeof form !== 'string' || FORMS[inf.spine || inf.defId]?.[form])) inf.form = typeof form === 'string' ? form : null; views.get(ex4.id)?.setForm?.(form); } fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]); diff --git a/public/js/render/units.js b/public/js/render/units.js index d698a863..5e9c6dc1 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -108,8 +108,12 @@ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, g * 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; 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. - * 吉兆飞鳞's 晕眩模式 is its Stun clip already. + * - 假想敌:再生: knocked out → A_Die, the 傀儡 on B_Idle / B_Move / B_Die; back → B_Revive, then the A_* 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); + * - 守墓石像 (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({ @@ -121,6 +125,16 @@ const EMBER = Object.freeze({ husk: Object.freeze({ change: 'Die', roles: clipSet('Idle_2', 'Move_2', 'Die_2') }), revived: Object.freeze({ change: 'Revive', roles: Object.freeze({}) }), }); +/** A 重生 held on `hold` (also while the sim reports the rebirth's stun) after `begin`, then `end` and the second form. */ +const rebirth = (begin, hold, end, form2 = Object.freeze({})) => Object.freeze({ + reborn: Object.freeze({ change: begin, roles: Object.freeze({ idle: hold, deploy: hold, move: loop(hold), stun: loop(hold), attack: null, skill: null }) }), + form2: Object.freeze({ change: end, roles: form2 }), +}); +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: clipSet('Idle_02', 'Move_02', 'Die_02', 'Attack_02') }), @@ -137,6 +151,15 @@ export const FORMS = Object.freeze({ husk: Object.freeze({ change: 'A_Die', roles: clipSet('B_Idle', 'B_Move', 'B_Die') }), revived: Object.freeze({ change: 'B_Revive', roles: Object.freeze({}) }), }), + 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, }); /** @@ -363,12 +386,14 @@ 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 (sim fx 'phase' { id, kind } or another fx's `form`): the mode's clip set (FORMS) after its + * change clip; null goes back to the manifest clips; a kind this skeleton has no clip set for (an arts barrier, a + * broken charge …) changes nothing. Kept for a model built later. */ setForm(kind) { 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; diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index 285da740..230cf841 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -694,7 +694,8 @@ function freeAllPrisoners(b) { * a rebirth of `dur` s — 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) => { @@ -707,7 +708,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' }); + b.fx('revive', { x: e.x, y: e.y, id: e.id, kind: 'reborn', form: 'form2' }); }; return { killed(c, b, e, a) { @@ -725,7 +726,7 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu onTick: during ? ({ battle }) => during(battle, e, a, battle.time - a.t0) : null, onExpire: ({ battle }) => finish(battle, e, a), }); - b.fx('telegraph', { x: e.x, y: e.y, r: 1, dur, kind: 'reborn', id: e.id }); + b.fx('telegraph', { x: e.x, y: e.y, r: 1, dur, kind: 'reborn', id: e.id, form: 'reborn' }); return true; }, }; @@ -845,28 +846,35 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke }; } -/** 守墓石像: first knock-out → statue (DEF/RES up, immobile) → reborn as a flyer. */ +/** + * 守墓石像 (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 + * 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; 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']); + b.fx('stone', { x: e.x, y: e.y, id: e.id, dur, form: 'stone' }); 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' }); + battle.fx('revive', { x: e.x, y: e.y, id: e.id, kind: 'fly', form: 'fly' }); }, }); return true; @@ -2279,7 +2287,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 diff --git a/test/content/enemies_bosses.test.js b/test/content/enemies_bosses.test.js index fd0257e4..04163c67 100644 --- a/test/content/enemies_bosses.test.js +++ b/test/content/enemies_bosses.test.js @@ -648,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'); }); diff --git a/test/render/forms.test.js b/test/render/forms.test.js index a1bbbcb0..7e4165fd 100644 --- a/test/render/forms.test.js +++ b/test/render/forms.test.js @@ -212,6 +212,67 @@ describe('逐火 embers and the 再生 puppet (user report after 0.1.0, #8)', () }); }); +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); + v.setForm('reborn'); + assert.equal(clip(v), begin); + frames(v, Math.ceil(anims[begin] * 60) + 5); + assert.equal(clip(v), hold, 'held (its stun role) while the sim keeps it stunned'); + v.sync(sample(0, ANIM.IDLE), 6); + v.setForm('form2'); + assert.equal(clip(v), end); + frames(v, Math.ceil(anims[end] * 60) + 5); + assert.equal(clip(v), idle2); + 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('render/app.js hands the sim\'s fx \'phase\' kind — or the `form` of any fx — to the view and keeps the mode on the unit info', () => { const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8'); assert.match(src, /e\[1\] === 'phase' \? ex4\.kind : 'form' in ex4 \? ex4\.form[\s\S]{0,200}inf\.form = [\s\S]{0,120}setForm\?\.\(form\)/); diff --git a/test/sim/feedback1-transform.test.js b/test/sim/feedback1-transform.test.js index 35db61ef..64761e08 100644 --- a/test/sim/feedback1-transform.test.js +++ b/test/sim/feedback1-transform.test.js @@ -211,6 +211,45 @@ describe('#8 深池逐火: a knock-out is a walking 隐匿 ember that a blocker }); }); +// --------------------------------------------------------------------------------------------------------------- +// 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 转译基底·α From 06be81f67e5721628015848c667a368d75de9b37 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 02:07:59 +0800 Subject: [PATCH 3/6] feedback1 WD: a form change clip plays out; browser check of the forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - render/spine.js: no attack (wind-up or 'atk') cuts a form's change clip short; a knocked-out 逐火 no longer reports its last attack in the snapshot. - test/render/feedback1-forms.browser.test.js (opt-in RENDER_E2E=1): the real sim in headless Chrome on 战场#01 — 转译基底·α A_* → A_Die_B → B_* → B_Die, a 深池逐火战士 knocked out in front of its blocker: Die → the 隐匿 ember on Idle_2, beaten → Die_2. - PLAYING.md: the 余烬 and 转译基底 rules for players. --- docs/PLAYING.md | 2 +- public/js/render/spine.js | 5 +- server/sim/content/enemies.js | 1 + test/render/feedback1-forms.browser.test.js | 154 ++++++++++++++++++++ 4 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 test/render/feedback1-forms.browser.test.js diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 8774fff9..4e562a30 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -73,7 +73,7 @@ - **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**重装干员**的手动技能在受到伤害时释放;执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放。**自动触发**的技能按其本身规则,例如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。 - **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。 - **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。 -- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。 +- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡时才能被选为攻击目标:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),要用干员挡住它才能打掉,10 秒内没打掉就满血站起来;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。 - 右上角 🔍▶▶ 可以查看本回合即将来袭的敌人(预览区),左上角 🔍 查看本局信息(禁用盟约、策略)。 ## 5. 联防(同盟) diff --git a/public/js/render/spine.js b/public/js/render/spine.js index eb19706d..9956d0a3 100644 --- a/public/js/render/spine.js +++ b/public/js/render/spine.js @@ -13,6 +13,7 @@ // die() die clip once (callers fade out afterwards) // stunned (setBase('stun')) stun clip, or the current track frozen at timeScale 0 // setForm(roles, change) another clip set of the skeleton (an enemy's mode), after a change clip +// (no attack cuts the change clip short) // 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. @@ -212,7 +213,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 +233,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; diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index 230cf841..c1347f23 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -833,6 +833,7 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke a.noAtk = e.profile.noAttack; a.max = a.max ?? e.base.maxHp; 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, persist: true, flags: { disarm: true, ...(stealthy ? { stealth: true } : {}), ...(unblock ? { unblockable: true } : {}) } }); diff --git a/test/render/feedback1-forms.browser.test.js b/test/render/feedback1-forms.browser.test.js new file mode 100644 index 00000000..98044fbc --- /dev/null +++ b/test/render/feedback1-forms.browser.test.js @@ -0,0 +1,154 @@ +// 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. +// +// 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); +} + +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 + LAG && x.t < state.hit + 1.8); + 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(); + } + }); +}); From 5c23eeb80433df3316814dc102d1763ba59823fc Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 03:02:46 +0800 Subject: [PATCH 4/6] feedback1 WD: forms reach late views, stable enemy shapes, review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Every form change goes through content setForm(): the unit keeps it (e.form) and UnitInfo publishes it, so a view built mid-battle from fieldMeta (a watched teammate's field, 联防 observers, a reconnect) starts in the current form instead of the first-form model. - Enemy profile (dmgType) and Unit moving / form are initialised at spawn: one object shape for the sim's hot loop (the boss-field bench is back at the base speed). - 转译基底·α: an HP loss cannot knock out its original form (no death clip). - 假想敌:再生: 1 s 重生 and walking 傀儡 cited from PRTS; shields within 1.8 as the 再生状态 begins. - Renderer: a 重生's closing clip (and the ember's stand-up clip) is timed from the fx dur to end with it, so the next form attacks on its own clips. - Docs: ASSETS FORMS list, SIM form fx, PLAYING 隐匿 wording. --- docs/ASSETS.md | 15 ++++- docs/PLAYING.md | 2 +- docs/SIM.md | 16 +++-- public/js/render/app.js | 2 +- public/js/render/spine.js | 47 ++++++++++---- public/js/render/units.js | 41 +++++++----- server/sim/Battle.js | 4 +- server/sim/content/enemies.js | 79 ++++++++++++++-------- server/sim/snapshot.js | 6 +- server/sim/units.js | 2 + test/render/forms.test.js | 95 +++++++++++++++++++++------ test/sim/feedback1-transform.test.js | 97 +++++++++++++++++++++++++++- 12 files changed, 318 insertions(+), 88 deletions(-) diff --git a/docs/ASSETS.md b/docs/ASSETS.md index 1702ac31..d63bef74 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -204,9 +204,18 @@ The resolver's full precedence list is in the header of `tools/assets/anim-roles The manifest roles describe an enemy's first form. Enemies whose skeleton holds another form's clip set get it from `public/js/render/units.js FORMS` (keyed by Spine id, switched by the sim's 'phase' fx kind or the `form` of its -'ember' / 'revive' fx; a `change` clip plays once first): 掠海漂移体's crawl (`Change`, `*_02`), 转译基底·α's three forms -(`A_Die_B` / `_C` / `_D`, 2 s each, then `B_*` 寻仇者, `C_*` 幽灵, `D_*` 特战术师), the 深池逐火 embers (`Die`, then -`Idle_2` / `Move_2` / `Die_2`; `Revive` back) and 假想敌:再生's puppet (`A_Die`, `B_*`, `B_Revive`). +'ember' / 'revive' / 'telegraph' / 'stone' fx — or, for a view built mid-battle, UnitInfo `form`; a `change` clip plays +once first, an `end` clip is timed from the fx's `dur` to finish as that state ends): +- 掠海漂移体's crawl (`Change`, then `*_02`); +- 转译基底·α's three forms (`A_Die_B` / `_C` / `_D`, 2 s each, then `B_*` 寻仇者, `C_*` 幽灵, `D_*` 特战术师); +- the 深池逐火 embers (`Die`, then `Idle_2` / `Move_2` / `Die_2`; `Revive` ends as it stands up) and 假想敌:再生's puppet + (`A_Die`, then `B_*`; `B_Revive`); +- the leaders' 重生: 锏 (`Revive1`, `Revive2` held, `Revive3`, then `B_*`), 扎罗 (`A_revive_1` / `_2` / `_3`, then `B_*`), + “复仇者” (`Revive_Begin` / `_Loop` / `_End`), 杰斯顿 (`C1_Die`, then `C2_*`); +- 守墓石像 (the statue on `Sleep` [ASSUMED by name], then the flyer's `*_2`). + +Not mapped (clip names ambiguous): “自在”, “巨大的丑东西”, 主角阵营角色 and “余音” (`*_A` / `*_B`: which of its two forms is A +is not known) keep their manifest clips. Other renderer rules from research 07 §5.4–5.5: - **Choosing the model:** Front when the unit faces right or down; Front mirrored when facing left; Back when facing up. diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 4e562a30..ae780089 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -73,7 +73,7 @@ - **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**重装干员**的手动技能在受到伤害时释放;执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放。**自动触发**的技能按其本身规则,例如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。 - **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。 - **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。 -- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡时才能被选为攻击目标:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),要用干员挡住它才能打掉,10 秒内没打掉就满血站起来;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。 +- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡时才能被选为攻击目标:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(范围伤害也能打到),10 秒内没打掉就满血站起来;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。 - 右上角 🔍▶▶ 可以查看本回合即将来袭的敌人(预览区),左上角 🔍 查看本局信息(禁用盟约、策略)。 ## 5. 联防(同盟) diff --git a/docs/SIM.md b/docs/SIM.md index 608b0771..d1fc8ef8 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -233,11 +233,15 @@ wait for the real death, the only one with a `die` event. 重生 / form changes: form; fx forms 'reborn' → 'form2'), `statue()` (守墓石像: melee only while blocked; first knock-out → 10 s unblockable, immobile statue → a flyer with ranged arts attacks that skip flyers; forms 'stone' → 'fly'), `husk()` (talent Revive[Trigger], every knock-out: 1 s 重生 — 无敌, 无法阻挡, immobile — then a hit-count husk that -walks its route on: the 深池逐火 余烬 / 火灰 are 隐匿 and disarmed, so only a blocked one can be targeted and beaten; 假想敌: -再生's 傀儡 is unblockable; a husk still standing after `Revive[Trigger].interval` s stands up again with full HP), -转译基底·α (its original form cancels every damage instance; the 4th physical / arts instance or a block starts a 2 s -change). The model's clip set follows a 'phase' fx kind or the `form` of the 'ember' / 'revive' fx (render/units.js -FORMS). +walks its route on: the 深池逐火 余烬 / 火灰 are 隐匿 and disarmed, so only a blocked one can be targeted by operators (area +damage still reaches it); 假想敌:再生's 傀儡 is unblockable and, as it begins, shields the other enemies within 1.8; a husk +still standing after `Revive[Trigger].interval` s stands up again with full HP), +转译基底·α (its original form cancels every damage instance, and an HP loss stops at 1 HP; the 4th physical / arts +instance or a block starts a 2 s change). Each form change goes through `setForm(b, e, form, fxKind, params)`: the +unit keeps it (`e.form`, published as UnitInfo `form`, so a view built mid-battle from `fieldMeta()` — a watched +teammate's field, 联防 observers, a reconnect — starts in it) and the fx announces it: the `kind` of a form 'phase' fx +(crawl, translator_*), else the `form` of the 'ember' ('husk'), 'revive' ('revived' / 'form2' / 'fly'), 'telegraph' +('reborn') or 'stone' ('stone') fx — render/units.js FORMS. Barrier / charge 'phase' kinds are no forms. **Ownership** (`enemy.ownerId`, used for `killed/total` and leak attribution): `ownerPlayerId` if given, else the player whose half contains the spawn tile (cols ≥ 11 = right half / player with colOffset 8 or side R). A leak is @@ -970,7 +974,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te `['heal', tgt, amount]`, `['skill', id, 1|0]`, `['die', id, reason]`, `['leak', id]`, `['status', id, key, 1|0]`, `['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing), `['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`. -- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, skillIndex? }` (`skillIndex`: an ally's equipped skill, DESIGN §16) +- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: an enemy's current model form — `content/enemies.js setForm` — so a view built mid-battle from `fieldMeta()` starts on that clip set) (`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping) (`spine`/`avatar` are asset ids from data). - flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512); diff --git a/public/js/render/app.js b/public/js/render/app.js index c71fd17d..d827a837 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -1429,7 +1429,7 @@ export async function createFieldView(host, options = {}) { if (form !== undefined) { const inf = infos.get(ex4.id); if (inf && (typeof form !== 'string' || FORMS[inf.spine || inf.defId]?.[form])) inf.form = typeof form === 'string' ? form : null; - views.get(ex4.id)?.setForm?.(form); + views.get(ex4.id)?.setForm?.(form, ex4); } fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]); break; diff --git a/public/js/render/spine.js b/public/js/render/spine.js index 9956d0a3..40dcf725 100644 --- a/public/js/render/spine.js +++ b/public/js/render/spine.js @@ -12,8 +12,10 @@ // deploy() 'Start' once, then base // die() die clip once (callers fade out afterwards) // stunned (setBase('stun')) stun clip, or the current track frozen at timeScale 0 -// setForm(roles, change) another clip set of the skeleton (an enemy's mode), after a change clip -// (no attack cuts the change clip short) +// setForm(roles, change, end) another clip set of the skeleton (an enemy's mode), after a change clip +// (no attack cuts the change clip short); `end` = { clip, in, roles? }: a +// closing clip timed to end `in` s from now (a 重生's last clip ends with the +// 重生), landing in `roles` // update(dt) advances the skeleton (autoUpdate is off: one clock for everything) // Attack mode lasts until ~1.4 attack intervals without a new attack, then the end clip (if any) and base. @@ -81,7 +83,10 @@ export class SpineActor { } try { this.spine.stateData.defaultMix = 0.12; } catch { /* ignore */ } this.base = 'idle'; - this.mode = 'base'; // base | attack | skillBegin | deploy | die | stun + this.mode = 'base'; // base | attack | skillBegin | deploy | die | stun | change + this.endClip = null; // setForm's closing clip, played as a change clip once the clock reaches endAt, + this.endAt = 0; // landing in endRoles (the next form's) when given + this.endRoles = null; this.skillOn = false; this.attackUntil = 0; this.clock = 0; @@ -119,22 +124,34 @@ export class SpineActor { /** * Another clip set of the same skeleton — an enemy's mode (render/units.js FORMS: 掠海漂移体's 爬行模式 plays its *_02 * clips): `roles` override the manifest roles (null = back to them); `change` = a transition clip played once first - * (also while stunned: the pose it ends in is the one a stun then holds). + * (also while stunned: the pose it ends in is the one a stun then holds). `end` = { clip, in, roles? } (game s): a + * closing clip played the same way so that it ends `in` s from now, landing in `roles` — a leader's 重生 ends on its + * last clip while the sim still holds it, and the next form starts on its own clips. */ - setForm(roles, change = null) { + setForm(roles, change = null, end = null) { const anims = this.entry?.anims || {}; this.roles = roles ? { ...anims, ...roles } : anims; + this.endClip = null; if (this.dead) return; - if (change && this.has(change)) { - this.stunWanted = this.mode === 'stun'; - this.frozen = false; - this.mode = 'change'; - this._play(change, false, { mix: 0.08 }); - this.changeUntil = this.clock + this.dur(change); - } else if (this.mode === 'base') this._play(this._baseName(), true); + if (end && this.has(end.clip) && end.in > 0) { + this.endClip = end.clip; + this.endAt = this.clock + Math.max(0, end.in - this.dur(end.clip)); + this.endRoles = end.roles || null; + } + if (change && this.has(change)) this._change(change); + else if (this.mode === 'base') this._play(this._baseName(), true); else if (this.mode === 'stun' && this.has(this.roles.stun?.loop)) this._play(this.roles.stun.loop, true); } + /** Play a form's transition clip once; attacks and the resting state wait for it (mode 'change'). */ + _change(clip) { + if (this.mode !== 'change') this.stunWanted = this.mode === 'stun'; + this.frozen = false; + this.mode = 'change'; + this._play(clip, false, { mix: 0.08 }); + this.changeUntil = this.clock + this.dur(clip); + } + has(name) { return !!name && this.names.has(name); } dur(name) { const d = this.durations[name]; return typeof d === 'number' && d > 0 ? d : this._durFromData(name); } @@ -339,6 +356,12 @@ export class SpineActor { update(dt) { this.clock += dt; + if (this.endClip && this.clock >= this.endAt) { + const clip = this.endClip; + this.endClip = null; + if (this.endRoles) this.roles = { ...(this.entry?.anims || {}), ...this.endRoles }; + if (!this.dead) this._change(clip); + } if (this.windUntil != null && this.clock >= this.windUntil) { // a compressed wind-up reached its strike frame: back to the rhythm speed (attack() also does it) this.windUntil = null; diff --git a/public/js/render/units.js b/public/js/render/units.js index 5e9c6dc1..27aa58bf 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -24,8 +24,10 @@ // redeploy) plays the deploy clip and restores the normal look; `setDown(null)` on a dead view lets it fade out. // An operator that enters a battle already knocked out (联防, user playtest #5 item 2: sim 'die' reason 'forcedExit') // goes down with `die(true)`: straight to the held end of the clip, no fall. -// Enemy modes (sim fx 'phase' { id, kind } → `setForm(kind)`): 掠海漂移体 dropping to 爬行模式 (user playtest #5 item 1) -// plays its skeleton's 'Change' clip once, then the crawl set (*_02) — FORMS; a view built later keeps the mode. +// Enemy modes (sim fx 'phase' { id, kind } or another fx's `form` → `setForm(kind, fx)`): 掠海漂移体 dropping to 爬行模式 +// (user playtest #5 item 1) plays its skeleton's 'Change' clip once, then the crawl set (*_02); 转译基底's forms, the 逐火 +// embers, 再生's puppet, the leaders' 重生 and 守墓石像 likewise (user report after 0.1.0) — FORMS. A view built later +// (UnitInfo `form`, the sim's current form) starts in the mode. // Element gauges (b.snap `elem` → sample `el` / `elFill` / `elUntil` / `elDur`), the official form (PRTS 元素: "模型 // 下部会显示对应的元素图标,并以白条显示剩余的元素值"; enemies "小尺寸图标(不显示元素图标,仅根据元素种类改变背景色)"): a row // right under the unit's own HP / SP bars and inside their span — the element's disc at the left (operators with its @@ -109,9 +111,13 @@ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, g * - 深池逐火战士 / 精锐战士 / 护卫 (#8): knocked out → 'Die' (the 1 s 重生), the 余烬 on Idle_2 / Move_2 and its death on * Die_2; standing up again → 'Revive', then the warrior's manifest clips; * - 假想敌:再生: knocked out → A_Die, the 傀儡 on B_Idle / B_Move / B_Die; back → B_Revive, then the A_* manifest clips; + * (both: the stand-up clip (`end`) is timed from the 'ember' fx's `dur` to end as the husk stands up — the 'revive' + * fx then only lands in the manifest clips); * - the leaders' 重生 (sim reborn(): forms 'reborn' → 'form2'): 锏 Revive1, Revive2 held, Revive3, then B_*; 扎罗 A_revive_1 / * _2 / _3, then B_* (its double-hit attack); “复仇者” Revive_Begin / _Loop / _End; 杰斯顿 C1_Die (its 4 s 重生), then - * C2_* — their manifests mix the forms (锏 / 扎罗 died on B_Die from the A model, the look of report #5); + * C2_* — their manifests mix the forms (锏 / 扎罗 died on B_Die from the A model, the look of report #5). The closing + * clip (`end`) is timed from the 重生's `dur` (the 'telegraph' fx) to end with it, so the second form walks and + * attacks on its own clips at once (a view that missed the timing — built mid-重生 — skips the closing clip); * - 守墓石像 (forms 'stone' → 'fly'): the statue on Sleep [ASSUMED by name], then the flyer's *_2 clips. * A kind without a clip set of this skeleton (barriers, charges, …) changes nothing. 吉兆飞鳞's 晕眩模式 is its Stun clip. */ @@ -122,13 +128,14 @@ const clipSet = (idle, move, die, attack = null) => Object.freeze({ skill: attack ? Object.freeze({ begin: null, loop: attack, end: null, via: 'attack', index: 0, idle: null }) : null, }); const EMBER = Object.freeze({ - husk: Object.freeze({ change: 'Die', roles: clipSet('Idle_2', 'Move_2', 'Die_2') }), - revived: Object.freeze({ change: 'Revive', roles: Object.freeze({}) }), + husk: Object.freeze({ change: 'Die', end: 'Revive', next: 'revived', roles: clipSet('Idle_2', 'Move_2', 'Die_2') }), + revived: Object.freeze({ change: null, roles: Object.freeze({}) }), }); -/** A 重生 held on `hold` (also while the sim reports the rebirth's stun) after `begin`, then `end` and the second form. */ +/** A 重生 held on `hold` (also while the sim reports the rebirth's stun) after `begin`, closing on `end` as the 重生 ends, + * then the second form. */ const rebirth = (begin, hold, end, form2 = Object.freeze({})) => Object.freeze({ - reborn: Object.freeze({ change: begin, roles: Object.freeze({ idle: hold, deploy: hold, move: loop(hold), stun: loop(hold), attack: null, skill: null }) }), - form2: Object.freeze({ change: end, roles: form2 }), + reborn: Object.freeze({ change: begin, end, next: 'form2', roles: Object.freeze({ idle: hold, deploy: hold, move: loop(hold), stun: loop(hold), attack: null, skill: null }) }), + form2: Object.freeze({ change: null, roles: form2 }), }); const STATUE = Object.freeze({ stone: Object.freeze({ change: null, roles: Object.freeze({ idle: 'Sleep', deploy: 'Sleep', move: loop('Sleep'), stun: loop('Sleep'), attack: null, skill: null }) }), @@ -148,8 +155,8 @@ export const FORMS = Object.freeze({ enemy_1288_duskls_2: EMBER, enemy_1292_duskld: EMBER, enemy_9010_acpupp: Object.freeze({ - husk: Object.freeze({ change: 'A_Die', roles: clipSet('B_Idle', 'B_Move', 'B_Die') }), - revived: Object.freeze({ change: 'B_Revive', roles: Object.freeze({}) }), + husk: Object.freeze({ change: 'A_Die', end: 'B_Revive', next: 'revived', roles: clipSet('B_Idle', 'B_Move', 'B_Die') }), + revived: Object.freeze({ change: null, roles: Object.freeze({}) }), }), enemy_1525_blkswb: rebirth('Revive1', 'Revive2', 'Revive3', clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack')), enemy_1535_wlfmster: rebirth('A_revive_1', 'A_revive_2', 'A_revive_3', clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack')), @@ -386,11 +393,13 @@ export class UnitView { } /** - * The unit changed mode (sim fx 'phase' { id, kind } or another fx's `form`): the mode's clip set (FORMS) after its - * change clip; null goes back to the manifest clips; a kind this skeleton has no clip set for (an arts barrier, a - * broken charge …) changes nothing. Kept for a model built later. + * The unit changed mode (sim fx 'phase' { id, kind } or another fx's `form`; `fx` = that fx's extra): the mode's clip + * set (FORMS) after its change clip, and its closing clip (`end`, landing in the `next` form's clips) timed to end + * `fx.dur` game s later; null goes back + * to the manifest clips; a kind this skeleton has no clip set for (an arts barrier, a broken charge …) changes + * nothing. Kept for a model built later. */ - setForm(kind) { + setForm(kind, fx = null) { const k = typeof kind === 'string' ? kind : null; if (k === this.form) return; if (k && !FORMS[this.info.spine || this.info.defId]?.[k]) return; @@ -399,7 +408,9 @@ export class UnitView { this.info.form = k; const f = this._formSpec(); if (!this.actor) return; - if (f) this.actor.setForm(f.roles, f.change || null); + const dur = fx && Number(fx.dur); + const next = f && f.next ? FORMS[this.info.spine || this.info.defId]?.[f.next]?.roles || null : null; + if (f) this.actor.setForm(f.roles, f.change || null, f.end && dur > 0 ? { clip: f.end, in: dur, roles: next } : null); else if (had) this.actor.setForm(null); } diff --git a/server/sim/Battle.js b/server/sim/Battle.js index 68187bb4..e0315ce8 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -811,7 +811,9 @@ export class Battle { e.deployedAt = this.time; e.atkCd = 0; e.pauseUntil = -Infinity; - e.profile = { noAttack: def.dmgType === 'none', maxTargets: 1, atkScale: 1 }; + // every enemy profile starts with the same fields (stable object shapes keep the hot loop's property reads fast); + // `dmgType` null = the data's (content may arm a data-unarmed enemy: ai.js enemyAttack) + e.profile = { noAttack: def.dmgType === 'none', maxTargets: 1, atkScale: 1, dmgType: null }; e.route = { legs: route ? compileRoute(route, this.rect) : [], legIdx: 0, pts: null, ptIdx: 0, suffix: null, version: -1, waitLeft: null }; if (!e.route.legs.length) { const end = this.grid.specialTiles('end')[0]; diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index c1347f23..75dfe8bf 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -53,10 +53,13 @@ // 封冻/供暖器, 赘生甲壳, 锁链, 血债账款, 晦明) have no counterpart in this mode and are listed where they apply. // // fx kinds emitted (battle.fx(kind, {x, y, …})): 'explode' {r, kind} · 'zone' {r, dur, kind} · 'telegraph' {r, dur, -// kind, tiles?} (delayed strikes, charges, 'reborn') · 'beam' {from, to, kind} · 'summon' {id, key} · 'ember'/'revive'/ -// 'stone' {id, form?} · 'blink' {id, fx, fy} · 'charge' {id, tx, ty} · 'expose' {id} · 'shieldBreak' {id} · 'liberate' {id} -// · 'phase' {id, kind} (form / barrier changes) · 'lpLoss' {value, reason} · 'steal' · 'ignite'. A 'phase' kind, or -// the `form` of any other fx, is the model's clip set from then on (render/app.js → UnitView.setForm, units.js FORMS). +// kind, tiles?, id?, form?} (delayed strikes, charges, 'reborn' with form 'reborn') · 'beam' {from, to, kind} · +// 'summon' {id, key} · 'ember' {id, hits, dur, form: 'husk'} · 'revive' {id, kind?, form: 'form2' | 'revived' | 'fly'} +// · 'stone' {id, dur, form: 'stone'} · 'blink' {id, fx, fy} · 'charge' {id, tx, ty} · 'expose' {id} · 'shieldBreak' +// {id} · 'liberate' {id} · 'phase' {id, kind, dur?} (form changes — crawl, translator_* — and barrier / charge states) +// · 'lpLoss' {value, reason} · 'steal' · 'ignite'. Forms go through setForm(): the unit keeps it (`e.form`, UnitInfo +// `form`) and the fx's `form` — a form 'phase' fx's `kind` — is the model's clip set from then on (render/app.js → +// UnitView.setForm, units.js FORMS). // Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss. import { TICK, MOVE_SCALE, ELEMENT } from '../constants.js'; @@ -73,8 +76,8 @@ export const EROSION_BURST = Object.freeze({ defDown: ELEMENT.erosion.ally.defDo /** 假想敌:淤困: element damage its host's burst spreads to the allies of the 4 tiles around (PRTS 假想敌:淤困 "附着对象元素爆发时 * …对附着对象及周围4格内的所有我方单位…造成1000同类型元素损伤"; the host itself is in its 爆发冷却). */ const PARASITE_SPREAD = 1000; -/** 重生 of a Revive[Trigger] knock-out before its husk (PRTS 深池逐火战士 "被击倒后重生,持续1s"; the same talent template on - * 假想敌:再生, whose PRTS page gives no duration — 1 s there too [ASSUMED]; both models' knock-out clips last 1 s). */ +/** 重生 of a Revive[Trigger] knock-out before its husk (PRTS 深池逐火战士 and 假想敌:再生 天赋: "被击倒后重生,持续1s,随后变为 + * 怨恨的余烬 / 再生状态,1s内不移动且持有无敌+无法阻挡+失衡免疫"; both models' knock-out clips last 1 s). */ export const HUSK_REBIRTH = 1; /** 转译基底·α: "变化过程持续2s" (PRTS 天赋; the model's A_Die_B / _C / _D change clips last 2 s); the original form's * immunities ("免疫晕眩/沉睡/寒冷/冻结/浮空/恐惧", plus 失衡免疫). */ @@ -89,8 +92,8 @@ const TANK_ZONE_RADIUS = 1.7; const POLLUTION_INTERVAL = 1; /** "数个目标" of 假想敌:骨刺 while stealthed [ASSUMED]. */ const ACBUNN_TARGETS = 3; -/** 假想敌:再生 shield aura radius ("周围一定距离") [ASSUMED]. */ -const ACPUPP_AURA_RADIUS = 2; +/** 假想敌:再生 shield aura radius (PRTS 假想敌:再生 天赋 "进入此形态时,使半径1.8范围内的其他敌方单位(无视其可选性)获得5层…护盾"). */ +const ACPUPP_AURA_RADIUS = 1.8; /** 重弩突袭者 直击 reach along a row/column [ASSUMED]. */ const CROSS_REACH = 6; /** 暴鸰 投弹: the target's tile and its 8 neighbours (PRTS "对目标及其周围八格的我方单位造成100%物理伤害"). */ @@ -461,6 +464,18 @@ export function hitCount(b, e, on, artsOnly = false) { } export const isHitCount = (e) => !!e.findBuff(HIT_COUNT_KEY); +/** + * The enemy's model takes another form (render/units.js FORMS: a clip set of its skeleton): kept on the unit + * (`e.form`, published by snapshot.js unitInfo — a view built mid-battle from fieldMeta(): a teammate's field watched + * later, 联防 observers, a reconnect — starts in it) and announced by the fx `fxKind` (+ id, x, y, `params`): a 'phase' + * fx carries the form as its `kind`, any other fx as `form`. Barrier / charge 'phase' kinds are no forms: they go + * through b.fx directly. + */ +export function setForm(b, e, form, fxKind = 'phase', params = null) { + e.form = form; + b.fx(fxKind, fxKind === 'phase' ? { ...params, x: e.x, y: e.y, id: e.id, kind: form } : { ...params, x: e.x, y: e.y, id: e.id, form }); +} + /** Leader "扣除目标生命" effects: recorded for the match (hook 'lpLoss' + result.lpLoss). */ export function lpLoss(b, amount, reason, source = null) { if (!(amount > 0)) return; @@ -708,7 +723,7 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu if (invincible > 0) b.addBuff(e, { key: `${key}:inv`, duration: invincible, visible: true, flags: { invulnerable: true } }); if (e.route) e.route.pts = null; e.atkCd = 0; - b.fx('revive', { x: e.x, y: e.y, id: e.id, kind: 'reborn', form: 'form2' }); + setForm(b, e, 'form2', 'revive', { kind: 'reborn' }); }; return { killed(c, b, e, a) { @@ -726,7 +741,7 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu onTick: during ? ({ battle }) => during(battle, e, a, battle.time - a.t0) : null, onExpire: ({ battle }) => finish(battle, e, a), }); - b.fx('telegraph', { x: e.x, y: e.y, r: 1, dur, kind: 'reborn', id: e.id, form: 'reborn' }); + setForm(b, e, 'reborn', 'telegraph', { r: 1, dur, kind: 'reborn' }); return true; }, }; @@ -803,9 +818,10 @@ function artsBarrier(amount, { key = 'ab:artsBarrier', whileUp = null } = {}) { * 隐匿 (`stealthy`: targetable only while blocked, targeting.js canTargetEnemy — the 余烬 must be blocked to be beaten) * and / or unblockable (`unblock`: 再生's 傀儡 "不可被阻挡"). Killing the husk is the real death; a husk still standing * after `delay` s stands up in its first form with full HP, and every later knock-out starts it again ("一次又一次地站起"). + * `onHusk(b, e)` runs as the husk begins, after the 重生 ("进入此形态时": 再生's shields). * User report after 0.1.0 (#8): the v2.5 ember stood still, stealthed AND unblockable — nobody could ever target it. - * fx: 'ember' {id, hits, dur, form: 'husk'} at the knock-out, 'revive' {id, form: 'revived'} when it stands up (the - * renderer switches the model's clip set: render/units.js FORMS). + * fx (setForm): 'ember' {id, hits, dur, form: 'husk'} at the knock-out, 'revive' {id, form: 'revived'} when it stands + * up (the renderer switches the model's clip set: render/units.js FORMS). */ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, key = 'ab:ember' }) { const revive = (b, e, a) => { @@ -821,7 +837,7 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke b.removeBuff(e, `${key}:reborn`); if (e.route) e.route.pts = null; e.atkCd = 0; - b.fx('revive', { x: e.x, y: e.y, id: e.id, form: 'revived' }); + setForm(b, e, 'revived', 'revive'); }; return { killed(c, b, e, a) { @@ -839,8 +855,8 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke b.addBuff(e, { key, visible: true, persist: true, flags: { disarm: true, ...(stealthy ? { stealth: true } : {}), ...(unblock ? { unblockable: true } : {}) } }); b.addBuff(e, { key: `${key}:reborn`, duration: HUSK_REBIRTH, flags: { invulnerable: true, untargetable: true, unblockable: true, noMove: true, noDisplace: true } }); if (e.route) e.route.pts = null; - b.fx('ember', { x: e.x, y: e.y, id: e.id, hits, dur: HUSK_REBIRTH + delay, form: 'husk' }); - if (onHusk) onHusk(b, e); + setForm(b, e, 'husk', 'ember', { hits, dur: HUSK_REBIRTH + delay }); + if (onHusk) b.after(HUSK_REBIRTH, () => { if (e.alive && a.state === 'husk') onHusk(b, e); }, { owner: e }); b.after(HUSK_REBIRTH + delay, () => revive(b, e, a), { owner: e }); return true; }, @@ -865,7 +881,7 @@ function statue(ab) { e.profile.noAttack = true; e.hp = e.s.maxHp; ab2.immune = new Set([...(ab2.immune || []), 'levitate']); - b.fx('stone', { x: e.x, y: e.y, id: e.id, dur, form: 'stone' }); + setForm(b, e, 'stone', 'stone', { dur }); b.addBuff(e, { key: 'ab:stone', duration: dur, visible: true, flags: { noMove: true, unblockable: true, noDisplace: true }, mods: { defFlat: T(ab, 'stone.def') ?? 0, resFlat: T(ab, 'stone.magic_resistance') ?? 0 }, @@ -875,7 +891,7 @@ function statue(ab) { Object.assign(e.profile, { noAttack: a.noAtk, melee: false, dmgType: 'arts', canTarget: (u) => !u.isFlying }); battle.addBuff(e, { key: 'ab:flight', persist: true, flags: { noDisplace: true } }); if (e.route) e.route.pts = null; - battle.fx('revive', { x: e.x, y: e.y, id: e.id, kind: 'fly', form: 'fly' }); + setForm(battle, e, 'fly', 'revive', { kind: 'fly' }); }, }); return true; @@ -1094,7 +1110,7 @@ function kitSyufo(ab) { setFloat(b, e, false); e.profile.melee = true; b.applyStatus(e, 'stun', { duration: SYUFO_CRAWL_STUN, source: null }); - b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'crawl' }); + setForm(b, e, 'crawl'); }, }]; } @@ -1436,15 +1452,16 @@ function kitBlackCloud(ab, e) { ]; } -/** 假想敌:再生 · knock-out ⇒ a 傀儡 of prop_max_hp hits for `interval` s that "不可被阻挡" (data description) and walks on — its - * model has a 傀儡 walk cycle (B_Move); an immobile unit would not need to be unblockable [ASSUMED from both] — plus - * max_damage_block_cnt-hit shields on the enemies around it. */ +/** 假想敌:再生 (PRTS 假想敌:再生 天赋) · knock-out ⇒ 1 s 重生 ⇒ 再生状态: a 傀儡 of prop_max_hp (15) hits that is 不可阻挡 for + * `interval` (15) s and walks on ("1s内不移动": only the 重生 stands; its model has a 傀儡 walk cycle, B_Move), back with full + * HP if still standing; entering it gives the other enemies within 1.8 (targetable or not) max_damage_block_cnt (5) + * hit shields. */ function kitRegen(ab) { const hits = T(ab, 'Revive[Trigger].prop_max_hp'), delay = T(ab, 'Revive[Trigger].interval'), block = T(ab, 'Aura.max_damage_block_cnt') ?? 0; return [husk({ hits, delay, stealthy: false, unblock: true, key: 'ab:regen', onHusk(b, e) { - // 被击倒后…使周围一定距离内其他敌人获得可以抵挡物理及法术伤害的护盾 + // "进入此形态时,使半径1.8范围内的其他敌方单位(无视其可选性)获得5层吸收物理/法术伤害的护盾" b.fx('telegraph', { x: e.x, y: e.y, r: ACPUPP_AURA_RADIUS, kind: 'regenShield', id: e.id }); for (const o of b.enemiesInRadius(e.x, e.y, ACPUPP_AURA_RADIUS)) if (o !== e && block > 0) { const oab = abOf(b, o); oab.hitShield = Math.max(oab.hitShield, block); } }, @@ -2078,9 +2095,10 @@ function kitWolfLord(ab) { * 特战术师 (form D): ranged only (the data's 2.4 radius), arts, 2 targets at once; * 幽灵 (form C): no stat change, unblockable, no attack (PRTS lists none; its model has no attack clip). * The armed forms attack through the engine's enemy attack (ai.js enemyAttack, `profile.dmgType`), so attack clips, - * projectiles and the attack hooks are the normal ones. Before its change ends it cannot be killed by damage at all — - * user report after 0.1.0 (#5): the v2.5 kit let damage through, so it died in its first form, its model still in that - * form playing the 寻仇者's B_Die. fx 'phase' {id, kind: translator_fuchou | _shushi | _youling} starts the model's 2 s + * projectiles and the attack hooks are the normal ones. Before its change ends it cannot be killed at all (damage is + * cancelled; an HP loss stops at 1 HP) — user report after 0.1.0 (#5): its model never changed (no FORMS clip set), so + * it died on the manifest's die clip, the 寻仇者's B_Die, from its first-form look; and the v2.5 kit let damage through, + * so some lineups killed it before either counter reached 4. fx 'phase' {id, kind: translator_fuchou | _shushi | _youling} starts the model's 2 s * change clip (render/units.js FORMS). Form letters: B / C / D follow the talents' order Fuchou / Youling / Shushi — C, * the only clip set without an attack, is the non-attacking 幽灵. */ @@ -2110,7 +2128,7 @@ function kitTranslator(ab, e) { if (P.form || !e2.alive) return; P.form = form; b.addBuff(e2, { key: 'ab:change', duration: TRANSLATOR_CHANGE, persist: true, flags: { noMove: true }, onExpire: ({ battle }) => finish(battle, e2) }); - b.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: `translator_${form}`, dur: TRANSLATOR_CHANGE }); + setForm(b, e2, `translator_${form}`, 'phase', { dur: TRANSLATOR_CHANGE }); }; return [{ spawn(b, e2) { @@ -2126,6 +2144,13 @@ function kitTranslator(ab, e) { } c.dmg.cancel = true; }, + // an HP loss (流失 — no 伤害 instance, so not cancelled: 隐德来希's 心烛 hand-over …) cannot knock it out before its + // change ends: its original form has no death clip of its own (A_Die_B / _C / _D are the changes) [ASSUMED floor 1 HP] + killed(c, b, e2) { + if (P.done) return false; + e2.hp = Math.max(1, e2.hp); + return true; + }, blocked(c, b, e2) { change(b, e2, 'youling'); }, tick(b, e2) { // 寻仇者: "生命值低于50%时,攻击力+100%" @@ -2219,7 +2244,7 @@ export const KITS = Object.freeze({ enemy_1288_duskls: kitEmber, // 深池逐火战士 · every KO: 1 s 重生 ⇒ walking 隐匿 5-hit ember (block it to hit it), back after 10 s enemy_1288_duskls_2: kitEmber, // 深池逐火精锐战士 · same enemy_1292_duskld: kitEmber, // 深池逐火护卫 · 10-hit 火灰 - enemy_9010_acpupp: kitRegen, // 假想敌:再生 · every KO: 1 s 重生 ⇒ walking unblockable 15-hit puppet (15 s) + 5-hit shields nearby + enemy_9010_acpupp: kitRegen, // 假想敌:再生 · every KO: 1 s 重生 ⇒ walking unblockable 15-hit puppet (15 s) + 5-hit shields within 1.8 // --- ELEMENT 元素 enemy_1148_dssbr: kitEp('neural', 'epdamage.attack@ep_damage_ratio'), // 底海滑动者 · neural on hit diff --git a/server/sim/snapshot.js b/server/sim/snapshot.js index 5525bfc3..00a8592c 100644 --- a/server/sim/snapshot.js +++ b/server/sim/snapshot.js @@ -1,7 +1,8 @@ // server/sim/snapshot.js — compact serialization for clients (DESIGN §8.2). // // b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total } -// UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId? } +// UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, +// form?, skillIndex?, moduleId? } (form = an enemy's current model form, content/enemies.js setForm) // dir = 'UP'|'RIGHT'|'DOWN'|'LEFT' (allies: the deploy direction, sim/dir.js); facing = its horizontal sign (±1). // flags bits & anim codes come from shared/constants.js (UF / ANIM). @@ -32,6 +33,9 @@ export function unitInfo(u) { maxHp: Math.max(1, Math.round(u.s.maxHp)), motion: u.motion === 'FLY' ? 'FLY' : undefined, boss: u.isBoss ? true : undefined, + // an enemy's current model form (content/enemies.js setForm, render/units.js FORMS): a view built mid-battle + // (fieldMeta — a watched teammate's field, 联防 observers, a reconnect) starts on that clip set + form: typeof u.form === 'string' ? u.form : undefined, uid: u.uid ?? undefined, // DESIGN §16: the equipped skill's index (the renderer / audio pick that skill's Spine clip and sound) skillIndex: u.side === 'ally' && Number.isInteger(d.skill?.index) ? d.skill.index : undefined, diff --git a/server/sim/units.js b/server/sim/units.js index 59d43912..37f41f49 100644 --- a/server/sim/units.js +++ b/server/sim/units.js @@ -81,6 +81,8 @@ export class Unit { this.trait = {}; // profession runtime state this.stats = { dmg: 0, kills: 0, heal: 0, taken: 0, attacks: 0 }; this.hidden = false; // enemies inside a DISAPPEAR segment + this.moving = false; // enemies: walked this tick (drawn on the move clip; ai.js updateEnemy) + this.form = null; // enemies: the model's current form (content/enemies.js setForm → snapshot.js unitInfo) this.anim = 0; this.persist = { redeployMul: 1, freeRedeploys: 0 }; this.isBoss = false; diff --git a/test/render/forms.test.js b/test/render/forms.test.js index 7e4165fd..22c6f5a9 100644 --- a/test/render/forms.test.js +++ b/test/render/forms.test.js @@ -5,8 +5,9 @@ // after the change (render/app.js keeps the mode on the unit info). Headless fake PIXI (test/render/fakepixi.js). // Player reports after 0.1.0 (#5, #8): 转译基底·α changes on its 2 s A_Die_B / _C / _D clip into the 寻仇者 B_*, 幽灵 C_* or // 特战术师 D_* set (it used to stay on A_Idle / A_Move and die on B_Die — "加载变身动画然后就没了"); a knocked-out 逐火 plays -// 'Die' (its 1 s 重生) and walks as the ember on Idle_2 / Move_2, dying on Die_2, and 'Revive' brings the warrior back -// (sim fx 'ember' / 'revive' { form: 'husk' | 'revived' }); 假想敌:再生's 傀儡 the same with A_Die / B_* / B_Revive. +// 'Die' (its 1 s 重生) and walks as the ember on Idle_2 / Move_2, dying on Die_2, and 'Revive' — timed from the 'ember' +// fx's `dur` to end as it stands up — brings the warrior back (sim fx 'ember' / 'revive' { form: 'husk' | 'revived' }); +// 假想敌:再生's 傀儡 the same with A_Die / B_* / B_Revive; the leaders' 重生 close on their last clip as the 重生 ends. import { test, describe, before, after } from 'node:test'; import assert from 'node:assert/strict'; @@ -16,6 +17,7 @@ import { fileURLToPath } from 'node:url'; import { installFakePixi, fakeViewCtx } from './fakepixi.js'; import { presetCamera } from '../../public/js/render/projection.js'; import { UF, ANIM } from '../../shared/constants.js'; +import { makeBattle, chessRec } from '../helpers/battleHarness.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8')); @@ -172,44 +174,60 @@ describe('转译基底·α forms (user report after 0.1.0, #5)', () => { describe('逐火 embers and the 再生 puppet (user report after 0.1.0, #8)', () => { for (const id of ['enemy_1288_duskls', 'enemy_1288_duskls_2', 'enemy_1292_duskld']) { - test(`${id}: knocked out ⇒ 'Die' once, the ember on Idle_2 / Move_2 / Die_2; revived ⇒ 'Revive', then the warrior's clips`, async () => { + test(`${id}: knocked out ⇒ 'Die' once, the ember on Idle_2 / Move_2 / Die_2; 'Revive' ends as it stands up, then the warrior's clips`, async () => { const anims = assets.enemies[id].spine.animations; for (const name of ['Die', 'Idle_2', 'Move_2', 'Die_2', 'Revive']) assert.ok(name in anims, name); const v = await enemy(id); v.sync(sample(0, ANIM.MOVE), 1); assert.equal(clip(v), 'Move'); - v.setForm('husk'); + const dur = 4; // the 'ember' fx: 1 s 重生 + the husk (10 s officially) + v.setForm('husk', { dur }); assert.equal(clip(v), 'Die', 'the knock-out (its 1 s 重生)'); frames(v, 65); assert.equal(clip(v), 'Move_2', 'the ember walks'); v.sync(sample(UF.STEALTH, ANIM.IDLE), 2); assert.equal(clip(v), 'Idle_2'); - v.setForm('revived'); - assert.equal(clip(v), 'Revive'); - frames(v, 80); + frames(v, Math.round((dur - anims.Revive - 65 / 60) * 60) + 3); + assert.equal(clip(v), 'Revive', 'the stand-up clip starts so that it ends with the husk'); + frames(v, Math.round(anims.Revive * 60) - 6); + v.setForm('revived'); // the sim's 'revive' fx + assert.equal(clip(v), 'Revive', 'plays out, not restarted'); + frames(v, 10); assert.equal(clip(v), 'Idle', 'the warrior again'); - v.onAttack(null, 4); - assert.equal(clip(v), 'Attack'); - v.setForm('husk'); + v.onAttack(null, 6); + assert.equal(clip(v), 'Attack', 'it attacks at once'); + v.setForm('husk', { dur }); frames(v, 65); v.die(); assert.equal(clip(v), 'Die_2', 'the ember dies on its own clip'); + frames(v, 300); + assert.equal(clip(v), 'Die_2', 'no stand-up clip after its death'); }); } test('假想敌:再生: A_Die, the 傀儡 on B_Idle / B_Move / B_Die; B_Revive back to the A_* clips', async () => { const v = await enemy('enemy_9010_acpupp'); - v.setForm('husk'); + v.setForm('husk', { dur: 3 }); assert.equal(clip(v), 'A_Die'); frames(v, 65); assert.equal(clip(v), 'B_Idle'); v.sync(sample(0, ANIM.MOVE), 2); assert.equal(clip(v), 'B_Move'); + frames(v, 60); + assert.equal(clip(v), 'B_Revive', 'its last second'); + frames(v, 57); v.setForm('revived'); - assert.equal(clip(v), 'B_Revive'); - frames(v, 65); + frames(v, 6); assert.equal(clip(v), 'A_Move'); }); + + test('a view built mid-husk (UnitInfo form, no timing) shows the husk; the revival lands in the manifest clips', async () => { + const v = await enemy('enemy_1288_duskls', { form: 'husk' }); + v.sync(sample(UF.STEALTH, ANIM.MOVE), 1); + assert.equal(clip(v), 'Move_2'); + v.setForm('revived'); + assert.equal(clip(v), 'Move', 'no stand-up clip without the timing'); + }); }); describe('the leaders\' 重生 and 守墓石像 (audit of the knock-out forms after report #5)', () => { @@ -224,15 +242,21 @@ describe('the leaders\' 重生 and 守墓石像 (audit of the knock-out forms af for (const name of [begin, hold, end, idle2, die2]) assert.ok(name in anims, name); const v = await enemy(id); v.sync(sample(UF.STUNNED, ANIM.STUN), 1); - v.setForm('reborn'); + const dur = 6; // the 'telegraph' fx's dur (Reborn.duration) + v.setForm('reborn', { dur }); assert.equal(clip(v), begin); frames(v, Math.ceil(anims[begin] * 60) + 5); assert.equal(clip(v), hold, 'held (its stun role) while the sim keeps it stunned'); - v.sync(sample(0, ANIM.IDLE), 6); + const at = Math.ceil(anims[begin] * 60) + 5; + frames(v, Math.round((dur - anims[end]) * 60) - at + 3); + assert.equal(clip(v), end, 'the closing clip starts so that it ends with the 重生'); + frames(v, Math.round(anims[end] * 60) - 6); + v.sync(sample(0, ANIM.IDLE), dur + 1); // the sim's 'revive' fx: the 重生 is over v.setForm('form2'); - assert.equal(clip(v), end); - frames(v, Math.ceil(anims[end] * 60) + 5); - assert.equal(clip(v), idle2); + frames(v, 6); + assert.equal(clip(v), idle2, 'the second form at once'); + v.onAttack(null, dur + 1.2); + assert.notEqual(clip(v), idle2, 'its first attack is drawn, not swallowed by a change clip'); v.die(); assert.equal(clip(v), die2); }); @@ -273,7 +297,38 @@ describe('the leaders\' 重生 and 守墓石像 (audit of the knock-out forms af }); }); -test('render/app.js hands the sim\'s fx \'phase\' kind — or the `form` of any fx — to the view and keeps the mode on the unit info', () => { +test('a view built mid-battle from the real sim\'s fieldMeta (fx dropped by a silent catch-up) starts in the current form: the changed 转译基底·α on D_Idle, the 逐火 ember on Idle_2, 锏 after its 重生 on B_Idle', async () => { + const mage = chessRec({ id: 't_mage', profession: 'CASTER', stats: { atk: 50, blockCnt: 0 }, rangeGrid: [[0, 0]], skill: null }); + const h = makeBattle({ + stageId: 'act2autochess_m01', content: 'full', seed: 7, autoFinish: false, timeLimit: 600, modeId: 'mode_multi_hard', round: 3, + defs: { chess: { t_mage: mage } }, units: [{ chessId: 't_mage', row: 12, col: 2 }], kits: { t_mage: () => ({ trait: { noAttack: true } }) }, + }); + h.step(); + const still = { mods: { speedMul: 0 } }; + const tr = h.b.spawnEnemy('enemy_10081_mpplai', { pos: [9, 8], ...still }); + const ember = h.b.spawnEnemy('enemy_1288_duskls', { pos: [10, 8], ...still }); + const mace = h.b.spawnEnemy('enemy_1525_blkswb', { pos: [11, 8], ...still }); + const m = h.unit('t_mage'); + for (let i = 0; i < 4; i++) h.b.dealDamage(m, tr, { amount: 1, type: 'arts' }); // the 4th arts hit ⇒ 特战术师 + h.b.kill(ember, m); + h.b.kill(mace, m); + const until = h.b.time + 6; + while (h.b.time < until) { h.b.step(); h.b.drainEvents(); } + const meta = h.b.fieldMeta(); + const view = async (u) => { + const info = meta.units.find((x) => x.id === u.id); + const ctx = fakeViewCtx(fake.P, { assets: store(info.spine), cam }); + const v = new UnitView(ctx, info); + await tick(); await tick(); + v.sync(sample(), 1); + return v; + }; + assert.equal(clip(await view(tr)), 'D_Idle', '特战术师, not the A model'); + assert.equal(clip(await view(ember)), 'Idle_2', 'the ember, not the warrior'); + assert.equal(clip(await view(mace)), 'B_Idle', '锏\'s second form'); +}); + +test('render/app.js hands the sim\'s fx \'phase\' kind — or the `form` of any fx, with the fx — to the view and keeps the mode on the unit info', () => { const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8'); - assert.match(src, /e\[1\] === 'phase' \? ex4\.kind : 'form' in ex4 \? ex4\.form[\s\S]{0,200}inf\.form = [\s\S]{0,120}setForm\?\.\(form\)/); + assert.match(src, /e\[1\] === 'phase' \? ex4\.kind : 'form' in ex4 \? ex4\.form[\s\S]{0,200}inf\.form = [\s\S]{0,120}setForm\?\.\(form, ex4\)/); }); diff --git a/test/sim/feedback1-transform.test.js b/test/sim/feedback1-transform.test.js index 64761e08..db003d52 100644 --- a/test/sim/feedback1-transform.test.js +++ b/test/sim/feedback1-transform.test.js @@ -11,6 +11,10 @@ // every 逐火 enemy of a round leaked. // The real-round tests build the battle like Match._normalOpts: an official wave (waves.js buildNormalWave, a 悬赏 card // through withBounties), the real stage 战场#01, real chess, full content, buildBattleSpec → createBattleFromSpec. +// Review follow-ups: a view built mid-battle (fieldMeta — a watched teammate's field, 联防 observers, a reconnect; the +// client runner's silent catch-up drops the fx) learns the current form from UnitInfo `form`; an HP loss (流失) cannot +// knock 转译基底·α out before its change; 假想敌:再生's shields come with its 再生状态, within 1.8 (PRTS 天赋); enemy +// profiles keep one object shape from spawn (the sim's hot loop). import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; @@ -23,7 +27,7 @@ import { getDefaultSource } from '../../server/sim/simdata.js'; import { GEO } from '../../shared/constants.js'; import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js'; import { canTargetEnemy } from '../../server/sim/targeting.js'; -import { HUSK_REBIRTH, TRANSLATOR_CHANGE } from '../../server/sim/content/enemies.js'; +import { HUSK_REBIRTH, TRANSLATOR_CHANGE, abOf } from '../../server/sim/content/enemies.js'; const QUIET = { warn() {}, error() {}, info() {} }; const DATA = getData({ log: QUIET }); @@ -128,6 +132,7 @@ describe('#8 深池逐火: a knock-out is a walking 隐匿 ember that a blocker walked = Math.max(walked, Math.hypot(u.x - m.x, u.y - m.y)); } check(u.stats.attacks === m.attacks, 'an ember never attacks'); + if (n % 30 === 0) check(b.fieldMeta().units.find((x) => x.id === u.id)?.form === 'husk', 'a view built now (fieldMeta) shows the ember'); } } const r = b.result(); @@ -209,6 +214,23 @@ describe('#8 深池逐火: a knock-out is a walking 隐匿 ember that a blocker assert.equal(e.hp, n - 4); assert.ok(e.alive); }); + + test('假想敌:再生 (PRTS 天赋 "被击倒后重生,持续1s,随后变为再生状态 … 进入此形态时,使半径1.8范围内的其他敌方单位…获得5层…护盾"): no shields during the 1 s 重生, then 5-hit shields within 1.8 only', () => { + const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS }); + h.step(); + const still = { mods: { speedMul: 0 } }; + const e = put(h, 'enemy_9010_acpupp', { pos: [10, 8], ...still }); + const near = put(h, 'enemy_1007_slime', { pos: [10, 6.3], ...still }); // 1.7 away + const far = put(h, 'enemy_1007_slime', { pos: [11.9, 8], ...still }); // 1.9 away + h.b.kill(e, h.unit('t_gun')); + assert.ok(e.alive && e.form === 'husk'); + h.run(HUSK_REBIRTH - 0.1); + assert.equal(abOf(h.b, near).hitShield ?? 0, 0, 'no shield while it is still in its 重生'); + h.run(0.2); + assert.equal(abOf(h.b, near).hitShield, tb('enemy_9010_acpupp', 'Aura.max_damage_block_cnt'), 'the 再生状态 begins: 5 hits of shield within 1.8'); + assert.equal(abOf(h.b, far).hitShield ?? 0, 0, 'nothing 1.9 away'); + assert.ok(e.s.flags.unblockable && !e.s.flags.noMove, '不可阻挡, free to move after the 1 s'); + }); }); // --------------------------------------------------------------------------------------------------------------- @@ -268,10 +290,12 @@ describe('#5 转译基底·α: damage is cancelled until its form change; only t ], }); let tr = null, phaseAt = null, kind = null, diedAt = null, formAt = null, maxBefore = null, firstFormLoss = null, earlyAttack = false; + let lateForm; // UnitInfo.form of the translator in fieldMeta() 1 s after its change began let n = 0; while (!b.finished && n++ < 30 * 300) { b.step(); tr = tr ?? b.enemies.find((e) => e.defId === TR) ?? null; + if (tr && tr.alive && phaseAt != null && lateForm === undefined && b.time >= phaseAt + 1) lateForm = b.fieldMeta().units.find((u) => u.id === tr.id)?.form ?? null; for (const ev of b.drainEvents()) { if (!tr) continue; if (ev[0] === 'fx' && ev[1] === 'phase' && ev[4].id === tr.id) { phaseAt = b.time; kind = ev[4].kind; } @@ -289,6 +313,7 @@ describe('#5 转译基底·α: damage is cancelled until its form change; only t assert.ok(phaseAt != null && diedAt > phaseAt + TRANSLATOR_CHANGE - 0.05, 'it changed form before it died — never killed in its first form'); assert.equal(firstFormLoss, null, 'its first form loses no HP'); assert.equal(kind, 'translator_fuchou', '4 physical hits first ⇒ 寻仇者'); + assert.equal(lateForm, 'translator_fuchou', 'a view built mid-battle (fieldMeta) draws the changing form, not the A model'); approx(formAt - phaseAt, TRANSLATOR_CHANGE, 0.02, 'the change lasts 2 s'); assert.ok(!earlyAttack, 'no attack before its change'); const r = b.result(); @@ -356,6 +381,24 @@ describe('#5 转译基底·α: damage is cancelled until its form change; only t checkInvariants(h2.b); }); + test('an HP loss (流失 — no damage instance to cancel, e.g. 隐德来希\'s 心烛 hand-over) cannot knock out its original form; once changed it can', () => { + const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS }); + h.step(); + const e = put(h, TR, { mods: { speedMul: 0 } }); + const g = h.unit('t_gun'); + h.b.loseHp(e, 1e9, { source: g }); + assert.ok(e.alive && e.hp >= 1, 'still standing in its first form'); + assert.equal(h.b.killed, 0); + assert.equal(h.eventsOf('die').filter((ev) => ev[1] === e.id).length, 0, 'no die event: its first form has no death clip'); + for (let i = 0; i < 4; i++) h.b.dealDamage(g, e, { amount: 1, type: 'phys' }); + h.b.loseHp(e, 1e9, { source: g }); + assert.ok(e.alive, 'not during its change either'); + h.run(TRANSLATOR_CHANGE + 0.1); + h.b.loseHp(e, 1e9, { source: g }); + assert.ok(!e.alive && e.removeReason === 'killed', 'the changed form dies'); + assert.equal(h.b.killed, 1); + }); + test('the 4th arts hit ⇒ 特战术师: its stats, ranged arts attacks on 2 targets at once', () => { const h = arena({ units: [{ chessId: 't_mage', row: 11, col: 6 }, { chessId: 't_gun', row: 10, col: 7 }], kits: QUIET_GUNS, hooks: ['damaged'], captureNoisy: true }); h.step(); @@ -397,3 +440,55 @@ describe('#5 转译基底·α: damage is cancelled until its form change; only t assert.equal(e.mem.ab.list.length, 1); }); }); + +// --------------------------------------------------------------------------------------------------------------- +// review follow-ups: the form reaches a view built mid-battle; stable enemy shapes + +describe('a view built mid-battle learns the form (UnitInfo `form` in fieldMeta)', () => { + const formOf = (h, e) => h.b.fieldMeta().units.find((u) => u.id === e.id)?.form; + test('spawned: no form; 转译基底·α changing / changed, a 逐火 ember and its revival, a leader\'s 重生 and second form, 守墓石像 — each fieldMeta carries the current one', () => { + const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }, { chessId: 't_mage', row: 11, col: 4 }], kits: QUIET_GUNS }); + h.step(); + const still = { mods: { speedMul: 0 } }; + const tr = put(h, TR, { pos: [9, 8], ...still }); + const ember = put(h, 'enemy_1288_duskls', { pos: [10, 8], ...still }); + const mace = put(h, 'enemy_1525_blkswb', { pos: [11, 8], ...still }); + const stone = put(h, 'enemy_1172_dugago', { pos: [12, 8], ...still }); + const spawned = h.eventsOf('spawn').find((ev) => ev[1].id === tr.id)[1]; + assert.equal(spawned.form, undefined, 'a fresh enemy has no form'); + assert.equal(formOf(h, tr), undefined); + const m = h.unit('t_mage'); + for (let i = 0; i < 4; i++) h.b.dealDamage(m, tr, { amount: 1, type: 'arts' }); + h.b.kill(ember, m); + h.b.kill(mace, m); + h.b.kill(stone, m); + // the client runner's silent catch-up: the fx are drained and dropped + h.b.step(); + h.b.drainEvents(); + assert.equal(formOf(h, tr), 'translator_shushi', 'changing'); + assert.equal(formOf(h, ember), 'husk'); + assert.equal(formOf(h, mace), 'reborn'); + assert.equal(formOf(h, stone), 'stone'); + h.b.kill(ember, null); // still in its 1 s 重生: nothing changes + while (h.b.time < 12.5) { h.b.step(); h.b.drainEvents(); } + assert.equal(formOf(h, tr), 'translator_shushi', 'changed'); + assert.equal(formOf(h, ember), 'revived', 'the warrior again'); + assert.equal(formOf(h, mace), 'form2', 'after its 重生'); + assert.equal(formOf(h, stone), 'fly'); + }); +}); + +test('every enemy starts with the same profile fields and `moving` / `form` (one object shape for the hot loop)', () => { + const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS }); + h.step(); + const keys = [TR, ...EMBERS, 'enemy_1007_slime', 'enemy_1172_dugago', 'enemy_1525_blkswb', 'enemy_9010_acpupp']; + const shapes = new Set(); + for (const key of keys) { + const e = put(h, key); + assert.equal(e.moving, false, `${key} moving`); + assert.equal(e.form, null, `${key} form`); + assert.ok('dmgType' in e.profile && e.profile.dmgType === null, `${key} profile.dmgType`); + shapes.add(Object.keys(e).join()); + } + assert.equal(shapes.size, 1, 'one key order for every enemy'); +}); From e1ce0316dd98e420ad18762afa43e1139296821a Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 03:44:17 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feedback1=20WD:=20late=20views=20keep=20the?= =?UTF-8?q?=20form=20in=20the=20client,=20=E9=87=8D=E7=94=9F=20clears=20st?= =?UTF-8?q?atuses,=20blocked=20=E9=9A=90=E5=8C=BF=20drawn=20solid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- docs/ASSETS.md | 6 +- docs/PLAYING.md | 2 +- docs/SIM.md | 18 +++-- public/js/render/app.js | 47 ++++++++----- public/js/render/units.js | 2 +- server/sim/content/enemies.js | 49 ++++++++++--- server/sim/snapshot.js | 9 ++- test/render/feedback1-forms.browser.test.js | 76 +++++++++++++++++++- test/render/forms.test.js | 36 +++++++--- test/sim/feedback1-transform.test.js | 78 +++++++++++++++++++++ 10 files changed, 274 insertions(+), 49 deletions(-) diff --git a/docs/ASSETS.md b/docs/ASSETS.md index d63bef74..85595d2f 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -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 diff --git a/docs/PLAYING.md b/docs/PLAYING.md index ae780089..518e9258 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -73,7 +73,7 @@ - **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**重装干员**的手动技能在受到伤害时释放;执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放。**自动触发**的技能按其本身规则,例如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。 - **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。 - **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。 -- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡时才能被选为攻击目标:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(范围伤害也能打到),10 秒内没打掉就满血站起来;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。 +- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡时才能被选为攻击目标:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(挡住时它不再半透明),10 秒内没打掉就满血站起来;敌人重生时身上的减速、恐惧等状态会被清空,被恐惧的逐火战士变成余烬后也能被挡住;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。 - 右上角 🔍▶▶ 可以查看本回合即将来袭的敌人(预览区),左上角 🔍 查看本局信息(禁用盟约、策略)。 ## 5. 联防(同盟) diff --git a/docs/SIM.md b/docs/SIM.md index d1fc8ef8..38a2b951 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -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). --- diff --git a/public/js/render/app.js b/public/js/render/app.js index d827a837..e9e01acc 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -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; } diff --git a/public/js/render/units.js b/public/js/render/units.js index 27aa58bf..54bc5e4f 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -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 diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index 75dfe8bf..4ef112b3 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -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']); diff --git a/server/sim/snapshot.js b/server/sim/snapshot.js index 00a8592c..805275d7 100644 --- a/server/sim/snapshot.js +++ b/server/sim/snapshot.js @@ -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; diff --git a/test/render/feedback1-forms.browser.test.js b/test/render/feedback1-forms.browser.test.js index 98044fbc..9098c6d4 100644 --- a/test/render/feedback1-forms.browser.test.js +++ b/test/render/feedback1-forms.browser.test.js @@ -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(); + } + }); }); diff --git a/test/render/forms.test.js b/test/render/forms.test.js index 22c6f5a9..81934202 100644 --- a/test/render/forms.test.js +++ b/test/render/forms.test.js @@ -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', () => { diff --git a/test/sim/feedback1-transform.test.js b/test/sim/feedback1-transform.test.js index db003d52..8e876b94 100644 --- a/test/sim/feedback1-transform.test.js +++ b/test/sim/feedback1-transform.test.js @@ -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'); + }); +}); From 2591b8638e3bb830ced3a466e7369a5c48942277 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 04:27:24 +0800 Subject: [PATCH 6/6] feedback1 WD: form changes survive the client's catch-up frames, hidden tab and pre-entry buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every setForm fx now carries 'form' (shared/protocol.js fxForm). The runner keeps these fx in catch-up frames (keepsState), holds the state-bearing events of the battle on screen while the tab is hidden and delivers them with the first frame back (re-entering from the field meta past HELD_MAX); screens/game.js buffers them before a field is entered. A husk beaten while its stand-up clip plays dies on the husk's clip. 幽灵 form cited from PRTS; 特战术师 flyer question recorded. --- docs/ASSETS.md | 11 +- docs/SIM.md | 12 +- public/js/battle/runner.js | 42 ++++- public/js/render/app.js | 28 +-- public/js/render/units.js | 14 +- public/js/screens/game.js | 6 +- server/sim/content/enemies.js | 26 +-- shared/protocol.js | 14 ++ test/match/feedback1-runner-forms.test.js | 186 ++++++++++++++++++++ test/render/feedback1-forms.browser.test.js | 67 ++++++- test/render/forms.test.js | 23 ++- 11 files changed, 382 insertions(+), 47 deletions(-) create mode 100644 test/match/feedback1-runner-forms.test.js diff --git a/docs/ASSETS.md b/docs/ASSETS.md index 85595d2f..a5bfd9fc 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -203,11 +203,12 @@ 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 sim's 'phase' fx kind or the `form` of its -'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: +`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 diff --git a/docs/SIM.md b/docs/SIM.md index 38a2b951..4869cfd1 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -245,9 +245,12 @@ still standing after `Revive[Trigger].interval` s stands up again with full HP), 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: 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. +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 @@ -978,7 +981,8 @@ 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?, 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) diff --git a/public/js/battle/runner.js b/public/js/battle/runner.js index 58f2a0fc..c74e7540 100644 --- a/public/js/battle/runner.js +++ b/public/js/battle/runner.js @@ -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); diff --git a/public/js/render/app.js b/public/js/render/app.js index e9e01acc..f9a1bcb9 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -21,8 +21,10 @@ // 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 @@ -89,6 +91,7 @@ // `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'; @@ -1430,24 +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; 转译基底's forms, a 逐火 ember and its - // revival, the leaders' 重生 — user report after 0.1.0): a 'phase' kind, or the `form` any other fx carries, - // 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 - const ex4 = e[4] && typeof e[4] === 'object' && e[4].id != null ? e[4] : null; - const form = !ex4 ? undefined : e[1] === 'phase' ? ex4.kind : 'form' in ex4 ? ex4.form : undefined; + // 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(ex4.id); - if (inf && (typeof form !== 'string' || FORMS[inf.spine || inf.defId]?.[form])) inf.form = typeof form === 'string' ? form : null; - views.get(ex4.id)?.setForm?.(form, ex4); + const inf = infos.get(e[4].id); + 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; diff --git a/public/js/render/units.js b/public/js/render/units.js index 54bc5e4f..3e08db56 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -109,7 +109,8 @@ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, g * - 转译基底·α (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; standing up again → 'Revive', then the warrior's manifest clips; + * 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); @@ -393,11 +394,11 @@ export class UnitView { } /** - * The unit changed mode (sim fx 'phase' { id, kind } or another fx's `form`; `fx` = that fx's extra): the mode's clip + * 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; 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. + * `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, fx = null) { const k = typeof kind === 'string' ? kind : null; @@ -409,7 +410,8 @@ export class UnitView { const f = this._formSpec(); if (!this.actor) return; const dur = fx && Number(fx.dur); - const next = f && f.next ? FORMS[this.info.spine || this.info.defId]?.[f.next]?.roles || null : null; + 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); } diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 55c855a2..b7b5c349 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -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); diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index 4ef112b3..36653b27 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -61,10 +61,10 @@ // kind, tiles?, id?, form?} (delayed strikes, charges, 'reborn' with form 'reborn') · 'beam' {from, to, kind} · // 'summon' {id, key} · 'ember' {id, hits, dur, form: 'husk'} · 'revive' {id, kind?, form: 'form2' | 'revived' | 'fly'} // · 'stone' {id, dur, form: 'stone'} · 'blink' {id, fx, fy} · 'charge' {id, tx, ty} · 'expose' {id} · 'shieldBreak' -// {id} · 'liberate' {id} · 'phase' {id, kind, dur?} (form changes — crawl, translator_* — and barrier / charge states) -// · 'lpLoss' {value, reason} · 'steal' · 'ignite'. Forms go through setForm(): the unit keeps it (`e.form`, UnitInfo -// `form`) and the fx's `form` — a form 'phase' fx's `kind` — is the model's clip set from then on (render/app.js → -// UnitView.setForm, units.js FORMS). +// {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 } from '../constants.js'; @@ -472,13 +472,14 @@ export const isHitCount = (e) => !!e.findBuff(HIT_COUNT_KEY); /** * The enemy's model takes another form (render/units.js FORMS: a clip set of its skeleton): kept on the unit * (`e.form`, published by snapshot.js unitInfo — a view built mid-battle from fieldMeta(): a teammate's field watched - * later, 联防 observers, a reconnect — starts in it) and announced by the fx `fxKind` (+ id, x, y, `params`): a 'phase' - * fx carries the form as its `kind`, any other fx as `form`. Barrier / charge 'phase' kinds are no forms: they go - * through b.fx directly. + * 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 } : { ...params, x: e.x, y: e.y, id: e.id, 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). */ @@ -2119,15 +2120,18 @@ function kitWolfLord(ab) { * 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; - * 幽灵 (form C): no stat change, unblockable, no attack (PRTS lists none; its model has no attack clip). + * 特战术师 (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 幽灵. + * 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; diff --git a/shared/protocol.js b/shared/protocol.js index 912d9034..1e3f1313 100644 --- a/shared/protocol.js +++ b/shared/protocol.js @@ -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; +} diff --git a/test/match/feedback1-runner-forms.test.js b/test/match/feedback1-runner-forms.test.js new file mode 100644 index 00000000..5e397057 --- /dev/null +++ b/test/match/feedback1-runner-forms.test.js @@ -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\);/); +}); diff --git a/test/render/feedback1-forms.browser.test.js b/test/render/feedback1-forms.browser.test.js index 9098c6d4..90ab2923 100644 --- a/test/render/feedback1-forms.browser.test.js +++ b/test/render/feedback1-forms.browser.test.js @@ -7,7 +7,10 @@ // 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. +// 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. @@ -121,6 +124,49 @@ async function lateViewInPage(page, port, s, atSecs, setupSrc) { }, 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 () => { @@ -159,7 +205,7 @@ describe('player reports after 0.1.0: the models follow the knock-out forms (hea 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 + LAG && x.t < state.hit + 1.8); + 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))]})`); @@ -225,4 +271,21 @@ describe('player reports after 0.1.0: the models follow the knock-out forms (hea 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(); + } + }); }); diff --git a/test/render/forms.test.js b/test/render/forms.test.js index 81934202..cd48ec3e 100644 --- a/test/render/forms.test.js +++ b/test/render/forms.test.js @@ -223,6 +223,25 @@ describe('逐火 embers and the 再生 puppet (user report after 0.1.0, #8)', () 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); @@ -348,7 +367,7 @@ test('render/app.js renderInfo keeps UnitInfo `form` (it used to drop it: views 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', () => { +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' \? ex4\.kind : 'form' in ex4 \? ex4\.form[\s\S]{0,200}inf\.form = [\s\S]{0,120}setForm\?\.\(form, ex4\)/); + assert.match(src, /const form = fxForm\(e\);[\s\S]{0,200}inf\.form = form[\s\S]{0,120}setForm\?\.\(form, e\[4\]\)/); });