Files
ark-stronghold-protocol/public/js/render/interp.js
T
ZiyueW1116 8a64edc9e9 feedback7 R22-RENDER: an enemy standing out its attack recovery is drawn standing — the snapshot carries stand / standCut and the interpolator holds the position until the recovery ends (PR #381 by @ZiyueW1116, supersedes #307 / #314); golden: unchanged
Since 0.2.0 the sim stands an unblocked enemy for the rest of its attack clip (ai.js attackStand → atkStandUntil),
but render/interp.js lerped x/y over the whole snapshot gap, so the view slid through a recovery the sim held
(repro: a real 隐形弩手, snapshots 4.9 / 5.0, stand until 4.934 — sampled at 4.917 it slid at vx −0.2).

The snapshot now carries, only when non-empty, `stand: [[id, until]]` (atkStandUntil of enemies alive, deployed,
visible, neither feared nor stunned) and `standCut: [[id, at]]` (the latest time each listed enemy's recovery was cut
or ignored: a displacement, fear, a stun, hiding, leaving — Battle._cutAttackStand, display metadata the battle never
reads). sample() holds the older position until `until` and interpolates the rest (HP / SP keep the whole interval),
stays linear when the newer snapshot records a cut inside the interval, and extrapolates nothing for a newest snapshot
that shows a stand, a death, a stun or a block. Teleports and redeploys still snap first; old snapshots without the
fields interpolate as before.

Substantially @ZiyueW1116's code (PR #381), authored as theirs. Changed on review: the docs (SIM §9, design/network.md)
and code comments in English like the rest of them; the snapshot builds its listed-id set only when an enemy has a cut
to report.

Golden: unchanged — the digest canon hashes neither field and the sim's timing does not move (all 283 scenarios match).

Tests: test/render/attack-recovery-interp.test.js (new, 25: metadata validation, hold-then-slide, cuts, legacy
snapshots, extrapolation guards, real 隐形弩手 / Weedy push-kill / stun / freeze / sleep / levitate / block / fear
battles) 25/25; render/interp 16/16; sim battle, perf, robustness, downelem 69/69.
2026-10-08 18:54:54 +08:00

412 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// render/interp.js — snapshot interpolation buffer for battle rendering (pure logic, no PIXI / DOM).
//
// The server streams `b.snap` at ~20 Hz real time; snapshot time `t` is GAME seconds and combat runs at 2×
// real time (DESIGN §4), so the game clock advances `rate` ≈ 2 game-s per real second. The buffer:
// * keeps ~2 s of snapshots (each indexed id → tuple once, at push time),
// * estimates `rate` from arrival times (sliding window, clamped, default 2),
// * runs a render clock `renderT` that trails the newest snapshot by `delay` real seconds (default 100 ms),
// advancing at `rate` and gently steered back when network jitter pushes it off target (hard snap when it
// is more than `snapAfter` real seconds off),
// * extrapolation guard: renderT never runs more than `maxExtrapolate` real seconds past the newest snapshot;
// positions are extrapolated along the last velocity for at most that long, then freeze — never for a unit the
// newest snapshot shows dead, stunned (frozen, asleep) or blocked, nor for an enemy it shows standing (`stand`),
// * sample(): per unit, lerps x/y/hp/sp between the two snapshots bracketing renderT; flags/anim come from
// the older one. A unit missing from the newer snapshot (died/left mid-buffer) holds its last position
// until renderT reaches the newer snapshot; a unit that only exists in the newer one (spawned mid-buffer)
// appears when renderT reaches it. Moves longer than `teleport` tiles between two snapshots snap, and so does a
// unit whose deploy animation starts in between (a redeploy while it stays listed: 乌尔比安's 【移动】, a 突袭 jump).
// * 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 — 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]. Optional lists ride along
// (server/sim/battle/events.js snapshot; user playtest #4 items 8 / 9, PR #381):
// * `elem` [[id, element, fill, cooldownEnd, cooldown]] — the element gauge a unit shows: appended to that unit's
// normalised tuple (EL…EL_DUR) and handed out by sample() as `el`, `elFill`, `elUntil`, `elDur` (from the older
// snapshot, like flags);
// * `down` [[id, respawnAt, respawnTime, state, row?, col?]] — knocked-out operators waiting to redeploy (they are no
// longer in `units`) and the tile they lie on (where they fell, or their home — sim Battle._layBody; kept only when
// both are integers): downAt(time) returns the list of the snapshot at `time`;
// * `stand` [[id, until]] — the game time an enemy's attack recovery ends (sim atkStandUntil, PR #381): the position
// holds until then and interpolates the rest of the interval; nothing is inferred from the attack animation, and a
// snapshot without it interpolates linearly;
// * `standCut` [[id, at]] — the latest time that enemy's recovery was cut or ignored: an interval with a cut inside
// it stays linear.
// Game times in all of them (`cooldownEnd`, `respawnAt`, `until`, `at`) are on the snapshots' clock, so a view compares
// them with renderT.
import { fxForm } from '../../../shared/protocol.js';
import { ANIM, UF } from '../../../shared/constants.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 });
/** The unit was (re)deployed between tuples `a` and the newer `b`: `b` plays the deploy animation, `a` did not (sim snapshot animOf). */
const redeployed = (a, b) => b[8] === ANIM.DEPLOY && a[8] !== ANIM.DEPLOY;
/** Element keys a snapshot `elem` entry may carry (server/sim/constants.js ELEMENT_ORDER). */
const ELEMENT_KEYS = new Set(['neural', 'erosion', 'burn', 'apoptosis', 'necrosis']);
const MAX_EVENTS = 6000;
const finite = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
/**
* Game time unit `id` starts moving between snapshot `a` and the newer `b` (interpolation and extrapolation share it):
* the end of `a`'s stand when it falls inside the interval and `b` records no cut there, else `a.t`.
*/
function movementStart(a, b, id) {
const until = a.stand?.get(id), cutAt = b.standCut?.get(id);
return until > a.t && until <= b.t && !(cutAt >= a.t && cutAt <= b.t) ? until : a.t;
}
/** 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']);
/**
* 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
* sends the game time as `gt` (server/match/fields.js snapFrame); raw Battle snapshots and the demo recordings
* carry it as a numeric `t`. This is the only place that conversion happens.
*/
export function frameTime(msg) {
if (!msg || typeof msg !== 'object') return NaN;
const t = typeof msg.gt === 'number' ? msg.gt : msg.t;
return typeof t === 'number' && Number.isFinite(t) ? t : NaN;
}
/**
* Validate & normalise a b.snap payload. Returns `{ t, units: Map<id, tuple>, down: [[id, respawnAt, respawnTime,
* state, row?, col?]] | null, stand: Map<id, until> | null, standCut: Map<id, at> | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default
* to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` entries are dropped.
* `stand` keeps finite end times after `t` of units in the snapshot; `standCut` keeps finite times in [0, t] of units in it.
*/
export function normalizeSnapshot(snap) {
if (!snap || typeof snap !== 'object') return null;
const t = frameTime(snap);
if (!Number.isFinite(t)) return null;
const units = new Map();
const list = Array.isArray(snap.units) ? snap.units : [];
for (const u of list) {
if (!Array.isArray(u) || u.length < 3) continue;
const id = u[0];
if (!(typeof id === 'number' || typeof id === 'string')) continue;
const x = u[1], y = u[2];
if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) continue;
units.set(id, [id, x, y, finite(u[3]), finite(u[4]), finite(u[5]), finite(u[6]), finite(u[7]) | 0, finite(u[8]) | 0]);
}
if (Array.isArray(snap.elem)) {
for (const e of snap.elem) {
if (!Array.isArray(e) || !ELEMENT_KEYS.has(e[1])) continue;
const tu = units.get(e[0]);
if (tu && tu.length === 9) tu.push(e[1], clamp(finite(e[2]), 0, 1), finite(e[3]), Math.max(0, finite(e[4])));
}
}
let down = null;
if (Array.isArray(snap.down)) {
for (const d of snap.down) {
if (!Array.isArray(d) || !(typeof d[0] === 'number' || typeof d[0] === 'string')) continue;
const e = [d[0], finite(d[1]), Math.max(0, finite(d[2])), finite(d[3]) | 0];
if (Number.isInteger(d[4]) && Number.isInteger(d[5])) e.push(d[4], d[5]);
(down || (down = [])).push(e);
}
}
let stand = null;
if (Array.isArray(snap.stand)) {
for (const e of snap.stand) {
if (!Array.isArray(e) || !units.has(e[0]) || !Number.isFinite(e[1]) || e[1] <= t) continue;
(stand || (stand = new Map())).set(e[0], e[1]);
}
}
let standCut = null;
if (Array.isArray(snap.standCut)) {
for (const e of snap.standCut) {
if (!Array.isArray(e) || !units.has(e[0]) || !Number.isFinite(e[1]) || e[1] < 0 || e[1] > t) continue;
(standCut || (standCut = new Map())).set(e[0], e[1]);
}
}
return { t, units, down, stand, standCut, raw: snap };
}
export class SnapshotBuffer {
/**
* @param {{ delay?: number, rate?: number, maxExtrapolate?: number, keep?: number, teleport?: number,
* snapAfter?: number, minRate?: number, maxRate?: number }} [opts]
* delay / maxExtrapolate / snapAfter / keep are REAL seconds.
*/
constructor(options) {
const opts = options && typeof options === 'object' ? options : {};
this.delay = Math.max(0, finite(opts.delay, 0.1));
this.defaultRate = clamp(finite(opts.rate, 2), 0.05, 20);
this.minRate = finite(opts.minRate, 0.25);
this.maxRate = finite(opts.maxRate, 8);
this.maxExtrapolate = Math.max(0, finite(opts.maxExtrapolate, 0.12));
this.keep = Math.max(0.5, finite(opts.keep, 2.5));
this.teleport = Math.max(0.5, finite(opts.teleport, 2.5));
this.snapAfter = Math.max(0.2, finite(opts.snapAfter, 0.75));
this.reset();
}
reset() {
/** @type {{t:number, units:Map<any, any[]>, raw:any, at:number}[]} ascending by t */
this.snaps = [];
/** @type {{t:number, at:number}[]} arrival log for rate estimation */
this.arrivals = [];
this.rate = this.defaultRate;
this.renderT = NaN;
this.lastNow = NaN;
this.interval = 0.1 * this.defaultRate / 2; // game s between snapshots (estimated)
/** @type {{t:number, ev:any}[]} */
this.events = [];
this.meta = null; // latest snapshot's extra fields (dp, killed, total, boss…)
return this;
}
get newestT() { return this.snaps.length ? this.snaps[this.snaps.length - 1].t : NaN; }
get oldestT() { return this.snaps.length ? this.snaps[0].t : NaN; }
get size() { return this.snaps.length; }
/**
* Add a snapshot received at real time `now` (seconds). Out-of-order / duplicate snapshots are dropped;
* a snapshot far older than the newest (a server-side reset, e.g. a new battle) resets the buffer.
* @returns {boolean} accepted
*/
push(snap, now) {
const s = normalizeSnapshot(snap);
if (!s) return false;
now = finite(now, this.lastNow || 0);
const newest = this.newestT;
if (this.snaps.length) {
if (s.t < newest - 5) this.reset(); // the server restarted the stream (new battle): old events are obsolete
else if (s.t <= newest) return false;
}
s.at = now;
const prev = this.snaps[this.snaps.length - 1];
if (prev) {
const dt = s.t - prev.t;
if (dt > 0 && dt < 2) this.interval = this.interval * 0.8 + dt * 0.2;
}
this.snaps.push(s);
this.meta = s.raw;
// rate estimate over a ~1.5 s window of arrivals
this.arrivals.push({ t: s.t, at: now });
while (this.arrivals.length > 2 && now - this.arrivals[0].at > 1.5) this.arrivals.shift();
if (this.arrivals.length >= 4) {
const a = this.arrivals[0], b = this.arrivals[this.arrivals.length - 1];
const dReal = b.at - a.at, dGame = b.t - a.t;
if (dReal > 0.2 && dGame > 0) this.rate = clamp(this.rate * 0.7 + (dGame / dReal) * 0.3, this.minRate, this.maxRate);
}
// trim old snapshots (keep at least 2). Trimming never waits for the render clock: while the page is hidden
// (no animation frames) snapshots keep arriving and must not pile up; a clock left behind is clamped.
const horizon = s.t - this.keep * this.rate;
while (this.snaps.length > 2 && this.snaps[1].t < horizon) this.snaps.shift();
if (!Number.isFinite(this.renderT)) this.renderT = Math.max(this.snaps[0].t, s.t - this.delay * this.rate);
else if (this.renderT < this.snaps[0].t) this.renderT = this.snaps[0].t;
return true;
}
/**
* Queue a b.ev batch. `t` (game s, the frame's `gt`) when the server stamps it; otherwise the batch is placed
* inside the latest snapshot interval (see header).
*/
pushEvents(evs, now, t) {
if (!Array.isArray(evs) || !evs.length) return 0;
let stamp = typeof t === 'number' && Number.isFinite(t) ? t : NaN;
if (!Number.isFinite(stamp)) {
const n = this.newestT;
stamp = Number.isFinite(n) ? n - this.interval * 0.5 : (Number.isFinite(this.renderT) ? this.renderT : 0);
}
let added = 0;
for (const ev of evs) {
if (!Array.isArray(ev) || typeof ev[0] !== 'string') continue;
this.events.push({ t: stamp, ev });
added++;
}
// bounded queue (hidden tab: nothing consumes it) — shed the oldest cosmetic events first
if (this.events.length > MAX_EVENTS) {
const keep = [];
const excess = this.events.length - MAX_EVENTS;
let shed = 0;
for (const e of this.events) {
if (shed < excess && isCosmeticEvent(e.ev)) { shed++; continue; }
keep.push(e);
}
this.events = keep.length > MAX_EVENTS * 2 ? keep.slice(keep.length - MAX_EVENTS * 2) : keep;
}
// keep sorted (batches normally arrive in order; a server stamp may be older)
if (this.events.length > 1 && this.events[this.events.length - 1].t < this.events[this.events.length - 1 - added]?.t) {
this.events.sort((a, b) => a.t - b.t);
}
return added;
}
/**
* Advance the render clock to real time `now` (seconds). Returns renderT (NaN until the first snapshot).
*/
update(now) {
now = finite(now, 0);
if (!this.snaps.length) { this.lastNow = now; return NaN; }
const dt = Number.isFinite(this.lastNow) ? clamp(now - this.lastNow, 0, 1) : 0;
this.lastNow = now;
const newest = this.newestT;
const target = newest - this.delay * this.rate;
if (!Number.isFinite(this.renderT)) this.renderT = target;
this.renderT += dt * this.rate;
const err = target - this.renderT;
if (Math.abs(err) > this.snapAfter * this.rate) this.renderT = target;
else this.renderT += err * Math.min(1, dt * 3);
const cap = newest + this.maxExtrapolate * this.rate;
if (this.renderT > cap) this.renderT = cap;
if (this.renderT < this.snaps[0].t) this.renderT = this.snaps[0].t;
return this.renderT;
}
/** Jump the render clock to the newest snapshot (scrubbing / first frame after a reset). */
snapToNewest() {
if (this.snaps.length) this.renderT = this.newestT;
return this.renderT;
}
/** Index of the newest snapshot with t ≤ time (−1 when time is before all). */
_indexAt(time) {
const s = this.snaps;
let lo = 0, hi = s.length - 1, ans = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (s[mid].t <= time) { ans = mid; lo = mid + 1; } else hi = mid - 1;
}
return ans;
}
/**
* The `down` list (knocked-out operators waiting to redeploy, see header) of the snapshot shown at `time` (default
* renderT) — the same snapshot sample() reads flags from — or null.
*/
downAt(time = this.renderT) {
const s = this.snaps;
if (!s.length || !Number.isFinite(time)) return null;
const ia = this._indexAt(time);
return s[ia < 0 ? 0 : ia].down || null;
}
/**
* Interpolated state at `time` (default renderT). Fills and returns `out` (a Map id → sample object reused
* across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen, el, elFill, elUntil, elDur }` —
* `el` = the shown element gauge (null: none), see header). Samples of units no longer present are deleted from `out`.
*/
sample(time = this.renderT, out = new Map()) {
const s = this.snaps;
if (!s.length || !Number.isFinite(time)) { out.clear(); return out; }
let ia = this._indexAt(time);
if (ia < 0) ia = 0;
const A = s[ia];
const B = ia + 1 < s.length ? s[ia + 1] : null;
const span = B ? B.t - A.t : 0;
const alpha = B && span > 0 ? clamp((time - A.t) / span, 0, 1) : 0;
const P = !B && ia > 0 ? s[ia - 1] : null; // for extrapolation
const ext = !B ? clamp(time - A.t, 0, this.maxExtrapolate * this.rate) : 0;
const stamp = A.t;
for (const [id, a] of A.units) {
let o = out.get(id);
if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0, el: null, elFill: 0, elUntil: 0, elDur: 0 }; out.set(id, o); }
const b = B ? B.units.get(id) : null;
if (b) {
const dx = b[1] - a[1], dy = b[2] - a[2];
// (a deployment in between — the newer snapshot starts its deploy animation — lands on its tile, no slide)
const tele = dx * dx + dy * dy > this.teleport * this.teleport || redeployed(a, b);
const start = movementStart(A, B, id);
const moveSpan = B.t - start;
const moveAlpha = moveSpan > 0 ? clamp((time - start) / moveSpan, 0, 1) : 0;
o.x = tele ? (alpha < 1 ? a[1] : b[1]) : a[1] + dx * moveAlpha;
o.y = tele ? (alpha < 1 ? a[2] : b[2]) : a[2] + dy * moveAlpha;
o.vx = !tele && moveSpan > 0 && (start === A.t || time >= start) ? dx / moveSpan : 0;
o.vy = !tele && moveSpan > 0 && (start === A.t || time >= start) ? dy / moveSpan : 0;
o.hp = a[3] + (b[3] - a[3]) * alpha;
o.maxHp = b[4] || a[4];
o.sp = a[5] + (b[5] - a[5]) * alpha;
o.spMax = b[6] || a[6];
} else {
let vx = 0, vy = 0;
// a stand in the newest snapshot: the old velocity cannot predict the walk after it, even once the clock passes
// `until` — the next snapshot confirms the position
if (P && ext > 0 && a[8] !== ANIM.DIE && a[8] !== ANIM.STUN && !(a[7] & UF.BLOCKED) && !A.stand?.has(id)) {
const p = P.units.get(id);
const dtp = A.t - P.t;
const moveSpan = A.t - movementStart(P, A, id);
if (p && dtp > 0 && moveSpan > 0) {
const dx = a[1] - p[1], dy = a[2] - p[2];
if (dx * dx + dy * dy <= this.teleport * this.teleport && !redeployed(p, a)) {
vx = dx / moveSpan; vy = dy / moveSpan;
}
}
}
o.x = a[1] + vx * ext;
o.y = a[2] + vy * ext;
o.vx = vx; o.vy = vy;
o.hp = a[3]; o.maxHp = a[4]; o.sp = a[5]; o.spMax = a[6];
}
// the sim rounds hp up and maxHp to nearest, so a snapshot may say hp = maxHp + 1
if (o.hp > o.maxHp && o.maxHp > 0) o.hp = o.maxHp;
else if (!(o.hp > 0)) o.hp = 0;
o.flags = a[7];
o.anim = a[8];
if (a.length > 9) { o.el = a[9]; o.elFill = a[10]; o.elUntil = a[11]; o.elDur = a[12]; } else if (o.el !== null) { o.el = null; o.elFill = 0; o.elUntil = 0; o.elDur = 0; }
o.seen = stamp;
}
for (const id of out.keys()) if (!A.units.has(id)) out.delete(id);
return out;
}
/**
* 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; 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, 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++;
if (!n) return out;
const list = this.events;
for (let i = 0; i < n; i++) {
const e = list[i];
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;
list.copyWithin(0, n);
list.length = rest;
return out;
}
/**
* Look ahead: call `fn(ev, t)` for every queued event stamped in (from, to] (ascending; nothing is removed).
* Lets the renderer start animations that must peak when an event becomes due (attack wind-ups).
*/
forEachUpcoming(from, to, fn) {
const list = this.events;
for (let i = 0; i < list.length; i++) {
const e = list[i];
if (e.t > to) break;
if (e.t > from) fn(e.ev, e.t);
}
}
/** Deliver every queued event now (used before a reset so state events are not lost). */
flushEvents(out = []) {
for (const e of this.events) out.push(e.ev);
this.events.length = 0;
return out;
}
}