import { ObjectPool, TAU, randomRange, lerp } from "./utils.js"; class Particle { constructor() { this.reset(); } reset() { this.x = 0; this.y = 0; this.vx = 0; this.vy = 0; this.life = 0; this.maxLife = 1; this.size = 2; this.r = 255; this.g = 255; this.b = 255; this.alpha = 1; this.decay = 1; this.friction = 0.98; this.gravity = 0; this.type = "circle"; } } const PRESETS = { eat: { count: 6, speed: [20, 50], life: [0.3, 0.6], size: [1.5, 3], color: [0, 255, 170], friction: 0.92, type: "circle", }, attack: { count: 10, speed: [40, 80], life: [0.2, 0.4], size: [1.5, 3.5], color: [255, 51, 102], friction: 0.9, type: "spark", }, reproduce: { count: 14, speed: [30, 70], life: [0.5, 1.0], size: [2, 5], color: [204, 102, 255], friction: 0.94, type: "circle", }, death: { count: 20, speed: [20, 60], life: [0.6, 1.2], size: [2, 5], color: [255, 100, 100], friction: 0.96, gravity: 15, type: "spark", }, energy: { count: 4, speed: [10, 30], life: [0.4, 0.8], size: [2, 4], color: [255, 238, 68], friction: 0.95, type: "circle", }, signal: { count: 3, speed: [5, 15], life: [0.5, 0.8], size: [1.5, 3], color: [100, 200, 255], friction: 0.97, type: "circle", }, storm: { count: 1, speed: [30, 80], life: [0.8, 1.5], size: [1, 2.5], color: [255, 238, 68], friction: 0.99, gravity: 40, type: "spark", }, bloom: { count: 1, speed: [10, 40], life: [0.6, 1.2], size: [2, 4], color: [0, 255, 170], friction: 0.96, gravity: -5, type: "circle", }, }; export class ParticleSystem { constructor(maxParticles = 2000) { this.maxParticles = maxParticles; this.active = []; this._pool = new ObjectPool( () => new Particle(), (p) => p.reset(), maxParticles, ); } emit(x, y, preset, colorOverride = null) { const config = typeof preset === "string" ? PRESETS[preset] : preset; if (!config) return; const count = config.count || 5; for (let i = 0; i < count; i++) { if (this.active.length >= this.maxParticles) { const old = this.active.shift(); this._pool.release(old); } const p = this._pool.acquire(); const angle = Math.random() * TAU; const speed = randomRange(config.speed[0], config.speed[1]); p.x = x; p.y = y; p.vx = Math.cos(angle) * speed; p.vy = Math.sin(angle) * speed; p.maxLife = randomRange(config.life[0], config.life[1]); p.life = p.maxLife; p.size = randomRange(config.size[0], config.size[1]); p.friction = config.friction || 0.95; p.gravity = config.gravity || 0; p.type = config.type || "circle"; if (colorOverride) { p.r = colorOverride[0]; p.g = colorOverride[1]; p.b = colorOverride[2]; } else { p.r = config.color[0]; p.g = config.color[1]; p.b = config.color[2]; } this.active.push(p); } } emitTrail(x, y, r, g, b, size = 1.5) { if (this.active.length >= this.maxParticles) { const old = this.active.shift(); this._pool.release(old); } const p = this._pool.acquire(); p.x = x; p.y = y; p.vx = 0; p.vy = 0; p.maxLife = 0.4; p.life = 0.4; p.size = size; p.r = r; p.g = g; p.b = b; p.friction = 1.0; p.type = "trail"; this.active.push(p); } update(dt) { for (let i = this.active.length - 1; i >= 0; i--) { const p = this.active[i]; p.life -= dt; if (p.life <= 0) { this.active.splice(i, 1); this._pool.release(p); continue; } p.vy += p.gravity * dt; p.vx *= p.friction; p.vy *= p.friction; p.x += p.vx * dt; p.y += p.vy * dt; } } render(ctx) { if (this.active.length === 0) return; ctx.save(); for (const p of this.active) { const t = p.life / p.maxLife; const alpha = t * 0.8; const size = p.size * (0.3 + 0.7 * t); switch (p.type) { case "circle": ctx.fillStyle = `rgba(${p.r},${p.g},${p.b},${alpha * 0.3})`; ctx.beginPath(); ctx.arc(p.x, p.y, size * 2, 0, TAU); ctx.fill(); ctx.fillStyle = `rgba(${p.r},${p.g},${p.b},${alpha})`; ctx.beginPath(); ctx.arc(p.x, p.y, size, 0, TAU); ctx.fill(); break; case "spark": { const len = Math.sqrt(p.vx * p.vx + p.vy * p.vy) * 0.05 + 2; const angle = Math.atan2(p.vy, p.vx); ctx.strokeStyle = `rgba(${p.r},${p.g},${p.b},${alpha})`; ctx.lineWidth = size * 0.6; ctx.beginPath(); ctx.moveTo(p.x - Math.cos(angle) * len, p.y - Math.sin(angle) * len); ctx.lineTo( p.x + Math.cos(angle) * len * 0.3, p.y + Math.sin(angle) * len * 0.3, ); ctx.stroke(); break; } case "trail": ctx.fillStyle = `rgba(${p.r},${p.g},${p.b},${alpha * 0.4})`; ctx.beginPath(); ctx.arc(p.x, p.y, size, 0, TAU); ctx.fill(); break; } } ctx.shadowBlur = 0; ctx.restore(); } emitWeather(event, dt) { const preset = event.type === "storm" ? "storm" : "bloom"; const chance = event.type === "storm" ? 0.6 : 0.3; if (Math.random() < chance * dt * 10) { const angle = Math.random() * TAU; const dist = Math.random() * event.radius; const x = event.x + Math.cos(angle) * dist; const y = event.y + Math.sin(angle) * dist; this.emit(x, y, preset); } } get count() { return this.active.length; } clear() { for (const p of this.active) { this._pool.release(p); } this.active.length = 0; } }