Home

21. Amiga · 1984 · Retro techniques

Boing ball

Demos are animating. Following your system setting.
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.

Reference

Boing Ball, Amiga demo by Dale Luck and R. J. Mical, shown at CES January 1984

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. By default a demo holds a still frame when your system asks for reduced motion; the Motion buttons override that, and a standalone copy animates with #motion=play on its URL.

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;
  let raf = 0;
  let posed = false;
  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let reduced = motionQuery.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;
    wake();
  });

  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;
    } else if (!posed) { y = 0.8; x = 0; }
    posed = true;
    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) raf = requestAnimationFrame(frame);
  }

  // Motion. The system's reduced-motion preference decides by default. A
  // standalone copy animates anyway with "#motion=play" in its URL (or holds a
  // frame with "#motion=still"); an embedding page can post {motion: "play"}.
  let motionMode = "auto";
  function applyMotion() {
    const next = motionMode === "still" || (motionMode === "auto" && motionQuery.matches);
    if (next === reduced) return;
    reduced = next;
    last = 0;
    if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }
    else if (!raf) raf = requestAnimationFrame(frame);
  }
  function setMotion(mode) {
    if (mode === "play" || mode === "still" || mode === "auto") { motionMode = mode; applyMotion(); }
  }
  function readMotionHash() {
    const m = /motion=(play|still|auto)/.exec(location.hash);
    let mode = m ? m[1] : "auto";
    // Embedded in the gallery, the page sets window.__demoMotion before any frame loads.
    try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === "string") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }
    setMotion(mode);
  }
  function wake() { if (reduced) frame(performance.now()); }
  if (motionQuery.addEventListener) motionQuery.addEventListener("change", applyMotion);
  else if (motionQuery.addListener) motionQuery.addListener(applyMotion);
  window.addEventListener("hashchange", readMotionHash);
  window.addEventListener("message", function (e) { if (e.data && typeof e.data.motion === "string") setMotion(e.data.motion); });
  window.addEventListener("resize", wake);

  if (reduced) frame(performance.now());
  else raf = requestAnimationFrame(frame);
  readMotionHash();
})();