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

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.
/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
Each is a switch, on by default, in /config or with /plushie on|off <switch>:
| Switch | What Clawd does |
|---|---|
drag | drag it sideways along the band; it dangles while held, lands where you let go, and stays there next session |
pats | click it without dragging: a squish, a happy squint, hearts |
toolProps | holds a magnifier while reading, knitting needles while editing, a keyboard for the shell, binoculars on the web |
miniClawds | a half-size Clawd per running subagent, walking off when it finishes |
stuffing | grows rounder as the context window fills, deflates with a sigh on compaction |
blush | blushes at thanks and praise in your prompt |
fidgets | left alone, now and then looks around, scratches, yawns or hops |
sounds | little sounds for pats, jumps, wobbles, blushes, being picked up and put down, deflating and dozing off |
/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.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.
claude plugin test .
The tests drive every reaction through the real event that triggers it, each with its switch on and off.
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.
hooks/register.tsx 447 lines1/* @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}
447hooks/grab.tsx 45 lines1/* @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
45hooks/pet.ts 293 lines1/**
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