Draws an ASCII animation above the prompt while the model works: matrix rain, fire, an aquarium, a cat, or a GIF of your own imported as a character clip for…

While you wait out a long turn, all that moves above the prompt is a spinner. This mod fills that space with an ASCII animation while the model works. Four scenes are built in: matrix rain, fire, an aquarium and a cat. You can add your own: /idle-art import turns a GIF into a character clip and keeps it for every project. Display only: nothing reaches the model, so the mod costs no tokens and does not touch the prompt cache.
The picture appears 3 seconds into a turn, so a short turn shows nothing, and it goes away when the turn ends. By default each turn draws a scene at random from the built-in scenes and your saved clips, never the one shown before. A long turn moves on to another at random: a built-in scene after 20 seconds, the cat and a clip at the end of their first round or loop that ends after 20 seconds, so a long clip plays through once and a short one repeats until then. A scene you chose by name stays for the whole turn, and choosing one while a turn draws changes the picture at once.
● Brewing… (5s) .: . , , ::,,, ::;;:; ;;::; . , ;:+ :*;++++ , ,, :::*oO;*:;*:+: :+++,::o ++:, : ;,, ;,;+;+::+;,;+:+**,; ; , : +;*:+**o*;;;;*+**:,*:;;***o:,::;; :,::;oOoO*ooOOO#oO*o*O*o**OOOoOo;+;,
| Scene | What moves |
|---|---|
matrix | Streams of half-width katakana and digits fall in green, a bright head over a fading trail; glyphs under a trail flicker. |
fire | Heat rises from a hidden row under the band and cools on the way up, drawn from . to @ and from dark red to pale yellow. |
aquarium | A school of fish of four shapes crosses both ways, bubbles rise from them and from the sand and grow, tall seaweed sways on the sand, and the surface ripples on the top row. |
cat | A garden: two kittens play back and forth on the grass, one on each side, a ball of yarn rolls at their feet, and a butterfly drifts over them. A big cat walks in slowly to the middle, head first, sits and blinks, and says meow; then its tricks in a new order each round: it rolls on the ground and back, jumps three times, purrs with a heart floating up, and looks left and right. It says MEOW!, walks out slowly to the right and comes round again. Each word stands in a speech bubble over its head. A round takes about 38 seconds, and random waits for the cat to walk out before it moves on. |
● Brewing… (8s) .------. ( meow ) '------' / /\_/\ ( o.o )
^ <
(|)~
The band takes at most 8 rows, fewer when the terminal has less room, and the terminal's whole width; a clip stands in its middle. It draws nothing in a band under 3 rows. It draws on the terminal only, and gives way to a survey. The scenes draw about 60 frames a second, and each moves by the time that passed, so the rain, the fish and the cat glide a step on every frame. The fire's heat changes ten times a second, and a clip changes at its own frame delays.
/idle-art import ~/Downloads/kitty.gif kitty
The mod reads the GIF, decodes every frame with its own delay and transparency, and turns each frame into characters: every cell takes the mean colour of its pixels, rounded to six levels a channel so neighbouring cells share a colour, and a glyph from .:-=+*#%@ by its brightness, spread over the clip's own darkest to brightest cell. A cell mostly transparent stays blank. The picture keeps its shape and fills the 8 rows of the band, at most 100 columns wide; a cell counts as twice as tall as wide. The clip then plays in the middle of the band, each frame for its own delay, in a loop.
● Brewing… (6s) ....=*****++=+*+====-.... ....=******=----=*#=====-.... ....-+++++*==+----===---:.... ....:--=###++*=-:-=-:---:.... ....-+#%#%*--==-:-:-==--:.... ....+**#*++-:-:---::::--:.... ....-====+-=:--:::::.:-+-.... ....:-=*++*+++=-:-=--=-=-....
A clip is kept under 90,000 characters, because that is what one drawing may hand the drawing thread. A longer clip keeps every other frame, each kept frame showing for the time of both, and does so again until it fits; the answer says how many frames stayed. The clips live in the mod's store, which holds 4 MiB in all, so about forty clips fit; a clip that does not fit is refused with the reason.
A name is lowercase letters, digits and dashes, starts with a letter or digit, is up to 24 characters (a capital letter is lowered), and cannot be a built-in scene or a word the command reads. Importing under a saved name replaces that clip. A path starting with ~ is under your home directory, and a relative path is under the session's directory. A path may hold spaces: the last word is the name.
/idle-art the state: on or off, the style, the delay /idle-art on | off draw or stop drawing /idle-art <scene or clip> always draw that one: matrix, fire, aquarium, cat, or a saved clip /idle-art random a new scene or clip each turn and every 20 seconds or so (the default) /idle-art delay <n> wait n seconds into a turn, 0 to 60 (default 3) /idle-art import <gif path> <name> turn a GIF into a clip and keep it under that name /idle-art list the built-in scenes and the saved clips /idle-art remove <name> delete a saved clip; a style set to it goes back to random /idle-art help
The settings and the clips stay in the mod's store, shared by every project and every window, and survive updates. Each window reads them again at each /idle-art command, at each turn's start, and every 2 seconds while a turn runs, so an on, an off, a style, a delay or a clip set in another window shows here within 2 seconds in a running turn, and at the next turn otherwise. Two windows that import at the same time keep both clips.
An AbovePrompt ui.render hook mounts a Client element while isWorking is true. The Client runs hooks/scene.tsx on the drawing thread: a 16 ms surface.every tick advances the scene by 16 ms and asks for the next frame, so no hook runs per frame. Measured on 2.1.283, the frame clock keeps that rate: the cat, which walks 5 cells a second, moved 5 cells in each second of a live band. Each built-in scene is a pure module under hooks/art/ that answers a grid of cells; a saved clip reaches the drawing thread in the Client's props and plays from hooks/clip.ts. A row draws as one Text per run of one colour. The hooks module picks the scene and a random seed when the band first shows a working turn, a $.clock.after timer redraws the band once the delay has passed, and the main loop's turn.complete ends the turn, so the next one picks again. While a turn runs, a $.clock.every timer reads the settings and the clip names again every 2 seconds, and a turn.start hook reads them at each turn's start, so the draw itself never reads the store. The clips load again only when the stored names or the stamp an import or a remove writes changed, because one read of the store takes longer as the whole store grows: 0.28 ms for a small store and 3.1 ms for one of 3.5 MB, measured on 2.1.283. Under random the drawing thread counts a scene's time itself, and once it has run it posts the scene's name with surface.post; the ui.message hook picks the next scene and answers with its props, which the running instance takes in place. A message that names a scene no longer showing changes nothing, so a late or repeated post cannot skip one. No clip travels to the drawing thread before its turn to show, because one clip may take most of the 100,000 characters a Client's props hold. The GIF decoder in hooks/gif.ts is written for this mod and needs no tool on the machine.
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install idle-art@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
Load it from a local checkout for one session:
claude --plugin-dir plugins/idle-art
Restart Claude Code, or run /reload-plugins in an open session. The mod is on after an install; /idle-art off turns it off.
Validated with claude plugin validate on Claude Code 2.1.283:
❯ ./register.tsx hooks: session.start, ui.render{component=AbovePrompt}, ui.message, turn.start, turn.complete, command.run{command=idle-art} ❯ ./register.tsx calls: $.clock.after (via beginTurn), $.clock.every (via beginTurn), $.clock.now (via sceneFor), $.command.register, $.env.get (via resolvePath), $.fs.exists (via readGifBytes), $.fs.read (via readGifBytes), $.fs.stat (via readGifBytes), $.process.spawn (via streamedStdout), $.session.cwd (via resolvePath), $.store.delete (via removeClip), $.store.get (via clipsMark, loadClips, loadConfig, writeClipNames), $.store.set (via importGif, removeClip, setting, writeClipNames), $.ui.invalidate (via beginTurn, setting, sync), $.ui.log (via rereadOrStop), $.ui.resolve ❯ ./register.tsx env writes: nothing ❯ ./register.tsx env reads: HOME ❯ ./register.tsx surface modules: hooks/scene.tsx
Reach L2, runs base64 to read a GIF over 4 MiB.
$.fs.read. A larger one, up to 32 MiB, is read through base64 -i <path> piece by piece, because $.fs.read refuses a file over 4 MiB and $.process.run cuts its output at 4 MiB (measured on 2.1.283). The bytes that come back must match the file's size, or the import is refused.matrix draws half-width katakana. A font without those glyphs draws a replacement character.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.tsx 351 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2import { BAND_ROWS, CLIP_COLUMNS, clipNames, configOf, editNames, helpText, isStyle, parseArgs, pickStyle, sceneOf, statusText, STYLES, unknownText, type Action, type Config } from './config.ts'
3import { clipFromGif, isClip, type Clip } from './clip.ts'
4import { bytesOf } from './gif.ts'
5import type { NextMessage, SceneProps } from './scene.tsx'
6
7interface State {
8 cfg: Config
9 /** The saved clips by name, as the store held them at the last read. */
10 clips: Map<string, Clip>
11 /** The stored clip names and stamp the clips were loaded at (`clipsMark`), so a read loads them again only after a change. */
12 clipsMark: string
13 /** When the band first saw the model working in this turn; null while it is idle. */
14 since: number | null
15 /** The scene and seed of the running turn, and the scene of the one before. */
16 style: string | null
17 seed: number
18 last: string | null
19 /** The region the scene was last drawn in, which the next scene of the same turn takes. */
20 size?: Size
21 /** The timer that reads the settings again while a turn runs. */
22 reread?: Timer
23}
24
25type Size = { width: number; height: number }
26
27/** Rows below which a band has no room for a picture. */
28const MIN_ROWS = 3
29/** `$.fs.read` refuses a file over 4 MiB; a larger GIF is read through `base64`, up to 32 MiB. */
30const MAX_READ_BYTES = 4 * 1024 * 1024
31const MAX_BIG_GIF_BYTES = 32 * 1024 * 1024
32/** The store key of the saved clip names, and the prefix of each clip's own key. */
33const CLIPS_KEY = 'clips'
34const CLIP_PREFIX = 'clip:'
35/** The store key of the stamp each import and remove writes, which a replaced clip changes too. */
36const CLIPS_REV_KEY = 'clipsRev'
37/** How often a running turn reads the settings again, so a change made in another window shows within seconds. */
38const REREAD_MS = 2000
39
40function errorText(err: unknown): string {
41 return err instanceof Error ? err.message : String(err)
42}
43
44/**
45 * The saved clips; a name whose entry is gone or broken is left out, and so is one a built-in scene took
46 * after it was saved (`cat`), so the clip never hides the scene.
47 */
48async function loadClips($: EngineInterface): Promise<Map<string, Clip>> {
49 const clips = new Map<string, Clip>()
50 for (const name of clipNames(await $.store.get(CLIPS_KEY))) {
51 const clip = await $.store.get(`${CLIP_PREFIX}${name}`)
52 if (isClip(clip)) clips.set(name, clip)
53 }
54 return clips
55}
56
57async function loadConfig($: EngineInterface, clips: readonly string[]): Promise<Config> {
58 const [enabled, style, delay] = await Promise.all([$.store.get('enabled'), $.store.get('style'), $.store.get('delay')])
59 return configOf(enabled, style, delay, clips)
60}
61
62/** The stored clip names with the stamp beside them; it differs after any window added, replaced or removed a clip. */
63async function clipsMark($: EngineInterface): Promise<string> {
64 const [names, rev] = await Promise.all([$.store.get(CLIPS_KEY), $.store.get(CLIPS_REV_KEY)])
65 return JSON.stringify([names ?? null, rev ?? null])
66}
67
68/** Loads the clips again once their mark moved, and only then, because each clip costs one store read, which grows with the whole store. */
69async function refreshClips($: EngineInterface, state: State): Promise<void> {
70 const mark = await clipsMark($)
71 if (mark === state.clipsMark) return
72 state.clips = await loadClips($)
73 state.clipsMark = mark
74}
75
76/**
77 * Reads the settings and the saved clips again, because every window shares the store, and answers whether
78 * the band changes: the mod was turned on or off, or the style or the delay changed. A new style takes the
79 * place of the scene a running turn shows, as `/idle-art <scene>` does in the window that ran it.
80 */
81async function readSettings($: EngineInterface, state: State): Promise<boolean> {
82 await refreshClips($, state)
83 const was = state.cfg
84 state.cfg = await loadConfig($, [...state.clips.keys()])
85 if (state.cfg.style !== was.style && state.since !== null) pickScene(state)
86 return state.cfg.enabled !== was.enabled || state.cfg.style !== was.style || state.cfg.delaySec !== was.delaySec
87}
88
89/** Reads the settings again, and redraws the band when another window changed what it shows. */
90async function sync($: EngineInterface, state: State): Promise<void> {
91 if (await readSettings($, state)) $.ui.invalidate('ui.render')
92}
93
94/** The timer's read: a read that fails is named once and stops the timer, and the next turn reads again. */
95async function rereadOrStop($: EngineInterface, state: State): Promise<void> {
96 try {
97 await sync($, state)
98 } catch (err) {
99 stopRereading(state)
100 $.ui.log(`the settings could not be read again: ${errorText(err)}`)
101 }
102}
103
104function stopRereading(state: State): void {
105 state.reread?.cancel()
106 state.reread = undefined
107}
108
109/** The turn ended: the next one picks its scene again, and nothing reads the settings until it begins. */
110function endTurn(state: State): void {
111 state.since = null
112 stopRereading(state)
113}
114
115/** Picks the next scene and its seed, never the one shown last. */
116function pickScene(state: State): void {
117 state.style = pickStyle(state.cfg.style, state.last, Math.random, [...state.clips.keys()])
118 state.last = state.style
119 state.seed = Math.floor(Math.random() * 2 ** 31)
120}
121
122/**
123 * A turn began: pick its scene and seed, redraw once the delay has passed, and read the settings again
124 * every 2 seconds while the turn runs.
125 */
126function beginTurn($: EngineInterface, state: State, now: number): void {
127 state.since = now
128 pickScene(state)
129 if (state.cfg.delaySec > 0) $.clock.after(state.cfg.delaySec * 1000, () => $.ui.invalidate('ui.render'))
130 stopRereading(state)
131 state.reread = $.clock.every(REREAD_MS, () => void rereadOrStop($, state))
132}
133
134/** The props of the current scene in a region; under `random` the scene gives way to another once it has run. */
135function propsOf(state: State, style: string, size: Size): SceneProps {
136 const props: SceneProps = { style, seed: state.seed, ...size, rotate: state.cfg.style === 'random' }
137 const clip = state.clips.get(style)
138 return clip === undefined ? props : { ...props, clip }
139}
140
141/** The scene of a working band, or null while the delay runs or the band has no room. */
142async function sceneFor($: EngineInterface, state: State, band: { maxRows: number; bodyColumns: number }): Promise<SceneProps | null> {
143 const now = await $.clock.now()
144 if (state.since === null) beginTurn($, state, now)
145 const height = Math.min(BAND_ROWS, band.maxRows)
146 const waited = now - (state.since ?? now) >= state.cfg.delaySec * 1000
147 if (state.style === null || !waited || height < MIN_ROWS) return null
148 state.size = { width: band.bodyColumns, height }
149 return propsOf(state, state.style, state.size)
150}
151
152/**
153 * The next scene's props when the running one asks to leave: only under `random`, only for the scene
154 * showing now (a late or repeated message changes nothing), and only while a band is drawn.
155 */
156function nextScene(state: State, data: unknown): SceneProps | undefined {
157 const asked = (data as Partial<NextMessage> | null)?.next
158 if (state.cfg.style !== 'random' || state.style === null || state.size === undefined) return undefined
159 if (asked !== sceneOf({ style: state.style, seed: state.seed })) return undefined
160 pickScene(state)
161 return propsOf(state, state.style as string, state.size)
162}
163
164/** A path as typed: `~` is the home directory, and a relative path is under the session's directory. */
165async function resolvePath($: EngineInterface, path: string): Promise<string> {
166 if (path === '~' || path.startsWith('~/')) return `${(await $.env.get('HOME')) ?? ''}${path.slice(1)}`
167 if (path.startsWith('/')) return path
168 return `${(await $.session.cwd()).replace(/\/+$/, '')}/${path}`
169}
170
171/**
172 * A GIF's bytes: `$.fs.read` up to its 4 MiB limit, and `base64 <path>` above it,
173 * whose text the mod decodes itself, up to 32 MiB.
174 */
175async function readGifBytes($: EngineInterface, path: string): Promise<Uint8Array> {
176 if (!(await $.fs.exists(path))) throw new Error(`${path} does not exist`)
177 const st = await $.fs.stat(path)
178 if (st.kind !== 'file') throw new Error(`${path} is not a file`)
179 const size = st.size ?? 0
180 if (size > MAX_BIG_GIF_BYTES) throw new Error(`${path} is ${Math.round(size / 1024 / 1024)} MiB, over the 32 MiB limit`)
181 if (size <= MAX_READ_BYTES) return bytesOf((await $.fs.read(path, { as: 'bytes' })).base64)
182 // macOS base64 takes its input file only after `-i`; GNU base64 reads `-i` as --ignore-garbage, which only
183 // decoding uses, and the path as its input file.
184 const bytes = bytesOf(await streamedStdout($, ['base64', '-i', path]))
185 if (bytes.length !== size) throw new Error(`base64 gave ${bytes.length} bytes of ${size}`)
186 return bytes
187}
188
189/**
190 * A command's whole stdout, read piece by piece: `$.process.run` cuts stdout at
191 * 4 MiB (measured on 2.1.283), and a large GIF's base64 is longer.
192 */
193async function streamedStdout($: EngineInterface, argv: string[]): Promise<string> {
194 const parts: string[] = []
195 const errors: string[] = []
196 const child = $.process.spawn({ argv })
197 for (;;) {
198 const step = await child.next()
199 if (step.done) {
200 if (step.value.code !== 0) throw new Error(`${argv[0]} failed: ${errors.join('').trim().slice(0, 200)}`)
201 return parts.join('')
202 }
203 ;(step.value.stream === 'stdout' ? parts : errors).push(step.value.text)
204 }
205}
206
207/**
208 * Adds one name to the stored clip names or takes it out, over what the store holds now, so a clip another
209 * window saved meanwhile stays listed; the new stamp makes every window load the clips again, this one too.
210 */
211async function writeClipNames($: EngineInterface, state: State, name: string, keep: boolean): Promise<void> {
212 await $.store.set(CLIPS_KEY, editNames(clipNames(await $.store.get(CLIPS_KEY)), name, keep))
213 await $.store.set(CLIPS_REV_KEY, Math.random().toString(36).slice(2))
214 await refreshClips($, state)
215}
216
217/** Turns a GIF into a clip, keeps it in the store under its name, and says what was kept. */
218async function importGif($: EngineInterface, state: State, typed: string, name: string): Promise<string> {
219 const path = await resolvePath($, typed)
220 let clip: Clip
221 let dropped: number
222 let frames: number
223 try {
224 ;({ clip, dropped, frames } = clipFromGif(await readGifBytes($, path), CLIP_COLUMNS, BAND_ROWS))
225 } catch (err) {
226 return `cannot import ${typed}: ${errorText(err)}`
227 }
228 try {
229 await $.store.set(`${CLIP_PREFIX}${name}`, clip)
230 } catch (err) {
231 return `cannot save ${name}: ${errorText(err)}. The store holds 4 MiB in all; /idle-art remove <name> frees room.`
232 }
233 const replaced = state.clips.has(name)
234 await writeClipNames($, state, name, true)
235 const kept = dropped > 0 ? `${clip.frames.length} of ${frames} frames (the rest dropped to fit)` : `${frames} frames`
236 return `${replaced ? 'replaced' : 'saved'} ${name}: ${kept}, ${clip.width}×${clip.height} cells. Use it with /idle-art ${name}; random draws it too.`
237}
238
239async function removeClip($: EngineInterface, state: State, name: string): Promise<string> {
240 if (!state.clips.has(name)) return `no saved clip is named ${name}`
241 await $.store.delete(`${CLIP_PREFIX}${name}`)
242 await writeClipNames($, state, name, false)
243 const wasChosen = state.cfg.style === name
244 if (wasChosen) {
245 state.cfg.style = 'random'
246 await $.store.set('style', 'random')
247 }
248 return `removed ${name}${wasChosen ? '; the style is random again' : ''}`
249}
250
251function listText(state: State): string {
252 const clips = [...state.clips].map(([name, c]) => `${name} (${c.frames.length} frames, ${c.width}×${c.height})`)
253 return [`built in: ${STYLES.join(', ')}`, `saved clips: ${clips.length > 0 ? clips.join(', ') : 'none; add one with /idle-art import <gif path> <name>'}`].join('\n')
254}
255
256/** Stores a setting, redraws, and answers with the state. */
257async function setting($: EngineInterface, state: State, key: 'enabled' | 'style' | 'delay', value: boolean | string | number): Promise<string> {
258 if (key === 'enabled') state.cfg.enabled = value as boolean
259 if (key === 'style') state.cfg.style = value as string
260 if (key === 'delay') state.cfg.delaySec = value as number
261 await $.store.set(key, value)
262 $.ui.invalidate('ui.render')
263 return statusText(state.cfg)
264}
265
266/** Stores the chosen style, and a turn already drawing takes it at once instead of at its end. */
267async function chooseStyle($: EngineInterface, state: State, style: string): Promise<string> {
268 if (style !== 'random' && !isStyle(style) && !state.clips.has(style)) return unknownText(style, [...state.clips.keys()])
269 state.cfg.style = style
270 if (state.since !== null) pickScene(state)
271 return setting($, state, 'style', style)
272}
273
274async function apply($: EngineInterface, state: State, action: Action): Promise<string> {
275 switch (action.kind) {
276 case 'status':
277 return statusText(state.cfg)
278 case 'help':
279 return helpText(state.cfg)
280 case 'list':
281 return listText(state)
282 case 'error':
283 return action.text
284 case 'enable':
285 return setting($, state, 'enabled', action.enabled)
286 case 'delay':
287 return setting($, state, 'delay', action.seconds)
288 case 'style':
289 return chooseStyle($, state, action.style)
290 case 'import':
291 return importGif($, state, action.path, action.name)
292 case 'remove':
293 return removeClip($, state, action.name)
294 }
295}
296
297export const register: Register = on => {
298 const state: State = { cfg: configOf(undefined, undefined, undefined, []), clips: new Map(), clipsMark: '', since: null, style: null, seed: 0, last: null }
299
300 on('session.start', async ($, e, next) => {
301 const r = await next(e)
302 await readSettings($, state)
303 await $.command.register({ name: 'idle-art', description: 'ASCII animation above the prompt while the model works: on, off, a scene, random, delay, import a GIF (idle-art)', immediate: true })
304 return r
305 })
306
307 // The band's draw reads no store: it draws from the settings the last read left.
308 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
309 const drawn = await next(e)
310 if (!e.props.isWorking) endTurn(state)
311 if (!state.cfg.enabled || e.surface !== 'terminal' || e.props.hasSurvey || !e.props.isWorking) return drawn
312 const props = await sceneFor($, state, e.props)
313 if (props === null) return drawn
314 const { Box, Client } = $.ui.resolve(e)
315 // What another plugin drew in the band stays above the picture.
316 return (
317 <Box flexDirection="column">
318 {drawn}
319 <Client key="idle-art" module="./scene.tsx" width={props.width} height={props.height} props={props} />
320 </Box>
321 )
322 })
323
324 // A scene under `random` that has run its time asks for the next; the answer's props start it in place.
325 on('ui.message', async (_, e, next) => {
326 if (e.element !== 'idle-art') return next(e)
327 const props = nextScene(state, e.data)
328 return props === undefined ? next(e) : { props }
329 })
330
331 // Each turn reads the settings at its start, so an on, an off or a style another window stored applies here
332 // too, also while this window has the mod off and draws nothing.
333 on('turn.start', async ($, e, next) => {
334 const r = await next(e)
335 await sync($, state)
336 return r
337 })
338
339 // The band is not drawn idle between two turns, so the main loop's turn end is what starts the next
340 // turn with a new style and its own delay.
341 on('turn.complete', async (_, e, next) => {
342 if (e.agentId === undefined) endTurn(state)
343 return next(e)
344 })
345
346 on('command.run', { command: 'idle-art' }, async ($, e) => {
347 await sync($, state)
348 return { text: await apply($, state, parseArgs(e.args)) }
349 })
350}
351hooks/config.ts 144 lines1export const STYLES = ['matrix', 'fire', 'aquarium', 'cat'] as const
2export type Style = (typeof STYLES)[number]
3
4/**
5 * A stored choice: a built-in style, the name of a saved clip, or `random`, a
6 * new one at each turn from both, never the one before it.
7 */
8export type Choice = string
9
10export type Config = { enabled: boolean; style: Choice; delaySec: number }
11
12export const DEFAULT_DELAY_SEC = 3
13export const MAX_DELAY_SEC = 60
14
15/** Rows the band takes at most; a scene takes the band's whole width. */
16export const BAND_ROWS = 8
17/** Columns an imported clip may take at most. */
18export const CLIP_COLUMNS = 100
19
20/** Words the command reads itself, so no clip may take them as its name. */
21const RESERVED = new Set(['random', 'on', 'off', 'help', 'delay', 'import', 'list', 'remove'])
22
23/** A clip name: lowercase letters, digits and dashes, starting with a letter or digit, up to 24 characters. */
24const NAME = /^[a-z0-9][a-z0-9-]{0,23}$/
25
26/** One showing of a scene: its style and seed, which an instance names when it asks to leave it. */
27export function sceneOf(p: { style: string; seed: number }): string {
28 return `${p.style}:${p.seed}`
29}
30
31export function isStyle(v: unknown): v is Style {
32 return typeof v === 'string' && (STYLES as readonly string[]).includes(v)
33}
34
35/** Why a name cannot be a clip's, or null when it can. */
36export function nameProblem(name: string): string | null {
37 if (!NAME.test(name)) return `${name} is not a clip name: use lowercase letters, digits and dashes, up to 24 characters`
38 if (RESERVED.has(name) || isStyle(name)) return `${name} is taken by the command or a built-in scene`
39 return null
40}
41
42/**
43 * The clip names as the store holds them: a value that is not a list reads as none, and a name the mod
44 * refuses now is left out, as one a built-in scene took after it was saved (`cat`).
45 */
46export function clipNames(stored: unknown): string[] {
47 return Array.isArray(stored) ? stored.filter((n): n is string => typeof n === 'string' && nameProblem(n) === null) : []
48}
49
50/** The clip names with `name` added at the end, or taken out; a name already listed keeps its place. */
51export function editNames(names: readonly string[], name: string, keep: boolean): string[] {
52 if (!keep) return names.filter(n => n !== name)
53 return names.includes(name) ? [...names] : [...names, name]
54}
55
56/** The settings as the store holds them; a missing or broken value takes its default. */
57export function configOf(enabled: unknown, style: unknown, delay: unknown, clips: readonly string[]): Config {
58 const delaySec = typeof delay === 'number' && Number.isInteger(delay) && delay >= 0 && delay <= MAX_DELAY_SEC ? delay : DEFAULT_DELAY_SEC
59 const known = isStyle(style) || (typeof style === 'string' && clips.includes(style))
60 return { enabled: enabled !== false, style: known ? (style as string) : 'random', delaySec }
61}
62
63/** The scene of a new turn: the stored one, or at random any built-in style or clip but the last. */
64export function pickStyle(choice: Choice, last: string | null, rng: () => number, clips: readonly string[] = []): string {
65 if (choice !== 'random') return choice
66 const all = [...STYLES, ...clips]
67 const pool = all.length > 1 ? all.filter(s => s !== last) : all
68 return pool[Math.floor(rng() * pool.length)] ?? 'matrix'
69}
70
71export type Action =
72 | { kind: 'status' }
73 | { kind: 'help' }
74 | { kind: 'list' }
75 | { kind: 'enable'; enabled: boolean }
76 | { kind: 'style'; style: Choice }
77 | { kind: 'delay'; seconds: number }
78 | { kind: 'import'; path: string; name: string }
79 | { kind: 'remove'; name: string }
80 | { kind: 'error'; text: string }
81
82const USAGE = `/idle-art [on | off | ${STYLES.join(' | ')} | <clip> | random | delay <0-${MAX_DELAY_SEC}> | import <gif path> <name> | list | remove <name> | help]`
83
84const tooMany: Action = { kind: 'error', text: `too many arguments. Usage: ${USAGE}` }
85
86function delayAction(rest: string[]): Action {
87 const [value] = rest
88 const n = Number(value)
89 if (rest.length > 1) return tooMany
90 if (value === undefined || !Number.isInteger(n) || n < 0 || n > MAX_DELAY_SEC) return { kind: 'error', text: `delay takes whole seconds from 0 to ${MAX_DELAY_SEC}. Usage: ${USAGE}` }
91 return { kind: 'delay', seconds: n }
92}
93
94/** `import <path> <name>`: the name is the last word, so a path may hold spaces. */
95function importAction(rest: string[]): Action {
96 if (rest.length < 2) return { kind: 'error', text: `import takes a GIF path and a name. Usage: ${USAGE}` }
97 const name = (rest[rest.length - 1] as string).toLowerCase()
98 const problem = nameProblem(name)
99 if (problem !== null) return { kind: 'error', text: problem }
100 return { kind: 'import', path: rest.slice(0, -1).join(' '), name }
101}
102
103function removeAction(rest: string[]): Action {
104 if (rest.length !== 1) return { kind: 'error', text: `remove takes one clip name. Usage: ${USAGE}` }
105 return { kind: 'remove', name: (rest[0] as string).toLowerCase() }
106}
107
108const WITH_VALUE: Record<string, (rest: string[]) => Action> = { delay: delayAction, import: importAction, remove: removeAction }
109
110/** What a `/idle-art` argument asks for. The keyword reads case-blind; a path keeps its case. */
111export function parseArgs(args: string): Action {
112 const [first = '', ...rest] = args.trim().split(/\s+/)
113 const word = first.toLowerCase()
114 const withValue = WITH_VALUE[word]
115 if (withValue !== undefined) return withValue(rest)
116 return rest.length > 0 ? tooMany : wordAction(word)
117}
118
119/** What a single-word argument asks for; a word the command does not know names a scene or a clip. */
120function wordAction(word: string): Action {
121 const fixed: Record<string, Action> = { '': { kind: 'status' }, help: { kind: 'help' }, list: { kind: 'list' }, on: { kind: 'enable', enabled: true }, off: { kind: 'enable', enabled: false } }
122 return fixed[word] ?? { kind: 'style', style: word }
123}
124
125export function unknownText(word: string, clips: readonly string[]): string {
126 return `unknown scene: ${word}. Scenes: ${[...STYLES, ...clips].join(', ')}. Usage: ${USAGE}`
127}
128
129export function statusText(c: Config): string {
130 return `${c.enabled ? 'on' : 'off'} · style ${c.style} · shows ${c.delaySec}s into a turn`
131}
132
133export function helpText(c: Config): string {
134 return [
135 statusText(c),
136 `Usage: ${USAGE}`,
137 'on, off: draw or stop drawing the animation above the prompt while the model works.',
138 `${STYLES.join(', ')}, or a saved clip's name: always draw that one. random: a new one each turn, clips included.`,
139 'delay <n>: wait n seconds into a turn before drawing, so short turns show nothing.',
140 'import <gif path> <name>: turn a GIF into a character clip and keep it under that name, for every project.',
141 'list: the built-in scenes and the saved clips. remove <name>: delete a saved clip.',
142 ].join('\n')
143}
144hooks/clip.ts 200 lines1import { readGif, type Gif } from './gif.ts'
2import { BLANK, blankFrame, runsOf, type Animation, type Cell, type Frame } from './art/grid.ts'
3
4/** A run of one colour in a stored row: its text and its colour ('' for none). */
5export type ClipRun = [string, string]
6
7/** A GIF turned into character frames, as the store keeps it and the band plays it. */
8export type Clip = { width: number; height: number; delays: number[]; frames: ClipRun[][][] }
9
10/** Glyphs from the dimmest opaque cell to the brightest; a transparent cell is a space. */
11const RAMP = [...'.:-=+*#%@']
12
13/** The largest clip in JSON characters: a `Client`'s props are bounded at 100,000. */
14export const MAX_CLIP_CHARS = 90_000
15
16/** A cell less covered than this by opaque pixels is left blank. */
17const MIN_COVER = 0.5
18
19/** Rounds a channel to one of six levels, so neighbouring cells share a colour and a run. */
20function level(v: number): number {
21 return Math.round(v / 51) * 51
22}
23
24function hex(r: number, g: number, b: number): string {
25 return `#${[r, g, b].map(v => level(v).toString(16).padStart(2, '0')).join('')}`
26}
27
28type Box = { x0: number; x1: number; y0: number; y1: number }
29
30/** The mean colour and the opaque share of a pixel box of one frame. */
31function average(rgba: Uint8Array, width: number, box: Box): { r: number; g: number; b: number; cover: number } {
32 let r = 0
33 let g = 0
34 let b = 0
35 let opaque = 0
36 let all = 0
37 for (let y = box.y0; y < box.y1; y++) {
38 for (let x = box.x0; x < box.x1; x++) {
39 const p = (y * width + x) * 4
40 all += 1
41 if ((rgba[p + 3] ?? 0) < 128) continue
42 opaque += 1
43 r += rgba[p] ?? 0
44 g += rgba[p + 1] ?? 0
45 b += rgba[p + 2] ?? 0
46 }
47 }
48 if (opaque === 0) return { r: 0, g: 0, b: 0, cover: 0 }
49 return { r: r / opaque, g: g / opaque, b: b / opaque, cover: opaque / Math.max(1, all) }
50}
51
52type Mean = ReturnType<typeof average>
53
54function lumOf(a: Mean): number {
55 return (0.2126 * a.r + 0.7152 * a.g + 0.0722 * a.b) / 255
56}
57
58/** The darkest and brightest opaque cell of the whole clip, so the ramp spans the clip's own range. */
59type Range = { lo: number; hi: number }
60
61function rangeOf(means: Mean[][][]): Range {
62 let lo = 1
63 let hi = 0
64 for (const cell of means.flat(2)) {
65 if (cell.cover < MIN_COVER) continue
66 lo = Math.min(lo, lumOf(cell))
67 hi = Math.max(hi, lumOf(cell))
68 }
69 return hi > lo ? { lo, hi } : { lo: 0, hi: 1 }
70}
71
72function cellOf(a: Mean, range: Range): Cell {
73 if (a.cover < MIN_COVER) return BLANK
74 const t = (lumOf(a) - range.lo) / (range.hi - range.lo)
75 return { ch: RAMP[Math.max(0, Math.min(RAMP.length - 1, Math.floor(t * RAMP.length)))] as string, color: hex(a.r, a.g, a.b) }
76}
77
78/**
79 * The size in cells a picture takes in a band of `cols` by `rows`, its shape kept:
80 * a cell is about twice as tall as wide, so one row covers two pixel rows' worth.
81 */
82export function fitSize(width: number, height: number, cols: number, rows: number): { cols: number; rows: number } {
83 const scale = Math.min(cols / width, (rows * 2) / height)
84 return { cols: Math.max(1, Math.min(cols, Math.round(width * scale))), rows: Math.max(1, Math.min(rows, Math.round((height * scale) / 2))) }
85}
86
87/** The pixel span of cell `i` of `n` over `size` pixels, at least one pixel. */
88function span(i: number, n: number, size: number): [number, number] {
89 const a = Math.floor((i * size) / n)
90 return [a, Math.max(a + 1, Math.floor(((i + 1) * size) / n))]
91}
92
93/** The mean of every cell of one frame of `width` by `height` pixels, row by row. */
94function frameMeans(rgba: Uint8Array, width: number, height: number, cols: number, rows: number): Mean[][] {
95 return Array.from({ length: rows }, (_, cy) => {
96 const [y0, y1] = span(cy, rows, height)
97 return Array.from({ length: cols }, (_, cx) => {
98 const [x0, x1] = span(cx, cols, width)
99 return average(rgba, width, { x0, x1, y0, y1 })
100 })
101 })
102}
103
104function frameRows(means: Mean[][], range: Range): ClipRun[][] {
105 return means.map(row => runsOf(row.map(m => cellOf(m, range))).map(run => [run.text, run.color] as ClipRun))
106}
107
108/** Keeps every other frame, each kept frame showing for the time of both. */
109function thinned(clip: Clip): Clip {
110 const frames: ClipRun[][][] = []
111 const delays: number[] = []
112 for (let i = 0; i < clip.frames.length; i += 2) {
113 frames.push(clip.frames[i] as ClipRun[][])
114 delays.push((clip.delays[i] ?? 0) + (clip.delays[i + 1] ?? 0))
115 }
116 return { ...clip, frames, delays }
117}
118
119/** The cell means and delays of every frame, as the frames are decoded. */
120type Means = { cols: number; rows: number; means: Mean[][][]; delays: number[] }
121
122/** A clip from the frames' cell means, with frames dropped until it fits the size limit. */
123function clipOf(m: Means): { clip: Clip; dropped: number } {
124 const range = rangeOf(m.means)
125 let clip: Clip = { width: m.cols, height: m.rows, delays: m.delays, frames: m.means.map(f => frameRows(f, range)) }
126 while (JSON.stringify(clip).length > MAX_CLIP_CHARS && clip.frames.length > 1) clip = thinned(clip)
127 if (JSON.stringify(clip).length > MAX_CLIP_CHARS) throw new Error(`one frame is over ${MAX_CLIP_CHARS} characters`)
128 return { clip, dropped: m.means.length - clip.frames.length }
129}
130
131/** A decoded GIF as a clip for a band of `cols` by `rows`. */
132export function toClip(gif: Gif, cols: number, rows: number): { clip: Clip; dropped: number } {
133 const size = fitSize(gif.width, gif.height, cols, rows)
134 const means = gif.frames.map(f => frameMeans(f.rgba, gif.width, gif.height, size.cols, size.rows))
135 return clipOf({ ...size, means, delays: gif.frames.map(f => f.delayMs) })
136}
137
138/**
139 * A GIF file's bytes as a clip for a band of `cols` by `rows`: each frame is
140 * reduced to its cell means as it is decoded, so a large GIF never holds more
141 * than one frame of pixels.
142 */
143export function clipFromGif(bytes: Uint8Array, cols: number, rows: number): { clip: Clip; dropped: number; frames: number } {
144 const m: Means = { cols: 0, rows: 0, means: [], delays: [] }
145 const { count } = readGif(bytes, (frame, width, height) => {
146 if (m.means.length === 0) Object.assign(m, fitSize(width, height, cols, rows))
147 m.means.push(frameMeans(frame.rgba, width, height, m.cols, m.rows))
148 m.delays.push(frame.delayMs)
149 })
150 return { ...clipOf(m), frames: count }
151}
152
153/** Whether a stored value has the shape of a clip, so a broken store entry is refused rather than drawn. */
154export function isClip(v: unknown): v is Clip {
155 if (typeof v !== 'object' || v === null) return false
156 const c = v as Partial<Clip>
157 return typeof c.width === 'number' && typeof c.height === 'number' && Array.isArray(c.delays) && Array.isArray(c.frames) && c.frames.length > 0 && c.frames.length === c.delays.length
158}
159
160/** Draws a stored row into a frame from column `x`, cutting what falls outside. */
161function putRow(out: Frame, y: number, x: number, runs: ClipRun[]): void {
162 const row = out[y]
163 if (row === undefined) return
164 let col = x
165 for (const [text, color] of runs) {
166 for (const ch of text) {
167 if (col >= 0 && col < row.length) row[col] = ch === ' ' ? BLANK : { ch, color }
168 col += 1
169 }
170 }
171}
172
173/**
174 * Plays a clip centred in a region of `w` by `h`, each frame for its own delay, in a loop; a step without a
175 * length of its own is `tickMs` long.
176 */
177export function playClip(clip: Clip, w: number, h: number, tickMs: number): Animation {
178 let index = 0
179 let shown = 0
180 let wrapped = false
181 const x = Math.floor((w - clip.width) / 2)
182 const y = Math.floor((h - clip.height) / 2)
183 const step = (dtMs?: number): void => {
184 shown += dtMs ?? tickMs
185 wrapped = false
186 // A stored delay under 20 ms is read as 20 ms, so a broken entry cannot spin this loop.
187 for (let d = Math.max(20, clip.delays[index] ?? tickMs); shown >= d; d = Math.max(20, clip.delays[index] ?? tickMs)) {
188 shown -= d
189 index = (index + 1) % clip.frames.length
190 if (index === 0) wrapped = true
191 }
192 }
193 const frame = (): Frame => {
194 const out = blankFrame(w, h)
195 clip.frames[index]?.forEach((runs, row) => putRow(out, y + row, x, runs))
196 return out
197 }
198 return { step, frame, wrapped: () => wrapped }
199}
200hooks/gif.ts 275 lines1/** One composed frame of a GIF: RGBA pixels of the whole logical screen, and how long it shows. */
2export type GifFrame = { rgba: Uint8Array; delayMs: number }
3export type Gif = { width: number; height: number; frames: GifFrame[] }
4
5/** The frame control a Graphic Control Extension sets for the next image. */
6type Control = { delayMs: number; transparent: number | null; disposal: number }
7
8/** A delay of 0 or 10 ms is played by browsers as 100 ms; so is it here. */
9const MIN_DELAY_MS = 20
10const DEFAULT_DELAY_MS = 100
11/** Stop decoding past this many frames; the store could not hold more anyway. */
12const MAX_FRAMES = 500
13
14const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
15
16/** The bytes of a base64 text, as `$.fs.read(path, { as: 'bytes' })` hands them. */
17export function bytesOf(base64: string): Uint8Array {
18 const text = base64.replace(/[^A-Za-z0-9+/]/g, '')
19 const out = new Uint8Array(Math.floor((text.length * 3) / 4))
20 let acc = 0
21 let bits = 0
22 let o = 0
23 for (const ch of text) {
24 acc = ((acc << 6) | B64.indexOf(ch)) & 0xffff
25 bits += 6
26 if (bits >= 8) {
27 bits -= 8
28 out[o++] = (acc >> bits) & 0xff
29 }
30 }
31 return out.subarray(0, o)
32}
33
34class Reader {
35 pos = 0
36 readonly bytes: Uint8Array
37 constructor(bytes: Uint8Array) {
38 this.bytes = bytes
39 }
40 byte(): number {
41 const b = this.bytes[this.pos]
42 if (b === undefined) throw new Error('the GIF ends early')
43 this.pos += 1
44 return b
45 }
46 u16(): number {
47 return this.byte() | (this.byte() << 8)
48 }
49 take(n: number): Uint8Array {
50 if (this.pos + n > this.bytes.length) throw new Error('the GIF ends early')
51 const out = this.bytes.subarray(this.pos, this.pos + n)
52 this.pos += n
53 return out
54 }
55 /** The data sub-blocks up to their zero terminator, joined. */
56 blocks(): Uint8Array {
57 const parts: Uint8Array[] = []
58 let total = 0
59 for (let n = this.byte(); n !== 0; n = this.byte()) {
60 parts.push(this.take(n))
61 total += n
62 }
63 const out = new Uint8Array(total)
64 let at = 0
65 for (const p of parts) {
66 out.set(p, at)
67 at += p.length
68 }
69 return out
70 }
71}
72
73function colorTable(r: Reader, packed: number): Uint8Array {
74 return r.take(3 * (1 << ((packed & 7) + 1)))
75}
76
77/** Reads the variable-width, least-significant-bit-first codes of an LZW stream. */
78class Codes {
79 private pos = 0
80 private acc = 0
81 private bits = 0
82 private readonly data: Uint8Array
83 constructor(data: Uint8Array) {
84 this.data = data
85 }
86 /** The next code of `size` bits, or -1 when the data ends. */
87 next(size: number): number {
88 while (this.bits < size) {
89 const b = this.data[this.pos++]
90 if (b === undefined) return -1
91 this.acc |= b << this.bits
92 this.bits += 8
93 }
94 const code = this.acc & ((1 << size) - 1)
95 this.acc >>>= size
96 this.bits -= size
97 return code
98 }
99}
100
101/** The LZW string table: each code past the roots is a prefix code plus one suffix index. */
102class Table {
103 readonly prefix = new Int16Array(4096).fill(-1)
104 readonly suffix = new Uint8Array(4096)
105 readonly first = new Uint8Array(4096)
106 private readonly stack = new Uint8Array(4096)
107 readonly clear: number
108 next = 0
109 size = 0
110 readonly minCodeSize: number
111 constructor(minCodeSize: number) {
112 this.minCodeSize = minCodeSize
113 this.clear = 1 << minCodeSize
114 for (let i = 0; i < this.clear; i++) {
115 this.suffix[i] = i
116 this.first[i] = i
117 }
118 this.reset()
119 }
120 reset(): void {
121 this.size = this.minCodeSize + 1
122 this.next = this.clear + 2
123 }
124 /** Writes the string of `code` into `out` from `o`; the code being defined is the previous string plus its first index. */
125 expand(code: number, prev: number, out: Uint8Array, o: number): number {
126 if (code > this.next || (code === this.next && prev === -1)) throw new Error('the GIF image data is broken')
127 let sp = 0
128 let c = code
129 if (code === this.next) {
130 this.stack[sp++] = this.first[prev] as number
131 c = prev
132 }
133 while (c >= this.clear) {
134 this.stack[sp++] = this.suffix[c] as number
135 c = this.prefix[c] as number
136 }
137 this.stack[sp++] = c
138 while (sp > 0 && o < out.length) out[o++] = this.stack[--sp] as number
139 return o
140 }
141 /** Adds the previous string plus the first index of the current one. */
142 add(code: number, prev: number): void {
143 if (prev === -1 || this.next >= 4096) return
144 this.prefix[this.next] = prev
145 this.suffix[this.next] = this.first[code === this.next ? prev : code] as number
146 this.first[this.next] = this.first[prev] as number
147 this.next += 1
148 if (this.next === 1 << this.size && this.size < 12) this.size += 1
149 }
150}
151
152/** The LZW-coded pixel indices of one image, `count` of them; a short stream leaves the rest 0. */
153export function lzwDecode(data: Uint8Array, minCodeSize: number, count: number): Uint8Array {
154 if (minCodeSize < 2 || minCodeSize > 8) throw new Error('the GIF image data is broken')
155 const out = new Uint8Array(count)
156 const table = new Table(minCodeSize)
157 const codes = new Codes(data)
158 let prev = -1
159 let o = 0
160 while (o < count) {
161 const code = codes.next(table.size)
162 if (code === -1 || code === table.clear + 1) break
163 if (code === table.clear) {
164 table.reset()
165 prev = -1
166 continue
167 }
168 o = table.expand(code, prev, out, o)
169 table.add(code, prev)
170 prev = code
171 }
172 return out
173}
174
175/** The row order of an interlaced image: every 8th from 0, every 8th from 4, every 4th from 2, every 2nd from 1. */
176function interlacedRows(h: number): number[] {
177 const rows: number[] = []
178 for (const [start, stepBy] of [[0, 8], [4, 8], [2, 4], [1, 2]] as const) for (let y = start; y < h; y += stepBy) rows.push(y)
179 return rows
180}
181
182function readControl(r: Reader): Control {
183 const block = r.blocks()
184 const packed = block[0] ?? 0
185 const delay = ((block[1] ?? 0) | ((block[2] ?? 0) << 8)) * 10
186 return { disposal: (packed >> 2) & 7, transparent: packed & 1 ? (block[3] ?? 0) : null, delayMs: delay < MIN_DELAY_MS ? DEFAULT_DELAY_MS : delay }
187}
188
189type Screen = { width: number; height: number; canvas: Uint8Array; global: Uint8Array | null }
190
191/** Draws one image onto the canvas and answers the frame the screen then shows. */
192function drawImage(r: Reader, s: Screen, ctl: Control): GifFrame {
193 const x0 = r.u16()
194 const y0 = r.u16()
195 const w = r.u16()
196 const h = r.u16()
197 const packed = r.byte()
198 const table = packed & 0x80 ? colorTable(r, packed) : s.global
199 if (table === null) throw new Error('a GIF image has no color table')
200 const minCodeSize = r.byte()
201 const indices = lzwDecode(r.blocks(), minCodeSize, w * h)
202 const saved = ctl.disposal === 3 ? s.canvas.slice() : null
203 const rows = packed & 0x40 ? interlacedRows(h) : Array.from({ length: h }, (_, y) => y)
204 rows.forEach((y, i) => paintRow(s, table, indices.subarray(i * w, i * w + w), x0, y0 + y, ctl.transparent))
205 const frame = { rgba: s.canvas.slice(), delayMs: ctl.delayMs }
206 if (ctl.disposal === 2) clearRect(s, x0, y0, w, h)
207 if (saved !== null) s.canvas = saved
208 return frame
209}
210
211function paintRow(s: Screen, table: Uint8Array, row: Uint8Array, x0: number, y: number, transparent: number | null): void {
212 if (y < 0 || y >= s.height) return
213 row.forEach((index, i) => {
214 const x = x0 + i
215 if (index === transparent || x < 0 || x >= s.width) return
216 const p = (y * s.width + x) * 4
217 s.canvas.set([table[index * 3] ?? 0, table[index * 3 + 1] ?? 0, table[index * 3 + 2] ?? 0, 255], p)
218 })
219}
220
221function clearRect(s: Screen, x0: number, y0: number, w: number, h: number): void {
222 for (let y = Math.max(0, y0); y < Math.min(s.height, y0 + h); y++) s.canvas.fill(0, (y * s.width + Math.max(0, x0)) * 4, (y * s.width + Math.min(s.width, x0 + w)) * 4)
223}
224
225/** The signature, the logical screen and the global color table. */
226function readScreen(r: Reader): Screen {
227 const sig = String.fromCharCode(...r.take(6))
228 if (sig !== 'GIF87a' && sig !== 'GIF89a') throw new Error('the file is not a GIF')
229 const width = r.u16()
230 const height = r.u16()
231 const packed = r.byte()
232 r.take(2)
233 if (width === 0 || height === 0) throw new Error('the GIF has no size')
234 return { width, height, canvas: new Uint8Array(width * height * 4), global: packed & 0x80 ? colorTable(r, packed) : null }
235}
236
237/**
238 * Decodes a GIF87a or GIF89a file frame by frame, handing each composed frame
239 * to `visit` as it is made, so no more than one frame's pixels are held at once.
240 * Answers the screen's size and the number of frames. Throws with a reason on a
241 * file it cannot read.
242 */
243export function readGif(bytes: Uint8Array, visit: (frame: GifFrame, width: number, height: number) => void): { width: number; height: number; count: number } {
244 const r = new Reader(bytes)
245 const s = readScreen(r)
246 let count = 0
247 let ctl: Control = { delayMs: DEFAULT_DELAY_MS, transparent: null, disposal: 0 }
248 while (count < MAX_FRAMES) {
249 const kind = r.byte()
250 if (kind === 0x3b) break
251 if (kind === 0x2c) {
252 visit(drawImage(r, s, ctl), s.width, s.height)
253 count += 1
254 ctl = { delayMs: DEFAULT_DELAY_MS, transparent: null, disposal: 0 }
255 } else if (kind === 0x21) ctl = readExtension(r, ctl)
256 else throw new Error(`the GIF holds an unknown block 0x${kind.toString(16)}`)
257 }
258 if (count === 0) throw new Error('the GIF has no image')
259 return { width: s.width, height: s.height, count }
260}
261
262/** Decodes a GIF into all its composed frames at once; for a small GIF, as the tests use. */
263export function decodeGif(bytes: Uint8Array): Gif {
264 const frames: GifFrame[] = []
265 const { width, height } = readGif(bytes, f => frames.push(f))
266 return { width, height, frames }
267}
268
269function readExtension(r: Reader, ctl: Control): Control {
270 const label = r.byte()
271 if (label === 0xf9) return readControl(r)
272 r.blocks()
273 return ctl
274}
275hooks/scene.tsx 84 lines1import type { ClientModule, ClientSurface, RenderElement } from 'claude-code'
2import { isStyle, sceneOf } from './config.ts'
3import { rngOf, runsOf, sceneDone, type Animation, type Frame } from './art/grid.ts'
4import { SCENES } from './art/scenes.ts'
5import { playClip, type Clip } from './clip.ts'
6
7/**
8 * What the hooks module hands this instance: the scene's style and seed, the region, a saved clip's frames,
9 * and whether the scene gives way to another once it has run its time (the `random` choice).
10 */
11export type SceneProps = { style: string; seed: number; width: number; height: number; clip?: Clip; rotate?: boolean }
12
13/** What an instance posts when its scene has run its time: the scene it asks to leave. */
14export type NextMessage = { next: string }
15
16/**
17 * The running animation and what it was started for, so new props start a new one; how long it has run,
18 * and whether it has asked for the next scene.
19 */
20type Live = { anim: Animation; key: string; scene: string; rotate: boolean; ms: number; asked: boolean }
21
22/**
23 * Milliseconds per animation tick, about 60 frames a second. Each scene moves by the time a tick stands for,
24 * so its pace does not hang on this rate; a fire and a clip still change at their own pace.
25 */
26const TICK_MS = 16
27
28function keyOf(p: SceneProps): string {
29 return `${sceneOf(p)}:${p.width}x${p.height}`
30}
31
32function animOf(p: SceneProps): Animation | undefined {
33 if (p.width < 1 || p.height < 1) return undefined
34 if (p.clip !== undefined) return playClip(p.clip, p.width, p.height, TICK_MS)
35 return isStyle(p.style) ? SCENES[p.style](p.width, p.height, rngOf(p.seed)) : undefined
36}
37
38function start(p: SceneProps): Live | undefined {
39 const anim = animOf(p)
40 return anim === undefined ? undefined : { anim, key: keyOf(p), scene: sceneOf(p), rotate: p.rotate === true, ms: 0, asked: false }
41}
42
43/** One tick: advance the scene, and once it has run its time, ask the hooks module for the next one, once. */
44function tick(surface: ClientSurface<Live>): void {
45 const now = surface.state
46 if (now === undefined) return
47 now.anim.step(TICK_MS)
48 now.ms += TICK_MS
49 if (now.rotate && !now.asked && sceneDone(now.anim, now.ms)) {
50 now.asked = true
51 surface.post({ next: now.scene } satisfies NextMessage)
52 }
53 surface.setState({ ...now })
54}
55
56function draw(surface: ClientSurface<Live>, frame: Frame): RenderElement {
57 const { Box, Text } = surface.elements
58 return (
59 <Box flexDirection="column">
60 {frame.map((row, y) => (
61 <Text key={String(y)} wrap="truncate">
62 {runsOf(row).map((run, i) => (run.color === '' ? run.text : <Text key={String(i)} color={run.color}>{run.text}</Text>))}
63 </Text>
64 ))}
65 </Box>
66 )
67}
68
69/** Draws one frame per tick; the tick only advances the animation and asks for the next call. */
70const Scene: ClientModule<SceneProps, Live> = (props, surface) => {
71 let live = surface.state
72 if (live === undefined || live.key !== keyOf(props)) {
73 const fresh = start(props)
74 if (fresh === undefined) return surface.elements.Text({ children: '' })
75 // The first call starts the clock; later calls reach here only on new props.
76 if (live === undefined) surface.every(TICK_MS, () => tick(surface))
77 live = fresh
78 surface.setState(live)
79 }
80 return draw(surface, live.anim.frame())
81}
82
83export default Scene
84hooks/art/grid.ts 108 lines1/** One character cell of a frame; an empty `color` draws in the terminal's default colour. */
2export type Cell = { ch: string; color: string }
3
4/** A frame: `h` rows of `w` cells. */
5export type Frame = Cell[][]
6
7/** Consecutive cells of one colour, drawn as one Text. */
8export type Run = { text: string; color: string }
9
10/** A random source in [0, 1). */
11export type Rng = () => number
12
13/**
14 * One running animation: `step` advances it by `dtMs` milliseconds (STEP_MS when not given), `frame` draws
15 * where it stands. An animation with an end, a clip, says through `wrapped` whether the last step went from
16 * its last frame back to its first.
17 */
18export type Animation = { step: (dtMs?: number) => void; frame: () => Frame; wrapped?: () => boolean }
19
20/** The step every scene's speeds are written for: a speed of 1 is one cell per STEP_MS. */
21export const STEP_MS = 100
22
23/** A step of `dtMs` in units of STEP_MS, the factor a per-step speed is multiplied by. */
24export function unitsOf(dtMs: number | undefined): number {
25 return (dtMs ?? STEP_MS) / STEP_MS
26}
27
28/**
29 * Runs `fn` once for every whole STEP_MS the steps have added up to, for a scene that changes in whole steps
30 * (a fire's heat, a board's generation) and keeps its pace at any frame rate.
31 */
32export function everyStep(fn: () => void): (dtMs?: number) => void {
33 let owed = 0
34 return dtMs => {
35 owed += dtMs ?? STEP_MS
36 for (; owed >= STEP_MS; owed -= STEP_MS) fn()
37 }
38}
39
40/** Starts an animation for a region of `w` columns and `h` rows. */
41export type Maker = (w: number, h: number, rng: Rng) => Animation
42
43export const BLANK: Cell = { ch: ' ', color: '' }
44
45/** How long a scene runs before `random` moves to another in the same turn. */
46export const SCENE_MS = 20_000
47
48/**
49 * Whether a scene that has run `ms` has run its time: a built-in scene after SCENE_MS, a clip at the first
50 * end of a loop from then on, so a long clip plays through once and a short one repeats until then.
51 */
52export function sceneDone(anim: Animation, ms: number): boolean {
53 return ms >= SCENE_MS && (anim.wrapped === undefined || anim.wrapped())
54}
55
56/** A seeded generator (mulberry32), so a test replays the same frames. */
57export function rngOf(seed: number): Rng {
58 let a = seed >>> 0
59 return () => {
60 a = (a + 0x6d2b79f5) >>> 0
61 let t = a
62 t = Math.imul(t ^ (t >>> 15), t | 1)
63 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
64 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
65 }
66}
67
68/** A whole number in [0, n). */
69export function below(rng: Rng, n: number): number {
70 return Math.floor(rng() * n)
71}
72
73/** One item of a list, picked at random. */
74export function pick<T>(rng: Rng, items: readonly T[]): T {
75 return items[below(rng, items.length)] as T
76}
77
78export function blankFrame(w: number, h: number): Frame {
79 return Array.from({ length: h }, () => Array.from({ length: w }, () => BLANK))
80}
81
82/** Writes `text` into a row from column `x`, cutting what falls outside the frame. */
83export function put(frame: Frame, x: number, y: number, text: string, color: string): void {
84 const row = frame[y]
85 if (row === undefined) return
86 const chars = [...text]
87 for (let i = 0; i < chars.length; i++) {
88 const col = x + i
89 if (col >= 0 && col < row.length) row[col] = { ch: chars[i] as string, color }
90 }
91}
92
93/** A row as runs of one colour, so a frame draws as few Text elements. */
94export function runsOf(row: readonly Cell[]): Run[] {
95 const runs: Run[] = []
96 for (const cell of row) {
97 const last = runs[runs.length - 1]
98 if (last !== undefined && last.color === cell.color) last.text += cell.ch
99 else runs.push({ text: cell.ch, color: cell.color })
100 }
101 return runs
102}
103
104/** A frame as plain text, one line per row. */
105export function textOf(frame: Frame): string {
106 return frame.map(row => row.map(c => c.ch).join('')).join('\n')
107}
108hooks/art/scenes.ts 9 lines1import { aquarium } from './aquarium.ts'
2import { cat } from './cat.ts'
3import { fire } from './fire.ts'
4import type { Maker } from './grid.ts'
5import { matrix } from './matrix.ts'
6import type { Style } from '../config.ts'
7
8export const SCENES: Record<Style, Maker> = { matrix, fire, aquarium, cat }
9hooks/art/aquarium.ts 93 lines1import { below, blankFrame, pick, put, unitsOf, type Animation, type Frame, type Rng } from './grid.ts'
2
3/** Each fish as it looks swimming right, and the same fish swimming left. */
4const SHAPES: readonly { right: string; left: string }[] = [
5 { right: '><>', left: '<><' },
6 { right: '><(((°>', left: '<°)))><' },
7 { right: '>=>', left: '<=<' },
8 { right: '><((°>', left: '<°))><' },
9]
10const FISH_COLORS = ['#ffa94d', '#ffd43b', '#66d9e8', '#f783ac', '#b197fc']
11const BUBBLE = '#a5d8ff'
12const WEED = ['#2f9e44', '#51cf66']
13const SAND = '#c9a66b'
14const SURFACE = '#339af0'
15
16type Fish = { x: number; y: number; dir: 1 | -1; speed: number; shape: number; color: string }
17type Bubble = { x: number; y: number }
18type Weed = { x: number; height: number; phase: number }
19
20/** Ticks between two sways of the seaweed. */
21const SWAY_TICKS = 6
22
23function newFish(rng: Rng, w: number, h: number, anywhere: boolean): Fish {
24 const dir = rng() < 0.5 ? 1 : -1
25 const shape = below(rng, SHAPES.length)
26 const len = (SHAPES[shape]?.right.length ?? 3)
27 const edge = dir === 1 ? -len : w
28 return { x: anywhere ? below(rng, w) : edge, y: 1 + below(rng, Math.max(1, h - 2)), dir, speed: 0.3 + rng() * 0.6, shape, color: pick(rng, FISH_COLORS) }
29}
30
31function fishText(f: Fish): string {
32 const s = SHAPES[f.shape] ?? { right: '><>', left: '<><' }
33 return f.dir === 1 ? s.right : s.left
34}
35
36function isGone(f: Fish, w: number): boolean {
37 return f.dir === 1 ? f.x > w : f.x + fishText(f).length < 0
38}
39
40/** The glyph of a bubble: it grows as it rises. */
41function bubbleGlyph(y: number, h: number): string {
42 if (y > h * 0.66) return '.'
43 return y > h * 0.33 ? 'o' : 'O'
44}
45
46/** The water's surface, its ripple moving one cell each sway. */
47function surfaceRow(w: number, shift: number): string {
48 return Array.from({ length: w }, (_, x) => ((x + shift) % 4 === 0 ? '-' : '~')).join('')
49}
50
51/**
52 * An aquarium: fish crossing both ways, bubbles rising from them and from the sand, seaweed swaying on the
53 * sand, and the water's surface rippling on the top row.
54 */
55export function aquarium(w: number, h: number, rng: Rng): Animation {
56 const fish = Array.from({ length: Math.max(3, Math.floor(w / 7)) }, () => newFish(rng, w, h, true))
57 const bubbles: Bubble[] = []
58 const weeds: Weed[] = Array.from({ length: Math.max(3, Math.floor(w / 5)) }, () => ({ x: below(rng, w), height: 1 + below(rng, Math.max(1, h - 3)), phase: below(rng, 2) }))
59 const sand = Array.from({ length: w }, () => pick(rng, ['.', ',', '_', '.', ' ']))
60 let tick = 0
61
62 const step = (dtMs?: number): void => {
63 const u = unitsOf(dtMs)
64 tick += u
65 fish.forEach((f, i) => {
66 f.x += f.dir * f.speed * u
67 if (isGone(f, w)) fish[i] = newFish(rng, w, h, false)
68 else if (rng() < 0.05 * u) bubbles.push({ x: Math.round(f.dir === 1 ? f.x + fishText(f).length : f.x - 1), y: f.y })
69 })
70 if (rng() < (w / 40) * 0.3 * u) bubbles.push({ x: below(rng, w), y: h - 2 })
71 for (let i = bubbles.length - 1; i >= 0; i--) {
72 const b = bubbles[i] as Bubble
73 b.y -= 0.3 * u
74 if (b.y < 1) bubbles.splice(i, 1)
75 }
76 }
77
78 const frame = (): Frame => {
79 const out = blankFrame(w, h)
80 put(out, 0, h - 1, sand.join(''), SAND)
81 put(out, 0, 0, surfaceRow(w, Math.floor(tick / SWAY_TICKS)), SURFACE)
82 const sway = Math.floor(tick / SWAY_TICKS)
83 for (const wd of weeds) {
84 for (let k = 0; k < wd.height; k++) put(out, wd.x, h - 2 - k, (k + wd.phase + sway) % 2 === 0 ? '(' : ')', WEED[k % 2] as string)
85 }
86 for (const b of bubbles) put(out, b.x, Math.floor(b.y), bubbleGlyph(b.y, h), BUBBLE)
87 for (const f of fish) put(out, Math.round(f.x), f.y, fishText(f), f.color)
88 return out
89 }
90
91 return { step, frame }
92}
93hooks/art/cat.ts 253 lines1import { blankFrame, put, unitsOf, type Animation, type Frame, type Rng } from './grid.ts'
2
3const FUR = '#ffb86b'
4const KITTEN_FUR = ['#e9ecef', '#adb5bd']
5const SPEECH = '#ffffff'
6const HEART = '#ff6b9d'
7const GRASS = '#51cf66'
8const YARN = '#ff6b6b'
9const BUTTERFLY = '#f783ac'
10
11/** The big cat's sprites are 5 rows tall, drawn with their last row just over the ground row. */
12const SPRITE_ROWS = 5
13/** The sitting cat's width, which places it in the middle. */
14const SIT_COLS = 13
15
16/** The big cat walking right, its head ahead and its tail behind, two steps. */
17const WALK = [
18 [' /\\_/\\', ' ______( o.o )', ' ~( )', ' (__________)', ' /\\ /\\ /\\ /\\'],
19 [' /\\_/\\', ' ______( o.o )', ' ~( )', ' (__________)', ' || || || ||'],
20]
21const WALK_COLS = 19
22
23/** The big cat sitting; `eyes` is three characters, its first and last the two eyes. */
24function sitting(eyes: string): string[] {
25 const [l = 'o', , r = 'o'] = [...eyes]
26 return [' /\\_____/\\', ` ( ${l} ${r} )`, ' ( = ^ = )', ' ) (', ' (__(_|_)__)~']
27}
28
29/** The big cat rolling on the ground: on one side, on its back with its paws up, on the other side, on its back. */
30const ROLL = [
31 ['', ' /\\_/\\', ' _____( -.- )', ' (____________)=~', " `----------'"],
32 [' |\\/| |\\/|', ' ( )~', ' ( ( o.o ) )', ' \\__________/', ''],
33 ['', ' /\\_/\\', ' ( -.- )_____', '~=(____________)', " `----------'"],
34 [' |\\/| |\\/|', ' ( )~', ' ( ( ^.^ ) )', ' \\__________/', ''],
35]
36
37/** A kitten walking right, two steps, 4 rows tall. */
38const KITTEN = [
39 [' /\\_/\\ ', ' __( o.o )', '~( ) ', ' /\\ /\\ '],
40 [' /\\_/\\ ', ' __( o.o )', '~( ) ', ' || || '],
41]
42const KITTEN_COLS = 10
43
44/** Cells a walking cat moves per tick. */
45const WALK_SPEED = 0.5
46
47const MIRROR: Record<string, string> = { '/': '\\', '\\': '/', '(': ')', ')': '(', '<': '>', '>': '<', '{': '}', '}': '{' }
48
49/** A sprite row seen from the other side. */
50export function mirrored(row: string, width: number): string {
51 return [...row.padEnd(width)].reverse().map(c => MIRROR[c] ?? c).join('')
52}
53
54/** One part of the show: how many ticks it lasts, and how its tick-th frame is drawn. */
55type Phase = { ticks: number; draw: (t: number, out: Frame) => void }
56
57/** The band, the sitting cat's column, the big cat's top row, and the ground row. */
58type Stage = { w: number; h: number; center: number; base: number; ground: number }
59
60function sprite(out: Frame, rows: readonly string[], x: number, y: number, color = FUR): void {
61 rows.forEach((row, i) => {
62 // Only the blanks around a row let what is behind it show, so a kitten is not cut by the cat's box,
63 // and does not show through the cat's body either.
64 const lead = row.length - row.trimStart().length
65 put(out, x + lead, y + i, row.trim(), color)
66 })
67}
68
69/**
70 * A speech bubble beside the cat's head, its tail pointing down to the head:
71 *
72 * .------.
73 * ( meow )
74 * '------'
75 * /
76 */
77export function bubbleRows(text: string): string[] {
78 const rule = '-'.repeat(text.length + 2)
79 return [` .${rule}.`, `( ${text} )`, ` '${rule}'`, '/']
80}
81
82function bubble(out: Frame, s: Stage, text: string): void {
83 bubbleRows(text).forEach((row, i) => put(out, s.center + SIT_COLS + 1, s.base - 2 + i, row, SPEECH))
84}
85
86function walkPhase(s: Stage, from: number, to: number): Phase {
87 const ticks = Math.max(1, Math.ceil(Math.abs(to - from) / WALK_SPEED))
88 return { ticks, draw: (t, out) => sprite(out, WALK[Math.floor(t / 2) % 2] as string[], Math.round(from + t * WALK_SPEED), s.base) }
89}
90
91function sitPhase(s: Stage): Phase {
92 // Two blinks.
93 const blinking = (t: number): boolean => (t >= 8 && t < 10) || (t >= 18 && t < 20)
94 return { ticks: 25, draw: (t, out) => sprite(out, sitting(blinking(t) ? '-.-' : 'o.o'), s.center, s.base) }
95}
96
97function meowPhase(s: Stage, text: string, eyes: string): Phase {
98 return {
99 ticks: 20,
100 draw: (_, out) => {
101 sprite(out, sitting(eyes), s.center, s.base)
102 bubble(out, s, text)
103 },
104 }
105}
106
107/** Rolls three cells right and back, one pose every two ticks. */
108function rollPhase(s: Stage): Phase {
109 const drift = [0, 1, 2, 3, 3, 2, 1, 0]
110 return { ticks: 48, draw: (t, out) => sprite(out, ROLL[Math.floor(t / 2) % ROLL.length] as string[], s.center + (drift[Math.floor(t / 6) % drift.length] ?? 0), s.base) }
111}
112
113/** Three jumps. */
114function jumpPhase(s: Stage): Phase {
115 const lift = [0, 1, 2, 2, 1, 0, 0, 0]
116 return {
117 ticks: 24,
118 draw: (t, out) => {
119 const up = lift[Math.floor(t) % lift.length] ?? 0
120 sprite(out, sitting(up === 0 ? 'o.o' : '^o^'), s.center, s.base - up)
121 },
122 }
123}
124
125/** Purring, with a heart floating up from its head. */
126function purrPhase(s: Stage): Phase {
127 return {
128 ticks: 32,
129 draw: (t, out) => {
130 sprite(out, sitting('^.^'), s.center, s.base)
131 bubble(out, s, 'purr~')
132 put(out, s.center + 6, s.base - 1 - Math.floor(t / 8), '♥', HEART)
133 },
134 }
135}
136
137/** Looking left and right, twice. */
138function lookPhase(s: Stage): Phase {
139 const eyes = ['o.o', 'O.o', 'O.o', 'o.o', 'o.O', 'o.O']
140 return { ticks: 30, draw: (t, out) => sprite(out, sitting(eyes[Math.floor(t / 2.5) % eyes.length] as string), s.center, s.base) }
141}
142
143/** The tricks in the middle of the show, in a new order each time round. */
144function tricks(s: Stage, rng: Rng): Phase[] {
145 const all = [rollPhase(s), jumpPhase(s), purrPhase(s), lookPhase(s)]
146 for (let i = all.length - 1; i > 0; i--) {
147 const j = Math.floor(rng() * (i + 1))
148 ;[all[i], all[j]] = [all[j] as Phase, all[i] as Phase]
149 }
150 return all
151}
152
153/** One round: walk in to the middle, sit and blink, meow, the tricks, meow again, walk out. */
154function show(s: Stage, rng: Rng): Phase[] {
155 return [
156 walkPhase(s, -WALK_COLS, s.center),
157 sitPhase(s),
158 meowPhase(s, 'meow', 'o.o'),
159 ...tricks(s, rng),
160 meowPhase(s, 'MEOW!', '>.<'),
161 walkPhase(s, s.center, s.w),
162 ]
163}
164
165/** Where a thing that walks back and forth over `span` cells stands at `clock`, and which way it faces. */
166export function bounce(start: number, speed: number, span: number, clock: number): { x: number; dir: 1 | -1 } {
167 const period = 2 * Math.max(1, span)
168 const p = (((start + speed * clock) % period) + period) % period
169 return p < span ? { x: Math.round(p), dir: 1 } : { x: Math.round(period - p), dir: -1 }
170}
171
172/** The ground: grass tufts over the band's last row. */
173function groundRow(w: number, rng: Rng): string {
174 return Array.from({ length: w }, () => ['_', '_', ',', '"', '_', '.'][Math.floor(rng() * 6)] as string).join('')
175}
176
177/**
178 * Two kittens playing back and forth on the ground, each facing the way it walks: one left of the place the
179 * big cat sits, one right of it, so they never walk into each other or over the sitting cat.
180 */
181function kittens(out: Frame, s: Stage, clock: number): void {
182 const yards = [{ from: 0, to: s.center - KITTEN_COLS - 1, speed: 0.3 }, { from: s.center + SIT_COLS + 1, to: s.w - KITTEN_COLS, speed: 0.45 }]
183 yards.forEach((yard, i) => {
184 const span = yard.to - yard.from
185 if (span < 2) return
186 const { x, dir } = bounce(span * 0.4, yard.speed, span, clock)
187 const rows = KITTEN[Math.floor(clock / 2) % 2] as string[]
188 sprite(out, dir === 1 ? rows : rows.map(r => mirrored(r, KITTEN_COLS)), yard.from + x, s.ground - 4, KITTEN_FUR[i])
189 })
190}
191
192/** A ball of yarn rolling back and forth on the ground, its loose thread behind it. */
193function yarn(out: Frame, s: Stage, clock: number): void {
194 const { x, dir } = bounce(s.w * 0.6, 0.35, Math.max(1, s.w - 1), clock)
195 put(out, x - dir * 2, s.ground - 1, '~', YARN)
196 put(out, x - dir, s.ground - 1, '~', YARN)
197 put(out, x, s.ground - 1, '@', YARN)
198}
199
200/** A butterfly drifting across the top rows, its wings beating. */
201function butterfly(out: Frame, s: Stage, clock: number): void {
202 const x = Math.floor((clock * 0.4) % (s.w + 4)) - 2
203 const y = Math.round(1 + Math.sin(clock / 5))
204 put(out, x, y, Math.floor(clock / 3) % 2 === 0 ? '}{' : ')(', BUTTERFLY)
205}
206
207/**
208 * A cat and its garden: the big cat walks in slowly to the middle of the band, sits and blinks, says meow,
209 * rolls on the ground, jumps, purrs with a heart, looks about, says MEOW and walks out, then comes round
210 * again, while two kittens play on the grass, a ball of yarn rolls and a butterfly drifts over them.
211 */
212export function cat(w: number, h: number, rng: Rng): Animation {
213 const ground = h - 1
214 const s: Stage = { w, h, center: Math.max(0, Math.floor((w - SIT_COLS) / 2)), base: ground - SPRITE_ROWS, ground }
215 const grass = groundRow(w, rng)
216 let phases = show(s, rng)
217 let index = 0
218 let t = 0
219 let clock = 0
220 let wrapped = false
221
222 // `t` counts steps of STEP_MS within the phase, with a fraction between them, so a walk moves on every frame.
223 // A round that ends says so through `wrapped`, so `random` moves on only once the cat has walked out.
224 const step = (dtMs?: number): void => {
225 const u = unitsOf(dtMs)
226 t += u
227 clock += u
228 wrapped = false
229 for (let ticks = phases[index]?.ticks ?? 0; t >= ticks; ticks = phases[index]?.ticks ?? 0) {
230 t -= ticks
231 index += 1
232 if (index >= phases.length) {
233 index = 0
234 phases = show(s, rng)
235 wrapped = true
236 }
237 }
238 }
239
240 const frame = (): Frame => {
241 const out = blankFrame(w, h)
242 put(out, 0, ground, grass, GRASS)
243 kittens(out, s, clock)
244 butterfly(out, s, clock)
245 phases[index]?.draw(t, out)
246 // The yarn rolls in front, at the cats' feet, so no cat hides it.
247 yarn(out, s, clock)
248 return out
249 }
250
251 return { step, frame, wrapped: () => wrapped }
252}
253hooks/art/fire.ts 54 lines1import { below, blankFrame, everyStep, type Animation, type Frame, type Rng } from './grid.ts'
2
3/** Glyphs and colours from the coolest visible heat to the hottest. */
4const GLYPHS = [...'.,:;+*oO#@']
5const COLORS = ['#4a0d00', '#7a1a00', '#a82800', '#d23c00', '#ef6a00', '#ff9200', '#ffb52e', '#ffd75e', '#ffec9e', '#fff8dc']
6
7const MAX = 1
8
9/** The heat a column's source gives: full in the middle, falling off toward both edges. */
10function sourceHeat(x: number, w: number, rng: Rng): number {
11 const edge = Math.min(x + 1, w - x) / Math.max(1, w / 6)
12 return Math.min(1, edge) * (0.55 + rng() * 0.45)
13}
14
15/**
16 * A fire: heat rises from a hidden source row, each cell taking the heat of a cell
17 * below it (drifting one column at most) less a random cooling sized so flames
18 * reach about the top of the region.
19 */
20export function fire(w: number, h: number, rng: Rng): Animation {
21 // Row h is the hidden source; rows 0..h-1 are drawn.
22 const heat = Array.from({ length: h + 1 }, () => new Array<number>(w).fill(0))
23 const cooling = (2 * MAX) / h
24
25 // The heat changes in whole steps, ten a second, at any frame rate: the flicker the person liked.
26 const step = everyStep((): void => {
27 const source = heat[h] as number[]
28 for (let x = 0; x < w; x++) source[x] = sourceHeat(x, w, rng)
29 for (let y = 0; y < h; y++) {
30 const row = heat[y] as number[]
31 const under = heat[y + 1] as number[]
32 for (let x = 0; x < w; x++) {
33 const from = Math.min(w - 1, Math.max(0, x + below(rng, 3) - 1))
34 row[x] = Math.max(0, (under[from] ?? 0) - rng() * cooling)
35 }
36 }
37 })
38
39 const frame = (): Frame => {
40 const out = blankFrame(w, h)
41 for (let y = 0; y < h; y++) {
42 for (let x = 0; x < w; x++) {
43 const v = heat[y]?.[x] ?? 0
44 if (v < 0.08) continue
45 const i = Math.min(GLYPHS.length - 1, Math.floor(v * GLYPHS.length))
46 ;(out[y] as Frame[number])[x] = { ch: GLYPHS[i] as string, color: COLORS[i] as string }
47 }
48 }
49 return out
50 }
51
52 return { step, frame }
53}
54hooks/art/matrix.ts 65 lines1import { below, blankFrame, everyStep, pick, unitsOf, type Animation, type Frame, type Rng } from './grid.ts'
2
3/** Half-width katakana and digits: one cell wide each. */
4const GLYPHS = [...'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789']
5
6const HEAD = '#e6ffe6'
7/** The trail from just behind the head to its faded end. */
8const TRAIL = ['#5dff7a', '#1fd14a', '#12a03a', '#0b6e28', '#06451a']
9
10/** One falling stream: its head row (fractional), rows per tick and trail length. */
11type Drop = { y: number; speed: number; len: number }
12
13function newDrop(rng: Rng, h: number, fresh: boolean): Drop {
14 const len = 3 + below(rng, Math.max(2, h))
15 // A fresh start spreads the streams over the region; a respawn starts above it.
16 const y = fresh ? below(rng, h + len) - len : -below(rng, h * 2) - 1
17 return { y, speed: 0.25 + rng() * 0.6, len }
18}
19
20function trailColor(dist: number, len: number): string {
21 const i = Math.min(TRAIL.length - 1, Math.floor((dist / len) * TRAIL.length))
22 return TRAIL[i] as string
23}
24
25/** Matrix rain: one stream in every other column, glyphs flickering under it. */
26export function matrix(w: number, h: number, rng: Rng): Animation {
27 const glyphs = Array.from({ length: h }, () => Array.from({ length: w }, () => pick(rng, GLYPHS)))
28 const drops: (Drop | null)[] = Array.from({ length: w }, (_, x) => (x % 2 === 0 ? newDrop(rng, h, true) : null))
29
30 // A few glyphs change each step, so a still trail shimmers at the same pace at any frame rate.
31 const shimmer = everyStep(() => {
32 for (let i = 0; i < Math.ceil((w * h) / 30); i++) {
33 const row = glyphs[below(rng, h)]
34 if (row !== undefined) row[below(rng, w)] = pick(rng, GLYPHS)
35 }
36 })
37
38 const step = (dtMs?: number): void => {
39 const u = unitsOf(dtMs)
40 drops.forEach((d, x) => {
41 if (d === null) return
42 d.y += d.speed * u
43 if (d.y - d.len > h) drops[x] = newDrop(rng, h, false)
44 })
45 shimmer(dtMs)
46 }
47
48 const frame = (): Frame => {
49 const out = blankFrame(w, h)
50 drops.forEach((d, x) => {
51 if (d === null) return
52 const head = Math.floor(d.y)
53 for (let dist = 0; dist < d.len; dist++) {
54 const y = head - dist
55 const row = out[y]
56 if (row === undefined) continue
57 row[x] = { ch: glyphs[y]?.[x] ?? ' ', color: dist === 0 ? HEAD : trailColor(dist, d.len) }
58 }
59 })
60 return out
61 }
62
63 return { step, frame }
64}
65