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

+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';