feedback1 integrate: form fx survive the render engine's stale-event drop; hidden-tab backlog compacted and flushed when a finished battle is shown; same-field re-entry buffered

- render/interp.js isCosmeticEvent: an fx with a form is state, never dropped as stale nor shed from a full queue;
  a late one switches the model without its telegraph, its closing clip shortened (render/app.js)
- battle/runner.js: a backlog past HELD_MAX keeps only the last status / skill toggle per unit; a battle that ended
  while the tab was hidden delivers its backlog and last snapshot when the tab is shown
- screens/game.js: a new field meta for the field on screen buffers its frames until the view re-enters it
- browser test stalls the main thread (blocking 1 s long tasks); Node tests drive the runner into the render buffer
This commit is contained in:
sganggs committed 2026-10-03 07:30:52 +08:00
1 parent 88d419b095
commit 4e68100065
11 files changed
+259 -39

No files matched your search

+37 -6
View File
@@ -15,9 +15,10 @@
// ~1 Hz, the bounded fast-forward absorbs that); the server deadline + takeover cover anything worse.
// A frame that fast-forwards (catch-up) passes on only the state-bearing events (keepsState: spawns, deaths, deploys,
// statuses, skills, leaks and the fx that change an enemy's model form — shared/protocol.js fxForm); while the tab is
// hidden the battle on screen keeps the same events (≤ HELD_MAX, else the view re-enters from the field meta) and the
// first frame back delivers them, so the view still knows every unit and every enemy's current form (player report #5
// after 0.1.0: a 转译基底·α that changed form during a stall or in a background tab died in its first-form model).
// hidden the battle on screen keeps the same events (compacted past HELD_MAX; a backlog still past it makes the view
// re-enter from the field meta) and the first frame back delivers them — or, for a battle that ended while hidden, the
// visibilitychange itself — so the view still knows every unit and every enemy's current form (player report #5 after
// 0.1.0: a 转译基底·α that changed form during a stall or in a background tab died in its first-form model).
// Display replicas (a teammate's field after the own battle, 联防 observers, the partner of a boss pair) run the same
// spec fast-forwarded to the server's clock (`elapsed`) and never report.
// A b.result lost with the socket (the request failed DISCONNECTED / OFFLINE, or timed out twice) is kept and sent
@@ -75,8 +76,26 @@ const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill', 'leak']);
* enemy's model form (shared/protocol.js fxForm — dropped, the view kept the old model: player report #5 after 0.1.0).
*/
export const keepsState = (x) => Array.isArray(x) && (STATE_EV.has(x[0]) || fxForm(x) !== undefined);
/** Hidden-tab backlog cap (tuples) of the battle on screen; beyond it the view re-enters from the field meta. */
/**
* Hidden-tab backlog cap (tuples) of the battle on screen. Past it the backlog is compacted (compactHeld: only the last
* 'status' per unit and status, the last 'skill' per unit — what the view ends up showing); only a backlog still past it
* (thousands of spawns) makes the view re-enter from the field meta.
*/
export const HELD_MAX = 3000;
/**
* A hidden-tab backlog without the superseded toggles: of the 'status' tuples only the last per (unit, status), of the
* 'skill' tuples only the last per unit, every other tuple (spawn / die / deploy / leak / form fx — bounded by the units)
* kept; order preserved. A long hidden boss fight toggles statuses thousands of times, the rest stays small.
*/
export function compactHeld(list) {
const last = new Map();
list.forEach((x, i) => {
if (x[0] === 'status') last.set(`s:${x[1]}:${x[2]}`, i);
else if (x[0] === 'skill') last.set(`k:${x[1]}`, i);
});
return list.filter((x, i) => (x[0] === 'status' ? last.get(`s:${x[1]}:${x[2]}`) === i : x[0] === 'skill' ? last.get(`k:${x[1]}`) === i : true));
}
/** Data files the simulation reads (DataSource + content/support gameData()). */
export const SIM_DATA_FILES = Object.freeze(['chess', 'enemies', 'tokens', 'stages', 'waves', 'bonds', 'items', 'garrisons', 'bands', 'effects']);
@@ -343,7 +362,19 @@ export function createBattleRunner(deps) {
try { ev = e.battle.drainEvents() || []; } catch { ev = []; }
if (e !== cur || e.stale) return;
for (const x of ev) if (keepsState(x)) e.held.push(x);
if (e.held.length > HELD_MAX) { e.held = []; e.stale = true; }
if (e.held.length > HELD_MAX) {
e.held = compactHeld(e.held);
if (e.held.length > HELD_MAX) { e.held = []; e.stale = true; }
}
}
/**
* The tab is visible again: the battle on screen that ended while it was hidden (advance() no longer runs for it)
* delivers its backlog and last snapshot now — else the view kept the pre-hide state for the rest of the phase.
*/
function flushHidden() {
const e = cur;
if (e && e.battle.finished && (e.held.length || e.stale)) emitFrame(e, false);
}
function progress(e, force = false) {
@@ -671,7 +702,7 @@ export function createBattleRunner(deps) {
}));
}
if (doc && typeof doc.addEventListener === 'function') {
doc.addEventListener('visibilitychange', () => { if (!hidden()) schedule(); });
doc.addEventListener('visibilitychange', () => { if (hidden()) return; flushHidden(); schedule(); });
}
return {
+13 -6
View File
@@ -24,7 +24,7 @@
// user playtest #5 item 2) goes straight to the held pose, no burst; an
// fx with a `form` (shared/protocol.js fxForm) switches the enemy's
// model to that clip set (render/units.js FORMS)
// view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat):
// view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat):
// ~2-frame buffer at the battle's game speed
// view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'|
// 'hover'|'target'|{color,fill,line,group}; highlightTiles(null) clears all
@@ -1376,9 +1376,12 @@ export async function createFieldView(host, options = {}) {
}
const EVS = [];
/** State events handed out more than 1.5 game s late (a stall, a hidden tab, a field entered late) → how late. */
const LATE = new Map();
function processEvents(renderT) {
EVS.length = 0;
interp.takeEvents(renderT, EVS, renderT - 1.5);
LATE.clear();
interp.takeEvents(renderT, EVS, renderT - 1.5, LATE);
for (const e of EVS) {
try { handleEvent(e, renderT); } catch (err) { if (!handleEvent.warned) { handleEvent.warned = true; console.warn('[render] event failed', e, err); } }
}
@@ -1446,15 +1449,19 @@ export async function createFieldView(host, options = {}) {
// an enemy's mode change — the `form` of a sim setForm fx (shared/protocol.js fxForm: 掠海漂移体 → 爬行模式, user
// playtest #5 item 1; 转译基底's forms, a 逐火 ember and its revival, the leaders' 重生, 守墓石像 — user report after
// 0.1.0) — switches the view's clip set (UnitView.setForm; a kind without a clip set of that skeleton changes
// nothing); the info keeps it for a view built later. The client runner never drops these fx (catch-up frames,
// hidden-tab backlog), nor does the game screen's pre-entry buffer.
// nothing); the info keeps it for a view built later. No client stage drops these fx: battle/runner.js
// keepsState (catch-up frames, hidden-tab backlog), screens/game.js keepEarly (the pre-entry buffer) and
// render/interp.js isCosmeticEvent (stale-event drop, full-queue shed). One handed out late switches the model
// without its telegraph, its closing clip shortened by the lateness.
const form = fxForm(e);
const late = form !== undefined ? LATE.get(e) || 0 : 0;
if (form !== undefined) {
const inf = infos.get(e[4].id);
if (inf && (form === null || FORMS[inf.spine || inf.defId]?.[form])) inf.form = form;
views.get(e[4].id)?.setForm?.(form, e[4]);
const x = late > 0 && Number(e[4].dur) > 0 ? { ...e[4], dur: Math.max(0, Number(e[4].dur) - late) } : e[4];
views.get(e[4].id)?.setForm?.(form, x);
}
fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]);
if (!(late > 0)) fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]);
break;
}
case 'layer': {
+20 -5
View File
@@ -16,7 +16,8 @@
// * event queue: a `b.ev` batch is stamped with its game time (`gt`, the snapshot it was drained with); a batch
// without one is placed inside the latest snapshot interval (newest snapshot time minus half an interval),
// and handed out by `takeEvents()` once renderT passes the stamp. Stale cosmetic events (> `eventMaxLag`
// game s behind) are dropped by the caller's choice (`isCosmeticEvent`); state events are always delivered.
// game s behind) are dropped by the caller's choice (`isCosmeticEvent`); state events — an enemy's form fx
// included — are always delivered, and a full queue sheds only cosmetic ones.
//
// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Two optional lists ride along
// (server/sim/Battle.js snapshot, user playtest #4 items 8 / 9):
@@ -27,6 +28,8 @@
// `units`): 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.
import { fxForm } from '../../../shared/protocol.js';
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 });
/** Element keys a snapshot `elem` entry may carry (server/sim/constants.js ELEMENT_ORDER). */
const ELEMENT_KEYS = new Set(['neural', 'erosion', 'burn', 'apoptosis', 'necrosis']);
@@ -37,7 +40,13 @@ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
/** Cosmetic event kinds that may be dropped when far behind (never state-changing). */
export const COSMETIC_EVENTS = new Set(['atk', 'dmg', 'heal', 'fx', 'layer', 'bounty']);
export const isCosmeticEvent = (ev) => Array.isArray(ev) && COSMETIC_EVENTS.has(ev[0]);
/**
* Whether an event may be dropped when stale or shed from a full queue. An 'fx' that carries an enemy's model `form`
* (shared/protocol.js fxForm) is state: dropping it left the view on the old model after a long main-thread stall, on a
* watched field after a hidden tab and on a field entered late (user report #5 after 0.1.0) — the fourth place the client
* keeps it, with battle/runner.js keepsState and screens/game.js keepEarly.
*/
export const isCosmeticEvent = (ev) => Array.isArray(ev) && COSMETIC_EVENTS.has(ev[0]) && fxForm(ev) === undefined;
/**
* Game time (s) of a b.snap / b.ev payload, or NaN. On the wire every frame is `{ t: '<type>', … }`, so the server
@@ -308,9 +317,12 @@ export class SnapshotBuffer {
/**
* Remove and return (in order) the queued events due at `time` (default renderT). When `dropCosmeticBefore`
* is a number, cosmetic events stamped earlier than it are discarded instead of returned.
* is a number, cosmetic events stamped earlier than it are discarded instead of returned; a state event stamped
* earlier is returned, and when `late` (a Map) is given it records that event → how far (game s) its stamp lies
* before `time`, so the caller can skip its stale cosmetics (render/app.js: a late form fx switches the model without
* replaying its telegraph and with its closing clip shortened).
*/
takeEvents(time = this.renderT, out = [], dropCosmeticBefore = null) {
takeEvents(time = this.renderT, out = [], dropCosmeticBefore = null, late = null) {
if (!this.events.length || !Number.isFinite(time)) return out;
let n = 0;
while (n < this.events.length && this.events[n].t <= time) n++;
@@ -318,7 +330,10 @@ export class SnapshotBuffer {
const list = this.events;
for (let i = 0; i < n; i++) {
const e = list[i];
if (typeof dropCosmeticBefore === 'number' && e.t < dropCosmeticBefore && isCosmeticEvent(e.ev)) continue;
if (typeof dropCosmeticBefore === 'number' && e.t < dropCosmeticBefore) {
if (isCosmeticEvent(e.ev)) continue;
if (late) late.set(e.ev, time - e.t);
}
out.push(e.ev);
}
const rest = list.length - n;
+2 -2
View File
@@ -27,7 +27,7 @@
// redeploy) plays the deploy clip and restores the normal look; `setDown(null)` on a dead view lets it fade out.
// An operator that enters a battle already knocked out (联防, user playtest #5 item 2: sim 'die' reason 'forcedExit')
// goes down with `die(true)`: straight to the held end of the clip, no fall.
// Enemy modes (sim fx 'phase' { id, kind } or another fx's `form` → `setForm(kind, fx)`): 掠海漂移体 dropping to 爬行模式
// Enemy modes (the `form` of a sim fx — shared/protocol.js fxForm — → `setForm(form, fx)`): 掠海漂移体 dropping to 爬行模式
// (user playtest #5 item 1) plays its skeleton's 'Change' clip once, then the crawl set (*_02); 暴鸰 flies on without its
// bomb (*_2) after the drop (feedback D4); 转译基底's forms, the 逐火 embers, 再生's puppet, the leaders' 重生 and 守墓石像
// likewise (user report after 0.1.0) — FORMS. A view built later (`info.form` = UnitInfo `form`, the sim's current form,
@@ -113,7 +113,7 @@ export const ALIAS_TINT = Object.freeze({ enemy_1305_mhslim: 0xffc48a, enemy_130
export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, gap: 1, pulse: 0.45, pulseHz: 1.5 });
/**
* Enemy modes drawn with another clip set of the same skeleton (sim fx 'phase' kind, or the `form` of another sim fx
* Enemy modes drawn with another clip set of the same skeleton (the `form` of a sim fx — shared/protocol.js fxForm —
* → UnitView.setForm), per Spine id; the mode's roles override the manifest's (data/assets.json anims), `change` plays
* once first:
* - 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change';
+14 -3
View File
@@ -328,6 +328,10 @@ function MatchScreen() {
// m.field re-render): kept per fieldId and replayed on enter so no unit's 'spawn' UnitInfo is ever lost.
const evBufRef = useRef(new Map()); // fieldId → ev tuples since that field's m.field
const snapBufRef = useRef(new Map()); // fieldId → latest snapshot
// a new m.field for the field already on screen (a resync, or the runner re-showing it after a hidden-tab backlog
// overflowed): its frames are buffered like a new field's until the enter effect re-enters it — enterBattle resets
// the view, so a 'spawn' or form fx pushed in between used to be lost (placeholder views, the old model)
const reentryRef = useRef(null);
// prep rendering (own board) vs battle rendering (m.field). The field object present when prep starts is stale
// (last round's battle): combat only enters an m.field pushed after that (a new object), and every new m.field
@@ -361,6 +365,7 @@ function MatchScreen() {
viewModeRef.current = 'prep';
lastFieldRef.current = null;
enteredFieldRef.current = null;
reentryRef.current = null;
setHud(null);
}
if (priv) {
@@ -376,6 +381,7 @@ function MatchScreen() {
if (lastFieldRef.current && lastFieldRef.current !== field.fieldId) setDetail((d) => (d?.kind === 'unit' ? null : d));
enteredFieldRef.current = field;
lastFieldRef.current = field.fieldId;
reentryRef.current = null;
viewModeRef.current = 'battle';
snapUnitsRef.current = new Map();
view.enterBattle(field);
@@ -423,10 +429,15 @@ function MatchScreen() {
let pending = null;
const flush = () => { pending = null; last = performance.now(); setHud(hudRef.current); };
const onFieldMeta = (msg) => {
if (msg && typeof msg.fieldId === 'string') { evBufRef.current.set(msg.fieldId, []); snapBufRef.current.delete(msg.fieldId); }
if (!msg || typeof msg.fieldId !== 'string') return;
evBufRef.current.set(msg.fieldId, []);
snapBufRef.current.delete(msg.fieldId);
if (msg.fieldId === lastFieldRef.current) reentryRef.current = msg.fieldId;
};
// the field the view shows now (null while a re-entry of it is pending: its frames are buffered)
const shownId = () => (reentryRef.current && reentryRef.current === lastFieldRef.current ? null : lastFieldRef.current);
const onSnap = (snap) => {
const cur = lastFieldRef.current;
const cur = shownId();
if (!snap || typeof snap !== 'object') return;
if (!cur || (snap.fieldId && snap.fieldId !== cur)) {
if (typeof snap.fieldId === 'string') snapBufRef.current.set(snap.fieldId, snap);
@@ -444,7 +455,7 @@ function MatchScreen() {
else if (!pending) pending = setTimeout(flush, HUD_HZ_MS - dt);
};
const onEv = (msg) => {
const cur = lastFieldRef.current;
const cur = shownId();
if (!msg || !Array.isArray(msg.ev)) return;
if (!cur || (msg.fieldId && msg.fieldId !== cur)) {
if (typeof msg.fieldId !== 'string') return;