- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed - #2 two-tap confirm on 机变 cards (like the shop) - #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles) - #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band - #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from - #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others) - #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats) - #8 official element gauges on units; burst cooldown (阻回) per PRTS - #9 knocked-down operators stay on their tile with a redeploy countdown ring - #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼) - #11 赫默's drone only from her skill; placeable summons limited to talent tokens - #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel - #13 the occasional giant bond icon (loading texture scaled ×46) fixed - docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
350 lines
16 KiB
JavaScript
350 lines
16 KiB
JavaScript
// 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,
|
||
// * 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.
|
||
// * 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.
|
||
//
|
||
// 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):
|
||
// * `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]] — knocked-out operators waiting to redeploy (they are no longer in
|
||
// `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.
|
||
|
||
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']);
|
||
|
||
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);
|
||
|
||
/** 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]);
|
||
|
||
/**
|
||
* 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]] | 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.
|
||
*/
|
||
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;
|
||
(down || (down = [])).push([d[0], finite(d[1]), Math.max(0, finite(d[2])), finite(d[3]) | 0]);
|
||
}
|
||
}
|
||
return { t, units, down, 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];
|
||
const tele = dx * dx + dy * dy > this.teleport * this.teleport;
|
||
o.x = tele ? (alpha < 1 ? a[1] : b[1]) : a[1] + dx * alpha;
|
||
o.y = tele ? (alpha < 1 ? a[2] : b[2]) : a[2] + dy * alpha;
|
||
o.vx = !tele && span > 0 ? dx / span : 0;
|
||
o.vy = !tele && span > 0 ? dy / span : 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;
|
||
if (P && ext > 0) {
|
||
const p = P.units.get(id);
|
||
const dtp = A.t - P.t;
|
||
if (p && dtp > 0) {
|
||
vx = (a[1] - p[1]) / dtp; vy = (a[2] - p[2]) / dtp;
|
||
if (vx * vx + vy * vy > (this.teleport / dtp) ** 2) { vx = 0; vy = 0; }
|
||
}
|
||
}
|
||
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.
|
||
*/
|
||
takeEvents(time = this.renderT, out = [], dropCosmeticBefore = 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 && isCosmeticEvent(e.ev)) continue;
|
||
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;
|
||
}
|
||
}
|