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, type ParticleState } from './particleSim';

/**
 * Простая система частиц: эмиттер спавнит точки (Texture.WHITE, подкрашенные)
 * с заданной скоростью, временем жизни и затуханием.
 * Используется для атмосферы (пепел, мотыльки, пыль) и эффектов (взрывы — burst/oneShot).
 */
export interface EmitterOptions {
    /** Цвет частиц (0xRRGGBB). */
    color: number;
    /** Частиц в секунду. */
    rate: number;
    /** Время жизни частицы, секунды (min..max). */
    lifetime: [number, number];
    /** Начальная скорость, виртуальных пикселей/сек: (minX..maxX, minY..maxY). */
    velocity: { x: [number, number]; y: [number, number] };
    /** Ускорение (гравитация/ветер), пикселей/сек². */
    acceleration?: { x?: number; y?: number };
    /** Размер частицы в пикселях (1..4). */
    size?: number;
    /** Спавн в прямоугольнике вокруг позиции эмиттера (по умолчанию точка). */
    spawnArea?: { width: number; height: number };
    /** Затухание альфы к концу жизни (по умолчанию true). */
    fadeOut?: boolean;
    /** Seed для повторяемого движения. */
    seed?: number;
    /**
     * Радиальный разброс вместо velocity: скорость частиц (min..max) по кругу
     * (для burst-взрывов). Если задан — velocity игнорируется.
     */
    radialSpeed?: [number, number];
}

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

export class ParticleEmitter extends Container {
    private particles: Particle[] = [];
    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 alpha = stepParticle(p.state, dt, this.options.acceleration, this.options.fadeOut ?? true);
            p.sprite.x = p.state.x;
            p.sprite.y = p.state.y;
            p.sprite.alpha = alpha;
        }

        // Сбор «умерших»
        const dead = this.particles.filter((p) => p.state.age >= p.state.lifetime);
        for (const p of dead) {
            p.sprite.destroy();
        }
        if (dead.length > 0) {
            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.destroy();
        }
    }

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

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

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

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

    private addParticle(state: ParticleState): void {
        const size = this.options.size ?? 2;
        const sprite = new Sprite(Texture.WHITE);
        sprite.width = size;
        sprite.height = size;
        sprite.tint = this.options.color;
        sprite.alpha = 0; // появится в следующем кадре без «вспышки»
        this.particles.push({ state, sprite });
        this.addChild(sprite);
    }

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