skyline

[github]

a city at night, windows going on and off, a blinking mast

objects 64×23 10 fps 7.4 kB

use it

html

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

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

<Ascii piece={skyline} />

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

astro

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

<Ascii piece="skyline" />

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

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

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.

skyline.ts

[js] [github]
/*
 * skyline: a city at night across a river. Office windows go on and off,
 * sometimes a whole floor at once, the mast on the tallest tower blinks,
 * and the low lights and the moon are drawn down into the water as streaks.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "skyline",
  category: "objects",
  note: "a city at night, windows going on and off, a blinking mast",
  cols: 64,
  rows: 23,
  fps: 10,
} satisfies Meta;

const BASE = 18; // the embankment row; towers stand on it, water below
const SHARE = 0.4; // the share of windows lit, which the night drifts back toward

// Far towers in haze: left column, width, height in rows.
const FAR: [number, number, number][] = [[3, 6, 9], [14, 6, 12], [25, 5, 9], [36, 6, 12], [48, 7, 10], [57, 6, 12]];
// Near towers: left column, width, height, then the setbacks stacked on top,
// each as [inset, rows].
const NEAR: [number, number, number, [number, number][], (keyof typeof TOPS)?][] = [
  [-3, 8, 5, []],
  [6, 7, 8, [], "tank"],
  [13, 5, 6, []],
  [19, 9, 10, [[1, 1], [1, 1], [1, 1]]],
  [29, 7, 13, [[1, 1]], "mast"],
  [37, 5, 7, []],
  [42, 7, 10, [], "spire"],
  [50, 5, 5, []],
  [55, 11, 8, [[1, 2]], "crown"],
];
// Rooftop things, bottom row first, centred over the top tier.
const TOPS = {
  tank: ["╨ ╨", "▄▄▄"],
  mast: ["╱│╲", " │ ", " │ "],
  spire: ["╱   ╲", " ╱ ╲ ", "  ╿  "],
  crown: ["╭───╮", " ╭─╮ "],
};
const STARS: [number, number][] = [[1, 18], [4, 23], [2, 27], [6, 40], [1, 45], [7, 61], [3, 56], [5, 50], [0, 36], [3, 38], [8, 1], [0, 59]];
const MOON: [number, number, number] = [9, 3.5, 1.9]; // centre column, centre row, radius in rows

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;
  };
}

export default function skyline(): Frame {
  const { cols, rows } = meta;
  const rand = mulberry32(19);
  const grid: string[][] = Array.from({ length: rows }, () => new Array(cols).fill(" "));
  const put = (c: number, r: number, g: string) => {
    if (c >= 0 && c < cols && r >= 0 && r < rows) grid[r][c] = g;
  };

  // The still part of the city, drawn once: far towers as haze, near ones
  // as outlines round a grid of panes, each setback's walls landing on the
  // tier below it.
  const city: string[][] = Array.from({ length: BASE }, () => new Array(cols).fill(" "));
  const set = (c: number, r: number, g: string) => c >= 0 && c < cols && r >= 0 && (city[r][c] = g);
  const draw = (c: number, r: number, g: string) => g !== " " && set(c, r, g);
  const panes: [number, number, boolean, number][] = []; // [row, col, far, tower]
  for (const [x, w, h] of FAR) {
    for (let r = BASE - h; r < BASE; r++) for (let c = x; c < x + w; c++) set(c, r, "░");
    for (let r = BASE - h + 1; r < BASE; r += 2) for (let c = x + 1; c < x + w - 1; c += 2) panes.push([r, c, true, -1]);
  }
  NEAR.forEach(([x, w, h, steps, top], n) => {
    let [l, rgt, t] = [x, x + w - 1, BASE - h];
    const tier = (r: number) => {
      for (let c = l; c <= rgt; c++) set(c, r, c === l || c === rgt ? "│" : " ");
      for (let c = l + 2; c < rgt; c += 2) panes.push([r, c, false, n]);
    };
    for (let r = t + 1; r < BASE; r++) tier(r);
    for (let c = l; c <= rgt; c++) set(c, t, c === l ? "┌" : c === rgt ? "┐" : "─");
    for (const [inset, tall] of steps) {
      (l += inset), (rgt -= inset);
      set(l, t, "┴"), set(rgt, t, "┴");
      for (let r = t - tall + 1; r < t; r++) tier(r);
      t -= tall;
      for (let c = l; c <= rgt; c++) set(c, t, c === l ? "┌" : c === rgt ? "┐" : "─");
    }
    // `top` is undefined for a plain roof, and TOPS[undefined] is undefined at run time.
    (TOPS[top as keyof typeof TOPS] || []).forEach((s, i) => [...s].forEach((g, j) => draw(Math.floor((l + rgt + 1 - s.length) / 2) + j, t - 1 - i, g)));
  });
  // A far pane only shows where no near tower stands in front of it.
  const shown = panes.filter(([r, c, far]) => c >= 0 && c < cols && (!far || city[r][c] === "░"));
  const mast = NEAR.find((b) => b[4] === "mast")!;
  const light = [BASE - mast[2] - 5, mast[0] + (mast[1] >> 1)];

  // The moon, a crescent drawn in quadrant blocks: lit where inside its
  // disc and outside the shadow's.
  const QUAD = " ▗▖▄▝▐▞▟▘▚▌▙▀▜▛█";
  const moon: [number, number, string][] = [];
  const [mx, my, mr] = MOON;
  for (let r = Math.floor(my - mr); r <= my + mr; r++) {
    for (let c = Math.floor(mx - 2 * mr); c <= mx + 2 * mr; c++) {
      let q = 0;
      for (const [b, dx, dy] of [[8, 0.25, 0.25], [4, 0.75, 0.25], [2, 0.25, 0.75], [1, 0.75, 0.75]]) {
        const x = (c + dx - mx) / 2, y = r + dy - my;
        if (Math.hypot(x, y) < mr && Math.hypot(x - 1.1, y) > mr * 0.95) q |= b;
      }
      if (q) moon.push([r, c, QUAD[q]]);
    }
  }

  // Every pane keeps whether it is lit. One changes at a time, and below
  // the share, lighting one is likelier than putting one out; every couple
  // of seconds a whole floor of one near tower switches together.
  const lit = shown.map(() => rand() < SHARE);
  let next = 0.2, floor = 0.9;
  let clock = 0;
  const pick = (on: boolean, near: boolean) => {
    let w = (rand() * lit.length) | 0;
    for (let n = 0; n < lit.length && (lit[w] === on || (near && shown[w][2])); n++) w = (w + 1) % lit.length;
    return w;
  };
  const advance = (t: number) => {
    while (Math.min(next, floor) <= t) {
      const on = rand() < 0.5 + (SHARE - lit.filter(Boolean).length / lit.length) * 4;
      if (floor < next) {
        const [r, , , n] = shown[pick(on, true)];
        shown.forEach(([r2, , , n2], i) => r2 === r && n2 === n && (lit[i] = on));
        floor += 1.6 + rand() * 1.2;
      } else {
        lit[pick(on, false)] = on;
        next += 0.12 + rand() * 0.3;
      }
    }
  };

  return (t) => {
    clock += Math.min(Math.max(t - clock, 0), 1);
    advance(clock);
    for (let r = 0; r < rows; r++) grid[r].fill(" ");
    for (let r = 0; r < BASE; r++) for (let c = 0; c < cols; c++) grid[r][c] = city[r][c];
    const glow: number[] = new Array(cols).fill(0); // lit panes low enough to reach the water
    shown.forEach(([r, c, far], i) => {
      grid[r][c] = far ? (lit[i] ? "▓" : "░") : lit[i] ? "▀" : "·";
      if (lit[i] && !far && r > BASE - 4) glow[c] = Math.max(glow[c], r - (BASE - 4));
    });

    // The sky: stars that twinkle, the moon, and the blinking mast.
    for (const [r, c] of STARS) if (grid[r][c] === " ") put(c, r, Math.sin(clock * 1.3 + c * 2) > 0.7 ? "+" : "·");
    for (const [r, c, g] of moon) put(c, r, g);
    put(light[1], light[0], clock % 1.6 < 0.4 ? "*" : "·");

    // The embankment, then the water: under each low light and under the
    // moon, a streak running straight down, broken by ripples and thinning
    // with depth.
    for (let c = 0; c < cols; c++) put(c, BASE, "▀");
    for (let k = 1; k < rows - BASE; k++) {
      for (let c = 0; c < cols; c++) {
        const ripple = Math.sin(c * 1.7 + k * 2.3 - clock * 3.1) + 0.6 * Math.sin(c * 0.53 - k * 1.3 + clock * 1.9);
        const m = Math.abs(c + 0.5 - (mx - 1)) < 2.8 - k * 0.45;
        if (m && ripple > -1.1 + k * 0.35) put(c, BASE + k, k < 2 ? "═" : k < 3 ? "=" : "-");
        else if (glow[c] && ripple > -0.9 + k * 0.45 - glow[c] * 0.2) put(c, BASE + k, "╎╎┊·"[k - 1]);
      }
    }
    return grid.map((l) => l.join("")).join("\n");
  };
}