Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash

- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays
  under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed
- #2 two-tap confirm on 机变 cards (like the shop)
- #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles)
- #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band
- #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from
- #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others)
- #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats)
- #8 official element gauges on units; burst cooldown (阻回) per PRTS
- #9 knocked-down operators stay on their tile with a redeploy countdown ring
- #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼)
- #11 赫默's drone only from her skill; placeable summons limited to talent tokens
- #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel
- #13 the occasional giant bond icon (loading texture scaled ×46) fixed
- docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
This commit is contained in:
sganggs committed 2026-09-30 05:35:45 +08:00
1 parent 0aba602ecf
commit d4f868cd19
107 files changed
+4510 -1653

No files matched your search

+3 -2
View File
@@ -164,8 +164,9 @@ describe('render engine in headless Chrome', { skip }, () => {
const placed = await page.evaluate((uid) => window.__demo.scene.state.board.find((p) => p.uid === uid) || null, info.uid);
assert.ok(placed, 'piece moved to the board');
assert.deepEqual([placed.row, placed.col], [10, 5]);
const r2 = await page.evaluate(() => window.__demo.view.pieceScreenRect(window.__demo.scene.state.board[1].uid));
await page.mouse.click(r2.left + r2.width / 2, r2.top + r2.height / 2, { button: 'right' });
// picking is by tile (user playtest #4 item 1): right-click the unit's own tile
const r2 = await page.evaluate(() => { const p = window.__demo.scene.state.board[1]; return window.__demo.view.tileScreen(p.row, p.col); });
await page.mouse.click(r2.x, r2.y, { button: 'right' });
await wait(150);
const log = await page.evaluate(() => window.__demo.log.join('\n'));
assert.match(log, /pieceClick .*"detail":true/);
+137
View File
@@ -0,0 +1,137 @@
// test/render/downelem.browser.test.js — user playtest #4 items 8 and 9 in headless Chrome through the render demo
// (public/dev/render-demo.html, real Spine models): frames fed to the field view like the client runner does (local
// feed) with b.snap `down` / `elem` — a knocked-out operator stays on its tile in its held Die pose under a redeploy
// ring counting down, then "DP", then comes back with its deploy clip (a press on its tile selects it meanwhile);
// operators and enemies show the official element icon beside their bars, refilling over a 爆发冷却; a summon and an
// enemy that die still vanish.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/downelem.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/downelem-*.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)';
/** Page side: a small field fed frame by frame (local feed) — `step(gt, units, extra, ev)` pushes one frame. */
function installFeed() {
const v = window.__demo.view;
const U = (id, side, spine, x, y, extra = {}) => ({ id, kind: side === 'enemy' ? 'enemy' : 'op', side, defId: spine, spine, avatar: spine, x, y, maxHp: 1000, facing: 1, dir: side === 'enemy' ? undefined : 'RIGHT', tier: 3, ...extra });
const units = [
U(1, 'ally', 'char_102_texas', 5, 10), U(2, 'ally', 'char_1016_agoat2', 3, 11), U(3, 'ally', 'token_10028_vigil_wolf', 4, 12, { kind: 'token' }),
U(4, 'enemy', 'enemy_1000_gopro_2', 8, 10), U(5, 'enemy', 'enemy_1000_gopro_2', 9, 11),
];
v.enterBattle({ fieldId: 'dn', kind: 'normal', rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, stageId: 'act2autochess_m01', units });
v.setCamera('normal', { rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
const tup = (u, hp = 1000, anim = 0) => [u.id, u.x, u.y, hp, 1000, 0, 0, 0, anim];
window.__feed = {
units,
step(gt, list, extra = {}, ev = null) {
if (ev && ev.length) v.pushEvents({ t: 'b.ev', fieldId: 'dn', gt, ev });
v.pushSnapshot({ t: 'b.snap', fieldId: 'dn', gt, units: list.map((x) => (Array.isArray(x) ? x : tup(x))), dp: 5, killed: 0, total: 5, ...extra });
},
tup,
state() {
const out = {};
for (const [id, x] of v.debug.views) {
out[id] = { alive: x.alive, down: x.down ? x.down.state : null, ring: !!x._downRing?.root?.visible, label: x._downRing?.text?.text ?? null,
el: x._elRing?.root?.visible ? x.el : null, spine: !!x.spineReady, dieClip: x.actor?.current ?? null, alpha: +(x.alpha ?? 0).toFixed(2) };
}
return out;
},
};
}
describe('knocked-out operators and element gauges in 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'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('down ring countdown → DP → redeploy; element icons; summons and enemies still vanish', async () => {
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}`));
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
try {
await page.setViewport({ width: 1280, height: 720 });
// a recording scene, paused: it feeds nothing, the test drives the view
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
await page.evaluate(installFeed);
// phase 1 (gt 1–3): everyone stands; texas2's neighbour 艾雅法拉 carries a burn gauge, an enemy is in a neural cooldown
const run = (fn, from, to) => page.evaluate(async (src, from, to) => {
const f = window.__feed;
const body = new Function('f', 'gt', src);
for (let gt = from; gt <= to + 1e-9; gt += 1 / 30) { body(f, +gt.toFixed(4)); await new Promise((r) => requestAnimationFrame(() => r())); }
}, fn, from, to);
await run(`const U = f.units; f.step(gt, [U[0], U[1], U[2], U[3], U[4]], { elem: [[2, 'burn', 0.4, 0, 0], [4, 'neural', 1, 12, 10]] });`, 1, 3);
await new Promise((r) => setTimeout(r, 1500)); // Spine models load
await run(`const U = f.units; f.step(gt, [U[0], U[1], U[2], U[3], U[4]], { elem: [[2, 'burn', 0.4, 0, 0], [4, 'neural', 1, 12, 10]] });`, 3, 4);
let st = await page.evaluate(() => window.__feed.state());
assert.equal(st[2].el, 'burn', 'operator element icon');
assert.equal(st[4].el, 'neural', 'enemy element icon (cooldown)');
assert.equal(st[1].el, null);
// phase 2: texas (1) is knocked out, the wolf summon (3) and an enemy (5) die
await page.evaluate(() => {
const f = window.__feed, U = f.units;
f.step(4.05, [f.tup(U[0], 0, 4), U[1], f.tup(U[2], 0, 4), U[3], f.tup(U[4], 0, 4)], { down: [[1, 24.05, 20, 0]] }, [['die', 1, 'killed'], ['die', 3, 'killed'], ['die', 5, 'killed']]);
});
await run(`const U = f.units; f.step(gt, [U[1], U[3]], { down: [[1, 24.05, 20, 0]], elem: [[2, 'burn', 0.4, 0, 0]] });`, 4.1, 8);
await new Promise((r) => setTimeout(r, 1200)); // die clips and fades run out (the last frame stays)
await run(`const U = f.units; f.step(gt, [U[1], U[3]], { down: [[1, 24.05, 20, 0]], elem: [[2, 'burn', 0.4, 0, 0]] });`, 8, 9);
st = await page.evaluate(() => window.__feed.state());
await page.screenshot({ path: path.join(OUT, 'downelem-countdown.png') });
assert.equal(st[1].alive, false);
assert.equal(st[1].down, 0, 'knocked down');
assert.ok(st[1].ring, 'redeploy ring');
assert.equal(st[1].label, '16', 'seconds left');
assert.equal(st[1].dieClip, 'Die', 'held Die pose');
assert.ok(st[1].alpha > 0.8, `the model is drawn (${st[1].alpha})`);
assert.equal(st[3], undefined, 'the summon vanished');
assert.equal(st[5], undefined, 'the enemy vanished');
// a press on its tile selects it like any unit on a tile (picking by tile, user playtest #4 item 1)
const picked = await page.evaluate(() => {
const v = window.__demo.view, t = v.tileScreen(10, 5), r = v.debug.app.view.getBoundingClientRect();
return v.debug.pick.battleUnitAt(t.x - r.left, t.y - r.top)?.id ?? null;
});
assert.equal(picked, 1, 'the knocked-down operator is picked on its tile');
// phase 3: the timer is done but the DP is short
await run(`const U = f.units; f.step(gt, [U[1], U[3]], { down: [[1, 24.05, 20, 1]] });`, 24.1, 25);
st = await page.evaluate(() => window.__feed.state());
await page.screenshot({ path: path.join(OUT, 'downelem-waitdp.png') });
assert.equal(st[1].label, 'DP');
// phase 4: redeploy
await page.evaluate(() => { const f = window.__feed, U = f.units; f.step(25.05, [f.tup(U[0], 1000, 6), U[1], U[3]], {}, [['deploy', 1]]); });
await run(`const U = f.units; f.step(gt, [U[0], U[1], U[3]]);`, 25.1, 26);
st = await page.evaluate(() => window.__feed.state());
await page.screenshot({ path: path.join(OUT, 'downelem-redeployed.png') });
assert.equal(st[1].alive, true, 'back on its tile');
assert.equal(st[1].down, null);
assert.equal(st[1].ring, false);
assert.equal(st[2].el, null, 'the gauge left the snapshot: icon hidden');
assert.deepEqual(problems, []);
} finally {
await page.close();
}
});
});
+175
View File
@@ -0,0 +1,175 @@
// test/render/downring.test.js — user playtest #4 items 8 and 9, client side: render/interp.js carries b.snap `elem`
// (the element gauge a unit shows) and `down` (knocked-out operators waiting to redeploy); render/units.js keeps a
// knocked-out operator on its tile in its held Die pose under a redeploy ring (countdown → "DP" / "!" → redeploy)
// and draws the official element icon beside the bars (headless fake PIXI, test/render/fakepixi.js).
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { SnapshotBuffer, normalizeSnapshot, TUPLE } from '../../public/js/render/interp.js';
let fake, UnitView, DOWN_STATE, DOWN_LOOK, T;
before(async () => {
fake = installFakePixi();
({ UnitView, DOWN_STATE, DOWN_LOOK } = await import('../../public/js/render/units.js'));
T = await import('../../public/js/render/textures.js');
});
after(() => fake.restore());
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
const tuple = (id, x = 5, y = 10, hp = 1000) => [id, x, y, hp, 1000, 0, 0, 0, 0];
describe('interp: b.snap `elem` and `down`', () => {
test('`elem` entries are appended to their unit tuple and sampled as el / elFill / elUntil / elDur', () => {
const s = normalizeSnapshot({ t: 3, units: [tuple(1), tuple(2), tuple(3)], elem: [[1, 'burn', 0.4, 0, 0], [2, 'neural', 1, 12.5, 10], [9, 'burn', 0.5, 0, 0], [3, 'lava', 0.5, 0, 0], 'x'] });
assert.deepEqual(s.units.get(1).slice(TUPLE.EL), ['burn', 0.4, 0, 0]);
assert.deepEqual(s.units.get(2).slice(TUPLE.EL), ['neural', 1, 12.5, 10]);
assert.equal(s.units.get(3).length, 9, 'unknown element dropped');
assert.equal(s.down, null);
const buf = new SnapshotBuffer({ delay: 0 });
buf.push({ t: 1, units: [tuple(1)], elem: [[1, 'apoptosis', 0.2, 0, 0]] }, 0);
buf.push({ t: 1.1, units: [tuple(1)] }, 0.05);
const out = buf.sample(1.02);
assert.equal(out.get(1).el, 'apoptosis');
assert.equal(out.get(1).elFill, 0.2);
buf.sample(1.1, out);
assert.equal(out.get(1).el, null, 'cleared once the gauge is gone');
assert.equal(out.get(1).elFill, 0);
});
test('`down` is kept per snapshot; downAt(time) reads the snapshot shown at that time', () => {
const buf = new SnapshotBuffer({ delay: 0 });
buf.push({ t: 1, units: [] }, 0);
buf.push({ t: 1.1, units: [], down: [[7, 20.5, 18, 0], ['bad'], [8, 'x', -3, 1.7]] }, 0.05);
buf.push({ t: 1.2, units: [] }, 0.1);
assert.equal(buf.downAt(1.05), null);
assert.deepEqual(buf.downAt(1.15), [[7, 20.5, 18, 0], ['bad', 0, 0, 0], [8, 0, 0, 1]]);
assert.equal(buf.downAt(1.25), null);
});
});
function view(info = {}) {
const ctx = fakeViewCtx(fake.P, { cam });
return new UnitView(ctx, { id: 1, side: 'ally', kind: 'chess', defId: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT', ...info });
}
const frames = (v, n, dt = 1 / 60, gameT = null) => { for (let i = 0; i < n; i++) { if (gameT) v.gameT = gameT(i); v.update(dt, cam(), i * dt); } };
describe('knocked-down operator (UnitView.setDown)', () => {
test('stays on its tile greyed under a redeploy ring counting down, then "DP", then redeploys', () => {
const v = view();
frames(v, 3);
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 10);
assert.equal(v.alive, false, 'knocked down');
assert.ok(v.down);
frames(v, 400, 1 / 60, () => 10); // 6.7 s: a dying view would long be gone
assert.equal(v.remove, false, 'never removed while down');
assert.ok(v.alpha > 0.85, `drawn (alpha ${v.alpha})`);
const r = v._downRing;
assert.ok(r && r.root.visible, 'ring shown');
assert.equal(r.text.text, '20', 'seconds left (game s)');
assert.equal(r.arc.tint, DOWN_LOOK.ring[DOWN_STATE.COUNTING]);
assert.equal(r.arc.texture, T.ringArc(0), 'nothing elapsed yet');
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 25);
frames(v, 1, 1 / 60, () => 25);
assert.equal(r.text.text, '5');
assert.equal(r.arc.texture, T.ringArc(0.75), 'three quarters elapsed');
v.setDown([1, 30, 20, DOWN_STATE.WAIT_DP], 31);
frames(v, 1, 1 / 60, () => 31);
assert.equal(r.text.text, 'DP');
assert.equal(r.arc.tint, DOWN_LOOK.ring[DOWN_STATE.WAIT_DP]);
assert.equal(r.arc.texture, T.ringArc(1), 'full ring while it waits');
v.setDown([1, 30, 20, DOWN_STATE.WAIT_TILE], 32);
frames(v, 1);
assert.equal(r.text.text, '!');
const kids = v.hud.children.length;
frames(v, 30);
assert.equal(v.hud.children.length, kids, 'no per-frame allocation');
v.onDeploy(); // the redeploy (b.ev 'deploy')
assert.equal(v.alive, true);
assert.equal(v.down, null);
frames(v, 2);
assert.equal(r.root.visible, false, 'ring gone');
});
test('a view made for a unit already down starts on the held end of the Die clip', () => {
const v = view();
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 12, true);
assert.equal(v.alive, false);
assert.ok(v.dieT >= 30);
frames(v, 2, 1 / 60, () => 12);
assert.ok(v._downRing.root.visible);
assert.equal(v._downRing.text.text, '18');
});
test('leaving the `down` list without a redeploy fades the view out; a normal death still fades', () => {
const v = view();
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 10);
frames(v, 10);
v.setDown(null, 11);
frames(v, 60);
assert.equal(v.remove, true);
const w = view({ id: 2 });
frames(w, 2);
w.die();
frames(w, 200);
assert.equal(w.remove, true, 'a summon / enemy death is unchanged');
});
});
describe('element gauge ring', () => {
const sample = (o = {}) => ({ x: 5, y: 10, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, el: null, elFill: 0, elUntil: 0, elDur: 0, ...o });
test('operators: the element disc with its glyph in a white ring of the remaining 元素值', () => {
const v = view();
v.sync(sample(), 5);
frames(v, 2);
assert.equal(v._elRing, null, 'nothing built without a gauge');
v.sync(sample({ el: 'burn', elFill: 0.25 }), 5);
frames(v, 1);
const r = v._elRing;
assert.ok(r.root.visible);
assert.equal(r.disc.texture, T.hudRings().disc.burn);
assert.equal(r.arc.texture, T.ringArc(0.75), 'remaining EP = 1 − fill');
v.sync(sample({ el: 'neural', elFill: 0.6 }), 5);
frames(v, 1);
assert.equal(r.disc.texture, T.hudRings().disc.neural);
assert.equal(r.arc.texture, T.ringArc(0.4));
v.sync(sample(), 6);
frames(v, 1);
assert.equal(r.root.visible, false, 'hidden once the gauges are empty');
});
test('enemies: a smaller plain disc; during a 爆发冷却 the ring refills over the cooldown', () => {
const e = view({ id: 3, side: 'enemy', kind: 'enemy', defId: 'enemy_x', dir: undefined });
e.sync(sample({ el: 'apoptosis', elFill: 1, elUntil: 25, elDur: 15 }), 10);
frames(e, 1);
const r = e._elRing;
assert.equal(r.disc.texture, T.hudRings().discEnemy.apoptosis);
assert.equal(r.arc.texture, T.ringArc(0), 'empty at the burst');
e.sync(sample({ el: 'apoptosis', elFill: 1, elUntil: 25, elDur: 15 }), 17.5);
frames(e, 1);
assert.equal(r.arc.texture, T.ringArc(0.5), 'half refilled halfway through');
const op = view({ id: 4 });
op.sync(sample({ el: 'apoptosis', elFill: 0.5 }), 1);
frames(op, 1);
assert.ok(r.root.scale.x < op._elRing.root.scale.x, 'the enemy icon is smaller');
});
test('a burst lock status (爆发冷却) is not repeated in the status row while the element icon shows it', () => {
const e = view({ id: 5, side: 'enemy', kind: 'enemy', defId: 'enemy_x', dir: undefined });
e.onStatus('burnBurst', true);
e.onStatus('fragile', true);
assert.deepEqual([...e._iconKeys()], ['burn', 'fragile'], 'no gauge in the feed: the status shows the burst');
e.sync(sample({ el: 'burn', elFill: 1, elUntil: 20, elDur: 10 }), 12);
assert.deepEqual([...e._iconKeys()], ['fragile'], 'the element icon carries the 爆发冷却');
});
test('the atlas: one frame per step, every element in both styles', () => {
const R = T.hudRings();
assert.equal(R.arcs.length, T.RING_STEPS + 1);
for (const el of ['neural', 'erosion', 'burn', 'apoptosis', 'necrosis']) { assert.ok(R.disc[el]); assert.ok(R.discEnemy[el]); }
assert.equal(T.ringArc(0.001), R.arcs[1], 'a sliver never shows as empty');
assert.equal(T.ringArc(2), R.arcs[T.RING_STEPS]);
});
});
+42 -64
View File
@@ -77,7 +77,6 @@ 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]),
@@ -218,84 +217,63 @@ 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'));
// user playtest #4 item 1: everything is judged at the pointer — the tile under it is the target (tileHover, legality,
// the drop) for a mouse and a finger alike (v2.2 lifted a touch drag's target 0.6 tile above the finger)
test('the target, tileHover, legality and the drop are the tile under the pointer (mouse and touch)', () => {
for (const pointerType of ['mouse', 'touch']) {
const h = harness({ canPlace: (p, r) => r !== 11 });
h.c.setEditable(true);
h.ev('pointerDown', 50, 550, { pointerType });
h.ev('pointerMove', 450, 350, { pointerType });
let mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
assert.deepEqual([mv.x, mv.y, mv.target, mv.legal], [450, 350, { area: 'board', row: 9, col: 4 }, true], pointerType);
assert.ok(h.events.some((e) => e[0] === 'tileHover' && e[1] && e[1].row === 9 && e[1].col === 4), `${pointerType}: tileHover = the target`);
h.ev('pointerMove', 450, 150, { pointerType });
mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
assert.deepEqual([mv.target, mv.legal], [{ area: 'board', row: 11, col: 4 }, false], `${pointerType}: row 11 under the pointer is illegal`);
h.ev('pointerMove', 450, 350, { pointerType });
h.ev('pointerUp', 450, 350, { pointerType });
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 9, col: 4 }, pointerType);
}
});
// 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 = [];
test('presses and hover pick the piece at the pointer (the view\'s hitPiece: the piece on the tile there)', () => {
const asked = [];
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; },
hitPiece: (x, y) => { asked.push([x, y]); 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 });
const ev = (type, x, y, pointerType = 'mouse') => c[type]({ pointerId: 1, pointerType, 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');
ev('pointerMove', 60, 560);
ev('pointerDown', 55, 545, 'touch');
assert.deepEqual(asked, [[60, 560], [55, 545]], 'hover and press ask at the pointer itself');
ev('pointerMove', 355, 255, 'touch');
ev('pointerUp', 355, 255, 'touch');
assert.equal(asked.length, 2, 'a drag asks no hit-test');
});
// 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) });
// on a phone the bench is the lowest canvas row and the shop bar (DOM) sits right under it: a release with the finger
// on the shop bar is 'outside' (the piece goes back); on the bench slot above it, it lands there
test('DOM cover is judged at the pointer: a touch release over the shop bar is outside, on the bench it lands', () => {
const h = harness({ domCover: true });
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' });
h.ev('pointerMove', 250, 550, { pointerType: 'touch' });
let mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
assert.deepEqual([mv.target, mv.legal], [{ area: 'hand', idx: 2 }, true]);
h.ev('pointerUp', 250, 550, { 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');
h.ev('pointerMove', 250, 950, { pointerType: 'touch' });
mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
assert.equal(mv.target, null, 'the finger on the DOM: no target');
assert.ok(h.events.some((e) => e[0] === 'tileHover' && e[1] === null), 'no tile lit');
h.ev('pointerUp', 250, 950, { pointerType: 'touch' });
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'outside', clientX: 250, clientY: 950 });
});
test('a throwing listener or hit-test never breaks the controller', () => {
+2 -2
View File
@@ -120,9 +120,9 @@ describe('field view features in headless Chrome', { skip }, () => {
const clicks = [];
const off = v.on('pieceClick', (e) => clicks.push(e));
const pv = [...v.debug.penViews.values()].find((p) => !p.culled);
const b = pv.bounds();
const rect = v.debug.app.view.getBoundingClientRect();
out.click = { x: rect.left + b.x + b.width / 2, y: rect.top + b.y + b.height * 0.6, key: pv.info.enemyKey };
// picking is by tile (user playtest #4 item 1): a press at the figure's feet, on its own tile
out.click = { x: rect.left + pv.screen.x, y: rect.top + pv.screen.y, key: pv.info.enemyKey };
window.__clicks = clicks; window.__offClick = off;
return out;
});
+83
View File
@@ -0,0 +1,83 @@
// test/render/flash.browser.test.js — user playtest #4 item 13 in headless Chrome ("屏幕很偶尔的情况下会有个巨大的ui的一部分
// 遮挡一两秒": once in a while a huge piece of UI covered the screen for a second or two).
// Found by sampling long real-server sessions every 80 ms (DOM boxes > 25 % of the viewport, images drawn > 300 px, Pixi
// objects > 25 % of the viewport or showing a whole atlas page, CSS animations scaling ≥ 2): the only unexpected hit
// was the bond-layer pop of render/fx.js — the first in-battle layer gain of each bond (b.ev 'layer') popped the bond's
// icon, a PIXI.Texture.from(url) that is 1×1 until its image has loaded, and pop() sized the sprite from that: 46× too
// big, ~5000 px over the whole screen for the pop's 1.4 s (test/render/fxpop.test.js has the unit test). Here the real
// mock battle (public/dev/game-mock.html) gets 'layer' events for bonds whose icons were never loaded on the page.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/flash.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/flash-*.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));
describe('user playtest #4 item 13: no screen-sized UI flash (mock battle, 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();
});
for (const [w, h] of [[1920, 1080], [1280, 720]]) {
test(`${w}×${h}: a bond layer gained in battle pops its icon at ~46 px — also the first time (icon still loading)`, async () => {
const page = await browser.newPage();
const problems = [];
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&phase=COMBAT`, { waitUntil: 'networkidle0' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && globalThis.__SP_VIEW__?.raw?.mode === 'battle', { timeout: 30000 });
await sleep(1500);
const r = await page.evaluate(async () => {
const { net } = await import('/js/net.js');
const R = globalThis.__SP_VIEW__.raw, S = globalThis.__MOCK__.S();
const icons = (await import('/js/assets.js')).assets;
// bonds with an icon that no view has turned into a texture yet (the bond strip's <img>s do not count)
const bonds = ['yanShip', 'preciShip', 'abyssShip', 'kazimierzShip'].filter((b) => icons.bondIcon?.(b) && !PIXI.utils.TextureCache[icons.bondIcon(b)]);
const gt = (await new Promise((resolve) => { const off = net.on('b.snap', (m) => { off(); resolve(m.gt); }); })) + 0.05;
net._emit('b.ev', { t: 'b.ev', fieldId: S.battle.fieldRef.id, gt, ev: bonds.map((b, i) => ['layer', 'p1', b, i + 1]) });
const t0 = performance.now();
const icon = (p) => p.c.children.find((c) => c.texture && c.texture !== R.debug.fx.tex.glow);
let worst = 0, seen = 0;
// every frame of the pops' life (1.4 s): the icon sprite's drawn box
while (performance.now() - t0 < 1600) {
await new Promise((res) => requestAnimationFrame(res));
for (const p of R.debug.fx.pops) {
const sp = icon(p);
if (!sp || !sp.visible || !(sp.worldAlpha > 0.02)) continue;
const b = sp.getBounds();
seen = Math.max(seen, R.debug.fx.pops.length);
worst = Math.max(worst, b.width, b.height);
}
}
return { bonds, worst: Math.round(worst), seen, loaded: bonds.map((b) => !!PIXI.utils.TextureCache[icons.bondIcon(b)]?.valid) };
});
await page.screenshot({ path: path.join(OUT, `flash-bond-pop-${w}.png`) });
await page.close();
assert.ok(r.bonds.length >= 2 && r.seen >= 2, `premise: bond icons popped for the first time (${JSON.stringify(r)})`);
assert.ok(r.loaded.every(Boolean), `premise: their images loaded during the pop (${JSON.stringify(r)})`);
assert.ok(r.worst > 20 && r.worst < 80, `the icon is ~46 px (× the pop's scale-in), never screen-sized: ${r.worst} px`);
assert.deepEqual(problems, []);
});
}
});
+109
View File
@@ -0,0 +1,109 @@
// test/render/fxflame.test.js — 炎佑's 祛恶之焰 as a continuous jet of fire (render/fx.js _flame; user playtest #4 item 12
// "官方是一段持续时间的喷火"). The sim (server/sim/content/tokens.js yanyouKit) emits one fx 'yanyouFlame'
// {x, y, id: dragon, target, r, n, dur: 1} per game second of the channel; the renderer keeps ONE jet per dragon that each
// tick re-aims and extends, so the ticks join into a stream from the dragon onto its locked target with a burning disc
// of radius r around the target. It used to be the generic 'buff' sparkle once per second.
// Against the headless fake PIXI (test/render/fakepixi.js), like fxproj.test.js.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
let fake, FX;
before(async () => {
fake = installFakePixi();
FX = await import('../../public/js/render/fx.js');
});
after(() => fake.restore());
const DT = 1 / 120;
const cam = presetCamera('normal', { width: 1600, height: 900 });
const unit = (id, x, y, o = {}) => ({ id, x, y, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, info: { defId: 'x' }, onHit() {}, ...o });
function makeFx(views, ts = 2) {
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: 'high', damageNumbers: true },
timeScale: () => ts, loadLevel: () => 0, subProfOf: () => null, view: (id) => map.get(id) || null,
screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
});
return { fx, map };
}
const run = (fx, seconds) => { for (let t = 0; t < seconds - 1e-9; t += DT) fx.update(DT); };
/** One sim tick of the channel (the event tokens.js emits). */
const tick = (fx, dragon, target, r = 1) => fx.simFx('yanyouFlame', target.x, target.y, { x: target.x, y: target.y, id: dragon.id, target: target.id, r, n: 1, dur: 1 });
describe('炎佑 祛恶之焰: one continuous jet of fire (user playtest #4 item 12)', () => {
test('its own archetype (no longer the one-off buff sparkle)', () => {
assert.equal(FX.FX_KINDS.yanyouFlame.a, 'flame');
assert.equal(FX.fxSpec('yanyouFlame').a, 'flame');
});
test('the 1 s ticks join into one stream that burns while they come and ends after the last one', () => {
const dragon = unit(40, 6.2, 10.1, { hover: 0.32 }), foe = unit(20, 6, 10);
const { fx } = makeFx([dragon, foe]);
for (let s = 0; s < 6; s++) { // 6 game s of channel at the 2× clock: a tick every 0.5 real s
tick(fx, dragon, foe);
assert.equal(fx.flames.length, 1, `tick ${s}: still one jet`);
run(fx, 0.5);
assert.equal(fx.flames.length, 1, `between ticks ${s} and ${s + 1} the jet keeps burning`);
const F = fx.flames[0];
assert.ok(F.disc.alpha > 0.2 && F.edge.alpha > 0.2, 'the burning disc shows');
}
assert.ok(fx.counts.particles > 20, 'fire streams from the dragon and rises from the disc');
run(fx, 0.3);
assert.equal(fx.flames.length, 0, 'no tick came: the jet goes out within FLAME_TAIL');
});
test('the disc follows the locked target; a new tick re-aims the same jet', () => {
const dragon = unit(40, 3, 10, { hover: 0.32 }), a = unit(20, 3.2, 10), b = unit(21, 5, 11);
const { fx } = makeFx([dragon, a, b]);
tick(fx, dragon, a);
run(fx, 0.1);
const F = fx.flames[0];
const at0 = { x: F.disc.position.x, y: F.disc.position.y };
a.x = 4.2; // the target walks on: the disc goes with it
run(fx, 0.1);
assert.ok(F.disc.position.x > at0.x + 20, `disc follows the target (${at0.x} → ${F.disc.position.x})`);
tick(fx, dragon, b); // the next tick aims at another enemy: the same jet, re-aimed
run(fx, 0.1);
assert.equal(fx.flames.length, 1);
assert.equal(fx.flames[0], F);
assert.equal(F.tgt, 21);
assert.equal(F.r, 1);
});
test('the dragon gone (knocked out, left the field) puts its jet out at once; clear() drops every jet', () => {
const dragon = unit(40, 3, 10), foe = unit(20, 3.2, 10);
const { fx, map } = makeFx([dragon, foe]);
tick(fx, dragon, foe);
run(fx, 0.05);
assert.equal(fx.flames.length, 1);
map.delete(40);
run(fx, 0.05);
assert.equal(fx.flames.length, 0, 'no dragon, no fire');
map.set(40, dragon);
tick(fx, dragon, foe);
const disc = fx.flames[0].disc;
fx.clear();
assert.equal(fx.flames.length, 0);
assert.ok(disc.destroyed, 'its sprites are freed');
});
test('two dragons (9 炎) burn two jets; a target that died keeps the jet on its last spot until it fades', () => {
const d1 = unit(40, 3, 10), d2 = unit(41, 3, 10.8), foe = unit(20, 3.2, 10);
const { fx, map } = makeFx([d1, d2, foe]);
tick(fx, d1, foe); tick(fx, d2, foe);
assert.equal(fx.flames.length, 2);
run(fx, 0.1);
const x = fx.flames[0].x;
map.delete(20); // killed
run(fx, 0.1);
assert.equal(fx.flames[0].x, x, 'stays where the target was');
run(fx, 0.6);
assert.equal(fx.flames.length, 0);
});
});
+92
View File
@@ -0,0 +1,92 @@
// test/render/fxpop.test.js — user playtest #4 item 13 ("屏幕很偶尔的情况下会有个巨大的ui的一部分遮挡一两秒": once in a while a
// huge piece of UI covered the screen for a second or two). The first in-battle bond layer gain of each bond (b.ev
// 'layer' → render/app.js → FxSystem.pop) popped that bond's icon 46 times too big — ~5000 px, the whole screen, for
// the pop's 1.4 s. The icon is PIXI.Texture.from(url): a 1×1 placeholder until its image has loaded (then the real size
// and an 'update' event, Pixi 7.4), and pop() sized the sprite from the placeholder (46 / 1). Reproduced in headless
// Chrome (test/render/flash.browser.test.js); here against a fake PIXI with the same Texture semantics.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { FxSystem } from '../../public/js/render/fx.js';
class Emitter {
constructor() { this._on = new Map(); }
on(e, f, ctx) { if (!this._on.has(e)) this._on.set(e, []); this._on.get(e).push({ f, ctx, once: false }); return this; }
once(e, f, ctx) { if (!this._on.has(e)) this._on.set(e, []); this._on.get(e).push({ f, ctx, once: true }); return this; }
off(e, f) { const l = this._on.get(e); if (l) this._on.set(e, l.filter((x) => x.f !== f)); return this; }
emit(e, ...a) { const l = this._on.get(e) || []; this._on.set(e, l.filter((x) => !x.once)); for (const x of l) x.f.apply(x.ctx, a); return true; }
}
/** Pixi 7.4 Texture of an image: 1×1 and invalid until its base texture has loaded, then its size + 'update'. */
class Tex extends Emitter {
constructor(w, h, loaded = true) {
super();
this._size = [w, h];
this.baseTexture = { valid: loaded, width: loaded ? w : 1, height: loaded ? h : 1 };
this.orig = { width: loaded ? w : 1, height: loaded ? h : 1 };
this.valid = loaded;
}
get width() { return this.orig.width; }
get height() { return this.orig.height; }
load() {
[this.orig.width, this.orig.height] = this._size;
Object.assign(this.baseTexture, { valid: true, width: this._size[0], height: this._size[1] });
this.valid = true;
this.emit('update', this);
}
}
class Point { constructor(x = 1, y = x) { this.x = x; this.y = y; } set(x, y = x) { this.x = x; this.y = y; } }
class Sprite {
constructor(tex) { this.texture = tex; this.visible = true; this.scale = new Point(1); this.anchor = new Point(0); this.position = new Point(0); this.tint = 0xffffff; this.destroyed = false; }
destroy() { this.destroyed = true; }
}
class Container {
constructor() { this.children = []; this.position = new Point(0); this.scale = new Point(1); this.alpha = 1; this.destroyed = false; }
addChild(...c) { this.children.push(...c); return c[0]; }
destroy() { this.destroyed = true; for (const c of this.children) c.destroy?.(); }
}
class BitmapText { constructor(text) { this.text = text; this.anchor = new Point(0); this.position = new Point(0); this.tint = 0xffffff; } destroy() {} }
function fx() {
const f = Object.create(FxSystem.prototype);
Object.assign(f, {
P: { Sprite, Container, BitmapText, BLEND_MODES: { ADD: 1 } },
tex: { glow: new Tex(64, 64) }, pops: [],
ctx: { screenSize: () => ({ width: 1920, height: 1080 }), fieldTop: () => 200, layers: { screen: { addChild() {} } } },
});
return f;
}
/** Screen px of the icon sprite's longer side as drawn (0 while hidden). */
const drawn = (sp) => (sp.visible ? Math.max(sp.texture.width * Math.abs(sp.scale.x), sp.texture.height * Math.abs(sp.scale.y)) : 0);
const iconOf = (f, icon) => f.pops.at(-1).c.children.find((c) => c.texture === icon);
describe('FxSystem.pop: the bond icon is 46 px whenever its image loads (user playtest #4 item 13)', () => {
test('an icon still loading is sized when it has loaded — never from its 1×1 placeholder', () => {
const f = fx();
const icon = new Tex(110, 98, false); // bond/yanShip.png
f.pop(icon, '+2', 0xffffff, 0);
const sp = iconOf(f, icon);
assert.ok(drawn(sp) <= 46.5, `nothing big while loading (${drawn(sp)} px)`);
icon.load();
assert.ok(Math.abs(drawn(sp) - 46) < 0.5, `46 px once loaded (was 110 × 46 = 5060 px): ${drawn(sp)}`);
f._updatePops(0.5);
assert.ok(drawn(sp) * f.pops[0].c.scale.x < 60, 'with the pop\'s own scale-in');
});
test('an icon already loaded: 46 px along its longer side at once', () => {
const f = fx();
const icon = new Tex(102, 100);
f.pop(icon, '+1', 0xffffff, 1);
assert.ok(Math.abs(drawn(iconOf(f, icon)) - 46) < 0.5);
});
test('a pop that ended before its image loaded stays gone', () => {
const f = fx();
const icon = new Tex(110, 98, false);
f.pop(icon, '+3', 0xffffff, 0);
const sp = iconOf(f, icon);
f._updatePops(2);
assert.equal(f.pops.length, 0);
assert.doesNotThrow(() => icon.load());
assert.ok(sp.destroyed);
});
});
+257 -149
View File
@@ -1,16 +1,19 @@
// 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).
// test/render/models.browser.test.js — user playtest #3 item 1 and user playtest #4 item 1 in headless Chrome, through the
// in-match mock (public/dev/game-mock.html: the real game screen, a mock server behind net.request):
// #3.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).
// #4.1 picking by tile ("地上都画好了一个一个方格,点击对应方格就选中那个方格的人物就行"): a press anywhere on a tile — board
// (adjacent rows and columns, high ground), bench, temporary bench — selects the unit on it and an empty tile
// nothing (a press on a unit's head, drawn over the tile behind it, is a press on that tile), in prep and in battle
// (allies by their tile, enemies by their ground position or drawn body); a dragged unit is held under the pointer — its drawn
// feet DRAG_HOLD_TILES below it, the model around it — for a mouse and a finger (phone, 844×390); the drop, its
// tileHover and the direction wheel are the tile under the pointer; equipment dropped on a tile equips the unit
// on it (not the one in front whose head is drawn there); on a phone a release on a bench slot lands there and one
// over the shop bar goes back.
//
// 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.
@@ -169,7 +172,7 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
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 from = await page.evaluate((u) => window.__t.at('p:' + u, 0.1), p.uid); // on its own tile
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);
@@ -184,177 +187,282 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
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);
/** Client point on a board-space tile's top face: (u, v) ∈ [−1, 1] from its centre towards its left / right and near /
* far edges (view.tileScreen poly: far-left, far-right, near-right, near-left). */
const tileSpot = (page, row, col, u = 0, v = 0) => page.evaluate((r, c, u, v) => {
const t = globalThis.__SP_VIEW__.raw.tileScreen(r, c);
if (!t) return null;
const [fl, fr, nr, nl] = t.poly, a = (u + 1) / 2, b = (v + 1) / 2;
const near = [nl[0] + (nr[0] - nl[0]) * a, nl[1] + (nr[1] - nl[1]) * a], far = [fl[0] + (fr[0] - fl[0]) * a, fl[1] + (fr[1] - fl[1]) * a];
return { x: near[0] + (far[0] - near[0]) * b, y: near[1] + (far[1] - near[1]) * b };
}, row, col, u, v);
/** The render engine's own pick at a client point (render/pick.js through app.js pieceAt / battleUnitAt). */
const pickAt = (page, pt, battle = false) => page.evaluate((x, y, b) => {
const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect();
if (!b) return R.debug.pick.pieceAt(x - cv.left, y - cv.top)?.uid ?? null;
return R.debug.pick.battleUnitAt(x - cv.left, y - cv.top)?.id ?? null;
}, pt.x, pt.y, battle);
const listenClicks = (page) => page.evaluate(() => {
window.__picked = [];
window.__offPick?.();
window.__offPick = globalThis.__SP_VIEW__.raw.on('pieceClick', (e) => window.__picked.push(e.unitId ?? e.uid ?? null));
});
const clickAt = async (page, pt) => {
await page.mouse.click(pt.x, pt.y);
await sleep(90);
const got = await page.evaluate(() => window.__picked.splice(0));
await page.keyboard.press('Escape');
await sleep(40);
return got;
};
// spots of a tile's top face that show on screen: its centre, 0.3 tile out to the sides and 0.4 to the far edge, 0.15
// towards the near edge (a raised tile in front hides the strip of this one just behind it: a 0.3-high block the
// nearest 0.17 tile, the 0.55 separator wall in front of the bench 0.22 — the press there is on the block's top)
const SPOTS = [[0, 0], [-0.6, 0], [0.6, 0], [0, -0.3], [0, 0.8], [-0.6, 0.6], [0.6, 0.6], [-0.6, -0.3], [0.6, -0.3]];
test('#4.1 prep: a press anywhere on a tile selects the unit on it — board, high ground, bench, temp; an empty tile nothing', async () => {
const { page, problems } = await open('phase=PREP&variant=temp');
const pieces = await page.evaluate(() => window.__t.pieces());
const on = (r, c) => pieces.find((p) => p.row === r && p.col === c)?.uid ?? null;
assert.ok(on(9, 3) && on(9, 4) && on(9, 5) && on(10, 4) && on(11, 4), 'premise: neighbours in a row (9, 3–5) and a column (9–11, 4; rows 10–12 high ground)');
assert.ok(pieces.filter((p) => p.row === 7).length >= 5 && pieces.filter((p) => p.row === 8).length >= 2, 'premise: bench and temp pieces');
await listenClicks(page);
for (const p of pieces) {
for (const [u, v] of SPOTS) {
const pt = await tileSpot(page, p.row, p.col, u, v);
assert.equal(await pickAt(page, pt), p.uid, `${p.kind} ${p.uid} on (${p.row},${p.col}) at (${u}, ${v})`);
}
// the whole press path (canvas pointer events → the drag controller → pieceClick) at the centre and a corner
for (const [u, v] of [[0, 0], [0.6, 0.6]]) {
const got = await clickAt(page, await tileSpot(page, p.row, p.col, u, v));
assert.deepEqual(got.slice(-1), [p.uid], `click on (${p.row},${p.col}) at (${u}, ${v})`);
}
};
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})`);
}
// a press on a unit's head, drawn over the tile behind it: the unit on that tile — or nothing when it is empty
const head = (uid) => page.evaluate((u) => window.__t.at('p:' + u, 1.0), uid);
assert.equal(await pickAt(page, await head(on(9, 4))), on(10, 4), 'the head of (9,4) is drawn over (10,4)');
assert.equal(on(10, 3), null, 'premise: (10,3) is a forbidden block');
assert.equal(await pickAt(page, await head(on(9, 3))), null, 'the head of (9,3) over the empty (10,3)');
assert.deepEqual(await clickAt(page, await head(on(9, 3))), [], 'no pieceClick');
// the nearest strip of (9,3) is hidden by the forbidden block (8,3) in front of it: a press there is on that block
const hidden = await tileSpot(page, 9, 3, 0, -0.9);
assert.deepEqual(await page.evaluate((x, y) => { const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect(), g = R.debug.pick.groundTile(x - cv.left, y - cv.top); return g && [g.row, g.col]; }, hidden.x, hidden.y), [8, 3]);
assert.equal(await pickAt(page, hidden), null);
await page.screenshot({ path: path.join(OUT, 'models-pick-prep.png') });
// battle: the same presses on the battle views (ids → piece uids)
await page.close();
assert.deepEqual(problems, []);
});
test('#4.1 battle: allies by the tile under the pointer, enemies by their ground position or drawn body', async () => {
const { page, problems } = await open('phase=PREP');
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 sleep(6000); // several enemies spawned (one every 0.9 s)
// freeze the mock battle (one last snapshot): the enemies stand still for the presses
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; }) });
net._emit('b.snap', { ...last, gt: last.gt + 0.2 });
});
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 sleep(800);
const units = await page.evaluate(() => {
const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect(), out = [];
for (const [id, v] of R.debug.views) {
if (!v.info || !v.alive || v.culled || v.info.kind === 'device') continue;
const g = R.debug.cam.project(v.x, v.y, v.z);
out.push({ id, side: v.info.side, x: v.x, y: v.y, row: Math.round(v.y), col: Math.round(v.x), fly: !!v.flying, feet: { x: cv.left + g.x, y: cv.top + g.y },
body: { x: cv.left + v.screen.x, top: cv.top + v.screen.top, feet: cv.top + v.screen.y, s: v.screen.s } });
}
return out;
});
const allies = units.filter((u) => u.side === 'ally'), foes = units.filter((u) => u.side === 'enemy' && !u.fly);
assert.ok(allies.length >= 7 && foes.length >= 3, `premise: allies ${allies.length}, walking enemies ${foes.length}`);
await listenClicks(page);
const near = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
/** Tiles between a client point and an enemy's drawn body (the line from its feet up to its head). */
const bodyDist = (b, pt) => Math.hypot(pt.x - b.x, pt.y - Math.max(b.top, Math.min(b.feet, pt.y))) / b.s;
// the rule (render/pick.js), from the spot's own ground coordinates and client point: the ally on the tile, unless
// an enemy's ground position or drawn body is nearer (within 0.6 tile)
const expected = (a, g, pt) => {
let best = a, bd = near(a, g);
for (const f of foes) { const d = Math.min(near(f, g), bodyDist(f.body, pt)); if (d <= 0.6 && d < bd - 1e-6) { best = f; bd = d; } }
return best.id;
};
let byEnemy = 0;
for (const a of allies) {
for (const [u, v] of SPOTS) {
const pt = await tileSpot(page, a.row, a.col, u, v);
const g = await page.evaluate((x, y) => { const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect(); return R.debug.pick.groundTile(x - cv.left, y - cv.top); }, pt.x, pt.y);
const want = expected(a, g, pt);
if (want !== a.id) byEnemy++;
assert.equal(await pickAt(page, pt, true), want, `ally ${a.id} on (${a.row},${a.col}) at (${u}, ${v})`);
}
if (expected(a, { x: a.col, y: a.row }, await tileSpot(page, a.row, a.col)) === a.id) assert.deepEqual((await clickAt(page, await tileSpot(page, a.row, a.col))).slice(-1), [a.id], `click on ally ${a.id}`);
}
assert.ok(byEnemy < allies.length * SPOTS.length / 3, `most spots of the allies' tiles are the allies' (${byEnemy} went to an enemy standing nearer)`);
for (const f of foes) {
// at its feet, and on its drawn torso / head (a tall enemy's body is drawn over the ground a tile behind it)
for (const [k, pt] of [['feet', f.feet], ['torso', { x: f.body.x, y: (f.body.top + f.body.feet) / 2 }], ['head', { x: f.body.x, y: f.body.top + 0.1 * f.body.s }]]) {
const got = await pickAt(page, pt, true);
const other = units.find((u) => u.id === got);
assert.ok(got === f.id || (other && other.side === 'enemy' && near(other, f) < 0.3), `enemy ${f.id} at its ${k} (got ${got})`);
}
}
const f = foes.find((x) => !foes.some((y) => y !== x && near(x, y) < 0.3)) || foes[0];
const got = await clickAt(page, f.feet);
assert.ok(got.length === 1 && units.find((u) => u.id === got[0])?.side === 'enemy', `a click at an enemy's feet selects an enemy (${got})`);
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, []);
});
/** The dragged view against the pointer (client px): its drawn feet, px per tile, and the Spine model's drawn bounds. */
const ghostAt = (page, uid) => page.evaluate((u) => {
const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect(), v = R.debug.views.get('p:' + u);
const b = v.actor && v.spineReady && !v.imp ? v.actor.spine.getBounds() : null;
return { lift: v.lift, x: cv.left + v.screen.x, y: cv.top + v.screen.y, s: v.screen.s, model: b && { x0: cv.left + b.x, y0: cv.top + b.y, x1: cv.left + b.x + b.width, y1: cv.top + b.y + b.height } };
}, uid);
/** The model is under the pointer: its feet DRAG_HOLD_TILES (0.45) below it, the pointer inside its drawn body. */
const assertHeld = (g, ptr, label) => {
assert.ok(g.lift > 0, `${label}: lifted`);
assert.ok(Math.abs(g.x - ptr.x) < 1.5 && Math.abs(g.y - ptr.y - 0.45 * g.s) < 1.5, `${label}: feet 0.45 tile below the pointer (feet ${g.x.toFixed(1)},${g.y.toFixed(1)}, pointer ${ptr.x.toFixed(1)},${ptr.y.toFixed(1)}, s ${g.s.toFixed(1)})`);
if (g.model) {
const m = g.model, k = (ptr.y - m.y0) / (m.y1 - m.y0);
assert.ok(ptr.x > m.x0 && ptr.x < m.x1 && k > 0.2 && k < 0.8, `${label}: the pointer on the drawn model (${JSON.stringify(m)}, k ${k.toFixed(2)})`);
}
};
test('7: a dragged unit stands on the pointer; the drop and its direction wheel are that tile', async () => {
test('#4.1 drag (mouse): the unit is held under the pointer; target, tileHover and the direction wheel are the pointer\'s 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
const hand = await page.evaluate(() => { const p = globalThis.__MOCK__.S().priv; const i = p.hand.findIndex((x) => x && x.kind === 'chess'); return { uid: p.hand[i].uid, idx: i }; });
await page.evaluate(() => { window.__hover = []; globalThis.__SP_VIEW__.raw.on('tileHover', (t) => window.__hover.push(t && [t.row, t.col])); });
const from = await tileSpot(page, 7, hand.idx);
const to = await tileSpot(page, 9, 4, 0.3, 0.4); // an occupied melee tile (a swap), off its centre
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); }
for (let i = 1; i <= 12; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 12, from.y + ((to.y - from.y) * i) / 12); 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})`);
assertHeld(await ghostAt(page, hand.uid), to, 'mouse');
assert.deepEqual(await page.evaluate(() => window.__hover.at(-1)), [9, 4], 'tileHover = the tile under the pointer');
await page.screenshot({ path: path.join(OUT, 'models-drag-ghost.png') });
// the whole way: the model stays under the pointer (sampled at every step of a second drag leg)
const leg = await tileSpot(page, 10, 5);
for (let i = 1; i <= 6; i++) {
const pt = { x: to.x + ((leg.x - to.x) * i) / 6, y: to.y + ((leg.y - to.y) * i) / 6 };
await page.mouse.move(pt.x, pt.y);
await sleep(40);
assertHeld(await ghostAt(page, hand.uid), pt, `mouse step ${i}`);
}
await page.mouse.move(to.x, to.y);
await sleep(60);
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');
const c = await tileSpot(page, 9, 4);
assert.ok(Math.hypot(w.x - c.x, w.y - c.y) < 2, `the wheel opens on the pointer's tile (${w.x},${w.y} vs ${c.x},${c.y})`);
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 () => {
test('#4.1 equipment dropped on a tile equips the unit on it (the one behind, a bench operator); an empty tile takes nothing', async () => {
const { page, problems } = await open('phase=PREP');
const S = () => page.evaluate(() => { const p = globalThis.__MOCK__.S().priv; return { board: p.board.map((x) => ({ uid: x.uid, row: x.row, col: x.col, items: x.items.map((i) => i.uid) })), hand: p.hand.map((x) => x && { uid: x.uid, kind: x.kind, items: (x.items || []).map((i) => i.uid) }) }; });
// three items in the hand (slots 0, 1, 4 are free / an item in the mock)
await page.evaluate(() => globalThis.__MOCK__.mutate((M) => {
const src = M.priv.hand.find((x) => x && x.kind === 'item');
for (const i of [0, 1]) if (!M.priv.hand[i]) M.priv.hand[i] = { ...src, uid: 900 + i };
}));
await sleep(800);
let st = await S();
const items = st.hand.map((x, i) => x && x.kind === 'item' && { uid: x.uid, idx: i }).filter(Boolean);
const target = st.board.find((b) => b.row === 10 && b.col === 4), front = st.board.find((b) => b.row === 9 && b.col === 4);
const benchIdx = st.hand.findIndex((x) => x && x.kind === 'chess');
assert.ok(items.length >= 3 && target && front && benchIdx >= 0, 'premise');
const dragTo = async (it, to) => {
const from = await tileSpot(page, 7, it.idx);
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);
await page.mouse.up();
await sleep(700);
};
// (10,4) behind (9,4): the tile of the one behind — near its front edge, where the front one's head is drawn
await dragTo(items[0], await tileSpot(page, 10, 4, 0, -0.6));
st = await S();
assert.ok(st.board.find((b) => b.uid === target.uid).items.includes(items[0].uid), 'equipped on the unit behind');
assert.ok(!st.board.find((b) => b.uid === front.uid).items.includes(items[0].uid), 'not on the one in front');
await dragTo(items[1], await tileSpot(page, 7, benchIdx, 0.4, 0.4));
st = await S();
assert.ok(st.hand[benchIdx].items.includes(items[1].uid), 'equipped on the bench operator');
// the empty high-ground tile behind (12,4)? use a free deploy tile: the item stays in the hand
const free = await page.evaluate(() => { const S = globalThis.__MOCK__.S(), used = new Set(S.priv.board.map((b) => `${b.row},${b.col}`)); return S.stage.deployTiles.normal.melee.find(([r, c]) => !used.has(`${r},${c}`)); });
await dragTo(items[2], await tileSpot(page, free[0], free[1]));
st = await S();
assert.ok(st.hand.some((x) => x && x.uid === items[2].uid), 'an empty tile takes nothing: the item is back in the hand');
await page.close();
assert.deepEqual(problems, []);
});
// a phone (touch, landscape 844×390): the finger holds the model like the mouse (no lift above the finger); on the
// bench (the lowest canvas row, the shop bar right under it) a release on the slot lands there, one on the shop bar
// goes back
test('#4.1 touch (phone): held under the finger, dropped on the tile under it; a release over the shop bar goes back', 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(installHelpers);
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');
const touchDrag = async (from, to, { hold = null } = {}) => {
await page.touchscreen.touchStart(from.x, from.y);
for (let i = 1; i <= 12; i++) { await page.touchscreen.touchMove(from.x + ((to.x - from.x) * i) / 12, from.y + ((to.y - from.y) * i) / 12); await sleep(16); }
await sleep(100);
if (hold) await hold();
await page.touchscreen.touchEnd();
await sleep(800);
};
// a tap on a unit's tile selects it — and only that: the tap's compatibility click used to land on the underframe
// that had just opened over the tile (clamped under the top bar for rows 11–12) and pressed 撤退 (the unit left)
for (const row of [9, 10, 11, 12]) {
const u = await page.evaluate((r) => { const b = globalThis.__MOCK__.S().priv.board.find((p) => p.row === r && p.col === 4); return b && { uid: b.uid, n: globalThis.__MOCK__.S().requests.length }; }, row);
assert.ok(u, `premise: a unit on (${row},4)`);
const t = await tileSpot(page, row, 4);
await page.touchscreen.tap(t.x, t.y);
await sleep(600);
const s = await page.evaluate((u) => ({ onBoard: globalThis.__MOCK__.S().priv.board.some((p) => p.uid === u.uid), uframe: !!document.querySelector(`.uframe[data-uid="${u.uid}"]`), sent: globalThis.__MOCK__.S().requests.slice(u.n).map((r) => r[0]).filter((q) => q !== 'g.unitStats') }), u);
assert.deepEqual(s, { onBoard: true, uframe: true, sent: [] }, `a tap on (${row},4) selects its unit, nothing else`);
await page.keyboard.press('Escape');
await sleep(200);
}
// a bench operator onto the board: held under the finger, the wheel on the finger's tile
const hand = await page.evaluate(() => { const p = globalThis.__MOCK__.S().priv; const i = p.hand.findIndex((x) => x && x.kind === 'chess'); return { uid: p.hand[i].uid, idx: i }; });
const to = await tileSpot(page, 9, 4, -0.3, 0.3);
await touchDrag(await tileSpot(page, 7, hand.idx), to, { hold: async () => assertHeld(await ghostAt(page, hand.uid), to, 'touch') });
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 }; });
const c = await tileSpot(page, 9, 4);
assert.ok(Math.hypot(w.x - c.x, w.y - c.y) < 2, 'the wheel opens on the finger\'s tile');
await page.evaluate(() => document.querySelector('.fwheel__cancel')?.click());
await sleep(500);
// a board unit back onto the free bench slot 5: the finger on the slot → lands; on the shop bar below it → back
const unit = await page.evaluate(() => { const u = globalThis.__MOCK__.S().priv.board[0]; return { uid: u.uid, row: u.row, col: u.col }; });
const slot = await tileSpot(page, 7, 5);
// the first point below the slot's centre that the shop bar covers (it overlaps the lower half of bench slots 4–9)
const bar = await page.evaluate((p) => {
const cv = globalThis.__SP_VIEW__.raw.debug.app.view;
for (let dy = 2; dy < 80; dy += 2) { const el = document.elementFromPoint(p.x, p.y + dy); if (el && el !== cv && el.closest('.shopbar')) return { x: p.x, y: p.y + dy + 3 }; }
return null;
}, slot);
assert.ok(bar, 'premise: the shop bar covers the canvas right below the bench slot');
await touchDrag(await tileSpot(page, unit.row, unit.col), bar);
assert.ok(await page.evaluate((u) => globalThis.__MOCK__.S().priv.board.some((b) => b.uid === u), unit.uid), 'released over the shop bar: back on the board');
await touchDrag(await tileSpot(page, unit.row, unit.col), slot);
assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().priv.hand[5]?.uid ?? null), unit.uid, 'released on the bench slot: it is there');
await page.screenshot({ path: path.join(OUT, 'models-touch-phone.png') });
await page.close();
assert.deepEqual(problems, []);
});
+130 -177
View File
@@ -1,206 +1,159 @@
// 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).
// test/render/pick.test.js — render/pick.js, the one picking rule of every "which unit is under the pointer" path (user
// playtest #4 item 1: "地上都画好了一个一个方格,点击对应方格就选中那个方格的人物就行" — a press on a tile selects the unit on
// that tile). Checked on the geometry of the official cameras (render/projection.js presetCamera + pickTile: prep and
// battle at 1920×1080 and 1280×720, raised tops first): units in adjacent rows and columns, on raised high ground, on
// the bench (row 7) and the temporary bench (row 8); and the battle rule for walking / flying enemies (ground position,
// drawn body).
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 { presetCamera, pickTile } from '../../public/js/render/projection.js';
import { pickOnTile, pickBattle, ENEMY_REACH } 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 };
}
import { TILE_H } from '../../public/js/render/style.js';
import { GEO } from '../../shared/constants.js';
const CAMS = [['prep', 1920, 1080], ['prep', 1280, 720], ['normal', 1920, 1080], ['normal', 1280, 720]];
describe('pickBody on the official cameras: B directly behind A', () => {
/** A board on camera `kind`: tile heights (`raised` = [[r, c, z]…]), units standing on tiles, the ground under a point. */
function board(kind, W, H, raised = []) {
const cam = presetCamera(kind, { width: W, height: H });
const hz = new Map(raised.map(([r, c, z]) => [`${r},${c}`, z]));
const heightAt = (r, c) => hz.get(`${r},${c}`) || 0;
const levels = [...new Set([0, ...raised.map((x) => x[2])])];
const ground = (x, y) => { const t = pickTile(cam, x, y, heightAt, levels); return t && { row: t.row, col: t.col, x: t.x, y: t.y }; };
const unit = (key, row, col) => ({ key, tile: { row, col }, x: col, y: row, depth: unitDepthKey(cam, col, row) });
/** Screen point of a spot on the top face of tile (row, col): (u, v) ∈ [−0.5, 0.5] across / along the tile. */
const spot = (row, col, u = 0, v = 0) => cam.project(col + u, row + v, heightAt(row, col));
return { cam, heightAt, ground, unit, spot };
}
/** Spots inside a tile's top face: the centre and near its four edges and corners (0.4 of a tile out). */
const SPOTS = [[0, 0], [-0.4, 0], [0.4, 0], [0, -0.4], [0, 0.4], [-0.4, -0.4], [0.4, 0.4], [-0.4, 0.4], [0.4, -0.4]];
describe('pickOnTile: a press on a tile selects the unit on that tile (official cameras)', () => {
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}: a 3×3 block of units, the back row on high ground — every spot of a tile is its unit's`, () => {
// rows 9–11 × cols 3–5; row 11 raised like the ranged high ground (TILE_H.wall)
const B = board(kind, W, H, [[11, 3, TILE_H.wall], [11, 4, TILE_H.wall], [11, 5, TILE_H.wall]]);
const units = [];
for (let r = 9; r <= 11; r++) for (let c = 3; c <= 5; c++) units.push(B.unit(`${r},${c}`, r, c));
for (const u of units) {
for (const [du, dv] of SPOTS) {
const p = B.spot(u.tile.row, u.tile.col, du, dv);
assert.equal(pickOnTile(units, B.ground(p.x, p.y))?.key, u.key, `tile ${u.key} at (${du}, ${dv})`);
}
}
});
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(`${kind} ${W}×${H}: a press on a unit's head (drawn over the tile behind) is a press on that tile`, () => {
const B = board(kind, W, H);
const front = B.unit('front', 9, 4), behind = B.unit('behind', 10, 4);
const p = B.spot(9, 4);
const head = { x: p.x, y: p.y - 1.0 * p.s }; // a chibi is ~1.27 tiles tall, rows only ~0.85 tile apart on screen
assert.deepEqual([B.ground(head.x, head.y).row, B.ground(head.x, head.y).col], [10, 4], 'premise: the head is over row 10');
assert.equal(pickOnTile([front, behind], B.ground(head.x, head.y)).key, 'behind');
assert.equal(pickOnTile([front], B.ground(head.x, head.y)), null, 'an empty tile selects nothing');
const feet = B.spot(10, 4);
assert.equal(pickOnTile([front, behind], B.ground(feet.x, feet.y)).key, 'behind', 'its own tile, where the front one\'s head is drawn');
});
}
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`);
test('the bench (row 7) and the temporary bench (row 8): raised pads, one piece per slot', () => {
const pads = [];
for (let c = 0; c < GEO.HAND_SIZE; c++) pads.push([GEO.HAND_ROW, c, TILE_H.bench]);
for (let i = 0; i < GEO.TEMP_SIZE; i++) pads.push([GEO.TEMP_ROW, GEO.TEMP_C0 + i, TILE_H.bench]);
for (const [W, H] of [[1920, 1080], [1280, 720], [844, 390]]) {
const B = board('prep', W, H, pads);
const units = pads.map(([r, c]) => B.unit(`${r},${c}`, r, c));
// a board unit right in front of the temp row and behind nothing
units.push(B.unit('9,5', 9, 5));
for (const u of units) {
for (const [du, dv] of SPOTS) {
const p = B.spot(u.tile.row, u.tile.col, du * 0.9, dv * 0.9);
assert.equal(pickOnTile(units, B.ground(p.x, p.y))?.key, u.key, `${W}×${H} slot ${u.key} at (${du}, ${dv})`);
}
}
}
});
test('several on one tile (pen clusters, a held piece): the nearest to the pointer\'s point, ties the front-most', () => {
const t = { row: 15, col: 9 };
const a = { key: 'a', tile: t, x: 8.8, y: 15.15, depth: 1 }, b = { key: 'b', tile: t, x: 9.2, y: 15.15, depth: 1 }, c = { key: 'c', tile: t, x: 9, y: 14.8, depth: 2 };
assert.equal(pickOnTile([a, b, c], { ...t, x: 8.75, y: 15.2 }).key, 'a');
assert.equal(pickOnTile([a, b, c], { ...t, x: 9.3, y: 15.1 }).key, 'b');
assert.equal(pickOnTile([a, b, c], { ...t, x: 9, y: 14.7 }).key, 'c');
const d = { key: 'd', tile: t, x: 9, y: 15, depth: 5 }, e = { key: 'e', tile: t, x: 9, y: 15, depth: 3 };
assert.equal(pickOnTile([e, d], { ...t, x: 9.1, y: 15 }).key, 'd', 'same spot: the front-most');
assert.equal(pickOnTile([e, d], t).key, 'd', 'no point on the tile: the front-most');
});
test('bad input: nothing', () => {
const u = { tile: { row: 1, col: 1 }, x: 1, y: 1 };
assert.equal(pickOnTile([u], null), null);
assert.equal(pickOnTile([u], { row: 1.5, col: 1 }), null);
assert.equal(pickOnTile(null, { row: 1, col: 1 }), null);
assert.equal(pickOnTile([null, { x: 1, y: 1 }, { tile: null, x: 1, y: 1 }], { row: 1, col: 1 }), null);
assert.equal(pickOnTile([u], { row: 1, col: 2 }), null);
});
});
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 }; };
describe('pickBattle: allies by their tile, walking enemies by their ground position or drawn body, flying ones by their body', () => {
const ally = (key, row, col, depth = 0) => ({ key, tile: { row, col }, x: col, y: row, depth });
const foe = (key, x, y, depth = 0) => ({ key, tile: null, x, y, depth });
const at = (x, y) => ({ row: Math.round(y), col: Math.round(x), x, y });
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('an ally on the tile under the pointer; an empty tile with no enemy near: nothing', () => {
const A = ally('A', 10, 4);
assert.equal(pickBattle([A], at(4.3, 10.4), 0, 0).key, 'A');
assert.equal(pickBattle([A], at(4.3, 10.6), 0, 0), null, 'the tile behind');
assert.equal(pickBattle([A], null, 0, 0), null, 'off the grid');
});
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(`an enemy within ${ENEMY_REACH} tile of the pointer's ground point, the nearest of several`, () => {
const e1 = foe('e1', 6.3, 12.1), e2 = foe('e2', 6.9, 12.0);
assert.equal(pickBattle([e1, e2], at(6.4, 12.2), 0, 0).key, 'e1');
assert.equal(pickBattle([e1, e2], at(6.8, 11.9), 0, 0).key, 'e2');
assert.equal(pickBattle([e1], at(6.3 + ENEMY_REACH - 0.01, 12.1), 0, 0).key, 'e1');
assert.equal(pickBattle([e1], at(6.3 + ENEMY_REACH + 0.01, 12.1), 0, 0), null, 'too far');
});
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('an ally\'s tile and an enemy both there (a blocked enemy at the edge of the blocker\'s tile): the nearer, ties the ally', () => {
const A = ally('A', 10, 5), e = foe('e', 5.45, 10);
assert.equal(pickBattle([A, e], at(5.05, 10.05), 0, 0).key, 'A', 'the tile centre: the operator');
assert.equal(pickBattle([A, e], at(5.42, 10.02), 0, 0).key, 'e', 'on the enemy');
assert.equal(pickBattle([e, A], at(5.225, 10), 0, 0).key, 'A', 'halfway: the ally');
assert.equal(pickBattle([A, e], at(5.8, 10), 0, 0).key, 'e', 'the next tile: only the enemy is near');
});
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('a flying enemy: its drawn body (feet to head) within the reach × its px per tile, on screen', () => {
const f = { key: 'f', tile: null, x: 7, y: 11, fly: true, body: { x: 500, top: 260, feet: 340, s: 80 } };
const A = ally('A', 11, 7);
assert.equal(pickBattle([f], null, 510, 330).key, 'f', 'off any tile under it');
assert.equal(pickBattle([f], null, 505, 265).key, 'f', 'its head');
assert.equal(pickBattle([f], at(7, 11), 500 + ENEMY_REACH * 80 + 2, 300), null, 'the ground position does not count');
assert.equal(pickBattle([f], null, 500, 340 + ENEMY_REACH * 80 + 2), null, 'below its feet, beyond the reach');
assert.equal(pickBattle([f, A], at(7.4, 11.3), 505, 305).key, 'f', 'on the flyer, over an ally\'s tile');
assert.equal(pickBattle([f, A], at(7.02, 11), 500, 340 + 0.2 * 80).key, 'A', 'the ally\'s tile centre, the flyer 0.2 tile away: the ally is nearer');
});
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('a walking enemy: its drawn body counts too — a press on a tall enemy\'s / a boss\'s torso or head picks it', () => {
// a boss 2.4 tiles tall standing at (8, 12): its torso is drawn ~1.2 tiles above its feet, over the ground ~1 tile
// behind it — beyond the ground reach, on its body
const boss = { key: 'boss', tile: null, x: 8, y: 12, body: { x: 600, top: 600 - 2.4 * 90, feet: 600, s: 90 } };
assert.equal(pickBattle([boss], at(8.05, 13.1), 605, 600 - 1.2 * 90).key, 'boss', 'its torso');
assert.equal(pickBattle([boss], at(8, 13.8), 598, 600 - 2.3 * 90).key, 'boss', 'its head');
assert.equal(pickBattle([boss], at(8.3, 12.2), 700, 610).key, 'boss', 'at its feet (the ground rule)');
assert.equal(pickBattle([boss], at(8, 14.5), 600 + 0.7 * 90, 600 - 1.2 * 90), null, 'beside its body, beyond the reach');
// an ally on the tile its head is drawn over: the ally's tile centre stays the ally's, the head the enemy's
const e = { key: 'e', tile: null, x: 5, y: 10, body: { x: 400, top: 400 - 1.2 * 80, feet: 400, s: 80 } };
const A = ally('A', 11, 5);
assert.equal(pickBattle([A, e], at(5, 11), 400 + 0.3 * 80, 400 - 1.1 * 80).key, 'A', 'the ally\'s tile centre, 0.3 tile beside the head');
assert.equal(pickBattle([A, e], at(5, 10.6), 400, 400 - 1.0 * 80).key, 'e', 'on the enemy\'s head (over the near edge of that tile)');
});
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), []);
test('bad input: nothing', () => {
assert.equal(pickBattle(null, at(1, 1), 0, 0), null);
assert.equal(pickBattle([null, foe('x', NaN, 1), { key: 'y', tile: null, x: 1, y: 1, fly: true, body: { x: 1, top: 0, feet: 1, s: 0 } }], at(1, 1), 1, 1), null);
});
});
+15 -60
View File
@@ -195,65 +195,22 @@ 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 }));
// user playtest #4 item 1: picking is by tile (render/pick.js); bounds() is the body's screen rect for tooltips / overlays
describe('bounds (view.pieceScreenRect)', () => {
test('a unit: 0.7 tile wide, from its head (UNIT.headroom) to just below its feet; an enemy by its model height', async () => {
const v = view({ defId: 'char_p1', avatar: 'char_p1' }, { prep: true });
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 { x, y, s } = v.screen;
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');
assert.ok(Math.abs(r.x - (x - 0.35 * s)) < 1e-6 && Math.abs(r.width - 0.7 * s) < 1e-6);
assert.ok(Math.abs(r.y - (y - 1.18 * s)) < 1e-6 && Math.abs(r.y + r.height - (y + 0.1 * s)) < 1e-6);
assert.equal(typeof v.pickShape, 'undefined', 'no hit shapes any more');
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.actor.entry.bounds = { height: 640 }; // setup-pose bounds: 2 tiles (UNIT.modelScale 1/320) × 0.92
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);
const fr = foe.bounds();
assert.ok(Math.abs(fr.y - (foe.screen.y - foe._headTiles * foe.screen.s)) < 1e-6 && foe._headTiles > 1.5, 'its own height');
});
test('item plates: floating above the slot; centred on the pointer while dragged (lifted)', async () => {
@@ -262,16 +219,14 @@ describe('pickShape / bounds / hitTest', () => {
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');
let r = it.bounds();
assert.ok(Math.abs(r.y + r.height - it.screen.y) < 1e-6, 'resting: the plate above its anchor');
it.lift = 0.3;
it.update(1 / 60, cam(), 0);
b = it.pickShape();
r = it.bounds();
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.ok(Math.abs(r.x + r.width / 2 - g.x) < 1e-6 && Math.abs(r.y + r.height / 2 - 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));
});
});