feedback1f WV: the client draws a knocked-out operator on the tile it lies on

render/interp.js keeps the body tile of a b.snap `down` entry (row, col; only
when both are integers, so a 4-element entry is unchanged) and
render/units.js setDown moves the knocked-down view and its ring there: an
operator that fell on another piece's home lies on its own home (PRTS
卫戍协议/帮助 "…尝试返回其自身的初始位置"), and a view built from the unit's
deploy info (a field joined mid-battle) no longer stays on its first tile.

Tests: test/render/feedback1f-down-tile.test.js (fake PIXI, the sim's own
snapshot); opt-in test/render/feedback1f-down-tile.browser.test.js (real sim
frames in headless Chrome: drawn, picked and redeployed on its own home).
This commit is contained in:
sganggs committed 2026-10-03 07:16:34 +08:00
1 parent 5a3d562b75
commit 8de9002d1a
4 files changed
+198 -11

No files matched your search

+7 -4
View File
@@ -23,8 +23,9 @@
// * `elem` [[id, element, fill, cooldownEnd, cooldown]] — the element gauge a unit shows: appended to that unit's
// normalised tuple (EL…EL_DUR) and handed out by sample() as `el`, `elFill`, `elUntil`, `elDur` (from the older
// snapshot, like flags);
// * `down` [[id, respawnAt, respawnTime, state]] — knocked-out operators waiting to redeploy (they are no longer in
// `units`): downAt(time) returns the list of the snapshot at `time`.
// * `down` [[id, respawnAt, respawnTime, state, row?, col?]] — knocked-out operators waiting to redeploy (they are no
// longer in `units`) and the tile they lie on (where they fell, or their home — sim Battle._layBody; kept only when
// both are integers): downAt(time) returns the list of the snapshot at `time`.
// Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT.
export const TUPLE = Object.freeze({ ID: 0, X: 1, Y: 2, HP: 3, MAXHP: 4, SP: 5, SPMAX: 6, FLAGS: 7, ANIM: 8, EL: 9, EL_FILL: 10, EL_UNTIL: 11, EL_DUR: 12 });
@@ -52,7 +53,7 @@ export function frameTime(msg) {
/**
* Validate & normalise a b.snap payload. Returns `{ t, units: Map<id, tuple>, down: [[id, respawnAt, respawnTime,
* state]] | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default
* state, row?, col?]] | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default
* to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` entries are dropped.
*/
export function normalizeSnapshot(snap) {
@@ -80,7 +81,9 @@ export function normalizeSnapshot(snap) {
if (Array.isArray(snap.down)) {
for (const d of snap.down) {
if (!Array.isArray(d) || !(typeof d[0] === 'number' || typeof d[0] === 'string')) continue;
(down || (down = [])).push([d[0], finite(d[1]), Math.max(0, finite(d[2])), finite(d[3]) | 0]);
const e = [d[0], finite(d[1]), Math.max(0, finite(d[2])), finite(d[3]) | 0];
if (Number.isInteger(d[4]) && Number.isInteger(d[5])) e.push(d[4], d[5]);
(down || (down = [])).push(e);
}
}
return { t, units, down, raw: snap };
+14 -7
View File
@@ -16,8 +16,9 @@
// plane). `setDir(dir)` re-orients a live view (swapping Front ⇄ Back without a fallback flash). Enemies flip by the
// sign of their horizontal velocity (with hysteresis).
//
// Knocked-out operators (user playtest #4 item 9, b.snap `down`): `setDown([id, respawnAt, respawnTime, state])`
// keeps a dead operator on its tile in its knocked-down pose — the Spine Die clip played once and held on its last
// Knocked-out operators (user playtest #4 item 9, b.snap `down`): `setDown([id, respawnAt, respawnTime, state, row,
// col])` keeps a dead operator on the tile it lies on (row / col: where it fell, or its home — sim Battle._layBody,
// player report F5 after 0.1.0) in its knocked-down pose — the Spine Die clip played once and held on its last
// frame (the collapsed / kneeling pose with closed eyes), slightly greyed — with a redeploy ring above its head:
// a dark disc, a mint arc filling as the respawn timer runs and the seconds left; once the timer is done and it still
// waits, a full amber ring with "DP" (not enough DP) or a red ring with "!" (its tile is taken). `onDeploy` (the
@@ -589,11 +590,13 @@ export class UnitView {
}
/**
* Knocked-out state (b.snap `down` entry `[id, respawnAt, respawnTime, state]`, render/interp.js downAt) or null;
* `t` = the render clock's game time. A living view is knocked down first (its Die clip plays; `instant`: a view made
* for a unit that is already down — a field joined mid-battle — starts on the held end of the clip). While down the
* view never fades: the Die clip's last frame stays on the tile under the redeploy ring. null on a view still down
* (it left for good) starts the fade; the redeploy itself comes through onDeploy / revive.
* Knocked-out state (b.snap `down` entry `[id, respawnAt, respawnTime, state, row?, col?]`, render/interp.js downAt)
* or null; `t` = the render clock's game time. A living view is knocked down first (its Die clip plays; `instant`: a
* view made for a unit that is already down — a field joined mid-battle — starts on the held end of the clip). While
* down the view never fades: the Die clip's last frame stays on the tile the entry names (the view moves there when
* it stands elsewhere: a body that went back to its home, a view built from the unit's deploy info) under the
* redeploy ring. null on a view still down (it left for good) starts the fade; the redeploy itself comes through
* onDeploy / revive.
*/
setDown(d, t, instant = false) {
if (Number.isFinite(t)) this.gameT = t;
@@ -604,6 +607,10 @@ export class UnitView {
return;
}
if (this.alive) this.die(instant);
if (Number.isInteger(d[4]) && Number.isInteger(d[5]) && (this.x !== d[5] || this.y !== d[4])) {
this.x = d[5]; this.y = d[4];
this.z = this.zTarget = groundZ(this.ctx, this.x, this.y);
}
if (this.zTarget == null) this.z = this.zTarget = groundZ(this.ctx, this.x, this.y); // never synced: its tile top
this.dying = 0; // no death fade while down
const dn = this.down || (this.down = { until: 0, total: 0, state: DOWN_STATE.COUNTING });
@@ -0,0 +1,113 @@
// Player report F5 after the 0.1.0 release in headless Chrome through the render demo (public/dev/render-demo.html,
// real Spine models): the real sim's frames (Battle.snapshot / drainEvents, fed like the client runner's local feed) of
// an operator that jumped onto a teammate's home tile (a 突袭 landing) and was knocked out there. Officially it goes back
// to its own home ("若干员被击倒的位置为其他干员或召唤物的初始位置,则在被击倒后,尝试返回其自身的初始位置", PRTS
// 卫戍协议/帮助): the knocked-down model and its ring are drawn on its own home (b.snap `down` row / col), a press there
// selects it, the teammate's home stays empty, and it redeploys on that tile.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/feedback1f-down-tile.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/feedback1f-down-*.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 { makeBattle } from '../helpers/battleHarness.js';
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)';
describe('a knocked-out operator is drawn on the tile it lies on (real sim frames, headless Chrome)', { skip }, () => {
let srv, browser;
before(async () => {
const puppeteer = (await import('puppeteer-core')).default;
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: true, args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('fell on a teammate\'s home → drawn, picked and redeployed on its own home', async () => {
// 宴 (home 12,3) and 休谟斯 (home 10,3), real chess on 战场#01; no enemies, the battle keeps running
const h = makeBattle({ stageId: 'act2autochess_m01', units: [{ chessId: 'chess_char_1_18_a', row: 12, col: 3 }, { chessId: 'chess_char_2_09_a', row: 10, col: 3 }], autoFinish: false, timeLimit: 300 });
const b = h.b;
b.step();
const A = h.unit('chess_char_1_18_a'), B = h.unit('chess_char_2_09_a');
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()}`); });
const feed = async (n) => {
for (let i = 0; i < n; i++) {
b.step();
const ev = b.drainEvents();
await page.evaluate((snap, ev) => {
const v = window.__demo.view;
if (ev.length) v.pushEvents({ t: 'b.ev', fieldId: snap.fieldId, gt: snap.t, ev });
v.pushSnapshot({ t: 'b.snap', gt: snap.t, ...snap });
return new Promise((r) => requestAnimationFrame(() => r()));
}, b.snapshot(), ev);
}
};
const viewOf = (id) => page.evaluate((id) => { const x = window.__demo.view.debug.views.get(id); return x ? { x: x.x, y: x.y, alive: x.alive, down: x.down ? x.down.state : null, ring: !!x._downRing?.root?.visible } : null; }, id);
const pickAt = (r, c) => page.evaluate((r, c) => {
const v = window.__demo.view, t = v.tileScreen(r, c), rc = v.debug.app.view.getBoundingClientRect();
return v.debug.pick.battleUnitAt(t.x - rc.left, t.y - rc.top)?.id ?? null;
}, r, c);
try {
await page.setViewport({ width: 1280, height: 720 });
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 });
const meta = { ...b.fieldMeta(), fieldId: b.fieldId };
await page.evaluate((meta) => {
const v = window.__demo.view;
v.enterBattle(meta);
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
}, meta);
await feed(20);
await new Promise((r) => setTimeout(r, 1500)); // Spine models load
// A away from its home (11,6), B lands on A's home (12,3) — a 突袭-like redeployment — and is knocked out there
b.retreat(A, { reason: 'raid' });
assert.ok(b.redeploy(A, { free: true, tile: [11, 6] }));
b.retreat(B, { reason: 'raid' });
assert.ok(b.redeploy(B, { free: true, tile: [12, 3] }));
await feed(30);
assert.deepEqual(await viewOf(B.id).then((v) => [v.x, v.y]), [3, 12], 'B stands on A\'s home');
b.kill(B);
await feed(90);
await new Promise((r) => setTimeout(r, 1200)); // the Die clip runs out (its last frame stays)
await feed(10);
const vb = await viewOf(B.id);
await page.screenshot({ path: path.join(OUT, 'feedback1f-down-home.png') });
assert.equal(vb.alive, false);
assert.equal(vb.down, 0, 'knocked down, counting');
assert.ok(vb.ring, 'its redeploy ring');
assert.deepEqual([vb.x, vb.y], [3, 10], 'drawn on its own home (10,3), not on A\'s (12,3)');
assert.equal(await pickAt(10, 3), B.id, 'a press on its own home selects it');
assert.equal(await pickAt(12, 3), null, 'A\'s home is empty');
// its timer runs out (休谟斯 70 s at 2×): it redeploys on its own home
while (!B.alive && b.time < 200) b.step();
assert.ok(B.alive, 'redeployed');
assert.deepEqual([B.tileR, B.tileC], [10, 3]);
await feed(30);
const back = await viewOf(B.id);
await page.screenshot({ path: path.join(OUT, 'feedback1f-down-redeployed.png') });
assert.equal(back.alive, true);
assert.equal(back.down, null);
assert.deepEqual([back.x, back.y], [3, 10]);
assert.deepEqual(problems, []);
} finally {
await page.close();
}
});
});
+64
View File
@@ -0,0 +1,64 @@
// Player report F5 after the 0.1.0 release, client side: b.snap `down` entries carry the tile a knocked-out operator
// lies on ([id, respawnAt, respawnTime, state, row, col] — where it fell, or its own home when it fell on another board
// piece's home: PRTS 卫戍协议/帮助 "若干员被击倒的位置为其他干员或召唤物的初始位置,则在被击倒后,尝试返回其自身的初始
// 位置"; sim Battle._layBody). render/interp.js keeps them and render/units.js draws the knocked-down view on that tile
// (headless fake PIXI, test/render/fakepixi.js); older 4-element entries keep the view where it is.
import { test, 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 } from '../../public/js/render/interp.js';
import { makeBattle, chessRec } from '../helpers/battleHarness.js';
let fake, UnitView, DOWN_STATE, groundZ;
before(async () => {
fake = installFakePixi();
({ UnitView, DOWN_STATE, groundZ } = await import('../../public/js/render/units.js'));
});
after(() => fake.restore());
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
const view = (info = {}) => new UnitView(fakeViewCtx(fake.P, { cam }), { id: 1, side: 'ally', kind: 'chess', defId: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT', ...info });
const frames = (v, n) => { for (let i = 0; i < n; i++) v.update(1 / 60, cam(), i / 60); };
test('interp keeps the body tile of a `down` entry (integers only); a 4-element entry stays as it was', () => {
const s = normalizeSnapshot({ t: 2, units: [], down: [[7, 20, 18, 0, 9, 8], [8, 21, 18, 0], [9, 22, 18, 0, 9.5, 'x']] });
assert.deepEqual(s.down, [[7, 20, 18, 0, 9, 8], [8, 21, 18, 0], [9, 22, 18, 0]]);
const buf = new SnapshotBuffer({ delay: 0 });
buf.push({ t: 1, units: [], down: [[7, 20, 18, 0, 10, 3]] }, 0);
assert.deepEqual(buf.downAt(1), [[7, 20, 18, 0, 10, 3]]);
});
test('the knocked-down view is drawn on the tile the entry names: a body back on its home moves there', () => {
const v = view({ x: 6, y: 12 }); // fell on another piece's home (12,6)
frames(v, 2);
v.setDown([1, 30, 20, DOWN_STATE.COUNTING, 10, 3], 10); // it lies on its own home (10,3)
assert.equal(v.alive, false);
assert.deepEqual([v.x, v.y], [3, 10]);
frames(v, 30);
assert.equal(v.z, groundZ(v.ctx, 3, 10), 'on that tile\'s ground');
assert.ok(v._downRing.root.visible, 'its ring follows');
// a 4-element entry (an older feed) leaves it where it is
const w = view({ id: 2, x: 4, y: 11 });
frames(w, 2);
w.setDown([2, 30, 20, DOWN_STATE.COUNTING], 10);
assert.deepEqual([w.x, w.y], [4, 11]);
});
test('end to end: the sim\'s snapshot names the tile the body lies on, the view follows it', () => {
const op = (id) => chessRec({ id, profession: 'WARRIOR', skill: null });
const h = makeBattle({ defs: { chess: { e_a: op('e_a'), e_b: op('e_b') } }, units: [{ chessId: 'e_a', row: 12, col: 3 }, { chessId: 'e_b', row: 10, col: 3 }], content: 'none', autoFinish: false });
h.step();
const b = h.b, A = h.unit('e_a'), B = h.unit('e_b');
b.retreat(A, { reason: 'raid' });
b.redeploy(A, { free: true, tile: [11, 6] });
b.retreat(B, { reason: 'raid' });
b.redeploy(B, { free: true, tile: [12, 3] }); // on A's home
const v = view({ id: B.id, x: B.x, y: B.y });
frames(v, 2);
b.kill(B);
const d = normalizeSnapshot(b.snapshot()).down.find((e) => e[0] === B.id);
assert.deepEqual(d.slice(4), [10, 3], 'back on its own home');
v.setDown(d, b.time);
assert.deepEqual([v.x, v.y], [3, 10]);
});