SLOPSHOPPER

sprout

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.

newbandguardcommandtoastprompt
v0.1.0MITupdated 2026-10-14sgript-labs/claude-mods/sprout
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sprout
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /avatar ⎿ sprout: Sprout is already the Toad line. ▄ ▄▀▀▄ ▄ model ▀▀▀▄▄▄▀▀▀▀▀▀▄▄▄ effort ▀ ▀▄▄▀▀▄▄▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▄ ▄▀▀▄ ▄ model ▀▀▀▄▄▄▀▀▀▀▀▀▄▄▄ effort ▀ ▀▄▄▀▀▄▄▀
README

Claude Code mods: Sprout and friends

Three mods for Claude Code, made by Sgript. Built and tested on Claude Code 2.1.296.

ModWhat it does
sproutA 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.
caffeinateKeeps 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-hubA 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.

Avatars

Sprout can be any of three lines, each with three forms:

/avatarFormsGets about byAttacks
toadSprout, Bud, BloomwalkingVine Lash, Leaf Volley, Sunbeam
lizardSpark, Blaze, Infernowalking side-onEmber, Flamethrower, Fire Burst
foxDoze, Spoon, SageteleportingTeleport, 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.

Install

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.

Working on the mods

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.

Licence

MIT, © 2026 Sgript.

Source 13 files
hooks/register.tsx 409 lines
1import { 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}
409
hooks/avatar.ts 97 lines
1import 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}
97
hooks/cup.ts 40 lines
1/**
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}
40
hooks/hub.ts 23 lines
1/**
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}
23
hooks/engine.ts 41 lines
1/** 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}
41
hooks/evolve.ts 27 lines
1import 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}
27
hooks/mood.ts 88 lines
1import 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}
88
hooks/flipbook.ts 57 lines
1import 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}
57
hooks/names.ts 34 lines
1import 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}
34
hooks/sprite.ts 604 lines
1import 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}
604
hooks/walk.ts 90 lines
1import 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}
90
hooks/lines.ts 500 lines
1import 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