diff --git a/docs/ASSETS.md b/docs/ASSETS.md index a723f2ab..5fcc8512 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -239,8 +239,11 @@ stealth bit only while its 隐匿 is on: - the 傀儡师 operators' <替身> (form `doll` of the sim's 'substitute' / 'swap' / 'dollEnd' fx, DESIGN §22.11; the `*_B` clips draw the 替身's own slots and hide the 本体's): 归溟幽灵鲨 `Start_B` (it fades in), `Idle_B`, `Die_B` over its last second (it breaks apart and fades), `Die_B_2` when it is knocked out (it collapses), and the 本体 back on `Start_2` (a - form's `leave` clip); 风丸 `Start_B`, `Idle_B` / `Attack_B` / `Die_B`, the 本体 back on `Start`. Their Back skeletons - (facing up) only have `Idle_B` (归溟幽灵鲨 also `Start_2`): the missing switch clips are skipped. + form's `leave` clip); 风丸 `Start_B`, `Idle_B` / `Attack_B` / `Die_B`, the 本体 back on `Start`. Facing up, 归溟幽灵鲨's + Back skeleton has only `Idle_B` and `Start_2`, 风丸's `Start_B`, `Idle_B` and `Attack_B`: the missing clips are skipped. + Neither Back skeleton has the 替身's death clip, so a 替身 knocked out lies on the Front model (like every knocked-out + operator facing up, DESIGN §22.1) with its `Die_B_2` / `Die_B`: the view keeps the form it died in for that model and + for one rebuilt while it lies down, although the sim resets the form right after the knock-out. Not mapped (clip names ambiguous): “自在”, “巨大的丑东西”, 主角阵营角色 and “余音” (`*_A` / `*_B`: which of its two forms is A is not known) keep their manifest clips. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index bb4847e9..ee902999 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -248,7 +248,7 @@ Base stats (from data): `maxHp, atk, def, res` (0–100), `aspd` (100 base), `ba | `hit` | `{ source, target, dmg }` | before mitigation; mutate `dmg` (DamageInfo) | | `damaged` | `{ source, target, amount, type, dmg }` | after application | | `heal` | `{ source, target, amount }` | before application (mutable amount) | -| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — fired by every HP loss of a unit without a boss pool (hits of any type, element bursts, 无来源 damage, `loseHp`); set `prevented`. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤, once per deployment) `PRIO_REVIVE` −100 → items' 复活 (M3茧甲) `PRIO_RESPAWN` −101 → 埃芒加德 −110; the two 复活 revive in place and open a new deployment for the lock (§21.21) | +| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — fired by every HP loss of a unit without a boss pool (hits of any type, element bursts, 无来源 damage, `loseHp`); set `prevented`. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤, once per deployment) `PRIO_REVIVE` −100 → items' 复活 (M3茧甲) `PRIO_RESPAWN` −101 → 埃芒加德 −110; the two 复活 revive in place and open a new deployment for the lock (§21.21); a running 坚固 window's hook sits at `PRIO_UNDYING_HELD` −99, before a 傀儡师's switch to its 替身 (−100, ahead of the lock — a 本体 holding 不死 does not switch, §22.11) | | `kill` | `{ killer, victim }` | victim hp reached 0; a `killed` ability that keeps the enemy alive (a knock-out into a 重生 / form change, §21.4) hides it from every later handler, the kill count, credit and the bounty | | `death` | `{ unit }` | unit removed (ops may redeploy later); the `die` event and the death hooks fire only on removal | | `skillStart` / `skillEnd` | `{ unit, skill }` | | diff --git a/docs/SIM.md b/docs/SIM.md index bca5db18..9adc9dca 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -677,7 +677,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO | `elementHit` | `{ source, target, dmg }` | before a gauge fill (`dmg.type === 'element'`); mutate `dmg.amount`/`dmg.mul`, set `dmg.cancel` | | `damaged` | `{ source, target, amount, type, dmg, credit }` | after application (`amount` may be 0 when shielded); element fills too (with their source); 无来源: `source` null, `credit` set | | `heal` | `{ source, target, amount, opts }` | mutable `amount` | -| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, kit savers, 不死 / 复活 items, 埃芒加德; 不屈 is a `death` hook). Fired by every HP loss of a unit without a boss pool — hits of any type, element bursts, 无来源 damage, `loseHp` 流失. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤 — once per deployment: `items/battle.js deploymentOf`, a key every deploy changes and an in-place 复活 changes too; one battle-level hook holds the running windows (`holdsUndying`), so a window outlasts a lend, DESIGN §21.21 — `PRIO_REVIVE` −100; substitutes −100) → items' 复活 (M3茧甲, `PRIO_RESPAWN` −101: PRTS "复活" acts on a knock-out, which a 不死 prevents) → 埃芒加德 (−110); both 复活 revive in place and call `revivedInPlace` (a new deployment for the lock) | +| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, kit savers, 不死 / 复活 items, 埃芒加德; 不屈 is a `death` hook). Fired by every HP loss of a unit without a boss pool — hits of any type, element bursts, 无来源 damage, `loseHp` 流失. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤 — once per deployment: `items/battle.js deploymentOf`, a key every deploy changes and an in-place 复活 changes too; one battle-level hook holds the running windows (`holdsUndying`), so a window outlasts a lend, DESIGN §21.21 — the lock `PRIO_REVIVE` −100 after the substitutes (−100, registered first), the running windows `PRIO_UNDYING_HELD` −99 before them: a 傀儡师 holding 不死 does not switch, PRTS 分支特性信息 傀儡师 "未持有不死的情况下", DESIGN §22.11) → items' 复活 (M3茧甲, `PRIO_RESPAWN` −101: PRTS "复活" acts on a knock-out, which a 不死 prevents) → 埃芒加德 (−110); both 复活 revive in place and call `revivedInPlace` (a new deployment for the lock) | | `dollSwitch` | `{ unit, reason, done }` | content switches a 傀儡师 to its <替身> now (归溟幽灵鲨 S2 "技能结束后立刻切换为<替身>": no lethal HP loss); its trait does it unless it already is one or is not on the field, and sets `done` | | `kill` | `{ killer, victim }` | victim HP reached 0 (a handler may revive by restoring HP) | | `death` | `{ unit, reason:'killed'|'leak'|'retreat'|'merchant'|'expired'|'forcedExit', killer }` | unit removed (`'forcedExit'`: an operator entering 联防 knocked out, §1.1) | @@ -1098,7 +1098,7 @@ table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡 | agent / hookmaster | can hit FLY (ranged reach); hook displacement comes from skills (generic: `force`) | | bearer | block 0 while the skill is active | | alchemist | ranged lob, can hit FLY | -| dollkeeper | fatal damage (no 不死) ⇒ a 1 s switch animation [ASSUMED length] (无敌, 不死, 阻回, 禁疗, 孤立, 缴械, 眩晕 / 冻结 / 睡眠 immune; ends the running skill and removes the statuses), then the <替身> for 20 s (bb duration): block 0 from the switch on, 阻回, HP = its own 替身 token's (风丸 纸偶), else its own max HP (归溟幽灵鲨 [ASSUMED]); then the switch back (the same animation) at full HP; dies if the 替身 dies. Kit flag `dollNoAttack` (归溟幽灵鲨): the 替身 makes no normal attack and casts no skill. Hook `dollSwitch` switches it at once (归溟幽灵鲨 S2's end). Model form `'doll'` (PRTS 分支特性信息 傀儡师, DESIGN §22.11) | +| dollkeeper | fatal damage (no 不死 — a running 坚固维式重锤 window comes first) ⇒ a 1 s switch animation [ASSUMED length, also on a direct switch] (无敌, 不死, 阻回, 禁疗, 孤立, 缴械, 眩晕 / 冻结 / 睡眠 immune; ends the running skill and removes the statuses), then the <替身> for 20 s (bb duration): block 0 from the switch on, 阻回, HP = its own 替身 token's (风丸 纸偶), else its own max HP (归溟幽灵鲨: PRTS resets it "至最大值", the trait's 替身 HP bonus `max_hp` is 0, 风丸's 纸偶 has her HP); then the switch back (the same animation) at full HP; dies if the 替身 dies. Kit flag `dollNoAttack` (归溟幽灵鲨): the 替身 makes no normal attack and casts no skill. Hook `dollSwitch` switches it at once (归溟幽灵鲨 S2's end). Model form `'doll'` (PRTS 分支特性信息 傀儡师, DESIGN §22.11) | | geek | loses 1–3 % max HP per second (bb hp_ratio), never lethal on its own | | merchant | −3 DP every 3 s (bb cost/interval); retreats when DP runs out | | skywalker | can block FLY enemies (蒂比's kit: only while airborne — 起飞, flag `liftoff`, which also releases the ground enemies she blocked) | diff --git a/public/js/render/app.js b/public/js/render/app.js index 76893c83..8ee0e8d6 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -258,8 +258,9 @@ export const showsDeathFx = (info, consumed = false, reason = null) => !consumed /** * 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 + * unitInfo), sanitised; null for a malformed entry. `form` — the unit's current model form (an enemy's, content/enemies.js + * setForm: 转译基底·α's forms, a 逐火 余烬, a leader after its 重生, 掠海漂移体's crawl; a 傀儡师 fighting as its 替身, sim + * professions.js) — 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). */ @@ -273,7 +274,7 @@ export function renderInfo(u) { // 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) + // the unit's current model form (UnitInfo.form: an enemy's mode, a 傀儡师's 替身): 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) diff --git a/public/js/render/fx.js b/public/js/render/fx.js index bde545d1..cd357321 100644 --- a/public/js/render/fx.js +++ b/public/js/render/fx.js @@ -165,7 +165,7 @@ export const FX_KINDS = Object.freeze({ frostNova: { a: 'blast', c: 0x9fe6ff, smoke: 0x1c2630 }, sunBurst: { a: 'blast', c: 0xffe28a }, meltdown: { a: 'blast', c: 0xff5a2a, r: 1.5, heavy: true }, iceSpike: { a: 'blast', c: 0xbfeeff, smoke: 0x1c2630 }, rockfall: { a: 'blast', c: 0xc8a878, smoke: 0x4a3f33 }, rockslide: { a: 'blast', c: 0xc8a878, smoke: 0x4a3f33 }, finale: { a: 'blast', c: 0xffd45a, r: 1.5 }, swordStorm: { a: 'blast', c: 0xdfe8ff }, swordRain: { a: 'blast', c: 0xdfe8ff }, liberate: { a: 'blast', c: 0xffffff }, - knockout: { a: 'crit', c: 0xffc27a }, quadShot: { a: 'volley', c: 0xfff2d0 }, featherArrow: { a: 'counter', c: 0xfff2d0 }, + quadShot: { a: 'volley', c: 0xfff2d0 }, featherArrow: { a: 'counter', c: 0xfff2d0 }, burst: { a: 'element', c: 0xd0a0ff }, // areas zone: { a: 'zone', c: 0xffb35c, dur: 3 }, healField: { a: 'zone', c: 0x62f08a, dur: 4 }, firewall: { a: 'wall', c: 0xff6a2a, dur: 4 }, diff --git a/public/js/render/units.js b/public/js/render/units.js index 6feb58dc..dbd23b44 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -36,8 +36,9 @@ // Enemy modes (the `form` of a sim fx — shared/protocol.js fxForm — → `setForm(form, fx)`): 掠海漂移体 dropping to 爬行模式 // (user playtest #5 item 1) plays its skeleton's 'Change' clip once, then the crawl set (*_02); 暴鸰 flies on without its // bomb (*_2) after the drop (feedback D4); 转译基底's forms, the 逐火 embers, 再生's puppet, the leaders' 重生 and 守墓石像 -// likewise (user report after 0.1.0) — FORMS. A view built later (`info.form` = UnitInfo `form`, the sim's current form, -// through render/app.js renderInfo) starts in the mode. +// likewise (user report after 0.1.0) — FORMS; an operator's form is a 傀儡师's 替身 (GitHub issue #44). A view built later +// (`info.form` = UnitInfo `form`, the sim's current form, through render/app.js renderInfo) starts in the mode; a dead +// view keeps the form it died in (`_dieForm`) for a model built while it lies down. // 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 @@ -162,8 +163,11 @@ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, g * 本体's are hidden). 归溟幽灵鲨: Start_B fades it in (the 1 s switch), Idle_B (it never attacks), Die_B breaks it * apart over its last second (`end`, timed from the 'substitute' fx's `dur`), the 本体 comes back on Start_2 (`leave`: * played when the form ends); knocked out as the 替身 it collapses on Die_B_2 and stays down so. 风丸: Start_B, then - * Idle_B / Attack_B (her 替身 attacks), Die_B; the 本体 comes back on Start. A Back skeleton (facing UP) only has - * Idle_B (归溟幽灵鲨 also Start_2): the switch clips it lacks are skipped. + * Idle_B / Attack_B (her 替身 attacks), Die_B; the 本体 comes back on Start. Facing UP: 归溟幽灵鲨's Back skeleton has + * only Idle_B and Start_2, 风丸's Start_B, Idle_B and Attack_B — the clips a Back skeleton lacks are skipped; neither has + * the 替身's death clip, so a 替身 knocked out lies on the Front model like every knocked-out operator facing UP + * (_wantsBack). The sim resets the form right after the 'die' event; the form it died in (`_dieForm`) gives the model + * built for the knock-out — and one rebuilt while it is down — the 替身's death clip; it stands up as the 本体. * 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 }); @@ -328,7 +332,8 @@ export class UnitView { this.shake = 0; this.screen = { x: 0, y: 0, s: 1, top: 0 }; this.destroyed = false; - this.form = typeof info.form === 'string' ? info.form : null; // an enemy's mode (setForm, FORMS) + this.form = typeof info.form === 'string' ? info.form : null; // the unit's model form: an enemy's mode, a 傀儡师's 替身 (setForm, FORMS) + this._dieForm = null; // the form it died in (die): a model built while it lies down shows that form's death // --- display objects this.shadow = new P.Sprite(ctx.shadowTex || shadowTexture()); @@ -473,8 +478,10 @@ export class UnitView { this.spineReady = true; this.swapT = swap ? 1 : 0; this.actor.spine.alpha = swap ? 1 : 0; - // a mode the unit is already in (a model built or rebuilt after the change): its clip set, no change clip - const f = this._formSpec(); + // a mode the unit is already in (a model built or rebuilt after the change): its clip set, no change clip — a dead + // one lies in the form it died in (a 替身 knocked out: the sim resets the form at once, but the Front model it lies + // down with facing UP, §22.1, is built a frame later; a model rebuilt while it is down) + const f = this._formSpec() || (this.alive ? null : this._dieForm); if (f) this.actor.setForm(f.roles); // replay current state (a dead model resumes its Die clip where it would be — a knocked-down one holds its end) if (!this.alive) { @@ -763,12 +770,15 @@ export class UnitView { * An operator facing UP whose Back model has no Die clip (GitHub issue #25) falls with its Front model: the swap is * made by this frame's update (`_modelDirty` → _syncModel, after every event and snapshot of the frame, so a death * and a redeploy in one frame — a 突袭 jump, a 不屈 revive, a backlog after a hidden tab — load nothing); meanwhile the - * Back model holds still (SpineActor.die), and the Front model's Die clip times the fade. + * Back model holds still (SpineActor.die), and the Front model's Die clip times the fade. The form it dies in + * (`_dieForm`: a 傀儡师's 替身, which the sim resets right after the 'die' event) gives that model — and any built while + * it lies down — its death clip, until it stands up again. */ die(instant = false) { if (!this.alive) return; this.alive = false; this._modelDirty = true; + this._dieForm = this._formSpec(); let d = this.actor ? this.actor.die() : 0; if (this.actor && !d) d = this._fallDur(); const rate = this.ctx.animRate?.() || 1; @@ -789,6 +799,9 @@ export class UnitView { revive() { this.alive = true; this._modelDirty = true; + // a model built while it lay in a form's death pose (_dieForm) stands up on the unit's own clips + if (this._dieForm && this.actor && !this._formSpec()) this.actor.setForm(null); + this._dieForm = null; this.dying = 0; this.remove = false; this.alpha = 1; this.down = null; this.hp = this.maxHp; this.ghostHp = this.hp; diff --git a/server/sim/content/items/battle.js b/server/sim/content/items/battle.js index 970075a3..0cce0b4b 100644 --- a/server/sim/content/items/battle.js +++ b/server/sim/content/items/battle.js @@ -20,8 +20,10 @@ // either quality satisfies them; lent items count as carried. Both are evaluated live, at the moment of use. // // Hook priorities (non-default): 'fatal' — consumable death savers run LAST, so a skill's / talent's own undying (kits -// use 10 … −60) never wastes a charge: 坚固维式重锤's lock (异常效果 不死, once per deployment — deploymentOf; its running -// windows held by one battle-level hook) at PRIO_REVIVE −100, then the M3茧甲 revive at PRIO_RESPAWN −101 — PRTS +// use 10 … −60) never wastes a charge: 坚固维式重锤's lock (异常效果 不死, once per deployment — deploymentOf) at +// PRIO_REVIVE −100 and its running windows, held by one battle-level hook, at PRIO_UNDYING_HELD −99 — before a 傀儡师's +// switch to its 替身 (professions.js, −100: PRTS 分支特性信息 傀儡师 "受到足以致命的伤害且未持有不死的情况下"), while the +// lock itself still comes after that switch (as in 0.1.1); then the M3茧甲 revive at PRIO_RESPAWN −101 — PRTS // 卫戍协议:盟约 下半/PRTS盟约记录 备注 "“复活”的实现方式为:受益者因移动之外的原因退场时下次部署的再部署时间和费用归零": a // revive acts on a knock-out, which a 不死 prevents, so the lock always comes first whatever the equip order (player // report F1 after 0.1.0: with the 茧甲 equipped first the revive ran first and the first lethal hit showed no lock); @@ -65,6 +67,8 @@ const chance = (battle, p) => p > 0 && (p >= 1 || battle.rng() < p); const ENEMY_RECORD = /^enemy_/; /** 'fatal' priorities (see header). */ export const PRIO_REVIVE = -100; +/** A running 坚固 window (不死 held): before a 傀儡师's switch to its 替身 at −100 — see header. */ +export const PRIO_UNDYING_HELD = PRIO_REVIVE + 1; /** The items' 复活 (M3茧甲): acts on a knock-out, so after every 不死 (PRIO_REVIVE) — see header. */ export const PRIO_RESPAWN = PRIO_REVIVE - 1; const PRIO_FREE_UNDYING = 20; @@ -277,9 +281,10 @@ function hammerAcquire(battle, rt, u) { const hs = hammerState(battle, rt, u); hs.refs++; if (hs.scope) return hs; - // every running 坚固 window of the battle (holdsUndying): one hook that no grant owns, registered before any lock hook + // every running 坚固 window of the battle (holdsUndying): one hook that no grant owns, ahead of every lock hook and of a + // 傀儡师's switch to its 替身 (PRIO_UNDYING_HELD: a 本体 holding 不死 does not switch) if (!rt.undyingHook) { - rt.undyingHook = battle.on('fatal', (c) => { if (!c.prevented && holdsUndying(battle, c.unit)) c.prevented = true; }, { priority: PRIO_REVIVE }); + rt.undyingHook = battle.on('fatal', (c) => { if (!c.prevented && holdsUndying(battle, c.unit)) c.prevented = true; }, { priority: PRIO_UNDYING_HELD }); } const S = new Scope(battle, u, `item:hammer#${u.id}`); hs.scope = S; diff --git a/server/sim/content/kits/tier5.js b/server/sim/content/kits/tier5.js index f6fd8cb9..7ce21345 100644 --- a/server/sim/content/kits/tier5.js +++ b/server/sim/content/kits/tier5.js @@ -1700,11 +1700,11 @@ const KITS = { { install(battle, unit) { // 拥抱自我 (the 替身 fighting: not during a switch animation) const slow = num(t0.move_speed), scale = num(t0.atk_scale); whileOn(battle, unit, AURA_IV, () => { - if (!unit.trait.doll || unit.trait.dollSwitch || !slow) return; + if (!unit.trait.doll || unit.trait.dollSwitching || !slow) return; for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) if (!e.isFlying) battle.addBuff(e, { key: 'ghost2:embrace', duration: AURA_DUR, mods: { moveMul: Math.max(0, 1 + slow) } }); }); whileOn(battle, unit, 1, () => { - if (!unit.trait.doll || unit.trait.dollSwitch || !(scale > 0)) return; + if (!unit.trait.doll || unit.trait.dollSwitching || !(scale > 0)) return; for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) if (!e.isFlying) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', tags: ['talent', 'embrace'] }); }); } }, diff --git a/server/sim/professions.js b/server/sim/professions.js index 9ea79718..27f3644d 100644 --- a/server/sim/professions.js +++ b/server/sim/professions.js @@ -93,10 +93,12 @@ const installCharger = (battle, unit) => { /** * 傀儡师 (dollkeeper) trait "受到致命伤时不撤退,切换成<替身>作战(替身阻挡数为0),持续20秒后自身再次替换<替身>" with the - * branch rules of PRTS 分支特性信息 傀儡师: a lethal hit on the <本体> (one it survives with 不死 does not count) starts a - * switch animation, after which the operator fights as its <替身> for the trait's 20 s (bb duration) holding 阻回; then - * it switches back the same way. A switch animation clears the operator's Buffs and resets its HP to the max of the form - * it switches to; during one the operator holds 不死, 无敌, 阻回, 禁疗, 孤立, 强制缴械 and is immune to 眩晕 / 冻结 / 睡眠. + * branch rules of PRTS 分支特性信息 傀儡师: a lethal hit on the <本体> (one it survives with 不死 does not count: "受到足以致命的 + * 伤害且未持有不死的情况下" — a skill's / talent's own 不死 runs earlier, a running 坚固维式重锤 window just before this hook, + * items/battle.js PRIO_UNDYING_HELD −99) starts a switch animation, after which the operator fights as its <替身> for the + * trait's 20 s (bb duration) holding 阻回; then it switches back the same way. A switch animation clears the operator's + * Buffs and resets its HP to the max of the form it switches to; during one the operator holds 不死, 无敌, 阻回, 禁疗, 孤立, + * 强制缴械 and is immune to 眩晕 / 冻结 / 睡眠. * The 替身 blocks nothing from the start of the switch to it until the switch back starts; a lethal hit on it knocks the * operator out. Content may switch the operator at once (hook `dollSwitch` { unit, reason, done }: 归溟幽灵鲨 S2 "技能结束 * 后立刻切换为<替身>"). A kit's `dollNoAttack` profile flag (归溟幽灵鲨, PRTS 特性备注 "<替身>不进行普通攻击") disarms the 替身 @@ -104,10 +106,14 @@ const installCharger = (battle, unit) => { * 技能范围 — likewise); 风丸's 替身 attacks (PRTS: "<替身>状态下可对空"). * The form is the unit's model state (`unit.form` 'doll', snapshot.js unitInfo; fx 'substitute' / 'swap' / 'dollEnd' with * `form`, shared/protocol.js fxForm): the client draws the 替身 on the skeleton's *_B clips (render/units.js FORMS). + * The 替身's max HP is the operator's own (PRTS: HP "重设…至最大值"; the trait blackboard's 替身 HP bonus `max_hp` is 0 for + * both 傀儡师 — the elite PUM-Y module's +20 % aside — and 风丸's 纸偶 token has her max HP at every level), or its 替身 + * token's (风丸). * [ASSUMED]: a switch animation lasts DOLL_SWITCH (the skeletons' 1 s Start_B — the 替身 appearing — and Start_2 — the - * 本体 back); "清除自身一切Buff" ends the running skill and removes the statuses (buffs with a catalogue `status`) — the - * remake's bond / item / talent effects are buffs too and stay; a dollkeeper without a 替身 token of its own (归溟幽灵鲨) keeps - * its own max HP (PRTS: HP "重设…至最大值"; character_table has no 替身 of hers, and 风丸's 纸偶 token has her HP). + * 本体 back), also on a direct switch (`dollSwitch`); "清除自身一切Buff" ends the running skill and removes the statuses + * (buffs with a catalogue `status`) — every other buff stays, the remake's bond / item / talent effects (buffs too) + * and other units' timed buffs and shields alike; 无敌 is damage immunity only (as for every ally 无敌 here — PRTS's + * "无法被不同阵营选中" is not modelled for it). */ export const DOLL_SWITCH = 1; const DOLL_SWITCH_IMMUNE = new Set(['stun', 'freeze', 'sleep']); @@ -127,10 +133,10 @@ const installDollkeeper = (battle, unit) => { const sk = unit.skill; if (sk && sk.active && sk.kind !== 'passive') sk.end('substitute'); for (const b of unit.buffs.slice()) if (b.status) battle.removeBuff(unit, b); - unit.trait.dollSwitch = true; - const done = () => { unit.trait.dollSwitch = false; }; + unit.trait.dollSwitching = true; + const done = () => { unit.trait.dollSwitching = false; }; battle.addBuff(unit, { - key: 'trait:dollSwitch', duration: DOLL_SWITCH, onExpire: done, onRemove: done, + key: 'trait:dollSwitching', duration: DOLL_SWITCH, onExpire: done, onRemove: done, flags: { invulnerable: true, noSp: true, noHeal: true, isolated: true, disarm: true }, }); }; @@ -166,18 +172,18 @@ const installDollkeeper = (battle, unit) => { }; battle.on('fatal', (ctx) => { if (ctx.unit !== unit || ctx.prevented) return; - if (unit.trait.dollSwitch) { ctx.prevented = true; return; } // 不死 while switching (a 流失 ignores 无敌) + if (unit.trait.dollSwitching) { ctx.prevented = true; return; } // 不死 while switching (a 流失 ignores 无敌) if (unit.trait.doll) return; // the 替身 is knocked out ctx.prevented = enter(); }, { owner: unit, priority: -100 }); battle.on('dollSwitch', (ctx) => { if (ctx.unit === unit && !ctx.done) ctx.done = enter(); }, { owner: unit }); battle.on('beforeStatus', (ctx) => { - if (ctx.target === unit && unit.trait.dollSwitch && DOLL_SWITCH_IMMUNE.has(ctx.status)) ctx.cancel = true; + if (ctx.target === unit && unit.trait.dollSwitching && DOLL_SWITCH_IMMUNE.has(ctx.status)) ctx.cancel = true; }, { owner: unit }); battle.on('death', (ctx) => { if (ctx.unit !== unit) return; unit.trait.doll = false; - unit.trait.dollSwitch = false; + unit.trait.dollSwitching = false; // knocked out as the 替身: its death clip has played (the 'die' event came first); the redeploy is the 本体 again if (unit.form) { unit.form = null; battle.fx('dollEnd', at({ form: null })); } }, { owner: unit }); diff --git a/test/render/feedback2-doll.test.js b/test/render/feedback2-doll.test.js index b8fcf318..a1751cb3 100644 --- a/test/render/feedback2-doll.test.js +++ b/test/render/feedback2-doll.test.js @@ -2,8 +2,10 @@ // its <替身> is in the model form 'doll' (sim professions.js: fx 'substitute' { form: 'doll', dur } … 'swap' / 'dollEnd' // { form: null }, UnitInfo `form`), drawn with the skeleton's *_B clips (render/units.js FORMS): 归溟幽灵鲨's Start_B fades // the 替身 in, Idle_B, Die_B breaks it apart over its last second, the 本体 comes back on Start_2 (the form's `leave` -// clip); knocked out as the 替身 she collapses on Die_B_2 and stays so. 0.1.1 drew the 本体 all along. Headless fake PIXI -// (test/render/fakepixi.js); the clip meanings are checked on the real skeleton when the assets are fetched. +// clip); knocked out as the 替身 she collapses on Die_B_2 and stays so — facing UP too, on the Front model a knocked-out +// operator lies with (§22.1), and on any model built while she lies down. 0.1.1 drew the 本体 all along. Headless fake +// PIXI (test/render/fakepixi.js) on the real manifest and asset helpers; the clip meanings are checked on the real skeleton +// when the assets are fetched. import { test, describe, before, after } from 'node:test'; import assert from 'node:assert/strict'; @@ -15,6 +17,7 @@ import { installFakePixi, fakeViewCtx } from './fakepixi.js'; import { presetCamera } from '../../public/js/render/projection.js'; import { statusIconKey } from '../../public/js/render/style.js'; import { STATUS_KEYS } from '../../public/js/render/textures.js'; +import { spineEntry, hasBackSpine } from '../../public/js/assets.js'; import { fxForm } from '../../shared/protocol.js'; import { makeBattle, enemyRec } from '../helpers/battleHarness.js'; import { unitInfo } from '../../server/sim/snapshot.js'; @@ -34,19 +37,20 @@ after(() => fake.restore()); const tick = () => new Promise((r) => setImmediate(r)); const cam = () => presetCamera('normal', { width: 1280, height: 720 }); -/** Asset store with the operator's real Front / Back manifest entries and their animation names. */ -function store(id) { - const sp = assets.chars[id].spine; - return { +/** Asset store on the real manifest (Front / Back entries, their animation names) and asset helpers; loads counted. */ +function store() { + const s = { + loads: 0, picture: () => null, image: async () => null, - hasBack: () => !!sp.back, - spineEntry: (_, o = {}) => (o.back ? sp.back : sp.front), - spine: { acquire: async (entry) => ({ animations: Object.keys(entry.animations || {}).map((name) => ({ name })) }), release() {} }, + hasBack: (id) => hasBackSpine(assets, id), + spineEntry: (id, o) => spineEntry(assets, id, o), + spine: { acquire: async (entry) => { s.loads++; return { animations: Object.keys(entry.animations || {}).map((name) => ({ name })) }; }, release() {} }, }; + return s; } async function op(id, info = {}) { - const ctx = fakeViewCtx(fake.P, { assets: store(id), cam }); + const ctx = fakeViewCtx(fake.P, { assets: store(), cam }); const v = new UnitView(ctx, { id: 1, side: 'ally', kind: 'op', defId: id, spine: id, tier: 5, x: 5, y: 9, maxHp: 2657, dir: 'RIGHT', skillIndex: 1, ...info }); await tick(); await tick(); assert.ok(v.actor, 'Spine model built'); @@ -134,6 +138,99 @@ describe('归溟幽灵鲨\'s 替身 (FORMS doll)', () => { }); }); +// Knocked out as the 替身 with the sim's order in one frame: 'die', then fx 'dollEnd' { form: null } (the form back to the +// 本体 for the redeploy), then the frame's update. Facing UP the view lies down on the Front model (§22.1: the Back +// skeleton has no fall), loaded after the reset: it still lies as the 替身 (the form it died in, `_dieForm`), and so does +// any model built while she is down; the redeploy is the 本体 on the Back model. +describe('knocked out as the 替身: facing UP, models built while down, the redeploy (§22.1 × §22.11)', () => { + const DOWN = [1, 70, 70, 0, 9, 5]; + const settle = async () => { await tick(); await tick(); }; + async function doll(id, dir) { + const ctx = fakeViewCtx(fake.P, { assets: store(), cam }); + const v = new UnitView(ctx, { id: 1, side: 'ally', kind: 'op', defId: id, spine: id, tier: 5, x: 5, y: 9, maxHp: 2657, dir, skillIndex: 1 }); + await settle(); + v.setForm('doll', { id: 1, form: 'doll', dur: 21 }); + secs(v, 2); + assert.equal(clip(v), 'Idle_B'); + return v; + } + const knockOut = (v) => { v.die(); v.setForm(null, { id: 1, form: null }); v.setDown(DOWN, 0); }; + + test('归溟幽灵鲨 facing UP: the Front model shows the 替身 collapsing (Die_B_2), with the reset in the same frame or the next', async () => { + for (const late of [false, true]) { + const v = await doll(GHOST, 'UP'); + assert.ok(v.entryBack, 'standing: the Back model'); + v.die(); + if (!late) v.setForm(null, { id: 1, form: null }); + v.setDown(DOWN, 0); + frames(v, 1); + if (late) v.setForm(null, { id: 1, form: null }); + await settle(); secs(v, 0.5); + assert.ok(!v.entryBack, 'down: the Front model (its Back skeleton has no fall)'); + assert.equal(clip(v), 'Die_B_2', `the 替身 collapses (reset ${late ? 'a frame later' : 'in the same frame'})`); + assert.equal(v.form, null, 'the form is the 本体\'s again'); + } + }); + + test('a model built while she lies down keeps the 替身\'s pose (facing RIGHT: dropped and loaded again)', async () => { + const v = await doll(GHOST, 'RIGHT'); + knockOut(v); + frames(v, 1); await settle(); secs(v, 0.5); + assert.equal(clip(v), 'Die_B_2'); + v._dropActor(); v.retryAssets(); await settle(); frames(v, 5); + assert.equal(clip(v), 'Die_B_2', 'rebuilt: still the 替身'); + }); + + test('the redeploy is the 本体: facing UP back on the Back model, its deploy clip, then its idle', async () => { + const v = await doll(GHOST, 'UP'); + knockOut(v); + frames(v, 1); await settle(); secs(v, 2); + v.setDown(null); v.onDeploy(); + frames(v, 1); await settle(); frames(v, 2); + assert.ok(v.entryBack, 'the Back model again'); + assert.equal(clip(v), 'Start'); + secs(v, 1.2); + assert.equal(clip(v), 'Idle', 'the 本体\'s idle, not Idle_B'); + }); + + test('revived in the same frame (阿戈尔: die → deploy → dollEnd): the 本体 comes back on Start_2, no model is loaded', async () => { + for (const dir of ['UP', 'RIGHT']) { + const v = await doll(GHOST, dir); + const loads = v.ctx.assets.loads; + v.die(); v.onDeploy(); v.setForm(null, { id: 1, form: null }); + frames(v, 1); await settle(); frames(v, 2); + assert.equal(clip(v), 'Start_2', dir); + assert.equal(v.ctx.assets.loads, loads, `${dir}: no model load`); + } + }); + + test('风丸 facing UP: her Back skeleton\'s Start_B and Attack_B; knocked out as the 替身, the Front model\'s Die_B', async () => { + const ctx = fakeViewCtx(fake.P, { assets: store(), cam }); + const v = new UnitView(ctx, { id: 1, side: 'ally', kind: 'op', defId: KAZEMA, spine: KAZEMA, tier: 2, x: 5, y: 9, maxHp: 1816, dir: 'UP', skillIndex: 0 }); + await settle(); + assert.ok(v.entryBack); + v.setForm('doll', { id: 1, form: 'doll', dur: 21 }); + assert.equal(clip(v), 'Start_B', 'its Back skeleton has the switch clip'); + secs(v, 1.1); + v.onAttack(null, 2); + assert.equal(clip(v), 'Attack_B', '…and the 替身\'s attack'); + secs(v, 2); + knockOut(v); + frames(v, 1); await settle(); secs(v, 0.5); + assert.ok(!v.entryBack); + assert.equal(clip(v), 'Die_B'); + }); + + test('[ASSUMED] a view built from UnitInfo while she lies down (no form: the sim reset it) shows the 本体\'s fall', async () => { + const ctx = fakeViewCtx(fake.P, { assets: store(), cam }); + const v = new UnitView(ctx, { id: 1, side: 'ally', kind: 'op', defId: GHOST, spine: GHOST, tier: 5, x: 5, y: 9, maxHp: 2657, dir: 'UP', skillIndex: 1 }); + await settle(); + v.setDown(DOWN, 0, true); + frames(v, 1); await settle(); frames(v, 2); + assert.equal(clip(v), 'Die'); + }); +}); + test('风丸\'s 替身: Start_B, Idle_B / Attack_B (it attacks), Die_B; the 本体 back on Start', async () => { const anims = assets.chars[KAZEMA].spine.front.animations; for (const name of ['Start_B', 'Idle_B', 'Attack_B', 'Die_B', 'Start']) assert.ok(name in anims, name); diff --git a/test/sim/feedback2-doll.test.js b/test/sim/feedback2-doll.test.js index 97c59783..d2e8a5d2 100644 --- a/test/sim/feedback2-doll.test.js +++ b/test/sim/feedback2-doll.test.js @@ -9,6 +9,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; import { DOLL_SWITCH } from '../../server/sim/professions.js'; +import { holdsUndying } from '../../server/sim/content/items/battle.js'; import { hasGeneratedData } from '../../server/sim/simdata.js'; import { fxForm } from '../../shared/protocol.js'; @@ -18,11 +19,11 @@ const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${m const dummy = () => enemyRec({ key: 'enemy_dummy', hp: 1e9, atk: 0, speed: 0 }); /** 归溟幽灵鲨 on (9,5) facing right; a harmless parked enemy on (9,6) (in her range) unless `enemy` is false. */ -function setup({ id = G, skillIndex = 1, sp = 0, enemy = true, capture = false } = {}) { +function setup({ id = G, skillIndex = 1, sp = 0, enemy = true, capture = false, items = undefined } = {}) { const h = makeBattle({ seed: 4, timeLimit: 300, autoFinish: false, captureNoisy: capture, defs: { enemies: { enemy_dummy: dummy() } }, - units: [{ chessId: id, row: 9, col: 5, dir: 'RIGHT', skillIndex, carryState: { sp } }], + units: [{ chessId: id, row: 9, col: 5, dir: 'RIGHT', skillIndex, carryState: { sp }, items }], enemies: enemy ? [{ key: 'enemy_dummy', pos: [9, 6] }] : [], }); h.step(); @@ -46,7 +47,7 @@ test('A: a lethal hit with no skill running ⇒ the 替身 (form, full HP, block lethal(); assert.ok(g.alive && g.trait.doll && g.form === 'doll', `${id}/${skillIndex}: the 替身`); approx(g.hp, g.s.maxHp, 'full HP'); - approx(g.s.maxHp, full * (1 + (g.def.traitBb.max_hp || 0)), 'her own max HP (no 替身 token) [ASSUMED]'); + approx(g.s.maxHp, full * (1 + (g.def.traitBb.max_hp || 0)), 'her own max HP (no 替身 token; trait bb max_hp the module bonus)'); assert.equal(g.s.blockCnt, 0); const [sub] = fx('substitute'); assert.ok(sub && fxForm(sub) === 'doll', 'the fx puts the model in its 替身 form'); @@ -58,7 +59,7 @@ test('A: a lethal hit with no skill running ⇒ the 替身 (form, full HP, block assert.ok(e.findBuff('ghost2:embrace'), '拥抱自我 slows'); assert.ok(h.hooksOf('damaged').some((c) => c.source === g && c.target === e && (c.dmg.tags || []).includes('embrace')), '…and deals arts damage'); h.run(1.05); - assert.ok(!g.trait.doll && g.form === null && g.trait.dollSwitch, 'switching back after 1 s + 20 s'); + assert.ok(!g.trait.doll && g.form === null && g.trait.dollSwitching, 'switching back after 1 s + 20 s'); assert.ok(fxForm(fx('swap')[0]) === null, 'the fx gives the 本体 its model back'); assert.equal(g.s.blockCnt, 2); approx(g.hp, g.s.maxHp, 'full HP again'); @@ -157,6 +158,25 @@ test('S1 / S3 running at a lethal hit end with the switch (清除自身一切Buf assert.equal(s2.h.b.emit('dollSwitch', { unit: s2.g, reason: 'test', done: false }).done, false, 'already the 替身'); }); +test('a 本体 holding 不死 (a running 坚固维式重锤 window) does not switch: held at 1 HP (PRTS "受到足以致命的伤害且未持有不死的情况下")', { skip }, () => { + const { h, g, lethal } = setup({ items: ['chess_item_3_09_e_a'] }); + lethal(); + assert.ok(g.trait.doll && !holdsUndying(h.b, g), 'the first lethal hit: the switch, the hammer\'s lock unspent'); + h.run(15); + lethal(); + assert.ok(g.alive && g.trait.doll && holdsUndying(h.b, g), 'the 替身\'s lethal hit sets off the lock: 8 s of 不死'); + approx(g.hp, 1, 'held at 1 HP'); + assert.ok(h.runUntil(() => !g.trait.doll && !g.trait.dollSwitching, 10), 'switched back'); + assert.ok(holdsUndying(h.b, g), 'the window still runs'); + lethal(); + assert.ok(g.alive && !g.trait.doll && g.form === null, 'no switch while it holds 不死'); + approx(g.hp, 1, 'held at 1 HP'); + assert.ok(h.runUntil(() => !holdsUndying(h.b, g), 10)); + lethal(); + assert.ok(g.alive && g.trait.doll, 'the window over: the switch'); + checkInvariants(h.b); +}); + test('风丸: her 替身 still attacks (PRTS "<替身>状态下可对空"); the same 替身 form, switch and 阻回', { skip }, () => { const id = 'chess_char_2_11_a'; const { h, g, log, lethal } = setup({ id, skillIndex: 0 }); diff --git a/test/sim/fidelity.test.js b/test/sim/fidelity.test.js index 010b87c6..a438f4aa 100644 --- a/test/sim/fidelity.test.js +++ b/test/sim/fidelity.test.js @@ -260,7 +260,7 @@ test('dollkeeper without a substitute token (归溟幽灵鲨) keeps its own max const full = u.s.maxHp; h.b.dealDamage(null, u, { amount: 1e7, type: 'true' }); assert.ok(u.alive && u.hasBuff('trait:substitute')); - // PRTS 分支特性信息 傀儡师 "重设自身生命至最大值"; no 替身 of hers in character_table [ASSUMED: her own max HP] + // PRTS 分支特性信息 傀儡师 "重设自身生命至最大值"; the trait's 替身 HP bonus (bb max_hp) is 0 and 风丸's 纸偶 has her own HP approx(u.s.maxHp, full, 1e-6); approx(u.hp, full, 1e-6); }); diff --git a/test/sim/professions.test.js b/test/sim/professions.test.js index 2a8a38fa..507940c5 100644 --- a/test/sim/professions.test.js +++ b/test/sim/professions.test.js @@ -166,7 +166,7 @@ test('dollkeeper: fatal damage ⇒ a 1 s switch, 20 s as the substitute (block 0 h.b.dealDamage(null, u, { amount: 1e5, type: 'true' }); assert.equal(u.alive, true, 'substitute instead of death'); assert.equal(u.s.blockCnt, 0); - assert.ok(u.trait.doll && u.trait.dollSwitch, 'the switch animation'); + assert.ok(u.trait.doll && u.trait.dollSwitching, 'the switch animation'); assert.equal(u.form, 'doll'); approx(u.hp, 2000); // no 替身 token: its own max HP // the switch animation (PRTS 分支特性信息 傀儡师): 无敌, 不死 (a 流失 stops at 1 HP), 阻回, 眩晕 immunity @@ -177,15 +177,15 @@ test('dollkeeper: fatal damage ⇒ a 1 s switch, 20 s as the substitute (block 0 assert.ok(u.alive && u.trait.doll && u.hp >= 1, '不死'); u.hp = 2000; h.run(1.05); - assert.ok(u.trait.doll && !u.trait.dollSwitch, 'fighting as the substitute'); + assert.ok(u.trait.doll && !u.trait.dollSwitching, 'fighting as the substitute'); h.run(19.9); assert.ok(u.trait.doll && u.s.flags.noSp, 'the 20 s form, 阻回'); h.run(0.1); - assert.ok(!u.trait.doll && u.trait.dollSwitch && u.form === null, 'switching back'); + assert.ok(!u.trait.doll && u.trait.dollSwitching && u.form === null, 'switching back'); assert.equal(u.s.blockCnt, 2, 'blocks again from the start of the switch back'); approx(u.hp, 2000); h.run(1.05); - assert.ok(!u.trait.dollSwitch && !u.s.flags.noSp, 'the body again, no 阻回'); + assert.ok(!u.trait.dollSwitching && !u.s.flags.noSp, 'the body again, no 阻回'); h.b.dealDamage(null, u, { amount: 1e5, type: 'true' }); h.run(1.05); h.b.dealDamage(null, u, { amount: 1e5, type: 'true' });