Newer
Older
rpg / packages / engine / src / render / fx / __tests__ / fxSim.test.ts
import { describe, it, expect } from 'vitest';
import {
    sampleRing, ringDuration, sampleFloat, readSeconds, sampleFade, samplePop
} from '../fxSim';
import { quadOut } from '../../../core/easing';

describe('sampleRing', () => {
    const spec = { color: 0xffffff, from: 8, to: 96, duration: 0.4 };

    it('границы прохода: t=0 → from/alphaFrom, t=duration → to/alphaTo', () => {
        expect(sampleRing(0, spec)).toEqual({ radius: 8, alpha: 1 });
        expect(sampleRing(0.4, spec)).toEqual({ radius: 96, alpha: 0 });
    });

    it('ease применяется к радиусу, но не к альфе', () => {
        const eased = sampleRing(0.2, { ...spec, ease: quadOut });
        // quadOut(0.5) = 0.75 → радиус дальше середины
        expect(eased.radius).toBeCloseTo(8 + 88 * 0.75, 5);
        expect(eased.alpha).toBeCloseTo(0.5, 5);
    });

    it('repeat с gap: второй проход начинается с from после паузы', () => {
        const s = { ...spec, repeat: 3, gap: 0.1 };
        // конец первого прохода + пауза — радиус снова from (начало второго)
        const second = sampleRing(0.4 + 0.1 + 0.01, s);
        expect(second.radius).toBeCloseTo(8 + 88 * 0.025, 1);
        // после всех проходов (3*0.4 + 2*0.1 = 1.4) — конец
        expect(ringDuration(s)).toBeCloseTo(1.4, 5);
        expect(sampleRing(1.4, s).alpha).toBeCloseTo(0, 5);
    });

    it('repeat: Infinity — бесконечная длительность, циклы повторяются', () => {
        const s = { ...spec, repeat: Infinity, gap: 0.1 };
        expect(ringDuration(s)).toBe(Infinity);
        // t = 0.5 (заход во второй цикл) — как t = 0.5 - 0.5 цикла... проверяем цикличность
        const a = sampleRing(0.4 + 0.1 + 0.0, s);
        const b = sampleRing(0.4 + 0.1 + 0.5 + 0.4 + 0.1, s);
        expect(a.radius).toBeCloseTo(b.radius, 5);
        expect(a.alpha).toBeCloseTo(b.alpha, 5);
    });

    it('отрицательный t клампится к началу', () => {
        expect(sampleRing(-1, spec).radius).toBe(8);
    });
});

describe('sampleFloat', () => {
    it('границы: t=0 → y=0/alpha=0, конец → y=-rise/alpha=0', () => {
        const spec = { duration: 1, rise: 8 };
        expect(sampleFloat(0, spec)).toEqual({ x: 0, y: 0, alpha: 0, scale: 1 });
        const end = sampleFloat(1, spec);
        expect(end.y).toBeCloseTo(-8, 5);
        expect(end.alpha).toBe(0);
    });

    it('в середине — alpha 1, подъём по quadOut', () => {
        const s = sampleFloat(0.5, { duration: 1, rise: 8 });
        expect(s.alpha).toBe(1);
        expect(s.y).toBeCloseTo(-8 * 0.75, 5); // quadOut(0.5)
    });

    it('hold: плато позиции и альфы до конца hold', () => {
        const spec = { duration: 2, hold: 1, rise: 10 };
        const mid = sampleFloat(0.5, spec);
        expect(mid.y).toBe(0);
        expect(mid.alpha).toBe(1);
    });

    it('driftX смещает x пропорционально прогрессу', () => {
        const s = sampleFloat(1, { duration: 1, driftX: 12 });
        expect(s.x).toBeCloseTo(12, 5);
    });

    it('pop: масштаб растёт от fromScale к 1 в течение pop', () => {
        const s = sampleFloat(0.15, { duration: 1, pop: 0.3, fromScale: 0.6 });
        expect(s.scale).toBeCloseTo(0.6 + 0.4 * 0.5, 5);
        expect(sampleFloat(0.4, { duration: 1, pop: 0.3, fromScale: 0.6 }).scale).toBe(1);
    });
});

describe('readSeconds', () => {
    it('база + perChar за символ', () => {
        expect(readSeconds('')).toBeCloseTo(1.4, 5);
        expect(readSeconds('ab')).toBeCloseTo(1.4 + 2 * 0.045, 5);
    });

    it('не больше max', () => {
        expect(readSeconds('x'.repeat(500))).toBe(6);
    });
});

describe('sampleFade', () => {
    it('границы: from → to', () => {
        expect(sampleFade(0, 1, { duration: 0.5 })).toBe(1);
        expect(sampleFade(0.5, 1, { duration: 0.5 })).toBeCloseTo(0, 5);
    });

    it('delay сдвигает старт', () => {
        const spec = { duration: 0.5, delay: 0.25 };
        expect(sampleFade(0.25, 1, spec)).toBe(1);
        expect(sampleFade(0.5, 1, spec)).toBeCloseTo(0.25, 5); // половина пути
        expect(sampleFade(0.75, 1, spec)).toBeCloseTo(0, 5);
    });

    it('кастомная цель: проявление до 0.8', () => {
        expect(sampleFade(1, 0.2, { duration: 1, to: 0.8 })).toBeCloseTo(0.8, 5);
    });
});

describe('samplePop', () => {
    it('границы: t=0 → fromScale, конец → ровно 1', () => {
        expect(samplePop(0, {})).toBeCloseTo(0.5, 5);
        expect(samplePop(0.25, {})).toBe(1);
        expect(samplePop(10, {})).toBe(1);
    });

    it('пик у overshoot в районе 70% времени', () => {
        const peak = samplePop(0.175, { duration: 0.25 }); // u = 0.7
        expect(peak).toBeCloseTo(1.12, 5);
    });

    it('кастомные параметры honored', () => {
        const spec = { duration: 1, fromScale: 0.2, overshoot: 1.5 };
        expect(samplePop(0, spec)).toBeCloseTo(0.2, 5);
        expect(samplePop(0.7, spec)).toBeCloseTo(1.5, 5);
    });
});

describe('чистота сэмплов', () => {
    it('двойной прогон даёт одинаковые значения (нет Math.random)', () => {
        const ringSpec = { color: 0xd99a32, from: 8, to: 96, duration: 0.4, repeat: 3, gap: 0.1 };
        const floatSpec = { duration: 2, hold: 0.8, driftX: 5, pop: 0.3 };
        const a = [0, 0.13, 0.5, 1.23, 2].map((t) => JSON.stringify(sampleFloat(t, floatSpec)));
        const b = [0, 0.2, 0.5, 0.9, 1.4].map((t) => JSON.stringify(sampleRing(t, ringSpec)));
        const a2 = [0, 0.13, 0.5, 1.23, 2].map((t) => JSON.stringify(sampleFloat(t, floatSpec)));
        const b2 = [0, 0.2, 0.5, 0.9, 1.4].map((t) => JSON.stringify(sampleRing(t, ringSpec)));
        expect(a).toEqual(a2);
        expect(b).toEqual(b2);
    });
});