- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s - #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks, hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped at one per frame - #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates' pending LP in the team panel - #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue); temp-row warning and ready-button reason - #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx - #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught) - #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost - #8 特质 right under the detail card header, with its official type icon - #9 no spinning busy cursors / spinners next to texts; game data warmed before the match - docs, recordings and tests updated (2414 unit tests, all browser suites green)
469 lines
23 KiB
JavaScript
469 lines
23 KiB
JavaScript
// test/render/fxproj.test.js — battle FX of render/fx.js against a headless fake PIXI (test/render/fakepixi.js):
|
||
// * projectile speeds follow the sim (style.js PROJ mirrors server/sim/constants.js; the live table wins)
|
||
// * the FX atlas layout (render/textures.js fxFrames): no two frames share a pixel (the light pillar used to run into
|
||
// the status-icon row), frame sizes other modules scale by are kept
|
||
// * pooled particles: a freed sprite is a zero-size transparent quad (Pixi's ParticleRenderer ignores `visible`) and
|
||
// its record is reused — a steady stream of shots stops allocating sprites
|
||
// * shots: flight time = distance / sim speed / clock rate, homing on the moving target, one arrival burst, then gone
|
||
// * 回环射手 boomerang: out at the sim's speed, back to the thrower's CURRENT position at the return speed, caught there;
|
||
// dropped when the thrower is gone
|
||
// * 蕾缪安 S3: a lock reticle follows its enemy until the 'bombard' of the shell fired at it; 'bombardShell' flies for
|
||
// `t` game s — however long the S3 runs (extra ammo, stuns); blasts named after the shooter happen at their (x, y);
|
||
// the S2 aim lock ends with its 'crit'; a lock of a shooter gone quiet times out
|
||
// * fx anchoring (_where), quality / load gating of cosmetic particles, melee slash direction, skill burst + aura
|
||
|
||
import { test, describe, before, after } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import * as SIM from '../../server/sim/constants.js';
|
||
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
|
||
import { presetCamera } from '../../public/js/render/projection.js';
|
||
import { PROJ, HIT_TINT } from '../../public/js/render/style.js';
|
||
|
||
let fake, FX, T;
|
||
before(async () => {
|
||
fake = installFakePixi();
|
||
FX = await import('../../public/js/render/fx.js');
|
||
T = await import('../../public/js/render/textures.js');
|
||
});
|
||
after(() => { FX?.setSimProjectileSpeeds(null); fake.restore(); });
|
||
|
||
const DT = 1 / 120;
|
||
const cam = presetCamera('normal', { width: 1600, height: 900 });
|
||
|
||
/** A unit view as the FX system reads it. */
|
||
function unit(id, x, y, o = {}) {
|
||
return { id, x, y, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, isEnemy: false, maxHp: 3000, info: { defId: 'char_x' }, onHit() {}, ...o };
|
||
}
|
||
|
||
/** A FxSystem on fake layers; `views` resolves fx ids; ts = battle clock rate. */
|
||
function makeFx({ quality = 'high', ts = 2, load = 0, views = [] } = {}) {
|
||
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, damageNumbers: true },
|
||
timeScale: () => ts, loadLevel: () => load, subProfOf: () => null, view: (id) => map.get(id) || null,
|
||
screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
|
||
});
|
||
return { fx, map };
|
||
}
|
||
|
||
const run = (fx, seconds, dt = DT) => { for (let t = 0; t < seconds - 1e-9; t += dt) fx.update(dt); };
|
||
const texName = (fx, tex) => Object.keys(fx.tex).find((k) => fx.tex[k] === tex);
|
||
const liveTex = (fx, name) => fx.parts.filter((p) => p.sp.texture === fx.tex[name]);
|
||
|
||
describe('projectile speeds follow the sim', () => {
|
||
test('style.js PROJ mirrors PROJECTILE_SPEEDS for every sim kind (and the boomerang return speed)', () => {
|
||
for (const [kind, v] of Object.entries(SIM.PROJECTILE_SPEEDS)) {
|
||
assert.ok(PROJ[kind], `render/style.js PROJ has no visual for the sim's projectile kind '${kind}'`);
|
||
assert.equal(PROJ[kind].speed, v, `PROJ.${kind}.speed ${PROJ[kind].speed} ≠ sim PROJECTILE_SPEEDS.${kind} ${v}: update the mirror`);
|
||
}
|
||
if (SIM.BOOMERANG_RETURN_SPEED !== undefined) assert.equal(PROJ.boomerang.back, SIM.BOOMERANG_RETURN_SPEED, 'PROJ.boomerang.back mirrors BOOMERANG_RETURN_SPEED');
|
||
});
|
||
|
||
test('projSpeed: the live sim table first, then the style.js copy; boomerangReturn = the return speed', () => {
|
||
FX.setSimProjectileSpeeds(null);
|
||
assert.equal(FX.projSpeed('orb'), PROJ.orb.speed);
|
||
assert.equal(FX.projSpeed('boomerangReturn'), PROJ.boomerang.back);
|
||
assert.equal(FX.projSpeed('nope'), 12);
|
||
FX.setSimProjectileSpeeds({ ...SIM.PROJECTILE_SPEEDS, orb: 99, boomerang: 20 }, 5);
|
||
assert.equal(FX.projSpeed('orb'), 99);
|
||
assert.equal(FX.projSpeed('boomerang'), 20);
|
||
assert.equal(FX.projSpeed('boomerangReturn'), 5);
|
||
FX.setSimProjectileSpeeds({ arrow: 1 }); // an older sim table: the rest from the copy
|
||
assert.equal(FX.projSpeed('arrow'), 1);
|
||
assert.equal(FX.projSpeed('bolt'), PROJ.bolt.speed);
|
||
assert.equal(FX.projSpeed('boomerangReturn'), PROJ.boomerang.back);
|
||
FX.setSimProjectileSpeeds(SIM.PROJECTILE_SPEEDS, SIM.BOOMERANG_RETURN_SPEED);
|
||
for (const [k, v] of Object.entries(SIM.PROJECTILE_SPEEDS)) assert.equal(FX.projSpeed(k), v);
|
||
FX.setSimProjectileSpeeds(null);
|
||
});
|
||
});
|
||
|
||
describe('FX atlas layout', () => {
|
||
test('frames never overlap and stay inside the atlas; shared frame sizes are kept', () => {
|
||
const { size: [W, H], frames } = T.fxFrames();
|
||
const list = Object.entries(frames);
|
||
for (const [name, [x, y, w, h]] of list) assert.ok(x >= 0 && y >= 0 && x + w <= W && y + h <= H, `${name} inside ${W}×${H}`);
|
||
const hits = [];
|
||
for (let i = 0; i < list.length; i++) {
|
||
for (let j = i + 1; j < list.length; j++) {
|
||
const [a, [ax, ay, aw, ah]] = list[i], [b, [bx, by, bw, bh]] = list[j];
|
||
if (ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah) hits.push(`${a} × ${b}`);
|
||
}
|
||
}
|
||
assert.deepEqual(hits, [], 'overlapping FX atlas frames');
|
||
// sizes other modules scale sprites by (units.js glow / chevron, tiles.js soft / ring / dot / smoke, app.js coin)
|
||
const size = (n) => frames[n].slice(2).join('×');
|
||
for (const [n, s] of [['glow', '128×128'], ['soft', '128×128'], ['ring', '128×128'], ['hex', '128×128'], ['smoke', '128×128'], ['dot', '32×32'], ['chevron', '64×64'], ['coin', '48×48'], ['pillar', '64×256'], ['streak', '128×32'], ['slash', '128×64']]) assert.equal(size(n), s, n);
|
||
for (const k of T.STATUS_KEYS) assert.equal(size('st_' + k), '32×32', k);
|
||
for (const n of ['tracer', 'shock', 'reticle', 'flare', 'boomerang', 'muzzle']) assert.ok(frames[n], `new frame ${n}`);
|
||
});
|
||
|
||
test('fxAtlas builds one texture per frame on one base texture', () => {
|
||
const a = T.fxAtlas();
|
||
const { frames } = T.fxFrames();
|
||
assert.deepEqual(Object.keys(a.tex).sort(), Object.keys(frames).sort());
|
||
for (const t of Object.values(a.tex)) assert.equal(t.baseTexture, a.base);
|
||
});
|
||
});
|
||
|
||
describe('particles', () => {
|
||
test('a freed particle is a zero-size transparent quad and its record is reused', () => {
|
||
const { fx } = makeFx();
|
||
const p = fx.particle('glow', 10, 10, { life: 0.05, s0: 1, a0: 1, a1: 1 });
|
||
const sp = p.sp;
|
||
run(fx, 0.1);
|
||
assert.equal(fx.parts.length, 0);
|
||
assert.equal(sp.alpha, 0, 'alpha 0 (the particle renderer draws invisible children too)');
|
||
assert.deepEqual([sp.scale.x, sp.scale.y], [0, 0], 'zero size: no fill cost');
|
||
const n = fx.addPc.children.length;
|
||
const q = fx.particle('spark', 5, 5, { life: 0.3 });
|
||
assert.equal(q, p, 'the pooled record comes back');
|
||
assert.equal(fx.addPc.children.length, n, 'no new sprite');
|
||
assert.equal(q.sp.texture, fx.tex.spark);
|
||
assert.ok(q.sp.alpha > 0 && q.sp.scale.x > 0);
|
||
});
|
||
|
||
test('a steady stream of shots and hits only ever makes as many sprites as were alive at once', () => {
|
||
const a = unit(1, 3, 10), b = unit(2, 8, 11, { isEnemy: true });
|
||
const { fx } = makeFx({ views: [a, b] });
|
||
const kinds = ['arrow', 'bolt', 'orb', 'bomb', 'lob', 'drone', 'enemy', 'boomerang', 'none'];
|
||
let peakAdd = 0, peakNorm = 0, peakProj = 0;
|
||
const sample = () => {
|
||
let add = 0;
|
||
for (const p of fx.parts) if (p.add) add++;
|
||
peakAdd = Math.max(peakAdd, add);
|
||
peakNorm = Math.max(peakNorm, fx.parts.length - add);
|
||
peakProj = Math.max(peakProj, fx.projs.length);
|
||
};
|
||
for (let f = 0; f < 60 * 12; f++) {
|
||
if (f % 6 === 0) {
|
||
const i = f / 6;
|
||
fx.attack(a, b, kinds[i % kinds.length]);
|
||
fx.damage(b, 120, i % 2 ? 'arts' : 'phys', i % 9 === 8 ? a : null);
|
||
}
|
||
sample(); // (particles are freed only at the start of an update)
|
||
fx.update(1 / 60);
|
||
sample();
|
||
}
|
||
assert.ok(peakProj >= 3 && peakAdd > 20, `busy enough (${peakProj} shots, ${peakAdd} particles at once)`);
|
||
assert.equal(fx.addPc.children.length, peakAdd, 'additive particle sprites = the peak alive at once');
|
||
assert.equal(fx.normPc.children.length, peakNorm, 'normal-blend particle sprites = the peak alive at once');
|
||
assert.equal(fx.projLayer.children.length, 3 * peakProj, 'three sprites per projectile record');
|
||
assert.equal(fx.shadowLayer.children.length, peakProj);
|
||
assert.ok(fx.parts.length <= fx.maxParticles);
|
||
});
|
||
});
|
||
|
||
describe('shots', () => {
|
||
test('flight time = distance / sim speed / clock rate; homing on the moving target; one arrival burst', () => {
|
||
FX.setSimProjectileSpeeds(null);
|
||
const a = unit(1, 3, 10), b = unit(2, 9, 10, { isEnemy: true });
|
||
const { fx } = makeFx({ ts: 2, views: [a, b] });
|
||
fx.attack(a, b, 'arrow');
|
||
assert.equal(fx.projs.length, 1);
|
||
const pr = fx.projs[0];
|
||
const dist = Math.hypot(b.x - a.x, b.y - a.y);
|
||
assert.ok(Math.abs(pr.dur - dist / PROJ.arrow.speed / 2) < 1e-9, `dur ${pr.dur}`);
|
||
assert.equal(pr.trail.texture, fx.tex.tracer);
|
||
assert.equal(liveTex(fx, 'muzzle').length, 1, 'muzzle flash at the shooter');
|
||
b.x = 9.5; // the target moves: the shot follows it
|
||
let t = 0;
|
||
while (!pr.hit && t < 1) { fx.update(DT); t += DT; }
|
||
assert.ok(Math.abs(t - pr.dur) <= DT + 1e-9, `arrived after ${t} (dur ${pr.dur})`);
|
||
assert.equal(pr.tx, 9.5);
|
||
const flares = liveTex(fx, 'flare').length;
|
||
assert.ok(flares >= 1, 'arrival flare');
|
||
run(fx, 0.2);
|
||
assert.equal(fx.projs.length, 0, 'released after its short fade');
|
||
assert.equal(pr.trail.visible || pr.halo.visible || pr.core.visible, false);
|
||
});
|
||
|
||
test('every projectile kind flies and lands; shells cast a shadow and explode', () => {
|
||
const a = unit(1, 3, 10), b = unit(2, 7, 11, { isEnemy: true });
|
||
const { fx } = makeFx({ views: [a, b] });
|
||
for (const kind of Object.keys(PROJ)) {
|
||
fx.clear();
|
||
fx.attack(a, b, kind);
|
||
assert.equal(fx.projs.length, 1, kind);
|
||
const pr = fx.projs[0];
|
||
fx.update(DT);
|
||
assert.equal(pr.shadow.visible, kind === 'bomb' || kind === 'lob' || kind === 'boomerang', `${kind} shadow`);
|
||
if (kind === 'bomb') {
|
||
run(fx, pr.dur);
|
||
assert.ok(fx.rings.some((r) => r.sp.texture === fx.tex.shock), 'bomb explosion shockwave');
|
||
}
|
||
run(fx, 3);
|
||
assert.equal(fx.projs.length, 0, `${kind} done`);
|
||
}
|
||
});
|
||
|
||
test("an arts blastcaster's bomb explodes purple", () => {
|
||
const a = unit(1, 3, 10, { info: { defId: 'char_blast' } }), b = unit(2, 6, 10, { isEnemy: true });
|
||
const { fx } = makeFx({ views: [a, b] });
|
||
fx.ctx.subProfOf = (id) => (id === 'char_blast' ? 'blastcaster' : null);
|
||
fx.attack(a, b, 'bomb');
|
||
assert.equal(fx.projs[0].glow, 0xb36bff);
|
||
});
|
||
|
||
test('chain / beam kinds are beams, melee shots are no projectile', () => {
|
||
const a = unit(1, 3, 10), b = unit(2, 5, 10, { isEnemy: true });
|
||
const { fx } = makeFx({ views: [a, b] });
|
||
fx.attack(a, b, 'chain');
|
||
fx.attack(a, b, 'beam');
|
||
fx.attack(a, b, 'none');
|
||
assert.equal(fx.projs.length, 0);
|
||
assert.equal(fx.beamList.length, 2);
|
||
assert.equal(fx._slashAt, a.id);
|
||
});
|
||
});
|
||
|
||
describe('回环射手 boomerang', () => {
|
||
test('out at the boomerang speed, back to where the thrower is now at the return speed, caught there', () => {
|
||
FX.setSimProjectileSpeeds({ ...SIM.PROJECTILE_SPEEDS, boomerang: 15 }, 3.75);
|
||
const thrower = unit(1, 2, 10), target = unit(2, 8, 10, { isEnemy: true });
|
||
const { fx } = makeFx({ ts: 2, views: [thrower, target] });
|
||
fx.attack(thrower, target, 'boomerang');
|
||
const pr = fx.projs[0];
|
||
const out = Math.hypot(target.x - pr.bx, target.y - pr.by);
|
||
let t = 0;
|
||
while (pr.phase === 0 && t < 3) { fx.update(DT); t += DT; }
|
||
assert.ok(Math.abs(t - out / (15 * 2)) <= 2 * DT, `out leg ${t.toFixed(3)} s ≈ ${(out / 30).toFixed(3)} s`);
|
||
thrower.x = 3; // the thrower moved meanwhile: it flies to where it is now
|
||
const back = Math.hypot(thrower.x - target.x, thrower.y - target.y);
|
||
let t2 = 0;
|
||
while (fx.projs.includes(pr) && t2 < 5) { fx.update(DT); t2 += DT; }
|
||
assert.ok(Math.abs(t2 - back / (3.75 * 2)) <= 2 * DT, `back leg ${t2.toFixed(3)} s ≈ ${(back / 7.5).toFixed(3)} s`);
|
||
assert.ok(Math.abs(pr.bx - 3) < 1e-9, 'caught at the thrower');
|
||
FX.setSimProjectileSpeeds(null);
|
||
});
|
||
|
||
test('it spins, and is gone at once when the thrower leaves the field', () => {
|
||
const thrower = unit(1, 2, 10), target = unit(2, 8, 10, { isEnemy: true });
|
||
const { fx } = makeFx({ views: [thrower, target] });
|
||
fx.attack(thrower, target, 'boomerang');
|
||
const pr = fx.projs[0];
|
||
fx.update(DT);
|
||
const r0 = pr.core.rotation;
|
||
fx.update(DT);
|
||
assert.notEqual(pr.core.rotation, r0, 'spinning');
|
||
assert.equal(pr.core.texture, fx.tex.boomerang);
|
||
while (pr.phase === 0) fx.update(DT);
|
||
fx.update(DT);
|
||
thrower.destroyed = true;
|
||
fx.update(DT);
|
||
assert.equal(fx.projs.length, 0, 'dropped on the way back');
|
||
// a thrower already knocked out at the hit gets nothing back
|
||
const t2 = unit(3, 2, 11);
|
||
fx.attack(t2, target, 'boomerang');
|
||
t2.alive = false;
|
||
run(fx, 1);
|
||
assert.equal(fx.projs.length, 0);
|
||
});
|
||
});
|
||
|
||
describe('蕾缪安 S3: lock, bombardShell, bombard', () => {
|
||
const setup = () => {
|
||
const lem = unit(1, 2, 10), e1 = unit(21, 8, 10, { isEnemy: true }), e2 = unit(23, 9, 11.5, { isEnemy: true });
|
||
return { lem, e1, e2, ...makeFx({ ts: 2, views: [lem, e1, e2] }) };
|
||
};
|
||
|
||
test('a lock follows its enemy until the bombard of the shell fired at it', () => {
|
||
const { fx, e1 } = setup();
|
||
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
|
||
fx.simFx('lock', 9, 11.5, { id: 23, src: 1 });
|
||
assert.equal(fx.locks.length, 2);
|
||
const [L1, L2] = fx.locks;
|
||
e1.x = 8.4;
|
||
fx.update(DT);
|
||
assert.equal(L1.x, 8.4, 'the reticle follows the locked enemy');
|
||
assert.equal(L1.ring.texture, fx.tex.reticle);
|
||
// the shell fired at e1's spot takes that lock; it flies t game s = t / 2 real s
|
||
fx.simFx('bombardShell', 8.5, 10.1, { id: 1, r: 1.5, t: 0.3, i: 0 });
|
||
const shell = fx.projs.find((p) => p.kind === 'bombardShell');
|
||
assert.ok(shell, 'a shell in the air');
|
||
assert.ok(Math.abs(shell.dur - 0.15) < 1e-9);
|
||
assert.equal(L1.shell, true);
|
||
assert.equal(L2.shell, false);
|
||
assert.ok(fx.rings.filter((r) => r.x === 8.5 && r.y === 10.1).length >= 2, 'warning rings on the spot');
|
||
run(fx, 0.16);
|
||
assert.equal(fx.projs.filter((p) => p.kind === 'bombardShell').length, 0, 'landed');
|
||
assert.ok(L1.out < 0, 'the lock waits for the impact itself');
|
||
fx.simFx('bombard', 8.5, 10.1, { id: 1, r: 1.5 });
|
||
assert.ok(L1.out >= 0, 'released by its bombard');
|
||
assert.ok(L2.out < 0, 'the other lock stays');
|
||
run(fx, 0.3);
|
||
assert.deepEqual(fx.locks, [L2]);
|
||
});
|
||
|
||
test("bombard happens at its (x, y), never on the shooter named in `id`; it is a heavy blast", () => {
|
||
const { fx, lem } = setup();
|
||
fx.simFx('bombard', 8, 10, { id: 1, r: 1.5 });
|
||
assert.ok(fx.rings.length >= 2);
|
||
for (const r of fx.rings) assert.deepEqual([r.x, r.y], [8, 10]);
|
||
assert.ok(!fx.rings.some((r) => r.x === lem.x), 'nothing on 蕾缪安');
|
||
assert.ok(liveTex(fx, 'shard').length > 0, 'debris (heavy)');
|
||
});
|
||
|
||
test('a bombard with no shell (older sims) releases the nearest lock; locks time out; the S2 snipe ends its aim lock', () => {
|
||
const { fx } = setup();
|
||
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
|
||
fx.simFx('lock', 9, 11.5, { id: 23, src: 1 });
|
||
fx.simFx('bombard', 9, 11.5, { id: 1, r: 1.5 });
|
||
assert.deepEqual(fx.locks.map((L) => L.out >= 0), [false, true]);
|
||
run(fx, 5 / 2 + 0.3); // LOCK_T game s at 2×, plus the fade
|
||
assert.equal(fx.locks.length, 0, 'timed out');
|
||
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
|
||
fx.simFx('crit', 8, 10, { id: 23, src: 1 }); // another enemy: stays
|
||
assert.ok(fx.locks[0].out < 0);
|
||
fx.simFx('crit', 8, 10, { id: 21, src: 1 });
|
||
assert.ok(fx.locks[0].out >= 0, 'the snipe on the locked enemy ends the lock');
|
||
});
|
||
|
||
test('the shooter knocked out: locks still waiting for a shell go, the one whose shell is in the air stays', () => {
|
||
const { fx, lem } = setup();
|
||
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
|
||
fx.simFx('lock', 9, 11.5, { id: 23, src: 1 });
|
||
fx.simFx('bombardShell', 8, 10, { id: 1, r: 1.5, t: 0.3 });
|
||
const [L1, L2] = fx.locks;
|
||
lem.alive = false;
|
||
fx.update(DT);
|
||
assert.ok(L1.out < 0, 'its shell still lands');
|
||
assert.ok(L2.out >= 0, 'no shell will come');
|
||
fx.simFx('bombard', 8, 10, { id: 1, r: 1.5 });
|
||
assert.ok(L1.out >= 0);
|
||
});
|
||
|
||
test('a long S3 (extra ammo, a stun mid-lock): every reticle stays until the bombard of its own shell', () => {
|
||
// 拉特兰 ammo grants / reloads give S3 more than its 5 shots: 20 locks 0.5 game s apart (a 3 game s stun in the
|
||
// middle), then one shell every 0.3 game s landing 0.3 game s later — the first lock waits ≈ 16 game s for its shell
|
||
const enemies = Array.from({ length: 4 }, (_, k) => unit(30 + k, 7 + k, 9 + (k % 2), { isEnemy: true }));
|
||
const lem = unit(1, 2, 10);
|
||
const { fx } = makeFx({ ts: 2, views: [lem, ...enemies] });
|
||
const g = (s) => s / 2; // game s → real s at 2×
|
||
const locks = [];
|
||
for (let k = 0; k < 20; k++) {
|
||
const e = enemies[k % 4];
|
||
fx.simFx('lock', e.x, e.y, { id: e.id, src: 1 });
|
||
locks.push(fx.locks[fx.locks.length - 1]);
|
||
run(fx, g(k === 9 ? 3.5 : 0.5));
|
||
}
|
||
assert.equal(new Set(locks).size, 20, 'no reticle recycled for another lock');
|
||
for (let k = 0; k < 20; k++) {
|
||
const e = enemies[k % 4];
|
||
fx.simFx('bombardShell', e.x + 0.1, e.y, { id: 1, r: 1.5, t: 0.3, i: k });
|
||
run(fx, g(0.3));
|
||
const live = fx.locks.filter((L) => L.out < 0).length;
|
||
assert.equal(live, 20 - k, `shell ${k}: every lock whose bombard is still to come is up`);
|
||
fx.simFx('bombard', e.x + 0.1, e.y, { id: 1, r: 1.5 });
|
||
}
|
||
run(fx, 0.3);
|
||
assert.equal(fx.locks.length, 0);
|
||
});
|
||
|
||
test('a lock sticks to the enemy named in `id` even when its view is a little off the event spot', () => {
|
||
const { fx, e1 } = setup();
|
||
fx.simFx('lock', 9, 10, { id: 21, src: 1 }); // e1 drawn at (8, 10): a fast walker between snapshots
|
||
assert.equal(fx.locks[0].view, e1);
|
||
e1.x = 7.5;
|
||
fx.update(DT);
|
||
assert.equal(fx.locks[0].x, 7.5);
|
||
});
|
||
|
||
test('a lock whose shooter goes quiet still times out (S2 aim at an enemy that died)', () => {
|
||
const { fx } = setup();
|
||
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
|
||
run(fx, 5 / 2 - 0.05);
|
||
assert.equal(fx.locks[0].out, -1);
|
||
run(fx, 0.35);
|
||
assert.equal(fx.locks.length, 0);
|
||
});
|
||
|
||
test('a long shell flight climbs out of the shooter first; a short one only falls', () => {
|
||
const { fx, lem } = setup();
|
||
fx.simFx('bombardShell', 8, 10, { id: 1, r: 1.5, t: 1.2 });
|
||
fx.simFx('bombardShell', 9, 11.5, { id: 1, r: 1.5, t: 0.3 });
|
||
const [long, short] = fx.projs;
|
||
assert.ok(long.rise > 0 && short.rise === 0);
|
||
lem.x = 2.5;
|
||
fx.update(DT);
|
||
assert.equal(long.x0, 2.5, 'rising from where she stands');
|
||
run(fx, 1);
|
||
assert.equal(fx.projs.length, 0);
|
||
});
|
||
});
|
||
|
||
describe('fx placement, quality, melee, skill', () => {
|
||
test('_where: on the unit when the fx happens there, else at the event position', () => {
|
||
const caster = unit(1, 2, 10);
|
||
const { fx } = makeFx({ views: [caster] });
|
||
assert.equal(fx._where(2.2, 10.1, { id: 1 }).v, caster, 'on the unit');
|
||
assert.equal(fx._where(NaN, NaN, { id: 1 }).v, caster, 'no position: the unit');
|
||
const far = fx._where(5, 10, { id: 1 });
|
||
assert.deepEqual([far.x, far.y, far.v], [5, 10, null], 'an aoe ahead of its caster');
|
||
fx.simFx('aoe', 6, 11, { id: 1, r: 1 });
|
||
assert.ok(fx.rings.length && fx.rings.every((r) => r.x === 6 && r.y === 11), 'drawn where it happens');
|
||
});
|
||
|
||
test("quality 'low' and a heavy load skip the cosmetic particles, not the shots", () => {
|
||
const a = unit(1, 3, 10), b = unit(2, 8, 10, { isEnemy: true });
|
||
for (const [opts, cosmetic] of [[{ quality: 'high' }, true], [{ quality: 'low' }, false], [{ quality: 'high', load: 2 }, false]]) {
|
||
const { fx } = makeFx({ ...opts, views: [a, b] });
|
||
fx.attack(a, b, 'bolt');
|
||
fx.attack(a, b, 'arrow');
|
||
run(fx, 0.05);
|
||
assert.equal(fx.projs.length, 2);
|
||
const extras = liveTex(fx, 'dot').length + liveTex(fx, 'muzzle').length;
|
||
assert.equal(extras > 0, cosmetic, JSON.stringify(opts));
|
||
}
|
||
});
|
||
|
||
test('a melee blow draws a slash in the hit colour, swept along the blow', () => {
|
||
const a = unit(1, 4, 10), b = unit(2, 5, 10, { isEnemy: true });
|
||
const { fx } = makeFx({ views: [a, b] });
|
||
fx.attack(a, b, 'none');
|
||
fx.damage(b, 300, 'arts', a);
|
||
const slashes = liveTex(fx, 'slash');
|
||
assert.equal(slashes.length, 2);
|
||
assert.equal(slashes[0].sp.tint, HIT_TINT.arts);
|
||
const pa = cam.project(a.x, a.y, 0.54, {}), pb = cam.project(b.x, b.y, 0.54, {});
|
||
const blow = Math.atan2(pb.y - pa.y, pb.x - pa.x) + Math.PI / 2;
|
||
const d = Math.atan2(Math.sin(slashes[0].sp.rotation - blow), Math.cos(slashes[0].sp.rotation - blow));
|
||
assert.ok(Math.abs(d) <= 0.46, `rotation ${slashes[0].sp.rotation} vs blow ${blow}`);
|
||
assert.equal(fx._slashAt, null);
|
||
fx.damage(b, 300, 'phys', a); // a second hit without a new blow: no slash
|
||
assert.equal(liveTex(fx, 'slash').length, 2);
|
||
});
|
||
|
||
test('skill activation bursts (pillars, flare, shockwave + hex) and keeps an aura until it ends', () => {
|
||
const v = unit(1, 4, 10);
|
||
const { fx } = makeFx({ views: [v] });
|
||
fx.skill(v, true);
|
||
assert.equal(liveTex(fx, 'pillar').length, 2);
|
||
assert.equal(liveTex(fx, 'flare').length, 1);
|
||
assert.deepEqual(fx.rings.map((r) => texName(fx, r.sp.texture)).sort(), ['hex', 'shock']);
|
||
assert.equal(fx.auras.size, 1);
|
||
run(fx, 1);
|
||
const a = fx.auras.get(1);
|
||
assert.ok(a.sp.alpha > 0.9 && a.hex.alpha > 0.5, 'aura shown');
|
||
fx.skill(v, false);
|
||
run(fx, 0.6);
|
||
assert.equal(fx.auras.size, 0, 'faded out');
|
||
});
|
||
|
||
test('clear() drops shots, locks, auras and particles', () => {
|
||
const a = unit(1, 3, 10), b = unit(2, 8, 10, { isEnemy: true });
|
||
const { fx } = makeFx({ views: [a, b] });
|
||
fx.attack(a, b, 'boomerang');
|
||
fx.simFx('lock', 8, 10, { id: 2, src: 1 });
|
||
fx.simFx('bombardShell', 8, 10, { id: 1, r: 1.5, t: 0.3 });
|
||
fx.skill(a, true);
|
||
fx.update(DT);
|
||
fx.clear();
|
||
assert.deepEqual([fx.projs.length, fx.locks.length, fx.auras.size, fx.parts.length], [0, 0, 0, 0]);
|
||
fx.update(DT);
|
||
fx.destroy();
|
||
});
|
||
});
|