feedback7: merge fb7-render — #382 赤刃明霄陈 S3 sword-qi drawn (sim chen3Wave fx), #381 enemy attack recovery held in interpolation, #380 push / pull slide (corpse stays where pushed; same-step kills finish the slide), #384 eye masks, 香槟炸弹 HP bar hidden, #246 an unwound attack plays its whole clip
- Conflicts with the merged fb7-hud (both added display-only snapshot lists): server/sim/battle/events.js, types/core.js,
public/js/render/interp.js, docs/SIM.md, docs/design/network.md — union of ammo / wolves / neg and stand / standCut.
- Grok review: MERGE AFTER FIXES — the HIGH finding (same-step push kill) fixed on the branch (7a33bc7e).
- Golden: diy-073 / diy-074 (fx counts only; union OK); broad node --test 5442 (5441 pass, 0 fail, 1 skipped); lint 0
errors; typecheck.
This commit is contained in:
commit
cf98f4c3f8
27 files changed
+1812
-53
No files matched your search
@@ -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, '失衡已清除后摇');
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -506,3 +506,310 @@ 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 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 < 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, 'snapshots drive it again');
|
||||
});
|
||||
});
|
||||
|
||||
// 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)', () => {
|
||||
/**
|
||||
* 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 { 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, 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 && 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' && 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`);
|
||||
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 * 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);
|
||||
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)) syncView(v, sample.get(e.id), renderT);
|
||||
v.update(1 / 60, cam(), now);
|
||||
if (renderT > 2.8 && renderT < 4.6) xs.push(v.x);
|
||||
}
|
||||
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();
|
||||
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)`);
|
||||
});
|
||||
|
||||
// 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
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user