const TAU = Math.PI * 2;
const randomNumber = (min, max) =>
Math.random() * (max - min) + min;
class Vector {
constructor(x = 0, y = 0) {
this.x = x;
this.y = y;
}
add(v) {
return new Vector(this.x + v.x, this.y + v.y);
}
multiply(v) {
return new Vector(this.x * v.x, this.y * v.y);
}
multiplyScalar(s) {
return new Vector(this.x * s, this.y * s);
}
floor() {
return new Vector(Math.floor(this.x), Math.floor(this.y));
}
toString() {
return `<${this.x}, ${this.y}>`;
}
}
const rgbToHsl = (r, g, b) => {
const vmax = max(r, g, b), vmin = min(r, g, b);
let h, s, l = (vmax + vmin) / 2;
if (vmax === vmin) {
return [0, 0, l]; // achromatic
}
const d = vmax - vmin;
s = l > 0.5 ? d / (2 - vmax - vmin) : d / (vmax + vmin);
if (vmax === r) h = (g - b) / d + (g < b ? 6 : 0);
if (vmax === g) h = (b - r) / d + 2;
if (vmax === b) h = (r - g) / d + 4;
h /= 6;
return [h, s, l];
};
const hslToRgb = (h, s, l) => {
let r, g, b;
if (s === 0) {
r = g = b = l; // achromatic
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hueToRgb(p, q, h + 1/3);
g = hueToRgb(p, q, h);
b = hueToRgb(p, q, h - 1/3);
}
return [r, g, b];
};
const hueToRgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
class RGBColor {
constructor(r, g, b, a) {
this.r = r;
this.g = g;
this.b = b;
this.a = a;
}
toString() {
const r = Math.floor(this.r * 255);
const g = Math.floor(this.g * 255);
const b = Math.floor(this.b * 255);
return this.a == null
? `rgb(${r}, ${g}, ${b})`
: `rgba(${r}, ${g}, ${b}, ${this.a})`;
}
toHsl() {
const [h, s, l] = rgbToHsl(this.r, this.g, this.b);
return new HSLColor(h, s, l, this.a);
}
}
class HSLColor {
constructor(h, s, l, a) {
this.h = h;
this.s = s;
this.l = l;
this.a = a;
}
toString() {
const h = Math.floor(this.h * 360);
const s = Math.floor(this.s * 100);
const l = Math.floor(this.l * 100);
return this.a == null
? `hsl(${h}, ${s}%, ${l}%)`
: `hsla(${h}, ${s}%, ${l}%, ${this.a})`;
}
toRgb() {
const [r, g, b] = rgbToHsl(this.h, this.s, this.l);
return new RGBColor(r, g, b, this.a);
}
}
class Particle {
constructor(x, y, radius, color, accelerationX = 0, accelerationY = 0, friction = 1, lifetime = 2000) {
this.position = new Vector(x, y);
this.velocity = new Vector(0, 0);
this.acceleration = new Vector(accelerationX, accelerationY);
this.friction = new Vector(friction, friction);
this.radius = radius;
this.color = color;
this.lifetime = lifetime;
this.expiration = performance.now() + lifetime;
}
update() {
this.velocity = this.velocity.add(this.acceleration);
this.velocity = this.velocity.multiply(this.friction);
this.position = this.position.add(this.velocity);
this.color.a = (this.expiration - performance.now()) / this.lifetime;
}
render(ctx) {
const pos = this.position.floor();
ctx.save();
ctx.fillStyle = this.color.toString();
ctx.beginPath();
ctx.arc(pos.x, pos.y, this.radius, 0, TAU);
ctx.fill();
ctx.restore();
}
isDead() {
return performance.now() - this.expiration > 0;
}
}
const colorCount = 10;
const COLORS = Array.from({ length: colorCount }, (_, i) =>
new HSLColor(i / colorCount, 1.0, 0.8, 1.0));
const randomColor = () =>
COLORS[Math.floor(Math.random() * COLORS.length)];
const randInvert = (n) => Math.random() < 0.5 ? -n : n;
const randomParticle = () =>
new Particle(
click.x,
click.y,
Math.floor(randomNumber(2, 4)),
randomColor(),
randInvert(randomNumber(0.01, 0.03)),
randInvert(randomNumber(0.01, 0.03)),
0.1,
4000
);
let click;
let particleSpawnCount = 12;
let maxParticles = 24;
const gameObjects = [];
const canvas = document.querySelector('#particles');
const ctx = canvas.getContext('2d');
const origin = new Vector();
const centerPoint = (width, height) =>
new Vector(width, height)
.multiplyScalar(0.5)
.floor();
const recalculateOrigin = () => {
const c = centerPoint(ctx.canvas.width, ctx.canvas.height);
origin.x = c.x;
origin.y = c.y;
ctx.translate(c.x, c.y);
}
const spawnParticles = (e) => {
click = new Vector(
e.clientX - origin.x - 5,
e.clientY - origin.y - 5
);
const delta = gameObjects.length - maxParticles + particleSpawnCount;
if (delta >= 0) {
gameObjects.splice(0, delta);
}
for (let i = 0; i < particleSpawnCount; i++) {
gameObjects.push(randomParticle());
}
};
const update = (step) => {
for (let i = gameObjects.length - 1; i >= 0; i--) {
if (gameObjects[i].isDead()) {
gameObjects.splice(i, 1);
} else {
gameObjects[i].update(step);
}
}
};
const render = () => {
ctx.fillStyle = 'grey';
ctx.fillRect(-origin.x, -origin.y, ctx.canvas.width, ctx.canvas.height);
for (let obj of gameObjects) {
obj.render(ctx);
}
ctx.textAlign = 'right';
ctx.fillStyle = 'limegreen';
ctx.font = 'bold 16px Consolas';
ctx.fillText(
`FPS: ${fps.toString().padStart(2, ' ')}`,
origin.x - 8,
origin.y - 8
);
};
// Ref: https://stackoverflow.com/a/56507053/1762224
const timeStep = 1.0 / 30.0; // Cap at 30 FPS
let previousTime = 0.0, delta = 0.0, fps; // Actual FPS
const loop = time => {
const dt = time - previousTime;
delta = delta + dt;
previousTime = time;
while (delta > timeStep) {
fps = Math.floor(1 / delta);
update(timeStep);
delta = delta - timeStep;
}
render();
window.requestAnimationFrame(loop);
};
window.requestAnimationFrame((time) => {
previousTime = time;
window.requestAnimationFrame(loop);
resizeCanvas();
});
const resizeCanvas = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
recalculateOrigin();
};
window.addEventListener('resize', resizeCanvas);
canvas.addEventListener('click', spawnParticles);
*, *::before, *::after {
box-sizing: border-box;
}
#particles {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
}
<link href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet"/>
<canvas id="particles"></canvas>