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:
1 parent
88d419b095
commit
4e68100065
11 files changed
+259
-39
No files matched your search
@@ -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
@@ -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': {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user