import { describe, it, expect } from 'vitest';
import { stepParticle, sampleSpawn, sampleBurst, mergeEmitterOptions, type ParticleState } from '../particleSim';
import { createRng } from '../../math/rng';
import type { EmitterOptions } from '../Particles';
const opts: EmitterOptions = {
color: 0xffffff,
rate: 10,
lifetime: [1, 2],
velocity: { x: [-10, 10], y: [-10, 10] }
};
function state(over: Partial<ParticleState> = {}): ParticleState {
return { x: 0, y: 0, vx: 0, vy: 0, age: 0, lifetime: 2, rot: 0, spin: 0, scale: 1, tint: 0xffffff, k: 0, ...over };
}
describe('stepParticle', () => {
it('позиция после шагов (полу-неявный Эйлер)', () => {
const p = state({ vx: 10, vy: -5, lifetime: 10 });
stepParticle(p, 0.5, {});
expect(p.x).toBeCloseTo(5);
expect(p.y).toBeCloseTo(-2.5);
expect(p.age).toBeCloseTo(0.5);
});
it('ускорение влияет на скорость', () => {
const p = state({ lifetime: 10 });
stepParticle(p, 1, { acceleration: { y: 100 } });
expect(p.vy).toBeCloseTo(100);
expect(p.y).toBeCloseTo(100); // скорость применилась в том же шаге
});
it('fadeOut: true — вся жизнь; число — хвост в сек; false — без затухания', () => {
const a = state();
expect(stepParticle(a, 1, { fadeOut: true }).alpha).toBeCloseTo(0.5); // 1 из 2 сек
const b = state({ lifetime: 2 });
expect(stepParticle(b, 1, { fadeOut: 0.5 }).alpha).toBe(1); // хвост ещё далеко
const c = state({ age: 0.75 });
expect(stepParticle(c, 1, { fadeOut: 0.5 }).alpha).toBeCloseTo(0.5); // после шага age=1.75, хвост 0.5
const d = state();
expect(stepParticle(d, 1, { fadeOut: false }).alpha).toBe(1);
});
it('fadeIn наращивает альфу в начале', () => {
const p = state();
expect(stepParticle(p, 0.25, { fadeIn: 1 }).alpha).toBeCloseTo(0.25);
});
it('drag тормозит', () => {
const p = state({ vx: 100 });
stepParticle(p, 0.5, { drag: 2 }); // v *= 1 - 1.0
expect(p.vx).toBeCloseTo(0);
});
it('spin вращает, scaleOverLife меняет масштаб', () => {
const a = state({ spin: Math.PI });
expect(stepParticle(a, 0.5, {}).rotation).toBeCloseTo(Math.PI / 2);
const b = state({ scale: 4 });
expect(stepParticle(b, 1, { scaleOverLife: [1, 2] }).scale).toBeCloseTo(6); // 4 × 1.5
const c = state({ scale: 4 });
expect(stepParticle(c, 1, { scaleOverLife: 'shrink' }).scale).toBeCloseTo(2); // 4 × 0.5
});
it('colorOverLife лерпит цвет по жизни', () => {
const p = state({ lifetime: 2 });
const v = stepParticle(p, 1, { colorOverLife: [0xff0000, 0x0000ff] });
expect(v.tint).toBe(0x800080);
});
it('wobble дрейфует по синусу с фазой из k', () => {
const p = state({ k: 0, lifetime: 10 });
const before = p.x;
stepParticle(p, 0.1, { wobble: 10 }); // sin(0.3) > 0 → дрейф вправо
expect(p.x).toBeGreaterThan(before);
});
});
describe('sampleSpawn', () => {
it('учитывает spawnArea и диапазоны', () => {
const rng = createRng(1);
for (let i = 0; i < 50; i++) {
const s = sampleSpawn({ ...opts, spawnArea: { width: 20, height: 10 } }, rng);
expect(s.x).toBeGreaterThanOrEqual(-10);
expect(s.x).toBeLessThanOrEqual(10);
expect(s.y).toBeGreaterThanOrEqual(-5);
expect(s.y).toBeLessThanOrEqual(5);
expect(s.vx).toBeGreaterThanOrEqual(-10);
expect(s.vx).toBeLessThanOrEqual(10);
expect(s.lifetime).toBeGreaterThanOrEqual(1);
expect(s.lifetime).toBeLessThanOrEqual(2);
expect(s.age).toBe(0);
}
});
it('без области — старт из точки', () => {
const s = sampleSpawn(opts, createRng(1));
expect(s.x).toBe(0);
expect(s.y).toBe(0);
});
it('colors выбираются из списка, spin/rotation из диапазонов', () => {
const rng = createRng(3);
const o: EmitterOptions = {
...opts,
colors: [0xff0000, 0x00ff00],
rotation: [0, 1],
spin: [-2, 2]
};
for (let i = 0; i < 30; i++) {
const s = sampleSpawn(o, rng);
expect([0xff0000, 0x00ff00]).toContain(s.tint);
expect(s.rot).toBeGreaterThanOrEqual(0);
expect(s.rot).toBeLessThanOrEqual(1);
expect(s.spin).toBeGreaterThanOrEqual(-2);
expect(s.spin).toBeLessThanOrEqual(2);
}
});
});
describe('sampleBurst', () => {
it('нужное количество, скорость в диапазоне', () => {
const rng = createRng(5);
const parts = sampleBurst(12, [30, 80], [0.2, 0.5], rng, { color: 0x52525c });
expect(parts).toHaveLength(12);
for (const p of parts) {
const v = Math.hypot(p.vx, p.vy);
expect(v).toBeGreaterThanOrEqual(30 - 1e-9);
expect(v).toBeLessThanOrEqual(80 + 1e-9);
expect(p.lifetime).toBeGreaterThanOrEqual(0.2);
expect(p.lifetime).toBeLessThanOrEqual(0.5);
expect(p.tint).toBe(0x52525c);
}
});
it('детерминирован по seed', () => {
const a = sampleBurst(5, [10, 20], [0.1, 0.2], createRng(99));
const b = sampleBurst(5, [10, 20], [0.1, 0.2], createRng(99));
expect(a).toEqual(b);
});
});
describe('mergeEmitterOptions', () => {
it('слияет чисто: база и переопределение не мутируются', () => {
const base: EmitterOptions = { ...opts };
const over = { color: 0xff0000, lifetime: [0.1, 0.2] as [number, number] };
const merged = mergeEmitterOptions(base, over);
expect(merged.color).toBe(0xff0000);
expect(merged.lifetime).toEqual([0.1, 0.2]);
expect(base.color).toBe(0xffffff); // база цела
expect(base.lifetime).toEqual([1, 2]);
expect(merged.velocity).toBe(base.velocity); // непереопределённые — те же ссылки
const back = mergeEmitterOptions(base);
expect(back).not.toBe(base);
expect(back).toEqual(base);
});
});