From ffa538c39dc3aa9f60583dacff3adb9a59a42908 Mon Sep 17 00:00:00 2001 From: Convey123 Date: Thu, 8 Oct 2026 18:48:53 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feedback7=20R22-RENDER:=20=E8=B5=A4?= =?UTF-8?q?=E5=88=83=E6=98=8E=E9=9C=84=E9=99=88=20S3's=20sword=20wave=20is?= =?UTF-8?q?=20drawn=20along=20its=20path=20=E2=80=94=20the=20sim=20sends?= =?UTF-8?q?=20its=20position=20as=20a=20chen3Wave=20fx=20every=200.12=20s,?= =?UTF-8?q?=20the=20client=20draws=20a=20'qi'=20blade=20(PR=20#382=20by=20?= =?UTF-8?q?@Convey123,=20owner=20item=2012);=20golden:=20diy-073=20/=20diy?= =?UTF-8?q?-074=20events.fx=20and=20fx.chen3Wave=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 赤霄·天喟 wave is a kit-managed probe (it turns corners and lives as long as the skill), not a snapshot projectile, and the sim emitted one `dash` at the cast and a `slash` per hit — nothing along the way, so the client drew no wave. op-chen3.js now emits fx('chen3Wave', { x, y, fromX, fromY, dr, dc, id }) every WAVE_FX_EVERY (0.12 s; 4 ticks), the trail restarting at a corner; render/fx registers chen3Wave → 'qi' (pt: drawn at the event, never on her) and draws a streak plus a crescent head turned the way it travels. The look is a procedural stand-in, not the official particle prefab. Substantially @Convey123's code (PR #382), authored as theirs. Changed on review: the head is projected after streak() — streak() reuses the effect system's projection scratch, so the PR drew the crescent on the trail's start, one event behind the wave — and the trail, head and heading share one height; an unused test variable dropped. Golden: diy-073 (5_diy*=chen3 S3 in two slots) events.fx 508 → 1558, fx.chen3Wave → 1050; diy-074 (6_diy*=chen3 S3) events.fx 272 → 872, fx.chen3Wave → 600. HP, kills, damage, hooks and snapshot hashes unchanged: battle.fx only records an event. Tests: test/content/op_chen3.test.js (+1: the events, their cadence, id, direction, trail start, silence after the skill) 12/12; test/render/fxchen3wave.test.js (new, 6: the archetype, drawn at the wave not at her, the head on the wave and turned with it, a sweep joining into one trail, junk input, nothing left after) 6/6; boardart 15/15. --- public/js/render/fx/kinds.js | 3 + public/js/render/fx/simfx.js | 26 ++++- server/sim/content/kits/ops/op-chen3.js | 25 ++++- test/content/op_chen3.test.js | 33 ++++++ test/golden/diy.json | 8 +- test/render/fxchen3wave.test.js | 132 ++++++++++++++++++++++++ 6 files changed, 219 insertions(+), 8 deletions(-) create mode 100644 test/render/fxchen3wave.test.js diff --git a/public/js/render/fx/kinds.js b/public/js/render/fx/kinds.js index b9d0199c..b721a01a 100644 --- a/public/js/render/fx/kinds.js +++ b/public/js/render/fx/kinds.js @@ -15,6 +15,9 @@ export const FX_KINDS = Object.freeze({ frostNova: { a: 'blast', c: 0x9fe6ff, smoke: 0x1c2630 }, sunBurst: { a: 'blast', c: 0xffe28a }, meltdown: { a: 'blast', c: 0xff5a2a, r: 1.5, heavy: true }, iceSpike: { a: 'blast', c: 0xbfeeff, smoke: 0x1c2630 }, rockfall: { a: 'blast', c: 0xc8a878, smoke: 0x4a3f33 }, rockslide: { a: 'blast', c: 0xc8a878, smoke: 0x4a3f33 }, finale: { a: 'blast', c: 0xffd45a, r: 1.5 }, swordStorm: { a: 'blast', c: 0xdfe8ff }, swordRain: { a: 'blast', c: 0xdfe8ff }, liberate: { a: 'blast', c: 0xffffff }, + // 赤刃明霄陈 S3 赤霄·天喟 的龙剑气: a travelling blade, drawn from the sim's per-interval `chen3Wave` events (its own + // position + the previous point + direction); `pt` because it is not at the caster (it roams and passes by her tile) + chen3Wave: { a: 'qi', c: 0xdfe8ff, pt: true }, quadShot: { a: 'volley', c: 0xfff2d0 }, featherArrow: { a: 'counter', c: 0xfff2d0 }, burst: { a: 'element', c: 0xd0a0ff }, // areas diff --git a/public/js/render/fx/simfx.js b/public/js/render/fx/simfx.js index 4adf4327..0796e434 100644 --- a/public/js/render/fx/simfx.js +++ b/public/js/render/fx/simfx.js @@ -47,10 +47,11 @@ export class FxSim { /** * b.ev 'fx': every kind the sim / content emits has a visual (FX_KINDS archetypes: blast, shell, zone, telegraph, * heal, sp, shield, shatter, summon, vanish, blink, move, wave, mark, reticle, buff, lift, sleep, crit, dodge, - * counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame), except kinds whose + * counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame, qi), except kinds whose * archetype is 'none' (hitCap: a leader hit cancelled by 限伤 draws nothing); unknown kinds get a generic sparkle. `extra` keys used: id (anchor unit — or the shooter of a `pt` kind), r | radius, dur | duration, * t (shell flight, game s), src / from / to / target / targets (unit ids), fx, fy / fromX, fromY / tx, ty (positions), - * element, n, scale, kind, tiles, hold ('lock': the reticle waits while the shooter's skill runs). + * element, n, scale, kind, tiles, hold ('lock': the reticle waits while the shooter's skill runs), dr / dc (a + * direction in tiles: 'qi' points its blade that way). */ simFx(kind, x, y, extra) { const ex = extra && typeof extra === 'object' ? extra : {}; @@ -168,6 +169,27 @@ export class FxSim { } break; } + // a travelling blade (赤刃明霄陈 S3's 龙剑气): a short trail from where it was to where it is, plus a crescent head + // turned the way it moves — the sim sends one of these every 0.12 s, and the trail outlives that gap so the + // events join into one sweeping wave. A procedural stand-in (the official effect is a particle prefab this client + // does not extract). Trail, head and heading share one height; the head is projected after streak(), which + // reuses this._p / this._q (projected before it, the head was drawn at the trail's start, one event behind) + case 'qi': { + const z = at.z + 0.35; + const fx0 = num(ex.fromX, NaN), fy0 = num(ex.fromY, NaN); + if (Number.isFinite(fx0) && Number.isFinite(fy0)) this.streak(fx0, fy0, at.x, at.y, z, col, 0.34); + const h = cam.project(at.x, at.y, z, this._p), hx = h.x, hy = h.y, hs = h.s; + const dr = num(ex.dr, NaN), dc = num(ex.dc, NaN); + let rot = 0; + if (Number.isFinite(dr) && Number.isFinite(dc) && (dr || dc)) { + const q = cam.project(at.x + dc * 0.5, at.y + dr * 0.5, z, this._q); + // the `slash` sprite's crescent bulges along its local −y, so this turns that edge onto the travel direction + rot = Math.atan2(q.x - hx, -(q.y - hy)); + } + this.particle('slash', hx, hy, { tint: col, life: 0.3, s0: hs / 128 * 1.3, s1: hs / 128 * 2.0, a0: 0.9, a1: 0, rot }); + this.particle('glow', hx, hy, { tint: col, life: 0.26, s0: hs / 128 * 0.8, s1: hs / 128 * 1.5, a0: 0.55, a1: 0 }); + break; + } case 'wave': { const k = clamp(num(ex.scale, 1), 0.5, 4); const rr = Math.max(0.8, r * (ex.scale ? Math.min(2, k / 2) : 1)); diff --git a/server/sim/content/kits/ops/op-chen3.js b/server/sim/content/kits/ops/op-chen3.js index 772446e9..aaa86c20 100644 --- a/server/sim/content/kits/ops/op-chen3.js +++ b/server/sim/content/kits/ops/op-chen3.js @@ -46,7 +46,10 @@ // field, the stage tiles beyond it are another player's half] —, an 侵入点 or a 保护目标 it turns 90° clockwise (four // turns at most per tick; boxed in on every side it waits) and forgets whom it hit; each enemy it can select within 1.3 // (air too) is hit once per straight run for the larger of hp_ratio × its current HP (the unit's own HP, PRTS) and -// projectile_min_atk_scale × her ATK at the hit, arts (RES applies — PRTS "非伤害保底或无视法术抗性"; 普通伤害). Her +// projectile_min_atk_scale × her ATK at the hit, arts (RES applies — PRTS "非伤害保底或无视法术抗性"; 普通伤害). +// The wave is a kit-managed probe, not a snapshot projectile, so it is shown with `fx('chen3Wave')` events every +// WAVE_FX_EVERY s (its position, the previous point and its direction — render/fx 'qi': a procedural blade trail with +// a crescent head, not the official particle prefab); before that the 龙剑气 had no visual at all (PR #382). Her // begin clips (S1 0.2 s, S3 0.433 s) are not modelled [ASSUMED: the engine starts skills at once]. import { num, talentBb, traitBb, skillRec, statBuff, toggleBuff, up } from '../shared/tier1.js'; @@ -69,6 +72,14 @@ const RETARGET_RADIUS = 1.7; const WAVE_SPEED = 1.5; const WAVE_PROBE = 0.25; const WAVE_HIT_RADIUS = 1.3; +/** + * How often the wave's position is sent to the client as an `fx` (`chen3Wave`, carrying the previous point and its + * direction). The wave is a kit-managed probe (it turns corners and lives as long as the skill), so it is not in the + * snapshot's projectile list: without these events the client has nothing to draw and the 龙剑气 is invisible (the cast + * emitted one `dash` puff and each hit a `slash` spark). The rate is the renderer's: each event draws a short blade + * trail whose life outlasts the gap, so the events join into one continuous wave. + */ +const WAVE_FX_EVERY = 0.12; /** S1's early end: a silence of hers that ends within this of the skill's planned end ended with it. */ const SILENCE_SLACK = 0.1; const AIR = Object.freeze({ canHitFly: true }); @@ -215,7 +226,8 @@ export default { attack: { atkScale: num(b3['attack@atk_scale'], 1), hits: 3 }, // `_additionalTimes` 2 onStart({ battle, unit, skill }) { const [dr, dc] = unit.fwd; - unit.mem.chen3Wave = { x: unit.x, y: unit.y, dr, dc, hit: new Set(), act: skill.activations, seq: unit.deploySeq }; + unit.mem.chen3Wave = { x: unit.x, y: unit.y, dr, dc, hit: new Set(), act: skill.activations, seq: unit.deploySeq, + fx: 0, fxX: unit.x, fxY: unit.y }; battle.fx('dash', { x: unit.x, y: unit.y, id: unit.id, skill: 'chen3:wave' }); }, onEnd({ unit }) { unit.mem.chen3Wave = null; }, @@ -285,8 +297,17 @@ export default { w.dr = 0 - w.dc; // (`0 - x`: never a negative zero) w.dc = dr; w.hit.clear(); + w.fxX = w.x; w.fxY = w.y; // the drawn trail starts again at the corner (no streak across the turn) } if (!waveBlocked(battle, w)) { w.x += w.dc * WAVE_SPEED * dt; w.y += w.dr * WAVE_SPEED * dt; } + // the 龙剑气 itself: tell the client where it is now (and where it came from) so it can be drawn — while it + // runs, while it is boxed in waiting, and after a turn (the direction comes along for the renderer) + w.fx += dt; + if (w.fx >= WAVE_FX_EVERY) { + w.fx = 0; + battle.fx('chen3Wave', { x: w.x, y: w.y, fromX: w.fxX, fromY: w.fxY, dr: w.dr, dc: w.dc, id: unit.id, skill: 'chen3:wave' }); + w.fxX = w.x; w.fxY = w.y; + } for (const e of battle.foesInRadius(w.x, w.y, WAVE_HIT_RADIUS)) { if (w.hit.has(e) || !canTargetEnemy(unit, e, AIR)) continue; w.hit.add(e); diff --git a/test/content/op_chen3.test.js b/test/content/op_chen3.test.js index d917c164..aa9686e9 100644 --- a/test/content/op_chen3.test.js +++ b/test/content/op_chen3.test.js @@ -319,6 +319,39 @@ test('S3 赤霄·天喟 (MANUAL, data ACTIVE_RANGE on 3-12): range 3-12, each at } }); +// 用户报告: 「自选干员赤刃明霄陈三技能的龙剑气看不到」. 波是 kit 自己管的探针(会拐弯、跟技能同寿),不在快照的 projectile +// 列表里,所以客户端只能靠 fx 事件画它——原来只在起手发了一个 `dash`、每次命中发 `slash`,波本身**没有任何事件**。 +// 现在每 WAVE_FX_EVERY(0.12) 秒发一次 `chen3Wave`:位置 + 上一个点(拖尾)+ 方向(月牙朝向)。 +test('S3: the 龙剑气 streams its position to the client as `chen3Wave` fx (position, previous point, direction)', () => { + const { h, u } = field({ tier: 6, elite: true, skill: 2 }); + u.skill.gainSp(999); + h.spawn('enemy_dummy', { pos: [10, 6] }); // a target in range: the MANUAL skill casts + const waveFx = () => h.events.filter((e) => e[0] === 'fx' && e[1] === 'chen3Wave'); + assert.ok(h.runUntil(() => u.skill.active, 2)); + h.run(1); + const fx = waveFx(); + assert.ok(fx.length >= 6, `每 0.12 秒一次 ⇒ 1 秒约 8 次(实际 ${fx.length})`); + for (const [, , x, y, ex] of fx) { + assert.equal(ex.id, u.id, '带着她的 id(可定位到玩家/队伍)'); + assert.equal(ex.skill, 'chen3:wave'); + assert.ok(Math.abs(x - u.tileC) <= 12 && Math.abs(y - u.tileR) <= 12, '波在场上'); + } + // 位置随时间前移、方向与朝向一致(row 10 向右),from 是上一个点(拖尾) + const [, , x0, , ex0] = fx[0]; + const last = fx[fx.length - 1]; + assert.ok(last[2] > x0, `向前推进(${x0.toFixed(2)} → ${last[2].toFixed(2)})`); + assert.deepEqual([last[4].dc, last[4].dr], [1, 0], '方向 = 她的朝向(向右)'); + assert.ok(Math.abs(ex0.fromX - u.tileC) < 0.5 && Math.abs(ex0.fromY - u.tileR) < 0.5, '第一个事件从上场点开始'); + assert.ok(last[4].fromX < last[2], 'from 是上一个事件的点(拖尾向前)'); + // 技能结束后不再发 + h.runUntil(() => !u.skill.active, 25); + h.step(); + const n = waveFx().length; + h.run(2); + assert.equal(waveFx().length, n, '技能结束后不再有波的 fx'); + done(h); +}); + test('S3: the sword wave leaves her tile forward at 1.5 tiles/s, hits each enemy within 1.3 once per straight run (air too) for max(6 % of its HP, 530 % / 550 % ATK) arts, turns clockwise at the field\'s edge / high ground / gates and forgets whom it hit, and is gone with the skill', () => { for (const [tier, elite] of [[5, false], [6, true]]) { const sk = skillOf(tier, elite, S3); diff --git a/test/golden/diy.json b/test/golden/diy.json index c467dac7..42e8f659 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -3158,8 +3158,8 @@ "enemy_9010_acpupp": [39,6,33,47997,613287] }, "hooks": [1,188,6098,435,435,1218,1333,444,85,174,21,21,0,18383,8,44,156,89,1,91,0,0,4], - "events": {"atk":476,"deploy":30,"die":85,"dmg":1333,"engage":11,"fx":508,"heal":7,"leak":89,"skill":42,"spawn":170,"status":9}, - "fx": {"blink":7,"dash":14,"dodge":4,"ember":6,"shield":138,"slash":308,"summon":21,"telegraph":6,"thorns":4}, + "events": {"atk":476,"deploy":30,"die":85,"dmg":1333,"engage":11,"fx":1558,"heal":7,"leak":89,"skill":42,"spawn":170,"status":9}, + "fx": {"blink":7,"chen3Wave":1050,"dash":14,"dodge":4,"ember":6,"shield":138,"slash":308,"summon":21,"telegraph":6,"thorns":4}, "snaps": "f1faec58 cbf72c28 58e3f561 b8182f46 b1f5b0f5 1de38a22 bc7c2a2b f233c949 01d56108 560ee3fc 8ec7a4b9 b95b08ce 1943beb7 c593b60a e179c3a0 4a2ad117 171d3fbd 1e1c4411 efd84577 32339940 33fdecb2" }, "diy-074": { @@ -3197,8 +3197,8 @@ ], "enemies": {"enemy_1229_darmy":[27,7,20,398,133429],"enemy_1229_darmy_2":[57,14,43,11905,308752],"enemy_1275_dwlock_2":[21,6,15,50953,369503]}, "hooks": [1,133,6831,258,258,706,729,74,50,128,11,11,0,8921,19,29,105,78,1,50,0,2,24], - "events": {"atk":301,"deploy":28,"die":50,"dmg":729,"engage":12,"fx":272,"heal":1,"leak":78,"skill":22,"spawn":117,"status":9}, - "fx": {"beam":46,"blink":4,"burst":2,"dash":8,"dodge":24,"shield":74,"slash":114}, + "events": {"atk":301,"deploy":28,"die":50,"dmg":729,"engage":12,"fx":872,"heal":1,"leak":78,"skill":22,"spawn":117,"status":9}, + "fx": {"beam":46,"blink":4,"burst":2,"chen3Wave":600,"dash":8,"dodge":24,"shield":74,"slash":114}, "snaps": "498914af 0a79facb 2cda1fb6 0a030d4c 9ce66676 179c11c1 62b416c3 fe75747c 619a4d17 62a8fa9d 86e914bd b1b84020 f6408a48 d11bff81 68c04d21 052d976c 5b0d0f6d 7b80bb08 e10c9b3e 0befc79d 41d43704 1e0376b1 8d64e6cf" }, "diy-075": { diff --git a/test/render/fxchen3wave.test.js b/test/render/fxchen3wave.test.js new file mode 100644 index 00000000..fd4faaef --- /dev/null +++ b/test/render/fxchen3wave.test.js @@ -0,0 +1,132 @@ +// test/render/fxchen3wave.test.js — 赤刃明霄陈 S3 赤霄·天喟 的「龙剑气」(用户报告: 三技能的龙剑气看不到). +// +// The wave is a kit-managed probe in the sim (server/sim/content/kits/ops/op-chen3.js): it leaves her tile forward at +// 1.5 tiles/s, turns 90° at the field's edge / high ground / gates, and lives as long as the skill. It is not in the +// snapshot's projectile list, so the client can only draw it from `fx` events — and before this change the sim sent +// none for the wave itself (one `dash` puff at the cast, one `slash` spark per enemy hit): the 龙剑气 was invisible. +// The sim now emits `fx('chen3Wave', { x, y, fromX, fromY, dr, dc, id, skill })` every 0.12 s — its position, the +// previous point (the trail) and its direction (the crescent's heading) — and render/fx.js draws the 'qi' archetype. +// Against the headless fake PIXI (test/render/fakepixi.js), like fxflame.test.js. + +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { installFakePixi, fakeViewCtx } from './fakepixi.js'; +import { presetCamera } from '../../public/js/render/projection.js'; + +let fake, FX; +before(async () => { + fake = installFakePixi(); + FX = await import('../../public/js/render/fx.js'); +}); +after(() => fake.restore()); + +const DT = 1 / 120; +const cam = presetCamera('normal', { width: 1600, height: 900 }); +const unit = (id, x, y, o = {}) => ({ id, x, y, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, info: { defId: 'x' }, onHit() {}, ...o }); + +function makeFx(views, ts = 1) { + const P = fake.P; + const ctx = fakeViewCtx(P); + const map = new Map(views.map((v) => [v.id, v])); + const fx = new FX.FxSystem({ + P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality: 'high', damageNumbers: true }, + timeScale: () => ts, loadLevel: () => 0, subProfOf: () => null, view: (id) => map.get(id) || null, + screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120, + }); + return { fx, map }; +} +const run = (fx, seconds) => { for (let t = 0; t < seconds - 1e-9; t += DT) fx.update(DT); }; +/** One sim event of the wave: its position, the previous point and the direction (`dc` cols, `dr` rows). */ +const wave = (fx, id, x, y, fromX, fromY, dc = 1, dr = 0) => fx.simFx('chen3Wave', x, y, { x, y, fromX, fromY, dr, dc, id, skill: 'chen3:wave' }); +/** Screen box of the drawn trail/head: min/max of every live particle position. */ +const drawn = (fx) => { + const list = fx.parts.filter((p) => p.sp && p.sp.visible !== false); + if (!list.length) return null; + const xs = list.map((p) => p.sp.position.x), ys = list.map((p) => p.sp.position.y); + return { n: list.length, x0: Math.min(...xs), x1: Math.max(...xs), y0: Math.min(...ys), y1: Math.max(...ys) }; +}; + +describe('赤刃明霄陈 S3 龙剑气: 波自己会被画出来 (issue: 看不到)', () => { + test('its own archetype — a travelling blade, not the generic sparkle or a dust puff', () => { + assert.equal(FX.FX_KINDS.chen3Wave.a, 'qi'); + assert.equal(FX.fxSpec('chen3Wave').a, 'qi'); + assert.equal(FX.fxSpec('chen3Wave').pt, true, 'drawn at the wave (never anchored to the caster)'); + }); + + test('one event draws a trail and a crescent head at the wave, not at her', () => { + const her = unit(1, 3, 10), waveAt = { x: 8, y: 10 }; + const { fx } = makeFx([her]); + assert.equal(drawn(fx), null, 'nothing before the event'); + wave(fx, her.id, waveAt.x, waveAt.y, 7.8, 10); + run(fx, 1 / 60); + const box = drawn(fx); + assert.ok(box && box.n >= 2, `the trail and the head are drawn (${box && box.n} pieces)`); + const at = cam.project(waveAt.x, waveAt.y, 0.35, { x: 0, y: 0, s: 0, depth: 0 }); + const hers = cam.project(her.x, her.y, 0.5, { x: 0, y: 0, s: 0, depth: 0 }); + const near = (v, t) => Math.abs(v - t) <= 90; + assert.ok(near(box.x1, at.x) || near(box.x0, at.x), `drawn around the wave (${Math.round(box.x0)}…${Math.round(box.x1)} vs ${Math.round(at.x)})`); + assert.ok(Math.abs(box.x1 - hers.x) > 60, 'not at her tile'); + }); + + // streak() reuses the effect system's projection scratch: a head placed from it sat on the trail's start, one event + // (~0.2 tile) behind the wave + test('the crescent head sits on the wave itself (not on the trail\'s start) and points the way it travels', () => { + const her = unit(1, 3, 10); + const { fx } = makeFx([her]); + const heads = () => fx.parts.filter((p) => p.sp.visible !== false && p.sp.texture === fx.tex.slash); + wave(fx, her.id, 8, 10, 7.6, 10, 1, 0); // moving right (+col) + const right = heads(); + assert.equal(right.length, 1, 'one crescent head'); + const at = cam.project(8, 10, 0.35, { x: 0, y: 0, s: 0, depth: 0 }); + assert.ok(Math.abs(right[0].x - at.x) < 0.5 && Math.abs(right[0].y - at.y) < 0.5, + `head at the wave (${right[0].x.toFixed(1)}, ${right[0].y.toFixed(1)}) vs (${at.x.toFixed(1)}, ${at.y.toFixed(1)})`); + assert.ok(Math.abs(right[0].sp.rotation - Math.PI / 2) < 0.35, `bulge to the right (rot ${right[0].sp.rotation.toFixed(2)})`); + run(fx, 1); + wave(fx, her.id, 8, 10, 8, 9.6, 0, 1); // moving up the screen (+row: the far side) + const up = heads(); + assert.equal(up.length, 1); + assert.ok(Math.abs(up[0].sp.rotation) < 0.35, `bulge up (rot ${up[0].sp.rotation.toFixed(2)})`); + }); + + test('the events of a sweep join into one trail that goes the way it travels', () => { + const her = unit(1, 3, 10); + const { fx } = makeFx([her]); + let prev = 3; + const fronts = []; + for (let i = 0; i < 8; i++) { // 0.12 s apart at 1.5 tiles/s ⇒ 0.18 tile a step + const x = 3 + 0.18 * (i + 1); + wave(fx, her.id, x, 10, prev, 10); + prev = x; + run(fx, 0.12); + fronts.push(drawn(fx).x1); + } + const box = drawn(fx); + assert.ok(box && box.n >= 4, `the recent steps are still alive (${box && box.n} pieces)`); + // the drawn front walks forward with the wave (each event is drawn ahead of the last, the older ones still fading) + for (let i = 1; i < fronts.length; i++) { + assert.ok(fronts[i] > fronts[i - 1] - 1, `step ${i}: the front moved on (${Math.round(fronts[i - 1])} → ${Math.round(fronts[i])})`); + } + assert.ok(fronts[fronts.length - 1] - fronts[0] > 60, `the sweep crossed the field (${Math.round(fronts[0])} → ${Math.round(fronts[fronts.length - 1])} px)`); + }); + + test('a wave boxed in (no movement) and a junk extra never throw', () => { + const her = unit(1, 3, 10); + const { fx } = makeFx([her]); + wave(fx, her.id, 8, 10, 8, 10, 0, 0); // standing still: no direction + wave(fx, her.id, 8, 10, undefined, undefined); + fx.simFx('chen3Wave', 8, 10, { id: her.id }); // bare event + fx.simFx('chen3Wave', NaN, NaN, { id: her.id, dr: 1, dc: 0 }); + run(fx, 0.2); + assert.ok(fx.counts.particles > 0, 'still drawn'); + }); + + test('the whole thing is gone once the events stop', () => { + const her = unit(1, 3, 10); + const { fx } = makeFx([her]); + wave(fx, her.id, 8, 10, 7.8, 10); + run(fx, 0.05); + assert.ok(drawn(fx).n > 0); + run(fx, 1); // every trail/head particle expires (life 0.34 s max) + assert.equal(drawn(fx), null, 'nothing is left hanging on the field'); + }); +}); From 8a64edc9e99b15b54a3d9bb0c9c6acd09f91986a Mon Sep 17 00:00:00 2001 From: ZiyueW1116 Date: Thu, 8 Oct 2026 18:54:54 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feedback7=20R22-RENDER:=20an=20enemy=20stan?= =?UTF-8?q?ding=20out=20its=20attack=20recovery=20is=20drawn=20standing=20?= =?UTF-8?q?=E2=80=94=20the=20snapshot=20carries=20stand=20/=20standCut=20a?= =?UTF-8?q?nd=20the=20interpolator=20holds=20the=20position=20until=20the?= =?UTF-8?q?=20recovery=20ends=20(PR=20#381=20by=20@ZiyueW1116,=20supersede?= =?UTF-8?q?s=20#307=20/=20#314);=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/SIM.md | 15 +- docs/design/network.md | 9 +- public/js/render/interp.js | 71 +++- server/sim/ai.js | 2 + server/sim/battle/deploy.js | 1 + server/sim/battle/displacement.js | 1 + server/sim/battle/events.js | 27 +- server/sim/battle/spawns.js | 1 + test/render/attack-recovery-interp.test.js | 428 +++++++++++++++++++++ types/core.js | 6 +- 10 files changed, 541 insertions(+), 20 deletions(-) create mode 100644 test/render/attack-recovery-interp.test.js diff --git a/docs/SIM.md b/docs/SIM.md index 08a53a3f..80930f36 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -1296,7 +1296,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te ## 9. Wire format (snapshot.js, DESIGN §8.2) -- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem? }`. +- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem?, stand?, standCut? }`. `sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active (ammo: `ammoLeft / ammoMax`, the activation's real total — 拉特兰's and 逃犯引渡手续's extra bullets included, community report #35). Units in DIE state stay 0.8 s. Active finite zero-SP duration skills display `timeLeft/duration` using `duration` as `spMax`; after end they show @@ -1309,6 +1309,19 @@ Unknown subprofessions fall back to the profession default (test `professions.te knocked-out operator's tile, §1; `row, col` = the tile it lies on and comes back on, `unit.body`); `elem: [[id, element, fill, cooldownEnd, cooldown]]` (only when non-empty) = `elementView` of every unit with a gauge or a running 爆发冷却 (§3). `fieldMeta()` lists the knocked-out operators too (a client joining mid-battle shows them; DESIGN §18.3). + `stand: [[id, until]]` (only when non-empty) = the game time each enemy's attack recovery ends (`atkStandUntil`: it + stands for the rest of its attack clip, §1.2 `attackStand`) — enemies alive, deployed, visible, neither feared nor stunned; + display metadata the battle never reads (PR #381). When `until` falls between two snapshots the renderer holds the older + position until then and interpolates the rest of the interval (HP / SP keep the whole interval); a newer snapshot that + moved before `until` is interpolated linearly; while the newest snapshot still holds a stand nothing is extrapolated + (the next one confirms the move). A snapshot without the field interpolates over the whole interval, a teleport or a + redeploy still snaps first, and nothing is inferred from the attack animation. `standCut: [[id, at]]` (only when + non-empty) = the latest game time each enemy's recovery was cut or ignored (a displacement — 失衡 —, fear, a stun, hiding, + leaving the field), one per enemy: an interval whose newer snapshot carries a cut inside it is interpolated linearly even + when the older stand ended inside it too, and an older cut leaves a later stand alone. It covers the enemies listed in + `units` (the 0.8 s death window included), never a hidden or unlisted one. The newest snapshot showing a death, a stun + (freeze, sleep, 浮空 included) or a block is not extrapolated until a later snapshot shows it moving again; the + extrapolated velocity uses the same moving span as the interpolation (the stand is not in its denominator). - `drainEvents()` tuples: `['spawn', UnitInfo]` (first appearance), `['deploy', id]` (every (re)deploy and 【移动】 — the client plays the deploy and its interpolation snaps instead of sliding), `['atk', src, tgt, projKind]` (`none|arrow|bolt|bomb|lob|orb|drone|enemy|boomerang|droneBomb|chain|chainHeal`; a boomerang's way back has no event — the renderer flies it back to the thrower at `BOOMERANG_RETURN_SPEED`; an enemy's `profile.shot` may name another kind, diff --git a/docs/design/network.md b/docs/design/network.md index 384b3c9c..c1db75f0 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -23,7 +23,14 @@ S→C: - NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type). - Active finite zero-SP timed skills display `sp=timeLeft`, `spMax=duration` and `UF.SKILL`; after end they display `0/0`. Snapshot display capacity does not change runtime SP/cost. -- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem? }` 20 Hz (a countdown summon's `hp` is maxHp × the share of its life left, §25.16.5). Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0) +- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem?, stand?, standCut? }` 20 Hz (a countdown summon's `hp` is maxHp × the share of its life left, §25.16.5). Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0) + Optional, only when non-empty (PR #381; display metadata, SIM §9): `stand: [[id, until]]` — the game time an enemy's + attack recovery ends (`atkStandUntil`; enemies alive, deployed, visible, neither feared nor stunned): when it falls + between two snapshots the renderer holds the position until then and interpolates the rest (HP / SP unchanged), and + extrapolates nothing while the newest snapshot still holds a stand; `standCut: [[id, at]]` — the latest time each + listed enemy's recovery was cut or ignored (失衡, fear, stun, hiding, leaving): an interval with a cut inside it stays + linear. A newest snapshot showing a death, a stun or a block is not extrapolated. Old snapshots without them + interpolate linearly; nothing is inferred from the attack animation. - `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]` (`'hitCap' {id, n}` = a cancelled 限伤 hit, drawn as nothing, §20.12; `extra.form` = the unit's model form from then on (an enemy's mode, a 傀儡师's 替身 §22.11) — `shared/protocol.js fxForm`, state like a spawn: never dropped by the client, §21.4), `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]` - `m.toast {kind, text}` · `m.ticker {text, id, type, priority, playerId}` (the strip plays the highest priority first, §21.10) · `m.emote {playerId, id}` · `m.result {...}` - `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, range?, base: {…same}}` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16) as its next battle starts (§18.5) diff --git a/public/js/render/interp.js b/public/js/render/interp.js index a3d99c8a..1df0d9de 100644 --- a/public/js/render/interp.js +++ b/public/js/render/interp.js @@ -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) @@ -20,18 +21,24 @@ // 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]. Two optional lists ride along -// (server/sim/battle/events.js snapshot, user playtest #4 items 8 / 9): +// 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; user playtest #4 items 8 / 9, 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`. -// Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT. +// both are integers): downAt(time) returns the list of the snapshot at `time`; +// * `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). */ @@ -43,6 +50,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']); /** @@ -66,8 +82,9 @@ export function frameTime(msg) { /** * Validate & normalise a b.snap payload. Returns `{ t, units: Map, down: [[id, respawnAt, respawnTime, - * state, row?, col?]] | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default + * state, row?, col?]] | null, stand: Map | null, standCut: Map | 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` 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; @@ -99,7 +116,21 @@ export function normalizeSnapshot(snap) { (down || (down = [])).push(e); } } - return { t, units, down, 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, stand, standCut, raw: snap }; } export class SnapshotBuffer { @@ -288,22 +319,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; diff --git a/server/sim/ai.js b/server/sim/ai.js index c9a9f0eb..00f08b9e 100644 --- a/server/sim/ai.js +++ b/server/sim/ai.js @@ -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; diff --git a/server/sim/battle/deploy.js b/server/sim/battle/deploy.js index 3f991d9d..72a6a6c4 100644 --- a/server/sim/battle/deploy.js +++ b/server/sim/battle/deploy.js @@ -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; diff --git a/server/sim/battle/displacement.js b/server/sim/battle/displacement.js index 12a2cfc7..6c409430 100644 --- a/server/sim/battle/displacement.js +++ b/server/sim/battle/displacement.js @@ -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 }); diff --git a/server/sim/battle/events.js b/server/sim/battle/events.js index 690b4c54..4a5a3199 100644 --- a/server/sim/battle/events.js +++ b/server/sim/battle/events.js @@ -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; } diff --git a/server/sim/battle/spawns.js b/server/sim/battle/spawns.js index 17c1c5fd..761e162f 100644 --- a/server/sim/battle/spawns.js +++ b/server/sim/battle/spawns.js @@ -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 索敌不受阻挡 diff --git a/test/render/attack-recovery-interp.test.js b/test/render/attack-recovery-interp.test.js new file mode 100644 index 00000000..d317cd1c --- /dev/null +++ b/test/render/attack-recovery-interp.test.js @@ -0,0 +1,428 @@ +// 敌人普攻后摇:可选快照元数据的生产、校验与位置插值;不改动战斗规则。 +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { SnapshotBuffer, normalizeSnapshot } from '../../public/js/render/interp.js'; +import { ANIM, UF } from '../../shared/constants.js'; +import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js'; +import { snapFrame } from '../../server/match/fields.js'; +import { TICK } from '../../server/sim/constants.js'; +import { rupture } from '../../server/sim/content/kits/ops/op-weedy.js'; + +const tuple = (x, anim = ANIM.ATTACK) => [1, x, 9, 100, 100, 0, 0, 0, anim]; +const frame = (t, x, until, anim) => ({ t, units: [tuple(x, anim)], ...(until === undefined ? {} : { stand: [[1, until]] }) }); +const near = (got, want) => assert.ok(Math.abs(got - want) < 1e-8, `${got} ≠ ${want}`); + +test('stand:仅保留快照中存在的单位与有限、未来的结束时间', () => { + const valid = normalizeSnapshot({ t: 'b.snap', gt: 1, units: [tuple(0)], stand: [ + null, 'x', [1], [2, 2], [1, '2'], [1, NaN], [1, Infinity], [1, 1], [1, 0.9], [1, 1.08], + ] }); + assert.deepEqual([...valid.stand], [[1, 1.08]]); + for (const stand of [undefined, null, {}, [], [[1, 1]], [[2, 2]]]) { + assert.equal(normalizeSnapshot({ t: 1, units: [tuple(0)], stand }).stand, null); + } +}); + +test('standCut:仅保留已有单位的有限、非负且不晚于快照的时间', () => { + const valid = normalizeSnapshot({ t: 'b.snap', gt: 1, units: [tuple(0)], standCut: [ + null, 'x', [1], [2, 0.9], [1, '0.9'], [1, NaN], [1, Infinity], [1, -1], [1, 1.01], [1, 0.9], + ] }); + assert.deepEqual([...valid.standCut], [[1, 0.9]]); + for (const standCut of [undefined, null, {}, [], [[1, -1]], [[2, 0]], [[1, 1.01]]]) { + assert.equal(normalizeSnapshot({ t: 1, units: [tuple(0)], standCut }).standCut, null); + } + assert.equal(normalizeSnapshot({ t: 1, units: [tuple(0)], standCut: [[1, 0], [1, 1]] }).standCut.get(1), 1); +}); + +test('后摇结束前保持位置,结束后插值剩余区间;HP/SP 仍按整段时间插值', () => { + const b = new SnapshotBuffer(); + b.push(frame(1, 0, 1.08), 0); + const next = frame(1.1, 0.02, undefined, ANIM.MOVE); + next.units[0][3] = 50; + next.units[0][5] = 10; + b.push(next, 0.05); + for (const t of [1, 1.04, 1.079]) { + const s = b.sample(t).get(1); + assert.equal(s.x, 0); + assert.equal(s.vx, 0); + assert.equal(s.anim, ANIM.ATTACK); + } + const middle = b.sample(1.05).get(1); + near(middle.hp, 75); + near(middle.sp, 5); + near(b.sample(1.09).get(1).x, 0.01); + near(b.sample(1.09).get(1).vx, 1); + near(b.sample(1.1).get(1).x, 0.02); +}); + +test('旧快照、过期元数据及攻击→移动动画不推断停步', () => { + for (const until of [undefined, 0.9, 1, NaN, Infinity]) { + const b = new SnapshotBuffer(); + b.push(frame(1, 0, until), 0); + b.push(frame(1.1, 0.1, undefined, ANIM.MOVE), 0.05); + near(b.sample(1.05).get(1).x, 0.05); + near(b.sample(1.05).get(1).vx, 1); + } +}); + +test('后摇结束时间恰为下一帧时,位置在该帧之前保持不动', () => { + const b = new SnapshotBuffer(); + b.push(frame(1, 0, 1.1), 0); + b.push(frame(1.1, 0.02), 0.05); + assert.equal(b.sample(1.09).get(1).x, 0); + near(b.sample(1.1).get(1).x, 0.02); +}); + +test('后摇尚未到期但新帧已移动时,不阻止权威位置更新', () => { + const b = new SnapshotBuffer(); + b.push(frame(1, 0, 2), 0); + b.push(frame(1.1, 0.1, undefined, ANIM.MOVE), 0.05); + near(b.sample(1.05).get(1).x, 0.05); +}); + +test('后摇结束时间落在两帧之间,但该区间有打断时,插值和外推均使用完整区间', () => { + for (const cutAt of [1, 1.04, 1.1]) { + const b = new SnapshotBuffer(); + b.push(frame(1, 0, 1.08), 0); + b.push({ ...frame(1.1, 0.1, undefined, ANIM.MOVE), standCut: [[1, cutAt]] }, 0.05); + near(b.sample(1.05).get(1).x, 0.05); + near(b.sample(1.099).get(1).vx, 1); + near(b.sample(1.101).get(1).vx, 1); + near(b.sample(1.15).get(1).x, 0.15); + } +}); + +test('旧的打断记录不影响后续未被打断的后摇', () => { + const b = new SnapshotBuffer(); + b.push(frame(1, 0, 1.08), 0); + b.push({ ...frame(1.1, 0.02), standCut: [[1, 0.9]] }, 0.05); + assert.equal(b.sample(1.04).get(1).x, 0); + near(b.sample(1.09).get(1).vx, 1); + near(b.sample(1.15).get(1).vx, 1); +}); + +test('后摇恢复段进入外推时速度连续,零长度移动区间不产生无穷速度', () => { + const b = new SnapshotBuffer(); + b.push(frame(1, 0, 1.08), 0); + b.push(frame(1.1, 0.02, undefined, ANIM.MOVE), 0.05); + near(b.sample(1.099).get(1).vx, 1); + near(b.sample(1.101).get(1).vx, 1); + near(b.sample(1.15).get(1).x, 0.07); + const edge = new SnapshotBuffer(); + edge.push(frame(1, 0, 1.1), 0); + edge.push(frame(1.1, 0.02), 0.05); + near(edge.sample(1.15).get(1).x, 0.02); + assert.equal(edge.sample(1.15).get(1).vx, 0); +}); + +test('停步期间及网络停顿越过结束时间后,都不使用攻击前的速度外推', () => { + const b = new SnapshotBuffer({ maxExtrapolate: 0.2 }); + b.push(frame(0.9, -0.1), 0); + b.push(frame(1, 0, 1.08), 0.05); + for (const t of [1.04, 1.09, 5]) { + const s = b.sample(t).get(1); + assert.equal(s.x, 0); + assert.equal(s.vx, 0); + } + b.push(frame(1.1, 0.02, undefined, ANIM.MOVE), 0.1); + assert.ok(b.sample(1.15).get(1).x > 0.02, '新位置确认后恢复原有外推'); +}); + +test('瞬移与重新部署的跳转优先于后摇插值', () => { + for (const [x, anim] of [[8, ANIM.MOVE], [0.1, ANIM.DEPLOY]]) { + const b = new SnapshotBuffer({ teleport: 2 }); + b.push(frame(1, 0, 1.04), 0); + b.push(frame(1.1, x, undefined, anim), 0.05); + assert.equal(b.sample(1.09).get(1).x, 0); + near(b.sample(1.1).get(1).x, x); + assert.equal(b.sample(1.09).get(1).vx, 0); + near(b.sample(1.15).get(1).x, x); + assert.equal(b.sample(1.15).get(1).vx, 0); + } +}); + +test('死亡或眩晕的最新帧不沿用旧速度外推;恢复移动后仍可外推', () => { + for (const anim of [ANIM.DIE, ANIM.STUN]) { + const b = new SnapshotBuffer(); + b.push(frame(0.9, -0.1, undefined, ANIM.MOVE), 0); + const stopped = frame(1, 0, undefined, anim); + if (anim === ANIM.DIE) stopped.units[0][3] = 0; + b.push(stopped, 0.05); + for (const t of [1.01, 1.24, 5]) { + assert.equal(b.sample(t).get(1).x, 0); + assert.equal(b.sample(t).get(1).vx, 0); + } + b.push(frame(1.1, 0.02, undefined, ANIM.MOVE), 0.1); + near(b.sample(1.15).get(1).x, 0.03); + } +}); + +test('最新帧中已消失的单位不外推,并从复用的采样结果中删除', () => { + const b = new SnapshotBuffer(), out = new Map(); + b.push(frame(0.9, -0.1, undefined, ANIM.MOVE), 0); + b.push(frame(1, 0, 1.08), 0.05); + b.push({ t: 1.1, units: [] }, 0.1); + assert.equal(b.sample(1.05, out).get(1).x, 0); + assert.equal(b.sample(1.05, out).get(1).vx, 0); + assert.equal(b.sample(1.2, out).has(1), false); +}); + +function arena(key, wallRow = 10) { + return makeBattle({ + defs: { chess: { t_wall: chessRec({ id: 't_wall', stats: { maxHp: 1e9, atk: 0, def: 1e6 }, skill: null }) } }, + units: [{ chessId: 't_wall', row: wallRow, col: 6 }], + enemies: [{ key, time: 0, route: 0 }], + content: 'none', autoFinish: false, timeLimit: 60, + }); +} + +test('真实隐形弩手:Battle → b.snap → SnapshotBuffer 在后摇结束前不滑动', () => { + const h = arena('enemy_1019_jshoot'); + let prev, pair; + for (let i = 0; i < 1200 && !pair; i++) { + h.step(); + if (i % 3 !== 2) continue; + const next = h.b.snapshot(); + const e = h.enemy('enemy_1019_jshoot'); + const a = prev?.units.find((u) => u[0] === e.id), z = next.units.find((u) => u[0] === e.id); + const until = prev?.stand?.find((v) => v[0] === e.id)?.[1]; + if (a && z && until > prev.t && until < next.t && (a[1] !== z[1] || a[2] !== z[2])) pair = { a, z, until, prev, next }; + prev = next; + } + assert.ok(pair, '真实攻击后摇跨越两帧且随后恢复移动'); + const { a, z, until, prev: A, next: B } = pair; + const b = new SnapshotBuffer(); + b.push(snapFrame('test', A), 0); + b.push(snapFrame('test', B), 0.05); + const held = b.sample((A.t + until) / 2).get(a[0]); + assert.equal(held.x, a[1]); + assert.equal(held.y, a[2]); + assert.equal(held.vx, 0); + const moved = b.sample((until + B.t) / 2).get(a[0]); + near(moved.x, (a[1] + z[1]) / 2); + near(moved.y, (a[2] + z[2]) / 2); + near(b.sample(B.t - 1e-6).get(a[0]).vx, b.sample(B.t + 1e-6).get(a[0]).vx); + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0); +}); + +test('真实隐形弩手:失衡跨帧打断时不按旧后摇时间冻结和集中追赶', () => { + const h = arena('enemy_1019_jshoot'); + assert.ok(h.runUntil(() => { + const e = h.enemy('enemy_1019_jshoot'); + return e && e.atkStandUntil - h.b.time > TICK && e.atkStandUntil - h.b.time < 3 * TICK; + }, 10)); + const e = h.enemy('enemy_1019_jshoot'), A = h.b.snapshot(); + const until = A.stand.find((s) => s[0] === e.id)[1]; + h.step(); + const cutAt = Math.round(h.b.time * 1000) / 1000; + assert.ok(h.b.displace(e, { x: -1, y: 0 }, 0.6) > 0); + h.step(2); + const B = h.b.snapshot(); + assert.ok(until > A.t && until < B.t); + assert.equal(B.standCut.find((s) => s[0] === e.id)[1], cutAt); + const b = new SnapshotBuffer(); + b.push(snapFrame('test', A), 0); + b.push(snapFrame('test', B), 0.05); + const a = A.units.find((u) => u[0] === e.id), z = B.units.find((u) => u[0] === e.id); + near(b.sample((A.t + B.t) / 2).get(e.id).x, (a[1] + z[1]) / 2); + near(b.sample(B.t + 1e-6).get(e.id).vx, (z[1] - a[1]) / (B.t - A.t)); + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0); +}); + +test('真实隐形弩手:后摇中击退并被移动创伤击杀,死亡帧保留打断且尸体不外推', () => { + const h = arena('enemy_1019_jshoot'); + assert.ok(h.runUntil(() => { + const e = h.enemy('enemy_1019_jshoot'); + return e && e.atkStandUntil - h.b.time > TICK && e.atkStandUntil - h.b.time < 3 * TICK; + }, 10)); + const e = h.enemy('enemy_1019_jshoot'), A = h.b.snapshot(); + const until = A.stand.find((s) => s[0] === e.id)[1]; + h.step(); + const cutAt = Math.round(h.b.time * 1000) / 1000; + // 温蒂的真实移动创伤实现:击退后下一次伤害结算在同一个快照间隔内击杀目标。 + rupture(h.b, h.unit('t_wall'), e, { duration: 5, perTile: 1e6, interval: TICK }); + near(h.b.push(e, e.s.massLevel + 1, { dir: { x: -1, y: 0 }, fixed: true }), 2.14); + h.step(2); + const B = h.b.snapshot(); + assert.equal(e.alive, false); + assert.ok(until > A.t && until < B.t); + assert.equal(B.standCut?.find((s) => s[0] === e.id)?.[1], cutAt); + assert.equal(B.stand?.some((s) => s[0] === e.id) ?? false, false); + const a = A.units.find((u) => u[0] === e.id), z = B.units.find((u) => u[0] === e.id); + assert.equal(z[8], ANIM.DIE); + const b = new SnapshotBuffer(); + b.push(snapFrame('test', A), 0); + b.push(snapFrame('test', B), 0.05); + near(b.sample((A.t + B.t) / 2).get(e.id).x, (a[1] + z[1]) / 2); + for (const t of [B.t, B.t + 0.01, B.t + 0.24, B.t + 1]) { + assert.equal(b.sample(t).get(e.id).x, z[1]); + assert.equal(b.sample(t).get(e.id).vx, 0); + } + b.update(0.05); + for (let i = 1; i <= 90; i++) b.update(0.05 + i / 120); + assert.ok(b.renderT > B.t); + assert.equal(b.sample().get(e.id).x, z[1]); + h.run(0.9); + const C = h.b.snapshot(); + assert.equal(C.units.some((u) => u[0] === e.id), false); + assert.equal(C.standCut?.some((s) => s[0] === e.id) ?? false, false); + b.push(snapFrame('test', C), 0.5); + assert.equal(b.sample(C.t + 0.1).has(e.id), false); + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0); +}); + +test('真实隐形弩手:AI 更新前死亡也记录后摇终止,隐藏和死亡窗口外不发送记录', () => { + const h = arena('enemy_1019_jshoot'); + assert.ok(h.runUntil(() => h.enemy('enemy_1019_jshoot')?.atkStandUntil > h.b.time + 0.1, 10)); + const e = h.enemy('enemy_1019_jshoot'); + const at = Math.round(h.b.time * 1000) / 1000; + h.b.kill(e, h.unit('t_wall')); + assert.equal(Math.round(e.atkStandCutAt * 1000) / 1000, at); + assert.deepEqual(h.b.snapshot().standCut, [[e.id, at]]); + e.hidden = true; + assert.equal(h.b.snapshot().standCut, undefined); + e.hidden = false; + h.run(0.9); + assert.equal(h.b.snapshot().standCut, undefined); + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0); +}); + +for (const status of ['stun', 'freeze', 'sleep', 'levitate']) { + test(`真实隐形弩手:移动后${status}的最新帧不继续滑动,解除后恢复移动`, () => { + const h = arena('enemy_1019_jshoot'); + h.step(3); + const e = h.enemy('enemy_1019_jshoot'), A = h.b.snapshot(); + h.step(); + assert.equal(h.b.applyStatus(e, status, { duration: 1, force: true, source: h.unit('t_wall') }), true); + h.step(2); + const B = h.b.snapshot(), z = B.units.find((u) => u[0] === e.id); + assert.equal(z[8], ANIM.STUN); + assert.notEqual(A.units.find((u) => u[0] === e.id)[1], z[1]); + const b = new SnapshotBuffer(); + b.push(snapFrame('test', A), 0); + b.push(snapFrame('test', B), 0.05); + assert.equal(b.sample(B.t + 0.1).get(e.id).x, z[1]); + assert.equal(b.sample(B.t + 0.1).get(e.id).vx, 0); + h.run(1.1); + const C = h.b.snapshot(); + assert.equal(e.s.flags.stun, false); + assert.ok(C.units.find((u) => u[0] === e.id)[1] < z[1]); + b.push(snapFrame('test', C), 0.6); + assert.ok(b.sample(C.t + 0.1).get(e.id).vx < 0); + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0); + }); +} + +test('真实隐形弩手:刚被阻挡的最新帧不继续滑动,阻挡者离场后恢复移动', () => { + const h = arena('enemy_1019_jshoot', 9); + let prev, pair; + for (let i = 0; i < 1200 && !pair; i++) { + h.step(); + if (i % 3 !== 2) continue; + const next = h.b.snapshot(), e = h.enemy('enemy_1019_jshoot'); + const a = prev?.units.find((u) => u[0] === e.id), z = next.units.find((u) => u[0] === e.id); + if (a && z && (z[7] & UF.BLOCKED) && a[1] !== z[1] && !next.stand?.some((s) => s[0] === e.id)) pair = { A: prev, B: next, e, z }; + prev = next; + } + assert.ok(pair, '移动与首次阻挡发生于同一个快照间隔,且没有后摇元数据'); + const { A, B, e, z } = pair; + const b = new SnapshotBuffer(); + b.push(snapFrame('test', A), 0); + b.push(snapFrame('test', B), 0.05); + assert.equal(b.sample(B.t + 0.24).get(e.id).x, z[1]); + assert.equal(b.sample(B.t + 0.24).get(e.id).vx, 0); + h.b.retreat(h.unit('t_wall'), { permanent: true }); + h.step(3); + const C = h.b.snapshot(); + assert.equal(e.blockedBy, null); + assert.ok(C.units.find((u) => u[0] === e.id)[1] < z[1]); + b.push(snapFrame('test', C), 0.1); + assert.ok(b.sample(C.t + 0.1).get(e.id).vx < 0); + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0); +}); + +test('真实隐形弩手:恐惧打断在延迟帧之后仍恢复线性插值', () => { + const h = arena('enemy_1019_jshoot'); + assert.ok(h.runUntil(() => { + const e = h.enemy('enemy_1019_jshoot'); + return e && e.atkStandUntil - h.b.time > 4 * TICK && e.atkStandUntil - h.b.time < 5 * TICK; + }, 10)); + const e = h.enemy('enemy_1019_jshoot'), A = h.b.snapshot(); + const until = A.stand.find((s) => s[0] === e.id)[1]; + h.step(); + assert.equal(h.b.applyStatus(e, 'fear', { duration: 1, source: h.unit('t_wall') }), true); + // 本地 runner 在卡帧时会推进多个 tick 后再发出一帧。 + h.step(5); + const B = h.b.snapshot(); + assert.ok(e.s.flags.fear && until > A.t && until < B.t); + assert.ok(B.standCut.some((s) => s[0] === e.id && s[1] >= A.t)); + const b = new SnapshotBuffer(); + b.push(snapFrame('test', A), 0); + b.push(snapFrame('test', B), (B.t - A.t) / 2); + const a = A.units.find((u) => u[0] === e.id), z = B.units.find((u) => u[0] === e.id); + const s = b.sample((A.t + until) / 2).get(e.id); + const alpha = (until - A.t) / (2 * (B.t - A.t)); + near(s.x, a[1] + (z[1] - a[1]) * alpha); + assert.ok(s.x > a[1]); + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0); +}); + +test('真实枯朽之种:命中帧即动画末尾,没有后摇时仍连续移动', () => { + const h = arena('enemy_1269_nhfly'); + let prev, checked = false; + for (let i = 0; i < 1200 && !checked; i++) { + h.step(); + if (i % 3 !== 2) continue; + const next = h.b.snapshot(); + const e = h.enemy('enemy_1269_nhfly'); + assert.ok(!next.stand?.some((s) => s[0] === e.id), '零后摇不发送 stand'); + const a = prev?.units.find((u) => u[0] === e.id), z = next.units.find((u) => u[0] === e.id); + if (a && z && a[8] === ANIM.ATTACK && z[8] === ANIM.MOVE && (a[1] !== z[1] || a[2] !== z[2])) { + const b = new SnapshotBuffer(); + b.push(prev, 0); + b.push(next, 0.05); + const s = b.sample((prev.t + next.t) / 2).get(e.id); + near(s.x, (a[1] + z[1]) / 2); + near(s.y, (a[2] + z[2]) / 2); + checked = true; + } + prev = next; + } + assert.ok(checked, '真实攻击→移动帧对'); + assert.equal(h.b.errors.length, 0); +}); + +test('快照不泄漏无效后摇:盟友、隐藏、未部署、死亡、恐惧、眩晕、到期及失衡打断', () => { + const h = arena('enemy_1019_jshoot'); + assert.ok(h.runUntil(() => h.enemy('enemy_1019_jshoot')?.atkStandUntil > h.b.time + 0.1, 10)); + const e = h.enemy('enemy_1019_jshoot'); + const has = () => h.b.snapshot().stand?.some((s) => s[0] === e.id) ?? false; + assert.ok(has()); + const ally = h.unit('t_wall'); + ally.atkStandUntil = e.atkStandUntil; + assert.ok(!h.b.snapshot().stand.some((s) => s[0] === ally.id)); + for (const key of ['hidden', 'alive', 'deployed']) { + const old = e[key]; + e[key] = key === 'hidden'; + assert.equal(has(), false, key); + e[key] = old; + } + for (const key of ['fear', 'stun']) { + e.s.flags[key] = true; + assert.equal(has(), false, key); + e.s.flags[key] = false; + } + for (const until of [h.b.time, -Infinity, NaN, Infinity]) { + e.atkStandUntil = until; + assert.equal(has(), false); + } + e.atkStandUntil = h.b.time + 1; + assert.ok(h.b.displace(e, { x: -1, y: 0 }, 0.2) > 0, '实际发生位移'); + assert.equal(has(), false, '失衡已清除后摇'); +}); diff --git a/types/core.js b/types/core.js index effe5f3a..f4ac108f 100644 --- a/types/core.js +++ b/types/core.js @@ -91,7 +91,9 @@ /** * `snapshot()` (SIM §9). On the wire this object is the payload of `b.snap`, * and `t` is sent as `gt` because the frame's `t` is the message type (DESIGN §8.2). - * `dp` is one number. `down` and `elem` are omitted when empty. + * `dp` is one number. `down`, `elem`, `stand` and `standCut` are omitted when empty: `stand` [id, until] = the game + * time an enemy's attack recovery ends, `standCut` [id, at] = the latest time it was cut or ignored (enemies in the + * snapshot's death window included). * * @typedef {Object} FieldSnapshot * @property {string} fieldId @@ -104,6 +106,8 @@ * @property {Object} [boss] * @property {DownSnap[]} [down] * @property {ElemSnap[]} [elem] + * @property {[number, number][]} [stand] + * @property {[number, number][]} [standCut] */ /** From 2669b6ab57d433850785c7548937f63ef9de2239 Mon Sep 17 00:00:00 2001 From: xcdoge Date: Thu, 8 Oct 2026 19:08:27 +0800 Subject: [PATCH 3/7] feedback7 R22-RENDER: a pushed / pulled enemy slides to where the sim put it, a dying flyer keeps its lift through the Die clip, a long Die clip plays whole (PR #380 by @xcdoge); golden: unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sim displaces at once (battle/displacement.js), so the snapshots only show the destination: a ≤ 2.5-tile push was lerped over one snapshot interval (3 frames, 0.58 tiles in one frame — frame-loop repro over a real battle), a longer pull snapped. render/app.js now starts UnitView.slideTo as soon as the interval it samples ends on the snapshot that carries the destination (a `displace` fx still queued up to SnapshotBuffer.nextSnapT); the view holds where it stood until that snapshot is shown, then eases into the sampled position under a constant deceleration (v0 = 2D/T, a = v0/T, T = 0.14·√D in 0.12–0.45 s — ours, [ASSUMED]: no source gives the official timing; docs/research/13 has the official impulse-and-friction classes). A unit that dies on the way finishes the slide and lies where the push put it. Death: DIE_CLIP_MAX 1.6 → 8 s (data/assets.json: 234 enemy Die clips, the longest 7.97 s, 25 over 1.6 s), and a flyer holds FLY_HOVER until the fade tail (DIE_FADE_TAIL 0.55 s). Substantially @xcdoge's code (PR #380: the slide, the death timing, research 13 / 14, most tests), authored as theirs. Changed on review: the corpse no longer goes back to where it stood (the PR's test asserted that; the sim's body is at the destination); the PR's pre-pass scanned (renderT − 0.5, renderT] after processEvents had taken those events, so it never fired and the slide started a frame before landing — it now looks ahead to the next snapshot and holds the view until then; the slide eases into the live sample instead of the fixed destination (no jump back onto an enemy that walked on); each step uses its mean velocity (the end-of-step speed ran 9 % short, then jumped); the constants' comments say the timing is ours; research 14 no longer cites a sweep script and a data guard that were not in the PR. Golden: unchanged (client only). Tests: test/render/unitview.test.js 35/35 (slide: deceleration, idempotence, facing, snapshots in flight, friction, √D, corpse lies where pushed, hold-then-ease without a landing jump, a never-shown slide handed back; the app.js frame order over a real battle: a 1.7-tile push over ≥ 8 frames with every step < 0.35 tiles, a push-kill's body staying at the destination; death: a dying flyer's lift, every Die clip within DIE_CLIP_MAX and a 7.97 s clip scheduled whole); test/sim/displace-fx.test.js 1/1; render/interp 16/16; docs tests pass. --- docs/research/00-INDEX.md | 2 + docs/research/13-knockback-official.md | 134 ++++++++++++ docs/research/14-death-animations.md | 109 ++++++++++ public/js/render/app.js | 22 ++ public/js/render/interp.js | 10 + public/js/render/units.js | 108 +++++++++- test/render/unitview.test.js | 274 +++++++++++++++++++++++++ test/sim/displace-fx.test.js | 33 +++ 8 files changed, 686 insertions(+), 6 deletions(-) create mode 100644 docs/research/13-knockback-official.md create mode 100644 docs/research/14-death-animations.md create mode 100644 test/sim/displace-fx.test.js diff --git a/docs/research/00-INDEX.md b/docs/research/00-INDEX.md index 64a68fa8..0f4d19c7 100644 --- a/docs/research/00-INDEX.md +++ b/docs/research/00-INDEX.md @@ -19,6 +19,8 @@ Tags: **[DATA]** client game data · **[VERIFIED]** official or wiki rule text, | `07-assets.md` / `07-assets.json` | Verified URL patterns and resolved URLs for avatars, portraits, skills, bond/item/band icons, UI, Spine (ops + enemies), BGM/SFX, fonts; build plan. Addendum: spot check 40/40 OK. | `operators[*]`, `enemies[*]`, `bonds`, `items`, `bands`, `autochessUi`, `audio` | | `11-limits-official.md` | Official limits read from the client (2.7.71 il2cpp) and the community: bond layers cap at **999** per bond (`MAX_GARRISON_STACK`, `AddBondCount` = min(L + n, 999)); in boss battles outside training a single hit of **≥ 300000** on a leader (`IsBossEnemy` = an `autoChessData.bossInfoDict` enemyId) is **cancelled** ("限伤", `MAX_BATTLE_DAMAGE`); implemented in DESIGN §20.12. | `shared/constants.js BOND_LAYER_CAP / BOSS_HIT_LIMIT` | | `12-flying-visuals-official.md` | Official flying visuals and model placement, read from the client (PR #211 by @xcdoge; ported into 0.2.0 by the owner's decision of 2026-10-06): the fly offset is the single model-independent constant **0.35** (`CharacterAnimator..ctor` stores `Vector3(0,0.35,0)` at field +0x114; `_SetFlyMountPointOffset` / `_SetFlyHitOffset` add it while flying, negate it on landing) — in the client's *character* space, whose unit is the standard prefab scale 0.27, so **0.35 / 0.27 ≈ 1.3 tiles**, confirmed by pixel-measuring an official screenshot (1.2–1.4). `FLY_HOVER` 0.32 → **1.3**, flat (no per-model term). The same sweep found two model quirks: `modelScaleY` 1.263 (帝国炮火先兆者 pair, vertically stretched) and `mirrorX` (木制瑞印, mirrored prefab). | `public/js/render/units.js FLY_HOVER / enemyModelScaleY`, `data/enemies.json`, `tools/local-extract/enemy_model_offsets.py` | +| `13-knockback-official.md` | Push / pull (推拉) in the official client (PR #380 by @xcdoge; written in Chinese): the knockback abilities (`Knockback`, `KnockBackWithDirection`, `KnockBackWithCharacterDirection`, `DragTowardSource`) carry an impulse (`m_force`) and a velocity decaying under friction (`m_friction*`, the tile's `additionalFriction` — `GrasslandType` NORMAL_LAND / ICE_LAND / SLIME_LAND); the data has no `move_duration` / `move_distance` key (distances are emergent; 力度 comes from `skill_table` / `enemy_database`); every tile of this mode's 11 maps is normal land. Also noted, not modelled: wall bounces (`_bounceTimesAsDamageTimes`) and `unbalanceProtectDueTime`. The client's slide (`v0 = 2D/T`, `a = v0/T`, T = 0.14·√D in 0.12–0.45 s) is ours [ASSUMED: no source gives the official timing]. | `render/units.js DISPLACE_SLIDE / slideTo`, `render/app.js` `displace` fx | +| `14-death-animations.md` | Enemy death animations (PR #380 by @xcdoge): the art never drops the body (the PR author's 234-clip sweep — no Die/Idle/Move/Attack clip translates the root), the 1.6 s death cap cut 25 clips short (盐风主教昆图斯 7.97 s) so it is 8 s, and a flying unit keeps its lift for the whole Die clip, dropping only inside the fade tail (the client removes its fly offset in `CharacterAnimator.OnFinish`). | `render/units.js DIE_CLIP_MAX / DIE_FADE_TAIL` | ## 2. Match structure diff --git a/docs/research/13-knockback-official.md b/docs/research/13-knockback-official.md new file mode 100644 index 00000000..19d20d2d --- /dev/null +++ b/docs/research/13-knockback-official.md @@ -0,0 +1,134 @@ +# 13 · 推拉(击退 / 拖拽)的官方实现:冲量 + 摩擦 + +这份文档回答两个问题:**官方推拉到底怎么动**,以及**摩擦是谁的参数**。结论全部来自官方客户端本体的 +`global-metadata.dat`(il2cpp 元数据)、官方 gamedata(社区镜像 Kengxxiao/ArknightsGameData)与官方 AB 资源包, +并附上每一步的验证方式,便于复核。 + +## 0. 结论速览 + +| 问题 | 答案 | +|---|---| +| 官方怎么动 | **冲量 + 摩擦**:给被推者一个力(`m_force` / `m_forceVectorX/Y`),速度**逐帧被摩擦衰减**直到停下 | +| 有固定时长/距离吗 | **没有**:数据里不存在 `move_duration` / `move_distance` / `move_tiles` 键(`moveDuration`、`moveTiles` 属于 UI 类)⇒ 位移量是**涌现**的,这也是社区只能给出"**推力-位移近似对应表**"的原因 | +| "力度"从哪来 | **官方数据**:`skill_table.json` 的 blackboard `force`(41 个技能带它),敌人侧在 `enemy_database.json`(8 条) | +| 摩擦是谁的参数 | **地板(地形)+ 单位质量**,**不是**技能参数:`GrasslandType { NORMAL_LAND, ICE_LAND, SLIME_LAND }`,每格是一条 `GrasslandData { modeIdx, grasslandType, additionalFriction }`,运行时还会被 `UpdateFrictionFactor` / `RestoreFrictionFactor` 临时改写 | +| 卫戍协议里有几种地板 | **实际只有普通地面**:11 张地图共 18 种格子字符,无冰面/黏液 ⇒ 本模式下摩擦恒定,滑行时长**不必按地板分档** | +| 飞行目标怎么算 | **没有独立的空中摩擦**(`boatAirFactor` / `m_airBase` 属于"船"玩法):飞行用的是**同一套**公式(单位摩擦 × 所在格子的 `additionalFriction`);差别只在**运动模式**(`ENEMY_MOTION_TYPE_FLY`、`HEIGHT_CHANGED_TO_FLY_BLOCK_MOTION_MODE` ⇒ 忽略地面阻挡),以及**失衡免疫 / 静态刚体**的空中单位根本不可位移 | +| 曲线形状 | 恒定减速 ⇒ `d/D = 2k − k²`(ease-out quad)、`T ∝ √D`;客户端用的 `T = 0.14·√D`(夹 0.12–0.45 s)是我们拟定的 [ASSUMED],没有来源给出官方时长 | + +## 1. 官方类与字段(`global-metadata.dat`) + +推拉能力的类簇(与 PRTS 的"推与拉"术语逐字对应): + +``` +Knockback · KnockBackWithDirection · KnockBackWithCharacterDirection · KnockBackTargetsByRootTile · DragTowardSource +_decreaseForceLevelWhenNotInDirection(PRTS"特殊修正"−2 力度)· _dontChangeFaceByDirection · _GetPushDir +TOO_CLOSE_TOLERANCE_SQR(PRTS"< 0.25 格转径向") +``` + +运动学字段(同一批类里): + +``` +m_force · m_forceVectorX/Y · forceScale · m_mass · m_velocity · get_mass · get_rigidbody2D +m_friction · m_frictionBase · m_frictionFactor · get_frictionFactor · m_frictionFactorAdditional · get_additionalFriction +UpdateFrictionFactor · RestoreFrictionFactor · _UpdateFrictionFactorAdditional · RestoreCached… +_ApplyForce · _UpdateVelocityAndPos · _UpdateAllUnitDeltaPos · _GetForceByDirectionAndDamage +m_bounceFrictionFactor · bounce · bounceease · _bounceTimesAsDamageTimes · _bounceBBKey · _bounceConditionValidator +unbalanceProtectDueTime +``` + +⇒ `m_force*`(冲量)+ `m_velocity` / `_UpdateVelocityAndPos`(逐帧速度积分)+ `m_friction*`(衰减)就是"冲量 + 摩擦"; +`UpdateFrictionFactor` / `RestoreFrictionFactor`(改完还要还原)说明摩擦**由外部临时提供**,不是能力自带。 + +## 2. 地板(地形)摩擦 + +地板系统就在**卫戍协议自己的格子类**里(同簇可见 `TileState { SHARED, MY_SIDE, MATE_SIDE }`、 +`CooperateStartTile` / `CooperateEndTile` / `CoopFootballTile`、`get_modeIndex` / `get_tilePlayerSide` / +`_OnPlayerDying` / `_OnPlayerRevive`): + +``` +_grasslandDatas · GrasslandType { NORMAL_LAND, ICE_LAND, SLIME_LAND } · GrasslandData { modeIdx, grasslandType, additionalFriction } +DynamicBuffQuickSandTile(流沙)· DynamicBuffTile · DirectionTile(带方向的格子) +``` + +⇒ 每格可带 `additionalFriction`,即"**地板提供摩擦**"。另有冰面相关因子(`iceFactor`、`iceTile`、`OnIce`、`Frozen`) +与"船/水/空"那套(`m_boatFrictionFactor`、`m_boatAirFactor`、`m_airBase`、`m_waterFlowForce*` —— 属于多索雷斯船玩法, +**不是**飞行单位)。 + +### 卫戍协议实际用到哪些地板 + +`docs/research/05-maps.json`(11 张地图)里出现过的全部格子字符: + +``` +# ×1776(墙/虚空) r ×580(道路) X ×477(分区隔断) f ×448(地面) a ×330 b ×250 A ×165 +p ×77(预览栏) d ×76(装置) S ×66(起点) I / E ×33(路线起/终) h ×24(手牌栏) O ×22 m ×12 i ×8 g ×8 R ×4 +``` + +**没有冰面或黏液** ⇒ 本模式下所有可站地板摩擦一致。 + +## 3. 力度来自官方数据(不是我们拟定的) + +`excel/skill_table.json` 的 blackboard 里 **`force` 出现 357 次、41 个技能**,例如: + +| 技能 | 名称 | 力度(按等级) | +|---|---|---| +| `skchr_weedy_1` / `_3` | 温蒂 炮管敲击 / 液氮大炮 | 0,1,2 / 1,2,3 | +| `skchr_forcer_1` / `_2` | 见行者 护身射击 / 惊爆射击 | 0,1,2 / 1,2,3 | +| `skchr_panda_1` / `_2` | 食铁兽 铁意六合 / 崩拳式 | 0,1,2 / 1,2,3 | +| `skchr_rope_1` / `_2` | 崖心 勾爪发射 / 复式勾爪 | 0,1,2 / 0,1,2 | +| `skchr_glady_1` / `_3` | 歌蕾蒂娅 缺水的大洋裂断 / 缺水的碎漩狂舞 | 0,1,2 / 0,1,2 | +| `skchr_sqrrel_1` / `_2` | 阿消 水蒸气泵 / 高压水炮 | 0,1,2 / 0,1,2 | +| `skchr_moeshd_2` | 可颂 磁爆锤 | 1,2,3 | +| `sktok_archook` | 钩索 token 发射 | **11** | + +同一张表里 **没有** `friction` / `mass` / `knockback` 键 ⇒ **力度是数据(可查表),摩擦与质量在引擎里**。 + +### 靠"职业"就能推拉的干员 + +`character_table.json` 的 `subProfessionId` 有两个专门职业,**普通攻击**即推/拉,力不在技能 blackboard 里: + +- **`pusher`(推击手)**:阿消 · 见行者 · 食铁兽 · 温蒂 +- **`hookmaster`(钩索师)**:暗索 · 歌蕾蒂娅 · 杏仁 · 雪雉 · 崖心 + +其余靠技能/天赋推拉的还有:Misery、灰烬、黑键、燧石、溯光星源、可颂、焰狐龙梓兰、傀影、莱伊、罗宾、圣聆初雪、 +琳琅诗怀雅、乌尔比安,以及 `勾爪` / `暴风雪` / `喷拒器` / `猎潮的骑士` / `流形` / `weedy_token` 等 token 与装置。 + +## 4. 顺带查清的两条官方机制(尚未建模) + +- **撞墙弹跳**:`bounce` / `bounceease` / `m_bounceFrictionFactor` / `_bounceBBKey`,并且 **`_bounceTimesAsDamageTimes` + —— 弹跳次数计入伤害次数**(温蒂 / 推击手那类撞墙伤害的官方口径)。 +- **失衡保护**:`unbalanceProtectDueTime` —— 连续推拉之间有保护窗口。 + +## 5. 客户端的做法(本次实现) + +`render/units.js`:模拟位移是**瞬时**的(`sim/battle/displacement.js` 在一次调用里走完 0.1 格步进),快照只带终点, +所以客户端补上中间帧——`UnitView.slideTo(x, y, { friction })` 建立一次滑行,`update()` **逐帧做速度积分** +(`v ← max(0, v − a·dt)`、`d ← d + v·dt`),终点由模拟给出(**权威**),`v0 = 2D/T`、`a = v0/T` 保证 +`T` 秒内正好走完 `D` 且速度归零 —— 即官方"冲量 + 摩擦"的等效形式;`T = 0.14·√D`(夹 0.12–0.45 s)是我们拟定的 +[ASSUMED](让画面按模拟的距离到位;没有来源给出官方时长),`friction` 是**地板因子**(本模式恒为 1;将来有冰面就乘 +< 1 ⇒ 滑得更久)。`render/app.js` 在插值器开始显示「以带终点的快照结尾」的那一段时就用 `displace` fx 启动滑行: +到达那张快照之前画面停在原处,之后从原处减速滑入模拟的实时位置(终点,以及敌人随后继续走的路),落地不跳。 +**模拟层、协议、golden 一律未改。** + +滑行途中死亡:滑行照常走完,尸体停在被推到的位置(模拟里它已经在那里;0.2.2 合入时改掉了原 PR 的「回到出发格」); +倒地的单位交给 `setDown` 放到它的格子上。 + +## 6. 复核方式 + +1. 元数据:`node` 读 `global-metadata.dat`,搜上列标识符(本文件所有类名/字段名都是这样得到的)。 +2. 力度表:`zh_CN/gamedata/excel/skill_table.json` → 各技能的 `levels[].blackboard` 里 `key === 'force'`; + 敌人侧 `levels/enemydata/enemy_database.json`。 +3. 职业:`excel/character_table.json` 的 `subProfessionId ∈ {pusher, hookmaster}`。 +4. 地板:`docs/research/05-maps.json` 的格子字符统计(本文件 §2)。 + +## 7. 附:官方 AB 资源包的读取要点(踩过的坑) + +供以后要读官方战斗配置时参考(本次为定位"摩擦是不是数据"而打通): + +- `Flags` 里的 `0x200`(`UsesAssetBundleEncryption`)在本客户端上是**虚设**:blocksInfo 既没有 70 字节加密前导, + 也不需要任何 16 字节 key(暴力扫 `global-metadata.dat` 144 万候选、`GameAssembly.dll` 5 万候选均无命中即因此)。 +- blocksInfo 位置 = `align16(headerEnd)`;**块数据是连续存储**的(不要逐块对齐)。 +- 压缩:`comp 3` = 普通 LZ4 ✓;`comp 4 / 5` = Arknights 自定义 LZ4AK(nibble 交换后按 LZ4 解, + 终止条件是 `op === uncompressedSize`,**严格相等**)。 +- 包内官方配置是**带 `$type` 的 JSON**(如 `Torappu.Battle.Action.Nodes+SetBodyDirection`), + 内嵌类型树 ⇒ 字段名可直接读;但**摩擦不在其中**(本文档 §2 已说明原因)。 diff --git a/docs/research/14-death-animations.md b/docs/research/14-death-animations.md new file mode 100644 index 00000000..a6c8ecf8 --- /dev/null +++ b/docs/research/14-death-animations.md @@ -0,0 +1,109 @@ +# 14 · Enemy death animations: the art never drops the body, and 25 clips were being cut short + +A player report — "a dying 帝国炮火先兆者 seems to drop at the very end", then "what about the other enemies' +animations?" — answered by reading the client's own data and its code. + +## 0. TL;DR + +- **No enemy animation moves the whole body.** Playing all 251 enemies' clips and sampling every bone's world position, + the **root bone's vertical displacement is 0.000 tiles in every Die (234), Idle (235), Move (234) and Attack (215) + clip**. Limbs move (up to 2.16 tiles for “帝国的甲胄”'s arms); the body never does. Every vertical placement is the + renderer's: the fly lift, the model's own art hang (`.skel bounds.y`) and nothing else. +- **A dying flyer dropped at the wrong moment.** The client removes its fly offset in `CharacterAnimator.OnFinish` — + when the finish state ends, not when the unit dies — so a drone stays aloft through the death animation and drops as + it goes. The remake dropped it the instant `alive` went false, so the death clip played on the ground. Fixed: the lift + is held for the Die clip and released inside the fade tail (§4). +- **25 death clips were cut short by a 1.6 s cap** — 盐风主教昆图斯's 7.97 s clip played 20 % of itself, 鼠王 37 %, + 杰斯顿·威廉姆斯 40 %, and 22 more (§3). The cap is now 8 s, above every clip in the data. +- 17 manifest entries have no Die clip at all; they are the ground items/collectibles (§5). + +## 1. How the clips were read [DATA] + +A sweep script of the PR author (PR #380, not in this repository) reads `data/assets.json` + the extracted `.skel` files +and **plays** each clip: `Skeleton` + `AnimationState`, stepped at 30 Hz, `state.apply(skeleton)` and +`skeleton.updateWorldTransform()` after every step, recording the range of `skeleton.y` (the root) and of every +`bone.worldY`. Ranges are converted to tiles with the model's `modelScale` (320 skeleton units = one tile at scale 1 — +the conversion the flying-visuals write-up uses for the same models). + +Do **not** try to read the timelines directly: in this build of `@pixi-spine/runtime-3.8` they hold packed numeric +arrays (`timeline.frames[0]` is a number, not `{time, x, y}`), so a keyframe walk silently reports zero motion — the +first attempt here did, and 270 Attack clips "had no rotation". Sampling the played skeleton is layout-agnostic and +matches what the client draws. + +## 2. Nothing translates the body [DATA] + +| clip | read | root displacement > 0.01 tiles | +|---|---|---| +| Die | 234 | **0** | +| Idle / Default | 235 | **0** | +| Move / Walk | 234 | **0** | +| Attack | 215 | **0** | + +The largest single-bone travel inside a Die clip is 2.16 tiles (“帝国的甲胄”, a flailing limb), then 1.40 (节日气球), +1.29 (“独轮车玩具”), 1.18 (业余竞演者) — normal animation, not a fall. A "the body sinks" effect can therefore only come +from the code paths that place the model (`FLY_HOVER`, the `zTarget` ease, `lift`, and the artist's `bounds.y`). + +## 3. Death clip durations, and the 1.6 s cap that cut them [DATA] + +234 Death clips: shortest **0.50 s**, median **1.00 s**, longest **7.97 s**. `render/units.js` used +`clamp(d / rate, 0.35, 1.6)`, so **25 clips were trimmed** (11 %), every one of them a boss or elite: + +| enemy | Die clip | played by the old cap | +|---|---|---| +| 盐风主教昆图斯 | 7.97 s | 1.60 s (20 %) | +| 鼠王 | 4.33 s | 37 % | +| 杰斯顿·威廉姆斯 | 4.00 s | 40 % | +| W / 泥岩 | 3.33 s | 48 % | +| 假想敌:胄 ×2 | 3.10 s | 52 % | +| “巨大的丑东西” | 2.83 s | 56 % | +| 腐败骑士 | 2.67 s | 60 % | +| 高普尼克 ×2 / 卢西恩,“猩红血钻” / 假想敌:管 ×2 | 2.33 s | 69 % | +| 假想敌:弦 | 2.30 s | 70 % | +| 雪孩子 / 伊利昂的木驮兽 / 鸭爵 ×2 / 纠缠藤蔓 / 凋零骑士 | 2.00 s | 80 % | +| 陷落雪祀 | 1.80 s | 89 % | +| “帝国的甲胄” / 底海滑动者 / 富营养的滑动者 | 1.67 s | 96 % | + +The lower bound (0.35 s) trims nothing: the shortest real clip is 0.50 s. The client plays its clip out — its `OnFinish` +fires when the finish state ends — so the cap only ever made the remake's deaths shorter than the official's. + +## 4. A dying flyer drops at the end of the clip, like the client [DATA] + +The client's fly offset is one constant (`Vector3(0, 0.35f, 0)` in `Torappu.Battle.CharacterAnimator`, applied by +`_SetFlyMountPointOffset` / `_SetFlyHitOffset` while a unit flies and negated when it lands; the remake draws 0.35 in +character space ≈ **1.3 tiles** as `FLY_HOVER` — the flying-visuals write-up has the full derivation). Its only direct +caller is `OnFinish` (#62924, call site +0x1805feb87), disassembled as `xor edx, edx; mov rcx, rbx; call _SetFlyMountPointOffset` — **arg 0**, the negation +branch — followed by clearing `[this+0x128]` and a tail call to another cleanup. `OnFinish` has no direct `call` site of +its own: it is a virtual lifecycle method (there is a `<>xLuaBaseProxy_OnFinish`), so the battle framework calls it +through the vtable when the character's **finish** state ends. + +Since the Die clips carry no vertical motion (§2): + +- **Client**: the drone stays aloft for the whole death animation and drops as the finish state ends. +- **Remake before**: `hoverTo = this.flying && this.alive ? FLY_HOVER : 0` — `die()` clears `alive` *before* the clip + plays, so every flyer sank to the ground inside ≈0.42 s (lerp τ = 1/6 s) and played its death clip **on the ground**. +- **Remake now**: the hover target is `this.alive || (this.dying > DIE_FADE_TAIL && !this.down)` — the flyer holds + `FLY_HOVER` for the whole clip (the view fades over the last `DIE_FADE_TAIL` = 0.55 s of `dying`) and drops inside + that fade, which is the client's `OnFinish` moment. A knocked-down (`down`) flyer still lies on the ground. + +All 27 flying enemies of the data have a Die clip of 0.67–1.00 s, well inside the cap, so the cap change never touches +them; 帝国炮火先兆者 and 帝国炮火中枢先兆者 are unexceptional (Die 1.00 s, root displacement 0.000). + +## 5. The 17 entries with no Die clip [DATA] + +未装配刀片 · 防护背心 · 冲击式施术单元 (deployable equipment) and 木制瑞印 · 红木瑞印 · 小说卷轴 · 诗画卷轴 · 青铜镜 · +黄铜镜 · 木制镇纸 · 红木镇纸 · 青瓷茶器 · 彩瓷茶器 · 铜矛头 · 铁矛头 · 铜灯盘 · 铁灯盘 (the collectible/relic props). They take +the documented frozen-first-idle-frame path (see the `die()` comment in `render/spine.js`), which is right for an item +that is picked up rather than killed. + +## 6. Implementation (2026-10-06, client-only) + +- `public/js/render/units.js`: new `DIE_FADE_TAIL = 0.55` (the fade tail `die()` already appended as a literal) and + `DIE_CLIP_MAX = 8`; `dying = clamp(d / rate, 0.35, DIE_CLIP_MAX) + DIE_FADE_TAIL`; the hover target as in §4; the three + `0.55` literals in `die()` / the knockdown branch / the fade now use `DIE_FADE_TAIL`. +- `test/render/unitview.test.js`: a dying flyer keeps `FLY_HOVER` through the Die clip and is under a quarter of it + inside the fade (a walker stays at 0); a data guard — no enemy Die clip of `data/assets.json` exceeds `DIE_CLIP_MAX` + (234 clips, the longest 7.97 s: the §3 durations re-checked from the manifest when this was merged in 0.2.2) — and + the 7.97 s clip is scheduled whole, the view still on screen 5 s in. +- The root-displacement sweep of §2 is the PR author's measurement (their script is not in the repository). +- Client-only: the server serves `units.js` per request, so a hard refresh (Ctrl+F5) applies it — no restart. diff --git a/public/js/render/app.js b/public/js/render/app.js index 32784604..40b505a7 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -1412,6 +1412,14 @@ export async function createFieldView(host, options = {}) { consumedIds.add(e[4].id); if (consumedIds.size > 200) consumedIds.delete(consumedIds.values().next().value); } + // 推拉: a push / pull (battle/displacement.js) moves the enemy inside one call; its `displace` fx carries the + // destination ['fx', 'displace', x, y, { id }] and the view slides there (UnitView.slideTo, PR #380) — normally + // started already by syncBattle's look-ahead, which this repeats harmlessly + if (e[1] === 'displace') { + const d = e[4] && typeof e[4] === 'object' ? e[4] : null; + const v = d && d.id != null ? views.get(d.id) : null; + if (v && v.slideTo) v.slideTo(Number(e[2]), Number(e[3])); + } // an enemy's mode change — the `form` of a sim setForm fx (shared/protocol.js fxForm: 掠海漂移体 → 爬行模式, user // playtest #5 item 1; 转译基底's forms, a 逐火 ember and its revival, the leaders' 重生, 守墓石像 — user report after // 0.1.0) — switches the view's clip set (UnitView.setForm; a kind without a clip set of that skeleton changes @@ -1464,10 +1472,24 @@ export async function createFieldView(host, options = {}) { if (best) { tiles.flashObjective(best[0], best[1]); board3d?.flashObjective(best[0], best[1]); } } + /** syncBattle's look-ahead: a queued `displace` fx ['fx', 'displace', x, y, { id }] starts that view's slide. */ + let slideAt = 0; + function startSlide(e) { + if (!Array.isArray(e) || e[0] !== 'fx' || e[1] !== 'displace') return; + const extra = e[4] && typeof e[4] === 'object' ? e[4] : null; + const v = extra && extra.id != null ? views.get(extra.id) : null; + if (v && v.slideTo) v.slideTo(Number(e[2]), Number(e[3]), { at: slideAt }); + } let renderT0Battle = null; // game time of the first rendered battle frame (spawn puffs skip the initial wave) let downSeq = 0; // syncBattle pass counter: a view still marked down after a pass left the `down` list function syncBattle(renderT) { if (renderT0Battle == null) renderT0Battle = renderT; + // A push / pull's slide starts as soon as the interval sampled below ends on the snapshot that carries the + // destination: its `displace` fx is due only when renderT reaches that snapshot, and by then sample() has already + // lerped (≤ teleport) or snapped the enemy most of the way — no frames in between (PR #380). The fx are still queued + // here (processEvents took only those ≤ renderT); the view holds until `at`, then eases into the sim's position. + slideAt = interp.nextSnapT(renderT); + if (slideAt > renderT) interp.forEachUpcoming(renderT, slideAt, startSlide); interp.sample(renderT, sample); for (const [id, s] of sample) { let v = views.get(id); diff --git a/public/js/render/interp.js b/public/js/render/interp.js index 1df0d9de..608fdbf9 100644 --- a/public/js/render/interp.js +++ b/public/js/render/interp.js @@ -283,6 +283,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. diff --git a/public/js/render/units.js b/public/js/render/units.js index 4e38aaf7..a7de69ad 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -161,6 +161,36 @@ const RAISED_Z = 0.12; * ride the body (`hover`); range highlights are tiles. */ export const FLY_HOVER = 1.3; +/** + * A push / pull slide (推拉, PR #380 by @xcdoge): real seconds per √tile of travel. The sim displaces at once — + * server/sim/battle/displacement.js walks its 0.1-tile steps inside one call — so the snapshots only ever show the + * destination, and a pushed / pulled enemy appeared at the end of its path (a ≤ 2.5-tile push in one snapshot interval, + * a longer pull snapped). The view now slides there under a constant deceleration (UnitView.slideTo): the official + * knockback abilities (`Knockback`, `KnockBackWithDirection`, `DragTowardSource` in the client's metadata) carry an + * impulse (`m_force`) and a velocity decaying under friction (`m_friction`, the tile's `additionalFriction`), and the data + * has no per-skill duration (docs/research/13-knockback-official.md). The duration T = DISPLACE_SLIDE·√D, kept in + * 0.12–0.45 s, is ours [ASSUMED: a fit so the view arrives where the sim put it — no source gives the official + * timing]; v0 = 2D/T, a = v0/T then run the distance in exactly T. Purely visual: the sim's timing, damage, blocking and + * the golden digests are untouched. + */ +export const DISPLACE_SLIDE = 0.14; +/** + * Terrain friction factor of a slide: 1 = normal land, < 1 = slippery (ICE_LAND) slides longer. Every tile of this mode + * is normal land (its 11 maps have no ice or slime), so it is always 1. + */ +export const SLIDE_FRICTION = 1; +/** + * Seconds of the death fade that follows the Die clip in `dying` (die(): the clip, then this tail). A flying unit keeps + * its lift for the whole clip and drops only inside this tail (PR #380: the official client removes its fly offset in + * `CharacterAnimator.OnFinish`, when the finish state ends — docs/research/14-death-animations.md). + */ +const DIE_FADE_TAIL = 0.55; +/** + * Longest Die clip a death plays out (s): the longest enemy Die clip of data/assets.json is 盐风主教昆图斯's 7.97 s (234 + * enemy Die clips; the old 1.6 s cap cut 25 of them short — the bosses' most), and the official client plays its clip + * out (PR #380). It only bounds a view whose data lies. + */ +export const DIE_CLIP_MAX = 8; /** b.snap `down` entry states (server/sim/constants.js DOWN_STATE). */ export const DOWN_STATE = Object.freeze({ COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 }); @@ -393,6 +423,7 @@ export class UnitView { this.tier = clamp(Number(info.tier) || 1, 1, 6); this.x = Number(info.x) || 0; this.y = Number(info.y) || 0; this.z = 0; this.zTarget = null; // battle: standing height the feet ease towards (tile top under the unit; 0 for enemies) + this.slide = null; // a push / pull slide (slideTo): held, then easing into the sim's position (update) /** @type {number|null} */ this.shadowZ = null; // an enemy flyer's shadow height (the tile top under it), else null: the shadow is at z /** @type {number|null} */ @@ -726,13 +757,18 @@ export class UnitView { // the element gauge shown (b.snap `elem`): element, fill 0..1, cooldown end (game s) and length this.el = typeof s.el === 'string' ? s.el : null; this.elFill = this.el ? s.elFill || 0 : 0; this.elUntil = this.el ? s.elUntil || 0 : 0; this.elDur = this.el ? s.elDur || 0 : 0; - this.x = s.x; this.y = s.y; + // a push / pull slide owns the drawn position (slideTo): the sample only anchors where it eases to, from the + // snapshot that carries the destination on (`at`) — before that the sample is still sliding or snapping there + const sl = this.slide; + if (!sl) { this.x = s.x; this.y = s.y; } + else if (!(t < sl.at)) { sl.lx = s.x; sl.ly = s.y; sl.live = true; } this.flying = !!(s.flags & UF.FLYING) || this.info.motion === 'FLY'; // enemies keep to the road plane: ground enemies only ever walk low tiles (a rounding step onto a block edge must not // pop them up), and a flyer hovers FLY_HOVER above the road whatever tile it crosses — the official lift is one // constant over the route (docs/research/12), so a block under it is no step (GitHub #277: a flyer passing over one // high-ground / forbidden block rose and dropped like stairs). Its shadow still lies on the tile top under it. - const floor = groundZ(this.ctx, s.x, s.y); + // While sliding, the floor is read under the view's own (mid-flight) position, not the destination's. + const floor = this.slide ? groundZ(this.ctx, this.x, this.y) : groundZ(this.ctx, s.x, s.y); const gz = this.isEnemy ? 0 : floor; if (this.zTarget == null) this.z = gz; this.zTarget = gz; @@ -755,6 +791,39 @@ export class UnitView { setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; } + /** + * Slide to a displacement's destination (推拉, the `displace` fx at (x, y)) instead of appearing there (DISPLACE_SLIDE). + * `at` (game s): the time of the snapshot that carries the destination — render/app.js starts the slide as soon as the + * interpolator shows the interval before it, and until the render clock reaches `at` the view holds where it stood + * (the sample in between is a lerp or a snap towards the destination). From then on it eases from there into the + * sampled position — the destination, and the walk the enemy resumes from it —, so it lands without a jump. Without + * `at` the destination counts as shown. A unit that dies on the way finishes the slide: its body lies where the push + * put it (the sim's position); a dead one is placed there, a knocked-down one is left to setDown. + */ + slideTo(x, y, opts = {}) { + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + // already on the way there: app.js starts the slide ahead of the event, then the event itself asks again + if (this.slide && this.slide.x1 === x && this.slide.y1 === y) return; + if (this.down) { this.slide = null; return; } + const dx = x - this.x, dy = y - this.y; + const D = Math.hypot(dx, dy); + if (!this.alive || !(D > 0.05)) { this.x = x; this.y = y; this.slide = null; return; } + const f = Number.isFinite(opts.friction) && opts.friction > 0 ? opts.friction : SLIDE_FRICTION; + // a slippery tile (f < 1) slides longer; the duration still follows √D (constant deceleration) + const T = Math.max(0.05, (opts.dur > 0 ? opts.dur : clamp(DISPLACE_SLIDE * Math.sqrt(D), 0.12, 0.45)) / f); + // v0 = 2D/T and a = v0/T run the distance in exactly T with the velocity reaching 0 there, integrated in update(). + // The official turns a displaced unit towards the force (its _dontChangeFaceByDirection is an opt-in flag), while + // our facing comes from the snapshot's vx — which points back down the route the enemy resumes after the + // displacement, i.e. against the push. The slide therefore owns the facing until it lands. + if (this.isEnemy) this.visFacing = dx < 0 ? -1 : 1; + const v0 = 2 * D / T; + const at = Number.isFinite(opts.at) ? opts.at : -Infinity; + this.slide = { + x1: x, y1: y, sx: this.x, sy: this.y, ux: dx / D, uy: dy / D, D, done: 0, v: v0, a: v0 / T, t: 0, dur: T, + at, live: at === -Infinity, lx: x, ly: y, ox: 0, oy: 0, run: false, wait: 0, + }; + } + setFacing(f) { if (this.isEnemy) { this.facing = f === -1 ? -1 : 1; return; } // the legacy ±1 only speaks about left / right: an UP / DOWN operator keeps its direction @@ -909,7 +978,7 @@ export class UnitView { if (this.actor && !d) d = this._fallDur(); const rate = this.ctx.animRate?.() || 1; this.dieT = 0; - this.dying = clamp(d / rate, 0.35, 1.6) + 0.55; + this.dying = clamp(d / rate, 0.35, DIE_CLIP_MAX) + DIE_FADE_TAIL; this.dieDur = this.dying; if (instant) { this.dieT = 30; if (this.actor) this.actor.update(30); } } @@ -948,7 +1017,7 @@ export class UnitView { if (!d) { if (!this.down) return; this.down = null; - if (!this.alive) { this.dying = 0.55; this.dieDur = 0.55; } + if (!this.alive) { this.dying = DIE_FADE_TAIL; this.dieDur = DIE_FADE_TAIL; } return; } if (this.alive) this.die(instant); @@ -969,6 +1038,31 @@ export class UnitView { /** @param {number} dt real seconds @param {any} cam camera @param {number} t real clock */ update(dt, cam, t) { if (this.destroyed) return; + // a displacement's slide (slideTo): held where it stood until the snapshot with the destination is shown, then + // eased into the sampled position (sync keeps `lx, ly` current) under a constant deceleration + if (this.slide) { + const sl = this.slide; + if (this.down) this.slide = null; // a knocked-down view lies on its tile (setDown) + else if (!sl.live) { + this.x = sl.sx; this.y = sl.sy; + // never shown (the unit left the snapshots, a stalled stream): the snapshot takes the view back + if ((sl.wait += dt) > 0.5) this.slide = null; + } else { + if (!sl.run) { sl.run = true; sl.ox = sl.sx - sl.lx; sl.oy = sl.sy - sl.ly; } + const step = Math.min(dt, 0.1); // a hidden tab's catch-up frame must not overshoot + const v0 = sl.v; + sl.v = Math.max(0, sl.v - sl.a * step); // friction: the velocity decays every frame + // the step's mean velocity — exact under a constant deceleration (the end-of-step speed fell ~D·dt/T short: + // 9 % of a 0.18 s slide at 60 fps, then a jump onto the end) + sl.done = Math.min(sl.D, sl.done + (v0 + sl.v) / 2 * step); + sl.t += step; + if (this.isEnemy) this.visFacing = sl.ux < 0 ? -1 : 1; + const k = 1 - sl.done / sl.D; // the share of the way still to go + this.x = sl.lx + sl.ox * k; this.y = sl.ly + sl.oy * k; + // the clock or the spent velocity lands it on the sampled position + if (sl.t >= sl.dur - 1e-9 || sl.done >= sl.D - 1e-9 || sl.v <= 1e-9) { this.x = sl.lx; this.y = sl.ly; this.slide = null; } + } + } const P = this.P; // the model for the state the frame's events and snapshot left (Front ⇄ Back when it went down / stood up: die, revive) if (this._modelDirty) { this._modelDirty = false; this._syncModel(); } @@ -987,7 +1081,9 @@ export class UnitView { const d = this.shadowZTarget - this.shadowZ; this.shadowZ = Math.abs(d) < 1e-3 ? this.shadowZTarget : this.shadowZ + d * Math.min(1, dt * 12); } - const hoverTo = this.flying && this.alive ? FLY_HOVER : 0; + // a flyer keeps its lift through the Die clip and drops only as it fades out: the client removes the fly offset in + // CharacterAnimator.OnFinish, when the finish state ends; a knocked-down flyer lies on the ground + const hoverTo = this.flying && (this.alive || (this.dying > DIE_FADE_TAIL && !this.down)) ? FLY_HOVER : 0; if (this.hover !== hoverTo) this.hover = Math.abs(hoverTo - this.hover) < 1e-3 ? hoverTo : this.hover + (hoverTo - this.hover) * Math.min(1, dt * 6); const p = cam.project(this.x, this.y, this.z + this.hover + this.lift, this.screen); const s = p.s; @@ -1001,7 +1097,7 @@ export class UnitView { } else if (this.dying > 0) { this.dieT += dt; this.dying -= dt; - const tail = 0.55; + const tail = DIE_FADE_TAIL; if (this.dying < tail) alpha *= Math.max(0, this.dying / tail); if (this.dying <= 0) { this.dying = 0; this.remove = true; alpha = 0; } } diff --git a/test/render/unitview.test.js b/test/render/unitview.test.js index 26e426b9..1e033118 100644 --- a/test/render/unitview.test.js +++ b/test/render/unitview.test.js @@ -435,3 +435,277 @@ describe('a fast enemy walks on its Run cycle (PR #275)', () => { assert.equal(hound.actor.roles.skill.loop, 'Skill_02', 'the cast slot survives that too'); }); }); + +// 推拉 (player report): the sim displaces instantly (battle/displacement.js walks 0.1-tile steps inside one call) and the +// snapshot only carries the destination, so the view eases there (app.js `displace` fx → UnitView.slideTo) instead of +// appearing at the end of the path. +describe('a push / pull slide (推拉: the official impulse under friction)', () => { + const view = async (id = 1) => { + const ctx = fakeViewCtx(fake.P, { assets: store({ spine: true }), cam }); + const v = new UnitView(ctx, { id, side: 'enemy', kind: 'enemy', defId: 'enemy_1007_slime', x: 5, y: 12, maxHp: 100 }, {}); + await tick(); await tick(); + return v; + }; + // 推进 n 帧(update 会把单帧 dt 截到 0.1 s,所以必须多帧走完) + const step = (v, dur, n = 60) => { for (let i = 0; i < n; i++) v.update(dur / n, cam(), 0); }; + + test('slideTo runs a real deceleration: the velocity falls every frame and stops on the destination', async () => { + const v = await view(); + v.slideTo(7.7, 12); + const sl = v.slide; + assert.ok(sl, 'a slide is in flight'); + assert.ok(sl.v > 0 && sl.a > 0, 'it starts with a speed and a deceleration'); + assert.ok(Math.abs(sl.v * sl.v / (2 * sl.a) - 2.7) < 1e-6, 'v²/2a = the distance (constant deceleration)'); + let prev = sl.v; + let monotone = true; + for (let i = 0; i < 40; i++) { + v.update(sl.dur / 80, cam(), 0); + if (v.slide && v.slide.v > prev + 1e-9) monotone = false; + if (!v.slide) break; + prev = v.slide.v; + } + assert.ok(monotone, 'the velocity only ever decreases'); + step(v, sl.dur); + assert.equal(v.x, 7.7, 'it arrives exactly on the authoritative destination'); + assert.equal(v.y, 12, 'the cross axis holds'); + assert.equal(v.slide, null); + }); + + test('starting a slide from the destination is a no-op (why app.js pre-scans the frame)', async () => { + // The trap this guards: app.js's snapshot loop runs before processEvents, and the snapshot already carries the + // displacement's destination, so a fx handler that runs after it finds the view there and has nothing to animate — + // the reported pause with no frames. app.js therefore starts the slide from the pre-snapshot position. + const v = await view(8); + v.sync({ x: 7.7, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 0 }); + assert.equal(v.x, 7.7); + v.slideTo(7.7, 12); + assert.equal(v.slide, null, 'no slide when the view is already there'); + }); + + test('a second request for the same destination never restarts the slide in flight', async () => { + const v = await view(9); + v.slideTo(7.7, 12); + const first = v.slide; + for (let i = 0; i < 10; i++) v.update(first.dur / 40, cam(), 0); + const mid = v.x, done = v.slide.done; + v.slideTo(7.7, 12); // the fx handler, later in the same frame + assert.equal(v.slide, first, 'the same slide object, not a restart'); + assert.equal(v.x, mid, 'the position is untouched'); + assert.equal(v.slide.done, done); + step(v, first.dur); + assert.equal(v.x, 7.7, 'and it still lands'); + }); + + test('a pushed enemy faces the way it is pushed, not the route it resumes', async () => { + // The official turns a displaced unit towards the force (_dontChangeFaceByDirection is an opt-in flag); our facing + // comes from the snapshot's vx, which after a displacement points back down the route the enemy walks again — + // i.e. against the push (player report: 被推开敌人的方向反过来了). + const v = await view(10); + v.sync({ x: 5, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 0, vx: -1.2 }); + assert.equal(v.visFacing, -1, 'walking left before the push'); + v.slideTo(7.7, 12); // pushed to the right + assert.equal(v.visFacing, 1, 'faces the push at once'); + for (let i = 0; i < 20; i++) v.update(v.slide.dur / 40, cam(), 0); + assert.equal(v.visFacing, 1, 'still facing the push in flight'); + v.slideTo(5.2, 12); // now pulled back to the left + assert.equal(v.visFacing, -1, 'and the other way for a pull'); + }); + + test('a snapshot in flight does not teleport it (the slide owns the position until it lands)', async () => { + const v = await view(7); + v.slideTo(7.7, 12); + const dur = v.slide.dur; + for (let i = 0; i < 12; i++) v.update(dur / 40, cam(), 0); + const mid = v.x; + assert.ok(mid > 5.2 && mid < 7.6, `mid-flight (${mid})`); + // the sim's snapshots already carry the destination (it displaced the enemy inside one call), so a plain sync + // would snap the view there — the reported "no frames in between" + v.sync({ x: 7.7, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 0 }); + assert.equal(v.x, mid, 'the snapshot does not move a sliding view'); + assert.equal(v.y, 12); + step(v, dur); + assert.equal(v.x, 7.7, 'and it still lands on the destination'); + // once landed, snapshots drive it again + v.update(dur, cam(), 0); + v.sync({ x: 6.4, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 0 }); + assert.equal(v.x, 6.4, 'a landed view follows the snapshot again'); + }); + + test('a slippery floor (a smaller friction factor) slides longer and lands just the same', async () => { + const dry = await view(2), ice = await view(3); + dry.slideTo(7.7, 12); + ice.slideTo(7.7, 12, { friction: 0.5 }); + assert.ok(ice.slide.dur > dry.slide.dur * 1.5, `ice ${ice.slide.dur} > dry ${dry.slide.dur}`); + step(ice, ice.slide.dur * 1.05, 90); + assert.equal(ice.x, 7.7, 'the destination is authoritative whatever the floor'); + }); + + test('the duration follows √distance, not distance', async () => { + const near = await view(4), far = await view(5); + near.slideTo(6.2, 12); far.slideTo(10.5, 12); // 1.2 vs 5.5 tiles + assert.ok(far.slide.dur > near.slide.dur, `${far.slide.dur} > ${near.slide.dur}`); + assert.ok(far.slide.dur / near.slide.dur < 2.6, 'sub-linear (∝ √d)'); + }); + + test('a trivial move snaps, an unknown target is ignored; a unit that dies on the way lies where it was pushed', async () => { + const v = await view(6); + v.slideTo(5.01, 12); + assert.equal(v.slide, null); assert.equal(v.x, 5.01, 'a sub-0.05 tile move is not a displacement'); + v.slideTo(NaN, 12); + assert.equal(v.slide, null); assert.equal(v.x, 5.01); + v.slideTo(7.7, 12); + step(v, v.slide.dur / 3, 20); + assert.ok(v.x > 5 && v.x < 7.7, `moving (${v.x})`); + // the sim's displacement already put the body at the destination (a Weedy wall kill dies there): the corpse + // finishes the slide and stays — PR #380 sent it back to where it stood, and the next snapshot then jumped it + v.die(); + v.update(1, cam(), 0); + v.update(0.1, cam(), 0); + assert.equal(v.x, 7.7, 'the corpse lies where the push put it'); + assert.equal(v.slide, null); + const dead = await view(11); + dead.die(); + dead.slideTo(7.7, 12); + assert.equal(dead.slide, null, 'a dead view does not start a slide'); + assert.equal(dead.x, 7.7, 'it is placed on the destination'); + }); + + test('held until the snapshot with the destination is shown (`at`), then eased into the sampled position — the walk it resumes included — with no jump on landing', async () => { + const v = await view(12); + v.sync({ x: 5, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: -0.8 }, 2.9); + v.slideTo(7.7, 12, { at: 3 }); // app.js: the interval ending on the destination's snapshot is shown + for (const [x, t] of [[5.9, 2.93], [6.8, 2.96]]) { // the interpolator lerps there in the meantime + v.sync({ x, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: 27 }, t); + v.update(1 / 60, cam(), 0); + assert.equal(v.x, 5, `held where it stood at ${t}`); + } + // from the destination's snapshot on the sample anchors the slide, and the enemy walks back down its route + let live = 7.7, prev = v.x, maxStep = 0, frames = 0; + for (let t = 3; v.slide && frames < 120; t += 1 / 30, frames++) { + v.sync({ x: live, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: -0.8 }, t); + v.update(1 / 60, cam(), 0); + maxStep = Math.max(maxStep, Math.abs(v.x - prev)); prev = v.x; + live -= 0.8 / 30; + } + assert.ok(frames >= 8, `a slide of ${frames} frames, not a jump`); + assert.equal(v.slide, null); + assert.ok(maxStep < 0.4, `no frame moves more than ${maxStep.toFixed(3)} tiles`); + const landed = v.x; + v.sync({ x: live, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: -0.8 }, 3 + frames / 30); + assert.ok(Math.abs(v.x - landed) < 0.05, `landed on the walking enemy (${landed.toFixed(3)} → ${v.x.toFixed(3)})`); + }); + + test('a slide that is never shown (the unit left the snapshots) hands the view back', async () => { + const v = await view(13); + v.slideTo(7.7, 12, { at: 3 }); + for (let i = 0; i < 40; i++) v.update(1 / 60, cam(), 0); + assert.equal(v.slide, null); + v.sync({ x: 6.1, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1 }, 3.4); + assert.equal(v.x, 6.1); + }); +}); + +// the app.js frame order (render/app.js: processEvents takes the events ≤ renderT, then syncBattle's look-ahead starts a +// slide for a `displace` fx still queued up to the next snapshot, then sample → sync, then update) run over a real +// battle: a pushed enemy is drawn sliding, not snapped +describe('a push through SnapshotBuffer and UnitView in the app.js frame order (PR #380)', () => { + async function pushed({ kill = false } = {}) { + const { makeBattle, enemyRec } = await import('../helpers/battleHarness.js'); + const { SnapshotBuffer } = await import('../../public/js/render/interp.js'); + const { snapFrame } = await import('../../server/match/fields.js'); + const h = makeBattle({ defs: { enemies: { enemy_walker: enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 0.8 }) } }, + enemies: [{ key: 'enemy_walker', time: 0, route: 0 }], content: 'none', autoFinish: false, timeLimit: 60 }); + const frames = []; + let e = null, info = null, moved = 0, dest = null; + for (let i = 0; i < 30 * 6; i++) { + h.step(); + e = e || h.enemy('enemy_walker'); + if (!moved && h.b.time >= 3) { + moved = h.b.displace(e, { x: 1, y: 0 }, 1.7); + dest = e.x; + if (kill) h.b.kill(e, null); + } + if (i % 3 === 2) { + const ev = h.b.drainEvents(); + info = info || ev.find((x) => x[0] === 'spawn')?.[1]; + const s = h.b.snapshot(); + frames.push({ at: s.t / 2, snap: snapFrame('f', s), ev, gt: s.t }); + } + } + assert.ok(moved > 1, `the sim pushed it ${moved} tiles`); + const ctx = fakeViewCtx(fake.P, { cam }); + const v = new UnitView(ctx, { ...info, id: e.id }, {}); + const buf = new SnapshotBuffer(), sample = new Map(), xs = []; + const start = (ev, at) => { if (ev[0] === 'fx' && ev[1] === 'displace' && ev[4]?.id === e.id) v.slideTo(Number(ev[2]), Number(ev[3]), at == null ? {} : { at }); }; + for (let f = 0, fi = 0; f < 60 * 3; f++) { + const now = f / 60; + for (; fi < frames.length && frames[fi].at <= now; fi++) { buf.push(frames[fi].snap, frames[fi].at); buf.pushEvents(frames[fi].ev, frames[fi].at, frames[fi].gt); } + const renderT = buf.update(now); + if (!Number.isFinite(renderT)) continue; + for (const ev of buf.takeEvents(renderT, [])) { start(ev); if (ev[0] === 'die' && ev[1] === e.id && v.alive) v.die(); } + const at = buf.nextSnapT(renderT); + if (at > renderT) buf.forEachUpcoming(renderT, at, (ev) => start(ev, at)); + buf.sample(renderT, sample); + if (sample.get(e.id)) v.sync(sample.get(e.id), renderT); + v.update(1 / 60, cam(), now); + if (renderT > 2.8 && renderT < 3.8) xs.push(v.x); + } + return { xs, dest }; + } + + test('a 1.7-tile push is drawn over a dozen frames, every frame a step under 0.35 tiles', async () => { + const { xs } = await pushed(); + let big = 0, moving = 0; + for (let i = 1; i < xs.length; i++) { const d = Math.abs(xs[i] - xs[i - 1]); big = Math.max(big, d); if (d > 0.02) moving++; } + assert.ok(moving >= 8, `${moving} frames show it moving (the bare interpolator: 3)`); + assert.ok(big < 0.35, `largest step ${big.toFixed(3)} tiles (the bare interpolator: 0.58)`); + }); + + test('killed right after the push, the body slides on to where the sim left it and stays there', async () => { + const { xs, dest } = await pushed({ kill: true }); + assert.ok(Math.abs(xs[xs.length - 1] - dest) < 0.011, `${xs[xs.length - 1]} vs ${dest}`); + const from = xs.findIndex((x, i) => i > 0 && x > xs[i - 1] + 0.01); // the slide's first frame (it walked left before) + assert.ok(from > 0); + for (let i = from; i < xs.length; i++) assert.ok(xs[i] >= xs[i - 1] - 1e-9, `never back towards where it stood (frame ${i})`); + }); +}); + +// PR #380, death: a flyer holds its lift through the Die clip (the official client drops its fly offset in +// CharacterAnimator.OnFinish) and a long Die clip plays whole (DIE_CLIP_MAX, data/assets.json's longest 7.97 s) +describe('death timing (PR #380): a dying flyer drops inside the fade, a long Die clip plays whole', () => { + test('a dying flyer keeps FLY_HOVER through its Die clip and sinks only in the fade tail; a walker stays down', async () => { + const { FLY_HOVER, UF } = { ...(await import('../../public/js/render/units.js')), ...(await import('../../shared/constants.js')) }; + const ctx = fakeViewCtx(fake.P, { cam }); + const fly = new UnitView(ctx, { id: 21, side: 'enemy', kind: 'enemy', defId: 'enemy_fly', motion: 'FLY', x: 5, y: 12, maxHp: 100 }, {}); + fly.sync({ x: 5, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: UF.FLYING, anim: 1 }, 1); + for (let i = 0; i < 120; i++) fly.update(1 / 60, cam(), 0); + assert.ok(Math.abs(fly.hover - FLY_HOVER) < 1e-3, 'aloft while alive'); + fly.die(); // no clip known: 0.35 s of clip, then the 0.55 s fade + for (let i = 0; i < 18; i++) fly.update(1 / 60, cam(), 0); + assert.ok(Math.abs(fly.hover - FLY_HOVER) < 1e-3, `still aloft in the Die clip (${fly.hover.toFixed(3)})`); + for (let i = 0; i < 30; i++) fly.update(1 / 60, cam(), 0); + assert.ok(fly.hover < FLY_HOVER * 0.25, `sinking in the fade (${fly.hover.toFixed(3)})`); + const walker = new UnitView(ctx, { id: 22, side: 'enemy', kind: 'enemy', defId: 'enemy_walk', x: 5, y: 12, maxHp: 100 }, {}); + walker.die(); + walker.update(1 / 60, cam(), 0); + assert.equal(walker.hover, 0); + }); + + test('every enemy Die clip of data/assets.json is within DIE_CLIP_MAX, and a 7.97 s one is scheduled whole', async () => { + const { DIE_CLIP_MAX, dieClipDur } = await import('../../public/js/render/units.js'); + const durs = Object.values(ASSETS.enemies).map((e) => dieClipDur(e && e.spine)).filter((d) => d > 0); + assert.ok(durs.length > 200, `${durs.length} enemy Die clips`); + const longest = Math.max(...durs); + assert.ok(longest <= DIE_CLIP_MAX, `longest Die clip ${longest} s ≤ ${DIE_CLIP_MAX}`); + assert.ok(durs.filter((d) => d > 1.6).length > 0, 'the old 1.6 s cap cut some clips short'); + const entry = { skel: '/s/q.skel', atlas: '/s/q.atlas', textures: ['/s/q.png'], anims: { idle: 'Idle', die: 'Die' }, animations: { Idle: 1, Die: longest } }; + const assets = { ...store({ spine: true }), spineEntry: () => entry }; + const v = new UnitView(fakeViewCtx(fake.P, { assets, cam }), { id: 23, side: 'enemy', kind: 'enemy', defId: 'enemy_1521_dslily', x: 5, y: 12, maxHp: 100 }, {}); + await tick(); await tick(); + assert.ok(v.actor, 'the model is up'); + v.die(); + assert.ok(Math.abs(v.dying - (longest + 0.55)) < 1e-6, `the whole clip, then the fade (${v.dying.toFixed(3)} s)`); + for (let i = 0; i < 300; i++) v.update(1 / 60, cam(), 0); + assert.ok(!v.remove && v.alpha > 0.5, 'still on screen 5 s in'); + }); +}); diff --git a/test/sim/displace-fx.test.js b/test/sim/displace-fx.test.js new file mode 100644 index 00000000..680e3743 --- /dev/null +++ b/test/sim/displace-fx.test.js @@ -0,0 +1,33 @@ +// The contract between server/sim/battle/displacement.js and render/app.js: a displacement puts a +// ['fx', 'displace', x, y, { id }] tuple into the stream the client renders. The client must read the destination +// from the tuple's own slots — e[4] carries the id only, and reading x/y from it silently produced NaN, which made +// slideTo a no-op (player report: 有停顿但没有中间帧). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec } from '../helpers/battleHarness.js'; + +const walker = (o = {}) => enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 1, ...o }); + +test('a displacement puts a `displace` fx into the event stream the client receives', () => { + const h = makeBattle({ + defs: { enemies: { enemy_walker: walker({ speed: 0.01 }) } }, + enemies: [{ key: 'enemy_walker', pos: [10, 6] }], + content: 'none', autoFinish: false, + }); + h.step(); + const e = h.enemy('enemy_walker'); + h.b.drainEvents(); // clear the spawn/etc. batch + const moved = h.b.displace(e, { x: 1, y: 0 }, 2, { force: 1 }); + assert.ok(moved > 1.8, 'the enemy moved: ' + moved); + const evs = h.b.drainEvents(); + const fx = (evs || []).filter((t) => Array.isArray(t) && t[0] === 'fx'); + const disp = fx.find((t) => t[1] === 'displace'); + assert.ok(disp, 'the stream carries a displace fx: ' + JSON.stringify(fx)); + // the fx rounds its coordinates to 2 decimals (events.js fx), the unit keeps full precision + assert.ok(Math.abs(disp[2] - e.x) <= 0.01, 'e[2] is the destination x: ' + disp[2] + ' vs ' + e.x); + assert.ok(Math.abs(disp[3] - e.y) <= 0.01, 'e[3] is the destination y: ' + disp[3] + ' vs ' + e.y); + assert.equal(disp[4] && disp[4].id, e.id, 'e[4].id is the displaced unit'); + assert.equal(disp[4].x, undefined, 'e[4] carries no x — reading it gave NaN, so no slide ever ran'); + assert.equal(disp[4].y, undefined, 'e[4] carries no y either'); +}); + From dd23572b4a62a3f32b1f187bc9ba9cacb23e4312 Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 19:10:15 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feedback7=20R22-RENDER:=20=E7=90=B3?= =?UTF-8?q?=E7=90=85=E8=AF=97=E6=80=80=E9=9B=85's=20=E9=A6=99=E6=A7=9F?= =?UTF-8?q?=E7=82=B8=E5=BC=B9=20never=20shows=20an=20HP=20bar=20=E2=80=94?= =?UTF-8?q?=20PRTS=20=E5=A4=87=E6=B3=A8=20"=E5=8D=B3=E4=BD=BF=E8=87=AA?= =?UTF-8?q?=E8=BA=AB=E7=94=9F=E5=91=BD=E5=80=BC=E6=9C=AA=E6=BB=A1=EF=BC=8C?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E4=B8=8B=E6=96=B9=E4=B9=9F=E4=B8=8D=E4=BC=9A?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E7=94=9F=E5=91=BD=E5=80=BC=E6=A7=BD"=20(owne?= =?UTF-8?q?r=20item=2011);=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner reported 「琳琅诗怀雅的2技能的香槟会掉血,会被治疗」. Allies always draw an HP bar (render/units.js _updateHud), so the bomb's every HP change showed. PRTS 香槟炸弹 (实体类型 装置, 1000 HP) says no HP bar is shown even below full HP; nothing there makes it invulnerable or unhealable. Its bar is now hidden at any HP (NO_HP_BAR). Its HP, the damage it takes (活性源石 hurts what stands on it — 70 true damage a second) and healing are left as they are: no invulnerability added; whether it should be unhealable is the owner's decision (no written source says so). Tests: test/render/champagne-hpbar.test.js (new, 2: no bar at full HP, hurt or healed; another summon keeps its bar) 2/2; render/unitview 35/35. --- public/js/render/units.js | 8 +++++- test/render/champagne-hpbar.test.js | 43 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 test/render/champagne-hpbar.test.js diff --git a/public/js/render/units.js b/public/js/render/units.js index a7de69ad..206c8278 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -117,6 +117,12 @@ const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Dat * 无头像; data/assets.json has no avatar or model for it, so the token fallback showed 圣聆初雪's own face) — the marker of a * frozen gate. */ const ICE_TOKENS = new Set(['token_10058_sbell2_icetgt']); +/** + * Units that never show an HP bar: 琳琅诗怀雅's 香槟炸弹 — PRTS 香槟炸弹 备注 "即使自身生命值未满,模型下方也不会显示生命值槽" + * (the owner's report of 2026-10-08, 「香槟会掉血,会被治疗」: the bar showed every change). Only the bar: its HP, the + * damage it takes (活性源石 hurts what stands on it) and healing are unchanged — no source makes it invulnerable. + */ +const NO_HP_BAR = new Set(['token_10031_swire2_gdtrap']); const ICE_FRAME = 0x9fe6ff; /** How long a view waits for its avatar before showing the image-less placeholder diamond. */ const PIC_WAIT_MS = 400; @@ -1249,7 +1255,7 @@ export class UnitView { const prep = this.prep; const showBars = !prep && this.alive && this.info.kind !== 'item'; const damaged = this.hp < this.maxHp - 0.5; - const showHp = showBars && (!this.isEnemy || damaged || this.isBoss); + const showHp = showBars && (!this.isEnemy || damaged || this.isBoss) && !NO_HP_BAR.has(this.info.defId); const bw = clamp(s * (this.isBoss ? UNIT.bossBarWidth : UNIT.barWidth), 24, this.isBoss ? 260 : 96); const bh = clamp(s * (this.isBoss ? 0.12 : 0.075), 3, this.isBoss ? 12 : 7); // a knocked-down operator's HUD is its redeploy ring alone, drawn at full strength over the greyed model diff --git a/test/render/champagne-hpbar.test.js b/test/render/champagne-hpbar.test.js new file mode 100644 index 00000000..d05548d3 --- /dev/null +++ b/test/render/champagne-hpbar.test.js @@ -0,0 +1,43 @@ +// test/render/champagne-hpbar.test.js — 琳琅诗怀雅's 香槟炸弹 (token_10031_swire2_gdtrap) never shows an HP bar: PRTS 香槟炸弹 +// 备注 "即使自身生命值未满,模型下方也不会显示生命值槽" (the owner's report of 2026-10-08, 「香槟会掉血,会被治疗」). Allies +// otherwise always draw one (render/units.js _updateHud). Only the bar changes; its HP and healing stay as they are. +// Run: node --test test/render/champagne-hpbar.test.js + +import { test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { installFakePixi, fakeViewCtx } from './fakepixi.js'; +import { presetCamera } from '../../public/js/render/projection.js'; + +let fake, UnitView; +before(async () => { + fake = installFakePixi(); + ({ UnitView } = await import('../../public/js/render/units.js')); +}); +after(() => fake.restore()); + +const cam = () => presetCamera('normal', { width: 1280, height: 720 }); +const view = (defId, id) => new UnitView(fakeViewCtx(fake.P, { cam }), { id, side: 'ally', kind: 'summon', defId, tier: 1, x: 11, y: 10, maxHp: 1000, hp: 1000 }, {}); +const frames = (v) => { for (let i = 0; i < 2; i++) v.update(1 / 60, cam(), i / 60); }; +const bar = (v) => [v.hpBg.visible, v.hpFill.visible, v.hpGhost.visible]; + +test('香槟炸弹 shows no HP bar at full HP, below it, or healed back up', () => { + const bomb = view('token_10031_swire2_gdtrap', 1); + frames(bomb); + assert.deepEqual(bar(bomb), [false, false, false], 'full HP'); + bomb.sync({ x: 11, y: 10, hp: 860, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0 }, 1); + frames(bomb); + assert.equal(bomb.hp, 860, 'the view keeps its HP'); + assert.deepEqual(bar(bomb), [false, false, false], 'hurt (活性源石: 70 true damage a second)'); + bomb.sync({ x: 11, y: 10, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0 }, 2); + frames(bomb); + assert.deepEqual(bar(bomb), [false, false, false], 'healed'); +}); + +test('another summon still draws its HP bar, full or hurt', () => { + const other = view('token_10000_silent_healrb', 2); + frames(other); + assert.deepEqual(bar(other), [true, true, true]); + other.sync({ x: 11, y: 10, hp: 400, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0 }, 1); + frames(other); + assert.deepEqual(bar(other), [true, true, true]); +}); From 1e78648ad45e3f3c61c1396fdad3035c0052042c Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 19:18:01 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feedback7=20R22-RENDER:=20closed=20eyes=20s?= =?UTF-8?q?tay=20closed=20without=20the=20stencil=20masks=20=E2=80=94=20th?= =?UTF-8?q?e=20eyelid=20clips'=20covered=20slots=20hide=20when=20their=20c?= =?UTF-8?q?entre=20leaves=20the=20clip=20polygon=20(GitHub=20#177,=20re-im?= =?UTF-8?q?plemented=20from=20PR=20#384=20by=20@Convey123);=20golden:=20un?= =?UTF-8?q?changed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eyelids of 佩佩, 仇白, 隐德来希, 琳琅诗怀雅 and other models are Spine clipping attachments: the polygon is the eye's opening and the eyeball / eye-white meshes drawn after it are clipped to it, so a blink or a knock-down closes the eye through the mask. render/app.js pickClipping keeps the masks only for a lone clipped skeleton at high quality, so in a battle the eyeballs showed through the closed lids. SpineActor._eyeMaskFallback now runs after each update while the masks are off: it walks the draw order like pixi-spine (from a slot whose attachment in force is a clip, to its endSlot — any skin) and hides the slot container of a covered slot whose drawn centre (a mesh's mean vertex, a region's quad centre) lies outside the clip polygon [ASSUMED: all or nothing per slot]; turning the masks on shows them again. PR #384's version hid `slot.currentSprite`, which pixi-spine leaves null on a mesh slot — every covered slot of the 417 local operator skeletons' 73 clipped ones is a mesh (313) or a region (54; regions have no worldVerticesLength, so it never measured them) — and its test stubbed a currentSprite onto every slot, so nothing was hidden in the game. Kept from it: the diagnosis, the ray-casting pointInPolygon and the key-frame expectations. Tests: test/render/eyemask.test.js (new, 7, on real runtime objects — the official skeletons in @pixi-spine/runtime-3.8's real Spine, @pixi/core display objects with a 16 px canvas stand-in, the real SpineActor: the eyeballs are meshes in the clip's container; 佩佩 / 仇白 Die and 隐德来希 / 琳琅诗怀雅 Idle hide exactly the eye slots in the closed frames and nothing open; a 30 Hz sweep of six clips; masks on hide nothing, turning them on shows all) 7/7 — the five skeleton tests skip without public/assets (not committed; npm run assets), and fail on PR #384's code; render unitview, enemy attack clips, texas skill clip 45/45. --- public/js/render/app.js | 5 +- public/js/render/spine.js | 99 ++++++++++++++++++++++++-- test/render/eyemask.test.js | 135 ++++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 9 deletions(-) create mode 100644 test/render/eyemask.test.js diff --git a/public/js/render/app.js b/public/js/render/app.js index 40b505a7..3370685b 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -1569,8 +1569,9 @@ export async function createFieldView(host, options = {}) { // Crowded fields render skeletons through staggered RenderTexture impostors (units.js): the interval grows with // the number of Spine units so the per-frame vertex work stays roughly constant (hysteresis: re-evaluated // every 30 frames). Prep and ordinary fields keep full-rate direct rendering. - // Spine clipping masks (only eyeball clips on the current roster, invisible at chibi scale) each cost a stencil - // render-pass break (~2–5 ms of GPU on tiled GPUs): kept only for a lone clipped skeleton at high quality + // Spine clipping masks (the eyelids on the current roster) each cost a stencil render-pass break (~2–5 ms of GPU on + // tiled GPUs): kept only for a lone clipped skeleton at high quality; without them SpineActor hides the eyeball slots a + // closed lid would cut (GitHub #177, spine.js _eyeMaskFallback) let clipAllowed = true; function pickClipping() { let n = 0; diff --git a/public/js/render/spine.js b/public/js/render/spine.js index 8025f17b..d86bce68 100644 --- a/public/js/render/spine.js +++ b/public/js/render/spine.js @@ -62,20 +62,53 @@ export function windUpPlan(loopDur, hit, interval, lead) { return { ts, tsWind, start }; } -/** Whether any skin of the skeleton data has a clipping attachment (pixi-spine AttachmentType.Clipping = 6). */ +/** A clipping attachment (pixi-spine AttachmentType.Clipping = 6). */ +const isClip = (a) => !!a && (a.type === 6 || a.constructor?.name === 'ClippingAttachment' || ('endSlot' in a && 'vertices' in a && !('uvs' in a))); + +/** Whether any skin of the skeleton data has a clipping attachment. */ export function hasClipping(data) { try { for (const skin of data?.skins || []) { const list = typeof skin.getAttachments === 'function' ? skin.getAttachments() : []; - for (const e of list) { - const a = e && e.attachment; - if (a && (a.type === 6 || a.constructor?.name === 'ClippingAttachment' || ('endSlot' in a && 'vertices' in a && !('uvs' in a)))) return true; - } + for (const e of list) if (isClip(e && e.attachment)) return true; } } catch { /* unknown runtime shape: assume none */ } return false; } +/** + * Is (x, y) inside the polygon of `n` points `pts` (flat [x0, y0, x1, y1, …])? Ray casting (PR #384 by @Convey123). + */ +export function pointInPolygon(x, y, pts, n) { + let inside = false; + for (let i = 0, j = n - 1; i < n; j = i++) { + const xi = pts[i * 2], yi = pts[i * 2 + 1], xj = pts[j * 2], yj = pts[j * 2 + 1]; + if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; + } + return inside; +} + +/** + * World centre of what a slot draws, into `out` ([x, y]); false when it draws nothing measurable. A mesh: the mean of its + * world vertices (`buf` grows to fit); a region: its quad's centre — the attachment's (x, y) through the bone (the four + * corners sit symmetrically around it; RegionAttachment.computeWorldVertices takes a bone in runtime 3.8, a slot in 4.1). + */ +function slotCentre(slot, att, buf, out) { + const n = att.worldVerticesLength | 0; + if (n >= 2 && typeof att.computeWorldVertices === 'function') { + if (buf.v.length < n) buf.v = new Float32Array(n); + const v = buf.v; + att.computeWorldVertices(slot, 0, n, v, 0, 2); + let cx = 0, cy = 0; + for (let k = 0; k < n; k += 2) { cx += v[k]; cy += v[k + 1]; } + out[0] = cx / (n >> 1); out[1] = cy / (n >> 1); + } else if (att.region !== undefined && Number.isFinite(att.x) && Number.isFinite(att.y) && slot.bone) { + const b = slot.bone; + out[0] = b.a * att.x + b.b * att.y + b.worldX; out[1] = b.c * att.x + b.d * att.y + b.worldY; + } else return false; + return Number.isFinite(out[0]) && Number.isFinite(out[1]); +} + export class SpineActor { /** * @param {any} spineData PIXI.spine skeleton data @@ -91,11 +124,12 @@ export class SpineActor { this.names = new Set((spineData.animations || []).map((a) => a.name)); /** * Clipping attachments render as stencil masks (≈1.5 ms of GPU each per frame on tiled GPUs): such skeletons - * are drawn through the impostor atlas while clipping is on, and clipping is switched off (unclipped slots, - * visually negligible on battle chibis) when too many of them share a field (app.js budget). + * are drawn through the impostor atlas while clipping is on, and clipping is switched off when too many of them + * share a field (app.js budget) — then _eyeMaskFallback hides what a closed eyelid would cut (GitHub #177). */ this.clipped = hasClipping(spineData); this.clipOn = true; + this._clipHidden = new Set(); // slot indexes the eyelid fallback hid in its last pass (_eyeMaskFallback) if (this.clipped) { const sp = this.spine; const orig = typeof sp.createGraphics === 'function' ? sp.createGraphics.bind(sp) : null; @@ -134,6 +168,7 @@ export class SpineActor { on = !!on; if (!this.clipped || on === this.clipOn) return; this.clipOn = on; + if (on) this._showClipHidden(); // the stencil takes over from the eyelid fallback for (const slot of this.spine?.skeleton?.slots || []) { if (!slot.clippingContainer) continue; slot.clippingContainer.mask = on ? slot.currentGraphics || null : null; @@ -546,6 +581,56 @@ export class SpineActor { if (!this.frozen) { try { this.spine.update(dt); } catch { /* a broken skeleton must not stop the frame */ } } + if (this.clipped && !this.clipOn) this._eyeMaskFallback(); + } + + /** + * The eyelids without the stencil masks (GitHub #177; PR #384 by @Convey123, re-implemented). On this roster the + * clipping attachments are eyelids: the polygon is the eye's opening and the eyeball / eye-white meshes drawn after + * it are clipped to it, so a closing eye (a blink, a knock-down) is hidden by the mask, not by the art. The masks + * are off on a crowded field or below high quality (render/app.js pickClipping), and the eyeballs showed through + * the closed lids (佩佩, 仇白, 隐德来希, 琳琅诗怀雅 …). While they are off, every slot a clip covers — walked in draw + * order from the clipping slot to its endSlot, with the attachments in force (any skin), as pixi-spine does — is + * hidden for the frame when the centre of what it draws lies outside the clip polygon [ASSUMED: all or nothing per + * slot, an approximation of the mask's cut]. pixi-spine shows every attached slot container again on each update. + */ + _eyeMaskFallback() { + const sp = this.spine, skel = sp && sp.skeleton, boxes = sp && sp.slotContainers; + if (!skel || !Array.isArray(skel.drawOrder) || !boxes) return; + this._showClipHidden(); // undo the last pass (a pixi-spine update re-shows them anyway; a frozen one does not) + try { + const buf = this._clipBuf || (this._clipBuf = { v: new Float32Array(64), poly: new Float32Array(16), c: [0, 0] }); + let pn = -1, end = null; // pn: points of the clip in force (−1: none; < 3: degenerate, hides nothing) + for (const slot of skel.drawOrder) { + const att = typeof slot.getAttachment === 'function' ? slot.getAttachment() : slot.attachment; + if (isClip(att)) { + const n = att.worldVerticesLength | 0; + if (buf.poly.length < n) buf.poly = new Float32Array(n); + if (n >= 6) att.computeWorldVertices(slot, 0, n, buf.poly, 0, 2); + pn = n >> 1; + end = att.endSlot && att.endSlot !== slot.data ? att.endSlot : null; + continue; + } + if (pn < 0) continue; + const box = boxes[slot.data.index]; + if (pn >= 3 && att && box && slotCentre(slot, att, buf, buf.c) && !pointInPolygon(buf.c[0], buf.c[1], buf.poly, pn)) { + box.visible = false; + this._clipHidden.add(slot.data.index); + } + if (end && slot.data === end) { pn = -1; end = null; } + } + } catch { /* an unknown runtime shape must never stop the frame */ } + } + + /** Show the slot containers the fallback hid (those still drawing an attachment). */ + _showClipHidden() { + if (!this._clipHidden.size) return; + const slots = this.spine?.skeleton?.slots, boxes = this.spine?.slotContainers; + for (const i of this._clipHidden) { + const slot = slots && slots[i]; + if (boxes && boxes[i] && slot && (typeof slot.getAttachment === 'function' ? slot.getAttachment() : slot.attachment)) boxes[i].visible = true; + } + this._clipHidden.clear(); } /** Model height in skeleton units (setup-pose bounds, else a chibi default). */ diff --git a/test/render/eyemask.test.js b/test/render/eyemask.test.js new file mode 100644 index 00000000..da4288b2 --- /dev/null +++ b/test/render/eyemask.test.js @@ -0,0 +1,135 @@ +// test/render/eyemask.test.js — closed eyes without the stencil masks (GitHub #177; PR #384 by @Convey123, re-implemented): +// the eyelids of these models are Spine clipping attachments, the masks are off on a crowded field or below high +// quality (render/app.js pickClipping), and SpineActor._eyeMaskFallback (render/spine.js) hides a covered slot whose +// centre leaves the clip polygon. +// +// Real runtime objects throughout: the official skeletons read by @pixi-spine/runtime-3.8, drawn by its real Spine +// (pixi-spine's slot containers and meshes on @pixi/core display objects — a 16 px canvas stand-in is all it needs +// headless) inside the real SpineActor. The eyeballs are meshes, drawn through `slot.currentMesh` — PR #384 hid +// `slot.currentSprite` (null on a mesh slot) and its test stubbed one onto every slot, so nothing was hidden in the +// game. The skeletons are not in the repository (public/assets: `npm run assets`): without them those tests skip. +// Run: node --test test/render/eyemask.test.js + +import { test, describe, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const require = createRequire(path.join(ROOT, 'package.json')); +const ASSETS = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8')); +const dirOf = (id) => path.join(ROOT, 'public', 'assets', 'spine', 'op', id, 'front'); +const have = (id) => existsSync(path.join(dirOf(id), `${id}.skel`)) && existsSync(path.join(dirOf(id), `${id}.atlas`)); +const MODELS = ['char_4058_pepe', 'char_4082_qiubai', 'char_4010_etlchi', 'char_1033_swire2']; + +let SpineActor, pointInPolygon, rt, base, core; +before(async () => { + // headless pixi: its shader-precision probe and Texture.WHITE ask for a canvas; no WebGL context is needed to build + // and update a Spine's display tree + class HeadlessCanvas { constructor() { this.width = 16; this.height = 16; } getContext(kind) { return kind === '2d' ? { fillStyle: '', fillRect() {}, clearRect() {}, drawImage() {} } : null; } } + globalThis.HTMLCanvasElement ??= HeadlessCanvas; + globalThis.document ??= { createElement: () => new HeadlessCanvas() }; + rt = require('@pixi-spine/runtime-3.8'); + base = require('@pixi-spine/base'); + core = require('@pixi/core'); + globalThis.PIXI = { spine: { Spine: rt.Spine } }; // SpineActor builds `new PIXI.spine.Spine(data)` + ({ SpineActor, pointInPolygon } = await import('../../public/js/render/spine.js')); +}); + +const cache = new Map(); +function skeletonData(id) { + if (!cache.has(id)) { + const atlas = new base.TextureAtlas(readFileSync(path.join(dirOf(id), `${id}.atlas`), 'utf8'), (line, cb) => cb(new core.BaseTexture(null, { width: 1024, height: 1024 }))); + cache.set(id, new rt.SkeletonBinary(new rt.AtlasAttachmentLoader(atlas)).readSkeletonData(new Uint8Array(readFileSync(path.join(dirOf(id), `${id}.skel`))))); + } + return cache.get(id); +} +/** A real SpineActor of `id` with its manifest entry, clipping masks on or off. */ +function actor(id, clip) { + const a = new SpineActor(skeletonData(id), ASSETS.chars[id].spine.front); + a.setClipping(clip); + return a; +} +/** Slots whose container the fallback hid: still attached (pixi-spine shows those), yet not visible. */ +const hidden = (a) => a.spine.skeleton.slots.filter((s) => s.getAttachment() && a.spine.slotContainers[s.data.index].visible === false).map((s) => s.data.name).sort(); +/** Hidden slots `t` s into clip `clip` ('Die' through SpineActor.die — a knock-down — or 'Idle'), masks off. A fresh + * actor per time: pixi-spine's state.update takes a delta. */ +function hiddenAt(id, clip, t) { + const a = actor(id, false); + if (clip === 'Die') a.die(); else a.spine.state.setAnimation(0, clip, true); + a.update(t); + return hidden(a); +} + +test('pointInPolygon: inside / outside of a convex and a concave polygon', () => { + const square = [0, 0, 10, 0, 10, 10, 0, 10]; + assert.equal(pointInPolygon(5, 5, square, 4), true); + for (const [x, y] of [[-1, 5], [11, 5], [5, -3]]) assert.equal(pointInPolygon(x, y, square, 4), false); + const l = [0, 0, 10, 0, 10, 4, 4, 4, 4, 10, 0, 10]; // an L: the notch is outside + assert.equal(pointInPolygon(6, 6, l, 6), false); + assert.equal(pointInPolygon(2, 6, l, 6), true); + assert.equal(pointInPolygon(8, 2, l, 6), true); +}); + +describe('eyelids on the official skeletons, drawn by the real pixi-spine runtime', { skip: !MODELS.every(have) && 'the operator skeletons are not in public/assets (npm run assets)' }, () => { + test('the eyeballs are meshes drawn through slot.currentMesh, inside the eyelid clip\'s container', () => { + const a = actor('char_4058_pepe', true); + a.update(0.1); + const slots = a.spine.skeleton.slots.filter((s) => /^F_[LR]_Eye_[BC]$/.test(s.data.name)); + assert.equal(slots.length, 4); + const clip = a.spine.skeleton.slots.find((s) => s.clippingContainer); + for (const s of slots) { + assert.equal(s.getAttachment().constructor.name, 'MeshAttachment', s.data.name); + assert.ok(s.currentMesh && !s.currentSprite, `${s.data.name}: a mesh, no sprite`); + assert.equal(a.spine.slotContainers[s.data.index].parent, clip.clippingContainer, `${s.data.name} is clipped`); + } + }); + + test('佩佩 knocked down (Die): the four eyeball / eye-white slots hide once the eyes close (0.13 → 1.00 s), none before', () => { + for (const t of [0, 0.1]) assert.deepEqual(hiddenAt('char_4058_pepe', 'Die', t), [], `open at ${t}`); + for (const t of [0.3, 0.52, 1]) assert.deepEqual(hiddenAt('char_4058_pepe', 'Die', t), ['F_L_Eye_B', 'F_L_Eye_C', 'F_R_Eye_B', 'F_R_Eye_C'], `closed at ${t}`); + }); + + test('仇白 knocked down: both eyeballs hide once the eyes close', () => { + for (const t of [0, 0.1]) assert.deepEqual(hiddenAt('char_4082_qiubai', 'Die', t), [], `open at ${t}`); + for (const t of [0.2, 0.52, 1]) assert.deepEqual(hiddenAt('char_4082_qiubai', 'Die', t), ['F_L_Eyeball', 'F_R_Eyeball'], `closed at ${t}`); + }); + + test('隐德来希 / 琳琅诗怀雅 blink in Idle (2.13–2.37 s / 2.90–3.00 s): hidden then, never with the eyes open', () => { + for (const t of [0.5, 1.9, 3]) assert.deepEqual(hiddenAt('char_4010_etlchi', 'Idle', t), [], `隐德来希 open at ${t}`); + for (const t of [2.13, 2.25, 2.37]) assert.ok(hiddenAt('char_4010_etlchi', 'Idle', t).length >= 4, `隐德来希 blinking at ${t}`); + for (const t of [0.5, 2.6, 3.2]) assert.deepEqual(hiddenAt('char_1033_swire2', 'Idle', t), [], `琳琅诗怀雅 open at ${t}`); + for (const t of [2.9, 2.97, 3]) assert.deepEqual(hiddenAt('char_1033_swire2', 'Idle', t), ['F_H_L_Eyeball', 'F_H_L_Eyew', 'F_H_R_Eyeball', 'F_H_R_Eyew'], `琳琅诗怀雅 at ${t}`); + }); + + test('a whole clip sampled at 30 Hz: hiding is rare outside a knock-down and never takes more than the eyes', () => { + // [model, clip, seconds, share of frames with something hidden: at least, at most] + for (const [id, clip, dur, lo, hi] of [ + ['char_4058_pepe', 'Idle', 4, 0, 0], ['char_4058_pepe', 'Die', 1.5, 0.5, 1], + ['char_4082_qiubai', 'Idle', 4, 0, 0.35], ['char_4082_qiubai', 'Die', 1, 0.5, 1], + ['char_4010_etlchi', 'Idle', 4, 0.02, 0.2], ['char_1033_swire2', 'Idle', 4, 0.01, 0.2], + ]) { + let hit = 0, frames = 0, most = 0; + for (let t = 0; t <= dur; t += 1 / 30) { const n = hiddenAt(id, clip, t).length; frames++; if (n) hit++; most = Math.max(most, n); } + assert.ok(hit / frames >= lo && hit / frames <= hi, `${id} ${clip}: ${Math.round(hit / frames * 100)} % of frames (want ${lo * 100}–${hi * 100} %)`); + assert.ok(most <= 8, `${id} ${clip}: at most ${most} slots in one frame`); + } + }); + + test('with the masks on nothing is hidden (the stencil cuts), and turning them back on shows what the fallback hid', () => { + const on = actor('char_4058_pepe', true); + on.die(); + on.update(0.52); + assert.deepEqual(hidden(on), [], 'masks on: the fallback stays out'); + const off = actor('char_4058_pepe', false); + off.die(); + off.update(0.52); + assert.equal(hidden(off).length, 4, 'masks off: the closed eyes are hidden'); + off.setClipping(true); + assert.deepEqual(hidden(off), [], 'shown again for the stencil'); + const clip = off.spine.skeleton.slots.find((s) => s.clippingContainer); + assert.equal(clip.clippingContainer.mask, clip.currentGraphics, 'the mask is back on'); + }); +}); From 6326dd4d0b2a944ef4b1c2c60806175b07ba1ed5 Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 19:20:55 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feedback7=20R22-RENDER:=20an=20attack=20see?= =?UTF-8?q?n=20late=20plays=20its=20whole=20clip=20from=20the=20wind-up=20?= =?UTF-8?q?when=20the=20rhythm=20leaves=20room=20for=20it=20=E2=80=94=20?= =?UTF-8?q?=E9=87=8D=E7=8A=AF's=20iron=20ball=20lifts=20before=20the=20sla?= =?UTF-8?q?m=20(#246=20by=20@TsangAsuna,=20accepted=20by=20the=20owner);?= =?UTF-8?q?=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SpineActor.attack() starts an attack that was not wound up (no look-ahead: a batch that arrived late) on its strike frame — right for a quick rhythm, but for a slow clip-per-attack enemy the wind-up was cut out: 重犯 (Attack_grey 2.333 s, strike at 1.0 s, attacks every 3 s) showed the slam without the ball ever rising, and only 1.33 s of the clip. The owner's decision of 2026-10-07 on #246: re-implement on the current spine.js so that, when the attack interval leaves room for the whole clip, it plays once completely from the start. A clip-per-attack actor (enemies) with interval ≥ the clip now plays it from 0 at its own speed for the whole clip, the strike landing a little late; a quicker rhythm keeps the strike-frame start, and operators' looping attacks are unchanged (the PR's condition had it the other way round: it changed the looping attacks and left out the clip-per-attack enemies it was written for). Tests: test/render/feedback2-enemy-attack-clip.test.js (+1: 重犯 plays the whole clip from its wind-up, a quicker rhythm and an operator start on the strike frame) 5/5; render eyemask, unitview, texas skill clip, skill idle 51/51. --- public/js/render/spine.js | 10 ++++++--- .../feedback2-enemy-attack-clip.test.js | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/public/js/render/spine.js b/public/js/render/spine.js index d86bce68..e805074e 100644 --- a/public/js/render/spine.js +++ b/public/js/render/spine.js @@ -367,9 +367,13 @@ export class SpineActor { this.windUntil = null; this.wound = false; if (!wasAttacking) { - // not wound up (no look-ahead, e.g. a batch that arrived late): the sim already resolved the hit, so show - // the strike frame now - this._play(clip.loop, !single, { timeScale: ts, start: hit, mix: 0.06 }); + // not wound up (no look-ahead, e.g. a batch that arrived late): the sim already resolved the hit, so show the + // strike frame now — unless a clip-per-attack actor's rhythm leaves room for the whole clip (interval ≥ clip): + // then the clip plays complete from its wind-up at its own speed, the strike a little late (重犯's iron ball + // lifts before the slam; #246 by @TsangAsuna, accepted by the owner on 2026-10-07) + const whole = per && this.interval >= loopDur; + if (whole) this.attackUntil = this.clock + loopDur / ts; + this._play(clip.loop, !single, { timeScale: ts, start: whole ? 0 : hit, mix: 0.06 }); } else if (single) { const e = this.spine.state.tracks[0]; if (e) e.timeScale = ts; diff --git a/test/render/feedback2-enemy-attack-clip.test.js b/test/render/feedback2-enemy-attack-clip.test.js index 999877a1..5c560624 100644 --- a/test/render/feedback2-enemy-attack-clip.test.js +++ b/test/render/feedback2-enemy-attack-clip.test.js @@ -71,6 +71,27 @@ test('an enemy attacking quicker than its clip plays it faster, one clip per att assert.equal(a.current, 'Move', 'the rest of the clip at 2×: 0.23 s'); }); +// #246 (by @TsangAsuna, accepted by the owner on 2026-10-07): seen late, an attack whose rhythm leaves room for the whole +// clip plays it complete from the wind-up — 重犯's iron ball lifts before the slam instead of the slam appearing alone +test('an attack seen late plays the whole clip from its wind-up when the rhythm leaves room for it (重犯), else from the strike frame', () => { + const LIFBOS = 'enemy_1121_lifbos'; // 重犯: Attack_grey 2.333 s, strike at 1.0 s; attacks every 3 s + const a = actor(LIFBOS, true); + a.attack(3); + assert.equal(a.current, 'Attack_grey'); + assert.equal(track(a).trackTime, 0, 'from the wind-up (the ball lifts), not the 1.0 s strike frame'); + assert.equal(track(a).timeScale, 1, 'at its own speed'); + run(a, 2.25); + assert.equal(a.current, 'Attack_grey', 'the whole 2.333 s clip plays (it used to end 1.333 s after the strike frame)'); + run(a, 0.15); + assert.equal(a.current, 'Move_grey', 'then the resting clip'); + const quick = actor(LIFBOS, true); + quick.attack(2); // quicker than the clip: no room for the wind-up + assert.ok(Math.abs(track(quick).trackTime - 1) < 1e-9, 'from the strike frame'); + const op = actor(LIFBOS, false); + op.attack(3); // an operator's looping attack is unchanged + assert.ok(Math.abs(track(op).trackTime - 1) < 1e-9); +}); + test('an operator keeps its attack loop stretched over the attack rhythm (unchanged)', () => { const a = actor(JSHOOT, false); a.attack(2.7); From 7a33bc7e192264978c580d58001735014a7cc4ad Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 20:40:33 +0800 Subject: [PATCH 7/7] =?UTF-8?q?feedback7=20R22-RENDER:=20a=20unit=20killed?= =?UTF-8?q?=20on=20the=20step=20of=20its=20push=20lies=20where=20the=20sim?= =?UTF-8?q?=20put=20it=20=E2=80=94=20a=20dying=20view's=20sample=20anchors?= =?UTF-8?q?=20the=20slide=20too=20(Grok's=20review=20of=20fb7-render,=20HI?= =?UTF-8?q?GH);=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A push and a kill on the same sim step bring the displace fx and the 'die' together: the view died before its slide could anchor, and render/app.js syncBattle gives a dying view only its position (v.x = s.x) — never sync(), the only place that set slide.live — so update() held the slide's start until its 0.5 s timeout, past the 0.8 s DIE window, and dropped it: the body stayed on its pre-push tile (8.852 instead of the sim's 10.5; a summoned enemy and a flyer alike). The per-view step of syncBattle is now units.js syncView (unchanged for living, device and revived views); its dying branch calls UnitView.followSample, the position part of sync(): the sample anchors a slide from `at` on, and is the position only when no slide owns it. A slide whose destination snapshot never shows (the unit left the snapshots) now goes on to the fx's destination after the 0.5 s hold instead of being dropped where it started. The frame-order test fed the view with sync() after its death and stepped the battle through the harness (whose step() drained the events away from the test's own queue): it now steps the battle itself and goes through syncView. Tests: test/render/unitview.test.js 38/38 — the app.js frame order over real battles: a 1.7-tile push; a kill on the push's step for a route enemy, a summoned one and a flyer (the three failed before: bodies at 8.852 / 7.262 / 8.852) and a kill 6 ticks later — the body ends where the sim's lies, every frame step < 0.35 tiles, no step back for the same-step kills; a never-shown slide goes to the destination. Render interp, attack recovery, champagne, eyemask, enemy attack clips, sim displace-fx: 94/94 with unitview. --- public/js/render/app.js | 6 +-- public/js/render/units.js | 33 +++++++++--- test/render/unitview.test.js | 99 ++++++++++++++++++++++++------------ 3 files changed, 94 insertions(+), 44 deletions(-) diff --git a/public/js/render/app.js b/public/js/render/app.js index 3370685b..b552e6ea 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -117,7 +117,7 @@ import { fxForm } from '../../../shared/protocol.js'; import { Camera, presetCamera, lerpCamera, easeInOutCubic, pickTile, normRect } from './projection.js'; import { SnapshotBuffer, frameTime } from './interp.js'; import { TileField } from './tiles.js'; -import { UnitView, ItemView, DeviceView, FORMS } from './units.js'; +import { UnitView, ItemView, DeviceView, FORMS, syncView } from './units.js'; import { FxSystem, ensureDamageFonts } from './fx.js'; import { createDragController, pieceTile } from './drag.js'; import { backdropTextures, shadowTexture, refreshTierChips, silhouetteTexture } from './textures.js'; @@ -1500,9 +1500,7 @@ export async function createFieldView(host, options = {}) { if (!v) v = battleView(id) || createUnknown(id, s); if (!v) continue; if (!v._seen) { v._seen = true; v.fadeIn = 0; } - if (v.alive || v.info?.kind === 'device') v.sync(s, renderT); - else if (v.dying > 0) { v.x = s.x; v.y = s.y; } - else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s, renderT); } + syncView(v, s, renderT); } // knocked-out operators waiting to redeploy (b.snap `down`, user playtest #4 item 9): their view stays on the // field knocked down under a redeploy ring (UnitView.setDown) — made on the spot for one already down when this diff --git a/public/js/render/units.js b/public/js/render/units.js index 206c8278..b4866d17 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -110,6 +110,16 @@ export function dieClipDur(entry) { const d = Object.hasOwn(durs, name) ? Number(durs[name]) : NaN; return Number.isFinite(d) && d > 0 ? d : 0; } +/** + * render/app.js syncBattle's step for one view and its interpolated sample `s` at render time `t`: a living view (or a + * device) takes the whole sample; a dying one only the position — its DIE window in the snapshots; one shown alive + * again (redeployed without an event) stands up first. + */ +export function syncView(v, s, t) { + if (v.alive || v.info?.kind === 'device') v.sync(s, t); + else if (v.dying > 0) { if (typeof v.followSample === 'function') v.followSample(s, t); else { v.x = s.x; v.y = s.y; } } + else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s, t); } +} /** World step (x = col, y = row) of a direction. */ export const DIR_STEP = Object.freeze({ UP: [0, 1], RIGHT: [1, 0], DOWN: [0, -1], LEFT: [-1, 0] }); const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now()); @@ -763,11 +773,7 @@ export class UnitView { // the element gauge shown (b.snap `elem`): element, fill 0..1, cooldown end (game s) and length this.el = typeof s.el === 'string' ? s.el : null; this.elFill = this.el ? s.elFill || 0 : 0; this.elUntil = this.el ? s.elUntil || 0 : 0; this.elDur = this.el ? s.elDur || 0 : 0; - // a push / pull slide owns the drawn position (slideTo): the sample only anchors where it eases to, from the - // snapshot that carries the destination on (`at`) — before that the sample is still sliding or snapping there - const sl = this.slide; - if (!sl) { this.x = s.x; this.y = s.y; } - else if (!(t < sl.at)) { sl.lx = s.x; sl.ly = s.y; sl.live = true; } + this.followSample(s, t); this.flying = !!(s.flags & UF.FLYING) || this.info.motion === 'FLY'; // enemies keep to the road plane: ground enemies only ever walk low tiles (a rounding step onto a block edge must not // pop them up), and a flyer hovers FLY_HOVER above the road whatever tile it crosses — the official lift is one @@ -797,6 +803,18 @@ export class UnitView { setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; } + /** + * The sampled position: the view's — unless a push / pull slide owns it (slideTo), which only anchors where it eases + * to on it, from the snapshot that carries the destination on (`at`; before that the sample is still sliding or + * snapping there). sync() starts with it; a dying view gets nothing else (syncView: the DIE window of the snapshots), + * so a unit killed on the step of its push still lands where the sim put the body (Grok's review of fb7-render). + */ + followSample(s, t) { + const sl = this.slide; + if (!sl) { this.x = s.x; this.y = s.y; } + else if (!(t < sl.at)) { sl.lx = s.x; sl.ly = s.y; sl.live = true; } + } + /** * Slide to a displacement's destination (推拉, the `displace` fx at (x, y)) instead of appearing there (DISPLACE_SLIDE). * `at` (game s): the time of the snapshot that carries the destination — render/app.js starts the slide as soon as the @@ -1051,8 +1069,9 @@ export class UnitView { if (this.down) this.slide = null; // a knocked-down view lies on its tile (setDown) else if (!sl.live) { this.x = sl.sx; this.y = sl.sy; - // never shown (the unit left the snapshots, a stalled stream): the snapshot takes the view back - if ((sl.wait += dt) > 0.5) this.slide = null; + // the destination's snapshot never shown (the unit left the snapshots, a stalled stream): it slides to the + // destination the fx named + if ((sl.wait += dt) > 0.5) sl.live = true; } else { if (!sl.run) { sl.run = true; sl.ox = sl.sx - sl.lx; sl.oy = sl.sy - sl.ly; } const step = Math.min(dt, 0.1); // a hidden tab's catch-up frame must not overshoot diff --git a/test/render/unitview.test.js b/test/render/unitview.test.js index 1e033118..851dc19f 100644 --- a/test/render/unitview.test.js +++ b/test/render/unitview.test.js @@ -595,49 +595,59 @@ describe('a push / pull slide (推拉: the official impulse under friction)', () assert.ok(Math.abs(v.x - landed) < 0.05, `landed on the walking enemy (${landed.toFixed(3)} → ${v.x.toFixed(3)})`); }); - test('a slide that is never shown (the unit left the snapshots) hands the view back', async () => { + test('a slide whose destination snapshot never shows (the unit left the snapshots) goes to the fx\'s destination', async () => { const v = await view(13); v.slideTo(7.7, 12, { at: 3 }); - for (let i = 0; i < 40; i++) v.update(1 / 60, cam(), 0); + for (let i = 0; i < 24; i++) v.update(1 / 60, cam(), 0); + assert.equal(v.x, 5, 'held while the snapshot may still come'); + for (let i = 0; i < 30; i++) v.update(1 / 60, cam(), 0); assert.equal(v.slide, null); + assert.equal(v.x, 7.7, 'then slid to where the push put it'); v.sync({ x: 6.1, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1 }, 3.4); - assert.equal(v.x, 6.1); + assert.equal(v.x, 6.1, 'snapshots drive it again'); }); }); -// the app.js frame order (render/app.js: processEvents takes the events ≤ renderT, then syncBattle's look-ahead starts a -// slide for a `displace` fx still queued up to the next snapshot, then sample → sync, then update) run over a real -// battle: a pushed enemy is drawn sliding, not snapped +// the app.js frame order (render/app.js: processEvents takes the events ≤ renderT — a 'die' kills the view —, then +// syncBattle's look-ahead starts a slide for a `displace` fx still queued up to the next snapshot, then each sampled unit +// goes through syncView — a dying view takes only the position —, then update) run over a real battle: a pushed enemy is +// drawn sliding, not snapped, and one killed by the push lies where the sim put it describe('a push through SnapshotBuffer and UnitView in the app.js frame order (PR #380)', () => { - async function pushed({ kill = false } = {}) { + /** + * A real battle: an enemy (`kind` 'route': on its route; 'summon': spawned mid-battle; 'flyer': a flying one) pushed + * 1.7 tiles at 3 s, killed `killDelay` ticks later when `kill`. Returns the drawn x of every frame from renderT 2.8 to + * 4.6, the sim's position right after the push and where the body lies (the sim's position at the kill). + */ + async function pushed({ kill = false, killDelay = 0, kind = 'route' } = {}) { const { makeBattle, enemyRec } = await import('../helpers/battleHarness.js'); const { SnapshotBuffer } = await import('../../public/js/render/interp.js'); const { snapFrame } = await import('../../server/match/fields.js'); - const h = makeBattle({ defs: { enemies: { enemy_walker: enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 0.8 }) } }, - enemies: [{ key: 'enemy_walker', time: 0, route: 0 }], content: 'none', autoFinish: false, timeLimit: 60 }); + const { syncView } = await import('../../public/js/render/units.js'); + const rec = enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 0.8, ...(kind === 'flyer' ? { motion: 'FLY' } : {}) }); + const h = makeBattle({ defs: { enemies: { enemy_walker: rec } }, enemies: kind === 'summon' ? [] : [{ key: 'enemy_walker', time: 0, route: 0 }], + content: 'none', autoFinish: false, timeLimit: 60 }); const frames = []; - let e = null, info = null, moved = 0, dest = null; - for (let i = 0; i < 30 * 6; i++) { - h.step(); + let e = null, info = null, moved = 0, dest = null, since = -1, body = null; + for (let i = 0; i < 30 * 7; i++) { + h.b.step(); // the battle's own step: h.step() would drain the events into h.events + if (kind === 'summon' && !e && h.b.time >= 1) e = h.b.spawnEnemy('enemy_walker', { pos: [10, 8] }); // (h.spawn drains too) e = e || h.enemy('enemy_walker'); - if (!moved && h.b.time >= 3) { - moved = h.b.displace(e, { x: 1, y: 0 }, 1.7); - dest = e.x; - if (kill) h.b.kill(e, null); - } + if (!moved && e && h.b.time >= 3) { moved = h.b.displace(e, { x: 1, y: 0 }, 1.7); dest = e.x; since = 0; } + else if (since >= 0) since++; + if (kill && since === killDelay) { h.b.kill(e, null); body = e.x; } if (i % 3 === 2) { const ev = h.b.drainEvents(); - info = info || ev.find((x) => x[0] === 'spawn')?.[1]; + info = info || ev.find((x) => x[0] === 'spawn' && e && x[1].id === e.id)?.[1]; const s = h.b.snapshot(); frames.push({ at: s.t / 2, snap: snapFrame('f', s), ev, gt: s.t }); } } assert.ok(moved > 1, `the sim pushed it ${moved} tiles`); - const ctx = fakeViewCtx(fake.P, { cam }); - const v = new UnitView(ctx, { ...info, id: e.id }, {}); + assert.equal(e.alive, !kill); + const v = new UnitView(fakeViewCtx(fake.P, { cam }), info, {}); const buf = new SnapshotBuffer(), sample = new Map(), xs = []; const start = (ev, at) => { if (ev[0] === 'fx' && ev[1] === 'displace' && ev[4]?.id === e.id) v.slideTo(Number(ev[2]), Number(ev[3]), at == null ? {} : { at }); }; - for (let f = 0, fi = 0; f < 60 * 3; f++) { + for (let f = 0, fi = 0; f < 60 * 4; f++) { const now = f / 60; for (; fi < frames.length && frames[fi].at <= now; fi++) { buf.push(frames[fi].snap, frames[fi].at); buf.pushEvents(frames[fi].ev, frames[fi].at, frames[fi].gt); } const renderT = buf.update(now); @@ -646,28 +656,51 @@ describe('a push through SnapshotBuffer and UnitView in the app.js frame order ( const at = buf.nextSnapT(renderT); if (at > renderT) buf.forEachUpcoming(renderT, at, (ev) => start(ev, at)); buf.sample(renderT, sample); - if (sample.get(e.id)) v.sync(sample.get(e.id), renderT); + if (sample.get(e.id)) syncView(v, sample.get(e.id), renderT); v.update(1 / 60, cam(), now); - if (renderT > 2.8 && renderT < 3.8) xs.push(v.x); + if (renderT > 2.8 && renderT < 4.6) xs.push(v.x); } - return { xs, dest }; + return { xs, dest, body }; } + /** The largest frame-to-frame step, the frames moving, the slide's first frame and any step back after it. */ + const steps = (xs) => { + let big = 0, moving = 0, back = false; + const from = xs.findIndex((x, i) => i > 0 && x > xs[i - 1] + 0.01); // the slide's first frame (it walked left before) + for (let i = 1; i < xs.length; i++) { + const d = xs[i] - xs[i - 1]; + big = Math.max(big, Math.abs(d)); if (Math.abs(d) > 0.02) moving++; + if (from > 0 && i > from && d < -1e-9) back = true; + } + return { big, moving, from, back }; + }; test('a 1.7-tile push is drawn over a dozen frames, every frame a step under 0.35 tiles', async () => { const { xs } = await pushed(); - let big = 0, moving = 0; - for (let i = 1; i < xs.length; i++) { const d = Math.abs(xs[i] - xs[i - 1]); big = Math.max(big, d); if (d > 0.02) moving++; } + const { big, moving } = steps(xs); assert.ok(moving >= 8, `${moving} frames show it moving (the bare interpolator: 3)`); assert.ok(big < 0.35, `largest step ${big.toFixed(3)} tiles (the bare interpolator: 0.58)`); }); - test('killed right after the push, the body slides on to where the sim left it and stays there', async () => { - const { xs, dest } = await pushed({ kill: true }); - assert.ok(Math.abs(xs[xs.length - 1] - dest) < 0.011, `${xs[xs.length - 1]} vs ${dest}`); - const from = xs.findIndex((x, i) => i > 0 && x > xs[i - 1] + 0.01); // the slide's first frame (it walked left before) - assert.ok(from > 0); - for (let i = from; i < xs.length; i++) assert.ok(xs[i] >= xs[i - 1] - 1e-9, `never back towards where it stood (frame ${i})`); - }); + // a unit killed on the very step of its push: its 'die' comes due with the displace fx, so its view is dying before the + // slide can anchor — and syncBattle gives a dying view only its position (syncView). The slide used to stay on hold, + // be dropped after 0.5 s, past the 0.8 s DIE window, and leave the body on its pre-push tile (Grok's review of fb7-render) + for (const [name, o] of [ + ['killed on the step of the push', { kind: 'route', killDelay: 0 }], + ['a summoned enemy killed on the step of the push', { kind: 'summon', killDelay: 0 }], + ['a flyer killed on the step of the push', { kind: 'flyer', killDelay: 0 }], + ['killed 6 ticks after the push', { kind: 'route', killDelay: 6 }], + ]) { + test(`${name}: the body slides on to where the sim put it and stays there, no frame stepping 0.35 tiles`, async () => { + const { xs, dest, body } = await pushed({ kill: true, ...o }); + const { big, moving, from, back } = steps(xs); + if (!o.killDelay) assert.equal(body, dest, 'killed where the push put it'); + assert.ok(Math.abs(xs[xs.length - 1] - body) < 0.011, `lies at ${xs[xs.length - 1].toFixed(3)}, the sim's body at ${body.toFixed(3)}`); + assert.ok(from > 0 && moving >= 8, `${moving} frames show it sliding`); + assert.ok(big < 0.35, `largest step ${big.toFixed(3)} tiles`); + // (killed later, it walked back down its route a little first: the body eases onto that) + if (!o.killDelay) assert.ok(!back, 'never back towards where it stood'); + }); + } }); // PR #380, death: a flyer holds its lift through the Die clip (the official client drops its fly offset in