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)
This commit is contained in:
1 parent
01a44cd288
commit
0aba602ecf
72 files changed
+5132
-571
No files matched your search
+143
-3
@@ -10,7 +10,7 @@ import {
|
||||
avatarUrl, portraitUrl, enemyIconUrl, tokenAvatarUrl, bondIconUrl, bandIconUrl, itemIconUrl, skillIconUrl, uiUrl,
|
||||
profIconUrl, subProfIconUrl, spineEntry, hasBackSpine, unitPictureUrl, bgmEntry, sfxUrl, unitSfxUrl, baseCharId,
|
||||
validSpine, RefLru, createAssets, unloadSpineData, spinePages, spineDataWeight, SPINE_WEIGHT_MIN, SPINE_IDLE_BYTES,
|
||||
SPINE_IDLE_GRACE_MS,
|
||||
SPINE_IDLE_GRACE_MS, SPINE_EVICT_DELAY_MS, SPINE_QUIET_DELAY_MS,
|
||||
} from '../../public/js/assets.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
@@ -295,6 +295,143 @@ describe('RefLru', () => {
|
||||
await p;
|
||||
assert.deepEqual(unloads, ['a']);
|
||||
});
|
||||
|
||||
// user playtest #3 item 1 (invisible operator models): an unload is asynchronous; a new load of the same key while
|
||||
// it is in flight must not get the doomed value
|
||||
test('a load of a key whose unload is still in flight waits for that unload (never the doomed value)', async () => {
|
||||
const order = [];
|
||||
let finishUnload;
|
||||
let n = 0;
|
||||
const lru = new RefLru({ max: 1,
|
||||
load: async (k) => { order.push(`load ${k}`); return `${k}#${++n}`; },
|
||||
unload: (k, v) => { order.push(`unload ${v}`); return new Promise((r) => { finishUnload = () => { order.push(`unloaded ${v}`); r(); }; }); } });
|
||||
assert.equal(await lru.acquire('a'), 'a#1');
|
||||
await lru.acquire('b');
|
||||
lru.release('a'); // over max: a goes, its unload is in flight
|
||||
assert.ok(lru.unloading('a') && lru.stats().unloading === 1);
|
||||
const again = lru.acquire('a'); // same task: taken again
|
||||
await tick();
|
||||
assert.deepEqual(order, ['load a', 'load b', 'unload a#1'], 'the new load waits');
|
||||
finishUnload();
|
||||
assert.equal(await again, 'a#3', 'a fresh value once the old one is gone (b was #2)');
|
||||
assert.deepEqual(order.slice(3), ['unloaded a#1', 'load a']);
|
||||
await tick();
|
||||
assert.equal(lru.unloading('a'), false);
|
||||
// an unloader that throws or returns nothing never blocks a later load
|
||||
const lru2 = new RefLru({ max: 1, load: async (k) => k, unload: () => { throw new Error('x'); } });
|
||||
await lru2.acquire('a'); await lru2.acquire('b'); lru2.release('a');
|
||||
assert.equal(await lru2.acquire('a'), 'a');
|
||||
});
|
||||
|
||||
test('evictDelay: releases schedule one eviction pass; a scene rebuilt in one go keeps what it takes back', async () => {
|
||||
const c = clock(), unloads = [];
|
||||
let loads = 0;
|
||||
const lru = new RefLru({ max: 2, load: async (k) => { loads++; return k; }, unload: (k) => unloads.push(k), evictDelay: 500, now: c.now, timers: c.timers });
|
||||
for (const k of ['a', 'b', 'c']) await lru.acquire(k);
|
||||
// every view destroyed, then the next scene's views built (same task): nothing is dropped meanwhile
|
||||
for (const k of ['a', 'b', 'c']) lru.release(k);
|
||||
for (const k of ['a', 'b']) await lru.acquire(k);
|
||||
assert.deepEqual(unloads, []);
|
||||
assert.equal(c.pending.length, 1, 'one pass pending');
|
||||
c.advance(510);
|
||||
assert.deepEqual(unloads, ['c'], 'the pass drops only what nobody took back');
|
||||
assert.equal(loads, 3, 'a and b were never reloaded');
|
||||
});
|
||||
|
||||
test('quietDelay: the instant zero of a scene switch is not "no scene"; a lasting one is', async () => {
|
||||
const c = clock(), unloads = [];
|
||||
const lru = new RefLru({ max: 100, load: async (k) => k, unload: (k) => unloads.push(k), weigh: () => 10,
|
||||
maxIdleWeight: 100, quietWeight: 0, idleGrace: 5000, quietDelay: 3000, now: c.now, timers: c.timers });
|
||||
await lru.acquire('bench'); await lru.acquire('op');
|
||||
lru.release('bench'); // prep → battle: the bench model idles for the whole battle
|
||||
c.advance(20000);
|
||||
lru.release('op'); // battle → prep: nothing referenced for an instant …
|
||||
assert.deepEqual(unloads, [], 'the budget of a shown scene applies (10 + 10 ≤ 100)');
|
||||
await lru.acquire('bench'); await lru.acquire('op'); // … the prep views take their models back
|
||||
c.advance(5000);
|
||||
assert.deepEqual(unloads, []);
|
||||
lru.release('bench'); lru.release('op'); // the view is gone for good (lobby)
|
||||
c.advance(1000);
|
||||
assert.deepEqual(unloads, [], 'not quiet long enough yet');
|
||||
c.advance(2100);
|
||||
assert.deepEqual(unloads, [], 'quiet, but both are still inside their grace');
|
||||
c.advance(2000);
|
||||
assert.deepEqual(unloads.sort(), ['bench', 'op'], 'the quiet budget empties the cache');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Spine store: unload / reload of the same skeleton (user playtest #3 item 1)', () => {
|
||||
const tick = () => new Promise((r) => setImmediate(r));
|
||||
/**
|
||||
* PIXI.Assets semantics that caused the invisible models: `unload` frees an asset a microtask after the call
|
||||
* (Loader.unload awaits the cached promise, then deletes the cache entry and destroys the asset), and `load` of that
|
||||
* URL meanwhile returns the cached — doomed — asset.
|
||||
*/
|
||||
function pixiAssets() {
|
||||
const cache = new Map();
|
||||
let seq = 0;
|
||||
const loader = {
|
||||
async unload(url) {
|
||||
const hit = cache.get(url);
|
||||
if (!hit) return;
|
||||
const asset = await hit;
|
||||
cache.delete(url);
|
||||
asset.destroyed = true;
|
||||
},
|
||||
};
|
||||
const Assets = {
|
||||
loader,
|
||||
load(url) {
|
||||
if (!cache.has(url)) cache.set(url, Promise.resolve({ url, id: ++seq, destroyed: false, animations: [] }));
|
||||
return cache.get(url);
|
||||
},
|
||||
unload(url) { return loader.unload(url); },
|
||||
};
|
||||
const prev = globalThis.PIXI;
|
||||
globalThis.PIXI = { Assets, spine: {} };
|
||||
return { Assets, restore: () => { if (prev === undefined) delete globalThis.PIXI; else globalThis.PIXI = prev; } };
|
||||
}
|
||||
|
||||
test('the hazard: PIXI.Assets hands out an asset whose unload is in flight', async () => {
|
||||
const f = pixiAssets();
|
||||
try {
|
||||
await f.Assets.load('/x.skel');
|
||||
f.Assets.unload('/x.skel');
|
||||
const again = await f.Assets.load('/x.skel');
|
||||
await tick();
|
||||
assert.equal(again.destroyed, true, 'the model a view would build from it has no textures');
|
||||
} finally { f.restore(); }
|
||||
});
|
||||
|
||||
test('battle → prep after a long battle: the bench models come back alive (defaults), with or without an eviction', async () => {
|
||||
for (const eager of [false, true]) {
|
||||
const f = pixiAssets();
|
||||
try {
|
||||
const c = clock();
|
||||
// eager = the old timing (evict at once, no quiet delay): the bench model IS dropped at the instant zero —
|
||||
// the re-acquire must then wait for its unload and load a fresh skeleton
|
||||
const a = createAssets({ manifest: M, spineNow: c.now, spineTimers: c.timers, ...(eager ? { spineEvictDelay: 0, spineQuietDelay: 0 } : {}) });
|
||||
const bench = a.spineEntry('char_010_chen'), op = a.spineEntry('char_002_amiya'), foe = a.spineEntry('enemy_1007_slime');
|
||||
const bench0 = await a.spine.acquire(bench);
|
||||
await a.spine.acquire(op);
|
||||
// prep → battle: the prep views go (the bench model idles), the battle views take the board model and the foe
|
||||
a.spine.release(bench); a.spine.release(op);
|
||||
await a.spine.acquire(op); await a.spine.acquire(foe);
|
||||
c.advance(SPINE_IDLE_GRACE_MS + 20000); // a long battle: the bench model idles past its grace
|
||||
// battle → prep in one go (render/app.js enterPrepMode + setPrep): every battle view destroyed, prep views built
|
||||
a.spine.release(op); a.spine.release(foe);
|
||||
const [b1, o1] = await Promise.all([a.spine.acquire(bench), a.spine.acquire(op)]);
|
||||
await tick(); await tick();
|
||||
c.advance(SPINE_EVICT_DELAY_MS + SPINE_QUIET_DELAY_MS + 10);
|
||||
await tick(); await tick();
|
||||
assert.equal(b1.destroyed, false, `${eager ? 'eager' : 'default'}: the bench model is alive`);
|
||||
assert.equal(o1.destroyed, false, `${eager ? 'eager' : 'default'}: the board model is alive`);
|
||||
if (eager) assert.notEqual(b1, bench0, 'eager: dropped, then loaded afresh');
|
||||
else assert.equal(b1, bench0, 'default: never dropped, never reloaded');
|
||||
assert.equal(a.spine.stats().unloading, 0);
|
||||
} finally { f.restore(); }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('spineDataWeight', () => {
|
||||
@@ -418,12 +555,15 @@ describe('spine unload frees the atlas page images too', () => {
|
||||
const f = fakePixi();
|
||||
try {
|
||||
const E = (id, pages) => ({ ...SP(id), textures: pages });
|
||||
const a = createAssets({ manifest: M, loadSpine: async (e) => ({ animations: [], from: e.skel }), spineMax: 1 });
|
||||
const c = clock();
|
||||
const a = createAssets({ manifest: M, loadSpine: async (e) => ({ animations: [], from: e.skel }), spineMax: 1, spineNow: c.now, spineTimers: c.timers });
|
||||
const ea = E('a', ['/assets/spine/a.png', '/assets/spine/common.png']);
|
||||
const eb = E('b', ['/assets/spine/b.png', '/assets/spine/common.png']);
|
||||
await a.spine.acquire(ea);
|
||||
await a.spine.acquire(eb);
|
||||
a.spine.release(ea); // a evicted (max 1) while b still uses common.png
|
||||
a.spine.release(ea); // a evicted (max 1) while b still uses common.png …
|
||||
assert.deepEqual(f.calls.assets, [], '… in the eviction pass a moment later (SPINE_EVICT_DELAY_MS)');
|
||||
c.advance(SPINE_EVICT_DELAY_MS + 10);
|
||||
assert.deepEqual(f.calls.assets, ['/assets/spine/a.skel', '/assets/spine/a.atlas']);
|
||||
assert.deepEqual(f.calls.loader, ['/assets/spine/a.png']);
|
||||
a.spine.release(eb);
|
||||
|
||||
@@ -77,28 +77,70 @@ describe('render engine in headless Chrome', { skip }, () => {
|
||||
|
||||
test('fx gallery: every sim fx kind, projectile and status renders without errors; board art in use', async () => {
|
||||
const { page, problems } = await open('scene=fx&panel=0', 1280, 720);
|
||||
// cycle through the whole FX_KINDS table (one kind every 0.5 game s = 0.25 real s)
|
||||
// cycle through the whole FX_KINDS table (one kind every 0.5 game s = 0.25 real s), watching the FX system: shots
|
||||
// in flight (every projectile kind incl. the boomerang) and 蕾缪安's S3 locks (the gallery plays it at 'lock')
|
||||
const kinds = await page.evaluate(async () => (await import('/js/render/fx.js')).FX_KINDS).then((k) => Object.keys(k).length);
|
||||
await wait(Math.min(45000, kinds * 260 + 1500));
|
||||
const seen = await page.evaluate(async (ms) => {
|
||||
const seen = { projectiles: 0, particles: 0, locks: 0, auras: 0 };
|
||||
const t0 = performance.now();
|
||||
while (performance.now() - t0 < ms) {
|
||||
const st = window.__demo.stats();
|
||||
for (const k of Object.keys(seen)) seen[k] = Math.max(seen[k], st[k] || 0);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return seen;
|
||||
}, Math.min(45000, kinds * 260 + 1500));
|
||||
const st = await page.evaluate(() => window.__demo.stats());
|
||||
await page.screenshot({ path: path.join(OUT, 'render-fx.png') });
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
assert.equal(st.mode, 'battle');
|
||||
assert.ok(st.units >= 10, `units ${st.units}`);
|
||||
assert.ok(seen.projectiles > 0 && seen.particles > 20 && seen.auras > 0, `projectiles, particles and a skill aura drawn (${JSON.stringify(seen)})`);
|
||||
assert.ok(seen.locks >= 2, `蕾缪安's lock reticles shown (${JSON.stringify(seen)})`);
|
||||
if (existsSync(path.join(ROOT, 'public/assets/local/map/autochess/tiles.json'))) assert.equal(st.boardArt, true, 'real board art composed');
|
||||
});
|
||||
|
||||
test('stress: 120 units + FX keep the per-frame JS cost bounded', async () => {
|
||||
/** fps / JS-per-frame (frame() + Pixi render EMAs, app.js stats) / particles over `ms`, sampled every 200 ms. */
|
||||
async function frameCost(page, ms) {
|
||||
return page.evaluate(async (ms) => {
|
||||
const rows = [];
|
||||
const t0 = performance.now();
|
||||
while (performance.now() - t0 < ms) {
|
||||
const s = window.__demo.stats();
|
||||
rows.push([s.fps, s.cpuMs, s.renderMs, s.particles, s.projectiles]);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
const avg = (i) => rows.reduce((a, r) => a + r[i], 0) / rows.length;
|
||||
return { fps: avg(0), cpuMs: avg(1), renderMs: avg(2), particles: avg(3), maxParticles: Math.max(...rows.map((r) => r[3])), projectiles: avg(4) };
|
||||
}, ms);
|
||||
}
|
||||
const costLine = (c) => `fps ${c.fps.toFixed(1)} · JS ${c.cpuMs.toFixed(2)} + render ${c.renderMs.toFixed(2)} ms/frame · particles ${c.particles.toFixed(0)} (max ${c.maxParticles}) · shots ${c.projectiles.toFixed(1)}`;
|
||||
|
||||
test('stress: 120 units + FX keep the per-frame JS cost bounded', async (t) => {
|
||||
const { page, problems } = await open('scene=stress&panel=0');
|
||||
await wait(6000);
|
||||
await wait(3000);
|
||||
const cost = await frameCost(page, 3000);
|
||||
const st = await page.evaluate(() => window.__demo.stats());
|
||||
await page.screenshot({ path: path.join(OUT, 'render-stress.png') });
|
||||
await page.close();
|
||||
t.diagnostic(`stress 1920×1080: ${costLine(cost)}`);
|
||||
assert.deepEqual(problems, []);
|
||||
assert.equal(st.units, 120);
|
||||
assert.ok(st.impostor >= 2, 'crowded field switches to impostor rendering');
|
||||
assert.ok(st.cpuMs + st.renderMs < 16, `JS per frame ${st.cpuMs} + ${st.renderMs} ms`);
|
||||
assert.ok(cost.cpuMs + cost.renderMs < 16, `JS per frame ${cost.cpuMs} + ${cost.renderMs} ms`);
|
||||
});
|
||||
|
||||
test('crowded boss battle: the FX keep the per-frame JS cost bounded', async (t) => {
|
||||
const { page, problems } = await open('scene=boss-m02&t=16&panel=0');
|
||||
await wait(2500);
|
||||
// t ≈ 21–29 (2×): 蕾缪安's S3 locks (21–24) and her shells one by one (24–26), skills, crowds of shots and hits
|
||||
const cost = await frameCost(page, 4000);
|
||||
await page.close();
|
||||
t.diagnostic(`boss-m02 1920×1080: ${costLine(cost)}`);
|
||||
assert.deepEqual(problems, []);
|
||||
assert.ok(cost.fps > 20, `fps ${cost.fps}`);
|
||||
assert.ok(cost.cpuMs + cost.renderMs < 16, `JS per frame ${cost.cpuMs} + ${cost.renderMs} ms`);
|
||||
});
|
||||
|
||||
test('drag a hand piece onto a legal board tile; right-click opens detail', async () => {
|
||||
|
||||
@@ -77,6 +77,7 @@ function harness(opts = {}) {
|
||||
const c = createDragController({
|
||||
hitPiece: (x, y) => pieces.find((p) => Math.abs(p.x - x) < 40 && Math.abs(p.y - y) < 40) || null,
|
||||
pickTile: (x, y) => (x < 0 || y < 0 || x >= 2100 ? null : { row: 12 - Math.floor(y / 100), col: Math.floor(x / 100) }),
|
||||
dropPoint: opts.dropPoint,
|
||||
isOverCanvas: (cx, cy) => !(opts.domCover && cy > 900),
|
||||
canPlace: opts.canPlace,
|
||||
emit: (name, p) => events.push([name, p]),
|
||||
@@ -217,6 +218,86 @@ describe('drag controller', () => {
|
||||
assert.equal(h.events.filter((e) => e[0] === 'pieceDrop').length, 0);
|
||||
});
|
||||
|
||||
// user playtest #3 item 7: the drop target is the tile under the dragged ghost's feet (render/app.js dropPoint: the
|
||||
// pointer for a mouse, a little above the finger for touch) — the hover tile, legality, the ghost and the drop agree
|
||||
test('dropPoint: target, tileHover, legality and the drop follow the ghost\'s feet; default = the pointer', () => {
|
||||
const calls = [];
|
||||
const h = harness({
|
||||
dropPoint: (e, piece) => { calls.push([e.pointerType, piece.uid]); return e.pointerType === 'touch' ? { x: e.x, y: e.y - 100 } : null; },
|
||||
canPlace: (p, r) => r !== 11,
|
||||
});
|
||||
h.c.setEditable(true);
|
||||
// mouse: the hook returns nothing → the pointer itself
|
||||
h.ev('pointerDown', 50, 550);
|
||||
h.ev('pointerMove', 350, 250);
|
||||
let mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
|
||||
assert.deepEqual([mv.hx, mv.hy, mv.target], [350, 250, { area: 'board', row: 10, col: 3 }]);
|
||||
h.ev('pointerUp', 350, 250);
|
||||
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 10, col: 3 });
|
||||
// touch: the ghost (and the target) one row above the finger
|
||||
h.events.length = 0;
|
||||
h.ev('pointerDown', 50, 550, { pointerType: 'touch' });
|
||||
h.ev('pointerMove', 450, 350, { pointerType: 'touch' });
|
||||
mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
|
||||
assert.deepEqual([mv.x, mv.y, mv.hx, mv.hy], [450, 350, 450, 250], 'pointer and drop point both reported');
|
||||
assert.deepEqual(mv.target, { area: 'board', row: 10, col: 4 });
|
||||
assert.equal(mv.legal, true);
|
||||
assert.ok(h.events.some((e) => e[0] === 'tileHover' && e[1] && e[1].row === 10 && e[1].col === 4), 'tileHover = the target');
|
||||
h.ev('pointerMove', 450, 250, { pointerType: 'touch' });
|
||||
assert.equal(h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1].legal, false, 'row 11 under the ghost: illegal');
|
||||
h.ev('pointerUp', 450, 350, { pointerType: 'touch' });
|
||||
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 10, col: 4 });
|
||||
assert.ok(calls.every(([, uid]) => uid === 1) && calls.some(([t]) => t === 'touch'));
|
||||
});
|
||||
|
||||
// the view may skip its pixel probe on hover picks (a readback per mouse move stalls on the GPU): the controller says
|
||||
// which calls are hovers — mouse moves without a press, drag moves — and which must be exact: presses and releases
|
||||
test('hitPiece / dropPoint tell hovers from presses and releases', () => {
|
||||
const hits = [];
|
||||
const drops = [];
|
||||
const pieces = [{ uid: 1, area: 'hand', idx: 0, x: 50, y: 550 }];
|
||||
const c = createDragController({
|
||||
hitPiece: (x, y, hover) => { hits.push(hover === true); return pieces.find((p) => Math.abs(p.x - x) < 40 && Math.abs(p.y - y) < 40) || null; },
|
||||
pickTile: (x, y) => ({ row: 12 - Math.floor(y / 100), col: Math.floor(x / 100) }),
|
||||
dropPoint: (e, piece, final) => { drops.push(final === true); return null; },
|
||||
emit: () => {},
|
||||
});
|
||||
const ev = (type, x, y) => c[type]({ pointerId: 1, pointerType: 'mouse', button: 0, x, y, clientX: x, clientY: y });
|
||||
c.setEditable(true);
|
||||
ev('pointerMove', 50, 550);
|
||||
assert.deepEqual(hits, [true], 'a mouse move without a press is a hover');
|
||||
ev('pointerDown', 50, 550);
|
||||
assert.deepEqual(hits, [true, false], 'a press probes');
|
||||
ev('pointerMove', 350, 250);
|
||||
ev('pointerMove', 360, 250);
|
||||
ev('pointerUp', 360, 250);
|
||||
assert.ok(drops.length >= 3);
|
||||
assert.ok(drops.slice(0, -1).every((f) => f === false), 'drag moves are hovers');
|
||||
assert.equal(drops[drops.length - 1], true, 'the release is final');
|
||||
});
|
||||
|
||||
// review of item 7: on a phone the bench is the lowest canvas row and the shop bar (DOM) sits right under it — a
|
||||
// touch ghost lifted onto the bench has the finger on the shop bar. DOM cover is judged at the drop point.
|
||||
test('dropPoint: DOM cover is tested at the ghost\'s feet, not at the finger (touch onto the bench above the shop bar)', () => {
|
||||
const h = harness({ domCover: true, dropPoint: (e) => (e.pointerType === 'touch' ? { x: e.x, y: e.y - 400 } : null) });
|
||||
h.c.setEditable(true);
|
||||
// board unit (10,4) dragged by touch; the finger ends on the covered strip (y > 900), its ghost on bench slot 2
|
||||
h.ev('pointerDown', 450, 250, { pointerType: 'touch' });
|
||||
h.ev('pointerMove', 250, 950, { pointerType: 'touch' });
|
||||
const mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
|
||||
assert.deepEqual(mv.target, { area: 'hand', idx: 2 });
|
||||
assert.equal(mv.legal, true);
|
||||
h.ev('pointerUp', 250, 950, { pointerType: 'touch' });
|
||||
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'hand', idx: 2 });
|
||||
// the ghost itself under the DOM: outside (the piece goes back), as for a mouse released on the DOM
|
||||
h.events.length = 0;
|
||||
h.ev('pointerDown', 450, 250, { pointerType: 'touch' });
|
||||
h.ev('pointerMove', 250, 1350, { pointerType: 'touch' });
|
||||
assert.equal(h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1].target, null);
|
||||
h.ev('pointerUp', 250, 1350, { pointerType: 'touch' });
|
||||
assert.equal(h.events.find((e) => e[0] === 'pieceDrop')[1].target.area, 'outside');
|
||||
});
|
||||
|
||||
test('a throwing listener or hit-test never breaks the controller', () => {
|
||||
const c = createDragController({
|
||||
hitPiece: () => { throw new Error('boom'); },
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
// test/render/fxproj.test.js — battle FX of render/fx.js against a headless fake PIXI (test/render/fakepixi.js):
|
||||
// * projectile speeds follow the sim (style.js PROJ mirrors server/sim/constants.js; the live table wins)
|
||||
// * the FX atlas layout (render/textures.js fxFrames): no two frames share a pixel (the light pillar used to run into
|
||||
// the status-icon row), frame sizes other modules scale by are kept
|
||||
// * pooled particles: a freed sprite is a zero-size transparent quad (Pixi's ParticleRenderer ignores `visible`) and
|
||||
// its record is reused — a steady stream of shots stops allocating sprites
|
||||
// * shots: flight time = distance / sim speed / clock rate, homing on the moving target, one arrival burst, then gone
|
||||
// * 回环射手 boomerang: out at the sim's speed, back to the thrower's CURRENT position at the return speed, caught there;
|
||||
// dropped when the thrower is gone
|
||||
// * 蕾缪安 S3: a lock reticle follows its enemy until the 'bombard' of the shell fired at it; 'bombardShell' flies for
|
||||
// `t` game s — however long the S3 runs (extra ammo, stuns); blasts named after the shooter happen at their (x, y);
|
||||
// the S2 aim lock ends with its 'crit'; a lock of a shooter gone quiet times out
|
||||
// * fx anchoring (_where), quality / load gating of cosmetic particles, melee slash direction, skill burst + aura
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as SIM from '../../server/sim/constants.js';
|
||||
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
import { PROJ, HIT_TINT } from '../../public/js/render/style.js';
|
||||
|
||||
let fake, FX, T;
|
||||
before(async () => {
|
||||
fake = installFakePixi();
|
||||
FX = await import('../../public/js/render/fx.js');
|
||||
T = await import('../../public/js/render/textures.js');
|
||||
});
|
||||
after(() => { FX?.setSimProjectileSpeeds(null); fake.restore(); });
|
||||
|
||||
const DT = 1 / 120;
|
||||
const cam = presetCamera('normal', { width: 1600, height: 900 });
|
||||
|
||||
/** A unit view as the FX system reads it. */
|
||||
function unit(id, x, y, o = {}) {
|
||||
return { id, x, y, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, isEnemy: false, maxHp: 3000, info: { defId: 'char_x' }, onHit() {}, ...o };
|
||||
}
|
||||
|
||||
/** A FxSystem on fake layers; `views` resolves fx ids; ts = battle clock rate. */
|
||||
function makeFx({ quality = 'high', ts = 2, load = 0, views = [] } = {}) {
|
||||
const P = fake.P;
|
||||
const ctx = fakeViewCtx(P);
|
||||
const map = new Map(views.map((v) => [v.id, v]));
|
||||
const fx = new FX.FxSystem({
|
||||
P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality, damageNumbers: true },
|
||||
timeScale: () => ts, loadLevel: () => load, subProfOf: () => null, view: (id) => map.get(id) || null,
|
||||
screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
|
||||
});
|
||||
return { fx, map };
|
||||
}
|
||||
|
||||
const run = (fx, seconds, dt = DT) => { for (let t = 0; t < seconds - 1e-9; t += dt) fx.update(dt); };
|
||||
const texName = (fx, tex) => Object.keys(fx.tex).find((k) => fx.tex[k] === tex);
|
||||
const liveTex = (fx, name) => fx.parts.filter((p) => p.sp.texture === fx.tex[name]);
|
||||
|
||||
describe('projectile speeds follow the sim', () => {
|
||||
test('style.js PROJ mirrors PROJECTILE_SPEEDS for every sim kind (and the boomerang return speed)', () => {
|
||||
for (const [kind, v] of Object.entries(SIM.PROJECTILE_SPEEDS)) {
|
||||
assert.ok(PROJ[kind], `render/style.js PROJ has no visual for the sim's projectile kind '${kind}'`);
|
||||
assert.equal(PROJ[kind].speed, v, `PROJ.${kind}.speed ${PROJ[kind].speed} ≠ sim PROJECTILE_SPEEDS.${kind} ${v}: update the mirror`);
|
||||
}
|
||||
if (SIM.BOOMERANG_RETURN_SPEED !== undefined) assert.equal(PROJ.boomerang.back, SIM.BOOMERANG_RETURN_SPEED, 'PROJ.boomerang.back mirrors BOOMERANG_RETURN_SPEED');
|
||||
});
|
||||
|
||||
test('projSpeed: the live sim table first, then the style.js copy; boomerangReturn = the return speed', () => {
|
||||
FX.setSimProjectileSpeeds(null);
|
||||
assert.equal(FX.projSpeed('orb'), PROJ.orb.speed);
|
||||
assert.equal(FX.projSpeed('boomerangReturn'), PROJ.boomerang.back);
|
||||
assert.equal(FX.projSpeed('nope'), 12);
|
||||
FX.setSimProjectileSpeeds({ ...SIM.PROJECTILE_SPEEDS, orb: 99, boomerang: 20 }, 5);
|
||||
assert.equal(FX.projSpeed('orb'), 99);
|
||||
assert.equal(FX.projSpeed('boomerang'), 20);
|
||||
assert.equal(FX.projSpeed('boomerangReturn'), 5);
|
||||
FX.setSimProjectileSpeeds({ arrow: 1 }); // an older sim table: the rest from the copy
|
||||
assert.equal(FX.projSpeed('arrow'), 1);
|
||||
assert.equal(FX.projSpeed('bolt'), PROJ.bolt.speed);
|
||||
assert.equal(FX.projSpeed('boomerangReturn'), PROJ.boomerang.back);
|
||||
FX.setSimProjectileSpeeds(SIM.PROJECTILE_SPEEDS, SIM.BOOMERANG_RETURN_SPEED);
|
||||
for (const [k, v] of Object.entries(SIM.PROJECTILE_SPEEDS)) assert.equal(FX.projSpeed(k), v);
|
||||
FX.setSimProjectileSpeeds(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FX atlas layout', () => {
|
||||
test('frames never overlap and stay inside the atlas; shared frame sizes are kept', () => {
|
||||
const { size: [W, H], frames } = T.fxFrames();
|
||||
const list = Object.entries(frames);
|
||||
for (const [name, [x, y, w, h]] of list) assert.ok(x >= 0 && y >= 0 && x + w <= W && y + h <= H, `${name} inside ${W}×${H}`);
|
||||
const hits = [];
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
for (let j = i + 1; j < list.length; j++) {
|
||||
const [a, [ax, ay, aw, ah]] = list[i], [b, [bx, by, bw, bh]] = list[j];
|
||||
if (ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah) hits.push(`${a} × ${b}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(hits, [], 'overlapping FX atlas frames');
|
||||
// sizes other modules scale sprites by (units.js glow / chevron, tiles.js soft / ring / dot / smoke, app.js coin)
|
||||
const size = (n) => frames[n].slice(2).join('×');
|
||||
for (const [n, s] of [['glow', '128×128'], ['soft', '128×128'], ['ring', '128×128'], ['hex', '128×128'], ['smoke', '128×128'], ['dot', '32×32'], ['chevron', '64×64'], ['coin', '48×48'], ['pillar', '64×256'], ['streak', '128×32'], ['slash', '128×64']]) assert.equal(size(n), s, n);
|
||||
for (const k of T.STATUS_KEYS) assert.equal(size('st_' + k), '32×32', k);
|
||||
for (const n of ['tracer', 'shock', 'reticle', 'flare', 'boomerang', 'muzzle']) assert.ok(frames[n], `new frame ${n}`);
|
||||
});
|
||||
|
||||
test('fxAtlas builds one texture per frame on one base texture', () => {
|
||||
const a = T.fxAtlas();
|
||||
const { frames } = T.fxFrames();
|
||||
assert.deepEqual(Object.keys(a.tex).sort(), Object.keys(frames).sort());
|
||||
for (const t of Object.values(a.tex)) assert.equal(t.baseTexture, a.base);
|
||||
});
|
||||
});
|
||||
|
||||
describe('particles', () => {
|
||||
test('a freed particle is a zero-size transparent quad and its record is reused', () => {
|
||||
const { fx } = makeFx();
|
||||
const p = fx.particle('glow', 10, 10, { life: 0.05, s0: 1, a0: 1, a1: 1 });
|
||||
const sp = p.sp;
|
||||
run(fx, 0.1);
|
||||
assert.equal(fx.parts.length, 0);
|
||||
assert.equal(sp.alpha, 0, 'alpha 0 (the particle renderer draws invisible children too)');
|
||||
assert.deepEqual([sp.scale.x, sp.scale.y], [0, 0], 'zero size: no fill cost');
|
||||
const n = fx.addPc.children.length;
|
||||
const q = fx.particle('spark', 5, 5, { life: 0.3 });
|
||||
assert.equal(q, p, 'the pooled record comes back');
|
||||
assert.equal(fx.addPc.children.length, n, 'no new sprite');
|
||||
assert.equal(q.sp.texture, fx.tex.spark);
|
||||
assert.ok(q.sp.alpha > 0 && q.sp.scale.x > 0);
|
||||
});
|
||||
|
||||
test('a steady stream of shots and hits only ever makes as many sprites as were alive at once', () => {
|
||||
const a = unit(1, 3, 10), b = unit(2, 8, 11, { isEnemy: true });
|
||||
const { fx } = makeFx({ views: [a, b] });
|
||||
const kinds = ['arrow', 'bolt', 'orb', 'bomb', 'lob', 'drone', 'enemy', 'boomerang', 'none'];
|
||||
let peakAdd = 0, peakNorm = 0, peakProj = 0;
|
||||
const sample = () => {
|
||||
let add = 0;
|
||||
for (const p of fx.parts) if (p.add) add++;
|
||||
peakAdd = Math.max(peakAdd, add);
|
||||
peakNorm = Math.max(peakNorm, fx.parts.length - add);
|
||||
peakProj = Math.max(peakProj, fx.projs.length);
|
||||
};
|
||||
for (let f = 0; f < 60 * 12; f++) {
|
||||
if (f % 6 === 0) {
|
||||
const i = f / 6;
|
||||
fx.attack(a, b, kinds[i % kinds.length]);
|
||||
fx.damage(b, 120, i % 2 ? 'arts' : 'phys', i % 9 === 8 ? a : null);
|
||||
}
|
||||
sample(); // (particles are freed only at the start of an update)
|
||||
fx.update(1 / 60);
|
||||
sample();
|
||||
}
|
||||
assert.ok(peakProj >= 3 && peakAdd > 20, `busy enough (${peakProj} shots, ${peakAdd} particles at once)`);
|
||||
assert.equal(fx.addPc.children.length, peakAdd, 'additive particle sprites = the peak alive at once');
|
||||
assert.equal(fx.normPc.children.length, peakNorm, 'normal-blend particle sprites = the peak alive at once');
|
||||
assert.equal(fx.projLayer.children.length, 3 * peakProj, 'three sprites per projectile record');
|
||||
assert.equal(fx.shadowLayer.children.length, peakProj);
|
||||
assert.ok(fx.parts.length <= fx.maxParticles);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shots', () => {
|
||||
test('flight time = distance / sim speed / clock rate; homing on the moving target; one arrival burst', () => {
|
||||
FX.setSimProjectileSpeeds(null);
|
||||
const a = unit(1, 3, 10), b = unit(2, 9, 10, { isEnemy: true });
|
||||
const { fx } = makeFx({ ts: 2, views: [a, b] });
|
||||
fx.attack(a, b, 'arrow');
|
||||
assert.equal(fx.projs.length, 1);
|
||||
const pr = fx.projs[0];
|
||||
const dist = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
assert.ok(Math.abs(pr.dur - dist / PROJ.arrow.speed / 2) < 1e-9, `dur ${pr.dur}`);
|
||||
assert.equal(pr.trail.texture, fx.tex.tracer);
|
||||
assert.equal(liveTex(fx, 'muzzle').length, 1, 'muzzle flash at the shooter');
|
||||
b.x = 9.5; // the target moves: the shot follows it
|
||||
let t = 0;
|
||||
while (!pr.hit && t < 1) { fx.update(DT); t += DT; }
|
||||
assert.ok(Math.abs(t - pr.dur) <= DT + 1e-9, `arrived after ${t} (dur ${pr.dur})`);
|
||||
assert.equal(pr.tx, 9.5);
|
||||
const flares = liveTex(fx, 'flare').length;
|
||||
assert.ok(flares >= 1, 'arrival flare');
|
||||
run(fx, 0.2);
|
||||
assert.equal(fx.projs.length, 0, 'released after its short fade');
|
||||
assert.equal(pr.trail.visible || pr.halo.visible || pr.core.visible, false);
|
||||
});
|
||||
|
||||
test('every projectile kind flies and lands; shells cast a shadow and explode', () => {
|
||||
const a = unit(1, 3, 10), b = unit(2, 7, 11, { isEnemy: true });
|
||||
const { fx } = makeFx({ views: [a, b] });
|
||||
for (const kind of Object.keys(PROJ)) {
|
||||
fx.clear();
|
||||
fx.attack(a, b, kind);
|
||||
assert.equal(fx.projs.length, 1, kind);
|
||||
const pr = fx.projs[0];
|
||||
fx.update(DT);
|
||||
assert.equal(pr.shadow.visible, kind === 'bomb' || kind === 'lob' || kind === 'boomerang', `${kind} shadow`);
|
||||
if (kind === 'bomb') {
|
||||
run(fx, pr.dur);
|
||||
assert.ok(fx.rings.some((r) => r.sp.texture === fx.tex.shock), 'bomb explosion shockwave');
|
||||
}
|
||||
run(fx, 3);
|
||||
assert.equal(fx.projs.length, 0, `${kind} done`);
|
||||
}
|
||||
});
|
||||
|
||||
test("an arts blastcaster's bomb explodes purple", () => {
|
||||
const a = unit(1, 3, 10, { info: { defId: 'char_blast' } }), b = unit(2, 6, 10, { isEnemy: true });
|
||||
const { fx } = makeFx({ views: [a, b] });
|
||||
fx.ctx.subProfOf = (id) => (id === 'char_blast' ? 'blastcaster' : null);
|
||||
fx.attack(a, b, 'bomb');
|
||||
assert.equal(fx.projs[0].glow, 0xb36bff);
|
||||
});
|
||||
|
||||
test('chain / beam kinds are beams, melee shots are no projectile', () => {
|
||||
const a = unit(1, 3, 10), b = unit(2, 5, 10, { isEnemy: true });
|
||||
const { fx } = makeFx({ views: [a, b] });
|
||||
fx.attack(a, b, 'chain');
|
||||
fx.attack(a, b, 'beam');
|
||||
fx.attack(a, b, 'none');
|
||||
assert.equal(fx.projs.length, 0);
|
||||
assert.equal(fx.beamList.length, 2);
|
||||
assert.equal(fx._slashAt, a.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('回环射手 boomerang', () => {
|
||||
test('out at the boomerang speed, back to where the thrower is now at the return speed, caught there', () => {
|
||||
FX.setSimProjectileSpeeds({ ...SIM.PROJECTILE_SPEEDS, boomerang: 15 }, 3.75);
|
||||
const thrower = unit(1, 2, 10), target = unit(2, 8, 10, { isEnemy: true });
|
||||
const { fx } = makeFx({ ts: 2, views: [thrower, target] });
|
||||
fx.attack(thrower, target, 'boomerang');
|
||||
const pr = fx.projs[0];
|
||||
const out = Math.hypot(target.x - pr.bx, target.y - pr.by);
|
||||
let t = 0;
|
||||
while (pr.phase === 0 && t < 3) { fx.update(DT); t += DT; }
|
||||
assert.ok(Math.abs(t - out / (15 * 2)) <= 2 * DT, `out leg ${t.toFixed(3)} s ≈ ${(out / 30).toFixed(3)} s`);
|
||||
thrower.x = 3; // the thrower moved meanwhile: it flies to where it is now
|
||||
const back = Math.hypot(thrower.x - target.x, thrower.y - target.y);
|
||||
let t2 = 0;
|
||||
while (fx.projs.includes(pr) && t2 < 5) { fx.update(DT); t2 += DT; }
|
||||
assert.ok(Math.abs(t2 - back / (3.75 * 2)) <= 2 * DT, `back leg ${t2.toFixed(3)} s ≈ ${(back / 7.5).toFixed(3)} s`);
|
||||
assert.ok(Math.abs(pr.bx - 3) < 1e-9, 'caught at the thrower');
|
||||
FX.setSimProjectileSpeeds(null);
|
||||
});
|
||||
|
||||
test('it spins, and is gone at once when the thrower leaves the field', () => {
|
||||
const thrower = unit(1, 2, 10), target = unit(2, 8, 10, { isEnemy: true });
|
||||
const { fx } = makeFx({ views: [thrower, target] });
|
||||
fx.attack(thrower, target, 'boomerang');
|
||||
const pr = fx.projs[0];
|
||||
fx.update(DT);
|
||||
const r0 = pr.core.rotation;
|
||||
fx.update(DT);
|
||||
assert.notEqual(pr.core.rotation, r0, 'spinning');
|
||||
assert.equal(pr.core.texture, fx.tex.boomerang);
|
||||
while (pr.phase === 0) fx.update(DT);
|
||||
fx.update(DT);
|
||||
thrower.destroyed = true;
|
||||
fx.update(DT);
|
||||
assert.equal(fx.projs.length, 0, 'dropped on the way back');
|
||||
// a thrower already knocked out at the hit gets nothing back
|
||||
const t2 = unit(3, 2, 11);
|
||||
fx.attack(t2, target, 'boomerang');
|
||||
t2.alive = false;
|
||||
run(fx, 1);
|
||||
assert.equal(fx.projs.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('蕾缪安 S3: lock, bombardShell, bombard', () => {
|
||||
const setup = () => {
|
||||
const lem = unit(1, 2, 10), e1 = unit(21, 8, 10, { isEnemy: true }), e2 = unit(23, 9, 11.5, { isEnemy: true });
|
||||
return { lem, e1, e2, ...makeFx({ ts: 2, views: [lem, e1, e2] }) };
|
||||
};
|
||||
|
||||
test('a lock follows its enemy until the bombard of the shell fired at it', () => {
|
||||
const { fx, e1 } = setup();
|
||||
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
|
||||
fx.simFx('lock', 9, 11.5, { id: 23, src: 1 });
|
||||
assert.equal(fx.locks.length, 2);
|
||||
const [L1, L2] = fx.locks;
|
||||
e1.x = 8.4;
|
||||
fx.update(DT);
|
||||
assert.equal(L1.x, 8.4, 'the reticle follows the locked enemy');
|
||||
assert.equal(L1.ring.texture, fx.tex.reticle);
|
||||
// the shell fired at e1's spot takes that lock; it flies t game s = t / 2 real s
|
||||
fx.simFx('bombardShell', 8.5, 10.1, { id: 1, r: 1.5, t: 0.3, i: 0 });
|
||||
const shell = fx.projs.find((p) => p.kind === 'bombardShell');
|
||||
assert.ok(shell, 'a shell in the air');
|
||||
assert.ok(Math.abs(shell.dur - 0.15) < 1e-9);
|
||||
assert.equal(L1.shell, true);
|
||||
assert.equal(L2.shell, false);
|
||||
assert.ok(fx.rings.filter((r) => r.x === 8.5 && r.y === 10.1).length >= 2, 'warning rings on the spot');
|
||||
run(fx, 0.16);
|
||||
assert.equal(fx.projs.filter((p) => p.kind === 'bombardShell').length, 0, 'landed');
|
||||
assert.ok(L1.out < 0, 'the lock waits for the impact itself');
|
||||
fx.simFx('bombard', 8.5, 10.1, { id: 1, r: 1.5 });
|
||||
assert.ok(L1.out >= 0, 'released by its bombard');
|
||||
assert.ok(L2.out < 0, 'the other lock stays');
|
||||
run(fx, 0.3);
|
||||
assert.deepEqual(fx.locks, [L2]);
|
||||
});
|
||||
|
||||
test("bombard happens at its (x, y), never on the shooter named in `id`; it is a heavy blast", () => {
|
||||
const { fx, lem } = setup();
|
||||
fx.simFx('bombard', 8, 10, { id: 1, r: 1.5 });
|
||||
assert.ok(fx.rings.length >= 2);
|
||||
for (const r of fx.rings) assert.deepEqual([r.x, r.y], [8, 10]);
|
||||
assert.ok(!fx.rings.some((r) => r.x === lem.x), 'nothing on 蕾缪安');
|
||||
assert.ok(liveTex(fx, 'shard').length > 0, 'debris (heavy)');
|
||||
});
|
||||
|
||||
test('a bombard with no shell (older sims) releases the nearest lock; locks time out; the S2 snipe ends its aim lock', () => {
|
||||
const { fx } = setup();
|
||||
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
|
||||
fx.simFx('lock', 9, 11.5, { id: 23, src: 1 });
|
||||
fx.simFx('bombard', 9, 11.5, { id: 1, r: 1.5 });
|
||||
assert.deepEqual(fx.locks.map((L) => L.out >= 0), [false, true]);
|
||||
run(fx, 5 / 2 + 0.3); // LOCK_T game s at 2×, plus the fade
|
||||
assert.equal(fx.locks.length, 0, 'timed out');
|
||||
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
|
||||
fx.simFx('crit', 8, 10, { id: 23, src: 1 }); // another enemy: stays
|
||||
assert.ok(fx.locks[0].out < 0);
|
||||
fx.simFx('crit', 8, 10, { id: 21, src: 1 });
|
||||
assert.ok(fx.locks[0].out >= 0, 'the snipe on the locked enemy ends the lock');
|
||||
});
|
||||
|
||||
test('the shooter knocked out: locks still waiting for a shell go, the one whose shell is in the air stays', () => {
|
||||
const { fx, lem } = setup();
|
||||
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
|
||||
fx.simFx('lock', 9, 11.5, { id: 23, src: 1 });
|
||||
fx.simFx('bombardShell', 8, 10, { id: 1, r: 1.5, t: 0.3 });
|
||||
const [L1, L2] = fx.locks;
|
||||
lem.alive = false;
|
||||
fx.update(DT);
|
||||
assert.ok(L1.out < 0, 'its shell still lands');
|
||||
assert.ok(L2.out >= 0, 'no shell will come');
|
||||
fx.simFx('bombard', 8, 10, { id: 1, r: 1.5 });
|
||||
assert.ok(L1.out >= 0);
|
||||
});
|
||||
|
||||
test('a long S3 (extra ammo, a stun mid-lock): every reticle stays until the bombard of its own shell', () => {
|
||||
// 拉特兰 ammo grants / reloads give S3 more than its 5 shots: 20 locks 0.5 game s apart (a 3 game s stun in the
|
||||
// middle), then one shell every 0.3 game s landing 0.3 game s later — the first lock waits ≈ 16 game s for its shell
|
||||
const enemies = Array.from({ length: 4 }, (_, k) => unit(30 + k, 7 + k, 9 + (k % 2), { isEnemy: true }));
|
||||
const lem = unit(1, 2, 10);
|
||||
const { fx } = makeFx({ ts: 2, views: [lem, ...enemies] });
|
||||
const g = (s) => s / 2; // game s → real s at 2×
|
||||
const locks = [];
|
||||
for (let k = 0; k < 20; k++) {
|
||||
const e = enemies[k % 4];
|
||||
fx.simFx('lock', e.x, e.y, { id: e.id, src: 1 });
|
||||
locks.push(fx.locks[fx.locks.length - 1]);
|
||||
run(fx, g(k === 9 ? 3.5 : 0.5));
|
||||
}
|
||||
assert.equal(new Set(locks).size, 20, 'no reticle recycled for another lock');
|
||||
for (let k = 0; k < 20; k++) {
|
||||
const e = enemies[k % 4];
|
||||
fx.simFx('bombardShell', e.x + 0.1, e.y, { id: 1, r: 1.5, t: 0.3, i: k });
|
||||
run(fx, g(0.3));
|
||||
const live = fx.locks.filter((L) => L.out < 0).length;
|
||||
assert.equal(live, 20 - k, `shell ${k}: every lock whose bombard is still to come is up`);
|
||||
fx.simFx('bombard', e.x + 0.1, e.y, { id: 1, r: 1.5 });
|
||||
}
|
||||
run(fx, 0.3);
|
||||
assert.equal(fx.locks.length, 0);
|
||||
});
|
||||
|
||||
test('a lock sticks to the enemy named in `id` even when its view is a little off the event spot', () => {
|
||||
const { fx, e1 } = setup();
|
||||
fx.simFx('lock', 9, 10, { id: 21, src: 1 }); // e1 drawn at (8, 10): a fast walker between snapshots
|
||||
assert.equal(fx.locks[0].view, e1);
|
||||
e1.x = 7.5;
|
||||
fx.update(DT);
|
||||
assert.equal(fx.locks[0].x, 7.5);
|
||||
});
|
||||
|
||||
test('a lock whose shooter goes quiet still times out (S2 aim at an enemy that died)', () => {
|
||||
const { fx } = setup();
|
||||
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
|
||||
run(fx, 5 / 2 - 0.05);
|
||||
assert.equal(fx.locks[0].out, -1);
|
||||
run(fx, 0.35);
|
||||
assert.equal(fx.locks.length, 0);
|
||||
});
|
||||
|
||||
test('a long shell flight climbs out of the shooter first; a short one only falls', () => {
|
||||
const { fx, lem } = setup();
|
||||
fx.simFx('bombardShell', 8, 10, { id: 1, r: 1.5, t: 1.2 });
|
||||
fx.simFx('bombardShell', 9, 11.5, { id: 1, r: 1.5, t: 0.3 });
|
||||
const [long, short] = fx.projs;
|
||||
assert.ok(long.rise > 0 && short.rise === 0);
|
||||
lem.x = 2.5;
|
||||
fx.update(DT);
|
||||
assert.equal(long.x0, 2.5, 'rising from where she stands');
|
||||
run(fx, 1);
|
||||
assert.equal(fx.projs.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fx placement, quality, melee, skill', () => {
|
||||
test('_where: on the unit when the fx happens there, else at the event position', () => {
|
||||
const caster = unit(1, 2, 10);
|
||||
const { fx } = makeFx({ views: [caster] });
|
||||
assert.equal(fx._where(2.2, 10.1, { id: 1 }).v, caster, 'on the unit');
|
||||
assert.equal(fx._where(NaN, NaN, { id: 1 }).v, caster, 'no position: the unit');
|
||||
const far = fx._where(5, 10, { id: 1 });
|
||||
assert.deepEqual([far.x, far.y, far.v], [5, 10, null], 'an aoe ahead of its caster');
|
||||
fx.simFx('aoe', 6, 11, { id: 1, r: 1 });
|
||||
assert.ok(fx.rings.length && fx.rings.every((r) => r.x === 6 && r.y === 11), 'drawn where it happens');
|
||||
});
|
||||
|
||||
test("quality 'low' and a heavy load skip the cosmetic particles, not the shots", () => {
|
||||
const a = unit(1, 3, 10), b = unit(2, 8, 10, { isEnemy: true });
|
||||
for (const [opts, cosmetic] of [[{ quality: 'high' }, true], [{ quality: 'low' }, false], [{ quality: 'high', load: 2 }, false]]) {
|
||||
const { fx } = makeFx({ ...opts, views: [a, b] });
|
||||
fx.attack(a, b, 'bolt');
|
||||
fx.attack(a, b, 'arrow');
|
||||
run(fx, 0.05);
|
||||
assert.equal(fx.projs.length, 2);
|
||||
const extras = liveTex(fx, 'dot').length + liveTex(fx, 'muzzle').length;
|
||||
assert.equal(extras > 0, cosmetic, JSON.stringify(opts));
|
||||
}
|
||||
});
|
||||
|
||||
test('a melee blow draws a slash in the hit colour, swept along the blow', () => {
|
||||
const a = unit(1, 4, 10), b = unit(2, 5, 10, { isEnemy: true });
|
||||
const { fx } = makeFx({ views: [a, b] });
|
||||
fx.attack(a, b, 'none');
|
||||
fx.damage(b, 300, 'arts', a);
|
||||
const slashes = liveTex(fx, 'slash');
|
||||
assert.equal(slashes.length, 2);
|
||||
assert.equal(slashes[0].sp.tint, HIT_TINT.arts);
|
||||
const pa = cam.project(a.x, a.y, 0.54, {}), pb = cam.project(b.x, b.y, 0.54, {});
|
||||
const blow = Math.atan2(pb.y - pa.y, pb.x - pa.x) + Math.PI / 2;
|
||||
const d = Math.atan2(Math.sin(slashes[0].sp.rotation - blow), Math.cos(slashes[0].sp.rotation - blow));
|
||||
assert.ok(Math.abs(d) <= 0.46, `rotation ${slashes[0].sp.rotation} vs blow ${blow}`);
|
||||
assert.equal(fx._slashAt, null);
|
||||
fx.damage(b, 300, 'phys', a); // a second hit without a new blow: no slash
|
||||
assert.equal(liveTex(fx, 'slash').length, 2);
|
||||
});
|
||||
|
||||
test('skill activation bursts (pillars, flare, shockwave + hex) and keeps an aura until it ends', () => {
|
||||
const v = unit(1, 4, 10);
|
||||
const { fx } = makeFx({ views: [v] });
|
||||
fx.skill(v, true);
|
||||
assert.equal(liveTex(fx, 'pillar').length, 2);
|
||||
assert.equal(liveTex(fx, 'flare').length, 1);
|
||||
assert.deepEqual(fx.rings.map((r) => texName(fx, r.sp.texture)).sort(), ['hex', 'shock']);
|
||||
assert.equal(fx.auras.size, 1);
|
||||
run(fx, 1);
|
||||
const a = fx.auras.get(1);
|
||||
assert.ok(a.sp.alpha > 0.9 && a.hex.alpha > 0.5, 'aura shown');
|
||||
fx.skill(v, false);
|
||||
run(fx, 0.6);
|
||||
assert.equal(fx.auras.size, 0, 'faded out');
|
||||
});
|
||||
|
||||
test('clear() drops shots, locks, auras and particles', () => {
|
||||
const a = unit(1, 3, 10), b = unit(2, 8, 10, { isEnemy: true });
|
||||
const { fx } = makeFx({ views: [a, b] });
|
||||
fx.attack(a, b, 'boomerang');
|
||||
fx.simFx('lock', 8, 10, { id: 2, src: 1 });
|
||||
fx.simFx('bombardShell', 8, 10, { id: 1, r: 1.5, t: 0.3 });
|
||||
fx.skill(a, true);
|
||||
fx.update(DT);
|
||||
fx.clear();
|
||||
assert.deepEqual([fx.projs.length, fx.locks.length, fx.auras.size, fx.parts.length], [0, 0, 0, 0]);
|
||||
fx.update(DT);
|
||||
fx.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
// test/render/models.browser.test.js — user playtest #3 items 1 and 7 in headless Chrome, through the in-match mock
|
||||
// (public/dev/game-mock.html: the real game screen, a mock server behind net.request):
|
||||
// 1 operator models never go missing: after a battle that outlasts the Spine LRU grace, the battle → prep switch
|
||||
// (every battle view destroyed, the prep views built in the same task) used to unload the bench models and hand
|
||||
// the doomed skeletons straight back (PIXI.Assets serves an unloading asset from its loader cache): invisible
|
||||
// chibis. Also a burst of rapid drags / swaps / direction-wheel previews and commits / cancels / re-drags. After
|
||||
// each: every prep piece has a view, visible, on its tile, drawing a live Spine model (textures alive) — and the
|
||||
// canvas pixels over it change when it is hidden (it really draws something).
|
||||
// 7 picking: with an operator directly behind another (same column), presses on its head / torso / legs, on its
|
||||
// feet where they show and on its tile beside the front one's head select IT; the front one's face selects the
|
||||
// front one — in prep and in battle; equipment dropped on the body of the one behind equips it; a dragged unit's
|
||||
// ghost stands on the pointer and the drop (direction wheel) is that tile. Every press on a drawn unit pixel (the
|
||||
// whole prep board and a battle) picks the front-most unit drawn there (render/pick.js pixel probe).
|
||||
//
|
||||
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/models.browser.test.js
|
||||
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/models-*.png.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const OUT = path.join(ROOT, 'test/e2e/out');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
|
||||
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Page-side helpers (installed once per page): the prep pieces, their views, the invariants of item 1. */
|
||||
function installHelpers() {
|
||||
const G = { HAND_ROW: 7, TEMP_ROW: 8, TEMP_C0: 4 };
|
||||
const raw = () => globalThis.__SP_VIEW__.raw;
|
||||
const priv = () => globalThis.__MOCK__.store.get().match.private;
|
||||
const texOk = (t) => !!t && !t.destroyed && !!t.baseTexture && !t.baseTexture.destroyed && t.baseTexture.valid !== false;
|
||||
window.__t = {
|
||||
pieces() {
|
||||
const p = priv(), out = [];
|
||||
(p.board || []).forEach((x) => x && out.push({ uid: x.uid, kind: x.kind, row: x.row, col: x.col }));
|
||||
(p.hand || []).forEach((x, i) => x && out.push({ uid: x.uid, kind: x.kind, row: G.HAND_ROW, col: i }));
|
||||
(p.temp || []).forEach((x, i) => x && out.push({ uid: x.uid, kind: x.kind, row: G.TEMP_ROW, col: G.TEMP_C0 + i }));
|
||||
return out;
|
||||
},
|
||||
/** Client point `up` tiles above the feet of a view (+ `dx` tiles aside); views keyed 'p:uid' (prep) or unit id. */
|
||||
at(key, up, dx = 0) {
|
||||
const R = raw(), v = R.debug.views.get(key), cv = R.debug.app.view.getBoundingClientRect();
|
||||
return { x: cv.left + v.screen.x + dx * v.screen.s, y: cv.top + v.screen.y - up * v.screen.s };
|
||||
},
|
||||
/** Item 1 invariants; returns the problems found. */
|
||||
check() {
|
||||
const R = raw(), problems = [];
|
||||
for (const p of this.pieces()) {
|
||||
const v = R.debug.views.get('p:' + p.uid), tag = `${p.uid}@${p.row},${p.col}`;
|
||||
if (!v || v.destroyed) { problems.push(`${tag}: no view`); continue; }
|
||||
if (!v.root.visible || !(v.root.alpha > 0.9)) problems.push(`${tag}: hidden (${v.root.visible}, ${v.root.alpha})`);
|
||||
if (Math.abs(v.x - p.col) > 0.05 || Math.abs(v.y - p.row) > 0.05) problems.push(`${tag}: parked at ${v.x.toFixed(2)},${v.y.toFixed(2)}`);
|
||||
if (p.kind === 'item') continue;
|
||||
if (!v.actor || !v.spineReady) { problems.push(`${tag}: no Spine model`); continue; }
|
||||
let dead = 0, n = 0;
|
||||
for (const slot of v.actor.spine.skeleton.slots) { const s = slot.currentSprite || slot.currentMesh; if (!s || !s.texture) continue; n++; if (!texOk(s.texture)) dead++; }
|
||||
if (dead) problems.push(`${tag}: ${dead}/${n} textures destroyed`);
|
||||
if (v.imp) { if (!texOk(v.imp.sprite.texture)) problems.push(`${tag}: impostor texture destroyed`); }
|
||||
else if (v.actor.spine.parent !== v.body || !v.actor.spine.visible || !(v.actor.spine.alpha > 0.9)) problems.push(`${tag}: model not shown`);
|
||||
}
|
||||
return problems;
|
||||
},
|
||||
/** Share of the pixels over each unit's body that change when the unit is hidden (≈ 0: nothing drawn). */
|
||||
drawn() {
|
||||
const R = raw(), app = R.debug.app, rd = app.renderer, gl = rd.gl, res = rd.resolution, H = rd.view.height, out = {};
|
||||
for (const p of this.pieces()) {
|
||||
if (p.kind === 'item') continue;
|
||||
const v = R.debug.views.get('p:' + p.uid);
|
||||
if (!v) continue;
|
||||
const b = v.bounds();
|
||||
const x0 = Math.max(0, Math.floor(b.x * res)), y0 = Math.max(0, Math.floor(b.y * res));
|
||||
const w = Math.max(1, Math.min(Math.floor(b.width * res), rd.view.width - x0)), h = Math.max(1, Math.min(Math.floor(b.height * res), H - y0));
|
||||
const read = () => { R.debug.ctx.impostors?.flush?.(); rd.render(app.stage); const px = new Uint8Array(w * h * 4); gl.readPixels(x0, H - y0 - h, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); return px; };
|
||||
const a = read();
|
||||
v.root.visible = false;
|
||||
const c = read();
|
||||
v.root.visible = true;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i += 4) if (Math.abs(a[i] - c[i]) + Math.abs(a[i + 1] - c[i + 1]) + Math.abs(a[i + 2] - c[i + 2]) + Math.abs(a[i + 3] - c[i + 3]) > 40) diff++;
|
||||
out[p.uid] = diff / (w * h);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip }, () => {
|
||||
let srv, browser;
|
||||
before(async () => {
|
||||
const require = createRequire(path.join(ROOT, 'package.json'));
|
||||
const puppeteer = require('puppeteer-core');
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run', '--force-device-scale-factor=1'] });
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
async function open(query, w = 1920, h = 1080) {
|
||||
const page = await browser.newPage();
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
await page.setViewport({ width: w, height: h });
|
||||
await page.goto(`http://127.0.0.1:${srv.port}/dev/game-mock.html?shot=1&render=engine&${query}`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__?.raw?.debug, { timeout: 30000 });
|
||||
await page.evaluate(installHelpers);
|
||||
await sleep(2000); // Spine models
|
||||
return { page, problems };
|
||||
}
|
||||
const setPhase = (page, ph) => page.evaluate(async (p) => { const { PHASE } = await import('/shared/constants.js'); globalThis.__MOCK__.setPhase(PHASE[p]); }, ph);
|
||||
const uidAt = (page, row, col) => page.evaluate((r, c) => globalThis.__MOCK__.S().priv.board.find((p) => p.row === r && p.col === c)?.uid ?? null, row, col);
|
||||
const assertAllDrawn = async (page, label) => {
|
||||
assert.deepEqual(await page.evaluate(() => window.__t.check()), [], `${label}: every prep model alive and in place`);
|
||||
const drawn = await page.evaluate(() => window.__t.drawn());
|
||||
for (const [uid, frac] of Object.entries(drawn)) assert.ok(frac > 0.05, `${label}: unit ${uid} draws pixels (${frac.toFixed(3)})`);
|
||||
};
|
||||
|
||||
test('1: the prep models come back alive after a battle that outlasts the Spine LRU grace', async () => {
|
||||
const { page, problems } = await open('phase=PREP');
|
||||
await assertAllDrawn(page, 'first prep');
|
||||
// a short grace stands in for a long battle (default 15 s)
|
||||
await page.evaluate(async () => { const { assets } = await import('/js/assets.js'); assets.spine.cache.idleGrace = 400; });
|
||||
for (let round = 0; round < 2; round++) {
|
||||
await setPhase(page, 'COMBAT');
|
||||
await sleep(2000);
|
||||
await setPhase(page, 'PREP');
|
||||
await sleep(2500);
|
||||
await assertAllDrawn(page, `prep after battle ${round + 1}`);
|
||||
}
|
||||
const st = await page.evaluate(async () => (await import('/js/assets.js')).assets.spine.stats());
|
||||
assert.equal(st.unloading, 0);
|
||||
await page.screenshot({ path: path.join(OUT, 'models-after-battles.png') });
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
||||
test('1: rapid drags, swaps, wheel previews (all four directions) with commit / Esc / ✕ and re-drags keep every model', async () => {
|
||||
const { page, problems } = await open('phase=PREP', 1600, 900);
|
||||
let seed = 7;
|
||||
const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 2 ** 32; };
|
||||
const tiles = await page.evaluate(() => { const st = globalThis.__MOCK__.S().stage; return [...st.deployTiles.normal.melee, ...st.deployTiles.normal.rangedOnly]; });
|
||||
const drag = async (from, to) => {
|
||||
await page.mouse.move(from.x, from.y); await page.mouse.down();
|
||||
for (let i = 1; i <= 6; i++) await page.mouse.move(from.x + ((to.x - from.x) * i) / 6, from.y + ((to.y - from.y) * i) / 6);
|
||||
await page.mouse.up();
|
||||
};
|
||||
const wheel = async (how) => {
|
||||
const g = await page.evaluate(() => { const el = document.querySelector('.fwheel__dia'); if (!el) return null; const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, h: r.height / 2 }; });
|
||||
if (!g) return;
|
||||
if (how === 'esc') { await page.keyboard.press('Escape'); return; }
|
||||
if (how === 'x') { await page.click('.fwheel__cancel').catch(() => {}); return; }
|
||||
await page.mouse.move(g.x, g.y); await page.mouse.down();
|
||||
for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0]]) await page.mouse.move(g.x + dx * g.h * 0.6, g.y + dy * g.h * 0.6, { steps: 3 });
|
||||
if (how === 'up') await page.mouse.move(g.x, g.y - g.h * 0.6, { steps: 2 });
|
||||
await page.mouse.up();
|
||||
};
|
||||
for (let burst = 0; burst < 2; burst++) {
|
||||
for (let k = 0; k < 16; k++) {
|
||||
const units = (await page.evaluate(() => window.__t.pieces())).filter((p) => p.kind !== 'item');
|
||||
const p = units[Math.floor(rnd() * units.length)];
|
||||
const [row, col] = rnd() < 0.8 ? tiles[Math.floor(rnd() * tiles.length)] : [7, Math.floor(rnd() * 10)];
|
||||
const from = await page.evaluate((u) => window.__t.at('p:' + u, 0.5), p.uid);
|
||||
const to = await page.evaluate((r, c) => globalThis.__SP_VIEW__.raw.tileScreen(r, c), row, col);
|
||||
await drag(from, to);
|
||||
await sleep(40 + rnd() * 80);
|
||||
await wheel(['up', 'up', 'left', 'esc', 'x'][Math.floor(rnd() * 5)]);
|
||||
await sleep(rnd() < 0.5 ? 20 : 300); // re-drag before the mock server answered, or after
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(1800);
|
||||
await assertAllDrawn(page, `burst ${burst + 1}`);
|
||||
}
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
||||
test('7: picking an operator standing directly behind another — prep and battle; equipment on the one behind', async () => {
|
||||
const { page, problems } = await open('phase=PREP');
|
||||
// col 4: row 9 (ground) · rows 10–12 (high ground) — a ranged operator behind a melee one, and two on the same height
|
||||
const [a9, b10, c11] = [await uidAt(page, 9, 4), await uidAt(page, 10, 4), await uidAt(page, 11, 4)];
|
||||
assert.ok(a9 && b10 && c11, 'mock premise: three operators in one column');
|
||||
const clicks = async (keyOf, idOf, cases) => {
|
||||
await page.evaluate(() => { window.__picked = []; window.__offPick?.(); window.__offPick = globalThis.__SP_VIEW__.raw.on('pieceClick', (e) => window.__picked.push(e.uid)); });
|
||||
for (const [label, key, up, dx, want] of cases) {
|
||||
const pt = await page.evaluate((k, u, d) => window.__t.at(k, u, d), keyOf(key), up, dx);
|
||||
await page.mouse.click(pt.x, pt.y);
|
||||
await sleep(120);
|
||||
const got = await page.evaluate(() => window.__picked.splice(0));
|
||||
assert.deepEqual(got.slice(-1), [idOf(want)], `${label}`);
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(60);
|
||||
}
|
||||
};
|
||||
const cases = [
|
||||
// [label, whose feet, tiles up, tiles aside, expected]
|
||||
['raised B: face', b10, 0.86, 0, b10], ['raised B: torso', b10, 0.45, 0, b10], ['raised B: legs', b10, 0.22, 0, b10],
|
||||
// B's feet where they show (straight under B's centre, A's hair crown is drawn over them: that pixel is A's)
|
||||
['raised B: feet', b10, 0.05, -0.12, b10],
|
||||
['raised B: its tile beside A\'s head', b10, 0, 0.42, b10],
|
||||
['A (in front): face', a9, 0.86, 0, a9], ['A: torso', a9, 0.45, 0, a9], ['A: feet', a9, 0.05, 0, a9],
|
||||
// (B's head and staff are drawn over the middle of C's lower torso and legs: those pixels are B's)
|
||||
['same height C behind B: face', c11, 0.86, 0, c11], ['C: torso', c11, 0.62, 0, c11], ['C: legs beside B\'s head', c11, 0.4, -0.2, c11],
|
||||
['C: its tile beside B\'s head', c11, 0, -0.42, c11], ['B in front of C: face', b10, 0.86, 0, b10],
|
||||
];
|
||||
await clicks((u) => 'p:' + u, (u) => u, cases);
|
||||
// equipment: an item dropped on the torso of the one behind (C) equips C; one on A's face equips A
|
||||
const items = await page.evaluate(() => globalThis.__MOCK__.S().priv.hand.filter((x) => x && x.kind === 'item').map((x) => x.uid));
|
||||
for (const [target, up] of [[c11, 0.62], [a9, 0.86]]) {
|
||||
const it = items.shift();
|
||||
const from = await page.evaluate((u) => { const r = globalThis.__SP_VIEW__.pieceScreenRect(u); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }, it);
|
||||
const to = await page.evaluate((u, k) => window.__t.at('p:' + u, k), target, up);
|
||||
await page.mouse.move(from.x, from.y); await page.mouse.down();
|
||||
for (let i = 1; i <= 10; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 10, from.y + ((to.y - from.y) * i) / 10); await sleep(16); }
|
||||
await sleep(120);
|
||||
assert.equal(await page.evaluate(() => globalThis.__SP_VIEW__.raw.debug.dragHit?.over ?? null), target, 'the plate is over the target (pieceDragOver)');
|
||||
await page.mouse.up();
|
||||
await sleep(600);
|
||||
const eq = await page.evaluate((u) => globalThis.__MOCK__.S().priv.board.find((p) => p.uid === u).items.map((x) => x.uid), target);
|
||||
assert.ok(eq.includes(it), `item ${it} equipped on ${target} (got ${eq})`);
|
||||
}
|
||||
await page.screenshot({ path: path.join(OUT, 'models-pick-prep.png') });
|
||||
// battle: the same presses on the battle views (ids → piece uids)
|
||||
await setPhase(page, 'COMBAT');
|
||||
await sleep(2500);
|
||||
// …at a moment of the battle when the units stand at rest: a press takes whatever is DRAWN under it (pixel probe),
|
||||
// and the mock battle keeps every operator attacking (lunges, attack clips) and in random skill poses, which move
|
||||
// the drawn face / torso / legs off the aimed points (then the unit in front or behind rightly takes the press —
|
||||
// the pixel-truth test below covers moving poses). So the mock battle is stopped with one last snapshot that clears
|
||||
// the skill flags, and the presses wait until every ally is back in its idle pose.
|
||||
await page.evaluate(async () => {
|
||||
const { net } = await import('/js/net.js');
|
||||
const S = globalThis.__MOCK__.S();
|
||||
const last = await new Promise((resolve) => { const off = net.on('b.snap', (m) => { off(); resolve(m); }); });
|
||||
clearInterval(S.battle.timer);
|
||||
net._emit('b.snap', { ...last, gt: last.gt + 0.2, units: last.units.map((u) => { const c = u.slice(); c[7] = 0; return c; }) });
|
||||
});
|
||||
await page.waitForFunction(() => [...globalThis.__SP_VIEW__.raw.debug.views.values()]
|
||||
.filter((v) => v.info?.side === 'ally' && v.alive && v.actor)
|
||||
.every((v) => v.actor.mode === 'base' && !v.actor.skillOn && !(v.lunge > 0)), { timeout: 15000, polling: 100 });
|
||||
await sleep(300);
|
||||
const ids = await page.evaluate(() => { const out = {}; for (const [id, v] of globalThis.__SP_VIEW__.raw.debug.views) if (v.info?.uid != null) out[v.info.uid] = id; return out; });
|
||||
await clicks((u) => ids[u], (u) => u, cases.filter(([label]) => !label.includes('tile')));
|
||||
await page.screenshot({ path: path.join(OUT, 'models-pick-battle.png') });
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
||||
// review of item 7: the pick against what is really drawn. Every unit is rendered alone (its pixels = where its model
|
||||
// is opaque); the truth at a pixel is the front-most unit drawn there. Sampled over the whole prep board and a
|
||||
// battle: the shape rule alone got 7 % (battle 18 %) of these presses wrong, mostly giving the front unit's hair /
|
||||
// arms to the unit behind; the pixel probe settles every overlap.
|
||||
test('7: picks agree with the rendered pixels (prep and battle)', async () => {
|
||||
const { page, problems } = await open('phase=PREP');
|
||||
const measure = (phase) => page.evaluate((ph) => {
|
||||
const R = globalThis.__SP_VIEW__.raw, app = R.debug.app, rd = app.renderer, gl = rd.gl, res = rd.resolution;
|
||||
const keyOf = new Map([...R.debug.views].map(([k, v]) => [v, k]));
|
||||
const views = [...R.debug.views].filter(([k, v]) => v.pickShape && v.info && v.info.kind !== 'item' && v.info.kind !== 'device'
|
||||
&& v.alive && v.spineReady && !v.culled && (ph === 'PREP' ? k.startsWith('p:') : true));
|
||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (const [, v] of views) { const s = v.screen.s; x0 = Math.min(x0, v.screen.x - s); x1 = Math.max(x1, v.screen.x + s); y0 = Math.min(y0, v.screen.y - 2 * s); y1 = Math.max(y1, v.screen.y + 0.3 * s); }
|
||||
x0 = Math.max(0, Math.floor(x0)); y0 = Math.max(0, Math.floor(y0));
|
||||
x1 = Math.min(rd.view.width / res - 1, Math.ceil(x1)); y1 = Math.min(rd.view.height / res - 1, Math.ceil(y1));
|
||||
const w = Math.floor((x1 - x0) * res), h = Math.floor((y1 - y0) * res), H = rd.view.height;
|
||||
const all = [...R.debug.views.values()];
|
||||
const saved = all.map((v) => [v, v.root?.visible, v.hud?.visible, v.shadow?.visible]);
|
||||
for (const v of all) { if (v.hud) v.hud.visible = false; if (v.shadow) v.shadow.visible = false; if (v.root) v.root.visible = false; }
|
||||
const read = () => { R.debug.ctx.impostors?.flush?.(); rd.render(app.stage); const px = new Uint8Array(w * h * 4); gl.readPixels(Math.floor(x0 * res), H - Math.floor(y0 * res) - h, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); return px; };
|
||||
const bg = read();
|
||||
const masks = [];
|
||||
for (const [k, v] of views) {
|
||||
v.root.visible = true; const a = read(); v.root.visible = false;
|
||||
const m = new Uint8Array(w * h);
|
||||
for (let i = 0; i < w * h; i++) { const j = i * 4; if (a[j + 3] > 40 && Math.abs(a[j] - bg[j]) + Math.abs(a[j + 1] - bg[j + 1]) + Math.abs(a[j + 2] - bg[j + 2]) + Math.abs(a[j + 3] - bg[j + 3]) > 40) m[i] = 1; }
|
||||
masks.push({ k, m, z: v.root.zIndex });
|
||||
}
|
||||
for (const [v, a, b, c] of saved) { if (v.root) v.root.visible = a; if (v.hud) v.hud.visible = b; if (v.shadow) v.shadow.visible = c; }
|
||||
const pick = (x, y) => { if (ph === 'PREP') { const p = R.debug.pick.pieceAt(x, y); return p ? 'p:' + p.uid : null; } const v = R.debug.pick.battleUnitAt(x, y); return v ? keyOf.get(v) : null; };
|
||||
let n = 0, ok = 0, wrong = 0;
|
||||
for (let py = 0; py < h; py += 4) for (let px = 0; px < w; px += 4) {
|
||||
const i = (h - 1 - py) * w + px;
|
||||
let truth = null, tz = -Infinity;
|
||||
for (const mk of masks) if (mk.m[i] && mk.z > tz) { tz = mk.z; truth = mk.k; }
|
||||
if (!truth) continue;
|
||||
const got = pick(x0 + px / res, y0 + py / res);
|
||||
n++;
|
||||
if (got === truth) ok++; else if (got) wrong++;
|
||||
}
|
||||
return { units: views.length, n, ok: ok / n, wrong: wrong / n };
|
||||
}, phase);
|
||||
const prep = await measure('PREP');
|
||||
assert.ok(prep.units >= 10 && prep.n > 3000, `premise: a full prep board (${JSON.stringify(prep)})`);
|
||||
assert.ok(prep.ok > 0.95 && prep.wrong < 0.015, `prep: presses on a drawn unit pick it (${JSON.stringify(prep)})`);
|
||||
await setPhase(page, 'COMBAT');
|
||||
await sleep(3000);
|
||||
const battle = await measure('COMBAT');
|
||||
assert.ok(battle.units >= 10 && battle.n > 3000, `premise: a battle (${JSON.stringify(battle)})`);
|
||||
assert.ok(battle.ok > 0.93 && battle.wrong < 0.03, `battle: presses on a drawn unit pick it (${JSON.stringify(battle)})`);
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
||||
test('7: a dragged unit stands on the pointer; the drop and its direction wheel are that tile', async () => {
|
||||
const { page, problems } = await open('phase=PREP');
|
||||
const hand = await page.evaluate(() => globalThis.__MOCK__.S().priv.hand.find((x) => x && x.kind === 'chess').uid);
|
||||
const from = await page.evaluate((u) => window.__t.at('p:' + u, 0.5), hand);
|
||||
const to = await page.evaluate(() => globalThis.__SP_VIEW__.raw.tileScreen(9, 4)); // an occupied melee tile: a swap
|
||||
await page.mouse.move(from.x, from.y); await page.mouse.down();
|
||||
for (let i = 1; i <= 10; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 10, from.y + ((to.y - from.y) * i) / 10); await sleep(16); }
|
||||
await sleep(150);
|
||||
const ghost = await page.evaluate((u) => { const v = globalThis.__SP_VIEW__.raw.debug.views.get('p:' + u); return { x: v.x, y: v.y, lift: v.lift }; }, hand);
|
||||
assert.ok(Math.abs(ghost.x - 4) < 0.06 && Math.abs(ghost.y - 9) < 0.06 && ghost.lift > 0, `the ghost stands on the pointer's tile (${ghost.x}, ${ghost.y})`);
|
||||
await page.screenshot({ path: path.join(OUT, 'models-drag-ghost.png') });
|
||||
await page.mouse.up();
|
||||
await page.waitForSelector('.fwheel__dia', { timeout: 3000 });
|
||||
const w = await page.evaluate(() => { const r = document.querySelector('.fwheel__dia').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
|
||||
assert.ok(Math.hypot(w.x - to.x, w.y - to.y) < 2, 'the wheel opens on that tile');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
||||
// review of item 7: a touch drag holds its ghost (and drop point) 0.6 tile above the finger. On a phone the bench is
|
||||
// the lowest canvas row with the shop bar right under it, so putting a unit back on the bench has the finger on the
|
||||
// shop bar: the drop is judged where the ghost is, not under the finger (it used to send the piece back).
|
||||
test('7: touch — a board unit dragged back onto a bench slot above the shop bar lands there (phone)', async () => {
|
||||
const page = await browser.newPage();
|
||||
const problems = [];
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
await page.setViewport({ width: 844, height: 390, deviceScaleFactor: 2, isMobile: true, hasTouch: true, isLandscape: true });
|
||||
await page.goto(`http://127.0.0.1:${srv.port}/dev/game-mock.html?shot=1&render=engine&phase=PREP`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__?.raw?.debug, { timeout: 30000 });
|
||||
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.hand[5] = null; }));
|
||||
await sleep(2000);
|
||||
const g = await page.evaluate(() => {
|
||||
const V = globalThis.__SP_VIEW__.raw, cv = V.debug.app.view.getBoundingClientRect();
|
||||
const u = globalThis.__MOCK__.S().priv.board[0], v = V.debug.views.get('p:' + u.uid), t = V.tileScreen(7, 5);
|
||||
const finger = { x: t.x, y: t.y + 0.6 * t.s };
|
||||
const el = document.elementFromPoint(finger.x, finger.y);
|
||||
return { uid: u.uid, from: { x: cv.left + v.screen.x, y: cv.top + v.screen.y - 0.5 * v.screen.s }, finger, onDom: !!el && el !== V.debug.app.view };
|
||||
});
|
||||
assert.ok(g.onDom, 'premise: the finger is on the DOM (shop bar) below the bench slot');
|
||||
await page.touchscreen.touchStart(g.from.x, g.from.y);
|
||||
for (let i = 1; i <= 12; i++) { await page.touchscreen.touchMove(g.from.x + ((g.finger.x - g.from.x) * i) / 12, g.from.y + ((g.finger.y - g.from.y) * i) / 12); await sleep(16); }
|
||||
await sleep(100);
|
||||
await page.touchscreen.touchEnd();
|
||||
await sleep(800);
|
||||
assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().priv.hand[5]?.uid ?? null), g.uid, 'the unit is on bench slot 5');
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
// test/render/pick.test.js — render/pick.js, the one picking rule of every "which unit is under the pointer" path
|
||||
// (user playtest #3 item 7: "clicking an operator often selects the one a row below it; equipment too"), checked on the
|
||||
// geometry of the official cameras (render/projection.js presetCamera: prep / battle at 1920×1080 and 1280×720): a unit
|
||||
// B standing directly behind a unit A (same column, adjacent rows; B on the same height or on a raised tile).
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
import { pickBody, hitBody, coreDistance, bodyBounds, bodyParts, BODY_H, probeCandidates, PROBE_MARGIN } from '../../public/js/render/pick.js';
|
||||
import { unitDepthKey } from '../../public/js/render/units.js';
|
||||
|
||||
const COL = 4;
|
||||
/** A / B bodies on a camera: A at row 9 (height zA), B at row 10 (height zB), model heights hA / hB. */
|
||||
function pair(kind, W, H, { zA = 0, zB = 0, hA = BODY_H, hB = BODY_H } = {}) {
|
||||
const cam = presetCamera(kind, { width: W, height: H });
|
||||
const body = (key, row, z, h) => { const p = cam.project(COL, row, z); return { key, x: p.x, y: p.y, s: p.s, h, flip: 1, depth: unitDepthKey(cam, COL, row), tile: { row, col: COL } }; };
|
||||
const A = body('A', 9, zA, hA), B = body('B', 10, zB, hB);
|
||||
const heights = new Map([[`9,${COL}`, zA], [`10,${COL}`, zB]]);
|
||||
// the tile under a screen point, raised tops first (render/projection.js pickTile semantics)
|
||||
const ground = (x, y) => {
|
||||
for (const hz of [...new Set([zA, zB, 0])].sort((a, b) => b - a)) {
|
||||
const g = cam.unproject(x, y, hz);
|
||||
if (!g) continue;
|
||||
const t = { row: Math.round(g.y), col: Math.round(g.x) };
|
||||
if (hz === 0 || Math.abs((heights.get(`${t.row},${t.col}`) || 0) - hz) < 1e-6) return t;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
/** Who a press `up` tiles above the feet of `u` (and `dx` tiles to the side) picks. */
|
||||
const at = (u, up, dx = 0, bodies = [A, B]) => { const x = u.x + dx * u.s, y = u.y - up * u.s; return pickBody(bodies, x, y, ground(x, y))?.key ?? null; };
|
||||
return { cam, A, B, at, rowGap: (A.y - B.y) / A.s };
|
||||
}
|
||||
|
||||
const CAMS = [['prep', 1920, 1080], ['prep', 1280, 720], ['normal', 1920, 1080], ['normal', 1280, 720]];
|
||||
|
||||
describe('pickBody on the official cameras: B directly behind A', () => {
|
||||
for (const [kind, W, H] of CAMS) {
|
||||
test(`${kind} ${W}×${H}, same height: B's head / face / torso / legs / tile beside A are B's; A's face, torso and feet are A's`, () => {
|
||||
const { at, A, B, rowGap } = pair(kind, W, H);
|
||||
assert.ok(rowGap > 0.8 && rowGap < 0.95, `rows ${rowGap.toFixed(2)} of a tile apart on screen (test premise)`);
|
||||
assert.ok(BODY_H > rowGap, 'a chibi is taller than a row: A covers B\'s feet (test premise)');
|
||||
for (const up of [1.2, 0.86, 0.6, 0.45, 0.3]) assert.equal(at(B, up), 'B', `B ${up} tile above its feet`);
|
||||
for (const dx of [-0.4, 0.4]) assert.equal(at(B, 0, dx), 'B', `B's tile beside A's head (${dx})`);
|
||||
for (const up of [0.86, 0.6, 0.3, 0.05, -0.1]) assert.equal(at(A, up), 'A', `A ${up} tile above its feet`);
|
||||
// the feet of B are drawn behind A's face: that press is A's (nothing of B shows there)
|
||||
assert.equal(at(B, 0), 'A');
|
||||
// A's hair crown over B's legs / tile goes to B; with nobody behind it is A's
|
||||
assert.equal(at(A, 1.2), 'B');
|
||||
assert.equal(at(A, 1.2, 0, [A]), 'A');
|
||||
assert.equal(at(A, 0.86, 0, [A]), 'A');
|
||||
});
|
||||
|
||||
test(`${kind} ${W}×${H}, B on a raised tile (ranged high ground behind a melee lane): B's feet are B's too`, () => {
|
||||
const { at, B, A } = pair(kind, W, H, { zB: 0.42 });
|
||||
for (const up of [1.2, 0.86, 0.45, 0.2, 0]) assert.equal(at(B, up), 'B', `B ${up} tile above its feet`);
|
||||
for (const up of [0.86, 0.45, 0.05]) assert.equal(at(A, up), 'A', `A ${up} tile above its feet`);
|
||||
});
|
||||
}
|
||||
|
||||
test('the old rule would have picked A for most of B\'s lower half (the playtest report)', () => {
|
||||
// the pre-fix hit box: 0.7 tile wide, 1.18 tiles above the feet + 0.1 below, front-most wins
|
||||
const { A, B } = pair('prep', 1920, 1080);
|
||||
const box = (u, x, y) => Math.abs(x - u.x) <= 0.35 * u.s && y >= u.y - 1.18 * u.s && y <= u.y + 0.1 * u.s;
|
||||
const old = (x, y) => (box(A, x, y) ? 'A' : box(B, x, y) ? 'B' : null);
|
||||
let wrong = 0;
|
||||
for (const up of [0.3, 0.2, 0.1, 0]) for (const dx of [-0.3, 0, 0.3]) if (old(B.x + dx * B.s, B.y - up * B.s) === 'A') wrong++;
|
||||
assert.ok(wrong >= 6, `old rule: ${wrong}/12 presses on B's lower body / feet went to A`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickBody rule details', () => {
|
||||
const cam = presetCamera('prep', { width: 1920, height: 1080 });
|
||||
const at = (row, col, extra = {}) => { const p = cam.project(col, row, 0); return { x: p.x, y: p.y, s: p.s, h: BODY_H, depth: unitDepthKey(cam, col, row), tile: { row, col }, ...extra }; };
|
||||
|
||||
test('off every body, the unit standing on the pressed tile is picked; an empty tile picks nothing', () => {
|
||||
const U = { key: 'U', ...at(10, 6) };
|
||||
const tile = cam.project(6, 10, 0);
|
||||
// the tile's corner, well away from the body
|
||||
const cx = tile.x + 0.45 * tile.s, cy = tile.y + 0.3 * tile.s;
|
||||
assert.equal(hitBody(U, cx, cy), 0, 'premise: not on the body');
|
||||
assert.equal(pickBody([U], cx, cy, { row: 10, col: 6 }), U);
|
||||
assert.equal(pickBody([U], cx, cy, { row: 10, col: 7 }), null);
|
||||
assert.equal(pickBody([U], cx, cy, null), null);
|
||||
});
|
||||
|
||||
test('side by side (same row): each unit keeps its own body, weapon edges go by the tile', () => {
|
||||
const L = { key: 'L', ...at(10, 5) }, R = { key: 'R', ...at(10, 6) };
|
||||
for (const u of [L, R]) for (const up of [0.9, 0.5, 0.15]) assert.equal(pickBody([L, R], u.x, u.y - up * u.s, u.tile), u);
|
||||
const mid = (L.x + R.x) / 2;
|
||||
assert.equal(pickBody([L, R], mid - 2, L.y - 0.3 * L.s, { row: 10, col: 5 }), L);
|
||||
assert.equal(pickBody([L, R], mid + 2, L.y - 0.3 * L.s, { row: 10, col: 6 }), R);
|
||||
});
|
||||
|
||||
test('a moving unit (no tile, e.g. an enemy walking in front) is picked by its body only, front-most on cores', () => {
|
||||
const op = { key: 'op', ...at(10, 5) };
|
||||
const foe = { key: 'foe', ...at(9, 5), tile: null, w: 1.1, h: 1.3 };
|
||||
assert.equal(pickBody([op, foe], foe.x, foe.y - 0.5 * foe.s, { row: 9, col: 5 }), foe, 'its torso');
|
||||
assert.equal(pickBody([op, foe], op.x, op.y - 0.6 * op.s, { row: 10, col: 5 }), op, 'the operator above it');
|
||||
// off its body on the tile it walks over: nothing stands there
|
||||
assert.equal(pickBody([foe], foe.x + 0.48 * foe.s, foe.y + 0.35 * foe.s, { row: 9, col: 5 }), null);
|
||||
});
|
||||
|
||||
test('the HUD (tier chip / bars) is an outline: its own unit when nothing else is there, never over a torso', () => {
|
||||
const B = { key: 'B', ...at(10, 5) }, C = { key: 'C', ...at(11, 5) };
|
||||
const chipY = B.y - 1.3 * B.s;
|
||||
B.hud = { x0: B.x - 12, y0: chipY - 12, x1: B.x + 12, y1: chipY + 12 };
|
||||
assert.equal(pickBody([B], B.x, chipY, null), B, 'alone: the chip is its unit\'s');
|
||||
assert.equal(hitBody(C, B.x, chipY), 2, 'premise: C\'s legs are behind B\'s chip');
|
||||
assert.equal(pickBody([B, C], B.x, chipY, { row: 11, col: 5 }), C);
|
||||
});
|
||||
|
||||
test('model height, width factor and facing shape the outline', () => {
|
||||
const tall = { ...at(10, 5), h: 1.45 }, short = { ...at(10, 5), h: 1.1 };
|
||||
const topOf = (b) => bodyBounds(b).y;
|
||||
assert.ok(topOf(tall) < topOf(short), 'taller model → higher outline');
|
||||
assert.ok(Math.abs(topOf(tall) - (tall.y - 1.45 * tall.s)) < 1, 'the outline tops out at the model height');
|
||||
const big = { ...at(10, 5), h: 2.4, w: 2 };
|
||||
assert.ok(bodyBounds(big).width > 1.9 * bodyBounds(short).width, 'enemies scale with their size');
|
||||
const right = bodyParts({ ...at(10, 5), flip: 1 }).outline[1].rect, left = bodyParts({ ...at(10, 5), flip: -1 }).outline[1].rect;
|
||||
const x = at(10, 5).x;
|
||||
assert.ok(x - right.x0 > right.x1 - x && left.x1 - x > x - left.x0, 'the back side (hair, capes) is the wider one');
|
||||
});
|
||||
|
||||
test('avatar diamonds and item plates', () => {
|
||||
const d = { kind: 'diamond', ...at(10, 5), d: 0.78 };
|
||||
const centre = d.y - (0.08 + 0.39) * d.s;
|
||||
assert.equal(hitBody(d, d.x, centre), 2);
|
||||
assert.equal(hitBody(d, d.x + 0.33 * d.s, centre), 1, 'near its tip');
|
||||
assert.equal(hitBody(d, d.x + 0.45 * d.s, centre), 0);
|
||||
const plate = { kind: 'plate', x: 500, y: 300, s: 100, r: 0.31 };
|
||||
assert.equal(hitBody(plate, 500, 300), 2);
|
||||
assert.equal(hitBody(plate, 528, 300), 1);
|
||||
assert.equal(hitBody(plate, 540, 300), 0);
|
||||
assert.deepEqual(bodyBounds(plate), { x: 469, y: 269, width: 62, height: 62 });
|
||||
assert.equal(coreDistance(plate, 500, 300), 0);
|
||||
assert.ok(Math.abs(coreDistance(plate, 541.7, 300) - 0.2) < 1e-6, 'px to the core / px per tile');
|
||||
});
|
||||
|
||||
test('junk input picks nothing', () => {
|
||||
const U = { key: 'U', ...at(10, 5) };
|
||||
assert.equal(pickBody([U], NaN, 3), null);
|
||||
assert.equal(pickBody(null, 3, 3), null);
|
||||
assert.equal(pickBody([null, { x: 1, y: 1, s: 0 }, { x: NaN, y: 1, s: 5 }], 1, 1, { row: 1, col: 1 }), null);
|
||||
assert.equal(hitBody(null, 0, 0), 0);
|
||||
});
|
||||
});
|
||||
|
||||
// Review of item 7: measured against the rendered pixels of the mock match (every drawn unit pixel of the prep board at
|
||||
// 1920×1080), the shape rule alone put ~8 % of the presses on the wrong unit — mostly the unit BEHIND: the hair crown
|
||||
// and the sides (arms, clothes, weapons) of the unit in front lie over the legs / tile of the one behind. The pixel
|
||||
// probe (render/app.js drawnAt) settles every overlap by what is really drawn on top.
|
||||
describe('pickBody with the pixel probe', () => {
|
||||
const { A, B, cam } = pair('prep', 1920, 1080);
|
||||
const ground = (x, y) => { const g = cam.unproject(x, y, 0); return g ? { row: Math.round(g.y), col: Math.round(g.x) } : null; };
|
||||
/** A probe that says which bodies draw the point (and records who was asked, in order). */
|
||||
const probeOf = (drawing, calls = []) => (b) => { calls.push(b.key); return drawing.includes(b.key); };
|
||||
|
||||
test('A\'s hair crown drawn over B\'s torso: A (the shape rule alone says B)', () => {
|
||||
const x = A.x, y = A.y - 1.2 * A.s;
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y))?.key, 'B', 'premise: the rule gives it to B');
|
||||
const calls = [];
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf(['A', 'B'], calls))?.key, 'A');
|
||||
assert.deepEqual(calls, ['A'], 'front-most first; stops at the first that draws');
|
||||
});
|
||||
|
||||
test('B\'s legs showing beside A\'s head: B', () => {
|
||||
const x = B.x + 0.3 * B.s, y = B.y - 0.3 * B.s;
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf(['B']))?.key, 'B');
|
||||
});
|
||||
|
||||
test('A\'s arm over the tile of B, outside B\'s outline: A (a single candidate the rule did not pick)', () => {
|
||||
// a point on B's tile in front of B's feet, beside A's body: the rule picks B (standing on the pressed tile)
|
||||
const t = cam.project(COL, 10, 0);
|
||||
const x = t.x + 0.45 * t.s, y = t.y + 0.3 * t.s;
|
||||
assert.equal(ground(x, y)?.row, 10, 'premise: B\'s tile');
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y))?.key, 'B');
|
||||
const cand = probeCandidates([A, B], x, y);
|
||||
if (cand.length === 1) assert.equal(cand[0].key, 'A');
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf(['A']))?.key, 'A');
|
||||
// nothing drawn there: the rule (B's tile)
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf([]))?.key, 'B');
|
||||
});
|
||||
|
||||
test('the probe is only asked where it can change the answer; null / throwing → the shape rule', () => {
|
||||
const lone = { ...A, key: 'L' };
|
||||
const calls = [];
|
||||
assert.equal(pickBody([lone], lone.x, lone.y - 0.5 * lone.s, null, probeOf([], calls))?.key, 'L');
|
||||
assert.deepEqual(calls, [], 'one body that the rule picks: no probe');
|
||||
assert.equal(pickBody([lone], lone.x + 5 * lone.s, lone.y, null, probeOf([], calls)), null);
|
||||
assert.deepEqual(calls, [], 'no body around: no probe');
|
||||
const x = A.x, y = A.y - 1.2 * A.s;
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y), () => null)?.key, 'B', 'cannot tell → the rule');
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y), () => { throw new Error('lost context'); })?.key, 'B');
|
||||
// the one in front cannot tell: the rule decides (never a unit behind an unknown one)
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y), (b) => (b.key === 'A' ? null : true))?.key, 'B');
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y), (b) => (b.key === 'A' ? null : true)), pickBody([A, B], x, y, ground(x, y)));
|
||||
});
|
||||
|
||||
test('probe candidates: bodies whose outline + PROBE_MARGIN holds the point, front-most first', () => {
|
||||
const r = bodyBounds({ ...A, hud: null });
|
||||
assert.deepEqual(probeCandidates([B, A], A.x, A.y - 0.9 * A.s).map((b) => b.key), ['A', 'B']);
|
||||
assert.deepEqual(probeCandidates([A], r.x - (PROBE_MARGIN - 0.05) * A.s, A.y - 0.5 * A.s).map((b) => b.key), ['A']);
|
||||
assert.deepEqual(probeCandidates([A], r.x - (PROBE_MARGIN + 0.05) * A.s, A.y - 0.5 * A.s), []);
|
||||
assert.deepEqual(probeCandidates([null, { x: NaN, y: 0, s: 1 }], 0, 0), []);
|
||||
});
|
||||
});
|
||||
@@ -195,6 +195,86 @@ describe('field view teardown (app.js 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) {
|
||||
|
||||
Reference in new issue
Block a user