starlings

[github]

a murmuration wheeling about, split by a passing falcon

creatures 64×16 30 fps 6.2 kB

use it

html

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

<ascii-art piece="starlings"></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 { starlings } from "ascii.rest/pieces";

<Ascii piece={starlings} />

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

astro

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

<Ascii piece="starlings" />

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 { starlings } from "ascii.rest/pieces";

const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, starlings);

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.

starlings.ts

[js] [github]
/*
 * starlings: a murmuration. Each bird matches its neighbours' heading, keeps
 * to their middle and makes room for the one beside it, while a slow drift
 * wheels the cloud about the middle. Every so often a falcon cuts through it.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "starlings",
  category: "creatures",
  note: "a murmuration wheeling about, split by a passing falcon",
  cols: 64,
  rows: 16,
  fps: 30,
} satisfies Meta;

const CW = 0.6, RH = 1.2; // a column and a row, in em
const N = 320, DT = 1 / 30;
const WARM = 200, PASS = 210; // steps before the first frame; between falcons
const FIRST = WARM - 10; // the step the first falcon comes in on
const R = 1.6, SEP = 0.85; // neighbours are this close; this close is too close
const LOOK = 0.6, TURN = 90; // how far ahead a bird looks for the edge; how hard it turns
const FLEE = 4.2; // birds this close to the falcon scatter

// A smooth wave in [-1, 1] with period p.
const osc = (t: number, p: number) => {
  const u = 1 - Math.abs(2 * (t / p - Math.floor(t / p)) - 1);
  return 2 * u * u * (3 - 2 * u) - 1;
};

export default function starlings(): Frame {
  const { cols, rows } = meta;
  const W = cols * CW, H = rows * RH;
  const MX = 1.5, MY = 1.2; // the open sky's oval, in from each side
  const GX = Math.ceil(W / R), GY = Math.ceil(H / R);
  let seed = 26;
  const rnd = () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let z = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    z = (z + Math.imul(z ^ (z >>> 7), 61 | z)) ^ z;
    return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
  };
  const x = new Float64Array(N), y = new Float64Array(N), vx = new Float64Array(N), vy = new Float64Array(N);
  for (let i = 0; i < N; i++) {
    x[i] = W * (0.3 + 0.3 * rnd());
    y[i] = H * (0.3 + 0.4 * rnd());
    vx[i] = 6 + 2 * rnd();
    vy[i] = 2 * rnd() - 1;
  }
  const head = new Int32Array(GX * GY), next = new Int32Array(N);
  let k = 0, fx = NaN, fy = 0, fd = -1;

  const step = () => {
    const t = k * DT, ph = (k - FIRST + PASS * 9) % PASS, hunting = k++ >= FIRST && ph < 90;
    const tx = W * (0.5 + 0.12 * osc(t, 23)), ty = H * (0.5 + 0.1 * osc(t + 4, 11));
    let cy = 0;
    for (let i = 0; i < N; i++) cy += y[i] / N;
    // Each falcon comes in from the side the last one left by, level with the
    // flock, and bends toward the middle of it as it goes.
    if (hunting && ph === 0) [fd, fx, fy] = [-fd, fd < 0 ? -2 : W + 2, cy];
    fx = hunting ? fx + fd * 20 * DT : NaN;
    if (hunting) fy += (cy - fy) * 3 * DT;
    head.fill(-1);
    for (let i = 0; i < N; i++) {
      const c = Math.min(GY - 1, Math.max(0, Math.floor(y[i] / R))) * GX + Math.min(GX - 1, Math.max(0, Math.floor(x[i] / R)));
      next[i] = head[c];
      head[c] = i;
    }
    for (let i = 0; i < N; i++) {
      const gx0 = Math.floor(x[i] / R), gy0 = Math.floor(y[i] / R);
      let n = 0, sx = 0, sy = 0, svx = 0, svy = 0, px = 0, py = 0;
      for (let gy = Math.max(0, gy0 - 1); gy <= Math.min(GY - 1, gy0 + 1); gy++) {
        for (let gx = Math.max(0, gx0 - 1); gx <= Math.min(GX - 1, gx0 + 1); gx++) {
          for (let j = head[gy * GX + gx]; j >= 0; j = next[j]) {
            const dx = x[j] - x[i], dy = y[j] - y[i], d2 = dx * dx + dy * dy;
            if (j === i || d2 > R * R) continue;
            n++;
            sx += dx; sy += dy; svx += vx[j]; svy += vy[j];
            if (d2 < SEP * SEP) (px -= dx / (d2 + 0.02)), (py -= dy / (d2 + 0.02));
          }
        }
      }
      // Drift; align, cohere and separate.
      let ax = (tx - x[i]) * 0.25, ay = (ty - y[i]) * 0.5;
      if (n) {
        ax += (svx / n - vx[i]) * 3 + (sx / n) * 0.5 + px * 4;
        ay += (svy / n - vy[i]) * 3 + (sy / n) * 0.5 + py * 4;
      }
      // A bird heading out of the open sky, an oval in the frame, turns back
      // toward the middle, harder the further out it is headed. It turns
      // rather than brakes, so the cloud wheels short of the edge.
      const lx = (x[i] + vx[i] * LOOK - W / 2) / (W / 2 - MX), ly = (y[i] + vy[i] * LOOK - H / 2) / (H / 2 - MY);
      const out = Math.hypot(lx, ly) - 0.6;
      if (out > 0) {
        const s = Math.hypot(vx[i], vy[i]);
        const side = vx[i] * (H / 2 - y[i]) - vy[i] * (W / 2 - x[i]) > 0 ? 1 : -1;
        ax += (-vy[i] / s) * side * TURN * out;
        ay += (vx[i] / s) * side * TURN * out;
      }
      // The falcon: birds near it break hard away and fly fast.
      let top = 10;
      const dx = x[i] - fx, dy = y[i] - fy, d2 = dx * dx + dy * dy;
      if (d2 < FLEE * FLEE) {
        ax += (dx / (d2 + 0.4)) * 90;
        ay += (dy / (d2 + 0.4)) * 90;
        top = 15;
      }
      const ux = vx[i] + ax * DT, uy = vy[i] + ay * DT;
      const s = Math.sqrt(ux * ux + uy * uy), m = s > top ? top / s : s < 5 ? 5 / s : 1;
      vx[i] = ux * m;
      vy[i] = uy * m;
    }
    for (let i = 0; i < N; i++) (x[i] += vx[i] * DT), (y[i] += vy[i] * DT);
  };
  while (k < WARM) step();

  // Each character holds two places, one over the other: ' is a bird up top,
  // . one below, : two, and heavier marks as more crowd in.
  const top = new Uint8Array(cols * rows), bot = new Uint8Array(cols * rows);
  const RAMP = "  :;+*%%##";
  const draw = () => {
    top.fill(0);
    bot.fill(0);
    for (let i = 0; i < N; i++) {
      const c = Math.floor(x[i] / CW), r = Math.floor((y[i] / RH) * 2);
      if (c >= 0 && c < cols && r >= 0 && r < rows * 2) (r & 1 ? bot : top)[(r >> 1) * cols + c]++;
    }
    const lines: string[] = [];
    for (let r = 0; r < rows; r++) {
      let line = "";
      for (let c = 0; c < cols; c++) {
        const i = r * cols + c, n = top[i] + bot[i];
        line += n === 1 ? (top[i] ? "'" : ".") : RAMP[Math.min(RAMP.length - 1, n)];
      }
      lines.push(line);
    }
    // The falcon, beating its wings as it goes.
    const fc = Math.round(fx / CW), fr = Math.floor(fy / RH);
    if (fc > -2 && fc < cols + 1 && fr >= 0 && fr < rows) {
      const bird = (k >> 2) & 1 ? "\\v/" : "-v-";
      const row = lines[fr].split("");
      for (let i = 0; i < 3; i++) if (fc - 1 + i >= 0 && fc - 1 + i < cols) row[fc - 1 + i] = bird[i];
      lines[fr] = row.join("");
    }
    return lines.join("\n");
  };

  return (t) => {
    const want = WARM + Math.floor(t / DT + 1e-6);
    for (let s = 0; s < 12 && k < want; s++) step();
    return draw();
  };
}