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;
}
}