← 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>&nbsp;click anywhere to launch &middot; 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>