coffee
[github]steam curling up from a cup of coffee on its saucer
objects 52×24 20 fps 10.1 kB
\_ \_ ) . . / \_ ( / \_ \ / . \ ( . \ ( / ***+++====-----:::: %%##..............::-:::: @@....-==:.............-- #@@@@@.............**++=.*= **##@@@@@@%%%%%###***.... .== _+********+++==--::....... *. _=====:+++++++++===--::....... %+._ _+========:=+++++===---::....... %@+......_ *---=======:=++====---::.................:. *+=--=======.=====---::............::::--:. +++==--=====.------:::...:::.--------::.. '+++=======...............---::::...' ''===-====--:::-:::....'' \_ \_ ) . . / \_ ( / \_ \ / . \ ( . \ ( / ---===++++*****#### ..::%%%%@@@@@@@@@%##*#### ..%@%%*++#%@@@@@@@@@@@%** :.....@@@@@@@@@@@@@--==+%-+ --::...........:::---%%%% %++ _=--------===++**##%%%%%%% -% _+++++#=========+++**##%%%%%%% .=%_ _=++++++++#+=====+++***##%%%%%%% ..=%%%%%%_ -***+++++++#+==++++***##%%%%%%%%%%%%%%%%%#% -=+**+++++++%+++++***##%%%%%%%%%%%%####**#% ===++**+++++%******###%%%###%********##%@ '===+++++++%%%%%%%%%%%%%%%***####%%%' ''+++*++++**###*###%%%%''
use it
html
<script type="module" src="https://ascii.rest/ascii.js"></script> <ascii-art piece="coffee"></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 { coffee } from "ascii.rest/pieces";
<Ascii piece={coffee} /> A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="coffee", to fetch the piece only when it mounts.
astro
--- // npm install github:bas3line/ascii import Ascii from "ascii.rest/astro"; --- <Ascii piece="coffee" />
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 { coffee } from "ascii.rest/pieces";
const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, coffee); 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.
/*
* coffee: a cup of coffee on its saucer, lit from the upper left, with a few
* thin wisps of steam lifting off the surface and curling as they climb.
*/
import type { Frame, Meta } from "../types.ts";
export const meta = {
name: "coffee",
category: "objects",
note: "steam curling up from a cup of coffee on its saucer",
cols: 52,
rows: 24,
fps: 20,
} satisfies Meta;
type Vec = [number, number, number];
const RAMP = " .:-=+*#%@";
const TILT = 0.34; // how steeply we look down at the table
const H = 7.6, RT = 6.2; // the cup's height and its radius at the rim, in rows
const HC = H - 0.55; // the coffee's level
const PERIOD = 6; // seconds before the steam repeats
// Wisps: x and z where they leave the coffee, height in rows, phase, lean.
const WISPS: [number, number, number, number, number][] = [
[-2.4, 0.6, 11, 0, -0.12],
[0.3, -1.4, 14, 2.2, 0.05],
[2.9, 0.5, 10, 4.1, 0.15],
];
// The saucer turned on a lathe, as [radius, height]: a flat well, rising to a rounded lip.
const SAUCER: [number, number][] = [[0, 0], [4.4, 0], [6.5, 0.12], [8.7, 0.45], [9.9, 0.78], [10.4, 0.92], [10.6, 0.8], [10.55, 0.55], [10.2, 0.4]];
const cupR = (h: number) => RT * (0.62 + 0.38 * Math.sin((Math.PI / 2) * Math.min(1, Math.max(0, h) / H)));
const smooth = (a: number, b: number, x: number) => {
const u = Math.min(1, Math.max(0, (x - a) / (b - a)));
return u * u * (3 - 2 * u);
};
const norm = (x: number, y: number, z: number): Vec => {
const m = Math.hypot(x, y, z);
return [x / m, y / m, z / m];
};
export default function coffee(): Frame {
const { cols, rows } = meta;
const SX = 2, SY = 4; // looks per cell, across and down
const W = cols * SX, HH = rows * SY, N = cols * rows;
const depth = new Float32Array(W * HH).fill(-1e9);
const lum = new Float32Array(W * HH).fill(-1);
const part = new Uint8Array(W * HH); // 1 saucer, 2 cup, 3 lip, 4 coffee, 5 handle
const cs = Math.sqrt(1 - TILT * TILT), sn = TILT;
const CX = cols / 2 - 1.5, BASE = rows - 4.4; // the saucer's centre, a little left so the handle fits
const L = norm(-0.62, 0.62, 0.48);
const toCell = (x: number, y: number, z: number): [number, number] => [CX + 2 * x, BASE - (y * cs - z * sn)];
let id = 1;
const plot = (x: number, y: number, z: number, n: Vec, albedo: number, extra = 0) => {
const [c, r] = toCell(x, y, z);
const px = Math.floor(c * SX), py = Math.floor(r * SY);
if (px < 0 || px >= W || py < 0 || py >= HH) return;
const k = py * W + px, d = z * cs + y * sn;
if (d <= depth[k]) return;
depth[k] = d;
part[k] = id;
const diff = Math.max(0, n[0] * L[0] + n[1] * L[1] + n[2] * L[2]);
lum[k] = albedo * (0.1 + 0.9 * diff) + extra;
};
// A glaze's glint, where the light bounces toward us.
const HV = norm(L[0], L[1] + sn, L[2] + cs);
const glint = (n: Vec, p: number) => Math.max(0, n[0] * HV[0] + n[1] * HV[1] + n[2] * HV[2]) ** p;
// Is the cup in the way between this point and the light?
const shadowed = (x: number, y: number, z: number) => {
for (let s = 0.3; s < 16; s += 0.25) {
const yy = y + L[1] * s;
if (yy > H) return false;
if (yy > 0 && Math.hypot(x + L[0] * s, z + L[2] * s) < cupR(yy)) return true;
}
return false;
};
// The saucer: lit more on the left as it turns from the light, shaded by the cup
// and darkened close round its foot.
for (let s = 0; s < SAUCER.length - 1; s++) {
const [r1, y1] = SAUCER[s], [r2, y2] = SAUCER[s + 1];
const len = Math.hypot(r2 - r1, y2 - y1), nr = -(y2 - y1) / len, ny = (r2 - r1) / len;
for (let f = 0; f <= 1; f += 0.04 / len) {
const p = r1 + (r2 - r1) * f, y = y1 + (y2 - y1) * f;
for (let a = 0; a < 2 * Math.PI; a += 0.09 / Math.max(p, 0.5)) {
const x = p * Math.cos(a), z = p * Math.sin(a), n = norm(nr * Math.cos(a), ny, nr * Math.sin(a));
const side = 0.78 - 0.22 * (x / 10.6);
const ao = 0.35 + 0.65 * smooth(cupR(0) - 0.1, cupR(0) + 1.4, p);
plot(x, y, z, n, 0.78 * side * ao * (shadowed(x, y, z) ? 0.35 : 1), 0.25 * glint(n, 12));
}
}
}
// The cup, outside and in, its rounded lip, and the coffee.
id = 2;
for (let h = 0; h <= H; h += 0.05) {
const r = cupR(h), dr = (cupR(h + 0.05) - cupR(h - 0.05)) / 0.1;
for (let a = 0; a < 2 * Math.PI; a += 0.1 / r) {
const ca = Math.cos(a), sa = Math.sin(a);
if (sa > -0.3) {
const n = norm(ca, -dr, sa);
plot(r * ca, h, r * sa, n, 0.95, 0.35 * glint(n, 10));
}
if (h > HC && sa < 0.3) plot((r - 0.4) * ca, h, (r - 0.4) * sa, norm(-ca, dr, -sa), 0.75);
}
}
// The lip, lit as one rounded edge facing out and up: bright on the left, falling off to the right.
id = 3;
const R = cupR(H);
for (let v = 0; v <= Math.PI; v += 0.12)
for (let a = 0; a < 2 * Math.PI; a += 0.06 / R) {
const p = R - 0.2 - 0.2 * Math.cos(v), y = H + 0.12 * Math.sin(v), ca = Math.cos(a), sa = Math.sin(a);
plot(p * ca, y, p * sa, norm(0.6 * ca, 0.8, 0.6 * sa), 0.95, 0.12);
}
// The coffee: a dark pool, a paler ring of crema at the wall, one glint of the light.
id = 4;
const RC = cupR(HC) - 0.4;
for (let p = 0; p < RC; p += 0.08)
for (let a = 0; a < 2 * Math.PI; a += 0.08 / Math.max(p, 0.5)) {
const x = p * Math.cos(a), z = p * Math.sin(a);
const shine = Math.exp(-(((x + 2.4) / 1.1) ** 2 + ((z + 1.4) / 0.8) ** 2));
plot(x, HC, z, [0, 1, 0], 0.04 + 0.16 * smooth(0.82 * RC, RC, p), 0.85 * shine);
}
// The handle: a thin loop on the right, standing out from the wall at z = 0.
const HY = 4.1, AX = 3.9, AY = 2.6, TUBE = 0.42, X0 = cupR(HY) - 1.3;
const loop = (u: number): [number, number] => [X0 + AX * Math.cos(u), HY + AY * Math.sin(u)];
// Down to cells: the lip wins any cell it is in, so it reads as one line; elsewhere
// the mean light where most looks land, and a soft edge where a few do.
const still: (number | string | null)[] = [], kind: number[] = [];
for (let k = 0; k < N; k++) {
const c = k % cols, r = Math.floor(k / cols);
let n = 0, sum = 0, low = 0, lip = 0, lipSum = 0, owner = 0;
for (let j = 0; j < SY; j++)
for (let i = 0; i < SX; i++) {
const q = (r * SY + j) * W + c * SX + i, v = lum[q];
if (v < 0) continue;
n++;
sum += v;
if (j >= SY / 2) low++;
if (part[q] === 3) lip++, (lipSum += v);
owner = Math.max(owner, part[q] === 1 ? 1 : 2);
}
kind.push(owner);
still.push(lip >= 2 ? Math.min(1, lipSum / lip) : n === 0 ? null : n < 3 ? (low * 2 > n ? "_" : "'") : Math.min(1, sum / n));
}
// One thin dark line where the cup stands in front of the saucer, darkest under its foot.
const foot = toCell(0, 0, 0)[1];
const was = kind.slice();
for (let k = cols; k < N - 1; k++) {
if (was[k] !== 1 || typeof still[k] !== "number") continue;
if (was[k - cols] === 2 && k / cols > foot - 0.5) still[k] = Math.min(still[k] as number, 0.06);
else if (was[k - 1] === 2 || was[k + 1] === 2) (still[k] as number) *= 0.4;
}
// The handle, a cell thick: each cell near its line lit by the tube's own normal there.
// Through its loop the saucer is in shade, so the hole reads open.
const ring: [number, number][] = [];
for (let u = -2.1; u <= 2.1; u += 0.02) ring.push(loop(u));
for (let k = 0; k < N; k++) {
const c = k % cols, r = Math.floor(k / cols);
const x = (c + 0.5 - CX) / 2, y = (BASE - (r + 0.5)) / cs;
if (x < cupR(y) - 0.1 || x > X0 + AX + 1) continue;
let best = 9, ox = 0, oy = 0;
for (const [lx, ly] of ring) {
const d = Math.hypot(x - lx, y - ly);
if (d < best) (best = d), (ox = (x - lx) / TUBE), (oy = (y - ly) / TUBE);
}
if (best < TUBE + 0.12) {
const n = norm(ox, oy, Math.sqrt(Math.max(0.05, 1 - ox * ox - oy * oy)));
still[k] = Math.min(1, 0.1 + 0.85 * Math.max(0, n[0] * L[0] + n[1] * L[1] + n[2] * L[2]) + 0.3 * glint(n, 10));
continue;
}
const inside = (x - X0) ** 2 / AX ** 2 + (y - HY) ** 2 / AY ** 2 < 1;
if (inside && kind[k] !== 2) still[k] = null;
}
const top = Math.floor(toCell(0, HC, 0)[1]);
// Where a wisp is at height s rows: a bend that rides up it and grows as it climbs.
const W0 = WISPS.map(([x, z]) => toCell(x, HC, z));
const colAt = (i: number, s: number, t: number) => {
const [, , len, ph, lean] = WISPS[i];
const f = s / len, w = (2 * Math.PI * t) / PERIOD;
return W0[i][0] + 2 * (lean * s + (0.2 + 1.7 * f ** 1.5) * Math.sin(0.75 * s - 2 * w + ph) + 0.3 * f * Math.sin(w + ph));
};
return (t, { paper = false } = {}) => {
const out = still.map((v) => (v === null ? " " : typeof v === "string" ? v : RAMP[Math.max(1, Math.round((paper ? 1 - v : v) * 9))]));
const free = (k: number, r: number) => still[k] === null || (r >= top && typeof still[k] === "number" && still[k] < 0.3);
WISPS.forEach(([, , len, ph], i) => {
for (let r = Math.floor(W0[i][1] - 0.5); r >= 0; r--) {
const s = W0[i][1] - (r + 0.5);
if (s > len) break;
const c = colAt(i, s, t), cc = Math.floor(c), k = r * cols + cc;
if (cc < 0 || cc >= cols || !free(k, r)) continue;
// Faint off the surface and at the top, with breaks that rise with it.
const thin = 0.5 + 0.5 * Math.sin(0.55 * s - (2 * Math.PI * t) / PERIOD + ph * 2);
const a = smooth(0.3, 1.8, s) * (1 - smooth(0.6 * len, len, s)) * (0.3 + 0.7 * thin);
if (a < 0.2) continue;
const up = colAt(i, s + 1, t), dn = colAt(i, s - 1, t);
const lean = (up - dn) / 2, bow = c - (up + dn) / 2;
out[k] = a < 0.32 ? "." : Math.abs(lean) > 0.7 ? (lean > 0 ? "/" : "\\") : bow > 0.15 ? ")" : bow < -0.15 ? "(" : "|";
// Where it swings across faster than it climbs, it curls over: a run along the row.
const run = Math.round(up - c), n = Math.abs(run), dir = Math.sign(run);
if (a < 0.32 || n < 2) continue;
out[k] = "_";
for (let j = 1; j < n; j++)
if (cc + dir * j >= 0 && cc + dir * j < cols && free(k + dir * j, r)) out[k + dir * j] = j < n - 1 ? "_" : dir > 0 ? "/" : "\\";
}
});
const lines: string[] = [];
for (let r = 0; r < rows; r++) lines.push(out.slice(r * cols, (r + 1) * cols).join(""));
return lines.join("\n");
};
}