From 1bd16288dc67798462a7c90faa42742847345582 Mon Sep 17 00:00:00 2001 From: Patrick Desjardins Date: Thu, 8 Oct 2026 13:56:19 -0700 Subject: [PATCH 1/5] Earthrise: the Earth over the moon, turning faster with the work --- .claude-plugin/plugin.json | 5 +- README.md | 9 +- hooks/earthrise.ts | 725 +++++++++++++++++++++++++++++++++++++ hooks/styles.ts | 4 +- 4 files changed, 736 insertions(+), 7 deletions(-) create mode 100644 hooks/earthrise.ts diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d637eb7..c5f8467 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -36,7 +36,7 @@ "style": { "type": "string", "title": "Scene", - "description": "fire: a ░▒▓█ fire with sparks and smoke; warp: stars that speed up to hyperspace; avalon: a colony ship whose shield burns up the asteroids that hit it; balloon: a hot-air balloon that climbs from the grass to space; engine: a steampunk engine that runs up to full speed; falcon: a Falcon 9 carrying Dragon: its booster lands on its legs, Dragon comes home under parachutes to a splashdown; starship: a bigger rocket that hot-stages: its booster is caught by the tower's arms, the Ship splashes down; surf: a surfer and the swell; ski: a skier down the mountain; bubbles: bubbles fizzing up; train: a train through the countryside, faster with the work", + "description": "fire: a ░▒▓█ fire with sparks and smoke; warp: stars that speed up to hyperspace; avalon: a colony ship whose shield burns up the asteroids that hit it; balloon: a hot-air balloon that climbs from the grass to space; engine: a steampunk engine that runs up to full speed; falcon: a Falcon 9 carrying Dragon: its booster lands on its legs, Dragon comes home under parachutes to a splashdown; starship: a bigger rocket that hot-stages: its booster is caught by the tower's arms, the Ship splashes down; surf: a surfer and the swell; ski: a skier down the mountain; bubbles: bubbles fizzing up; train: a train through the countryside, faster with the work; earthrise: the Earth over the moon, turning faster with the work", "default": "fire", "options": [ "fire", @@ -49,7 +49,8 @@ "surf", "ski", "bubbles", - "train" + "train", + "earthrise" ] }, "idle": { diff --git a/README.md b/README.md index 7e98ae8..4e084b5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Flow's fire above the prompt: pilot lights at idle, climbing with each of Claude's tool calls, an inferno with three subagents, then back down when Claude is done -Ambient scenes in your terminal that move with the work your coding agent is doing: a fire, fizzing bubbles, the surf, a ski run, two rockets, a hot-air balloon, a steam engine, a train through the countryside, and a colony ship behind its shield. They run above the prompt of Claude Code (in the terminal or the Claude desktop app) or [pi](https://github.com/badlogic/pi-mono), or in a tall pane beside Claude Code's transcript. +Ambient scenes in your terminal that move with the work your coding agent is doing: a fire, fizzing bubbles, the surf, a ski run, two rockets, a hot-air balloon, a steam engine, a train through the countryside, a colony ship behind its shield, and the Earth rising over the moon. They run above the prompt of Claude Code (in the terminal or the Claude desktop app) or [pi](https://github.com/badlogic/pi-mono), or in a tall pane beside Claude Code's transcript. Each scene is drawn in 24-bit color from Unicode block, quadrant and braille characters, so several pixels share each terminal cell. In the Claude desktop app, which has no character grid to draw into, the same frames are drawn as images. @@ -69,6 +69,7 @@ Every scene has a level from 0 (off) to 10 (as busy as it gets). | `ski` | an easy run | a steep mogul run at speed | | `bubbles` | a couple of lazy strings of bubbles | a rolling, fizzing boil | | `train` | waiting at a red signal or a platform, its diesel idling (when the work starts the signal clears, the horn sounds and it pulls away; when it winds down the train pulls up again) | flat out past fields, villages, woods, rivers and stations, the poles and the line beside it a blur | +| `earthrise` | the Earth over a cratered lunar horizon in long, low sunlight, turning once in about two minutes (after [earthrise](https://ascii.rest/earthrise/) by @bas3line on ascii.rest) | the Earth spinning once every four seconds, its clouds running ahead | `/flow pick` shows them all at once, each one live, to choose from with the arrow keys and Enter (in pi, a list). @@ -88,9 +89,9 @@ Balloon, falcon, starship, surf, ski and train also have a **night** version: st | running commands | sparks; a slow command keeps it ticking over | | reading or searching, or using any other tool (an MCP server's too) | a small spark each | | running subagents | busier with each one (with diminishing returns), and each one gets a companion of its own (see below); the rest add company: more stars, more lit windows, more streams of bubbles, more lights on the launch tower | -| hitting a failed command, or compacting | smoke for a moment: smoky flame tips, sooty steam, a grey sky, a wipeout, a rocket's plume sputtering grey, black smoke pouring from the train's diesel | -| near a full context (≥85%) | blue: a blue-white flame, a storm, dusk on the slopes, a blue gas flame, rain driving past the train | -| waiting on you: a permission to grant, its question, a plan to approve | settles to a calm 2 and holds, the whole scene turned a warm sepia that breathes slowly, about every 4 s: the fire banks to a bed of glowing coals, the stars stop, the colony ship comes to rest, the balloon hovers with its pilot light glowing, the engine runs down and lets off steam, a rocket holds its stage with amber lights up the tower, the surfer sits up waiting for a wave, the skier stops, the bubbles slow, the train draws up at a red signal or a platform, its headlight and the signal glowing with each breath. With sound on, a soft chime as the wait begins | +| hitting a failed command, or compacting | smoke for a moment: smoky flame tips, sooty steam, a grey sky, a wipeout, a rocket's plume sputtering grey, black smoke pouring from the train's diesel, a pall of moondust over the lunar horizon | +| near a full context (≥85%) | blue: a blue-white flame, a storm, dusk on the slopes, a blue gas flame, rain driving past the train, cold blue light over the moon | +| waiting on you: a permission to grant, its question, a plan to approve | settles to a calm 2 and holds, the whole scene turned a warm sepia that breathes slowly, about every 4 s: the fire banks to a bed of glowing coals, the stars stop, the colony ship comes to rest, the balloon hovers with its pilot light glowing, the engine runs down and lets off steam, a rocket holds its stage with amber lights up the tower, the surfer sits up waiting for a wave, the skier stops, the bubbles slow, the train draws up at a red signal or a platform, its headlight and the signal glowing with each breath, the Earth stops turning. With sound on, a soft chime as the wait begins | A plain answer sits around 5, edit-and-test loops reach about 8, and 10 takes several subagents editing in parallel, or a long turn: it climbs a level every 30 s it keeps going. diff --git a/hooks/earthrise.ts b/hooks/earthrise.ts new file mode 100644 index 0000000..421727f --- /dev/null +++ b/hooks/earthrise.ts @@ -0,0 +1,725 @@ +// REVISION: flow-v1-earthrise +// +// Earthrise (the `earthrise` scene): the Earth coming up over a cratered +// lunar horizon in long, low sunlight, after "earthrise" by @bas3line on +// ascii.rest (https://ascii.rest/earthrise/, MIT). The level is how fast the +// Earth turns: a slow two minutes a turn at 1, a few seconds at 10, its +// clouds running a little ahead. It climbs and settles very slowly whatever +// the level, and a few stars breathe. +// +// The ground is the original's heightfield of craters and boulders, seen +// from a camera standing on it and marched column by column, with real +// shadows marched toward the sun. It is built once a size (`resize`); each +// frame only the Earth (a lit globe with its own map of land, sea, ice and +// cloud, spun) and the stars are drawn again. Everything is laid out in the +// original's own coordinates (its 200 × 100 cells, square) and sampled into +// the pixel layer: the band sees a wide strip across the horizon (squeezed +// to fit a wide terminal), the spine a tall view, the Earth high in it. +// +// Smoke raises a grey pall of moondust over the horizon and greys +// everything; a full context turns the light cold and blue. While Claude +// waits on the person the Earth stops turning and holds (PixelScene breathes +// the frame in sepia). + +import { FRAME_MS } from './activity' +import { CLEAR, PixelScene, type Dials, type Painter } from './pixel-scene' +import { clamp, grey, hash, hash1, luma, mix, rampAt, smoothstep as smooth } from './pixels' +import { defineScene } from './scene-def' + +// --- the world, in the original's units -------------------------------------- + +/** Screen row of eye level in the original's 200 × 100 view; the horizon dips below it. */ +const EYE = 37 +/** Focal length, in the original's cells. */ +const F = 112 +const CAM_H = 20 +/** The moon's radius in ground units, for the falling horizon. */ +const RM = 1500 +const ZMAX = 520 +/** The Earth in the original's view: centre and radius (its lower edge still behind the horizon). */ +const EC_X = 141 +const EC_Y = 32 +const ER = 22 +/** Which face of the globe is turned to us at the start. */ +const LON0 = 3.5 +/** It climbs RISE rows and settles back over RISE_S seconds. */ +const RISE = 5 +const RISE_S = 150 +/** Toward the sun: low, from the right and a little behind us (z is forward). */ +const SUN = (() => { + const v = [0.94, 0.14, -0.3] + const l = Math.hypot(...v) + return v.map(c => c / l) as [number, number, number] +})() + +/** Turn rate (radians a second) at level 1 and at 10, eased between on a log scale. */ +const SPIN_CALM = 0.05 +const SPIN_BUSY = 1.6 +/** Clouds run this much ahead of the ground as it turns. */ +const CLOUD_AHEAD = 0.27 + +/** + * The band: BAND_S of the original's cells to a pixel across (twice that + * down), its skyline (where most of it falls) BAND_SKY of the way down, + * and the Earth BAND_R pixels high from its centre, rising where the + * skyline dips lowest between BAND_FROM and BAND_TO of the way across, its + * lower BAND_HID behind the horizon (or more, so its top stays in the band). + */ +const BAND_S = 2.4 +const BAND_SKY = 0.6 +const BAND_R = 3.9 +const BAND_FROM = 0.62 +const BAND_TO = 0.86 +const BAND_HID = 0.2 +/** The widest view, in radians: a wider band is squeezed into it rather than looking round behind. */ +const SPAN = 2.8 +/** The spine: the Earth this much of the width, its centre this far down. */ +const EARTH_W = 0.74 +const EARTH_DOWN = 0.42 + +/** The ground's greys, dark to light (the original's palette). */ +const GROUND = [0x0b0b0d, 0x18181b, 0x232326, 0x303033, 0x414143, 0x555556, 0x6b6a69, 0x83817d, 0x9c9993, 0xb6b2aa, 0xcfcac1, 0xe6e1d8, 0xf7f4ee] as const +/** Steps the ground's brightness is held to: Raster paints at most 1024 colour pairs a frame. */ +const GROUND_STEPS = 28 +/** A pall of moondust (smoke). */ +const DUST = 0x77746f +/** At most this many ground samples down each pixel, and the Earth's across and down (fewer where a pixel is small). */ +const GROUND_SUB = 3 +const EARTH_SUB_X = 2 +const EARTH_SUB_Y = 3 +/** Toward the sun, for the Earth alone: a little more behind us than the ground's, so the globe shows a fuller face. */ +const EARTH_SUN = (() => { + const v = [0.82, 0.16, -0.75] + const l = Math.hypot(...v) + return v.map(c => c / l) as [number, number, number] +})() + +// --- noise ------------------------------------------------------------------- + +/** Value noise that can wrap in x every `period` (0: never), as the globe's maps need. */ +function noise(x: number, y: number, period: number): number { + const xi = Math.floor(x) + const yi = Math.floor(y) + const fx = x - xi + const fy = y - yi + const u = fx * fx * (3 - 2 * fx) + const v = fy * fy * (3 - 2 * fy) + let x0 = xi + let x1 = xi + 1 + if (period) { + x0 = ((xi % period) + period) % period + x1 = (x0 + 1) % period + } + const a = hash(x0, yi) + const b = hash(x1, yi) + const c = hash(x0, yi + 1) + const d = hash(x1, yi + 1) + return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v +} + +function fbm(x: number, y: number, octaves: number, period: number): number { + let s = 0 + let n = 0 + let amp = 0.5 + let f = 1 + for (let i = 0; i < octaves; i++) { + s += amp * noise(x * f, y * f, period * f) + n += amp + amp *= 0.5 + f *= 2 + } + return s / n +} + +// --- the ground -------------------------------------------------------------- + +/** A bowl with a raised rim, r in ground units, q its distance in radii. */ +const bowl = (q: number, r: number): number => r * ((q < 1 ? -0.36 * (1 - q * q) : 0) + 0.14 * Math.exp(-(((q - 1) / 0.25) ** 2))) + +/** Craters scattered one to a cell. */ +function craters(X: number, Z: number, cell: number, salt: number, r0: number, r1: number, p: number): number { + const ci = Math.floor(X / cell) + const cj = Math.floor(Z / cell) + let h = 0 + for (let j = cj - 1; j <= cj + 1; j++) { + for (let i = ci - 1; i <= ci + 1; i++) { + if (hash(i + salt, j - salt) > p) continue + const cx = (i + hash(i, j + salt * 3)) * cell + const cz = (j + hash(i + salt * 5, j)) * cell + const e = hash(j + salt, i - salt * 7) + const r = cell * (r0 + (r1 - r0) * e * e) + const dx = X - cx + const dz = Z - cz + const d2 = dx * dx + dz * dz + if (d2 > 4 * r * r) continue + h += bowl(Math.sqrt(d2) / r, r) * 0.95 + } + } + return h +} + +/** A few big boulders in the near ground: [x, z, radius]. */ +const ROCKS = [[30, 50, 2.4], [62, 63, 1.8], [12, 70, 1.3], [-4, 45, 1.2], [90, 55, 1.6], [46, 90, 1.4]] as const + +function height(X: number, Z: number): number { + let h = 6 * fbm(X * 0.006 + 50, Z * 0.006 + 50, 3, 0) + 0.6 * fbm(X * 0.04, Z * 0.04, 2, 0) + // Old, worn highlands at the edge of sight, rising to the left, and a lower ridge in front of them. + if (Z > 150) { + const lift = smooth(150, 300, Z) + const m = 1 - Math.abs(2 * fbm(X * 0.006 + 7, Z * 0.006, 4, 0) - 1) + h += lift * (28 * Math.exp(-(((X + 260) / 230) ** 2)) + 45 * (m * m - 0.35)) + const ridge = Math.exp(-(((Z - 205) / 20) ** 2)) * Math.exp(-(((X + 140) / 130) ** 2)) + h += ridge * 22 * (0.35 + fbm(X * 0.018 + 3, Z * 0.01, 3, 0)) + } + // Big basins only out in the middle distance, so we do not stand in one. + if (Z > 90) h += craters(X, Z, 110, 11, 0.14, 0.34, 0.55) * smooth(90, 150, Z) + if (Z < 300) h += craters(X, Z, 34, 23, 0.12, 0.36, 0.85) + if (Z < 170) h += craters(X, Z, 10, 37, 0.12, 0.34, 0.85) * smooth(170, 110, Z) + // One big crater in the near ground, off to the left. + { + const dx = X + 24 + const dz = Z - 58 + const q = Math.sqrt(dx * dx + dz * dz) / 16 + if (q < 2) h += bowl(q, 16) + } + // Boulders strewn close by, and a few big ones. + if (Z < 90) { + const c = 5 + const ci = Math.floor(X / c) + const cj = Math.floor(Z / c) + if (hash(ci + 91, cj) < 0.14) { + const bx = (ci + 0.2 + 0.6 * hash(ci, cj + 92)) * c + const bz = (cj + 0.2 + 0.6 * hash(ci + 93, cj)) * c + const br = 0.3 + 0.5 * hash(ci + 94, cj + 95) ** 2 + const d2 = ((X - bx) ** 2 + (Z - bz) ** 2) / (br * br) + if (d2 < 4) h += br * 0.9 * Math.exp(-d2 * 1.4) + } + for (const [bx, bz, br] of ROCKS) { + const d2 = ((X - bx) ** 2 + (Z - bz) ** 2) / (br * br) + if (d2 < 1) h += br * (0.85 + 0.3 * noise(X * 1.3, Z * 1.3, 0)) * Math.sqrt(1 - d2) + } + } + return h +} + +/** The camera's height. */ +const camY = (() => { + let y: number | undefined + return () => (y ??= height(0, 7) + CAM_H) +})() + +/** The row (in the original's view) the skyline falls at, looking `ang` radians right of ahead. */ +function skyline(ang: number): number { + const sx = Math.sin(ang) + const cz = Math.cos(ang) + let top = Infinity + for (let D = 20; D < ZMAX; D += 0.03 + D * 0.012) { + const Y = height(sx * D, cz * D) - (D * D) / (2 * RM) + top = Math.min(top, EYE - (F * (Y - camY())) / D) + } + return top +} + +/** How bright the ground is at (X, Z), height Y, from the camera at height camY; `crest` near the far skyline. */ +function groundLight(X: number, Z: number, Y: number, camY: number, crest: number): number { + const e = 0.1 + Z * 0.004 + const hx = (height(X + e, Z) - height(X - e, Z)) / (2 * e) + const hz = (height(X, Z + e) - height(X, Z - e)) / (2 * e) + const nl = Math.hypot(hx, 1, hz) + const lam = (-hx * SUN[0] + SUN[1] - hz * SUN[2]) / nl + // Toward the eye, for the moon's own way of reflecting (Lommel-Seeliger). + const vx = -X + const vy = camY - Y + const vz = -Z + const vl = Math.hypot(vx, vy, vz) + const mu = Math.max(0.02, (-hx * vx + vy - hz * vz) / (nl * vl)) + let lit = 0 + if (lam > 0) { + // March toward the sun; a soft edge for the sun's own width. + lit = 1 + let s = 0.1 + Z * 0.003 + while (s < 120) { + const d = Y + SUN[1] * s - height(X + SUN[0] * s, Z + SUN[2] * s) + if (d < 0) { + lit = 0 + break + } + lit = Math.min(lit, (d * 30) / s) + s += 0.05 + s * 0.2 + } + lit = smooth(0, 1, lit) + } + // Regolith: patchy, the maria darker. + const albedo = + 0.5 + + 0.9 * fbm(X * 0.025 + 3, Z * 0.025, 3, 0) + + 0.3 * (fbm(X * 0.35, Z * 0.35, 2, 0) - 0.5) - + 0.22 * smooth(0.46, 0.64, fbm(X * 0.0035, Z * 0.0035 + 20, 3, 0)) + const ls = lam > 0 ? ((0.2 * lam) / (lam + mu) + 2.6 * lam) * lit : 0 + let b = (1 - Math.exp(-ls * 1.5)) * albedo + // The far crest catches the sun along its whole length. + if (crest < 2 && lit > 0.2) b = Math.max(b, (crest < 1 ? 0.85 : 0.55) * albedo) + return b +} + +// --- the Earth's maps -------------------------------------------------------- + +/** Equirectangular maps, wrapping in longitude: surface colour, open sea, cloud. */ +const TW = 192 +const TH = 96 +type Maps = { r: Float32Array; g: Float32Array; b: Float32Array; sea: Float32Array; cloud: Float32Array } +let maps: Maps | undefined + +/** The globe's maps: made once, on first use, and shared. */ +function earthMaps(): Maps { + if (maps) return maps + const n = TW * TH + const tr = new Float32Array(n) + const tg = new Float32Array(n) + const tb = new Float32Array(n) + const sea = new Float32Array(n) + const cloud = new Float32Array(n) + const elev = new Float32Array(n) + const storms = [[0.9, 0.8, 1], [3.1, -0.85, -1], [4.6, 0.62, 1], [1.9, 0.25, 1], [5.6, -0.55, -1]] as const + for (let j = 0; j < TH; j++) { + const v = (j + 0.5) / TH + const lat = (0.5 - v) * Math.PI + const al = Math.abs(lat) + for (let i = 0; i < TW; i++) { + const u = i / TW + const lon = u * Math.PI * 2 + const t = j * TW + i + const wx = fbm(u * 6, v * 3 + 9, 3, 6) + elev[t] = fbm(u * 8 + 1.6 * wx, v * 4, 5, 8) - 0.04 * smooth(1.2, 1.5, al) + // Clouds: warped noise, wound into spirals around a few storms. + let cx = u * 14 + let cy = v * 7 + for (const [slon, slat, spin] of storms) { + let dl = lon - slon + dl -= Math.round(dl / (Math.PI * 2)) * Math.PI * 2 + const lx = dl * Math.cos(lat) + const ly = lat - slat + const a = spin * 5 * Math.exp(-Math.hypot(lx, ly) / 0.2) + if (a * spin > 0.02) { + const ca = Math.cos(a) + const sa = Math.sin(a) + cx += ((lx * ca - ly * sa - lx) / (Math.PI * 2)) * 14 + cy -= ((lx * sa + ly * ca - ly) / Math.PI) * 7 + } + } + // Streaked along the winds, folded into filaments the way fronts string out. + const q = fbm(cx * 0.5 + 3, cy * 1.2, 3, 7) + const n0 = fbm(cx + 1.6 * q, cy * 1.3 + 0.5 * q, 5, 14) + const nn = 0.4 * n0 + 0.6 * (1 - Math.abs(2 * fbm(cx * 1.5 + 2.2 * q, cy * 1.6 + 9, 4, 21) - 1)) + // Cloudy at the equator and in the storm belts, clearer in the subtropics. + const belt = 0.05 * Math.exp(-((lat / 0.12) ** 2)) - 0.07 * Math.exp(-(((al - 0.42) / 0.16) ** 2)) + 0.05 * Math.exp(-(((al - 0.95) / 0.25) ** 2)) + cloud[t] = nn + belt + } + } + // Thresholds by share of the globe: about three tenths land, a third cloud. + const quantile = (A: Float32Array, p: number): number => Float32Array.from(A).sort()[Math.floor(p * (A.length - 1))]! + const shore = quantile(elev, 0.7) + const c0 = quantile(cloud, 0.6) + const c1 = quantile(cloud, 0.86) + for (let j = 0; j < TH; j++) { + const v = (j + 0.5) / TH + const al = Math.abs((0.5 - v) * Math.PI) + for (let i = 0; i < TW; i++) { + const u = i / TW + const t = j * TW + i + const e = elev[t]! + const land = smooth(shore - 0.004, shore + 0.006, e) + const ice = smooth(1.22, 1.32, al + 0.12 * fbm(u * 12, v * 6, 2, 12)) + // Desert in the subtropics and on high ground, forest and scrub elsewhere, broken up finely. + const grain = fbm(u * 36 + 2, v * 18, 3, 36) - 0.5 + const arid = clamp(Math.exp(-(((al - 0.4) / 0.22) ** 2)) * (0.1 + 1.2 * fbm(u * 10 + 4, v * 5, 3, 10)) + (e - shore - 0.04) * 4 + 0.8 * grain) + const relief = 1 + 1.2 * grain - 2.5 * Math.max(0, e - shore - 0.08) + let r = lerp(0.13, 0.4, arid) * relief + let g = lerp(0.25, 0.34, arid) * relief + let b = lerp(0.08, 0.19, arid) * relief + // Ocean, lighter over the shelves near the coasts. + const shelf = smooth(shore - 0.06, shore, e) + r = lerp(lerp(0.025, 0.07, shelf), r, land) + g = lerp(lerp(0.11, 0.3, shelf), g, land) + b = lerp(lerp(0.38, 0.62, shelf), b, land) + tr[t] = lerp(r, 0.92, ice) + tg[t] = lerp(g, 0.95, ice) + tb[t] = lerp(b, 0.99, ice) + sea[t] = (1 - land) * (1 - ice) + cloud[t] = smooth(c0, c1, cloud[t]!) + } + } + maps = { r: tr, g: tg, b: tb, sea, cloud } + return maps +} + +const lerp = (a: number, b: number, k: number): number => a + (b - a) * k + +/** A map at longitude `lon` and map row `vy`, bilinear, wrapping round. */ +function sample(A: Float32Array, lon: number, vy: number): number { + const fx = ((((lon / (Math.PI * 2)) % 1) + 1) % 1) * TW + const x0 = Math.floor(fx) % TW + const ax = fx - Math.floor(fx) + const x1 = (x0 + 1) % TW + const y0 = Math.max(0, Math.min(TH - 2, Math.floor(vy))) + const ay = clamp(vy - y0) + const a = A[y0 * TW + x0]! + const b = A[y0 * TW + x1]! + const c = A[y0 * TW + TW + x0]! + const d = A[y0 * TW + TW + x1]! + return a + (b - a) * ax + (c - a) * ay + (a - b - c + d) * ax * ay +} + +/** Toward the sun in screen space (z toward us), and halfway to the eye, for the sea's glint. */ +const L = [EARTH_SUN[0], EARTH_SUN[1], -EARTH_SUN[2]] as const +const HV = (() => { + const v = [L[0], L[1], L[2] + 1] + const l = Math.hypot(...v) + return v.map(c => c / l) as [number, number, number] +})() +const TILT = 0.4 +const NOD = 0.22 +const CT = Math.cos(TILT) +const ST = Math.sin(TILT) +const CN = Math.cos(NOD) +const SN = Math.sin(NOD) + +/** + * One point of the sky round the Earth, (dx, dy) from its centre over its + * radius `er` (the original's cells, its radius ER): [r, g, b, alpha], into `out`. + */ +function earthAt(dx: number, dy: number, er: number, spin: number, drift: number, out: Float32Array): void { + const m = earthMaps() + // In the original's measure, whatever the size it's drawn at. + dx *= ER / er + dy *= ER / er + let cr = 0 + let cg = 0 + let cb = 0 + let a = 0 + const d = Math.hypot(dx, dy) + // The Earth's air: a thin blue rim on its sunlit side. + if (d >= ER - 1 && d < ER + 12) { + const side = smooth(-0.3, 0.75, (dx * L[0] - dy * L[1]) / d) + let g = Math.exp(-Math.max(0, d - ER) / 1.2) * 0.55 * side + if (g < 0.05) g = 0 + cr += 0.25 * g + cg += 0.52 * g + cb += 1.0 * g + a = Math.max(a, clamp(g * 3)) + } + if (d < ER) { + const nx = dx / ER + const ny = -dy / ER + const q2 = nx * nx + ny * ny + const nz = Math.sqrt(1 - q2) + // Into the globe's own frame: tip the pole toward us, then lean it. + const ax = nx * CT + ny * ST + const ay0 = -nx * ST + ny * CT + const ay = ay0 * CN - nz * SN + const az = ay0 * SN + nz * CN + const lat = Math.asin(clamp(ay, -1, 1)) + const lon = Math.atan2(ax, az) + LON0 + spin + const vy = (0.5 - lat / Math.PI) * TH - 0.5 + const ndl = nx * L[0] + ny * L[1] + nz * L[2] + // Full sun at the right limb, dimming toward the terminator, so the disc reads as a ball. + const day = smooth(-0.005, 0.06, ndl) * (0.45 + 0.8 * Math.sqrt(clamp(ndl))) + const dusk = Math.exp(-(((ndl - 0.02) / 0.035) ** 2)) + const cl = sample(m.cloud, lon + drift, vy) + // The cloud's own shadow, offset away from the sun. + const sh = sample(m.cloud, lon + drift - 0.03, vy + 0.4) + const sw = sample(m.sea, lon, vy) + const shade = 1 - 0.45 * sh * (1 - cl) + let er = lerp(sample(m.r, lon, vy) * shade, 0.95, cl) + let eg = lerp(sample(m.g, lon, vy) * shade, 0.97, cl) + let eb = lerp(sample(m.b, lon, vy) * shade, 1.0, cl) + er *= day * (1 + 0.06 * dusk) + eg *= day * (1 - 0.03 * dusk) + eb *= day * (1 - 0.1 * dusk) + const glint = Math.pow(Math.max(0, nx * HV[0] + ny * HV[1] + nz * HV[2]), 70) * 0.8 * sw * (1 - cl) + const rim = Math.pow(1 - nz, 2.2) * smooth(0, 0.3, ndl) + er += glint * 0.95 + rim * 0.2 + eg += glint * 0.92 + rim * 0.42 + eb += glint * 0.85 + rim * 0.85 + // A crisp edge where the disc meets space; the night side is a void. + const edge = smooth(1, 0.95, Math.sqrt(q2)) + cr = lerp(cr, er, edge) + cg = lerp(cg, eg, edge) + cb = lerp(cb, eb, edge) + a = lerp(a, clamp(Math.max(er, eg, eb) * 12), edge) + } + out[0] = cr + out[1] = cg + out[2] = cb + out[3] = a +} + +/** Light (0..~1.2 a channel) as a colour, a little lifted, held to 5 bits a channel. */ +function lightColor(r: number, g: number, b: number): number { + const c = (v: number) => Math.min(255, Math.round(Math.pow(clamp(v, 0, 1.2) / 1.2, 0.72) * 255 * 1.22)) & 0xf8 + return (c(r) << 16) | (c(g) << 8) | c(b) +} + +// --- the scene --------------------------------------------------------------- + +/** + * The view: the original's (col, row) under pixel (x, y) is (col0 + x·s, + * row0 + y·2s), its columns turned by `k`; the Earth's centre (ex, ey, before + * it rises) and radius `er`, in the same units. + */ +type View = { s: number; col0: number; row0: number; k: number; ex: number; ey: number; er: number } + +export class Earthrise extends PixelScene { + private view: View = { s: 1, col0: 0, row0: 0, k: 1, ex: EC_X, ey: EC_Y, er: ER } + /** Per pixel: the ground's brightness (0..1), or -1 for sky. */ + private ground = new Float32Array(0) + /** Per pixel: the Earth may show here (sky, near it). */ + private near = new Uint8Array(0) + /** How far the Earth has turned, and its clouds (radians): each one made starts on its own face. */ + private spin = hash1(this.seed) * Math.PI * 2 + private drift = 0 + /** Smoke and blue, eased (0..1). */ + private kSmoke = 0 + private kBlue = 0 + private readonly rgba = new Float32Array(4) + + protected resize(d: Dials): void { + const pw = d.columns * 2 + const ph = d.rows * 2 + let v: View + if (!d.tall) { + const s = BAND_S + const col0 = 100 - 0.5 * pw * s + const k = Math.min(1, (SPAN * F) / (pw * s)) + const er = BAND_R * 2 * s + const skyAt = (col: number) => skyline(((col - 100) / F) * k) + // Where the skyline falls, across the band: its middle sets how much ground shows. + const sky: number[] = [] + for (let i = 0; i < 24; i++) sky.push(skyAt(col0 + ((i + 0.5) / 24) * pw * s)) + sky.sort((p, q) => p - q) + const row0 = sky[12]! - BAND_SKY * ph * 2 * s + // The Earth: where the skyline under it dips lowest. + let ex = col0 + BAND_TO * pw * s + let low = -Infinity + for (let i = 0; i <= 12; i++) { + const col = col0 + (BAND_FROM + ((BAND_TO - BAND_FROM) * i) / 12) * pw * s + const under = Math.min(skyAt(col - er * 0.6), skyAt(col), skyAt(col + er * 0.6)) + if (under > low) { + low = under + ex = col + } + } + const ey = Math.max(row0 + er + RISE * (er / ER), low + er * (2 * BAND_HID - 1)) + v = { s, col0, row0, k, ex, ey, er } + } else { + // As wide as the Earth wants of the width, or no more than four tenths of the height. + const s = Math.max((2 * ER) / (EARTH_W * pw), (2 * ER) / (0.4 * 2 * ph)) + v = { s, col0: EC_X - 0.5 * pw * s, row0: EC_Y - EARTH_DOWN * ph * 2 * s, k: 1, ex: EC_X, ey: EC_Y, er: ER } + } + v.k = Math.min(1, (SPAN * F) / (pw * v.s)) + this.view = v + this.buildGround(pw, ph) + } + + /** March every pixel column from near to far, then light what each pixel sees. */ + private buildGround(pw: number, ph: number): void { + const { s, col0, row0, k, ex, ey, er } = this.view + const sub = Math.max(1, Math.min(GROUND_SUB, Math.round(2 * s))) + const n = pw * ph + const ground = new Float32Array(n).fill(-1) + const near = new Uint8Array(n) + const rows = ph * sub + const at = new Float32Array(rows) + for (let i = 0; i < rows; i++) at[i] = row0 + ((i + 0.5) / sub) * 2 * s + const gx = new Float32Array(rows) + const gz = new Float32Array(rows) + const gy = new Float32Array(rows) + const cam = camY() + for (let x = 0; x < pw; x++) { + const col = col0 + (x + 0.5) * s + const ang = ((col - 100) / F) * k + const sx = Math.sin(ang) + const cz = Math.cos(ang) + // The sample rows still sky above the ground found so far: [0, top). + let top = rows + let D = 3 + let prevY = 0 + let prevD = 0 + let prevF = 1e9 + let skyline = Infinity + while (D < ZMAX && top > 0) { + const X = sx * D + const Z = cz * D + const hy = height(X, Z) + const Y = hy - (D * D) / (2 * RM) + const yf = EYE - (F * (Y - cam)) / D + while (top > 0 && at[top - 1]! >= yf) { + const i = --top + // Between this sample and the last, by where its row falls. + const a = prevF > yf + 1e-6 ? clamp((prevF - at[i]!) / (prevF - yf)) : 1 + const dd = prevD ? lerp(prevD, D, a) : D + gx[i] = sx * dd + gz[i] = cz * dd + gy[i] = prevD ? lerp(prevY, hy, a) : hy + } + skyline = Math.min(skyline, yf) + prevF = yf + prevY = hy + prevD = D + D += 0.03 + D * 0.012 + } + // Each pixel: the ground if most of its samples are, lit as their mean. + for (let y = 0; y < ph; y++) { + let sum = 0 + let m = 0 + for (let j = 0; j < sub; j++) { + const i = y * sub + j + if (i < top) continue + sum += groundLight(gx[i]!, gz[i]!, gy[i]!, cam, (at[i]! - skyline) / (2 * s)) + m++ + } + if (m * 2 > sub) ground[y * pw + x] = sum / m + } + } + // Where the Earth and its air can reach, as it rises and settles. + for (let y = 0; y < ph; y++) { + const row = row0 + (y + 0.5) * 2 * s + for (let x = 0; x < pw; x++) { + const col = col0 + (x + 0.5) * s + const i = y * pw + x + const air = (13 * er) / ER + if (ground[i]! < 0 && Math.abs(col - ex) < er + air && row > ey - RISE - er - air - 2 * s && row < ey + er + air) near[i] = 1 + } + } + this.ground = ground + this.near = near + } + + protected update(d: Dials): void { + // The level is how fast it turns; waiting on the person, it stops. + const busy = clamp((d.level - 1) / 9) + const rate = SPIN_CALM * Math.pow(SPIN_BUSY / SPIN_CALM, busy) * (1 - d.wait) + const dt = FRAME_MS / 1000 + this.spin += rate * dt + this.drift += rate * CLOUD_AHEAD * dt + this.kSmoke = clamp(this.kSmoke + clamp((d.tint === 'smoke' ? 1 : 0) - this.kSmoke, -0.05, 0.05)) + this.kBlue = clamp(this.kBlue + clamp((d.tint === 'blue' ? 1 : 0) - this.kBlue, -0.04, 0.04)) + } + + /** A colour as the tints show it now. */ + private tinted(c: number): number { + if (this.kSmoke > 0) c = mix(grey(c, 0.9), 0x8a8682, this.kSmoke * 0.5 * (1 - luma(c) / 255)) + if (this.kBlue > 0) { + const v = luma(c) / 255 + const cold = ((Math.round(v * 40) & 255) << 16) | ((Math.round(14 + v * 100) & 255) << 8) | Math.round(60 + v * 195) + c = mix(c, cold, this.kBlue * 0.85) + } + return c + } + + paint(px: Painter, d: Dials): void { + const { s, col0, row0, ex, er } = this.view + const pw = px.w + const ph = px.h + const tSec = (d.t * FRAME_MS) / 1000 + const ey = this.view.ey - RISE * (er / ER) * (0.5 - 0.5 * Math.cos((tSec / RISE_S) * Math.PI * 2)) + + // The ground, as built. + for (let i = 0; i < pw * ph; i++) { + const b = this.ground[i]! + if (b < 0) continue + const q = Math.round(Math.pow(clamp(b), 0.85) * GROUND_STEPS) / GROUND_STEPS + px.px[i] = this.tinted(rampAt(GROUND, q)) + } + + // Moondust (smoke): a grey pall over the horizon, drifting. + if (this.kSmoke > 0) { + for (let x = 0; x < pw; x++) { + let sky = 0 + while (sky < ph && this.ground[sky * pw + x]! < 0) sky++ + for (let y = 0; y < sky; y++) { + const up = (sky - y) / Math.max(4, ph * 0.5) + const n = fbm(x * 0.08 + tSec * 0.4, y * 0.3, 2, 0) + const k = this.kSmoke * clamp(1.3 - up) * (0.55 + 0.45 * n) + if (k > 0.12) px.px[y * pw + x] = mix(0x1c1b1a, DUST, Math.round(k * 6) / 6) + } + } + } + + // The Earth and its air. + const out = this.rgba + const nx = Math.max(1, Math.min(EARTH_SUB_X, Math.round(s))) + const ny = Math.max(1, Math.min(EARTH_SUB_Y, Math.round(2 * s))) + const sw = s / nx + const shh = (2 * s) / ny + const pall = this.kSmoke + for (let y = 0; y < ph; y++) { + for (let x = 0; x < pw; x++) { + const i = y * pw + x + if (!this.near[i]) continue + let r = 0 + let g = 0 + let b = 0 + let a = 0 + for (let jy = 0; jy < ny; jy++) { + const dy = row0 + y * 2 * s + (jy + 0.5) * shh - ey + for (let jx = 0; jx < nx; jx++) { + const dx = col0 + x * s + (jx + 0.5) * sw - ex + earthAt(dx, dy, er, this.spin, this.drift, out) + r += out[0]! + g += out[1]! + b += out[2]! + a += out[3]! + } + } + const m = nx * ny + a /= m + if (a < 0.3) continue + const c = this.tinted(lightColor(r / m / a, g / m / a, b / m / a)) + // Through the pall the Earth shows dimmer. + const under = px.px[i]! + px.px[i] = under === CLEAR ? c : mix(under, c, 1 - pall * 0.5) + } + } + + // Stars: braille specks in the open sky, thicker along the galaxy, none near the Earth; a few breathe. + const dw = px.dw + const dh = px.dh + const density = 0.011 * (1 + d.boost / 30) + for (let y = 0; y < dh; y++) { + const pyy = y >> 1 + const row = row0 + ((y + 0.5) / 2) * 2 * s + for (let x = 0; x < dw; x++) { + const i = pyy * pw + x + if (this.near[i] || px.px[i] !== CLEAR) continue + const cell = (pyy >> 1) * (pw >> 1) + (x >> 1) + // A cell any of whose pixels are painted keeps clear of specks. + const c0 = (pyy & ~1) * pw + (x & ~1) + if (px.px[c0] !== CLEAR || px.px[c0 + 1] !== CLEAR || px.px[c0 + pw] !== CLEAR || px.px[c0 + pw + 1] !== CLEAR) continue + const col = col0 + (x + 0.5) * s + const bandD = (row - (4 + col * 0.32)) / 1.05 + const band = Math.exp(-((bandD / 10) ** 2)) * smooth(120, 70, col) + const h = hash(x * 3 + 1, y * 7 + 2, 11) + if (h < 1 - density * (1 + 3 * band)) continue + let v = 0.25 + 0.75 * Math.pow(hash(x + 17, y + 29, 5), 3) + // One in eight breathes, each at its own pace. + const tw = hash(x, y, 23) + if (tw > 0.875) v *= 0.55 + 0.45 * Math.sin((tSec / (3 + 3 * hash(x, y, 31))) * Math.PI * 2 + tw * 50) + v *= 1 - 0.6 * this.kSmoke + const tint = hash(x + 5, y + 77, 3) + const [tr, tg, tb] = tint < 0.3 ? [0.84, 0.9, 1] : tint > 0.88 ? [1, 0.93, 0.84] : [0.96, 0.96, 0.98] + const star = this.tinted(lightColor(v * tr, v * tg, v * tb) | 0x101010) + // A cell's specks share one colour: the brightest of them. + const prev = px.dots[cell] ? px.dotColor[cell]! : -1 + px.dot(x, y, star) + if (prev >= 0 && luma(prev) > luma(star)) px.dotColor[cell] = prev + } + } + } +} + +export const earthriseScene = defineScene({ + name: 'earthrise', + blurb: 'the Earth over the moon, turning faster with the work', + make: seed => new Earthrise(seed), +}) diff --git a/hooks/styles.ts b/hooks/styles.ts index 38126df..8192cd4 100644 --- a/hooks/styles.ts +++ b/hooks/styles.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v170-dry-scenes +// REVISION: flow-v172-earthrise // // The scenes, all driven by the same dials (strength 0..10, coverage boost, // the agents one by one, tint, night, waiting): SCENES, the one list of them (each scene file exports its @@ -32,6 +32,7 @@ import { falconScene, starshipScene } from './rocket' import { warpScene } from './starfield' import { bubblesScene } from './bubbles' import { trainScene } from './train' +import { earthriseScene } from './earthrise' /** What shows over a scene: smoke after a failure or a compaction, blue when the context is nearly full. */ @@ -557,6 +558,7 @@ export const SCENES = [ skiScene, bubblesScene, trainScene, + earthriseScene, ] as const export type SceneName = (typeof SCENES)[number]['name'] export const STYLES: readonly SceneName[] = SCENES.map(d => d.name) From ffa70626a40209c83daf8ede0719b3da88356e08 Mon Sep 17 00:00:00 2001 From: Patrick Desjardins Date: Thu, 8 Oct 2026 14:22:04 -0700 Subject: [PATCH 2/5] Earthrise: rises with the level; drawn as a picture in pi where the terminal draws images --- .claude-plugin/plugin.json | 2 +- AGENTS.md | 2 + README.md | 4 +- hooks/earthrise.ts | 575 ++++++++++++++++++++++++++----------- hooks/styles.ts | 14 +- pi/index.ts | 173 ++++++++++- pi/picture.ts | 91 ++++++ 7 files changed, 678 insertions(+), 183 deletions(-) create mode 100644 pi/picture.ts diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c5f8467..aefafb4 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -36,7 +36,7 @@ "style": { "type": "string", "title": "Scene", - "description": "fire: a ░▒▓█ fire with sparks and smoke; warp: stars that speed up to hyperspace; avalon: a colony ship whose shield burns up the asteroids that hit it; balloon: a hot-air balloon that climbs from the grass to space; engine: a steampunk engine that runs up to full speed; falcon: a Falcon 9 carrying Dragon: its booster lands on its legs, Dragon comes home under parachutes to a splashdown; starship: a bigger rocket that hot-stages: its booster is caught by the tower's arms, the Ship splashes down; surf: a surfer and the swell; ski: a skier down the mountain; bubbles: bubbles fizzing up; train: a train through the countryside, faster with the work; earthrise: the Earth over the moon, turning faster with the work", + "description": "fire: a ░▒▓█ fire with sparks and smoke; warp: stars that speed up to hyperspace; avalon: a colony ship whose shield burns up the asteroids that hit it; balloon: a hot-air balloon that climbs from the grass to space; engine: a steampunk engine that runs up to full speed; falcon: a Falcon 9 carrying Dragon: its booster lands on its legs, Dragon comes home under parachutes to a splashdown; starship: a bigger rocket that hot-stages: its booster is caught by the tower's arms, the Ship splashes down; surf: a surfer and the swell; ski: a skier down the mountain; bubbles: bubbles fizzing up; train: a train through the countryside, faster with the work; earthrise: the Earth over the moon, rising and turning faster with the work", "default": "fire", "options": [ "fire", diff --git a/AGENTS.md b/AGENTS.md index 92ddc48..d958bff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,7 @@ hooks/ engine, rocket (falcon, starship), surf, ski, bubbles, train pi/ index.ts pi adapter (widget above the editor, /flow, ~/.pi/agent/flow.json) + picture.ts a scene's `picture` as a kitty image: whether the terminal draws them, its size, the escape sequence session.ts pi's settings for a session: flow.json's defaults and the session's own (pure) ansi.ts mapping.ts types.ts sounds/ the soundscapes' clips (AAC), built by scripts/make-sounds.ts @@ -68,6 +69,7 @@ A scene implements `Scene` (`hooks/styles.ts`). Its file exports a `SceneDef` (` - Most scenes paint a pixel layer at 2 × 2 pixels per cell, then fold it into quadrant glyphs with `fitQuad`. A cell holds only two colors; give `fitQuad` a sprite's pixels as its `keep` so a small figure never drops out of its own cell. - Braille (`BRAILLE`, 2 × 4 dots a cell) is for fine specks: spray, stars, bubbles. - `DEFAULT_COLOR` (0x01000000) is the terminal's own color, i.e. transparent. + - A scene can also draw itself at real pixels (`Scene.picture(w, h, budget)`: RGBA, the open sky transparent), which pi shows as a kitty image in a terminal that draws them (`pi/picture.ts`); earthrise alone does so far. A new size may take a while to make: `picture` makes `budget` of it a call and returns undefined until it's done (pi makes it between frames, showing the cells meanwhile). What took long goes in `pictureCache()` under `pictureKey(w, h)`, and pi keeps it in `~/.cache/flow` (`restorePicture` takes it back): bump the scene's version of it when what it makes changes. - **Limits**: - Claude Code's `Raster` paints at most 1024 distinct (fg, bg) pairs a frame and nearest-maps the rest, so quantize gradients. - Keep `step()` + `grid()` under ~2 ms at 250 × 5 and 22 × 60; frames run at ~14 fps (8 when calm). diff --git a/README.md b/README.md index 4e084b5..e4420a6 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Every scene has a level from 0 (off) to 10 (as busy as it gets). | `ski` | an easy run | a steep mogul run at speed | | `bubbles` | a couple of lazy strings of bubbles | a rolling, fizzing boil | | `train` | waiting at a red signal or a platform, its diesel idling (when the work starts the signal clears, the horn sounds and it pulls away; when it winds down the train pulls up again) | flat out past fields, villages, woods, rivers and stations, the poles and the line beside it a blur | -| `earthrise` | the Earth over a cratered lunar horizon in long, low sunlight, turning once in about two minutes (after [earthrise](https://ascii.rest/earthrise/) by @bas3line on ascii.rest) | the Earth spinning once every four seconds, its clouds running ahead | +| `earthrise` | the Earth half risen over a cratered lunar horizon in long, low sunlight, turning once in about two minutes (after [earthrise](https://ascii.rest/earthrise/) by @bas3line on ascii.rest) | the Earth risen clear of the horizon, spinning once every four seconds, its clouds running ahead | `/flow pick` shows them all at once, each one live, to choose from with the arrow keys and Enter (in pi, a list). @@ -170,6 +170,8 @@ The same scenes run as a pi extension (`pi/index.ts`): a widget above pi's edito | `session_compact` | so does a compaction | | `ctx.getContextUsage()` | a nearly-full context shows as blue | +In a terminal that draws images (Ghostty, kitty, WezTerm, Warp; not under tmux), `earthrise` shows in pi as a picture at the terminal's own pixels rather than in characters, 14 rows tall (never more than half the terminal). Set `"rows"` in `~/.pi/agent/flow.json` for another height, or `"pictures": false` to keep the characters. The first time pi opens at a new width the characters show for a few seconds while the picture's ground is made. Flow keeps the ground of the last few sizes in `~/.cache/flow`, so the picture shows at once the next time. The other scenes stay in characters. + pi tells an extension nothing of a wait on you, so there the scenes never show one. Nor has it a player, so the scenes are silent there: `/flow sound` says so. As in Claude Code, each session keeps its own settings, kept in the session itself (an entry the model never sees), so resuming or forking it brings them back. `~/.pi/agent/flow.json` holds the defaults new sessions start with; `/flow save` writes it. To try it without installing: `pi --extension ./pi/index.ts`. diff --git a/hooks/earthrise.ts b/hooks/earthrise.ts index 421727f..1a8b93e 100644 --- a/hooks/earthrise.ts +++ b/hooks/earthrise.ts @@ -1,11 +1,17 @@ -// REVISION: flow-v1-earthrise +// REVISION: flow-v2-earthrise-picture // // Earthrise (the `earthrise` scene): the Earth coming up over a cratered // lunar horizon in long, low sunlight, after "earthrise" by @bas3line on -// ascii.rest (https://ascii.rest/earthrise/, MIT). The level is how fast the -// Earth turns: a slow two minutes a turn at 1, a few seconds at 10, its -// clouds running a little ahead. It climbs and settles very slowly whatever -// the level, and a few stars breathe. +// ascii.rest (https://ascii.rest/earthrise/, MIT). The level is how high the +// Earth stands and how fast it turns: at 1 it is half behind the horizon, +// turning once in about two minutes; at 10 it stands clear of the horizon, +// turning every few seconds, its clouds running a little ahead. It climbs +// and sinks over several seconds as the work picks up or winds down, and a +// few stars breathe. +// +// Where the terminal draws images (pi in Ghostty, kitty, WezTerm), it is +// also drawn as a picture at real pixels (`picture`): the same world, its +// ground built a few columns a frame for each new size, the sky left clear. // // The ground is the original's heightfield of craters and boulders, seen // from a camera standing on it and marched column by column, with real @@ -18,13 +24,14 @@ // // Smoke raises a grey pall of moondust over the horizon and greys // everything; a full context turns the light cold and blue. While Claude -// waits on the person the Earth stops turning and holds (PixelScene breathes -// the frame in sepia). +// waits on the person the Earth holds where it stands and stops turning +// (PixelScene breathes the frame in sepia; a picture takes the same tone). import { FRAME_MS } from './activity' import { CLEAR, PixelScene, type Dials, type Painter } from './pixel-scene' import { clamp, grey, hash, hash1, luma, mix, rampAt, smoothstep as smooth } from './pixels' import { defineScene } from './scene-def' +import { SEPIA_AMOUNT, waitColor, waitGlow, waitLift } from './waiting' // --- the world, in the original's units -------------------------------------- @@ -42,9 +49,17 @@ const EC_Y = 32 const ER = 22 /** Which face of the globe is turned to us at the start. */ const LON0 = 3.5 -/** It climbs RISE rows and settles back over RISE_S seconds. */ -const RISE = 5 -const RISE_S = 150 +/** + * How much of the Earth is behind the horizon at level 1 and at 10 (below 0: + * clear of it, by that much of its radius), and how far its height moves + * toward the level each frame (a whole climb in about ten seconds). + */ +const HID_CALM = 0.62 +const HID_BUSY = -0.14 +const CLIMB = 0.007 +/** In the spine: the Earth's centre this many radii below the original's at level 1, and above it at 10. */ +const SPINE_LOW = 0.5 +const SPINE_HIGH = 0.35 /** Toward the sun: low, from the right and a little behind us (z is forward). */ const SUN = (() => { const v = [0.94, 0.14, -0.3] @@ -62,15 +77,26 @@ const CLOUD_AHEAD = 0.27 * The band: BAND_S of the original's cells to a pixel across (twice that * down), its skyline (where most of it falls) BAND_SKY of the way down, * and the Earth BAND_R pixels high from its centre, rising where the - * skyline dips lowest between BAND_FROM and BAND_TO of the way across, its - * lower BAND_HID behind the horizon (or more, so its top stays in the band). + * skyline dips lowest between BAND_FROM and BAND_TO of the way across, as + * far behind the horizon as the level puts it (or more, so its top stays in + * the band). */ const BAND_S = 2.4 const BAND_SKY = 0.6 -const BAND_R = 3.9 +const BAND_R = 3.4 const BAND_FROM = 0.62 const BAND_TO = 0.86 -const BAND_HID = 0.2 +/** + * A picture: the Earth PIC_R of its height from its centre, PIC_SIZE of the + * original's size against the ground, the skyline PIC_SKY of the way down, + * the ground's brightness in PIC_STEPS steps. + */ +const PIC_R = 0.28 +const PIC_SIZE = 0.85 +const PIC_SKY = 0.55 +const PIC_STEPS = 255 +/** Bump when the ground changes: a picture's ground kept from before (`pictureCache`) is then made afresh. */ +const GROUND_VERSION = 1 /** The widest view, in radians: a wider band is squeezed into it rather than looking round behind. */ const SPAN = 2.8 /** The spine: the Earth this much of the width, its centre this far down. */ @@ -235,9 +261,10 @@ function groundLight(X: number, Z: number, Y: number, camY: number, crest: numbe const mu = Math.max(0.02, (-hx * vx + vy - hz * vz) / (nl * vl)) let lit = 0 if (lam > 0) { - // March toward the sun; a soft edge for the sun's own width. + // March toward the sun; a soft edge for the sun's own width. Each point starts its steps a little + // differently, so the shadows' edges don't step with the march. lit = 1 - let s = 0.1 + Z * 0.003 + let s = (0.1 + Z * 0.003) * (0.6 + 0.8 * hash(Math.floor(X * 37), Math.floor(Z * 37), 41)) while (s < 120) { const d = Y + SUN[1] * s - height(X + SUN[0] * s, Z + SUN[2] * s) if (d < 0) { @@ -454,150 +481,77 @@ function earthAt(dx: number, dy: number, er: number, spin: number, drift: number out[3] = a } -/** Light (0..~1.2 a channel) as a colour, a little lifted, held to 5 bits a channel. */ -function lightColor(r: number, g: number, b: number): number { - const c = (v: number) => Math.min(255, Math.round(Math.pow(clamp(v, 0, 1.2) / 1.2, 0.72) * 255 * 1.22)) & 0xf8 +/** Light (0..~1.2 a channel) as a colour, a little lifted, held to 5 bits a channel (`bits`: Raster's colour pairs are few). */ +function lightColor(r: number, g: number, b: number, bits = 0xf8): number { + const c = (v: number) => Math.min(255, Math.round(Math.pow(clamp(v, 0, 1.2) / 1.2, 0.72) * 255 * 1.22)) & bits return (c(r) << 16) | (c(g) << 8) | c(b) } // --- the scene --------------------------------------------------------------- /** - * The view: the original's (col, row) under pixel (x, y) is (col0 + x·s, - * row0 + y·2s), its columns turned by `k`; the Earth's centre (ex, ey, before - * it rises) and radius `er`, in the same units. + * One way of seeing the world at one size: the original's (col, row) under + * pixel (x, y) is (col0 + x·s, row0 + y·sy), its columns turned by `k`; the + * Earth's centre `ex` and radius `er` in the same units, its centre `eyLow` + * at level 1 and `eyHigh` at 10. The ground under each pixel (its + * brightness, or -1 for sky) is built `done` columns so far; `near` marks + * the sky the Earth and its air can reach, once it's all built. */ -type View = { s: number; col0: number; row0: number; k: number; ex: number; ey: number; er: number } +type Layout = { + pw: number + ph: number + s: number + sy: number + col0: number + row0: number + k: number + ex: number + eyLow: number + eyHigh: number + er: number + ground: Float32Array + near: Uint8Array + done: number + /** Ground samples down each pixel, and their rows. */ + sub: number + at: Float32Array + /** How many steps the ground's brightness is held to. */ + steps: number +} + +/** The ground's sample buffers, shared (one column at a time). */ +let gx = new Float32Array(0) +let gz = new Float32Array(0) +let gy = new Float32Array(0) + +/** A picture's pixels: its colour (CLEAR for the open sky) and how much it covers (0..1). */ +type Canvas = { w: number; h: number; px: Int32Array; alpha: Float32Array; rgba: Uint8Array } export class Earthrise extends PixelScene { - private view: View = { s: 1, col0: 0, row0: 0, k: 1, ex: EC_X, ey: EC_Y, er: ER } - /** Per pixel: the ground's brightness (0..1), or -1 for sky. */ - private ground = new Float32Array(0) - /** Per pixel: the Earth may show here (sky, near it). */ - private near = new Uint8Array(0) + /** The view the cells see, and a picture's. */ + private cells: Layout = layout(0, 0, 2, false) + private pic: Layout | undefined + private canvas: Canvas | undefined /** How far the Earth has turned, and its clouds (radians): each one made starts on its own face. */ private spin = hash1(this.seed) * Math.PI * 2 private drift = 0 + /** How high the Earth stands, 0 (level 1) .. 1 (level 10), eased; NaN until the first frame. */ + private alt = Number.NaN /** Smoke and blue, eased (0..1). */ private kSmoke = 0 private kBlue = 0 private readonly rgba = new Float32Array(4) protected resize(d: Dials): void { - const pw = d.columns * 2 - const ph = d.rows * 2 - let v: View - if (!d.tall) { - const s = BAND_S - const col0 = 100 - 0.5 * pw * s - const k = Math.min(1, (SPAN * F) / (pw * s)) - const er = BAND_R * 2 * s - const skyAt = (col: number) => skyline(((col - 100) / F) * k) - // Where the skyline falls, across the band: its middle sets how much ground shows. - const sky: number[] = [] - for (let i = 0; i < 24; i++) sky.push(skyAt(col0 + ((i + 0.5) / 24) * pw * s)) - sky.sort((p, q) => p - q) - const row0 = sky[12]! - BAND_SKY * ph * 2 * s - // The Earth: where the skyline under it dips lowest. - let ex = col0 + BAND_TO * pw * s - let low = -Infinity - for (let i = 0; i <= 12; i++) { - const col = col0 + (BAND_FROM + ((BAND_TO - BAND_FROM) * i) / 12) * pw * s - const under = Math.min(skyAt(col - er * 0.6), skyAt(col), skyAt(col + er * 0.6)) - if (under > low) { - low = under - ex = col - } - } - const ey = Math.max(row0 + er + RISE * (er / ER), low + er * (2 * BAND_HID - 1)) - v = { s, col0, row0, k, ex, ey, er } - } else { - // As wide as the Earth wants of the width, or no more than four tenths of the height. - const s = Math.max((2 * ER) / (EARTH_W * pw), (2 * ER) / (0.4 * 2 * ph)) - v = { s, col0: EC_X - 0.5 * pw * s, row0: EC_Y - EARTH_DOWN * ph * 2 * s, k: 1, ex: EC_X, ey: EC_Y, er: ER } - } - v.k = Math.min(1, (SPAN * F) / (pw * v.s)) - this.view = v - this.buildGround(pw, ph) - } - - /** March every pixel column from near to far, then light what each pixel sees. */ - private buildGround(pw: number, ph: number): void { - const { s, col0, row0, k, ex, ey, er } = this.view - const sub = Math.max(1, Math.min(GROUND_SUB, Math.round(2 * s))) - const n = pw * ph - const ground = new Float32Array(n).fill(-1) - const near = new Uint8Array(n) - const rows = ph * sub - const at = new Float32Array(rows) - for (let i = 0; i < rows; i++) at[i] = row0 + ((i + 0.5) / sub) * 2 * s - const gx = new Float32Array(rows) - const gz = new Float32Array(rows) - const gy = new Float32Array(rows) - const cam = camY() - for (let x = 0; x < pw; x++) { - const col = col0 + (x + 0.5) * s - const ang = ((col - 100) / F) * k - const sx = Math.sin(ang) - const cz = Math.cos(ang) - // The sample rows still sky above the ground found so far: [0, top). - let top = rows - let D = 3 - let prevY = 0 - let prevD = 0 - let prevF = 1e9 - let skyline = Infinity - while (D < ZMAX && top > 0) { - const X = sx * D - const Z = cz * D - const hy = height(X, Z) - const Y = hy - (D * D) / (2 * RM) - const yf = EYE - (F * (Y - cam)) / D - while (top > 0 && at[top - 1]! >= yf) { - const i = --top - // Between this sample and the last, by where its row falls. - const a = prevF > yf + 1e-6 ? clamp((prevF - at[i]!) / (prevF - yf)) : 1 - const dd = prevD ? lerp(prevD, D, a) : D - gx[i] = sx * dd - gz[i] = cz * dd - gy[i] = prevD ? lerp(prevY, hy, a) : hy - } - skyline = Math.min(skyline, yf) - prevF = yf - prevY = hy - prevD = D - D += 0.03 + D * 0.012 - } - // Each pixel: the ground if most of its samples are, lit as their mean. - for (let y = 0; y < ph; y++) { - let sum = 0 - let m = 0 - for (let j = 0; j < sub; j++) { - const i = y * sub + j - if (i < top) continue - sum += groundLight(gx[i]!, gz[i]!, gy[i]!, cam, (at[i]! - skyline) / (2 * s)) - m++ - } - if (m * 2 > sub) ground[y * pw + x] = sum / m - } - } - // Where the Earth and its air can reach, as it rises and settles. - for (let y = 0; y < ph; y++) { - const row = row0 + (y + 0.5) * 2 * s - for (let x = 0; x < pw; x++) { - const col = col0 + (x + 0.5) * s - const i = y * pw + x - const air = (13 * er) / ER - if (ground[i]! < 0 && Math.abs(col - ex) < er + air && row > ey - RISE - er - air - 2 * s && row < ey + er + air) near[i] = 1 - } - } - this.ground = ground - this.near = near + this.cells = layout(d.columns * 2, d.rows * 2, 2, d.tall) + build(this.cells, Infinity) } protected update(d: Dials): void { - // The level is how fast it turns; waiting on the person, it stops. + // The level is how high it stands and how fast it turns; waiting on the person, it holds and stops. const busy = clamp((d.level - 1) / 9) + if (Number.isNaN(this.alt)) this.alt = busy + else if (d.wait < 0.5) this.alt += clamp(busy - this.alt, -CLIMB, CLIMB) const rate = SPIN_CALM * Math.pow(SPIN_BUSY / SPIN_CALM, busy) * (1 - d.wait) const dt = FRAME_MS / 1000 this.spin += rate * dt @@ -617,31 +571,41 @@ export class Earthrise extends PixelScene { return c } - paint(px: Painter, d: Dials): void { - const { s, col0, row0, ex, er } = this.view - const pw = px.w - const ph = px.h + /** + * The ground, the moondust and the Earth into `px` (CLEAR left for the open + * sky); `alpha`, for a picture, takes how much of each pixel they cover + * (without it, the faint edge of the Earth's air is left out). + */ + private paintWorld(L: Layout, px: Int32Array, alpha: Float32Array | undefined, d: Dials): void { + const { pw, ph, s, sy, col0, row0, ex, er, steps } = L const tSec = (d.t * FRAME_MS) / 1000 - const ey = this.view.ey - RISE * (er / ER) * (0.5 - 0.5 * Math.cos((tSec / RISE_S) * Math.PI * 2)) + const ey = lerp(L.eyLow, L.eyHigh, Number.isNaN(this.alt) ? clamp((d.level - 1) / 9) : this.alt) + const bits = alpha ? 0xff : 0xf8 // The ground, as built. for (let i = 0; i < pw * ph; i++) { - const b = this.ground[i]! + const b = L.ground[i]! if (b < 0) continue - const q = Math.round(Math.pow(clamp(b), 0.85) * GROUND_STEPS) / GROUND_STEPS - px.px[i] = this.tinted(rampAt(GROUND, q)) + const q = Math.round(Math.pow(clamp(b), 0.85) * steps) / steps + px[i] = this.tinted(rampAt(GROUND, q)) + if (alpha) alpha[i] = 1 } // Moondust (smoke): a grey pall over the horizon, drifting. if (this.kSmoke > 0) { + const dustSteps = alpha ? 24 : 6 for (let x = 0; x < pw; x++) { let sky = 0 - while (sky < ph && this.ground[sky * pw + x]! < 0) sky++ + while (sky < ph && L.ground[sky * pw + x]! < 0) sky++ + const col = col0 + x * s for (let y = 0; y < sky; y++) { const up = (sky - y) / Math.max(4, ph * 0.5) - const n = fbm(x * 0.08 + tSec * 0.4, y * 0.3, 2, 0) + const n = fbm(col * 0.033 + tSec * 0.4, (row0 + y * sy) * 0.0625, 2, 0) const k = this.kSmoke * clamp(1.3 - up) * (0.55 + 0.45 * n) - if (k > 0.12) px.px[y * pw + x] = mix(0x1c1b1a, DUST, Math.round(k * 6) / 6) + if (k <= 0.12) continue + const i = y * pw + x + px[i] = mix(0x1c1b1a, DUST, Math.round(k * dustSteps) / dustSteps) + if (alpha) alpha[i] = Math.min(1, k * 1.6) } } } @@ -649,20 +613,20 @@ export class Earthrise extends PixelScene { // The Earth and its air. const out = this.rgba const nx = Math.max(1, Math.min(EARTH_SUB_X, Math.round(s))) - const ny = Math.max(1, Math.min(EARTH_SUB_Y, Math.round(2 * s))) + const ny = Math.max(1, Math.min(EARTH_SUB_Y, Math.round(sy))) const sw = s / nx - const shh = (2 * s) / ny + const shh = sy / ny const pall = this.kSmoke for (let y = 0; y < ph; y++) { for (let x = 0; x < pw; x++) { const i = y * pw + x - if (!this.near[i]) continue + if (!L.near[i]) continue let r = 0 let g = 0 let b = 0 let a = 0 for (let jy = 0; jy < ny; jy++) { - const dy = row0 + y * 2 * s + (jy + 0.5) * shh - ey + const dy = row0 + y * sy + (jy + 0.5) * shh - ey for (let jx = 0; jx < nx; jx++) { const dx = col0 + x * s + (jx + 0.5) * sw - ex earthAt(dx, dy, er, this.spin, this.drift, out) @@ -674,13 +638,31 @@ export class Earthrise extends PixelScene { } const m = nx * ny a /= m - if (a < 0.3) continue - const c = this.tinted(lightColor(r / m / a, g / m / a, b / m / a)) - // Through the pall the Earth shows dimmer. - const under = px.px[i]! - px.px[i] = under === CLEAR ? c : mix(under, c, 1 - pall * 0.5) + if (a < (alpha ? 0.02 : 0.3)) continue + const c = this.tinted(lightColor(r / m / a, g / m / a, b / m / a, bits)) + const under = px[i]! + if (!alpha) { + // Through the pall the Earth shows dimmer. + px[i] = under === CLEAR ? c : mix(under, c, 1 - pall * 0.5) + continue + } + const k = Math.min(1, a) * (1 - pall * 0.5) + if (under === CLEAR) { + px[i] = c + alpha[i] = Math.min(1, a) + } else { + px[i] = mix(under, c, k / Math.max(k, alpha[i]!) || 1) + alpha[i] = Math.max(alpha[i]!, k) + } } } + } + + paint(px: Painter, d: Dials): void { + const L = this.cells + const { pw, s, sy, col0, row0 } = L + this.paintWorld(L, px.px, undefined, d) + const tSec = (d.t * FRAME_MS) / 1000 // Stars: braille specks in the open sky, thicker along the galaxy, none near the Earth; a few breathe. const dw = px.dw @@ -688,27 +670,17 @@ export class Earthrise extends PixelScene { const density = 0.011 * (1 + d.boost / 30) for (let y = 0; y < dh; y++) { const pyy = y >> 1 - const row = row0 + ((y + 0.5) / 2) * 2 * s + const row = row0 + ((y + 0.5) / 2) * sy for (let x = 0; x < dw; x++) { const i = pyy * pw + x - if (this.near[i] || px.px[i] !== CLEAR) continue + if (L.near[i] || px.px[i] !== CLEAR) continue const cell = (pyy >> 1) * (pw >> 1) + (x >> 1) // A cell any of whose pixels are painted keeps clear of specks. const c0 = (pyy & ~1) * pw + (x & ~1) if (px.px[c0] !== CLEAR || px.px[c0 + 1] !== CLEAR || px.px[c0 + pw] !== CLEAR || px.px[c0 + pw + 1] !== CLEAR) continue - const col = col0 + (x + 0.5) * s - const bandD = (row - (4 + col * 0.32)) / 1.05 - const band = Math.exp(-((bandD / 10) ** 2)) * smooth(120, 70, col) - const h = hash(x * 3 + 1, y * 7 + 2, 11) - if (h < 1 - density * (1 + 3 * band)) continue - let v = 0.25 + 0.75 * Math.pow(hash(x + 17, y + 29, 5), 3) - // One in eight breathes, each at its own pace. - const tw = hash(x, y, 23) - if (tw > 0.875) v *= 0.55 + 0.45 * Math.sin((tSec / (3 + 3 * hash(x, y, 31))) * Math.PI * 2 + tw * 50) - v *= 1 - 0.6 * this.kSmoke - const tint = hash(x + 5, y + 77, 3) - const [tr, tg, tb] = tint < 0.3 ? [0.84, 0.9, 1] : tint > 0.88 ? [1, 0.93, 0.84] : [0.96, 0.96, 0.98] - const star = this.tinted(lightColor(v * tr, v * tg, v * tb) | 0x101010) + const v = this.star(x, y, col0 + (x + 0.5) * s, row, density, tSec) + if (v < 0) continue + const star = this.tinted(v | 0x101010) // A cell's specks share one colour: the brightest of them. const prev = px.dots[cell] ? px.dotColor[cell]! : -1 px.dot(x, y, star) @@ -716,10 +688,265 @@ export class Earthrise extends PixelScene { } } } + + /** A star at speck (x, y), at (col, row) in the original's view: its colour, or -1 for none. */ + private star(x: number, y: number, col: number, row: number, density: number, tSec: number, bits = 0xf8): number { + const bandD = (row - (4 + col * 0.32)) / 1.05 + const band = Math.exp(-((bandD / 10) ** 2)) * smooth(120, 70, col) + const h = hash(x * 3 + 1, y * 7 + 2, 11) + if (h < 1 - density * (1 + 3 * band)) return -1 + let v = 0.25 + 0.75 * Math.pow(hash(x + 17, y + 29, 5), 3) + // One in eight breathes, each at its own pace. + const tw = hash(x, y, 23) + if (tw > 0.875) v *= 0.55 + 0.45 * Math.sin((tSec / (3 + 3 * hash(x, y, 31))) * Math.PI * 2 + tw * 50) + v *= 1 - 0.6 * this.kSmoke + const tint = hash(x + 5, y + 77, 3) + const [tr, tg, tb] = tint < 0.3 ? [0.84, 0.9, 1] : tint > 0.88 ? [1, 0.93, 0.84] : [0.96, 0.96, 0.98] + return lightColor(v * tr, v * tg, v * tb, bits) + } + + /** + * This frame as a picture `w` × `h` pixels (square), RGBA, the open sky + * transparent: for a terminal that draws images. A new size's ground is + * built at most `budget` pixels a call; until it's all built (and at level + * 0) there is no picture: draw `grid()`. + */ + picture(w: number, h: number, budget = Infinity): Uint8Array | undefined { + if (this.strength <= 0 || w < 2 || h < 2) return undefined + let L = this.pic + if (!L || L.pw !== w || L.ph !== h) L = this.pic = layout(w, h, 1, h > w) + if (L.done < w) { + build(L, budget) + if (L.done < w) return undefined + } + let cv = this.canvas + if (!cv || cv.w !== w || cv.h !== h) { + cv = this.canvas = { w, h, px: new Int32Array(w * h), alpha: new Float32Array(w * h), rgba: new Uint8Array(w * h * 4) } + } + const d = this.dials() + const { px, alpha, rgba } = cv + px.fill(CLEAR) + alpha.fill(0) + this.paintWorld(L, px, alpha, d) + + // Stars: a pixel each in the open sky, as thick across the sky as the specks are. + const tSec = (d.t * FRAME_MS) / 1000 + const density = 0.011 * (1 + d.boost / 30) * ((L.s * L.sy) / (BAND_S * BAND_S)) + for (let y = 0; y < h; y++) { + const row = L.row0 + (y + 0.5) * L.sy + for (let x = 0; x < w; x++) { + const i = y * w + x + if (L.near[i] || px[i] !== CLEAR) continue + const v = this.star(x, y, L.col0 + (x + 0.5) * L.s, row, density, tSec, 0xff) + if (v < 0) continue + px[i] = this.tinted(v) + alpha[i] = 1 + } + } + + // Waiting on the person: the same sepia breath as the cells'. + const lift = d.wait > 0 ? waitLift(d.wait, d.t) : 1 + const glow = d.wait > 0 ? waitGlow(d.wait, d.t) : 0 + for (let i = 0, o = 0; i < w * h; i++, o += 4) { + let c = px[i]! + if (c === CLEAR) { + rgba[o + 3] = 0 + continue + } + if (d.wait > 0) c = waitColor(c, d.wait, lift, SEPIA_AMOUNT, glow) + rgba[o] = (c >> 16) & 255 + rgba[o + 1] = (c >> 8) & 255 + rgba[o + 2] = c & 255 + rgba[o + 3] = Math.round(clamp(alpha[i]!) * 255) + } + return rgba + } + + pictureKey(w: number, h: number): string { + return `earthrise-ground-v${GROUND_VERSION}-${w}x${h}` + } + + pictureCache(): { key: string; data: Float32Array } | undefined { + const L = this.pic + return L && L.done >= L.pw ? { key: this.pictureKey(L.pw, L.ph), data: L.ground } : undefined + } + + restorePicture(w: number, h: number, data: Float32Array): boolean { + if (w < 2 || h < 2 || data.length !== w * h) return false + const L = layout(w, h, 1, h > w) + L.ground.set(data) + L.done = w + markNear(L) + this.pic = L + return true + } +} + +/** The view for `pw` × `ph` pixels, each `ry` times as tall as it is wide; its ground not yet built. */ +function layout(pw: number, ph: number, ry: number, tall: boolean): Layout { + let s: number + let col0: number + let row0: number + let ex: number + let er: number + let eyLow: number + let eyHigh: number + if (pw <= 0 || ph <= 0) { + s = 1 + col0 = row0 = 0 + ex = EC_X + er = ER + eyLow = eyHigh = EC_Y + } else if (!tall) { + // A picture's pixels are square and many: the Earth a share of its height. The cells' are BAND_S across. + const pic = ry === 1 + const rpx = pic ? PIC_R * ph : BAND_R + s = pic ? (ER * PIC_SIZE) / (rpx * ry) : BAND_S + const sy = ry * s + er = rpx * sy + col0 = 100 - 0.5 * pw * s + const k = Math.min(1, (SPAN * F) / (pw * s)) + const skyAt = (col: number) => skyline(((col - 100) / F) * k) + // Where the skyline falls, across the band: its middle sets how much ground shows. + const sky: number[] = [] + for (let i = 0; i < 24; i++) sky.push(skyAt(col0 + ((i + 0.5) / 24) * pw * s)) + sky.sort((p, q) => p - q) + row0 = sky[12]! - (pic ? PIC_SKY : BAND_SKY) * ph * sy + // The Earth: where the skyline under it dips lowest. + ex = col0 + BAND_TO * pw * s + let low = -Infinity + for (let i = 0; i <= 12; i++) { + const col = col0 + (BAND_FROM + ((BAND_TO - BAND_FROM) * i) / 12) * pw * s + const under = Math.min(skyAt(col - er * 0.6), skyAt(col), skyAt(col + er * 0.6)) + if (under > low) { + low = under + ex = col + } + } + // Its top kept in the band, a little clear of the edge. + const top = row0 + er + Math.max(0.4 * sy, 0.08 * er) + eyLow = Math.max(top, low + er * (2 * HID_CALM - 1)) + eyHigh = Math.max(top, low + er * (2 * HID_BUSY - 1)) + } else { + // As wide as the Earth wants of the width, or no more than four tenths of the height. + s = Math.max((2 * ER) / (EARTH_W * pw), (2 * ER) / (0.4 * ry * ph)) + col0 = EC_X - 0.5 * pw * s + row0 = EC_Y - EARTH_DOWN * ph * ry * s + ex = EC_X + er = ER + eyLow = EC_Y + SPINE_LOW * ER + eyHigh = EC_Y - SPINE_HIGH * ER + } + const sy = ry * s + const sub = Math.max(1, Math.min(GROUND_SUB, Math.round(sy))) + const rows = ph * sub + const at = new Float32Array(rows) + for (let i = 0; i < rows; i++) at[i] = row0 + ((i + 0.5) / sub) * sy + return { + pw, + ph, + s, + sy, + col0, + row0, + k: Math.min(1, (SPAN * F) / (Math.max(1, pw) * s)), + ex, + eyLow, + eyHigh, + er, + ground: new Float32Array(pw * ph).fill(-1), + near: new Uint8Array(pw * ph), + done: 0, + sub, + at, + steps: ry === 1 ? PIC_STEPS : GROUND_STEPS, + } +} + +/** + * Build `L`'s ground on from where it got to, about `budget` pixels' worth: + * march each pixel column from near to far, then light what each pixel sees. + * Once the last column is in, mark where the Earth can reach. + */ +function build(L: Layout, budget: number): void { + const { pw, ph, s, sy, col0, row0, k, ex, er, sub, at, ground } = L + if (L.done >= pw) return + const rows = ph * sub + if (gx.length < rows) { + gx = new Float32Array(rows) + gz = new Float32Array(rows) + gy = new Float32Array(rows) + } + const cam = camY() + let spent = 0 + while (L.done < pw && spent < budget) { + const x = L.done++ + const col = col0 + (x + 0.5) * s + const ang = ((col - 100) / F) * k + const sx = Math.sin(ang) + const cz = Math.cos(ang) + // The sample rows still sky above the ground found so far: [0, top). + let top = rows + let D = 3 + let prevY = 0 + let prevD = 0 + let prevF = 1e9 + let skyline = Infinity + while (D < ZMAX && top > 0) { + const X = sx * D + const Z = cz * D + const hy = height(X, Z) + const Y = hy - (D * D) / (2 * RM) + const yf = EYE - (F * (Y - cam)) / D + while (top > 0 && at[top - 1]! >= yf) { + const i = --top + // Between this sample and the last, by where its row falls. + const a = prevF > yf + 1e-6 ? clamp((prevF - at[i]!) / (prevF - yf)) : 1 + const dd = prevD ? lerp(prevD, D, a) : D + gx[i] = sx * dd + gz[i] = cz * dd + gy[i] = prevD ? lerp(prevY, hy, a) : hy + } + skyline = Math.min(skyline, yf) + prevF = yf + prevY = hy + prevD = D + D += 0.03 + D * 0.012 + } + // Each pixel: the ground if most of its samples are, lit as their mean. + for (let y = 0; y < ph; y++) { + let sum = 0 + let m = 0 + for (let j = 0; j < sub; j++) { + const i = y * sub + j + if (i < top) continue + sum += groundLight(gx[i]!, gz[i]!, gy[i]!, cam, (at[i]! - skyline) / sy) + m++ + } + if (m * 2 > sub) ground[y * pw + x] = sum / m + } + spent += Math.max(1, rows - top) + 16 + } + if (L.done >= pw) markNear(L) +} + +/** Where the Earth and its air can reach, as it climbs and sinks: the sky round it, once the ground is in. */ +function markNear(L: Layout): void { + const { pw, ph, s, sy, col0, row0, ex, er, ground } = L + const air = (13 * er) / ER + for (let y = 0; y < ph; y++) { + const row = row0 + (y + 0.5) * sy + if (row <= L.eyHigh - er - air - sy || row >= L.eyLow + er + air) continue + for (let x = 0; x < pw; x++) { + const col = col0 + (x + 0.5) * s + const i = y * pw + x + if (ground[i]! < 0 && Math.abs(col - ex) < er + air) L.near[i] = 1 + } + } } export const earthriseScene = defineScene({ name: 'earthrise', - blurb: 'the Earth over the moon, turning faster with the work', + blurb: 'the Earth over the moon, rising and turning faster with the work', make: seed => new Earthrise(seed), }) diff --git a/hooks/styles.ts b/hooks/styles.ts index 8192cd4..186b2fb 100644 --- a/hooks/styles.ts +++ b/hooks/styles.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v172-earthrise +// REVISION: flow-v173-pictures // // The scenes, all driven by the same dials (strength 0..10, coverage boost, // the agents one by one, tint, night, waiting): SCENES, the one list of them (each scene file exports its @@ -67,6 +67,18 @@ export interface Scene { sounds?: SoundEvent[] /** What it's doing now, for its soundscape's background. */ ambience?(): Ambience + /** + * This frame at real pixels, `w` × `h` (square), RGBA with the open sky + * transparent, for a terminal that draws images (pi's, earthrise's alone + * so far). A new size may take a while to make, `budget` a call: until + * then (and at level 0) undefined, and the adapter draws `grid()`. + */ + picture?(w: number, h: number, budget?: number): Uint8Array | undefined + /** What a picture's size takes long to make, to keep across runs (`key` names it): once it's made, else undefined. */ + pictureCache?(): { key: string; data: Float32Array } | undefined + /** The key `pictureCache` would give a picture `w` × `h`, and taking back what was kept under it (false: not usable). */ + pictureKey?(w: number, h: number): string + restorePicture?(w: number, h: number, data: Float32Array): boolean } function defOf(style: SceneName): SceneDef { diff --git a/pi/index.ts b/pi/index.ts index 14f9def..fc3f1af 100644 --- a/pi/index.ts +++ b/pi/index.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v171-dry-adapter +// REVISION: flow-v172-pictures // // Flow for pi (badlogic/pi-mono), by Rob Macrae: the same ambient // scenes as the Claude Code mod, in a widget above pi's editor. pi's events @@ -26,8 +26,16 @@ // and no side panes, so there is no spine, and `/flow pick` is a plain list (pi's own select) // rather than thumbnails, its choice going the way `/flow ` goes. Nor has it a player, so // there is no soundscape: `/flow sound` says so, and the help and the status leave it out. +// +// In a terminal that draws kitty images (Ghostty, kitty, WezTerm, Warp), a +// scene that can draw itself at real pixels (earthrise) shows as a picture +// instead of its cells, taller than the band (picture.ts): flow.json's +// `"rows"` sets how tall (14 unless it says; never more than half the +// terminal), `"pictures": false` keeps the cells. A new size's picture is made +// a little each frame, the cells showing meanwhile, and what took long is +// kept in ~/.cache/flow for the next time. -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, join } from 'node:path' @@ -35,8 +43,9 @@ import { Activity } from '../hooks/activity' import { FRAME_MS, SceneDriver } from '../hooks/scene' import { pickBlurb } from '../hooks/picker' import { type Host, parseFlowArgs, readConfig, replyTo, storedValue, type FlowConfig } from '../hooks/settings' -import { SCENES } from '../hooks/styles' +import { SCENES, type Scene } from '../hooks/styles' import { gridToAnsi } from './ansi' +import { cellPixels, kittyImages, kittyLines, pictureId, pictureSize } from './picture' import { COMMAND_TOOLS, effortOf, FLOW_ENTRY, piLinesWritten, READ_TOOLS } from './mapping' import { PiSettings, type SessionEntries } from './session' import type { PiApi, PiComponent, PiContext, PiTui } from './types' @@ -51,6 +60,38 @@ const SETTINGS = join(homedir(), '.pi', 'agent', 'flow.json') const PI: Host = { panes: false, sound: false } /** Where the settings lived before, newest first: as vista, then as ascii-fire. */ const OLD_SETTINGS = [join(homedir(), '.pi', 'agent', 'vista.json'), join(homedir(), '.pi', 'agent', 'ascii-fire.json')] +/** A picture's rows unless flow.json says (`rows`), and at most this share of the terminal's. */ +const PICTURE_ROWS = 14 +const PICTURE_SHARE = 0.5 +/** At most this many pixels a picture (the terminal scales it up to its cells), and at most one this often. */ +const PICTURE_PIXELS = 160_000 +const PICTURE_MS = 100 +/** A new size's picture is made between frames, this long at a time, with this long between for everything else. */ +const PICTURE_BUILD_MS = 12 +const PICTURE_YIELD_MS = 4 +/** Where what a picture took long to make is kept: the most recent few sizes. */ +const CACHE = join(homedir(), '.cache', 'flow') +const CACHE_KEPT = 8 + +/** Drop all but the CACHE_KEPT newest kept pictures' parts. */ +async function pruneCache(): Promise { + const names = (await readdir(CACHE)).filter(n => n.endsWith('.f32')) + const dated = await Promise.all(names.map(async n => ({ n, t: (await stat(join(CACHE, n))).mtimeMs }))) + dated.sort((a, b) => b.t - a.t) + await Promise.all(dated.slice(CACHE_KEPT).map(({ n }) => unlink(join(CACHE, n)))) +} + +/** flow.json's own keys for pi's pictures: whether to draw them, and how many rows. */ +async function loadPicturePrefs(): Promise<{ pictures: boolean; rows: number }> { + let raw: Record = {} + try { + raw = JSON.parse(await readFile(SETTINGS, 'utf8')) as Record + } catch { + // None yet: the defaults. + } + const rows = typeof raw.rows === 'number' && Number.isFinite(raw.rows) ? Math.round(raw.rows) : PICTURE_ROWS + return { pictures: raw.pictures !== false, rows: Math.max(3, Math.min(60, rows)) } +} async function loadSettings(): Promise { for (const file of [SETTINGS, ...OLD_SETTINGS]) { @@ -111,6 +152,117 @@ export default function flow(pi: PiApi) { let sinceContext = 0 let sinceDefaults = 0 + /** Pictures: whether the terminal draws them, its cells' pixels, flow.json's say, and the one image id this pi uses. */ + let kitty = false + let cell = { width: 9, height: 18 } + let prefs = { pictures: true, rows: PICTURE_ROWS } + const picId = pictureId() + /** Whether the widget shows a picture now, and how long since the last was sent. */ + let showingPicture = false + let sincePicture = 0 + /** How much of a new size's picture one slice makes (the scene's own units), learned from how long it takes. */ + let budget = 300 + /** The size being made between frames (`w`x`h`), if any. */ + let making: string | undefined + /** What a picture of each size took long to make, kept in CACHE: being read back, read back, not there, or written. */ + const kept = new Map() + /** The rows a picture takes now. */ + const pictureRows = () => { + const terminal = process.stdout.rows || 40 + return Math.max(3, Math.min(prefs.rows, Math.floor(terminal * PICTURE_SHARE))) + } + + /** Whether this scene shows as a picture here. */ + const pictured = (f: Scene) => kitty && prefs.pictures && typeof f.picture === 'function' + + /** Read back what a picture of this size took long to make, if it was kept: false until that's known (make nothing yet). */ + const readKept = (f: Scene, w: number, h: number): boolean => { + const key = f.pictureKey?.(w, h) + if (!key) return true + const state = kept.get(key) + if (state) return state !== 'reading' + kept.set(key, 'reading') + void readFile(join(CACHE, `${key}.f32`)) + .then(buf => { + const data = new Float32Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)) + kept.set(key, f.restorePicture?.(w, h, data) ? 'restored' : 'absent') + }) + .catch(() => kept.set(key, 'absent')) + return false + } + + /** Keep what a picture made here took long to make, once (written aside, then moved in, so no reader sees half). */ + const writeKept = (f: Scene) => { + const made = f.pictureCache?.() + if (!made || kept.get(made.key) !== 'absent') return + kept.set(made.key, 'written') + const file = join(CACHE, `${made.key}.f32`) + const bytes = new Uint8Array(made.data.buffer, made.data.byteOffset, made.data.byteLength).slice() + const tmp = `${file}.${process.pid}.tmp` + void mkdir(CACHE, { recursive: true }) + .then(() => writeFile(tmp, bytes)) + .then(() => rename(tmp, file)) + .then(pruneCache) + .catch(() => { + // Not kept: made afresh next time. + }) + } + + /** This frame as a picture's lines, or undefined while it's still being made (draw the cells). */ + const drawPicture = (f: Scene): string[] | undefined => { + if (!f.picture || width <= 0) return undefined + const rows = pictureRows() + const { w, h } = pictureSize(width, rows, cell, PICTURE_PIXELS) + if (!readKept(f, w, h)) return undefined + const rgba = f.picture(w, h, 0) + if (!rgba) { + makeBetweenFrames(f, w, h) + return undefined + } + writeKept(f) + return kittyLines(rgba, w, h, width, rows, picId) + } + + /** Make a new size's picture a slice at a time, off the frame loop, until it's made (or the size or scene moves on). */ + const makeBetweenFrames = (f: Scene, w: number, h: number) => { + const key = `${w}x${h}` + if (making === key) return + making = key + const slice = () => { + if (making !== key) return + if (driver.built(driver.cfg.style) !== f || !f.picture) { + making = undefined + return + } + const t0 = performance.now() + const made = f.picture(w, h, budget) !== undefined + if (made) { + making = undefined + writeKept(f) + return + } + const ms = performance.now() - t0 + budget = Math.max(20, Math.min(1e6, (budget * PICTURE_BUILD_MS) / Math.max(1, ms))) + setTimeout(slice, PICTURE_YIELD_MS) + } + setTimeout(slice, 0) + } + + /** This frame's lines: a picture when it's time for one, else the cells; undefined to keep the last picture. */ + const nextLines = (f: Scene, force = false): string[] | undefined => { + if (pictured(f)) { + if (showingPicture && !force && sincePicture < PICTURE_MS) return undefined + const p = drawPicture(f) + if (p) { + showingPicture = true + sincePicture = 0 + return p + } + } + showingPicture = false + return gridToAnsi(f.grid()) + } + /** Read the local clock (pi runs on this machine, so its time is the person's). */ const readClock = () => { const d = new Date() @@ -130,7 +282,7 @@ export default function flow(pi: PiApi) { render(w: number) { if (w !== width) { width = w - lines = gridToAnsi(dial().grid()) + lines = nextLines(dial(), true) ?? lines } return lines }, @@ -161,6 +313,7 @@ export default function flow(pi: PiApi) { const usage = ctx.getContextUsage() if (usage?.percent != null) activity.contextPercent = usage.percent readClock() + void cellPixels().then(c => (cell = c)) } sinceDefaults += elapsed if (sinceDefaults >= DEFAULTS_EVERY_MS && !refreshing) { @@ -177,8 +330,12 @@ export default function flow(pi: PiApi) { f.step() // pi has no player: a scene's events (for Claude Code's soundscape) are taken and dropped each frame. if (f.sounds) f.sounds.length = 0 - lines = gridToAnsi(f.grid()) - tui.requestRender() + sincePicture += elapsed + const next = nextLines(f) + if (next) { + lines = next + tui.requestRender() + } } const pace = driver.pace() timer = setTimeout(() => frame(pace), pace) @@ -197,6 +354,9 @@ export default function flow(pi: PiApi) { if (ctx.mode !== 'tui' || !ctx.hasUI) return ctxRef = ctx width = 0 + kitty = await kittyImages() + cell = await cellPixels() + prefs = await loadPicturePrefs() readClock() stop() isMounted = false @@ -261,6 +421,7 @@ export default function flow(pi: PiApi) { const entries = entriesOf(ctx) // flow.json as it is now: what follows compares with it. await settings.refresh(entries) + prefs = await loadPicturePrefs() if (cmd.kind === 'pick') { // No panes for thumbnails here: pi's own list, each scene with its blurb; the one chosen is // `/flow ` from here on. diff --git a/pi/picture.ts b/pi/picture.ts new file mode 100644 index 0000000..ac6e71c --- /dev/null +++ b/pi/picture.ts @@ -0,0 +1,91 @@ +// REVISION: flow-v1-pictures +// +// Pictures for pi: a scene that can draw itself at real pixels (`picture`, +// earthrise so far) is sent to a terminal that speaks the kitty graphics +// protocol (Ghostty, kitty, WezTerm, Warp) as one image a frame, in place of +// its cells. The frame goes as raw RGBA, zlib-compressed, under one image id +// that each frame replaces; pi's own renderer reserves the image's rows +// (from its `r=`) and deletes it when the line changes or goes. Node only. + +import { deflateSync } from 'node:zlib' + +/** Whether this terminal draws kitty images: pi's own answer when it's there, else the same reading of the environment. */ +export async function kittyImages(): Promise { + try { + const tui = (await import('@earendil-works/pi-tui' as string)) as { getCapabilities?: () => { images: string | null } } + if (tui.getCapabilities) return tui.getCapabilities().images === 'kitty' + } catch { + // Not inside pi (a test, a script): read the environment. + } + return kittyFromEnv(process.env) +} + +/** As pi-tui reads it: PI_IMAGE_PROTOCOL first, never under tmux or screen, then the terminals known to draw them. */ +export function kittyFromEnv(env: NodeJS.ProcessEnv): boolean { + const forced = env.PI_IMAGE_PROTOCOL?.toLowerCase() + if (forced) return forced === 'kitty' + const term = env.TERM?.toLowerCase() ?? '' + const program = env.TERM_PROGRAM?.toLowerCase() ?? '' + if (env.TMUX || term.startsWith('tmux') || term.startsWith('screen')) return false + return Boolean( + env.KITTY_WINDOW_ID || + program === 'kitty' || + program === 'ghostty' || + term.includes('ghostty') || + env.GHOSTTY_RESOURCES_DIR || + env.WEZTERM_PANE || + program === 'wezterm' || + program === 'warpterminal' || + env.WARP_SESSION_ID, + ) +} + +/** A cell's size in the terminal's pixels, as pi measured it (or a common one). */ +export async function cellPixels(): Promise<{ width: number; height: number }> { + try { + const tui = (await import('@earendil-works/pi-tui' as string)) as { getCellDimensions?: () => { widthPx: number; heightPx: number } } + const d = tui.getCellDimensions?.() + if (d && d.widthPx > 0 && d.heightPx > 0) return { width: d.widthPx, height: d.heightPx } + } catch { + // Not inside pi. + } + return { width: 9, height: 18 } +} + +/** + * How many pixels to draw a picture `columns` × `rows` cells: the cells' + * own shape, at the terminal's own pixels or fewer, at most `budget` of + * them (the terminal scales it up to fill the cells). + */ +export function pictureSize(columns: number, rows: number, cell: { width: number; height: number }, budget: number): { w: number; h: number } { + const fw = columns * cell.width + const fh = rows * cell.height + const k = Math.min(1, Math.sqrt(budget / Math.max(1, fw * fh))) + return { w: Math.max(2, Math.round(fw * k)), h: Math.max(2, Math.round(fh * k)) } +} + +const CHUNK = 4096 + +/** + * The picture as a pi widget's lines: the image's escape sequence on the + * first (placed over `columns` × `rows` cells, the cursor left where it + * was), then empty lines for the rest of its rows. + */ +export function kittyLines(rgba: Uint8Array, w: number, h: number, columns: number, rows: number, id: number): string[] { + const data = deflateSync(rgba, { level: 1 }).toString('base64') + const head = `a=T,f=32,o=z,s=${w},v=${h},c=${columns},r=${rows},i=${id},C=1,q=2` + let seq = '' + for (let at = 0; at < data.length; at += CHUNK) { + const more = at + CHUNK < data.length ? 1 : 0 + const part = data.slice(at, at + CHUNK) + seq += at === 0 ? `\x1b_G${head},m=${more};${part}\x1b\\` : `\x1b_Gm=${more};${part}\x1b\\` + } + const lines = [seq] + for (let i = 1; i < rows; i++) lines.push('') + return lines +} + +/** A fresh image id for this pi: random, so two extensions (or two sessions' pi) never share one. */ +export function pictureId(): number { + return 1 + Math.floor(Math.random() * 0xfffffffe) +} From 188bcd7fabdf09a02d7ec510add6660c7d19fba1 Mon Sep 17 00:00:00 2001 From: Patrick Desjardins Date: Thu, 8 Oct 2026 15:20:08 -0700 Subject: [PATCH 3/5] Earthrise: drawn as the original's halftone of dots in pi's picture; the sky comes alive with the level (drifting star layers, shooting stars, comets) --- AGENTS.md | 2 +- README.md | 4 +- hooks/earthrise.ts | 640 +++++++++++++++++++++++++++++++++++++-------- pi/index.ts | 10 +- 4 files changed, 541 insertions(+), 115 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d958bff..e025e2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ A scene implements `Scene` (`hooks/styles.ts`). Its file exports a `SceneDef` (` - Most scenes paint a pixel layer at 2 × 2 pixels per cell, then fold it into quadrant glyphs with `fitQuad`. A cell holds only two colors; give `fitQuad` a sprite's pixels as its `keep` so a small figure never drops out of its own cell. - Braille (`BRAILLE`, 2 × 4 dots a cell) is for fine specks: spray, stars, bubbles. - `DEFAULT_COLOR` (0x01000000) is the terminal's own color, i.e. transparent. - - A scene can also draw itself at real pixels (`Scene.picture(w, h, budget)`: RGBA, the open sky transparent), which pi shows as a kitty image in a terminal that draws them (`pi/picture.ts`); earthrise alone does so far. A new size may take a while to make: `picture` makes `budget` of it a call and returns undefined until it's done (pi makes it between frames, showing the cells meanwhile). What took long goes in `pictureCache()` under `pictureKey(w, h)`, and pi keeps it in `~/.cache/flow` (`restorePicture` takes it back): bump the scene's version of it when what it makes changes. + - A scene can also draw itself at real pixels (`Scene.picture(w, h, budget)`: RGBA, the open sky transparent), which pi shows as a kitty image in a terminal that draws them (`pi/picture.ts`); earthrise alone does so far, as a halftone like its original (each cell of a square grid a dot sized by its light, Bayer-dithered, in the original's palette), its ground built at the dots' grid, not at every pixel. A new size may take a while to make: `picture` makes `budget` of it a call and returns undefined until it's done (pi makes it between frames, showing the cells meanwhile). What took long goes in `pictureCache()` under `pictureKey(w, h)`, and pi keeps it in `~/.cache/flow` (`restorePicture` takes it back): bump the scene's version of it when what it makes changes. - **Limits**: - Claude Code's `Raster` paints at most 1024 distinct (fg, bg) pairs a frame and nearest-maps the rest, so quantize gradients. - Keep `step()` + `grid()` under ~2 ms at 250 × 5 and 22 × 60; frames run at ~14 fps (8 when calm). diff --git a/README.md b/README.md index e4420a6..6e86611 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Every scene has a level from 0 (off) to 10 (as busy as it gets). | `ski` | an easy run | a steep mogul run at speed | | `bubbles` | a couple of lazy strings of bubbles | a rolling, fizzing boil | | `train` | waiting at a red signal or a platform, its diesel idling (when the work starts the signal clears, the horn sounds and it pulls away; when it winds down the train pulls up again) | flat out past fields, villages, woods, rivers and stations, the poles and the line beside it a blur | -| `earthrise` | the Earth half risen over a cratered lunar horizon in long, low sunlight, turning once in about two minutes (after [earthrise](https://ascii.rest/earthrise/) by @bas3line on ascii.rest) | the Earth risen clear of the horizon, spinning once every four seconds, its clouds running ahead | +| `earthrise` | the Earth half risen over a cratered lunar horizon in long, low sunlight, turning once in about two minutes, under a still sky of a few breathing stars (after [earthrise](https://ascii.rest/earthrise/) by @bas3line on ascii.rest) | the Earth risen clear of the horizon, spinning once every four seconds, its clouds running ahead; more stars, drifting past in layers, shooting stars, and now and then a comet trailing its tails | `/flow pick` shows them all at once, each one live, to choose from with the arrow keys and Enter (in pi, a list). @@ -170,7 +170,7 @@ The same scenes run as a pi extension (`pi/index.ts`): a widget above pi's edito | `session_compact` | so does a compaction | | `ctx.getContextUsage()` | a nearly-full context shows as blue | -In a terminal that draws images (Ghostty, kitty, WezTerm, Warp; not under tmux), `earthrise` shows in pi as a picture at the terminal's own pixels rather than in characters, 14 rows tall (never more than half the terminal). Set `"rows"` in `~/.pi/agent/flow.json` for another height, or `"pictures": false` to keep the characters. The first time pi opens at a new width the characters show for a few seconds while the picture's ground is made. Flow keeps the ground of the last few sizes in `~/.cache/flow`, so the picture shows at once the next time. The other scenes stay in characters. +In a terminal that draws images (Ghostty, kitty, WezTerm, Warp; not under tmux), `earthrise` shows in pi as a picture at the terminal's own pixels rather than in characters, drawn as the original is: a halftone of dots sized by brightness in the original's 38 colours, 14 rows tall (never more than half the terminal). Set `"rows"` in `~/.pi/agent/flow.json` for another height, or `"pictures": false` to keep the characters. The first time pi opens at a new width the characters show for a moment while the picture's ground is made. Flow keeps the ground of the last few sizes in `~/.cache/flow`, so the picture shows at once the next time. The other scenes stay in characters. pi tells an extension nothing of a wait on you, so there the scenes never show one. Nor has it a player, so the scenes are silent there: `/flow sound` says so. diff --git a/hooks/earthrise.ts b/hooks/earthrise.ts index 1a8b93e..107dff4 100644 --- a/hooks/earthrise.ts +++ b/hooks/earthrise.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v2-earthrise-picture +// REVISION: flow-v3-earthrise-halftone // // Earthrise (the `earthrise` scene): the Earth coming up over a cratered // lunar horizon in long, low sunlight, after "earthrise" by @bas3line on @@ -6,12 +6,20 @@ // Earth stands and how fast it turns: at 1 it is half behind the horizon, // turning once in about two minutes; at 10 it stands clear of the horizon, // turning every few seconds, its clouds running a little ahead. It climbs -// and sinks over several seconds as the work picks up or winds down, and a -// few stars breathe. +// and sinks over several seconds as the work picks up or winds down. +// +// The dark sky comes alive with the work: at 1 a few stars hold still and +// breathe; as the level rises more of them come out, the field drifts past +// in three layers (the near ones faster), more of them twinkle, shooting +// stars streak across, and from about 3 up a comet now and then drifts +// through, its tails streaming away from the sun. // // Where the terminal draws images (pi in Ghostty, kitty, WezTerm), it is -// also drawn as a picture at real pixels (`picture`): the same world, its -// ground built a few columns a frame for each new size, the sky left clear. +// also drawn as a picture (`picture`), the original's way: every cell of a +// square grid a halftone dot (none, ·, • or ●) sized by its brightness, +// ordered-dithered, in the nearest of the original's 38 colours. The +// ground is built a few columns a frame for each new size, the sky left +// clear. // // The ground is the original's heightfield of craters and boulders, seen // from a camera standing on it and marched column by column, with real @@ -28,6 +36,7 @@ // (PixelScene breathes the frame in sepia; a picture takes the same tone). import { FRAME_MS } from './activity' +import { Rng } from './cells' import { CLEAR, PixelScene, type Dials, type Painter } from './pixel-scene' import { clamp, grey, hash, hash1, luma, mix, rampAt, smoothstep as smooth } from './pixels' import { defineScene } from './scene-def' @@ -96,7 +105,61 @@ const PIC_SIZE = 0.85 const PIC_SKY = 0.55 const PIC_STEPS = 255 /** Bump when the ground changes: a picture's ground kept from before (`pictureCache`) is then made afresh. */ -const GROUND_VERSION = 1 +const GROUND_VERSION = 2 +/** + * A picture's halftone: about DOT_ROWS dots down it, each DOT_MIN to + * DOT_MAX of its pixels apart, sized (by radius, of that pitch) as the + * original's glyphs ink: none, ·, •, ●. + */ +const DOT_ROWS = 46 +const DOT_MIN = 4 +const DOT_MAX = 10 +const DOT_R = [0, 0.17, 0.3, 0.47] as const +/** How much of a full dot each step inks, and the 4 × 4 ordered dither between steps (the original's). */ +const COVER = [0, 0.3, 0.6, 1] as const +const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map(v => v / 16 - 0.47) +/** The original's palette: the ground's greys, the night's blues, the Earth's seas, clouds, land. */ +const PALETTE = [ + 0x18181b, 0x232326, 0x303033, 0x414143, 0x555556, 0x6b6a69, 0x83817d, 0x9c9993, 0xb6b2aa, 0xcfcac1, 0xe6e1d8, 0xf7f4ee, + 0x0c1120, 0x131a2e, 0x1b2540, 0x262f4a, 0xdfe9ff, 0x0a2259, 0x0f2f72, 0x15408c, 0x1d53a6, 0x2a69bf, 0x4386d3, 0x6eaeea, + 0xa8d3f6, 0xe8f0fa, 0xc2d0e3, 0x8b9fbc, 0x2f4a26, 0x3b5a2c, 0x5b7238, 0x7a9150, 0x77783f, 0x8f8550, 0xa8955e, 0x6b5634, + 0xb9774a, 0x8a4838, +] as const + +// --- the living sky ---------------------------------------------------------- + +/** + * The star field's layers, far to near: a lattice `cell` wide (the + * original's units) with a star in about `dens` of each unit of sky at level + * 1 and (1 + `more`) times that at 10, drifting `speed` times the sky's + * pace, `lo`..`hi` bright. + */ +const STAR_LAYERS = [ + { cell: 4, dens: 0.0032, more: 2.6, speed: 0.35, lo: 0.17, hi: 0.45, salt: 101 }, + { cell: 6, dens: 0.0016, more: 2.2, speed: 0.7, lo: 0.24, hi: 0.7, salt: 211 }, + { cell: 12, dens: 0.0005, more: 1.4, speed: 1.4, lo: 0.45, hi: 1.05, salt: 307 }, +] as const +/** The sky's drift (the original's units a second) at level 1 and the most added at 10, leftward and a little down. */ +const DRIFT_CALM = 0.03 +const DRIFT_BUSY = 5 +const DRIFT_DOWN = 0.12 +/** Shooting stars a second at level 1 and at 10 (eased between as the square of the level), and with each subagent's boost. */ +const METEOR_CALM = 0.015 +const METEOR_BUSY = 0.9 +/** Comets a second, from about level 3 up to 10, and at most this many in the sky at once (one below about 6). */ +const COMET_BUSY = 0.055 +const COMETS_MAX = 2 +/** Toward the sun on the screen (column right, row down): a comet's tails stream the other way. */ +const SUN_SCREEN = (() => { + const l = Math.hypot(SUN[0], SUN[1]) + return [SUN[0] / l, -SUN[1] / l] as const +})() + +type Meteor = { x: number; y: number; ux: number; uy: number; speed: number; age: number; life: number; len: number; b: number } +type Comet = { x: number; y: number; vx: number; vy: number; age: number; life: number; len: number; b: number; curl: number } + +/** Where sky light is gathered: sample (x, y) of `w` × `h` is centred on (col0 + (x + ½)·fx, row0 + (y + ½)·fy). */ +type View = { col0: number; row0: number; fx: number; fy: number; w: number; h: number } /** The widest view, in radians: a wider band is squeezed into it rather than looking round behind. */ const SPAN = 2.8 /** The spine: the Earth this much of the width, its centre this far down. */ @@ -413,17 +476,21 @@ const SN = Math.sin(NOD) /** * One point of the sky round the Earth, (dx, dy) from its centre over its - * radius `er` (the original's cells, its radius ER): [r, g, b, alpha], into `out`. + * radius `er` (the original's cells, its radius ER), over the sky's light + * (br, bg, bb): [r, g, b, alpha, cap, floor] into `out`, the last two for a + * halftone dot (how bright its colour may go, how big it must be at least). */ -function earthAt(dx: number, dy: number, er: number, spin: number, drift: number, out: Float32Array): void { +function earthAt(dx: number, dy: number, er: number, spin: number, drift: number, out: Float32Array, br = 0, bg = 0, bb = 0): void { const m = earthMaps() // In the original's measure, whatever the size it's drawn at. dx *= ER / er dy *= ER / er - let cr = 0 - let cg = 0 - let cb = 0 + let cr = br + let cg = bg + let cb = bb let a = 0 + let cap = 1 + let floor = 0 const d = Math.hypot(dx, dy) // The Earth's air: a thin blue rim on its sunlit side. if (d >= ER - 1 && d < ER + 12) { @@ -474,13 +541,61 @@ function earthAt(dx: number, dy: number, er: number, spin: number, drift: number cg = lerp(cg, eg, edge) cb = lerp(cb, eb, edge) a = lerp(a, clamp(Math.max(er, eg, eb) * 12), edge) + // Land keeps its earth tones rather than washing out to cream; the day side, the brightest thing in the sky, full round dots. + cap = lerp(1, 0.66, (1 - sw) * (1 - cl) * smooth(0.95, 0.85, Math.abs(ay))) + floor = 0.15 * Math.min(1, day) * edge } out[0] = cr out[1] = cg out[2] = cb out[3] = a + out[4] = cap + out[5] = floor } +/** A colour (0..1 a channel) as the palette's nearest, by a 32³ table filled as it's asked. */ +const nearestLut = new Int16Array(32768).fill(-1) +function nearest(r: number, g: number, b: number): number { + const k = (Math.min(31, (clamp(r) * 31.99) | 0) << 10) | (Math.min(31, (clamp(g) * 31.99) | 0) << 5) | Math.min(31, (clamp(b) * 31.99) | 0) + const hit = nearestLut[k]! + if (hit >= 0) return PALETTE[hit]! + let best = 0 + let bd = Infinity + for (let i = 0; i < PALETTE.length; i++) { + const c = PALETTE[i]! + const dr = ((c >> 16) & 255) / 255 - r + const dg = ((c >> 8) & 255) / 255 - g + const db = (c & 255) / 255 - b + const d = 0.3 * dr * dr + 0.5 * dg * dg + 0.2 * db * db + if (d < bd) { + bd = d + best = i + } + } + nearestLut[k] = best + return PALETTE[best]! +} + +/** The ink of each dot step at `p` pixels a dot: how much of each of its p × p pixels it covers (4 × 4 samples a pixel). */ +function dotStamps(p: number): Float32Array[] { + return DOT_R.map(rf => { + const st = new Float32Array(p * p) + const r = rf * p + const c = p / 2 + for (let y = 0; y < p; y++) { + for (let x = 0; x < p; x++) { + let n = 0 + for (let j = 0; j < 4; j++) for (let i = 0; i < 4; i++) if (Math.hypot(x + (i + 0.5) / 4 - c, y + (j + 0.5) / 4 - c) < r) n++ + st[y * p + x] = n / 16 + } + } + return st + }) +} + +/** A picture's dot pitch for `h` pixels down it. */ +const dotPitch = (h: number): number => Math.max(DOT_MIN, Math.min(DOT_MAX, Math.round(h / DOT_ROWS))) + /** Light (0..~1.2 a channel) as a colour, a little lifted, held to 5 bits a channel (`bits`: Raster's colour pairs are few). */ function lightColor(r: number, g: number, b: number, bits = 0xf8): number { const c = (v: number) => Math.min(255, Math.round(Math.pow(clamp(v, 0, 1.2) / 1.2, 0.72) * 255 * 1.22)) & bits @@ -517,6 +632,8 @@ type Layout = { at: Float32Array /** How many steps the ground's brightness is held to. */ steps: number + /** The row (the original's) the skyline falls at on average, once the ground is in: where shooting stars and comets keep above. */ + horizon: number } /** The ground's sample buffers, shared (one column at a time). */ @@ -524,14 +641,31 @@ let gx = new Float32Array(0) let gz = new Float32Array(0) let gy = new Float32Array(0) -/** A picture's pixels: its colour (CLEAR for the open sky) and how much it covers (0..1). */ -type Canvas = { w: number; h: number; px: Int32Array; alpha: Float32Array; rgba: Uint8Array } +/** A picture: its pixels, its dots' grid (`gw` × `gh`, `p` pixels apart, inset `ox`, `oy`), each dot's light, and the dots' ink. */ +type Canvas = { + w: number + h: number + p: number + gw: number + gh: number + ox: number + oy: number + r: Float32Array + g: Float32Array + b: Float32Array + rgba: Uint8Array + stamps: Float32Array[] +} + +/** Sky light gathered for the cells' specks. */ +type Specks = { r: Float32Array; g: Float32Array; b: Float32Array } export class Earthrise extends PixelScene { /** The view the cells see, and a picture's. */ private cells: Layout = layout(0, 0, 2, false) private pic: Layout | undefined private canvas: Canvas | undefined + private specks: Specks = { r: new Float32Array(0), g: new Float32Array(0), b: new Float32Array(0) } /** How far the Earth has turned, and its clouds (radians): each one made starts on its own face. */ private spin = hash1(this.seed) * Math.PI * 2 private drift = 0 @@ -540,11 +674,20 @@ export class Earthrise extends PixelScene { /** Smoke and blue, eased (0..1). */ private kSmoke = 0 private kBlue = 0 - private readonly rgba = new Float32Array(4) + private readonly rgba = new Float32Array(6) + /** How far the sky has drifted (the original's units, at the pace of a layer of speed 1), and what's crossing it. */ + private skyOff = hash1(this.seed + 7) * 1000 + private readonly meteors: Meteor[] = [] + private readonly comets: Comet[] = [] + private readonly rng = new Rng(Math.floor(hash1(this.seed + 13) * 0x7fffffff)) + /** The frame a picture was last asked for: the sky's visitors cross the view that's on show. */ + private picT = -1e9 protected resize(d: Dials): void { this.cells = layout(d.columns * 2, d.rows * 2, 2, d.tall) build(this.cells, Infinity) + const n = d.columns * 2 * d.rows * 4 + this.specks = { r: new Float32Array(n), g: new Float32Array(n), b: new Float32Array(n) } } protected update(d: Dials): void { @@ -552,12 +695,218 @@ export class Earthrise extends PixelScene { const busy = clamp((d.level - 1) / 9) if (Number.isNaN(this.alt)) this.alt = busy else if (d.wait < 0.5) this.alt += clamp(busy - this.alt, -CLIMB, CLIMB) - const rate = SPIN_CALM * Math.pow(SPIN_BUSY / SPIN_CALM, busy) * (1 - d.wait) + const still = 1 - d.wait + const rate = SPIN_CALM * Math.pow(SPIN_BUSY / SPIN_CALM, busy) * still const dt = FRAME_MS / 1000 this.spin += rate * dt this.drift += rate * CLOUD_AHEAD * dt this.kSmoke = clamp(this.kSmoke + clamp((d.tint === 'smoke' ? 1 : 0) - this.kSmoke, -0.05, 0.05)) this.kBlue = clamp(this.kBlue + clamp((d.tint === 'blue' ? 1 : 0) - this.kBlue, -0.04, 0.04)) + this.moveSky(d, dt, still) + } + + /** The sky drifts with the work, and shooting stars and comets come and go across the view on show. */ + private moveSky(d: Dials, dt: number, still: number): void { + const busy = this.alt + this.skyOff += (DRIFT_CALM + DRIFT_BUSY * Math.pow(busy, 1.7)) * still * dt + const L = this.pic && d.t - this.picT < 30 ? this.pic : this.cells + if (L.pw <= 0) return + const c0 = L.col0 + const c1 = L.col0 + L.pw * L.s + const r0 = L.row0 + const r1 = Math.max(r0 + 4, L.horizon) + const rng = this.rng + + for (let i = this.meteors.length - 1; i >= 0; i--) { + const m = this.meteors[i]! + m.age += dt + m.x += m.ux * m.speed * dt + m.y += m.uy * m.speed * dt + if (m.age >= m.life) this.meteors.splice(i, 1) + } + const meteors = (METEOR_CALM + METEOR_BUSY * busy * busy) * (1 + d.boost / 30) * still + if (this.meteors.length < 6 && rng.f() < meteors * dt) { + const a = 0.2 + 0.55 * rng.f() + const dir = rng.f() < 0.65 ? -1 : 1 + this.meteors.push({ + x: c0 + (c1 - c0) * rng.f(), + y: r0 + (r1 - r0) * 0.6 * rng.f(), + ux: dir * Math.cos(a), + uy: Math.sin(a), + speed: 45 + 60 * rng.f(), + age: 0, + life: 0.3 + 0.5 * rng.f(), + len: 6 + 9 * rng.f(), + b: 0.7 + 0.6 * rng.f(), + }) + } + + for (let i = this.comets.length - 1; i >= 0; i--) { + const c = this.comets[i]! + c.age += dt * still + c.x += c.vx * dt * still + c.y += c.vy * dt * still + if (c.age >= c.life) this.comets.splice(i, 1) + } + const comets = COMET_BUSY * smooth(0.2, 1, busy) * still + const room = busy > 0.55 ? COMETS_MAX : 1 + if (this.comets.length < room && rng.f() < comets * dt) { + const a = (rng.f() - 0.5) * 0.7 + (rng.f() < 0.5 ? 0 : Math.PI) + const v = 1.5 + 2.5 * rng.f() + this.comets.push({ + x: c0 + (c1 - c0) * (0.12 + 0.76 * rng.f()), + y: r0 + (r1 - r0) * (0.12 + 0.4 * rng.f()), + vx: Math.cos(a) * v, + vy: Math.sin(a) * v * 0.4, + age: 0, + life: 18 + 16 * rng.f(), + len: 16 + 20 * rng.f(), + b: 0.75 + 0.35 * rng.f(), + curl: (rng.f() < 0.5 ? -1 : 1) * (0.002 + 0.003 * rng.f()), + }) + } + } + + /** + * This frame's sky into r, g, b (one value per sample of `v`, added to): + * the drifting stars (spread over the four samples round each when `soft`, + * else in the nearest), the shooting stars and the comets. + */ + private sky(v: View, R: Float32Array, G: Float32Array, B: Float32Array, soft: boolean, tSec: number): void { + const { col0, row0, fx, fy, w, h } = v + const busy = this.alt + const dim = 1 - 0.6 * this.kSmoke + const add = (col: number, row: number, k: number, tr: number, tg: number, tb: number) => { + const px = (col - col0) / fx - 0.5 + const py = (row - row0) / fy - 0.5 + if (soft) { + const x0 = Math.floor(px) + const y0 = Math.floor(py) + const ax = px - x0 + const ay = py - y0 + for (let j = 0; j < 2; j++) { + const y = y0 + j + if (y < 0 || y >= h) continue + const wy = j ? ay : 1 - ay + for (let i = 0; i < 2; i++) { + const x = x0 + i + if (x < 0 || x >= w) continue + const q = k * wy * (i ? ax : 1 - ax) + const o = y * w + x + R[o]! += q * tr + G[o]! += q * tg + B[o]! += q * tb + } + } + return + } + const x = Math.round(px) + const y = Math.round(py) + if (x < 0 || y < 0 || x >= w || y >= h) return + const o = y * w + x + R[o]! += k * tr + G[o]! += k * tg + B[o]! += k * tb + } + + // Stars, layer by layer: a lattice in the sky's own coordinates, drifting by. + for (const S of STAR_LAYERS) { + const ox = this.skyOff * S.speed + const oy = ox * DRIFT_DOWN + const u0 = col0 + ox + const v0 = row0 + oy + const C = S.cell + const p0 = S.dens * C * C * (1 + S.more * busy) + const twinkling = 0.12 + 0.35 * busy + for (let j = Math.floor(v0 / C); j <= Math.floor((v0 + h * fy) / C); j++) { + for (let i = Math.floor(u0 / C); i <= Math.floor((u0 + w * fx) / C); i++) { + const h0 = hash(i, j, S.salt) + let p = p0 + if (S.salt === STAR_LAYERS[0].salt) { + // The galaxy: bands across the far layer, thick with faint stars. + let q = j * C - 0.32 * i * C - 4 + q -= 170 * Math.round(q / 170) + p *= 1 + 3 * Math.exp(-((q / 10.5) ** 2)) * smooth(0.35, 0.65, fbm(i * C * 0.05, j * C * 0.08, 3, 0)) + } + if (h0 >= p) continue + // Coming out as the level climbs, not popping. + const fade = clamp((p - h0) / (0.3 * p0)) + const col = (i + hash(i, j, S.salt + 1)) * C - ox + const row = (j + hash(i, j, S.salt + 2)) * C - oy + let k = (S.lo + (S.hi - S.lo) * Math.pow(hash(i, j, S.salt + 3), 3)) * fade * dim * (1 + 0.35 * busy) + const tw = hash(i, j, S.salt + 4) + if (tw > 1 - twinkling) k *= 0.5 + 0.5 * Math.sin(((tSec * (1 + 1.5 * busy)) / (2 + 3 * hash(i, j, S.salt + 5))) * Math.PI * 2 + tw * 50) + const tint = hash(i, j, S.salt + 6) + const [tr, tg, tb] = tint < 0.3 ? [0.84, 0.9, 1] : tint > 0.88 ? [1, 0.93, 0.84] : [0.96, 0.96, 0.98] + add(col, row, k, tr, tg, tb) + // The brightest carry a faint cross, as the original's do. + if (soft && k > 0.7) { + add(col - fx, row, k * 0.2, tr, tg, tb) + add(col + fx, row, k * 0.2, tr, tg, tb) + add(col, row - fy, k * 0.2, tr, tg, tb) + add(col, row + fy, k * 0.2, tr, tg, tb) + } + } + } + } + + // What crosses the sky: lit where it falls on a sample, within its reach. + const field = (x0: number, x1: number, y0: number, y1: number, at: (col: number, row: number) => void) => { + const xa = Math.max(0, Math.floor((x0 - col0) / fx)) + const xb = Math.min(w - 1, Math.ceil((x1 - col0) / fx)) + const ya = Math.max(0, Math.floor((y0 - row0) / fy)) + const yb = Math.min(h - 1, Math.ceil((y1 - row0) / fy)) + for (let y = ya; y <= yb; y++) for (let x = xa; x <= xb; x++) at(col0 + (x + 0.5) * fx, row0 + (y + 0.5) * fy) + } + for (const m of this.meteors) { + // A streak: the head, and a tail as long as it's flown, fading back; in and out quickly. + const tl = Math.max(0.5, Math.min(m.len, m.speed * m.age)) + const wd = Math.max(0.45, 0.6 * Math.min(fx, fy)) + const env = Math.min(1, m.age / 0.08) * (1 - smooth(0.55 * m.life, m.life, m.age)) * m.b * dim + const tx = m.x - m.ux * tl + const ty = m.y - m.uy * tl + field(Math.min(m.x, tx) - 2, Math.max(m.x, tx) + 2, Math.min(m.y, ty) - 2, Math.max(m.y, ty) + 2, (col, row) => { + const back = -((col - m.x) * m.ux + (row - m.y) * m.uy) + if (back < -wd * 2 || back > tl + wd) return + const perp = (col - m.x) * m.uy - (row - m.y) * m.ux + const along = back < 0 ? Math.exp(-((back / wd) ** 2)) : Math.pow(1 - clamp(back / tl), 1.6) + const k = env * along * Math.exp(-((perp / wd) ** 2)) + if (k < 0.01) return + add(col, row, k, 0.92, 0.96, 1) + }) + } + const [sx, sy] = SUN_SCREEN + for (const c of this.comets) { + // A comet: a bright head in its coma, a straight blue ion tail away from the sun, a wider dust tail curving off it. + const env = smooth(0, 3, c.age) * (1 - smooth(c.life - 4, c.life, c.age)) * c.b * dim + if (env <= 0) continue + const reach = c.len * 1.1 + 4 + const ux = -sx + const uy = -sy + field(c.x - reach, c.x + reach, c.y - reach * 0.5, c.y + reach * 0.5, (col, row) => { + const dx = col - c.x + const dy = row - c.y + const r2 = dx * dx + dy * dy + let kr = 1.3 * Math.exp(-r2 / 0.35) + 0.6 * Math.exp(-r2 / 5) + let kg = kr + let kb = kr + const a = dx * ux + dy * uy + if (a > 0) { + const p = dx * uy - dy * ux + const wi = 0.5 + 0.022 * a + const ion = 0.85 * Math.exp(-a / (0.6 * c.len)) * Math.exp(-((p / wi) ** 2)) * smooth(0, 2, a) + const pd = p - c.curl * a * a + const wd = 0.8 + 0.1 * a + const dust = 0.8 * Math.exp(-a / (0.4 * c.len)) * Math.exp(-((pd / wd) ** 2)) + kr += ion * 0.62 + dust * 1 + kg += ion * 0.82 + dust * 0.95 + kb += ion * 1.05 + dust * 0.82 + } + const m = Math.max(kr, kg, kb) * env + if (m < 0.015) return + add(col, row, env, kr, kg, kb) + }) + } } /** A colour as the tints show it now. */ @@ -571,16 +920,11 @@ export class Earthrise extends PixelScene { return c } - /** - * The ground, the moondust and the Earth into `px` (CLEAR left for the open - * sky); `alpha`, for a picture, takes how much of each pixel they cover - * (without it, the faint edge of the Earth's air is left out). - */ - private paintWorld(L: Layout, px: Int32Array, alpha: Float32Array | undefined, d: Dials): void { + /** The ground, the moondust and the Earth into the cells' pixels (CLEAR left for the open sky). */ + private paintWorld(L: Layout, px: Int32Array, d: Dials): void { const { pw, ph, s, sy, col0, row0, ex, er, steps } = L const tSec = (d.t * FRAME_MS) / 1000 const ey = lerp(L.eyLow, L.eyHigh, Number.isNaN(this.alt) ? clamp((d.level - 1) / 9) : this.alt) - const bits = alpha ? 0xff : 0xf8 // The ground, as built. for (let i = 0; i < pw * ph; i++) { @@ -588,24 +932,17 @@ export class Earthrise extends PixelScene { if (b < 0) continue const q = Math.round(Math.pow(clamp(b), 0.85) * steps) / steps px[i] = this.tinted(rampAt(GROUND, q)) - if (alpha) alpha[i] = 1 } // Moondust (smoke): a grey pall over the horizon, drifting. if (this.kSmoke > 0) { - const dustSteps = alpha ? 24 : 6 for (let x = 0; x < pw; x++) { let sky = 0 while (sky < ph && L.ground[sky * pw + x]! < 0) sky++ - const col = col0 + x * s for (let y = 0; y < sky; y++) { - const up = (sky - y) / Math.max(4, ph * 0.5) - const n = fbm(col * 0.033 + tSec * 0.4, (row0 + y * sy) * 0.0625, 2, 0) - const k = this.kSmoke * clamp(1.3 - up) * (0.55 + 0.45 * n) + const k = this.dust(L, x, y, sky, tSec) if (k <= 0.12) continue - const i = y * pw + x - px[i] = mix(0x1c1b1a, DUST, Math.round(k * dustSteps) / dustSteps) - if (alpha) alpha[i] = Math.min(1, k * 1.6) + px[y * pw + x] = mix(0x1c1b1a, DUST, Math.round(k * 6) / 6) } } } @@ -638,49 +975,50 @@ export class Earthrise extends PixelScene { } const m = nx * ny a /= m - if (a < (alpha ? 0.02 : 0.3)) continue - const c = this.tinted(lightColor(r / m / a, g / m / a, b / m / a, bits)) + if (a < 0.3) continue + const c = this.tinted(lightColor(r / m / a, g / m / a, b / m / a)) const under = px[i]! - if (!alpha) { - // Through the pall the Earth shows dimmer. - px[i] = under === CLEAR ? c : mix(under, c, 1 - pall * 0.5) - continue - } - const k = Math.min(1, a) * (1 - pall * 0.5) - if (under === CLEAR) { - px[i] = c - alpha[i] = Math.min(1, a) - } else { - px[i] = mix(under, c, k / Math.max(k, alpha[i]!) || 1) - alpha[i] = Math.max(alpha[i]!, k) - } + // Through the pall the Earth shows dimmer. + px[i] = under === CLEAR ? c : mix(under, c, 1 - pall * 0.5) } } } + /** How thick the moondust (smoke) is at pixel (x, y) of `L`, `sky` pixels of open sky above the ground in its column. */ + private dust(L: Layout, x: number, y: number, sky: number, tSec: number): number { + const up = (sky - y) / Math.max(4, L.ph * 0.5) + const n = fbm((L.col0 + x * L.s) * 0.033 + tSec * 0.4, (L.row0 + y * L.sy) * 0.0625, 2, 0) + return this.kSmoke * clamp(1.3 - up) * (0.55 + 0.45 * n) + } + paint(px: Painter, d: Dials): void { const L = this.cells const { pw, s, sy, col0, row0 } = L - this.paintWorld(L, px.px, undefined, d) + this.paintWorld(L, px.px, d) const tSec = (d.t * FRAME_MS) / 1000 - // Stars: braille specks in the open sky, thicker along the galaxy, none near the Earth; a few breathe. + // The sky, as braille specks in the open: the stars drifting by, shooting stars, comets. const dw = px.dw const dh = px.dh - const density = 0.011 * (1 + d.boost / 30) + const { r: R, g: G, b: B } = this.specks + if (R.length !== dw * dh) return + R.fill(0) + G.fill(0) + B.fill(0) + this.sky({ col0, row0, fx: s, fy: sy / 2, w: dw, h: dh }, R, G, B, false, tSec) for (let y = 0; y < dh; y++) { const pyy = y >> 1 - const row = row0 + ((y + 0.5) / 2) * sy for (let x = 0; x < dw; x++) { + const o = y * dw + x + const v = Math.max(R[o]!, G[o]!, B[o]!) + if (v < 0.16) continue const i = pyy * pw + x - if (L.near[i] || px.px[i] !== CLEAR) continue + if (px.px[i] !== CLEAR) continue const cell = (pyy >> 1) * (pw >> 1) + (x >> 1) // A cell any of whose pixels are painted keeps clear of specks. const c0 = (pyy & ~1) * pw + (x & ~1) if (px.px[c0] !== CLEAR || px.px[c0 + 1] !== CLEAR || px.px[c0 + pw] !== CLEAR || px.px[c0 + pw + 1] !== CLEAR) continue - const v = this.star(x, y, col0 + (x + 0.5) * s, row, density, tSec) - if (v < 0) continue - const star = this.tinted(v | 0x101010) + const star = this.tinted(lightColor(R[o]!, G[o]!, B[o]!) | 0x101010) // A cell's specks share one colour: the brightest of them. const prev = px.dots[cell] ? px.dotColor[cell]! : -1 px.dot(x, y, star) @@ -689,93 +1027,169 @@ export class Earthrise extends PixelScene { } } - /** A star at speck (x, y), at (col, row) in the original's view: its colour, or -1 for none. */ - private star(x: number, y: number, col: number, row: number, density: number, tSec: number, bits = 0xf8): number { - const bandD = (row - (4 + col * 0.32)) / 1.05 - const band = Math.exp(-((bandD / 10) ** 2)) * smooth(120, 70, col) - const h = hash(x * 3 + 1, y * 7 + 2, 11) - if (h < 1 - density * (1 + 3 * band)) return -1 - let v = 0.25 + 0.75 * Math.pow(hash(x + 17, y + 29, 5), 3) - // One in eight breathes, each at its own pace. - const tw = hash(x, y, 23) - if (tw > 0.875) v *= 0.55 + 0.45 * Math.sin((tSec / (3 + 3 * hash(x, y, 31))) * Math.PI * 2 + tw * 50) - v *= 1 - 0.6 * this.kSmoke - const tint = hash(x + 5, y + 77, 3) - const [tr, tg, tb] = tint < 0.3 ? [0.84, 0.9, 1] : tint > 0.88 ? [1, 0.93, 0.84] : [0.96, 0.96, 0.98] - return lightColor(v * tr, v * tg, v * tb, bits) - } - /** * This frame as a picture `w` × `h` pixels (square), RGBA, the open sky - * transparent: for a terminal that draws images. A new size's ground is - * built at most `budget` pixels a call; until it's all built (and at level - * 0) there is no picture: draw `grid()`. + * transparent: for a terminal that draws images. A halftone, as the + * original draws: a grid of dots, each sized by its light (Bayer-dithered + * between the steps), in the palette's nearest colour, the colour making + * up what the size could not. A new size's ground is built at most + * `budget` dots a call; until it's all built (and at level 0) there is no + * picture: draw `grid()`. */ picture(w: number, h: number, budget = Infinity): Uint8Array | undefined { if (this.strength <= 0 || w < 2 || h < 2) return undefined + const p = dotPitch(h) + const gw = Math.floor(w / p) + const gh = Math.floor(h / p) + if (gw < 2 || gh < 2) return undefined let L = this.pic - if (!L || L.pw !== w || L.ph !== h) L = this.pic = layout(w, h, 1, h > w) - if (L.done < w) { + if (!L || L.pw !== gw || L.ph !== gh) L = this.pic = layout(gw, gh, 1, h > w) + if (L.done < gw) { build(L, budget) - if (L.done < w) return undefined + if (L.done < gw) return undefined } let cv = this.canvas if (!cv || cv.w !== w || cv.h !== h) { - cv = this.canvas = { w, h, px: new Int32Array(w * h), alpha: new Float32Array(w * h), rgba: new Uint8Array(w * h * 4) } + const n = gw * gh + cv = this.canvas = { + w, + h, + p, + gw, + gh, + ox: Math.floor((w - gw * p) / 2), + oy: Math.floor((h - gh * p) / 2), + r: new Float32Array(n), + g: new Float32Array(n), + b: new Float32Array(n), + rgba: new Uint8Array(w * h * 4), + stamps: dotStamps(p), + } } const d = this.dials() - const { px, alpha, rgba } = cv - px.fill(CLEAR) - alpha.fill(0) - this.paintWorld(L, px, alpha, d) - - // Stars: a pixel each in the open sky, as thick across the sky as the specks are. + this.picT = d.t const tSec = (d.t * FRAME_MS) / 1000 - const density = 0.011 * (1 + d.boost / 30) * ((L.s * L.sy) / (BAND_S * BAND_S)) - for (let y = 0; y < h; y++) { - const row = L.row0 + (y + 0.5) * L.sy - for (let x = 0; x < w; x++) { - const i = y * w + x - if (L.near[i] || px[i] !== CLEAR) continue - const v = this.star(x, y, L.col0 + (x + 0.5) * L.s, row, density, tSec, 0xff) - if (v < 0) continue - px[i] = this.tinted(v) - alpha[i] = 1 + const { r: R, g: G, b: B, rgba, stamps } = cv + R.fill(0) + G.fill(0) + B.fill(0) + rgba.fill(0) + const { s, sy, col0, row0, ex, er, ground } = L + this.sky({ col0, row0, fx: s, fy: sy, w: gw, h: gh }, R, G, B, true, tSec) + + // Moondust (smoke) over the horizon, as grey light in the sky. + if (this.kSmoke > 0) { + for (let x = 0; x < gw; x++) { + let sky = 0 + while (sky < gh && ground[sky * gw + x]! < 0) sky++ + for (let y = 0; y < sky; y++) { + const k = this.dust(L, x, y, sky, tSec) * 0.5 + const o = y * gw + x + R[o] = R[o]! * (1 - k) + k * 0.47 + G[o] = G[o]! * (1 - k) + k * 0.455 + B[o] = B[o]! * (1 - k) + k * 0.435 + } } } - // Waiting on the person: the same sepia breath as the cells'. + const ey = lerp(L.eyLow, L.eyHigh, Number.isNaN(this.alt) ? clamp((d.level - 1) / 9) : this.alt) + const out = this.rgba const lift = d.wait > 0 ? waitLift(d.wait, d.t) : 1 const glow = d.wait > 0 ? waitGlow(d.wait, d.t) : 0 - for (let i = 0, o = 0; i < w * h; i++, o += 4) { - let c = px[i]! - if (c === CLEAR) { - rgba[o + 3] = 0 - continue + for (let y = 0; y < gh; y++) { + for (let x = 0; x < gw; x++) { + const i = y * gw + x + let cr: number + let cg: number + let cb: number + let cap = 1 + let floor = 0 + const gb = ground[i]! + if (gb >= 0) { + // The ground: grey, a touch warm; dim ground in darker ink, so it stays grey. + cr = gb + cg = gb * 0.95 + cb = gb * 0.86 + cap = 0.42 + 0.62 * gb + } else { + cr = R[i]! + cg = G[i]! + cb = B[i]! + } + if (L.near[i]) { + // The Earth and its air over the sky, 2 × 2 samples a dot. + let ar = 0 + let ag = 0 + let ab = 0 + let ac = 0 + let af = 0 + for (let jy = 0; jy < 2; jy++) { + const dy = row0 + (y + (jy + 0.5) / 2) * sy - ey + for (let jx = 0; jx < 2; jx++) { + earthAt(col0 + (x + (jx + 0.5) / 2) * s - ex, dy, er, this.spin, this.drift, out, cr, cg, cb) + ar += out[0]! + ag += out[1]! + ab += out[2]! + ac += out[4]! + af += out[5]! + } + } + const pall = 1 - this.kSmoke * 0.5 + cr = lerp(cr, ar / 4, pall) + cg = lerp(cg, ag / 4, pall) + cb = lerp(cb, ab / 4, pall) + cap = ac / 4 + floor = af / 4 + } + // The dot: its size from the light, dithered; its colour from the hue, making up what the size could not. + const peak = Math.max(cr, cg, cb, 1e-4) + const level = clamp(floor + (1 - floor) * Math.pow(peak, 0.85) * 0.95) + const step = Math.max(0, Math.min(3, Math.round(level * 3 + BAYER[(y & 3) * 4 + (x & 3)]!))) + if (!step) continue + const want = Math.min(1, (level + 0.06) / COVER[step]!) + const k = Math.min(cap, 0.3 + 0.7 * want) / peak + let c = this.tinted(nearest(cr * k, cg * k, cb * k)) + if (d.wait > 0) c = waitColor(c, d.wait, lift, SEPIA_AMOUNT, glow) + const st = stamps[step]! + const r8 = (c >> 16) & 255 + const g8 = (c >> 8) & 255 + const b8 = c & 255 + const px0 = cv.ox + x * p + const py0 = cv.oy + y * p + for (let yy = 0; yy < p; yy++) { + let o = ((py0 + yy) * w + px0) * 4 + for (let xx = 0; xx < p; xx++, o += 4) { + const a = st[yy * p + xx]! + if (a <= 0) continue + rgba[o] = r8 + rgba[o + 1] = g8 + rgba[o + 2] = b8 + rgba[o + 3] = Math.round(a * 255) + } + } } - if (d.wait > 0) c = waitColor(c, d.wait, lift, SEPIA_AMOUNT, glow) - rgba[o] = (c >> 16) & 255 - rgba[o + 1] = (c >> 8) & 255 - rgba[o + 2] = c & 255 - rgba[o + 3] = Math.round(clamp(alpha[i]!) * 255) } return rgba } pictureKey(w: number, h: number): string { - return `earthrise-ground-v${GROUND_VERSION}-${w}x${h}` + const p = dotPitch(h) + return `earthrise-ground-v${GROUND_VERSION}-${Math.floor(w / p)}x${Math.floor(h / p)}` } pictureCache(): { key: string; data: Float32Array } | undefined { const L = this.pic - return L && L.done >= L.pw ? { key: this.pictureKey(L.pw, L.ph), data: L.ground } : undefined + return L && L.done >= L.pw ? { key: `earthrise-ground-v${GROUND_VERSION}-${L.pw}x${L.ph}`, data: L.ground } : undefined } restorePicture(w: number, h: number, data: Float32Array): boolean { - if (w < 2 || h < 2 || data.length !== w * h) return false - const L = layout(w, h, 1, h > w) + const p = dotPitch(h) + const gw = Math.floor(w / p) + const gh = Math.floor(h / p) + if (gw < 2 || gh < 2 || data.length !== gw * gh) return false + const L = layout(gw, gh, 1, h > w) L.ground.set(data) - L.done = w + L.done = gw markNear(L) this.pic = L return true @@ -860,6 +1274,7 @@ function layout(pw: number, ph: number, ry: number, tall: boolean): Layout { sub, at, steps: ry === 1 ? PIC_STEPS : GROUND_STEPS, + horizon: row0 + ph * sy * 0.6, } } @@ -933,6 +1348,13 @@ function build(L: Layout, budget: number): void { /** Where the Earth and its air can reach, as it climbs and sinks: the sky round it, once the ground is in. */ function markNear(L: Layout): void { const { pw, ph, s, sy, col0, row0, ex, er, ground } = L + let sum = 0 + for (let x = 0; x < pw; x++) { + let y = 0 + while (y < ph && ground[y * pw + x]! < 0) y++ + sum += y + } + if (pw > 0) L.horizon = row0 + (sum / pw) * sy const air = (13 * er) / ER for (let y = 0; y < ph; y++) { const row = row0 + (y + 0.5) * sy diff --git a/pi/index.ts b/pi/index.ts index fc3f1af..9f8c8ac 100644 --- a/pi/index.ts +++ b/pi/index.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v172-pictures +// REVISION: flow-v173-halftone-pictures // // Flow for pi (badlogic/pi-mono), by Rob Macrae: the same ambient // scenes as the Claude Code mod, in a widget above pi's editor. pi's events @@ -63,8 +63,12 @@ const OLD_SETTINGS = [join(homedir(), '.pi', 'agent', 'vista.json'), join(homedi /** A picture's rows unless flow.json says (`rows`), and at most this share of the terminal's. */ const PICTURE_ROWS = 14 const PICTURE_SHARE = 0.5 -/** At most this many pixels a picture (the terminal scales it up to its cells), and at most one this often. */ -const PICTURE_PIXELS = 160_000 +/** + * At most this many pixels a picture (the terminal scales it up to its cells), and at most one this often: + * enough for earthrise's halftone dots at the terminal's own pixels in most windows, so they stay crisp + * (a frame of dots on a clear sky packs small, well under 100 KB). + */ +const PICTURE_PIXELS = 600_000 const PICTURE_MS = 100 /** A new size's picture is made between frames, this long at a time, with this long between for everything else. */ const PICTURE_BUILD_MS = 12 From 9654f3518dd370c1944a41f6df705117459e91c4 Mon Sep 17 00:00:00 2001 From: Patrick Desjardins Date: Fri, 9 Oct 2026 14:18:07 -0700 Subject: [PATCH 4/5] Doom and doom3d: a space marine fighting through a 1993 techbase, faster and harder with the work; doom3d through his own eyes in a taller band (Scene.taller); demo videos of earthrise, doom and doom3d in the README --- .claude-plugin/plugin.json | 6 +- AGENTS.md | 6 +- README.md | 29 +- hooks/doom.ts | 1534 ++++++++++++++++++++++++++++++++++++ hooks/register.tsx | 7 +- hooks/styles.ts | 11 +- pi/index.ts | 15 +- scripts/check.ts | 19 +- tests/flow.test.ts | 85 +- 9 files changed, 1684 insertions(+), 28 deletions(-) create mode 100644 hooks/doom.ts diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index aefafb4..2361dc5 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -36,7 +36,7 @@ "style": { "type": "string", "title": "Scene", - "description": "fire: a ░▒▓█ fire with sparks and smoke; warp: stars that speed up to hyperspace; avalon: a colony ship whose shield burns up the asteroids that hit it; balloon: a hot-air balloon that climbs from the grass to space; engine: a steampunk engine that runs up to full speed; falcon: a Falcon 9 carrying Dragon: its booster lands on its legs, Dragon comes home under parachutes to a splashdown; starship: a bigger rocket that hot-stages: its booster is caught by the tower's arms, the Ship splashes down; surf: a surfer and the swell; ski: a skier down the mountain; bubbles: bubbles fizzing up; train: a train through the countryside, faster with the work; earthrise: the Earth over the moon, rising and turning faster with the work", + "description": "fire: a ░▒▓█ fire with sparks and smoke; warp: stars that speed up to hyperspace; avalon: a colony ship whose shield burns up the asteroids that hit it; balloon: a hot-air balloon that climbs from the grass to space; engine: a steampunk engine that runs up to full speed; falcon: a Falcon 9 carrying Dragon: its booster lands on its legs, Dragon comes home under parachutes to a splashdown; starship: a bigger rocket that hot-stages: its booster is caught by the tower's arms, the Ship splashes down; surf: a surfer and the swell; ski: a skier down the mountain; bubbles: bubbles fizzing up; train: a train through the countryside, faster with the work; earthrise: the Earth over the moon, rising and turning faster with the work; doom: a space marine fighting through a 1993 techbase, faster and harder with the work; doom3d: the same fight through his own eyes, in a taller band", "default": "fire", "options": [ "fire", @@ -50,7 +50,9 @@ "ski", "bubbles", "train", - "earthrise" + "earthrise", + "doom", + "doom3d" ] }, "idle": { diff --git a/AGENTS.md b/AGENTS.md index e025e2a..751be64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ hooks/ fire.ts the fire scene's automaton (its heat grid), glyphs and 256-color ramps fire-palette.ts the fire scene's truecolor ramps .ts one file per scene: starfield (warp), colony, balloon + sky + clouds/, - engine, rocket (falcon, starship), surf, ski, bubbles, train + engine, rocket (falcon, starship), surf, ski, bubbles, train, doom (doom3d) pi/ index.ts pi adapter (widget above the editor, /flow, ~/.pi/agent/flow.json) picture.ts a scene's `picture` as a kitty image: whether the terminal draws them, its size, the escape sequence @@ -59,12 +59,12 @@ A scene implements `Scene` (`hooks/styles.ts`). Its file exports a `SceneDef` (` - **Dials**, set every frame by `SceneDriver.dial()`: - `strength` 0–10: 0 draws nothing, every cell blank; 1 is calm idle; 10 is the busiest. Ease changes; never jump. - `coverageBoost`: above 0 while subagents run (15 each; in manual mode too). Add company (more streams, thicker steam). - - `agents`: each running subagent on its own (`AgentDial`: a stable `id`, `state` `'working' | 'idle' | 'waiting' | 'done'`, `ok` once done, its `task` and `type`, `ms` run), in the order they started (in manual mode too: holding a level holds how busy it looks, not who's about); `[]` in pi. The adapter derives it (`Roster`, `agents.ts`) from `agent.list()` polls (teammates left out) and each agent's own events by `agentId`: a model step or streamed output or a tool call is working, a tool running keeps it working, nothing for `QUIET_MS` (20 s) or a held/between-turns status is idle, a dialog put to the person is waiting (a permission once `classic.PermissionRequest` shows its dialog, since auto mode's classifier settles most asks alone, matched to that loop's running call of the tool (by its input when the loop runs several), each noted as `tool.call` starts it, so another call ending never ends it; `AskUserQuestion`/`ExitPlanMode` from the start; it ends with the call, a refusal too since the permission prompt runs beneath `tool.call`, or its `ToolProgress` pill; one tracker, `Waits` in `waits.ts`, keeps every loop's waits for the roster and the `waiting` dial alike), its `turn.complete` (or its status ending, or its leaving the list) is done, kept `DONE_MS` (6 s) so its companion can leave. What's heard from a loop before a poll names it (tools running, waits) is kept and counts once it is. A scene that gives each agent a companion keeps a `Crew` (`crew.ts`): set `crew.room` to how many companions this layout can actually show (the spine fits fewer), `update(this.agents, this.coverageBoost)` once a frame, then draw `crew.mates`, every one of them on screen: each keeps its `slot` (its colors, its place; only slots below `room` are given, and a layout with less room sends the rest back to wait) while on screen, `here` eases 0 → 1 as it arrives and back to 0 as it leaves (then its slot frees for an agent waiting past `capacity`), `busy` eases 1 working / 0 resting, `waiting` and `leaving`/`ok` say the rest (`resting`, `beckon` for a waiting one's blink, `finished`, `failed`, `easeTo` and `crew.inSlot` say them alike in every scene). Arrive from an edge, work visibly, rest visibly (and show waiting on you), leave out of sight (failed, another way); never pop. An adapter with no list (only a count) still gets anonymous working companions from `coverageBoost`. Note each companion's cells with `crew.mark` while drawing and return `crew.marks` from `agentMarks()`: desktop puts the agent's hover card there. Fire, surf, ski, balloon, falcon, starship (a mini rocket each: a hosted `LaunchSite` flying the same code, a few frames late, in the host's world and pixel layer), engine, train and avalon (probes launched from the ship) have companions (the fire's are small fires of their own beside the main one, which narrows to make room: one automaton, its burners shaped column by column); warp and bubbles keep plain coverage (a first-person stream, anonymous bubbles: nothing stays put to be someone). + - `agents`: each running subagent on its own (`AgentDial`: a stable `id`, `state` `'working' | 'idle' | 'waiting' | 'done'`, `ok` once done, its `task` and `type`, `ms` run), in the order they started (in manual mode too: holding a level holds how busy it looks, not who's about); `[]` in pi. The adapter derives it (`Roster`, `agents.ts`) from `agent.list()` polls (teammates left out) and each agent's own events by `agentId`: a model step or streamed output or a tool call is working, a tool running keeps it working, nothing for `QUIET_MS` (20 s) or a held/between-turns status is idle, a dialog put to the person is waiting (a permission once `classic.PermissionRequest` shows its dialog, since auto mode's classifier settles most asks alone, matched to that loop's running call of the tool (by its input when the loop runs several), each noted as `tool.call` starts it, so another call ending never ends it; `AskUserQuestion`/`ExitPlanMode` from the start; it ends with the call, a refusal too since the permission prompt runs beneath `tool.call`, or its `ToolProgress` pill; one tracker, `Waits` in `waits.ts`, keeps every loop's waits for the roster and the `waiting` dial alike), its `turn.complete` (or its status ending, or its leaving the list) is done, kept `DONE_MS` (6 s) so its companion can leave. What's heard from a loop before a poll names it (tools running, waits) is kept and counts once it is. A scene that gives each agent a companion keeps a `Crew` (`crew.ts`): set `crew.room` to how many companions this layout can actually show (the spine fits fewer), `update(this.agents, this.coverageBoost)` once a frame, then draw `crew.mates`, every one of them on screen: each keeps its `slot` (its colors, its place; only slots below `room` are given, and a layout with less room sends the rest back to wait) while on screen, `here` eases 0 → 1 as it arrives and back to 0 as it leaves (then its slot frees for an agent waiting past `capacity`), `busy` eases 1 working / 0 resting, `waiting` and `leaving`/`ok` say the rest (`resting`, `beckon` for a waiting one's blink, `finished`, `failed`, `easeTo` and `crew.inSlot` say them alike in every scene). Arrive from an edge, work visibly, rest visibly (and show waiting on you), leave out of sight (failed, another way); never pop. An adapter with no list (only a count) still gets anonymous working companions from `coverageBoost`. Note each companion's cells with `crew.mark` while drawing and return `crew.marks` from `agentMarks()`: desktop puts the agent's hover card there. Fire, surf, ski, balloon, falcon, starship (a mini rocket each: a hosted `LaunchSite` flying the same code, a few frames late, in the host's world and pixel layer), engine, train, avalon (probes launched from the ship) and doom and doom3d (marines of a co-op squad) have companions (the fire's are small fires of their own beside the main one, which narrows to make room: one automaton, its burners shaped column by column); warp and bubbles keep plain coverage (a first-person stream, anonymous bubbles: nothing stays put to be someone). - `tint`: `'smoke'` (a failed command or compaction) or `'blue'` (context nearly full). Must be clearly visible in every scene, at every level, in both layouts, and to colour-blind eyes: move lightness or blue-yellow, not only red-green (white to cyan, say, is a change of red alone, and red-green colour blindness can't see it). `npm run cvd` measures it. - `night`: for scenes with a night version. Ease a `kNight` toward it with `easeNight` rather than switching in one frame. Use `night.ts` so every night matches, and falls at the same pace. - `waiting`: Claude waits on the person (a dialog is up for them: a permission, its question, a plan to approve; auto mode only, never in pi). Any loop's dialog counts (a background subagent's too); the main turn's 30 s clock stops for the main loop's own waits and for a dialog up, not for a subagent's ask the mode is still deciding, and the main turn ending forgets only its own. A wait is its session's: the adapter forgets them when the session ends, so a /clear or a resume never carries one over. The level drops to a calm 2 by itself (`WAIT_LEVEL`). Every scene eases a `kWait` toward it (`easeWait`) and ends `grid()` with `waitTone(out, kWait, t)`: the frame turns sepia and breathes, the same in every scene, so it reads at a glance and never like smoke or blue (`PixelScene` does both; `d.wait` says how far). Then settle and hold your own way, never as if working: a place the level stands for holds (the balloon hovers, a rocket keeps its stage and doesn't launch), what moves comes to rest where that's natural (the skier stops, the engine runs down, the stars stop, the train draws up at a signal or a platform). A light can shine through the sepia, only breathing (`waitTone`'s `lamps`; in a `PixelScene`, paint it with `px.lamp`), as the train's signals do. A warm scene can skip the sepia (`waitTone(..., 0)`) but must breathe, as the fire's coals do. - **Sound** (`hooks/sound.ts`, see *Soundscapes* below): a scene's bed is layers of clips (`LAYERS`) at gains from the level and its `ambience()`, mixed ahead for each of its moods (`MOODS`); its events (`sounds`, an array the scene pushes `{ kind, v }` onto as things happen on screen, through `hear`, which caps it, so an adapter that never takes them stays bounded; pi takes and drops them each frame) play a clip (`EVENTS`) or, small and dense ones, a synthesized burst (`VOICES`). A scene without layers is silent. -- **Sizes**: the band is 5 rows × 80–250 columns, above the prompt. The spine is a pane about 13–22 columns × 30–60 rows. Every scene must look right in both. +- **Sizes**: the band is 5 rows × 80–250 columns, above the prompt. The spine is a pane about 13–22 columns × 30–60 rows. Every scene must look right in both. A scene with a view taller than the band's (`Scene.taller`: doom3d, the marine's own eyes) is given more above the prompt: in pi flow.json's `rows` (14 unless it says, at most half the terminal), in Claude Code as many as the prompt leaves, up to 14 (`TALL_ROWS`); `npm run check` times it at 250 × 14. It must still look right at 5 (a short terminal, the picker's thumbnail). Its first-person view, wider than tall, is a panorama (each column its own ray, about 140° across a wide band) rather than the spine's flat projection, so the far edges don't stretch. - **Drawing**: - Most scenes paint a pixel layer at 2 × 2 pixels per cell, then fold it into quadrant glyphs with `fitQuad`. A cell holds only two colors; give `fitQuad` a sprite's pixels as its `keep` so a small figure never drops out of its own cell. - Braille (`BRAILLE`, 2 × 4 dots a cell) is for fine specks: spray, stars, bubbles. diff --git a/README.md b/README.md index 6e86611..7b024c4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Flow's fire above the prompt: pilot lights at idle, climbing with each of Claude's tool calls, an inferno with three subagents, then back down when Claude is done -Ambient scenes in your terminal that move with the work your coding agent is doing: a fire, fizzing bubbles, the surf, a ski run, two rockets, a hot-air balloon, a steam engine, a train through the countryside, a colony ship behind its shield, and the Earth rising over the moon. They run above the prompt of Claude Code (in the terminal or the Claude desktop app) or [pi](https://github.com/badlogic/pi-mono), or in a tall pane beside Claude Code's transcript. +Ambient scenes in your terminal that move with the work your coding agent is doing: a fire, fizzing bubbles, the surf, a ski run, two rockets, a hot-air balloon, a steam engine, a train through the countryside, a colony ship behind its shield, the Earth rising over the moon, and a space marine fighting through a 1993 techbase. They run above the prompt of Claude Code (in the terminal or the Claude desktop app) or [pi](https://github.com/badlogic/pi-mono), or in a tall pane beside Claude Code's transcript. Each scene is drawn in 24-bit color from Unicode block, quadrant and braille characters, so several pixels share each terminal cell. In the Claude desktop app, which has no character grid to draw into, the same frames are drawn as images. @@ -70,6 +70,20 @@ Every scene has a level from 0 (off) to 10 (as busy as it gets). | `bubbles` | a couple of lazy strings of bubbles | a rolling, fizzing boil | | `train` | waiting at a red signal or a platform, its diesel idling (when the work starts the signal clears, the horn sounds and it pulls away; when it winds down the train pulls up again) | flat out past fields, villages, woods, rivers and stations, the poles and the line beside it a blur | | `earthrise` | the Earth half risen over a cratered lunar horizon in long, low sunlight, turning once in about two minutes, under a still sky of a few breathing stars (after [earthrise](https://ascii.rest/earthrise/) by @bas3line on ascii.rest) | the Earth risen clear of the horizon, spinning once every four seconds, its clouds running ahead; more stars, drifting past in layers, shooting stars, and now and then a comet trailing its tails | +| `doom` | a space marine in green armor plodding down a techbase corridor, pistol lowered, raising it now and then at a lone zombieman far down the hall | running and gunning with a plasma rifle, imps hurling fireballs, pinky demons charging, cacodemons floating in and barrels going up among them; the busier the work, the faster he moves, the faster he shoots and the heavier his gun (pistol, then shotgun from 3, chaingun from 6, plasma rifle from 9). In the tall pane it's his own view: down the corridor, doors rising as he nears them, his gun bobbing and the status bar with his ammo, health and face | +| `doom3d` | the same fight through his own eyes, everywhere: above the prompt too, in a taller band (14 rows where there's room) that sweeps wide down the corridor, his ammo in one corner, his face and health in the other | as `doom` | + +**`earthrise`**: the Earth rising over the moon and turning faster as the work picks up, drawn here in pi as a halftone picture. + +https://github.com/user-attachments/assets/afe7e272-0a48-4668-b106-f2ae4732e977 + +**`doom`**: the marine side-on above the prompt, moving faster and changing to heavier guns as the work grows, imps, pinky demons and cacodemons coming down the hall at him. + +https://github.com/user-attachments/assets/2e9e4247-8f1f-42b6-b0d1-60598e6e4957 + +**`doom3d`**: the same fight through his own eyes, in pi's taller 14-row band, his ammo in one corner and his face and health in the other. + +https://github.com/user-attachments/assets/20196ad3-8dae-4e6e-99db-83fcd560b20e `/flow pick` shows them all at once, each one live, to choose from with the arrow keys and Enter (in pi, a list). @@ -89,9 +103,9 @@ Balloon, falcon, starship, surf, ski and train also have a **night** version: st | running commands | sparks; a slow command keeps it ticking over | | reading or searching, or using any other tool (an MCP server's too) | a small spark each | | running subagents | busier with each one (with diminishing returns), and each one gets a companion of its own (see below); the rest add company: more stars, more lit windows, more streams of bubbles, more lights on the launch tower | -| hitting a failed command, or compacting | smoke for a moment: smoky flame tips, sooty steam, a grey sky, a wipeout, a rocket's plume sputtering grey, black smoke pouring from the train's diesel, a pall of moondust over the lunar horizon | -| near a full context (≥85%) | blue: a blue-white flame, a storm, dusk on the slopes, a blue gas flame, rain driving past the train, cold blue light over the moon | -| waiting on you: a permission to grant, its question, a plan to approve | settles to a calm 2 and holds, the whole scene turned a warm sepia that breathes slowly, about every 4 s: the fire banks to a bed of glowing coals, the stars stop, the colony ship comes to rest, the balloon hovers with its pilot light glowing, the engine runs down and lets off steam, a rocket holds its stage with amber lights up the tower, the surfer sits up waiting for a wave, the skier stops, the bubbles slow, the train draws up at a red signal or a platform, its headlight and the signal glowing with each breath, the Earth stops turning. With sound on, a soft chime as the wait begins | +| hitting a failed command, or compacting | smoke for a moment: smoky flame tips, sooty steam, a grey sky, a wipeout, a rocket's plume sputtering grey, black smoke pouring from the train's diesel, a pall of moondust over the lunar horizon, the techbase's lights going out under drifting smoke (and the marine's face bloodied) | +| near a full context (≥85%) | blue: a blue-white flame, a storm, dusk on the slopes, a blue gas flame, rain driving past the train, cold blue light over the moon, the techbase flooded with cold blue light | +| waiting on you: a permission to grant, its question, a plan to approve | settles to a calm 2 and holds, the whole scene turned a warm sepia that breathes slowly, about every 4 s: the fire banks to a bed of glowing coals, the stars stop, the colony ship comes to rest, the balloon hovers with its pilot light glowing, the engine runs down and lets off steam, a rocket holds its stage with amber lights up the tower, the surfer sits up waiting for a wave, the skier stops, the bubbles slow, the train draws up at a red signal or a platform, its headlight and the signal glowing with each breath, the Earth stops turning, the marine stops and lowers his gun as the monsters slink back into the dark, the computer panels glowing with each breath. With sound on, a soft chime as the wait begins | A plain answer sits around 5, edit-and-test loops reach about 8, and 10 takes several subagents editing in parallel, or a long turn: it climbs a level every 30 s it keeps going. @@ -99,7 +113,7 @@ Waiting on you shows only while a dialog is actually up for you: a permission as ### Subagents, one by one -In fire, surf, ski, balloon, falcon, starship, engine, train and avalon, each running subagent has a companion of its own, in its own colors or its own place, so you can tell at a glance which are busy, which have gone quiet or are waiting on you, and when each finishes: +In fire, surf, ski, balloon, falcon, starship, engine, train, avalon, doom and doom3d, each running subagent has a companion of its own, in its own colors or its own place, so you can tell at a glance which are busy, which have gone quiet or are waiting on you, and when each finishes: | Scene | Arrives | Working | Quiet (nothing for 20 s) | Waiting on you (a permission dialog, its question) | Done | |---|---|---|---|---|---| @@ -110,9 +124,10 @@ In fire, surf, ski, balloon, falcon, starship, engine, train and avalon, each ru | `falcon`, `starship` | a mini rocket (the same rocket, smaller) on a little launch site of its own beside the big one | flies the mission with the big one, a beat behind: lifts off, stages (its booster coming home beside the big one's: falcon's on its legs, starship's into its own tower's arms), keeps formation into orbit and comes home with it (Dragon under its own chutes; the Ship belly-first, carried home and stacked) | coasts, dropped back, a light blinking on its nose; on the ground it doesn't launch | an amber beacon flashes | fades as it pulls away ahead (failed: tumbles away in smoke) | | `engine` | its lamps on the bed plate warm up | a light runs along its lamps | its lamps glow low | its lamps flash together | its lamps fade out (failed: fading red) | | `train` | a short train of its own draws up alongside from out of sight | keeps pace on the next track, gaining and losing a little, headlamp lit | drops back a little, headlamp out | its cab flashes amber | falls back out of sight | +| `doom`, `doom3d` | another marine in co-op colors (indigo, brown, red, grey) runs in from behind (through his eyes, past him) and falls in with the squad | fires with him at whatever comes | drops back a little, gun lowered | a lamp blinks amber over its head | teleports out in a shimmer of green fog (failed: falls where it stood, and the squad walks on) | | `avalon` | a probe launches from a bay on the ship's hull (the hatch glowing in its color) and flies up beside it | holds station by the ship on its own flickering drive | drive off, falls back along the ship, a running light blinking | an amber beacon flashes | turns about and burns away aft, off toward home (failed: drive dead, tumbles away dark) | -A scene has room for a few (the fire 4 to 6 small fires across the band, 1 or 2 in the spine, surf 5, ski 4, balloon 4, the rockets 3 mini rockets (one of them behind the big launch site in the spine; 1 in a spine narrower than 19 columns), the engine 6 lamp groups, the train up to 4 trains, avalon 4 probes; fewer in the narrow spine or a narrow band: only as many as it shows whole); more wait for a place to come free. Warp and bubbles keep the plain company above: they're a stream of stars, a glass of anonymous bubbles, with nothing that stays put long enough to be someone. In the Claude desktop app, put the pointer over a companion to see its subagent's task, what it's doing and for how long. +A scene has room for a few (the fire 4 to 6 small fires across the band, 1 or 2 in the spine, surf 5, ski 4, balloon 4, the rockets 3 mini rockets (one of them behind the big launch site in the spine; 1 in a spine narrower than 19 columns), the engine 6 lamp groups, the train up to 4 trains, avalon 4 probes, doom 4 marines (2 in a spine narrower than 14 columns); fewer in the narrow spine or a narrow band: only as many as it shows whole); more wait for a place to come free. Warp and bubbles keep the plain company above: they're a stream of stars, a glass of anonymous bubbles, with nothing that stays put long enough to be someone. In the Claude desktop app, put the pointer over a companion to see its subagent's task, what it's doing and for how long. ## The command @@ -172,6 +187,8 @@ The same scenes run as a pi extension (`pi/index.ts`): a widget above pi's edito In a terminal that draws images (Ghostty, kitty, WezTerm, Warp; not under tmux), `earthrise` shows in pi as a picture at the terminal's own pixels rather than in characters, drawn as the original is: a halftone of dots sized by brightness in the original's 38 colours, 14 rows tall (never more than half the terminal). Set `"rows"` in `~/.pi/agent/flow.json` for another height, or `"pictures": false` to keep the characters. The first time pi opens at a new width the characters show for a moment while the picture's ground is made. Flow keeps the ground of the last few sizes in `~/.cache/flow`, so the picture shows at once the next time. The other scenes stay in characters. +`doom3d` takes the same height in characters (`"rows"` sets it too), for the view through the marine's eyes. In Claude Code its band is as tall as the prompt leaves room for, up to 14 rows. + pi tells an extension nothing of a wait on you, so there the scenes never show one. Nor has it a player, so the scenes are silent there: `/flow sound` says so. As in Claude Code, each session keeps its own settings, kept in the session itself (an entry the model never sees), so resuming or forking it brings them back. `~/.pi/agent/flow.json` holds the defaults new sessions start with; `/flow save` writes it. To try it without installing: `pi --extension ./pi/index.ts`. diff --git a/hooks/doom.ts b/hooks/doom.ts new file mode 100644 index 0000000..b382902 --- /dev/null +++ b/hooks/doom.ts @@ -0,0 +1,1534 @@ +// REVISION: flow-v176-doom-plain-names +// +// Doom (the `doom` scene): a space marine fighting his way through a 1993 +// techbase, on the same dials as the fire; the level is how hard he goes at +// it. At 1 he plods along with his pistol lowered, now and then raising it at +// a lone zombieman far down the hall. The busier the work, the faster he moves +// and the faster he shoots, and the heavier the gun: the pistol up to 2, the +// pump shotgun to 5, the chaingun to 8, then the plasma rifle, with imps +// hurling fireballs, pinky demons charging, cacodemons floating in, and +// barrels going up among them. When it eases off he slows and lowers his gun +// again; between enemies he keeps walking. +// +// Each subagent is another marine in co-op colors (crew.ts): indigo, brown, +// red, grey. One runs in from behind when its agent starts and falls in with +// the squad, firing with him while its agent works; quiet, it drops back a +// little, gun lowered; waiting on you, a lamp blinks amber over its head. Done, +// it teleports out in a shimmer of green fog (failed: it falls where it +// stood, and the squad walks on). +// +// A failed command (or a compaction) puts the lights out: the base goes grey +// and dim, smoke drifting under the ceiling (and in the tall view the +// marine's face is bloodied). A nearly-full context floods it with cold blue +// light. While Claude waits on the person he stops and lowers his gun, the +// monsters slink back into the dark and the fireballs fizzle; the computer +// panels and lamps glow through the sepia with each slow breath. +// +// The band is side-on: the corridor's back wall streaming past (tan and grey +// panels, computer banks blinking, doors, windows onto the mountains, wall +// lamps, nukage pools in the floor, flickering lights), the marine on the +// left with the squad behind him, the monsters coming from the right. The +// tall spine is his own view, as the game was: down a corridor fading into +// the dark, doors rising as he nears them, his gun bobbing at the bottom with +// its muzzle flash, and the status bar beneath with his ammo, his health and +// his face, glancing about, grimacing as he fires, grinning at a new gun. +// `doom3d` is the same scene through his eyes everywhere, the band too: it +// asks the adapter for a taller one (`taller`), and a view wider than tall is +// a panorama, each column its own ray (about 140° across a wide band), so +// its edges don't stretch as a flat projection's would; too short for the +// status bar, his ammo, face and health sit in its corners. +// Painted as pixels (2 × 2 a cell) by PixelScene, which eases the level, +// folds the pixels into glyphs and blanks it all at level 0. Everything here +// is drawn from scratch in the spirit of the original; no game data is used. + +import type { AgentDial } from './agents' +import { Rng } from './cells' +import { beckon, Crew, easeTo, failed, finished, type AgentMark, type Mate } from './crew' +import { PixelScene, type Dials, type Painter } from './pixel-scene' +import { defineScene } from './scene-def' +import { approach, clamp, grey, hash, hash1, luma, mix, noise1, noise2, rampAt, retain } from './pixels' + +// ── Sprites ────────────────────────────────────────────────────────────── + +/** A sprite: palette indices, 0 transparent. */ +type Sprite = { readonly w: number; readonly h: number; readonly p: Uint8Array } + +/** A sprite from rows of characters: '.' transparent, '1'-'9' (and on) a palette index. */ +function sprite(rows: readonly string[]): Sprite { + const h = rows.length + const w = Math.max(...rows.map(r => r.length)) + const p = new Uint8Array(w * h) + rows.forEach((r, y) => { + for (let x = 0; x < r.length; x++) p[y * w + x] = r[x] === '.' ? 0 : parseInt(r[x]!, 36) + }) + return { w, h, p } +} + +// The marine, side-on, facing right: 1 armor, 2 its shade, 3 visor, 4 gloves and boots, 5 belt. +const M_TOP = ['..2222..', '.211133.', '.2111112', '21111144', '2111112.', '.25552..'] +const M_TOP_REST = ['..2222..', '.211133.', '.2111112', '2111112.', '21111144', '.25552..'] +const M_LEGS = [ + ['.11.11..', '.12.12..', '.44.44..'], + ['.11..11.', '12....12', '44....44'], + ['..111...', '..112...', '..4444..'], +] +/** Ready (gun up) and at rest (gun lowered), each standing, striding and passing. */ +const MARINE = [M_TOP, M_TOP_REST].map(top => M_LEGS.map(legs => sprite([...top, ...legs]))) +/** Where the hand is that holds the gun: ready, and at rest. */ +const HAND = [ + [7, 3], + [7, 4], +] as const +/** Doom's co-op colors: the marine's green, then the squad's indigo, brown, red and grey (armor, its shade). */ +const ARMOR = [ + [0x3e9a30, 0x24601c], + [0x4a4ab8, 0x2a2a76], + [0x94703c, 0x5c4422], + [0xbc3024, 0x761a12], + [0xa0a09c, 0x62625e], +] as const +const marinePal = (i: number): number[] => [0, ARMOR[i]![0], ARMOR[i]![1], 0x1c1814, 0x5a4026, 0x7c5c2c] +const MARINE_PAL = ARMOR.map((_, i) => marinePal(i)) + +/** The four guns, side-on: 3 dark steel, 4 steel, 5 bright steel, 6 wood, 8 plasma. `ax`: how far back of the tip the hand is. */ +const PISTOL = 0 +const SHOTGUN = 1 +const CHAINGUN = 2 +const PLASMA = 3 +const GUNS = [ + { s: sprite(['345']), ax: 0 }, + { s: sprite(['6633445']), ax: 2 }, + { s: sprite(['3345454', '3333...']), ax: 1 }, + { s: sprite(['33444588', '333333..']), ax: 1 }, +] +const GUN_PAL = [0, 0, 0, 0x2e2e30, 0x5c5c60, 0x9a9aa0, 0x7a4a22, 0x4e2c12, 0x50b0ff, 0xd8f0ff] +/** Muzzle flashes: 1 orange, 2 yellow, 3 white (the plasma rifle's in blues). */ +const FLASH_SIDE = sprite(['.2.', '232', '.2.']) +const FLASH_PAL = [0, 0xff8020, 0xffd040, 0xfff8d8] +const PLASMA_FLASH_PAL = [0, 0x3070ff, 0x70c0ff, 0xe0f4ff] + +// The monsters, facing left. Each: walking (two frames), attacking. +const ZOMBIE = 0 +const IMP = 1 +const PINKY = 2 +const CACO = 3 +const FOES = [ + { + // A zombieman: 1 khaki shirt, 2 pale skin, 3 hair, 4 rifle, 5 green trousers, 6 boots. + frames: [ + ['..33...', '.2233..', '.222...', '..11...', '44411..', '.1111..', '.5..5..', '.5..5..', '66..66.'], + ['..33...', '.2233..', '.222...', '..11...', '44411..', '.1111..', '..55...', '..5.5..', '.66.66.'], + ['..33...', '.2233..', '.222...', '..11...', '44441..', '.1111..', '.5..5..', '.5..5..', '66..66.'], + ].map(sprite), + pal: [0, 0x9c8458, 0xb4a48c, 0x2a2218, 0x3a3a3c, 0x4c5c34, 0x2a2418], + hp: 2, + speed: 0.3, + stand: 72, + near: 36, + float: 0, + }, + { + // An imp: 1 brown hide, 2 its shade, 3 bone spikes, 4 eyes. + frames: [ + ['3.....3', '.11111.', '.14141.', '3111113', '1.333.1', '..111..', '.11.11.', '.1...1.', '33...33'], + ['3.....3', '.11111.', '.14141.', '3111113', '1.333.1', '..111..', '..1.1..', '..1.1..', '.33.33.'], + ['1.3.3.1', '1.111.1', '1141411', '.11111.', '..333..', '..111..', '.11.11.', '.1...1.', '33...33'], + ].map(sprite), + pal: [0, 0x86502a, 0x4e2c14, 0xdcc8a2, 0xffc828], + hp: 3.5, + speed: 0.36, + stand: 56, + near: 30, + float: 0, + }, + { + // A pinky demon: 1 pink hide, 2 its shade, 3 teeth, 4 eye. + frames: [ + ['...111111.', '..14111111', '.333111111', '.222111111', '.333211111', '..1111111.', '..22..22..'], + ['...111111.', '..14111111', '.333111111', '.222111111', '.333211111', '..1111111.', '...22.22..'], + ['...111111.', '..14111111', '3332111111', '2...211111', '3332111111', '..1111111.', '..22..22..'], + ].map(sprite), + pal: [0, 0xd47c8a, 0x904454, 0xf2ead2, 0xffe040], + hp: 7, + speed: 0.8, + stand: 14, + near: 14, + float: 0, + }, + { + // A cacodemon: 1 red hide, 2 its shade, 3 green eye, 4 mouth, 5 teeth, 6 horns. + frames: [ + ['.6.....6.', '..11111..', '.1113111.', '111333111', '115555511', '114444411', '.1155511.', '..22222..'], + ['.6.....6.', '..11111..', '.1113111.', '111333111', '115555511', '114444411', '.1155511.', '..22222..'], + ['.6.....6.', '..11111..', '.1113111.', '115555511', '144444441', '144444441', '.1555551.', '..22222..'], + ].map(sprite), + pal: [0, 0xc8301e, 0x861a10, 0x48e048, 0x1c1c60, 0xf0f0e0, 0xd8c8a0], + hp: 9, + speed: 0.26, + stand: 60, + near: 34, + float: 3, + }, +] as const + +/** The monsters' colors when a blast has torn them apart. */ +const GIB_PAL = FOES.map(k => k.pal.map((c, i) => (i ? mix(c, 0x9a1010, 0.6) : c))) + +/** A barrel of nukage: 1 its drum, 2 the bands, 3 the glowing slime on top. */ +const BARREL = sprite(['3333', '1221', '1111', '1221', '1111', '2222']) +const BARREL_PAL = [0, 0x6c7258, 0x3e4434, 0x5cdc3a] +/** What flies: an imp's fireball, a cacodemon's lightning, the plasma rifle's bolt. */ +const BALL = sprite(['.121.', '12321', '23332', '12321', '.121.']) +/** ...and as small as the band can show it. */ +const BALL_SMALL = sprite(['121', '232', '121']) +const FIRE_PAL = [0, 0xd03010, 0xff8a20, 0xfff0a0] +const CACO_PAL = [0, 0x9020a0, 0xe050d0, 0xffd0ff] +const BOLT = sprite(['12', '21']) +const BOLT_PAL = [0, 0x3a8aff, 0xd8f4ff] + +// The tall view's own: the gun in his hands, its flash, his face, and the status bar's digits. +const FP_GUNS = [ + ['.....55.....', '.....45.....', '.....43.....', '....4433....', '....4433....', '...114431...', '..1111311...', '..1121111...', '.111211111..', '.112211111..', '1122111111..', 'aa222111aa..'], + ['.....55.....', '.....45.....', '.....45.....', '.....43.....', '....4433....', '....6677....', '....6677....', '...166771...', '..11667711..', '..11444411..', '.1114433111.', '.1124433211.', 'aa22443322aa'], + ['....5.5.5....', '....4.4.4....', '....45454....', '....45454....', '...3454543...', '...3333333...', '..134444431..', '..113333311..', '.11143334111.', '.11243334211.', 'aa224333422aa'], + ['.....898.....', '.....989.....', '....49894....', '....45554....', '...4455544...', '...3444443...', '..133333331..', '..113333311..', '.11122222111.', 'aa112222211aa'], +].map(sprite) +/** 1 skin, 2 its shade, 3 dark steel, 4 steel, 5 bright steel, 6 wood, 7 dark wood, 8 and 9 the plasma rifle's glow, a his sleeves. */ +const FP_PAL = [0, 0xc08a62, 0x8a5a3a, 0x2e2e30, 0x5c5c60, 0x9a9aa0, 0x7a4a22, 0x4e2c12, 0x50b0ff, 0xd8f0ff, 0x2e6a2a] +const FP_FLASH = sprite(['..1.1..', '.12221.', '1223221', '.12221.', '..121..']) + +/** His face: 1 hair, 2 skin, 3 its shade, 4 eye whites, 5 pupils, 6 mouth, 7 blood, 8 teeth, 9 brows. */ +const FACE_PAL = [0, 0x5a3a1c, 0xc8946a, 0x946442, 0xece6dc, 0x1a1410, 0x5a2016, 0xb01010, 0xe8e0c8, 0x3a2410] +const FACE_ROWS = (eyes: string, mouth: string, under = '..322223..', brows = '1299229921') => [ + '..111111..', + '.11111111.', + '1122222211', + brows, + '12' + eyes.slice(0, 2) + '22' + eyes.slice(2) + '21', + '1222332221', + '.32222223.', + mouth, + under, + '...3333...', +] +const LOOK_LEFT = 0 +const LOOK_AHEAD = 1 +const LOOK_RIGHT = 2 +const GRIN = 3 +const OUCH = 4 +const RAGE = 5 +const FACES = [ + FACE_ROWS('5454', '.32666623.'), + FACE_ROWS('4554', '.32666623.'), + FACE_ROWS('4545', '.32666623.'), + FACE_ROWS('4554', '.36888863.', '..366663..'), + FACE_ROWS('4444', '.32266223.', '..326623..', '1222222221'), + FACE_ROWS('4554', '.38888883.', '..322223..', '1999999991'), +].map(sprite) +/** Where blood shows on his face when things go badly. */ +const BLOODY = [ + [2, 2], + [3, 2], + [2, 3], + [7, 5], + [8, 6], + [7, 6], + [5, 8], +] as const +const DIGITS = ['111101101101111', '010110010010111', '111001111100111', '111001111001111', '101101111001001', '111100111001111', '111100111101111', '111001001001001', '111101111101111', '111101111001111'] + +// ── The world ──────────────────────────────────────────────────────────── + +/** A stretch of wall, in band pixels: one panel's kind and one sector's light. */ +const SEG = 32 +/** Band pixels to one unit of the tall view's world (the corridor is two wide). */ +const UNIT = 16 +/** A sprite pixel, in the tall view's units: the marine stands 0.85 tall, his eyes at 0.62. */ +const SPX = 0.095 + +const TAN = 0 +const COMPUTER = 1 +const WINDOW = 2 +const DOOR = 3 +const LAMP = 4 +const GREY = 5 + +function segKind(seg: number, seed: number): number { + const h = hash1(seg * 7919 + seed) + return h < 0.32 ? TAN : h < 0.48 ? COMPUTER : h < 0.6 ? WINDOW : h < 0.7 ? DOOR : h < 0.84 ? LAMP : GREY +} + +/** A sector's light: most steady, a few flickering as Doom's broken lights do. */ +function segLight(seg: number, t: number, seed: number): number { + let l = 0.6 + 0.24 * hash1(seg * 977 + seed) + if (segKind(seg, seed) === LAMP) l += 0.2 + if (hash1(seg * 131 + seed + 5) < 0.14 && hash1(((t >> 1) * 7 + seg * 13) ^ seed) < 0.22) l *= 0.5 + return l +} + +/** What the last texel was: a light of its own (a lamp: shines through the sepia) or glowing (unlit by the sector). */ +let lampHit = false +let glowHit = false +/** A window's texel: the sky beyond, drawn by the caller. */ +const SKY = -2 + +/** The wall at `lx` across its stretch (0..SEG), `v` up it (0 the floor, 1 the ceiling). */ +function wallTex(kind: number, lx: number, v: number, seg: number, t: number, seed: number): number { + lampHit = false + glowHit = false + const p = lx & 15 + switch (kind) { + case COMPUTER: { + if (lx < 2 || lx > 29 || v < 0.16 || v > 0.86) return lx === 1 || lx === 30 ? 0x3a3a38 : 0x5e5e5a + const row = Math.floor(v * 11) + if ((lx & 3) === 2 && (row & 1) === 0) { + const h = hash(seg * 64 + lx, row, seed) + if (hash(lx + (t >> 3) * 17, row + seg * 5, seed) < 0.72) { + lampHit = true + return h < 0.4 ? 0x38e848 : h < 0.65 ? 0xe83020 : h < 0.85 ? 0xf0d040 : 0x40d8e8 + } + return 0x2a3034 + } + return 0x1c2024 + } + case WINDOW: + if (lx < 4 || lx > 27) return lx === 0 || lx === 31 ? 0x3e2e1a : (lx & 1) && ((v * 9) | 0) % 3 === 0 ? 0x4a3820 : 0x70563a + if (v < 0.2 || v > 0.86) return v < 0.12 ? 0x5c503e : 0x7e705a + if (lx === 4 || lx === 27 || v < 0.25 || v > 0.81) return 0x2a2622 + return SKY + case DOOR: + if (v >= 0.82) return p === 0 ? 0x3e352a : 0x7f705a + if (lx === 4 || lx === 5 || lx === 26 || lx === 27) return (lx + Math.floor(v * 12)) & 1 ? 0xd8b020 : 0x201c14 + if (lx === 6 || lx === 25) return 0x4c4c48 + if (lx === 15 || lx === 16) return 0x5a5852 + return Math.floor(v * 16) % 3 === 0 ? 0x6a6862 : 0x8c8a82 + case LAMP: + if (lx >= 13 && lx <= 18 && v >= 0.5 && v <= 0.7) { + if (lx >= 14 && lx <= 17 && v >= 0.54 && v <= 0.66) { + lampHit = true + return 0xfff4d0 + } + return 0x6a6a64 + } + break + case GREY: + if (p === 0) return 0x3e3e3a + if (p === 1) return 0x9c9c96 + if (v < 0.14) return 0x50504c + return hash(lx + seg * SEG, Math.floor(v * 12), seed) < 0.1 ? 0x6c6c68 : 0x7c7c78 + } + // Tan panels (and round the lamps). + if (p === 0) return 0x3e352a + if (p === 1) return 0x9e8d70 + if (v < 0.14) return 0x5a4e3e + if (v > 0.6 && v < 0.72) return 0x625642 + return hash(lx + seg * SEG, Math.floor(v * 12), seed) < 0.1 ? 0x706450 : 0x7f705a +} + +/** Through a window: grey-blue sky over brown mountains (`sx` across the view, `v` up the window, 0..1). */ +function skyTex(sx: number, v: number, seed: number): number { + glowHit = true + const m = 0.3 + 0.32 * noise1(sx * 0.07, seed) + 0.12 * noise1(sx * 0.23, seed + 3) + if (v < m) return v < m - 0.12 ? 0x4a3e32 : 0x64544a + return v - m > 0.3 ? 0xa0a8b4 : 0x7c8898 +} + +/** The floor at `a` along and `lat` across (band pixels): brown tiles, now and then a pool of nukage. */ +function floorTex(a: number, lat: number, t: number, seed: number): number { + lampHit = false + glowHit = false + const seg = Math.floor(a / SEG) + const lx = a - seg * SEG + if (hash1(seg * 31 + 7 + seed) < 0.16 && lx > 3 && lx < 28 && Math.abs(lat) < 12) { + if (lx === 4 || lx === 27 || Math.abs(lat) >= 11) return 0x4a4a44 + glowHit = true + const n = noise2(a * 0.22 + t * 0.05, lat * 0.25 - t * 0.03, seed) + return n < 0.4 ? 0x1e6a10 : n < 0.68 ? 0x36a01c : 0x6ad83a + } + const l = Math.floor(lat) + if ((a & 15) === 0 || (l & 15) === 0) return 0x3a3428 + // Each tile its own shade, worn in patches. + const tile = hash(a >> 4, l >> 4, seed) < 0.5 + const worn = hash(a >> 2, l >> 2, seed + 1) < 0.22 + return tile ? (worn ? 0x52483a : 0x5c5242) : worn ? 0x4c4436 : 0x564c3e +} + +/** The ceiling: dark tiles, a light panel every other stretch. */ +function ceilTex(a: number, lat: number): number { + lampHit = false + glowHit = false + const m = ((a % 64) + 64) % 64 + if (m >= 28 && m < 36 && Math.abs(lat) < 4) { + glowHit = true + return 0xf0ecd8 + } + const l = Math.floor(lat) + if ((a & 15) === 0 || (l & 15) === 0) return 0x2c2822 + return hash(a >> 2, l >> 2, 77) < 0.2 ? 0x36302a : 0x3c362e +} + +/** `c` lit at `l` (1 its own color). */ +function lit(c: number, l: number): number { + if (l === 1) return c + const r = Math.min(255, ((c >> 16) & 255) * l) | 0 + const g = Math.min(255, ((c >> 8) & 255) * l) | 0 + const b = Math.min(255, (c & 255) * l) | 0 + return (r << 16) | (g << 8) | b +} + +/** Light in eighths: Raster paints at most 1024 color pairs a frame. */ +const q8 = (l: number) => Math.round(l * 8) / 8 + +/** The cold blue light of a nearly-full context, by brightness (lifting the dark: it floods the base). */ +const BLUE = [0x0c1a48, 0x2048a8, 0x5a8ae8, 0xb4d4ff, 0xf0f6ff] + +/** `c` under the tints: `s` of the way to grey and dim (smoke), `b` of the way to cold blue. */ +function tone(c: number, s: number, b: number): number { + if (s > 0) c = mix(c, lit(grey(c, 1), 0.42), s) + if (b > 0) c = mix(c, rampAt(BLUE, 0.22 + 0.78 * (luma(c) / 255)), b) + return c +} + +// ── What moves ─────────────────────────────────────────────────────────── + +/** How fast he goes at each level (band pixels a frame): a plod at 1, running at 10. */ +const SPEED = [0, 0.15, 0.24, 0.34, 0.46, 0.6, 0.78, 1, 1.28, 1.62, 2.05] +/** Frames between shots, by gun, at the bottom and the top of its range. */ +const RATE = [ + [20, 12], + [16, 11], + [5, 3], + [3, 2], +] as const +/** Damage a shot does, by gun (a zombieman takes 2, a cacodemon 9). */ +const DAMAGE = [1, 4, 1, 1.6] +/** Where each gun's range of levels starts and ends. */ +const GUN_FROM = [0, 2.5, 5.5, 8.5, 10] +/** He stands his ground when a monster's this close (band pixels). */ +const HOLD = 28 +/** ...and in the tall view, where they come nearer to be seen. */ +const HOLD_NEAR = 22 +/** Frames a weapon switch takes (lowered, then raised). */ +const SWITCH = 14 +/** Frames a monster takes to fall. */ +const DIE_T = 8 + +const WALK = 0 +const ATTACK = 1 +const PAIN = 2 +const DIE = 3 +const DEAD = 4 +const FLEE = 5 + +type Foe = { kind: number; x: number; u: number; hp: number; st: number; t: number; walk: number; cool: number; flash: number; gib: boolean; seed: number } +type Missile = { kind: number; x: number; u: number; h: number; v: number; t: number } +type Puff = { kind: number; x: number; u: number; h: number; t: number; life: number; seed: number } +type Barrel = { x: number; u: number; fuse: number } +type Door = { x: number; open: number } + +const BALL_FIRE = 0 +const BALL_CACO = 1 +const BALL_PLASMA = 2 +const BLOOD = 0 +const DUST = 1 +const BOOM = 2 +const SPARK = 3 + +/** The squad: as many as can follow him, frames to arrive and to leave. */ +const SQUAD = 4 +const ARRIVE = 30 +const LEAVE = 34 + +function speedAt(level: number): number { + const l = clamp(level, 0, 10) + const i = Math.min(9, Math.floor(l)) + return SPEED[i]! + (SPEED[i + 1]! - SPEED[i]!) * (l - i) +} + +function gunFor(level: number, now: number): number { + // A little either side of each change, so a level hovering there doesn't swap guns back and forth. + let g = now + while (g < PLASMA && level > GUN_FROM[g + 1]! + 0.25) g++ + while (g > PISTOL && level < GUN_FROM[g]! - 0.25) g-- + return g +} + +function rateOf(gun: number, level: number): number { + const k = clamp((level - GUN_FROM[gun]!) / Math.max(0.5, GUN_FROM[gun + 1]! - GUN_FROM[gun]!)) + return Math.round(RATE[gun]![0] + (RATE[gun]![1] - RATE[gun]![0]) * k) +} + +export class Doom extends PixelScene { + /** The subagents, one by one: each a marine of the squad. */ + agents: readonly AgentDial[] = [] + /** The squad (`x`: how far along from him, band pixels; `y`: frames to its next shot). */ + private crew = new Crew(SQUAD, ARRIVE, LEAVE) + private rng: Rng + /** How far he's come, band pixels, and how fast he's going. */ + private pos = 0 + private v = 0 + /** How far his legs have gone: their stride. */ + private walk = 0 + private gun = PISTOL + private nextGun = PISTOL + private switchT = 0 + private cool = 0 + private flash = 0 + private recoil = 0 + /** Frames since he last fired, or had something in his sights. */ + private sinceShot = 999 + private sinceSighted = 999 + private ammo = [50, 20, 120, 100] + private hp = 100 + private ouch = 0 + private grin = 0 + private look = LOOK_AHEAD + private lookT = 0 + private spawnT = 40 + private foes: Foe[] = [] + private missiles: Missile[] = [] + private puffs: Puff[] = [] + private barrels: Barrel[] = [] + private doors: Door[] = [] + /** The last stretch of corridor given its barrels (and, in the tall view, its doors). */ + private laid = Number.NaN + /** The smoke and the blue, eased, and in sixths. */ + private kSmk = 0 + private kBlu = 0 + private hold = false + + // Layout, from the grid. + private W = 0 + private H = 0 + private tall = false + /** Band: his column. Tall: the view's height (above the status bar), the status bar's. */ + private mx = 0 + private hv = 0 + private sb = 0 + /** Tall: each pixel's depth (sprites hide behind a closed door, and behind each other). */ + private zb = new Float32Array(0) + private sp: number[] = new Array(16).fill(0) + private sp2: number[] = new Array(16).fill(0) + /** + * Tall: how the view is projected. In a pane taller than wide, flat (as the + * game: `fy` pixels a unit at a unit away, across and down); in a view wider + * than tall, a panorama (`fth` pixels a radian across, so the far edges + * don't stretch): each column's ray, `ux` across and `uz` ahead. + */ + private pano = false + private fy = 1 + private fth = 1 + private ux = new Float32Array(0) + private uz = new Float32Array(0) + + /** Whether it asks for a taller band than the side-on view's: through his eyes everywhere. */ + readonly taller: boolean + + /** `firstPerson`: his own view in every layout, the band too (doom3d), not only in a tall pane. */ + constructor(seed = 1, readonly firstPerson = false) { + super(seed) + this.taller = firstPerson + this.rng = new Rng(Math.imul(seed, 2654435761) ^ 0x51ed27) + } + + /** The gun he has now: 0 the pistol, 1 the shotgun, 2 the chaingun, 3 the plasma rifle. */ + get weapon(): number { + return this.gun + } + + /** How fast he's going (band pixels a frame). */ + get speed(): number { + return this.v + } + + /** How far he's come (band pixels). */ + get distance(): number { + return this.pos + } + + /** The monsters coming at him (not falling, fallen, or slinking away). */ + get coming(): number { + return this.foes.filter(f => f.st < DIE).length + } + + /** Shots he's fired since the scene began. */ + shots = 0 + + agentMarks(): readonly AgentMark[] { + return this.strength > 0 ? this.crew.marks : [] + } + + protected resize(d: Dials): void { + this.W = d.columns * 2 + this.H = d.rows * 2 + this.tall = d.tall || this.firstPerson + this.mx = clamp(Math.round(this.W * 0.32), 8, Math.max(8, this.W - 16)) + this.sb = this.tall && this.H >= 50 ? clamp(Math.round(this.H * 0.13), 12, 14) : 0 + this.hv = this.H - this.sb + this.zb = new Float32Array(this.W * Math.max(1, this.hv)) + this.project() + this.laid = Number.NaN + this.doors.length = 0 + this.barrels.length = 0 + } + + /** The tall view's projection for this size (see `pano`). */ + private project(): void { + const W = this.W + const HV = Math.max(1, this.hv) + this.pano = this.tall && W > HV + this.ux = new Float32Array(W) + this.uz = new Float32Array(W) + if (this.pano) { + // Wider the view, wider the sweep it takes in (to about 140° across a wide band); down it, a little + // more than his height between the floor at its foot and the ceiling at its top. + const fov = clamp(0.9 + 0.27 * (W / (2 * HV)), 1.4, 2.4) + this.fth = W / fov + this.fy = HV + for (let x = 0; x < W; x++) { + const a = (x + 0.5 - W / 2) / this.fth + this.ux[x] = Math.sin(a) + this.uz[x] = Math.cos(a) + } + } else { + this.fy = this.fth = W * 0.72 + for (let x = 0; x < W; x++) { + this.ux[x] = (x + 0.5 - W / 2) / this.fy + this.uz[x] = 1 + } + } + } + + /** Where a point `x` along (band pixels), `u` across lands in the tall view: false if out of sight. Sets `px`, `pt` (its distance), `pk` (pixels a unit there). */ + private onView(x: number, u: number, nearest: number): boolean { + const z = (x - this.pos) / UNIT + if (z < nearest || z > 12) return false + if (this.pano) { + this.pt = Math.hypot(u, z) + this.ps = this.W / 2 + Math.atan2(u, z) * this.fth + } else { + this.pt = z + this.ps = this.W / 2 + (this.fth * u) / z + } + this.pk = this.fy / this.pt + return true + } + private ps = 0 + private pt = 0 + private pk = 0 + + /** How far ahead of him a monster comes into sight (band pixels). */ + private ahead(): number { + return this.tall ? 11 * UNIT : this.W - this.mx + } + + /** How far he can shoot. */ + private range(): number { + return this.tall ? 6.5 * UNIT : Math.max(36, this.ahead() * 0.82) + } + + /** How far behind him things are out of sight. */ + private behind(): number { + return this.tall ? 8 : this.mx + 16 + } + + /** How many of the squad this layout shows: behind him in the band, ahead of him in the tall view. */ + private room(): number { + if (this.tall) return this.W >= 28 ? SQUAD : 2 + return clamp(Math.floor((this.mx - 6) / 11), 0, SQUAD) + } + + /** A closed door between him and `x` (the tall view's): nothing beyond it can be seen or shot. */ + private blocked(x: number): boolean { + for (const dr of this.doors) if (dr.x > this.pos && dr.x < x && dr.open < 0.7) return true + return false + } + + // ── Each frame ── + + protected update(d: Dials): void { + const L = d.level + this.kSmk = approach(this.kSmk, d.tint === 'smoke' ? 1 : 0, 0.1) + this.kBlu = approach(this.kBlu, d.tint === 'blue' ? 1 : 0, 0.1) + this.hold = d.wait > 0.3 + this.lay() + this.updateGun(L) + this.updateHim(L) + this.updateFoes(L) + this.updateSquad(L, d) + this.updateMissiles() + this.updateDoors() + for (const p of this.puffs) p.t++ + retain(this.puffs, p => p.t < p.life) + for (const b of this.barrels) if (b.fuse > 0 && --b.fuse === 0) this.explode(b) + const gone = this.pos - this.behind() - 24 + retain(this.barrels, b => b.fuse >= 0 && b.x > gone) + retain(this.foes, f => (f.st === DEAD ? f.x > gone : f.st !== FLEE || f.x - this.pos < this.ahead() + 20)) + // (No more than a few dozen fallen lying about: the oldest go first.) + let dead = 0 + for (let i = this.foes.length - 1; i >= 0; i--) if (this.foes[i]!.st === DEAD && ++dead > 30) this.foes.splice(i, 1) + this.updateFace(d) + } + + /** Barrels along the walls (and, in the tall view, doors across the corridor) for the stretches coming into sight. */ + private lay(): void { + const to = Math.floor((this.pos + this.ahead() + 40) / SEG) + if (Number.isNaN(this.laid)) this.laid = Math.floor((this.pos - this.behind()) / SEG) - 1 + while (this.laid < to) { + const seg = ++this.laid + if (hash1(seg * 53 + this.seed) < 0.24) + this.barrels.push({ x: seg * SEG + 6 + Math.floor(hash1(seg * 59 + this.seed) * 20), u: hash1(seg * 61 + this.seed) < 0.5 ? -0.78 : 0.78, fuse: 0 }) + if (this.tall && seg * SEG > this.pos + 2 * UNIT && hash1(seg * 41 + this.seed + 9) < 0.2) this.doors.push({ x: seg * SEG, open: 0 }) + } + } + + private updateGun(L: number): void { + const want = gunFor(L, this.nextGun) + if (want !== this.nextGun && this.switchT === 0) { + this.nextGun = want + this.switchT = 1 + } + if (this.switchT > 0) { + this.switchT++ + if (this.switchT === SWITCH >> 1) { + this.gun = this.nextGun + this.grin = 22 + } + if (this.switchT >= SWITCH) this.switchT = 0 + } + } + + /** The nearest monster still coming at him: how far ahead (Infinity: none). */ + private nearest(): number { + let best = Infinity + for (const f of this.foes) if (f.st < DIE && f.x - this.pos > -4) best = Math.min(best, f.x - this.pos) + return best + } + + /** What he (or one of the squad, from `from`) shoots at: a barrel with a monster by it, else the nearest monster. */ + private target(from: number, barrels: boolean): Foe | Barrel | undefined { + const range = this.range() + if (barrels) + for (const b of this.barrels) { + const db = b.x - from + if (b.fuse !== 0 || db < 34 || db > range || this.blocked(b.x)) continue + for (const f of this.foes) if (f.st < DIE && Math.abs(f.x - b.x) < 16) return b + } + let best: Foe | undefined + for (const f of this.foes) { + if (f.st >= DIE) continue + const df = f.x - from + if (df < -2 || df > range || this.blocked(f.x)) continue + if (!best || f.x < best.x) best = f + } + return best + } + + private updateHim(L: number): void { + let goal = this.hold ? 0 : speedAt(L) + if (this.nearest() < (this.tall ? HOLD_NEAR : HOLD)) goal = 0 + this.v += clamp(goal - this.v, -0.06, 0.035) + if (Math.abs(this.v) < 0.005) this.v = 0 + this.pos += this.v + this.walk += this.v + this.flash = Math.max(0, this.flash - 1) + this.recoil = Math.max(0, this.recoil - 1) + this.cool-- + this.sinceShot++ + this.sinceSighted++ + if (this.hold || L <= 0 || this.switchT > 0) return + const tg = this.target(this.pos, true) + if (!tg) return + this.sinceSighted = 0 + if (this.cool > 0) return + this.cool = rateOf(this.gun, L) + this.flash = this.gun === CHAINGUN || this.gun === PLASMA ? 1 : 2 + this.recoil = this.gun === SHOTGUN ? 5 : 2 + this.sinceShot = 0 + this.shots++ + this.ammo[this.gun]!-- + if (this.ammo[this.gun]! < 5) this.ammo[this.gun]! += [50, 20, 100, 100][this.gun]! + this.shoot(tg, this.gun, 1, this.pos + (this.tall ? 16 : 8)) + } + + /** A shot from `gun` at `tg`, doing `k` of its damage; a plasma bolt leaves from `muzzle`. */ + private shoot(tg: Foe | Barrel, gun: number, k: number, muzzle: number): void { + if (gun === PLASMA) { + this.missiles.push({ kind: BALL_PLASMA, x: muzzle, u: 0.12, h: 5, v: 7, t: 0 }) + return + } + if (!('kind' in tg)) { + this.explode(tg) + return + } + this.hit(tg, DAMAGE[gun]! * k) + if (gun === SHOTGUN) { + for (let i = 0; i < 2; i++) this.puff(BLOOD, tg.x + (this.rng.f() - 0.5) * 6, tg.u, 2 + this.rng.f() * 6, 5) + for (const f of this.foes) if (f !== tg && f.st < DIE && Math.abs(f.x - tg.x) < 10) this.hit(f, k) + } + } + + private hit(f: Foe, dmg: number): void { + const kind = FOES[f.kind]! + this.puff(BLOOD, f.x + (this.rng.f() - 0.5) * 3, f.u, kind.float + 2 + this.rng.f() * 5, 5) + f.hp -= dmg + if (f.hp <= 0) { + f.st = DIE + f.t = 0 + f.gib = dmg >= 8 + } else if (f.st === WALK && this.rng.f() < 0.45) { + f.st = PAIN + f.t = 3 + } + } + + private explode(b: Barrel): void { + if (b.fuse < 0) return + b.fuse = -1 + this.puff(BOOM, b.x + 2, b.u, 3, 9) + for (const f of this.foes) if (f.st < DIE && Math.abs(f.x - b.x) < 22) this.hit(f, 12) + for (const o of this.barrels) if (o.fuse === 0 && Math.abs(o.x - b.x) < 20) o.fuse = 3 + } + + private puff(kind: number, x: number, u: number, h: number, life: number): void { + if (this.puffs.length < 80) this.puffs.push({ kind, x, u, h, t: 0, life, seed: this.rng.int() & 0xffff }) + } + + private updateFoes(L: number): void { + const ahead = this.ahead() + let up = 0 + for (const f of this.foes) if (f.st < DIE) up++ + if (!this.hold && L > 0) { + this.spawnT-- + const most = 1 + Math.round(L * 0.75) + if (this.spawnT <= 0 && up < most) { + this.spawn(L, ahead) + this.spawnT = Math.round((230 - 218 * Math.pow(clamp(L / 10), 0.6)) * (0.6 + 0.8 * this.rng.f())) + } + } + const pace = 0.7 + 0.06 * L + for (const f of this.foes) { + const kind = FOES[f.kind]! + f.flash = Math.max(0, f.flash - 1) + if (f.st === DIE) { + if (++f.t >= DIE_T) f.st = DEAD + continue + } + if (f.st === DEAD) continue + if (this.hold) f.st = FLEE + else if (f.st === FLEE) f.st = WALK + const dist = f.x - this.pos + if (f.st === FLEE) { + // Back into the dark, away from him. + f.x += kind.speed * pace + 0.25 + f.walk += kind.speed * pace + 0.25 + continue + } + if (f.st === PAIN) { + if (--f.t <= 0) f.st = WALK + continue + } + if (f.st === ATTACK) { + f.t++ + if (f.t === 4) this.attack(f) + if (f.t >= 9) { + f.st = WALK + f.cool = Math.round((50 + 60 * this.rng.f()) * (1.3 - 0.07 * L)) + } + continue + } + const stand = this.tall ? kind.near : kind.stand + if (dist > stand) { + const step = Math.min(kind.speed * pace, dist - stand) + f.x -= step + f.walk += step + } + if (--f.cool <= 0 && dist < ahead - 4 && !this.blocked(f.x) && (f.kind !== PINKY || dist < stand + 4)) { + f.st = ATTACK + f.t = 0 + } + } + } + + private spawn(L: number, ahead: number): void { + const w = [1, L >= 2 ? 0.9 : 0, L >= 4 ? 0.45 + 0.05 * L : 0, L >= 6 ? 0.25 + 0.05 * L : 0] + let r = this.rng.f() * (w[0]! + w[1]! + w[2]! + w[3]!) + let kind = 0 + while (kind < 3 && r >= w[kind]!) r -= w[kind++]! + const k = FOES[kind]! + this.foes.push({ + kind, + x: this.pos + ahead + 6 + this.rng.f() * 14, + u: (this.rng.f() * 2 - 1) * 0.5, + hp: k.hp, + st: WALK, + t: 0, + walk: this.rng.f() * 8, + cool: 20 + Math.floor(this.rng.f() * 40), + flash: 0, + gib: false, + seed: this.rng.int() & 0xffff, + }) + } + + private attack(f: Foe): void { + const dist = f.x - this.pos + if (f.kind === ZOMBIE) { + f.flash = 2 + if (this.rng.f() < 0.25) this.hurt(2 + this.rng.f() * 4) + } else if (f.kind === IMP) { + this.missiles.push({ kind: BALL_FIRE, x: f.x - 2, u: f.u, h: 6, v: 1.5, t: 0 }) + } else if (f.kind === CACO) { + this.missiles.push({ kind: BALL_CACO, x: f.x - 3, u: f.u, h: FOES[CACO].float + 4, v: 1.15, t: 0 }) + } else if (dist < FOES[PINKY].stand + 6) { + this.hurt(3 + this.rng.f() * 5) + } + } + + private hurt(n: number): void { + this.hp = Math.max(12, this.hp - n) + this.ouch = 8 + } + + private updateMissiles(): void { + for (const m of this.missiles) { + m.t++ + if (m.kind === BALL_PLASMA) { + m.x += m.v + for (const f of this.foes) + if (f.st < DIE && m.x >= f.x - 2 && m.x - m.v <= f.x + 6) { + this.hit(f, DAMAGE[PLASMA]!) + m.t = -1 + break + } + if (m.x - this.pos > this.range() + 20 || this.blocked(m.x)) m.t = -1 + continue + } + if (this.hold) { + // Fizzling out as everything settles. + this.puff(SPARK, m.x, m.u, m.h, 4) + m.t = -1 + continue + } + m.x -= m.v + // Homing in on him (the tall view's camera): toward the middle of the corridor and down to his chest. + const dist = m.x - this.pos + const k = m.v / Math.max(m.v, dist) + m.u += (0 - m.u) * k + m.h += (5 - m.h) * k + if (dist < 4) { + this.puff(SPARK, this.pos + 4, 0, 5, 5) + this.hurt(4 + this.rng.f() * 6) + m.t = -1 + } + } + retain(this.missiles, m => m.t >= 0) + } + + private updateDoors(): void { + for (const dr of this.doors) { + let want = dr.x - this.pos < 3.5 * UNIT ? 1 : 0 + for (const f of this.foes) if (f.st !== DEAD && Math.abs(f.x - dr.x) < 1.2 * UNIT) want = 1 + dr.open = clamp(dr.open + (want ? 0.045 : -0.03)) + } + retain(this.doors, dr => dr.x > this.pos - 2 * UNIT) + } + + /** Where a member of the squad keeps, from him (band pixels; behind him in the band, ahead in the tall view). */ + private squadPlace(m: Mate): number { + const rest = m.busy < 0.5 && !m.leaving + if (this.tall) return [24, 32, 46, 54][m.slot]! * (rest ? 0.7 : 1) + return -(11 + m.slot * 11) - (rest ? 5 : 0) + } + + private updateSquad(L: number, d: Dials): void { + this.crew.room = this.room() + this.crew.update(this.agents, d.boost) + for (const m of this.crew.mates) { + if (Number.isNaN(m.x)) { + // In from behind: past the band's left edge, or past him in the tall view. + m.x = this.tall ? -14 : -(this.mx + 10) + m.y = 10 + Math.floor(m.seed * 20) + } + if (failed(m)) { + // It fell where it stood: the squad walks on. + m.x -= this.v + continue + } + if (finished(m)) continue + m.x = easeTo(m.x, this.squadPlace(m), m.age < ARRIVE * 2 ? 0.07 : 0.05) + m.y-- + if (this.hold || m.busy < 0.5 || m.here < 0.6 || this.switchT > 0) continue + const at = this.pos + m.x + const tg = this.target(at, false) + if (!tg || m.y > 0) continue + m.y = Math.round(rateOf(this.gun, L) * (1.4 + m.seed * 0.6)) + this.shoot(tg, this.gun, 0.6, at + 8) + } + } + + private updateFace(d: Dials): void { + this.ouch = Math.max(0, this.ouch - 1) + this.grin = Math.max(0, this.grin - 1) + if (d.t % 12 === 0) this.hp = Math.min(100, this.hp + 1) + if (--this.lookT <= 0) { + const r = this.rng.f() + this.look = this.hold ? LOOK_AHEAD : r < 0.3 ? LOOK_LEFT : r < 0.6 ? LOOK_RIGHT : LOOK_AHEAD + this.lookT = 18 + Math.floor(this.rng.f() * 30) + } + } + + // ── Drawing ── + + paint(px: Painter, d: Dials): void { + this.crew.clearMarks() + if (this.tall) this.paintView(px, d) + else this.paintBand(px, d) + // The tints over it all (a little less on the figures, so they still read). + const s = Math.round(this.kSmk * 6) / 6 + const b = Math.round(this.kBlu * 6) / 6 + if (s <= 0 && b <= 0) return + for (let i = 0; i < px.px.length; i++) { + const k = px.keep[i] ? 0.5 : 1 + px.px[i] = tone(px.px[i]!, s * k, b * k) + } + for (let i = 0; i < px.lamps.length; i++) if (px.lamps[i]! >= 0) px.lamps[i] = tone(px.lamps[i]!, s * 0.5, b * 0.5) + } + + /** A sprite with its top left at (x0, y0), `scale` pixels a sprite pixel, `rows` of it squashed into its height (a fall). */ + private blit(px: Painter, s: Sprite, pal: readonly number[], x0: number, y0: number, flip: boolean, scale = 1, rows = s.h, fade = 1, seed = 0, z = -1, lamp = -1): void { + const dw = Math.max(1, Math.round(s.w * scale)) + const dh = Math.max(1, Math.round(rows * scale)) + const X0 = Math.round(x0) + const Y0 = Math.round(y0) + const W = px.w + const yMax = z >= 0 ? this.hv : px.h + for (let j = 0; j < dh; j++) { + const y = Y0 + j + if (y < 0 || y >= yMax) continue + const sy = Math.min(s.h - 1, Math.floor(((j + 0.5) / dh) * s.h)) + for (let i = 0; i < dw; i++) { + const x = X0 + i + if (x < 0 || x >= W) continue + let sx = Math.min(s.w - 1, Math.floor(((i + 0.5) / dw) * s.w)) + if (flip) sx = s.w - 1 - sx + const c = s.p[sy * s.w + sx]! + if (!c) continue + if (fade < 1 && hash(i, j, seed) >= fade) continue + if (z >= 0) { + const k = y * W + x + if (this.zb[k]! <= z) continue + this.zb[k] = z + } + if (c === lamp) px.lamp(x, y, pal[c]!) + else px.set(x, y, pal[c]!, true) + } + } + } + + /** `pal` lit at `l`, into one of the two scratch palettes. */ + private shade(pal: readonly number[], l: number, second = false): number[] { + const out = second ? this.sp2 : this.sp + for (let i = 0; i < pal.length; i++) out[i] = lit(pal[i]!, l) + return out + } + + /** Whether he has his gun up: something in his sights lately, and not holding for the person. */ + private ready(): boolean { + return !this.hold && this.sinceSighted < 40 + } + + // The band: side-on. + + private paintBand(px: Painter, d: Dials): void { + const W = px.w + const H = px.h + const camX = this.pos - this.mx + const ceil = H >= 8 ? 1 : 0 + const floorRows = H >= 8 ? 2 : 1 + const wallBot = H - floorRows + const wallRows = Math.max(1, wallBot - ceil) + const fx = this.mx + 9 + const flashing = this.flash > 0 + const smoke = this.kSmk + for (let x = 0; x < W; x++) { + const a = Math.floor(camX + x) + const seg = Math.floor(a / SEG) + const lx = a - seg * SEG + const kind = segKind(seg, this.seed) + const sl = segLight(seg, d.t, this.seed) + const boost = flashing ? 0.35 * Math.max(0, 1 - Math.abs(x - fx) / 14) : 0 + for (let y = 0; y < H; y++) { + let c: number + if (y < ceil) c = ceilTex(a, 0) + else if (y < wallBot) { + const v = 1 - (y - ceil + 0.5) / wallRows + c = wallTex(kind, lx, v, seg, d.t, this.seed) + if (c === SKY) c = skyTex(x + camX * 0.15, (v - 0.25) / 0.56, this.seed) + } else c = floorTex(a, (y - wallBot) * 8 - 4, d.t, this.seed) + const lamp = lampHit + if (!lamp && !glowHit) c = lit(c, q8(sl + boost)) + if (smoke > 0 && y < wallBot) { + // Smoke drifting under the ceiling. + const n = noise2(a * 0.07 - d.t * 0.05, y * 0.45, this.seed + 11) + if (n > 0.45) c = mix(c, 0x707070, Math.round(smoke * (n - 0.45) * 2.2 * 4) / 4) + } + if (lamp) px.lamp(x, y, c) + else px.set(x, y, c) + } + } + // (In a band too short for them, their feet go below the bottom rather than their heads off the top.) + const foot = H - 1 + (H < 10 ? (10 - H) >> 1 : 0) + const sx = (wx: number) => Math.round(wx - camX) + // Barrels by the wall, then the fallen, then those still up (the farthest first). + for (const b of this.barrels) { + const x = sx(b.x) + if (x > -6 && x < W + 2) this.blit(px, BARREL, BARREL_PAL, x, foot + 1 - BARREL.h - 1, false, 1, BARREL.h, 1, 0, -1, 3) + } + for (let round = 0; round < 2; round++) + for (let i = this.foes.length - 1; i >= 0; i--) { + const f = this.foes[i]! + if ((f.st === DEAD || f.st === DIE) !== (round === 0)) continue + this.drawFoeBand(px, f, sx(f.x), foot, d) + } + // The squad, the farthest back first, then him. + for (let s = SQUAD - 1; s >= 0; s--) { + const m = this.crew.inSlot(s) + if (m) this.drawMateBand(px, m, sx(this.pos + m.x), foot, d) + } + this.drawMarineBand(px, 0, this.mx, foot, this.ready() ? 0 : 1, this.walk, this.v > 0.02, this.flash > 0, 1, 0, -1) + for (const m of this.missiles) { + const x = sx(m.x) + const y = foot - Math.round(m.h) + if (m.kind === BALL_PLASMA) { + this.blit(px, BOLT, BOLT_PAL, x, y - 1, false) + px.set(x - 2, y, 0x3a6ac8, true) + } else { + this.blit(px, BALL_SMALL, m.kind === BALL_FIRE ? FIRE_PAL : CACO_PAL, x - 1, y - 1, (m.t & 2) === 0) + px.set(x + 2, y, m.kind === BALL_FIRE ? 0xa02810 : 0x701880, true) + } + } + for (const p of this.puffs) this.drawPuffBand(px, p, sx(p.x), foot) + } + + private drawFoeBand(px: Painter, f: Foe, x: number, foot: number, d: Dials): void { + const kind = FOES[f.kind]! + const W = px.w + if (x < -12 || x > W + 4) return + const frame = f.st === ATTACK && f.t >= 2 && f.t < 7 ? 2 : Math.floor(f.walk / 3) & 1 + const s = kind.frames[frame]! + const bob = kind.float ? Math.round(Math.sin(d.t * 0.12 + f.seed) * 0.8) : 0 + if (f.st === DIE || f.st === DEAD) { + const k = f.st === DEAD ? 1 : f.t / DIE_T + const rows = Math.max(2, Math.round(s.h - (s.h - 2) * k)) + const lift = Math.round(kind.float * (1 - k)) + if (k > 0.5) for (let i = -1; i < s.w + 1; i++) px.set(x + i, foot, f.gib ? 0x9a0c0c : 0x6a0808, true) + const pal = f.gib && k > 0.5 ? GIB_PAL[f.kind]! : kind.pal + this.blit(px, s, pal, x, foot + 1 - rows - lift - (k > 0.5 ? 1 : 0), false, 1, rows) + return + } + const top = foot + 1 - s.h - kind.float + bob + const pal = f.st === PAIN ? this.shade(kind.pal, 1.5) : kind.pal + this.blit(px, s, pal, x, top, f.st === FLEE) + if (f.kind === IMP && f.st === ATTACK && f.t < 4) this.blit(px, BALL_SMALL, FIRE_PAL, x + 2, top - 2, false) + if (f.flash > 0) px.lamp(x - 1, top + 4, 0xffe070) + } + + /** A marine: `i` his colors, his feet's row, `pose` 0 ready or 1 at rest. */ + private drawMarineBand(px: Painter, i: number, x: number, foot: number, pose: number, walk: number, moving: boolean, firing: boolean, fade: number, seed: number, rows: number): void { + const leg = moving ? 1 + (Math.floor(walk / 3) & 1) : 0 + const s = MARINE[pose]![leg]! + const h = rows < 0 ? s.h : rows + const top = foot + 1 - h + this.blit(px, s, MARINE_PAL[i]!, x, top, false, 1, h, fade, seed) + if (rows >= 0) return + if (this.switchT > 2 && this.switchT < SWITCH - 2) return + const g = GUNS[this.gun]! + const [hx, hy] = HAND[pose]! + const gx = x + hx + 1 - g.ax + const gy = top + hy + this.blit(px, g.s, GUN_PAL, gx, gy, false, 1, g.s.h, fade, seed) + if (firing && fade > 0.5) this.blit(px, FLASH_SIDE, this.gun === PLASMA ? PLASMA_FLASH_PAL : FLASH_PAL, gx + g.s.w, gy - 1, false, 1, 3, 1, 0, -1, 3) + } + + private drawMateBand(px: Painter, m: Mate, x: number, foot: number, d: Dials): void { + const i = 1 + m.slot + const goal = this.squadPlace(m) + const running = Math.abs(goal - m.x) > 1.5 && !m.leaving + const pose = m.busy > 0.5 && !this.hold && !m.leaving ? 0 : 1 + const flash = pose === 0 && m.y > Math.round(rateOf(this.gun, d.level) * (1.4 + m.seed * 0.6)) - 2 + if (failed(m)) { + // It falls, then lies there fading. + const k = clamp((1 - m.here) * 2.2) + const rows = Math.max(2, Math.round(9 - 7 * k)) + if (k > 0.6) for (let j = -1; j < 8; j++) px.set(x + j, foot, 0x6a0808, true) + this.drawMarineBand(px, i, x, foot, 1, 0, false, false, clamp(m.here * 2), m.seed * 9999, rows) + } else { + const fade = finished(m) ? m.here : 1 + this.drawMarineBand(px, i, x, foot, pose, this.walk + m.seed * 6 + (running ? m.age * 0.6 : 0), this.v > 0.02 || running, flash, fade, m.seed * 9999 + m.age, -1) + if (finished(m)) this.fog(px, x + 3, foot - 4, 1 - m.here, m.age, 4) + } + if (m.waiting && beckon(m, d.t)) px.lamp(x + 3, foot - 9, 0xffb020) + this.crew.mark(m, x / 2, (foot - 9) / 2, 4, 5, d.columns, d.rows) + } + + /** Teleport fog: green-white sparkles about (x, y), `k` how far it has gone (dots `r` across). */ + private fog(px: Painter, x: number, y: number, k: number, t: number, r: number): void { + const n = Math.round(10 * Math.sin(Math.PI * clamp(k))) + for (let i = 0; i < n; i++) { + const a = hash1(t * 31 + i) * Math.PI * 2 + const rr = r * (0.3 + 0.7 * hash1(t * 17 + i * 3)) + px.dot((x + Math.cos(a) * rr) * 1, (y + Math.sin(a) * rr * 1.6) * 2, i & 1 ? 0xa8ff98 : 0xe8ffe0) + } + } + + private drawPuffBand(px: Painter, p: Puff, x: number, foot: number): void { + const y = foot - Math.round(p.h) + const k = p.t / p.life + if (p.kind === BLOOD) { + px.set(x, y + Math.floor(p.t / 2), 0xc81818, true) + if (p.t > 1) px.set(x + ((p.seed & 1) ? 1 : -1), y + p.t - 1, 0x8a0a0a, true) + } else if (p.kind === SPARK || p.kind === DUST) { + const c = p.kind === SPARK ? (k < 0.5 ? 0xfff0a0 : 0xff7020) : 0xa0a0a0 + px.set(x, y, c, true) + if (p.t < 3) { + px.set(x - 1, y - 1, c, true) + px.set(x + 1, y + 1, c, true) + } + } else { + // A barrel going up: a ball of fire swelling and dying red. + const r = 1.5 + 4.5 * Math.sqrt(k) + for (let dy = -6; dy <= 6; dy++) + for (let dx = -7; dx <= 7; dx++) { + const q = Math.hypot(dx, dy * 1.2) / r + if (q > 1) continue + const heat = (1 - q) * (1.15 - k) + if (heat < 0.08) continue + px.set(x + dx, y + dy, heat > 0.75 ? 0xfff4c0 : heat > 0.5 ? 0xffd040 : heat > 0.28 ? 0xff7a18 : 0xb02a0a, true) + } + } + } + + // The tall view: his own eyes. + + private paintView(px: Painter, d: Dials): void { + const W = px.w + const HV = this.hv + const fy = this.fy + const bobK = Math.min(1, this.v * 1.5) + const hz = HV * 0.5 + Math.sin(this.walk * 0.5) * 0.6 * bobK + this.hz = hz + const camZ = this.pos / UNIT + const HW = 1 + const EYE = 0.62 + const CEIL = 1.38 + const extra = this.flash > 0 ? 0.2 : 0 + const zb = this.zb + const smoke = this.kSmk + const UX = this.ux + const UZ = this.uz + // Each sector's light once a frame, for the stretches in sight (nothing beyond them is lit). + const s0 = Math.floor(this.pos / SEG) + const lights = this.lights + for (let k = 0; k < lights.length; k++) lights[k] = segLight(s0 + k, d.t, this.seed) + // (Each column's ray: `t` along it, `t * ux` across the corridor, `t * uz` ahead.) + for (let y = 0; y < HV; y++) { + const dy = y + 0.5 - hz + const tp = dy > 0 ? (fy * EYE) / dy : dy < 0 ? (fy * CEIL) / -dy : 1e9 + for (let x = 0; x < W; x++) { + const ux = UX[x]! + const uz = UZ[x]! + const tw = Math.abs(ux) > 1e-6 ? HW / Math.abs(ux) : 1e9 + let t = Math.min(tw, tp) + let c = 0 + let surf = tw < tp ? 0 : 1 + // A door across the corridor, raised as far as it's open. + for (const dr of this.doors) { + const td = (dr.x - this.pos) / UNIT / uz + if (td <= 0.05 || td >= t || dr.open >= 1) continue + const h = (-dy * td) / fy + if (h < -EYE + dr.open * (EYE + CEIL)) continue + t = td + surf = 2 + const lx = Math.floor((td * ux + 1) * 16) + const v = (h + EYE) / (EYE + CEIL) - dr.open + c = lx <= 1 || lx >= 30 ? 0x4c4c48 : lx === 15 || lx === 16 ? 0x5a5852 : Math.floor(v * 16) % 3 === 0 ? 0x6a6862 : 0x8c8a82 + lampHit = false + glowHit = false + break + } + if (surf === 0) { + const a = Math.floor((camZ + tw * uz) * UNIT) + const seg = Math.floor(a / SEG) + const side = ux < 0 ? 0 : 1 + const v = clamp(((-dy * tw) / fy + EYE) / (EYE + CEIL), 0, 0.999) + // Door tracks either side of a door's frame. + let track = false + for (const dr of this.doors) if (Math.abs(a - dr.x) <= 1) track = true + if (track) { + c = 0x4c4c48 + lampHit = false + glowHit = false + } else { + c = wallTex(segKind(seg * 2 + side, this.seed), a - seg * SEG, v, seg, d.t, this.seed + side) + if (c === SKY) c = skyTex(x * 2 + side * 60 + d.t * 0.02, (v - 0.25) / 0.56, this.seed) + } + } else if (surf === 1) { + const a = Math.floor((camZ + tp * uz) * UNIT) + const lat = tp * ux * UNIT + c = dy > 0 ? floorTex(a, lat, d.t, this.seed) : ceilTex(a, lat) + } + const fogL = t < 9.75 ? Math.min(1.1, 1.3 - t / 7.5) : 0 + const k = Math.floor(((camZ + t * uz) * UNIT) / SEG) - s0 + const l = q8((glowHit ? Math.max(0.85, fogL * 1.2) : fogL > 0 && k < lights.length ? lights[k]! * fogL : 0) + (t < 3 ? extra : 0)) + if (l <= 0.06) c = 0x0a0806 + else c = lit(c, l) + if (smoke > 0 && dy < 0) { + const n = noise2(x * 0.18 - d.t * 0.03, y * 0.2 + d.t * 0.01, this.seed + 11) + if (n > 0.45) c = mix(c, 0x686868, Math.round(smoke * (n - 0.45) * 2 * 4) / 4) + } + zb[y * W + x] = t + if (lampHit && l > 0.4) px.lamp(x, y, c) + else px.set(x, y, c) + } + } + // Everything in the corridor, as billboards, each hidden where something nearer stands. + for (const b of this.barrels) this.billboard(px, BARREL, BARREL_PAL, b.x, b.u, 0, false, 1, BARREL.h, d, 3) + for (const fo of this.foes) this.drawFoeView(px, fo, d) + for (const m of this.crew.mates) this.drawMateView(px, m, d) + for (const m of this.missiles) + if (m.kind === BALL_PLASMA) this.billboard(px, BOLT, BOLT_PAL, m.x, m.u, m.h, false, 0.9, BOLT.h, d, -1, true) + else this.billboard(px, BALL, m.kind === BALL_FIRE ? FIRE_PAL : CACO_PAL, m.x, m.u, m.h, (m.t & 2) === 0, 1, BALL.h, d, -1, true, 1, 0, 0.7) + for (const p of this.puffs) this.drawPuffView(px, p, d) + this.drawGun(px, d) + if (this.sb > 0) this.drawStatus(px) + else if (this.pano) this.drawHud(px) + } + + /** A wide view too short for the status bar: his ammo in the corner on the left, his face and health on the right. */ + private drawHud(px: Painter): void { + const HV = this.hv + if (HV < 20) return + const W = px.w + const y = HV - 7 + const ammo = this.ammo[this.gun]! + this.number(px, ammo, 2 + String(ammo).length * 4, y) + let right = W - 3 + if (HV >= 24) { + const fx = W - 14 + const fy = HV - 13 + px.rect(fx, fy, 12, 12, 0x2e2a24, true) + this.blit(px, FACES[this.faceNow()]!, FACE_PAL, fx + 1, fy + 1, false) + this.bloody(px, fx + 1, fy + 1) + right = fx - 3 + } + this.number(px, Math.round(this.hp), right, y) + } + + /** The face he makes now. */ + private faceNow(): number { + if (this.hold) return LOOK_AHEAD + if (this.ouch > 0) return OUCH + if (this.grin > 0) return GRIN + return this.sinceShot < 5 && this.gun >= CHAINGUN ? RAGE : this.look + } + + /** Blood on his face (its top left at x, y) when things go badly. */ + private bloody(px: Painter, x: number, y: number): void { + if (this.kSmk <= 0.5 && this.hp >= 40) return + const n = this.kSmk > 0.5 ? BLOODY.length : 3 + for (let i = 0; i < n; i++) px.set(x + BLOODY[i]![0], y + BLOODY[i]![1], 0xb01010, true) + } + + /** The tall view's sectors' lights this frame, from the one he's in (as far as can be seen: 9.75 units). */ + private lights = new Float32Array(Math.ceil((9.75 * UNIT) / SEG) + 2) + /** The tall view's horizon this frame. */ + private hz = 0 + /** Where the last billboard went (screen): its left, top, width, height. */ + private bx = 0 + private by = 0 + private bw = 0 + private bh = 0 + + /** + * A sprite standing in the corridor at `x` along (band pixels), `u` across + * (-1..1), `h` above the floor (sprite pixels), `size` times its own size; + * lit by its sector and the dark. False if it's out of sight. + */ + private billboard(px: Painter, s: Sprite, pal: readonly number[], x: number, u: number, h: number, flip: boolean, size: number, rows: number, d: Dials, lampIdx = -1, glow = false, fade = 1, seed = 0, nearest = 0.3): boolean { + if (!this.onView(x, u, nearest)) return false + const z = this.pt + const scale = this.pk * SPX * size + const footY = this.hz + this.pk * (0.62 - h * SPX) + const dw = s.w * scale + const dh = rows * scale + this.bx = this.ps - dw / 2 + this.by = footY - dh + this.bw = dw + this.bh = dh + const seg = Math.floor(x / SEG) + const l = glow ? 1 : q8(segLight(seg, d.t, this.seed) * clamp(1.3 - z / 7.5, 0, 1.1) + (this.flash > 0 && z < 3 ? 0.2 : 0)) + if (l <= 0.06) return false + this.blit(px, s, l === 1 ? pal : this.shade(pal, l, true), this.bx, this.by, flip, scale, rows, fade, seed, z, lampIdx) + return true + } + + private drawFoeView(px: Painter, fo: Foe, d: Dials): void { + const kind = FOES[fo.kind]! + const frame = fo.st === ATTACK && fo.t >= 2 && fo.t < 7 ? 2 : Math.floor(fo.walk / 3) & 1 + const s = kind.frames[frame]! + const flip = fo.st === FLEE + if (fo.st === DIE || fo.st === DEAD) { + const k = fo.st === DEAD ? 1 : fo.t / DIE_T + const rows = Math.max(2, Math.round(s.h - (s.h - 2) * k)) + const pal = fo.gib && k > 0.5 ? GIB_PAL[fo.kind]! : kind.pal + if (k > 0.5) this.billboard(px, POOL, POOL_PAL, fo.x + 0.5, fo.u, 0, false, 1, 1, d) + this.billboard(px, s, pal, fo.x, fo.u, kind.float * (1 - k), flip, 1, rows, d) + return + } + const bob = kind.float ? Math.sin(d.t * 0.12 + fo.seed) * 0.8 : 0 + const pal = fo.st === PAIN ? this.shade(kind.pal, 1.5) : kind.pal + if (!this.billboard(px, s, pal, fo.x, fo.u, kind.float + bob, flip, 1, s.h, d)) return + if (fo.flash > 0) px.lamp(this.bx + this.bw * 0.1, this.by + this.bh * 0.45, 0xffe070) + if (fo.kind === IMP && fo.st === ATTACK && fo.t < 4) this.billboard(px, BALL, FIRE_PAL, fo.x - 0.5, fo.u, 9, false, 1, BALL.h, d, -1, true) + } + + private drawMateView(px: Painter, m: Mate, d: Dials): void { + const i = 1 + m.slot + const u = [-0.5, 0.5, -0.3, 0.3][m.slot]! + const x = this.pos + m.x + const pose = m.busy > 0.5 && !this.hold && !m.leaving ? 0 : 1 + const running = Math.abs(this.squadPlace(m) - m.x) > 1.5 && !m.leaving + const moving = this.v > 0.02 || running + const leg = moving ? 1 + (Math.floor((this.walk + m.seed * 6 + (running ? m.age * 0.6 : 0)) / 3) & 1) : 0 + // Seen from behind, walking away from him: the visor doesn't show. + const s = MARINE_BACK[pose]![leg]! + let rows = s.h + let fade = 1 + if (failed(m)) { + const k = clamp((1 - m.here) * 2.2) + rows = Math.max(2, Math.round(s.h - 7 * k)) + fade = clamp(m.here * 2) + if (k > 0.6) this.billboard(px, POOL, POOL_PAL, x + 0.5, u, 0, false, 1, 1, d) + } else if (finished(m)) fade = m.here + // (Nearer than this they're running past him, beside or behind: out of his sight.) + if (!this.billboard(px, s, MARINE_PAL[i]!, x, u, 0, false, 1, rows, d, -1, false, fade, m.seed * 9999 + m.age, 0.9)) return + const { bx, by, bw, bh } = this + if (finished(m)) this.fog(px, bx + bw / 2, by + bh / 2, 1 - m.here, m.age, Math.max(3, bw * 0.8)) + if (!failed(m) && pose === 0) { + const flash = m.y > Math.round(rateOf(this.gun, d.level) * (1.4 + m.seed * 0.6)) - 2 + if (flash) px.lamp(bx + bw * 0.5, by - 1, this.gun === PLASMA ? 0x70c0ff : 0xffd040) + } + if (m.waiting && beckon(m, d.t)) px.lamp(bx + bw / 2, by - Math.max(2, bh * 0.15), 0xffb020) + this.crew.mark(m, bx / 2, by / 2, bw / 2, bh / 2, d.columns, d.rows) + } + + private drawPuffView(px: Painter, p: Puff, d: Dials): void { + if (!this.onView(p.x, p.u, 0.3)) return + const z = this.pt + const k = p.t / p.life + if (p.kind === BOOM) { + const r = (1.5 + 4.5 * Math.sqrt(k)) * this.pk * SPX + const cx = this.ps + const cy = this.hz + this.pk * (0.62 - p.h * SPX) + for (let dy = -Math.ceil(r); dy <= Math.ceil(r); dy++) + for (let dx = -Math.ceil(r); dx <= Math.ceil(r); dx++) { + const q = Math.hypot(dx, dy) / r + if (q > 1) continue + const heat = (1 - q) * (1.15 - k) + if (heat < 0.08) continue + const X = Math.round(cx + dx) + const Y = Math.round(cy + dy) + if (X < 0 || Y < 0 || X >= px.w || Y >= this.hv || this.zb[Y * px.w + X]! < z - 0.3) continue + px.set(X, Y, heat > 0.75 ? 0xfff4c0 : heat > 0.5 ? 0xffd040 : heat > 0.28 ? 0xff7a18 : 0xb02a0a, true) + } + return + } + const s = p.kind === BLOOD ? BLOODS : SPARKS + const pal = p.kind === BLOOD ? BLOOD_PAL : p.kind === SPARK ? SPARK_PAL : DUST_PAL + this.billboard(px, s, pal, p.x, p.u, p.h - p.t * (p.kind === BLOOD ? 0.6 : 0), (p.seed & 1) === 1, 1, s.h, d, -1, p.kind === SPARK) + } + + /** His gun, bobbing as he walks, kicking as it fires, lowered while he switches or holds for the person. */ + private drawGun(px: Painter, d: Dials): void { + const W = px.w + const HV = this.hv + // (About a third of his view's height, as the game had it; less in a band too short for it.) + const scale = this.pano ? (HV < 26 ? HV / 26 : Math.max(1, Math.round(HV / 34))) : Math.max(1, Math.round(Math.min(W / 40, HV / 50))) + const s = FP_GUNS[this.gun]! + const bob = Math.min(3, this.v * 2.4) + const ph = this.walk * 0.32 + const sw = this.switchT > 0 ? Math.sin((Math.PI * this.switchT) / SWITCH) * s.h * scale * 1.1 : 0 + const down = (this.hold ? 4 : 0) * scale + this.recoil * 0.5 * scale + sw + const x0 = Math.round(W / 2 - (s.w * scale) / 2 + 1 + Math.cos(ph) * bob) + const y0 = Math.round(HV - s.h * scale + Math.abs(Math.sin(ph)) * bob + down + (this.sb ? 1 : 0)) + const light = q8(segLight(Math.floor(this.pos / SEG), d.t, this.seed) * 0.95 + (this.flash > 0 ? 0.25 : 0)) + const shaded = this.shade(FP_PAL, light) + // (The plasma rifle's glow is its own light.) + shaded[8] = FP_PAL[8]! + shaded[9] = FP_PAL[9]! + this.blit(px, s, shaded, x0, y0, false, scale, s.h, 1, 0, -1, this.gun === PLASMA ? 8 : -1) + if (this.flash > 0 && this.switchT === 0) { + const fl = FP_FLASH + this.blit(px, fl, this.gun === PLASMA ? PLASMA_FLASH_PAL : FLASH_PAL, x0 + (s.w * scale) / 2 - (fl.w * scale) / 2 - 0.5, y0 - fl.h * scale + scale, false, scale, fl.h, 1, 0, -1, 3) + } + // The barrels of the chaingun turning. + if (this.gun === CHAINGUN && this.sinceShot < 6 && (d.t & 1)) for (let i = 0; i < 3; i++) px.set(x0 + (4 + i * 2) * scale, y0, 0x5c5c60, true) + } + + /** The status bar: his ammo, his face, his health. */ + private drawStatus(px: Painter): void { + const W = px.w + const y0 = this.hv + const sb = this.sb + px.rect(0, y0, W, sb, 0x5a5248) + px.rect(0, y0, W, 1, 0x8a8070) + px.rect(0, y0 + sb - 1, W, 1, 0x38322a) + const fx = Math.round(W / 2 - 6) + px.rect(fx, y0 + 1, 12, sb - 2, 0x2e2a24) + const fy = y0 + Math.max(1, Math.round((sb - 10) / 2)) + this.blit(px, FACES[this.faceNow()]!, FACE_PAL, fx + 1, fy, false) + this.bloody(px, fx + 1, fy) + // Ammo on the left, health on the right, where there's room. + const panel = fx + if (panel >= 12) { + const dy = y0 + Math.round((sb - 5) / 2) + this.number(px, this.ammo[this.gun]!, fx - 2, dy) + this.number(px, Math.round(this.hp), W - 2, dy) + } + } + + /** A number in the status bar's red digits, right-aligned at `right`. */ + private number(px: Painter, n: number, right: number, y: number): void { + const s = String(Math.max(0, Math.min(999, Math.round(n)))) + let x = right - s.length * 4 + 1 + for (const ch of s) { + const g = DIGITS[ch.charCodeAt(0) - 48]! + for (let j = 0; j < 5; j++) for (let i = 0; i < 3; i++) if (g[j * 3 + i] === '1') px.set(x + i, y + j, 0xd82818, true) + x += 4 + } + } +} + +/** The squad's marines seen from behind (the tall view): no visor, a pack. */ +const MARINE_BACK = [M_TOP, M_TOP_REST].map(top => M_LEGS.map(legs => sprite([...top.map(r => r.replace(/3/g, '1')), ...legs]))) +/** A pool of blood under the fallen, and the puffs of the tall view. */ +const POOL = sprite(['.1111111.']) +const POOL_PAL = [0, 0x6a0808] +const BLOODS = sprite(['.1.', '121', '.1.']) +const BLOOD_PAL = [0, 0x8a0a0a, 0xd01818] +const SPARKS = sprite(['1.1', '.2.', '1.1']) +const SPARK_PAL = [0, 0xff7020, 0xfff0a0] +const DUST_PAL = [0, 0x808080, 0xc0c0c0] + +export const doomScene = defineScene({ + name: 'doom', + blurb: 'a space marine fighting through a 1993 techbase, faster and harder with the work', + make: seed => new Doom(seed), +}) + +export const doom3dScene = defineScene({ + name: 'doom3d', + blurb: 'the same fight through his own eyes, in a taller band', + make: seed => new Doom(seed, true), +}) diff --git a/hooks/register.tsx b/hooks/register.tsx index d4220fa..5c12402 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -1,4 +1,4 @@ -// REVISION: flow-v177-config-hook +// REVISION: flow-v178-taller-scenes // // Flow for Claude Code, by Rob Macrae: ambient scenes (a fire, the surf, a ski run, // rockets, a hot-air balloon and more) drawn as one terminal `Raster` in the @@ -125,6 +125,8 @@ const DEFAULTS_POLL_MS = 30_000 /** A blit unanswered for this many ticks is presumed lost, not in flight. */ const BLIT_STALE_TICKS = 15 const MAX_ROWS = 5 +/** ...and for a scene with a taller view of its own (`Scene.taller`: doom3d), as many as the prompt leaves up to this. */ +const TALL_ROWS = 14 /** A desktop site redraws at most 10 times a second (`$.ui.invalidate`'s limit there). */ const DESKTOP_MS = 100 /** @@ -1544,7 +1546,8 @@ export const register: Register = (on, options) => { }) on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => { - const rows = Math.min(MAX_ROWS, e.props.maxRows - 1) + const most = cfg.layout !== 'spine' && driver.isShown() && driver.dial().taller ? TALL_ROWS : MAX_ROWS + const rows = Math.min(most, e.props.maxRows - 1) const hidden = cfg.layout === 'spine' || e.props.hasSurvey || !driver.isShown() || rows < 2 if (e.surface === 'desktop') { // No Raster here: the frame is one Svg, redrawn by the frame loop. diff --git a/hooks/styles.ts b/hooks/styles.ts index 186b2fb..aa441f4 100644 --- a/hooks/styles.ts +++ b/hooks/styles.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v173-pictures +// REVISION: flow-v175-doom3d // // The scenes, all driven by the same dials (strength 0..10, coverage boost, // the agents one by one, tint, night, waiting): SCENES, the one list of them (each scene file exports its @@ -33,6 +33,7 @@ import { warpScene } from './starfield' import { bubblesScene } from './bubbles' import { trainScene } from './train' import { earthriseScene } from './earthrise' +import { doom3dScene, doomScene } from './doom' /** What shows over a scene: smoke after a failure or a compaction, blue when the context is nearly full. */ @@ -79,6 +80,12 @@ export interface Scene { /** The key `pictureCache` would give a picture `w` × `h`, and taking back what was kept under it (false: not usable). */ pictureKey?(w: number, h: number): string restorePicture?(w: number, h: number, data: Float32Array): boolean + /** + * It draws a view taller than the band's 5 rows (doom3d: his own eyes), so + * above the prompt the adapter gives it more where it has them (pi: + * flow.json's `rows`; Claude Code: as many as the prompt leaves). + */ + readonly taller?: boolean } function defOf(style: SceneName): SceneDef { @@ -571,6 +578,8 @@ export const SCENES = [ bubblesScene, trainScene, earthriseScene, + doomScene, + doom3dScene, ] as const export type SceneName = (typeof SCENES)[number]['name'] export const STYLES: readonly SceneName[] = SCENES.map(d => d.name) diff --git a/pi/index.ts b/pi/index.ts index 9f8c8ac..9016a17 100644 --- a/pi/index.ts +++ b/pi/index.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v173-halftone-pictures +// REVISION: flow-v174-taller-scenes // // Flow for pi (badlogic/pi-mono), by Rob Macrae: the same ambient // scenes as the Claude Code mod, in a widget above pi's editor. pi's events @@ -33,7 +33,8 @@ // `"rows"` sets how tall (14 unless it says; never more than half the // terminal), `"pictures": false` keeps the cells. A new size's picture is made // a little each frame, the cells showing meanwhile, and what took long is -// kept in ~/.cache/flow for the next time. +// kept in ~/.cache/flow for the next time. A scene with a taller view of its +// own (doom3d, through the marine's eyes) gets as many rows in cells. import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' @@ -60,7 +61,7 @@ const SETTINGS = join(homedir(), '.pi', 'agent', 'flow.json') const PI: Host = { panes: false, sound: false } /** Where the settings lived before, newest first: as vista, then as ascii-fire. */ const OLD_SETTINGS = [join(homedir(), '.pi', 'agent', 'vista.json'), join(homedir(), '.pi', 'agent', 'ascii-fire.json')] -/** A picture's rows unless flow.json says (`rows`), and at most this share of the terminal's. */ +/** A picture's rows (and a taller scene's, `Scene.taller`) unless flow.json says (`rows`), and at most this share of the terminal's. */ const PICTURE_ROWS = 14 const PICTURE_SHARE = 0.5 /** @@ -170,8 +171,8 @@ export default function flow(pi: PiApi) { let making: string | undefined /** What a picture of each size took long to make, kept in CACHE: being read back, read back, not there, or written. */ const kept = new Map() - /** The rows a picture takes now. */ - const pictureRows = () => { + /** The rows a picture, or a taller scene's view, takes now. */ + const tallRows = () => { const terminal = process.stdout.rows || 40 return Math.max(3, Math.min(prefs.rows, Math.floor(terminal * PICTURE_SHARE))) } @@ -215,7 +216,7 @@ export default function flow(pi: PiApi) { /** This frame as a picture's lines, or undefined while it's still being made (draw the cells). */ const drawPicture = (f: Scene): string[] | undefined => { if (!f.picture || width <= 0) return undefined - const rows = pictureRows() + const rows = tallRows() const { w, h } = pictureSize(width, rows, cell, PICTURE_PIXELS) if (!readKept(f, w, h)) return undefined const rgba = f.picture(w, h, 0) @@ -276,7 +277,7 @@ export default function flow(pi: PiApi) { /** Point the current scene at this frame's dials and size. */ const dial = () => { const f = driver.dial() - if (width > 0) f.ensure(width, ROWS) + if (width > 0) f.ensure(width, f.taller ? tallRows() : ROWS) return f } diff --git a/scripts/check.ts b/scripts/check.ts index eb14953..a17c6a1 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -1,9 +1,10 @@ -// REVISION: flow-v171-dry-adapter +// REVISION: flow-v174-taller-scenes // // What every scene must hold to (AGENTS.md), measured: plugin.json lists // the scenes in SCENES, hooks/sound-files.ts lists the clips in sounds/, // level 0 draws nothing, a frame has at most 1024 colour pairs, and // step() + grid() takes under ~2 ms at the band's and the spine's sizes +// (a scene with a taller view of its own, doom3d's, at its taller band too) // (with subagents' companions too, working and waiting on the person: the // slower is shown). Each over every backdrop a scene passes through (avalon's // suns and nebulae). Exits 1 on any failure. Not part of the mod (Node). @@ -15,6 +16,7 @@ import { readdirSync } from 'node:fs' import { join } from 'node:path' import { SOUND_FILES } from '../hooks/sound-files' +import { makeScene } from '../hooks/styles' import { syncManifest } from './sync-manifest' import { backdropsOf, build, LOOKS, nightsOf, pairs, scenesFrom } from './scene-lab' @@ -32,6 +34,11 @@ const TIMED = [ [250, 5], [22, 60], ] as const +/** A taller scene's band (`Scene.taller`: as many rows as pi's flow.json gives it, 14 unless it says), timed in place of the 5-row one. */ +const TALLER = [ + [250, 14], + [80, 14], +] as const let failed = 0 const bad = (why: string) => { @@ -58,7 +65,8 @@ for (const scene of scenesFrom(process.argv.slice(2))) { let most = 0 let where = '' const backdrops = backdropsOf(scene) - for (const [columns, rows] of SIZES) { + const taller = makeScene(scene, 1).taller === true + for (const [columns, rows] of taller ? [...SIZES, ...TALLER] : SIZES) { for (const backdrop of backdrops) { for (const level of [1, 5, 10]) { for (const night of nightsOf(scene)) { @@ -89,7 +97,8 @@ for (const scene of scenesFrom(process.argv.slice(2))) { } } // (Working at 10, and waiting on the person, its sepia over every cell, over each backdrop: the slowest.) - const ms = TIMED.map(([columns, rows]) => { + const timed = taller ? [TALLER[0], TIMED[1]] : TIMED + const ms = timed.map(([columns, rows]) => { let worst = 0 for (const backdrop of backdrops) { for (const waiting of [false, true]) { @@ -105,10 +114,10 @@ for (const scene of scenesFrom(process.argv.slice(2))) { } return worst }) - console.log(`${scene.padEnd(10)} ${String(most).padStart(10)} ${ms.map(m => m.toFixed(2).padStart(11)).join(' ')}`) + console.log(`${scene.padEnd(10)} ${String(most).padStart(10)} ${ms.map(m => m.toFixed(2).padStart(11)).join(' ')}${taller ? ` (its band at ${TALLER[0].join('×')})` : ''}`) if (most > MAX_PAIRS) bad(`${scene}: ${most} colour pairs (at ${where}); Raster paints ${MAX_PAIRS}, quantize the gradients`) ms.forEach((m, i) => { - if (m > MAX_MS) bad(`${scene}: ${m.toFixed(2)} ms a frame at ${TIMED[i]!.join('×')} (budget ${MAX_MS} ms)`) + if (m > MAX_MS) bad(`${scene}: ${m.toFixed(2)} ms a frame at ${timed[i]!.join('×')} (budget ${MAX_MS} ms)`) }) } console.log(failed ? `\n${failed} problem${failed > 1 ? 's' : ''}` : '\nall good') diff --git a/tests/flow.test.ts b/tests/flow.test.ts index 996431a..42f4133 100644 --- a/tests/flow.test.ts +++ b/tests/flow.test.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v171-dry-adapter +// REVISION: flow-v175-doom3d import type { EngineInterface, On } from 'claude-code' import { expect, mock, test } from 'claude-code/testing' @@ -16,6 +16,7 @@ import { Balloon, skyColor } from '../hooks/balloon' import { Falcon } from '../hooks/rocket' import { Colony } from '../hooks/colony' import { Train } from '../hooks/train' +import { Doom } from '../hooks/doom' import { makeScene, nextStyle, SCENES, STYLES, styleNamed, type SceneName } from '../hooks/styles' import { finishSave, openSession, planSave, runScene, type SceneCtx } from '../hooks/register' import { coverage, frameSvg, gridPixels, SVG_LIMIT } from '../hooks/svg' @@ -1379,6 +1380,23 @@ test('the band draws its 5 rows on the terminal', async ($, on) => { await ui.unmount() }) +test('a scene with a taller view of its own (doom3d) gets a taller band, as far as the prompt leaves; back to 5 for the rest', async ($, on) => { + mock.clock(on) + mock.store(on) + engine(on) + await start($) + await flow($, 'doom3d') + const rowsWith = async (maxRows: number) => { + const ui = await $.ui.mount({ plugin: 'flow-scenes', surface: 'terminal', ...BAND, props: { ...BAND.props, maxRows } }) + const rows = (await ui.find({ type: 'Raster', key: 'flow' }))?.props.rows + await ui.unmount() + return rows + } + expect([await rowsWith(40), await rowsWith(12)]).toEqual([14, 11]) + await flow($, 'doom') + expect(await rowsWith(40)).toBe(5) +}) + test('the band yields to other surfaces, surveys, and a one-row squeeze', async ($, on) => { mock.clock(on) mock.store(on) @@ -2929,6 +2947,69 @@ test("sessions: a wait is its own session's: the chime follows that session's so await ui.unmount() }) +test('doom: the level is how hard he goes at it: a pistol at a plod at 1, faster and with heavier guns up to a plasma rifle at a run at 10', () => { + for (const [columns, rows] of [[120, 5], [22, 40]] as const) { + const run = (level: number) => { + const d = makeScene('doom', 3) as Doom + d.strength = level + d.ensure(columns, rows) + for (let i = 0; i < 900; i++) d.step() + return d + } + const [calm, mid, busy] = [run(1), run(5), run(10)] + const at = `${columns}×${rows}` + expect([at, calm.weapon, mid.weapon, busy.weapon]).toEqual([at, 0, 1, 3]) + expect([at, calm.distance < mid.distance && mid.distance < busy.distance]).toEqual([at, true]) + expect([at, calm.shots > 0 && calm.shots < mid.shots && mid.shots < busy.shots]).toEqual([at, true]) + // The level easing up, he changes guns on the way. + const d = run(1) + const guns = new Set() + d.strength = 10 + for (let i = 0; i < 400; i++) { + d.step() + guns.add(d.weapon) + } + expect([at, [...guns].sort()]).toEqual([at, [0, 1, 2, 3]]) + } +}) + +test('doom: waiting on the person, he stops and holds his fire and the monsters slink away; answered, the fight goes on', () => { + for (const [columns, rows] of [[120, 5], [22, 40]] as const) { + const d = makeScene('doom', 4) as Doom + d.strength = 8 + d.ensure(columns, rows) + for (let i = 0; i < 300; i++) d.step() + d.waiting = true + d.strength = WAIT_LEVEL + for (let i = 0; i < 120; i++) d.step() + const shots = d.shots + const where = d.distance + for (let i = 0; i < 200; i++) d.step() + const at = `${columns}×${rows}` + expect([at, d.speed, d.coming, d.shots - shots, d.distance - where]).toEqual([at, 0, 0, 0, 0]) + d.waiting = false + d.strength = 6 + for (let i = 0; i < 600; i++) d.step() + expect([at, d.shots > shots, d.distance > where]).toEqual([at, true, true]) + } +}) + +test('doom3d: the same fight through his own eyes, in a band of any height, and it asks for a taller one', () => { + expect([(makeScene('doom3d', 1) as Doom).taller, (makeScene('doom', 1) as Doom).taller]).toEqual([true, false]) + for (const [columns, rows] of [[160, 14], [120, 5], [22, 40]] as const) { + const run = (level: number) => { + const d = makeScene('doom3d', 3) as Doom + d.strength = level + d.ensure(columns, rows) + for (let i = 0; i < 900; i++) d.step() + return d + } + const [calm, busy] = [run(1), run(10)] + const at = `${columns}×${rows}` + expect([at, calm.weapon, busy.weapon, calm.distance < busy.distance, calm.shots < busy.shots]).toEqual([at, 0, 3, true, true]) + } +}) + test('train: waiting on the person, it draws up at a red signal or a platform and stands, its lamps shining through the sepia; answered, the horn and away', () => { for (const [columns, rows] of [[120, 5], [22, 60]] as const) for (const seed of [3, 8]) { @@ -3503,7 +3584,7 @@ test('crew: only places the layout can show are given; less room sends the rest }) /** The scenes that give each subagent a companion of its own. */ -const CREW_SCENES = ['fire', 'surf', 'ski', 'balloon', 'falcon', 'starship', 'engine', 'train', 'avalon'] as const +const CREW_SCENES = ['fire', 'surf', 'ski', 'balloon', 'falcon', 'starship', 'engine', 'train', 'avalon', 'doom', 'doom3d'] as const test('companion scenes: each agent gets one that arrives, is marked where it is, and leaves when done, in the band and the spine', () => { for (const style of CREW_SCENES) { From bd0f0be23fe3ded6d9bce1b83f179344b1e7e17f Mon Sep 17 00:00:00 2001 From: Patrick Desjardins Date: Fri, 9 Oct 2026 14:36:07 -0700 Subject: [PATCH 5/5] Plain names in earthrise's picture cache: its ground's key and pi's cache paths built with +, as the directory reads a key assembled from a template as a command (the keys themselves unchanged) --- hooks/earthrise.ts | 8 +++++--- pi/index.ts | 10 +++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/hooks/earthrise.ts b/hooks/earthrise.ts index 107dff4..9edec9b 100644 --- a/hooks/earthrise.ts +++ b/hooks/earthrise.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v3-earthrise-halftone +// REVISION: flow-v4-earthrise-plain-names // // Earthrise (the `earthrise` scene): the Earth coming up over a cratered // lunar horizon in long, low sunlight, after "earthrise" by @bas3line on @@ -106,6 +106,8 @@ const PIC_SKY = 0.55 const PIC_STEPS = 255 /** Bump when the ground changes: a picture's ground kept from before (`pictureCache`) is then made afresh. */ const GROUND_VERSION = 2 +/** What a picture's ground `pw` × `ph` dots is kept under (the same key pi's cache has always used). */ +const groundKey = (pw: number, ph: number) => 'earthrise-ground-v' + GROUND_VERSION + '-' + pw + 'x' + ph /** * A picture's halftone: about DOT_ROWS dots down it, each DOT_MIN to * DOT_MAX of its pixels apart, sized (by radius, of that pitch) as the @@ -1174,12 +1176,12 @@ export class Earthrise extends PixelScene { pictureKey(w: number, h: number): string { const p = dotPitch(h) - return `earthrise-ground-v${GROUND_VERSION}-${Math.floor(w / p)}x${Math.floor(h / p)}` + return groundKey(Math.floor(w / p), Math.floor(h / p)) } pictureCache(): { key: string; data: Float32Array } | undefined { const L = this.pic - return L && L.done >= L.pw ? { key: `earthrise-ground-v${GROUND_VERSION}-${L.pw}x${L.ph}`, data: L.ground } : undefined + return L && L.done >= L.pw ? { key: groundKey(L.pw, L.ph), data: L.ground } : undefined } restorePicture(w: number, h: number, data: Float32Array): boolean { diff --git a/pi/index.ts b/pi/index.ts index 9016a17..01e6b59 100644 --- a/pi/index.ts +++ b/pi/index.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v174-taller-scenes +// REVISION: flow-v175-plain-names // // Flow for pi (badlogic/pi-mono), by Rob Macrae: the same ambient // scenes as the Claude Code mod, in a widget above pi's editor. pi's events @@ -187,7 +187,7 @@ export default function flow(pi: PiApi) { const state = kept.get(key) if (state) return state !== 'reading' kept.set(key, 'reading') - void readFile(join(CACHE, `${key}.f32`)) + void readFile(join(CACHE, key + '.f32')) .then(buf => { const data = new Float32Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)) kept.set(key, f.restorePicture?.(w, h, data) ? 'restored' : 'absent') @@ -201,9 +201,9 @@ export default function flow(pi: PiApi) { const made = f.pictureCache?.() if (!made || kept.get(made.key) !== 'absent') return kept.set(made.key, 'written') - const file = join(CACHE, `${made.key}.f32`) + const file = join(CACHE, made.key + '.f32') const bytes = new Uint8Array(made.data.buffer, made.data.byteOffset, made.data.byteLength).slice() - const tmp = `${file}.${process.pid}.tmp` + const tmp = file + '.' + process.pid + '.tmp' void mkdir(CACHE, { recursive: true }) .then(() => writeFile(tmp, bytes)) .then(() => rename(tmp, file)) @@ -230,7 +230,7 @@ export default function flow(pi: PiApi) { /** Make a new size's picture a slice at a time, off the frame loop, until it's made (or the size or scene moves on). */ const makeBetweenFrames = (f: Scene, w: number, h: number) => { - const key = `${w}x${h}` + const key = w + 'x' + h if (making === key) return making = key const slice = () => {