/**
* Чистая симуляция частицы — без Pixi, тестируется в Vitest.
* ParticleEmitter использует эти функции для интеграции и спавна.
*/
import type { Rng } from '../math/rng';
import { lerpColor } from '../anim/flash';
import type { EmitterOptions } from './Particles';
/** Состояние частицы без привязки к отображению. */
export interface ParticleState {
x: number;
y: number;
vx: number;
vy: number;
age: number;
/** Секунд до смерти. */
lifetime: number;
/** Угол (рад) и скорость вращения (рад/сек). */
rot: number;
spin: number;
/** Базовый размер: px для квадратов, множитель для текстурных частиц. */
scale: number;
/** Цвет, выбранный при спавне (из color/colors). */
tint: number;
/** Персональное «зерно» 0..1: фаза wobble и прочая вариация. */
k: number;
}
/** Опции симуляции — общие для всех частиц эмиттера. */
export interface ParticleSimOptions {
acceleration?: { x?: number; y?: number };
/** Затухание альфы: true — вся жизнь; число — хвост в секундах. */
fadeOut?: boolean | number;
/** Сек нарастания альфы в начале жизни. */
fadeIn?: number;
/** Торможение, 1/сек: v *= max(0, 1 - drag*dt). */
drag?: number;
/** Масштаб по жизни: явный диапазон или предустановка. */
scaleOverLife?: [number, number] | 'shrink' | 'grow';
/** Цвет по жизни: лерп 0xRRGGBB от начала к концу. */
colorOverLife?: [number, number];
/** Амплитуда синус-дрейфа по X, px/сек (мотыльки, планктон). */
wobble?: number;
}
/** Видимая часть частицы на шаге (базовый размер уже учтён в scale). */
export interface ParticleVisual {
alpha: number;
scale: number;
rotation: number;
tint: number;
}
/**
* Один шаг интеграции (полу-неявный Эйлер) + видимые кривые по жизни.
* Мутирует p (позиция/скорость/угол/возраст), возвращает визуал.
*/
export function stepParticle(p: ParticleState, dt: number, opts: ParticleSimOptions): ParticleVisual {
p.age += dt;
const drag = Math.max(0, 1 - (opts.drag ?? 0) * dt);
p.vx *= drag;
p.vy *= drag;
p.vx += (opts.acceleration?.x ?? 0) * dt;
p.vy += (opts.acceleration?.y ?? 0) * dt;
const wobble = opts.wobble ? Math.sin(p.age * 3 + p.k * Math.PI * 2) * opts.wobble : 0;
p.x += (p.vx + wobble) * dt;
p.y += p.vy * dt;
p.rot += p.spin * dt;
// Альфа: нарастание в начале × затухание в конце.
const fadeIn = opts.fadeIn ? Math.min(1, p.age / opts.fadeIn) : 1;
const fadeTail = opts.fadeOut === false ? 1
: opts.fadeOut === true || opts.fadeOut === undefined
? 1 - p.age / p.lifetime
: Math.min(1, (p.lifetime - p.age) / opts.fadeOut);
const alpha = Math.max(0, Math.min(fadeIn, fadeTail));
// Масштаб: кривая по жизни, умноженная на базовый размер частицы.
const lifeK = Math.min(1, p.age / p.lifetime);
const curve = opts.scaleOverLife === 'shrink'
? 1 - lifeK
: opts.scaleOverLife === 'grow'
? lifeK
: opts.scaleOverLife
? opts.scaleOverLife[0] + (opts.scaleOverLife[1] - opts.scaleOverLife[0]) * lifeK
: 1;
return {
alpha,
scale: Math.max(0, p.scale * curve),
rotation: p.rot,
tint: opts.colorOverLife ? lerpColor(opts.colorOverLife[0], opts.colorOverLife[1], lifeK) : p.tint
};
}
/** Случайная начальная частица по опциям эмиттера (позиция относительно центра). */
export function sampleSpawn(options: EmitterOptions, rng: Rng): ParticleState {
const area = options.spawnArea;
const v = options.velocity;
return {
x: area ? rng.int(0, area.width) - area.width / 2 : 0,
y: area ? rng.int(0, area.height) - area.height / 2 : 0,
vx: v.x[0] + rng.next() * (v.x[1] - v.x[0]),
vy: v.y[0] + rng.next() * (v.y[1] - v.y[0]),
age: 0,
lifetime:
options.lifetime[0] + rng.next() * (options.lifetime[1] - options.lifetime[0]),
rot: sampleRange(options.rotation, rng),
spin: sampleRange(options.spin, rng),
scale: options.scale ?? 1,
tint: options.colors ? options.colors[rng.int(0, options.colors.length - 1)] : options.color ?? 0xffffff,
k: rng.next()
};
}
/**
* Случайный «взрыв»: частицы разлетаются из точки по кругу.
* speed — (min..max) пикселей/сек; spin/scale — опциональная вариация.
*/
export function sampleBurst(
count: number,
speed: [number, number],
lifetime: [number, number],
rng: Rng,
opts: { spin?: [number, number]; scale?: number; colors?: number[]; color?: number } = {}
): ParticleState[] {
const out: ParticleState[] = [];
for (let i = 0; i < count; i++) {
const angle = rng.next() * Math.PI * 2;
const v = speed[0] + rng.next() * (speed[1] - speed[0]);
out.push({
x: 0,
y: 0,
vx: Math.cos(angle) * v,
vy: Math.sin(angle) * v,
age: 0,
lifetime: lifetime[0] + rng.next() * (lifetime[1] - lifetime[0]),
rot: sampleRange(opts.spin, rng) + angle,
spin: 0,
scale: opts.scale ?? 1,
tint: opts.colors ? opts.colors[rng.int(0, opts.colors.length - 1)] : opts.color ?? 0xffffff,
k: rng.next()
});
}
return out;
}
/** Слияние базовых опций с переопределением burst-а — чистое, без мутации. */
export function mergeEmitterOptions(base: EmitterOptions, over?: Partial<EmitterOptions>): EmitterOptions {
return over ? { ...base, ...over } : { ...base };
}
function sampleRange(range: [number, number] | undefined, rng: Rng): number {
return range ? range[0] + rng.next() * (range[1] - range[0]) : 0;
}