Home

21. Amiga · 1984

Boing ball

Colors and knobs

These rewrite the live file. Copy HTML picks up whatever you set.

About

Commodore's 1984 show ball: a sphere with longitude/latitude checkers, Lambert-shaded, a flattened shadow on a perspective grid. It bounces with a little energy loss and rolls with its spin. Click to kick it.

Click the preview to kick the ball.

Browser APIs

  • Canvas 2D
  • ImageData
  • Pointer Events
  • requestAnimationFrame
  • matchMedia

If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. prefers-reduced-motion freezes the first still.

Source

(function () {
  const PARAMS = {
    color: "#d8241c",
    speed: 1,
    checks: 8
  };
  function hexToRgb(hex) {
    const n = parseInt(String(hex).replace("#", ""), 16);
    if (isNaN(n)) return [216, 36, 28];
    return [n >> 16 & 255, n >> 8 & 255, n & 255];
  }

  const canvas = document.getElementById("c");
  const fallback = document.getElementById("fallback");
  const ctx = canvas.getContext("2d");
  if (!ctx) {
    fallback.hidden = false;
    fallback.textContent = "Canvas 2D is not available in this browser.";
    return;
  }

  let x = 0, y = 1.8, vx = 1.15, vy = 0, spin = 0.4, last = 0;
  let poke = 0;
  const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const G = 9.4, FLOOR = 0, R = 0.72;

  canvas.addEventListener("pointerdown", function (ev) {
    const rect = canvas.getBoundingClientRect();
    const nx = ((ev.clientX - rect.left) / rect.width - 0.5) * 4.4;
    vx = (nx - x) * 1.4;
    vy = 4.2;
    poke = 1;
  });

  function project(px, py, pz, w, h) {
    const z = pz + 5.2;
    const f = Math.min(w, h) * 0.95 / z;
    return { x: w * 0.5 + px * f, y: h * 0.62 - py * f, s: f };
  }

  function drawGrid(w, h, rgb) {
    ctx.strokeStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ",0.22)";
    ctx.lineWidth = 1;
    for (let i = -6; i <= 6; i++) {
      const a = project(i * 0.55, FLOOR, -3.2, w, h);
      const b = project(i * 0.55, FLOOR, 3.4, w, h);
      ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
      const c = project(-3.4, FLOOR, i * 0.55, w, h);
      const d = project(3.4, FLOOR, i * 0.55, w, h);
      ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(d.x, d.y); ctx.stroke();
    }
  }

  function drawBall(w, h, rgb) {
    const p = project(x, y + R, 0, w, h);
    const rad = R * p.s;
    const sh = project(x + 0.35, FLOOR + 0.01, 0.15, w, h);
    const lift = Math.max(0.15, 1 - (y / 2.6));
    ctx.fillStyle = "rgba(0,0,0," + (0.38 * lift) + ")";
    ctx.beginPath();
    ctx.ellipse(sh.x, sh.y, rad * 0.85, rad * 0.22, 0, 0, Math.PI * 2);
    ctx.fill();

    const nCheck = Math.max(4, PARAMS.checks | 0);
    const xmin = Math.max(0, (p.x - rad) | 0);
    const xmax = Math.min(w - 1, (p.x + rad) | 0);
    const ymin = Math.max(0, (p.y - rad) | 0);
    const ymax = Math.min(h - 1, (p.y + rad) | 0);
    const id = ctx.getImageData(xmin, ymin, xmax - xmin + 1, ymax - ymin + 1);
    const data = id.data;
    const cw = xmax - xmin + 1;
    const cs = Math.cos(spin), sn = Math.sin(spin);
    const white = [236, 232, 224];
    for (let py = ymin; py <= ymax; py++) {
      for (let px = xmin; px <= xmax; px++) {
        const nx = (px - p.x) / rad;
        const ny = (py - p.y) / rad;
        const r2 = nx * nx + ny * ny;
        if (r2 > 1) continue;
        const nz = Math.sqrt(1 - r2);
        const rx = nx * cs + nz * sn;
        const rz = -nx * sn + nz * cs;
        const lon = Math.atan2(rx, rz);
        const lat = Math.asin(Math.max(-1, Math.min(1, ny)));
        const u = Math.floor(((lon + Math.PI) / (Math.PI * 2)) * nCheck);
        const v = Math.floor(((lat + Math.PI / 2) / Math.PI) * (nCheck * 0.5));
        const on = (u + v) & 1;
        const wrap = 0.18 + 0.85 * Math.max(0, 0.35 + 0.75 * (nx * 0.4 + ny * -0.5 + nz * 0.7));
        const col = on ? rgb : white;
        const i = ((py - ymin) * cw + (px - xmin)) * 4;
        data[i] = col[0] * wrap;
        data[i + 1] = col[1] * wrap;
        data[i + 2] = col[2] * wrap;
        data[i + 3] = 255;
      }
    }
    ctx.putImageData(id, xmin, ymin);
  }

  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w;
      canvas.height = h;
    }
  }

  function frame(now) {
    const dt = Math.min(0.032, last ? (now - last) * 0.001 : 0.016);
    last = now;
    if (!reduced) {
      vy -= G * dt * PARAMS.speed;
      y += vy * dt * PARAMS.speed;
      x += vx * dt * PARAMS.speed;
      spin += vx * dt * 0.85 * PARAMS.speed;
      if (y < FLOOR) {
        y = FLOOR;
        vy = Math.abs(vy) * 0.84;
        if (vy < 0.4) vy = 3.6 + poke * 1.2;
      }
      if (x > 2.1) { x = 2.1; vx = -Math.abs(vx); }
      if (x < -2.1) { x = -2.1; vx = Math.abs(vx); }
      poke *= 0.9;
    }
    resize();
    const w = canvas.width, h = canvas.height;
    ctx.fillStyle = "#0b0c0e";
    ctx.fillRect(0, 0, w, h);
    const rgb = hexToRgb(PARAMS.color);
    drawGrid(w, h, rgb);
    drawBall(w, h, rgb);
    if (!reduced) requestAnimationFrame(frame);
  }

  if (reduced) {
    y = 0.8; x = 0;
    resize();
    ctx.fillStyle = "#0b0c0e";
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    const rgb = hexToRgb(PARAMS.color);
    drawGrid(canvas.width, canvas.height, rgb);
    drawBall(canvas.width, canvas.height, rgb);
  } else requestAnimationFrame(frame);
})();