Reuse held Spine poses and redeploy countdown textures (#434)
This commit is contained in:
1 parent
48f5159357
commit
2fdc565c7c
9 files changed
+351
-25
No files matched your search
@@ -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)`);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user