SLOPSHOPPER

plushie

A plush Clawd above the prompt that reacts to what Claude is doing: works, celebrates, wobbles, blushes, naps, and can be patted and dragged.

newbandguardcommandprompttimer
★ 1v0.2.0MITupdated 2026-10-02xyc/plushie
A shopper browsing a rack in a slop shop
README

plushie

A plush Clawd that lives above the Claude Code prompt and reacts to what Claude is doing: idles and blinks, wiggles while a turn runs, holds a little prop for each tool, wobbles when a tool fails or you interrupt, jumps after a long turn, blushes when you say thanks, dozes off when left alone. Pat it, or pick it up and move it.

It is a Claude Code mod: frames baked in Blender, played in the terminal by swapping an Image's source with $.ui.blit. Terminals with an image protocol (Ghostty, kitty) only; elsewhere it stays out of the way.

An unofficial fan project, not affiliated with or endorsed by Anthropic. Clawd and Claude Code are Anthropic's.

Install

/plugin marketplace add xyc/plushie
/plugin install plushie@plushie

Needs Claude Code 2.1.287 or later with mods on. Mods are rolling out; if /plushie isn't listed after installing, they aren't on for your account yet.

To run it from a checkout instead:

claude --plugin-dir path/to/plushie

Switches

Each is a switch, on by default, in /config or with /plushie on|off <switch>:

SwitchWhat Clawd does
dragdrag it sideways along the band; it dangles while held, lands where you let go, and stays there next session
patsclick it without dragging: a squish, a happy squint, hearts
toolPropsholds a magnifier while reading, knitting needles while editing, a keyboard for the shell, binoculars on the web
miniClawdsa half-size Clawd per running subagent, walking off when it finishes
stuffinggrows rounder as the context window fills, deflates with a sigh on compaction
blushblushes at thanks and praise in your prompt
fidgetsleft alone, now and then looks around, scratches, yawns or hops
soundslittle sounds for pats, jumps, wobbles, blushes, being picked up and put down, deflating and dozing off

Commands

  • /plushie hides or shows it.
  • /plushie <reaction> plays one of Clawd's reactions: happy, oops, pat, blush, sleepy, fidget-yawn, working-binoculars, idle-stuffed, … (every folder in assets/frames).
  • /plushie list shows every reaction, and every switch with its state.
  • /plushie on <switch> / /plushie off <switch> flips a switch.

Rebake the frames

blender/sheet.sh idle:0 happy:16          # contact sheet: build/sheet.png
/Applications/Blender.app/Contents/MacOS/Blender -b --factory-startup \
  -P blender/plush.py -- render build/layers [clip ...]
python3 blender/composite.py build/layers assets/frames [clip ...]

blender/plush.py holds the model, the fur, the props, the lights and every clip's motion, and renders each frame twice: Clawd alone, and the shadow alone. blender/composite.py lays one over the other into assets/frames and writes the manifest the mod reads. The shadow's strength and shape live in the compositor, so changing them is a re-run of composite.py, not a render.

Test

claude plugin test .

The tests drive every reaction through the real event that triggers it, each with its switch on and off.

License

MIT for the code, the Blender and sound scripts, and the frames and sounds they make; see LICENSE. The Clawd character and Claude Code are Anthropic's, and the license doesn't cover them.

Source 3 files
hooks/register.tsx 447 lines
1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { EngineInterface, On, PluginOptions, RenderElement } from 'claude-code'
5
6import type { GrabMessage, GrabProps } from './grab'
7import { FEATURES, LEAVE_FRAMES, PROP_FOR_TOOL, Pet, isManifest, moodOf, sizeFor } from './pet'
8import type { Feature, Manifest } from './pet'
9
10/**
11 * The plush Clawd above the prompt: frames baked by blender/plush.py, played
12 * by swapping one Image's source on a timer, the clip picked by what the
13 * main conversation is doing (pet.ts).
14 *
15 * Only the terminal draws pixels; elsewhere, or in a terminal without an
16 * image protocol, the band is left as it was. Every reaction beyond the base
17 * moods is a `userConfig` switch, on unless turned off.
18 */
19
20type Host = {
21  blit: (requestId: string, key: string, file: string) => Promise<{ deny?: string }>
22  invalidate: () => void
23  now: () => Promise<number>
24  sound: (name: string) => void
25}
26
27/** Everything one load of the plugin keeps between events. */
28type Plushie = {
29  isOn: (feature: Feature) => boolean
30  host: Host | null
31  manifest: Manifest | null
32  pet: Pet | null
33  root: string
34  starting: Promise<void> | null
35  requestId: string | null
36  isShown: boolean
37  // Set when the terminal draws the Image's alt instead of pixels: the pet
38  // stops drawing for the rest of the session.
39  isUnsupported: boolean
40  // The band column of the pet's left edge; null keeps it at the right end.
41  left: number | null
42  bandColumns: number
43  // The size the pet is drawn at: the manifest's, or less when the band is short.
44  petColumns: number
45  // Added to every Image key when the band is short: an Image drawn again at
46  // another size keeps its old placement in the terminal and is clipped, so a
47  // new size gets new Images (the pet's and the minis', whose size follows it).
48  sizeSuffix: string
49  // A press on the pet: where in it the pointer took hold, and whether it has
50  // moved yet (a press that never moves is a pat).
51  grab: { offset: number; downColumn: number; isMoved: boolean } | null
52}
53
54const KEY = 'plush'
55// A turn shorter than this ends without a celebration: quick answers would
56// otherwise make it jump after every message.
57const HAPPY_AFTER_MS = 4000
58const KIND_WORDS =
59  /\b(thanks|thank you|thx|good job|great job|nice work|well done|love it|love you|awesome|you rock)\b/i
60
61// The moods with a sound, by the name of their file in assets/sounds; the
62// rest (idle, working, the fidgets) stay quiet, or Clawd would never stop.
63const SOUNDS = new Set(['pat', 'happy', 'oops', 'blush', 'held', 'drop', 'deflate', 'sleepy'])
64const SOUND_GAIN = 0.6
65// Below this many rows Clawd is too small to read and isn't drawn at all.
66const MIN_ROWS = 4
67
68const clamp = (value: number, low: number, high: number) =>
69  Math.max(low, Math.min(high, Math.round(value)))
70
71const fileOf = (s: Plushie, clip: string, frame: number) =>
72  `${s.root}/assets/frames/${clip}/${String(frame).padStart(3, '0')}.png`
73
74/** The pet's left column in a band this wide: where it was left, or the right end. */
75const leftIn = (s: Plushie, columns: number, petColumns: number) =>
76  clamp(s.left ?? columns - petColumns, 0, Math.max(0, columns - petColumns))
77
78/**
79 * Binds to the engine once per load. A session start does it; so does the
80 * first drawing, since a plugin reloaded by a settings change sees no
81 * session start.
82 */
83function start($: EngineInterface, s: Plushie) {
84  s.starting ??= (async () => {
85    s.root = $.plugin.root
86    const host: Host = {
87      blit: (id, key, file) => $.ui.blit({ requestId: id, key, source: { file, format: 'png' } }),
88      invalidate: () => $.ui.invalidate('ui.render'),
89      now: () => $.clock.now(),
90      sound: name => {
91        if (s.isOn('sounds')) {
92          void $.audio.play({ asset: `assets/sounds/${name}.wav` }, { gain: SOUND_GAIN }).catch(() => undefined)
93        }
94      },
95    }
96    s.host = host
97
98    let parsed: unknown
99    try {
100      parsed = JSON.parse(await $.fs.read(`${s.root}/assets/frames/manifest.json`))
101    } catch {
102      return
103    }
104    if (!isManifest(parsed)) {
105      $.ui.log('plushie: assets/frames/manifest.json is not a manifest this version can play; rebake the frames')
106      return
107    }
108    const manifest: Manifest = parsed
109    s.manifest = manifest
110    s.pet = new Pet(manifest, s.isOn, clip => {
111      const mood = moodOf(clip)
112      if (SOUNDS.has(mood)) {
113        host.sound(mood)
114      }
115    })
116    s.pet.lastActivityMs = await host.now()
117
118    const saved = await $.store.get('left').catch(() => undefined)
119    s.left = typeof saved === 'number' ? saved : null
120
121    await $.command
122      .register({
123        name: 'plushie',
124        description: 'Show or hide the plush Clawd, play one of its reactions, or turn a switch on or off',
125        argumentHint: '[happy | oops | pat | blush | sleepy | … | list | on <switch> | off <switch>]',
126      })
127      .catch(() => undefined)
128
129    $.clock.every(Math.round(1000 / manifest.fps), () => {
130      void tick(s).catch(() => undefined)
131    })
132  })()
133  return s.starting
134}
135
136async function tick(s: Plushie) {
137  const { host, pet, requestId } = s
138  if (!host || !pet || !requestId || !s.isShown || s.isUnsupported) {
139    return
140  }
141  pet.sync(await host.now())
142
143  const blits = [host.blit(requestId, KEY + s.sizeSuffix, fileOf(s, pet.clip, pet.fileFrame()))]
144  const working = pet.framesOf('working')
145  for (const mini of pet.minis) {
146    blits.push(host.blit(requestId, `mini-${mini.id}${s.sizeSuffix}`, fileOf(s, 'working', mini.frame % working)))
147  }
148  const [main] = await Promise.all(blits)
149
150  if (main?.deny && /alt/i.test(main.deny)) {
151    s.isUnsupported = true
152    host.invalidate()
153    return
154  }
155  if (pet.advance()) {
156    host.invalidate()
157  }
158}
159
160async function noteActivity($: EngineInterface, s: Plushie) {
161  if (s.pet) {
162    s.pet.lastActivityMs = await $.clock.now()
163  }
164}
165
166export function register(on: On, options: PluginOptions) {
167  const s: Plushie = {
168    isOn: feature => options[feature] !== false,
169    host: null,
170    manifest: null,
171    pet: null,
172    root: '',
173    starting: null,
174    requestId: null,
175    isShown: true,
176    isUnsupported: false,
177    left: null,
178    bandColumns: 0,
179    petColumns: 0,
180    sizeSuffix: '',
181    grab: null,
182  }
183
184  on('session.start', async ($, e, next) => {
185    await start($, s)
186    return next(e)
187  })
188
189  on('command.run', { command: 'plushie' }, async ($, e) => {
190    const [verb = '', name = ''] = e.args.trim().split(/\s+/)
191
192    if (verb === '') {
193      s.isShown = !s.isShown
194      s.host?.invalidate()
195      return { text: s.isShown ? 'Plush Clawd is back' : 'Plush Clawd is napping out of sight' }
196    }
197
198    if (verb === 'list') {
199      const reactions = s.pet?.clipNames().join(', ') ?? 'none yet: the frames are missing'
200      const switches = FEATURES.map(f => `${s.isOn(f) ? 'on ' : 'off'}  ${f}`).join('\n')
201      return {
202        text: `Reactions to play with /plushie <reaction>:\n${reactions}\n\nSwitches (/plushie on|off <switch>):\n${switches}`,
203      }
204    }
205
206    if (verb === 'on' || verb === 'off') {
207      const feature = FEATURES.find(f => f.toLowerCase() === name.toLowerCase())
208      if (!feature) {
209        return { text: `No switch called "${name}". There are: ${FEATURES.join(', ')}` }
210      }
211      // The switch is the plugin's userConfig field; its key carries however
212      // the engine names this plugin (a --plugin-dir load adds @inline).
213      const rows = await $.config.list()
214      const row = rows.find(r => r.key.endsWith(`.${feature}`) && r.key.startsWith($.plugin.name))
215      if (!row) {
216        return { text: `Couldn't find the ${feature} setting` }
217      }
218      const { deny } = await $.config.set({ key: row.key, value: verb === 'on' })
219      return { text: deny ?? `Plush Clawd's ${feature} is ${verb}` }
220    }
221
222    if (!s.pet?.play(verb, true)) {
223      return { text: `Plush Clawd has no "${verb}" reaction; /plushie list shows them all` }
224    }
225    s.isShown = true
226    s.host?.invalidate()
227    return { text: `Plush Clawd plays ${verb}` }
228  })
229
230  on('turn.start', async ($, e, next) => {
231    // The start doesn't say whose turn it is; any turn means work is under way.
232    s.pet?.runningTurns.add(e.turnId)
233    await noteActivity($, s)
234    return next(e)
235  })
236
237  on('turn.complete', async ($, e, next) => {
238    const result = await next(e)
239    const { pet } = s
240    if (!pet) {
241      return result
242    }
243    pet.runningTurns.delete(e.turnId)
244    if (e.agentId) {
245      pet.finishMini(e.agentId)
246      return result
247    }
248
249    await noteActivity($, s)
250    if (e.isAborted || e.reason === 'error') {
251      pet.play('oops')
252    } else if (e.durationMs >= HAPPY_AFTER_MS) {
253      pet.play('happy')
254    }
255    if (s.isOn('stuffing')) {
256      try {
257        const { context } = await $.session.usage()
258        pet.size = sizeFor(context.percent ?? 0)
259      } catch {
260        // No reading this time; the size stays as it was.
261      }
262    }
263    return result
264  })
265
266  on('tool.call', async ($, e, next) => {
267    const { pet } = s
268    // A subagent's tool calls are its own business: no prop, no wobble.
269    if (e.agentId || !pet) {
270      return next(e)
271    }
272    const prop = PROP_FOR_TOOL[e.tool]
273    if (prop) {
274      pet.toolStarted(e.tool_use_id, prop, await $.clock.now())
275    }
276    try {
277      const result = await next(e)
278      if (result.deny !== undefined || result.isError) {
279        pet.play('oops')
280      }
281      return result
282    } finally {
283      pet.toolEnded(e.tool_use_id)
284    }
285  })
286
287  on('prompt.submit', async ($, e, next) => {
288    await noteActivity($, s)
289    if (s.pet && s.isOn('blush') && KIND_WORDS.test(e.text)) {
290      s.pet.play('blush')
291    }
292    return next(e)
293  })
294
295  on('agent.spawn', async ($, e, next) => {
296    const result = await next(e)
297    if (s.pet && s.isOn('miniClawds') && result.agentId) {
298      s.pet.spawnMini(result.agentId)
299      s.host?.sound('mini')
300      $.ui.invalidate('ui.render')
301    }
302    return result
303  })
304
305  on('session.compact', async ($, e, next) => {
306    const result = await next(e)
307    // Only a stuffed or plump Clawd has anything to let out.
308    if (s.pet && !e.agentId && s.isOn('stuffing') && s.pet.size !== 'normal') {
309      s.pet.play('deflate')
310      s.pet.size = 'normal'
311    }
312    return result
313  })
314
315  on('ui.message', { element: 'grab' }, async ($, e, next) => {
316    const message = e.data as GrabMessage
317    const { pet, manifest } = s
318    if (!pet || !manifest || typeof message?.column !== 'number') {
319      return next(e)
320    }
321    const shownLeft = leftIn(s, s.bandColumns, s.petColumns)
322
323    // A held move with no press before it starts the grab too: a terminal can
324    // fold the press into the first move of a quick drag.
325    if (message.phase === 'down' || (message.phase === 'move' && !s.grab)) {
326      s.grab = { offset: message.column - shownLeft, downColumn: message.column, isMoved: false }
327      await noteActivity($, s)
328    }
329    if (message.phase !== 'down' && s.grab) {
330      const { grab } = s
331      // It takes a column of travel to turn a press into a drag.
332      if (!grab.isMoved && s.isOn('drag') && Math.abs(message.column - grab.downColumn) >= 1) {
333        grab.isMoved = true
334        pet.isHeld = true
335      }
336      if (grab.isMoved) {
337        s.left = clamp(message.column - grab.offset, 0, s.bandColumns - s.petColumns)
338      }
339      if (message.phase === 'up') {
340        if (grab.isMoved) {
341          pet.isHeld = false
342          pet.play('drop')
343          await $.store.set('left', s.left).catch(() => undefined)
344        } else if (s.isOn('pats')) {
345          pet.play('pat')
346        }
347        s.grab = null
348      }
349      $.ui.invalidate('ui.render')
350    }
351    return next(e)
352  })
353
354  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
355    const below = await next(e)
356    if (e.surface !== 'terminal') {
357      return below
358    }
359    await start($, s)
360
361    const { manifest, pet } = s
362    const isDrawable =
363      s.isShown &&
364      !s.isUnsupported &&
365      !e.props.hasSurvey &&
366      manifest !== null &&
367      e.props.maxRows >= MIN_ROWS &&
368      e.props.bodyColumns >= manifest.columns + 20
369    if (!isDrawable || !manifest || !pet) {
370      return below
371    }
372
373    s.requestId = e.requestId
374    s.bandColumns = e.props.bodyColumns
375    pet.sync(await $.clock.now())
376
377    const { Box, Image, Client, Text } = await $.ui.resolve(e)
378    // A short band (agents listed under the prompt take its rows) gets a
379    // smaller Clawd, same shape, rather than none.
380    const rows = Math.min(manifest.rows, e.props.maxRows)
381    const columns = Math.round((manifest.columns * rows) / manifest.rows)
382    s.petColumns = columns
383    s.sizeSuffix = rows === manifest.rows ? '' : `-${rows}`
384    const petLeft = leftIn(s, s.bandColumns, columns)
385
386    // Mini Clawds stand to the pet's left, or to its right when there's no
387    // room; a leaving one walks off a column every other frame. Half the
388    // pet's height, never under two rows, in the frames' proportions.
389    const miniRows = Math.max(2, Math.floor(rows / 2))
390    const miniColumns = Math.round((manifest.columns * miniRows) / manifest.rows)
391    const working = pet.framesOf('working')
392    const minis: RenderElement[] = []
393    pet.minis.forEach((mini, i) => {
394      const slot = (i + 1) * (miniColumns + 1)
395      const home = petLeft - slot >= 0 ? petLeft - slot : petLeft + columns + 1 + i * (miniColumns + 1)
396      const x = home - (mini.leaving === null ? 0 : Math.floor(mini.leaving / 2))
397      if (x < 0 || x + miniColumns > s.bandColumns || (mini.leaving ?? 0) >= LEAVE_FRAMES) {
398        return
399      }
400      minis.push(
401        <Box key={`mini-slot-${mini.id}`} position="absolute" left={x} top={rows - miniRows}>
402          <Image
403            key={`mini-${mini.id}${s.sizeSuffix}`}
404            source={{ file: fileOf(s, 'working', mini.frame % working), format: 'png' }}
405            columns={miniColumns}
406            rows={miniRows}
407            alt=" "
408          />
409        </Box>,
410      )
411    })
412
413    const isGrabbable = s.isOn('drag') || s.isOn('pats')
414    const grabProps: GrabProps = { left: petLeft, columns, rows }
415
416    // The pet has its own strip; inside it the picture and, on top, the
417    // invisible grab region sit at the same column.
418    return (
419      <Box flexDirection="column">
420        {below}
421        <Box width={s.bandColumns} height={rows}>
422          {minis}
423          {pet.extraMinis > 0 ? (
424            <Box key="extra-minis" position="absolute" left={Math.max(0, petLeft - 4)} top={0}>
425              <Text dimColor>+{pet.extraMinis}</Text>
426            </Box>
427          ) : null}
428          <Box key="pet" position="absolute" left={petLeft} top={0}>
429            <Image
430              key={KEY + s.sizeSuffix}
431              source={{ file: fileOf(s, pet.clip, pet.fileFrame()), format: 'png' }}
432              columns={columns}
433              rows={rows}
434              alt=" "
435            />
436          </Box>
437          {isGrabbable ? (
438            <Box position="absolute" left={petLeft} top={0}>
439              <Client key="grab" module="./grab.tsx" props={grabProps} />
440            </Box>
441          ) : null}
442        </Box>
443      </Box>
444    )
445  })
446}
447
hooks/grab.tsx 45 lines
1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { ClientModule } from 'claude-code'
5
6/**
7 * An invisible region laid over the pet that turns a press-and-drag into
8 * band columns for the hooks module, which moves the pet and redraws.
9 *
10 * The pet's `Image` takes no pointer events and a `Client` can't draw an
11 * `Image`, so the grab sits on top of the picture and draws an empty box:
12 * no characters, nothing to cover the pixels.
13 */
14
15export type GrabProps = {
16  /** The band column the pet's left edge is drawn at. */
17  left: number
18  columns: number
19  rows: number
20}
21
22/** What the region posts: the pointer's band column, with the grab's phase. */
23export type GrabMessage = { phase: 'down' | 'move' | 'up'; column: number }
24
25const Grab: ClientModule<GrabProps> = (props, surface) => {
26  const { Box } = surface.elements
27
28  surface.onPointer(event => {
29    if (event.type === 'enter' || event.type === 'leave') {
30      return
31    }
32    // A hover move has no button; only a held drag counts.
33    if (event.type === 'move' && event.button === undefined) {
34      return
35    }
36    const x = event.fine?.x ?? event.x + 0.5
37    const message: GrabMessage = { phase: event.type, column: props.left + x }
38    surface.post(message)
39  })
40
41  return <Box width={props.columns} height={props.rows} />
42}
43
44export default Grab
45
hooks/pet.ts 293 lines
1/**
2 * What Clawd is doing and which frame it shows: the mood picked from what is
3 * under way, the one-off clips that play over it, the variant of a clip a
4 * reaction asks for, and the mini Clawds standing in for subagents.
5 *
6 * No engine here: register.tsx feeds it events and the time, and draws and
7 * blits what it says.
8 */
9
10export const FEATURES = ['drag', 'pats', 'toolProps', 'miniClawds', 'stuffing', 'blush', 'fidgets', 'sounds'] as const
11export type Feature = (typeof FEATURES)[number]
12
13export type Manifest = {
14  fps: number
15  columns: number
16  rows: number
17  clips: Record<string, { frames: number; loop: boolean }>
18}
19
20/** Whether parsed JSON is a manifest this mod can play: a frames folder from
21 * an older bake has another shape, and drawing from it would throw on every
22 * render. */
23export function isManifest(value: unknown): value is Manifest {
24  if (typeof value !== 'object' || value === null) {
25    return false
26  }
27  const m = value as Record<string, unknown>
28  if (typeof m.fps !== 'number' || typeof m.columns !== 'number' || typeof m.rows !== 'number') {
29    return false
30  }
31  if (typeof m.clips !== 'object' || m.clips === null || !('idle' in m.clips)) {
32    return false
33  }
34  return Object.values(m.clips as Record<string, unknown>).every(c => {
35    const clip = c as Record<string, unknown> | null
36    return typeof clip?.frames === 'number' && clip.frames > 0 && typeof clip.loop === 'boolean'
37  })
38}
39
40export type Prop = 'magnifier' | 'needles' | 'keyboard' | 'binoculars'
41export type Size = 'normal' | 'plump' | 'stuffed'
42
43export const PROP_FOR_TOOL: Record<string, Prop> = {
44  Read: 'magnifier',
45  Grep: 'magnifier',
46  Glob: 'magnifier',
47  Edit: 'needles',
48  Write: 'needles',
49  NotebookEdit: 'needles',
50  Bash: 'keyboard',
51  WebFetch: 'binoculars',
52  WebSearch: 'binoculars',
53}
54
55export const sizeFor = (percent: number): Size =>
56  percent >= 80 ? 'stuffed' : percent >= 50 ? 'plump' : 'normal'
57
58const SLEEP_AFTER_MS = 2 * 60 * 1000
59// Every call shows its prop at least this long: reads and edits finish in
60// milliseconds and would otherwise never show one, and a burst of them shows
61// one steady prop instead of a flicker.
62const PROP_HOLD_MS = 1500
63const MAX_MINIS = 3
64// Frames a finished subagent's mini spends walking off before it goes.
65export const LEAVE_FRAMES = 16
66// How long Clawd idles between fidgets, picked fresh each time.
67const FIDGET_MIN_MS = 15_000
68const FIDGET_MAX_MS = 40_000
69
70// Which one-off clip may cut another short: a higher number wins, an equal
71// one restarts. A clip played by hand outranks them all.
72const PRIORITY: Record<string, number> = {
73  fidget: 0,
74  pat: 1,
75  blush: 1,
76  happy: 2,
77  drop: 2,
78  deflate: 2,
79  oops: 3,
80}
81const BY_HAND = 10
82
83/** The mood a clip belongs to: `idle-stuffed` is idle, stuffed. */
84export const moodOf = (clip: string) => clip.split('-')[0] ?? clip
85
86type OneOff = { clip: string; serial: number; length: number; priority: number }
87
88export type Mini = { id: string; frame: number; leaving: number | null }
89
90export class Pet {
91  readonly runningTurns = new Set<string>()
92  lastActivityMs = 0
93  isHeld = false
94  size: Size = 'normal'
95
96  clip = 'idle'
97  frame = 0
98  readonly minis: Mini[] = []
99  extraMinis = 0
100
101  private readonly tools = new Map<string, { prop: Prop; startMs: number; isDone: boolean }>()
102  private oneOff: OneOff | null = null
103  private serial = 0
104  private playing = ''
105  private nextFidgetMs: number | null = null
106
107  constructor(
108    private readonly manifest: Manifest,
109    private readonly isOn: (feature: Feature) => boolean,
110    // Told each time a different clip starts (not when a variant of the same
111    // mood takes over mid-loop): where the sounds hang off.
112    private readonly onStart: (clip: string) => void = () => {},
113  ) {}
114
115  has(clip: string) {
116    return clip in this.manifest.clips
117  }
118
119  /** Every clip there are frames for, the names `/plushie <reaction>` takes. */
120  clipNames() {
121    return Object.keys(this.manifest.clips)
122  }
123
124  framesOf(clip: string) {
125    return this.manifest.clips[clip]?.frames ?? 1
126  }
127
128  /** The frame of the current clip's files to show. */
129  fileFrame() {
130    return this.frame % this.framesOf(this.clip)
131  }
132
133  /**
134   * Plays a clip once over whatever is running, unless a stronger one is
135   * playing. By hand, a looping clip runs for at least three seconds so it can
136   * be seen.
137   */
138  play(clip: string, isByHand = false) {
139    if (!this.has(clip)) {
140      return false
141    }
142    const priority = isByHand ? BY_HAND : (PRIORITY[moodOf(clip)] ?? 1)
143    if (this.oneOff && this.oneOff.priority > priority) {
144      return false
145    }
146    const frames = this.framesOf(clip)
147    const length = isByHand && this.manifest.clips[clip]?.loop ? Math.max(frames, 3 * this.manifest.fps) : frames
148    this.serial += 1
149    this.oneOff = { clip, serial: this.serial, length, priority }
150    return true
151  }
152
153  toolStarted(id: string, prop: Prop, nowMs: number) {
154    this.tools.set(id, { prop, startMs: nowMs, isDone: false })
155  }
156
157  toolEnded(id: string) {
158    const call = this.tools.get(id)
159    if (call) {
160      call.isDone = true
161    }
162  }
163
164  spawnMini(id: string) {
165    if (this.minis.filter(m => m.leaving === null).length >= MAX_MINIS) {
166      this.extraMinis += 1
167      return
168    }
169    this.minis.push({ id, frame: this.minis.length * 7, leaving: null })
170  }
171
172  finishMini(id: string) {
173    const mini = this.minis.find(m => m.id === id)
174    if (mini) {
175      mini.leaving = 0
176    } else if (this.extraMinis > 0) {
177      this.extraMinis -= 1
178    }
179  }
180
181  /** Settles which clip shows now; a change of mood starts it from its first frame. */
182  sync(nowMs: number) {
183    this.fidget(nowMs)
184    let key: string
185    let clip: string
186    if (this.isHeld && this.has('held')) {
187      key = 'held'
188      clip = 'held'
189    } else if (this.oneOff) {
190      key = `once:${this.oneOff.serial}`
191      clip = this.oneOff.clip
192    } else {
193      key =
194        this.runningTurns.size > 0
195          ? 'working'
196          : nowMs - this.lastActivityMs > SLEEP_AFTER_MS
197            ? 'sleepy'
198            : 'idle'
199      clip = this.variantOf(key, nowMs)
200    }
201
202    if (!this.has(clip)) {
203      clip = this.has(moodOf(clip)) ? moodOf(clip) : 'idle'
204    }
205    // Variants of one mood share a length, so switching between them (idle to
206    // idle-stuffed) carries on from the same frame instead of jumping back.
207    const isSameMood = key === this.playing && this.framesOf(clip) === this.framesOf(this.clip)
208    if (!isSameMood) {
209      this.frame = 0
210    }
211    if (key !== this.playing) {
212      this.onStart(clip)
213    }
214    this.playing = key
215    this.clip = clip
216  }
217
218  /** Steps one frame on; returns whether the band must be drawn again. */
219  advance() {
220    this.frame += 1
221    if (this.oneOff && this.playing === `once:${this.oneOff.serial}` && this.frame >= this.oneOff.length) {
222      this.oneOff = null
223    }
224
225    let isRedrawn = false
226    for (const mini of this.minis) {
227      mini.frame += 1
228      if (mini.leaving !== null) {
229        mini.leaving += 1
230        isRedrawn = true
231      }
232    }
233    const before = this.minis.length
234    this.minis.splice(0, this.minis.length, ...this.minis.filter(m => m.leaving === null || m.leaving < LEAVE_FRAMES))
235    return isRedrawn || this.minis.length !== before
236  }
237
238  /**
239   * Now and then an idle Clawd plays a random fidget. Anything else under way
240   * (a turn, a hold, sleep) cuts a fidget short and puts the next one off.
241   */
242  private fidget(nowMs: number) {
243    const isIdle =
244      !this.isHeld && this.runningTurns.size === 0 && nowMs - this.lastActivityMs <= SLEEP_AFTER_MS
245    if (this.oneOff && moodOf(this.oneOff.clip) === 'fidget' && !isIdle) {
246      this.oneOff = null
247    }
248    if (!this.isOn('fidgets') || !isIdle || this.oneOff) {
249      this.nextFidgetMs = null
250      return
251    }
252    if (this.nextFidgetMs === null) {
253      this.nextFidgetMs = nowMs + FIDGET_MIN_MS + Math.random() * (FIDGET_MAX_MS - FIDGET_MIN_MS)
254      return
255    }
256    if (nowMs >= this.nextFidgetMs) {
257      const fidgets = Object.keys(this.manifest.clips).filter(clip => moodOf(clip) === 'fidget')
258      const picked = fidgets[Math.floor(Math.random() * fidgets.length)]
259      if (picked) {
260        this.play(picked)
261      }
262      this.nextFidgetMs = null
263    }
264  }
265
266  private variantOf(mood: string, nowMs: number) {
267    if (mood === 'working') {
268      if (this.isOn('toolProps')) {
269        let latest: { prop: Prop; startMs: number } | null = null
270        for (const [id, call] of this.tools) {
271          if (call.isDone && nowMs - call.startMs >= PROP_HOLD_MS) {
272            this.tools.delete(id)
273          } else if (!latest || call.startMs > latest.startMs) {
274            latest = call
275          }
276        }
277        if (latest) {
278          return `working-${latest.prop}`
279        }
280      }
281      if (this.isOn('stuffing') && this.size !== 'normal') {
282        return `working-${this.size}`
283      }
284    }
285    if (mood === 'idle') {
286      if (this.isOn('stuffing') && this.size !== 'normal') {
287        return `idle-${this.size}`
288      }
289    }
290    return mood
291  }
292}
293