← back to Fireworks Sim
index.html
156 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Particle Fireworks</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #05060f; }
canvas { display: block; cursor: crosshair; }
#hud { position: fixed; top: 12px; left: 12px; color: #9aa4c7;
font: 13px/1.6 system-ui, sans-serif; user-select: none; }
#hud button { background: #1b2140; color: #cdd6ff; border: 1px solid #39406b;
border-radius: 6px; padding: 4px 12px; font: inherit; cursor: pointer; }
#hud button:hover { background: #262e58; }
</style>
</head>
<body>
<canvas id="sky"></canvas>
<div id="hud">
<button id="autoBtn">auto-show: on</button>
<span> click anywhere to launch · press A to toggle</span>
</div>
<script>
'use strict';
const canvas = document.getElementById('sky');
const ctx = canvas.getContext('2d');
let W, H;
function resize() { W = canvas.width = innerWidth; H = canvas.height = innerHeight; }
addEventListener('resize', resize);
resize();
const GRAVITY = 0.05, FRICTION = 0.985, TRAIL = 7;
const rand = (a, b) => a + Math.random() * (b - a);
const rockets = [], particles = [];
class Rocket {
constructor(tx, ty) {
this.x = tx + rand(-25, 25);
this.y = H;
this.ty = Math.min(ty, H * 0.85);
// launch speed chosen so the rocket's apex lands at the target height
this.vy = -Math.sqrt(2 * GRAVITY * (H - this.ty)) - rand(0.2, 0.8);
this.vx = (tx - this.x) / (Math.abs(this.vy) / GRAVITY);
this.hue = rand(0, 360);
this.trail = [];
}
update() {
this.trail.push({ x: this.x, y: this.y });
if (this.trail.length > TRAIL) this.trail.shift();
this.x += this.vx;
this.y += this.vy;
this.vy += GRAVITY;
if (this.vy >= 0 || this.y <= this.ty) { explode(this.x, this.y, this.hue); return true; }
return false;
}
draw() {
const tail = this.trail[0] || this;
ctx.strokeStyle = `hsl(${this.hue} 100% 75%)`;
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(tail.x, tail.y);
ctx.lineTo(this.x, this.y);
ctx.stroke();
}
}
class Particle {
constructor(x, y, hue) {
const angle = rand(0, Math.PI * 2);
// sqrt gives an even fill of the burst disc instead of a hollow ring
const speed = Math.sqrt(Math.random()) * rand(5, 9);
this.x = x; this.y = y;
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed;
this.hue = hue + rand(-18, 18);
this.life = 1;
this.decay = rand(0.008, 0.022);
this.trail = [];
}
update() {
this.trail.push({ x: this.x, y: this.y });
if (this.trail.length > TRAIL) this.trail.shift();
this.vx *= FRICTION;
this.vy = this.vy * FRICTION + GRAVITY;
this.x += this.vx;
this.y += this.vy;
this.life -= this.decay;
return this.life <= 0;
}
draw() {
if (this.life < 0.35 && Math.random() < 0.4) return; // twinkle as it dies
const tail = this.trail[0] || this;
ctx.strokeStyle = `hsla(${this.hue} 100% ${45 + this.life * 30}% / ${this.life})`;
ctx.lineWidth = 1 + this.life * 1.5;
ctx.beginPath();
ctx.moveTo(tail.x, tail.y);
ctx.lineTo(this.x, this.y);
ctx.stroke();
}
}
function explode(x, y, hue) {
const count = Math.floor(rand(70, 120));
const twoTone = Math.random() < 0.3 ? hue + 180 : hue;
for (let i = 0; i < count; i++) particles.push(new Particle(x, y, i % 2 ? hue : twoTone));
// brief flash at the burst point
const flash = ctx.createRadialGradient(x, y, 0, x, y, 60);
flash.addColorStop(0, `hsla(${hue} 100% 90% / 0.9)`);
flash.addColorStop(1, 'transparent');
ctx.fillStyle = flash;
ctx.fillRect(x - 60, y - 60, 120, 120);
}
function launch(tx, ty) { rockets.push(new Rocket(tx, ty)); }
let autoOn = true, autoTimer = 0;
function tick(now) {
// translucent fill (not clearRect) so everything leaves a fading trail
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(5, 6, 15, 0.22)';
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = 'lighter'; // additive blend = glow where sparks overlap
for (let i = rockets.length - 1; i >= 0; i--) {
if (rockets[i].update()) rockets.splice(i, 1);
else rockets[i].draw();
}
for (let i = particles.length - 1; i >= 0; i--) {
if (particles[i].update()) particles.splice(i, 1);
else particles[i].draw();
}
if (autoOn && now > autoTimer) {
launch(rand(W * 0.1, W * 0.9), rand(H * 0.1, H * 0.45));
autoTimer = now + rand(500, 1600);
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
canvas.addEventListener('pointerdown', e => {
launch(e.clientX, e.clientY);
autoTimer = performance.now() + 4000; // give the user the sky for a few seconds
});
const autoBtn = document.getElementById('autoBtn');
function setAuto(on) {
autoOn = on;
autoBtn.textContent = `auto-show: ${on ? 'on' : 'off'}`;
}
autoBtn.addEventListener('click', e => { e.stopPropagation(); setAuto(!autoOn); });
addEventListener('keydown', e => { if (e.key.toLowerCase() === 'a') setAuto(!autoOn); });
</script>
</body>
</html>