Last active
February 1, 2025 17:52
-
-
Save Garciat/40699e763651c22754c0057f89151087 to your computer and use it in GitHub Desktop.
Chispitas // A fun little 2D particle physics simulator.
This file contains hidden or 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> | |
| <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" | |
| >☰</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> |
This file contains hidden or 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
| { | |
| "compilerOptions": { | |
| "strict": true, | |
| "checkJs": true, | |
| "lib": [ | |
| "DOM", | |
| "ESNext" | |
| ] | |
| } | |
| } |
This file contains hidden or 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
| "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
