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.
109 lines
4.7 KiB
JavaScript
109 lines
4.7 KiB
JavaScript
// server/sim/battle/events.js — Battle methods: client events (DESIGN §8.2 tuples, bounded buffer), the snapshot, field
|
|
// meta, fx and the sim log.
|
|
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
|
|
|
|
import { EVENT_BUFFER_CAP } from '../constants.js';
|
|
import { elementView } from '../damage.js';
|
|
import { unitInfo, snapshotUnits } from '../snapshot.js';
|
|
|
|
export class BattleEvents {
|
|
fx(kind, params = {}) {
|
|
const { x = 0, y = 0, ...extra } = params || {};
|
|
this._ev(['fx', kind, Math.round(x * 100) / 100, Math.round(y * 100) / 100, extra]);
|
|
}
|
|
|
|
log(msg) {
|
|
const k = 'log:' + msg;
|
|
if (this._errKeys.has(k)) return;
|
|
this._errKeys.add(k);
|
|
if (this.opts.verbose) this.logger.warn?.(`[sim] ${msg}`);
|
|
}
|
|
|
|
_ev(tuple) {
|
|
if (!this.recordEvents) return;
|
|
this._evq.push(tuple);
|
|
if (this._evq.length > EVENT_BUFFER_CAP) this._evq.splice(0, this._evq.length - EVENT_BUFFER_CAP / 2);
|
|
}
|
|
|
|
/** Client-facing events since the last drain (DESIGN §8.2 tuples). */
|
|
drainEvents() {
|
|
const ev = this._evq;
|
|
this._evq = [];
|
|
return ev;
|
|
}
|
|
|
|
/**
|
|
* Record that an enemy's attack recovery (atkStandUntil) is cut or ignored now — snapshot `standCut`, display metadata
|
|
* (render/interp.js); never changes the attack's timing or state.
|
|
*/
|
|
_cutAttackStand(unit) {
|
|
if (unit.side === 'enemy' && Number.isFinite(unit.atkStandUntil) && unit.atkStandUntil > this.time) {
|
|
unit.atkStandCutAt = this.time;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compact full snapshot of this field (DESIGN §8.2 b.snap), plus (only when non-empty):
|
|
* down: [[id, respawnAt, respawnTime, state, row, col]] — operators that left the field waiting to redeploy (isDown): the
|
|
* game time their respawn timer ends, its length (s), constants.js DOWN_STATE and the tile they lie on (and
|
|
* come back on: _layBody — where they fell, or their home);
|
|
* elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView).
|
|
* stand: [[id, until]] — when each enemy's attack recovery ends (atkStandUntil; alive, deployed, visible, not
|
|
* feared or stunned) — display metadata (render/interp.js holds the position until then);
|
|
* standCut: [[id, at]] — the latest time each listed enemy's recovery was cut or ignored (_cutAttackStand), the
|
|
* dying ones in their death window included.
|
|
*/
|
|
snapshot() {
|
|
const snap = {
|
|
fieldId: this.fieldId,
|
|
t: Math.round(this.time * 1000) / 1000,
|
|
units: snapshotUnits(this.units, this.time),
|
|
dp: this.players.length ? Math.floor(this.players[0].dp) : 0,
|
|
killed: this.killed,
|
|
total: this.total,
|
|
};
|
|
if (this.players.length > 1) {
|
|
snap.dps = {};
|
|
for (const p of this.players) snap.dps[p.playerId] = Math.floor(p.dp);
|
|
}
|
|
if (this.sharedBoss) snap.boss = { hp: Math.max(0, Math.round(this.sharedBoss.hp)), max: Math.round(this.sharedBoss.maxHp) };
|
|
const r2 = (v) => Math.round(v * 100) / 100;
|
|
let down = null;
|
|
for (const u of this.allyUnits) {
|
|
if (!this.isDown(u)) continue;
|
|
(down || (down = [])).push([u.id, r2(u.respawnAt), r2(Math.max(0, u.respawnAt - u.deathAt)), this._downState(u), ...this.restTile(u)]);
|
|
}
|
|
if (down) snap.down = down;
|
|
let elem = null, stand = null, standCut = null;
|
|
let listed = null; // the ids in snap.units, built for the first enemy with a cut to report
|
|
for (const u of this.units) {
|
|
if (u.side === 'enemy' && u.atkStandCutAt >= 0) {
|
|
const cutAt = Math.round(u.atkStandCutAt * 1000) / 1000;
|
|
if (cutAt <= snap.t && (listed || (listed = new Set(snap.units.map((x) => x[0])))).has(u.id)) (standCut || (standCut = [])).push([u.id, cutAt]);
|
|
}
|
|
if (!u.alive || !u.deployed || u.hidden) continue;
|
|
const until = Math.round(u.atkStandUntil * 1000) / 1000;
|
|
if (u.side === 'enemy' && !u.s.flags.fear && !u.s.flags.stun && Number.isFinite(until) && until > snap.t) {
|
|
(stand || (stand = [])).push([u.id, until]);
|
|
}
|
|
const v = elementView(u, this.time);
|
|
if (v) (elem || (elem = [])).push([u.id, v[0], v[1], v[2], v[3]]);
|
|
}
|
|
if (elem) snap.elem = elem;
|
|
if (stand) snap.stand = stand;
|
|
if (standCut) snap.standCut = standCut;
|
|
return snap;
|
|
}
|
|
|
|
/**
|
|
* Field meta for m.field: { fieldId, kind, rect, stageId, units: UnitInfo[] } — the units on the field, knocked-out
|
|
* operators waiting to redeploy included (a client joining mid-battle shows them down).
|
|
*/
|
|
fieldMeta() {
|
|
return {
|
|
fieldId: this.fieldId, kind: this.kind, rect: { ...this.rect }, stageId: this.stageId,
|
|
units: this.units.filter((u) => (u.alive && u.deployed && !u.hidden) || this.isDown(u)).map(unitInfo),
|
|
};
|
|
}
|
|
}
|