/**
* Чистая симуляция частицы — без Pixi, тестируется в Vitest.
* ParticleEmitter использует эти функции для интеграции и спавна.
*/
import type { Rng } from '../math/rng';
import type { EmitterOptions } from './Particles';
/** Состояние частицы без привязки к отображению. */
export interface ParticleState {
x: number;
y: number;
vx: number;
vy: number;
age: number;
/** Секунд до смерти. */
lifetime: number;
}
/** Один шаг интеграции (полу-неявный Эйлер). Возвращает alpha с учётом fadeOut. */
export function stepParticle(
p: ParticleState,
dt: number,
acceleration: { x?: number; y?: number } | undefined,
fadeOut: boolean
): number {
p.age += dt;
p.vx += (acceleration?.x ?? 0) * dt;
p.vy += (acceleration?.y ?? 0) * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
return fadeOut ? Math.max(0, 1 - p.age / p.lifetime) : 1;
}
/** Случайная начальная частица по опциям эмиттера (позиция относительно центра). */
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])
};
}
/**
* Случайный «взрыв»: частицы разлетаются из точки по кругу.
* speed — (min..max) пикселей/сек; возвращает состояния для эмиттера.
*/
export function sampleBurst(
count: number,
speed: [number, number],
lifetime: [number, number],
rng: Rng
): 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])
});
}
return out;
}