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