snowfall
[github]snow swaying down, building drifts on the ground and a fence
nature 64×22 20 fps 6.4 kB
. * + . + . * + . + . * . . . + * + . . . . + + . + . . . + . . . * . + ▄ ▄ .▃ + ▄ . ┃ +* ┃ . ┃ ┃ * + + . ┃▃▄▄▄▄▅▅▅▅▅┃▄▄▃▄▄▄▄▄▄▅┃▄▆▆▅▄▃▃▄▄▅┃ + ┃ ┃ ┃ . ┃ . + ┃. ┃ .┃ . ┃ ┃▄▄▄▄▄▅▅▄▃▃┃▄▃▃▄▅▄▄▄▄▄┃▄▄▃▃▄▅▅▅▄▄┃ + . ┃ . ┃ . ┃ . ┃ + + ┃ ┃ ┃ ┃ . ▁▃▄ + ┃ . ┃ . ┃ . ┃ ▂▅███▇ ┃ ┃ ┃ * ┃ ▁▁▂▃▄▆███████▅ ▁▂▂▂▁ *.┃ ┃ ┃▁ ▂▄▆▇██████████████▃ ██████▆▄▂▁▁▁▁▁▁▂▁▁ ┃ ▁▂▄▆▇███▆▅▅▅▅▆████████████████████▆▅▅▅▅ ███████████████████▇▆▆▆▆▇███████████████████████████████████████ . * + . + . * + . + . * . . . + * + . . . . + + . + . . . + . . . * . + ▄ ▄ .▃ + ▄ . ┃ +* ┃ . ┃ ┃ * + + . ┃▃▄▄▄▄▅▅▅▅▅┃▄▄▃▄▄▄▄▄▄▅┃▄▆▆▅▄▃▃▄▄▅┃ + ┃ ┃ ┃ . ┃ . + ┃. ┃ .┃ . ┃ ┃▄▄▄▄▄▅▅▄▃▃┃▄▃▃▄▅▄▄▄▄▄┃▄▄▃▃▄▅▅▅▄▄┃ + . ┃ . ┃ . ┃ . ┃ + + ┃ ┃ ┃ ┃ . ▁── + ┃ . ┃ . ┃ . ┃ ▁─░░░▔ ┃ ┃ ┃ * ┃ ▁▁▁──▔▔░░░░░░─ ▁▁▁▁▁ *.┃ ┃ ┃▁ ▁─▔▔░░░░░░░░░░░░░░─ ░░░░░▔▔─▁▁▁▁▁▁▁▁▁▁ ┃ ▁▁─▔▔▔▔░▔▔───▔▔░░░░░░░░░░░░░░░░░░░▔──── ░░░░░░░░░░░░░░░░░░▔▔▔▔▔▔▔░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
use it
html
<script type="module" src="https://ascii.rest/ascii.js"></script> <ascii-art piece="snowfall"></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 { snowfall } from "ascii.rest/pieces";
<Ascii piece={snowfall} /> A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="snowfall", to fetch the piece only when it mounts.
astro
--- // npm install github:bas3line/ascii import Ascii from "ascii.rest/astro"; --- <Ascii piece="snowfall" />
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 { snowfall } from "ascii.rest/pieces";
const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, snowfall); 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.
/*
* snowfall: snow drifting down in three depths on a light wind, swaying as
* it falls. Flakes settle where they land, building a drift in the fence's
* lee, a ridge along each rail and a cap on every post.
*/
import type { Frame, Meta } from "../types.ts";
export const meta = {
name: "snowfall",
category: "nature",
note: "snow swaying down, building drifts on the ground and a fence",
cols: 64,
rows: 22,
fps: 20,
} satisfies Meta;
const EIGHTHS = " ▁▂▃▄▅▆▇█";
const LINE = "▔─▁"; // on paper the drift's surface is a line, by where it crosses a cell
const POSTS = [9, 20, 31, 42]; // the fence posts' columns
const TOP = 10; // the posts' top row
const RAILS = [11, 14]; // the rails' rows
const RAIL = 2; // a bare rail's thickness, in eighths of a row
// Each depth: flakes, fall speed and drift in cells a second, sway, glyph.
const LAYERS = [
{ n: 44, v: 1.1, wind: 0.6, sway: 0.5, glyph: "." },
{ n: 30, v: 1.8, wind: 1.1, sway: 1, glyph: "+" },
{ n: 13, v: 2.8, wind: 1.8, sway: 1.6, glyph: "*" },
];
interface Flake {
li: number;
x: number;
y: number;
ph: number;
w: number;
}
function mulberry32(a: number) {
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
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);
};
export default function snowfall(): Frame {
const { cols, rows } = meta;
const rand = mulberry32(7);
// The ground's snow in rows at each column: rolling, swept into a long
// drift in the fence's lee that ends in a steep face.
const ground = Float64Array.from({ length: cols }, (_, c) => {
const lee = 3.6 * smooth(36, 55, c) * (1 - smooth(55, 59, c));
return 1 + 0.55 * Math.sin(c * 0.19 + 1.3) + 0.3 * Math.sin(c * 0.47 + 0.4) + lee;
});
const extra = new Float64Array(cols).fill(0.3); // fresh snow on top of it
const wind = Float64Array.from({ length: cols }, (_, c) => 0.7 + 1.6 * Math.exp(-(((c - 52) / 7) ** 2)));
const rail = RAILS.map((_, k) => Float64Array.from({ length: cols }, (_, c) => 2 + 0.7 * Math.sin(c * 0.45 + k * 2) + 0.4 * rand()));
const cap = new Float64Array(POSTS.length).fill(7);
const inFence = (c: number) => c > POSTS[0] && c < POSTS[POSTS.length - 1];
const flakes: Flake[] = [];
const spawn = (f: Flake, top: boolean) => {
f.x = rand() * cols;
f.y = top ? -rand() * 2 : rand() * rows;
f.ph = rand() * 6.283;
f.w = 0.8 + rand() * 0.45;
};
LAYERS.forEach((L, li) => {
for (let i = 0; i < L.n; i++) {
const f = { li } as Flake;
spawn(f, false);
flakes.push(f);
}
});
const step = (dt: number, t: number) => {
for (const f of flakes) {
const L = LAYERS[f.li];
const y0 = f.y;
f.y += L.v * f.w * dt;
f.x = (((f.x + (L.wind + L.sway * Math.cos(t * f.w * 1.3 + f.ph)) * dt) % cols) + cols) % cols;
const c = Math.floor(f.x);
// Flakes at the fence's depth may come to rest on a rail or a post top.
if (f.li === 1) {
const k = RAILS.findIndex((r) => y0 < r + 0.5 && f.y >= r + 0.5);
if (k >= 0 && inFence(c) && !POSTS.includes(c) && rand() < 0.7) {
for (const [d, a] of [[-1, 0.3], [0, 0.6], [1, 0.3]]) rail[k][c + d] = Math.min(5, rail[k][c + d] + a);
spawn(f, true);
continue;
}
const p = POSTS.indexOf(c);
if (p >= 0 && y0 < TOP && f.y >= TOP) {
cap[p] = Math.min(13, cap[p] + 1);
spawn(f, true);
continue;
}
}
if (f.y < rows - ground[c] - extra[c]) continue;
// The two nearer depths settle where they land, more where the wind drops them.
if (f.li > 0) extra[c] += 0.3 * wind[c];
spawn(f, true);
}
// Fresh snow evens out between neighbours and packs down slowly.
const next = Float64Array.from(extra, (e, c) => e + 1.5 * dt * ((extra[c - 1] ?? e) + (extra[c + 1] ?? e) - 2 * e));
for (let c = 0; c < cols; c++) extra[c] = next[c] * (1 - 0.02 * dt);
for (const r of rail) for (let c = 0; c < cols; c++) r[c] -= r[c] * 0.004 * dt;
for (let p = 0; p < cap.length; p++) cap[p] -= cap[p] * 0.01 * dt;
};
for (let i = 0; i < 400; i++) step(0.05, i * 0.05); // twenty seconds of snow before the first frame
let last = 0;
return (time, { paper = false } = {}) => {
let dt = Math.min(0.1, Math.max(0, time - last));
last = time;
for (; dt > 1e-9; dt -= 0.05) step(Math.min(dt, 0.05), 20 + time);
const g = Array.from({ length: rows }, () => new Array<string>(cols).fill(" "));
// A flake shows only in open air, in front of any farther flake.
const draw = (li: number) => {
for (const f of flakes) {
const r = Math.floor(f.y), c = Math.floor(f.x);
if (f.li !== li || r < 0 || r >= rows) continue;
const k = LAYERS.findIndex((L) => L.glyph === g[r][c]);
if (g[r][c] === " " || (k >= 0 && k < li)) g[r][c] = LAYERS[li].glyph;
}
};
draw(0);
// The fence: posts, rails thickened by the snow lying on them, a cap on each post.
RAILS.forEach((r, k) => {
for (let c = POSTS[0] + 1; c < POSTS[POSTS.length - 1]; c++) g[r][c] = EIGHTHS[RAIL + Math.floor(rail[k][c])];
});
POSTS.forEach((c, p) => {
for (let r = TOP; r < rows; r++) g[r][c] = "┃";
const n = Math.floor(cap[p]);
g[TOP - 1][c] = EIGHTHS[Math.min(8, 1 + Math.floor(n / 2))];
if (n > 8) g[TOP - 1][c - 1] = g[TOP - 1][c + 1] = EIGHTHS[n - 8];
});
// The drift: on a dark ground, solid under an eighth-block surface; on
// paper, a line over a light fill, with an edge down any step of two rows.
const top = Array.from({ length: cols }, (_, c) => rows - ground[c] - extra[c]);
for (let c = 0; c < cols; c++) {
const s = top[c], r = Math.floor(s), f = s - r;
for (let rr = r + 1; rr < rows; rr++) g[rr][c] = paper ? "░" : "█";
if (paper) g[r][c] = LINE[Math.min(2, Math.floor(f * 3))];
else {
g[r][c] = EIGHTHS[Math.max(1, Math.round((1 - f) * 8))];
}
}
if (paper)
for (let c = 0; c + 1 < cols; c++) {
const a = Math.floor(top[c]), b = Math.floor(top[c + 1]);
for (let r = a + 1; r < b; r++) g[r][c + 1] = "▏";
for (let r = b + 1; r < a; r++) g[r][c] = "▕";
}
draw(1);
draw(2);
return g.map((row) => row.join("")).join("\n");
};
}