feedback7 R22-RENDER: an enemy standing out its attack recovery is drawn standing — the snapshot carries stand / standCut and the interpolator holds the position until the recovery ends (PR #381 by @ZiyueW1116, supersedes #307 / #314); golden: unchanged

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.
This commit is contained in:
ZiyueW1116 authored and sganggs committed 2026-10-08 18:54:54 +08:00
1 parent ffa538c39d
commit 8a64edc9e9
10 files changed
+541 -20

No files matched your search

+2
View File
@@ -535,6 +535,7 @@ export function updateEnemy(b, e, dt) {
if (!e.alive) return;
// hidden (teleporting) enemies only advance wait legs
const stunned = e.s.flags.stun;
if (stunned || e.hidden || e.s.flags.fear) b._cutAttackStand(e);
const prevCd = e.atkCd;
if (e.atkCd > 0 && !stunned && !e.hidden) e.atkCd = Math.max(0, e.atkCd - dt);
// a stun / freeze / sleep / 浮空 — or leaving the field — takes the enemy out of its attack: a swing short of its damage
@@ -644,6 +645,7 @@ function advanceRoute(b, e, dt, R, standing = false) {
continue;
}
if (leg.t === 'disappear') {
b._cutAttackStand(e);
b._setHidden(e, true);
e.atkStandUntil = -Infinity; standing = false; // off the field: its attack clip is over
R.legIdx++; R.pts = null;
+1
View File
@@ -97,6 +97,7 @@ export class BattleDeploy {
}
_remove(unit, reason, killer = null, permanent = false, dying = false) {
this._cutAttackStand(unit);
unit.alive = false;
unit.removeReason = reason;
unit.deployed = false;
+1
View File
@@ -141,6 +141,7 @@ export class BattleDisplacement {
if (moved > 0) {
this._unblock(e);
// 失衡 ends the attack clip it stood for (PRTS 状态机: the states are exclusive — UNBALANCE, then DEFAULT → MOVE)
this._cutAttackStand(e);
e.atkStandUntil = -Infinity;
if (e.route) e.route.pts = null;
this.fx('displace', { x: e.x, y: e.y, id: e.id });
+26 -1
View File
@@ -32,12 +32,26 @@ export class BattleEvents {
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 = {
@@ -60,13 +74,24 @@ export class BattleEvents {
(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;
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;
}
+1
View File
@@ -132,6 +132,7 @@ export class BattleSpawns {
e.atkCd = 0;
e.pauseUntil = -Infinity; // content holds (暴鸰's drop)
e.atkStandUntil = -Infinity; // standing for its attack clip (ai.js attackStand)
e.atkStandCutAt = -Infinity; // display metadata: when its stand was last cut or ignored (snapshot standCut)
e.swing = false; // a normal attack swung, its damage frame not reached yet (ai.js enemyAttack)
// every enemy profile starts with the same fields (stable object shapes keep the hot loop's property reads fast);
// `dmgType` null = the data's (content may arm a data-unarmed enemy: ai.js enemyAttack); `blockFree` = its 索敌不受阻挡