Last active
January 2, 2025 10:56
-
-
Save Garciat/40699e763651c22754c0057f89151087 to your computer and use it in GitHub Desktop.
Chispitas // Emits random particles on click.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html> | |
<head> | |
<title>Chispitas</title> | |
<style>html,body{margin:0;height:100%;}</style> | |
</head> | |
<body> | |
<script defer> | |
"use strict"; | |
// === SETUP | |
const SPACEW = document.body.clientWidth; | |
const SPACEH = document.body.clientHeight; | |
const canvas = document.createElement('canvas'); | |
canvas.width = SPACEW; | |
canvas.height = SPACEH; | |
document.body.appendChild(canvas); | |
const ctx = canvas.getContext('2d'); | |
// === MATHS | |
Math.TAU = 2 * Math.PI; | |
class Vec2 { | |
constructor(x, y) { | |
this.x = x || 0; | |
this.y = y || 0; | |
} | |
clone() { | |
return new Vec2(this.x, this.y); | |
} | |
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); | |
} | |
add(v) { | |
return this.clone().add_(v); | |
} | |
add_(v) { | |
this.x += v.x; | |
this.y += v.y; | |
return this; | |
} | |
sub(v) { | |
return this.clone().sub_(v); | |
} | |
sub_(v) { | |
this.x -= v.x; | |
this.y -= v.y; | |
return this; | |
} | |
smul(k) { | |
return this.clone().smul_(k); | |
} | |
smul_(k) { | |
this.x *= k; | |
this.y *= k; | |
return this; | |
} | |
sdiv(k) { | |
return this.clone().sdiv_(k); | |
} | |
sdiv_(k) { | |
this.x /= k; | |
this.y /= k; | |
return this; | |
} | |
static zero() { | |
return Vec2.fromXY(0, 0); | |
} | |
static fromXY(x, y) { | |
return new Vec2(x, y); | |
} | |
static fromRads(r) { | |
return Vec2.fromXY(Math.cos(r), Math.sin(r)); | |
} | |
static fromAngle(a) { | |
return Vec2.fromRads(a / 360 * Math.TAU); | |
} | |
} | |
function uniformI(a, b) { | |
return Math.floor(a + (b - a) * Math.random()); | |
} | |
function randomHsla() { | |
return randomHslaWithHue(uniformI(0, 360)); | |
} | |
function randomHslaWithHue(hue) { | |
return [hue, 100, uniformI(20, 80), 1]; | |
} | |
function hslaToColor(hsla) { | |
return `hsla(${hsla[0]}, ${hsla[1]}%, ${hsla[2]}%, ${hsla[3]})`; | |
} | |
function randomDirectionVec2() { | |
return Vec2.fromRads(Math.random() * Math.TAU); | |
} | |
// === ENTITIES | |
function makeCircleDrawing(size, color) { | |
var canvas = document.createElement('canvas'); | |
canvas.width = canvas.height = size * 2; | |
var ctx = canvas.getContext('2d'); | |
ctx.fillStyle = color; | |
ctx.beginPath(); | |
ctx.arc(size, size, size, 0, Math.TAU); | |
ctx.fill(); | |
return canvas; | |
} | |
class Particle { | |
constructor(pos, spd, hsla, trace, size) { | |
this.deleted = false; | |
this.active = false; | |
this.trace = Boolean(trace); | |
this.pos = pos.clone(); | |
this.spd = spd.clone(); | |
this.size = size || uniformI(2, 6); | |
this.hsla = hsla; | |
this.color = hslaToColor(hsla); | |
this.path = []; | |
this.canvas = makeCircleDrawing(this.size, this.color); | |
} | |
} | |
class Force { | |
constructor(pos, value, color, pulsate) { | |
this.pos = pos.clone(); | |
this.value = value; | |
this.color = color || (value < 0 ? 'blue' : 'white'); | |
this.pulsate = pulsate || null; | |
this.pulsateState = -1.0 * Math.sign(value); | |
} | |
step() { | |
const pulsateLimit = 30; | |
this.value += this.pulsateState * 0.8; | |
if (this.value >= pulsateLimit || this.value <= -pulsateLimit) { | |
this.pulsateState *= -1; | |
} | |
this.color = hslaToColor([240, 100, 50 + 50 * Math.abs(pulsateLimit + this.value) / (pulsateLimit * 2), 1]); | |
} | |
} | |
class ParticleGenerator { | |
constructor(pos, value, color) { | |
this.pos = pos.clone(); | |
this.value = value; | |
this.color = color || 'orange'; | |
} | |
} | |
// === STATE | |
let particles = []; | |
let forces = []; | |
let mousePositions = []; | |
let particleGenerators = []; | |
let mousePosition = Vec2.zero(); | |
let mouseSpeed = Vec2.zero(); | |
let shouldProduce = false; | |
let shiftPressed = false; | |
let controlPressed = false; | |
let altPressed = false; | |
let clickDown = null; | |
let friction = false; | |
let showForceGens = true; | |
let currentHue = null; | |
const COMPOSITE_OPERATION_DEFAULT = 'source-over'; | |
let compositeOperation = COMPOSITE_OPERATION_DEFAULT; | |
const SINK_RADIUS = 10; | |
let showParticleSpeedVector = false; | |
// === HELPERS | |
function produceParticlesAtPos(bag, pos, n) { | |
for (let i = 0; i < n; ++i) { | |
const spd = randomDirectionVec2().smul_(5 * Math.random() + 5); | |
bag.push(new Particle(pos, spd, randomHsla())); | |
} | |
} | |
function updateMouseSpeed(pos) { | |
mousePositions.push(pos); | |
if (mousePositions.length === 11) { | |
mousePositions.shift(); | |
} | |
const n = mousePositions.length; | |
let spd = Vec2.zero(); | |
for (var i = 1; i < n; ++i) { | |
spd.add_(mousePositions[i]); | |
spd.sub_(mousePositions[i - 1]); | |
} | |
spd.sdiv_(n); | |
mouseSpeed = spd; | |
} | |
function cleanOutOfBounds(bag) { | |
var nP = bag.length; | |
var ds = 0; | |
for (var iP = 0; iP < nP; ++iP) { | |
var p = bag[iP]; | |
if (p.deleted) { | |
ds += 1; | |
continue; | |
} | |
if (!checkBounds(p)) { | |
p.deleted = true; | |
} | |
} | |
if (ds > 5000) { | |
return bag.filter(p => !p.deleted); | |
} | |
return null; | |
} | |
function checkBounds(subject) { | |
return subject.pos.x >= 0 && | |
subject.pos.y >= 0 && | |
subject.pos.x <= SPACEW && | |
subject.pos.y <= SPACEH; | |
} | |
// === EVENTS | |
window.addEventListener('mousemove', function (ev) { | |
const pos = Vec2.fromXY(ev.clientX, ev.clientY); | |
if (clickDown === 1 && particles.length > 0) { | |
const lastParticle = particles[particles.length - 1]; | |
if (pos.distanceTo(lastParticle.pos) > 25) { | |
let hsla = randomHslaWithHue(lastParticle.hsla[0]); | |
particles.push(new Particle(pos, Vec2.zero(), hsla, true)); | |
} | |
} else if (clickDown === 2 && forces.length > 0) { | |
if (controlPressed) { | |
const axL = mouseSpeed.perpCW().norm(); | |
const axR = mouseSpeed.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)); | |
} else { | |
const lastForce = forces[forces.length - 1]; | |
if (pos.distanceTo(lastForce.pos) > 25) { | |
forces.push(new Force(pos, lastForce.value)); | |
} | |
} | |
} | |
mousePosition = pos; | |
}); | |
window.addEventListener('mousedown', function (ev) { | |
const pos = Vec2.fromXY(ev.clientX, ev.clientY); | |
if (ev.button === 0) { | |
if (shiftPressed) { | |
particleGenerators.push(new ParticleGenerator(pos, 2)); | |
} else { | |
shouldProduce = true; | |
} | |
} else if (ev.button === 1) { | |
particles.push(new Particle(pos, Vec2.zero(), randomHsla(), true)); | |
} else if (ev.button === 2) { | |
let forceValue = 10; | |
let pulsate = null; | |
if (shiftPressed) { | |
forceValue *= -1; | |
} | |
if (altPressed) { | |
pulsate = 'linear'; | |
} | |
forces.push(new Force(pos, forceValue, null, pulsate)); | |
} | |
clickDown = ev.button; | |
}); | |
window.addEventListener('mouseup', function (ev) { | |
if (ev.button === 0) { | |
shouldProduce = false; | |
} | |
particles.filter(p => p.trace).forEach(p => p.active = true); | |
clickDown = null; | |
}); | |
window.addEventListener('keydown', function (ev) { | |
if (ev.keyCode === 16) { // shift | |
shiftPressed = true; | |
} else if (ev.keyCode === 17) { // control | |
controlPressed = true; | |
} else if (ev.keyCode === 18) { // alt | |
altPressed = true; | |
} else if (ev.keyCode === 32) { // space | |
particles = []; | |
} else if (ev.keyCode === 70) { // f | |
friction = !friction; | |
} else if (ev.keyCode === 68) { // d | |
forces = forces.filter(f => f.pos.distanceTo(mousePosition) > SINK_RADIUS); | |
particleGenerators = particleGenerators.filter(f => f.pos.distanceTo(mousePosition) > SINK_RADIUS); | |
} else if (ev.keyCode === 72) { // h | |
showForceGens = !showForceGens; | |
} else if (ev.keyCode === 67) { // c | |
compositeOperation = prompt('Composite operation', compositeOperation) || COMPOSITE_OPERATION_DEFAULT; | |
} else if (ev.keyCode === 90) { // z | |
forces = forces.slice(0, forces.length - 1); | |
} else if (ev.keyCode === 86) { // v | |
showParticleSpeedVector = !showParticleSpeedVector; | |
} | |
}); | |
window.addEventListener('keyup', function (ev) { | |
if (ev.keyCode === 16) { // shift | |
shiftPressed = false; | |
} else if (ev.keyCode === 17) { // control | |
controlPressed = false; | |
} else if (ev.keyCode === 18) { // alt | |
altPressed = false; | |
} | |
}); | |
canvas.addEventListener('contextmenu', function (ev) { | |
ev.preventDefault(); | |
}); | |
setInterval(function () { | |
updateMouseSpeed(mousePosition); | |
if (shouldProduce) { | |
produceParticlesAtPos(particles, mousePosition, uniformI(10, 20)); | |
} | |
particleGenerators.forEach(gen => { | |
produceParticlesAtPos(particles, gen.pos, uniformI(1, gen.value)); | |
}); | |
}, 16); | |
// clean up out-of-bounds particles | |
setInterval(function () { | |
var newParticles = cleanOutOfBounds(particles); | |
if (newParticles) { | |
particles = newParticles; | |
} | |
}, 5000) | |
// === DRAWING | |
function applyGravity(sink, subject) { | |
const dpos = sink.pos.sub(subject.pos); | |
const dd = dpos.length(); | |
if (dd <= SINK_RADIUS) { | |
return; | |
} | |
const ir2 = Math.pow(dd, 2); | |
const vel = dpos.sdiv(dd).smul(100 * sink.value / ir2); | |
subject.spd.add_(vel); | |
} | |
function applyFriction(subject) { | |
subject.spd.add_(subject.spd.smul(-0.05)); | |
} | |
function drawVector(ctx, pos, vec, scale, color) { | |
const vecS = pos.add(vec.smul(scale)); | |
ctx.beginPath(); | |
ctx.moveTo(pos.x, pos.y); | |
ctx.lineTo(vecS.x, vecS.y); | |
ctx.strokeStyle = color; | |
ctx.lineWidth = 2 | |
ctx.stroke(); | |
} | |
function simulate() { | |
var nP = particles.length; | |
var nF = forces.length; | |
for (var iP = 0; iP < nP; ++iP) { | |
var particle = particles[iP]; | |
if (particle.deleted) { | |
continue; | |
} | |
if (particle.trace && !particle.active) { | |
continue; | |
} | |
particle.pos.add_(particle.spd); | |
for (var iF = 0; iF < nF; ++iF) { | |
applyGravity(forces[iF], particle); | |
} | |
if (friction) { | |
applyFriction(particle); | |
} | |
if (particle.trace) { | |
particle.path.push(particle.pos.clone()); | |
} | |
} | |
} | |
function clearScreen() { | |
ctx.globalCompositeOperation = 'source-over'; | |
ctx.fillStyle = 'black'; | |
ctx.fillRect(0, 0, SPACEW, SPACEH); | |
} | |
function drawPath(path, color) { | |
ctx.beginPath(); | |
ctx.moveTo(path[0].x, path[0].y); | |
var nXY = path.length; | |
for (var iXY = 0; iXY < nXY; ++iXY) { | |
var p = path[iXY]; | |
ctx.lineTo(p.x, p.y); | |
} | |
ctx.strokeStyle = color; | |
ctx.lineWidth = 2; | |
ctx.stroke(); | |
} | |
function drawParticles() { | |
var nP = particles.length; | |
for (var iP = 0; iP < nP; ++iP) { | |
var particle = particles[iP]; | |
if (particle.deleted) { | |
continue; | |
} | |
var sz = +particle.size; | |
ctx.drawImage(particle.canvas, particle.pos.x - sz, particle.pos.y - sz); | |
if (particle.trace && particle.active) { | |
drawPath(particle.path, particle.color); | |
} | |
if (showParticleSpeedVector) { | |
drawVector(ctx, particle.pos, particle.spd, 5, particle.color); | |
} | |
} | |
} | |
function drawForces() { | |
var nF = forces.length; | |
for (var iF = 0; iF < nF; ++iF) { | |
var force = forces[iF]; | |
ctx.fillStyle = force.color; | |
ctx.beginPath(); | |
ctx.arc(force.pos.x, force.pos.y, SINK_RADIUS, 0, Math.TAU); | |
ctx.fill(); | |
} | |
} | |
function applyForcePulsation() { | |
var nF = forces.length; | |
for (var iF = 0; iF < nF; ++iF) { | |
var force = forces[iF]; | |
if (force.pulsate) { | |
force.step(); | |
} | |
} | |
} | |
function drawParticleGenerators() { | |
var nF = particleGenerators.length; | |
for (var iF = 0; iF < nF; ++iF) { | |
var gen = particleGenerators[iF]; | |
ctx.fillStyle = gen.color; | |
ctx.beginPath(); | |
ctx.arc(gen.pos.x, gen.pos.y, SINK_RADIUS, 0, Math.TAU); | |
ctx.fill(); | |
} | |
} | |
function draw() { | |
clearScreen(); | |
ctx.globalCompositeOperation = compositeOperation; | |
drawParticles(); | |
if (showForceGens) { | |
drawForces(); | |
drawParticleGenerators(); | |
} | |
applyForcePulsation(); | |
// drawVector(ctx, mousePosition, mouseSpeed, 5, 'green'); | |
} | |
function loop() { | |
simulate(); | |
draw(); | |
requestAnimationFrame(loop); | |
} | |
// === GO ! | |
loop(); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment