macOS workstation dotfiles managed by chezmoi

Personal macOS dev environment, managed with chezmoi.
This repo ships the installation: settings.json, which plugins are enabled, MCP registration, CLAUDE.md, the machine lifecycle hooks — plus everything non-Claude (shell, git, brew, mise, macOS defaults).
Reusable Claude Code capability lives in jasonm4130/claude-skills, a plugin marketplace.
The test: could a stranger install this and have it work? If yes it belongs in a plugin. If it references this machine's paths, this prose's calibration, or this machine's state, it belongs here. A third-party skill bound to a locally installed tool is installation too: herdr's skill renders from the herdr binary (see Claude Code setup).
One rule that follows from it and is easy to get wrong: an override of a built-in belongs here, not in a plugin. ~/.claude/agents/Explore.md shadows the built-in Explore to pin model: sonnet and deny the write tools; a plugin agent can only register as plugin:name and ranks below user-level, so the same file shipped as a plugin would stop shadowing anything.
sh -c "$(curl -fsLS get.chezmoi.io)" -- -b "$HOME/.local/bin" init --apply jasonm4130
Installs chezmoi, clones to ~/.local/share/chezmoi, runs the install scripts (Homebrew, Brewfile, macOS defaults), and writes all dotfiles.
Re-running the one-liner does not update an existing checkout. chezmoi init only clones when it finds no git repo in the source directory, so on a machine that already has ~/.local/share/chezmoi it silently leaves the old revision in place — including the broken script you are trying to replace. Use chezmoi update.
chezmoi: command not found after a partial bootstrap. get.chezmoi.io defaults to a relative install dir and execs the binary directly, so it never reaches PATH. The command above pins it with -b; a machine bootstrapped before that has it somewhere like ~/bin/chezmoi. Don't reach for Homebrew to recover — 01-homebrew.sh is the step most likely to have failed. Find it and use an absolute path:
find ~ -maxdepth 3 -name chezmoi -type f 2>/dev/null
~/bin/chezmoi update -v
One dead package stops the entire chain. brew bundle attempts every entry then exits non-zero if any failed, and 02-brew-bundle.sh runs under set -euo pipefail — so a single bad formula takes down scripts 04 through 13 with it. The symptom is a machine with most of its packages but no Oh My Zsh, no macOS defaults and no launchd agents. chezmoi state dump | grep runAt shows how far the chain got; fix packages.yaml and re-apply rather than re-bootstrapping. When a package moves tap, check whether it also changed between formulae: and casks: — ls $(brew --repo <tap>) answers it.
Never re-run any of this under sudo. macOS sudoers keeps HOME, so root-owned files land in the real home directory — chezmoi's own config among them, after which every non-root run fails with invalid config: … permission denied, an error naming neither sudo nor ownership. The first script now refuses to start as root; an already-poisoned machine needs sudo chown -R "$(id -u):$(id -g)" over ~/.config/chezmoi, ~/.local/share/chezmoi, ~/.cache/chezmoi, plus anything find ~ -maxdepth 3 -user root turns up.
| Claude Code | settings, hooks, agents, rules, MCP config, guards, sounds |
| Components | zsh, mise, Starship, language servers, Ghostty, Zed, fonts, Codex, Brewfile, CI |
| Maintenance | launchd jobs, one-time manual steps, fanfare voice clips |
Run chezmoi managed for the authoritative file list.
| Task | Command | |
|---|---|---|
| See what would change | chezmoi diff | |
| Apply changes | chezmoi apply | |
Add a file from $HOME | chezmoi add ~/.somefile | |
Edit a tracked file (opens in $EDITOR) | chezmoi edit ~/.somefile | |
Re-add after editing in $HOME | chezmoi re-add ~/.somefile | |
| Open the source dir | chezmoi cd | |
| Check what's ignored | `chezmoi ignored \ | grep <pattern>` |
| Render a template | chezmoi execute-template --file <path> | |
| Pull + apply remote changes | chezmoi update | |
| Settle source↔destination drift interactively | chezmoi-drift | |
| Check the language servers are actually up | lsp-doctor (add --repair to fix) | |
| Check the Claude Code harness (plugins, capture, stale sessions) | claude-canary (runs daily at 09:35) | |
| Rebuild the compiled hook guard | go build -C ~/.local/src/claude-hooks -o ~/.local/bin/claude-hooks . (or just chezmoi apply) |
See docs/specs/2026-05-08-dotfiles-design.md.
MIT — see LICENSE.
hooks/register.ts 232 lines1// fleet-weather: a ship sailing the band above the prompt, in the weather of the
2// captain's Firstmate fleet. Each session sails one of four vessels (galleon, schooner,
3// sloop, junk), picked from a seed taken when the mod loads and kept for the session's
4// life; FLEET_WEATHER_SHIP pins one by name (anything else means pick for me).
5// FLEET_WEATHER_GLYPHS picks the glyphs the scene is fitted to: extended (the default:
6// quadrants, eighth blocks and corner triangles), quadrant or half. octant and sextant
7// fold to extended here, because the Raster refuses any code point beyond the BMP;
8// preview.mjs draws them. Each frame is fitted against the one before it at the same
9// width, so a cell redraws only when the scene under it really changed.
10//
11// This file is the only one that touches the engine interface `$`; the forecast
12// (../lib/weather.mjs), the scene (../lib/galleon.mjs) and the Raster packing
13// (../lib/pack.mjs) are pure and tested under `node --test` from the dotfiles repo.
14//
15// Scope: the captain's own sessions. A Firstmate worker (FM_TASK_ID set) loads this
16// plugin too, because ~/.claude-fm-workers/skills links to ~/.claude/skills, and gets a
17// complete no-op: no timer, no read, every drawing passed on. A session whose home has
18// no readable state/home-summary.json draws nothing either, but keeps checking, so the
19// band appears once Firstmate publishes one.
20//
21// Drawing: the `AbovePrompt` band, not the working row, so Firstmate Calm's `Spinner`
22// hook and this one never contest a site. One Raster is repainted in place through
23// `$.ui.blit` on the scene's tick (slower while no turn runs); the caption under it
24// names the weather's reason and redraws only when the forecast changes.
25//
26// Reading the fleet: every 5 s, stat three small files and re-read one only when its
27// size or mtime moved (a file changed within the last 5 s is read again next time,
28// as Firstmate Calm does). No process is started.
29import type { EngineInterface, Register } from "claude-code";
30import { forecast, parseSummary, trackBlocked } from "../lib/weather.mjs";
31import { paletteFamily, resolveGlyphs, resolveVariant, sceneFrame, SCENE_ROWS, TICK_MS } from "../lib/galleon.mjs";
32import { packCells } from "../lib/pack.mjs";
33
34type Forecast = { weather: "storm" | "rain" | "clouds" | "night" | "calm"; reason: string };
35
36const RASTER_KEY = "fleet-weather-galleon";
37const MAX_COLUMNS = 512;
38const POLL_MS = 5000;
39const SETTLED_MS = 5000;
40/** While no turn runs the band repaints every Nth tick: the same sea, a quieter cost. */
41const IDLE_TICKS_PER_FRAME = 4;
42/** The plugin store key holding when each open `blocked` decision was first seen. */
43const FIRST_SEEN_KEY = "blocked-first-seen";
44
45let activation: Promise<boolean> | undefined;
46let loading: Promise<void> | undefined;
47let paths: { summary: string; health: string; watcher: string } | undefined;
48let family: "dark" | "light" = "light";
49let look: { seed: number; variant: string; glyphs: string } = { seed: 0, variant: "galleon", glyphs: "extended" };
50let firstSeen: Record<string, number> = {};
51let current: Forecast | undefined;
52let summary: object | undefined;
53let summaryText: string | undefined;
54const files = new Map<string, { stamp: string | undefined; text: string | undefined }>();
55let polling = false;
56let tick = 0;
57let frameTick = 0;
58let site: { requestId: string; columns: number; working: boolean } | undefined;
59type Frame = ReturnType<typeof sceneFrame>;
60/** The last frame drawn, which the next one at the same width holds its unchanged cells from. */
61let last: { columns: number; frame: Frame } | undefined;
62
63/** The scene at `columns` for the frame tick, fitted against the last frame drawn at that width. */
64function frameAt(weather: Forecast["weather"], columns: number): Frame {
65 const prev = last?.columns === columns ? last.frame : undefined;
66 const frame = sceneFrame(frameTick, weather, family, columns, { ...look, prev });
67 last = { columns, frame };
68 return frame;
69}
70
71function isActive($: EngineInterface): Promise<boolean> {
72 if (activation === undefined) {
73 activation = $.env.get("FM_TASK_ID").then(
74 (value) => value === undefined || value === "",
75 () => false,
76 );
77 }
78 return activation;
79}
80
81async function readTheme($: EngineInterface): Promise<unknown> {
82 try {
83 return (await $.config.list()).find((row) => row.key === "theme")?.value;
84 } catch {
85 return undefined;
86 }
87}
88
89/**
90 * A file's text, re-read only when its size or mtime moved since the last read; a file
91 * changed within SETTLED_MS keeps no stamp, so the next poll reads it again.
92 */
93async function follow($: EngineInterface, path: string, now: number): Promise<string | undefined> {
94 const known = files.get(path);
95 try {
96 if (!(await $.fs.exists(path))) {
97 files.delete(path);
98 return undefined;
99 }
100 const stat = await $.fs.stat(path);
101 const stamp = `${stat.size}:${stat.mtimeMs}`;
102 if (known !== undefined && known.stamp === stamp) return known.text;
103 const text = await $.fs.read(path);
104 files.set(path, { stamp: now - stat.mtimeMs >= SETTLED_MS ? stamp : undefined, text });
105 return text;
106 } catch {
107 return known?.text;
108 }
109}
110
111async function poll($: EngineInterface): Promise<void> {
112 if (paths === undefined || polling) return;
113 polling = true;
114 try {
115 const now = await $.clock.now();
116 const raw = await follow($, paths.summary, now);
117 if (raw !== summaryText) {
118 summaryText = raw;
119 summary = parseSummary(raw);
120 }
121 const health = await follow($, paths.health, now);
122 const watcher = await follow($, paths.watcher, now);
123
124 const seen = trackBlocked(summary, firstSeen, now);
125 if (JSON.stringify(seen) !== JSON.stringify(firstSeen)) {
126 firstSeen = seen;
127 await $.store.set(FIRST_SEEN_KEY, seen).catch(() => undefined);
128 }
129 const next = forecast({ summary, health, watcher, now, firstSeen }) as Forecast | undefined;
130 if (next?.weather !== current?.weather || next?.reason !== current?.reason) {
131 current = next;
132 $.ui.invalidate("ui.render");
133 }
134 } finally {
135 polling = false;
136 }
137}
138
139async function repaint($: EngineInterface): Promise<void> {
140 tick += 1;
141 const mounted = site;
142 if (mounted === undefined || current === undefined) return;
143 if (!mounted.working && tick % IDLE_TICKS_PER_FRAME !== 0) return;
144 frameTick += 1;
145 const packed = packCells(frameAt(current.weather, mounted.columns), mounted.columns);
146 let shown: boolean;
147 try {
148 const result = await $.ui.blit({ requestId: mounted.requestId, key: RASTER_KEY, cells: packed.cells, columns: mounted.columns, rows: packed.rows });
149 shown = result.deny === undefined;
150 } catch {
151 shown = false;
152 }
153 // Denied: the band no longer shows this Raster (collapsed, resized, a survey took it);
154 // the next drawing mounts it again.
155 if (!shown && site === mounted) site = undefined;
156}
157
158async function load($: EngineInterface): Promise<void> {
159 const home = (await $.env.get("FM_HOME")) || (await $.env.get("FM_ROOT_OVERRIDE")) || `${(await $.env.get("HOME")) ?? ""}/Work/Git/firstmate`;
160 const state = (await $.env.get("FM_STATE_OVERRIDE")) || `${home}/state`;
161 paths = {
162 summary: `${state}/home-summary.json`,
163 health: `${state}/.supervision-host-health`,
164 watcher: `${state}/.watcher-down`,
165 };
166 family = paletteFamily(await readTheme($));
167 const seed = Math.floor(await $.clock.now()) % 0x7fffffff;
168 look = {
169 seed,
170 variant: resolveVariant(await $.env.get("FLEET_WEATHER_SHIP"), seed),
171 glyphs: resolveGlyphs(await $.env.get("FLEET_WEATHER_GLYPHS"), { bmpOnly: true }),
172 };
173 try {
174 const stored = await $.store.get(FIRST_SEEN_KEY);
175 if (stored !== null && typeof stored === "object") firstSeen = stored as Record<string, number>;
176 } catch {
177 // An unreadable store only restarts the two-hour storm window.
178 }
179 await poll($);
180 $.clock.every(POLL_MS, () => {
181 void poll($);
182 });
183 $.clock.every(TICK_MS, () => {
184 void repaint($);
185 });
186}
187
188function ensureLoaded($: EngineInterface): Promise<void> {
189 if (loading === undefined) loading = load($);
190 return loading;
191}
192
193export const register: Register = (on) => {
194 on("session.start", async ($, e, next) => {
195 if (await isActive($)) await ensureLoaded($);
196 return next(e);
197 });
198
199 // Follow a theme change: the next drawing and every later blit use the new family.
200 on("config.set", { key: "theme" }, async ($, e, next) => {
201 const result = await next(e);
202 if (result.deny === undefined && (await isActive($))) {
203 const chosen = paletteFamily(result.value);
204 if (chosen !== family) {
205 family = chosen;
206 $.ui.invalidate("ui.render");
207 }
208 }
209 return result;
210 });
211
212 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
213 if (!(await isActive($))) return next(e);
214 await ensureLoaded($);
215 const forecastNow = current;
216 if (e.surface !== "terminal" || e.props.hasSurvey || forecastNow === undefined || e.props.maxRows < SCENE_ROWS) {
217 site = undefined;
218 return next(e);
219 }
220 const columns = Math.max(1, Math.min(MAX_COLUMNS, e.props.bodyColumns));
221 site = { requestId: e.requestId, columns, working: e.props.isWorking };
222 const packed = packCells(frameAt(forecastNow.weather, columns), columns);
223 const { Box, Raster, Text } = $.ui.resolve(e);
224 const scene = Raster({ key: RASTER_KEY, columns, rows: packed.rows, cells: packed.cells });
225 if (e.props.maxRows < SCENE_ROWS + 1) return Box({ flexDirection: "column", children: scene });
226 return Box({
227 flexDirection: "column",
228 children: [scene, Text({ dimColor: true, wrap: "truncate-end", children: [`${forecastNow.weather} · ${forecastNow.reason}`] })],
229 });
230 });
231};
232lib/weather.mjs 136 lines1// Fleet weather: Firstmate's published fleet view folded into one of five skies.
2//
3// Pure, so `node --test` drives it with fixtures and ../hooks/register.ts only feeds it
4// file text and the clock. The one source is `$FM_HOME/state/home-summary.json`
5// (schema fm-secondmate-home-summary.v1), which Firstmate republishes on every
6// watcher-observed status change, task spawn and teardown, and at least every 300 s;
7// two supervision sidecars add health. The first matching row wins:
8//
9// storm a fresh `blocked` decision, a blocked crew, the supervision latch cooling
10// down, watcher downtime, or a summary nobody has republished for 10 min
11// rain a crew whose validation failed or is fixing a red step, or a `blocked`
12// decision first seen more than two hours ago (the captain's decay rule)
13// clouds a decision or captain hold waiting on the captain
14// night no active crews
15// calm crews working and nothing open
16
17/** A `blocked` decision storms this long after it is first seen, then rains. */
18export const BLOCKED_STORM_MS = 2 * 60 * 60 * 1000;
19
20/** Twice FM_HOME_SUMMARY_INTERVAL's 300 s default: older means nothing is republishing. */
21export const SUMMARY_STALE_MS = 10 * 60 * 1000;
22
23/** @typedef {"storm" | "rain" | "clouds" | "night" | "calm"} Weather */
24/** @typedef {{ weather: Weather, reason: string }} Forecast */
25
26const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
27const list = (value) => (Array.isArray(value) ? value.filter(isObject) : []);
28const text = (value) => (typeof value === "string" ? value : "");
29
30/**
31 * The summary's JSON when it is a home summary at all, else undefined: a missing,
32 * unreadable, or foreign file means no fleet to forecast, and the mod draws nothing.
33 * @param {string | undefined} raw
34 */
35export function parseSummary(raw) {
36 if (raw === undefined) return undefined;
37 let value;
38 try {
39 value = JSON.parse(raw);
40 } catch {
41 return undefined;
42 }
43 if (!isObject(value) || typeof value.schema !== "string" || !value.schema.startsWith("fm-secondmate-home-summary.")) return undefined;
44 return value;
45}
46
47/**
48 * Whether `.supervision-host-health` reports the broken-session latch cooling down.
49 * @param {string | undefined} raw
50 */
51export function supervisionCooling(raw) {
52 const match = /^cooldown=(\d+)\s*$/m.exec(raw ?? "");
53 return match !== null && Number(match[1]) > 0;
54}
55
56/**
57 * Whether `.watcher-down` holds a downtime episode; a `handling` token is a wake in
58 * progress, not downtime.
59 * @param {string | undefined} raw
60 */
61export function watcherDown(raw) {
62 return /^(pending|announced):downtime:/.test((raw ?? "").trim());
63}
64
65/** The key a blocked decision is remembered by between polls and sessions. */
66export const blockedKey = (decision) => `${text(decision.id)}:${text(decision.key) || text(decision.summary)}`;
67
68/**
69 * The first-seen map carried forward: every open blocked decision keeps its first
70 * sighting (or gains `now`), and a decision no longer open is forgotten.
71 * @param {object | undefined} summary
72 * @param {Record<string, number>} firstSeen
73 * @param {number} now
74 * @returns {Record<string, number>}
75 */
76export function trackBlocked(summary, firstSeen, now) {
77 const next = {};
78 for (const decision of list(summary?.decisions_open)) {
79 if (decision.verb !== "blocked") continue;
80 const key = blockedKey(decision);
81 const seen = firstSeen[key];
82 next[key] = typeof seen === "number" && Number.isFinite(seen) && seen <= now ? seen : now;
83 }
84 return next;
85}
86
87const named = (decision) => {
88 const id = text(decision.id) || "a crew";
89 const key = text(decision.key);
90 return key ? `${id} (${key})` : id;
91};
92
93const withMore = (reasons) => (reasons.length > 1 ? `${reasons[0]} +${reasons.length - 1} more` : reasons[0]);
94
95/**
96 * The fleet's weather and the one-line reason the band shows beside it.
97 * @param {{ summary: object | undefined, health?: string, watcher?: string, now: number, firstSeen?: Record<string, number> }} input
98 * @returns {Forecast | undefined} undefined when there is no summary to read
99 */
100export function forecast({ summary, health, watcher, now, firstSeen = {} }) {
101 if (summary === undefined) return undefined;
102 const children = list(summary.active_children);
103 const decisions = list(summary.decisions_open);
104
105 const storm = [];
106 const rain = [];
107 const generated = typeof summary.generated_epoch === "number" ? summary.generated_epoch * 1000 : undefined;
108 if (generated === undefined || now - generated > SUMMARY_STALE_MS) {
109 const minutes = generated === undefined ? undefined : Math.floor((now - generated) / 60000);
110 storm.push(minutes === undefined ? "fleet view has no timestamp" : `fleet view not refreshed for ${minutes} min`);
111 }
112 if (supervisionCooling(health)) storm.push("supervision cooling down after engine errors");
113 if (watcherDown(watcher)) storm.push("watcher down");
114 for (const decision of decisions) {
115 if (decision.verb !== "blocked") continue;
116 const seen = firstSeen[blockedKey(decision)] ?? now;
117 if (now - seen < BLOCKED_STORM_MS) storm.push(`${named(decision)} blocked`);
118 else rain.push(`${named(decision)} blocked for ${Math.floor((now - seen) / 3600000)}h+`);
119 }
120 for (const child of children) if (child.state === "blocked") storm.push(`${text(child.id) || "a crew"} blocked`);
121 if (storm.length > 0) return { weather: "storm", reason: withMore(storm) };
122
123 for (const child of children) {
124 if (child.state === "failed") rain.push(`${text(child.id) || "a crew"} validation failed`);
125 else if (child.doing === "validating (fixing)") rain.push(`${text(child.id) || "a crew"} fixing a red check`);
126 }
127 if (rain.length > 0) return { weather: "rain", reason: withMore(rain) };
128
129 const waiting = decisions.filter((d) => d.verb === "needs-decision" || d.verb === "captain-hold").map((d) => `${named(d)} needs a decision`);
130 if (waiting.length > 0) return { weather: "clouds", reason: withMore(waiting) };
131
132 const active = typeof summary.counts?.active_children === "number" ? summary.counts.active_children : children.length;
133 if (active === 0) return { weather: "night", reason: "fleet idle" };
134 return { weather: "calm", reason: active === 1 ? "1 crew working" : `${active} crews working` };
135}
136lib/galleon.mjs 327 lines1// The fleet scene: a ship sailing a procedural sea under the fleet's weather, drawn as
2// an RGB pixel buffer at 2x4 pixels per terminal cell and fitted to Raster cells.
3//
4// Pure and deterministic per tick and seed, so tests seek animation time exactly.
5// `scenePixels` draws the scene: 20 pixel rows over SCENE_ROWS (5) terminal rows, two
6// square pixels to a column. `sceneFrame` fits that buffer to glyphs (./cells.mjs): each
7// cell becomes the glyph of its glyph set, and the two colours, that best reproduce its
8// 2x4 pixels. The ship (one of four variants in ./ships.mjs, four rows tall) bounces
9// along the band, flipping to face its heading; it heaves and pitches with the sea under
10// its bow and stern, throws foam at the bow and leaves a wake. Its position, heave and
11// pitch are fractional: it glides half a pixel a tick, its outline anti-aliased, so the
12// eighth blocks can show it between pixel columns rather than in column steps. The sea
13// is procedural (./sea.mjs): swell and drifting noise, with foam, spray and glints
14// derived from the height and slope of that surface, its edge covering eighths of a pixel. The board in Firstmate's data/ship-mod-design is the
15// original galleon scene's mockup.
16import { CELL_H, CELL_W, DEFAULT_GLYPHS, fitCells } from "./cells.mjs";
17import { hash, makeSea, vnoise } from "./sea.mjs";
18import { SHIP, SHIPS, WATERLINE, shipPixels } from "./ships.mjs";
19
20export { GLYPH_SETS, resolveGlyphs } from "./cells.mjs";
21export { VARIANTS, resolveVariant } from "./ships.mjs";
22
23/** `0x01000000` (bit 24 alone) asks the Raster for the terminal's default colour. */
24export const DEFAULT_COLOR = 0x01000000;
25
26/** Terminal rows the scene takes. */
27export const SCENE_ROWS = 5;
28const H = SCENE_ROWS * CELL_H;
29
30/**
31 * Scene pixels per unit of the sea's own field (./sea.mjs works in the half-block
32 * pixels the scene used to draw in), so the swell keeps its size on screen.
33 */
34const K = 2;
35
36/** The mean surface row: the ship's waterline sits here in a flat calm, leaving 5 pixels of sea beneath. */
37const BASE = 15;
38
39/** One scheduler tick; water, weather and pennants advance every tick. */
40export const TICK_MS = 150;
41
42/** The ship moves one pixel (half a column) every Nth tick (300 ms), half a pixel each tick. */
43const TICKS_PER_MOVE = 2;
44
45/** The widest ship's local box in pixels (the galleon and schooner; the others are narrower). */
46export const SHIP_WIDTH = 40;
47
48const hex = (s) => parseInt(s.slice(1), 16);
49const mix = (a, b, t) => {
50 const ar = a >> 16, ag = (a >> 8) & 255, ab = a & 255;
51 const br = b >> 16, bg = (b >> 8) & 255, bb = b & 255;
52 return (Math.round(ar + (br - ar) * t) << 16) | (Math.round(ag + (bg - ag) * t) << 8) | Math.round(ab + (bb - ab) * t);
53};
54const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
55const smooth = (a, b, v) => { const t = clamp01((v - a) / (b - a)); return t * t * (3 - 2 * t); };
56const quant = (v, n) => Math.round(v * n) / n;
57
58/**
59 * Per weather: the sea (`amp` units of swell, `speed`, share of drifting noise `chop`,
60 * crest `steep`ness, `swell` component weights), its whitecaps (`cap` the crest height
61 * they start at, `foam` their strength, `spray` the storm's flung water) and the sky.
62 */
63export const WEATHER = {
64 calm: { amp: 0.8, speed: 1.0, chop: 0.22, steep: 0.1, swell: [1, 0.4, 0.12], cap: 0.6, foam: 0.4, spray: 0, cloud: 0, rain: 0, bolt: false, night: false },
65 clouds: { amp: 0.95, speed: 1.0, chop: 0.28, steep: 0.15, swell: [1, 0.5, 0.2], cap: 0.5, foam: 0.5, spray: 0, cloud: 0.8, rain: 0, bolt: false, night: false },
66 rain: { amp: 1.3, speed: 1.4, chop: 0.42, steep: 0.25, swell: [1, 0.7, 0.4], cap: 0.35, foam: 0.65, spray: 0.15, cloud: 1, rain: 0.35, bolt: false, night: false },
67 storm: { amp: 1.9, speed: 1.9, chop: 0.55, steep: 0.45, swell: [1, 0.8, 0.55], cap: 0.15, foam: 1.0, spray: 0.55, cloud: 1, rain: 0.6, bolt: true, night: false },
68 night: { amp: 0.55, speed: 0.6, chop: 0.2, steep: 0.08, swell: [1, 0.35, 0.1], cap: 0.65, foam: 0.3, spray: 0, cloud: 0, rain: 0, bolt: false, night: true },
69};
70
71/**
72 * Sky, sea and weather colours per theme family and weather.
73 * @type {Record<string, Record<string, Record<string, any>>>}
74 */
75const PALETTES = {
76 dark: {
77 calm: { top: "#1f3f7a", bot: "#79acdc", sea: ["#4b92cf", "#2d68a6", "#173d70"], foam: "#e3f2fd", sun: "#ffd27a", halo: "#b9d3ec" },
78 clouds: { top: "#3a4656", bot: "#8a98ab", sea: ["#55799a", "#3a5a77", "#213b54"], foam: "#d3dde6", cl: "#c9d0d9", cd: "#8e98a6" },
79 rain: { top: "#262d38", bot: "#5c6878", sea: ["#41607b", "#2b445b", "#18293a"], foam: "#b7c7d4", cl: "#7f8a99", cd: "#596473", rain: "#a8bdd6" },
80 storm: { top: "#121220", bot: "#33314a", sea: ["#2d3a58", "#1c253b", "#0e1424"], foam: "#b3bdd6", cl: "#4a4862", cd: "#29283a", rain: "#8a98bd", flash: "#c9c6ff", bolt: "#fffbe0" },
81 night: { top: "#050a1a", bot: "#1c2c52", sea: ["#203462", "#142347", "#0a1430"], foam: "#5a6c96", moon: "#f2eed6", star: "#ffffff", glint: "#d8d3b0" },
82 },
83 light: {
84 calm: { top: "#5fa8e8", bot: "#d3ebfb", sea: ["#4aa3e3", "#2f80c5", "#1d5d9c"], foam: "#ffffff", sun: "#ffbf2e", halo: "#fff1c4" },
85 clouds: { top: "#9fb0c2", bot: "#e3e9f0", sea: ["#7298b8", "#577c9c", "#3d5f7d"], foam: "#f4f8fb", cl: "#ffffff", cd: "#c7d0db" },
86 rain: { top: "#7b8999", bot: "#bcc6d1", sea: ["#5d7f9b", "#466681", "#314d66"], foam: "#e7eef4", cl: "#dbe2ea", cd: "#a2aebc", rain: "#4f6884" },
87 storm: { top: "#393b50", bot: "#707290", sea: ["#43506f", "#2f3a55", "#1e263b"], foam: "#d6dcea", cl: "#8b8ca6", cd: "#55566e", rain: "#c6cde3", flash: "#f3f1ff", bolt: "#fff3a0" },
88 night: { top: "#0f1d40", bot: "#3a5694", sea: ["#2a4778", "#1c3460", "#112447"], foam: "#7c90bd", moon: "#fff6d8", star: "#ffffff", glint: "#e8dfb4" },
89 },
90};
91
92/** Lightning flashes on three ticks of every 34; the bolt shows on two of them. */
93const flashAt = (t) => { const p = t % 34; return p === 0 || p === 1 || p === 4; };
94const boltAt = (t) => { const p = t % 34; return p === 0 || p === 4; };
95
96/**
97 * Bounce track: the ship's left edge in pixels, fractional, and its heading after `t`
98 * ticks on a track `span` pixels long.
99 */
100export function track(t, span) {
101 if (span <= 0) return { pos: 0, dir: 1 };
102 const k = (t / TICKS_PER_MOVE) % (span * 2);
103 return k < span ? { pos: k, dir: 1 } : { pos: span * 2 - k, dir: -1 };
104}
105
106/**
107 * Lays unit-square pixels at fractional positions on a W x H buffer: each buffer pixel
108 * gets the area the squares cover of it (`cover`), the colour of the square covering
109 * most of it (`colour`) and the area covered by rope (`rope`); `touched` lists the
110 * pixels any square reached.
111 * @param {[number, number, number, boolean][]} items [x, y, colour, rope] each, x and y the square's top left
112 */
113export function layShip(items, W, H) {
114 const cover = new Float32Array(W * H), most = new Float32Array(W * H), colour = new Int32Array(W * H), rope = new Float32Array(W * H);
115 const touched = [];
116 for (const [x0, y0, c, isRope] of items) {
117 const ix = Math.floor(x0), iy = Math.floor(y0), fx = x0 - ix, fy = y0 - iy;
118 for (const [x, y, a] of [[ix, iy, (1 - fx) * (1 - fy)], [ix + 1, iy, fx * (1 - fy)], [ix, iy + 1, (1 - fx) * fy], [ix + 1, iy + 1, fx * fy]]) {
119 if (a <= 0 || x < 0 || x >= W || y < 0 || y >= H) continue;
120 const i = y * W + x;
121 if (cover[i] === 0) touched.push(i);
122 cover[i] += a;
123 if (isRope) rope[i] += a;
124 if (a > most[i]) { most[i] = a; colour[i] = c; }
125 }
126 }
127 return { cover, colour, rope, touched };
128}
129
130/**
131 * A ship facing right in its local box, as [x, y, colour key] pixels.
132 * @param {string} weather
133 * @param {number} t
134 * @param {string} [variant] one of VARIANTS
135 */
136export function galleonPixels(weather, t, variant = "galleon") {
137 return shipPixels(weather, t, variant);
138}
139
140/**
141 * The scene's pixels: `width * 2` by SCENE_ROWS * 4 RGB numbers, row-major.
142 * @param {number} t tick
143 * @param {"storm" | "rain" | "clouds" | "night" | "calm"} weather
144 * @param {"dark" | "light"} family
145 * @param {number} width terminal columns
146 * @param {{ seed?: number, variant?: string }} [opts] the sea's seed and the ship (default the galleon)
147 * @returns {{ width: number, height: number, px: Int32Array, thin: Uint8Array }} `thin` marks the
148 * pixels of thin lines (rigging, pennants, rain, spray, stars, lightning) braille may draw
149 */
150export function scenePixels(t, weather, family, width, opts = {}) {
151 const W = Math.max(1, Math.floor(width)) * CELL_W;
152 const wx = WEATHER[weather] ?? WEATHER.calm;
153 const p = (PALETTES[family] ?? PALETTES.light)[weather] ?? PALETTES.light.calm;
154 const ship = SHIPS[opts.variant] ?? SHIPS.galleon;
155 const seed = opts.seed ?? 0;
156 const field = makeSea(seed, wx, 0);
157 const seaAt = (x, tt) => BASE + K * field.height(x / K, tt);
158 const px = new Int32Array(W * H);
159 const thin = new Uint8Array(W * H);
160 const get = (x, y) => px[y * W + x];
161 const set = (x, y, c, line = 0) => { px[y * W + x] = c; thin[y * W + x] = line; };
162 const inside = (x, y) => x >= 0 && x < W && y >= 0 && y < H;
163 const blend = (x, y, c, a, line = 0) => { if (inside(x, y) && a > 0) set(x, y, a >= 1 ? c : mix(get(x, y), c, a), line); };
164 const flash = wx.bolt && flashAt(t);
165 const top = hex(p.top), bot = hex(p.bot);
166
167 for (let y = 0; y < H; y++) {
168 let c = mix(top, bot, y / (H - 1));
169 if (flash) c = mix(c, hex(p.flash), 0.65);
170 px.fill(c, y * W, (y + 1) * W);
171 }
172 const sx = Math.floor(W * 0.82), sy = 5;
173 if (weather === "calm") {
174 for (let y = 0; y < 11; y++) for (let x = sx - 6; x <= sx + 6; x++) {
175 const d = Math.hypot(x + 0.5 - sx, y + 0.5 - sy);
176 blend(x, y, hex(p.halo), 0.5 * smooth(5.4, 3.6, d));
177 blend(x, y, hex(p.sun), smooth(3.6, 2.8, d));
178 }
179 }
180 if (wx.night) {
181 for (let i = 0; i < Math.floor(W / 7); i++) {
182 const x = Math.floor(hash(i, 3) * W), y = Math.floor(hash(i, 9) * 13);
183 if (hash(i, Math.floor(t / 5)) > 0.3) blend(x, y, hex(p.star), 0.35 + 0.65 * hash(i, 77), 1);
184 }
185 for (let y = 0; y < 11; y++) for (let x = sx - 5; x <= sx + 5; x++) {
186 const lit = smooth(4.1, 3.3, Math.hypot(x + 0.5 - sx, y + 0.5 - sy)) * smooth(2.6, 3.4, Math.hypot(x + 0.5 - sx - 2.2, y + 0.5 - sy + 1));
187 blend(x, y, hex(p.moon), lit);
188 }
189 }
190 if (wx.cloud > 0) {
191 const n = Math.round(2 + wx.cloud * 4), span = W + 48;
192 for (let k = 0; k < n; k++) {
193 const cx = ((k * 46 + hash(k, 5) * span + t * 0.24 * wx.speed) % span) - 24;
194 const cy = 2.4 + (k % 3) * 1.8, rx = 8 + hash(k, 1) * 10, ry = 2.8 + hash(k, 2) * 1.8;
195 for (let y = 0; y < 14; y++) for (let x = Math.max(0, Math.floor(cx - rx - 2)); x < Math.min(W, cx + rx + 2); x++) {
196 // puffed edges: the ellipse's rim pushed in and out by noise that drifts with the cloud
197 const puff = 0.3 * (vnoise((x - cx) * 0.3 + k * 17, y * 0.45, seed) - 0.5);
198 const r = ((x + 0.5 - cx) / rx) ** 2 + ((y + 0.5 - cy) / ry) ** 2;
199 if (r >= 1 + puff) continue;
200 const c = flash ? mix(hex(p.cl), hex(p.flash), 0.6) : mix(hex(p.cl), hex(p.cd), smooth(cy - ry * 0.6, cy + ry * 0.7, y + 0.5));
201 set(x, y, c);
202 }
203 }
204 }
205
206 // the surface, sampled one pixel beyond each edge so every column has a slope
207 const S = new Float64Array(W + 2);
208 for (let i = 0; i < W + 2; i++) S[i] = seaAt(i - 1, t);
209 const surf = (x) => S[x + 1];
210 const slope = (x) => (S[x + 2] - S[x]) / 2; // > 0 where the surface falls away to the right
211
212 // the ship rides the sea under its stern and bow: heave with their mean, pitch with their difference
213 const tr = track(t, W - ship.width);
214 const xOf = (lx) => (tr.dir > 0 ? tr.pos + lx : tr.pos + ship.width - 1 - lx);
215 const lxS = Math.round(ship.width * 0.15), lxB = Math.round(ship.width * 0.75);
216 const hS = seaAt(xOf(lxS), t), hB = seaAt(xOf(lxB), t);
217 const heave = BASE + 0.6 * ((hS + hB) / 2 - BASE) - WATERLINE;
218 const pitch = (hB - hS) * 0.5;
219 const tint = (c) => {
220 if (weather === "storm" || weather === "rain") c = mix(c, bot, 0.22);
221 if (wx.night) c = mix(c, top, 0.45);
222 if (flash) c = mix(c, 0xffffff, 0.3);
223 return c;
224 };
225 // The ship sits at a fractional position, heave and pitch: each of its pixels is a
226 // unit square laid on the buffer off the pixel grid. A buffer pixel takes the colour
227 // of the ship pixel that covers most of it (so the art's texture stays crisp) blended
228 // over what is behind by how much of it the ship covers (so its outline moves by
229 // fractions of a pixel, and the fitter's eighth blocks can follow it).
230 const rowOf = (lx, ly) => Math.max(-2, heave + (pitch * (lx - (lxS + lxB) / 2)) / (lxB - lxS)) + ly;
231 const laid = layShip(shipPixels(weather, t, opts.variant).map(([lx, ly, key]) => [
232 xOf(lx), rowOf(lx, ly), key === "w" && wx.night ? (t % 9 < 7 ? hex(SHIP.L) : mix(hex(SHIP.L), 0, 0.3)) : tint(hex(SHIP[key])), key === "r" || key === "f",
233 ]), W, H);
234 for (const i of laid.touched) {
235 const a = Math.min(1, laid.cover[i]);
236 px[i] = a >= 0.999 ? laid.colour[i] : mix(px[i], laid.colour[i], a);
237 thin[i] = laid.rope[i] >= 0.25 ? 1 : 0;
238 }
239
240 // the sea, drawn over the ship's waterline: lightness falls with depth and in the
241 // troughs, rises into foam on tall, front-facing crests; the top pixel of each column
242 // is blended with what is behind it by how much of it the surface covers
243 const ramp = [hex(p.sea[2]), hex(p.sea[2]), hex(p.sea[1]), hex(p.sea[0]), hex(p.foam)];
244 ramp[0] = mix(ramp[1], 0, 0.3);
245 const stops = [-0.35, 0, 0.5, 1, 1.5];
246 const shade = (L) => {
247 if (L <= stops[0]) return ramp[0];
248 for (let i = 1; i < stops.length; i++) if (L <= stops[i]) return mix(ramp[i - 1], ramp[i], (L - stops[i - 1]) / (stops[i] - stops[i - 1]));
249 return ramp[4];
250 };
251 const foamC = hex(p.foam);
252 const glintColour = hex(weather === "calm" ? p.sun : p.glint ?? p.foam);
253 const glints = weather === "calm" || wx.night;
254 const aim = (x) => ((sx - x) * 0.045) / K; // the slope that turns a facet toward the sun or moon
255 // the ship's bow and stern on the surface: foam where the bow cuts it, a wake astern
256 const bowX = xOf(ship.bow), sternX = xOf(ship.stern), dir = tr.dir;
257 for (let x = 0; x < W; x++) {
258 const s = surf(x), sl = slope(x);
259 const u = (BASE - s) / (K * wx.amp); // height in swell units: +1 a crest, -1 a trough
260 const foam = quant(smooth(wx.cap, wx.cap + 0.35, u) * (0.75 + 0.25 * clamp01(sl / 0.6 + 0.5)) * wx.foam, 4);
261 const trough = smooth(-0.25, -1, u) * 0.3;
262 const lit = glints ? Math.exp(-(((x - sx) / (3 * K)) ** 2)) * clamp01(1 - Math.abs(sl - aim(x)) / 0.3) ** 2 : 0;
263 const ahead = (x - bowX) * dir, astern = (sternX - x) * dir;
264 const wash = ahead >= -1 && ahead <= 2 ? 0.8 - 0.2 * Math.max(0, ahead) : astern > 0 && astern < 18 ? 0.55 * (1 - astern / 18) * (0.5 + 0.5 * vnoise(x * 0.5 + t * 0.15 * dir, t * 0.05, seed + 3)) : 0;
265 for (let y = Math.max(0, Math.floor(s)); y < H; y++) {
266 const cov = quant(clamp01(y + 1 - s), 8);
267 if (cov === 0) continue;
268 const d = Math.max(0, y + 0.5 - s);
269 const rim = 0.3 * smooth(-0.2, 1, u) * Math.max(0, 1 - d / (1.2 * K));
270 let c = shade(quant(1 - d / (2.2 * K) - trough + rim + 0.8 * foam * Math.max(0, 1 - d / (1.6 * K)), 8));
271 if (lit > 0 && d < 2 * K) c = mix(c, glintColour, quant(0.7 * lit * (1 - d / (2 * K)), 4));
272 if (wash > 0 && d < 2) c = mix(c, foamC, quant(wash * (1 - d / 2), 4));
273 if (flash) c = mix(c, hex(p.flash), 0.35);
274 set(x, y, cov < 1 ? mix(get(x, y), c, cov) : c);
275 }
276 // storm and rain fling spray off the whitecaps: flecks of foam in the air above a crest
277 if (wx.spray > 0 && foam > 0.25) {
278 for (let k = 1; k <= 2 * K; k++) {
279 const y = Math.floor(s) - k;
280 if (y < 0 || y >= H) continue;
281 const a = quant(clamp01((vnoise((x * 0.6) / K - t * 0.3, (y * 1.7) / K + t * 0.12, seed) - 0.5) * 2) * wx.spray * foam * (k <= K ? 1 : 0.5), 4);
282 if (a > 0) blend(x, y, foamC, a, 1);
283 }
284 }
285 }
286 if (wx.rain > 0) {
287 for (let i = 0; i < Math.floor((W / K) * wx.rain); i++) {
288 const y = Math.floor((hash(i, 11) * (H + 8) + t * 1.7 * K) % (H + 8)) - 4;
289 const x = Math.floor((hash(i, 12) * W + t * 0.9 * K + y * 0.6) % W);
290 for (let k = 0; k < 3; k++) {
291 const yy = y - k, xx = x - Math.floor(k / 2);
292 if (inside(xx, yy) && yy < surf(xx)) blend(xx, yy, hex(p.rain), 0.75 - 0.15 * k, 1);
293 }
294 }
295 }
296 if (wx.bolt && boltAt(t)) {
297 let bx = Math.floor(W * (0.15 + 0.7 * hash(Math.floor(t / 34), 4)));
298 for (let y = 0; y < H && y < surf(bx); y++) {
299 set(bx, y, hex(p.bolt), 1);
300 bx = Math.max(0, Math.min(W - 1, bx + (hash(y, Math.floor(t / 34)) > 0.5 ? 1 : -1)));
301 }
302 }
303 return { width: W, height: H, px, thin };
304}
305
306/**
307 * One frame of the scene, exactly `width` cells by SCENE_ROWS, as rows of
308 * `{ ch, fg, bg }` with RGB numbers (DEFAULT_COLOR never appears: the scene is opaque).
309 * @param {number} t tick
310 * @param {"storm" | "rain" | "clouds" | "night" | "calm"} weather
311 * @param {"dark" | "light"} family
312 * @param {number} width terminal columns
313 * @param {{ seed?: number, variant?: string, glyphs?: string, prev?: { ch: string, fg: number, bg: number }[][] }} [opts]
314 * the sea's seed, the ship (default the galleon), the glyph set (default extended) and the
315 * frame shown before this one, whose cells stand where the new fit barely differs (hysteresis)
316 */
317export function sceneFrame(t, weather, family, width, opts = {}) {
318 const columns = Math.max(1, Math.floor(width));
319 const { px, thin } = scenePixels(t, weather, family, columns, opts);
320 return fitCells(px, columns, SCENE_ROWS, opts.glyphs ?? DEFAULT_GLYPHS, thin, opts.prev);
321}
322
323/** The theme family for a `theme` setting: `dark*` is dark, everything else light, as Calm does. */
324export function paletteFamily(theme) {
325 return typeof theme === "string" && theme.startsWith("dark") ? "dark" : "light";
326}
327lib/pack.mjs 49 lines1// Packs a frame of `{ ch, fg, bg }` cells as a Claude Code Raster's `cells`: standard
2// padded base64 of `columns * rows` little-endian u32 triplets `[codePoint, fg, bg]`
3// (RasterProps). Every row is padded to the full width and clipped at it.
4
5import { DEFAULT_COLOR } from "./galleon.mjs";
6
7const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
8
9/** Standard padded base64, written here because the hooks environment has no Node Buffer. */
10export function encodeBase64(bytes) {
11 let out = "";
12 let i = 0;
13 for (; i + 2 < bytes.length; i += 3) {
14 const w = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
15 out += ALPHABET[(w >> 18) & 63] + ALPHABET[(w >> 12) & 63] + ALPHABET[(w >> 6) & 63] + ALPHABET[w & 63];
16 }
17 const rest = bytes.length - i;
18 if (rest === 1) {
19 const w = bytes[i] << 16;
20 out += ALPHABET[(w >> 18) & 63] + ALPHABET[(w >> 12) & 63] + "==";
21 } else if (rest === 2) {
22 const w = (bytes[i] << 16) | (bytes[i + 1] << 8);
23 out += ALPHABET[(w >> 18) & 63] + ALPHABET[(w >> 12) & 63] + ALPHABET[(w >> 6) & 63] + "=";
24 }
25 return out;
26}
27
28/**
29 * @param {{ ch: string, fg: number, bg: number }[][]} frame
30 * @param {number} columns
31 * @returns {{ rows: number, cells: string }}
32 */
33export function packCells(frame, columns) {
34 const rows = Math.max(1, frame.length);
35 // Little-endian on every host the engine runs on; set explicitly rather than trusting a typed-array view.
36 const bytes = new Uint8Array(columns * rows * 12);
37 const view = new DataView(bytes.buffer);
38 for (let r = 0; r < rows; r++) {
39 for (let c = 0; c < columns; c++) {
40 const cell = frame[r]?.[c];
41 const offset = (r * columns + c) * 12;
42 view.setUint32(offset, cell ? cell.ch.codePointAt(0) ?? 0x20 : 0x20, true);
43 view.setUint32(offset + 4, cell ? cell.fg : DEFAULT_COLOR, true);
44 view.setUint32(offset + 8, cell ? cell.bg : DEFAULT_COLOR, true);
45 }
46 }
47 return { rows, cells: encodeBase64(bytes) };
48}
49lib/cells.mjs 408 lines1// The cell fitter: turns an RGB pixel buffer drawn at 2x4 pixels per terminal cell
2// into one glyph, a foreground and a background per cell, chafa-style. Every glyph is
3// a coverage map over the cell's sub-pixels (alpha 1 where its ink fills a sub-pixel,
4// a fraction where an edge crosses it, 0 where it leaves the background), so a cell
5// drawn with it shows alpha * fg + (1 - alpha) * bg in each sub-pixel. For each glyph
6// the two colours that best reproduce the pixels are a closed-form least-squares
7// solve, and the error is what they leave unexplained; the fitter takes the glyph with
8// the least error.
9//
10// Glyph sets, finest first:
11// octant 2x4 U+1CD00 block (Unicode 16) and the older blocks it reuses
12// sextant 2x3 U+1FB00 block (Unicode 13)
13// extended 2x4 quadrant, plus the lower and left eighth blocks and the corner
14// triangles ◢◣◤◥, all BMP and all drawn by Ghostty itself
15// quadrant 2x2 U+2596-U+259F, plus ▂ and ▆ so a horizontal edge keeps all four sub-rows
16// half 1x2 ▀, the original renderer's look
17// Every set but half is overlaid with braille (U+2800, 2x4, one colour of dots on a
18// background) where a thin line beats the block fit: the scene marks the pixels of its
19// thin things (rigging, pennants, rain, spray, stars, lightning) and braille may draw
20// only those, so the dots never fringe a sail or the sun.
21//
22// The extended set's coverage is Ghostty's own drawing (src/font/sprite/draw/block.zig
23// and geometric_shapes.zig): an eighth block is a rectangle a whole number of eighths
24// of the cell tall or wide (Ghostty rounds that to device pixels, a few percent at
25// most), a corner triangle is the filled half-cell on one side of the cell's diagonal.
26// Both are rasterised here onto the 2x4 grid by area, so an eighth block's edge lands
27// mid-sub-pixel as a half-covered row or a quarter-covered column. Glyphs Ghostty
28// strokes at the font's line thickness (the box-drawing diagonals ╱╲╳) or leaves to
29// the font (◀▶▲▼, East Asian ambiguous width, two of them with emoji presentations)
30// have no fixed coverage and are left out; the shades ░▒▓ fill the whole cell at one
31// alpha, which a flat cell already draws.
32//
33// Claude Code's Raster takes only BMP code points (checked on 2.1.289: a sextant or
34// octant cell refuses the whole tree, "beyond the Basic Multilingual Plane"; every
35// extended glyph passes), so the band uses extended, quadrant or half
36// (BMP_GLYPH_SETS); octant and sextant draw in preview.mjs, which writes straight to
37// a terminal.
38//
39// Stability, within a frame: a choice that only noise separates from a simpler one
40// would flip from frame to frame, so every glyph pays a small cost per edge it draws
41// and braille a larger one, and a glyph displaces the one before it in the set's fixed
42// order only by a margin; near-equal fits fall to the simplest, earliest glyph.
43// Between frames (hysteresis): given the previous frame, a cell keeps its glyph and
44// colours unless the new fit beats them on the new pixels by HOLD, so slow drift and
45// noise leave it alone and only real change redraws it.
46// Colours are quantised, coarser only when a frame would pass the Raster's 1024 pairs.
47
48export const GLYPH_SETS = ["octant", "sextant", "extended", "quadrant", "half"];
49/** The sets whose every glyph is a BMP code point: what Claude Code's Raster accepts. */
50export const BMP_GLYPH_SETS = ["extended", "quadrant", "half"];
51export const DEFAULT_GLYPHS = "extended";
52
53/** Sub-pixels per cell in the buffer the scene draws. */
54export const CELL_W = 2;
55export const CELL_H = 4;
56
57/** The Raster paints this many distinct colour pairs at once; a frame stays at or under it. */
58export const PAIR_CAP = 1024;
59
60/** Per edge a glyph draws between unlike sub-pixels: one pixel ~8 levels off. */
61const EDGE_COST = 3 * 8 * 8;
62/**
63 * What a later glyph must win by to displace an earlier one, at least half an edge and
64 * 5% of the cell's variance: ties go the same way every frame, however sharp the cell.
65 */
66const MARGIN = EDGE_COST / 2;
67const MARGIN_SHARE = 0.05;
68/** What braille pays over a block glyph: its dots read lighter than the pixels they stand for. */
69const BRAILLE_COST = 3000;
70/** Braille stands in only for thin detail: at most this many dots. */
71const BRAILLE_DOTS = 3;
72/**
73 * What a new fit must beat the previous frame's cell by, on the new pixels, to replace
74 * it: the cell's whole error at 6 levels RMS per channel, too little to see.
75 */
76export const HOLD = 3 * 8 * 6 * 6;
77const STEPS = [4, 8, 16, 32, 64];
78
79/**
80 * The glyph set for a setting (FLEET_WEATHER_GLYPHS): a set's name, singular or
81 * plural, in any case; anything else is the default. `bmpOnly` folds the non-BMP sets
82 * to the default, for the Raster.
83 * @param {unknown} value
84 * @param {{ bmpOnly?: boolean }} [opts]
85 */
86export function resolveGlyphs(value, { bmpOnly = false } = {}) {
87 const named = typeof value === "string" ? value.trim().toLowerCase().replace(/s$/, "") : "";
88 const set = GLYPH_SETS.includes(named) ? named : DEFAULT_GLYPHS;
89 return bmpOnly && !BMP_GLYPH_SETS.includes(set) ? DEFAULT_GLYPHS : set;
90}
91
92// ---- glyph tables: a sub-pixel mask (bit r*gw+c set where the glyph's ink is) to a code point ----
93
94/** Octant masks Unicode 16 left out because an older block already draws them. */
95const OCTANT_REUSED = {
96 0x01: 0x1cea8, 0x02: 0x1ceab, 0x03: 0x1fb82, 0x05: 0x2598, 0x0a: 0x259d, 0x0f: 0x2580, 0x14: 0x1fbe6,
97 0x28: 0x1fbe7, 0x3f: 0x1fb85, 0x40: 0x1cea3, 0x50: 0x2596, 0x55: 0x258c, 0x5a: 0x259e, 0x5f: 0x259b,
98 0x80: 0x1cea0, 0xa0: 0x2597, 0xa5: 0x259a, 0xaa: 0x2590, 0xaf: 0x259c, 0xc0: 0x2582, 0xf0: 0x2584,
99 0xf5: 0x2599, 0xfa: 0x259f, 0xfc: 0x2586,
100};
101
102function octantTable() {
103 const table = new Map();
104 let next = 0x1cd00;
105 for (let m = 1; m < 255; m++) table.set(m, OCTANT_REUSED[m] ?? next++);
106 return table;
107}
108
109function sextantTable() {
110 const table = new Map();
111 for (let m = 1; m < 63; m++) table.set(m, m === 21 ? 0x258c : m === 42 ? 0x2590 : 0x1fb00 + m - 1 - (m > 21 ? 1 : 0) - (m > 42 ? 1 : 0));
112 return table;
113}
114
115const QUADRANT = new Map([
116 [0x05, 0x2598], [0x0a, 0x259d], [0x0f, 0x2580], [0x50, 0x2596], [0x55, 0x258c], [0x5a, 0x259e], [0x5f, 0x259b],
117 [0xa0, 0x2597], [0xa5, 0x259a], [0xaf, 0x259c], [0xf5, 0x2599], [0xfa, 0x259f], [0xc0, 0x2582], [0xfc, 0x2586],
118]);
119
120/**
121 * The extended set's shapes beyond quadrant, as Ghostty draws them: an ink test on the
122 * cell's unit square (x right, y down), in the order the fitter tries them.
123 */
124const rect = (x0, x1, y0, y1) => (x, y) => x >= x0 && x < x1 && y >= y0 && y < y1;
125const EXTENDED = [
126 [0x2581, rect(0, 1, 7 / 8, 1)], // ▁ lower one eighth
127 [0x2583, rect(0, 1, 5 / 8, 1)], // ▃ lower three eighths
128 [0x2585, rect(0, 1, 3 / 8, 1)], // ▅ lower five eighths
129 [0x2587, rect(0, 1, 1 / 8, 1)], // ▇ lower seven eighths
130 [0x258f, rect(0, 1 / 8, 0, 1)], // ▏ left one eighth
131 [0x258e, rect(0, 2 / 8, 0, 1)], // ▎ left one quarter
132 [0x258d, rect(0, 3 / 8, 0, 1)], // ▍ left three eighths
133 [0x258b, rect(0, 5 / 8, 0, 1)], // ▋ left five eighths
134 [0x258a, rect(0, 6 / 8, 0, 1)], // ▊ left three quarters
135 [0x2589, rect(0, 7 / 8, 0, 1)], // ▉ left seven eighths
136 [0x25e2, (x, y) => x + y >= 1], // ◢ lower right triangle (◤ is its complement)
137 [0x25e3, (x, y) => y >= x], // ◣ lower left triangle (◥ is its complement)
138];
139/** Samples per sub-pixel side when rasterising a shape: eighth edges fall on a sample boundary. */
140const SAMPLES = 16;
141
142/** A shape's coverage of each sub-pixel of a gw x gh grid, by area. */
143function rasterise(shape, gw, gh) {
144 const alpha = new Float64Array(gw * gh);
145 for (let r = 0; r < gh; r++) for (let c = 0; c < gw; c++) {
146 let inked = 0;
147 for (let sy = 0; sy < SAMPLES; sy++) for (let sx = 0; sx < SAMPLES; sx++) {
148 if (shape((c + (sx + 0.5) / SAMPLES) / gw, (r + (sy + 0.5) / SAMPLES) / gh)) inked++;
149 }
150 alpha[r * gw + c] = inked / (SAMPLES * SAMPLES);
151 }
152 return alpha;
153}
154
155const maskAlpha = (mask, n) => Float64Array.from({ length: n }, (_, i) => (mask >> i) & 1);
156
157/** Braille dot bits by sub-pixel (bit r*2+c of the 2x4 grid). */
158const BRAILLE_DOT = [0x01, 0x08, 0x02, 0x10, 0x04, 0x20, 0x40, 0x80];
159const brailleChar = (mask) => {
160 let dots = 0;
161 for (let i = 0; i < 8; i++) if (mask & (1 << i)) dots |= BRAILLE_DOT[i];
162 return String.fromCodePoint(0x2800 + dots);
163};
164
165const popcount = (m) => { let n = 0; for (; m; m &= m - 1) n++; return n; };
166
167/** Edges a coverage map draws on a gw x gh grid: the coverage step between each pair of neighbours. */
168function edges(alpha, gw, gh) {
169 let n = 0;
170 for (let r = 0; r < gh; r++) for (let c = 0; c < gw; c++) {
171 const a = alpha[r * gw + c];
172 if (c + 1 < gw) n += Math.abs(a - alpha[r * gw + c + 1]);
173 if (r + 1 < gh) n += Math.abs(a - alpha[(r + 1) * gw + c]);
174 }
175 return n;
176}
177
178/**
179 * A glyph compiled for fitting: its coverage, the sub-pixels it touches, and the sums
180 * the two-colour solve needs (A = sum alpha, Q = sum alpha^2, D the normal equations'
181 * determinant).
182 */
183function part(ch, alpha, cost) {
184 const n = alpha.length;
185 let A = 0, Q = 0;
186 const idx = [];
187 for (let i = 0; i < n; i++) { A += alpha[i]; Q += alpha[i] * alpha[i]; if (alpha[i] > 0) idx.push(i); }
188 return { ch, alpha, idx: Int8Array.from(idx), A, Q, D: Q * (n - 2 * A + Q) - (A - Q) * (A - Q), cost };
189}
190
191/**
192 * A set compiled for fitting: one part per glyph shape (a shape and its complement are
193 * one glyph with the colours swapped, so only the first is kept), and every glyph's
194 * coverage by character, for holding a cell from the previous frame.
195 * @param {[string, Float64Array][]} glyphs in the order the fitter tries them
196 */
197function compile(glyphs, gw, gh, braille) {
198 const n = gw * gh;
199 const key = (alpha) => Array.from(alpha, (a) => a.toFixed(4)).join(",");
200 const seen = new Set();
201 const parts = [];
202 const coverage = new Map([[" ", new Float64Array(n)]]);
203 for (const [ch, alpha] of glyphs) {
204 coverage.set(ch, alpha);
205 const k = key(alpha);
206 if (seen.has(k) || seen.has(key(alpha.map((a) => 1 - a)))) continue;
207 seen.add(k);
208 parts.push(part(ch, alpha, EDGE_COST * edges(alpha, gw, gh)));
209 }
210 if (braille) for (const b of BRAILLE) coverage.set(b.ch, b.alpha);
211 return { gw, gh, n, parts, braille, coverage };
212}
213
214const fromTable = (table, n) => [...table].map(([mask, cp]) => [String.fromCodePoint(cp), maskAlpha(mask, n)]);
215
216const OCTANTS = octantTable();
217const SEXTANTS = sextantTable();
218
219const BRAILLE = [];
220for (let m = 1; m < 255; m++) if (popcount(m) <= BRAILLE_DOTS) BRAILLE.push(part(brailleChar(m), maskAlpha(m, 8), BRAILLE_COST));
221
222const SETS = {
223 octant: compile(fromTable(OCTANTS, 8), 2, 4, true),
224 sextant: compile(fromTable(SEXTANTS, 6), 2, 3, true),
225 extended: compile([...fromTable(QUADRANT, 8), ...EXTENDED.map(([cp, shape]) => [String.fromCodePoint(cp), rasterise(shape, 2, 4)])], 2, 4, true),
226 quadrant: compile(fromTable(QUADRANT, 8), 2, 4, true),
227 half: compile(fromTable(new Map([[0x0f, 0x2580]]), 8), 2, 4, false),
228};
229
230/** Every glyph a set can put in a cell, braille included: what the tests hold frames to. */
231export function glyphsOf(name) {
232 const set = SETS[name] ?? SETS[DEFAULT_GLYPHS];
233 const all = new Set([" ", ...set.parts.map((p) => p.ch)]);
234 if (set.braille) for (const b of BRAILLE) all.add(b.ch);
235 return all;
236}
237
238const EXTENDED_COVERAGE = new Map(EXTENDED.map(([cp, shape]) => [cp, rasterise(shape, 2, 4)]));
239/** The complements the extended set draws by swapping colours: ▄ ▐ ◤ ◥ ▔ ▕ and the rest of the block. */
240const COMPLEMENTS = [[0x2584, 0x2580], [0x2590, 0x258c], [0x25e4, 0x25e2], [0x25e5, 0x25e3], [0x2594, 0x2587], [0x2595, 0x2589]];
241
242/**
243 * What a glyph inks, for drawing a frame back to pixels: its coverage `alpha` on its
244 * grid (2x4 or 2x3), `mask` where every sub-pixel is wholly in or out (undefined for an
245 * eighth block or triangle), and whether it is braille (dots, not blocks). A space inks
246 * nothing.
247 * @returns {{ gw: number, gh: number, alpha: Float64Array, mask: number | undefined, braille: boolean } | undefined}
248 */
249export function inkOf(ch) {
250 const ink = (gw, gh, alpha, braille = false) => {
251 let mask = 0;
252 for (let i = 0; i < alpha.length; i++) {
253 if (alpha[i] !== 0 && alpha[i] !== 1) return { gw, gh, alpha, mask: undefined, braille };
254 if (alpha[i] === 1) mask |= 1 << i;
255 }
256 return { gw, gh, alpha, mask, braille };
257 };
258 if (ch === " ") return ink(2, 4, new Float64Array(8));
259 const cp = ch.codePointAt(0) ?? 0;
260 if (cp > 0x2800 && cp <= 0x28ff) {
261 let mask = 0;
262 for (let i = 0; i < 8; i++) if ((cp - 0x2800) & BRAILLE_DOT[i]) mask |= 1 << i;
263 return ink(2, 4, maskAlpha(mask, 8), true);
264 }
265 for (const [grid, table] of [[[2, 4], OCTANTS], [[2, 3], SEXTANTS]]) {
266 for (const [mask, c] of table) if (c === cp) return ink(grid[0], grid[1], maskAlpha(mask, grid[0] * grid[1]));
267 }
268 const alpha = EXTENDED_COVERAGE.get(cp);
269 if (alpha) return ink(2, 4, alpha);
270 for (const [c, of] of COMPLEMENTS) {
271 const base = EXTENDED_COVERAGE.get(of) ?? inkOf(String.fromCodePoint(of))?.alpha;
272 if (c === cp && base) return ink(2, 4, base.map((a) => 1 - a));
273 }
274 return undefined;
275}
276
277const quantise = (c, step) => {
278 const q = (v) => Math.min(255, Math.round(v / step) * step);
279 return (q(c >> 16) << 16) | (q((c >> 8) & 255) << 8) | q(c & 255);
280};
281
282const clamp255 = (v) => (v < 0 ? 0 : v > 255 ? 255 : v);
283
284/**
285 * Fits a pixel buffer to `columns` x `rows` cells.
286 * @param {Int32Array | number[]} px RGB pixels, row-major, `columns * CELL_W` wide and `rows * CELL_H` tall
287 * @param {number} columns
288 * @param {number} rows
289 * @param {string} [glyphs] one of GLYPH_SETS
290 * @param {Uint8Array} [thin] per pixel, non-zero where the scene drew a thin line braille may stand for
291 * @param {{ ch: string, fg: number, bg: number }[][]} [prev] the previous frame at the same size and
292 * set: a cell keeps its glyph and colours unless the new fit beats them by HOLD
293 * @returns {{ ch: string, fg: number, bg: number }[][]}
294 */
295export function fitCells(px, columns, rows, glyphs = DEFAULT_GLYPHS, thin = undefined, prev = undefined) {
296 const set = SETS[glyphs] ?? SETS[DEFAULT_GLYPHS];
297 const pw = columns * CELL_W;
298 const n = set.n;
299 const R = new Float64Array(n), G = new Float64Array(n), B = new Float64Array(n);
300 const raw = new Array(8);
301 const held = prev?.length === rows && prev.every((line) => line?.length === columns) ? prev : undefined;
302 const frame = [];
303 for (let row = 0; row < rows; row++) {
304 const line = [];
305 for (let col = 0; col < columns; col++) {
306 // the cell's sub-pixels; a 2x3 set takes the 2x4 block area-weighted into thirds
307 for (let i = 0; i < 8; i++) {
308 const c = px[(row * CELL_H + (i >> 1)) * pw + col * CELL_W + (i & 1)];
309 if (n === 8) { R[i] = c >> 16; G[i] = (c >> 8) & 255; B[i] = c & 255; } else raw[i] = c;
310 }
311 if (n === 6) {
312 for (let k = 0; k < 2; k++) {
313 const ch = (i, s) => (raw[i * 2 + k] >> s) & 255;
314 for (const [s, out] of [[16, R], [8, G], [0, B]]) {
315 out[k] = (3 * ch(0, s) + ch(1, s)) / 4;
316 out[2 + k] = (ch(1, s) + ch(2, s)) / 2;
317 out[4 + k] = (ch(2, s) + 3 * ch(3, s)) / 4;
318 }
319 }
320 }
321 let lines = 0;
322 if (thin) for (let i = 0; i < 8; i++) if (thin[(row * CELL_H + (i >> 1)) * pw + col * CELL_W + (i & 1)]) lines |= 1 << i;
323 let sr = 0, sg = 0, sb = 0, ssq = 0;
324 for (let i = 0; i < n; i++) { sr += R[i]; sg += G[i]; sb += B[i]; ssq += R[i] * R[i] + G[i] * G[i] + B[i] * B[i]; }
325 const flat = ssq - (sr * sr + sg * sg + sb * sb) / n;
326 // the best glyph so far: its error with and without its cost, and its colours
327 let bestScore = flat, bestErr = flat, bestPart;
328 let fr = sr / n, fg = sg / n, fb = sb / n, br = fr, bgg = fg, bb = fb;
329 /** The exact error of colours (f, b) under a coverage map. */
330 const exact = (alpha, f0, f1, f2, b0, b1, b2) => {
331 let e = 0;
332 for (let i = 0; i < n; i++) {
333 const a = alpha[i], z = 1 - a;
334 const d0 = a * f0 + z * b0 - R[i], d1 = a * f1 + z * b1 - G[i], d2 = a * f2 + z * b2 - B[i];
335 e += d0 * d0 + d1 * d1 + d2 * d2;
336 }
337 return e;
338 };
339 /** Solves a glyph's two colours in closed form and takes it when it wins by the margin. */
340 const consider = (p, margin) => {
341 let ar = 0, ag = 0, ab = 0;
342 for (let j = 0; j < p.idx.length; j++) { const i = p.idx[j], a = p.alpha[i]; ar += a * R[i]; ag += a * G[i]; ab += a * B[i]; }
343 // normal equations: [Q, A-Q; A-Q, n-2A+Q] [f; b] = [sum a p; sum (1-a) p]
344 const u = n - 2 * p.A + p.Q, v = p.A - p.Q, D = p.D;
345 const cr = sr - ar, cg = sg - ag, cb = sb - ab;
346 let f0 = (u * ar - v * cr) / D, f1 = (u * ag - v * cg) / D, f2 = (u * ab - v * cb) / D;
347 let b0 = (p.Q * cr - v * ar) / D, b1 = (p.Q * cg - v * ag) / D, b2 = (p.Q * cb - v * ab) / D;
348 let err = ssq - (f0 * ar + b0 * cr + f1 * ag + b1 * cg + f2 * ab + b2 * cb);
349 if (f0 < 0 || f0 > 255 || f1 < 0 || f1 > 255 || f2 < 0 || f2 > 255 || b0 < 0 || b0 > 255 || b1 < 0 || b1 > 255 || b2 < 0 || b2 > 255) {
350 // a fractional coverage can ask for colours past the gamut: score the ones the cell can show
351 f0 = clamp255(f0); f1 = clamp255(f1); f2 = clamp255(f2); b0 = clamp255(b0); b1 = clamp255(b1); b2 = clamp255(b2);
352 err = exact(p.alpha, f0, f1, f2, b0, b1, b2);
353 }
354 if (err + p.cost < bestScore - margin) {
355 bestScore = err + p.cost; bestErr = err; bestPart = p;
356 fr = f0; fg = f1; fb = f2; br = b0; bgg = b1; bb = b2;
357 }
358 };
359 if (flat > EDGE_COST) {
360 const margin = Math.max(MARGIN, MARGIN_SHARE * flat);
361 for (const p of set.parts) consider(p, margin);
362 // braille dots only where the scene drew a thin line, and only by a margin
363 if (set.braille && lines !== 0 && n === 8 && bestScore > BRAILLE_COST) {
364 for (const b of BRAILLE) {
365 let mask = 0;
366 for (let j = 0; j < b.idx.length; j++) mask |= 1 << b.idx[j];
367 if ((mask & lines) === mask) consider(b, margin);
368 }
369 }
370 }
371 const rgb = (r, g, b) => (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b);
372 const was = held?.[row][col];
373 const wasAlpha = was && set.coverage.get(was.ch);
374 if (wasAlpha) {
375 // the previous frame's cell stands unless the new fit beats it by HOLD
376 const f = was.fg, b = was.bg;
377 if (exact(wasAlpha, f >> 16, (f >> 8) & 255, f & 255, b >> 16, (b >> 8) & 255, b & 255) - bestErr < HOLD) {
378 line.push({ ch: was.ch, fg: f, bg: b, held: true });
379 continue;
380 }
381 }
382 if (bestPart === undefined) {
383 const c = rgb(fr, fg, fb);
384 line.push({ ch: " ", fg: c, bg: c });
385 } else line.push({ ch: bestPart.ch, fg: rgb(fr, fg, fb), bg: rgb(br, bgg, bb) });
386 }
387 frame.push(line);
388 }
389 return finish(frame);
390}
391
392/**
393 * Quantises colours, coarser until the frame is within PAIR_CAP distinct pairs. A held
394 * cell is already quantised and passes through unchanged unless the step coarsens.
395 */
396function finish(frame) {
397 for (const step of STEPS) {
398 const pairs = new Set();
399 const out = frame.map((line) => line.map((c) => {
400 const fg = quantise(c.fg, step), bg = quantise(c.bg, step);
401 pairs.add(fg * 0x1000000 + bg);
402 return { ch: c.ch === " " || fg === bg ? " " : c.ch, fg: c.ch === " " ? bg : fg, bg };
403 }));
404 if (pairs.size <= PAIR_CAP || step === STEPS[STEPS.length - 1]) return out;
405 }
406 return frame;
407}
408lib/sea.mjs 81 lines1// The procedural sea: a surface height field `y = height(x, t)` in scene pixels, built
2// from a seed. Three travelling swell components (their wavenumbers, speeds and phases
3// jittered by the seed) are shaped by a slow amplitude envelope and blended with a few
4// octaves of smooth value noise drifting at different speeds and directions, so crests
5// neither repeat on a visible period nor line up between sessions. Pure and
6// deterministic: the same seed, tick and weather give the same sea.
7
8const TAU = Math.PI * 2;
9
10/** A 32-bit integer hash of two integers, as a number in [0, 1). */
11export const hash = (a, b = 0) => {
12 let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
13 h = Math.imul(h ^ (h >>> 13), 1274126177);
14 h ^= h >>> 16;
15 return (h >>> 0) / 4294967296;
16};
17
18const fade = (t) => t * t * t * (t * (t * 6 - 15) + 10);
19
20/** Smooth 2-D value noise in [0, 1], C2-continuous across lattice cells. */
21export function vnoise(x, y, seed = 0) {
22 const xi = Math.floor(x), yi = Math.floor(y);
23 const u = fade(x - xi), v = fade(y - yi);
24 const ox = Math.imul(seed | 0, 7919), oy = Math.imul(seed | 0, 104729);
25 const a = hash(xi + ox, yi + oy), b = hash(xi + 1 + ox, yi + oy);
26 const c = hash(xi + ox, yi + 1 + oy), d = hash(xi + 1 + ox, yi + 1 + oy);
27 return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
28}
29
30/** Swell components: wavenumber (rad/px), speed (rad/tick, negative runs against the swell), weight. */
31const SWELL = [
32 { k: 0.36, w: 0.2, a: 1.0 },
33 { k: 0.63, w: 0.31, a: 0.5 },
34 { k: 1.13, w: -0.43, a: 0.2 },
35];
36
37/** Noise octaves: spatial frequency, drift (px-units/tick), evolution rate, weight. */
38const OCTAVES = [
39 { f: 0.11, d: 0.03, g: 0.012, a: 0.5 },
40 { f: 0.23, d: 0.07, g: 0.02, a: 0.3 },
41 { f: 0.47, d: -0.05, g: 0.035, a: 0.2 },
42];
43
44/**
45 * @param {number} seed integer
46 * @param {{ amp: number, speed: number, chop: number, steep: number, swell: number[] }} wx the weather's sea
47 * @param {number} base mean surface row
48 * @returns {{ height: (x: number, t: number) => number }}
49 */
50export function makeSea(seed, wx, base) {
51 const s = seed | 0;
52 const r = (i) => hash(s, 1000 + i);
53 const comps = SWELL.map((c, i) => ({
54 k: c.k * (1 + (r(i) - 0.5) * 0.2),
55 w: c.w * wx.speed * (0.9 + 0.2 * r(10 + i)),
56 ph: r(20 + i) * TAU,
57 a: c.a * wx.swell[i],
58 }));
59 const total = comps.reduce((n, c) => n + c.a, 0);
60 const sp = wx.speed;
61 const envOff = r(30) * 100, nOff = r(31) * 100;
62 return {
63 height(x, t) {
64 let sw = 0;
65 for (const c of comps) sw += c.a * Math.sin(c.k * x - c.w * t + c.ph);
66 sw /= total;
67 // slow groups of taller and flatter swell: irregular crest heights
68 const env = 0.55 + 0.6 * vnoise(x * 0.045 - t * 0.006 * sp + envOff, 5.0 + t * 0.003 * sp, s);
69 let n = 0;
70 for (let o = 0; o < OCTAVES.length; o++) {
71 const q = OCTAVES[o];
72 n += q.a * (vnoise(x * q.f - t * q.d * sp + nOff, o * 9.1 + t * q.g * sp, s + o) * 2 - 1);
73 }
74 const h = 1.7 * ((1 - wx.chop) * sw * env + wx.chop * n * 1.5);
75 // peaked crests over flatter troughs, more so as the weather steepens
76 const q = Math.max(-1.25, Math.min(1.25, h + wx.steep * (h * h - 0.2)));
77 return base - wx.amp * q;
78 },
79 };
80}
81lib/ships.mjs 266 lines1// The fleet's four vessels, drawn as [x, y, colour key] pixels in a local box
2// SHIP_HEIGHT rows tall at the scene's sub-cell resolution (2x4 pixels a terminal cell,
3// so 16 rows is four cells), whose rows from WATERLINE down ride under the sea. Every
4// ship faces right; the scene flips it to face its heading. Each is built from parts:
5// a hull of rail, wale and planking with gunports or portholes and lit stern windows; masts
6// and spars; standing rigging as one-pixel lines; sails filled as polygons and shaded
7// from the lit leading edge into the belly; pennants that wave. The rig answers the
8// weather: full sail in calm and cloud, reefed in rain, a storm sail in a storm and
9// furled at night, when the stern windows light.
10
11/** Pixel colours by key. */
12export const SHIP = {
13 f: "#d77757", M: "#4a2e1c", r: "#3b2c22", S: "#f4ecd8", s: "#c9b994", c: "#e2d6b8", W: "#fffaf0",
14 R: "#5a3420", H: "#7a4a2a", P: "#8e5a34", h: "#4f2f1a", G: "#d9a441", o: "#2a1a10", w: "#ffcf70",
15 b: "#bfa77a", F: "#cdbf98", L: "#ffb347", N: "#2c3e5c", n: "#1c2a42", u: "#bdb8a8", U: "#ece8dc",
16 E: "#c44a3a", J: "#c0472e", j: "#6f2618", K: "#9c3823",
17};
18
19/** The keys that are sail cloth: what the rig tests count. */
20export const SAIL_KEYS = "SscWJjK";
21
22/** The local box's height in pixels: four terminal rows. */
23export const SHIP_HEIGHT = 16;
24
25/** The first local row under water. */
26export const WATERLINE = 13;
27
28export const VARIANTS = ["galleon", "schooner", "sloop", "junk"];
29
30/** Rig levels: 0 full sail, 1 reefed (rain), 2 a storm sail (storm), 3 furled (night). */
31const levelFor = (weather) => (weather === "night" ? 3 : weather === "storm" ? 2 : weather === "rain" ? 1 : 0);
32
33/**
34 * A hull: columns x0..x1, each from its deck row down to its bottom row. The deck row is
35 * the rail, `wale` the row under it; `plank` names each lower row's key by its depth
36 * under the rail (so planking can alternate); under the waterline it is `dark`.
37 * `ports(x, y)` and `windows` ([x, y] pairs) break the planking.
38 */
39function hull(put, o) {
40 const lit = new Set((o.windows ?? []).map(([x, y]) => y * 64 + x));
41 for (let x = o.x0; x <= o.x1; x++) {
42 const deck = o.deck(x), bottom = o.bottom(x);
43 for (let y = deck; y <= bottom; y++) {
44 let k = y === deck ? o.rail : y === deck + 1 ? o.wale : y >= WATERLINE ? o.dark : o.plank(y - deck);
45 if (o.ports?.(x, y)) k = "o";
46 if (lit.has(y * 64 + x)) k = "w";
47 put(x, y, k);
48 }
49 }
50}
51
52/** A one-pixel line (Bresenham). */
53function line(put, x0, y0, x1, y1, k) {
54 const dx = Math.abs(x1 - x0), dy = -Math.abs(y1 - y0), sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
55 let err = dx + dy;
56 for (;;) {
57 put(x0, y0, k);
58 if (x0 === x1 && y0 === y1) return;
59 const e2 = 2 * err;
60 if (e2 >= dy) { err += dy; x0 += sx; }
61 if (e2 <= dx) { err += dx; y0 += sy; }
62 }
63}
64
65const mast = (put, x, y0, y1) => line(put, x, y0, x, y1, "M");
66const spar = (put, x0, x1, y, k = "M") => line(put, x0, y, x1, y, k);
67
68/**
69 * A filled polygon: every pixel whose centre is inside `pts`, keyed by `key(x, y)`.
70 * @param {[number, number][]} pts
71 */
72function poly(put, pts, key) {
73 const ys = pts.map((p) => p[1]), xs = pts.map((p) => p[0]);
74 for (let y = Math.floor(Math.min(...ys)); y <= Math.ceil(Math.max(...ys)); y++) {
75 for (let x = Math.floor(Math.min(...xs)); x <= Math.ceil(Math.max(...xs)); x++) {
76 const px = x + 0.5, py = y + 0.5;
77 let inside = false;
78 for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
79 const [xi, yi] = pts[i], [xj, yj] = pts[j];
80 if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) inside = !inside;
81 }
82 if (inside) put(x, y, key(x, y));
83 }
84 }
85}
86
87/**
88 * A square sail hanging from a yard: rows y0..y1 between x0 and x1, the foot rounded
89 * where the wind bellies it. Lit on the leading (right) edge, shaded aft into the belly.
90 */
91function square(put, x0, x1, y0, y1) {
92 for (let y = y0; y <= y1; y++) {
93 const inset = y === y1 && y1 > y0 ? 1 : 0;
94 for (let x = x0 + inset; x <= x1 - inset; x++) {
95 const f = (x - x0) / Math.max(1, x1 - x0);
96 put(x, y, x === x1 - inset ? "W" : f < 0.25 ? "s" : f < 0.5 || y === y1 ? "c" : "S");
97 }
98 }
99}
100
101/** Cloth shaded by distance from its luff at `lx`: lit there, shading towards the leech. */
102const fore = (lx, span) => (x) => {
103 const f = Math.abs(x - lx) / span;
104 return f < 0.2 ? "W" : f < 0.55 ? "S" : f < 0.8 ? "c" : "s";
105};
106
107/** A furled sail: a bundle of cloth along a spar. */
108const furl = (put, x0, x1, y) => spar(put, x0, x1, y, "F");
109
110/** Pennants and flags: [x, y, length] each, streaming right and waving with the tick. */
111function pennants(put, list, t) {
112 for (const [x, y, len] of list) {
113 for (let i = 0; i < len; i++) {
114 const wave = i === 0 ? 0 : Math.round(0.5 + 0.5 * Math.sin(t * 0.8 - i * 1.1));
115 put(x + i, y + wave, "f");
116 }
117 }
118}
119
120const galleon = {
121 width: 40,
122 stern: 2,
123 bow: 34,
124 build(put, lvl) {
125 // standing rigging behind everything: backstay, stays between the masts, forestay to the bowsprit
126 line(put, 8, 1, 1, 6, "r"); line(put, 8, 1, 19, 1, "r"); line(put, 19, 1, 28, 2, "r"); line(put, 28, 2, 39, 5, "r");
127 line(put, 19, 2, 14, 8, "r"); line(put, 28, 3, 32, 7, "r");
128 hull(put, {
129 x0: 2, x1: 34, rail: "R", wale: "G", dark: "h",
130 plank: (d) => (d % 2 === 0 ? "H" : "P"),
131 deck: (x) => (x <= 9 ? 6 : x === 10 ? 7 : x >= 29 ? 7 : 8),
132 bottom: (x) => [10, 12, 13, 14][x - 2] ?? [10, 11, 12, 13, 14][34 - x] ?? 15,
133 ports: (x, y) => y === 10 && x >= 12 && x <= 27 && x % 3 === 0,
134 windows: [[3, 8], [4, 8], [3, 9], [5, 8]],
135 });
136 put(1, 5, "w"); // stern lantern
137 line(put, 34, 7, 39, 4, "b"); // bowsprit
138 put(35, 8, "G"); // figurehead
139 mast(put, 8, 1, 5); mast(put, 19, 0, 7); mast(put, 28, 1, 6);
140 spar(put, 16, 22, 1); spar(put, 15, 23, 4); spar(put, 26, 30, 2); spar(put, 25, 31, 4);
141 if (lvl === 0) { square(put, 16, 22, 2, 3); square(put, 26, 30, 3, 3); } else { furl(put, 16, 22, 2); furl(put, 26, 30, 3); }
142 if (lvl <= 1) { square(put, 15, 23, 5, 7); square(put, 25, 31, 5, 6); }
143 else if (lvl === 2) { square(put, 15, 23, 5, 6); furl(put, 25, 31, 5); }
144 else { furl(put, 15, 23, 5); furl(put, 25, 31, 5); }
145 if (lvl <= 2) poly(put, [[8, 2], [8, 6], [2, 6], [6, 2]], fore(8, 6)); // mizzen gaff
146 else furl(put, 3, 7, 5);
147 if (lvl <= 1) poly(put, [[29, 2], [38, 5.5], [30, 7]], fore(38, 9)); // jib
148 },
149 pennants: [[20, 0, 4], [29, 1, 3], [9, 1, 2]],
150};
151
152const schooner = {
153 width: 40,
154 stern: 2,
155 bow: 34,
156 build(put, lvl) {
157 line(put, 13, 0, 2, 7, "r"); line(put, 13, 1, 24, 1, "r"); line(put, 24, 1, 39, 6, "r"); line(put, 24, 2, 32, 7, "r");
158 hull(put, {
159 x0: 2, x1: 34, rail: "u", wale: "E", dark: "n",
160 plank: () => "N",
161 deck: (x) => (x >= 30 ? 8 : 9),
162 bottom: (x) => [10, 11, 12, 13, 14][x - 2] ?? [9, 9, 10, 11, 12, 13, 14][34 - x] ?? 15,
163 windows: [[3, 10], [4, 10], [3, 11], [4, 11]],
164 });
165 put(2, 7, "w"); // stern lantern
166 line(put, 34, 8, 39, 6, "b"); // bowsprit
167 mast(put, 13, 0, 8); mast(put, 24, 1, 8);
168 if (lvl <= 1) {
169 const top = lvl === 0 ? 2 : 4;
170 poly(put, [[12.5, top], [6, top - 1], [3, 8], [12.5, 8]], fore(12, 10)); // mainsail
171 poly(put, [[23.5, top], [18, top - 1], [15, 8], [23.5, 8]], fore(23, 9)); // foresail
172 line(put, 12, top, 6, top - 1, "b"); line(put, 23, top, 18, top - 1, "b"); // gaffs
173 } else if (lvl === 2) poly(put, [[12.5, 5], [8, 8], [12.5, 8]], fore(12, 5)); // storm trysail
174 else { furl(put, 4, 12, 7); furl(put, 16, 23, 7); }
175 spar(put, 3, 12, 8, "b"); spar(put, 15, 23, 8, "b"); // booms
176 if (lvl === 0) { poly(put, [[12.5, 0], [12.5, 1.5], [8, 1.5]], fore(12, 4)); poly(put, [[25, 2], [37, 6.5], [27, 8]], fore(37, 10)); }
177 if (lvl <= 1) poly(put, [[24.5, 3], [31, 8], [25, 8]], fore(31, 6)); // staysail
178 },
179 pennants: [[14, 0, 3], [25, 1, 2]],
180};
181
182const sloop = {
183 width: 30,
184 stern: 2,
185 bow: 25,
186 build(put, lvl) {
187 line(put, 12, 0, 1, 8, "r"); line(put, 12, 0, 29, 7, "r"); line(put, 12, 1, 15, 8, "r");
188 hull(put, {
189 x0: 2, x1: 25, rail: "u", wale: "N", dark: "E",
190 plank: () => "U",
191 deck: (x) => (x >= 22 ? 8 : 9),
192 bottom: (x) => [10, 11, 12, 13, 14][x - 2] ?? [10, 11, 12, 13, 14][25 - x] ?? 15,
193 ports: (x, y) => y === 11 && (x === 9 || x === 13 || x === 17),
194 windows: [[3, 10], [4, 10], [3, 11], [4, 11]],
195 });
196 put(2, 8, "w"); // stern lantern
197 line(put, 25, 8, 29, 7, "b"); // bowsprit
198 mast(put, 12, 0, 8);
199 if (lvl === 0) { poly(put, [[11.5, 1], [11.5, 8], [3, 8], [7, 4]], fore(11, 9)); poly(put, [[13, 1], [28, 7.5], [14, 8.5]], fore(28, 15)); }
200 else if (lvl === 1) { poly(put, [[11.5, 3], [11.5, 8], [5, 8]], fore(11, 7)); poly(put, [[13, 4], [26, 7.5], [14, 8.5]], fore(26, 13)); }
201 else if (lvl === 2) poly(put, [[11.5, 5], [11.5, 8], [8, 8]], fore(11, 4));
202 else furl(put, 4, 11, 7);
203 spar(put, 3, 11, 8, "b"); // boom
204 },
205 pennants: [[13, 0, 3]],
206};
207
208const junk = {
209 width: 36,
210 stern: 2,
211 bow: 31,
212 build(put, lvl) {
213 line(put, 17, 0, 31, 7, "r"); line(put, 17, 0, 7, 1, "r");
214 hull(put, {
215 x0: 2, x1: 31, rail: "R", wale: "G", dark: "h",
216 plank: (d) => (d % 2 === 0 ? "H" : "P"),
217 deck: (x) => (x <= 7 ? 5 : x === 8 ? 6 : x === 9 ? 7 : x >= 28 ? 6 : x >= 26 ? 7 : 8),
218 bottom: (x) => [9, 11, 12, 13, 14][x - 2] ?? [8, 9, 10, 11, 12, 13, 14][31 - x] ?? 15,
219 windows: [[2, 7], [3, 7], [4, 7], [2, 8], [3, 8], [4, 8]],
220 });
221 put(28, 8, "U"); put(29, 8, "o"); // the bow's eye
222 // battened lug sails: cloth panels between dark battens, the leech shaded
223 const lug = (pts, y0, leech) => poly(put, pts, (x, y) => ((y - y0) % 2 === 1 ? "j" : x <= leech ? "K" : "J"));
224 if (lvl <= 2) {
225 const top = [1, 3, 5][lvl];
226 lug([[12, top], [19, top], [20, 8], [9.5, 8]], top, 11);
227 } else furl(put, 10, 19, 7);
228 if (lvl <= 1) lug([[23, lvl === 0 ? 3 : 5], [28, lvl === 0 ? 3 : 5], [29, 7.5], [22, 7.5]], lvl === 0 ? 3 : 5, 23);
229 else furl(put, 22, 28, 7);
230 if (lvl <= 1) lug([[4, 1], [8, 1], [8.5, 5], [3, 5]], 1, 4);
231 mast(put, 17, 0, 8); mast(put, 27, 2, 7); mast(put, 7, 1, 4);
232 },
233 pennants: [[18, 0, 3], [8, 0, 2]],
234};
235
236export const SHIPS = { galleon, schooner, sloop, junk };
237
238/**
239 * A ship in its local box, facing right, as [x, y, colour key] pixels.
240 * @param {string} weather
241 * @param {number} t tick
242 * @param {string} [variant]
243 */
244export function shipPixels(weather, t, variant = "galleon") {
245 const ship = SHIPS[variant] ?? galleon;
246 const cells = new Map();
247 const put = (x, y, k) => {
248 if (x >= 0 && x < ship.width && y >= 0 && y < SHIP_HEIGHT) cells.set(y * 64 + x, [x, y, k]);
249 };
250 ship.build(put, levelFor(weather));
251 pennants(put, ship.pennants, t);
252 return [...cells.values()];
253}
254
255/**
256 * The variant for a session: the pin when it names a ship, otherwise one chosen by the
257 * seed, so a session keeps its ship for as long as it keeps its seed.
258 * @param {unknown} pin
259 * @param {number} seed
260 */
261export function resolveVariant(pin, seed) {
262 const named = typeof pin === "string" ? pin.trim().toLowerCase() : "";
263 if (VARIANTS.includes(named)) return named;
264 return VARIANTS[Math.abs(Math.imul(seed | 0, 2654435761) >>> 8) % VARIANTS.length];
265}
266