diff --git a/public/js/render/spine.js b/public/js/render/spine.js index 4ac86919..b9b40a87 100644 --- a/public/js/render/spine.js +++ b/public/js/render/spine.js @@ -12,7 +12,8 @@ // clip plays once at its own speed, then base // setSkill(on) skill begin→loop while active (skill idle replaces idle), end on stop // deploy() 'Start' once, then base -// die() die clip once (callers fade out afterwards) +// die() die clip once (callers fade out afterwards); a skeleton without one holds its +// idle clip's first frame (GitHub issue #25: the attack loop went on) // 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 // (no attack cuts the change clip short); `end` = { clip, in, roles? }: a @@ -341,24 +342,45 @@ export class SpineActor { if (this.has(d) && d !== this.roles.idle) { this.mode = 'deploy'; this._play(d, false, { mix: 0 }); + this.deployAt = this.clock; this.deployUntil = this.clock + this.dur(d); } } - /** Play the death clip; returns its duration (0 when there is none). */ + /** Seconds into the deploy clip while it plays, else null (a model swapped mid-deploy carries it over: units.js). */ + deployElapsed() { + return this.mode === 'deploy' ? Math.max(0, this.clock - (this.deployAt || 0)) : null; + } + + /** The skeleton's death clip, or null. */ + dieClip() { + const d = this.roles.die || (this.has('Die') ? 'Die' : null); + return d && this.has(d) ? d : null; + } + + /** + * Play the death clip; returns its duration (0 when there is none). A skeleton without one (131 of the 135 Back + * models, GitHub issue #25; a few idle-only summons and enemies) stops whatever looped — an attack, a skill or the idle — + * and holds the first frame of its idle clip (frozen when it has none either): a dead unit never goes on attacking. A + * knocked-out operator shows its fall with the Front model instead (render/units.js _wantsBack). + */ die() { if (this.dead) return 0; this.dead = true; this.frozen = false; this.mode = 'die'; - const d = this.roles.die || (this.has('Die') ? 'Die' : null); - if (d && this.has(d)) { this._play(d, false, { mix: 0.05 }); return this.dur(d); } + const d = this.dieClip(); + if (d) { this._play(d, false, { mix: 0.05 }); return this.dur(d); } + const idle = this.has(this.roles.idle) ? this.roles.idle : this.has('Idle') ? 'Idle' : null; + if (idle) this._play(idle, false, { mix: 0.1, timeScale: 0 }); + else this.frozen = true; return 0; } /** Revive (redeploy after death). */ revive() { this.dead = false; + this.frozen = false; this.mode = 'base'; this.skillOn = false; this._play(this._baseName(), true); diff --git a/public/js/render/units.js b/public/js/render/units.js index e88c92b9..db52cc3b 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -19,12 +19,15 @@ // the legacy `facing` ±1): the Back model for UP (when one exists, research 07 §5.5), the Front model for RIGHT and // DOWN, mirrored for LEFT; the orange ground wedge "›" of prep board pieces points along `dir` (rotated on the ground // plane). `setDir(dir)` re-orients a live view (swapping Front ⇄ Back without a fallback flash). Enemies flip by the -// sign of their horizontal velocity (with hysteresis). +// sign of their horizontal velocity (with hysteresis). A dead or knocked-out operator facing UP falls and lies with its +// Front model — 131 of the 135 Back skeletons have no Die clip (GitHub issue #25: the Back model went on with its +// attack loop under the redeploy ring) — and stands up again with the Back model (`_wantsBack`, `_syncModel`). // // Knocked-out operators (user playtest #4 item 9, b.snap `down`): `setDown([id, respawnAt, respawnTime, state, row, // col])` keeps a dead operator on the tile it lies on (row / col: where it fell, or its home — sim Battle._layBody, // player report F5 after 0.1.0) in its knocked-down pose — the Spine Die clip played once and held on its last -// frame (the collapsed / kneeling pose with closed eyes), slightly greyed — with a redeploy ring above its head: +// frame (the collapsed / kneeling pose with closed eyes; the Front model's for one facing UP, see above), slightly +// greyed — with a redeploy ring above its head: // a dark disc, a mint arc filling as the respawn timer runs and the seconds left; once the timer is done and it still // waits, a full amber ring with "DP" (not enough DP) or a red ring with "!" (its tile is taken). `onDeploy` (the // redeploy) plays the deploy clip and restores the normal look; `setDown(null)` on a dead view lets it fade out. @@ -81,6 +84,17 @@ export function enemyModelScale(rec) { const k = Number(rec && rec.modelScale); return Number.isFinite(k) && k > 0.05 && k < 20 ? k : 1; } +/** + * Seconds of the death clip of a manifest Spine entry (`anims.die`, else a 'Die' clip, as SpineActor.dieClip; its + * `animations` duration), 0 when it has none — 131 of the 135 operator Back models (GitHub issue #25). + */ +export function dieClipDur(entry) { + const durs = entry && entry.animations && typeof entry.animations === 'object' ? entry.animations : null; + if (!durs) return 0; + const name = (entry.anims && typeof entry.anims.die === 'string' && entry.anims.die) || 'Die'; + const d = Object.hasOwn(durs, name) ? Number(durs[name]) : NaN; + return Number.isFinite(d) && d > 0 ? d : 0; +} /** World step (x = col, y = row) of a direction. */ export const DIR_STEP = Object.freeze({ UP: [0, 1], RIGHT: [1, 0], DOWN: [0, -1], LEFT: [-1, 0] }); const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now()); @@ -325,6 +339,7 @@ export class UnitView { this.body.addChild(this.fallback); this.actor = null; this.spineReady = false; + this._modelDirty = false; // died / stood up since the last frame: update() checks Front ⇄ Back (_syncModel) this._spineBusy = false; // a Spine load of this view is in flight this._spineTries = 0; // failed loads since the last model (SPINE_RETRY_MS) this._retryAt = 0; // when the next retry is due (ms, performance clock; 0 = none) @@ -381,7 +396,8 @@ export class UnitView { const a = this.ctx.assets; if (!a || !a.spineEntry || !a.spine) return; const id = this.info.spine || this.info.defId; - // Front/Back rule (research 07 §5.5 / 09 §1.2): Front facing right/down (mirrored for left), Back facing up. + // Front/Back rule (research 07 §5.5 / 09 §1.2): Front facing right/down (mirrored for left), Back facing up — while + // standing (a knocked-out operator lies with the model that has a fall: _wantsBack). const back = this._wantsBack(); const entry = id ? a.spineEntry(id, { back }) : null; if (!entry || this.ctx.settings?.quality === 'low' && this.isEnemy && !this.isBoss && this.ctx.crowded?.()) return; @@ -427,8 +443,11 @@ export class UnitView { this._releaseEntry(entry); return; } - // a Front ⇄ Back swap (setDir) replaces the previous model in place: no fallback diamond in between + // a Front ⇄ Back swap (setDir, or knocked out / standing again: _syncModel) replaces the previous model in place: + // no fallback diamond in between; a deploy clip it was playing goes on on the new model (an operator facing UP + // redeployed: its Back model takes over from the Front model it lay down with) const swap = !!this.actor; + const deployed = swap && this.alive ? this.actor.deployElapsed() : null; if (swap) this._dropActor(); this.actor = actor; this._actorEntry = entry; @@ -448,6 +467,7 @@ export class UnitView { } else { if (this.flags & UF.SKILL) this.actor.setSkill(true); this.actor.setBase(this._baseFromAnim()); + if (deployed != null) { this.actor.deploy(); if (deployed > 0) this.actor.update(deployed); } } }, () => { if (req === this._spineReq) this._spineBusy = false; @@ -602,11 +622,27 @@ export class UnitView { else if (this.dir === 'LEFT') this.setDir('RIGHT'); } - /** Whether this unit should show its Back model (facing UP and a Back model exists). */ + /** + * Whether this unit should show its Back model: facing UP and a Back model exists — while it stands. A dead or + * knocked-out one keeps the Back model only when that skeleton has a fall of its own (4 of the 135 Back models); the + * others have no Die clip, so the Front model shows the fall and the held knocked-down pose (GitHub issue #25: the + * Back model went on with its attack loop under the redeploy ring) [ASSUMED: no source says which model the official + * client lies down with; a Back skeleton without a Die clip cannot show a fall]. + */ _wantsBack() { const a = this.ctx.assets; const id = this.info.spine || this.info.defId; - return !this.isEnemy && this.dir === 'UP' && !!id && !!a && typeof a.hasBack === 'function' && !!a.hasBack(id); + if (this.isEnemy || this.dir !== 'UP' || !id || !a || typeof a.hasBack !== 'function' || !a.hasBack(id)) return false; + return this.alive || dieClipDur(typeof a.spineEntry === 'function' ? a.spineEntry(id, { back: true }) : null) > 0; + } + + /** + * Load the model the unit wants when it is not the one shown or loading (Front ⇄ Back: its direction, or it went down / + * stood up again). A swap replaces the model in place (_acquireSpine: no diamond in between; a fall or a deploy clip + * goes on where it is). Nothing before the first model was asked for (no manifest entry yet). + */ + _syncModel() { + if (this.entry && this._wantsBack() !== !!this.entryBack) this._loadSpine(); } /** @@ -623,7 +659,7 @@ export class UnitView { this.facing = d === 'LEFT' ? -1 : 1; this.visFacing = this.facing; if (this.imp) this.imp.dirty = true; - if (this.entry && this._wantsBack() !== !!this.entryBack) this._loadSpine(); + this._syncModel(); } _releaseEntry(entry) { @@ -637,6 +673,7 @@ export class UnitView { this.imp = null; } this._box = null; + this._tint = undefined; // the next model gets the current tint on its first frame (a knocked-down one its grey) const old = this.actor; this.actor = null; this.spineReady = false; @@ -705,11 +742,17 @@ export class UnitView { /** * The unit died: its Die clip plays, then it fades (unless it stays down, setDown). `instant`: it starts on the held * end of the clip — a unit that is already down (a field joined mid-battle, an operator entering 联防 knocked out). + * 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. */ die(instant = false) { if (!this.alive) return; this.alive = false; - const d = this.actor ? this.actor.die() : 0; + this._modelDirty = true; + let d = this.actor ? this.actor.die() : 0; + if (this.actor && !d) d = this._fallDur(); const rate = this.ctx.animRate?.() || 1; this.dieT = 0; this.dying = clamp(d / rate, 0.35, 1.6) + 0.55; @@ -717,8 +760,17 @@ export class UnitView { if (instant) { this.dieT = 30; if (this.actor) this.actor.update(30); } } + /** Seconds of the fall of the model the unit wants now (its manifest entry's Die clip), 0 without one. */ + _fallDur() { + const a = this.ctx.assets; + const id = this.info.spine || this.info.defId; + return a && typeof a.spineEntry === 'function' && id ? dieClipDur(a.spineEntry(id, { back: this._wantsBack() })) : 0; + } + + /** Standing again (a redeploy, a revive): an operator facing UP gets its Back model back (this frame's update). */ revive() { this.alive = true; + this._modelDirty = true; this.dying = 0; this.remove = false; this.alpha = 1; this.down = null; this.hp = this.maxHp; this.ghostHp = this.hp; @@ -761,6 +813,8 @@ export class UnitView { update(dt, cam, t) { if (this.destroyed) return; const P = this.P; + // the model for the state the frame's events and snapshot left (Front ⇄ Back when it went down / stood up: die, revive) + if (this._modelDirty) { this._modelDirty = false; this._syncModel(); } // a failed / timed-out model load is tried again once its wait is over (SPINE_RETRY_MS; frames only: never hidden) if (this._retryAt && nowMs() >= this._retryAt) { this._retryAt = 0; if (!this.actor && !this._spineBusy) this._loadSpine(true); } if (this.zTarget != null && this.z !== this.zTarget) { diff --git a/test/render/issue25-downback.test.js b/test/render/issue25-downback.test.js new file mode 100644 index 00000000..dae13b2e --- /dev/null +++ b/test/render/issue25-downback.test.js @@ -0,0 +1,308 @@ +// test/render/issue25-downback.test.js — GitHub issue #25 「部分干员被击倒后在部署状态时仍有攻击动作」, client side: an +// operator facing UP shows its Back model, and 131 of the 135 Back skeletons have no Die clip, so a knocked-out one went +// on with its looping attack / skill / idle clip under the redeploy ring (a front-facing one lay down). Now it falls and +// lies with its Front model (render/units.js _wantsBack / _syncModel), stands up again with the Back model (the deploy +// clip carried over), a Back skeleton with a fall of its own keeps it, and a skeleton without a Die clip holds still +// (render/spine.js SpineActor.die). Headless fake PIXI (test/render/fakepixi.js) with the real manifest entries +// (data/assets.json) and the real asset helpers (public/js/assets.js spineEntry / hasBackSpine). DESIGN §22.1. + +import { test, describe, before, after } 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 { installFakePixi, fakeViewCtx } from './fakepixi.js'; +import { presetCamera } from '../../public/js/render/projection.js'; +import { spineEntry, hasBackSpine } from '../../public/js/assets.js'; +import { ANIM } from '../../shared/constants.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const M = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8')); +const CAPER = 'char_4100_caper'; // 跃跃: the operator of the report, no Die clip in its Back skeleton +const MINT = 'char_388_mint'; // one of the 4 Back skeletons with a Die clip + +let fake, UnitView, DOWN_STATE, DOWN_LOOK, dieClipDur, SpineActor; +before(async () => { + fake = installFakePixi(); + ({ UnitView, DOWN_STATE, DOWN_LOOK, dieClipDur } = await import('../../public/js/render/units.js')); + ({ SpineActor } = await import('../../public/js/render/spine.js')); +}); +after(() => fake.restore()); + +const tick = () => new Promise((r) => setImmediate(r)); +const settle = async () => { await tick(); await tick(); }; +const cam = () => presetCamera('normal', { width: 1280, height: 720 }); +let clockT = 0; +const frames = (v, n, dt = 1 / 60) => { for (let i = 0; i < n; i++) { clockT += dt; v.update(dt, cam(), clockT); } }; + +/** The real manifest behind the asset store's helpers; acquire / release counted per skeleton. */ +function store() { + const refs = new Map(); + let acquired = 0; + return { + refs, acquired: () => acquired, + picture: () => null, + image: async () => null, + hasBack: (id) => hasBackSpine(M, id), + spineEntry: (id, o) => spineEntry(M, id, o), + spine: { + acquire: async (e) => { acquired++; refs.set(e.skel, (refs.get(e.skel) || 0) + 1); return { animations: Object.keys(e.animations || {}).map((name) => ({ name })) }; }, + release: (e) => { refs.set(e.skel, (refs.get(e.skel) || 0) - 1); }, + }, + }; +} +const held = (s) => [...s.refs].filter(([, n]) => n !== 0); + +async function op(id, dir, s = store(), extra = {}) { + const v = new UnitView(fakeViewCtx(fake.P, { assets: s, cam }), { id: 5, side: 'ally', kind: 'chess', defId: id, spine: id, tier: 1, x: 5, y: 10, maxHp: 1000, dir, ...extra }); + await settle(); + frames(v, 5); + return v; +} +const front = (id) => M.chars[id].spine.front; +const back = (id) => M.chars[id].spine.back; +const DOWN = (t = 1) => [5, t + 60, 60, DOWN_STATE.COUNTING, 10, 5]; + +describe('the manifest (data/assets.json)', () => { + test('most Back skeletons have no fall, every operator Front skeleton has one', () => { + const backs = Object.entries(M.chars).filter(([, c]) => c.spine?.back); + const withFall = backs.filter(([, c]) => dieClipDur(c.spine.back) > 0).map(([id]) => id).sort(); + assert.ok(backs.length >= 130, `${backs.length} Back skeletons`); + assert.deepEqual(withFall, ['char_388_mint', 'char_4064_mlynar', 'char_440_pinecn', 'char_602_cdfend']); + for (const [id, c] of Object.entries(M.chars)) assert.ok(dieClipDur(c.spine.front) > 0, `${id} Front has a Die clip`); + assert.equal(dieClipDur(back(CAPER)), 0); + assert.equal(dieClipDur(front(CAPER)), front(CAPER).animations.Die); + assert.equal(dieClipDur(null), 0); + assert.equal(dieClipDur({ anims: { die: null }, animations: { Die: 0.8 } }), 0.8, 'a Die clip the roles did not name'); + }); +}); + +describe('an operator facing UP knocked out (Back model without a Die clip)', () => { + test('mid-attack: the Back model stops at once, then the Front model falls and holds its Die pose under the ring', async () => { + const s = store(); + const v = await op(CAPER, 'UP', s); + assert.equal(v.entryBack, true); + assert.equal(v.actor.entry, back(CAPER), 'standing: the Back model'); + v.onAttack(null, 1, 'none'); + assert.equal(v.actor.current, 'Attack'); + const n0 = s.acquired(); + v.setDown(DOWN(), 1); // b.snap down: knocked out mid-attack + assert.equal(v.alive, false); + // until the Front model is there the Back model holds the first frame of its idle — no attack loop + assert.equal(v.actor.entry, back(CAPER)); + assert.equal(v.actor.current, 'Idle'); + assert.equal(v.actor.spine.state.tracks[0].timeScale, 0, 'held still'); + v.onAttack(null, 1.2, 'none'); // a late 'atk' of the same frame + assert.equal(v.windUp(0.1, 'none'), false); + assert.equal(v.actor.current, 'Idle', 'nothing restarts the attack'); + assert.equal(s.acquired(), n0, 'nothing loads before the frame\'s update'); + frames(v, 1); + assert.equal(v.entryBack, false, 'the Front model requested'); + assert.equal(v.actor.current, 'Idle', 'the Back model holds still while it loads'); + await settle(); + assert.equal(v.actor.entry, front(CAPER), 'the Front model in place'); + assert.equal(v.actor.current, 'Die'); + assert.equal(v.swapT, 1, 'swapped in place (no fallback diamond)'); + frames(v, 180); // 3 s down + assert.equal(v.actor.current, 'Die', 'the Die pose is held'); + assert.ok(v.actor.clock >= front(CAPER).animations.Die, 'past the end of the fall'); + assert.ok(v._downRing.root.visible, 'under the redeploy ring'); + assert.equal(v.actor.spine.tint, DOWN_LOOK.tint, 'the Front model greyed like any knocked-down one'); + assert.equal(v.remove, false); + assert.deepEqual(held(s), [[front(CAPER).skel, 1]], 'the Back model released, the Front one held'); + v.destroy(); + await settle(); + assert.deepEqual(held(s), [], 'every acquire paired with a release'); + }); + + test('facing RIGHT it falls with its Front model as before (one model, no swap)', async () => { + const s = store(); + const v = await op(CAPER, 'RIGHT', s); + v.onAttack(null, 1, 'none'); + const a = v.actor; + v.setDown(DOWN(), 1); + assert.equal(a.current, 'Die'); + frames(v, 2); + await settle(); + assert.equal(v.actor, a, 'the same model'); + assert.equal(s.acquired(), 1); + }); + + test('the redeploy: the Back model again, its deploy clip carried over from the Front model', async () => { + const s = store(); + const v = await op(CAPER, 'UP', s); + v.setDown(DOWN(), 1); + frames(v, 1); + await settle(); + frames(v, 60); + assert.equal(v.actor.entry, front(CAPER)); + v.onDeploy(); // b.ev 'deploy' (timed, 不屈, 阿戈尔: every redeploy) + assert.equal(v.alive, true); + assert.equal(v.actor.current, 'Start', 'the Front model starts the deploy clip'); + frames(v, 3); + assert.equal(v.entryBack, true, 'the Back model requested'); + await settle(); + assert.equal(v.actor.entry, back(CAPER), 'standing facing UP: the Back model'); + assert.equal(v.actor.current, 'Start', 'the deploy clip goes on on the Back model'); + assert.equal(v.actor.mode, 'deploy'); + assert.ok(Math.abs(v.actor.deployElapsed() - 3 / 60) < 1e-9, 'where the Front model was in it'); + frames(v, 70); + assert.equal(v.actor.current, 'Idle'); + assert.equal(v.actor.spine.tint, 0xffffff, 'not greyed any more'); + v.onAttack(null, 9, 'none'); + assert.equal(v.actor.current, 'Attack', 'it attacks again'); + assert.equal(v.down, null); + v.destroy(); + await settle(); + assert.deepEqual(held(s), []); + }); + + test('the snapshot\'s DIE anim (no die event) and a revive from the snapshot go the same way', async () => { + const s = store(); + const v = await op(CAPER, 'UP', s); + const sample = (anim, hp) => ({ x: 5, y: 10, hp, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim, vx: 0, vy: 0 }); + v.sync(sample(ANIM.DIE, 0), 2); + frames(v, 1); + await settle(); + assert.equal(v.actor.entry, front(CAPER)); + assert.equal(v.actor.current, 'Die'); + v.revive(); // render/app.js syncBattle: alive again without an event + v.sync(sample(ANIM.IDLE, 1000), 3); + frames(v, 1); + await settle(); + assert.equal(v.actor.entry, back(CAPER)); + assert.equal(v.actor.current, 'Idle'); + }); + + test('a death and a redeploy in one frame (不屈, a 突袭 jump, a backlog after a hidden tab) load nothing', async () => { + const s = store(); + const v = await op(CAPER, 'UP', s); + const a = v.actor, n0 = s.acquired(); + v.die(); // b.ev 'die' … + v.onDeploy(); // … and 'deploy' handed out in the same frame + frames(v, 2); + await settle(); + assert.equal(s.acquired(), n0, 'no model loaded'); + assert.equal(v.actor, a, 'the Back model stays'); + assert.equal(v.actor.current, 'Start'); + assert.equal(v.alive, true); + }); + + test('a view built for one already down (a field entered late, a re-entry after a hidden tab) lies with the Front model at the end of its fall', async () => { + const s = store(); + const v = new UnitView(fakeViewCtx(fake.P, { assets: s, cam }), { id: 5, side: 'ally', kind: 'chess', defId: CAPER, spine: CAPER, tier: 1, x: 5, y: 10, maxHp: 1000, dir: 'UP' }); + v.setDown(DOWN(30), 30, true); // render/app.js syncBattle: made on the spot, `fresh` + frames(v, 1); + await settle(); + assert.equal(v.actor.entry, front(CAPER)); + assert.equal(v.actor.current, 'Die'); + assert.ok(v.actor.clock >= front(CAPER).animations.Die, 'the held end, no fall'); + assert.deepEqual(held(s), [[front(CAPER).skel, 1]], 'the Back model asked for at construction was released unseen'); + }); + + test('an operator entering 联防 knocked out (deploy + die FORCED_EXIT in one batch): the Front model\'s held pose', async () => { + const s = store(); + const v = new UnitView(fakeViewCtx(fake.P, { assets: s, cam }), { id: 5, side: 'ally', kind: 'chess', defId: CAPER, spine: CAPER, tier: 1, x: 5, y: 10, maxHp: 1000, dir: 'UP' }); + v.onDeploy(); + v.die(true); + v.setDown(DOWN(0.03), 0.03); + frames(v, 1); + await settle(); + assert.equal(v.actor.entry, front(CAPER)); + assert.equal(v.actor.current, 'Die'); + assert.ok(v.actor.clock >= front(CAPER).animations.Die); + }); + + test('withdrawn (no `down` entry): the Front model\'s fall times the fade, as for one facing RIGHT', async () => { + const up = await op(CAPER, 'UP'); + const right = await op(CAPER, 'RIGHT'); + up.die(); right.die(); + assert.ok(up.dieDur > 1.5 && up.dieDur === right.dieDur, `${up.dieDur} = ${right.dieDur}`); + frames(up, 1); + await settle(); + assert.equal(up.actor.current, 'Die'); + frames(up, 120); + assert.equal(up.remove, true, 'faded out'); + }); + + test('a Back skeleton with a fall of its own keeps it (薄绿)', async () => { + const s = store(); + const v = await op(MINT, 'UP', s); + const a = v.actor, n0 = s.acquired(); + v.setDown(DOWN(), 1); + frames(v, 2); + await settle(); + assert.equal(v.actor, a, 'the Back model stays'); + assert.equal(v.actor.entry, back(MINT)); + assert.equal(v.actor.current, 'Die'); + assert.equal(s.acquired(), n0); + }); +}); + +describe('the safety net: a skeleton without a Die clip (SpineActor.die)', () => { + const actor = (entry) => new SpineActor({ animations: Object.keys(entry.animations).map((name) => ({ name })) }, entry); + + test('holds the first frame of its idle; no attack, skill, form or stun moves it; a revive plays again', () => { + const a = actor(back(CAPER)); + a.attack(1); + assert.equal(a.current, 'Attack'); + assert.equal(a.die(), 0, 'no clip to time'); + assert.equal(a.current, 'Idle'); + assert.equal(a.spine.state.tracks[0].timeScale, 0); + assert.equal(a.mode, 'die'); + a.attack(1); + assert.equal(a.windUp(1, 0.1), false); + a.setSkill(true); + a.setBase('move'); a.setBase('stun'); + a.setForm({ idle: 'Skill_Idle' }, 'Skill_Begin'); + a.update(5); + assert.equal(a.current, 'Idle'); + assert.equal(a.spine.state.tracks[0].timeScale, 0); + a.setForm(null); + a.revive(); + assert.equal(a.current, 'Idle'); + assert.equal(a.spine.state.tracks[0].timeScale, 1, 'moving again'); + assert.equal(a.spine.state.tracks[0].loop, true); + }); + + test('without an idle clip either it freezes; a revive thaws it; a skeleton with a Die clip plays it', () => { + const a = actor({ anims: { idle: null, attack: { begin: null, loop: 'Attack', end: null } }, animations: { Attack: 1 } }); + a.attack(1); + a.die(); + assert.equal(a.frozen, true); + assert.equal(a.current, 'Attack', 'the pose it had'); + a.revive(); + assert.equal(a.frozen, false); + const b = actor(front(CAPER)); + assert.equal(b.die(), front(CAPER).animations.Die); + assert.equal(b.current, 'Die'); + assert.equal(b.spine.state.tracks[0].timeScale, 1); + }); +}); + +describe('enemies and summons are unchanged', () => { + test('an enemy dies on its own model (no swap); an idle-only summon facing UP holds still and fades', async () => { + const s = store(); + const e = new UnitView(fakeViewCtx(fake.P, { assets: s, cam }), { id: 7, side: 'enemy', kind: 'enemy', defId: 'enemy_1007_slime', spine: 'enemy_1007_slime', tier: 1, x: 8, y: 9, maxHp: 100, facing: -1 }); + await settle(); + const ea = e.actor; + e.die(); + frames(e, 2); + await settle(); + assert.equal(e.actor, ea); + assert.equal(e.actor.current, 'Die'); + const tok = 'token_10017_skadi2_dedant'; // 浊心斯卡蒂's 海嗣: Idle / Skill / Start, no Die clip, no Back model + const t = new UnitView(fakeViewCtx(fake.P, { assets: s, cam }), { id: 8, side: 'ally', kind: 'token', defId: tok, spine: tok, tier: 1, x: 4, y: 9, maxHp: 100, dir: 'UP' }); + await settle(); + assert.equal(t.entryBack, false); + const ta = t.actor, n0 = s.acquired(); + t.onAttack(null, 1, 'none'); + t.die(); + assert.equal(ta.current, 'Idle'); + assert.equal(ta.spine.state.tracks[0].timeScale, 0, 'held still while it fades'); + frames(t, 90); + assert.equal(t.remove, true); + assert.equal(s.acquired(), n0, 'no model loaded'); + assert.equal(t.actor, ta); + }); +});