- #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)
207 lines
13 KiB
JavaScript
207 lines
13 KiB
JavaScript
// 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), []);
|
||
});
|
||
});
|