A Clawd-style pixel creature above the prompt that reacts to your prompts and to what Claude is doing, and evolves as the context fills.

Three mods for Claude Code, made by Sgript. Built and tested on Claude Code 2.1.296.
| Mod | What it does |
|---|---|
| sprout | A pixel creature above the prompt that acts out what Claude is doing: thinking, reading, writing, running commands, waiting on you. It evolves as the session's context window fills (under 40%, 40 to 75%, over 75%) and drops back after /compact. Choose who it is with /avatar. |
| caffeinate | Keeps the Mac awake while a turn runs longer than a minute or background work is going. /caffeinate shows what it's doing; /caffeinate on, off or auto changes it. macOS only. |
| session-hub | A Sessions pane listing every open Claude Code session, ordered by who needs you. /sessions opens it; /sessions 3 jumps to the third; /sessions new starts one. Jumping to a session needs the Warp terminal. |
Sprout works on its own. With caffeinate installed it shows a coffee cup while the Mac is held awake; with session-hub it signals when another session needs you or has finished.
Sprout can be any of three lines, each with three forms:
/avatar | Forms | Gets about by | Attacks |
|---|---|---|---|
toad | Sprout, Bud, Bloom | walking | Vine Lash, Leaf Volley, Sunbeam |
lizard | Spark, Blaze, Inferno | walking side-on | Ember, Flamethrower, Fire Burst |
fox | Doze, Spoon, Sage | teleporting | Teleport, Mind Beam, Mind Wave |
Run /avatar on its own to pick from a list, or name one: /avatar lizard (any form's name works too, such as /avatar inferno). The choice is kept for every session after. The toad line is four terminal rows tall; the other two are six.
Open docs/sprite-lab.html in a browser to see every animation for every form, a tab per line.
Start Claude Code in a terminal (the install command doesn't work in the desktop app's Code tab), add this repo as a marketplace, then install the mods you want:
/plugin marketplace add sgript-labs/claude-mods
/plugin install sprout@sgript-labs
/plugin install caffeinate@sgript-labs
/plugin install session-hub@sgript-labs
Choose the user scope when asked. Sprout appears above the prompt straight away. Installed at the user scope, the mods also run in new desktop app sessions.
From a shell, the same is:
claude plugin marketplace add sgript-labs/claude-mods
claude plugin install sprout@sgript-labs
To remove one, run /plugin and uninstall it from there.
Clone the repo, then point Claude Code at the mod folders by setting CLAUDE_CODE_PLUGIN_DIRS under env in ~/.claude/settings.json:
"CLAUDE_CODE_PLUGIN_DIRS": "~/repos/claude-mods/sprout:~/repos/claude-mods/caffeinate:~/repos/claude-mods/session-hub"
Terminal sessions reload a mod when its files change; desktop app sessions need a new session.
Run a mod's tests, and check its manifest:
claude plugin test ./sprout
claude plugin validate --strict ./sprout
After changing Sprout's sprites, rebuild the sprite lab:
bun sprout/tools/gallery.ts docs/sprite-lab.html
Every name Sprout shows (lines, forms and moves) is in sprout/hooks/names.ts. The evolution thresholds are SECOND_AT and THIRD_AT in sprout/hooks/evolve.ts.
MIT, © 2026 Sgript.
hooks/register.tsx 409 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Activity, ActivityKind, Avatar, Engine, Reaction, Stage } from '../types'
5import { AVATARS, avatarCells, heightOf, homeOf, isAvatar, rowsOf, stepWalk, strideOf } from './avatar'
6import { cupOf } from './cup'
7import { nudgeOf } from './hub'
8import type { Nudge } from './hub'
9import { canonicalModel, effortMeter, modelLabel, settingsEffort } from './engine'
10import { EVOLVE_MS, evolutionToast, stageFor } from './evolve'
11import { LISTEN_FOR_MS, SLEEP_AFTER_MS, afterTurn, lookAt, promptReaction, toolActivity } from './mood'
12import { flipbook } from './flipbook'
13import { NAMES, formName, withArticle } from './names'
14import { FRAME_MS, MIN_WIDTH, SVG_SCALE } from './sprite'
15import type { Walk } from './walk'
16
17const AVATAR = 'sprout'
18/** Columns the model and effort label takes at the band's right end. */
19const LABEL_W = 16
20const ENGINE_EVERY = 20
21/** The widest strip Sprout walks where it is drawn as an SVG. */
22const SVG_MAX_W = 60
23
24const activity = atom({ plugin: 'sprout', key: 'activity' } as const, { kind: 'idle', since: 0 })
25const reaction = atom({ plugin: 'sprout', key: 'reaction' } as const, null)
26const engine = atom({ plugin: 'sprout', key: 'engine' } as const, { model: '', effort: '' })
27const stageAtom = atom({ plugin: 'sprout', key: 'stage' } as const, 0)
28const avatarAtom = atom({ plugin: 'sprout', key: 'avatar' } as const, 'toad')
29// The caffeinate mod's hold, read for the coffee cup above the label; never written here.
30const caffeine = { plugin: 'caffeinate', key: 'caffeine' } as const
31// The session hub's counts, read for the nudge when another session wants you; never written here.
32const hubCounts = { plugin: 'session-hub', key: 'counts' } as const
33
34// The frame clock runs eight times a second, so it reads these mirrors, not
35// the host's state. This module is the values' only writer, so the mirrors
36// cannot drift, and a reload reads them back from the state.
37let act: Activity = { kind: 'idle', since: 0 }
38let react: Reaction | null = null
39let tick = 0
40let bandId: string | undefined
41let width = MIN_WIDTH
42// Placed at home, the strip's right end, on the first draw (x -1 until then).
43let walk: Walk = { x: -1, target: -1, facing: -1 }
44let isAnimating = true
45let isDrawing = false
46let lastCells = ''
47let lastEditAt = 0
48let isWorking = false
49// Claude ended its turn with a question: Sprout waits on the answer until the
50// person sends one, through any typing they do and undo meanwhile.
51let isAsked = false
52let streaming: 'thinking' | 'talking' | undefined
53// What the last model request of a turn ran on: the model as resolved, and
54// its effort, which the settings may not show (an /effort for this session).
55let stepModel: string | undefined
56let stepEffort: { model: string; effort: string } | undefined
57let shownEngine: Engine = { model: '', effort: '' }
58let nudge: Nudge = 'none'
59// The line Sprout is drawn as (/avatar, kept between sessions) and how far
60// along it the context fill has him, mirrored for the frame clock as the others are.
61let avatar: Avatar = 'toad'
62let stage: Stage = 0
63
64function loopOf(e: object): string | undefined {
65 const { agentId } = e as { agentId?: unknown }
66
67 return typeof agentId === 'string' ? agentId : undefined
68}
69
70async function setActivity($: EngineInterface, kind: ActivityKind): Promise<void> {
71 if (act.kind === kind) return
72 act = { kind, since: await $.clock.now() }
73 const next = act
74 await update($, activity, () => next)
75}
76
77async function setReaction($: EngineInterface, next: Reaction | null): Promise<void> {
78 react = next
79 await update($, reaction, () => next)
80}
81
82// The live model, as /model shows it (a bare alias falls back to the model the
83// last request resolved), and its effort: the last request's, else the settings'.
84async function refreshEngine($: EngineInterface): Promise<void> {
85 const live = await $.session.model()
86 const model = /\d/.test(live) ? live : (stepModel ?? live)
87 const effort =
88 stepEffort && stepEffort.model === canonicalModel(model)
89 ? stepEffort.effort
90 : settingsEffort((await $.settings.read()) as Record<string, unknown>, model)
91 if (shownEngine.model === model && shownEngine.effort === effort) return
92 shownEngine = { model, effort }
93 const next = shownEngine
94 await update($, engine, () => next)
95}
96
97// The context fill picks Sprout's stage. A change while the session runs plays
98// the evolution and says why; one found at the start (a resumed session) is
99// taken as it is.
100async function setContext($: EngineInterface, percent: number | undefined, isQuiet = false): Promise<void> {
101 const next = stageFor(percent)
102 if (next === stage) return
103 const from = { avatar, stage }
104 stage = next
105 await update($, stageAtom, () => next)
106 if (isQuiet) return
107 await setReaction($, { kind: 'evolving', until: (await $.clock.now()) + EVOLVE_MS, from })
108 $.ui.toast(evolutionToast(avatar, from.stage, next, percent))
109}
110
111// A new avatar flashes in as an evolution does, from the old one, at the same
112// stage; it is kept for every session after.
113async function setAvatar($: EngineInterface, next: Avatar): Promise<void> {
114 if (next === avatar) return
115 const from = { avatar, stage }
116 avatar = next
117 await update($, avatarAtom, () => next)
118 await $.store.set('avatar', next)
119 await setReaction($, { kind: 'evolving', until: (await $.clock.now()) + EVOLVE_MS, from })
120}
121
122/** The avatar a word names: its id, its line, or one of its forms (`inferno` is the Lizard line). */
123function avatarNamed(word: string): Avatar | undefined {
124 const said = word.trim().toLowerCase()
125 if (said === '') return undefined
126 return AVATARS.find(
127 id => id === said || NAMES[id].line.toLowerCase() === said || NAMES[id].forms.some(name => name.toLowerCase() === said),
128 )
129}
130
131// One frame: lapse what has run its time, take a step, then repaint the strip
132// when the picture changed. A refused blit (the band collapsed, a survey up,
133// a surface with no Raster) stops the clock's painting until the next draw.
134async function frame($: EngineInterface): Promise<void> {
135 const now = await $.clock.now()
136 if (react && now >= react.until) await setReaction($, null)
137 if (act.kind === 'listening' && now - lastEditAt > LISTEN_FOR_MS) await setActivity($, isAsked ? 'awaiting' : 'idle')
138 if (act.kind === 'idle' && act.since > 0 && now - act.since > SLEEP_AFTER_MS) await setActivity($, 'sleeping')
139 if (!isAnimating || bandId === undefined) return
140
141 tick += 1
142 if (tick % ENGINE_EVERY === 0) {
143 await refreshEngine($)
144 nudge = nudgeOf((await $.state.get(hubCounts).catch(() => undefined))?.value)
145 }
146 const look = lookAt(act, react, now)
147 walk = stepWalk(avatar, walk, look, tick, homeOf(width, avatar, stage), Math.random(), Math.random())
148 const cells = avatarCells(avatar, stage, width, walk.x, look, tick, strideOf(walk), nudge, react?.from)
149 if (cells === lastCells) return
150 lastCells = cells
151 const painted = await $.ui
152 .blit({ requestId: bandId, key: AVATAR, cells, columns: width, rows: rowsOf(avatar) })
153 .catch(() => ({ deny: 'failed' }))
154 if (painted.deny) isAnimating = false
155}
156
157export const register: Register = on => {
158 on('session.start', async ($, e, next) => {
159 const started = await next(e)
160 act = await read($, activity)
161 react = await read($, reaction)
162 isAsked = act.kind === 'awaiting'
163 if (act.since === 0) {
164 act = { kind: 'idle', since: await $.clock.now() }
165 const fresh = act
166 await update($, activity, () => fresh)
167 }
168 shownEngine = await read($, engine)
169 await refreshEngine($)
170 const kept = await $.store.get('avatar')
171 avatar = isAvatar(kept) ? kept : 'toad'
172 const chosen = avatar
173 await update($, avatarAtom, () => chosen)
174 stage = await read($, stageAtom)
175 await setContext($, (await $.session.usage()).context.percent, true)
176 await $.command.register({
177 name: 'avatar',
178 description: 'Choose who Sprout is: the Toad, Lizard or Fox line',
179 argumentHint: '[toad|lizard|fox]',
180 })
181 $.clock.every(FRAME_MS, () => {
182 if (isDrawing) return
183 isDrawing = true
184 void frame($).finally(() => {
185 isDrawing = false
186 })
187 })
188
189 return started
190 })
191
192 // `/avatar lizard` switches at once; `/avatar` alone asks which, in the
193 // engine's own picker. Any form's name picks its line.
194 on('command.run', { command: 'avatar' }, async ($, e) => {
195 const options = AVATARS.map(id => `${NAMES[id].line}${id === avatar ? ' (now)' : ''}`)
196 const asked =
197 avatarNamed(e.args) ??
198 (e.args.trim() === ''
199 ? await $.ui
200 .ask('Which line should Sprout be?', { options, header: 'Avatar' })
201 .then(answer => avatarNamed(answer.replace(/ \(now\)$/, '')))
202 .catch(() => undefined)
203 : undefined)
204 if (!asked) return { text: `Sprout can be ${AVATARS.map(id => `/avatar ${id}`).join(', ')}.` }
205 if (asked === avatar) return { text: `Sprout is already the ${NAMES[asked].line}.` }
206 await setAvatar($, asked)
207
208 return { text: `Sprout is now ${withArticle(formName(asked, stage))}, of the ${NAMES[asked].line}.` }
209 })
210
211 // After each turn the engine measures the session: the context fill picks the stage.
212 on('session.measure', async ($, e, next) => {
213 const measured = await next(e)
214 if (e.changed.includes('context')) await setContext($, e.context.percent)
215
216 return measured
217 }).catch(($, e, next) => next(e))
218
219 // A compaction shrinks the context at once, before the next turn measures it.
220 on('session.compact', async ($, e, next) => {
221 const compacted = await next(e)
222 const after = 'skip' in compacted ? undefined : compacted.tokensAfter
223 if (e.agentId !== undefined || e.trigger === 'precompute' || after === undefined) return compacted
224 const { window } = (await $.session.usage()).context
225 if (window > 0) await setContext($, (after / window) * 100)
226
227 return compacted
228 }).catch(($, e, next) => next(e))
229
230 // Typing in an idle session: Sprout heads home and looks down at the prompt.
231 on('prompt.edit', async ($, e, next) => {
232 const edited = await next(e)
233 if (isWorking) return edited
234 lastEditAt = await $.clock.now()
235 const resting = isAsked ? 'awaiting' : 'idle'
236 await setActivity($, edited.text.trim() === '' ? resting : 'listening')
237
238 return edited
239 }).catch(($, e, next) => next(e))
240
241 on('prompt.submit', async ($, e, next) => {
242 isWorking = true
243 isAsked = false
244 await setReaction($, promptReaction(e.text, await $.clock.now()))
245 await setActivity($, 'thinking')
246
247 return next(e)
248 }).catch(($, e, next) => next(e))
249
250 on('turn.start', async ($, e, next) => {
251 isWorking = true
252 streaming = undefined
253 await setActivity($, 'thinking')
254
255 return next(e)
256 })
257
258 // As the reply streams: thinking blocks keep Sprout thinking, text has it talk.
259 on('turn.step', async function* ($, e, next) {
260 const isMain = loopOf(e) === undefined
261 if (isMain) {
262 stepModel = e.model
263 if (e.effort !== undefined) stepEffort = { model: canonicalModel(e.model), effort: String(e.effort) }
264 void refreshEngine($).catch(() => undefined)
265 }
266 for await (const chunk of next(e)) {
267 if (isMain && chunk.kind === 'thinking' && streaming !== 'thinking') {
268 streaming = 'thinking'
269 void setActivity($, 'thinking')
270 } else if (isMain && chunk.kind === 'text' && streaming !== 'talking') {
271 streaming = 'talking'
272 void setActivity($, 'talking')
273 }
274 yield chunk
275 }
276 })
277
278 on('tool.call', async ($, e, next) => {
279 if (loopOf(e) !== undefined) return next(e)
280 const doing = toolActivity(String(e.tool))
281 streaming = undefined
282 await setActivity($, doing)
283 const ran = await next(e)
284 if (ran.isError) await setReaction($, { kind: 'oops', until: (await $.clock.now()) + 1_500 })
285 if (act.kind === doing) await setActivity($, 'thinking')
286
287 return ran
288 }).catch(($, e, next) => next(e))
289
290 // A call that needs the person's say-so: Sprout waits with them.
291 on('tool.check', async ($, e, next) => {
292 const verdict = await next(e)
293 if (verdict.decision === 'ask' && loopOf(e) === undefined) await setActivity($, 'asking')
294
295 return verdict
296 }).catch(($, e, next) => next(e))
297
298 on('turn.complete', async ($, e, next) => {
299 const done = await next(e)
300 if (loopOf(e) !== undefined) return done
301 isWorking = false
302 streaming = undefined
303 const after = afterTurn(e.reason, done.text, await $.clock.now())
304 isAsked = after.activity === 'awaiting'
305 await setReaction($, after.reaction)
306 await setActivity($, after.activity)
307
308 return done
309 })
310
311 // Sprout stands on the band's bottom row, the line just above the prompt,
312 // in a strip as wide as the band so it has room to walk. At the strip's
313 // right end, just beside Sprout's home, sits the model and effort label:
314 // each half opens the matching built-in picker.
315 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
316 const drawnAvatar = await read($, avatarAtom)
317 const drawnStage = await read($, stageAtom)
318 if (e.props.hasSurvey || e.props.maxRows < rowsOf(drawnAvatar) || e.props.bodyColumns < MIN_WIDTH) return next(e)
319 const shownReaction = await read($, reaction)
320 const look = lookAt(await read($, activity), shownReaction, await $.clock.now())
321 const from = shownReaction?.from
322 const running = await read($, engine)
323 // Unset where caffeinate is not loaded, and so no cup.
324 const coffee = cupOf((await $.state.get(caffeine).catch(() => undefined))?.value)
325 const { Box, Button, Text } = $.ui.resolve(e)
326 // What other plugins draw in the band (the task and session lines) goes
327 // above the strip, so Sprout keeps the row just above the prompt.
328 const below = await next(e)
329 const stacked = (strip: RenderElement) =>
330 below.type === 'engine' ? (
331 strip
332 ) : (
333 <Box flexDirection="column">
334 {below}
335 {strip}
336 </Box>
337 )
338 const hasLabel = e.props.bodyColumns >= MIN_WIDTH + LABEL_W
339 const { meter, color } = effortMeter(running.effort)
340 const pick = (command: 'model' | 'effort') => () =>
341 void $.command
342 .run({ command })
343 .then(() => {
344 if (command === 'effort') stepEffort = undefined
345 return refreshEngine($)
346 })
347 .catch(() => undefined)
348 const label = hasLabel && (
349 <Box flexDirection="column" width={LABEL_W} paddingLeft={1}>
350 {coffee && (
351 <Text color={coffee.color} dimColor={coffee.isDim} wrap="truncate-end">
352 {coffee.text}
353 </Text>
354 )}
355 <Button key="model" plain onPress={pick('model')}>
356 <Text color="#5fb44a" bold>
357 {modelLabel(running.model)}
358 </Text>
359 </Button>
360 <Button key="effort" plain dimColor={running.effort === ''} onPress={pick('effort')}>
361 {running.effort || 'effort'} <Text color={color}>{meter}</Text>
362 </Button>
363 </Box>
364 )
365
366 // No Raster here: the strip is an SVG that plays its own loop of the
367 // terminal's walk and poses, so it moves with no redraws, and sits at the
368 // band's right end as the terminal's does.
369 if (e.surface !== 'terminal') {
370 isAnimating = false
371 const { Svg } = $.ui.resolve(e)
372 const stripW = Math.max(MIN_WIDTH, Math.min(SVG_MAX_W, e.props.bodyColumns - (hasLabel ? LABEL_W : 0)))
373 nudge = nudgeOf((await $.state.get(hubCounts).catch(() => undefined))?.value)
374 return stacked(
375 <Box flexDirection="row" alignItems="flex-end" justifyContent="flex-end" width="100%">
376 <Svg
377 source={flipbook(stripW, look, nudge, Math.random, drawnAvatar, drawnStage, from)}
378 alt={`Sprout the ${formName(drawnAvatar, drawnStage)}, ${look}`}
379 width={stripW * SVG_SCALE}
380 height={heightOf(drawnAvatar) * SVG_SCALE}
381 isInteractive
382 />
383 {label}
384 </Box>,
385 )
386 }
387
388 const { Raster } = $.ui.resolve(e)
389 bandId = e.requestId
390 // First drawn, or at home when the band changed width: stand at its new home.
391 const wasHome = walk.x < 0 || walk.x === homeOf(width, avatar, stage)
392 width = Math.min(512, e.props.bodyColumns - (hasLabel ? LABEL_W : 0))
393 avatar = drawnAvatar
394 stage = drawnStage
395 const home = homeOf(width, avatar, stage)
396 walk = wasHome ? { x: home, target: home, facing: -1 } : { x: Math.min(walk.x, home), target: Math.min(walk.target, home), facing: walk.facing }
397 isAnimating = true
398 nudge = nudgeOf((await $.state.get(hubCounts).catch(() => undefined))?.value)
399 lastCells = avatarCells(avatar, stage, width, walk.x, look, tick, strideOf(walk), nudge, from)
400
401 return stacked(
402 <Box flexDirection="row" alignItems="flex-end">
403 <Raster key={AVATAR} columns={width} rows={rowsOf(avatar)} cells={lastCells} />
404 {label}
405 </Box>,
406 )
407 })
408}
409hooks/avatar.ts 97 lines1import type { Avatar, Form, Stage } from '../types'
2import type { Nudge } from './hub'
3import { TALL_H, tallMoves, tallPixels, tallStill, tallWidth } from './lines'
4import type { Look } from './mood'
5import { Canvas, FORM_MOVES, PX_H, cellsOf, effectAt, stagePixels } from './sprite'
6import type { Stride } from './sprite'
7import { fadeOf, homeFor, nextTeleport, nextWalk } from './walk'
8import type { Walk } from './walk'
9
10// Which line Sprout is drawn as, and everything that depends on it: how tall
11// the strip is, where home is, how he gets about, and the pixels for a
12// moment. The Toad line is sprite.ts; the tall lines are lines.ts.
13
14export const AVATARS: readonly Avatar[] = ['toad', 'lizard', 'fox']
15const FORMS: readonly [Form, Form, Form] = ['sprout', 'bud', 'bloom']
16
17/** What Sprout changes from while evolving: an earlier stage, or the last avatar. */
18export type From = { avatar: Avatar; stage: Stage }
19
20export const isAvatar = (value: unknown): value is Avatar => AVATARS.includes(value as Avatar)
21
22export function formOf(stage: Stage): Form {
23 return FORMS[stage]
24}
25
26/** The moves a form attacks with, in turn, when another session has finished. */
27export function movesOf(avatar: Avatar, stage: Stage): readonly string[] {
28 return avatar === 'toad' ? FORM_MOVES[formOf(stage)] : tallMoves(avatar, stage)
29}
30
31/** Pixels down the strip: the Toad line 8, the tall lines 12. */
32export function heightOf(avatar: Avatar): number {
33 return avatar === 'toad' ? PX_H : TALL_H
34}
35
36/** Terminal rows the strip takes, two pixels a row. */
37export function rowsOf(avatar: Avatar): number {
38 return heightOf(avatar) / 2
39}
40
41/** Home on a strip this wide: the right end, the whole form on the strip. */
42export function homeOf(width: number, avatar: Avatar, stage: Stage): number {
43 return avatar === 'toad' ? homeFor(width, formOf(stage)) : Math.max(0, width - tallWidth(avatar, stage))
44}
45
46/** One tick of getting about: the Fox line teleports, the others walk. */
47export function stepWalk(avatar: Avatar, walk: Walk, look: Look, tick: number, home: number, chance: number, spot: number): Walk {
48 return avatar === 'fox' ? nextTeleport(walk, look, tick, home, chance, spot) : nextWalk(walk, look, tick, home, chance, spot)
49}
50
51/** How Sprout is moving, from his walk. */
52export function strideOf(walk: Walk): Stride {
53 const fade = fadeOf(walk)
54 return { isWalking: walk.x !== walk.target && walk.blink === undefined, facing: walk.facing, ...(fade === undefined ? {} : { fade }) }
55}
56
57/** Front-on and still, eyes closed: what an evolution's silhouette is cut from. */
58function stillOf(avatar: Avatar, stage: Stage, width: number, x: number): string[] {
59 // Tick 0 of Sprout's idle is a blink.
60 return avatar === 'toad' ? stagePixels(width, x, 'idle', 0, undefined, 'none', formOf(stage)) : tallStill(avatar, stage, width, x)
61}
62
63/** The strip's pixel rows for a moment: Sprout at `x` as `avatar` at `stage`. */
64export function avatarPixels(
65 avatar: Avatar,
66 stage: Stage,
67 width: number,
68 x: number,
69 look: Look,
70 tick: number,
71 stride: Stride = { isWalking: false, facing: 1 },
72 nudge: Nudge = 'none',
73 from?: From,
74): string[] {
75 if (look === 'evolving' && from && !(avatar === 'toad' && from.avatar === 'toad')) {
76 // A white silhouette flickering between what he was and what he is now,
77 // each the same distance from its own home, standing on the same line.
78 const height = heightOf(avatar)
79 const shown = Math.floor(tick / 2) % 2 === 0 ? from : { avatar, stage }
80 const offset = x - homeOf(width, avatar, stage)
81 const rows = stillOf(shown.avatar, shown.stage, width, homeOf(width, shown.avatar, shown.stage) + offset)
82 const c = new Canvas(width, height)
83 c.stamp(0, height - rows.length, rows.map(row => row.replace(/[^.]/g, 'W')))
84 const wide = avatar === 'toad' ? 10 : tallWidth(avatar, stage)
85 effectAt(c, 'sparkles', x, height - 8, tick, x + wide + 7 <= width ? 1 : -1, wide, height - 8)
86 return c.rows()
87 }
88 if (avatar === 'toad') return stagePixels(width, x, look, tick, stride, nudge, formOf(stage), from && formOf(from.stage))
89
90 return tallPixels(avatar, stage, width, x, look, tick, stride, nudge)
91}
92
93/** The same moment as a Raster's cells. */
94export function avatarCells(...args: Parameters<typeof avatarPixels>): string {
95 return cellsOf(avatarPixels(...args), args[2])
96}
97hooks/cup.ts 40 lines1/**
2 * The caffeinate mod's hold, as much of its contract as the cup draws. Sprout
3 * only reads it, and where caffeinate is not loaded nothing is ever written,
4 * so no cup shows.
5 */
6export type Caffeine = {
7 isAwake: boolean
8 minutes: number
9 source: 'ac' | 'battery' | 'unknown'
10 battery: number | null
11 isHeldBack: boolean
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 caffeinate: { caffeine: Caffeine }
17 }
18}
19
20const BROWN = '#c08552'
21
22export type Cup = { text: string; color: string; isDim: boolean }
23
24/**
25 * The line above the model label while the Mac is held awake: `☕ 12m`, with
26 * the charge on battery (`☕ 1h04m · 64%`), dimmed and paused when a low
27 * battery made caffeinate let go. Null while nothing is held.
28 */
29export function cupOf(caffeine: Caffeine | undefined): Cup | null {
30 if (!caffeine) return null
31 if (caffeine.isHeldBack) return { text: `☕ paused · ${caffeine.battery ?? '?'}%`, color: BROWN, isDim: true }
32 if (!caffeine.isAwake) return null
33 const { minutes } = caffeine
34 const time =
35 minutes < 1 ? '<1m' : minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
36 const charge = caffeine.source === 'battery' && caffeine.battery !== null ? ` · ${caffeine.battery}%` : ''
37
38 return { text: `☕ ${time}${charge}`, color: BROWN, isDim: false }
39}
40hooks/hub.ts 23 lines1/**
2 * The session hub's counts, as much of its contract as Sprout's nudge reads.
3 * Sprout only reads them; where session-hub is not loaded nothing is written,
4 * so there is no nudge.
5 */
6export type HubCounts = { needs: number; turn: number; working: number; idle: number; stale: number }
7
8declare module 'claude-code' {
9 interface PluginState {
10 'session-hub': { counts: HubCounts }
11 }
12}
13
14/** What another session wants from you, as Sprout signals it: a `!`, a ✓, or nothing. */
15export type Nudge = 'needs' | 'turn' | 'none'
16
17export function nudgeOf(counts: HubCounts | undefined): Nudge {
18 if (!counts) return 'none'
19 if (counts.needs > 0) return 'needs'
20
21 return counts.turn > 0 ? 'turn' : 'none'
22}
23hooks/engine.ts 41 lines1/** The levels `/effort` takes, lowest first. */
2export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
3
4const EFFORT_COLORS = ['#6cc4a1', '#5fb44a', '#ffd23f', '#f0913a', '#e53935'] as const
5
6/** `claude-opus-5-5[1m]` → `claude-opus-5-5`, the key settings file models under. */
7export function canonicalModel(id: string): string {
8 const found = /(opus|sonnet|haiku|fable)[- ]?(\d+)(?:[-.](\d+))?/i.exec(id)
9 if (!found) return id.trim()
10 const [, family = '', major = '', minor] = found
11
12 return `claude-${family.toLowerCase()}-${major}${minor ? `-${minor}` : ''}`
13}
14
15/** `claude-opus-5-5` → `Opus 5.5`; anything unrecognised as given. */
16export function modelLabel(id: string): string {
17 const found = /(opus|sonnet|haiku|fable)[- ]?(\d+)(?:[-.](\d+))?/i.exec(id)
18 if (!found) return id.trim() || 'model'
19 const [, family = '', major = '', minor] = found
20
21 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? `.${minor}` : ''}`
22}
23
24/** The effort the settings give a model: its own entry, else the top-level one. */
25export function settingsEffort(settings: Readonly<Record<string, unknown>>, model: string): string {
26 const perModel = settings.modelSettings as Record<string, { effortLevel?: unknown } | undefined> | undefined
27 const own = perModel?.[canonicalModel(model)]?.effortLevel
28 if (typeof own === 'string') return own
29 const top = settings.effortLevel
30
31 return typeof top === 'string' ? top : ''
32}
33
34/** A five-step meter for a level, and the colour it is drawn in. */
35export function effortMeter(effort: string): { meter: string; color: string } {
36 const step = EFFORTS.indexOf(effort as (typeof EFFORTS)[number])
37 if (step < 0) return { meter: '', color: '#9aa4ad' }
38
39 return { meter: '▮'.repeat(step + 1) + '▯'.repeat(EFFORTS.length - step - 1), color: EFFORT_COLORS[step] ?? '#9aa4ad' }
40}
41hooks/evolve.ts 27 lines1import type { Avatar, Stage } from '../types'
2import { formName, withArticle } from './names'
3
4/** Context fill, in percent of the model's window, at which Sprout evolves: to the second form, then the third. */
5export const SECOND_AT = 40
6export const THIRD_AT = 75
7/** How long the evolution plays. */
8export const EVOLVE_MS = 2_800
9
10/** The stage for a context fill; no reading yet is a fresh session. */
11export function stageFor(percent: number | undefined): Stage {
12 const fill = percent ?? 0
13 if (fill >= THIRD_AT) return 2
14
15 return fill >= SECOND_AT ? 1 : 0
16}
17
18/** What the toast says when the stage changes: up as the context fills, back down after /compact or /clear. */
19export function evolutionToast(avatar: Avatar, from: Stage, to: Stage, percent: number | undefined): string {
20 const fill = `${Math.round(percent ?? 0)}%`
21 const name = formName(avatar, to)
22 if (to < from) return `Sprout is ${withArticle(name)} again: context down to ${fill}`
23 if (to === 2) return `Sprout evolved into ${name}: context ${fill} full. A good time to /compact or start fresh`
24
25 return `Sprout evolved into ${name}: context ${fill} full`
26}
27hooks/mood.ts 88 lines1import type { Activity, ActivityKind, Reaction } from '../types'
2
3/** What Sprout looks like right now: a reaction while it lasts, else the activity. */
4export type Look = ActivityKind | Reaction['kind']
5
6export const SLEEP_AFTER_MS = 180_000
7export const LISTEN_FOR_MS = 4_000
8
9/** What a tool call has Sprout do. */
10export function toolActivity(tool: string): ActivityKind {
11 switch (tool) {
12 case 'Read':
13 case 'Grep':
14 case 'Glob':
15 case 'WebFetch':
16 case 'WebSearch':
17 return 'reading'
18 case 'Edit':
19 case 'MultiEdit':
20 case 'Write':
21 case 'NotebookEdit':
22 return 'writing'
23 case 'Agent':
24 case 'Task':
25 return 'delegating'
26 case 'AskUserQuestion':
27 return 'awaiting'
28 }
29
30 return 'running'
31}
32
33/** How Sprout greets a prompt, by what it says. */
34export function promptReaction(text: string, now: number): Reaction {
35 const said = text.toLowerCase()
36 if (/\b(thanks|thank you|cheers|great job|nice|awesome|love it|perfect|brilliant)\b/.test(said)) {
37 return { kind: 'happy', until: now + 2_200 }
38 }
39 if (/\b(bug|error|broken|fails?|failing|crash(es|ed)?|wrong)\b/.test(said)) return { kind: 'determined', until: now + 1_800 }
40 if (/\?\s*$/.test(text.trim())) return { kind: 'curious', until: now + 1_800 }
41
42 return { kind: 'excited', until: now + 1_600 }
43}
44
45export type TurnEnd = 'answer' | 'aborted' | 'refusal' | 'error'
46
47/** How Sprout takes the end of a turn. */
48export function finishReaction(reason: TurnEnd, now: number): Reaction {
49 switch (reason) {
50 case 'answer':
51 return { kind: 'celebrate', until: now + 2_600 }
52 case 'aborted':
53 return { kind: 'startled', until: now + 2_000 }
54 case 'refusal':
55 case 'error':
56 return { kind: 'oops', until: now + 3_500 }
57 }
58}
59
60/** Whether a reply ends by asking the person something. */
61export function asksQuestion(text: string): boolean {
62 const last = text.trim().split(/\n\s*\n/).pop()?.trim() ?? ''
63
64 return /\?[\s"'”’)*_`]*$/.test(last)
65}
66
67/**
68 * Where a finished turn leaves Sprout: waiting on the person when the reply
69 * ends with a question, else idle after the turn's own reaction.
70 */
71export function afterTurn(reason: TurnEnd, text: string, now: number): { activity: ActivityKind; reaction: Reaction | null } {
72 if (reason === 'answer' && asksQuestion(text)) return { activity: 'awaiting', reaction: null }
73
74 return { activity: 'idle', reaction: finishReaction(reason, now) }
75}
76
77/**
78 * The look for this moment: a reaction until it lapses, sleep after a long
79 * idle. An activity with no start time (`since` 0, before the session began)
80 * never counts as a long idle.
81 */
82export function lookAt(activity: Activity, reaction: Reaction | null, now: number): Look {
83 if (reaction && now < reaction.until) return reaction.kind
84 if (activity.kind === 'idle' && activity.since > 0 && now - activity.since > SLEEP_AFTER_MS) return 'sleeping'
85
86 return activity.kind
87}
88hooks/flipbook.ts 57 lines1import type { Avatar, Stage } from '../types'
2import { avatarPixels, homeOf, stepWalk, strideOf } from './avatar'
3import type { From } from './avatar'
4import type { Nudge } from './hub'
5import type { Look } from './mood'
6import { SVG_LIMIT, flipbookSvg } from './sprite'
7import type { Walk } from './walk'
8
9/** Ticks of the frame clock a desktop loop plays before Sprout heads home. */
10export const LOOP_TICKS = 96
11/** The most a loop runs on past its ticks to get home: a whole strip, walking. */
12const HOMEWARD_MAX = 200
13
14/**
15 * Sprout's next few seconds as the terminal would play them, for a surface
16 * with no Raster: the same walk (or teleports) and poses, tick by tick, from
17 * home and back to home so the loop has no seam. `random` is passed in so it
18 * can be tested.
19 */
20export function flipbookFrames(
21 width: number,
22 look: Look,
23 nudge: Nudge,
24 ticks: number,
25 random: () => number,
26 avatar: Avatar = 'toad',
27 stage: Stage = 0,
28 from?: From,
29): string[][] {
30 const home = homeOf(width, avatar, stage)
31 let walk: Walk = { x: home, target: home, facing: -1 }
32 const frames: string[][] = []
33 for (let tick = 0; tick < ticks || ((walk.x !== home || walk.blink !== undefined) && tick < ticks + HOMEWARD_MAX); tick++) {
34 // Past its ticks the loop heads home as if listening: walking back, or one teleport.
35 walk = tick < ticks ? stepWalk(avatar, walk, look, tick, home, random(), random()) : stepWalk(avatar, walk, 'listening', tick, home, 0.5, 0.5)
36 frames.push(avatarPixels(avatar, stage, width, walk.x, look, tick, strideOf(walk), nudge, from))
37 }
38
39 return frames
40}
41
42/** The loop as an SVG, shortened until its markup fits what an Svg may hold. */
43export function flipbook(
44 width: number,
45 look: Look,
46 nudge: Nudge,
47 random: () => number = Math.random,
48 avatar: Avatar = 'toad',
49 stage: Stage = 0,
50 from?: From,
51): string {
52 for (let ticks = LOOP_TICKS; ; ticks = Math.floor(ticks / 2)) {
53 const svg = flipbookSvg(width, flipbookFrames(width, look, nudge, ticks, random, avatar, stage, from))
54 if (svg.length < SVG_LIMIT || ticks <= 1) return svg
55 }
56}
57hooks/names.ts 34 lines1import type { Avatar, Stage } from '../types'
2
3/**
4 * Every name Sprout shows, in one place: each line's name, its three forms,
5 * and its moves. Renaming a line or a form is an edit here alone; the ids
6 * (`toad`, `lizard`, `fox`) are what /avatar takes and what is stored.
7 */
8export const NAMES: Readonly<Record<Avatar, { line: string; forms: readonly [string, string, string] }>> = {
9 toad: { line: 'Toad line', forms: ['Sprout', 'Bud', 'Bloom'] },
10 lizard: { line: 'Lizard line', forms: ['Spark', 'Blaze', 'Inferno'] },
11 fox: { line: 'Fox line', forms: ['Doze', 'Spoon', 'Sage'] },
12}
13
14export const MOVE_NAMES: Readonly<Record<string, string>> = {
15 vine: 'Vine Lash',
16 leaf: 'Leaf Volley',
17 solar: 'Sunbeam',
18 ember: 'Ember',
19 flamethrower: 'Flamethrower',
20 fireburst: 'Fire Burst',
21 teleport: 'Teleport',
22 mindbeam: 'Mind Beam',
23 mindwave: 'Mind Wave',
24}
25
26export function formName(avatar: Avatar, stage: Stage): string {
27 return NAMES[avatar].forms[stage]
28}
29
30/** `a Spark`, `an Inferno`. */
31export function withArticle(name: string): string {
32 return `${/^[aeiou]/i.test(name) ? 'an' : 'a'} ${name}`
33}
34hooks/sprite.ts 604 lines1import type { Form } from '../types'
2import type { Nudge } from './hub'
3import type { Look } from './mood'
4
5// Sprout, drawn the way Clawd is: flat colour, no outlines and eyes cut out as
6// holes, but with four little legs and no arms, and a bulb on top. One pixel is
7// one terminal column wide and half a row tall, so Sprout is 10 columns by 4
8// rows, standing on the strip's bottom row: the line just above the prompt.
9// Effects sit beside Sprout on whichever side has room.
10
11/** Pixels down the strip; the terminal shows two per row. */
12export const PX_H = 8
13export const ROWS = PX_H / 2
14/** Sprout's own width (its body is the middle 8), and the narrowest strip it is drawn in. */
15export const BODY_W = 10
16export const MIN_WIDTH = 24
17/** Columns an effect beside Sprout may reach, counted from Sprout's left edge. */
18const EFFECT_REACH = BODY_W + 7
19/** Where an effect beside Sprout starts, a column clear of it. */
20const BESIDE = BODY_W + 1
21export const FRAME_MS = 120
22
23const COLORS: Readonly<Record<string, number>> = {
24 T: 0x6cc4a1, // body
25 G: 0x5fb44a, // bulb
26 L: 0x8fdc6a, // bulb, glowing
27 g: 0x3e8a32, // bulb seam
28 y: 0xffd23f, // sparkle, alert
29 b: 0x6ec6ff, // sweat
30 z: 0xb7c3ff, // sleep
31 w: 0xcfd6dd, // thought
32 p: 0xff7a9a, // heart
33 W: 0xf4f6f8, // speech bubble
34 o: 0xd97757, // question mark, Clawd's orange
35 P: 0xf27fa0, // bud and petals (Bud, Bloom)
36 Q: 0xffb8cc, // bud and petals, glowing
37 S: 0xfde9ee, // petal spots
38 V: 0x2f6e2a, // palm leaves (Bloom)
39 // The tall lines (lines.ts).
40 O: 0xf08a3c, // Spark, Inferno
41 n: 0xc8642a, // their shade: feet, wing bones
42 M: 0xd2463a, // Blaze
43 m: 0xa3322a, // its shade
44 C: 0xf8d898, // belly
45 U: 0x3fa3a8, // Inferno's wing membrane
46 R: 0xe8402a, // flame, outer; Spoon's star
47 F: 0xff8c1a, // flame
48 Y: 0xffd23f, // flame core
49 A: 0xe9b83a, // Doze, Spoon, Sage
50 a: 0xb8862a, // their shade: whiskers, inner ears
51 B: 0x8a5634, // their brown armour
52 k: 0xc9d1d8, // spoons
53 h: 0xf07ad0, // psychic, pink
54 v: 0x9b6bff, // psychic, violet
55}
56
57// The bulb sits on the head, its base as wide as the head, so the teal starts
58// at the eyes and Sprout is mostly face and bulb.
59const BULB = ['....GG....', '..GgGGgG..', '.GgGGGGgG.']
60// Bud: the bulb opened into a pink bud in green sepals, two leaves
61// spread either side and drooping past the body. Twelve wide, from x - 1.
62const BUD = ['.....PP.....', '....PPPP....', '...gGPPGg...', 'GGgGGGGGGgGG', 'L..........L']
63// Bloom: the bud in full flower, white-spotted petals over a ring of palm leaves.
64const FLOWER = ['.PP.PPPP.PP.', 'PSPPPyyPPPSP', '.PPPPPPPPPP.', 'VGVGVGGVGVGV', 'V..........V']
65const HEAD = ['.TTTTTTTT.', '.TTTTTTTT.', '.TTTTTTTT.']
66const LEGS = [1, 3, 6, 8]
67
68// Eye stamps, left eye then right; `.` cuts a hole the background shows in.
69const EYES = {
70 open: [['T.', 'T.'], ['.T', '.T']],
71 blink: [['TT', 'TT'], ['TT', 'TT']],
72 shut: [['TT', '..'], ['TT', '..']],
73 up: [['T.', 'TT'], ['.T', 'TT']],
74 upLeft: [['.T', 'TT'], ['.T', 'TT']],
75 upRight: [['T.', 'TT'], ['T.', 'TT']],
76 down: [['TT', 'T.'], ['TT', '.T']],
77 left: [['.T', '.T'], ['.T', '.T']],
78 right: [['T.', 'T.'], ['T.', 'T.']],
79 wide: [['..', '..'], ['..', '..']],
80 happy: [['..', 'TT'], ['..', 'TT']],
81 cross: [['.T', 'T.'], ['T.', '.T']],
82} as const
83type Eyes = keyof typeof EYES
84
85const BANG = ['y', 'y', '.', 'y']
86const QUESTION = ['yy', '.y', 'y.', '..', 'y.']
87const SPARK = ['.y.', 'yyy', '.y.']
88const HEART = ['p.p', 'ppp', '.p.']
89const ZED = ['zz.', '.z.', '.zz']
90const DROP = ['b', 'b']
91// Each form side-on, facing right (mirrored to face left), as it turns to
92// attack: what grows on its back, one eye cut out, the snout forward, legs.
93const SIDE = ['..........', '...GG.....', '..GgGG....', '.GgGGgG...', '.TTTTTTTT.', '.TTTTTT.TT', '.TTTTTTTTT', '..T.T.T.T.']
94const SIDE_BUD = ['..PP......', '.PPPP.....', '.gPPgG....', 'GGgGGgGG..', 'LTTTTTTTT.', '.TTTTTT.TT', '.TTTTTTTTT', '..T.T.T.T.']
95const SIDE_FLOWER = ['PP.PP.....', 'PSPPSPP...', 'PPPPPPPP..', 'VGVGVGVGV.', 'VTTTTTTTT.', '.TTTTTT.TT', '.TTTTTTTTT', '.TT.T..TT.']
96
97export type { Form }
98export type Move = 'vine' | 'leaf' | 'solar'
99
100/** Each form's crown (front-on, and where it sits), its side-on art, and the moves it attacks with in turn. */
101const FORMS: Readonly<Record<Form, { crown: readonly string[]; crownX: number; crownY: number; side: readonly string[]; moves: readonly Move[] }>> = {
102 sprout: { crown: BULB, crownX: 0, crownY: 1, side: SIDE, moves: ['vine', 'leaf'] },
103 bud: { crown: BUD, crownX: -1, crownY: 0, side: SIDE_BUD, moves: ['vine', 'leaf'] },
104 bloom: { crown: FLOWER, crownX: -1, crownY: 0, side: SIDE_FLOWER, moves: ['vine', 'leaf', 'solar'] },
105}
106
107/** Columns a form's leaves reach past Sprout's outline on the right, so home stands that much further in. */
108export const FORM_OVERHANG: Readonly<Record<Form, number>> = { sprout: 0, bud: 2, bloom: 2 }
109
110export const FORM_MOVES: Readonly<Record<Form, readonly Move[]>> = {
111 sprout: FORMS.sprout.moves,
112 bud: FORMS.bud.moves,
113 bloom: FORMS.bloom.moves,
114}
115
116/** Bulb, bud or flower lit up: the greens brighten, and so do the petals. */
117function glowing(isOn: boolean): (ch: string) => string {
118 return ch => (!isOn ? ch : ch === 'G' ? 'L' : ch === 'P' ? 'Q' : ch)
119}
120// A leaf volley mid-spin, flat then on its edge, facing right.
121const LEAF_FLAT = ['GL']
122const LEAF_EDGE = ['.L', 'G.']
123// The vine's crack at full reach: sparks round its tip.
124const CRACK = ['y.y', '...', 'y.y']
125// A speech bubble holding a question mark, its tail pointing back at Sprout.
126const BUBBLE = ['WWWWW', 'WoooW', 'WWWoW', 'WWoWW', 'WWWWW', 'WWoWW', 'WWWWW']
127// The bubble and its tail fill the strip's full height, so it flashes rather than bobs.
128
129export type Effect = 'none' | 'bang' | 'question' | 'sparkles' | 'hearts' | 'zzz' | 'sweat' | 'dots' | 'ask'
130
131export type Pose = {
132 dy: number
133 bulbDx: number
134 glow: boolean
135 legs: readonly [boolean, boolean, boolean, boolean]
136 eyes: Eyes
137 isMouthOpen: boolean
138 effect: Effect
139}
140
141/**
142 * How Sprout is moving along the line, if at all; `fade` (0 there, 1 gone) is
143 * how far through a teleport a line that teleports is.
144 */
145export type Stride = { isWalking: boolean; facing: -1 | 1; fade?: number }
146
147const STILL: Pose = {
148 dy: 0,
149 bulbDx: 0,
150 glow: false,
151 legs: [true, true, true, true],
152 eyes: 'open',
153 isMouthOpen: false,
154 effect: 'none',
155}
156
157const HOP = [0, -1, -1, 0]
158
159/** The pose for a look at a tick of the frame clock. */
160export function poseFor(look: Look, tick: number): Pose {
161 const blinking = tick % 40 < 2
162 const swing = tick % 4 < 2
163 switch (look) {
164 case 'idle':
165 return { ...STILL, eyes: blinking ? 'blink' : 'open' }
166 case 'sleeping':
167 return { ...STILL, eyes: 'shut', effect: 'zzz' }
168 case 'listening':
169 return { ...STILL, eyes: blinking ? 'blink' : 'down' }
170 case 'thinking':
171 return { ...STILL, eyes: tick % 24 < 12 ? 'upLeft' : 'upRight', glow: tick % 8 < 4, effect: 'dots' }
172 case 'talking':
173 return { ...STILL, eyes: blinking ? 'blink' : 'open', isMouthOpen: swing }
174 case 'reading':
175 return { ...STILL, eyes: tick % 16 < 8 ? 'left' : 'right' }
176 case 'writing':
177 // Head down, tapping the inner feet as if typing.
178 return { ...STILL, eyes: 'down', legs: [true, swing, !swing, true] }
179 case 'running':
180 return { ...STILL, eyes: 'open' }
181 case 'delegating':
182 return { ...STILL, eyes: 'up', glow: swing, effect: 'sparkles' }
183 case 'asking':
184 return { ...STILL, dy: tick % 12 < 2 ? -1 : 0, eyes: 'wide', effect: tick % 6 < 4 ? 'bang' : 'none' }
185 case 'awaiting':
186 return {
187 ...STILL,
188 dy: tick % 10 < 2 ? -1 : 0,
189 eyes: tick % 24 < 14 ? 'up' : blinking ? 'blink' : 'down',
190 effect: 'ask',
191 }
192 case 'excited':
193 return { ...STILL, dy: HOP[tick % HOP.length] ?? 0, eyes: 'happy', isMouthOpen: true }
194 case 'happy':
195 return { ...STILL, bulbDx: [0, 1, 0, -1][Math.floor(tick / 2) % 4] ?? 0, eyes: 'happy', effect: 'hearts' }
196 case 'curious':
197 return { ...STILL, bulbDx: 1, eyes: 'up', effect: 'question' }
198 case 'determined':
199 // Eyes narrowed, stamping the feet.
200 return { ...STILL, eyes: 'shut', legs: [swing, !swing, swing, !swing] }
201 case 'celebrate':
202 return {
203 ...STILL,
204 dy: HOP[tick % HOP.length] ?? 0,
205 eyes: 'happy',
206 isMouthOpen: true,
207 glow: true,
208 effect: 'sparkles',
209 }
210 case 'oops':
211 return { ...STILL, eyes: tick % 20 < 10 ? 'cross' : 'down', effect: 'sweat' }
212 case 'startled':
213 return { ...STILL, dy: -1, eyes: 'wide', effect: 'bang' }
214 case 'evolving':
215 // Drawn as a white silhouette flickering between the two forms (stagePixels).
216 return { ...STILL, effect: 'sparkles' }
217 }
218}
219
220/** A pose on the move: legs step in turn, and the eyes look where Sprout is going. */
221function stepping(pose: Pose, look: Look, stride: Stride, tick: number): Pose {
222 if (!stride.isWalking) return pose
223 const step = tick % 4 < 2
224 const legs = [step, !step, step, !step] as const
225 const isGlancing = look === 'idle' || look === 'running' || look === 'listening'
226
227 return { ...pose, legs, eyes: isGlancing ? (stride.facing < 0 ? 'left' : 'right') : pose.eyes }
228}
229
230export class Canvas {
231 readonly px: string[][]
232
233 constructor(
234 readonly width: number,
235 height = PX_H,
236 ) {
237 this.px = Array.from({ length: height }, () => Array<string>(width).fill('.'))
238 }
239
240 rows(): string[] {
241 return this.px.map(row => row.join(''))
242 }
243
244 put(x: number, y: number, c: string): void {
245 const row = this.px[y]
246 if (row && x >= 0 && x < this.width) row[x] = c
247 }
248
249 /** Pastes rows of pixels; with `clear`, a `.` cuts a hole instead of leaving what is beneath. */
250 stamp(x0: number, y0: number, art: readonly string[], clear = false, recolor: (c: string) => string = c => c): void {
251 art.forEach((line, dy) => {
252 for (let dx = 0; dx < line.length; dx++) {
253 const c = line.charAt(dx)
254 if (c !== '.' || clear) this.put(x0 + dx, y0 + dy, c === '.' ? c : recolor(c))
255 }
256 })
257 }
258}
259
260function effect(c: Canvas, kind: Effect, x: number, y: number, tick: number, side: 1 | -1): void {
261 effectAt(c, kind, x, y, tick, side, BODY_W, 0)
262}
263
264/**
265 * Draws an effect beside a creature `bodyW` wide at `x`. `at(dx, w)` places
266 * art `w` wide that sits `dx` columns from its left edge on the right side,
267 * or mirrored onto the left when `side` is -1; the art itself is never
268 * mirrored. `top` is where the full-height speech bubble starts.
269 */
270export function effectAt(c: Canvas, kind: Effect, x: number, y: number, tick: number, side: 1 | -1, bodyW: number, top: number): void {
271 const BODY_W = bodyW
272 const BESIDE = bodyW + 1
273 const at = (dx: number, w: number) => (side > 0 ? x + dx : x + BODY_W - dx - w)
274 switch (kind) {
275 case 'bang':
276 return c.stamp(at(BESIDE, 1), y + 1, BANG)
277 case 'question':
278 return c.stamp(at(BESIDE, 2), y, QUESTION)
279 case 'dots': {
280 const shown = 1 + (Math.floor(tick / 4) % 3)
281 const spots = [[BODY_W, 4], [BESIDE, 2], [BESIDE + 1, 0]] as const
282 spots.slice(0, shown).forEach(([dx, dy]) => c.put(at(dx, 1), y + dy, 'w'))
283 return
284 }
285 case 'sparkles': {
286 if (Math.floor(tick / 3) % 2 === 0) {
287 c.stamp(at(BESIDE, 3), y + 1, SPARK)
288 c.put(at(1, 1), y + 1, 'y')
289 } else {
290 c.stamp(at(BESIDE, 3), y + 4, SPARK)
291 c.put(at(BODY_W - 2, 1), y + 1, 'y')
292 }
293 return
294 }
295 case 'hearts':
296 return c.stamp(at(BESIDE, 3), y + 3 - (Math.floor(tick / 3) % 4), HEART)
297 case 'zzz':
298 return c.stamp(at(BESIDE, 3), y + 3 - (Math.floor(tick / 5) % 4), ZED)
299 case 'sweat':
300 return c.stamp(at(BODY_W - 1, 1), y + 2 + (Math.floor(tick / 5) % 2), DROP)
301 case 'ask': {
302 // The question mark flashes, and the tail points back at Sprout.
303 const left = at(BESIDE, 5)
304 const flash = (ch: string) => (ch === 'o' && Math.floor(tick / 4) % 3 === 2 ? 'y' : ch)
305 c.stamp(left, top, BUBBLE, false, flash)
306 c.put(side > 0 ? left : left + 4, top + BUBBLE.length, 'W')
307 return
308 }
309 case 'none':
310 return
311 }
312}
313
314/** Ticks between the starts of two attacks, and how long one lasts. */
315export const ATTACK_EVERY = 48
316export const ATTACK_TICKS = 16
317/** How far a vine reaches, and a leaf volley flies, from Sprout's front. */
318const VINE_REACH = 14
319const LEAF_REACH = 26
320/** How far Sunbeam reaches: across the strip. */
321const SOLAR_REACH = 48
322/** Leaf Volley's three leaves: the tick each leaves the bulb, and its line. They fly three columns a tick. */
323const VOLLEY = [
324 { at: 2, dy: 2 },
325 { at: 4, dy: 4 },
326 { at: 6, dy: 1 },
327] as const
328
329export type Attack = { move: Move; t: number }
330
331/**
332 * Another session has finished and Sprout has nothing else to do: every few
333 * seconds he turns side-on to the open space and attacks it, Vine Lash and
334 * Leaf Volley in turn. Between attacks, and while he is walking, none.
335 */
336export function attackAt(look: Look, nudge: Nudge, tick: number, isWalking: boolean, form: Form = 'sprout'): Attack | undefined {
337 if (nudge !== 'turn' || isWalking || (look !== 'idle' && look !== 'listening')) return undefined
338 const t = tick % ATTACK_EVERY
339 if (t >= ATTACK_TICKS) return undefined
340 const { moves } = FORMS[form]
341
342 return { move: moves[Math.floor(tick / ATTACK_EVERY) % moves.length] ?? 'vine', t }
343}
344
345export function mirrored(art: readonly string[], facing: 1 | -1): readonly string[] {
346 return facing > 0 ? art : art.map(line => [...line].reverse().join(''))
347}
348
349/**
350 * Sprout side-on at `x`, facing the open space, and the attack's frame `t`.
351 * Vine Lash: the bulb glows, two vines lash out, the upper one cracks with a
352 * spark at full reach, then both pull back. Leaf Volley: three leaves fly from
353 * the bulb, spinning, one after another.
354 */
355function drawAttack(c: Canvas, attack: Attack, x: number, y: number, facing: 1 | -1, form: Form): void {
356 const { move, t } = attack
357 const isGlowing = move === 'vine' ? t < 9 : move === 'leaf' ? t < 8 : t < 13
358 c.stamp(x, y, mirrored(FORMS[form].side, facing), false, glowing(isGlowing))
359 // The first column clear of Sprout's front, and a column `d` further out.
360 const front = facing > 0 ? x + BODY_W : x - 1
361 const out = (d: number) => front + facing * d
362
363 if (move === 'solar') {
364 // Sunbeam: light gathers at the snout, then a beam crosses the strip
365 // and thins out.
366 if (t < 6) {
367 for (let i = 0; i < 3; i++) c.put(out(Math.max(0, (6 - t) * 2 + i * 2 - 2)), y + 2 + i * 2, 'y')
368 if (t >= 3) c.put(out(0), y + 4, 'W')
369 return
370 }
371 const reach = Math.min(SOLAR_REACH, (t - 5) * 10)
372 for (let d = 0; d < reach; d++) {
373 if (t < 15) c.put(out(d), y + 4, 'W')
374 if (t < 13 || (t < 14 && d % 2 === 0)) {
375 c.put(out(d), y + 3, (d + t) % 4 === 0 ? 'W' : 'y')
376 c.put(out(d), y + 5, (d + t) % 4 === 2 ? 'W' : 'y')
377 }
378 }
379 return
380 }
381
382 if (move === 'vine') {
383 const reach = t < 3 ? 0 : t < 9 ? Math.min(VINE_REACH, (t - 2) * 3) : t < 11 ? VINE_REACH : Math.max(0, VINE_REACH - (t - 10) * 4)
384 const isCracking = t === 9 || t === 10
385 for (let d = 0; d < reach; d++) {
386 // A slight wave down the vine, still at the tip while it cracks.
387 const sway = !isCracking && Math.floor((d + t) / 3) % 2 === 1 ? 1 : 0
388 const isTip = d === reach - 1
389 c.put(out(d), y + 4 + sway - (isCracking && isTip ? 1 : 0), isTip ? 'L' : 'G')
390 if (d < reach - 3) c.put(out(d), y + 6, d === reach - 4 ? 'L' : 'g')
391 }
392 if (isCracking) {
393 c.put(out(VINE_REACH), y + 2, 'y')
394 if (t === 10) c.stamp(out(VINE_REACH) - 1, y + 1, CRACK)
395 }
396 return
397 }
398
399 for (const { at, dy } of VOLLEY) {
400 const flown = (t - at) * 3
401 if (t < at || flown >= LEAF_REACH) continue
402 const art = mirrored((t - at) % 2 === 0 ? LEAF_FLAT : LEAF_EDGE, facing)
403 c.stamp(facing > 0 ? out(flown) : out(flown) - 1, y + dy, art)
404 }
405}
406
407/** One frame of an attack on its own, for the gallery: `form` at `x`, facing the open space. */
408export function attackPixels(width: number, x: number, form: Form, attack: Attack, facing: 1 | -1): string[] {
409 const c = new Canvas(width)
410 drawAttack(c, attack, x, 0, facing, form)
411
412 return c.px.map(row => row.join(''))
413}
414
415/**
416 * The nudge for another session, drawn only when Sprout shows no effect of his
417 * own: a blinking `!` while one needs you; while one has finished, an attack
418 * when he is free (attackAt) and a spinning leaf beside him while he is busy.
419 * Idle, he keeps glancing towards it.
420 */
421function nudged(pose: Pose, look: Look, nudge: Nudge, side: 1 | -1, tick: number): Pose {
422 if (nudge === 'none' || pose.effect !== 'none') return pose
423 const isGlancing = (look === 'idle' || look === 'listening') && tick % 16 < 8
424
425 return isGlancing ? { ...pose, eyes: side > 0 ? 'right' : 'left' } : pose
426}
427
428/** The strip's pixel rows with Sprout standing at column `x`; `.` is see-through. */
429export function stagePixels(
430 width: number,
431 x: number,
432 look: Look,
433 tick: number,
434 stride: Stride = { isWalking: false, facing: 1 },
435 nudge: Nudge = 'none',
436 form: Form = 'sprout',
437 from?: Form,
438): string[] {
439 const side = x + EFFECT_REACH <= width ? 1 : -1
440 if (look === 'evolving') {
441 // Evolving: a white silhouette flickering between the old form and the
442 // new, in a shower of sparkles; the new form shows once it lapses.
443 const shown = from !== undefined && Math.floor(tick / 2) % 2 === 0 ? from : form
444 const c = new Canvas(width)
445 // Tick 0 of idle is a blink, so the silhouette has no eye holes.
446 c.stamp(0, 0, stagePixels(width, x, 'idle', 0, undefined, 'none', shown).map(row => row.replace(/[^.]/g, 'W')))
447 effect(c, 'sparkles', x, 0, tick, side)
448 return c.px.map(row => row.join(''))
449 }
450 const own = stepping(poseFor(look, tick), look, stride, tick)
451 const pose = nudged(own, look, nudge, side, tick)
452 const c = new Canvas(width)
453 const y = pose.dy
454 const attack = own.effect === 'none' ? attackAt(look, nudge, tick, stride.isWalking, form) : undefined
455 if (attack) {
456 drawAttack(c, attack, x, y, side, form)
457 return c.px.map(row => row.join(''))
458 }
459 const { crown, crownX, crownY } = FORMS[form]
460
461 c.stamp(x + crownX + pose.bulbDx, y + crownY, crown, false, glowing(pose.glow))
462 c.stamp(x, y + 4, HEAD)
463 LEGS.forEach((dx, i) => {
464 if (pose.legs[i]) c.put(x + dx, y + 7, 'T')
465 })
466 const [left, right] = EYES[pose.eyes]
467 c.stamp(x + 2, y + 4, left, true)
468 c.stamp(x + 6, y + 4, right, true)
469 if (pose.isMouthOpen) c.stamp(x + 4, y + 6, ['..'], true)
470 effect(c, pose.effect, x, y, tick, side)
471 if (pose.effect === 'none' && nudge !== 'none') {
472 const at = (w: number) => (side > 0 ? x + BESIDE : x + BODY_W - BESIDE - w)
473 if (nudge === 'needs' && tick % 8 < 5) c.stamp(at(1), y + 1, BANG)
474 if (nudge === 'turn') c.stamp(at(2), y + 2 + (Math.floor(tick / 6) % 2), mirrored(tick % 8 < 4 ? LEAF_FLAT : LEAF_EDGE, side))
475 }
476
477 return c.px.map(row => row.join(''))
478}
479
480const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
481const DEFAULT_COLOR = 0x01000000
482
483function base64(bytes: Uint8Array): string {
484 const out: string[] = []
485 for (let i = 0; i < bytes.length; i += 3) {
486 const [a = 0, b, c] = [bytes[i], bytes[i + 1], bytes[i + 2]]
487 const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
488 out.push(
489 ALPHABET.charAt((n >> 18) & 63),
490 ALPHABET.charAt((n >> 12) & 63),
491 b === undefined ? '=' : ALPHABET.charAt((n >> 6) & 63),
492 c === undefined ? '=' : ALPHABET.charAt(n & 63),
493 )
494 }
495
496 return out.join('')
497}
498
499function colorOf(rows: readonly string[], x: number, y: number): number | undefined {
500 const c = rows[y]?.charAt(x) ?? '.'
501
502 return c === '.' ? undefined : COLORS[c]
503}
504
505/**
506 * Pixel rows as a Raster's cells, two pixels a row: `▀` with the upper pixel
507 * as foreground and the lower as background, `▄` where only the lower is
508 * drawn, else blank.
509 */
510export function cellsOf(pixels: readonly string[], width: number): string {
511 const rows = Math.ceil(pixels.length / 2)
512 const view = new DataView(new ArrayBuffer(width * rows * 12))
513 for (let row = 0; row < rows; row++) {
514 for (let col = 0; col < width; col++) {
515 const top = colorOf(pixels, col, row * 2)
516 const bottom = colorOf(pixels, col, row * 2 + 1)
517 const cell =
518 top !== undefined
519 ? [0x2580, top, bottom ?? DEFAULT_COLOR]
520 : bottom !== undefined
521 ? [0x2584, bottom, DEFAULT_COLOR]
522 : [0x20, DEFAULT_COLOR, DEFAULT_COLOR]
523 cell.forEach((word, i) => view.setUint32(((row * width + col) * 3 + i) * 4, word, true))
524 }
525 }
526
527 return base64(new Uint8Array(view.buffer))
528}
529
530/** The strip as a Raster's cells, Sprout standing at column `x`. */
531export function stageCells(
532 width: number,
533 x: number,
534 look: Look,
535 tick: number,
536 stride?: Stride,
537 nudge: Nudge = 'none',
538 form: Form = 'sprout',
539 from?: Form,
540): string {
541 return cellsOf(stagePixels(width, x, look, tick, stride, nudge, form, from), width)
542}
543
544/** Pixel rows as SVG paths, one per colour, each pixel one unit square. */
545function pathsOf(rows: readonly string[]): string {
546 const paths = new Map<string, string[]>()
547 rows.forEach((line, y) => {
548 for (let x = 0; x < line.length; ) {
549 const c = line.charAt(x)
550 let end = x + 1
551 while (line.charAt(end) === c) end++
552 if (c !== '.') paths.set(c, [...(paths.get(c) ?? []), `M${x} ${y}h${end - x}v1h${x - end}z`])
553 x = end
554 }
555 })
556
557 return [...paths]
558 .map(([c, d]) => `<path fill="#${(COLORS[c] ?? 0).toString(16).padStart(6, '0')}" d="${d.join('')}"/>`)
559 .join('')
560}
561
562/** CSS pixels per Sprout pixel where it is drawn as an SVG. */
563export const SVG_SCALE = 6
564/** The most an Svg's markup may hold. */
565export const SVG_LIMIT = 131_072
566
567function svgOf(width: number, body: string, height = PX_H): string {
568 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width * SVG_SCALE}" height="${height * SVG_SCALE}" shape-rendering="crispEdges">${body}</svg>`
569}
570
571/** Sprout alone as an SVG, for the surfaces with no Raster. */
572export function spriteSvg(look: Look, tick: number): string {
573 return svgOf(MIN_WIDTH, pathsOf(stagePixels(MIN_WIDTH, 1, look, tick)))
574}
575
576/**
577 * A strip as an SVG that plays itself: one group per run of identical frames,
578 * each shown for its share of the loop by a discrete SMIL animation, so a
579 * surface with no Raster animates Sprout with no redraws. The first frame is
580 * what shows where SMIL does not run.
581 */
582export function flipbookSvg(width: number, frames: readonly (readonly string[])[]): string {
583 const height = frames[0]?.length ?? PX_H
584 const runs: { rows: readonly string[]; from: number; to: number }[] = []
585 frames.forEach((rows, i) => {
586 const last = runs[runs.length - 1]
587 if (last && last.rows.join('\n') === rows.join('\n')) last.to = i + 1
588 else runs.push({ rows, from: i, to: i + 1 })
589 })
590 if (runs.length <= 1) return svgOf(width, pathsOf(runs[0]?.rows ?? []), height)
591
592 const at = (i: number) => String(Number((i / frames.length).toFixed(4)))
593 const dur = frames.length * FRAME_MS
594 const groups = runs.map(({ rows, from, to }) => {
595 const [values, keyTimes] =
596 from === 0 ? ['1;0', `0;${at(to)}`] : to === frames.length ? ['0;1', `0;${at(from)}`] : ['0;1;0', `0;${at(from)};${at(to)}`]
597 const animate = `<animate attributeName="opacity" calcMode="discrete" dur="${dur}ms" repeatCount="indefinite" values="${values}" keyTimes="${keyTimes}"/>`
598
599 return `<g opacity="${from === 0 ? 1 : 0}">${animate}${pathsOf(rows)}</g>`
600 })
601
602 return svgOf(width, groups.join(''), height)
603}
604hooks/walk.ts 90 lines1import type { Look } from './mood'
2import { BODY_W, FORM_OVERHANG } from './sprite'
3import type { Form } from './sprite'
4
5/** Where Sprout stands and is heading; `blink` counts down a teleport (8 to 1) for a line that teleports. */
6export type Walk = { x: number; target: number; facing: -1 | 1; blink?: number }
7
8/**
9 * The furthest column Sprout's left edge may reach on a strip this wide: its
10 * body (one column in from the sprite's edges) ending on the strip's last
11 * column, beside the label there.
12 */
13export function maxXFor(width: number): number {
14 return Math.max(0, width - BODY_W + 1)
15}
16
17/** Home on a strip this wide for a form: the right end, clear of its leaves. */
18export function homeFor(width: number, form: Form = 'sprout'): number {
19 return Math.max(0, maxXFor(width) - FORM_OVERHANG[form])
20}
21
22/**
23 * Where Sprout heads next, one tick at a time. Home is the right end of the
24 * line, at `maxX`, just above the prompt. Running paces the whole line,
25 * thinking shuffles to and fro, idling wanders a little way left of home and
26 * drifts back, and listening, sleeping and waiting on an answer go home;
27 * anything else stands still. `chance` and `spot` are random numbers in
28 * [0, 1), passed in so the walk can be tested.
29 */
30export function nextWalk(walk: Walk, look: Look, tick: number, maxX: number, chance: number, spot: number): Walk {
31 const clamp = (x: number) => Math.max(0, Math.min(maxX, x))
32 const home = maxX
33 const x = clamp(walk.x)
34 let target = clamp(walk.target)
35 const isThere = x === target
36 let isQuick = false
37
38 switch (look) {
39 case 'running':
40 isQuick = true
41 if (isThere) target = Math.floor(spot * (maxX + 1))
42 break
43 case 'thinking':
44 if (isThere && chance < 0.05) target = clamp(x + (spot < 0.5 ? -3 : 3))
45 break
46 case 'idle':
47 if (isThere && chance < 0.012) target = clamp(home - Math.floor(spot * 22))
48 else if (isThere && x !== home && chance < 0.05) target = home
49 break
50 case 'listening':
51 case 'sleeping':
52 case 'awaiting':
53 target = home
54 break
55 default:
56 target = x
57 }
58
59 if (x === target || (!isQuick && tick % 2 === 1)) return { x, target, facing: walk.facing }
60 const facing = target < x ? -1 : 1
61
62 return { x: x + facing, target, facing }
63}
64
65/** Ticks a teleport takes: half fading out where it was, half fading in where it went. */
66const BLINK = 8
67
68/**
69 * As nextWalk, for a line that teleports: rather than stepping, it fades out
70 * where it is and in where it was heading, then waits there as a walker
71 * would. Running, it keeps hopping round the line.
72 */
73export function nextTeleport(walk: Walk, look: Look, tick: number, maxX: number, chance: number, spot: number): Walk {
74 if (walk.blink !== undefined) {
75 const blink = walk.blink - 1
76 if (blink <= 0) return { x: walk.target, target: walk.target, facing: walk.facing }
77 return { ...walk, x: blink === BLINK / 2 ? walk.target : walk.x, blink }
78 }
79 const next = nextWalk(walk, look, tick, maxX, chance, spot)
80 if (next.target === walk.x || next.x === walk.x) return { ...next, x: walk.x }
81
82 return { x: walk.x, target: next.target, facing: next.facing, blink: BLINK }
83}
84
85/** How far through a teleport: 0 there, 1 gone; undefined when not teleporting. */
86export function fadeOf(walk: Walk): number | undefined {
87 if (walk.blink === undefined) return undefined
88 return walk.blink > BLINK / 2 ? (BLINK + 1 - walk.blink) / (BLINK / 2) : (walk.blink - 1) / (BLINK / 2)
89}
90hooks/lines.ts 500 lines1import type { Nudge } from './hub'
2import type { Look } from './mood'
3import { ATTACK_EVERY, Canvas, effectAt, mirrored, poseFor } from './sprite'
4import type { Stride } from './sprite'
5
6// The tall lines: the Lizard line and the Fox line, drawn the way Sprout
7// is (flat colour, no outlines, eyes cut out as holes) but 12 pixels tall, six
8// terminal rows to Sprout's four. They take Sprout's poses (hops, effects,
9// a blink) and add their own: tail flames that burn high or low with the
10// mood, spoons that glint, Sage's float. The Lizard line walks
11// side-on; the Fox line teleports.
12
13/** Pixels down a tall line's strip. */
14export const TALL_H = 12
15/** Where Sprout's 8-pixel effects sit on the taller strip: beside the head. */
16const EFFECT_Y = 4
17
18export type TallLine = 'lizard' | 'fox'
19export type TallMove = 'ember' | 'flamethrower' | 'fireburst' | 'teleport' | 'mindbeam' | 'mindwave'
20
21type Point = { x: number; y: number }
22type Art = readonly string[]
23
24type Creature = {
25 /** Front-on, the tail flame left off (Lizard line). */
26 art: Art
27 /** The eye holes, closed by a blink or a shut-eyed pose. */
28 eyes: readonly Point[]
29 /** The colour that closes them. */
30 body: string
31 /** Doze sleeps: its eyes stay shut. */
32 isAsleep?: boolean
33 wide: number
34 moves: readonly TallMove[]
35 // The Lizard line: its tail flame, front-on and side-on, and its walk.
36 flame?: { size: number; front: Point; side: Point }
37 side?: Art
38 stepRow?: string
39 mouth?: Point
40 // The Fox line: spoons that glint, where its power leaves it, a float.
41 spoons?: readonly Point[]
42 focus?: Point
43 isFloating?: boolean
44}
45
46// Tail flames, small to large, each three frames of flicker, anchored top-left.
47const FLAMES: readonly (readonly Art[])[] = [
48 [['.Y.', 'RYR', '.R.'], ['Y..', 'RYR', '.R.'], ['..Y', 'RYR', '.R.']],
49 [['.Y..', 'RYYR', 'RYFR', '.RR.'], ['Y...', 'RYY.', 'RYFR', '.RR.'], ['..Y.', '.YYR', 'RFYR', '.RR.']],
50 [['..Y..', '.RYR.', 'RYWYR', 'RFYFR', '.RRR.'], ['.Y...', '.RYR.', 'RYWY.', 'RFYFR', '.RRR.'], ['...Y.', '.RYR.', '.YWYR', 'RFYFR', '.RRR.']],
51]
52// Burning low: a small flame, then a last ember.
53const LOW_FLAMES: readonly (readonly Art[])[] = [
54 [['Y', 'R'], ['Y', 'R'], ['.', 'R']],
55 [['R'], ['.'], ['R']],
56]
57
58const spark: Creature = {
59 art: [
60 '................',
61 '................',
62 '................',
63 '................',
64 '.....OOOO.......',
65 '....OOOOOO......',
66 '....O.OO.O......',
67 '....O.OO.O......',
68 '.....OOOO...O...',
69 '...OOCCCCOO.O...',
70 '....OCCCCOOOO...',
71 '....OO..OO......',
72 ],
73 eyes: [{ x: 5, y: 6 }, { x: 5, y: 7 }, { x: 8, y: 6 }, { x: 8, y: 7 }],
74 body: 'O',
75 wide: 16,
76 moves: ['ember'],
77 flame: { size: 0, front: { x: 11, y: 5 }, side: { x: 2, y: 7 } },
78 side: [
79 '................',
80 '................',
81 '................',
82 '................',
83 '........OOOO....',
84 '.......OOOOOO...',
85 '.......OOOO.OO..',
86 '.......OOOOOOO..',
87 '........OOOO....',
88 '.......OCCCOO...',
89 '...O..OOCCCO....',
90 '....OOOO.n.n....',
91 ],
92 stepRow: '....OOOOn.n.....',
93 mouth: { x: 14, y: 7 },
94}
95
96const blaze: Creature = {
97 art: [
98 '................',
99 '................',
100 '.....M..........',
101 '.....MMMMM......',
102 '....MMMMMM......',
103 '....M.MM.M......',
104 '....MMMMMM......',
105 '.....MMMM.......',
106 '....MMCCMM...M..',
107 '...m.MCCCMm..M..',
108 '.....MCCMMMMM...',
109 '....mm..mm......',
110 ],
111 eyes: [{ x: 5, y: 5 }, { x: 8, y: 5 }],
112 body: 'M',
113 wide: 16,
114 moves: ['ember', 'flamethrower'],
115 flame: { size: 1, front: { x: 12, y: 4 }, side: { x: 0, y: 5 } },
116 side: [
117 '................',
118 '................',
119 '.......M........',
120 '........MMMM....',
121 '.......MMMMMM...',
122 '.......MMMM.MM..',
123 '.......MMMMMMMM.',
124 '........MMMM....',
125 '.......MCCMMm...',
126 '.M....MMCCCM....',
127 '..M..MMMCCM.....',
128 '...MMMM.m..m....',
129 ],
130 stepRow: '...MMMMm..m.....',
131 mouth: { x: 15, y: 6 },
132}
133
134const inferno: Creature = {
135 art: [
136 '......O...O............',
137 '......OOOOO............',
138 'nO....O.O.O.....On.....',
139 'UUn...OOOOO....nUU.....',
140 'UUUn...OOO....nUUU.....',
141 'UUUUn.OOOOO..nUUUU.....',
142 '.UUUUOOCCCOOUUUU.......',
143 '..UUUOOCCCOOUUU........',
144 '...UU.OCCCO.UU.........',
145 '......OCCCO............',
146 '.......OOOOO...........',
147 '......nO..OnOOOO.......',
148 ],
149 eyes: [{ x: 7, y: 2 }, { x: 9, y: 2 }],
150 body: 'O',
151 wide: 23,
152 moves: ['flamethrower', 'fireburst'],
153 flame: { size: 2, front: { x: 16, y: 7 }, side: { x: 0, y: 6 } },
154 side: [
155 '..nO................',
156 '..UUn...............',
157 '..UUUn.......O.O....',
158 '..UUUUn.....OOOO....',
159 '..UUUUUn...OOO.OO...',
160 '...UUUUUn.OOOOOOOO..',
161 '.....OOOOOOOOO......',
162 '....OOOOCCCOO.......',
163 'O...OOOOCCCCn.......',
164 'O..OOOOOCCCO........',
165 '.OOOOO..OCCO........',
166 '.....n..n..n........',
167 ],
168 stepRow: '......n..n.n........',
169 mouth: { x: 18, y: 5 },
170}
171
172const doze: Creature = {
173 art: [
174 '................',
175 '................',
176 '................',
177 '...A.......A....',
178 '...aA.....Aa....',
179 '...aAAAAAAAa....',
180 '....AAAAAAA.....',
181 '....A..A..A.....',
182 '.....AAAAA......',
183 '....BBBBBBB.....',
184 '...ABBBBBBBA..A.',
185 '...AAAA.AAAA.AA.',
186 ],
187 eyes: [],
188 body: 'A',
189 isAsleep: true,
190 wide: 16,
191 moves: ['teleport'],
192 focus: { x: 3, y: 7 },
193}
194
195const spoon: Creature = {
196 art: [
197 '................',
198 '...A.......A....',
199 '...aA.....Aa....',
200 '...aAAARAAAa....',
201 '....AARRRAA.....',
202 '....A.AAA.A.....',
203 '....AAAAAAA.....',
204 '..a..AAAAA..akk.',
205 '.a..BBBBBBB..akk',
206 '...ABBBBBBBA.k..',
207 '...A.BBBBB.Ak...',
208 '.....AA.AA......',
209 ],
210 eyes: [{ x: 5, y: 5 }, { x: 9, y: 5 }],
211 body: 'A',
212 wide: 16,
213 moves: ['teleport', 'mindbeam'],
214 spoons: [{ x: 14, y: 7 }, { x: 15, y: 7 }, { x: 14, y: 8 }, { x: 15, y: 8 }, { x: 13, y: 9 }, { x: 12, y: 10 }],
215 focus: { x: 7, y: 4 },
216}
217
218const sage: Creature = {
219 // A row of headroom above, so its float stays on the strip.
220 art: [
221 '..................',
222 '...A.......A......',
223 '...aA.....Aa......',
224 '...AAAAAAAAA......',
225 '...AA.AAA.AA......',
226 '....AAAAAAA.......',
227 '....aAAAAAa.......',
228 '...BaBBBBBaB......',
229 '..BBa.BBB.aBB.....',
230 '.k.Aa.AAA.aA.k....',
231 'kk.A.aAAAa.A.kk...',
232 '.....A...A........',
233 ],
234 eyes: [{ x: 5, y: 4 }, { x: 9, y: 4 }],
235 body: 'A',
236 wide: 18,
237 moves: ['mindbeam', 'mindwave'],
238 spoons: [{ x: 1, y: 9 }, { x: 0, y: 10 }, { x: 1, y: 10 }, { x: 13, y: 9 }, { x: 13, y: 10 }, { x: 14, y: 10 }],
239 focus: { x: 7, y: 4 },
240 isFloating: true,
241}
242
243const LINES: Readonly<Record<TallLine, readonly [Creature, Creature, Creature]>> = {
244 lizard: [spark, blaze, inferno],
245 fox: [doze, spoon, sage],
246}
247
248/** How wide a tall form is drawn, so home stands it at the strip's right end. */
249export function tallWidth(line: TallLine, stage: number): number {
250 return LINES[line][stage]?.wide ?? 16
251}
252
253export function tallMoves(line: TallLine, stage: number): readonly TallMove[] {
254 return LINES[line][stage]?.moves ?? []
255}
256
257/** How long each move takes, in ticks; all start and end inside one ATTACK_EVERY. */
258const MOVE_TICKS: Readonly<Record<TallMove, number>> = {
259 ember: 22,
260 flamethrower: 22,
261 fireburst: 26,
262 teleport: 18,
263 mindbeam: 22,
264 mindwave: 24,
265}
266
267export type TallAttack = { move: TallMove; t: number }
268
269/** As the Toad line's attackAt: another session finished and this one is free, so every few seconds an attack. */
270export function tallAttackAt(line: TallLine, stage: number, look: Look, nudge: Nudge, tick: number, isWalking: boolean): TallAttack | undefined {
271 if (nudge !== 'turn' || isWalking || (look !== 'idle' && look !== 'listening')) return undefined
272 const moves = tallMoves(line, stage)
273 const move = moves[Math.floor(tick / ATTACK_EVERY) % moves.length]
274 const t = tick % ATTACK_EVERY
275 if (!move || t >= MOVE_TICKS[move]) return undefined
276
277 return { move, t }
278}
279
280/** A fixed scatter, so a teleport dissolves the same way each time. */
281function scatter(x: number, y: number): number {
282 let h = Math.imul(x + 1, 0x27d4eb2d) ^ Math.imul(y + 7, 0x165667b1)
283 h = Math.imul(h ^ (h >>> 15), 0x2c1b3c6d)
284 return ((h ^ (h >>> 13)) >>> 0) / 4294967296
285}
286
287/** Rows dissolved by `amount` (0 whole, 1 gone), a few pixels left as psychic sparks. */
288function dissolve(rows: readonly string[], amount: number, tick: number): string[] {
289 return rows.map((row, y) =>
290 [...row]
291 .map((ch, x) => (ch === '.' || scatter(x, y) >= amount ? ch : scatter(x + tick, y) > 0.86 ? (tick % 2 ? 'h' : 'v') : '.'))
292 .join(''),
293 )
294}
295
296/** Where a flame of `size` sits so its bottom centre matches the form's own. */
297function flameBase(creature: Creature, at: Point): Point {
298 const art = FLAMES[creature.flame?.size ?? 0]![0]!
299 return { x: at.x + Math.floor(art[0]!.length / 2), y: at.y + art.length }
300}
301
302function stampFlame(c: Canvas, art: Art, base: Point, facing: 1 | -1): void {
303 const w = art[0]!.length
304 c.stamp(base.x - Math.floor(w / 2), base.y - art.length, mirrored(art, facing))
305}
306
307/**
308 * How high the tail flame burns for a look: low asleep or after a slip, high
309 * when excited or set on something, as the flame shows the creature's mood.
310 */
311function flameLevel(creature: Creature, look: Look): number {
312 const own = creature.flame?.size ?? 0
313 if (look === 'sleeping') return -2
314 if (look === 'oops') return -1
315 if (look === 'excited' || look === 'celebrate' || look === 'determined' || look === 'evolving') return Math.min(2, own + 1)
316 return own
317}
318
319function flameArt(level: number, tick: number): Art {
320 const frames = level >= 0 ? FLAMES[level]! : LOW_FLAMES[-level - 1]!
321 return frames[Math.floor(tick / 2) % 3]!
322}
323
324/** How far a form may lift (a hop, a float) before its top would leave the strip. */
325function headroom(creature: Creature): number {
326 const top = creature.art.findIndex(row => /[^.]/.test(row))
327 return Math.max(0, top)
328}
329
330/** Front-on at `x`, lifted `dy`, with its eyes, flame and spoons for the look. */
331function drawFront(c: Canvas, creature: Creature, x: number, dy: number, look: Look, tick: number, shut: boolean, glow: boolean): void {
332 c.stamp(x, dy, creature.art)
333 if (shut || creature.isAsleep) for (const { x: ex, y: ey } of creature.eyes) c.put(x + ex, ey + dy, creature.body)
334 if (creature.flame) {
335 const base = flameBase(creature, { x: x + creature.flame.front.x, y: creature.flame.front.y + dy })
336 stampFlame(c, flameArt(flameLevel(creature, look), tick), base, 1)
337 }
338 if (creature.spoons) {
339 const glint = glow ? (tick % 2 === 0 ? 'h' : 'W') : tick % 24 < 2 ? 'W' : 'k'
340 for (const { x: sx, y: sy } of creature.spoons) c.put(x + sx, sy + dy, glint)
341 }
342}
343
344/** Side-on at `x`, facing `facing`, mid-stride on alternate steps: the Lizard line walking or breathing fire. */
345function drawSide(c: Canvas, creature: Creature, x: number, facing: 1 | -1, isStep: boolean, tick: number): void {
346 const side = creature.side ?? creature.art
347 const body = isStep && creature.stepRow ? [...side.slice(0, TALL_H - 1), creature.stepRow] : side
348 c.stamp(x, 0, mirrored(body, facing))
349 if (creature.flame) {
350 const art = flameArt(creature.flame.size, tick)
351 const w = art[0]!.length
352 const fx = facing > 0 ? creature.flame.side.x : creature.wide - creature.flame.side.x - w
353 c.stamp(x + fx, creature.flame.side.y, mirrored(art, facing))
354 }
355}
356
357const BURST = ['.Y.', 'YRY', '.Y.']
358const FIREBALL = [['FY', 'RF'], ['YF', 'FR']]
359const BIG_FIREBALL = [['.FY', 'FYW', 'RF.'], ['YF.', 'WYF', '.FR']]
360// Fire Burst's burst: an eight-pointed star.
361const STAR = ['Y..Y..Y', '.Y.Y.Y.', '..YYY..', 'YYYWYYY', '..YYY..', '.Y.Y.Y.', 'Y..Y..Y']
362const PSY = ['h', 'v', 'Y', 'v']
363
364/** One frame of a move, the form at `x` facing the open space. */
365function drawAttack(c: Canvas, creature: Creature, attack: TallAttack, x: number, facing: 1 | -1, tick: number): void {
366 const { move, t } = attack
367 if (move === 'ember' || move === 'flamethrower' || move === 'fireburst') {
368 drawSide(c, creature, x, facing, false, tick)
369 const mouth = creature.mouth ?? { x: creature.wide - 1, y: 6 }
370 const mouthX = facing > 0 ? x + mouth.x : x + creature.wide - 1 - mouth.x
371 const out = (d: number) => mouthX + facing * d
372 if (move === 'ember') {
373 // Three embers, one after another, arcing down as they fly.
374 for (const at of [2, 6, 10]) {
375 const d = (t - at) * 2
376 if (t < at || d > 26) continue
377 if (d > 22) c.stamp(out(24) - 1, mouth.y + 2, BURST)
378 else c.stamp(out(d) - (facing > 0 ? 0 : 1), mouth.y - 1 + Math.round((d / 12) ** 2), FIREBALL[t % 2]!)
379 }
380 if (t < 12 && t % 4 < 2) c.put(out(0), mouth.y, 'Y')
381 } else if (move === 'flamethrower') {
382 // A breath in, then a cone of flame that pours out and pulls away.
383 if (t < 3) c.put(out(0), mouth.y, t === 2 ? 'Y' : 'R')
384 else {
385 const reach = Math.min(26, (t - 2) * 4)
386 const gone = t < 16 ? 0 : (t - 15) * 5
387 for (let d = gone; d < reach; d++) {
388 const spread = Math.min(2, Math.floor(d / 6))
389 c.put(out(d), mouth.y, (d + t) % 3 === 0 ? 'W' : 'Y')
390 for (let k = 1; k <= spread; k++) {
391 if ((d + k + t) % 4 !== 0) c.put(out(d), mouth.y - k, k === spread ? 'R' : 'F')
392 if ((d + k * 2 + t) % 4 !== 1) c.put(out(d), mouth.y + k, k === spread ? 'R' : 'F')
393 }
394 }
395 }
396 } else {
397 // Fire Burst: light gathers, a fireball flies, and bursts into a star.
398 const target = 30
399 if (t < 4) c.stamp(out(1) - 1, mouth.y - 1, t < 2 ? ['.Y.', 'Y.Y', '.Y.'] : ['.F.', 'FWF', '.F.'])
400 else if (t < 11) c.stamp(out(Math.min(target, (t - 4) * 5)) - 1, mouth.y - 1, mirrored(BIG_FIREBALL[t % 2]!, facing))
401 else if (t < 24) {
402 const hue = (ch: string) => (ch === 'Y' ? (['Y', 'F', 'W', 'Y', 'R'][t % 5] ?? 'Y') : ch)
403 const art = t < 13 ? ['.Y.', 'YWY', '.Y.'] : STAR
404 c.stamp(out(target) - Math.floor(art[0]!.length / 2), 6 - Math.floor(art.length / 2), art, false, hue)
405 }
406 }
407 return
408 }
409
410 const dy = Math.max(-headroom(creature), creature.isFloating && tick % 16 < 8 ? -1 : 0)
411 const focus = creature.focus ?? { x: Math.floor(creature.wide / 2), y: 4 }
412 if (move === 'teleport') {
413 // Gone in a scatter of sparks, out into the open space, and back.
414 const away = x + facing * 18
415 const at = (x0: number) => {
416 const own = new Canvas(c.width, TALL_H)
417 drawFront(own, creature, x0, dy, 'idle', tick, false, true)
418 return own.rows()
419 }
420 const frame =
421 t < 4 ? dissolve(at(x), (t + 1) / 4, tick) : t < 9 ? dissolve(at(away), 1 - (t - 3) / 5, tick) : t < 13 ? dissolve(at(away), (t - 8) / 4, tick) : dissolve(at(x), 1 - (t - 12) / 5, tick)
422 c.stamp(0, 0, frame)
423 return
424 }
425
426 drawFront(c, creature, x, dy, 'idle', tick, false, true)
427 const origin = { x: x + focus.x, y: focus.y + dy }
428 if (move === 'mindbeam') {
429 // The star flares, then a beam of shifting colour with rings rolling along it.
430 if (t < 3) c.put(origin.x, origin.y, t % 2 ? 'W' : 'Y')
431 else {
432 const reach = (t - 2) * 6
433 const gone = t < 16 ? 0 : (t - 15) * 8
434 for (let d = Math.max(4, gone); d < reach; d++) {
435 const px = origin.x + facing * d
436 if (px < 0 || px >= c.width) break
437 c.put(px, origin.y, PSY[Math.floor((d + t) / 2) % PSY.length]!)
438 if ((d - t * 2) % 6 === 0) {
439 c.put(px, origin.y - 1, 'v')
440 c.put(px, origin.y + 1, 'v')
441 }
442 }
443 }
444 return
445 }
446 // Mind Wave: rings ripple out from its head, one after another, each a ( or ).
447 for (const start of [2, 6, 10]) {
448 const r = (t - start) * 3 + 4
449 if (t < start || r > 40) continue
450 const h = Math.min(5, Math.floor(r / 2))
451 for (let k = -h; k <= h; k++) c.put(origin.x + facing * (r - Math.round(k * k * 0.4)), origin.y + k, (k + start) % 2 === 0 ? 'h' : 'v')
452 }
453}
454
455/** Front-on and still with its eyes closed: what an evolution's silhouette is cut from. */
456export function tallStill(line: TallLine, stage: number, width: number, x: number): string[] {
457 const creature = LINES[line][stage]!
458 const c = new Canvas(width, TALL_H)
459 drawFront(c, creature, x, 0, 'idle', 0, true, false)
460 return c.rows()
461}
462
463/** A tall line's strip: the form for `stage` at `x`, for the look, the walk and the nudge. */
464export function tallPixels(line: TallLine, stage: number, width: number, x: number, look: Look, tick: number, stride: Stride, nudge: Nudge): string[] {
465 const creature = LINES[line][stage]!
466 const c = new Canvas(width, TALL_H)
467 const side = x + creature.wide + 7 <= width ? 1 : -1
468 const pose = poseFor(look, tick)
469 const attack = pose.effect === 'none' ? tallAttackAt(line, stage, look, nudge, tick, stride.isWalking) : undefined
470 if (attack) {
471 drawAttack(c, creature, attack, x, side, tick)
472 return c.rows()
473 }
474
475 if (stride.isWalking && creature.side) {
476 drawSide(c, creature, x, stride.facing, tick % 4 < 2, tick)
477 return c.rows()
478 }
479
480 const float = creature.isFloating && tick % 16 < 8 ? -1 : 0
481 const dy = Math.max(-headroom(creature), pose.dy + float)
482 const shut = pose.eyes === 'blink' || pose.eyes === 'shut' || pose.eyes === 'happy'
483 const glow = pose.glow || look === 'delegating' || look === 'thinking'
484 drawFront(c, creature, x, dy, look, tick, shut, glow)
485 effectAt(c, pose.effect, x, dy + EFFECT_Y, tick, side, creature.wide, EFFECT_Y)
486 if (pose.effect === 'none' && nudge !== 'none') {
487 const at = side > 0 ? x + creature.wide + 1 : x - 2
488 if (nudge === 'needs' && tick % 8 < 5) c.stamp(at, EFFECT_Y + 1, ['y', 'y', '.', 'y'])
489 if (nudge === 'turn') {
490 // Another session finished while this one works: an ember, or a psychic spark, beside it.
491 const bob = EFFECT_Y + 2 + (Math.floor(tick / 6) % 2)
492 c.stamp(at, bob, creature.flame ? (tick % 4 < 2 ? ['Y', 'R'] : ['F', 'R']) : [tick % 4 < 2 ? 'h' : 'v'])
493 }
494 }
495
496 const rows = c.rows()
497 // Teleporting: dissolving out of one place or into the next.
498 return stride.fade ? dissolve(rows, stride.fade, tick) : rows
499}
500