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