Reuse held Spine poses and redeploy countdown textures (#434)

This commit is contained in:
sganggs committed 2026-10-09 17:14:50 +08:00
1 parent 48f5159357
commit 2fdc565c7c
9 files changed
+351 -25

No files matched your search

+209
View File
@@ -0,0 +1,209 @@
// test/render/held-pose.test.js — a model whose pose cannot change is neither re-posed nor redrawn (render/spine.js
// SpineActor.poseHeld, render/units.js UnitView.update / _updateImpostor): a knocked-out operator lying in the held end
// of its Die clip, a frozen model. On a slow device in a late round a dozen operators lie so, and each was re-posed and
// redrawn into the impostor atlas every couple of frames for the same pixels. The actor's clock still runs; anything
// that can move the pose again (a new clip, a revive, a wind-up, a form's closing clip, clipping masks switched, a zoom
// past the impostor's rescale threshold, a tint the slots do not show yet) brings the updates back. Headless fake PIXI
// (test/render/fakepixi.js).
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
let fake, UnitView, DOWN_STATE, SpineActor;
before(async () => {
fake = installFakePixi();
({ UnitView, DOWN_STATE } = 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 cam = () => presetCamera('normal', { width: 1280, height: 720 });
const ENTRY = { skel: '/s/op.skel', atlas: '/s/op.atlas', textures: ['/s/op.png'], anims: { idle: 'Idle', die: 'Die' }, animations: { Idle: 1, Die: 0.8 } };
const DATA = { animations: [{ name: 'Idle' }, { name: 'Die' }] };
/** Play the current track 0 clip to its end (the fake skeleton does not advance clip time itself). */
const finish = (a) => { const e = a.spine.state.tracks[0]; e.trackTime = e.animationEnd - e.animationStart; };
describe('SpineActor.poseHeld', () => {
test('a dead model on the finished end of its Die clip is held; a clip still playing, looping or mixing is not', () => {
const a = new SpineActor(DATA, ENTRY);
assert.equal(a.poseHeld(), false, 'idle loop');
a.die();
assert.equal(a.current, 'Die');
assert.equal(a.poseHeld(), false, 'falling');
finish(a);
assert.equal(a.poseHeld(), false, 'not before an update has posed the end of the clip');
a.update(0);
assert.equal(a.poseHeld(), true, 'lying on the last frame');
const e = a.spine.state.tracks[0];
e.mixingFrom = {};
assert.equal(a.poseHeld(), false, 'still mixing out of the previous clip');
e.mixingFrom = null;
a.spine.state.tracks[1] = {};
assert.equal(a.poseHeld(), false, 'another track plays');
a.spine.state.tracks.length = 1;
e.loop = true;
assert.equal(a.poseHeld(), false, 'a looping clip moves on');
e.loop = false;
assert.equal(a.poseHeld(), true);
a.revive();
assert.equal(a.poseHeld(), false, 'standing again');
});
test('a skeleton without a Die clip holds its idle at timeScale 0 at once; a frozen model is held', () => {
const a = new SpineActor({ animations: [{ name: 'Idle' }] }, { ...ENTRY, anims: { idle: 'Idle' }, animations: { Idle: 1 } });
a.die();
assert.equal(a.spine.state.tracks[0].timeScale, 0);
assert.equal(a.poseHeld(), false, 'not before an update has posed it');
a.update(0);
assert.equal(a.poseHeld(), true);
const b = new SpineActor(DATA, ENTRY);
b.frozen = true;
b.update(0);
assert.equal(b.poseHeld(), true, 'frozen: update never moves the skeleton');
});
test('a tint the slots do not show yet waits for one update (pixi-spine applies tint in update)', () => {
const a = new SpineActor(DATA, ENTRY);
a.die();
finish(a);
a.update(0);
assert.equal(a.poseHeld(), true);
a.spine.tint = 0xb4b4b4; // the knocked-out grey, set after the frame's update
assert.equal(a.poseHeld(), false, 'the new tint is not on the slots yet');
a.update(0);
assert.equal(a.poseHeld(), true, 'shown: held again');
});
test('never while something pending acts on the clock: a form\'s closing clip, a wind-up', () => {
const a = new SpineActor(DATA, ENTRY);
a.die();
finish(a);
a.update(0);
a.endClip = 'Die'; a.endAt = 5;
assert.equal(a.poseHeld(), false, 'closing clip pending');
a.endClip = null;
a.windUntil = 3;
assert.equal(a.poseHeld(), false, 'wind-up pending');
a.windUntil = null;
a.frozen = true;
a.endClip = 'X';
assert.equal(a.poseHeld(), false, 'frozen, but a closing clip is due');
});
});
describe('UnitView: a held pose is neither re-posed nor redrawn', () => {
/** An operator with a Spine model in a context with an impostor atlas (interval `iv`) and counters. */
async function operator(iv) {
let frame = 0;
let draws = 0;
let clip = true;
const atlas = {
alloc: (w, h, o) => ({ w, h, tex: new fake.P.Texture(), clip: !!o?.clip }),
free() {}, park(o) { o.visible = false; }, unpark(o) { o.visible = true; }, draw() { draws++; },
};
const ctx = fakeViewCtx(fake.P, {
cam, frameNo: () => frame, impostors: atlas, impostorInterval: () => iv, clipAllowed: () => clip,
renderer: { resolution: 1, render() {} },
assets: { picture: () => null, image: async () => null, spineEntry: () => ENTRY, spine: { acquire: async () => DATA, release() {} } },
});
const v = new UnitView(ctx, { id: 1, side: 'ally', kind: 'chess', defId: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT' });
await tick(); await tick();
assert.ok(v.spineReady, 'spine ready');
let updates = 0;
const upd = v.actor.update.bind(v.actor);
v.actor.update = (dt) => { updates++; return upd(dt); };
const step = (n = 1) => { for (let i = 0; i < n; i++) { v.update(1 / 60, cam(), frame / 60); frame++; } };
const counts = () => ({ updates, draws });
return { v, step, counts, setClip: (on) => { clip = on; } };
}
const knockOut = (v) => { v.setDown([1, 60, 60, DOWN_STATE.COUNTING], 1); };
test('impostor mode: lying in the held Die pose, no re-pose and no atlas redraw; the clock still runs', async () => {
const { v, step, counts } = await operator(2);
step(4);
assert.ok(v.imp, 'impostor mode');
knockOut(v);
step(4);
const falling = counts();
assert.ok(falling.updates >= 2 && falling.draws >= 2, 'the fall is animated');
finish(v.actor);
step(1); // the frame the clip ends on may still draw (dirty / turn)
const c0 = counts(), clock0 = v.actor.clock;
step(60);
assert.deepEqual(counts(), c0, 'one second down: nothing re-posed, nothing redrawn');
assert.ok(Math.abs(v.actor.clock - clock0 - 1) < 1e-6, `the actor clock ran on (${v.actor.clock - clock0})`);
assert.ok(v._downRing.root.visible, 'under its redeploy ring');
});
test('a zoom past the rescale threshold, clipping masks switched or a redeploy bring the redraws back', async () => {
const { v, step, counts, setClip } = await operator(2);
step(4);
knockOut(v);
step(2);
finish(v.actor);
step(2);
let c = counts();
v.modelK *= 1.3; // the impostor's scale is off by > 12 %
step(1);
assert.equal(counts().draws, c.draws + 1, 'redrawn once at the new scale');
step(10);
assert.equal(counts().draws, c.draws + 1, 'then held again');
v.actor.clipped = true; // a skeleton with clipping masks, the masks switched off
c = counts();
setClip(false);
step(1);
assert.equal(counts().draws, c.draws + 1, 'masks off: redrawn');
assert.equal(counts().updates, c.updates + 1, '… and re-posed (the eyelid fallback runs in the update)');
step(10);
assert.equal(counts().draws, c.draws + 1, 'then held again');
c = counts();
v.onDeploy(); // b.ev 'deploy'
v.setDown(null, 70);
step(6);
assert.ok(counts().updates >= c.updates + 3, 'standing up: animated again');
});
test('direct mode (no impostor): no re-pose while held, the clock still runs', async () => {
const { v, step, counts } = await operator(0);
step(2);
assert.ok(!v.imp, 'drawn directly');
knockOut(v);
step(3);
finish(v.actor);
step(1);
const c0 = counts(), clock0 = v.actor.clock;
step(30);
assert.equal(counts().updates, c0.updates, 'no update while held');
assert.ok(Math.abs(v.actor.clock - clock0 - 0.5) < 1e-6, 'clock advanced by the frames\' time');
});
test('direct mode: a hit flash on a lying model is updated until its tint is steady on the slots', async () => {
const { v, step, counts } = await operator(0);
step(2);
knockOut(v);
step(3);
finish(v.actor);
step(2);
const c0 = counts();
step(10);
assert.equal(counts().updates, c0.updates, 'held');
v.flash = 1; // a hit: the tint mixes toward red and fades back over ~10 frames
step(20);
const c1 = counts();
assert.ok(c1.updates > c0.updates, `updated while the tint changes (${c1.updates - c0.updates})`);
step(10);
assert.equal(counts().updates, c1.updates, 'held again once the tint is steady');
});
test('a living model in the same context keeps its usual refreshes', async () => {
const { step, counts } = await operator(2);
step(2);
const c0 = counts();
step(20);
const c1 = counts();
assert.ok(c1.updates - c0.updates >= 9 && c1.draws - c0.draws >= 9, `every 2nd frame (${c1.updates - c0.updates} updates, ${c1.draws - c0.draws} draws in 20)`);
});
});