rocket
[github]a rocket lifting off its pad at night, then landing back
space 48×28 20 fps 9.5 kB
. . . . . | /\ |\| /#=\ + |/| /%#+:\ |\| /%%*+-.\ |/| |%%*+-.| |\| |%%*+-.| |/| \%%*+-./ |\| |%#+:| --..·· |/|=========|%#+:|~~ . |\| /---:..\ |/| |%%*+-.| |\| |%%*+-.| |/| |%%*+-.| |\| |%%*+-.| |/| |%%*+-.| |\| /|%%*+-.|\ |/| /=|%%*+-.|:\ |\| /==|%%*+-.|::\ |/| [ \+=-./ ] |\| [ /+=-.\ ] |/| [ /++=-:.\ ] __________________ __________________ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ . . . . . | /\ |\| /:+\ + |/| /.:=#\ |\| /..-=*%\ |/| |..-=*%| |\| |..-=*%| |/| \..-=*%/ |\| |.:=#| --..·· |/|=========|.:=#|~~ . |\| /***#%%\ |/| |..-=*%| |\| |..-=*%| |/| |..-=*%| |\| |..-=*%| |/| |..-=*%| |\| /|..-=*%|\ |/| /+|..-=*%|#\ |\| /++|..-=*%|##\ |/| [ \=+*%/ ] |\| [ /=+*%\ ] |/| [ /==+*#%\ ] __________________ __________________ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
use it
html
<script type="module" src="https://ascii.rest/ascii.js"></script> <ascii-art piece="rocket"></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 { rocket } from "ascii.rest/pieces";
<Ascii piece={rocket} /> A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="rocket", to fetch the piece only when it mounts.
astro
--- // npm install github:bas3line/ascii import Ascii from "ascii.rest/astro"; --- <Ascii piece="rocket" />
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 { rocket } from "ascii.rest/pieces";
const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, rocket); 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.
/*
* rocket: a night launch. Boil-off vents from the upper stage, the arm swings
* back, the engine lights, exhaust billows out of the trench and the rocket
* climbs out of the frame. It comes back down on a landing burn and settles.
*/
import type { Frame, Meta } from "../types.ts";
export const meta = {
name: "rocket",
category: "space",
note: "a rocket lifting off its pad at night, then landing back",
cols: 48,
rows: 28,
fps: 20,
} satisfies Meta;
// w is white paint, k the dark interstage, g the bare metal of the bell; each
// is shaded as a cylinder lit from the tower's floodlights on the left.
const ROCKET = [
" /\\ ",
" /ww\\ ",
" /wwww\\ ",
" /wwwwww\\ ",
" |wwwwww| ",
" |wwwwww| ",
" \\wwwwww/ ",
" |wwww| ",
" |wwww| ",
" /kkkkkk\\ ",
" |wwwwww| ",
" |wwwwww| ",
" |wwwwww| ",
" |wwwwww| ",
" |wwwwww| ",
" /|wwwwww|\\ ",
" /f|wwwwww|f\\ ",
"/ff|wwwwww|ff\\",
" \\gggg/ ",
" /gggg\\ ",
" /gggggg\\ ",
];
const TONE: Record<string, number> = { w: 1, k: 0.42, g: 0.62, f: 0.6 };
const BODY = " .:-=+*#%";
// Smoke is drawn as how much of it there is, not as light, so its ramp keeps
// its sense on paper, and it stops at % so the clouds stay soft.
const SMOKE = " .:-=+*#%";
const FLAME = "░▒▓█";
const LOOP = 18;
const ARM = 0.8; // the service arm starts to swing away
const IGNITE = 1.5; // engine lights
const LIFT = 3; // the clamps let go
const CLIMB = 2.6; // rows per second squared on the way up
const OUT = 40; // rows above the pad where it is well out of sight
const LAND = 14.6; // touchdown
const DECEL = 4.4; // braking on the landing burn, which starts out of sight
const BURN = Math.sqrt((2 * OUT) / DECEL);
const RATE = 36; // exhaust puffs a second
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;
};
}
// Rows the rocket's base stands above the pad at time t in the loop, or -1
// while it is out of sight.
function height(t: number): number {
if (t < LIFT || t >= LAND) return 0;
const up = 0.5 * CLIMB * (t - LIFT) ** 2;
if (up < OUT) return up;
const left = LAND - t;
return left < BURN ? 0.5 * DECEL * left * left : -1;
}
const burning = (t: number) => t >= IGNITE && t < LAND && height(t) >= 0;
const onPad = (t: number) => t < ARM - 0.3 || t >= LAND + 1;
export default function rocket(): Frame {
const { cols, rows } = meta;
const W = ROCKET[0].length, HT = ROCKET.length;
const left = Math.floor((cols - W) / 2), mid = left + W / 2 - 0.5;
const pad = rows - 2; // the pad's deck; the base of the rocket sits on the row above
const tower = left - 8;
const armRow = pad - HT + 8; // level with the upper stage
const rand = mulberry32(3);
// The sprite, shaded once for a dark page and once for paper.
const sprite = [false, true].map((paper) =>
ROCKET.map((line) => {
const a = line.search(/[wkg]/), b = line.lastIndexOf(line[a]) + 1;
return [...line].map((ch, c) => {
if (!TONE[ch]) return ch;
const x = (c + 0.5 - W / 2) / ((b - a) / 2 + 0.5);
// The fins are flat, so each takes one tone.
const lit = ch === "f" ? (x < 0 ? 0.75 : 0.3) : Math.max(0, -0.72 * x + 0.69 * Math.sqrt(Math.max(0, 1 - x * x)));
const i = Math.max(1, Math.round(TONE[ch] * (0.12 + 0.88 * lit) * (BODY.length - 1)));
return BODY[paper ? BODY.length - i : i];
});
}),
);
// The rocket is solid from its first mark to its last on every row.
const span = ROCKET.map((s) => [s.search(/\S/), s.trimEnd().length]);
// Stars, kept off the rocket, its vapour and the tower's top so none reads
// as a spark.
const stars: [number, number, number, boolean][] = [];
for (let r = 0; r < pad - 9; r++)
for (let c = 0; c < cols; c++) {
const byRocket = c >= left - 2 && c <= left + W + 1 + (Math.abs(r - armRow) < 4 ? 12 : 0) && r >= pad - HT - 2;
const byTower = Math.abs(c - tower - 1) < 4 && r >= pad - 24;
if (rand() < 0.026 && !byRocket && !byTower) stars.push([c, r, rand() * 6.283, rand() < 0.2]);
}
// Every puff of exhaust keeps its own spread, speed and life.
const puffs = Array.from({ length: Math.ceil(LOOP * RATE) }, () => ({
jx: (rand() - 0.5) * 2.4, side: rand() < 0.5 ? -1 : 1, fan: 14 + rand() * 12,
rise: 0.5 + rand() * 0.9, life: 2.2 + rand() * 1.4, grow: 0.6 + rand() * 0.6,
}));
const grid = new Array<string>(cols * rows);
const fog = new Float32Array(cols * rows);
const put = (x: number, y: number, ch: string) => {
const c = Math.round(x), r = Math.round(y);
if (c >= 0 && c < cols && r >= 0 && r < rows) grid[r * cols + c] = ch;
};
const splat = (x: number, y: number, rad: number, s: number) => {
const ry = rad / 2;
for (let r = Math.max(0, Math.ceil(y - ry)); r <= Math.min(rows - 1, Math.floor(y + ry)); r++)
for (let c = Math.max(0, Math.ceil(x - rad)); c <= Math.min(cols - 1, Math.floor(x + rad)); c++) {
const q = ((c - x) / rad) ** 2 + ((r - y) / ry) ** 2;
if (q < 1) fog[r * cols + c] += s * (1 - q);
}
};
// A puff leaves the nozzle going down; where it meets the deck it fans out
// sideways, and either way it slows, swells, rises and thins.
const puff = (p: (typeof puffs)[number], x0: number, y0: number, age: number, thick: number) => {
const down = 26 * 0.2 * (1 - Math.exp(-age / 0.2));
let x = x0 + p.jx * (1 + age), y = y0 + down;
if (y > pad) {
const hit = -0.2 * Math.log(1 - (pad - y0) / (26 * 0.2));
y = pad - 0.2;
x = x0 + p.jx + p.side * p.fan * 0.7 * (1 - Math.exp(-(age - hit) / 0.7));
}
y -= p.rise * age * age * 0.5 + 0.3 * age;
const fade = Math.max(0, 1 - age / p.life);
splat(x + 0.8 * age, y, 1.2 + p.grow * age * 2.2, 0.9 * thick * fade * fade);
};
return (time, { paper = false } = {}) => {
const t = ((time % LOOP) + LOOP) % LOOP;
grid.fill(" ");
fog.fill(0);
for (const [c, r, ph, big] of stars) {
const tw = Math.sin(time * 1.7 + ph);
put(c, r, big ? (tw > 0.3 ? "+" : "*") : tw > -0.6 ? "." : " ");
}
// Tower, its beacon and the service arm.
for (let r = pad - 20; r <= pad; r++) {
put(tower, r, "|");
put(tower + 2, r, "|");
put(tower + 1, r, r % 2 ? "/" : "\\");
}
put(tower + 1, pad - 21, "|");
put(tower + 1, pad - 22, Math.floor(time * 1.25) % 2 ? "*" : ".");
const swing = t < ARM ? 1 : t < IGNITE ? 1 - (t - ARM) / (IGNITE - ARM) : t < LAND + 1 ? 0 : Math.min(1, (t - LAND - 1) / 0.6);
for (let i = 0; i < Math.round(swing * (left + 1 - tower)); i++) put(tower + 3 + i, armRow, "=");
// The deck, with the flame trench under the engine.
for (let c = 0; c < cols; c++) put(c, rows - 1, "▀");
for (let c = 2; c < cols - 2; c++) put(c, pad, Math.abs(c - mid) < 4 ? " " : "_");
// Exhaust, from every puff still alive. Births are on a fixed beat, so a
// puff's age and where it was born follow from t alone.
for (let k = 0; k < puffs.length; k++) {
const born = k / RATE, age = t - born;
if (age < 0 || age > puffs[k].life || !burning(born)) continue;
const h = height(born);
// Coming down it flies into its own plume, so only the last rows of the burn leave smoke.
if (pad - 1 - h >= -2) puff(puffs[k], mid, pad - 0.5 - h, age, Math.exp(-h / (born > LAND - BURN ? 4 : 9)));
}
for (let k = 0; k < cols * rows; k++)
if (fog[k] > 0.06) grid[k] = SMOKE[1 + Math.min(SMOKE.length - 2, Math.floor((1 - Math.exp(-fog[k] / 0.9)) * (SMOKE.length - 1)))];
// The two stands the fins rest on.
for (let r = pad - 3; r < pad; r++) {
put(left, r, "[");
put(left + W - 1, r, "]");
}
const h = height(t);
if (h >= 0) {
const base = pad - 1 - Math.round(h);
const art = sprite[paper ? 1 : 0];
for (let r = 0; r < HT; r++)
for (let c = span[r][0]; c < span[r][1]; c++) put(left + c, base - HT + 1 + r, art[r][c]);
// While it waits on the pad, liquid oxygen boils off through a vent on
// the upper stage in a thin wisp that drifts away on the wind.
for (let k = 23; k >= 0; k--) {
const born = (Math.floor(time / 0.1) - k) * 0.1, age = time - born;
if (!onPad(((born % LOOP) + LOOP) % LOOP) || age > 2.4) continue;
const f = age / 2.4, gust = 1 + 0.3 * Math.sin(0.9 * born);
const y = base - HT + 9 - 0.4 * age - 0.2 * age * age + 1.1 * Math.sin(2.3 * born) * f;
put(left + W - 4 + gust * (3 * age + 0.6 * age * age), y, f < 0.2 ? "~" : f < 0.45 ? "-" : f < 0.75 ? "." : "·");
}
if (burning(t)) {
// Short at ignition, long in the climb, shorter braking.
const len = t < LIFT ? 1 + 2.5 * ((t - IGNITE) / (LIFT - IGNITE)) : t < LAND - BURN ? 6 : 4.5;
const f = Math.floor(time * 20);
for (let k = 1; k <= len + ((f * 7) % 3) - 1; k++) {
const heat = 1 - (k - 1) / (len + 1);
const half = 0.4 + 2.8 * heat ** 0.6;
for (let c = Math.ceil(mid - half); c <= Math.floor(mid + half); c++) {
const edge = Math.abs(c - mid) / (half + 0.5);
const i = Math.floor((heat * 0.8 + 0.35 - edge * 0.5 + (((f + c * 3 + k) * 13) % 5) * 0.03) * FLAME.length);
if (i >= 0) put(c, base + k, FLAME[Math.min(FLAME.length - 1, i)]);
}
}
}
}
const lines: string[] = [];
for (let r = 0; r < rows; r++) lines.push(grid.slice(r * cols, (r + 1) * cols).join(""));
return lines.join("\n");
};
}