SLOPSHOPPER

clawd-spinner

Clawd acts out the spinner's word above it while Claude works: cooking for Sautéing, dancing for Vibing, pacing for Pondering, 11 acts across all 179 words.

newspinnerprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-spinner
› 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 ▄█████▄ ▀▀▀▀▀ ✻ Thinking (0s) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Spinner
▄█████▄ ▀▀▀▀▀ ✻ Thinking (0s)
README

clawd-spinner

A Claude Code mod: while Claude works, Clawd acts out the spinner word above the spinner line.

  • Sautéing → cooking · Pondering → pacing and thinking · Moonwalking → dancing (backwards)
  • 11 acts: cook, think, dance, build, compute, herd, stroll, weather, garden, space, magic
  • Covers all 179 built-in spinner words. Anything unmatched gets the magic act.

How a word picks an act

Claude Code picks a random spinner word each turn. actFor() in hooks/acts.ts matches it against stem lists (saut|whisk|brew… → cook, ponder|mull|ruminat… → think, …). The first match wins.

Install

/plugin marketplace add Saiharsharudra03/clawd-spinner
/plugin install clawd-spinner@clawd-spinner

Or from a clone: claude --plugin-dir ./clawd-spinner.

Terminal only. Needs a window at least 54 columns wide; narrower, the stock spinner shows.

Develop

claude plugin validate .
claude plugin test .

/plugin-types .claude-plugin/types regenerates the editor typings (git-ignored).

Credits

Spinner word list: shanraisshan/claude-code-best-practice (reports/claude-spinner-verbs-and-tips.md). Clawd is Anthropic's mascot; this is an unofficial fan mod.

MIT licensed.

Source 2 files
hooks/register.tsx 76 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import * as A from './acts'
4
5const FRAME_MS = 83  // ~12 frames a second
6const EDGE = 4  // columns kept clear at the right edge
7
8type $ = EngineInterface
9
10/** The spinner row while a turn runs. Module state: a reload starts the act over, which is fine. */
11const spin = {
12  turnAt: 0, word: '', working: false, last: '', blit: true,
13  mount: null as { requestId: string; columns: number } | null,
14}
15
16async function paint($: $) {
17  const m = spin.mount
18  if (!m || !spin.working) return
19  const now = await $.clock.now()
20  const cells = A.encode(A.frame({ word: spin.word, t: now - spin.turnAt }, m.columns))
21  if (cells === spin.last) return
22  spin.last = cells
23  if (!spin.blit) {
24    // This row refused blits: redraw it instead (the engine folds these to its own rate).
25    $.ui.invalidate('ui.render')
26    return
27  }
28  const r = await $.ui.blit({ requestId: m.requestId, key: 'act', cells })
29  if ('deny' in r && r.deny) {
30    spin.blit = false
31    $.ui.invalidate('ui.render')
32  }
33}
34
35export const register: Register = on => {
36  on('session.start', async ($, e, next) => {
37    if (e.isInteractive) $.clock.every(FRAME_MS, () => void paint($))
38    return next(e)
39  })
40
41  on('prompt.submit', async ($, e, next) => {
42    spin.turnAt = await $.clock.now()
43    spin.working = true
44    return next(e)
45  })
46
47  on('turn.complete', async ($, e, next) => {
48    const done = await next(e)
49    if (!e.agentId) [spin.working, spin.mount] = [false, null]
50    return done
51  })
52
53  // Clawd acts out the word, and under him the spinner's line: the word and the turn's time.
54  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
55    const columns = (e.viewport?.columns ?? 0) - EDGE
56    if (e.surface !== 'terminal' || columns < 50) {
57      spin.mount = null
58      return next(e)
59    }
60    const { Raster, Box, Text } = $.ui.resolve(e)
61    const now = await $.clock.now()
62    if (!spin.working) [spin.working, spin.turnAt] = [true, now]  // a reload mid-turn
63    if (spin.mount?.requestId !== e.requestId) spin.blit = true
64    spin.word = e.props.word
65    spin.mount = { requestId: e.requestId, columns }
66    const cells = A.encode(A.frame({ word: spin.word, t: now - spin.turnAt }, columns))
67    spin.last = cells
68    return (
69      <Box flexDirection="column">
70        <Raster key="act" columns={columns} rows={A.ROWS} cells={cells} />
71        <Text><Text color="#d97757">✻ {e.props.message ?? e.props.word}{e.props.suffix}</Text><Text dimColor> ({A.elapsed(now - spin.turnAt)})</Text></Text>
72      </Box>
73    )
74  })
75}
76
hooks/acts.ts 440 lines
1// Clawd acts out the spinner's word ("Sautéing", "Vibing", "Pondering"...), in the spinner's row.
2// Pure: frame() turns (the word, time, width) into Raster cells. Each cell is two pixels stacked (▀).
3
4export type Act = 'cook' | 'think' | 'dance' | 'build' | 'compute' | 'stroll' | 'herd' | 'weather' | 'garden' | 'space' | 'magic'
5
6export type Moment = {
7  word: string  // the spinner's word, as drawn
8  t: number  // ms since the turn began
9}
10
11export const ROWS = 8
12export const PH = ROWS * 2
13const DEF = 0x01000000  // the terminal's own colour: transparent here
14const GROUND = PH - 1
15
16// ------------------------------------------------------------------ which act
17
18// Stems of Claude Code's 179 spinner words, grouped by what Clawd can act out. Anything else: magic.
19const STEMS: [Act, RegExp][] = [
20  ['cook', /bak|blanch|brew|bunn|caramel|concoct|cook|drizzl|ferment|flamb|frost|garnish|infus|marinat|percolat|proof|saut|smoosh|stew|temper|whisk|zest/],
21  ['think', /cerebrat|cogitat|consider|contemplat|deciph|deliberat|determin|elucidat|envision|ideat|imagin|infer|mull|musing|philosoph|ponder|pontificat|puzzl|ruminat|think|perus|bloviat|befuddl|flummox|combobulat|newspaper/],
22  ['dance', /bebop|boogie|choreograph|frolic|groov|jitterbug|moonwalk|sock-hop|vib|razzle|razzmatazz|hullaballoo|tomfool|topsy|wibbl|undulat|noodl|flibbertigibbet|harmoniz|compos|orchestrat|honk|improvis|twist/],
23  ['build', /architect|bootstrap|craft|creat|forg|form|generat|sketch|doodl|embellish|tinker|accomplish|action|actualiz|^doing|effect|working|finagl|fiddle|boondoggl|gitif/],
24  ['compute', /calculat|churn|comput|crunch|hash|process|reticulat|spinn|whirr|(?<!photo)synthes|quantum|ioniz|nucleat|crystal|coalesc/],
25  ['herd', /herd|wrangl|muster/],
26  ['stroll', /dilly|gallivant|lollygag|meander|mosey|perambulat|putter|scamper|schlep|scurr|waddl|wander|zigzag|gallop|slither|pounc|spelunk/],
27  ['weather', /billow|evaporat|gust|mist|nebuliz|precipitat|thunder|cascad|flow|ebb|whirlpool|swirl|unfurl|osmos|sublimat/],
28  ['garden', /cultivat|germinat|photosynth|pollinat|propagat|sprout|nest|roost|incubat|^hatch|symbiot|burrow|flutter/],
29  ['space', /beam|catapult|hyperspac|levitat|orbit|swoop|warp/],
30]
31
32export function actFor(word: string): Act {
33  const w = word.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
34  return STEMS.find(([, re]) => re.test(w))?.[0] ?? 'magic'
35}
36
37// ------------------------------------------------------------------ pixels
38
39type Canvas = { W: number; px: Uint32Array }
40type Cell = [number, number, number]
41
42const canvas = (W: number): Canvas => ({ W, px: new Uint32Array(W * PH).fill(DEF) })
43
44function put(c: Canvas, x: number, y: number, colour: number) {
45  x = Math.round(x)
46  y = Math.round(y)
47  if (x >= 0 && x < c.W && y >= 0 && y < PH) c.px[y * c.W + x] = colour
48}
49
50function rect(c: Canvas, x: number, y: number, w: number, h: number, colour: number) {
51  for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) put(c, x + i, y + j, colour)
52}
53
54function sprite(c: Canvas, art: string[], pal: Record<string, number>, x: number, y: number, flip = false) {
55  const w = Math.max(...art.map(r => r.length))
56  art.forEach((row, j) => [...row].forEach((ch, i) => ch !== '.' && pal[ch] !== undefined && put(c, x + (flip ? w - 1 - i : i), y + j, pal[ch]!)))
57}
58
59/** A stable random number in [0, 1) for the same inputs. */
60export function rnd(a: number, b = 0): number {
61  let h = Math.imul(a ^ 0x9e3779b9, 0x85ebca6b) ^ Math.imul(b + 0x632be5ab, 0xc2b2ae35)
62  h = Math.imul(h ^ (h >>> 15), 0x27d4eb2f)
63  return ((h ^ (h >>> 13)) >>> 0) / 4294967296
64}
65
66const mix = (a: number, b: number, k: number) => {
67  k = Math.min(1, Math.max(0, k))
68  const ch = (s: number) => Math.round(((a >> s) & 255) * (1 - k) + ((b >> s) & 255) * k) << s
69  return ch(16) | ch(8) | ch(0)
70}
71
72const wave = (t: number, period: number, phase = 0) => (Math.sin((t / period) * Math.PI * 2 + phase) + 1) / 2
73
74// ------------------------------------------------------------------ Clawd
75
76const CLAWD = 0xd97757
77const SPARKS = [0xff4b3e, 0xffd54f, 0xdfe7ff, 0xff9e3d]
78const CLAWD_ART = [
79  '..CCCCCCCCCC..',
80  '..CCECCCCECC..',
81  '..CCECCCCECC..',
82  '..CCCCCCCCCC..',
83  '..CCCCCCCCCC..',
84]
85const LEGS = ['..C.C....C.C..', '...C.C..C.C...']
86const CLAWD_TOP = GROUND - CLAWD_ART.length - 1
87const START = 3
88
89type Pose = 'stand' | 'cast' | 'walk' | 'pan' | 'hammer' | 'crank' | 'dance' | 'carry' | 'water' | 'umbrella' | 'float'
90type Hands = { hand: [number, number]; tip: [number, number] | null }
91
92/** Clawd (no hat here) at x, facing right (1) or left (-1), lifted `lift` pixels. */
93function clawd(c: Canvas, t: number, pose: Pose, x: number, facing: 1 | -1 = 1, lift = 0): Hands {
94  const f = Math.floor(t / 83)
95  const blink = f % 47 < 2
96  const ox = Math.round(x)
97  const hop = pose === 'dance' ? -(Math.floor(t / 300) % 2) : 0
98  const breathe = (pose === 'stand' || pose === 'cast') && Math.floor(t / 700) % 2 ? 1 : 0
99  const y = CLAWD_TOP + hop + breathe - lift
100  const at = (i: number) => (facing > 0 ? ox + i : ox + 13 - i)
101  const P = (i: number, yy: number, colour: number) => put(c, at(i), yy, colour)
102  const shade = mix(CLAWD, 0x000000, 0.18)
103  const pal: Record<string, number> = { C: CLAWD, E: blink ? CLAWD : 0x1a1410 }
104  CLAWD_ART.forEach((row, j) => [...row].forEach((ch, i) => ch !== '.' && pal[ch] !== undefined && P(i, y + j, pal[ch]!)))
105  for (let i = 2; i < 12; i++) P(i, y + 4, shade)
106  const stepping = pose === 'walk' || pose === 'dance' || pose === 'carry' || pose === 'umbrella'
107  ;[...LEGS[stepping ? (f >> 1) % 2 : 0]!].forEach((ch, i) => ch === 'C' && P(i, CLAWD_TOP + CLAWD_ART.length + hop - lift, shade))
108  const armY = y + 2
109  const beat = Math.floor(t / 300) % 2
110  const left: [number, number][] = pose === 'dance' && beat === 0 ? [[1, armY - 1], [0, armY - 2]] : [[1, armY], [0, armY]]
111  let right: [number, number][] = [[12, armY], [13, armY]]
112  if (pose === 'cast' || (pose === 'dance' && beat === 1) || pose === 'umbrella') right = [[12, armY - 1], [13, armY - 2]]
113  if (pose === 'hammer') right = t % 700 < 450 ? [[12, armY - 1], [13, armY - 2]] : [[12, armY], [13, armY + 1]]
114  if (pose === 'crank') right = [[12, armY], [13 + Math.round(Math.cos(t / 250)), armY + Math.round(Math.sin(t / 250))]]
115  if (pose === 'pan' || pose === 'water' || pose === 'carry') right = [[12, armY], [13, armY], [14, armY]]
116  for (const [i, yy] of [...left, ...right]) P(i, yy, CLAWD)
117  const [hi, hy] = right[right.length - 1]!
118  const hand: [number, number] = [at(hi), hy]
119  if (pose !== 'stand' && pose !== 'cast') return { hand, tip: null }
120  const wood = 0x7a4a24
121  P(hi + 1, hy - 1, wood); P(hi + 2, hy - 2, wood)
122  const tip: [number, number] = [at(hi + 3), hy - 3]
123  if (pose === 'cast' || f % 14 < 7) put(c, tip[0], tip[1], SPARKS[f % SPARKS.length]!)
124  return { hand, tip }
125}
126
127// ------------------------------------------------------------------ walking routes
128
129const WALK = 0.016  // pixels per ms
130
131type Stop = { x: number; stay: number; pose: Pose }
132type Where = { x: number; facing: 1 | -1; pose: Pose; stop: number; still: boolean; stayed: number }
133
134/** Clawd's place on a looping route: stands at each stop for `stay` ms, walks between them. */
135function along(stops: Stop[], ms: number, walkPose: Pose = 'walk'): Where {
136  const legs = stops.map((s, i) => {
137    const next = stops[(i + 1) % stops.length]!
138    return { s, next, walk: Math.abs(next.x - s.x) / WALK }
139  })
140  const total = legs.reduce((sum, l) => sum + l.s.stay + l.walk, 0) || 1
141  let t = ms % total
142  for (const [i, l] of legs.entries()) {
143    if (t < l.s.stay) return { x: l.s.x, facing: 1, pose: l.s.pose, stop: i, still: true, stayed: t }
144    t -= l.s.stay
145    if (t < l.walk) return { x: l.s.x + (l.next.x - l.s.x) * (t / l.walk), facing: l.next.x >= l.s.x ? 1 : -1, pose: walkPose, stop: i, still: false, stayed: 0 }
146    t -= l.walk
147  }
148  return { x: stops[0]!.x, facing: 1, pose: stops[0]!.pose, stop: 0, still: true, stayed: 0 }
149}
150
151const spot = (W: number, k: number) => Math.round(Math.max(24, Math.min(W - 24, W * k)))
152
153function ground(c: Canvas, colour = 0x2a2d36) {
154  for (let x = 0; x < c.W; x++) put(c, x, GROUND, colour)
155}
156
157// ------------------------------------------------------------------ the acts
158
159type Glyph = { x: number; row: number; ch: string; fg: number }
160type Draw = (c: Canvas, m: Moment, glyphs: Glyph[]) => void
161
162const cook: Draw = (c, m) => {
163  const fire = spot(c.W, 0.35)
164  const table = spot(c.W, 0.62)
165  // campfire: logs and flames
166  rect(c, fire - 3, GROUND - 1, 7, 1, 0x5a3a22)
167  for (let i = -2; i <= 2; i++) {
168    const h = 1 + Math.floor(rnd(i + 5, Math.floor(m.t / 110)) * 3)
169    for (let k = 0; k < h; k++) put(c, fire + i, GROUND - 2 - k, k === h - 1 ? 0xffd54f : 0xff8c2a)
170  }
171  // prep table with a cutting board and veg
172  rect(c, table - 4, GROUND - 5, 9, 1, 0x7a4a24); put(c, table - 3, GROUND - 4, 0x7a4a24); put(c, table + 3, GROUND - 4, 0x7a4a24)
173  for (let k = 0; k < 3; k++) rect(c, table - 3 + k * 2, GROUND - 4, 1, 4, 0x7a4a24)
174  put(c, table - 1, GROUND - 6, 0x6aa84f); put(c, table + 1, GROUND - 6, 0xd9534f)
175  const at = along([{ x: fire - 19, stay: 3200, pose: 'pan' }, { x: table - 19, stay: 1600, pose: 'hammer' }], m.t)
176  const { hand } = clawd(c, m.t, at.pose, at.x, at.facing)
177  if (at.pose === 'pan' && at.still) {
178    rect(c, hand[0] + 1, hand[1], 5, 1, 0x3a3a42)  // the pan over the fire
179    const flip = (m.t % 1400) / 1400
180    if (flip < 0.6) {
181      const fx = hand[0] + 3
182      const fy = hand[1] - Math.sin((flip / 0.6) * Math.PI) * 6
183      put(c, fx, fy, 0xfff3d6); put(c, fx + 1, fy, 0xffd54f)
184    } else { put(c, hand[0] + 3, hand[1] - 1, 0xfff3d6); put(c, hand[0] + 4, hand[1] - 1, 0xffd54f) }
185    for (let k = 0; k < 3; k++) {  // steam
186      const life = ((m.t / 120) + k * 5) % 8
187      put(c, hand[0] + 2 + k, hand[1] - 2 - life, mix(0x9aa0b0, 0x1a1c24, life / 8))
188    }
189  }
190  if (at.pose === 'hammer' && at.still && m.t % 700 > 450) put(c, table + (rnd(Math.floor(m.t / 700)) > 0.5 ? 1 : -1), GROUND - 7, 0x6aa84f)
191  ground(c)
192}
193
194const think: Draw = (c, m, glyphs) => {
195  const at = along([{ x: START, stay: 1800, pose: 'stand' }, { x: spot(c.W, 0.45), stay: 1800, pose: 'stand' }], m.t)
196  clawd(c, m.t, at.pose, at.x, at.facing)
197  const hx = Math.round(at.x) + (at.facing > 0 ? 13 : 0)
198  const dir = at.facing
199  put(c, hx + dir * 2, CLAWD_TOP + 1, 0xc8ccd8)
200  put(c, hx + dir * 4, CLAWD_TOP - 1, 0xc8ccd8)
201  // the thought: a cloud, and now and then a lit bulb
202  const cx = hx + dir * 6 - (dir < 0 ? 8 : 0)
203  const eureka = m.t % 6000 > 4800
204  if (eureka) {
205    sprite(c, ['.yyy.', 'yyyyy', 'yyyyy', '.yyy.', '.ggg.'], { y: 0xffe066, g: 0x9aa0b0 }, cx + 2, 0)
206    for (const [dx, dy] of [[-1, 1], [7, 1], [3, -1]]) if (m.t % 300 < 150) put(c, cx + 2 + dx!, dy!, 0xfff6c8)
207  } else {
208    sprite(c, ['.ccccc.', 'ccccccc', '.ccccc.'], { c: 0x5e6478 }, cx, 0)
209    const dots = Math.floor(m.t / 400) % 4
210    for (let k = 0; k < dots; k++) put(c, cx + 2 + k * 2, 1, 0xe8e4c8)
211  }
212  ground(c)
213  void glyphs
214}
215
216const dance: Draw = (c, m, glyphs) => {
217  const moon = /moonwalk/i.test(m.word)
218  const at = along([{ x: START, stay: 900, pose: 'dance' }, { x: spot(c.W, 0.5), stay: 900, pose: 'dance' }], m.t, 'dance')
219  // moonwalking: facing away from where he glides
220  clawd(c, m.t, 'dance', at.x, moon ? (at.facing > 0 ? -1 : 1) : at.facing)
221  // the disco floor under him
222  const beat = Math.floor(m.t / 300)
223  for (let x = 0; x < c.W; x++) put(c, x, GROUND, Math.abs(x - at.x - 7) < 14 ? [0xd94f8a, 0x4fa3d9, 0xd9c84f, 0x6ad94f][(Math.floor(x / 3) + beat) % 4]! : 0x2a2d36)
224  // notes float up from him
225  for (let k = 0; k < 3; k++) {
226    const life = ((m.t / 700) + k / 3) % 1
227    glyphs.push({ x: Math.round(at.x) + 15 + k * 3 + Math.round(life * 4), row: Math.max(0, 3 - Math.floor(life * 4)), ch: k % 2 ? '♫' : '♪', fg: [0xd94f8a, 0x4fa3d9, 0xd9c84f][k]! })
228  }
229}
230
231const build: Draw = (c, m) => {
232  const anvil = spot(c.W, 0.35)
233  const forge = spot(c.W, 0.65)
234  sprite(c, ['aaaaaaa', '.aaaaa.', '..aaa..', '.aaaaa.'], { a: 0x55555f }, anvil - 3, GROUND - 4)
235  const hot = mix(0xff4b3e, 0xffd54f, wave(m.t, 600))
236  rect(c, anvil - 1, GROUND - 5, 4, 1, hot)
237  // the forge: a brick hearth with a glow
238  sprite(c, ['bbbbbbb', 'b.....b', 'b.....b', 'bbbbbbb'], { b: 0x7a3b2e }, forge - 3, GROUND - 4)
239  for (let i = -2; i <= 2; i++) if (rnd(i + 9, Math.floor(m.t / 120)) > 0.3) put(c, forge + i, GROUND - 2 - Math.floor(rnd(i, Math.floor(m.t / 120)) * 2), rnd(i + 2, Math.floor(m.t / 120)) > 0.5 ? 0xff8c2a : 0xffd54f)
240  const at = along([{ x: anvil - 17, stay: 3500, pose: 'hammer' }, { x: forge - 19, stay: 1200, pose: 'stand' }], m.t, 'carry')
241  const { hand } = clawd(c, m.t, at.pose, at.x, at.facing)
242  if (at.pose === 'hammer') {
243    const down = m.t % 700 >= 450
244    rect(c, hand[0] + 1, hand[1] - (down ? 0 : 2), 2, 2, 0x8a919b)  // hammer head
245    if (down) for (let s = 0; s < 6; s++) {
246      const k = ((m.t % 700) - 450) / 250
247      put(c, anvil + Math.cos(s * 1.1) * k * 5, GROUND - 6 - Math.abs(Math.sin(s * 1.1)) * k * 4, s % 2 ? 0xffd54f : 0xff8c2a)
248    }
249  }
250  if (at.pose === 'carry') rect(c, hand[0] + (at.facing > 0 ? 1 : -3), hand[1] - 1, 3, 1, hot)
251  ground(c)
252}
253
254function gear(c: Canvas, cx: number, cy: number, r: number, angle: number, colour: number) {
255  for (let a = 0; a < 24; a++) put(c, cx + Math.cos((a / 24) * Math.PI * 2) * r, cy + Math.sin((a / 24) * Math.PI * 2) * r, colour)
256  for (let k = 0; k < 6; k++) {
257    const a = angle + (k / 6) * Math.PI * 2
258    put(c, cx + Math.cos(a) * (r + 1), cy + Math.sin(a) * (r + 1), colour)
259  }
260  put(c, cx, cy, mix(colour, 0xffffff, 0.4))
261}
262
263const compute: Draw = (c, m, glyphs) => {
264  const g = spot(c.W, 0.35)
265  const cab = spot(c.W, 0.65)
266  gear(c, g, 9, 4, m.t / 400, 0x8a919b)
267  gear(c, g + 8, 5, 2, -m.t / 200, 0xb08d57)
268  // a cabinet of blinking lights
269  rect(c, cab - 3, GROUND - 9, 7, 9, 0x2e3440)
270  for (let j = 0; j < 4; j++) for (let i = 0; i < 3; i++) if (rnd(i + j * 3, Math.floor(m.t / 250)) > 0.4) put(c, cab - 2 + i * 2, GROUND - 8 + j * 2, [0x6ad94f, 0xd9c84f, 0x4fa3d9][(i + j) % 3]!)
271  const at = along([{ x: g - 19, stay: 3200, pose: 'crank' }, { x: cab - 19, stay: 1400, pose: 'stand' }], m.t)
272  clawd(c, m.t, at.pose, at.x, at.facing)
273  for (let k = 0; k < 3; k++) {
274    const life = ((m.t / 900) + k / 3) % 1
275    glyphs.push({ x: g - 2 + k * 3, row: Math.max(0, 3 - Math.floor(life * 4)), ch: rnd(k, Math.floor(m.t / 900)) > 0.5 ? '1' : '0', fg: mix(0x6ad94f, 0x1a1c24, life * 0.7) })
276  }
277  ground(c)
278}
279
280/** Walks the whole strip and wraps; quicker for scurrying, scampering and galloping. */
281const stroll: Draw = (c, m) => {
282  const fast = /scurr|scamper|gallop|pounc|zigzag/i.test(m.word) ? 2.2 : 1
283  const span = c.W + 18
284  const x = ((m.t * WALK * fast) % span) - 16
285  const lift = /gallop/i.test(m.word) ? Math.floor(m.t / 200) % 2 : 0
286  clawd(c, m.t, 'walk', x, 1, lift)
287  for (let k = 0; k < c.W; k += 9) put(c, k + Math.floor(rnd(k, 1) * 5), GROUND - 1, 0x3d5a3a)  // grass tufts
288  ground(c, 0x2d4a2b)
289}
290
291const herd: Draw = (c, m) => {
292  const span = c.W + 60
293  const x = ((m.t * WALK) % span) - 16
294  clawd(c, m.t, 'walk', x)
295  for (let k = 0; k < 3; k++) {  // sheep trot ahead of him
296    const sx = x + 22 + k * 9
297    const step = Math.floor(m.t / 160 + k) % 2
298    sprite(c, ['.www.', 'wwwwk', '.wwww', step ? '.l.l.' : 'l...l'], { w: 0xeeeeee, k: 0x2a2a30, l: 0x2a2a30 }, sx, GROUND - 4)
299  }
300  ground(c, 0x2d4a2b)
301}
302
303const weather: Draw = (c, m) => {
304  const at = along([{ x: START, stay: 1500, pose: 'umbrella' }, { x: spot(c.W, 0.55), stay: 1500, pose: 'umbrella' }], m.t, 'umbrella')
305  clawd(c, m.t, at.pose, at.x, at.facing)
306  const ux = Math.round(at.x) + 7
307  // umbrella over the hat, and the cloud that follows him
308  rect(c, ux - 6, CLAWD_TOP - 2, 13, 1, 0xd94f4f); rect(c, ux - 4, CLAWD_TOP - 3, 9, 1, 0xd94f4f)
309  for (let j = CLAWD_TOP - 1; j < CLAWD_TOP + 7; j++) put(c, ux + 6, j, 0x5a3a22)
310  const gusty = /gust|billow|swirl|whirl|unfurl/i.test(m.word)
311  const storm = /thunder/i.test(m.word)
312  const cloud = storm ? 0x3a3d4a : 0x6b7086
313  if (!gusty) {
314    rect(c, ux - 7, 0, 15, 1, cloud); rect(c, ux - 5, -1, 9, 1, cloud)
315    for (let d = 0; d < 7; d++) {
316      const fall = ((m.t / 60) + d * 7) % (CLAWD_TOP - 1)
317      const rx = ux - 7 + d * 2
318      if (Math.abs(rx - ux) > 6 || fall < CLAWD_TOP - 4) put(c, rx, 1 + fall, 0x7fa6d6)
319    }
320    for (let x = 0; x < c.W; x++) if (Math.abs(x - ux) > 7 && rnd(x, Math.floor(m.t / 70)) > 0.97) put(c, x, rnd(x, 3) * (GROUND - 1), 0x5c7ea8)
321    if (storm && m.t % 3000 < 160) {
322      let lx = ux + 14
323      for (let y = 0; y < GROUND; y++) { put(c, lx, y, 0xfff6c8); lx += (y % 3) - 1 }
324    }
325  } else {
326    for (let k = 0; k < 6; k++) {  // wind streaks
327      const wx = (m.t / 25 + k * 37) % (c.W + 20) - 10
328      rect(c, wx, 2 + k * 2, 5, 1, mix(0x9aa0b0, 0x1a1c24, 0.4))
329    }
330  }
331  ground(c)
332}
333
334const garden: Draw = (c, m) => {
335  const beds = [0.3, 0.45, 0.6].map(k => spot(c.W, k))
336  for (const [i, bx] of beds.entries()) {  // plants grow, bloom, start over
337    const grow = ((m.t / 4000) + i * 0.33) % 1
338    const h = 1 + Math.floor(grow * 6)
339    for (let k = 0; k < h; k++) put(c, bx, GROUND - 1 - k, 0x4f9a3a)
340    if (h > 2) { put(c, bx - 1, GROUND - 3, 0x6ab84f); put(c, bx + 1, GROUND - 4, 0x6ab84f) }
341    if (h > 5) sprite(c, ['.p.', 'pyp', '.p.'], { p: [0xd94f8a, 0xd9c84f, 0x9a6ad9][i]!, y: 0xffe066 }, bx - 1, GROUND - 1 - h - 2)
342  }
343  if (/nest|roost|incubat|hatch/i.test(m.word)) {  // a nest whose egg wobbles and hatches
344    const nx = spot(c.W, 0.75)
345    rect(c, nx - 3, GROUND - 2, 7, 2, 0x7a5a3a)
346    const crack = m.t % 5000 > 3600
347    if (crack) sprite(c, ['.y.', 'yyy'], { y: 0xffe066 }, nx - 1, GROUND - 5)
348    else rect(c, nx - 1 + (Math.floor(m.t / 200) % 2), GROUND - 4, 2, 2, 0xf3efe0)
349  }
350  const stops = beds.map(bx => ({ x: bx - 18, stay: 1300, pose: 'water' as Pose }))
351  const at = along(stops, m.t)
352  const { hand } = clawd(c, m.t, at.pose, at.x, at.facing)
353  if (at.pose === 'water' && at.still) {
354    rect(c, hand[0] + 1, hand[1] - 1, 3, 2, 0x7f8fa6)  // the watering can
355    for (let d = 0; d < 3; d++) put(c, hand[0] + 4, hand[1] + 1 + ((m.t / 80 + d * 2) % 4), 0x7fa6d6)
356  }
357  // a butterfly
358  const bx = spot(c.W, 0.5) + Math.sin(m.t / 900) * 18
359  const by = 4 + Math.sin(m.t / 300) * 2
360  const flap = Math.floor(m.t / 120) % 2
361  put(c, bx, by, 0x2a2a30); put(c, bx - 1, by - flap, 0xffb22e); put(c, bx + 1, by - flap, 0xffb22e)
362  ground(c, 0x4a3a2a)
363}
364
365const space: Draw = (c, m, glyphs) => {
366  const streak = /hyperspac|warp/i.test(m.word)
367  for (let k = 0; k < c.W / 4; k++) {  // stars, streaking in hyperspace
368    const sx = streak ? (rnd(k, 1) * c.W - (m.t / 8) * (0.5 + rnd(k, 2))) % c.W : rnd(k, 1) * c.W
369    const x = sx < 0 ? sx + c.W : sx
370    const y = rnd(k, 3) * GROUND
371    put(c, x, y, 0xdfe7ff)
372    if (streak) for (let tail = 1; tail < 4; tail++) put(c, x + tail, y, mix(0xdfe7ff, 0x1a1c24, tail / 4))
373  }
374  const at = along([{ x: START, stay: 1600, pose: 'stand' }, { x: spot(c.W, 0.5), stay: 1600, pose: 'stand' }], m.t, 'float')
375  const lift = 2 + Math.round(wave(m.t, 1600) * 2)
376  clawd(c, m.t, at.still ? 'cast' : 'float', at.x, at.facing, lift)
377  // a little planet circles him
378  const a = m.t / 500
379  const px = at.x + 7 + Math.cos(a) * 11
380  const py = 8 + Math.sin(a) * 4
381  rect(c, px - 1, py - 1, 3, 3, 0x6a8fd9); put(c, px - 2, py, 0xd9c84f); put(c, px + 2, py, 0xd9c84f)
382  void glyphs
383}
384
385const magic: Draw = (c, m) => {
386  const target = spot(c.W, 0.4)
387  const at = along([{ x: START, stay: 2600, pose: 'cast' }, { x: target - 26, stay: 2600, pose: 'cast' }], m.t)
388  const { tip } = clawd(c, m.t, at.pose, at.x, at.facing)
389  const cx = Math.round(at.x) + 26
390  if (tip && at.still) {
391    for (let k = 0; k < 10; k++) {  // a swirl of sparkles
392      const a = m.t / 250 + k * 0.63
393      const r = 1 + ((k + m.t / 200) % 10) * 0.45
394      put(c, cx + Math.cos(a) * r * 1.4, 8 + Math.sin(a) * r * 0.7, SPARKS[k % SPARKS.length]!)
395    }
396    // a top hat; the rabbit pops out every few seconds
397    rect(c, cx - 3, GROUND - 1, 7, 1, 0x4a4a5a); rect(c, cx - 2, GROUND - 4, 5, 3, 0x4a4a5a); rect(c, cx - 2, GROUND - 2, 5, 1, 0xae0001)
398    if (at.stayed > 1400) sprite(c, ['w.w', 'w.w', 'www', 'wkw'], { w: 0xf4f4f4, k: 0xe88aa0 }, cx - 1, GROUND - 8)
399  }
400  ground(c)
401}
402
403const ACTS: Record<Act, Draw> = { cook, think, dance, build, compute, stroll, herd, weather, garden, space, magic }
404
405// ------------------------------------------------------------------ cells
406
407/** The frame's cells, packed for a Raster: columns * ROWS triplets of [codePoint, fg, bg]. */
408export function frame(m: Moment, columns: number): Uint32Array {
409  const c = canvas(columns)
410  const glyphs: Glyph[] = []
411  ACTS[actFor(m.word)](c, m, glyphs)
412  const cells: Cell[] = []
413  for (let r = 0; r < ROWS; r++) {
414    for (let x = 0; x < columns; x++) {
415      const top = c.px[2 * r * columns + x]!
416      const bot = c.px[(2 * r + 1) * columns + x]!
417      if (top === DEF && bot === DEF) cells.push([0x20, DEF, DEF])
418      else if (top === bot) cells.push([0x2588, top, DEF])
419      else if (top === DEF) cells.push([0x2584, bot, DEF])
420      else cells.push([0x2580, top, bot])
421    }
422  }
423  for (const g of glyphs) {
424    const i = g.row * columns + g.x
425    if (g.x >= 0 && g.x < columns && g.row >= 0 && g.row < ROWS && cells[i]![0] === 0x20) cells[i] = [g.ch.codePointAt(0)!, g.fg, DEF]
426  }
427  const out = new Uint32Array(cells.length * 3)
428  cells.forEach(([cp, fg, bg], i) => out.set([cp, fg, bg], i * 3))
429  return out
430}
431
432/** Base64 of the cells, as RasterProps.cells wants them. */
433export const encode = (cells: Uint32Array): string => new Uint8Array(cells.buffer).toBase64()
434
435/** "12s", "1m 4s": the turn's time, as the spinner shows it. */
436export const elapsed = (ms: number) => {
437  const s = Math.max(0, Math.floor(ms / 1000))
438  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
439}
440