pond ripples
[github]raindrops opening rings that cross and fade on a pond
physics 64×20 20 fps 5.9 kB
________ _..---'''' ''''---.._ _.-'' ''-._ .-' '-. .-' ( ) '-. / \ / \ / \ ( ) ( ) ( _....._ ) ( -' '- ) \ / \ / \ ( ) / \ ( ) / '._ ( ) _.' -._ \ / _.- '--._ \_ _/ _.--' ''--...___ ___...--'' '''''''' ________ _..---'''' ''''---.._ _.-'' ''-._ .-' '-. .-' ( ) '-. / \ / \ / \ ( ) ( ) ( _....._ ) ( -' '- ) \ / \ / \ ( ) / \ ( ) / '._ ( ) _.' -._ \ / _.- '--._ \_ _/ _.--' ''--...___ ___...--'' ''''''''
use it
html
<script type="module" src="https://ascii.rest/ascii.js"></script> <ascii-art piece="pond-ripples"></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 { pondRipples } from "ascii.rest/pieces";
<Ascii piece={pondRipples} /> A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="pond-ripples", to fetch the piece only when it mounts.
astro
--- // npm install github:bas3line/ascii import Ascii from "ascii.rest/astro"; --- <Ascii piece="pond-ripples" />
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 { pondRipples } from "ascii.rest/pieces";
const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, pondRipples); 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.
/*
* pond-ripples: a pond from above in light rain. Each drop lands with a
* splash and opens a ring, a fainter one following it in, that weakens to
* dashes and dots before it reaches the bank. Where two rings cross they peak.
*/
import type { Frame, Meta } from "../types.ts";
export const meta = {
name: "pond ripples",
category: "physics",
note: "raindrops opening rings that cross and fade on a pond",
cols: 64,
rows: 20,
fps: 20,
} satisfies Meta;
const CW = 0.6, CH = 1.2; // a cell in em; distances are in em
const START = 7.35; // seconds into the rain: two rings open and a third drop about to land
const EVERY = 1.5; // seconds between drops, on average
const LIFE = 4; // seconds a ring lasts
const SPEED = 2.6; // em per second
const WAVE = 1.5; // em from the ring to the fainter one inside it
// A hash of (drop, salt) into [0, 1), integer maths only.
function rand(i: number, salt: number): number {
let h = Math.imul(i ^ 0x9e3779b9, 0x85ebca6b) ^ Math.imul(salt + 1, 0xc2b2ae35);
h = Math.imul(h ^ (h >>> 15), 0x2c1b3c6d);
h = Math.imul(h ^ (h >>> 12), 0x297a2d39);
return ((h ^ (h >>> 15)) >>> 0) / 4294967296;
}
/*
* An ellipse with half widths ax and ay around (x0, y0), as cells and marks:
* one mark a column where it runs flat, set at the height it crosses the
* cell, and one a row where it runs steep. put(cell, mark, angle) gets each.
* Small rings let the two overlap, so no corner goes missing.
*/
function ellipse(
cols: number,
rows: number,
x0: number,
y0: number,
ax: number,
ay: number,
put: (cell: number, mark: string, angle: number) => void,
small = true,
): void {
const [flat, steep] = small ? [1.05, 1.25] : [0.9, 1.1];
for (let c = 0; c < cols; c++) {
const x = ((c + 0.5) * CW - x0) / ax;
if (Math.abs(x) >= 1) continue;
const s = Math.sqrt(1 - x * x);
if (((ay / ax) * Math.abs(x)) / s > flat) continue;
for (const sign of [-1, 1]) {
const y = (y0 + sign * ay * s) / CH, r = Math.floor(y), f = y - r;
if (r >= 0 && r < rows) put(r * cols + c, f < 0.3 ? "'" : f < 0.6 ? "-" : f < 0.85 ? "." : "_", Math.atan2(sign * s, x));
}
}
for (let r = 0; r < rows; r++) {
const y = ((r + 0.5) * CH - y0) / ay;
if (Math.abs(y) >= 1) continue;
const s = Math.sqrt(1 - y * y), lean = ((ax / ay) * Math.abs(y)) / s;
if (lean > steep) continue;
for (const sign of [-1, 1]) {
const c = Math.floor((x0 + sign * ax * s) / CW);
const mark = lean < 0.45 ? (sign < 0 ? "(" : ")") : (sign < 0) === (y < 0) ? "/" : "\\";
if (c >= 0 && c < cols) put(r * cols + c, mark, Math.atan2(y, sign * s));
}
}
}
export default function pondRipples(): Frame {
const { cols, rows } = meta;
const N = cols * rows;
const CX = (cols * CW) / 2, CY = (rows * CH) / 2;
const AX = CX - 1.2, AY = CY - 1.12; // the bank, its top and bottom flat along a row's edge
const bank: string[] = new Array(N).fill("");
ellipse(cols, rows, CX, CY, AX, AY, (i, m) => (bank[i] = m), false);
// How far in from the bank, 1 well inside, falling to 0 just short of it.
const open = (x: number, y: number) => {
const e = Math.min(1, Math.max(0, (1 - ((x - CX) / AX) ** 2 - ((y - CY) / AY) ** 2) / 0.35));
return e * e * (3 - 2 * e);
};
// The drops alive at time t: where each fell, its age and how hard it struck.
const drops = (t: number) => {
const out: { x: number; y: number; age: number; a: number }[] = [];
for (let i = Math.floor((t - LIFE) / EVERY) - 1; i <= Math.floor(t / EVERY) + 1; i++) {
const age = t - (i + 0.7 * (rand(i, 0) - 0.5)) * EVERY;
if (age < 0 || age > LIFE) continue;
const r = 0.62 * Math.sqrt(rand(i, 1)), p = 2 * Math.PI * rand(i, 2);
out.push({ x: CX + AX * r * Math.cos(p), y: CY + AY * r * Math.sin(p), age, a: 0.8 + 0.2 * rand(i, 3) });
}
return out.sort((p, q) => q.age - p.age);
};
const ink: string[] = new Array(N), level = new Uint8Array(N), owner = new Int16Array(N);
return (time) => {
const t = START + time;
ink.fill(" "), level.fill(0), owner.fill(-1);
// Level 1 is the faint ring, 2 a weakening one, 3 a full one; two full rings crossing peak.
const kind = (m: string) => ("'-._".includes(m) ? 0 : "()".includes(m) ? 1 : m === "/" ? 2 : 3);
const draw = (i: number, mark: string, lv: number, who: number) => {
if (lv === 3 && level[i] === 3 && owner[i] !== who && kind(ink[i]) !== kind(mark)) (ink[i] = "x"), (level[i] = 4);
else if (lv >= level[i] && level[i] < 4) (ink[i] = mark), (level[i] = lv), (owner[i] = who);
};
drops(t).forEach((dp, who) => {
const c0 = Math.floor(dp.x / CW), r0 = Math.floor(dp.y / CH), at = r0 * cols + c0;
// The splash: a drop, then a small ring.
if (dp.age < 0.15) return draw(at, "o", 3, who);
if (dp.age < 0.4) return draw(at - 1, "(", 3, who), draw(at + 1, ")", 3, who);
const R = 0.5 + SPEED * dp.age, life = dp.a * (1 - dp.age / LIFE);
// The ring: drawn whole while strong, in spaced dots as it weakens.
ellipse(cols, rows, dp.x, dp.y, R, R, (i, mark, ang) => {
const near = open(((i % cols) + 0.5) * CW, (Math.floor(i / cols) + 0.5) * CH);
if (life > 0.45 && near > 0.6) draw(i, mark, 3, who);
else if (life > 0.2 && near > 0.45 && Math.floor(((ang + Math.PI) * R) / 0.9) % 2) draw(i, mark === "_" || mark === "." ? "." : "·", 2, who);
});
// The fainter ring following it in, only while the first is strong; a dot every other step.
const r2 = R - WAVE;
if (life > 0.55 && r2 > 1.4)
ellipse(cols, rows, dp.x, dp.y, r2, r2, (i, mark, ang) => {
if (Math.floor(((ang + Math.PI) * r2) / 1.1) % 2) draw(i, "·", 1, who);
});
});
const lines: string[] = [];
for (let r = 0; r < rows; r++) {
let line = "";
for (let c = 0; c < cols; c++) line += bank[r * cols + c] || ink[r * cols + c];
lines.push(line);
}
return lines.join("\n");
};
}