Files
ark-stronghold-protocol/test/render/fxproj.test.js
T
sganggs 0aba602ecf Playtest #3 fixes: lost models, picking, live LP, temp overflow, 蕾缪安 S3, 回环射手, FX, detail card, spinners
- #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)
2026-09-29 23:42:55 +08:00

469 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
});
});