- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap. - 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope). - 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki). - Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video). - 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane). - AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons. - golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
460 lines
23 KiB
JavaScript
460 lines
23 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 — 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; the first two from user playtest #4 items 8 / 9, `stand` / `standCut` from 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`;
|
||
// * three HP-bar readouts, kept per snapshot beside the tuples (the tuple layout is unchanged) and handed out by sample()
|
||
// from the older snapshot like flags, so they step with the skill flag instead of sliding:
|
||
// `ammo` [[id, rounds left, rounds in the magazine]] → sample().ammo [left, magazine] | null (whole numbers only)
|
||
// `wolves` [[id, 狼影 left, the talent's maximum]] → sample().wolves [left, maximum] | null
|
||
// `neg` [[id, fill]] (0.01–1: the negative-HP pool's share of its cap) → sample().neg number (0: none)
|
||
// An entry that is malformed, or names a unit the snapshot does not list, is dropped; a snapshot without the list
|
||
// clears the readout (render/units.js: the segmented ammo bar, the wolf pips, the red bar of 斩业星熊's 我执).
|
||
// * `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, ammo, wolves: Map id → [left, max] | null, neg: Map id → fill | 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` / `ammo` / `wolves` /
|
||
* `neg` 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])));
|
||
}
|
||
}
|
||
const ammo = countList(snap.ammo, units), wolves = countList(snap.wolves, units), coins = countList(snap.coins, units);
|
||
let neg = null;
|
||
if (Array.isArray(snap.neg)) {
|
||
for (const e of snap.neg) {
|
||
if (!Array.isArray(e) || !units.has(e[0]) || !(typeof e[1] === 'number' && e[1] > 0)) continue;
|
||
(neg || (neg = new Map())).set(e[0], Math.min(1, e[1]));
|
||
}
|
||
}
|
||
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, ammo, wolves, coins, neg, stand, standCut, raw: snap };
|
||
}
|
||
|
||
/**
|
||
* `[[id, left, max]]` → Map id → [left, max] for the ids in `units`: whole numbers with 0 ≤ left ≤ max and max ≥ 1 (the
|
||
* `ammo` and `wolves` lists); null without a valid entry.
|
||
*/
|
||
function countList(list, units) {
|
||
if (!Array.isArray(list)) return null;
|
||
let m = null;
|
||
for (const e of list) {
|
||
if (!Array.isArray(e) || !units.has(e[0]) || !Number.isSafeInteger(e[1]) || !Number.isSafeInteger(e[2])
|
||
|| e[1] < 0 || e[2] < 1 || e[1] > e[2]) continue;
|
||
(m || (m = new Map())).set(e[0], [e[1], e[2]]);
|
||
}
|
||
return m;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Game time of the first snapshot after `time` (default renderT) — the newer end of the interval sample() interpolates
|
||
* — or NaN when there is none (extrapolating). render/app.js starts a push's slide while that interval is shown.
|
||
*/
|
||
nextSnapT(time = this.renderT) {
|
||
const s = this.snaps;
|
||
const i = this._indexAt(time) + 1;
|
||
return Number.isFinite(time) && i < s.length ? s[i].t : NaN;
|
||
}
|
||
|
||
/**
|
||
* 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, ammo, wolves,
|
||
* neg }` — `el` = the shown element gauge (null: none), `ammo` / `wolves` / `neg` the HP-bar readouts (null / null / 0:
|
||
* 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, ammo: null, wolves: null, neg: 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; }
|
||
// the HP-bar readouts come with the older snapshot, like flags (whole rounds step with the skill flag)
|
||
o.ammo = A.ammo ? A.ammo.get(id) || null : null;
|
||
o.wolves = A.wolves ? A.wolves.get(id) || null : null;
|
||
o.coins = A.coins?.get(id) ?? null;
|
||
o.neg = A.neg ? A.neg.get(id) || 0 : 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;
|
||
}
|
||
}
|