Newer
Older
rpg / packages / engine / src / render / __tests__ / particleSim.test.ts
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);
    });
});