'use strict'; (function(global) { global.LabFX = global.LabFX || {}; /* ── pool ── */ var POOL_SIZE = 1500; var pool = []; (function buildPool() { for (var i = 0; i < POOL_SIZE; i++) { pool.push({ alive: false, ctx: null, x: 0, y: 0, vx: 0, vy: 0, ax: 0, ay: 0, life: 1000, age: 0, color: '#fff', size: 3, baseSize: 3, shape: 'dot', glow: false, fade: true, sizeFade: true, /* spark: previous position for motion-blur line */ px: 0, py: 0 }); } })(); /* grab a dead particle from pool; returns null if pool exhausted */ function acquire() { for (var i = 0; i < POOL_SIZE; i++) { if (!pool[i].alive) return pool[i]; } return null; } /* ── helpers ── */ function pickColor(color) { if (Array.isArray(color)) { return color[Math.floor(Math.random() * color.length)]; } return color; } /* ───────────────────────────────────────────── PUBLIC API ───────────────────────────────────────────── */ global.LabFX.particles = { /** * Spawn N particles at canvas coords (x, y). */ emit: function(opts) { opts = opts || {}; var ctx = opts.ctx; var x = opts.x || 0; var y = opts.y || 0; var count = opts.count != null ? opts.count : 10; var color = opts.color != null ? opts.color : '#fff'; var speed = opts.speed != null ? opts.speed : 60; var spread = opts.spread != null ? opts.spread : Math.PI * 2; var angle = opts.angle != null ? opts.angle : 0; var gravity = opts.gravity != null ? opts.gravity : 0; var life = opts.life != null ? opts.life : 1000; var fade = opts.fade != null ? opts.fade : true; var glow = opts.glow != null ? opts.glow : false; var shape = opts.shape != null ? opts.shape : 'dot'; var size = opts.size != null ? opts.size : 3; var sizeFade = opts.sizeFade != null ? opts.sizeFade : true; for (var i = 0; i < count; i++) { var p = acquire(); if (!p) break; var dir = angle + (Math.random() - 0.5) * spread; var spd = speed * (0.5 + Math.random() * 0.5); p.alive = true; p.ctx = ctx; p.x = x; p.y = y; p.px = x; p.py = y; p.vx = Math.cos(dir) * spd; p.vy = Math.sin(dir) * spd; p.ax = 0; p.ay = gravity; p.life = life; p.age = 0; p.color = pickColor(color); p.size = size * (0.7 + Math.random() * 0.6); p.baseSize = p.size; p.shape = shape; p.glow = glow; p.fade = fade; p.sizeFade = sizeFade; } }, /** * Advance all alive particles by dt seconds. * Call from your sim RAF loop. */ update: function(dt) { for (var i = 0; i < POOL_SIZE; i++) { var p = pool[i]; if (!p.alive) continue; p.age += dt * 1000; /* convert s → ms */ if (p.age >= p.life) { p.alive = false; continue; } p.px = p.x; p.py = p.y; p.vx += p.ax * dt; p.vy += p.ay * dt; p.x += p.vx * dt; p.y += p.vy * dt; } }, /** * Draw all live particles that belong to ctx. * Call after update, inside your sim's draw/render fn. */ draw: function(ctx) { for (var i = 0; i < POOL_SIZE; i++) { var p = pool[i]; if (!p.alive || p.ctx !== ctx) continue; var t = p.age / p.life; /* 0..1 */ var alpha = p.fade ? (1 - t) : 1; var sz = p.sizeFade ? p.baseSize * (1 - t * 0.8) : p.baseSize; ctx.save(); if (p.glow) { ctx.globalCompositeOperation = 'lighter'; } ctx.globalAlpha = alpha; ctx.fillStyle = p.color; ctx.strokeStyle = p.color; switch (p.shape) { case 'spark': { /* line from old to current pos — motion blur */ var len = Math.max(2, sz * 3); var dx = p.x - p.px; var dy = p.y - p.py; var d = Math.sqrt(dx * dx + dy * dy) || 1; var nx = dx / d * len; var ny = dy / d * len; ctx.lineWidth = Math.max(0.5, sz * 0.4); ctx.beginPath(); ctx.moveTo(p.x - nx, p.y - ny); ctx.lineTo(p.x, p.y); ctx.stroke(); break; } case 'ring': { var r = p.baseSize * (1 + t * 3); ctx.lineWidth = Math.max(0.5, sz * 0.5); ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.stroke(); break; } case 'smoke': { var smokeAlpha = alpha * 0.25; ctx.globalAlpha = smokeAlpha; var smokeR = sz * (2 + t * 3); var grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, smokeR); grad.addColorStop(0, p.color); grad.addColorStop(1, 'transparent'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(p.x, p.y, smokeR, 0, Math.PI * 2); ctx.fill(); break; } case 'dust': { ctx.globalAlpha = alpha * 0.6; ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(0.5, sz * 0.5), 0, Math.PI * 2); ctx.fill(); break; } case 'splash': /* same as dot but gravity param makes it droop — handled in update */ /* fall-through */ case 'dot': default: { ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(0.5, sz), 0, Math.PI * 2); ctx.fill(); break; } } ctx.restore(); } }, /** Remove all particles */ clear: function() { for (var i = 0; i < POOL_SIZE; i++) { pool[i].alive = false; } } }; })(window);