campfire
[github]a log fire flickering, sparks rising; denser ink is hotter
nature 52×22 20 fps 8.4 kB
. . , ' .. ' .,, + ,,;, ,;:;; ..==:==;, ,,.;+=;++=; :;.,=+==*+==: ;+;.:+*++*+===, (''--...__ ,+*=.;*#**#*++==, :::..--'') (==##===##:;;;**=;+*%#%%***++=:;;'##===::==) __.--===-#=,+*#++#*%%%%###**+;:=#-===--=__ ''--.__:::*%#%#%#@@%@%%%###;;;:__.--'' ..--''##:==%@%#%%%@@@@@@@%@@%;::::''--.. /@\##==#-===--##%%%%@@@@@%%%%##-===-:==##/@\ \_/-..___..--'' :::;:+;;;;;::''--..___==-\_/ -'' : ::: : ''- . . , ' .. ' .,, + ,,;, ,;:;; ..==:==;, ,,.;+=;++=; :;.,=+==*+==: ;+;.:+*++*+===, (''--...__ ,+*=.;*#**#*++==, :::..--'') (==##===##:;;;**=;+*%#%%***++=:;;'##===::==) __.--===-#=,+*#++#*%%%%###**+;:=#-===--=__ ''--.__:::*%#%#%#@@%@%%%###;;;:__.--'' ..--''##:==%@%#%%%@@@@@@@%@@%;::::''--.. /@\##==#-===--##%%%%@@@@@%%%%##-===-:==##/@\ \_/-..___..--'' :::;:+;;;;;::''--..___==-\_/ -'' : ::: : ''-
use it
html
<script type="module" src="https://ascii.rest/ascii.js"></script> <ascii-art piece="campfire"></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 { campfire } from "ascii.rest/pieces";
<Ascii piece={campfire} /> A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="campfire", to fetch the piece only when it mounts.
astro
--- // npm install github:bas3line/ascii import Ascii from "ascii.rest/astro"; --- <Ascii piece="campfire" />
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 { campfire } from "ascii.rest/pieces";
const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, campfire); 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.
/*
* campfire: two logs lying crossed on the ground, burning where they meet.
* Tongues of flame sway and tear over a low body of fire, the bark chars and
* glows beneath them, and sparks lift off the tips as they climb.
*/
import type { Frame, Meta } from "../types.ts";
export const meta = {
name: "campfire",
category: "nature",
note: "a log fire flickering, sparks rising; denser ink is hotter",
cols: 52,
rows: 22,
fps: 20,
} satisfies Meta;
// Coolest to hottest. Fire is light, so it keeps its ramp on paper, where
// the hottest parts print as the densest ink.
const FIRE = " .,:;=+*#%@";
const EMBER = ":;+*#%";
const SURF = "_.-'"; // a log's edge by where it crosses its cell, bottom to top
const BED = 18.4; // the row the flames stand on
// Tongues: [columns off centre, height in rows, half width, phase].
const TONGUES: [x0: number, h0: number, w0: number, ph: number][] = [
[-7.4, 9.5, 3.4, 2.4],
[-4.4, 12.5, 3.5, 0.0],
[-1.4, 15, 3.5, 1.7],
[1.7, 15.5, 3.5, 3.1],
[4.7, 12.5, 3.5, 4.4],
[7.6, 10, 3.4, 5.6],
];
// The logs cross in the fire, each lying low across the frame: [the side
// its near end lies on, half its length in columns, rows it drops a column
// toward that end, radius in rows].
const MID = 16.8; // the row their axes cross at
const LOGS: [side: number, half: number, k: number, R: number][] = [[1, 22, 0.115, 1.4], [-1, 22, 0.115, 1.4]];
const SPARKS = 9;
const hash = (x: number, y: number): number => {
let h = Math.imul(x, 0x27d4eb2d) ^ Math.imul(y, 0x165667b1);
h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
};
const ease = (t: number): number => t * t * (3 - 2 * t);
function noise(x: number, y: number): number {
const i = Math.floor(x), j = Math.floor(y), u = ease(x - i), v = ease(y - j);
const a = hash(i, j), b = hash(i + 1, j), c = hash(i, j + 1), d = hash(i + 1, j + 1);
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}
export default function campfire(): Frame {
const { cols, rows } = meta;
const cx = cols / 2;
// Each log as cells: a glyph, and how hot the fire makes it (the top of
// the log most, and the more the nearer the middle). The near half of a
// log, toward us, is drawn over the far half of the other.
const logs: (string | null)[] = new Array(cols * rows).fill(null);
const heat = new Float32Array(cols * rows);
const near = new Uint8Array(cols * rows);
for (const [side, half, k, R] of LOGS) {
const seed = side > 0 ? 3 : 8;
for (let c = Math.floor(cx - half); c < cx + half; c++) {
const x = c + 0.5 - cx, y = MID + k * x * side;
const front = x * side > 0 ? 1 : 0; // the near end is cut and shows its rings
const r0 = Math.floor(y - R), r1 = Math.floor(y + R);
const warm = Math.exp(-((x / 13) ** 2));
const set = (r: number, ch: string, h = 0) => {
const i = c + r * cols;
if (r < 0 || r >= rows || (near[i] && !front)) return;
logs[i] = ch;
heat[i] = h;
near[i] = front;
};
// How far in from the end of the log, in columns.
const e = Math.min(c - Math.floor(cx - half), Math.ceil(cx + half) - 1 - c);
set(r0, e === 0 || (front && e === 1) ? " " : SURF[Math.min(3, Math.floor((1 - (y - R - r0)) * 4))], warm);
set(r1, e === 0 || (front && e < 3) ? " " : SURF[Math.min(3, Math.floor((1 - (y + R - r1)) * 4))]);
// Bark in runs along the log that break and change: ridged where the
// firelight catches it on top, plainer in its own shadow below.
for (let r = r0 + 1; r < r1; r++) {
const upper = r === r0 + 1;
const u = x * side + (upper ? 0 : 1.7) + 40, run = Math.floor(u / 2.6), h = hash(run, seed + (upper ? 0 : 5));
let ch = run % 2 ? (upper ? "=" : "-") : upper ? (h < 0.6 ? "#" : ":") : h < 0.6 ? "=" : ".";
if (e === 0) ch = front ? (upper === x < 0 ? "/" : "\\") : x < 0 ? "(" : ")";
else if (front && e === 1) ch = upper ? "@" : "_";
else if (front && e === 2) ch = upper === x < 0 ? "\\" : "/";
set(r, ch, warm * (upper ? 0.7 : 0.3));
}
}
}
// Sparks: each rises from the flames on its own period, wavering.
const sparks = Array.from({ length: SPARKS }, (_, i) => ({
x: (hash(i, 1) - 0.5) * 12,
period: 2.6 + hash(i, 2) * 2.4,
phase: (i + hash(i, 3) * 0.5) / SPARKS,
rise: 7 + hash(i, 4) * 6,
drift: (hash(i, 5) - 0.5) * 8,
}));
// Coals on the ground under the crossing, brightest in the middle.
const coals: [k: number, w: number, ph: number][] = [];
for (let r = Math.ceil(BED) - 1; r < rows; r++)
for (let c = 0; c < cols; c++) {
const x = c + 0.5 - cx, k = c + r * cols;
if (logs[k] || Math.abs(x) > 10) continue;
coals.push([k, Math.exp(-((x / 6) ** 2)) * (1.3 - (r + 1 - BED) * 0.35), hash(c, r + 60) * 9]);
}
const fire = new Float32Array(cols * rows);
const out: string[] = new Array(cols * rows);
return (t) => {
out.fill(" ");
const glow = (k: number, ch: string): string => {
const c = k % cols, r = (k - c) / cols;
const g = heat[k] * (0.45 + 0.75 * noise(c * 0.7 + 3, t * 2.2 + r * 3)) - 0.3;
return g > 0 ? EMBER[Math.min(EMBER.length - 1, Math.floor(g * 1.6 * EMBER.length))] : ch;
};
const breath = 0.85 + 0.3 * noise(t * 3, 7.5);
fire.fill(0);
for (let r = 0; r < Math.ceil(BED); r++)
for (let c = 0; c < cols; c++) {
const x = c + 0.5 - cx;
let v = 0;
for (const sub of [0.25, 0.75]) {
const h = BED - (r + sub);
// A low body of fire across the crossing, and the tongues rising
// out of it, the outer ones leaning in toward the middle.
const low = h / (5.5 * breath);
let m = low < 1 ? (1 - (x / (10.5 * Math.sqrt(1 - low) + 0.5)) ** 2) * (0.85 - low * 0.5) : 0;
let top = low < 1 ? low * 0.5 : 0;
for (const [x0, h0, w0, ph] of TONGUES) {
const lift = h / (h0 * (0.72 + 0.5 * noise(t * 1.6 + ph * 3, ph * 5)) * breath);
if (lift >= 1) continue;
const sway = 1.4 * lift ** 1.4 * Math.sin(h * 0.55 - t * 5.5 + ph) + 0.5 * lift * Math.sin(t * 8.3 + ph * 2);
const q = (x - x0 * (1 - 0.45 * lift) - sway) / (w0 * (1 - lift) ** 0.6 + 0.5);
const f = (1 - q * q) * (1 - lift * 0.6);
if (f > m) [m, top] = [f, lift];
}
// Rising noise tears the fire, more toward the tips than the base.
const n = noise(x * 0.45, (h - t * 6) * 0.35) * 0.6 + noise(x * 0.9 + 9, (h - t * 8) * 0.7) * 0.4;
v += m - 0.55 * n * (0.35 + top * 0.65);
}
fire[c + r * cols] = v / 2;
}
// Only flame that joins the body of the fire is drawn: a tongue that
// tears free goes out.
const stack: number[] = [];
for (let c = 0, r = Math.ceil(BED) - 1; c < cols; c++) if (fire[c + r * cols] > 0.03) stack.push(c + r * cols), (fire[c + r * cols] += 2);
while (stack.length) {
const k = stack.pop()!, c = k % cols;
for (const d of [-cols - 1, -cols, -cols + 1, -1, 1]) {
const j = k + d;
if (j < 0 || Math.abs((j % cols) - c) > 1 || !(fire[j] > 0.03 && fire[j] < 2)) continue;
fire[j] += 2;
stack.push(j);
}
}
// The near halves of the logs stand in front of all but the hottest
// flame; the far halves go behind it.
for (let k = 0; k < cols * rows; k++) {
const v = fire[k] - 2;
if (v > 0.03 && !(near[k] && v < 0.6)) out[k] = FIRE[Math.min(FIRE.length - 1, 1 + Math.floor(((v - 0.03) / 0.85) * (FIRE.length - 1)))];
else if (logs[k]) out[k] = glow(k, logs[k]!);
}
// Coals glow on the ground under the crossing.
for (const [k, w, ph] of coals) {
const b = w * (0.35 + 0.8 * noise(ph + t * 1.3, ph * 3)) - 0.2;
if (b > 0 && out[k] === " ") out[k] = EMBER[Math.min(EMBER.length - 1, Math.floor(b * EMBER.length))];
}
for (const s of sparks) {
const u = (t / s.period + s.phase) % 1;
if (u > 0.8) continue;
const y = BED - 8 - u * s.rise;
const x = cx + s.x + s.drift * u + Math.sin(u * 11 + s.phase * 6);
const c = Math.floor(x), r = Math.floor(y), k = c + r * cols;
if (r < 0 || c < 0 || c >= cols || out[k] !== " ") continue;
out[k] = u < 0.25 ? "*" : u < 0.45 ? "+" : u < 0.65 ? "'" : ".";
}
const lines: string[] = [];
for (let r = 0; r < rows; r++) lines.push(out.slice(r * cols, (r + 1) * cols).join(""));
return lines.join("\n");
};
}