Newer
Older
rpg / packages / engine / src / render / Particles.ts
import { Container, Sprite, Texture } from 'pixi.js';
import { createRng, type Rng } from '../math/rng';
import { stepParticle, sampleSpawn, sampleBurst, mergeEmitterOptions, type ParticleState } from './particleSim';

/**
 * Система частиц: квадраты (Texture.WHITE) или текстуры, цвета/кривые по
 * жизни, пул спрайтов с лимитом. Используется для атмосферы (пепел, мотыльки)
 * и эффектов (взрывы — burst/oneShot). Тикается через engine.fx либо вручную.
 */
export interface EmitterOptions {
    /** Цвет частиц (0xRRGGBB); по умолчанию белый (для текстур — без подкраски). */
    color?: number;
    /** Вариация: случайный цвет из списка при спавне (вместо color). */
    colors?: number[];
    /** Цвет по жизни: лерп 0xRRGGBB от начала к концу. */
    colorOverLife?: [number, number];
    /** Текстура частицы вместо подкрашенного квадрата. */
    texture?: Texture;
    /** Частиц в секунду. */
    rate: number;
    /** Время жизни частицы, секунды (min..max). */
    lifetime: [number, number];
    /** Начальная скорость, виртуальных пикселей/сек: (minX..maxX, minY..maxY). */
    velocity: { x: [number, number]; y: [number, number] };
    /** Ускорение (гравитация/ветер), пикселей/сек². */
    acceleration?: { x?: number; y?: number };
    /** Размер квадрата в px (1..4) или диапазон размеров. */
    size?: number | [number, number];
    /** Множитель масштаба текстурных частиц. */
    scale?: number;
    /** Начальный угол, рад (min..max). */
    rotation?: [number, number];
    /** Скорость вращения, рад/сек (min..max). */
    spin?: [number, number];
    /** Масштаб по жизни: диапазон или предустановка ('shrink'/'grow'). */
    scaleOverLife?: [number, number] | 'shrink' | 'grow';
    /** Сек нарастания альфы в начале жизни. */
    fadeIn?: number;
    /** Затухание альфы: true (по умолчанию) — вся жизнь; число — хвост в сек. */
    fadeOut?: boolean | number;
    /** Торможение, 1/сек (искры, пух). */
    drag?: number;
    /** Амплитуда синус-дрейфа по X, px/сек (мотыльки). */
    wobble?: number;
    /** Спавн в прямоугольнике вокруг позиции эмиттера (по умолчанию точка). */
    spawnArea?: { width: number; height: number };
    /** Seed для повторяемого движения. */
    seed?: number;
    /**
     * Радиальный разброс вместо velocity: скорость частиц (min..max) по кругу
     * (для burst-взрывов). Если задан — velocity игнорируется.
     */
    radialSpeed?: [number, number];
    /** Лимит живых частиц (пул переиспользуется), по умолчанию 256. */
    max?: number;
    /** Режим смешивания: 'add' — светящиеся (огонь, искры, мотыльки). */
    blend?: 'normal' | 'add';
}

interface Particle {
    state: ParticleState;
    sprite: Sprite;
}

export class ParticleEmitter extends Container {
    private particles: Particle[] = [];
    private readonly free: Sprite[] = [];
    private spawnTimer = 0;
    private rng: Rng;
    private selfDestruct = false;

    constructor(private options: EmitterOptions) {
        super();
        this.rng = createRng(options.seed ?? 0x9e3779b9);
    }

    update(dt: number): void {
        // Спавн по rate (для burst-эмиттеров rate = 0)
        this.spawnTimer += dt * this.options.rate;
        while (this.spawnTimer >= 1) {
            this.spawnTimer -= 1;
            this.spawn();
        }

        // Обновление через чистую симуляцию
        for (const p of this.particles) {
            const v = stepParticle(p.state, dt, this.options);
            p.sprite.x = p.state.x;
            p.sprite.y = p.state.y;
            p.sprite.alpha = v.alpha;
            p.sprite.tint = v.tint;
            p.sprite.rotation = v.rotation;
            if (this.options.texture) {
                p.sprite.scale.set(v.scale);
            } else {
                p.sprite.width = v.scale;
                p.sprite.height = v.scale;
            }
        }

        // «Умершие» — спрайт в пул, не destroy
        const dead = this.particles.filter((p) => p.state.age >= p.state.lifetime);
        if (dead.length > 0) {
            for (const p of dead) {
                p.sprite.visible = false;
                p.sprite.alpha = 0;
                this.free.push(p.sprite);
            }
            this.particles = this.particles.filter((p) => p.state.age < p.state.lifetime);
        }
        // oneShot: все частицы умерли и новые не рождаются — уничтожиться
        if (this.selfDestruct && this.particles.length === 0 && this.options.rate <= 0) {
            this.onFinish?.();
            // children: pooled-спрайты — дети контейнера, с ними же и уничтожаются
            this.destroy({ children: true });
        }
    }

    /** Вызывается один раз при самоуничтожении oneShot (цепочки эффектов). */
    onFinish?: () => void;

    /** Убрать все частицы (например, при выходе со сцены). */
    clear(): void {
        for (const p of this.particles) p.sprite.destroy();
        for (const s of this.free) s.destroy();
        this.particles = [];
        this.free.length = 0;
    }

    get count(): number {
        return this.particles.length;
    }

    /** Заменить опции эмиттера (цвет/скорость/кривые) на лету. */
    setOptions(opts: Partial<EmitterOptions>): void {
        this.options = { ...this.options, ...opts };
    }

    /**
     * Мгновенно выпустить count частиц (взрыв/удар): игнорирует rate.
     * opts — разовые переопределения; базовые опции эмиттера не мутируют.
     */
    burst(count: number, opts?: Partial<EmitterOptions>): void {
        const o = mergeEmitterOptions(this.options, opts);
        if (o.radialSpeed) {
            for (const s of sampleBurst(count, o.radialSpeed, o.lifetime, this.rng, o)) {
                this.addParticle(s, o);
            }
        } else {
            for (let i = 0; i < count; i++) this.spawnFrom(o);
        }
    }

    /**
     * Одноразовый эмиттер-взрыв: спавнит count частиц сразу и уничтожает себя,
     * когда все умерли. Добавить в world и в engine.fx — тик и зачистка сами.
     */
    static oneShot(count: number, opts: EmitterOptions): ParticleEmitter {
        const e = new ParticleEmitter({ ...opts, rate: 0 });
        e.selfDestruct = true;
        e.burst(count);
        return e;
    }

    private spawn(): void {
        this.spawnFrom(this.options);
    }

    private spawnFrom(options: EmitterOptions): void {
        this.addParticle(sampleSpawn(options, this.rng), options);
    }

    private addParticle(state: ParticleState, options: EmitterOptions): void {
        const max = options.max ?? 256;
        if (this.particles.length >= max) return;
        const sprite = this.free.pop() ?? this.makeSprite(options);
        const size = typeof options.size === 'number' ? options.size
            : options.size ? options.size[0] + this.rng.next() * (options.size[1] - options.size[0])
            : 2;
        // Базовый размер частицы — в state.scale (кривые по жизни умножают его).
        state.scale = options.texture ? options.scale ?? 1 : size;
        sprite.visible = true;
        sprite.alpha = 0; // появится в следующем кадре без «вспышки»
        sprite.tint = state.tint;
        sprite.rotation = state.rot;
        this.particles.push({ state, sprite });
        if (sprite.parent !== this) this.addChild(sprite);
    }

    private makeSprite(options: EmitterOptions): Sprite {
        const sprite = new Sprite(options.texture ?? Texture.WHITE);
        if (options.texture) sprite.anchor.set(0.5, 0.5);
        sprite.blendMode = options.blend === 'add' ? 'add' : 'normal';
        return sprite;
    }
}