卫戍协议:盟约 web remake — initial import

Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+313
View File
@@ -0,0 +1,313 @@
// 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].
export const TUPLE = Object.freeze({ ID: 0, X: 1, Y: 2, HP: 3, MAXHP: 4, SP: 5, SPMAX: 6, FLAGS: 7, ANIM: 8 });
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>, raw }` or null when unusable.
* Tuples with a non-finite id/x/y are skipped; other numbers default to 0.
*/
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]);
}
return { t, units, 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;
}
/**
* 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 }`). 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 }; 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];
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;
}
}