SLOPSHOPPER

forest

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

panecommandprocesstimer
v0.1.0no licenseupdated 2026-10-19mech-a/claude-forest/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · forest
│ ┃ Forest ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /forest │ ┃ ⎿ forest: Forest: shown, growth 0%, autumn, 36% pines, deer, rabbi │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Forest
▣ cl
README

forest

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.

Install

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.

Commands

CommandWhat it does
/forestOpen 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 reseedRoll a new scene
/forest liveBack to real growth and the real clock

Click the scene for a sparkle.

Developing

  • 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.
  • See CLAUDE.md for the architecture.
Source 5 files
hooks/register.tsx 331 lines
1import { 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}
331
hooks/scene.ts 1092 lines
1// 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}
1092
hooks/text-rows.tsx 33 lines
1import 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}
33
hooks/pointer.tsx 16 lines
1// 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
16
types/index.d.ts 21 lines
1declare 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