Newer
Older
rpg / packages / engine / src / render / fx / __tests__ / FxLayer.test.ts
import { describe, it, expect } from 'vitest';
import { Container, Sprite, Texture } from 'pixi.js';
import { FxLayer } from '../FxLayer';
import { FxRing } from '../FxRing';

const TICK = 1 / 60;

describe('FxLayer.ring', () => {
    it('кольцо доигрывает и удаляется из слоя', () => {
        const layer = new FxLayer();
        const ring = layer.ring({ color: 0xd99a32, from: 8, to: 96, duration: 0.4 }, { x: 10, y: 20 });
        expect(layer.children).toContain(ring);
        for (let i = 0; i < 24; i++) layer.update(TICK);
        expect(ring.destroyed).toBe(true);
        expect(layer.children).not.toContain(ring);
    });

    it('зацикленное кольцо не самоуничтожается, cancel() гасит', () => {
        const layer = new FxLayer();
        const ring = layer.ring(
            { color: 0xff0000, from: 8, to: 96, duration: 0.3, repeat: Infinity },
            { x: 0, y: 0 }
        );
        for (let i = 0; i < 120; i++) layer.update(TICK);
        expect(ring.destroyed).toBe(false);
        ring.cancel();
        expect(ring.destroyed).toBe(true);
    });

    it('родитель-опция: вьюха уходит в переданный контейнер', () => {
        const layer = new FxLayer();
        const depth = new Container();
        const ring = layer.ring({ color: 0xffffff, from: 0, to: 10, duration: 0.1 }, { x: 0, y: 0 }, { parent: depth });
        expect(ring.parent).toBe(depth);
    });
});

describe('FxLayer.floatText', () => {
    it('текст создаётся, поднимается и уничтожается по завершении', () => {
        const layer = new FxLayer();
        const fx = layer.floatText('+1', { x: 100, y: 100 }, { duration: 1, rise: 8 });
        expect(layer.children).toContain(fx);
        layer.update(TICK);
        expect(fx.alpha).toBeGreaterThan(0);
        expect(fx.y).toBeLessThanOrEqual(100);
        for (let i = 0; i < 60; i++) layer.update(TICK);
        expect(fx.destroyed).toBe(true);
        expect(layer.children).not.toContain(fx);
    });
});

describe('FxLayer.fade', () => {
    it('вьюха растворяется и уничтожается', () => {
        const layer = new FxLayer();
        const parent = new Container();
        const view = new Container();
        view.alpha = 1;
        parent.addChild(view);
        layer.fade(view, { duration: 0.5 });
        for (let i = 0; i < 30; i++) layer.update(TICK);
        expect(view.destroyed).toBe(true);
        expect(view.parent).toBeNull();
    });

    it('keep: true — вьюха жива с целевой альфой', () => {
        const layer = new FxLayer();
        const parent = new Container();
        const view = new Container();
        parent.addChild(view);
        layer.fade(view, { duration: 0.5, to: 0.4, keep: true });
        for (let i = 0; i < 30; i++) layer.update(TICK);
        expect(view.destroyed).toBe(false);
        expect(view.alpha).toBeCloseTo(0.4, 5);
        expect(view.parent).toBe(parent);
    });

    it('вьюха, разрушенная извне во время эффекта, не роняет update', () => {
        const layer = new FxLayer();
        const view = new Container();
        layer.fade(view, { duration: 1 });
        layer.update(TICK);
        view.destroy({ children: true });
        expect(() => layer.update(TICK)).not.toThrow();
    });

    it('cancel без keep: альфа возвращена, вьюха погашена (контракт fx)', () => {
        const layer = new FxLayer();
        const view = new Container();
        view.alpha = 1;
        const fx = layer.fade(view, { duration: 1 });
        layer.update(TICK);
        fx.cancel();
        expect(view.alpha).toBe(1);
        expect(view.destroyed).toBe(true);
    });

    it('cancel с keep: альфа возвращена, вьюха жива (чужая — не наша)', () => {
        const layer = new FxLayer();
        const view = new Container();
        view.alpha = 1;
        const fx = layer.fade(view, { duration: 1, keep: true });
        layer.update(TICK);
        fx.cancel();
        expect(view.alpha).toBe(1);
        expect(view.destroyed).toBe(false);
    });
});

describe('FxLayer.pop', () => {
    it('масштаб возвращается к базовому, вьюха жива', () => {
        const layer = new FxLayer();
        const view = new Container();
        view.scale.set(2);
        layer.pop(view, { duration: 0.25, fromScale: 0.5 });
        layer.update(TICK);
        expect(view.scale.x).toBeGreaterThan(0.5 * 2); // разгоны от базового
        for (let i = 0; i < 15; i++) layer.update(TICK);
        expect(view.scale.x).toBeCloseTo(2, 5);
        expect(view.destroyed).toBe(false);
    });
});

describe('FxLayer.flash', () => {
    it('повторный флэш по тому же спрайту не дублируется, destroy спрайта чистится', () => {
        const layer = new FxLayer();
        const sprite = new Sprite(Texture.WHITE);
        layer.flash(sprite, 0xff0000);
        layer.flash(sprite, 0xff0000); // перезапуск, а не вторая запись
        layer.update(TICK);
        expect(sprite.tint).not.toBe(0xffffff); // вспышка применилась
        for (let i = 0; i < 20; i++) layer.update(TICK); // 0.15 с — флэш доиграл
        expect(sprite.tint).toBe(0xffffff);
        sprite.destroy();
        expect(() => layer.update(TICK)).not.toThrow();
    });
});

describe('FxLayer.burst', () => {
    it('детерминирован по seed: одинаковые позиции частиц', () => {
        const a = new FxLayer({ seed: 42 });
        const b = new FxLayer({ seed: 42 });
        const opts = { rate: 0, lifetime: [0.5, 0.5] as [number, number], radialSpeed: [50, 100] as [number, number] };
        const ea = a.burst(5, { x: 0, y: 0 }, opts);
        const eb = b.burst(5, { x: 0, y: 0 }, opts);
        a.update(TICK);
        b.update(TICK);
        expect(ea.children.map((c) => `${c.x},${c.y}`)).toEqual(eb.children.map((c) => `${c.x},${c.y}`));
    });

    it('эмиттер самоуничтожается, когда частицы умерли', () => {
        const layer = new FxLayer({ seed: 1 });
        const fx = layer.burst(2, { x: 0, y: 0 }, { rate: 0, lifetime: [0.2, 0.2] });
        for (let i = 0; i < 20; i++) layer.update(TICK);
        expect(fx.destroyed).toBe(true);
    });
});

describe('FxLayer.trail', () => {
    it('тянет эмиттер за якорем; stop() гасит хвост и завершает эффект', () => {
        const layer = new FxLayer();
        const anchor = new Container();
        anchor.position.set(10, 10);
        const trail = layer.trail(anchor, { rate: 30, lifetime: [0.1, 0.1], seed: 7 });
        anchor.position.set(50, 30);
        layer.update(TICK);
        expect(trail.view.x).toBe(50);
        trail.stop();
        for (let i = 0; i < 20; i++) layer.update(TICK);
        expect(trail.destroyed).toBe(true);
    });

    it('cancel() гасит эмиттер немедленно', () => {
        const layer = new FxLayer();
        const anchor = new Container();
        const trail = layer.trail(anchor, { rate: 30, lifetime: [0.1, 0.1], seed: 7 });
        trail.cancel();
        expect(trail.destroyed).toBe(true);
        expect(trail.view.destroyed).toBe(true);
    });
});

describe('FxLayer.clear', () => {
    it('гасит все активные эффекты и флэш-записи', () => {
        const layer = new FxLayer();
        const ring = layer.ring({ color: 0xffffff, from: 0, to: 10, duration: 5 }, { x: 0, y: 0 });
        const view = new Container();
        layer.pop(view, { duration: 5 });
        const sprite = new Sprite(Texture.WHITE);
        layer.flash(sprite, 0xff0000);
        layer.clear();
        expect(ring.destroyed).toBe(true);
        expect(view.scale.x).toBe(1); // pop вернул базовый масштаб
        layer.update(TICK); // не падает
    });
});

describe('FxRing', () => {
    it('радиус растёт по спеку, альфа гаснет к концу', () => {
        const ring = new FxRing({ color: 0xd99a32, from: 8, to: 96, duration: 0.4 });
        ring.update(0.2);
        ring.update(0.2);
        expect(ring.done).toBe(true);
        expect(ring.destroyed).toBe(true);
    });
});