Files
ark-stronghold-protocol/public/js/render/interp.js
T
sganggs d4f868cd19 Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #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)
2026-09-30 05:35:45 +08:00

350 lines
16 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,
// * 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;
}
}