A docked pane with an animated ASCII nature scene that grows as the conversation's context fills

A Claude Code mod that docks a pane beside your conversation and grows an animated ASCII nature scene in it as the conversation's context fills up.
The pane starts empty. A horizon appears, then the sun, clouds, grass and a pond with fish, then trees growing into groves, a rabbit, grazing sheep (with lambs), a deer, butterflies and a duck. The sky follows your local time of day (dawn, day, dusk, and a starry night with fireflies). Each session gets its own season, layout and animals.
At the prompt of a Claude Code terminal session:
/plugin install forest --marketplace mech-a/claude-forest
Answer y to add the marketplace, then press Enter to install for your user.
From a shell:
claude plugin install forest --marketplace mech-a/claude-forest
In the desktop app, where /plugin doesn't run: click + next to the prompt box, then Plugins, Add marketplace with mech-a/claude-forest, then Add plugin.
The pane opens by itself in fullscreen terminals at least 144 columns wide; otherwise type /forest.
| Command | What it does | ||
|---|---|---|---|
/forest | Open the pane | ||
| `/forest style ascii\ | soft\ | painted` | Glyphs alone, or with a painted sky (and ground); kept across sessions |
/forest <0-100> | Preview a growth level | ||
/forest set <0-100> | Fast-forward to a level and keep growing from there | ||
/forest hour <h> | Preview a time of day | ||
/forest speed <seconds> | Animation step length (0 holds still); kept across sessions | ||
/forest reseed | Roll a new scene | ||
/forest live | Back to real growth and the real clock |
Click the scene for a sparkle.
mod/ is the plugin. Run it from a checkout with claude --plugin-dir ./mod, which hot-reloads it as you edit.claude plugin validate mod checks the plugin, and claude plugin test mod runs its tests.bun harness/shoot.ts <preset> renders contact sheets of the scene to harness/out/ with headless Chrome. The presets are growth, sizes, hours, seeds, styles, style-hours, carve and gallery. It needs Bun and Google Chrome.CLAUDE.md for the architecture.hooks/register.tsx 331 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer, UiOpenResult } from 'claude-code'
3
4import { fitRows, moodOf, renderScene, withMarks, type Scene, type Style } from './scene'
5import { textRows } from './text-rows'
6
7const fill = atom({ plugin: 'forest', key: 'fill' } as const, 0)
8const preview = atom({ plugin: 'forest', key: 'preview' } as const, null)
9const hourOverride = atom({ plugin: 'forest', key: 'hour' } as const, null)
10const seed = atom({ plugin: 'forest', key: 'seed' } as const, null)
11const boost = atom({ plugin: 'forest', key: 'boost' } as const, 0)
12const style = atom({ plugin: 'forest', key: 'style' } as const, 'ascii')
13const baseline = atom({ plugin: 'forest', key: 'baseline' } as const, null)
14
15const PANE = 'forest'
16
17/** Seconds between animation steps unless `/forest speed` set another; kept across sessions. */
18const DEFAULT_SPEED = 5
19
20// The module's own variables: they start over on a reload, which is fine,
21// since each is re-derived (the tick, the offset, the theme).
22const local = {
23 tick: 0,
24 utcOffsetHours: null as number | null,
25 theme: 'dark' as 'dark' | 'light',
26 /** What each pane's Raster was last mounted at, so a blit matches it. */
27 mounted: new Map<string, { columns: number; rows: number }>(),
28 timer: null as Timer | null,
29 /** The running `/forest set` time-lapse, if any. */
30 sweep: null as Timer | null,
31 /** The size the scene is laid out at; small height changes are fitted to it, not re-laid. */
32 design: null as { columns: number; rows: number } | null,
33 /** Where the pointer is over the pane, if it is; kept for hover features. */
34 hover: null as { x: number; y: number } | null,
35 /** Sparkles left by clicks, and the timer that fades them. */
36 sparks: [] as { x: number; y: number; age: number }[],
37 sparkTimer: null as Timer | null,
38}
39
40const SWEEP_FRAMES = 24
41const SWEEP_MS = 125
42
43const clamp01 = (n: number) => Math.max(0, Math.min(1, n))
44
45/** The growth the scene shows: a preview if one is set, else live plus any boost. */
46async function shownFill($: EngineInterface) {
47 const forced = await read($, preview)
48 return forced ?? clamp01((await read($, fill)) + (await read($, boost)))
49}
50
51/**
52 * Plays the scene from its current growth to `target` as a time-lapse (the
53 * preview stepping, clouds and fish hurrying), then keeps the target by
54 * boosting the live fill, so real growth carries on from there.
55 */
56async function fastForward($: EngineInterface, target: number) {
57 local.sweep?.cancel()
58 const from = await shownFill($)
59 let frame = 0
60 local.sweep = $.clock.every(SWEEP_MS, async () => {
61 frame += 1
62 const t = frame / SWEEP_FRAMES
63 const eased = t < 1 ? 1 - (1 - t) ** 3 : 1
64 local.tick += 3
65 if (frame < SWEEP_FRAMES) {
66 await update($, preview, () => from + (target - from) * eased)
67 return
68 }
69 local.sweep?.cancel()
70 local.sweep = null
71 const live = await read($, fill)
72 await update($, boost, () => target - live)
73 await update($, preview, () => null)
74 })
75}
76
77const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
78
79/** Standard padded base64 of the cells' little-endian bytes, as Raster takes them. */
80function encode(cells: Uint32Array) {
81 const bytes = new Uint8Array(cells.buffer, cells.byteOffset, cells.byteLength)
82 const out: string[] = []
83 for (let i = 0; i < bytes.length; i += 3) {
84 const a = bytes[i]!
85 const b = bytes[i + 1]
86 const c = bytes[i + 2]
87 const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
88 out.push(B64[n >> 18]! + B64[(n >> 12) & 63]! + (b === undefined ? "=" : B64[(n >> 6) & 63]!) + (c === undefined ? "=" : B64[n & 63]!))
89 }
90 return out.join("")
91}
92
93async function hourNow($: EngineInterface) {
94 const forced = await read($, hourOverride)
95 if (forced !== null) return forced
96 const now = await $.clock.now()
97 const offset = local.utcOffsetHours ?? -new Date(now).getTimezoneOffset() / 60
98 return (((now / 3_600_000 + offset) % 24) + 24) % 24
99}
100
101/**
102 * The scene for a pane of this size. It is laid out at a design size that
103 * holds through small height changes (a prompt growing a line), and fitted to
104 * the pane by cutting or repeating rows of quiet sky, so nothing jumps. A new
105 * width, or a height far from the design's, lays it out afresh.
106 */
107async function sceneFor($: EngineInterface, columns: number, rows: number): Promise<Scene> {
108 // a module variable, not state: a render hook may not write state
109 let d = local.design
110 if (!d || d.columns !== columns || rows < d.rows * 0.6 || rows > d.rows * 1.5) d = local.design = { columns, rows }
111 const scene = renderScene({
112 columns,
113 rows: d.rows,
114 fill: await shownFill($),
115 tick: local.tick,
116 hour: await hourNow($),
117 theme: local.theme,
118 seed: (await read($, seed)) ?? 7,
119 style: await read($, style),
120 })
121 return withMarks(fitRows(scene, rows), local.sparks.map(p => ({ ...p, kind: 'sparkle' as const })))
122}
123
124/** Repaints the mounted Raster now, outside the animation step. */
125async function repaint($: EngineInterface) {
126 for (const [id, size] of local.mounted) {
127 const scene = await sceneFor($, size.columns, size.rows)
128 await $.ui.blit({ requestId: id, key: 'scene', cells: encode(scene.cells), ...size }).catch(() => null)
129 }
130}
131
132/** What the pointer overlay posted: where the pointer is, and clicks, which leave a sparkle. */
133async function onPointer($: EngineInterface, data: unknown) {
134 const d = data as { kind?: string; x?: number; y?: number }
135 if (typeof d.x !== 'number' || typeof d.y !== 'number') return
136 const at = { x: d.x, y: d.y }
137 local.hover = d.kind === 'leave' ? null : at
138 if (d.kind !== 'down') return
139 local.sparks = [...local.sparks, { ...at, age: 0 }]
140 if (!local.sparkTimer) {
141 local.sparkTimer = $.clock.every(100, () => {
142 local.sparks = local.sparks.map(p => ({ ...p, age: p.age + 1 })).filter(p => p.age < 7)
143 if (local.sparks.length === 0) {
144 local.sparkTimer?.cancel()
145 local.sparkTimer = null
146 }
147 void repaint($)
148 })
149 }
150 await repaint($)
151}
152
153async function measure($: EngineInterface) {
154 const usage = await $.session.usage({ breakdown: 'summary' })
155 const tokens = usage.context.tokens
156 if (tokens === undefined) return
157 const limit = usage.context.breakdown?.autoCompactThreshold ?? usage.context.window
158 // Growth counts the conversation alone: everything but the Messages row is
159 // fixed overhead (system prompt, tools, memory). Where no such row is
160 // listed, the lowest context seen this session stands in, kept in session
161 // state so a reload of the mod does not restart the growth.
162 const rows = usage.context.breakdown
163 const messages = rows?.categories.find(c => c.kind === 'used' && /message/i.test(c.name))
164 const lowest = await update($, baseline, b => Math.min(b ?? tokens, tokens))
165 const base = rows && messages ? Math.max(0, rows.totalTokens - messages.tokens) : (lowest ?? tokens)
166 const linear = Math.max(0, Math.min(1, (tokens - base) / Math.max(1, limit - base)))
167 // a square root, so the first stretch of a long window still shows growth
168 const next = Math.round(Math.sqrt(linear) * 1000) / 1000
169 await update($, fill, () => next)
170}
171
172function openPane($: EngineInterface): Promise<UiOpenResult> {
173 return $.ui.open({ id: PANE, title: 'Forest' })
174}
175
176async function animate($: EngineInterface) {
177 local.tick += 1
178 for (const [id, size] of local.mounted) {
179 const scene = await sceneFor($, size.columns, size.rows)
180 const r = await $.ui.blit({ requestId: id, key: 'scene', cells: encode(scene.cells), ...size }).catch(() => null)
181 if (!r || 'deny' in r) local.mounted.delete(id)
182 }
183}
184
185/** A small stable number from a string, so each session draws its own scene. */
186function seedFrom(text: string) {
187 let h = 2166136261
188 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619)
189 return (h >>> 0) % 100000
190}
191
192async function describeScene($: EngineInterface) {
193 const m = moodOf((await read($, seed)) ?? 7)
194 const animals = [...new Set(m.cast)].join(', ')
195 return `${m.season}, ${Math.round(m.pines * 100)}% pines, ${animals}`
196}
197
198const isStyle = (s: unknown): s is Style => s === 'ascii' || s === 'soft' || s === 'painted'
199
200async function speed($: EngineInterface) {
201 const kept = Number(await $.store.get('speed').catch(() => undefined))
202 return Number.isFinite(kept) && kept >= 0 ? kept : DEFAULT_SPEED
203}
204
205/** (Re)starts the animation at the kept speed; 0 holds the scene still. */
206async function startTimer($: EngineInterface) {
207 local.timer?.cancel()
208 local.timer = null
209 const seconds = await speed($)
210 if (seconds > 0) local.timer = $.clock.every(Math.round(seconds * 1000), () => void animate($))
211}
212
213async function readHost($: EngineInterface) {
214 const rows = await $.config.list().catch(() => [])
215 const t = rows.find(row => row.key === 'theme')?.value
216 local.theme = typeof t === 'string' && t.includes('light') ? 'light' : 'dark'
217 // the module's clock may run in UTC: ask the host for its offset
218 const out = await $.process.run(['date', '+%z']).catch(() => null)
219 const m = out?.stdout.trim().match(/^([+-])(\d\d)(\d\d)$/)
220 if (m) local.utcOffsetHours = (m[1] === '-' ? -1 : 1) * (Number(m[2]) + Number(m[3]) / 60)
221}
222
223export const register: Register = on => {
224 on('session.start', async ($, e, next) => {
225 await $.command.register({
226 name: 'forest',
227 description: 'Open the forest; style ascii|soft|painted, set <0-100> to fast-forward growth, <0-100> to preview it, reseed, hour <h>, speed <seconds>, live',
228 argumentHint: '[style <s>|set <n>|<n>|reseed|hour <h>|speed <s>|live]',
229 })
230 await readHost($)
231 const kept = await $.store.get('style').catch(() => undefined)
232 if (isStyle(kept)) await update($, style, () => kept)
233 // the scene's character follows the session; a reload keeps the one in state
234 if ((await read($, seed)) === null) {
235 const id = await $.session.id().catch(async () => String(await $.clock.now()))
236 await update($, seed, () => seedFrom(id))
237 }
238 await measure($).catch(() => {})
239 void openPane($)
240 await startTimer($)
241
242 return next(e)
243 })
244
245 on('ui.message', async ($, e, next) => {
246 if (e.requestId !== PANE) return next(e)
247 await onPointer($, e.data)
248 return {}
249 })
250
251 on('session.measure', async ($, e, next) => {
252 await measure($).catch(() => {})
253 return next(e)
254 })
255
256 on('command.run', { command: 'forest' }, async ($, e) => {
257 const [first, second] = (e.args ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean)
258 if (first === 'style') {
259 if (!isStyle(second)) {
260 return { text: `Forest: drawn in ${await read($, style)}. Choose with /forest style ascii|soft|painted.` }
261 }
262 await $.store.set('style', second)
263 await update($, style, () => second)
264 await openPane($)
265 return { text: `Forest: drawn in ${second}.` }
266 }
267 if (first === 'reseed') {
268 const rolled = Math.floor(Math.random() * 100000)
269 await update($, seed, () => rolled)
270 await openPane($)
271 return { text: `Forest: a new scene (${await describeScene($)}).` }
272 }
273 if (first === 'speed') {
274 const seconds = Number(second)
275 if (second === undefined || !Number.isFinite(seconds) || seconds < 0) {
276 return { text: `Forest: animation steps every ${await speed($)}s. Set it with /forest speed <seconds>; 0 holds the scene still.` }
277 }
278 const kept = Math.min(600, Math.round(seconds * 10) / 10)
279 await $.store.set('speed', kept)
280 await startTimer($)
281 return { text: kept === 0 ? 'Forest: the scene holds still.' : `Forest: animation steps every ${kept}s.` }
282 }
283 if (first === 'set' && second !== undefined && !Number.isNaN(Number(second))) {
284 const target = clamp01(Number(second) / 100)
285 await update($, preview, () => null)
286 void fastForward($, target)
287 await openPane($)
288 return { text: `Forest: fast-forwarding to ${Math.round(target * 100)}%; it grows on from there with the conversation.` }
289 } else if (first === 'live') {
290 local.sweep?.cancel()
291 local.sweep = null
292 await update($, preview, () => null)
293 await update($, hourOverride, () => null)
294 await update($, boost, () => 0)
295 } else if (first === 'hour' && second !== undefined && !Number.isNaN(Number(second))) {
296 await update($, hourOverride, () => ((Number(second) % 24) + 24) % 24)
297 } else if (first !== undefined && !Number.isNaN(Number(first))) {
298 await update($, preview, () => Math.max(0, Math.min(100, Number(first))) / 100)
299 }
300 const opened = await openPane($)
301 const growth = await shownFill($)
302 const shown = opened.isPlaced ? 'shown' : `waiting (${opened.reason})`
303 return { text: `Forest: ${shown}, growth ${Math.round(growth * 100)}%, ${await describeScene($)}.` }
304 })
305
306 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
307 const columns = Math.max(1, Math.min(512, e.props.bodyColumns))
308 const rows = Math.max(1, Math.min(256, e.props.scroll.bodyRows))
309 const scene = await sceneFor($, columns, rows)
310
311 if (e.surface === 'terminal') {
312 // the scene, with an invisible Client laid over it for the pointer
313 const { Box, Client, Raster } = $.ui.resolve(e)
314 local.mounted.set(PANE, { columns, rows })
315 return (
316 <Box width={columns} height={rows}>
317 <Raster key="scene" columns={columns} rows={rows} cells={encode(scene.cells)} />
318 <Box position="absolute" top={0} left={0} width={columns} height={rows}>
319 <Client key="pointer" module="./pointer.tsx" width={columns} height={rows} />
320 </Box>
321 </Box>
322 )
323 }
324
325 // surfaces without Raster: one Text per row, coloured runs inside
326 const { Box, Text } = $.ui.resolve(e)
327 const lines = textRows(scene, Text)
328 return <Box flexDirection="column">{lines}</Box>
329 })
330}
331hooks/scene.ts 1092 lines1// The forest scene: a pure function of size, growth, time and an animation
2// tick, drawn into a grid of [codePoint, fg, bg] cells. No engine imports, so
3// the harness renders exactly what the pane shows.
4
5export const DEFAULT = 0x01000000
6
7export type Daypart = 'day' | 'dusk' | 'night' | 'dawn'
8
9export type SceneInput = {
10 columns: number
11 rows: number
12 /** Growth, 0 (bare) to 1 (lush). */
13 fill: number
14 /** Animation step; advances every couple of seconds. */
15 tick: number
16 /** Local hour, 0–23.99. */
17 hour: number
18 /** Pointer marks drawn over everything: click sparkles. */
19 marks?: Mark[]
20 /** The terminal's background family; light themes get deeper colours. */
21 theme?: 'dark' | 'light'
22 /** Glyphs on the terminal's own background, or with a painted backdrop. */
23 style?: Style
24 seed?: number
25}
26
27export type Scene = {
28 columns: number
29 rows: number
30 cells: Uint32Array
31 /** Cells whose glyph is backdrop, which a stretched row keeps while dropping glyphs. */
32 backdrop?: Uint8Array
33 /** Rows of sky from the top: the only rows `fitRows` cuts or repeats. */
34 skyRows?: number
35 /** Sky rows holding something drawn there for good (clouds, the sun), which seams avoid. */
36 keepRows?: number[]
37}
38
39export type Mark = { x: number; y: number; kind: 'sparkle'; age: number }
40
41/**
42 * ascii: glyphs alone. soft: a faint sky, hills and water painted behind them.
43 * painted: soft, plus a ground that greens with growth and a glow around the sun.
44 */
45export type Style = 'ascii' | 'soft' | 'painted'
46
47// ---------------------------------------------------------------- helpers
48
49const clamp = (v: number, lo = 0, hi = 1) => Math.max(lo, Math.min(hi, v))
50const ramp = (v: number, from: number, to: number) => clamp((v - from) / (to - from))
51
52function rng(seed: number) {
53 let a = seed >>> 0
54 return () => {
55 a = (a + 0x6d2b79f5) >>> 0
56 let t = a
57 t = Math.imul(t ^ (t >>> 15), t | 1)
58 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
59 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
60 }
61}
62
63/** A stable 0..1 number for a handful of integers. */
64function hash(...n: number[]) {
65 let h = 2166136261
66 for (const x of n) {
67 h ^= x | 0
68 h = Math.imul(h, 16777619)
69 h ^= h >>> 13
70 }
71 h = Math.imul(h ^ (h >>> 16), 2246822507)
72 return ((h ^ (h >>> 13)) >>> 0) / 4294967296
73}
74
75/** Smooth 2D value noise, 0..1, for clumping grass and flowers. */
76function noise(x: number, y: number, s: number) {
77 const xi = Math.floor(x)
78 const yi = Math.floor(y)
79 const fx = x - xi
80 const fy = y - yi
81 const u = fx * fx * (3 - 2 * fx)
82 const v = fy * fy * (3 - 2 * fy)
83 const a = hash(xi, yi, s)
84 const b = hash(xi + 1, yi, s)
85 const c = hash(xi, yi + 1, s)
86 const d = hash(xi + 1, yi + 1, s)
87 return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v
88}
89
90function mix(a: number, b: number, t: number) {
91 const m = (sh: number) => {
92 const x = (a >> sh) & 255
93 const y = (b >> sh) & 255
94 return Math.round(x + (y - x) * clamp(t)) << sh
95 }
96 return m(16) | m(8) | m(0)
97}
98
99const pick = (list: number[], n: number) => list[Math.floor(clamp(n, 0, 0.9999) * list.length)]!
100
101// ---------------------------------------------------------------- mood
102
103export type Season = 'spring' | 'summer' | 'autumn'
104
105/** What a seed decides about a scene beyond where things stand. */
106export type Mood = {
107 season: Season
108 /** Share of trees that are pines. */
109 pines: number
110 /** Whether the pond sits left of centre. */
111 pondLeft: boolean
112 cloudiness: number
113 flowers: number
114 /** Which animals graze. */
115 cast: ('rabbit' | 'sheep' | 'deer')[]
116}
117
118const CASTS: Mood['cast'][] = [
119 ['rabbit', 'sheep', 'deer', 'sheep'],
120 ['rabbit', 'rabbit', 'deer'],
121 ['sheep', 'sheep', 'sheep', 'rabbit'],
122 ['deer', 'deer', 'rabbit'],
123]
124
125export { mix }
126
127export function moodOf(seed: number): Mood {
128 const r = rng(seed * 2654435761)
129 const s = r()
130 const pick = <T,>(list: T[]) => list[Math.floor(r() * list.length)]!
131 return {
132 season: s < 0.3 ? 'spring' : s < 0.7 ? 'summer' : 'autumn',
133 pines: 0.2 + r() * 0.6,
134 pondLeft: r() < 0.5,
135 cloudiness: 0.6 + r() * 0.8,
136 flowers: 0.7 + r() * 0.6,
137 cast: pick(CASTS),
138 }
139}
140
141// ---------------------------------------------------------------- palette
142
143export type Palette = {
144 ink: number // what "faded out" fades toward: the terminal background
145 cloud: number
146 cloudShade: number
147 sun: number
148 star: number
149 starDim: number
150 bird: number
151 hill: number
152 hillFar: number
153 distant: number[]
154 grass: number[]
155 leaf: number[]
156 leafHi: number
157 pine: number[]
158 pineHi: number
159 trunk: number
160 flower: number[]
161 water: number[]
162 shore: number
163 reed: number
164 fish: number[]
165 wool: number
166 face: number
167 fur: number
168 deer: number
169 wing: number[]
170}
171
172const INK = 0x1b1d23
173
174export const DAY: Palette = {
175 ink: INK,
176 cloud: 0xd3dbe4,
177 cloudShade: 0x98a6b6,
178 sun: 0xf2cf6b,
179 star: 0xe8e6d8,
180 starDim: 0x76859c,
181 bird: 0x7a8696,
182 hill: 0x4f6e5c,
183 hillFar: 0x5a6f7c,
184 distant: [0x4e7a5c, 0x5c8866, 0x46705a],
185 grass: [0x5f9c55, 0x7bb264, 0x4c8248],
186 leaf: [0x4e9452, 0x5fa55a, 0x438a4c, 0x3d7d47],
187 leafHi: 0x8cc46c,
188 pine: [0x3a7650, 0x336c4a, 0x417f56],
189 pineHi: 0x5f9b68,
190 trunk: 0x8e6a4c,
191 flower: [0xef9ac4, 0xf3d36b, 0xf1ece2, 0xb8a2e8, 0xf28b7a],
192 water: [0x5b9fd0, 0x7fbbe0, 0x467fb0],
193 shore: 0x9c8c6a,
194 reed: 0x6f9a4a,
195 fish: [0xf08a4b, 0xf3b24f, 0xe87a6a],
196 wool: 0xece7dd,
197 face: 0x7a6656,
198 fur: 0xc8ae92,
199 deer: 0xb57d50,
200 wing: [0xf2b8d8, 0xf6d97a, 0xa8c8f0],
201}
202
203/** The daytime palette for a season; dawn, dusk and night shade it. */
204function seasonal(season: Season): Palette {
205 if (season === 'spring') {
206 return {
207 ...DAY,
208 grass: [0x72b45e, 0x93c870, 0x5e9a52],
209 leaf: [0x67ad5c, 0x7cbd66, 0x5aa052, 0x4f9150],
210 leafHi: 0xf2b8d0, // blossom
211 flower: [0xf4a6c8, 0xf6e27a, 0xf6f1ea, 0xc6b0f0, 0xf6b0a0],
212 }
213 }
214 if (season === 'autumn') {
215 return {
216 ...DAY,
217 grass: [0x9aa65a, 0xb3a862, 0x7f8c4c],
218 leaf: [0xd9823f, 0xc85a3c, 0xe0b04a, 0x8f9a48],
219 leafHi: 0xf0c060,
220 distant: [0x8a7a4c, 0x7a6e48, 0x6a7450],
221 flower: [0xe0a050, 0xd07050, 0xf0d080],
222 }
223 }
224 return DAY
225}
226
227/** On a light background, pale colours deepen so they still read. */
228function forLight(p: Palette): Palette {
229 const deepen = (c: number) => {
230 const l = (0.2126 * ((c >> 16) & 255) + 0.7152 * ((c >> 8) & 255) + 0.0722 * (c & 255)) / 255
231 return l > 0.5 ? mix(c, 0x4a4f58, clamp((l - 0.42) * 1.4)) : c
232 }
233 const out: Record<string, unknown> = {}
234 for (const [k, val] of Object.entries(p)) out[k] = Array.isArray(val) ? val.map(deepen) : deepen(val as number)
235 return { ...(out as Palette), ink: 0xf7f6f2, sun: mix(p.sun, 0xe0962e, 0.45) }
236}
237
238export function themed(hour: number, theme: 'dark' | 'light' = 'dark', season: Season = 'summer') {
239 const r = palette(hour, seasonal(season))
240 return theme === 'light' ? { part: r.part, p: forLight(r.p) } : r
241}
242
243export function palette(hour: number, base: Palette = DAY): { p: Palette; part: Daypart } {
244 const part: Daypart =
245 hour >= 20.5 || hour < 5.5 ? 'night' : hour < 7.5 ? 'dawn' : hour < 17.75 ? 'day' : 'dusk'
246 if (part === 'day') return { p: base, part }
247 const night = 0x1a2238
248 const warm = 0xe88a6a
249 const dim = (c: number, t: number) => mix(c, night, t)
250 const all = (list: number[], f: (c: number) => number) => list.map(f)
251 if (part === 'night') {
252 return {
253 part,
254 p: {
255 ...base,
256 cloud: 0x56637d,
257 cloudShade: 0x3f495f,
258 sun: 0xe9e3c4,
259 hill: 0x30425a,
260 hillFar: 0x34405a,
261 distant: all(base.distant, c => dim(c, 0.55)),
262 grass: all(base.grass, c => dim(c, 0.5)),
263 leaf: all(base.leaf, c => dim(c, 0.5)),
264 leafHi: dim(base.leafHi, 0.5),
265 pine: all(base.pine, c => dim(c, 0.45)),
266 pineHi: dim(base.pineHi, 0.45),
267 trunk: dim(base.trunk, 0.5),
268 flower: all(base.flower, c => dim(c, 0.45)),
269 water: [0x3d5f8a, 0x56789f, 0x2f4d75],
270 shore: dim(base.shore, 0.5),
271 reed: dim(base.reed, 0.5),
272 fish: all(base.fish, c => dim(c, 0.35)),
273 wool: dim(base.wool, 0.4),
274 face: dim(base.face, 0.4),
275 fur: dim(base.fur, 0.45),
276 deer: dim(base.deer, 0.45),
277 },
278 }
279 }
280 const glow = part === 'dusk' ? 0xb07a90 : 0xc09aa8
281 return {
282 part,
283 p: {
284 ...base,
285 cloud: part === 'dusk' ? 0xe9a9a0 : 0xf0c0b4,
286 cloudShade: glow,
287 sun: part === 'dusk' ? 0xf0915a : 0xf4b770,
288 bird: 0x7a6a7e,
289 hill: mix(base.hill, warm, 0.15),
290 hillFar: mix(base.hillFar, glow, 0.4),
291 distant: all(base.distant, c => mix(c, glow, 0.25)),
292 grass: all(base.grass, c => mix(c, warm, 0.14)),
293 leaf: all(base.leaf, c => mix(c, warm, 0.14)),
294 pine: all(base.pine, c => mix(c, warm, 0.1)),
295 water: all(base.water, c => mix(c, 0xd890a0, 0.3)),
296 },
297 }
298}
299
300// ---------------------------------------------------------------- sprites
301
302/** Glyph rows and matching colour-key rows; a space is transparent. */
303export type Sprite = { rows: string[]; keys: string[] }
304export const sp = (rows: string[], keys?: string[]): Sprite => ({
305 rows,
306 keys: keys ?? rows.map(r => r.replace(/\S/g, 'x')),
307})
308const width = (s: Sprite) => Math.max(...s.rows.map(r => [...r].length))
309
310// Clouds: c lit, s shade.
311const CLOUDS: Sprite[] = [
312 sp(['.--.', "'--'"], ['cccc', 'ssss']),
313 sp([' .-~-. ', "(_____)"], [' ccccc ', 'sssssss']),
314 sp([' .--. ', ' .( ). ', '(___.___)'], [' cccc ', ' cc cc ', 'sssssssss']),
315 sp([' .--. ', ' .-( ). ', ' (___.___)__) '], [' cccc ', ' cccc cc ', ' ssssssssssss ']),
316]
317
318// Pines, smallest to largest: o outline (lighter), f fill, t trunk.
319const PINES: Sprite[] = [
320 sp(['↟'], ['f']),
321 sp([' ^ ', '/^\\', ' | '], [' f ', 'ofo', ' t ']),
322 sp([' ^ ', ' /^\\ ', '/^^^\\', ' | '], [' f ', ' ofo ', 'offfo', ' t ']),
323 sp([' ^ ', ' /^\\ ', ' /^^^\\ ', ' /^\\ ', ' /^^^\\ ', '/^^^^^\\', ' | '],
324 [' f ', ' ofo ', ' offfo ', ' ofo ', ' offfo ', 'offfffo', ' t ']),
325 sp([' ^ ', ' /^\\ ', ' /^^^\\ ', ' /^\\ ', ' /^^^\\ ', ' /^^^^^\\ ', ' /^^^\\ ', ' /^^^^^\\ ', '/^^^^^^^\\', ' | | '],
326 [' f ', ' ofo ', ' offfo ', ' ofo ', ' offfo ', ' offfffo ', ' offfo ', ' offfffo ', 'offfffffo', ' t t ']),
327]
328
329// Broadleaf trees: h highlight, l leaf, d dark leaf, t trunk.
330const ROUNDS: Sprite[] = [
331 sp(['♣'], ['l']),
332 sp(['.@.', '(@)', ' | '], ['hlh', 'ldl', ' t ']),
333 sp([' .@@. ', '(@@&@)', " '|/' ", ' | '], [' hllh ', 'llddll', ' dtd ', ' t ']),
334 sp([' .-@@-. ', ' (@&@@&@) ', '(&@@%@@&@)', " '-@&\\|-' ", ' || ', ' || '],
335 [' hhllhh ', ' llllllll ', 'lldddddlll', ' ddddtddd ', ' tt ', ' tt ']),
336 sp([' .-@@@-. ', ' .(@&@@&@@). ', '(@@&@%@@&@&@)', '(&@@@&@%@@@&)', " '-.@&\\|/@.-' ", ' || ', ' || ', ' /||\\ '],
337 [' hhllllh ', ' hllllllllll ', 'lllllddddllll', 'ldddddddddddl', ' ddddddtdddd ', ' tt ', ' tt ', ' tttt ']),
338]
339
340const SUN_BIG = sp([' ▄▄▄ ', '█████', ' ▀▀▀ '])
341const SUN = sp(['▄▄', '▀▀'])
342const MOON = sp([' ▄▀▀', '█ ', ' ▀▄▄'])
343
344// Animals: w wool, f face/legs, u fur, e deer.
345// Sheep: W wool with a soft fill behind it, E the face (bright eyes on a
346// solid dark face, so it reads on any background), f legs.
347const SHEEP: Sprite[] = [
348 sp([' .oOOo.', ' (OOOOOO)', "•ᴗ' ''"], [' WWWWWW', ' WWWWWWWW', 'EEf ff']), // grazing, head down
349 sp([' .oOOo.', '•ᴗ(OOOOo)', " '' ''"], [' WWWWWW', 'EEWWWWWWW', ' ff ff']), // head up
350]
351const LAMB: Sprite[] = [
352 sp(['.oOo.', '•ᴗOo)', " ' '"], ['WWWWW', 'EEWWW', ' f f']),
353 sp(['', '.oOo.', '•ᴗOo)'], ['', 'WWWWW', 'EEWWW']), // mid-hop
354]
355const RABBIT: Sprite[] = [
356 sp(['(\\_/)', "(='.')"], ['uuuuu', 'uuuuuu']),
357 sp(['(\\_/)', '(=-.-)'], ['uuuuu', 'uuuuuu']),
358]
359const DEER: Sprite[] = [
360 sp([' Y', " ('>____", ' /| |\\'], [' e', ' eeeeeee', ' ee ee']),
361 sp(['', " ,____", "('/| |\\"], ['', ' eeeee', 'eeeee ee']), // grazing
362]
363const DUCK: Sprite[] = [sp(['<(.)__', ' (___/'], ['fwwwww', ' wwwww']), sp(['__(.)>', '\\___) '], ['wwwwwf', 'wwwww '])]
364
365// ---------------------------------------------------------------- canvas
366
367export class Canvas {
368 cells: Uint32Array
369 /** Cells whose glyph is backdrop (a half-block of sky, hill or water): empty to everything drawn over it. */
370 backdrop: Uint8Array
371 constructor(
372 readonly w: number,
373 readonly h: number,
374 ) {
375 this.cells = new Uint32Array(w * h * 3)
376 this.backdrop = new Uint8Array(w * h)
377 for (let i = 0; i < w * h; i++) {
378 this.cells[i * 3] = 32
379 this.cells[i * 3 + 1] = DEFAULT
380 this.cells[i * 3 + 2] = DEFAULT
381 }
382 }
383 put(x: number, y: number, ch: string, fg: number) {
384 x = Math.round(x)
385 y = Math.round(y)
386 if (x < 0 || y < 0 || x >= this.w || y >= this.h || ch === ' ') return
387 const i = (y * this.w + x) * 3
388 this.cells[i] = ch.codePointAt(0)!
389 this.cells[i + 1] = fg
390 this.backdrop[y * this.w + x] = 0
391 }
392 /** A backdrop half-block: `▀` or `▄` in `fg` over `bg`, which glyphs may then cover. */
393 shade(x: number, y: number, ch: '▀' | '▄', fg: number, bg: number) {
394 if (x < 0 || y < 0 || x >= this.w || y >= this.h) return
395 const i = (y * this.w + x) * 3
396 this.cells[i] = ch === '▀' ? 0x2580 : 0x2584
397 this.cells[i + 1] = fg
398 this.cells[i + 2] = bg
399 this.backdrop[y * this.w + x] = 1
400 }
401 /** Sets a cell's background; its glyph stays. */
402 paint(x: number, y: number, bg: number) {
403 if (x < 0 || y < 0 || x >= this.w || y >= this.h) return
404 this.cells[(y * this.w + x) * 3 + 2] = bg
405 }
406 bgAt(x: number, y: number) {
407 if (x < 0 || y < 0 || x >= this.w || y >= this.h) return DEFAULT
408 return this.cells[(y * this.w + x) * 3 + 2]!
409 }
410 /** Removes a cell's glyph; backdrop stays, since nothing drawn sits behind it. */
411 clear(x: number, y: number) {
412 if (x < 0 || y < 0 || x >= this.w || y >= this.h || this.backdrop[y * this.w + x]) return
413 this.cells[(y * this.w + x) * 3] = 32
414 }
415 isEmpty(x: number, y: number) {
416 if (x < 0 || y < 0 || x >= this.w || y >= this.h) return false
417 return this.cells[(y * this.w + x) * 3] === 32 || this.backdrop[y * this.w + x] === 1
418 }
419 text(x: number, y: number, s: string, fg: number) {
420 ;[...s].forEach((ch, i) => this.put(x + i, y, ch, fg))
421 }
422 /**
423 * Draws a sprite; with `solid`, cells inside each row's outline are cleared
424 * first. `fill` gives a key a background too (spaces under it included, and
425 * told the background already there), so a body can be a soft block of
426 * colour rather than an outline.
427 */
428 sprite(
429 x: number,
430 y: number,
431 s: Sprite,
432 color: (k: string) => number,
433 solid = false,
434 fill?: (k: string, under: number) => number | undefined,
435 ) {
436 s.rows.forEach((row, r) => {
437 const chars = [...row]
438 const keys = [...(s.keys[r] ?? '')]
439 if (solid) {
440 const first = chars.findIndex(ch => ch !== ' ')
441 const last = chars.length - 1 - [...chars].reverse().findIndex(ch => ch !== ' ')
442 for (let i = first; i <= last && first >= 0; i++) this.clear(x + i, y + r)
443 }
444 const span = Math.max(chars.length, keys.length)
445 for (let i = 0; i < span; i++) {
446 const k = keys[i] ?? ' '
447 const bg = fill?.(k, this.bgAt(x + i, y + r))
448 if (bg !== undefined) this.paint(x + i, y + r, bg)
449 this.put(x + i, y + r, chars[i] ?? ' ', color(k))
450 }
451 })
452 }
453}
454
455// ---------------------------------------------------------------- layout
456
457type Tree = { x: number; base: number; kind: 'pine' | 'round'; birth: number; size: number }
458type Cloud = { x: number; y: number; shape: number; birth: number; speed: number }
459type Animal = { kind: 'sheep' | 'rabbit' | 'deer'; x: number; y: number; birth: number; phase: number }
460type Pond = { cx: number; cy: number; rx: number; ry: number }
461type Speck = { x: number; ch: string; birth: number; shade: number }
462
463type Layout = {
464 skyH: number
465 hills: number[]
466 hillsFar: number[]
467 treeline: Speck[]
468 trees: Tree[]
469 clouds: Cloud[]
470 animals: Animal[]
471 pond?: Pond
472}
473
474const layouts = new Map<string, Layout>()
475
476function inPond(pd: Pond | undefined, x: number, y: number, pad = 0) {
477 if (!pd) return false
478 return ((x - pd.cx) / (pd.rx + pad * 2)) ** 2 + ((y - pd.cy) / (pd.ry + pad)) ** 2 <= 1
479}
480
481function layout(w: number, h: number, seed: number): Layout {
482 const key = `${w}x${h}:${seed}`
483 const hit = layouts.get(key)
484 if (hit) return hit
485 const r = rng(seed * 7919 + w * 131 + h * 17 + 4)
486 const m = moodOf(seed)
487 const short = h < 16
488 const skyH = Math.max(2, Math.round(h * (short ? 0.4 : 0.42)))
489 const groundH = h - skyH
490
491 // rolling hills just above the horizon, two soft layers
492 const hillAmp = clamp(skyH * 0.16, 0.6, 3.5)
493 const wave = (amp: number, f1: number, f2: number, o: number) =>
494 Array.from({ length: w }, (_, x) => amp * (0.5 + 0.32 * Math.sin(x * f1 + o) + 0.18 * Math.sin(x * f2 + o * 2.3)))
495 const hills = wave(hillAmp, 0.08 + r() * 0.04, 0.21 + r() * 0.05, r() * 6)
496 const hillsFar = wave(hillAmp * 1.7, 0.045 + r() * 0.02, 0.15 + r() * 0.04, r() * 6)
497
498 // a distant treeline on the near hills; fills in steadily
499 const treeline: Speck[] = []
500 for (let x = 0; x < w; x++) {
501 if (r() < 0.55) treeline.push({ x, ch: r() < 0.5 ? '♣' : r() < 0.6 ? '↟' : '^', birth: 0.12 + r() * 0.8, shade: r() })
502 }
503
504 let pond: Pond | undefined
505 if (groundH >= 5) {
506 const rx = clamp(Math.round(w * 0.2), 4, 13)
507 const ry = clamp(Math.round(groundH * 0.11), 1, 3)
508 const cx = Math.round(w * (m.pondLeft ? 0.33 + r() * 0.1 : 0.57 + r() * 0.1))
509 const cy = Math.min(h - ry - 2, skyH + Math.round(groundH * 0.66))
510 pond = { cx, cy, rx, ry }
511 }
512
513 // clouds at staggered heights
514 const clouds: Cloud[] = []
515 const nClouds = clamp(Math.round((w / 15) * m.cloudiness), 1, 7)
516 const cloudRows = Math.max(1, skyH - Math.ceil(hillAmp * 1.7) - 3)
517 for (let i = 0; i < nClouds; i++) {
518 const big = w >= 36 && skyH >= 10
519 clouds.push({
520 x: ((i + r() * 0.6) / nClouds) * (w + 14),
521 y: Math.floor(((i * 0.618 + r() * 0.3) % 1) * cloudRows) + (skyH > 8 ? 1 : 0),
522 shape: big ? 1 + Math.floor(r() * 3) : Math.floor(r() * 2),
523 birth: 0.08 + (i / nClouds) * 0.45 + r() * 0.05,
524 speed: 0.06 + r() * 0.1,
525 })
526 }
527
528 // trees gather in groves at the sides; the middle stays an open meadow
529 const trees: Tree[] = []
530 const n = Math.round(clamp((w * groundH) / 170, 2, 12))
531 const scale = clamp(groundH / 22, 0.45, 1.25)
532 const groves = [0.16, 0.86]
533 const reach = (size: number, kind: Tree['kind']) => {
534 const set = kind === 'pine' ? PINES : ROUNDS
535 const s = set[clamp(Math.round(size * (set.length - 1) + 0.2), 0, set.length - 1)]!
536 return { half: Math.ceil(width(s) / 2), tall: s.rows.length }
537 }
538 const free = (x: number, base: number, size: number, kind: Tree['kind']) => {
539 const { half, tall } = reach(size, kind)
540 for (let dy = 0; dy < tall; dy++)
541 for (const dx of [-half, 0, half]) if (inPond(pond, x + dx, base - dy, 2)) return false
542 return !trees.some(t => {
543 const o = reach(t.size, t.kind)
544 return Math.abs(t.x - x) < (o.half + half) * 0.8 && Math.abs(t.base - base) < Math.max(2, Math.min(o.tall, tall) * 0.6)
545 })
546 }
547 // the framing trees come first, so the frame forms before the groves fill
548 const frame = [2, w - 3]
549 frame.forEach((x, i) => {
550 const base = h - 1 - Math.floor(r() * Math.max(1, groundH * 0.12))
551 const kind = r() < 0.5 ? 'pine' : 'round'
552 trees.push({ x, base, kind, birth: 0.3 + i * 0.07, size: scale })
553 })
554 for (let tries = 0; trees.length < n + frame.length && tries < 800; tries++) {
555 const depth = Math.pow(r(), 0.7)
556 const base = skyH + 1 + Math.round(depth * (groundH - 2))
557 const center = groves[Math.floor(r() * groves.length)]!
558 const spread = 0.1 + depth * 0.12
559 const x = Math.round(w * clamp(center + (r() - 0.5) * 2 * spread, 0.02, 0.98))
560 const size = (0.2 + depth * 0.8) * scale
561 const kind = r() < m.pines ? 'pine' : 'round'
562 if (!free(x, base, size, kind)) continue
563 trees.push({ x, base, kind, birth: 0.34 + r() * 0.56, size })
564 }
565 trees.sort((a, b) => a.base - b.base)
566
567 // animals graze in open meadow
568 const animals: Animal[] = []
569 const wanted = m.cast
570 const births: Record<Animal['kind'], number> = { rabbit: 0.42, sheep: 0.52, deer: 0.66 }
571 wanted.forEach((kind, i) => {
572 for (let tries = 0; tries < 80; tries++) {
573 const x = 2 + Math.floor(r() * Math.max(1, w - 12))
574 const y = skyH + 1 + Math.floor(r() * Math.max(1, groundH - 5))
575 if (y + 3 >= h || inPond(pond, x, y + 2, 2) || inPond(pond, x + 7, y + 2, 2)) continue
576 if (animals.some(a => Math.abs(a.y - y) < 4 && Math.abs(a.x - x) < 11)) continue
577 if (trees.some(t => Math.abs(t.x - x - 4) < 3 + reach(t.size, t.kind).half + 4 && t.base >= y - 1 && t.base - y < reach(t.size, t.kind).tall + 2)) continue
578 animals.push({ kind, x, y, birth: births[kind] + i * 0.03, phase: Math.floor(r() * 1000) })
579 break
580 }
581 })
582
583 const out = { skyH, hills, hillsFar, treeline, trees, clouds, animals, pond }
584 layouts.set(key, out)
585 if (layouts.size > 64) layouts.delete(layouts.keys().next().value!)
586 return out
587}
588
589// ---------------------------------------------------------------- scene
590
591export function renderScene(input: SceneInput): Scene {
592 const w = Math.max(1, Math.floor(input.columns))
593 const h = Math.max(1, Math.floor(input.rows))
594 const v = clamp(input.fill)
595 const t = Math.floor(input.tick)
596 const seed = input.seed ?? 7
597 const c = new Canvas(w, h)
598 if (v <= 0) return { columns: w, rows: h, cells: c.cells, backdrop: c.backdrop, skyRows: Math.round(h * 0.42) }
599
600 const mood = moodOf(seed)
601 const { p, part } = themed(input.hour, input.theme, mood.season)
602 const L = layout(w, h, seed)
603 const { skyH } = L
604 const fade = (col: number, o: number) => mix(p.ink, col, o)
605 const style = input.style ?? 'ascii'
606
607 // where the sun or moon stands on its arc with the hour
608 const night = part === 'night'
609 const arcT = night ? ((input.hour + 24 - 20.5) % 24) / 9 : clamp((input.hour - 5.5) / 15.25)
610 const body = night ? MOON : w >= 40 && skyH >= 12 ? SUN_BIG : SUN
611 const sx = Math.round((w - width(body)) * (0.1 + 0.8 * arcT))
612 const sy = Math.round((1 - Math.sin(arcT * Math.PI)) * Math.max(0, skyH - 4)) + (skyH > 9 ? 1 : 0)
613
614 if (style !== 'ascii') {
615 const sun = { x: sx + width(body) / 2, y: sy + body.rows.length / 2, o: ramp(v, 0.05, 0.14) }
616 drawBackdrop(c, L, v, part, mood.season, p.ink, style, sun, input.theme === 'light' ? 0.55 : 1)
617 }
618
619 // stars
620 if (part === 'night') {
621 const density = 0.01 + ramp(v, 0.05, 0.6) * 0.025
622 for (let y = 0; y < skyH - 2; y++)
623 for (let x = 0; x < w; x++) {
624 const n = hash(x, y, seed)
625 if (n > density) continue
626 const dimmed = hash(x, y, Math.floor(t / 3)) < 0.2
627 const ch = n < density * 0.12 ? '✦' : n < density * 0.45 ? '·' : '.'
628 c.put(x, y, ch, dimmed || n > density * 0.4 ? p.starDim : p.star)
629 }
630 }
631
632 // sun or moon on an arc with the hour
633 if (v >= 0.05) c.sprite(sx, sy, body, () => fade(p.sun, ramp(v, 0.05, 0.14)))
634
635 // clouds drift and wrap; they puff up from small to full size
636 for (const cl of L.clouds) {
637 if (v < cl.birth) continue
638 const grown = ramp(v, cl.birth, cl.birth + 0.15)
639 const s = CLOUDS[Math.max(0, cl.shape - (grown < 1 ? 1 : 0))]!
640 const sw = width(s)
641 const span = w + sw + 6
642 const x = ((((cl.x + t * cl.speed) % span) + span) % span) - sw
643 c.sprite(Math.round(x), cl.y, s, k => fade(k === 's' ? p.cloudShade : p.cloud, 0.4 + 0.6 * grown), true)
644 }
645
646 // a pair of birds crosses now and then
647 if (v >= 0.4 && part !== 'night') {
648 const period = 140
649 const cycle = Math.floor(t / period)
650 if (hash(cycle, seed, 3) < 0.6) {
651 const pos = (t % period) / (period * 0.6)
652 if (pos <= 1) {
653 const by = 1 + Math.floor(hash(cycle, 9) * Math.max(1, skyH * 0.5))
654 const bx = Math.round(pos * (w + 8)) - 4
655 const flap = t % 2 === 0
656 c.put(bx, by, flap ? 'v' : '‿', p.bird)
657 c.put(bx - 3, by + 1, flap ? '‿' : 'v', p.bird)
658 }
659 }
660 }
661
662 // the horizon: a faint line at first, which the hills replace
663 if (v < 0.3) {
664 const o = ramp(v, 0, 0.05) * (1 - ramp(v, 0.16, 0.3))
665 for (let x = 0; x < w; x++)
666 if (hash(x, 11) < 0.25 + 0.6 * o) c.put(x, skyH, '_', fade(p.grass[2]!, 0.25 + 0.5 * o))
667 }
668
669 // hills: a dotted far ridge and a soft near ridge (painted styles fill them instead)
670 if (v >= 0.16 && style === 'ascii') {
671 const o = ramp(v, 0.16, 0.3)
672 for (let x = 0; x < w; x++) {
673 const top = skyH - L.hillsFar[x]!
674 const y = Math.round(top)
675 if (y < skyH - 1 && hash(x, 13) < 0.7) c.put(x, y, '.', fade(p.hillFar, o * 0.7))
676 }
677 for (let x = 0; x < w; x++) {
678 const top = skyH - L.hills[x]!
679 const y = Math.floor(top)
680 const prev = Math.floor(x > 0 ? skyH - L.hills[x - 1]! : top)
681 const next = Math.floor(x < w - 1 ? skyH - L.hills[x + 1]! : top)
682 const ch = next < y ? '/' : prev < y ? '\\' : top - y < 0.5 ? '-' : '_'
683 if (y <= skyH) c.put(x, Math.min(y, skyH), ch, fade(p.hill, o))
684 }
685 }
686
687 // the distant treeline sits on the near ridge
688 for (const s of L.treeline) {
689 if (v < s.birth) continue
690 const y = Math.floor(skyH - L.hills[s.x]!) - 1
691 if (y < 0 || y >= skyH) continue
692 c.put(s.x, y, s.ch, fade(pick(p.distant, s.shade), 0.55 + 0.45 * ramp(v, s.birth, s.birth + 0.1)))
693 }
694
695 // ground: grass clumps, denser toward the front; flowers in patches
696 const gH = h - skyH
697 for (let y = skyH + 1; y < h; y++) {
698 const depth = (y - skyH) / Math.max(1, gH)
699 for (let x = 0; x < w; x++) {
700 if (inPond(L.pond, x, y, 1)) continue
701 const clump = noise(x / 5, y / 2.5, seed)
702 const grass = ramp(v, 0, 0.55) * (0.04 + 0.2 * depth) * (0.35 + 1.3 * clump) + 0.015
703 const flowers = ramp(v, 0.15, 1) * 0.06 * mood.flowers * (mood.season === 'spring' ? 1.5 : mood.season === 'autumn' ? 0.5 : 1) * Math.max(0, noise(x / 4 + 9, y / 2 + 9, seed + 1) - 0.45) * 2
704 const n = hash(x, y, seed, 1)
705 if (n < grass) {
706 const g = hash(x, y, 2)
707 const ch = depth < 0.3 ? (g < 0.6 ? '.' : ',') : g < 0.3 ? ',' : g < 0.55 ? "'" : g < 0.75 ? '"' : g < 0.9 ? '.' : 'ʷ'
708 c.put(x, y, ch, fade(pick(p.grass, hash(x, y, 4)), 0.6 + 0.4 * depth))
709 } else if (n < grass + flowers) {
710 const f = hash(x, y, 5)
711 c.put(x, y, depth < 0.3 ? '.' : f < 0.45 ? '✿' : f < 0.7 ? '*' : '❀', pick(p.flower, hash(x, y, 7)))
712 }
713 }
714 }
715
716 // pond
717 if (L.pond && v >= 0.2) drawPond(c, L.pond, v, t, p, seed, style !== 'ascii')
718
719 // trees, back to front, plus the animals interleaved by their feet
720 type Thing = { y: number; draw: () => void }
721 const things: Thing[] = []
722 for (const tr of L.trees) {
723 if (v < tr.birth) continue
724 things.push({ y: tr.base, draw: () => drawTree(c, tr, ramp(v, tr.birth, tr.birth + 0.3), p) })
725 }
726 for (const a of L.animals) {
727 if (v < a.birth) continue
728 const set = a.kind === 'sheep' ? SHEEP : a.kind === 'rabbit' ? RABBIT : DEER
729 const s = set[0]!
730 things.push({
731 y: a.y + s.rows.length - 1,
732 draw: () => {
733 const beat = Math.floor((t + a.phase) / 5)
734 const frame = hash(beat, a.phase) < 0.7 ? 0 : 1
735 const wander = Math.round(Math.sin((t + a.phase) / 60) * 2)
736 const color = (k: string) =>
737 k === 'E' ? p.wool : k === 'w' || k === 'W' ? p.wool : k === 'u' ? p.fur : k === 'e' ? p.deer : p.face
738 const fill = (k: string, under: number) => {
739 const base = under === DEFAULT ? p.ink : under
740 return k === 'E' ? mix(p.face, 0x000000, 0.4) : k === 'W' ? mix(base, p.wool, 0.14) : undefined
741 }
742 const x = a.x + wander
743 c.sprite(x, a.y, set[frame]!, color, true, fill)
744 if (a.kind !== 'sheep') return
745 // now and then a sheep says so
746 if (hash(beat, a.phase, 5) < 0.05) c.text(x + (frame ? 0 : 1), a.y - 1, 'ᵇᵃᵃ', mix(p.ink, p.wool, 0.7))
747 // a lamb trails its mother once the meadow is lush, hopping now and then
748 if (v >= 0.8) {
749 const hop = hash(Math.floor((t + a.phase) / 2), a.phase, 6) < 0.2 ? 1 : 0
750 const lx = x + 10 + Math.round(Math.sin((t + a.phase) / 23) * 1.5)
751 if (lx + 5 < w) c.sprite(lx, a.y + (hop ? -1 : 0), LAMB[hop]!, color, true, fill)
752 }
753 },
754 })
755 }
756 things.sort((a, b) => a.y - b.y).forEach(th => th.draw())
757
758 // autumn: a few leaves drift down past the trees
759 if (mood.season === 'autumn' && v >= 0.4) {
760 const fall = h - skyH + 4
761 for (let i = 0; i < 3; i++) {
762 const phase = Math.floor(hash(i, seed, 71) * fall)
763 const y = skyH - 3 + ((Math.floor(t / 2) + phase) % fall)
764 const x = Math.round(w * hash(i, seed, 72) + Math.sin(t / 3 + i * 2) * 2)
765 if (y >= 0 && y < h && c.isEmpty(x, y)) c.put(x, y, t % 2 ? ',' : '`', pick(p.leaf, hash(i, 73) * 0.75))
766 }
767 }
768
769 // butterflies drift over the meadow
770 if (v >= 0.55 && part !== 'night') {
771 const n = v >= 0.85 ? 2 : 1
772 for (let i = 0; i < n; i++) {
773 const bx = Math.round(w * (0.25 + 0.5 * hash(i, seed, 21)) + Math.sin(t / 7 + i * 3) * 5 + Math.sin(t / 17 + i) * 3)
774 const by = Math.round(skyH + 2 + (gH - 5) * hash(i, seed, 22) + Math.sin(t / 4 + i) * 1.5)
775 const col = p.wing[i % p.wing.length]!
776 if ((t + i) % 4 === 0) c.put(bx, by, 'ж', col)
777 else c.text(bx, by, 'ʚɞ', col)
778 }
779 }
780
781 // fireflies at night
782 if (v >= 0.5 && part === 'night') {
783 for (let i = 0; i < 4; i++) {
784 if (hash(i, Math.floor(t / 2)) < 0.4) continue
785 const fx = Math.round(w * hash(i, seed, 61) + Math.sin(t / 6 + i) * 3)
786 const fy = Math.round(skyH + 1 + (gH - 2) * hash(i, seed, 62) + Math.cos(t / 5 + i))
787 c.put(fx, fy, '·', 0xe8e07a)
788 }
789 }
790
791 for (const m of input.marks ?? []) drawMark(c, m)
792
793 const keep = new Set<number>()
794 for (const cl of L.clouds) if (v >= cl.birth) for (let r = 0; r < 3; r++) keep.add(cl.y + r)
795 if (v >= 0.05) for (let r = 0; r < body.rows.length; r++) keep.add(sy + r)
796 // the hills and the treeline on them
797 const ridge = Math.floor(skyH - Math.max(...L.hillsFar, ...L.hills)) - 1
798 for (let y = Math.max(0, ridge); y < skyH; y++) keep.add(y)
799 return { columns: w, rows: h, cells: c.cells, backdrop: c.backdrop, skyRows: skyH, keepRows: [...keep] }
800}
801
802/**
803 * Fits a scene drawn at its design height to `rows`, the way seam carving
804 * resizes a picture: whole rows of quiet sky are cut (shorter) or repeated
805 * with their glyphs left out (taller), spread across the sky, so the ground
806 * stays where it is and nothing jumps when the pane changes by a few rows.
807 */
808export function fitRows(scene: Scene, rows: number): Scene {
809 const { columns: w, rows: h, cells } = scene
810 if (rows === h || rows < 1) return scene
811 const sky = Math.min(h, scene.skyRows ?? Math.round(h * 0.42))
812 const keep = new Set(scene.keepRows ?? [])
813 // seams: sky rows below the top one, the quiet ones first, then any sky row
814 const quiet = [...Array(Math.max(0, sky - 1)).keys()].map(i => i + 1).filter(y => !keep.has(y))
815 const busy = [...Array(Math.max(0, sky - 1)).keys()].map(i => i + 1).filter(y => keep.has(y))
816 const pick = (pool: number[], n: number) =>
817 Array.from({ length: Math.min(n, pool.length) }, (_, i) => pool[Math.floor(((i + 0.5) * pool.length) / Math.min(n, pool.length))]!)
818
819 let order: number[] // source row for each output row
820 if (rows < h) {
821 const n = h - rows
822 const fromQuiet = pick(quiet, n)
823 const cut = new Set([...fromQuiet, ...pick(busy, n - fromQuiet.length)])
824 order = [...Array(h).keys()].filter(y => !cut.has(y))
825 // more to cut than the sky holds: drop rows from the top
826 order = order.slice(order.length - rows)
827 } else {
828 const n = rows - h
829 const pool = quiet.length ? quiet : [...Array(Math.max(1, sky)).keys()]
830 const repeats = new Map<number, number>()
831 for (let i = 0; i < n; i++) {
832 const y = pool[Math.floor(((i + 0.5) * pool.length) / n) % pool.length]!
833 repeats.set(y, (repeats.get(y) ?? 0) + 1)
834 }
835 order = []
836 for (let y = 0; y < h; y++) {
837 order.push(y)
838 for (let k = 0; k < (repeats.get(y) ?? 0); k++) order.push(-1 - y) // a repeat: backdrop only
839 }
840 }
841
842 const out = new Uint32Array(w * rows * 3)
843 const backdrop = new Uint8Array(w * rows)
844 order.forEach((src, y) => {
845 const from = src < 0 ? -1 - src : src
846 for (let x = 0; x < w; x++) {
847 const i = (from * w + x) * 3
848 const o = (y * w + x) * 3
849 const isBackdrop = scene.backdrop?.[from * w + x] === 1
850 const blank = src < 0 && !isBackdrop
851 out[o] = blank ? 32 : cells[i]!
852 out[o + 1] = blank ? DEFAULT : cells[i + 1]!
853 out[o + 2] = cells[i + 2]!
854 backdrop[y * w + x] = isBackdrop ? 1 : 0
855 }
856 })
857 const shift = rows - h
858 return {
859 columns: w,
860 rows,
861 cells: out,
862 backdrop,
863 skyRows: Math.max(0, sky + shift),
864 keepRows: [],
865 }
866}
867
868/** Draws pointer marks over a finished scene, in its own rows. */
869export function withMarks(scene: Scene, marks: Mark[]): Scene {
870 if (marks.length === 0) return scene
871 const c = new Canvas(scene.columns, scene.rows)
872 c.cells.set(scene.cells)
873 if (scene.backdrop) c.backdrop.set(scene.backdrop)
874 for (const m of marks) drawMark(c, m)
875 return { ...scene, cells: c.cells, backdrop: c.backdrop }
876}
877
878// ---------------------------------------------------------------- backdrop
879
880type Sky = { stops: [number, number][]; ground: number; groundLow: number; hill: number; hillFar: number; glow: number }
881
882// Sky colours from the zenith (0) to the horizon (1), several stops each, changing
883// fastest near the horizon as real skies do.
884const SKIES: Record<Daypart, Sky> = {
885 day: {
886 stops: [[0, 0x24497a], [0.5, 0x4f7fb0], [0.85, 0x8fb4d4], [1, 0xb9d0df]],
887 ground: 0x5c8a4a, groundLow: 0x3f6e3c, hill: 0x4f7a5a, hillFar: 0x6a8494, glow: 0xf2d27a,
888 },
889 dawn: {
890 stops: [[0, 0x34386e], [0.45, 0x7a6fa0], [0.75, 0xd99e94], [0.92, 0xf0bc8c], [1, 0xf6d49c]],
891 ground: 0x5f7f4c, groundLow: 0x43633e, hill: 0x5a6a68, hillFar: 0x8a7a92, glow: 0xf4c27a,
892 },
893 dusk: {
894 stops: [[0, 0x26265a], [0.4, 0x5c4884], [0.7, 0xc06a78], [0.9, 0xe88a5e], [1, 0xf2a85a]],
895 ground: 0x5a6e44, groundLow: 0x3c5238, hill: 0x4f5a5c, hillFar: 0x7a6278, glow: 0xf0915a,
896 },
897 night: {
898 stops: [[0, 0x060a1a], [0.55, 0x101a38], [0.9, 0x22325a], [1, 0x2e4068]],
899 ground: 0x23362c, groundLow: 0x1a2a22, hill: 0x22303e, hillFar: 0x2a3450, glow: 0xe9e3c4,
900 },
901}
902
903/** The colour at `t` along a gradient's stops, eased between neighbours. */
904function along(stops: [number, number][], t: number) {
905 const u = clamp(t)
906 for (let i = 1; i < stops.length; i++) {
907 const [p1, c1] = stops[i]!
908 const [p0, c0] = stops[i - 1]!
909 if (u <= p1) {
910 const k = (u - p0) / Math.max(1e-6, p1 - p0)
911 return mix(c0, c1, k * k * (3 - 2 * k))
912 }
913 }
914 return stops[stops.length - 1]![1]
915}
916const EARTH = 0x6a5a40
917
918/**
919 * Paints the backgrounds behind the glyphs: a sky graded to the horizon, a
920 * glow around the sun, filled hills with half-block edges, and (painted) a
921 * ground that turns from bare earth to green as the scene grows. Every colour
922 * is mixed from the terminal's background, so it fades in with growth.
923 */
924function drawBackdrop(
925 c: Canvas,
926 L: Layout,
927 v: number,
928 part: Daypart,
929 season: Season,
930 ink: number,
931 style: Style,
932 sun: { x: number; y: number; o: number },
933 mul: number,
934) {
935 const sky = SKIES[part]
936 const k = (style === 'soft' ? 0.65 : 1) * mul
937 const { skyH } = L
938 const oSky = ramp(v, 0.02, 0.2)
939 const seedPhase = (L.hills[0] ?? 0) * 3.7
940
941 // two sub-rows per cell (▀: top half in the glyph's colour, bottom half in the
942 // background), the band edges waved by slow noise and lightly dithered
943 const skyAt = (x: number, sub: number) => {
944 const t0 = sub / Math.max(1, skyH * 2 - 1)
945 const wave = 0.035 * Math.sin(x * 0.13 + seedPhase) + 0.03 * (noise(x / 9, sub / 5, 77) - 0.5)
946 const dither = (hash(x, sub, 91) - 0.5) * 0.018
947 const t = clamp(t0 + wave * (0.4 + t0) + dither)
948 let col = mix(ink, along(sky.stops, t), (0.32 + 0.26 * t) * oSky * k)
949 // the glow falls off with distance, cells counted twice as tall as wide
950 const d = Math.hypot(x + 0.5 - sun.x, (sub / 2 + 0.25 - sun.y) * 2) / (part === 'night' ? 7 : 11)
951 if (d < 1) col = mix(col, sky.glow, (1 - d) ** 2 * (part === 'night' ? 0.12 : 0.28) * sun.o * k)
952 return col
953 }
954 for (let y = 0; y < Math.min(skyH, c.h); y++)
955 for (let x = 0; x < c.w; x++) c.shade(x, y, '▀', skyAt(x, y * 2), skyAt(x, y * 2 + 1))
956
957 // hills: the far ridge, then the near one over it; the top cell is half filled
958 // when the ridge crosses its middle
959 const hill = (heights: number[], hue: number, o: number) => {
960 for (let x = 0; x < c.w; x++) {
961 const top = skyH - heights[x]!
962 const fill = mix(ink, hue, o * k)
963 const y0 = Math.floor(top)
964 for (let y = Math.max(0, y0); y < skyH && y < c.h; y++) {
965 if (y === y0 && top - y0 > 0.5) c.shade(x, y, '▄', fill, c.bgAt(x, y))
966 else {
967 c.paint(x, y, fill)
968 if (c.backdrop[y * c.w + x]) c.cells[(y * c.w + x) * 3 + 1] = fill
969 }
970 }
971 }
972 }
973 if (v >= 0.16) {
974 const o = ramp(v, 0.16, 0.3)
975 hill(L.hillsFar, sky.hillFar, 0.45 * o)
976 hill(L.hills, sky.hill, 0.55 * o)
977 }
978
979 if (style === 'painted') {
980 const green = ramp(v, 0.05, 0.6)
981 const tint = season === 'autumn' ? 0x8a7a40 : season === 'spring' ? 0x6aa055 : 0
982 for (let y = skyH; y < c.h; y++) {
983 const depth = (y - skyH) / Math.max(1, c.h - skyH - 1)
984 let hue = mix(sky.ground, sky.groundLow, depth)
985 if (tint) hue = mix(hue, tint, 0.35)
986 hue = mix(EARTH, hue, green)
987 const col = mix(ink, hue, (0.22 + 0.18 * depth) * ramp(v, 0, 0.25) * k)
988 for (let x = 0; x < c.w; x++) c.paint(x, y, col)
989 }
990 }
991}
992
993function drawMark(c: Canvas, m: Mark) {
994 // a sparkle: a star, then a ring of motes spreading and fading
995 const col = mix(0xfff2b0, INK, m.age / 6)
996 if (m.age === 0) return c.put(m.x, m.y, '✦', col)
997 const r = Math.min(m.age, 3)
998 for (const [dx, dy] of [[-2, 0], [2, 0], [0, -1], [0, 1], [-1, -1], [1, 1], [1, -1], [-1, 1]] as const)
999 c.put(m.x + Math.round(dx * r * 0.7), m.y + Math.round(dy * r * 0.6), m.age < 3 ? '*' : '·', col)
1000}
1001
1002function drawPond(c: Canvas, pd: Pond, v: number, t: number, p: Palette, seed: number, filled: boolean) {
1003 const grow = ramp(v, 0.2, 0.36)
1004 const P: Pond = { ...pd, rx: Math.max(2, pd.rx * (0.45 + 0.55 * grow)), ry: Math.max(1, pd.ry * (0.6 + 0.4 * grow)) }
1005 for (let y = Math.floor(pd.cy - P.ry - 1); y <= pd.cy + P.ry + 1; y++)
1006 for (let x = Math.floor(pd.cx - P.rx - 2); x <= pd.cx + P.rx + 2; x++) {
1007 if (inPond(P, x, y)) {
1008 c.clear(x, y)
1009 if (filled) c.paint(x, y, mix(c.bgAt(x, y) === DEFAULT ? p.ink : c.bgAt(x, y), p.water[2]!, 0.3))
1010 const n = hash(x, y, seed, 31)
1011 const drift = hash(x + Math.floor(t / 3), y, seed, 32)
1012 const edge = !inPond(P, x, y - 1) || !inPond(P, x, y + 1)
1013 if (edge) c.put(x, y, y < pd.cy ? '~' : '-', pick(p.water, 0.9))
1014 else if (n < 0.3 || drift < 0.07) c.put(x, y, n < 0.2 ? '~' : '-', pick(p.water, hash(x, y, 33)))
1015 } else if (filled && (inPond(P, x, y + 0.3) || inPond(P, x, y - 0.3))) {
1016 // half a cell of water rounds the rim
1017 if (x < 0 || y < 0 || x >= c.w || y >= c.h) continue
1018 const ground = c.bgAt(x, y) === DEFAULT ? p.ink : c.bgAt(x, y)
1019 c.shade(x, y, inPond(P, x, y + 0.3) ? '▄' : '▀', mix(ground, p.water[2]!, 0.3), c.bgAt(x, y))
1020 } else if (inPond(P, x, y, 0.6)) {
1021 c.clear(x, y)
1022 if (y > pd.cy && hash(x, y, 34) < 0.6) c.put(x, y, '.', mix(p.ink, p.shore, 0.7))
1023 }
1024 }
1025 // a duck paddles slowly on a roomy pond
1026 if (v >= 0.6 && P.ry >= 2 && P.rx >= 8) {
1027 const ph = t / 40 + seed
1028 const dx = Math.round(Math.sin(ph) * (P.rx - 6))
1029 const s = DUCK[Math.cos(ph) > 0 ? 1 : 0]!
1030 c.sprite(pd.cx + dx - 3, Math.round(pd.cy - P.ry), s, k => (k === 'f' ? 0xe8a24a : p.wool), true)
1031 }
1032 // reeds at the edges
1033 if (v >= 0.3) {
1034 const left = Math.round(pd.cx - P.rx - 1)
1035 const right = Math.round(pd.cx + P.rx + 1)
1036 c.put(left - 1, pd.cy - 1, 'ı', p.reed)
1037 c.put(left - 2, pd.cy - 1, '|', p.reed)
1038 if (v >= 0.5) {
1039 c.put(right + 1, pd.cy - 1, '|', p.reed)
1040 c.put(right + 2, pd.cy - 2, 'ı', p.reed)
1041 }
1042 }
1043 // lily pads, one with a flower
1044 if (v >= 0.45) {
1045 const pads = v >= 0.75 ? 3 : 2
1046 for (let i = 0; i < pads; i++) {
1047 const lx = Math.round(pd.cx + (hash(i, seed, 41) - 0.5) * P.rx * 1.3)
1048 const ly = Math.round(pd.cy + (hash(i, seed, 42) - 0.5) * P.ry)
1049 if (!inPond(P, lx, ly) || !inPond(P, lx + 1, ly)) continue
1050 c.put(lx, ly, 'ₒ', p.leaf[1]!)
1051 if (i === 0 && v >= 0.6) c.put(lx + 1, ly, '✿', p.flower[0]!)
1052 }
1053 }
1054 // fish swim back and forth
1055 if (v >= 0.3) {
1056 const fish = Math.min(v >= 0.7 ? 3 : v >= 0.45 ? 2 : 1, Math.max(1, Math.round(P.ry * 2 - 1)))
1057 for (let i = 0; i < fish; i++) {
1058 const fy = Math.round(pd.cy + 0.5 + (i - (fish - 1) / 2) * Math.max(1, P.ry * 0.7))
1059 const rowHalf = P.rx * Math.sqrt(Math.max(0, 1 - ((fy - pd.cy) / (P.ry + 0.5)) ** 2))
1060 const half = Math.max(0.5, rowHalf - 2.5)
1061 const speed = 0.25 + hash(i, seed, 51) * 0.25
1062 const ph = (t * speed) / half + hash(i, 52) * 10
1063 const dir = Math.cos(ph) > 0
1064 const fx = Math.round(pd.cx + Math.sin(ph) * half) - 1
1065 for (let k = -1; k <= 3; k++) c.clear(fx + k, fy)
1066 c.text(fx, fy, dir ? '><>' : '<><', pick(p.fish, hash(i, 53)))
1067 if (hash(i, Math.floor(t / 2)) < 0.2 && inPond(P, fx + 1, fy - 1)) c.put(fx + (dir ? 3 : -1), fy - 1, '°', p.water[1]!)
1068 }
1069 }
1070}
1071
1072
1073function drawTree(c: Canvas, tr: Tree, g: number, p: Palette) {
1074 // grow through the sizes this tree is allowed to reach
1075 const set = tr.kind === 'pine' ? PINES : ROUNDS
1076 const maxIdx = clamp(Math.round(tr.size * (set.length - 1) + 0.2), 0, set.length - 1)
1077 const idx = g < 0.15 ? -1 : Math.round(g * maxIdx)
1078 if (idx < 0) {
1079 c.put(tr.x, tr.base, 'ı', p.grass[1]!)
1080 return
1081 }
1082 const s = set[idx]!
1083 const sw = width(s)
1084 const x = tr.x - Math.floor(sw / 2)
1085 const y = tr.base - s.rows.length + 1
1086 const shade = (n: number) => pick(tr.kind === 'pine' ? p.pine : p.leaf, n)
1087 c.sprite(x, y, s, k =>
1088 k === 't' ? p.trunk : k === 'o' ? p.pineHi : k === 'h' ? p.leafHi : k === 'd' ? p.leaf[3]! : shade((tr.x * 0.13) % 1),
1089 true,
1090 )
1091}
1092hooks/text-rows.tsx 33 lines1import type { ElementConstructor, RenderNode, TextProps } from 'claude-code'
2
3import { DEFAULT, type Scene } from './scene'
4
5const hex = (n: number) => '#' + n.toString(16).padStart(6, '0')
6
7/** A scene as one Text per row, each holding runs of one colour: for surfaces without Raster. */
8export function textRows(scene: Scene, Text: ElementConstructor<TextProps>): RenderNode[] {
9 const { columns, rows, cells } = scene
10 const lines: RenderNode[] = []
11 for (let y = 0; y < rows; y++) {
12 const runs: RenderNode[] = []
13 let run = ''
14 let color = -1
15 const flush = () => {
16 if (run) runs.push(color === DEFAULT ? <Text>{run}</Text> : <Text color={hex(color)}>{run}</Text>)
17 run = ''
18 }
19 for (let x = 0; x < columns; x++) {
20 const i = (y * columns + x) * 3
21 const fg = cells[i] === 32 ? DEFAULT : cells[i + 1]!
22 if (fg !== color) {
23 flush()
24 color = fg
25 }
26 run += String.fromCodePoint(cells[i]!)
27 }
28 flush()
29 lines.push(<Text>{runs}</Text>)
30 }
31 return lines
32}
33hooks/pointer.tsx 16 lines1// A surface module laid invisibly over the scene: it draws nothing and hands
2// the pointer to the hooks module, which owns the scene and repaints it.
3
4import type { ClientModule } from 'claude-code'
5
6const Pointer: ClientModule = (_props, surface) => {
7 if (surface.state === undefined) {
8 surface.setState(true)
9 surface.onPointer(e => surface.post({ kind: e.type, x: e.x, y: e.y }))
10 }
11 const { Box } = surface.elements
12 return <Box width={surface.columns || 1} height={surface.rows || 1} />
13}
14
15export default Pointer
16types/index.d.ts 21 lines1declare module 'claude-code' {
2 interface PluginState {
3 forest: {
4 /** Growth from the live context, 0 to 1. */
5 fill: number
6 /** A fill set by `/forest <n>` that stands in for the live one. */
7 preview: number | null
8 /** An hour set by `/forest hour <h>` that stands in for the clock. */
9 hour: number | null
10 /** The drawing style; kept across sessions in the store, mirrored here so a change redraws. */
11 style: 'ascii' | 'soft' | 'painted'
12 /** The scene's seed: from the session id, or rolled by `/forest reseed`. */
13 seed: number | null
14 /** Growth `/forest set` added on top of the live fill, -1 to 1. */
15 boost: number
16 /** The lowest context seen this session, in tokens: growth counts from it. */
17 baseline: number | null
18 }
19 }
20}
21