bouncing balls

[github]

rubber balls bouncing lower each time, squashing on landing

physics 64×22 30 fps 7.9 kB

use it

html

<script type="module" src="https://ascii.rest/ascii.js"></script>

<ascii-art piece="bouncing-balls" options='{"balls":3}'></ascii-art>

No install and no build step. ascii.js defines the <ascii-art> tag, loads the piece from ascii.rest, plays it while it is on screen, and holds the first frame for anyone who prefers reduced motion.

react

// npm install github:bas3line/ascii
import { Ascii } from "ascii.rest/react";
import { bouncingBalls } from "ascii.rest/pieces";

<Ascii piece={bouncingBalls} options={{"balls":3}} />

A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="bouncing-balls", to fetch the piece only when it mounts.

astro

---
// npm install github:bas3line/ascii
import Ascii from "ascii.rest/astro";
---

<Ascii piece="bouncing-balls" options={{"balls":3}} />

Renders the first frame on the server, so the page is whole before any script runs, then plays the piece once it loads.

typescript

// npm install github:bas3line/ascii
import { mount } from "ascii.rest";
import { bouncingBalls } from "ascii.rest/pieces";

const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, bouncingBalls, {"balls":3});

mount plays a piece in a <pre>, or on a <canvas> for the coloured ones, and returns a function that stops it. Every piece, option and frame is typed.

bouncing-balls.ts

[js] [github]
/*
 * bouncing-balls: rubber balls dropping onto a floor and knocking into each
 * other, squashing on every landing; a ball that comes to rest crouches and leaps.
 */
import type { Frame, Meta } from "../types.ts";

export interface BouncingBallsOptions {
  [key: string]: unknown;
  balls: number;
}

export const meta = {
  name: "bouncing balls",
  category: "physics",
  note: "rubber balls bouncing lower each time, squashing on landing",
  cols: 64,
  rows: 22,
  fps: 30,
  options: { balls: 3 },
} satisfies Meta<BouncingBallsOptions>;

const RAMP = " .:-=+*#%@";
const G = 80; // gravity, rows per second squared
const H = 1 / 900; // physics step
const STIFF = 120; // contact stiffness: a ball of radius r rings at STIFF / r
const SIZES = [3, 2.3, 1.7, 2.6, 2]; // radii, in rows; a column is half a row
const BOUNCE = [0.84, 0.82, 0.8, 0.83, 0.81];
const SPREAD = 2.1; // seconds between the balls' first leaps
const WARM = 9.1; // seconds played before the first frame
const SHADOW = "▀▒░"; // a shadow's glyphs, from a ball on the floor to one high above it

function mulberry32(seed: number) {
  let s = seed | 0;
  return () => {
    s = (s + 0x6d2b79f5) | 0;
    let t = Math.imul(s ^ (s >>> 15), 1 | s);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

interface Ball {
  r: number;
  k: number;
  c: number;
  x: number;
  y: number;
  vx: number;
  vy: number;
  rest: number;
  crouch: number | null;
  sq: number;
}

export default function bouncingBalls({ balls = 3 }: Partial<BouncingBallsOptions> = {}): Frame {
  const { cols, rows } = meta;
  const W = cols / 2; // the floor's width, in rows
  const FLOOR = rows - 1; // the floor line sits on the top edge of this row
  const m = Math.hypot(-0.45, 0.55, 0.7);
  const [lx, ly, lz] = [-0.45 / m, 0.55 / m, 0.7 / m];
  const hm = Math.hypot(lx, ly, lz + 1);
  const [hx, hy, hz] = [lx / hm, ly / hm, (lz + 1) / hm]; // halfway between the light and the eye
  const rand = mulberry32(11);
  const n = Math.max(1, Math.min(SIZES.length, Math.round(balls)));
  const bs: Ball[] = Array.from({ length: n }, (_, i) => {
    const r = SIZES[i], w = STIFF / r, e = BOUNCE[i];
    const z = -Math.log(e) / Math.hypot(Math.PI, Math.log(e));
    const x = W * (0.2 + (0.6 * i) / Math.max(1, n - 1));
    // They start resting on the floor and take turns to leap, so they never move in step.
    return { r, k: w * w, c: 2 * z * w, x, y: r, vx: 0, vy: 0, rest: 0, crouch: -SPREAD * i, sq: 0 };
  });

  const launch = (b: Ball) => {
    // High enough to fill the frame, never so high the ball leaves it.
    b.vy = Math.sqrt(2 * G * (FLOOR - 1.5 - 2 * b.r) * (0.75 + 0.25 * rand()));
    b.vx = (W / 2 - b.x) * 0.3 + (rand() - 0.5) * 10;
    b.y = b.r; // spring straight back to round
    b.crouch = null;
    b.rest = 0;
  };

  const step = () => {
    for (const b of bs) {
      if (b.crouch !== null) {
        // Settled: crouch into the floor for a moment, then spring up.
        b.crouch += H;
        const u = Math.max(0, Math.min(1, b.crouch / 0.45));
        b.sq = 0.3 * b.r * u * u * (3 - 2 * u);
        b.y = b.r - b.sq;
        b.vx *= 1 - 6 * H;
        if (u >= 1) launch(b);
        continue;
      }
      b.vy -= G * H;
      const d = b.r - b.y;
      if (d > 0) {
        b.vy += Math.max(0, b.k * d - b.c * b.vy) * H;
        b.vx *= 1 - 0.8 * H;
        b.rest += H;
        if (b.rest > 0.3 && b.crouch === null) b.crouch = 0;
      } else b.rest = 0;
      b.sq = Math.max(0, d);
      // A knock from below never sends a ball out of the top of the frame.
      b.vy = Math.min(b.vy, Math.sqrt(2 * G * Math.max(0, FLOOR - 1.2 - b.r - b.y)));
      b.x += b.vx * H;
      b.y += b.vy * H;
      if (b.x < b.r) (b.x = b.r), (b.vx = Math.abs(b.vx) * 0.85);
      if (b.x > W - b.r) (b.x = W - b.r), (b.vx = -Math.abs(b.vx) * 0.85);
    }
    // Balls that touch trade momentum along the line between their centres.
    for (let i = 0; i < n; i++) {
      for (let j = i + 1; j < n; j++) {
        const a = bs[i], b = bs[j];
        const dx = b.x - a.x, dy = b.y - a.y, dist = Math.hypot(dx, dy), gap = a.r + b.r - dist;
        if (gap <= 0 || dist === 0) continue;
        const nx = dx / dist, ny = dy / dist;
        const ma = a.r * a.r, mb = b.r * b.r;
        const vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
        if (vn < 0) {
          const jm = (-1.85 * vn) / (1 / ma + 1 / mb);
          (a.vx -= (jm * nx) / ma), (a.vy -= (jm * ny) / ma), (b.vx += (jm * nx) / mb), (b.vy += (jm * ny) / mb);
        }
        const pa = mb / (ma + mb), pb = ma / (ma + mb);
        (a.x -= nx * gap * pa), (a.y -= ny * gap * pa), (b.x += nx * gap * pb), (b.y += ny * gap * pb);
        if (a.crouch !== null && ny < 0) a.crouch = null;
        if (b.crouch !== null && ny > 0) b.crouch = null;
      }
    }
  };

  const advance = (dt: number) => {
    for (let i = 0; i < Math.round(dt / H); i++) step();
  };
  for (let i = 0; i < WARM * 30; i++) advance(1 / 30);
  let last = 0;

  const out: string[] = new Array(cols * rows);
  // How bright rubber is at a normal: soft light from the upper left, a small highlight set in from the rim.
  const shade = (nx: number, ny: number, nz: number) => {
    const d = Math.max(0, nx * lx + ny * ly + nz * lz);
    const s = Math.max(0, nx * hx + ny * hy + nz * hz) ** 40;
    return Math.min(1, 0.14 + 0.66 * d + 0.5 * s + 0.12 * Math.max(0, -ny)); // the floor lights the underside a little
  };

  const draw = (b: Ball, paper: boolean) => {
    // A squashed ball keeps its area: shorter by what it has sunk, wider to match.
    const ry = b.r - b.sq, rx = (b.r * b.r) / ry;
    const cy = FLOOR - ry - Math.max(0, b.y - b.r), cx = b.x * 2;
    const runs: [number, string][][] = [];
    for (let r = Math.floor(cy - ry); r <= cy + ry; r++) {
      const run: [number, string][] = [];
      runs.push(run);
      if (r < 0 || r >= FLOOR) continue;
      for (let c = Math.floor(cx - 2 * rx); c <= cx + 2 * rx; c++) {
        if (c < 0 || c >= cols) continue;
        let hit = 0, v = 0;
        for (let j = 0; j < 4; j++) {
          for (let i = 0; i < 4; i++) {
            const u = (c + 0.125 + i * 0.25 - cx) / 2 / rx, w = (r + 0.125 + j * 0.25 - cy) / ry;
            const q = u * u + w * w;
            if (q < 1) (hit++, (v += shade(u, -w, Math.sqrt(1 - q))));
          }
        }
        if (hit < 4) continue;
        // A cell the rim only clips gets a lighter glyph, so the edge is smooth rather than outlined.
        const ink = paper ? 1.1 - v / hit : v / hit;
        const g = Math.round(Math.min(1, ink) * Math.sqrt(hit / 16) * (RAMP.length - 1));
        if (g > 0) run.push([r * cols + c, RAMP[g]]);
      }
    }
    // A sliver of a row on the very top or bottom, over a much wider one, reads as a nub: leave it off.
    const n = runs.length;
    if (n > 2 && runs[0].length < 3 && runs[1].length > 5) runs[0].length = 0;
    if (n > 2 && runs[n - 1].length < 3 && runs[n - 2].length > 5) runs[n - 1].length = 0;
    for (const run of runs) for (const [k, g] of run) out[k] = g;
  };

  return (t, { paper = false } = {}) => {
    advance(Math.min(0.1, Math.max(0, t - last)));
    last = t;
    out.fill(" ");
    for (let c = 0; c < cols; c++) out[FLOOR * cols + c] = "▔";
    for (const b of bs) {
      // The shadow narrows and pales as the ball rises.
      const h = Math.max(0, b.y - b.r) / (FLOOR - 2 * b.r);
      const rx = (b.r * b.r) / (b.r - b.sq), sw = rx * 2 * (1 - 0.6 * h);
      for (let c = Math.ceil(b.x * 2 - sw); c < b.x * 2 + sw; c++) {
        const edge = Math.abs(c + 0.5 - b.x * 2) / sw;
        const g = Math.min(SHADOW.length - 1, Math.floor(h * 2.6 + edge * 1.3));
        if (c >= 0 && c < cols) out[FLOOR * cols + c] = SHADOW[g];
      }
    }
    // Smaller balls are nearer, so they draw last.
    for (const b of [...bs].sort((p, q) => q.r - p.r)) draw(b, paper);
    const lines: string[] = [];
    for (let r = 0; r < rows; r++) lines.push(out.slice(r * cols, (r + 1) * cols).join(""));
    return lines.join("\n");
  };
}