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

+43 -6
View File
@@ -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();
+4 -1
View File
@@ -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() {} }
+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)`);
});
});