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

207 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// test/render/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), []);
});
});