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
@@ -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