Felladrin's picture
Add the simulator files
17fd0c2 verified
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;
}
}