Skip to content

Instantly share code, notes, and snippets.

@Garciat
Last active February 1, 2025 17:52
Show Gist options
  • Save Garciat/40699e763651c22754c0057f89151087 to your computer and use it in GitHub Desktop.
Save Garciat/40699e763651c22754c0057f89151087 to your computer and use it in GitHub Desktop.
Chispitas // A fun little 2D particle physics simulator.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Chispitas</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
}
body {
font-family:
system-ui,
-apple-system,
Segoe UI,
Roboto,
Helvetica,
Arial,
sans-serif,
Apple Color Emoji,
Segoe UI Emoji;
font-size: 0.8rem;
background-color: #000;
}
header {
z-index: 1;
position: absolute;
top: 0;
left: 0;
max-height: 100%;
overflow-y: scroll;
}
nav {
position: relative;
display: flex;
justify-content: start;
flex-direction: column;
-webkit-user-select: none;
user-select: none;
}
nav.collapse > *:not(.toggle) {
display: none;
}
nav > h3 {
width: 100%;
margin: 0;
padding: 0.25em 0;
background-color: rgba(0, 0, 0, 0.8);
color: white;
font-size: inherit;
font-weight: normal;
text-align: center;
}
nav > a {
display: block;
padding: 0.5em 1em;
background-color: rgba(40, 40, 40, 0.8);
color: white;
cursor: pointer;
text-align: center;
}
nav > a.toggle {
position: sticky;
top: 0;
background-color: #268bd2;
}
nav > a[data-action-type="option"]::after {
content: "off";
margin-left: 0.5em;
}
nav > a[data-action-type="option"].active::after {
content: "on";
margin-left: 0.5em;
}
nav > a.active {
background-color: rgba(54, 161, 86, 0.8);
}
nav > p {
display: block;
margin: 0;
padding: 0.5em 1em;
background-color: rgba(40, 40, 40, 0.8);
color: white;
cursor: pointer;
text-align: left;
font-size: 0.6rem;
}
canvas {
width: 100%;
height: 100%;
-webkit-touch-action: none;
touch-action: none;
-webkit-user-select: none;
user-select: none;
}
</style>
</head>
<body>
<header>
<nav>
<a data-action-type="action" data-action-name="toolbar" class="toggle"
>&#x2630;</a>
<h3>Tools</h3>
<a data-action-type="tool" data-action-name="burst">Burst</a>
<a data-action-type="tool" data-action-name="tracer">Tracer</a>
<a data-action-type="tool" data-action-name="emitter">Emitter</a>
<a data-action-type="tool" data-action-name="attractor">Attractor</a>
<a data-action-type="tool" data-action-name="repulsor">Repulsor</a>
<a data-action-type="tool" data-action-name="wall">Wall</a>
<a data-action-type="tool" data-action-name="deleteForces"
>Delete Forces</a>
<h3>Options</h3>
<a data-action-type="option" data-action-name="flatColors"
>Flat Colors</a>
<a data-action-type="option" data-action-name="friction">Friction</a>
<a data-action-type="option" data-action-name="showForceGens"
>Show Forces</a>
<a data-action-type="option" data-action-name="showParticleSpeedVector"
>Show Speed</a>
<!-- <a data-action-type="option" data-action-name="showPointers">Show Pointers</a> -->
<h3>Actions</h3>
<a data-action-type="action" data-action-name="clearParticles"
>Clear Particles</a>
<a data-action-type="action" data-action-name="clearAll">Clear All</a>
<h3>Info</h3>
<p>Particles: <span data-info="particleCount"></span></p>
<p>FPS: <span data-info="fps"></span></p>
<p>Color: <span data-info="colorSpace"></span></p>
</nav>
</header>
<canvas></canvas>
<script defer src="main.js"></script>
</body>
</html>
{
"compilerOptions": {
"strict": true,
"checkJs": true,
"lib": [
"DOM",
"ESNext"
]
}
}
"use strict";
function _getSupportedColorSpace() {
try {
const canvas = document.createElement("canvas");
canvas.getContext("2d", {
colorSpace: "display-p3",
});
return "display-p3";
} catch (_e) {
return "srgb";
}
}
/**
* @template T
* @param {T} x
* @param {string} [message]
* @returns {asserts x is NonNullable<T>}
*/
function assertNotNull(x, message) {
if (x === undefined || x === null) {
throw new Error(message ?? "Expected non-null value");
}
}
/**
* @template T
* @param {T} x
* @param {string} [message]
* @returns {NonNullable<T>}
*/
function requireNotNull(x, message) {
assertNotNull(x, message);
return x;
}
// === SCREEN
const ratioScreenToCanvas = globalThis.devicePixelRatio;
const ratioWorldToScreen = 1;
const ratioWorldToCanvas = ratioWorldToScreen * ratioScreenToCanvas;
const canvas = requireNotNull(document.querySelector("canvas"));
const ctx = requireNotNull(canvas.getContext("2d", {
// colorSpace: getSupportedColorSpace(), // can't get HDR yet
// desynchronized: true,
alpha: false,
willReadFrequently: false,
}));
console.log(ctx.getContextAttributes());
const world = {
width: 0,
height: 0,
};
function resizeCanvas() {
canvas.width = document.body.clientWidth * ratioScreenToCanvas;
canvas.height = document.body.clientHeight * ratioScreenToCanvas;
world.width = document.body.clientWidth / ratioWorldToScreen;
world.height = document.body.clientHeight / ratioWorldToScreen;
}
resizeCanvas();
globalThis.addEventListener("resize", resizeCanvas);
/**
* @param {number} x
* @param {number} y
* @returns {Vec2}
*/
function screenToWorldXY(x, y) {
return Vec2.fromXY(x / ratioWorldToScreen, y / ratioWorldToScreen);
}
// === MATHS
const TAU = 2 * Math.PI;
class Vec2 {
/**
* @param {number} x
* @param {number} y
*/
constructor(x, y) {
this.x = x;
this.y = y;
}
clone() {
return new Vec2(this.x, this.y);
}
/**
* @param {Vec2} v
*/
distanceTo(v) {
return v.clone().sub_(this).length();
}
lengthSq() {
return this.x * this.x + this.y * this.y;
}
length() {
return Math.sqrt(this.lengthSq());
}
norm() {
return this.clone().sdiv_(this.length());
}
perpCW() {
return new Vec2(-this.y, this.x);
}
perpCCW() {
return new Vec2(this.y, -this.x);
}
/**
* @param {Vec2} v
*/
add(v) {
return this.clone().add_(v);
}
/**
* @param {Vec2} v
*/
add_(v) {
this.x += v.x;
this.y += v.y;
return this;
}
/**
* @param {Vec2} v
*/
sub(v) {
return this.clone().sub_(v);
}
/**
* @param {Vec2} v
*/
sub_(v) {
this.x -= v.x;
this.y -= v.y;
return this;
}
/**
* @param {number} k
*/
smul(k) {
return this.clone().smul_(k);
}
/**
* @param {number} k
*/
smul_(k) {
this.x *= k;
this.y *= k;
return this;
}
/**
* @param {number} k
*/
sdiv(k) {
return this.clone().sdiv_(k);
}
/**
* @param {number} k
*/
sdiv_(k) {
this.x /= k;
this.y /= k;
return this;
}
static zero() {
return Vec2.fromXY(0, 0);
}
/**
* @param {number} x
* @param {number} y
*/
static fromXY(x, y) {
return new Vec2(x, y);
}
/**
* @param {number} r
*/
static fromRads(r) {
return Vec2.fromXY(Math.cos(r), Math.sin(r));
}
/**
* @param {number} a
*/
static fromAngle(a) {
return Vec2.fromRads(a / 360 * TAU);
}
}
// === RANDOM
/**
* @param {number} a
* @param {number} b
*/
function uniformI(a, b) {
return Math.floor(a + (b - a) * Math.random());
}
function randomColor() {
return hslaToColor(randomHsla());
}
/**
* @returns {[number, number, number, number]}
*/
function randomHsla() {
return randomHslaWithHue(uniformI(0, 360));
}
/**
* @param {number} hue
* @returns {[number, number, number, number]}
*/
function randomHslaWithHue(hue) {
return [hue, 100, uniformI(20, 80), 1];
}
/**
* @param {[number, number, number, number]} hsla
*/
function hslaToColor(hsla) {
return `hsla(${hsla[0]}, ${hsla[1]}%, ${hsla[2]}%, ${hsla[3]})`;
}
function randomDirectionVec2() {
return Vec2.fromRads(Math.random() * TAU);
}
// === ENTITIES
class Particle {
/**
* @type {Vec2[]}
*/
path;
/**
* @param {Vec2} pos
* @param {Vec2} spd
* @param {string} color
* @param {boolean} [trace]
* @param {number} [size]
*/
constructor(pos, spd, color, trace = false, size) {
this.pos = pos.clone();
this.spd = spd.clone();
this.size = size || uniformI(2, 6);
this.color = color;
this.trace = trace;
this.path = [];
}
}
class Force {
/**
* @param {Vec2} pos
* @param {number} value
*/
constructor(pos, value) {
this.pos = pos.clone();
this.value = value;
}
}
class ParticleGenerator {
/**
* @param {Vec2} pos
* @param {number} value
* @param {string} color
*/
constructor(pos, value, color) {
this.pos = pos.clone();
this.value = value;
this.color = color;
}
}
// === STATE
/**
* @type {Particle[]}
*/
let particles = [];
/**
* @type {Force[]}
*/
let forces = [];
/**
* @type {ParticleGenerator[]}
*/
let particleGenerators = [];
let tool = "burst";
class BasicOption {
/**
* @param {boolean} value
*/
constructor(value) {
this.value = value;
}
}
const options = {
friction: new BasicOption(true),
showForceGens: new BasicOption(true),
showParticleSpeedVector: new BasicOption(false),
showPointers: new BasicOption(false),
flatColors: new BasicOption(false),
};
const actions = {
toolbar() {
const nav = requireNotNull(document.querySelector("nav"));
nav.classList.toggle("collapse");
},
clearParticles() {
particles = [];
},
clearAll() {
particles = [];
forces = [];
particleGenerators = [];
},
};
const info = {
particleCount: {
get value() {
return particles.length;
},
},
fps: {
numSamples: 100,
/**
* @type {number[]}
*/
samples: [],
total: 0,
cursor: 0,
get value() {
return (this.total / this.numSamples).toFixed(1);
},
/**
* @param {number} v
*/
addSample(v) {
this.total += v - (this.samples[this.cursor] ?? 0);
this.samples[this.cursor] = v;
this.cursor = (this.cursor + 1) % this.numSamples;
},
},
colorSpace: {
get value() {
return ctx.getContextAttributes().colorSpace;
},
},
};
const SINK_RADIUS = 10;
const ATTRACTOR_VALUE = 1000;
const REPULSOR_VALUE = -1000;
// === TOOLBAR
/**
* @param {string} newTool
*/
function setTool(newTool) {
tool = newTool;
refreshToolbar();
}
/**
* @param {keyof typeof options} name
*/
function toggleOption(name) {
options[name].value = !options[name].value;
refreshToolbar();
}
/**
* @param {string} name
* @returns {asserts name is keyof typeof options}
*/
function assertIsOptionName(name) {
if (!Object.hasOwn(options, name)) {
throw new Error(`Invalid option name: ${name}`);
}
}
/**
* @param {string} name
* @returns {asserts name is keyof typeof actions}
*/
function assertIsActionName(name) {
if (!Object.hasOwn(actions, name)) {
throw new Error(`Invalid action name: ${name}`);
}
}
function refreshToolbar() {
document.querySelector("nav")?.querySelectorAll("a").forEach(
(actionElement) => {
const actionType = actionElement.dataset.actionType;
const action = requireNotNull(actionElement.dataset.actionName);
switch (actionType) {
case "tool":
actionElement.classList.toggle("active", action === tool);
break;
case "option":
assertIsOptionName(action);
actionElement.classList.toggle("active", options[action].value);
break;
}
},
);
}
refreshToolbar();
document.querySelector("nav")?.querySelectorAll("a").forEach(
(actionElement) => {
const actionType = actionElement.dataset.actionType;
const action = requireNotNull(actionElement.dataset.actionName);
actionElement.addEventListener("click", () => {
switch (actionType) {
case "tool":
setTool(action);
break;
case "option":
assertIsOptionName(action);
toggleOption(action);
break;
case "action":
assertIsActionName(action);
actions[action]();
break;
}
});
},
);
function refreshInfo() {
for (const [key, obj] of Object.entries(info)) {
const el = document.querySelector(`[data-info="${key}"]`);
if (el) {
el.textContent = String(obj.value);
}
}
}
refreshInfo();
setInterval(function () {
refreshInfo();
}, 1000);
// === HELPERS
/**
* @param {Vec2} pos
* @param {number} n
* @param {string} color
*/
function produceParticlesAtPos(pos, n, color) {
for (let i = 0; i < n; ++i) {
const spd = randomDirectionVec2().smul_(5 * Math.random() + 5);
particles.push(new Particle(pos, spd, color, false));
}
}
/**
* @param {Pointer} pointer
*/
function updatePointerSpeed(pointer) {
const n = pointer.positions.length;
const spd = Vec2.zero();
for (let i = 1; i < n; ++i) {
spd.add_(pointer.positions[i]);
spd.sub_(pointer.positions[i - 1]);
}
spd.sdiv_(n);
pointer.speed = spd;
}
// === EVENTS
class Pointer {
/**
* @param {Vec2} pos
* @param {string} tool
* @param {string} color
*/
constructor(pos, tool, color) {
this.positions = [pos];
this.tool = tool;
this.down = true;
this.speed = Vec2.zero();
this.color = color;
this.lastInsertion = pos;
}
}
/**
* @type {Record<number, Pointer>}
*/
const pointers = {};
canvas.addEventListener("pointerdown", function (ev) {
ev.preventDefault();
const pos = screenToWorldXY(ev.clientX, ev.clientY);
const pointer = pointers[ev.pointerId] = new Pointer(
pos,
tool,
randomColor(),
);
switch (tool) {
case "burst":
// state
break;
case "tracer":
particles.push(new Particle(pos, Vec2.zero(), pointer.color, true));
break;
case "emitter":
particleGenerators.push(new ParticleGenerator(pos, 2, pointer.color));
break;
case "attractor":
forces.push(new Force(pos, ATTRACTOR_VALUE));
break;
case "repulsor":
forces.push(new Force(pos, REPULSOR_VALUE));
break;
}
});
canvas.addEventListener("pointermove", function (ev) {
ev.preventDefault();
const pos = screenToWorldXY(ev.clientX, ev.clientY);
if (!pointers[ev.pointerId]) {
return;
}
const pointer = pointers[ev.pointerId];
pointer.positions.push(pos);
if (pointer.positions.length > 10) {
pointer.positions.shift();
}
if (!pointer.down) {
return;
}
switch (tool) {
case "tracer":
{
if (pos.distanceTo(pointer.lastInsertion) > 25) {
particles.push(new Particle(pos, Vec2.zero(), pointer.color, true));
pointer.lastInsertion = pos;
}
}
break;
case "attractor":
{
if (pos.distanceTo(pointer.lastInsertion) > 25) {
forces.push(new Force(pos, ATTRACTOR_VALUE));
pointer.lastInsertion = pos;
}
}
break;
case "repulsor":
{
if (pos.distanceTo(pointer.lastInsertion) > 25) {
forces.push(new Force(pos, REPULSOR_VALUE));
pointer.lastInsertion = pos;
}
}
break;
case "wall":
{
const axL = pointer.speed.perpCW().norm();
const axR = pointer.speed.perpCCW().norm();
const f = 20;
forces.push(new Force(pos.add(axL.smul(10)), -f));
forces.push(new Force(pos.add(axL.smul(5)), f));
forces.push(new Force(pos.add(axR.smul(5)), f));
forces.push(new Force(pos.add(axR.smul(10)), -f));
}
break;
case "deleteForces":
{
forces = forces.filter((f) => f.pos.distanceTo(pos) > SINK_RADIUS);
particleGenerators = particleGenerators.filter((f) =>
f.pos.distanceTo(pos) > SINK_RADIUS
);
}
break;
}
});
canvas.addEventListener("pointerup", function (ev) {
delete pointers[ev.pointerId];
});
canvas.addEventListener("pointerout", function (ev) {
delete pointers[ev.pointerId];
});
canvas.addEventListener("contextmenu", function (ev) {
ev.preventDefault();
});
// === SIMULATION
function simulate() {
const nP = particles.length;
const nF = forces.length;
for (let iP = 0; iP < nP; ++iP) {
const particle = particles[iP];
// apply speed
particle.pos.add_(particle.spd);
// apply forces
for (let iF = 0; iF < nF; ++iF) {
applyForce(forces[iF], particle);
}
}
if (options.friction.value) {
for (let iP = 0; iP < nP; ++iP) {
applyFriction(particles[iP]);
}
}
}
/**
* @param {Force} force
* @param {Particle} particle
*/
function applyForce(force, particle) {
const distanceV = force.pos.sub(particle.pos);
const dir = distanceV.norm();
const distance = distanceV.length();
const g = force.value / Math.pow(distance, 2);
// OPTIMIZATION(guess): this avoids a branch in a tight loop
// becomes 0 if the particle is too close to the force
const distanceCutoff = Number(distance > SINK_RADIUS);
const dv = dir.smul(distanceCutoff * g);
particle.spd.add_(dv);
}
/**
* @param {Particle} particle
*/
function applyFriction(particle) {
particle.spd.add_(particle.spd.smul(-0.05));
}
// === DRAWING
function draw() {
clearScreen();
ctx.save();
ctx.globalCompositeOperation = options.flatColors.value
? "source-over"
: "lighter";
ctx.scale(ratioWorldToCanvas, ratioWorldToCanvas);
drawParticles();
if (options.showForceGens.value) {
drawForces();
drawParticleGenerators();
}
if (options.showPointers.value) {
for (const pointer of Object.values(pointers)) {
drawVector(
pointer.positions[pointer.positions.length - 1],
pointer.speed,
5,
"green",
);
}
}
ctx.restore();
}
function clearScreen() {
ctx.save();
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "black";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.restore();
}
function drawParticles() {
const n = particles.length;
for (let i = 0; i < n; ++i) {
const particle = particles[i];
drawParticle(particle);
}
for (let i = 0; i < n; ++i) {
const particle = particles[i];
if (particle.trace) {
particle.path.push(particle.pos.clone());
drawPath(particle.path, particle.color);
}
}
if (options.showParticleSpeedVector.value) {
for (let i = 0; i < n; ++i) {
const particle = particles[i];
drawVector(particle.pos, particle.spd, 5, particle.color);
}
}
}
/**
* @param {Particle} particle
*/
function drawParticle(particle) {
ctx.fillStyle = particle.color;
ctx.beginPath();
ctx.arc(particle.pos.x, particle.pos.y, particle.size, 0, TAU);
ctx.fill();
}
/**
* @param {Vec2[]} path
* @param {string} color
*/
function drawPath(path, color) {
ctx.beginPath();
ctx.moveTo(path[0].x, path[0].y);
for (let i = 1; i < path.length; ++i) {
const p = path[i];
ctx.lineTo(p.x, p.y);
}
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
/**
* @param {Vec2} pos
* @param {Vec2} vec
* @param {number} scale
* @param {string} color
*/
function drawVector(pos, vec, scale, color) {
const vecS = pos.add(vec.smul(scale));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(pos.x, pos.y);
ctx.lineTo(vecS.x, vecS.y);
ctx.stroke();
}
function drawForces() {
ctx.save();
for (let i = 0; i < forces.length; ++i) {
const force = forces[i];
if (force.value > 0) {
ctx.fillStyle = "white";
ctx.strokeStyle = "black";
} else {
ctx.fillStyle = "black";
ctx.strokeStyle = "white";
}
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(force.pos.x, force.pos.y, SINK_RADIUS, 0, TAU);
ctx.fill();
ctx.stroke();
}
ctx.restore();
}
function drawParticleGenerators() {
for (const emitter of particleGenerators) {
ctx.fillStyle = emitter.color;
ctx.beginPath();
ctx.arc(emitter.pos.x, emitter.pos.y, SINK_RADIUS, 0, TAU);
ctx.fill();
}
}
// === MAIN LOOP
let lastTime = performance.now();
function loop() {
const now = performance.now();
const dt = now - lastTime;
lastTime = now;
info.fps.addSample(1000 / dt);
simulate();
draw();
for (const pointer of Object.values(pointers)) {
updatePointerSpeed(pointer);
if (pointer.tool === "burst" && pointer.down) {
const pos = pointer.positions[pointer.positions.length - 1];
produceParticlesAtPos(pos, uniformI(10, 20), pointer.color);
}
}
for (const emitter of particleGenerators) {
produceParticlesAtPos(
emitter.pos,
uniformI(1, emitter.value),
emitter.color,
);
}
requestAnimationFrame(loop);
}
setInterval(function () {
particles = particles.filter(checkBounds);
}, 1000);
/**
* @param {Particle} particle
*/
function checkBounds(particle) {
return particle.pos.x >= 0 &&
particle.pos.y >= 0 &&
particle.pos.x <= world.width &&
particle.pos.y <= world.height;
}
// === GO !
{
const midH = world.height / 2;
const midW = world.width / 2;
forces.push(new Force(Vec2.fromXY(midW - 200, midH), ATTRACTOR_VALUE));
particleGenerators.push(
new ParticleGenerator(Vec2.fromXY(midW + 100, midH), 2, randomColor()),
);
forces.push(new Force(Vec2.fromXY(midW + 200, midH), REPULSOR_VALUE));
}
requestAnimationFrame(loop);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment