From ff294b8e40c93743749789e9b2cee65d4925c202 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 23:29:48 +0800 Subject: [PATCH] =?UTF-8?q?feedback2=20WI:=20the=20client=20draws=20a=20do?= =?UTF-8?q?llkeeper's=20=E6=9B=BF=E8=BA=AB=20on=20its=20*=5FB=20clips=20(?= =?UTF-8?q?=E5=BD=92=E6=BA=9F=E5=B9=BD=E7=81=B5=E9=B2=A8=20Start=5FB=20/?= =?UTF-8?q?=20Idle=5FB=20/=20Die=5FB=20/=20Die=5FB=5F2,=20back=20on=20Star?= =?UTF-8?q?t=5F2;=20=E9=A3=8E=E4=B8=B8=20too)=20with=20a=20=E6=9B=BF?= =?UTF-8?q?=E8=BA=AB=20status=20icon;=20a=20form=20ends=20in=20the=20unit'?= =?UTF-8?q?s=20own=20skill=20clips=20(issue=20#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ASSETS.md | 9 +- public/js/render/fx.js | 3 + public/js/render/spine.js | 20 +-- public/js/render/style.js | 2 + public/js/render/textures.js | 13 +- public/js/render/units.js | 26 +++- test/render/feedback2-doll.test.js | 200 +++++++++++++++++++++++++++++ 7 files changed, 258 insertions(+), 15 deletions(-) create mode 100644 test/render/feedback2-doll.test.js diff --git a/docs/ASSETS.md b/docs/ASSETS.md index ff1380b1..f2abf1a0 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -217,9 +217,9 @@ 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 +The manifest roles describe a unit's first form. Units whose skeleton holds another form's clip set get it from `public/js/render/units.js FORMS` (keyed by Spine id, switched by the `form` of the sim's 'phase' / 'ember' / 'revive' -/ 'telegraph' / 'stone' fx — `shared/protocol.js fxForm`; no client stage drops these fx: the runner keeps them through +/ 'telegraph' / 'stone' / 'substitute' / 'swap' / 'dollEnd' fx — `shared/protocol.js fxForm`; no client stage drops these fx: the runner keeps them through catch-up frames and hidden tabs (`keepsState`), the game screen's pre-entry buffer (`keepEarly`) and the render engine's event queue (`render/interp.js isCosmeticEvent`) too — 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 @@ -232,6 +232,11 @@ stealth bit only while its 隐匿 is on: - 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`). +- 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. 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/public/js/render/fx.js b/public/js/render/fx.js index f0855de9..bde545d1 100644 --- a/public/js/render/fx.js +++ b/public/js/render/fx.js @@ -184,6 +184,9 @@ export const FX_KINDS = Object.freeze({ appear: { a: 'summon', c: 0xb36bff }, copy: { a: 'summon', c: 0xd8b0ff }, manifoldCopy: { a: 'summon', c: 0xd8b0ff }, split: { a: 'summon', c: 0xff9a6a }, disappear: { a: 'vanish', c: 0xb36bff }, stealth: { a: 'vanish', c: 0x8fa0b0 }, camouflage: { a: 'vanish', c: 0x8fb08f }, phase: { a: 'vanish', c: 0xb36bff }, substitute: { a: 'vanish', c: 0xd8b0ff }, swap: { a: 'blink', c: 0xd8b0ff }, blink: { a: 'blink', c: 0xb36bff }, teleport: { a: 'blink', c: 0xb36bff }, + // a dollkeeper knocked out as its <替身> (sim professions.js): only its model form goes back to the 本体 for the + // redeploy — the 替身's death clip already shows the knock-out, nothing is drawn + dollEnd: { a: 'none', c: 0xd8b0ff }, ulpiaReturn: { a: 'blink', c: 0x9ff0dc }, manifoldSplit: { a: 'blink', c: 0xd8b0ff }, // displacement pull: { a: 'move', c: 0x9fd4ff }, push: { a: 'move', c: 0xffd9a0 }, displace: { a: 'move', c: 0xd0c0a0 }, lure: { a: 'move', c: 0xffb3ec }, diff --git a/public/js/render/spine.js b/public/js/render/spine.js index 4ac86919..22650a87 100644 --- a/public/js/render/spine.js +++ b/public/js/render/spine.js @@ -14,7 +14,7 @@ // 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, end) another clip set of the skeleton (an enemy's mode), after a change clip +// setForm(roles, change, end) another clip set of the skeleton (an enemy's mode, a 傀儡师's 替身), 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` @@ -121,18 +121,22 @@ export class SpineActor { setSkillIndex(index) { const anims = this.entry?.anims || {}; const clip = Number.isInteger(index) && anims.skills ? anims.skills[String(index)] : null; - this.roles = clip ? { ...anims, skill: clip } : anims; + this.roles = this.baseRoles = clip ? { ...anims, skill: clip } : anims; } + /** The unit's own roles: the manifest's with its equipped skill's clip (setSkillIndex) — what a form ends in. */ + _baseRoles() { return this.baseRoles || this.entry?.anims || {}; } + /** * 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). `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. + * clips), a 傀儡师's 替身: `roles` override the unit's own roles (null = back to them — the equipped skill's clip + * included); `change` = a transition clip played once first (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, end = null) { - const anims = this.entry?.anims || {}; + const anims = this._baseRoles(); this.roles = roles ? { ...anims, ...roles } : anims; this.endClip = null; if (this.dead) return; @@ -369,7 +373,7 @@ export class SpineActor { 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.endRoles) this.roles = { ...this._baseRoles(), ...this.endRoles }; if (!this.dead) this._change(clip); } if (this.windUntil != null && this.clock >= this.windUntil) { diff --git a/public/js/render/style.js b/public/js/render/style.js index 8729b145..fb791be3 100644 --- a/public/js/render/style.js +++ b/public/js/render/style.js @@ -125,6 +125,8 @@ export const STATUS_ICON = Object.freeze({ silence: 'silence', slow: 'slow', sluggish: 'slow', bind: 'bind', fear: 'fear', tremble: 'fear', weaken: 'weaken', levitate: 'levitate', taunt: 'taunt', defDown: 'weaken', resDown: 'weaken', aspdDown: 'slow', disarm: 'silence', burn: 'burn', burnBurst: 'burn', neural: 'neural', neuralBurst: 'neural', necrosis: 'necrosis', apoptosis: 'necrosis', + // a 傀儡师 fighting as its <替身> (sim professions.js buff 'trait:substitute', the 20 s form) + substitute: 'doll', }); /** Keyword fallbacks for namespaced / content status keys ('ab:frost', 'reed2:scorch', 'skill:shotst_shred' …). */ diff --git a/public/js/render/textures.js b/public/js/render/textures.js index 725f2f6e..d7d2c7b4 100644 --- a/public/js/render/textures.js +++ b/public/js/render/textures.js @@ -835,7 +835,7 @@ export function fxAtlas() { return _fx; } -export const STATUS_KEYS = ['stun', 'freeze', 'cold', 'stealth', 'shield', 'fragile', 'sleep', 'invuln', 'silence', 'slow', 'bind', 'fear', 'weaken', 'levitate', 'taunt', 'burn', 'neural', 'necrosis', 'blocked', 'skill']; +export const STATUS_KEYS = ['stun', 'freeze', 'cold', 'stealth', 'shield', 'fragile', 'sleep', 'invuln', 'silence', 'slow', 'bind', 'fear', 'weaken', 'levitate', 'taunt', 'burn', 'neural', 'necrosis', 'blocked', 'skill', 'doll']; function drawStatusIcon(c, key, x, y, s) { const cx = x + s / 2, cy = y + s / 2; @@ -930,6 +930,17 @@ function drawStatusIcon(c, key, x, y, s) { c.fillStyle = '#ffe066'; star(c, cx, cy, 10, 4.5); break; } + case 'doll': { + // a 傀儡师's <替身>: a puppet hanging from two strings + disc('rgba(40,25,55,0.92)', '#d8b0ff'); + c.strokeStyle = '#f0dcff'; c.lineWidth = 1.2; + c.beginPath(); c.moveTo(cx - 6, cy - 12); c.lineTo(cx - 6, cy - 1); c.moveTo(cx + 6, cy - 12); c.lineTo(cx + 6, cy - 1); c.stroke(); + c.fillStyle = '#f0dcff'; + c.beginPath(); c.arc(cx, cy - 5, 3.2, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.moveTo(cx - 6, cy - 1); c.lineTo(cx + 6, cy - 1); c.lineTo(cx + 3, cy + 5); c.lineTo(cx + 5, cy + 11); c.lineTo(cx + 1.5, cy + 11); + c.lineTo(cx, cy + 6); c.lineTo(cx - 1.5, cy + 11); c.lineTo(cx - 5, cy + 11); c.lineTo(cx - 3, cy + 5); c.closePath(); c.fill(); + break; + } default: disc('rgba(30,30,30,0.9)', '#ccc'); } } diff --git a/public/js/render/units.js b/public/js/render/units.js index e88c92b9..237cbf03 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -143,6 +143,13 @@ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, g * 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. + * - the 傀儡师 operators' <替身> (sim professions.js installDollkeeper: form 'doll' from the start of the switch to it + * until the switch back starts, GitHub issue #44): the skeletons draw it on their *_B clips (their own slots — the + * 本体'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. * 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 }); @@ -166,7 +173,17 @@ const STATUE = Object.freeze({ 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'); +/** A 傀儡师's 替身 roles: idle `idle`, death `die`, attack `attack` (null: none), no skill clip of its own. */ +const dollRoles = (idle, die, attack = null) => Object.freeze({ + idle, deploy: idle, die, attack: attack ? Object.freeze({ begin: null, loop: attack, end: null }) : null, attackDown: null, skill: null, +}); export const FORMS = Object.freeze({ + char_1023_ghost2: Object.freeze({ + doll: Object.freeze({ change: 'Start_B', end: 'Die_B', leave: 'Start_2', roles: dollRoles('Idle_B', 'Die_B_2') }), + }), + char_4016_kazema: Object.freeze({ + doll: Object.freeze({ change: 'Start_B', leave: 'Start', roles: dollRoles('Idle_B', 'Die_B', 'Attack_B') }), + }), enemy_1040_bombd: Object.freeze({ bombed: Object.freeze({ roles: Object.freeze({ @@ -467,14 +484,15 @@ export class UnitView { * The unit changed mode (the `form` of a sim fx — shared/protocol.js fxForm; `fx` = that fx's extra): the mode's clip * set (FORMS) after its change clip, and its closing clip (`end`, landing in the `next` form's clips) timed to end * `fx.dur` game s later — the unit is still in this mode while it plays (an ember can be beaten in its last second), - * so this mode's death clip stays until the next mode's fx; null goes back to the manifest clips; a kind this skeleton - * has no clip set for (an arts barrier, a broken charge …) changes nothing. Kept for a model built later. + * so this mode's death clip stays until the next mode's fx; null goes back to the manifest clips (through the old + * mode's `leave` clip when it has one: a 替身's 本体 coming back); 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; if (k === this.form) return; if (k && !FORMS[this.info.spine || this.info.defId]?.[k]) return; - const had = !!this._formSpec(); + const prev = this._formSpec(); this.form = k; this.info.form = k; const f = this._formSpec(); @@ -486,7 +504,7 @@ export class UnitView { const late = fx && Number(fx.late) > 0 ? Number(fx.late) : 0; const change = f && f.change && !(late > 0 && late >= (this.actor.dur?.(f.change) ?? Infinity)) ? f.change : null; if (f) this.actor.setForm(f.roles, change, f.end && dur > 0 ? { clip: f.end, in: dur, roles: next } : null); - else if (had) this.actor.setForm(null); + else if (prev) this.actor.setForm(null, prev.leave && !(late > 0 && late >= (this.actor.dur?.(prev.leave) ?? Infinity)) ? prev.leave : null); } // ---- HUD ------------------------------------------------------------------------------------------------- diff --git a/test/render/feedback2-doll.test.js b/test/render/feedback2-doll.test.js new file mode 100644 index 00000000..b8fcf318 --- /dev/null +++ b/test/render/feedback2-doll.test.js @@ -0,0 +1,200 @@ +// test/render/feedback2-doll.test.js — GitHub issue #44 「归鲨死了没替身」 (DESIGN §22.11), client side: a 傀儡师 fighting as +// 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. + +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +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 { fxForm } from '../../shared/protocol.js'; +import { makeBattle, enemyRec } from '../helpers/battleHarness.js'; +import { unitInfo } from '../../server/sim/snapshot.js'; +import { DOLL_SWITCH } from '../../server/sim/professions.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8')); +const GHOST = 'char_1023_ghost2', KAZEMA = 'char_4016_kazema'; + +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()); + +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 { + 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() {} }, + }; +} +async function op(id, info = {}) { + const ctx = fakeViewCtx(fake.P, { assets: store(id), 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'); + return v; +} +const clip = (v) => v.actor.current; +const frames = (v, n, dt = 1 / 60) => { for (let i = 0; i < n; i++) v.update(dt, cam(), i * dt); }; +const secs = (v, s) => frames(v, Math.round(s * 60)); + +describe('归溟幽灵鲨\'s 替身 (FORMS doll)', () => { + test('the clip set exists in her Front skeleton; the Back one (facing UP) only has Idle_B and Start_2', () => { + const f = FORMS[GHOST].doll; + const front = assets.chars[GHOST].spine.front.animations, back = assets.chars[GHOST].spine.back.animations; + for (const name of [f.change, f.end, f.leave, f.roles.idle, f.roles.die]) assert.ok(name in front, name); + assert.equal(f.roles.attack, null, 'the 替身 never attacks (PRTS 特性备注)'); + assert.equal(f.roles.skill, null); + for (const name of ['Idle_B', 'Start_2']) assert.ok(name in back, `Back ${name}`); + for (const name of ['Start_B', 'Die_B', 'Die_B_2']) assert.ok(!(name in back), `no Back ${name}`); + assert.equal(assets.chars[GHOST].spine.front.anims.idle, 'Idle', 'the manifest keeps the 本体'); + }); + + test('the switch: Start_B, Idle_B (no attack clip), Die_B over its last second, the 本体 back on Start_2 with her own skill clip', async () => { + const v = await op(GHOST, { skillIndex: 2 }); + assert.equal(clip(v), 'Idle'); + const dur = DOLL_SWITCH + 20; + v.setForm('doll', { id: 1, form: 'doll', dur }); + assert.equal(clip(v), 'Start_B', 'the 替身 fades in'); + secs(v, 1.1); + assert.equal(clip(v), 'Idle_B'); + v.onAttack(null, 2); + assert.equal(clip(v), 'Idle_B', 'no attack clip'); + secs(v, dur - 1 - 1.1 + 0.05); + assert.equal(clip(v), 'Die_B', 'it breaks apart over its last second'); + secs(v, 0.9); + v.setForm(null, { id: 1, form: null }); // the sim's 'swap' fx + assert.equal(clip(v), 'Start_2', 'the 本体 comes back'); + secs(v, 1.1); + assert.equal(clip(v), 'Idle'); + v.setSkill(true); + assert.equal(clip(v), 'Skill_3_Begin', 'her equipped skill\'s clips again (S3), not the manifest default'); + v.die(); + assert.equal(clip(v), 'Die', 'the 本体\'s death clip'); + }); + + test('knocked out as the 替身: Die_B_2, held through the form reset (\'dollEnd\'); the redeploy is the 本体', async () => { + const v = await op(GHOST); + v.setForm('doll', { dur: 21 }); + secs(v, 2); + v.die(); + assert.equal(clip(v), 'Die_B_2', 'the 替身 collapses'); + v.setForm(null, { form: null }); // fx 'dollEnd', after the 'die' event + assert.equal(clip(v), 'Die_B_2', 'no Start_2 on a knocked-out view'); + assert.equal(v.form, null); + secs(v, 3); + v.onDeploy(); + assert.equal(clip(v), 'Start', 'redeployed as the 本体'); + secs(v, 1.2); + assert.equal(clip(v), 'Idle'); + }); + + test('a view built while she is the 替身 (UnitInfo form) starts on Idle_B; facing UP the Back model shows the 替身 without the switch clips it lacks', async () => { + const v = await op(GHOST, { form: 'doll' }); + assert.equal(clip(v), 'Idle_B'); + const up = await op(GHOST, { dir: 'UP' }); + assert.ok(up.entryBack, 'the Back model'); + up.setForm('doll', { dur: 21 }); + assert.equal(clip(up), 'Idle_B', 'no Start_B in the Back skeleton: straight to the 替身'); + secs(up, 21); + up.setForm(null, { form: null }); + assert.equal(clip(up), 'Start_2', 'its Start_2'); + const turned = await op(GHOST, { form: 'doll' }); + turned.setDir('UP'); + await tick(); await tick(); + assert.ok(turned.entryBack && clip(turned) === 'Idle_B', 'turned UP mid-form: the Back model keeps the 替身'); + }); + + test('the 替身\'s status icon; a late switch fx skips a change clip that would already have ended', async () => { + assert.equal(statusIconKey('trait:substitute'), 'doll'); + assert.ok(STATUS_KEYS.includes('doll')); + const v = await op(GHOST); + v.setForm('doll', { dur: 21, late: 1.5 }); + assert.equal(clip(v), 'Idle_B'); + v.setForm(null, { form: null, late: 2 }); + assert.equal(clip(v), 'Idle', 'no Start_2 when it would be over'); + }); +}); + +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); + const v = await op(KAZEMA); + v.setForm('doll', { dur: 21 }); + assert.equal(clip(v), 'Start_B'); + secs(v, 1.1); + assert.equal(clip(v), 'Idle_B'); + v.onAttack(null, 2); + assert.equal(clip(v), 'Attack_B'); + secs(v, 3); + v.setForm(null, { form: null }); + assert.equal(clip(v), 'Start'); + const w = await op(KAZEMA, { form: 'doll' }); + w.die(); + assert.equal(clip(w), 'Die_B'); +}); + +test('sim → view: the real 归溟幽灵鲨\'s lethal hit puts UnitInfo `form` doll (renderInfo keeps it) and its fx carry the forms the view switches on', async () => { + const h = makeBattle({ autoFinish: false, timeLimit: 60, defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, atk: 0, speed: 0 }) } }, + units: [{ chessId: 'chess_char_5_13_a', row: 9, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [9, 6] }] }); + h.step(); + const g = h.unit('chess_char_5_13_a'); + const v = await op(GHOST, renderInfo(unitInfo(g))); + const apply = () => { for (const e of h.eventsOf('fx')) if (e[4]?.id === g.id && fxForm(e) !== undefined) v.setForm(fxForm(e), e[4]); h.events.length = 0; }; + h.b.dealDamage(null, g, { amount: 1e9, type: 'true' }); + apply(); + assert.equal(renderInfo(unitInfo(g)).form, 'doll'); + assert.equal(clip(v), 'Start_B'); + secs(v, 1.1); + h.run(DOLL_SWITCH + 20.05); + apply(); + assert.equal(clip(v), 'Start_2', 'back to the 本体'); + assert.equal(renderInfo(unitInfo(g)).form, undefined); +}); + +// The clip meanings, read from the official skeleton (tools/assets/skel.mjs's parser, @pixi-spine/runtime-3.8): the *_B +// clips draw only the 替身's own slots (names ending in _T), Start_B and Start_2 fade in, Die_B ends faded out (the 替身 +// gone: the switch back), Die_B_2 keeps it visible (collapsed: a knock-out). +const SKEL = path.join(ROOT, `public/assets/spine/op/${GHOST}/front/${GHOST}.skel`); +test('the official skeleton: *_B clips are the 替身, Die_B ends gone, Die_B_2 ends down, Start_2 brings the 本体 back', { skip: !existsSync(SKEL) && 'assets not fetched' }, () => { + const require = createRequire(path.join(ROOT, 'package.json')); + const r = require('@pixi-spine/runtime-3.8'), B = require('@pixi-spine/base'); + const att = (n) => new r.RegionAttachment(n); + const loader = { newRegionAttachment: (s, n) => att(n), newMeshAttachment: (s, n) => new r.MeshAttachment(n), newBoundingBoxAttachment: (s, n) => new r.BoundingBoxAttachment(n), newPathAttachment: (s, n) => new r.PathAttachment(n), newPointAttachment: (s, n) => new r.PointAttachment(n), newClippingAttachment: (s, n) => new r.ClippingAttachment(n) }; + const data = new r.SkeletonBinary(loader).readSkeletonData(new Uint8Array(readFileSync(SKEL))); + const sk = new r.Skeleton(data); + /** Slots drawn (attached, alpha > 0) at time t of clip `name`: { doll, body } counts. */ + const drawn = (name, t) => { + const a = data.findAnimation(name); + sk.setToSetupPose(); + a.apply(sk, 0, Math.min(t, a.duration), false, null, 1, B.MixBlend.setup, B.MixDirection.mixIn); + let doll = 0, body = 0; + for (const s of sk.slots) if (s.attachment && s.color.a > 0.01) { if (/_T\d*$/.test(s.data.name)) doll++; else body++; } + return { doll, body }; + }; + for (const name of ['Idle_B', 'Start_B', 'Die_B', 'Die_B_2']) assert.equal(drawn(name, 0.5).body, 0, `${name}: no 本体 slot`); + for (const name of ['Idle', 'Start_2', 'Die']) assert.equal(drawn(name, 0.5).doll, 0, `${name}: no 替身 slot`); + assert.ok(drawn('Idle_B', 0.5).doll > 30, 'Idle_B draws the 替身'); + assert.equal(drawn('Start_B', 0).doll, 0, 'Start_B: it fades in'); + assert.equal(drawn('Die_B', 1).doll, 0, 'Die_B: gone at its end'); + assert.ok(drawn('Die_B_2', 1).doll > 30, 'Die_B_2: still there, collapsed'); + assert.equal(drawn('Start_2', 0).body, 0, 'Start_2: the 本体 fades in'); +});