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
@@ -70,21 +70,25 @@ describe('knocked-down operator (UnitView.setDown)', () => {
|
||||
assert.ok(v.alpha > 0.85, `drawn (alpha ${v.alpha})`);
|
||||
const r = v._downRing;
|
||||
assert.ok(r && r.root.visible, 'ring shown');
|
||||
assert.equal(r.text.text, '20', 'seconds left (game s)');
|
||||
assert.equal(r.label, '20', 'seconds left (game s)');
|
||||
assert.equal(r.text.texture, T.downLabel('20', '#ffffff', 1), 'drawn with the shared label texture');
|
||||
assert.equal(r.arc.tint, DOWN_LOOK.ring[DOWN_STATE.COUNTING]);
|
||||
assert.equal(r.arc.texture, T.ringArc(0), 'nothing elapsed yet');
|
||||
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 25);
|
||||
frames(v, 1, 1 / 60, () => 25);
|
||||
assert.equal(r.text.text, '5');
|
||||
assert.equal(r.label, '5');
|
||||
assert.equal(r.text.texture, T.downLabel('5', '#ffffff', 1));
|
||||
assert.equal(r.arc.texture, T.ringArc(0.75), 'three quarters elapsed');
|
||||
v.setDown([1, 30, 20, DOWN_STATE.WAIT_DP], 31);
|
||||
frames(v, 1, 1 / 60, () => 31);
|
||||
assert.equal(r.text.text, 'DP');
|
||||
assert.equal(r.label, 'DP');
|
||||
assert.equal(r.text.texture, T.downLabel('DP', '#ffc600', 1), 'in the ring colour');
|
||||
assert.equal(r.arc.tint, DOWN_LOOK.ring[DOWN_STATE.WAIT_DP]);
|
||||
assert.equal(r.arc.texture, T.ringArc(1), 'full ring while it waits');
|
||||
v.setDown([1, 30, 20, DOWN_STATE.WAIT_TILE], 32);
|
||||
frames(v, 1);
|
||||
assert.equal(r.text.text, '!');
|
||||
assert.equal(r.label, '!');
|
||||
assert.equal(r.text.texture, T.downLabel('!', '#ff4b3e', 1));
|
||||
const kids = v.hud.children.length;
|
||||
frames(v, 30);
|
||||
assert.equal(v.hud.children.length, kids, 'no per-frame allocation');
|
||||
@@ -95,6 +99,39 @@ describe('knocked-down operator (UnitView.setDown)', () => {
|
||||
assert.equal(r.root.visible, false, 'ring gone');
|
||||
});
|
||||
|
||||
test('rings showing the same label share one rasterised texture, drawn once (textures.js downLabel)', () => {
|
||||
const a = view({ id: 11 }), b = view({ id: 12 });
|
||||
frames(a, 2);
|
||||
frames(b, 2);
|
||||
a.setDown([11, 30, 20, DOWN_STATE.COUNTING], 17.5);
|
||||
b.setDown([12, 31, 20, DOWN_STATE.COUNTING], 18.5); // both 12.5 s left → "13"
|
||||
frames(a, 1, 1 / 60, () => 17.5);
|
||||
frames(b, 1, 1 / 60, () => 18.5);
|
||||
const ra = a._downRing, rb = b._downRing;
|
||||
assert.equal(ra.label, '13');
|
||||
assert.equal(rb.label, '13');
|
||||
assert.equal(ra.text.texture, rb.text.texture, 'one texture for both rings');
|
||||
assert.ok(!(ra.text instanceof fake.P.Text), 'the ring holds a plain Sprite, not a Text of its own');
|
||||
const t13 = ra.text.texture;
|
||||
frames(a, 1, 1 / 60, () => 18.5);
|
||||
assert.equal(ra.label, '12');
|
||||
assert.notEqual(ra.text.texture, t13, 'a new number, a new texture');
|
||||
a.setDown([11, 30, 20, DOWN_STATE.COUNTING], 17.5);
|
||||
frames(a, 1, 1 / 60, () => 17.5);
|
||||
assert.equal(ra.text.texture, t13, 'a number seen before reuses its texture');
|
||||
assert.equal(T.downLabel('13', '#ffffff', 2) === t13, false, 'another resolution, another texture');
|
||||
});
|
||||
|
||||
test('the shared labels are redrawn in place once the web fonts land (refreshDownLabels)', () => {
|
||||
const tex = T.downLabel('47', '#ffffff', 1);
|
||||
const proto = fake.P.Text.prototype, up = proto.updateText;
|
||||
const redrawn = [];
|
||||
proto.updateText = function (respectDirty) { redrawn.push([this.text, respectDirty]); return up.call(this, respectDirty); };
|
||||
try { T.refreshDownLabels(); } finally { proto.updateText = up; }
|
||||
assert.ok(redrawn.some(([t, d]) => t === '47' && d === false), 'the cached label re-rasterised (forced)');
|
||||
assert.equal(T.downLabel('47', '#ffffff', 1), tex, 'same texture object, so every ring showing it updates');
|
||||
});
|
||||
|
||||
test('a view made for a unit already down starts on the held end of the Die clip', () => {
|
||||
const v = view();
|
||||
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 12, true);
|
||||
@@ -102,7 +139,7 @@ describe('knocked-down operator (UnitView.setDown)', () => {
|
||||
assert.ok(v.dieT >= 30);
|
||||
frames(v, 2, 1 / 60, () => 12);
|
||||
assert.ok(v._downRing.root.visible);
|
||||
assert.equal(v._downRing.text.text, '18');
|
||||
assert.equal(v._downRing.label, '18');
|
||||
});
|
||||
|
||||
test('an operator entering the battle knocked out (联防, reason FORCED_EXIT): the held pose at once, no death burst, then its ring', async () => {
|
||||
@@ -120,7 +157,7 @@ describe('knocked-down operator (UnitView.setDown)', () => {
|
||||
frames(v, 240, 1 / 60, () => 2);
|
||||
assert.equal(v.remove, false, 'stays on its tile');
|
||||
assert.ok(v._downRing.root.visible);
|
||||
assert.equal(v._downRing.text.text, '68');
|
||||
assert.equal(v._downRing.label, '68');
|
||||
const w = view({ id: 2 });
|
||||
frames(w, 2);
|
||||
w.die();
|
||||
|
||||
@@ -83,7 +83,10 @@ export function installFakePixi() {
|
||||
drawRect() { return this; } drawCircle() { return this; } drawEllipse() { return this; } drawPolygon() { return this; } drawRoundedRect() { return this; } closePath() { return this; }
|
||||
quadraticCurveTo() { return this; } bezierCurveTo() { return this; } arc() { return this; }
|
||||
}
|
||||
class Text extends Sprite { constructor(text = '', style = {}) { super(); this.text = text; this.style = style; } }
|
||||
class Text extends Sprite {
|
||||
constructor(text = '', style = {}) { super(); this.text = text; this.style = style; this.resolution = 1; this.rasters = 0; this.texture = new Texture(new BaseTexture({ width: 1, height: 1 })); }
|
||||
updateText() { this.rasters++; }
|
||||
}
|
||||
class BitmapText extends Text {}
|
||||
class Matrix { constructor() { this.a = 1; this.b = 0; this.c = 0; this.d = 1; this.tx = 0; this.ty = 0; } set() { return this; } translate() { return this; } scale() { return this; } identity() { return this; } }
|
||||
class RenderTexture extends Texture { static create({ width = 1, height = 1 } = {}) { return new RenderTexture(new BaseTexture({ width, height })); } resize() {} }
|
||||
|
||||
@@ -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