feedback1 WD: form changes survive the client's catch-up frames, hidden tab and pre-entry buffer

Every setForm fx now carries 'form' (shared/protocol.js fxForm). The runner keeps
these fx in catch-up frames (keepsState), holds the state-bearing events of the
battle on screen while the tab is hidden and delivers them with the first frame
back (re-entering from the field meta past HELD_MAX); screens/game.js buffers
them before a field is entered. A husk beaten while its stand-up clip plays dies
on the husk's clip. 幽灵 form cited from PRTS; 特战术师 flyer question recorded.
This commit is contained in:
sganggs committed 2026-10-03 04:27:24 +08:00
1 parent e1ce0316dd
commit 2591b8638e
11 files changed
+382 -47

No files matched your search

+38 -4
View File
@@ -13,6 +13,11 @@
// and b.result (compactResult) at the end; applies b.pool (LocalBossPool.sync) and b.end (forceEnd / takeover),
// * keeps an authoritative battle running while the tab is hidden (a 250 ms interval pump; browsers throttle it to
// ~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).
// 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
@@ -53,7 +58,7 @@
import { net as appNet } from '../net.js';
import { store as appStore } from '../store.js';
import { unitStatsEntry } from '../../../shared/protocol.js';
import { unitStatsEntry, fxForm } from '../../../shared/protocol.js';
const TICK = 1 / 30;
/** Fast-forward budget per frame (ticks) when far behind. */
@@ -62,6 +67,13 @@ export const CATCHUP_TICKS = 240;
const PREPARE_SLICE = 600;
const MAX_ENTRIES = 4;
const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill', 'leak']);
/**
* The b.ev tuples a catch-up frame or the hidden-tab backlog keeps: the state-bearing kinds, and every fx that sets an
* 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. */
export const HELD_MAX = 3000;
/** 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']);
@@ -304,16 +316,33 @@ export function createBattleRunner(deps) {
}
function emitFrame(e, catchingUp) {
// the hidden-tab backlog overflowed: the view starts again from the field meta (UnitInfo carries every unit's form)
if (e.stale) { show(e); return; }
let ev = [];
try { ev = e.battle.drainEvents() || []; } catch { ev = []; }
const gt = Number(e.battle.time) || 0;
if (ev.length) {
const list = catchingUp ? ev.filter((x) => Array.isArray(x) && STATE_EV.has(x[0])) : ev;
const held = e.held;
if (held.length) e.held = [];
if (ev.length || held.length) {
const list = held.concat(catchingUp ? ev.filter(keepsState) : ev);
if (list.length) emit('ev', { t: 'b.ev', fieldId: e.fieldId, gt, ev: list });
}
try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); }
}
/**
* Events of a step that is not rendered (hidden tab): the battle on screen keeps its state-bearing ones for the next
* rendered frame (keepsState, ≤ HELD_MAX — beyond that it is re-entered from the field meta); any other battle starts
* from its field meta when shown (show()), so its events go.
*/
function hold(e) {
let ev = [];
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; }
}
function progress(e, force = false) {
if (!e.authoritative || e.resultSent || !net) return;
const t = now();
@@ -420,7 +449,7 @@ export function createBattleRunner(deps) {
const dt = stepEntry(e, n);
if (dt > stats.maxFrameMs) stats.maxFrameMs = dt;
if (render) emitFrame(e, catchingUp);
else { try { e.battle.drainEvents(); } catch { /* ignore */ } }
else hold(e);
noteLeaks(e);
progress(e);
if (e.battle.finished) finished(e);
@@ -456,6 +485,8 @@ export function createBattleRunner(deps) {
/** Put an entry on screen: field meta into the store (the game screen enters it), then its current frame. */
function show(e) {
cur = e;
e.held = [];
e.stale = false;
let meta = null;
try { meta = e.battle.fieldMeta(); } catch { meta = { units: [] }; }
const field = {
@@ -538,6 +569,9 @@ export function createBattleRunner(deps) {
leaks: 0, leakMark: '', left: null,
// live bond layers grown in this battle { [playerId]: { [bondId]: n } } and their total gain (noteLayers)
live: null, layerSum: 0,
// state-bearing events of the steps run while the tab was hidden (hold(); delivered by the next rendered frame),
// and whether that backlog overflowed (the next frame re-enters the view from the field meta)
held: [], stale: false,
};
if (lastPool && battle.sharedBoss && typeof battle.sharedBoss.sync === 'function') {
battle.sharedBoss.sync(lastPool.hp, lastPool.acked ? lastPool.acked[e.fieldId] : undefined);
+16 -12
View File
@@ -21,8 +21,10 @@
// operators on the field under a redeploy ring and `elem` draws the
// element gauges (user playtest #4 items 8 / 9, render/units.js); a
// 'die' with reason FORCED_EXIT (an operator entering 联防 knocked out,
// user playtest #5 item 2) goes straight to the held pose, no burst
// view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat):
// 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):
// ~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
@@ -89,6 +91,7 @@
// `data` is the client data store (public/js/data.js: lookup(file, id)) or plain { chess, tokens, items, enemies } maps.
import { GEO, ANIM, UF } from '../../../shared/constants.js';
import { fxForm } from '../../../shared/protocol.js';
import { Camera, presetCamera, lerpCamera, easeInOutCubic, pickTile, normRect } from './projection.js';
import { SnapshotBuffer, frameTime } from './interp.js';
import { TileField } from './tiles.js';
@@ -1430,24 +1433,25 @@ export async function createFieldView(host, options = {}) {
break;
}
case 'status': { const v = views.get(e[1]); if (v) v.onStatus?.(e[2], !!e[3]); break; }
case 'fx':
case 'fx': {
if (e[4] && typeof e[4] === 'object' && e[4].consumed && e[4].id != null) {
consumedIds.add(e[4].id);
if (consumedIds.size > 200) consumedIds.delete(consumedIds.values().next().value);
}
// an enemy's mode change (掠海漂移体 → 爬行模式, user playtest #5 item 1; 转译基底's forms, a 逐火 ember and its
// revival, the leaders' 重生 — user report after 0.1.0): a 'phase' kind, or the `form` any other fx carries,
// 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
const ex4 = e[4] && typeof e[4] === 'object' && e[4].id != null ? e[4] : null;
const form = !ex4 ? undefined : e[1] === 'phase' ? ex4.kind : 'form' in ex4 ? ex4.form : undefined;
// 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.
const form = fxForm(e);
if (form !== undefined) {
const inf = infos.get(ex4.id);
if (inf && (typeof form !== 'string' || FORMS[inf.spine || inf.defId]?.[form])) inf.form = typeof form === 'string' ? form : null;
views.get(ex4.id)?.setForm?.(form, ex4);
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]);
}
fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]);
break;
}
case 'layer': {
const bondId = e[2], n = Number(e[3]) || 0;
if (!(n > 0) || typeof bondId !== 'string') break;
+8 -6
View File
@@ -109,7 +109,8 @@ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, g
* - 转译基底·α (user report after 0.1.0, #5): its 2 s change clip A_Die_B / _C / _D, then 寻仇者 B_*, 幽灵 C_* (no attack
* clip: it never attacks) or 特战术师 D_* — its manifest roles are the original form's A_Idle / A_Move;
* - 深池逐火战士 / 精锐战士 / 护卫 (#8): knocked out → 'Die' (the 1 s 重生), the 余烬 on Idle_2 / Move_2 and its death on
* Die_2; standing up again → 'Revive', then the warrior's manifest clips;
* Die_2 (also while 'Revive' plays: the ember can still be beaten); standing up again → 'Revive', then the warrior's
* manifest clips;
* - 假想敌:再生: knocked out → A_Die, the 傀儡 on B_Idle / B_Move / B_Die; back → B_Revive, then the A_* manifest clips;
* (both: the stand-up clip (`end`) is timed from the 'ember' fx's `dur` to end as the husk stands up — the 'revive'
* fx then only lands in the manifest clips);
@@ -393,11 +394,11 @@ export class UnitView {
}
/**
* The unit changed mode (sim fx 'phase' { id, kind } or another fx's `form`; `fx` = that fx's extra): the mode's clip
* The unit changed mode (the `form` of a sim fx — shared/protocol.js fxForm; `fx` = that fx's extra): the mode's clip
* set (FORMS) after its change clip, and its closing clip (`end`, landing in the `next` form's clips) timed to end
* `fx.dur` game s later; null goes back
* to the manifest clips; a kind this skeleton has no clip set for (an arts barrier, a broken charge …) changes
* nothing. Kept for a model built later.
* `fx.dur` game s later — the unit is still in this mode while it plays (an ember can be beaten in its last second),
* so this mode's death clip stays until the next mode's fx; null goes back to the manifest clips; a kind this skeleton
* has no clip set for (an arts barrier, a broken charge …) changes nothing. Kept for a model built later.
*/
setForm(kind, fx = null) {
const k = typeof kind === 'string' ? kind : null;
@@ -409,7 +410,8 @@ export class UnitView {
const f = this._formSpec();
if (!this.actor) return;
const dur = fx && Number(fx.dur);
const next = f && f.next ? FORMS[this.info.spine || this.info.defId]?.[f.next]?.roles || null : null;
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);
else if (had) this.actor.setForm(null);
}
+5 -1
View File
@@ -58,6 +58,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { PHASE, GEO } from '../../../shared/constants.js';
import { fxForm } from '../../../shared/protocol.js';
import { html, Spinner, PhaseBanner, Icon, Button, MicroLabel, confirmDialog, useTicker } from '../ui/components.js';
import { useGameData, GIcon } from '../ui/gameComponents.js';
import { useFieldView } from '../ui/fieldHost.js';
@@ -109,6 +110,9 @@ const SEL_RANGE = Object.freeze({ group: 'selRange', color: 0xff9c33, fill: 0.3,
/** The tile an armed merge-completing card's elite will take (its own group; gold like the promotion cue, render/fx.js). */
const MERGE_HL = Object.freeze({ group: 'mergeTile', color: 0xffd45a, fill: 0.34, line: 1 });
const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill']);
/** Event tuples replayed when a field is entered late: the state-bearing kinds and the fx that change an enemy's model
* form (shared/protocol.js fxForm — the field meta's UnitInfo `form` predates them). */
const keepEarly = (e) => Array.isArray(e) && (STATE_EV.has(e[0]) || fxForm(e) !== undefined);
/** Router for the in-match screens. */
export function GameScreen() {
@@ -446,7 +450,7 @@ function MatchScreen() {
if (typeof msg.fieldId !== 'string') return;
const buf = evBufRef.current.get(msg.fieldId) || [];
// only state-bearing tuples are replayed later (see the enter effect)
for (const e of msg.ev) if (Array.isArray(e) && STATE_EV.has(e[0])) buf.push(e);
for (const e of msg.ev) if (keepEarly(e)) buf.push(e);
if (buf.length > 1500) buf.splice(0, buf.length - 1500);
evBufRef.current.set(msg.fieldId, buf);
if (evBufRef.current.size > 8) evBufRef.current.delete(evBufRef.current.keys().next().value);