From 8de9002d1a5c1d534fd540564bc6d21fe9caee56 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 07:16:34 +0800 Subject: [PATCH] feedback1f WV: the client draws a knocked-out operator on the tile it lies on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- public/js/render/interp.js | 11 +- public/js/render/units.js | 21 ++-- .../feedback1f-down-tile.browser.test.js | 113 ++++++++++++++++++ test/render/feedback1f-down-tile.test.js | 64 ++++++++++ 4 files changed, 198 insertions(+), 11 deletions(-) create mode 100644 test/render/feedback1f-down-tile.browser.test.js create mode 100644 test/render/feedback1f-down-tile.test.js diff --git a/public/js/render/interp.js b/public/js/render/interp.js index eb284853..ba324a12 100644 --- a/public/js/render/interp.js +++ b/public/js/render/interp.js @@ -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, 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 }; diff --git a/public/js/render/units.js b/public/js/render/units.js index c2dcccb5..17c9552b 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -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 }); diff --git a/test/render/feedback1f-down-tile.browser.test.js b/test/render/feedback1f-down-tile.browser.test.js new file mode 100644 index 00000000..3a0f9855 --- /dev/null +++ b/test/render/feedback1f-down-tile.browser.test.js @@ -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(); + } + }); +}); diff --git a/test/render/feedback1f-down-tile.test.js b/test/render/feedback1f-down-tile.test.js new file mode 100644 index 00000000..06f92b94 --- /dev/null +++ b/test/render/feedback1f-down-tile.test.js @@ -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]); +});