feedback1 integrate: a form fx handed out late skips a change clip that would already have ended; browser check stalls then lets the clips catch up

This commit is contained in:
sganggs committed 2026-10-03 08:36:16 +08:00
1 parent f26e2014c4
commit 4bab58e508
4 files changed
+34 -16

No files matched your search

+1 -1
View File
@@ -1458,7 +1458,7 @@ export async function createFieldView(host, options = {}) {
if (form !== undefined) {
const inf = infos.get(e[4].id);
if (inf && (form === null || FORMS[inf.spine || inf.defId]?.[form])) inf.form = form;
const x = late > 0 && Number(e[4].dur) > 0 ? { ...e[4], dur: Math.max(0, Number(e[4].dur) - late) } : e[4];
const x = late > 0 ? { ...e[4], late, ...(Number(e[4].dur) > 0 ? { dur: Math.max(0, Number(e[4].dur) - late) } : {}) } : e[4];
views.get(e[4].id)?.setForm?.(form, x);
}
if (!(late > 0)) fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]);
+4 -1
View File
@@ -449,7 +449,10 @@ export class UnitView {
const dur = fx && Number(fx.dur);
let next = f && f.next ? FORMS[this.info.spine || this.info.defId]?.[f.next]?.roles || null : null;
if (next && typeof f.roles.die === 'string') next = { ...next, die: f.roles.die };
if (f) this.actor.setForm(f.roles, f.change || null, f.end && dur > 0 ? { clip: f.end, in: dur, roles: next } : null);
// an fx handed out late (render/app.js: a stall, a hidden tab) skips a change clip that would already have ended
const late = fx && Number(fx.late) > 0 ? Number(fx.late) : 0;
const change = f && f.change && !(late > 0 && late >= (this.actor.dur?.(f.change) ?? Infinity)) ? f.change : null;
if (f) this.actor.setForm(f.roles, change, f.end && dur > 0 ? { clip: f.end, in: dur, roles: next } : null);
else if (had) this.actor.setForm(null);
}
+26 -13
View File
@@ -12,7 +12,9 @@
// blocking 1 s long tasks (every frame a catch-up, and the render clock jumps past its 1.5 game s stale-event
// window) across the change of a 转译基底·α that walks into 百炼嘉维尔's block: the view turns 幽灵 (C_*) — the
// catch-up filter, and then render/interp.js's stale-event drop, used to lose the form fx, so it stayed on A_Move
// and died on B_Die.
// and died on B_Die. Whether a stall lands where a queued form fx falls out of the 1.5 game s window depends on the
// page's frame timing, so this is the real-page check; the deterministic one is test/match/feedback1-runner-forms
// (the runner feeding the render engine's buffer through a 1.2 s stall).
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/feedback1-forms.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/feedback1-*.png.
@@ -132,10 +134,10 @@ async function lateViewInPage(page, port, s, atSecs, setupSrc) {
* real ms long task, for `secs` real s. Returns samples { t, simForm, form, clip, spine, alive } of the 转译基底·α's view and the
* runner's catch-up count.
*/
async function runnerInPage(page, port, s, jankMs, secs) {
async function runnerInPage(page, port, s, jankMs, secs, calmSecs = 0) {
await page.goto(`http://127.0.0.1:${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 });
return page.evaluate(async (spec, jankMs, secs) => {
return page.evaluate(async (spec, jankMs, secs, calmSecs) => {
const { createBattleRunner } = await import('/js/battle/runner.js');
const { data } = await import('/js/data.js');
const v = window.__demo.view;
@@ -156,21 +158,28 @@ async function runnerInPage(page, port, s, jankMs, secs) {
const e = [...runner._entries.values()][0];
const out = [];
const end = performance.now() + secs * 1000;
while (performance.now() < end) {
const calmFrom = end;
const calmEnd = end + calmSecs * 1000;
while (performance.now() < calmEnd) {
// a blocking long task (the main thread stalls: the render engine's own ticker stalls too, so its clock jumps
// jankMs × 2 game s at once — past the 1.5 game s stale-event window), then one free frame
const until = performance.now() + jankMs;
while (performance.now() < until) { /* stall */ }
await sleep(16);
// jankMs × 2 game s at once — past the 1.5 game s stale-event window), one free frame for the renderer, then the
// runner's catch-up frame. After `secs` the stalls stop and the frames come smoothly for `calmSecs` (the clips
// catch up: PIXI caps a frame's delta).
const calm = performance.now() >= calmFrom;
if (!calm) {
const until = performance.now() + jankMs;
while (performance.now() < until) { /* stall */ }
}
await sleep(calm ? 33 : 16);
for (const fn of queue.splice(0)) fn(performance.now());
const tr = e.battle.units.find((u) => u.defId === 'enemy_10081_mpplai');
const view = tr && v.debug.views.get(tr.id);
if (tr) out.push({ t: +e.battle.time.toFixed(2), simForm: tr.form, form: view?.form ?? null, clip: view?.actor?.current ?? null, spine: !!view?.spineReady, alive: tr.alive });
if (tr) out.push({ t: +e.battle.time.toFixed(2), simForm: tr.form, form: view?.form ?? null, clip: view?.actor?.current ?? null, spine: !!view?.spineReady, alive: tr.alive, calm });
}
const catchups = runner.stats().catchups;
runner.dispose();
return { out, catchups };
}, s, jankMs, secs);
}, s, jankMs, secs, calmSecs);
}
describe('player reports after 0.1.0: the models follow the knock-out forms (headless Chrome, real sim)', { skip }, () => {
@@ -280,14 +289,18 @@ describe('player reports after 0.1.0: the models follow the knock-out forms (hea
test('the real client runner with blocking 1 s long tasks (every frame a catch-up, the render clock jumps 2 game s): 转译基底·α blocked by 百炼嘉维尔 turns 幽灵 in the view (C_*), never back to A_Move / B_Die', async () => {
const { p, problems } = await page();
try {
const { out, catchups } = await runnerInPage(p, srv.port, spec('enemy_10081_mpplai', 'runner', 9, 7), 1000, 12);
const { out, catchups } = await runnerInPage(p, srv.port, spec('enemy_10081_mpplai', 'runner', 9, 7), 1000, 9, 3);
await p.screenshot({ path: path.join(OUT, 'feedback1-runner.png') });
assert.ok(catchups >= 5, `catch-up frames (${catchups})`);
const changed = out.find((x) => x.simForm === 'translator_youling');
assert.ok(changed, `the sim's 转译基底·α turned 幽灵 (${JSON.stringify(out.slice(-3))})`);
const after = out.filter((x) => x.spine && x.t > changed.t + 2 + LAG);
const after = out.filter((x) => x.spine && x.t > changed.t + 2 + LAG && x.alive);
assert.ok(after.length > 0, 'samples after the change');
assert.ok(after.every((x) => x.form === 'translator_youling' && /^C_/.test(x.clip)), `幽灵's clips (${[...new Set(after.map((x) => `${x.form}:${x.clip}`))]})`);
// through the stalls the view is in the 幽灵 form (its 2 s change clip may still play: PIXI caps a frame's delta,
// so a clip advances 0.1 s per stalled frame) — never the first form's walk again
assert.ok(after.every((x) => x.form === 'translator_youling' && /^(C_|A_Die_C)/.test(x.clip)), `幽灵's clips (${[...new Set(after.map((x) => `${x.form}:${x.clip}`))]})`);
const calm = after.filter((x) => x.calm);
assert.ok(calm.length > 0 && /^C_/.test(calm[calm.length - 1].clip), `smooth frames again: 幽灵's own clips (${[...new Set(calm.map((x) => x.clip))]})`);
assert.ok(!out.some((x) => x.spine && /^B_/.test(x.clip)), 'never the 寻仇者\'s clips (B_Die was the look of report #5)');
assert.deepEqual(problems, []);
} finally {
+3 -1
View File
@@ -372,7 +372,9 @@ test('render/app.js hands the `form` of a sim fx (shared/protocol.js fxForm) wit
assert.match(src, /const form = fxForm\(e\);[\s\S]{0,250}inf\.form = form[\s\S]{0,250}setForm\?\.\(form, x\)/);
// a form fx handed out late (render/interp.js takeEvents `late`) switches the model with its closing clip shortened by
// the lateness and without replaying its telegraph
assert.match(src, /const x = late > 0 && Number\(e\[4\]\.dur\) > 0 \? \{ \.\.\.e\[4\], dur: Math\.max\(0, Number\(e\[4\]\.dur\) - late\) \} : e\[4\];/);
assert.match(src, /const x = late > 0 \? \{ \.\.\.e\[4\], late, \.\.\.\(Number\(e\[4\]\.dur\) > 0 \? \{ dur: Math\.max\(0, Number\(e\[4\]\.dur\) - late\) \} : \{\}\) \} : e\[4\];/);
// …and UnitView.setForm skips a change clip that would already have ended
assert.match(readFileSync(path.join(ROOT, 'public/js/render/units.js'), 'utf8'), /!\(late > 0 && late >= \(this\.actor\.dur\?\.\(f\.change\) \?\? Infinity\)\)/);
assert.match(src, /if \(!\(late > 0\)\) fx\.simFx\(/);
assert.match(src, /interp\.takeEvents\(renderT, EVS, renderT - 1\.5, LATE\);/);
});