Files
ark-stronghold-protocol/test/render/unitview.test.js
T
sganggs 0aba602ecf Playtest #3 fixes: lost models, picking, live LP, temp overflow, 蕾缪安 S3, 回环射手, FX, detail card, spinners
- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight
  (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s
- #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks,
  hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped
  at one per frame
- #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates'
  pending LP in the team panel
- #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue);
  temp-row warning and ready-button reason
- #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx
- #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught)
- #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost
- #8 特质 right under the detail card header, with its official type icon
- #9 no spinning busy cursors / spinners next to texts; game data warmed before the match
- docs, recordings and tests updated (2414 unit tests, all browser suites green)
2026-09-29 23:42:55 +08:00

309 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// test/render/unitview.test.js — render/units.js UnitView and render/textures.js caches against a headless fake PIXI
// (test/render/fakepixi.js): tier chips (tokens have no tier), fallback-portrait allocation (no throw-away
// placeholder canvases, nothing built when the Spine model is already there), and the per-mount texture helpers
// (mountain silhouette, tier-chip redraw) that must not allocate a new canvas each time.
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, T;
before(async () => {
fake = installFakePixi();
({ UnitView } = await import('../../public/js/render/units.js'));
T = await import('../../public/js/render/textures.js');
});
after(() => fake.restore());
const tick = () => new Promise((r) => setImmediate(r));
const cam = () => presetCamera('prep', { width: 1280, height: 720 });
const diamonds = () => fake.canvases.filter((c) => c.width === 160 && c.height === 160);
/** Asset store stub: avatar image + (optionally) a Spine model, both resolved asynchronously. */
function store({ image = true, spine = false, imageDelay = 0 } = {}) {
const img = { width: 180, height: 180 };
const entry = { skel: '/s/x.skel', atlas: '/s/x.atlas', textures: ['/s/x.png'], anims: { idle: 'Idle' }, animations: { Idle: 1 } };
return {
picture: (id) => (id ? `/pic/${id}.png` : null),
image: (u) => new Promise((r) => (imageDelay ? setTimeout(() => r(image ? img : null), imageDelay) : r(image ? img : null))),
spineEntry: () => (spine ? entry : null),
spine: { acquire: async () => ({ animations: [{ name: 'Idle' }] }), release() {} },
};
}
function view(info, opts = {}, assets = store()) {
const ctx = fakeViewCtx(fake.P, { assets, cam: cam });
return new UnitView(ctx, { id: 1, side: 'ally', kind: 'chess', defId: 'char_x', tier: 3, x: 5, y: 12, maxHp: 1000, ...info }, opts);
}
describe('tier chips', () => {
test('summon tokens in the hand show no tier chip (tokens have no tier)', async () => {
const v = view({ kind: 'token', defId: 'token_10028_vigil_wolf', avatar: 'token_10028_vigil_wolf' }, { prep: true });
for (let i = 0; i < 3; i++) v.update(1 / 60, cam(), i / 60);
assert.ok(!v.chip || !v.chip.visible, 'no chip on a token');
});
test('operators keep their chip in prep and in battle; enemies never have one', () => {
const p = view({ kind: 'chess', tier: 4 }, { prep: true });
p.update(1 / 60, cam(), 0);
assert.ok(p.chip && p.chip.visible);
const b = view({ kind: 'chess', tier: 2 });
b.update(1 / 60, cam(), 0);
assert.ok(b.chip && b.chip.visible);
const e = view({ side: 'enemy', kind: 'enemy', defId: 'enemy_1007_slime' });
e.update(1 / 60, cam(), 0);
assert.equal(e.chip, null);
});
});
describe('fallback portraits (avatar diamonds)', () => {
test('an avatar that loads builds one diamond — no image-less placeholder first', async () => {
const before = diamonds().length;
const v = view({ defId: 'char_a', avatar: 'char_a' });
v.update(1 / 60, cam(), 0);
await tick(); await tick();
v.update(1 / 60, cam(), 1 / 60);
assert.equal(diamonds().length - before, 1, 'one 160×160 canvas');
assert.notEqual(v.fallback.texture, fake.P.Texture.EMPTY, 'the diamond shows');
});
test('a unit whose Spine model is ready before its first frame builds no diamond at all', async () => {
const before = diamonds().length;
const v = view({ defId: 'char_b', avatar: 'char_b' }, {}, store({ spine: true }));
await tick(); await tick();
assert.ok(v.spineReady, 'spine ready');
for (let i = 0; i < 30; i++) v.update(1 / 60, cam(), i / 60);
assert.equal(diamonds().length - before, 0);
});
test('a missing avatar falls back to the procedural placeholder (once)', async () => {
const before = diamonds().length;
const v = view({ defId: 'char_c', avatar: 'char_c' }, {}, store({ image: false }));
await tick(); await tick();
for (let i = 0; i < 3; i++) v.update(1 / 60, cam(), i / 60);
assert.equal(diamonds().length - before, 1);
assert.notEqual(v.fallback.texture, fake.P.Texture.EMPTY);
});
test('a slow avatar shows the placeholder meanwhile, then the picture', async () => {
const before = diamonds().length;
let release;
const gate = new Promise((r) => { release = r; });
const assets = { ...store(), image: () => gate };
const v = view({ defId: 'char_d', avatar: 'char_d' }, {}, assets);
v.update(1 / 60, cam(), 0);
assert.equal(diamonds().length - before, 0, 'nothing built while the avatar may still arrive quickly');
const t0 = Date.now();
while (v.fallback.texture === fake.P.Texture.EMPTY && Date.now() - t0 < 5000) { await new Promise((r) => setTimeout(r, 40)); v.update(1 / 60, cam(), 0); }
assert.ok(Date.now() - t0 >= 300, 'placeholder only after the wait');
assert.notEqual(v.fallback.texture, fake.P.Texture.EMPTY, 'placeholder while waiting');
release({ width: 180, height: 180 });
await tick(); await tick();
v.update(1 / 60, cam(), 0);
assert.equal(diamonds().length - before, 2, 'placeholder + picture');
});
test('two views of the same unit share the cached diamond', async () => {
const a = view({ defId: 'char_e', avatar: 'char_e' });
a.update(1 / 60, cam(), 0);
await tick(); await tick();
a.update(1 / 60, cam(), 0);
const before = diamonds().length;
const b = view({ defId: 'char_e', avatar: 'char_e' });
b.update(1 / 60, cam(), 0);
await tick(); await tick();
b.update(1 / 60, cam(), 0);
assert.equal(diamonds().length, before);
assert.equal(a.fallback.texture, b.fallback.texture);
});
});
describe('diamond cache', () => {
test('bounded LRU; eviction never destroys a texture a view may still show', () => {
const first = T.diamondTexture('lru_0', null, 0xffffff);
for (let i = 1; i < 400; i++) {
T.diamondTexture(`lru_${i}`, null, 0xffffff);
if (i % 50 === 0) assert.equal(T.diamondTexture('lru_0', null, 0xffffff), first, 'recently used stays cached');
}
assert.ok(!first.destroyed && !first.baseTexture.destroyed);
const n0 = diamonds().length;
T.diamondTexture('lru_1', null, 0xffffff);
assert.equal(diamonds().length, n0 + 1, 'the least recently used ones were evicted');
});
});
describe('per-mount textures', () => {
test('silhouetteTexture is cached per image (the field view builds it on every mount)', () => {
const img = { width: 1024, height: 236 };
const n0 = fake.canvases.length;
const t1 = T.silhouetteTexture(img);
const t2 = T.silhouetteTexture(img);
assert.equal(t1, t2);
assert.equal(fake.canvases.length - n0, 1);
assert.notEqual(T.silhouetteTexture({ width: 512, height: 100 }), t1);
});
test('refreshTierChips redraws the chip atlas in place (no new canvas, chips handed out stay valid)', () => {
const chip = T.tierChip(3, false);
const n0 = fake.canvases.length;
const b0 = fake.baseTextures.length;
for (let i = 0; i < 5; i++) T.refreshTierChips();
const again = T.tierChip(3, false);
assert.equal(fake.canvases.length, n0, 'no new canvas');
assert.equal(fake.baseTextures.length, b0, 'no new base texture');
assert.equal(again.baseTexture, chip.baseTexture);
assert.ok(!chip.destroyed && !chip.baseTexture.destroyed);
});
});
describe('field view teardown (app.js releaseGl)', () => {
test('drops the dead renderer’s GL copies of shared textures, buffers, geometries and cached programs', async () => {
const { releaseGl } = await import('../../public/js/render/app.js');
const UID = 7;
const listeners = [];
const mkBt = () => ({ _glTextures: { [UID]: { texture: {} }, 3: { texture: {} } } });
const shared = [mkBt(), mkBt()];
const deleted = [];
const ts = {
managedTextures: shared.slice(),
destroyTexture(bt, skipRemove) {
deleted.push(bt);
delete bt._glTextures[UID];
listeners.push('off');
if (!skipRemove) this.managedTextures.splice(this.managedTextures.indexOf(bt), 1);
},
};
const disposed = [];
const sys = (name) => ({ disposeAll(lost) { disposed.push([name, lost]); } });
const prog = { glPrograms: { [UID]: { program: 'p7' }, 3: { program: 'p3' } } };
const gl = { deleted: [], deleteProgram(p) { this.deleted.push(p); } };
const prevPixi = globalThis.PIXI;
globalThis.PIXI = { ...prevPixi, utils: { ...(prevPixi?.utils || {}), ProgramCache: { src: prog } } };
try {
releaseGl({ CONTEXT_UID: UID, gl, texture: ts, geometry: sys('geometry'), buffer: sys('buffer'), framebuffer: sys('framebuffer') });
} finally { globalThis.PIXI = prevPixi; }
assert.deepEqual(deleted, shared);
assert.equal(ts.managedTextures.length, 0);
for (const bt of shared) assert.deepEqual(Object.keys(bt._glTextures), ['3'], 'other contexts untouched');
assert.deepEqual(disposed.map((d) => d[0]).sort(), ['buffer', 'framebuffer', 'geometry']);
assert.ok(disposed.every((d) => d[1] === false));
assert.deepEqual(Object.keys(prog.glPrograms), ['3']);
assert.deepEqual(gl.deleted, ['p7']);
assert.doesNotThrow(() => releaseGl(null));
assert.doesNotThrow(() => releaseGl({}));
});
});
// user playtest #3 item 7: the body as drawn, for render/pick.js (the shared picking rule)
describe('pickShape / bounds / hitTest', () => {
test('the avatar diamond until the Spine model shows, then the chibi at its own measured height', async () => {
const v = view({ defId: 'char_p1', avatar: 'char_p1' }, { prep: true }, store({ spine: true, imageDelay: 50 }));
v.update(1 / 60, cam(), 0);
assert.equal(v.pickShape().kind, 'diamond', 'no model yet');
await tick(); await tick();
assert.ok(v.spineReady);
// the posed skeleton's bounds: 425.6 units above the feet = 1.33 tiles (UNIT.modelScale 1/320)
v.actor.spine.getLocalBounds = () => ({ x: -60, y: -425.6, width: 120, height: 430 });
v.update(1 / 60, cam(), 1 / 60);
const b = v.pickShape();
assert.equal(b.kind, 'chibi');
assert.ok(Math.abs(b.h - 1.33) < 1e-6, `measured height ${b.h}`);
assert.equal(b.w, 1, 'operators: the measured width profile');
assert.equal(b.x, v.screen.x); assert.equal(b.y, v.screen.y); assert.equal(b.s, v.screen.s);
assert.equal(b.depth, v.root.zIndex);
// measured once: a later pose (a raised weapon) does not move the outline
v.actor.spine.getLocalBounds = () => ({ x: -60, y: -700, width: 120, height: 700 });
v.update(1 / 60, cam(), 2 / 60);
assert.ok(Math.abs(v.pickShape().h - 1.33) < 1e-6);
// the outline tops out at the model; the tier chip (prep) is part of the pick shape, not of bounds()
const r = v.bounds();
assert.ok(Math.abs(r.y - (v.screen.y - 1.33 * v.screen.s)) < 1, 'bounds top = model top');
assert.ok(Math.abs(r.y + r.height - (v.screen.y + 0.08 * v.screen.s)) < 1, 'bounds bottom = just below the feet');
assert.ok(b.hud && b.hud.y1 < v.screen.y - v.screen.s, 'the chip is up at the head (UNIT.headroom)');
assert.ok(v.hitTest(v.screen.x, v.screen.y - 0.5 * v.screen.s), 'the torso');
assert.ok(!v.hitTest(v.screen.x + 0.6 * v.screen.s, v.screen.y - 0.5 * v.screen.s), 'beside it');
// a new model (Front ⇄ Back swap, _dropActor) is measured again
v._dropActor();
assert.equal(v._bodyH, null);
});
test('implausible skeleton bounds are clamped; enemies scale their width with their size', async () => {
const op = view({ defId: 'char_p2', avatar: 'char_p2' }, { prep: true }, store({ spine: true }));
await tick(); await tick();
op.actor.spine.getLocalBounds = () => ({ x: -500, y: -1200, width: 1000, height: 1200 }); // an effect in the idle pose
op.update(1 / 60, cam(), 0);
assert.equal(op.pickShape().h, 1.55, 'operators ≤ 1.55 tiles');
const foe = view({ side: 'enemy', kind: 'enemy', defId: 'enemy_big' }, {}, store({ spine: true }));
await tick(); await tick();
foe.actor.spine.getLocalBounds = () => ({ x: -300, y: -640, width: 600, height: 640 });
foe.update(1 / 60, cam(), 0);
const b = foe.pickShape();
assert.ok(Math.abs(b.h - 2) < 1e-6);
assert.ok(Math.abs(b.w - 2 / 1.2) < 1e-6, 'width factor from the height');
assert.equal(b.hud, null, 'an undamaged enemy shows no bar');
});
test('a battle unit\'s bars are its HUD rect; a dead unit is not hit', async () => {
const v = view({ defId: 'char_p3', avatar: 'char_p3' }, {}, store({ spine: true }));
await tick(); await tick();
v.sync({ x: 5, y: 12, hp: 500, maxHp: 1000, sp: 3, spMax: 10, flags: 0, anim: 0, vx: 0 });
v.update(1 / 60, cam(), 0);
const hud = v.pickShape().hud;
assert.ok(hud && hud.x1 > hud.x0 && hud.y1 > hud.y0, 'HP / SP bars and chip');
assert.ok(hud.y1 < v.screen.y - v.screen.s, 'above the head');
v.die();
assert.equal(v.hitTest(v.screen.x, v.screen.y - 0.5 * v.screen.s), false);
});
test('item plates: floating above the slot; centred on the pointer while dragged (lifted)', async () => {
const { ItemView } = await import('../../public/js/render/units.js');
const ctx = fakeViewCtx(fake.P, { assets: store(), cam });
const it = new ItemView(ctx, { id: 'p:9', uid: 9, defId: 'item_x', x: 3, y: 7 });
it.setWorld(3, 7, 0.16);
it.update(1 / 60, cam(), 0);
let b = it.pickShape();
assert.equal(b.kind, 'plate');
assert.ok(Math.abs(b.y - (it.screen.y - 0.31 * it.screen.s)) < 1e-6, 'resting: the plate above its anchor');
it.lift = 0.3;
it.update(1 / 60, cam(), 0);
b = it.pickShape();
const g = cam().project(3, 7, 0.16);
assert.ok(Math.abs(b.x - g.x) < 1e-6 && Math.abs(b.y - g.y) < 1e-6, 'dragged: centred on its ground point (the pointer)');
assert.equal(it.plate.anchor.y, 0.5);
assert.ok(it.hitTest(g.x, g.y));
});
});
describe('enemy preview pen figures (lod idle)', () => {
/** A UnitView of a pen figure with a Spine model, an impostor atlas (full or not) and a counting renderer. */
async function penFigure(full) {
let frame = 0;
const renders = [];
const atlas = {
alloc: (w, h) => (full ? null : { w, h, tex: new fake.P.Texture(), clip: false }),
free() {}, park(o) { o.visible = false; }, unpark(o) { o.visible = true; }, draw() {},
};
const ctx = fakeViewCtx(fake.P, {
assets: store({ spine: true }), cam, frameNo: () => frame, impostors: atlas,
renderer: { resolution: 1, render: (obj, o) => renders.push(o?.renderTexture || null) },
});
const v = new UnitView(ctx, { id: 'e:0', side: 'enemy', kind: 'enemy', defId: 'enemy_1007_slime', tier: 1, x: 9, y: 15, maxHp: 1, facing: -1 }, { prep: true, lod: 'idle' });
await tick(); await tick();
assert.ok(v.spineReady, 'spine ready');
let steps = 0;
const upd = v.actor.update.bind(v.actor);
v.actor.update = (dt) => { steps++; return upd(dt); };
const step = () => { v.update(1 / 60, cam(), frame / 60); frame++; };
return { v, step, renders, steps: () => steps };
}
test('with room in the shared atlas: an impostor refreshed every 3rd frame, never a private render target', async () => {
const { v, step, renders, steps } = await penFigure(false);
for (let i = 0; i < 30; i++) step();
assert.ok(v.imp && v.imp.slot, 'atlas slot');
assert.equal(renders.length, 0, 'drawn by the atlas flush, no per-figure render call');
assert.ok(steps() <= 12, `idle loop stepped ≈ every 3rd frame (${steps()} of 30)`);
});
});