feedback7: merge fb7-render — #382 赤刃明霄陈 S3 sword-qi drawn (sim chen3Wave fx), #381 enemy attack recovery held in interpolation, #380 push / pull slide (corpse stays where pushed; same-step kills finish the slide), #384 eye masks, 香槟炸弹 HP bar hidden, #246 an unwound attack plays its whole clip
- Conflicts with the merged fb7-hud (both added display-only snapshot lists): server/sim/battle/events.js, types/core.js,
public/js/render/interp.js, docs/SIM.md, docs/design/network.md — union of ammo / wolves / neg and stand / standCut.
- Grok review: MERGE AFTER FIXES — the HIGH finding (same-step push kill) fixed on the branch (7a33bc7e).
- Golden: diy-073 / diy-074 (fx counts only; union OK); broad node --test 5442 (5441 pass, 0 fail, 1 skipped); lint 0
errors; typecheck.
This commit is contained in:
commit
cf98f4c3f8
27 files changed
+1812
-53
No files matched your search
+66
-16
@@ -8,7 +8,8 @@
|
||||
// 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,
|
||||
// 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)
|
||||
@@ -21,7 +22,7 @@
|
||||
// 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):
|
||||
// (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);
|
||||
@@ -35,10 +36,16 @@
|
||||
// `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 我执).
|
||||
// Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT.
|
||||
// * `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 } from '../../../shared/constants.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). */
|
||||
@@ -50,6 +57,15 @@ 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']);
|
||||
/**
|
||||
@@ -73,9 +89,11 @@ export function frameTime(msg) {
|
||||
|
||||
/**
|
||||
* 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, 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.
|
||||
* 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;
|
||||
@@ -115,7 +133,21 @@ export function normalizeSnapshot(snap) {
|
||||
(down || (down = [])).push(e);
|
||||
}
|
||||
}
|
||||
return { t, units, down, ammo, wolves, neg, raw: snap };
|
||||
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, neg, stand, standCut, raw: snap };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,6 +315,16 @@ export class SnapshotBuffer {
|
||||
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.
|
||||
@@ -320,22 +362,30 @@ export class SnapshotBuffer {
|
||||
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);
|
||||
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;
|
||||
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;
|
||||
if (P && ext > 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;
|
||||
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 || redeployed(p, a)) { vx = 0; vy = 0; }
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user