SLOPSHOPPER

desk-pet

A small animated pet above the prompt that reacts to what Claude is doing

newpanebandguardcommandtoast
v0.2.2MITupdated 2026-10-02isr431/desk-pet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · desk-pet
│ ┃ Desk Pet ✕ › 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 │ ┃ │ ┃ › /desk-pet │ ┃ Axolotl settings ⎿ desk-pet: Desk Pet settings opened. │ ┃ 🌙 hanging out │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Desk Pet
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ Axolotl settings 🌙 hanging out
Pane · Desk Pet
Pet [ Axolotl ] [ Quokka ] [ Tiny robot ] [ Dog ] [ Cat ] Position [ Side panel ] [ Above the prompt ] Sound [ Off ] a soft chime for needs input and turn finishes Reactions ● Turn starts preview ● Reading files preview ● Editing code preview ● Running shell commands preview ● Web search preview ● Tests pass preview ● Tests fail preview ● Long run (5+ min) preview ● Claude needs input preview ● Turn finishes preview ● Context compaction preview ● Idle /desk-pet demo plays every reaction · /desk-pet show reopens the panel
README

Desk Pet

A small animated pet that lives beside your Claude Code session and reacts to what Claude is doing, so long sessions are more fun to glance at.

Five desk pets reading, hammering, typing, celebrating and bringing gifts

Pick an axolotl, quokka, tiny robot, dog or cat. Each one has its own take on every reaction.

WhenYour pet
A turn startswakes up and stretches
Claude reads filesreads a tiny book
Claude edits codehammers (quokka, robot, dog) or knits (axolotl, cat)
Claude runs a shell commandtypes furiously at a mini keyboard
Claude searches or fetches the webpeers through binoculars
Tests passdances in a shower of confetti
Tests failhides behind a rock, then peeks out
A turn runs 5+ minutesgets bored: yawns, plays with yarn, builds a sandcastle
Claude needs your inputwaves until you respond
A turn finishesbrings you a little trophy or envelope
Context compactiontidies its room, stacking boxes
Nothing happeningbreathes and blinks

Your pet lives in a little pixel room (wallpaper, a curtained window, a pendant lamp, a wooden floor and a rug) that follows your local time of day: a pink sunrise at dawn (5–7), blue skies and drifting clouds in the morning (7–11) and at noon (11–14), golden light in the afternoon (14–17), a sunset in the evening (17–20), and a starry night with the lamp glowing (20–5). Sunlight through the window falls across the floor and shifts with the sun, and the caption's idle icon changes with the hour. It's drawn with half-block pixels in the terminal (48 columns by 12 rows) and as an animated SVG in the Claude desktop app.

You choose where it sits:

  • Side panel (default): its own panel beside the conversation, with the pet's name, what it's doing and a settings button. If you close it, /desk-pet show brings it back. A terminal opens the panel by itself only when it's at least 144 columns wide.
  • Above the prompt: a full-width card just above the message box, with the pet on the left, what it's doing beside it and a settings button.

Install

In Claude Code, add this repository as a plugin marketplace, then install the plugin:

/plugin marketplace add isr431/desk-pet
/plugin install desk-pet@desk-pet

Or from a shell:

claude plugin marketplace add isr431/desk-pet
claude plugin install desk-pet@desk-pet

Start a new session and your pet appears. Run /desk-pet to pick your pet and settings.

Desk Pet is a function-hooks plugin. It needs Claude Code 2.1.286 or newer. The function-hooks API is early access, so a future release may change it.

To try it from a local checkout without installing:

claude --plugin-dir /path/to/desk-pet

Settings

  • Pet: axolotl (default), quokka, robot, dog or cat.
  • Position: side panel (default) or above the prompt.
  • Sound: off by default. When on, a soft chime plays when Claude needs input and when a turn finishes. Both chimes are synthesized in code; no audio files are shipped.
  • Reactions: each one can be switched on or off. All are on by default.

You can change these in three places:

  • /desk-pet opens a settings pane with a preview button for each reaction.
  • /desk-pet cat (or axolotl, quokka, robot, dog) switches pets.
  • /desk-pet show reopens the side panel.
  • /config in the terminal lists every setting as a "Desk Pet: …" row. Pet and position are typed there as text (axolotl, quokka, robot, dog, cat; panel, above); anything else falls back to the default.

/desk-pet demo plays every reaction in turn, which is handy for a screenshot or a GIF.

How it decides what to show

  • Tests are detected from the shell command (npm test, pytest, go test, cargo test, vitest, jest, rspec and similar). A non-zero exit counts as a failure.
  • "Needs input" covers permission prompts, MCP elicitations, AskUserQuestion and plan approval. Permission prompts and MCP elicitations are noticed through Claude Code's notification event, the same one that drives desktop notifications. The pet stops waving when you send a prompt or when the tool call it was waiting on finishes. One known gap: after you approve a long-running command, the pet keeps waving until that command ends, because the hooks API has no event for the moment a prompt is answered.
  • Reactions from subagents count too. Only the main conversation's turns trigger wake-up, boredom and the end-of-turn gift.

Privacy and data handling

Privacy policy: the disclosure below describes the mod’s data handling.

Desk Pet only watches what Claude is doing and draws a pet. It makes no network requests, reads no files, runs no commands, and keeps no data of its own between sessions.

Hooks. Every hook looks, then passes the event on unchanged. None of them approves, blocks or rewrites anything.

HookWhat it readsWhy
session.startnothingstarts the animation, opens the side panel and registers /desk-pet
prompt.submitnothing, not even your prompt's textstops the "needs input" wave as soon as you reply
turn.start, turn.completewhether the turn ended with an answer, and whether it was a subagent'swake-up, boredom after 5 minutes, the end-of-turn gift
tool.callthe tool's name, plus a file's name (never its contents), a search pattern or query, a URL's host, the first ~38 characters of a shell command, and whether the call failedpicks the reaction and its caption, and spots test runs and whether they passed
session.compactwhether it was a subagent's, or a background precomputethe tidying-up reaction
classic.Notificationthe notification's type only (permission_prompt or elicitation_dialog)starts the "needs input" wave
classic.PermissionDeniednothingstops the wave when a prompt is refused
command.runwhat you typed after /desk-petthe command
ui.renderthe surface and the space availabledraws the pet

Captions appear on your screen only. The current caption is held in memory for the session ($.state) so the panel can redraw it. It is never written to disk or sent anywhere.

Settings it writes. Desk Pet changes a setting only when you ask: a button in the /desk-pet settings pane, or /desk-pet cat (or another pet). Each write is to one of Desk Pet's own settings (desk-pet.pet, desk-pet.position, desk-pet.sound or a desk-pet.react_… switch), which Claude Code saves in your settings file under pluginConfigs, exactly as /config would. It never touches any other setting, permission or environment variable.

Sound. Off by default. When on, it plays a short chime that it synthesizes in code.

In the "above the prompt" position, press ctrl+x ctrl+a to collapse the card in the terminal.

Development

claude plugin validate .
claude plugin test .

The pixel art lives in hooks/art.ts. It's plain TypeScript with no engine calls, so you can import it under Node to render previews.

License

MIT

Source 4 files
hooks/register.tsx 804 lines
1import { atom, read, update } from 'claude-code'
2import type { ConfigSetResult, EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
3
4import type { PetShow } from '../types'
5import {
6  animLength,
7  animSvg,
8  buildAnim,
9  frameAt,
10  PET_IDS,
11  PETS,
12  RASTER_COLUMNS,
13  RASTER_ROWS,
14  rasterCells,
15  roomFor,
16  dayPhase,
17  H,
18  W,
19  type Anim,
20  type DayPhase,
21  type Kind,
22  type PetId,
23} from './art'
24import { attentionChime, doneChime } from './chime'
25
26const PANE = 'desk-pet-settings'
27const PET_PANE = 'desk-pet'
28const RASTER_KEY = 'pet'
29const LONG_RUN_MS = 5 * 60 * 1000
30const BORED_SPAN_MS = 12 * 1000
31const MIN_TOOL_MS = 2400
32
33const IDLE: PetShow = { kind: 'idle', variant: 0, startedAt: 0, frameMs: 0, label: '' }
34const shown = atom({ plugin: 'desk-pet', key: 'show' } as const, IDLE)
35
36const REACTIONS = [
37  { field: 'react_turn_start', kind: 'wake', title: 'Turn starts' },
38  { field: 'react_reading', kind: 'read', title: 'Reading files' },
39  { field: 'react_editing', kind: 'edit', title: 'Editing code' },
40  { field: 'react_shell', kind: 'shell', title: 'Running shell commands' },
41  { field: 'react_web_search', kind: 'search', title: 'Web search' },
42  { field: 'react_tests_pass', kind: 'pass', title: 'Tests pass' },
43  { field: 'react_tests_fail', kind: 'fail', title: 'Tests fail' },
44  { field: 'react_long_run', kind: 'bored', title: 'Long run (5+ min)' },
45  { field: 'react_needs_input', kind: 'wave', title: 'Claude needs input' },
46  { field: 'react_turn_finish', kind: 'finish', title: 'Turn finishes' },
47  { field: 'react_compaction', kind: 'tidy', title: 'Context compaction' },
48  { field: 'react_idle', kind: 'idle', title: 'Idle' },
49] as const
50
51const BORED_LABELS = ['yawning', 'playing with yarn', 'building a sandcastle']
52const BORED_ICONS = ['🥱', '🧶', '🏖️']
53const PHASE_ICONS: Record<DayPhase, string> = {
54  dawn: '🌅',
55  morning: '🌤️',
56  noon: '☀️',
57  afternoon: '⛅',
58  evening: '🌇',
59  night: '🌙',
60}
61
62type Position = 'panel' | 'above'
63const POSITIONS: readonly { id: Position; title: string }[] = [
64  { id: 'panel', title: 'Side panel' },
65  { id: 'above', title: 'Above the prompt' },
66]
67
68/** The little icon beside the caption for what the pet is doing. */
69const iconFor = (show: PetShow): string => {
70  switch (show.kind) {
71    case 'wake':
72      return '☀️'
73    case 'read':
74      return '📖'
75    case 'edit':
76      return PETS[pet].edit === 'knit' ? '🧶' : '🔨'
77    case 'shell':
78      return '⌨️'
79    case 'search':
80      return '🔭'
81    case 'pass':
82      return '🎉'
83    case 'fail':
84      return '🙈'
85    case 'bored':
86      return BORED_ICONS[show.variant % 3]!
87    case 'wave':
88      return '👋'
89    case 'finish':
90      return PETS[pet].gift === 'trophy' ? '🏆' : '💌'
91    case 'tidy':
92      return '📦'
93  }
94
95  return PHASE_ICONS[phase]
96}
97
98const captionFor = (show: PetShow) => `${iconFor(show)} ${show.label || 'hanging out'}`
99
100const TEST_COMMAND =
101  /(^|[\s;&|(])((npm|pnpm|yarn|bun)\s+(run\s+)?test\b|(npx\s+|bunx\s+)?(jest|vitest|mocha|ava|playwright\s+test|cypress\s+run)\b|py\.?test\b|python3?\s+-m\s+(pytest|unittest)\b|go\s+test\b|cargo\s+(test|nextest)\b|deno\s+test\b|rspec\b|phpunit\b|mix\s+test\b|dotnet\s+test\b|(\.\/)?gradlew?\s+(\S+\s+)*test\b|mvn\s+(\S+\s+)*test\b|ctest\b|make\s+(test|check)\b|swift\s+test\b|xcodebuild\s+(\S+\s+)*test\b|tox\b|nox\b|rake\s+test\b|bundle\s+exec\s+(rspec|rake\s+test)\b)/
102
103type Reaction = {
104  kind: Kind
105  variant: number
106  startedAt: number
107  frameMs: number
108  until: number
109  active: number
110  label: string
111}
112
113type ToolReaction = { kind: Kind; label: string; frameMs?: number; isTest?: boolean }
114
115const basename = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path
116
117const clip = (text: string, max = 38) => {
118  const one = text.replace(/\s+/g, ' ').trim()
119
120  return one.length > max ? `${one.slice(0, max - 1)}…` : one
121}
122
123const field = (e: object, name: string): string => {
124  const value = (e as Record<string, unknown>)[name]
125
126  return typeof value === 'string' ? value : ''
127}
128
129const sameShow = (a: PetShow, b: PetShow) =>
130  a.kind === b.kind && a.variant === b.variant && a.startedAt === b.startedAt && a.frameMs === b.frameMs && a.label === b.label
131
132// The settings in force: the plugin's options from register(), with the
133// pane's own choices from $.store laid over them.
134let given: PluginOptions = {}
135let options: PluginOptions = {}
136let overrides: Record<string, string | boolean> = {}
137let pet: PetId = 'axolotl'
138let petName = PETS.axolotl.name
139let isSoundOn = false
140let position: Position = 'panel'
141/** True while the side panel is open but undrawn: the band stands in for it. */
142let isPaneWaiting = false
143
144const isOn = (kind: string) => {
145  const reaction = REACTIONS.find(one => one.kind === kind)
146
147  return reaction === undefined || options[reaction.field] !== false
148}
149
150// What drives the band. Plain module values: a reload starts them over,
151// and the band falls back to idle until the next event.
152let reaction: Reaction | null = null
153let waiting: { label: string; since: number } | null = null
154let turnStartedAt: number | null = null
155let current: PetShow | null = null
156// The site whose Raster the beat repaints in the terminal: the band or the pane.
157let site: { requestId: string; animKey: string; frame: number } | null = null
158let timer: Timer | null = null
159let wake: (() => void) | null = null
160let previews = 0
161
162const anims = new Map<string, Anim>()
163const cells = new Map<string, string[]>()
164const svgs = new Map<string, string>()
165
166/** The part of the day the room is lit for, from the local clock. */
167let phase: DayPhase = 'night'
168
169const phaseAt = (now: number): DayPhase => {
170  const local = new Date(now)
171
172  return dayPhase(local.getHours() + local.getMinutes() / 60)
173}
174
175const animKey = (show: PetShow) => `${phase}:${pet}:${show.kind}:${show.variant}:${show.frameMs}`
176
177const animFor = (show: PetShow): Anim => {
178  const key = animKey(show)
179  let anim = anims.get(key)
180  if (!anim) {
181    anim = buildAnim(pet, show.kind as Kind, show.variant, show.frameMs || undefined)
182    anims.set(key, anim)
183  }
184
185  return anim
186}
187
188const cellsFor = (show: PetShow, index: number): string => {
189  const key = animKey(show)
190  let list = cells.get(key)
191  if (!list) {
192    list = []
193    cells.set(key, list)
194  }
195  list[index] ??= rasterCells(animFor(show).frames[index]!, roomFor(phase))
196
197  return list[index]!
198}
199
200const svgFor = (show: PetShow): string => {
201  const key = animKey(show)
202  let svg = svgs.get(key)
203  if (!svg) {
204    svg = animSvg(animFor(show), 4, roomFor(phase))
205    svgs.set(key, svg)
206  }
207
208  return svg
209}
210
211/**
212 * Opens the pet's own pane, sized to the scene. Where the surface can't seat
213 * it, the pet moves to the card above the prompt rather than vanishing.
214 */
215async function openPetPane($: EngineInterface): Promise<void> {
216  try {
217    const opened = await $.ui.open({ id: PET_PANE, title: 'Desk Pet', columns: RASTER_COLUMNS + 2, rows: RASTER_ROWS + 4 })
218    isPaneWaiting = !opened.isPlaced
219    if (!opened.isPlaced) $.ui.toast(`Desk Pet: the side panel can't show here yet (${opened.reason}), so your pet sits above the prompt for now.`)
220  } catch {
221    isPaneWaiting = true
222  }
223  $.ui.invalidate('ui.render')
224}
225
226function chime($: EngineInterface, wav: string): void {
227  if (!isSoundOn) return
228  void $.audio.play({ base64: wav, mime: 'audio/wav' }, { gain: 0.8 }).catch(() => undefined)
229}
230
231/** What the pet should be doing at `now`, most important first. */
232const desired = (now: number): PetShow => {
233  if (waiting && isOn('wave')) {
234    return { kind: 'wave', variant: 0, startedAt: waiting.since, frameMs: 0, label: waiting.label }
235  }
236  if (reaction) {
237    if (reaction.active > 0 || now < reaction.until) {
238      const { kind, variant, startedAt, frameMs, label } = reaction
239
240      return { kind, variant, startedAt, frameMs, label }
241    }
242    reaction = null
243  }
244  if (turnStartedAt !== null && isOn('bored') && now - turnStartedAt >= LONG_RUN_MS) {
245    const take = Math.floor((now - turnStartedAt - LONG_RUN_MS) / BORED_SPAN_MS)
246    const variant = take % 3
247
248    return {
249      kind: 'bored',
250      variant,
251      startedAt: turnStartedAt + LONG_RUN_MS + take * BORED_SPAN_MS,
252      frameMs: 0,
253      label: BORED_LABELS[variant]!,
254    }
255  }
256
257  return isOn('idle') ? IDLE : { ...IDLE, kind: 'still' }
258}
259
260/** Moves the room to the part of the day at `now`; true when that changed it. */
261function relight(now: number): boolean {
262  const next = phaseAt(now)
263  if (next === phase) return false
264  phase = next
265  anims.clear()
266  cells.clear()
267  svgs.clear()
268
269  return true
270}
271
272/** One beat of the band: switch reactions, or repaint the terminal frame. */
273async function step($: EngineInterface): Promise<number> {
274  const now = await $.clock.now()
275  if (relight(now)) $.ui.invalidate('ui.render')
276  current ??= await read($, shown)
277  const want = desired(now)
278  if (!sameShow(want, current)) {
279    current = want
280    await update($, shown, () => want)
281
282    return 60
283  }
284  const anim = animFor(want)
285  const index = frameAt(anim, now - want.startedAt)
286  const key = animKey(want)
287  if (site && (site.frame !== index || site.animKey !== key)) {
288    site = { ...site, animKey: key, frame: index }
289    const { deny } = await $.ui.blit({ requestId: site.requestId, key: RASTER_KEY, cells: cellsFor(want, index) })
290    if (deny) site = null
291  }
292  const intoFrame = (now - want.startedAt) % anim.frameMs
293  const toNextFrame = anim.frameMs - intoFrame
294
295  return Math.max(40, Math.min(site ? toNextFrame + 5 : 250, 500))
296}
297
298/** One chain of beats: a nudge mid-beat runs the next one at once, never a second chain. */
299function loop($: EngineInterface): void {
300  timer?.cancel()
301  let isBeating = false
302  let isNudged = false
303  const beat = async () => {
304    isBeating = true
305    isNudged = false
306    let delay = 250
307    try {
308      delay = await step($)
309    } catch {
310      // A missed beat is harmless; the next one catches up.
311    }
312    isBeating = false
313    timer = $.clock.after(isNudged ? 1 : delay, () => void beat())
314  }
315  wake = () => {
316    if (isBeating) {
317      isNudged = true
318
319      return
320    }
321    timer?.cancel()
322    timer = $.clock.after(1, () => void beat())
323  }
324  void beat()
325}
326
327const nudge = () => wake?.()
328
329const play = (kind: Kind, now: number, opts: { label: string; variant?: number; frameMs?: number; holdMs?: number; active?: number }) => {
330  const anim = buildAnim(pet, kind, opts.variant ?? 0, opts.frameMs)
331  const length = anim.loop ? MIN_TOOL_MS : animLength(anim)
332  reaction = {
333    kind,
334    variant: opts.variant ?? 0,
335    startedAt: now,
336    frameMs: opts.frameMs ?? 0,
337    until: now + Math.max(length, opts.holdMs ?? 0),
338    active: opts.active ?? 0,
339    label: opts.label,
340  }
341  nudge()
342
343  return reaction
344}
345
346const finish = (mine: Reaction, now: number) => {
347  if (reaction !== mine) return
348  mine.active = Math.max(0, mine.active - 1)
349  mine.until = Math.max(mine.until, Math.min(now + 600, mine.startedAt + MIN_TOOL_MS))
350  nudge()
351}
352
353function ask($: EngineInterface, label: string, now: number): void {
354  if (waiting) return
355  waiting = { label, since: now }
356  if (isOn('wave')) chime($, attentionChime())
357  nudge()
358}
359
360const answered = () => {
361  if (!waiting) return
362  waiting = null
363  nudge()
364}
365
366const preview = (kind: Kind, now: number) => {
367  const variant = kind === 'bored' ? previews++ % 3 : 0
368  const label = REACTIONS.find(one => one.kind === kind)?.title.toLowerCase() ?? kind
369  play(kind, now, { label: `preview: ${label}`, variant, holdMs: 3600 })
370}
371
372/** Which reaction a tool call gets, from its name and input alone. */
373function classify(e: { tool: string }): ToolReaction | null {
374  switch (e.tool) {
375    case 'Read':
376    case 'NotebookRead':
377      return { kind: 'read', label: `reading ${basename(field(e, 'file_path') || field(e, 'notebook_path'))}` }
378    case 'Grep':
379    case 'Glob':
380      return { kind: 'read', label: `looking for ${clip(field(e, 'pattern'), 28)}` }
381    case 'Edit':
382    case 'MultiEdit':
383    case 'Write':
384    case 'NotebookEdit': {
385      const path = field(e, 'file_path') || field(e, 'notebook_path')
386      const verb = PETS[pet].edit === 'knit' ? 'knitting' : 'hammering at'
387
388      return { kind: 'edit', label: `${verb} ${basename(path)}` }
389    }
390    case 'Bash':
391    case 'PowerShell': {
392      const command = field(e, 'command')
393
394      return { kind: 'shell', label: `$ ${clip(command)}`, isTest: TEST_COMMAND.test(command) }
395    }
396    case 'WebSearch':
397      return { kind: 'search', label: `searching “${clip(field(e, 'query'), 30)}”` }
398    case 'WebFetch': {
399      const url = field(e, 'url')
400      let host = url
401      try {
402        host = new URL(url).host
403      } catch {
404        // Not a URL after all: show it as written.
405      }
406
407      return { kind: 'search', label: `peeking at ${clip(host, 30)}` }
408    }
409  }
410
411  return null
412}
413
414const SETTINGS = 'settings'
415
416/** Lays the stored choices over the plugin's options and reads the result. */
417function apply(): void {
418  options = { ...given, ...overrides }
419  pet = PET_IDS.includes(options.pet as PetId) ? (options.pet as PetId) : 'axolotl'
420  petName = PETS[pet].name
421  isSoundOn = options.sound === true
422  position = POSITIONS.some(one => one.id === options.position) ? (options.position as Position) : 'panel'
423}
424
425async function loadOverrides($: EngineInterface): Promise<void> {
426  try {
427    const saved = await $.store.get(SETTINGS)
428    overrides = saved !== null && typeof saved === 'object' ? (saved as Record<string, string | boolean>) : {}
429  } catch {
430    overrides = {}
431  }
432  apply()
433}
434
435/**
436 * Saves one setting from the pane. A plugin installed from a marketplace may
437 * have no /config row to write (`$.config.set` then rejects), so the choice is
438 * kept in $.store and applied at once; where the row exists it's written too.
439 */
440async function save($: EngineInterface, field: string, value: string | boolean, write: () => Promise<ConfigSetResult>): Promise<string | undefined> {
441  const had = overrides
442  const wasPosition = position
443  overrides = { ...overrides, [field]: value }
444  await $.store.set(SETTINGS, overrides)
445  apply()
446  let deny: string | undefined
447  try {
448    deny = (await write()).deny
449  } catch {
450    // No /config row for it: the stored choice stands on its own.
451  }
452  if (deny) {
453    overrides = had
454    await $.store.set(SETTINGS, overrides)
455    apply()
456  }
457  if (position !== wasPosition) {
458    if (position === 'panel') void openPetPane($)
459    else {
460      isPaneWaiting = false
461      void $.ui.close({ id: PET_PANE }).catch(() => undefined)
462    }
463  }
464  $.ui.invalidate('ui.render')
465  nudge()
466
467  return deny
468}
469
470export const register: Register = (on, opts) => {
471  given = opts
472  apply()
473
474  on('session.start', async ($, e, next) => {
475    await loadOverrides($)
476    relight(await $.clock.now())
477    loop($)
478    if (position === 'panel') void openPetPane($)
479    else void $.ui.close({ id: PET_PANE }).catch(() => undefined)
480    try {
481      await $.command.register({
482        name: 'desk-pet',
483        description: 'Desk Pet: pick a pet, sound and reactions',
484        argumentHint: '[show|demo|axolotl|quokka|robot|dog|cat]',
485      })
486    } catch {
487      // The pet still runs without its command.
488    }
489
490    return next(e)
491  })
492
493  // A change made in the /config menu wins over the pane's stored choice.
494  on('config.set', async ($, e, next) => {
495    const field = e.key.startsWith('desk-pet.') ? e.key.slice('desk-pet.'.length) : null
496    const result = await next(e)
497    if (field && e.origin.kind !== 'plugin' && !result.deny && field in overrides) {
498      const { [field]: _dropped, ...rest } = overrides
499      overrides = rest
500      await $.store.set(SETTINGS, overrides)
501    }
502
503    return result
504  })
505
506  on('prompt.submit', ($, e, next) => {
507    answered()
508
509    return next(e)
510  })
511
512  on('turn.start', async ($, e, next) => {
513    const now = await $.clock.now()
514    turnStartedAt = now
515    answered()
516    if (isOn('wake')) play('wake', now, { label: 'waking up' })
517
518    return next(e)
519  })
520
521  on('turn.complete', async ($, e, next) => {
522    if (e.agentId !== undefined) return next(e)
523    const now = await $.clock.now()
524    turnStartedAt = null
525    answered()
526    if (e.reason === 'answer' && isOn('finish')) {
527      const gift = PETS[pet].gift === 'trophy' ? 'a trophy' : 'a letter'
528      play('finish', now, { label: `brought you ${gift}` })
529      chime($, doneChime())
530    } else {
531      reaction = null
532      nudge()
533    }
534
535    return next(e)
536  })
537
538  on('tool.call', async ($, e, next) => {
539    const now = await $.clock.now()
540    if (e.tool === 'AskUserQuestion' || e.tool === 'ExitPlanMode') {
541      ask($, e.tool === 'ExitPlanMode' ? 'has a plan for you' : 'has a question for you', now)
542      try {
543        return await next(e)
544      } finally {
545        answered()
546      }
547    }
548
549    const wants = classify(e)
550    if (!wants) return next(e)
551    const mine = isOn(wants.kind) ? play(wants.kind, now, { label: wants.label, frameMs: wants.frameMs, active: 1 }) : null
552    let ran: Awaited<ReturnType<typeof next>>
553    try {
554      ran = await next(e)
555    } finally {
556      const end = await $.clock.now()
557      if (mine) finish(mine, end)
558      // The call ran (or was refused), so any permission prompt was answered.
559      answered()
560    }
561
562    if (wants.isTest && !('deny' in ran && ran.deny !== undefined)) {
563      const hasFailed = 'isError' in ran && ran.isError === true
564      const kind: Kind = hasFailed ? 'fail' : 'pass'
565      if (isOn(kind)) play(kind, await $.clock.now(), { label: hasFailed ? 'tests failed…' : 'tests pass!' })
566    }
567
568    return ran
569  })
570
571  on('session.compact', async ($, e, next) => {
572    if (e.agentId !== undefined || e.trigger === 'precompute' || !isOn('tidy')) return next(e)
573    const mine = play('tidy', await $.clock.now(), { label: 'tidying up the context', holdMs: 3000, active: 1 })
574    try {
575      return await next(e)
576    } finally {
577      finish(mine, await $.clock.now())
578    }
579  })
580
581  // Permission prompts and MCP questions are noticed from the notification
582  // alone, never from the hooks that could answer them.
583  on('classic.Notification', async ($, e, next) => {
584    if (e.notification_type === 'permission_prompt') ask($, 'needs your OK', await $.clock.now())
585    if (e.notification_type === 'elicitation_dialog') ask($, 'has a question for you', await $.clock.now())
586
587    return next(e)
588  })
589
590  on('classic.PermissionDenied', ($, e, next) => {
591    answered()
592
593    return next(e)
594  })
595
596  on('command.run', { command: 'desk-pet' }, async ($, e) => {
597    const arg = e.args.trim().toLowerCase()
598    if (arg === 'show') {
599      await openPetPane($)
600
601      return { text: `Desk Pet: ${petName} is in the side panel.` }
602    }
603    if (arg === 'demo') {
604      const kinds: Kind[] = ['wake', 'read', 'edit', 'shell', 'search', 'pass', 'fail', 'bored', 'wave', 'finish', 'tidy']
605      kinds.forEach((kind, i) => {
606        $.clock.after(i * 3800, () => void $.clock.now().then(now => preview(kind, now)))
607      })
608
609      return { text: `${petName} is running through all ${kinds.length} reactions (about 40 seconds).` }
610    }
611    const wanted = PET_IDS.find(id => id === arg || PETS[id].name.toLowerCase() === arg || (arg === 'tiny robot' && id === 'robot'))
612    if (wanted) {
613      const deny = await save($, 'pet', wanted, () => $.config.set({ key: 'desk-pet.pet', value: wanted })).catch((error: unknown) =>
614        error instanceof Error ? error.message : String(error),
615      )
616
617      return { text: deny ? `Desk Pet: couldn't switch pets (${deny}).` : `Desk Pet: say hello to your ${PETS[wanted].name.toLowerCase()}.` }
618    }
619    if (arg !== '') {
620      return { text: 'Desk Pet: try /desk-pet, /desk-pet show, /desk-pet demo, or /desk-pet axolotl|quokka|robot|dog|cat.' }
621    }
622    await $.ui.open({ id: PANE, title: 'Desk Pet', focus: true, closeOnEscape: true })
623
624    return { text: 'Desk Pet settings opened.' }
625  })
626
627  // The side panel: the room scene, then the pet's name and what it's up to.
628  on('ui.render', { component: 'Pane', requestId: PET_PANE }, async ($, e) => {
629    isPaneWaiting = false
630    const show = await read($, shown)
631    const caption = captionFor(show)
632    const openSettings = () => void $.ui.open({ id: PANE, title: 'Desk Pet settings', focus: true, closeOnEscape: true })
633
634    if (e.surface === 'terminal') {
635      const { Box, Button, Raster, Text } = $.ui.resolve(e)
636      const now = await $.clock.now()
637      const index = frameAt(animFor(show), now - show.startedAt)
638      site = { requestId: e.requestId, animKey: animKey(show), frame: index }
639
640      return (
641        <Box flexDirection="column">
642          <Raster key={RASTER_KEY} columns={RASTER_COLUMNS} rows={RASTER_ROWS} cells={cellsFor(show, index)} />
643          <Box flexDirection="row" justifyContent="space-between" marginTop={1}>
644            <Box flexDirection="column">
645              <Text bold>{petName}</Text>
646              <Text dimColor wrap="truncate-end">
647                {caption}
648              </Text>
649            </Box>
650            <Button key="settings" label="settings" plain dimColor onPress={openSettings} />
651          </Box>
652        </Box>
653      )
654    }
655
656    const { Box, Button, Svg, Text } = $.ui.resolve(e)
657
658    return (
659      <Box flexDirection="column" gap={1}>
660        <Svg source={svgFor(show)} alt={`${petName}: ${caption}`} width={W * 5} height={H * 5} isInteractive />
661        <Box flexDirection="row" justifyContent="space-between" alignItems="center">
662          <Box flexDirection="column">
663            <Text bold>{petName}</Text>
664            <Text dimColor>{caption}</Text>
665          </Box>
666          <Button key="settings" label="Settings" plain dimColor onPress={openSettings} />
667        </Box>
668      </Box>
669    )
670  })
671
672  // Above the prompt: a card across the band, the pet at its left.
673  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
674    const isCard = position === 'above' || (position === 'panel' && isPaneWaiting)
675    if (!isCard || e.props.hasSurvey) return next(e)
676    const show = await read($, shown)
677    const caption = captionFor(show)
678
679    if (e.surface === 'terminal') {
680      const { Box, Raster, Text } = $.ui.resolve(e)
681      const now = await $.clock.now()
682      const index = frameAt(animFor(show), now - show.startedAt)
683      site = { requestId: e.requestId, animKey: animKey(show), frame: index }
684      const hasRoom = e.props.bodyColumns >= RASTER_COLUMNS + 24
685
686      return (
687        <Box flexDirection="row" alignItems="center" gap={2}>
688          <Raster key={RASTER_KEY} columns={RASTER_COLUMNS} rows={RASTER_ROWS} cells={cellsFor(show, index)} />
689          {hasRoom && (
690            <Box flexDirection="column">
691              <Text bold>{petName}</Text>
692              <Text wrap="truncate-end">{caption}</Text>
693            </Box>
694          )}
695        </Box>
696      )
697    }
698
699    if (e.surface === 'desktop') {
700      const { Box, Button, Svg, Text } = $.ui.resolve(e)
701      const openSettings = () => void $.ui.open({ id: PANE, title: 'Desk Pet settings', focus: true, closeOnEscape: true })
702
703      return (
704        <Box flexDirection="row" alignItems="center" gap={2} width="100%" borderStyle="round" paddingX={1} paddingY={1}>
705          <Svg source={svgFor(show)} alt={`${petName}: ${caption}`} width={W * 4} height={H * 4} isInteractive />
706          <Box flexDirection="column" flexGrow={1} gap={1}>
707            <Text bold>{petName}</Text>
708            <Text>{caption}</Text>
709          </Box>
710          <Button key="settings" label="Settings" plain dimColor onPress={openSettings} />
711        </Box>
712      )
713    }
714
715    return next(e)
716  })
717
718  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
719    const { Box, Button, Text } = $.ui.resolve(e)
720    // Each key is spelled out so a reader (and the directory's scanner) can
721    // see the pane only ever writes Desk Pet's own settings.
722    const keep = (field: string, value: string | boolean, write: () => Promise<ConfigSetResult>) =>
723      void save($, field, value, write).then(
724        deny => deny && $.ui.toast(`Desk Pet: ${deny}`),
725        (error: unknown) => $.ui.toast(`Desk Pet: couldn't save that setting (${error instanceof Error ? error.message : String(error)})`),
726      )
727    const setPet = (value: PetId) => keep('pet', value, () => $.config.set({ key: 'desk-pet.pet', value: value }))
728    const setPosition = (value: Position) => keep('position', value, () => $.config.set({ key: 'desk-pet.position', value: value }))
729    const setSound = (value: boolean) => keep('sound', value, () => $.config.set({ key: 'desk-pet.sound', value: value }))
730    const setReaction: Record<(typeof REACTIONS)[number]['field'], (value: boolean) => void> = {
731      react_turn_start: value => keep('react_turn_start', value, () => $.config.set({ key: 'desk-pet.react_turn_start', value: value })),
732      react_reading: value => keep('react_reading', value, () => $.config.set({ key: 'desk-pet.react_reading', value: value })),
733      react_editing: value => keep('react_editing', value, () => $.config.set({ key: 'desk-pet.react_editing', value: value })),
734      react_shell: value => keep('react_shell', value, () => $.config.set({ key: 'desk-pet.react_shell', value: value })),
735      react_web_search: value => keep('react_web_search', value, () => $.config.set({ key: 'desk-pet.react_web_search', value: value })),
736      react_tests_pass: value => keep('react_tests_pass', value, () => $.config.set({ key: 'desk-pet.react_tests_pass', value: value })),
737      react_tests_fail: value => keep('react_tests_fail', value, () => $.config.set({ key: 'desk-pet.react_tests_fail', value: value })),
738      react_long_run: value => keep('react_long_run', value, () => $.config.set({ key: 'desk-pet.react_long_run', value: value })),
739      react_needs_input: value => keep('react_needs_input', value, () => $.config.set({ key: 'desk-pet.react_needs_input', value: value })),
740      react_turn_finish: value => keep('react_turn_finish', value, () => $.config.set({ key: 'desk-pet.react_turn_finish', value: value })),
741      react_compaction: value => keep('react_compaction', value, () => $.config.set({ key: 'desk-pet.react_compaction', value: value })),
742      react_idle: value => keep('react_idle', value, () => $.config.set({ key: 'desk-pet.react_idle', value: value })),
743    }
744
745    return (
746      <Box flexDirection="column">
747        <Text bold>Pet</Text>
748        <Box flexDirection="row" gap={1} marginBottom={1}>
749          {PET_IDS.map(id => (
750            <Button
751              key={`pet-${id}`}
752              label={PETS[id].name}
753              variant={id === pet ? 'primary' : 'secondary'}
754              onPress={() => setPet(id)}
755            />
756          ))}
757        </Box>
758        <Text bold>Position</Text>
759        <Box flexDirection="row" gap={1}>
760          {POSITIONS.map(one => (
761            <Button
762              key={`position-${one.id}`}
763              label={one.title}
764              variant={one.id === position ? 'primary' : 'secondary'}
765              onPress={() => setPosition(one.id)}
766            />
767          ))}
768        </Box>
769        <Box flexDirection="row" gap={1} marginTop={1}>
770          <Text bold>Sound</Text>
771          <Button key="sound" label={isSoundOn ? 'On' : 'Off'} onPress={() => setSound(!isSoundOn)} />
772          <Text dimColor>a soft chime for needs input and turn finishes</Text>
773        </Box>
774        <Box marginTop={1}>
775          <Text bold>Reactions</Text>
776        </Box>
777        {REACTIONS.map(one => (
778          <Box key={`row-${one.field}`} flexDirection="row" gap={1}>
779            <Button
780              key={one.field}
781              label={`${isOn(one.kind) ? '●' : '○'} ${one.title}`}
782              plain
783              dimColor={!isOn(one.kind)}
784              onPress={() => setReaction[one.field](!isOn(one.kind))}
785            />
786            {one.kind !== 'idle' && (
787              <Button
788                key={`preview-${one.kind}`}
789                label="preview"
790                plain
791                dimColor
792                onPress={() => void $.clock.now().then(now => preview(one.kind, now))}
793              />
794            )}
795          </Box>
796        ))}
797        <Box marginTop={1}>
798          <Text dimColor>/desk-pet demo plays every reaction · /desk-pet show reopens the panel</Text>
799        </Box>
800      </Box>
801    )
802  })
803}
804
hooks/art.ts 1516 lines
1// Desk Pet pixel art: five pets, one frame builder per reaction, and the two
2// encoders (terminal half-block cells, desktop animated SVG). Pure data and
3// math, no engine calls, so it also runs under plain Node for previews.
4//
5// Pets are authored without outlines; every pet layer, arm layer and prop is
6// outlined automatically, which keeps silhouettes crisp at any pose.
7
8export const W = 48
9export const H = 24
10
11export type PetId = 'axolotl' | 'quokka' | 'robot' | 'dog' | 'cat'
12
13export type Kind =
14  | 'idle'
15  | 'still'
16  | 'wake'
17  | 'read'
18  | 'edit'
19  | 'shell'
20  | 'search'
21  | 'pass'
22  | 'fail'
23  | 'bored'
24  | 'wave'
25  | 'finish'
26  | 'tidy'
27
28export type Frame = Uint32Array
29
30export type Anim = { frames: Frame[]; frameMs: number; loop: boolean }
31
32type Px = readonly [number, number, string]
33type Arm = 'rest' | 'up' | 'wave' | 'out' | 'outUp' | 'tap' | 'hold' | 'lift'
34type Eyes = 'open' | 'closed' | 'happy' | 'wide' | 'left' | 'right' | 'down'
35type Mouth = 'none' | 'smile' | 'open' | 'yawn'
36
37type Pose = {
38  dx?: number
39  dy?: number
40  flip?: boolean
41  squash?: boolean
42  eyes?: Eyes
43  mouth?: Mouth
44  armL?: Arm
45  armR?: Arm
46  part?: number
47}
48
49type PetDef = {
50  name: string
51  sprite: readonly string[]
52  palette: Readonly<Record<string, number>>
53  /** Top-left pixel of each 2x3 eye, in sprite coordinates. */
54  eyes: readonly [readonly [number, number], readonly [number, number]]
55  /** Which palette entries draw an eye: its dark, its iris and its glint. */
56  eyeInk: { readonly k: string; readonly i: string; readonly w: string }
57  mouths: Readonly<Record<Exclude<Mouth, 'none'>, readonly Px[]>>
58  defaultMouth: Mouth
59  /** Two takes of the pet's signature motion (gills, tail, antenna). */
60  parts: readonly [readonly Px[], readonly Px[]]
61  /** Drawn over the outline (whiskers). */
62  decor: readonly Px[]
63  edit: 'hammer' | 'knit'
64  gift: 'trophy' | 'envelope'
65  dance: 'sway' | 'hop' | 'flip'
66  book: number
67  yarn: readonly [number, number]
68}
69
70// Shared prop colors.
71const C = {
72  white: 0xffffff,
73  paper: 0xfff8e8,
74  ink: 0x9aa3b0,
75  spine: 0xd8cfbd,
76  gray: 0x9aa0a6,
77  lgray: 0xd0d5db,
78  dgray: 0x4a4f57,
79  outline: 0x24272c,
80  black: 0x111316,
81  wood: 0xb07a45,
82  lwood: 0xd39b62,
83  dwood: 0x6b4423,
84  steel: 0x6d7580,
85  lsteel: 0xb9c1cb,
86  gold: 0xf5c542,
87  dgold: 0xa8770c,
88  lgold: 0xfff2a8,
89  red: 0xe5484d,
90  dred: 0x8e1f24,
91  rose: 0xf06292,
92  green: 0x3ddc84,
93  dgreen: 0x23784a,
94  grass: 0x4caf50,
95  blue: 0x4f9dff,
96  lblue: 0xa8d4ff,
97  sky: 0x7cc4ff,
98  sand: 0xe9c97a,
99  dsand: 0xb8934a,
100  lsand: 0xf6e2a8,
101  box: 0xc8955a,
102  lbox: 0xe2b57e,
103  tape: 0x8a5a2b,
104  dbox: 0x6e4724,
105  orange: 0xff9f1c,
106  purple: 0xa970ff,
107  yellow: 0xffe066,
108  rock: 0x8a9099,
109  lrock: 0xb9bec6,
110  drock: 0x5f656e,
111  sweat: 0x7cc4ff,
112}
113
114const mirror = (px: readonly Px[]): Px[] => px.map(([x, y, c]) => [17 - x, y, c] as const)
115
116const gills = (fronds: readonly (readonly [number, number])[][]): Px[] => {
117  const left: Px[] = fronds.flat().map(([x, y], i) => [x, y, i % 3 === 2 ? 'P' : 'p'] as const)
118
119  return [...left, ...mirror(left)]
120}
121
122export const PETS: Readonly<Record<PetId, PetDef>> = {
123  axolotl: {
124    name: 'Axolotl',
125    sprite: [
126      '......bbbbbb......',
127      '....bbhhhhbbbb....',
128      '...bhhhbbbbbbbb...',
129      '..bhhbbbbbbbbbbs..',
130      '..bhbbbbbbbbbbbs..',
131      '.bbbbbbbbbbbbbbbs.',
132      '.bbbbbbbbbbbbbbbs.',
133      '.bbbbbbbbbbbbbbbs.',
134      '.bppbbbbbbbbbppbs.',
135      '..bbbbbbbbbbbbbs..',
136      '...bbbbbbbbbbbs...',
137      '....sbbbbbbbbs....',
138      '....bbllllllbb....',
139      '...bbllllllllbs...',
140      '...bbllllllllbs...',
141      '...bbllllllllbs...',
142      '....bbllllllbs....',
143      '...bbb.....bbb....',
144    ],
145    palette: {
146      o: 0x8a2f55, b: 0xf6a9c7, s: 0xe383aa, h: 0xffd3e4, l: 0xffe8f0,
147      p: 0xff6fa5, P: 0xd93c78, m: 0x9b2f57, k: 0x2a1420, w: 0xffffff,
148    },
149    eyes: [[4, 5], [12, 5]],
150    eyeInk: { k: 'k', i: 'k', w: 'w' },
151    mouths: {
152      smile: [[6, 9, 'm'], [7, 10, 'm'], [8, 10, 'm'], [9, 10, 'm'], [10, 10, 'm'], [11, 9, 'm']],
153      open: [[7, 10, 'm'], [8, 10, 'm'], [9, 10, 'm'], [10, 10, 'm'], [8, 11, 'p'], [9, 11, 'p']],
154      yawn: [[7, 9, 'm'], [8, 9, 'm'], [9, 9, 'm'], [10, 9, 'm'], [7, 10, 'm'], [8, 10, 'P'], [9, 10, 'P'], [10, 10, 'm'], [8, 11, 'm'], [9, 11, 'm']],
155    },
156    defaultMouth: 'smile',
157    parts: [
158      [
159        ...gills([[[1, 3], [0, 2], [-1, 1]], [[0, 5], [-1, 5], [-2, 4]], [[0, 7], [-1, 8], [-2, 8]]]),
160        [14, 14, 's'], [15, 14, 's'], [16, 13, 's'], [17, 13, 's'], [18, 12, 'h'],
161      ],
162      [
163        ...gills([[[1, 3], [0, 2], [-1, 2]], [[0, 5], [-1, 6], [-2, 6]], [[0, 7], [-1, 7], [-2, 7]]]),
164        [14, 14, 's'], [15, 15, 's'], [16, 15, 's'], [17, 15, 's'], [18, 14, 'h'],
165      ],
166    ],
167    decor: [],
168    edit: 'knit',
169    gift: 'trophy',
170    dance: 'sway',
171    book: 0x4f9dff,
172    yarn: [0x2ec4b6, 0x9be7df],
173  },
174  quokka: {
175    name: 'Quokka',
176    sprite: [
177      '..bbb........bbb..',
178      '.bbpbb......bbpbb.',
179      '.bbpbbbbbbbbbbpbb.',
180      '..bbbhhbbbbbbbbs..',
181      '..bhhbbbbbbbbbbs..',
182      '.bbbbbbbbbbbbbbbs.',
183      '.bbbbbbbbbbbbbbbs.',
184      '.bbbbbbllllbbbbbs.',
185      '.bbbbbllllllbbbbs.',
186      '..bbblllkklllbbs..',
187      '...bbllllllllbs...',
188      '....sbbbbbbbbs....',
189      '....bbllllllbb....',
190      '...bbllllllllbs...',
191      '..bbbllllllllbbs..',
192      '..bbbllllllllbbs..',
193      '...bbbllllllbbs...',
194      '...bbb.....bbb....',
195    ],
196    palette: {
197      o: 0x4a321e, b: 0xb98a5c, s: 0x936a43, h: 0xd9b083, l: 0xf0dcbc,
198      p: 0xe9a3a3, m: 0x5a3d26, k: 0x22160d, w: 0xffffff,
199    },
200    eyes: [[4, 5], [12, 5]],
201    eyeInk: { k: 'k', i: 'k', w: 'w' },
202    mouths: {
203      smile: [[6, 9, 'm'], [7, 10, 'm'], [8, 10, 'm'], [9, 10, 'm'], [10, 10, 'm'], [11, 9, 'm']],
204      open: [[7, 10, 'm'], [8, 10, 'm'], [9, 10, 'm'], [10, 10, 'm'], [8, 11, 'p'], [9, 11, 'p']],
205      yawn: [[7, 10, 'k'], [8, 10, 'k'], [9, 10, 'k'], [10, 10, 'k'], [7, 11, 'k'], [8, 11, 'p'], [9, 11, 'p'], [10, 11, 'k']],
206    },
207    defaultMouth: 'smile',
208    parts: [
209      [[15, 15, 's'], [16, 16, 's']],
210      [[15, 15, 's'], [16, 15, 's'], [15, 0, '_'], [14, 0, '_'], [16, 1, 'b']],
211    ],
212    decor: [],
213    edit: 'hammer',
214    gift: 'trophy',
215    dance: 'hop',
216    book: 0x3ddc84,
217    yarn: [0xe85d8a, 0xf7a1c0],
218  },
219  robot: {
220    name: 'Tiny robot',
221    sprite: [
222      '..................',
223      '........ss........',
224      '...ssssssssssss...',
225      '..sbbbbbbbbbbbbs..',
226      '..sbllllllllllbs..',
227      '..sbllllllllllbs..',
228      '.ksbllllllllllbsk.',
229      '..sbllllllllllbs..',
230      '..sbllllllllllbs..',
231      '..sbbbbbbbbbbbbs..',
232      '..ssssssssssssss..',
233      '......ssssss......',
234      '....sbbbbbbbbs....',
235      '...sbbbqqqqbbbs...',
236      '...sbbbqqqqbbbs...',
237      '...sbbbbbbbbbbs...',
238      '....sbbbbbbbbs....',
239      '....ss......ss....',
240    ],
241    palette: {
242      o: 0x2c333c, b: 0xc7d0da, s: 0x8794a3, h: 0xeef3f8, l: 0x18222d,
243      e: 0x5ff0e0, r: 0xff5a5a, R: 0x7a2a2a, q: 0x9dff6a, Q: 0x3c6a2a,
244      k: 0x3a4250, w: 0xffffff,
245    },
246    eyes: [[6, 4], [10, 4]],
247    eyeInk: { k: 'e', i: 'e', w: 'w' },
248    mouths: {
249      smile: [[7, 8, 'e'], [8, 8, 'e'], [9, 8, 'e'], [10, 8, 'e']],
250      open: [[8, 7, 'e'], [9, 7, 'e'], [7, 8, 'e'], [8, 8, 'e'], [9, 8, 'e'], [10, 8, 'e']],
251      yawn: [[7, 7, 'e'], [8, 7, 'e'], [9, 7, 'e'], [10, 7, 'e'], [7, 8, 'e'], [8, 8, 'e'], [9, 8, 'e'], [10, 8, 'e']],
252    },
253    defaultMouth: 'smile',
254    parts: [
255      [[8, 0, 'r'], [9, 0, 'r'], [7, 13, 'q'], [8, 13, 'Q'], [9, 14, 'q'], [10, 14, 'Q']],
256      [[8, 0, 'R'], [9, 0, 'R'], [7, 13, 'Q'], [8, 13, 'q'], [9, 14, 'Q'], [10, 14, 'q']],
257    ],
258    decor: [],
259    edit: 'hammer',
260    gift: 'trophy',
261    dance: 'flip',
262    book: 0xa970ff,
263    yarn: [0xe85d8a, 0xf7a1c0],
264  },
265  dog: {
266    name: 'Dog',
267    sprite: [
268      '.....bbbbbbbb.....',
269      '...bbhhhbbbbbbb...',
270      '.ssbhhbbbbbbbbbss.',
271      'ssssbbbbbbbbbbssss',
272      'ssssbbbbbbbbbbssss',
273      'ssssbbbbbbbbbbssss',
274      'sssbbbbbbbbbbbbsss',
275      '.ssbbbllllllbbbss.',
276      '.ss.bbllkkllbb.ss.',
277      '.....bllllllb.....',
278      '......rryyrr......',
279      '.....bbbbbbbb.....',
280      '....bbbllllbbb....',
281      '...bbbllllllbbb...',
282      '...bbbllllllbbb...',
283      '...bbbllllllbbb...',
284      '....bbbllllbbb....',
285      '...bbb.....bbb....',
286    ],
287    palette: {
288      o: 0x5a3a1c, b: 0xe2b26e, s: 0x9a6534, h: 0xf3d09c, l: 0xfdf0dc,
289      p: 0xf27c8c, k: 0x2b1e14, m: 0x5a3a1c, r: 0xd9443a, y: 0xf5c542, w: 0xffffff,
290    },
291    eyes: [[5, 4], [11, 4]],
292    eyeInk: { k: 'k', i: 'k', w: 'w' },
293    mouths: {
294      smile: [[7, 9, 'm'], [10, 9, 'm']],
295      open: [[7, 9, 'm'], [8, 9, 'm'], [9, 9, 'm'], [10, 9, 'm'], [8, 10, 'p'], [9, 10, 'p'], [8, 11, 'p']],
296      yawn: [[7, 9, 'k'], [8, 9, 'k'], [9, 9, 'k'], [10, 9, 'k'], [8, 10, 'p'], [9, 10, 'p']],
297    },
298    defaultMouth: 'smile',
299    parts: [
300      [[15, 13, 'b'], [16, 12, 'b'], [17, 11, 'b'], [17, 10, 'h']],
301      [[15, 13, 'b'], [16, 13, 'b'], [17, 14, 'b'], [18, 14, 'h']],
302    ],
303    decor: [],
304    edit: 'hammer',
305    gift: 'envelope',
306    dance: 'hop',
307    book: 0xe5484d,
308    yarn: [0x5b7cff, 0xaebcff],
309  },
310  cat: {
311    name: 'Cat',
312    sprite: [
313      '..b............b..',
314      '..bb..........bb..',
315      '..bpb........bpb..',
316      '..bppbbbbbbbbppb..',
317      '..bbbbbbssbbbbbb..',
318      '.bsbbbbbbbbbbbbsb.',
319      '.bbbbbbbbbbbbbbbb.',
320      '.bsbbbbbbbbbbbbsb.',
321      '.bbbbbbllllbbbbbb.',
322      '..bbbbllppllbbbb..',
323      '...bbbllllllbbb...',
324      '.....bbbbbbbb.....',
325      '....bbbllllbbb....',
326      '...bbbllllllbbb...',
327      '...bsbllllllbsb...',
328      '...bbbllllllbbb...',
329      '....bbbllllbbb....',
330      '...bbb.....bbb....',
331    ],
332    palette: {
333      o: 0x6a3510, b: 0xf2a154, s: 0xc96f22, h: 0xffc27f, l: 0xfde8cf,
334      p: 0xf59fb0, k: 0x2a1a10, m: 0x8a4a1c, g: 0x7ccf4a, W: 0xfff4e6, w: 0xffffff,
335    },
336    eyes: [[4, 5], [12, 5]],
337    eyeInk: { k: 'k', i: 'g', w: 'w' },
338    mouths: {
339      smile: [[7, 10, 'm'], [8, 11, 'm'], [9, 11, 'm'], [10, 10, 'm']],
340      open: [[7, 10, 'm'], [8, 10, 'm'], [9, 10, 'm'], [10, 10, 'm'], [8, 11, 'p'], [9, 11, 'p']],
341      yawn: [[7, 10, 'k'], [8, 10, 'k'], [9, 10, 'k'], [10, 10, 'k'], [8, 11, 'p'], [9, 11, 'p']],
342    },
343    defaultMouth: 'smile',
344    parts: [
345      [[15, 15, 'b'], [16, 14, 'b'], [17, 13, 'b'], [17, 12, 'b'], [17, 11, 's'], [16, 10, 's']],
346      [[15, 15, 'b'], [16, 15, 'b'], [17, 14, 'b'], [18, 13, 'b'], [18, 12, 's'], [18, 11, 's']],
347    ],
348    decor: [[-1, 9, 'W'], [-2, 9, 'W'], [-1, 11, 'W'], [-2, 12, 'W'], [18, 9, 'W'], [19, 9, 'W'], [18, 11, 'W'], [19, 12, 'W']],
349    edit: 'knit',
350    gift: 'envelope',
351    dance: 'sway',
352    book: 0x2ec4b6,
353    yarn: [0x5b7cff, 0xaebcff],
354  },
355}
356
357export const PET_IDS = Object.keys(PETS) as PetId[]
358
359// --- Canvas ---------------------------------------------------------------
360
361const OPAQUE = 0x1000000
362
363class Canvas {
364  readonly px = new Uint32Array(W * H)
365
366  get(x: number, y: number): number {
367    return x < 0 || y < 0 || x >= W || y >= H ? 0 : this.px[y * W + x]!
368  }
369
370  set(x: number, y: number, rgb: number): void {
371    if (x < 0 || y < 0 || x >= W || y >= H) return
372    this.px[y * W + x] = rgb < 0 ? 0 : OPAQUE | rgb
373  }
374
375  rect(x: number, y: number, w: number, h: number, rgb: number): void {
376    for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) this.set(x + i, y + j, rgb)
377  }
378
379  stamp(x: number, y: number, rows: readonly string[], pal: Readonly<Record<string, number>>): void {
380    rows.forEach((row, j) => {
381      for (let i = 0; i < row.length; i++) {
382        const ch = row[i]!
383        if (ch !== '.' && pal[ch] !== undefined) this.set(x + i, y + j, pal[ch]!)
384      }
385    })
386  }
387
388  /** Rings every filled pixel with `rgb` where it borders empty space. */
389  outline(rgb: number): this {
390    const ring: number[] = []
391    for (let y = 0; y < H; y++) {
392      for (let x = 0; x < W; x++) {
393        if (this.get(x, y)) continue
394        if (this.get(x - 1, y) || this.get(x + 1, y) || this.get(x, y - 1) || this.get(x, y + 1)) ring.push(y * W + x)
395      }
396    }
397    for (const i of ring) this.px[i] = OPAQUE | rgb
398
399    return this
400  }
401
402  over(top: Canvas): this {
403    top.px.forEach((v, i) => {
404      if (v) this.px[i] = v
405    })
406
407    return this
408  }
409}
410
411/** Draws something on its own layer, outlines it, and lays it over `c`. */
412function outlined(c: Canvas, rgb: number, draw: (layer: Canvas) => void): void {
413  const layer = new Canvas()
414  draw(layer)
415  c.over(layer.outline(rgb))
416}
417
418// --- Pets -----------------------------------------------------------------
419
420const PET_X = 3
421const PET_Y = 4
422const FEET_ROW = 16
423const SHOULDERS: readonly [readonly [number, number], readonly [number, number]] = [[2, 13], [15, 13]]
424
425const ARM_PATHS: Record<Arm, (sx: number, sy: number, d: number) => [number, number][]> = {
426  rest: (sx, sy) => [[sx, sy], [sx, sy + 1], [sx, sy + 2]],
427  up: (sx, sy, d) => [[sx, sy], [sx + d, sy - 1], [sx + d, sy - 2], [sx + d, sy - 3], [sx + d, sy - 4]],
428  wave: (sx, sy, d) => [[sx, sy], [sx + d, sy - 1], [sx + 2 * d, sy - 2], [sx + 3 * d, sy - 3], [sx + 3 * d, sy - 4]],
429  out: (sx, sy, d) => [[sx, sy], [sx + d, sy], [sx + 2 * d, sy], [sx + 3 * d, sy]],
430  outUp: (sx, sy, d) => [[sx, sy], [sx + d, sy - 1], [sx + 2 * d, sy - 1], [sx + 3 * d, sy - 2]],
431  tap: (sx, sy, d) => [[sx, sy], [sx, sy + 1], [sx - d, sy + 2], [sx - 2 * d, sy + 3]],
432  hold: (sx, sy, d) => [[sx, sy], [sx - d, sy + 1], [sx - 2 * d, sy + 1], [sx - 3 * d, sy + 1]],
433  lift: (sx, sy, d) => [[sx, sy], [sx, sy - 1], [sx - d, sy - 2], [sx - d, sy - 3], [sx - 2 * d, sy - 4]],
434}
435
436/** Where a pet's sprite pixel lands on the canvas for a pose. */
437function place(pose: Pose, x: number, y: number): [number, number] {
438  const sx = pose.flip ? 17 - x : x
439  const sy = pose.squash && y < FEET_ROW ? y + 1 : y
440
441  return [PET_X + (pose.dx ?? 0) + sx, PET_Y + (pose.dy ?? 0) + sy]
442}
443
444/** The canvas position of an arm's paw. */
445function paw(pose: Pose, side: 0 | 1): [number, number] {
446  const arm = (side === 0 ? pose.armL : pose.armR) ?? 'rest'
447  const [sx, sy] = SHOULDERS[side]!
448  const path = ARM_PATHS[arm](sx, sy, side === 0 ? -1 : 1)
449  const [x, y] = path[path.length - 1]!
450
451  return place(pose, x, y)
452}
453
454function drawEye(put: (x: number, y: number, ch: string) => void, pet: PetDef, ex: number, ey: number, eyes: Eyes, outward: number): void {
455  const { k, i, w } = pet.eyeInk
456  const open = (x: number) => {
457    put(x, ey, k)
458    put(x + 1, ey, w)
459    put(x, ey + 1, i)
460    put(x + 1, ey + 1, k)
461    put(x, ey + 2, k)
462    put(x + 1, ey + 2, k)
463  }
464  switch (eyes) {
465    case 'open':
466      return open(ex)
467    case 'left':
468      return open(ex - 1)
469    case 'right':
470      return open(ex + 1)
471    case 'closed':
472      put(ex - 1, ey + 1, k)
473      put(ex, ey + 2, k)
474      put(ex + 1, ey + 2, k)
475      put(ex + 2, ey + 1, k)
476      return
477    case 'happy':
478      put(ex - 1, ey + 2, k)
479      put(ex, ey + 1, k)
480      put(ex + 1, ey + 1, k)
481      put(ex + 2, ey + 2, k)
482      return
483    case 'wide':
484      put(ex - outward, ey, w)
485      open(ex)
486      put(ex - outward, ey + 1, w)
487      put(ex - outward, ey + 2, w)
488      return
489    case 'down':
490      put(ex, ey + 1, 's')
491      put(ex + 1, ey + 1, 's')
492      put(ex, ey + 2, k)
493      put(ex + 1, ey + 2, k)
494      return
495  }
496}
497
498function drawPet(c: Canvas, pet: PetDef, pose: Pose): void {
499  const pal = pet.palette
500  const putOn = (layer: Canvas) => (x: number, y: number, ch: string) => {
501    const rgb = ch === '_' ? -1 : pal[ch]
502    if (rgb === undefined) return
503    const [cx, cy] = place(pose, x, y)
504    layer.set(cx, cy, rgb)
505  }
506
507  outlined(c, pal.o!, body => {
508    const put = putOn(body)
509    pet.sprite.forEach((row, y) => {
510      for (let x = 0; x < row.length; x++) if (row[x] !== '.') put(x, y, row[x]!)
511    })
512    for (const [x, y, ch] of pet.parts[(pose.part ?? 0) % 2]!) put(x, y, ch)
513  })
514
515  const put = putOn(c)
516  for (const [x, y, ch] of pet.decor) put(x, y, ch)
517  pet.eyes.forEach(([ex, ey], side) => drawEye(put, pet, ex, ey, pose.eyes ?? 'open', side === 0 ? -1 : 1))
518  const mouth = pose.mouth ?? pet.defaultMouth
519  if (mouth !== 'none') for (const [x, y, ch] of pet.mouths[mouth]) put(x, y, ch)
520
521  outlined(c, pal.o!, arms => {
522    const put = putOn(arms)
523    ;([0, 1] as const).forEach(side => {
524      const arm = (side === 0 ? pose.armL : pose.armR) ?? 'rest'
525      const [sx, sy] = SHOULDERS[side]!
526      const path = ARM_PATHS[arm](sx, sy, side === 0 ? -1 : 1)
527      path.forEach(([x, y], i) => put(x, y, i === path.length - 1 ? 'h' : 'b'))
528    })
529  })
530}
531
532// --- Props ----------------------------------------------------------------
533
534const BOOK = [
535  '.wwwwwddwwwww.',
536  'cwgggwddwgggwc',
537  'cwwwwwddwwwwwc',
538  'cwggwwddwggwwc',
539  'cwwwwwddwwwwwc',
540  'cwgggwddwggwwc',
541  'cccccccccccccc',
542]
543const BOOK_ALT = [
544  '.wwwwwddwwwww.',
545  'cwggwwddwgggwc',
546  'cwwwwwddwwwwwc',
547  'cwgggwddwggwwc',
548  'cwwwwwddwwwwwc',
549  'cwggwwddwgggwc',
550  'cccccccccccccc',
551]
552const TROPHY = [
553  '..yyyyyyy..',
554  'yyyhyyyyyyy',
555  'y.yhyyyyy.y',
556  'y.yhyyyyy.y',
557  '.yyyyyyyyy.',
558  '...yyyyy...',
559  '....yyy....',
560  '.....y.....',
561  '....ddd....',
562  '..ddddddd..',
563]
564const ENVELOPE = [
565  'wwwwwwwwwww',
566  'wgwwwwwwwgw',
567  'wwgwwwwwgww',
568  'wwwgwwwgwww',
569  'wwwrrwrrwww',
570  'wwwrrrrrwww',
571  'wwwwrrrwwww',
572  'wwwwwrwwwww',
573]
574const HEART = ['rr.rr', 'rrrrr', '.rrr.', '..r..']
575const BOX = ['CCCCCCC', 'CCCdCCC', 'cccdccc', 'cccdccc', 'ccccccc', 'ccccccc']
576const BUBBLE = [
577  '.wwwwwww.',
578  'wwwwowwww',
579  'wwwwowwww',
580  'wwwwowwww',
581  'wwwwowwww',
582  'wwwwwwwww',
583  'wwwwowwww',
584  '.wwwwwww.',
585  '..ww.....',
586  '.w.......',
587]
588const ROCK = [
589  '........hhhhhh........',
590  '.....hhhhgggggghh.....',
591  '...hhhgggggggggggggd..',
592  '..hhggggggggggggggggd.',
593  '.hggggggggggggggggggdd',
594  'hggggggggggggggggggddd',
595  'gggggggggggggggggggddd',
596  'ggggggggggggggggggdddd',
597  'ggggggggggggggggdddddd',
598]
599const CHECK = ['......g', '.....gg', 'g...gg.', 'gg.gg..', '.ggg...', '..g....']
600const CROSS = ['r...r', 'rr.rr', '.rrr.', 'rr.rr', 'r...r']
601const QUESTION = ['.qqq.', 'q...q', '...q.', '..q..', '.....', '..q..']
602const Z_SMALL = ['zzz', '.z.', 'zzz']
603const Z_BIG = ['zzzz', '..z.', '.z..', 'zzzz']
604const YARN = [
605  ['.yyy.', 'yYyyy', 'yyYyy', 'yyyYy', '.yyy.'],
606  ['.yyy.', 'yyyYy', 'yyYyy', 'yYyyy', '.yyy.'],
607  ['.yYy.', 'yyYyy', 'yyYyy', 'yyYyy', '.yYy.'],
608]
609const CODE_LINES = [
610  [2, 7, 0], [4, 5, 1], [4, 8, 2], [2, 3, 0], [2, 9, 3], [4, 6, 1], [6, 5, 2], [2, 4, 0], [4, 8, 3], [2, 6, 1],
611] as const
612
613function sparkle(c: Canvas, x: number, y: number, isBig: boolean, rgb = C.lgold): void {
614  c.set(x, y, C.white)
615  c.set(x - 1, y, rgb)
616  c.set(x + 1, y, rgb)
617  c.set(x, y - 1, rgb)
618  c.set(x, y + 1, rgb)
619  if (isBig) {
620    c.set(x - 2, y, rgb)
621    c.set(x + 2, y, rgb)
622    c.set(x, y - 2, rgb)
623    c.set(x, y + 2, rgb)
624  }
625}
626
627function zzz(c: Canvas, step: number): void {
628  const z = { z: C.lblue }
629  if (step >= 0) outlined(c, C.outline, l => l.stamp(25, 7, Z_SMALL, z))
630  if (step >= 1) outlined(c, C.outline, l => l.stamp(29, 4, Z_SMALL, z))
631  if (step >= 2) outlined(c, C.outline, l => l.stamp(33, 0, Z_BIG, z))
632}
633
634function badge(c: Canvas, kind: 'check' | 'cross' | 'question' | 'bang', x: number, y: number): void {
635  if (kind === 'check') outlined(c, C.dgreen, l => l.stamp(x, y, CHECK, { g: C.green }))
636  if (kind === 'cross') outlined(c, C.dred, l => l.stamp(x, y, CROSS, { r: C.red }))
637  if (kind === 'question') outlined(c, C.outline, l => l.stamp(x, y, QUESTION, { q: C.yellow }))
638}
639
640function confetti(c: Canvas, f: number): void {
641  const colors = [C.red, C.gold, C.green, C.blue, C.purple, C.rose, C.orange]
642  for (let i = 0; i < 26; i++) {
643    const x = (i * 11 + 5) % W
644    const speed = 1 + (i % 3)
645    const y = ((i * 7 + f * speed) % (H + 6)) - 3
646    const rgb = colors[i % colors.length]!
647    c.set(x, y, rgb)
648    if (i % 2 === 0) c.set(x + ((f + i) % 2 === 0 ? 1 : 0), y + ((f + i) % 2 === 0 ? 0 : 1), rgb)
649  }
650}
651
652// --- Room -----------------------------------------------------------------
653
654export type DayPhase = 'dawn' | 'morning' | 'noon' | 'afternoon' | 'evening' | 'night'
655
656export const DAY_PHASES: readonly DayPhase[] = ['dawn', 'morning', 'noon', 'afternoon', 'evening', 'night']
657
658/** The part of the day at a local clock time, in hours since midnight. */
659export function dayPhase(hours: number): DayPhase {
660  if (hours >= 5 && hours < 7) return 'dawn'
661  if (hours >= 7 && hours < 11) return 'morning'
662  if (hours >= 11 && hours < 14) return 'noon'
663  if (hours >= 14 && hours < 17) return 'afternoon'
664  if (hours >= 17 && hours < 20) return 'evening'
665
666  return 'night'
667}
668
669/** The room's own colors in full daylight; each phase's light tints them. */
670const ROOM_COLORS = {
671  wallTop: 0x6f88ad,
672  wall: 0x7891b5,
673  wallLow: 0x809abd,
674  dot: 0x93accd,
675  frame: 0xd2a878,
676  frameDark: 0x9c7650,
677  curtain: 0xeee3cf,
678  curtainFold: 0xc9b89c,
679  rod: 0x6b4a2e,
680  cord: 0x2e3340,
681  shade: 0x55607a,
682  shadeRim: 0x7a86a0,
683  baseboard: 0x4d5875,
684  floor: 0xb98257,
685  floorLight: 0xc68e60,
686  seam: 0x8f5e3a,
687  rug: 0xd0607a,
688  rugEdge: 0xe8889b,
689}
690
691type Rgb = readonly [number, number, number]
692
693type Sky = {
694  /** Four bands of sky, top to horizon, two rows each. */
695  bands: readonly [number, number, number, number]
696  hills: readonly [far: number, near: number]
697  /** The light falling on the whole room, as a multiplier per channel. */
698  ambient: Rgb
699  /** How brightly the pendant lamp is lit, 0 for off. */
700  lamp: number
701  sun?: { x: number; y: number; r: number; core: number; glow: number }
702  moon?: { x: number; y: number }
703  stars?: { at: readonly (readonly [number, number])[]; rgb: number }
704  clouds?: readonly { x: number; y: number; w: number; rgb: number; shade: number }[]
705  /** Lit windows of a house on the far hill. */
706  houseLights?: number
707  /** Sunlight through the window onto the floor: its slant, how deep it reaches and its color. */
708  beam?: { skew: number; rows: number; light: Rgb }
709}
710
711const SKIES: Readonly<Record<DayPhase, Sky>> = {
712  dawn: {
713    bands: [0x3c3f7a, 0x8a6fae, 0xe89aa0, 0xffc28a],
714    hills: [0x6a5a88, 0x47406c],
715    ambient: [0.74, 0.64, 0.74],
716    lamp: 0.3,
717    sun: { x: 36, y: 8, r: 2, core: 0xffe2a4, glow: 0xffad72 },
718    stars: { at: [[35, 3], [43, 4], [41, 3]], rgb: 0xc9c6f0 },
719    clouds: [{ x: 40, y: 5, w: 4, rgb: 0xf8b9b0, shade: 0xd48c9c }],
720    houseLights: 0xffd58a,
721    beam: { skew: 2, rows: 5, light: [0.24, 0.14, 0.12] },
722  },
723  morning: {
724    bands: [0x5aa8ec, 0x7dbcf2, 0xa4d2f6, 0xcfe8f7],
725    hills: [0x86bf8e, 0x5f9f6b],
726    ambient: [0.98, 0.96, 0.9],
727    lamp: 0,
728    sun: { x: 35, y: 4, r: 1, core: 0xfff6c2, glow: 0xffe37e },
729    clouds: [
730      { x: 40, y: 4, w: 5, rgb: 0xffffff, shade: 0xdbe9f5 },
731      { x: 34, y: 7, w: 4, rgb: 0xf4f9ff, shade: 0xd2e3f2 },
732    ],
733    beam: { skew: 1, rows: 4, light: [0.26, 0.24, 0.16] },
734  },
735  noon: {
736    bands: [0x3b8fe0, 0x52a3ea, 0x6fb6f0, 0x93caf4],
737    hills: [0x72b77a, 0x4e995b],
738    ambient: [1.04, 1.04, 1.02],
739    lamp: 0,
740    sun: { x: 42, y: 4, r: 1, core: 0xffffe6, glow: 0xfff191 },
741    clouds: [{ x: 34, y: 4, w: 4, rgb: 0xffffff, shade: 0xd8e8f6 }],
742    beam: { skew: 0, rows: 2, light: [0.3, 0.3, 0.24] },
743  },
744  afternoon: {
745    bands: [0x4f97d8, 0x78b0e0, 0xb2cbd8, 0xf0d6a2],
746    hills: [0x93ad62, 0x6f8f4a],
747    ambient: [1.0, 0.92, 0.8],
748    lamp: 0,
749    sun: { x: 43, y: 7, r: 1, core: 0xfff2b4, glow: 0xffcb5c },
750    clouds: [{ x: 35, y: 4, w: 4, rgb: 0xfff5e2, shade: 0xe6cfae }],
751    beam: { skew: -1, rows: 4, light: [0.36, 0.25, 0.1] },
752  },
753  evening: {
754    bands: [0x4b3a78, 0xa04f7c, 0xec7a58, 0xffb25a],
755    hills: [0x5c3559, 0x3b2242],
756    ambient: [0.74, 0.54, 0.54],
757    lamp: 0.6,
758    sun: { x: 37, y: 8, r: 2, core: 0xffd870, glow: 0xff8a3c },
759    clouds: [{ x: 40, y: 4, w: 5, rgb: 0xf59aa0, shade: 0xc0688a }],
760    houseLights: 0xffcf7a,
761    beam: { skew: 2, rows: 5, light: [0.38, 0.16, 0.06] },
762  },
763  night: {
764    bands: [0x131a40, 0x18204a, 0x1d2757, 0x243168],
765    hills: [0x1c2448, 0x111733],
766    ambient: [0.34, 0.33, 0.44],
767    lamp: 0.85,
768    moon: { x: 42, y: 4 },
769    stars: { at: [[34, 3], [37, 4], [35, 7], [43, 7], [44, 4], [37, 8], [42, 5]], rgb: 0xe8ecff },
770    houseLights: 0xffd27a,
771  },
772}
773
774// The window: its frame, the glass inside it and the bars across it.
775const WIN = { x: 33, y: 2, w: 13, h: 10 } as const
776const GLASS = { x: WIN.x + 1, y: WIN.y + 1, w: WIN.w - 2, h: WIN.h - 2 } as const
777const BAR_X = GLASS.x + 5
778const BAR_Y = GLASS.y + 3
779const FAR_HILL = [2, 2, 2, 1, 1, 1, 2, 2, 2, 1, 1]
780const NEAR_HILL = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
781const LAMP = { x: 27, y: 4 } as const
782const LAMP_GLOW: Rgb = [1, 0.78, 0.5]
783
784const channels = (rgb: number): Rgb => [(rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff]
785const pack = (r: number, g: number, b: number) => {
786  const byte = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
787
788  return (byte(r) << 16) | (byte(g) << 8) | byte(b)
789}
790
791/** `a` moved `t` of the way to `b`. */
792function mix(a: number, b: number, t: number): number {
793  const [ar, ag, ab] = channels(a)
794  const [br, bg, bb] = channels(b)
795
796  return pack(ar + (br - ar) * t, ag + (bg - ag) * t, ab + (bb - ab) * t)
797}
798
799const isGlass = (x: number, y: number) =>
800  x >= GLASS.x && x < GLASS.x + GLASS.w && y >= GLASS.y && y < GLASS.y + GLASS.h && x !== BAR_X && y !== BAR_Y
801
802/** The view through the window: sky, sun or moon, clouds and two rows of hills. */
803function drawView(c: Canvas, sky: Sky): void {
804  const bottom = GLASS.y + GLASS.h - 1
805  for (let y = GLASS.y; y <= bottom; y++) c.rect(GLASS.x, y, GLASS.w, 1, sky.bands[Math.min(3, (y - GLASS.y) >> 1)]!)
806  const tint = (x: number, y: number, rgb: number, t: number) => c.set(x, y, mix(c.get(x, y) & 0xffffff, rgb, t))
807
808  for (const [x, y] of sky.stars?.at ?? []) c.set(x, y, sky.stars!.rgb)
809  if (sky.sun) {
810    const { x: sx, y: sy, r, core, glow } = sky.sun
811    for (let y = sy - r - 1; y <= sy + r + 1; y++) {
812      for (let x = sx - r - 1; x <= sx + r + 1; x++) {
813        const d = Math.hypot(x - sx, y - sy)
814        if (d <= r + 0.2) c.set(x, y, d < 0.5 || d <= r - 0.8 ? mix(core, 0xffffff, 0.4) : core)
815        else if (d <= r + 1.2) tint(x, y, glow, 0.4)
816      }
817    }
818  }
819  if (sky.moon) {
820    const { x, y } = sky.moon
821    for (const [dx, dy] of [[-1, -1], [1, -1], [-1, 1], [1, 1]] as const) tint(x + dx, y + dy, 0xfff2b0, 0.35)
822    for (const [dx, dy] of [[0, -1], [-1, 0], [0, 0], [1, 0], [0, 1]] as const) c.set(x + dx, y + dy, 0xfff2b0)
823    c.set(x + 1, y, 0xe2d49a)
824  }
825  for (const cloud of sky.clouds ?? []) {
826    c.rect(cloud.x + 1, cloud.y, cloud.w - 2, 1, cloud.rgb)
827    c.rect(cloud.x, cloud.y + 1, cloud.w, 1, cloud.rgb)
828    c.rect(cloud.x + 1, cloud.y + 2, cloud.w - 1, 1, cloud.shade)
829  }
830  FAR_HILL.forEach((h, i) => c.rect(GLASS.x + i, bottom - h + 1, 1, h, sky.hills[0]))
831  NEAR_HILL.forEach((h, i) => c.rect(GLASS.x + i, bottom - h + 1, 1, h, sky.hills[1]))
832  if (sky.houseLights) {
833    const x = GLASS.x + 9
834    const top = bottom - FAR_HILL[9]! - 1
835    c.rect(x, top, 2, 2, mix(sky.hills[0], 0x000000, 0.3))
836    c.set(x, top + 1, sky.houseLights)
837  }
838}
839
840/** Wallpaper, a curtained window, a pendant lamp, a wooden floor and a rug, unlit. */
841function drawRoom(c: Canvas): void {
842  const R = ROOM_COLORS
843  for (let y = 0; y < 18; y++) {
844    for (let x = 0; x < W; x++) {
845      const tone = y < 6 ? R.wallTop : y < 12 ? R.wall : R.wallLow
846      c.set(x, y, x % 6 === 3 && y % 6 === 3 ? R.dot : tone)
847    }
848  }
849  c.rect(WIN.x, WIN.y, WIN.w, WIN.h, R.frame)
850  for (let y = GLASS.y; y < GLASS.y + GLASS.h; y++) {
851    for (let x = GLASS.x; x < GLASS.x + GLASS.w; x++) if (isGlass(x, y)) c.px[y * W + x] = 0
852  }
853  c.rect(WIN.x - 1, WIN.y + WIN.h, WIN.w + 2, 1, R.frameDark)
854  // Curtains on a rod, gathered a little at the sill.
855  c.rect(WIN.x - 3, 1, WIN.w + 6, 1, R.rod)
856  for (const x0 of [WIN.x - 2, WIN.x + WIN.w]) {
857    const isLeft = x0 < WIN.x
858    for (let y = 2; y < WIN.y + WIN.h + 2; y++) {
859      c.set(x0, y, isLeft ? R.curtainFold : R.curtain)
860      c.set(x0 + 1, y, isLeft ? R.curtain : R.curtainFold)
861    }
862    c.set(isLeft ? x0 - 1 : x0 + 2, WIN.y + WIN.h + 1, R.curtainFold)
863    c.set(isLeft ? x0 + 1 : x0, 2, R.curtainFold)
864  }
865  // The pendant lamp.
866  c.rect(LAMP.x, 0, 1, 2, R.cord)
867  c.rect(LAMP.x - 1, 2, 3, 1, R.shade)
868  c.rect(LAMP.x - 2, 3, 5, 1, R.shadeRim)
869  c.rect(0, 18, W, 1, R.baseboard)
870  for (let y = 19; y < H; y++) {
871    for (let x = 0; x < W; x++) {
872      const isSeam = (x + (y % 2) * 6) % 12 === 0
873      c.set(x, y, isSeam ? R.seam : y % 2 === 0 ? R.floor : R.floorLight)
874    }
875  }
876  c.rect(6, 21, 13, 1, R.rugEdge)
877  c.rect(4, 22, 17, 1, R.rug)
878  c.rect(5, 23, 15, 1, R.rug)
879}
880
881/**
882 * How much light reaches each pixel: the phase's ambient light, the lamp's
883 * warm pool and the sunbeam the window throws on the floor.
884 */
885function lightField(sky: Sky): Float32Array {
886  const light = new Float32Array(W * H * 3)
887  for (let y = 0; y < H; y++) {
888    for (let x = 0; x < W; x++) {
889      const i = (y * W + x) * 3
890      light.set(sky.ambient, i)
891      if (sky.lamp > 0) {
892        const fall = Math.max(0, 1 - Math.hypot((x - LAMP.x) / 22, (y - LAMP.y) / 20)) ** 1.4
893        // The floor under the lamp catches a pool of its own.
894        const pool = y >= 19 ? Math.max(0, 1 - Math.hypot((x - LAMP.x) / 15, (y - 21) / 4)) : 0
895        for (let k = 0; k < 3; k++) light[i + k]! += LAMP_GLOW[k]! * sky.lamp * (fall * 0.9 + pool * 0.5)
896      }
897    }
898  }
899  if (sky.beam) {
900    const { skew, rows, light: tint } = sky.beam
901    for (let row = 0; row < rows; row++) {
902      const y = 19 + row
903      const shift = Math.round(skew * (row + 0.5))
904      for (let x = GLASS.x + shift; x < GLASS.x + GLASS.w + shift; x++) {
905        if (x < 0 || x >= W || x === BAR_X + shift) continue
906        // The beam fades toward its far edge.
907        const fade = 1 - row / (rows + 1)
908        for (let k = 0; k < 3; k++) light[(y * W + x) * 3 + k]! += tint[k]! * fade
909      }
910    }
911  }
912
913  return light
914}
915
916/** The pet's room at one part of the day. */
917function buildRoom(phase: DayPhase): Frame {
918  const sky = SKIES[phase]
919  const c = new Canvas()
920  drawRoom(c)
921  const light = lightField(sky)
922  c.px.forEach((v, i) => {
923    if (!v) return
924    const [r, g, b] = channels(v)
925    c.px[i] = OPAQUE | pack(r * light[i * 3]!, g * light[i * 3 + 1]!, b * light[i * 3 + 2]!)
926  })
927  if (sky.lamp > 0) {
928    c.rect(LAMP.x - 1, 4, 3, 1, mix(0xffe7a8, 0xffffff, sky.lamp * 0.4))
929    c.set(LAMP.x, 5, mix(0xffcf70, 0xfff0c0, sky.lamp))
930  }
931  const view = new Canvas()
932  drawView(view, sky)
933  for (let y = GLASS.y; y < GLASS.y + GLASS.h; y++) {
934    for (let x = GLASS.x; x < GLASS.x + GLASS.w; x++) if (isGlass(x, y)) c.px[y * W + x] = view.get(x, y)
935  }
936
937  return c.px
938}
939
940const rooms = new Map<DayPhase, Frame>()
941
942/** The room every frame is drawn over, lit for the part of the day. */
943export function roomFor(phase: DayPhase): Frame {
944  let room = rooms.get(phase)
945  if (!room) {
946    room = buildRoom(phase)
947    rooms.set(phase, room)
948  }
949
950  return room
951}
952
953// --- Animations -----------------------------------------------------------
954
955type Scene = (c: Canvas) => void
956
957function frame(pet: PetDef, pose: Pose, behind?: Scene, front?: Scene): Frame {
958  const c = new Canvas()
959  behind?.(c)
960  drawPet(c, pet, pose)
961  front?.(c)
962
963  return c.px
964}
965
966function idle(pet: PetDef): Frame[] {
967  return Array.from({ length: 16 }, (_, i) =>
968    frame(pet, {
969      squash: (i >> 2) % 2 === 1,
970      eyes: i === 10 ? 'closed' : 'open',
971      part: (i >> 2) % 2,
972    }),
973  )
974}
975
976function wake(pet: PetDef): Frame[] {
977  const sleepy: Pose = { squash: true, eyes: 'closed', mouth: 'none' }
978
979  return [
980    frame(pet, sleepy, undefined, c => zzz(c, 0)),
981    frame(pet, { ...sleepy, part: 1 }, undefined, c => zzz(c, 1)),
982    frame(pet, sleepy, undefined, c => zzz(c, 2)),
983    frame(pet, { eyes: 'down', mouth: 'none', part: 1 }),
984    frame(pet, { eyes: 'open' }),
985    frame(pet, { eyes: 'closed', part: 1 }),
986    frame(pet, { dy: -1, eyes: 'closed', mouth: 'yawn', armL: 'up', armR: 'up' }),
987    frame(pet, { dy: -2, eyes: 'closed', mouth: 'yawn', armL: 'up', armR: 'up', part: 1 }),
988    frame(pet, { dy: -1, eyes: 'closed', mouth: 'yawn', armL: 'up', armR: 'up' }),
989    frame(pet, { eyes: 'happy', mouth: 'open', armL: 'wave', armR: 'wave', part: 1 }, undefined, c => sparkle(c, 27, 6, true)),
990    frame(pet, { eyes: 'open' }),
991  ]
992}
993
994function read(pet: PetDef): Frame[] {
995  return [0, 1, 2, 3].map(i => {
996    const pose: Pose = { eyes: 'down', armL: 'hold', armR: 'hold', part: i >> 1, dy: i === 2 ? 1 : 0 }
997    const [bx, by] = place({ ...pose, squash: false }, 2, 12)
998
999    return frame(pet, pose, undefined, c => {
1000      outlined(c, C.outline, l => {
1001        l.stamp(bx, by, i % 2 === 0 ? BOOK : BOOK_ALT, { c: pet.book, w: C.paper, g: C.ink, d: C.spine })
1002        // The page being turned, lifting from the right and settling on the left.
1003        if (i === 1) (l.rect(bx + 9, by - 1, 4, 1, C.white), l.rect(bx + 10, by - 2, 2, 1, C.white))
1004        if (i === 2) l.rect(bx + 6, by - 4, 2, 4, C.white)
1005        if (i === 3) (l.rect(bx + 1, by - 1, 4, 1, C.white), l.rect(bx + 2, by - 2, 2, 1, C.white))
1006      })
1007      // Its paws on the book's edges.
1008      const [lx, ly] = paw(pose, 0)
1009      const [rx, ry] = paw(pose, 1)
1010      c.set(lx, ly, pet.palette.h!)
1011      c.set(rx, ry, pet.palette.h!)
1012    })
1013  })
1014}
1015
1016function hammer(pet: PetDef): Frame[] {
1017  const board = (c: Canvas, nail: number) =>
1018    outlined(c, C.dwood, l => {
1019      l.rect(25, 20, 18, 2, C.wood)
1020      l.rect(25, 20, 18, 1, C.lwood)
1021      for (let n = 0; n < nail; n++) l.set(33, 19 - n, C.lsteel)
1022      l.rect(32, 19 - nail, 3, 1, C.lsteel)
1023    })
1024  const swing = (angle: 'up' | 'mid' | 'down', pose: Pose, nail: number, isHit: boolean) => (c: Canvas) => {
1025    board(c, nail)
1026    const [px, py] = paw(pose, 1)
1027    outlined(c, C.outline, l => {
1028      if (angle === 'up') {
1029        for (let n = 1; n <= 5; n++) l.set(px, py - n, C.wood)
1030        l.rect(px - 2, py - 8, 5, 3, C.steel)
1031        l.rect(px - 2, py - 8, 5, 1, C.lsteel)
1032      }
1033      if (angle === 'mid') {
1034        for (let n = 1; n <= 4; n++) l.set(px + n, py - n, C.wood)
1035        l.rect(px + 4, py - 7, 3, 3, C.steel)
1036        l.set(px + 4, py - 7, C.lsteel)
1037      }
1038      if (angle === 'down') {
1039        for (let n = 1; n <= 7; n++) l.set(px + n, py, C.wood)
1040        l.rect(px + 8, py - 2, 3, 5, C.steel)
1041        l.rect(px + 8, py - 2, 1, 5, C.lsteel)
1042      }
1043    })
1044    if (angle === 'mid') for (let n = 0; n < 4; n++) c.set(px + 6 + n, py - 4 + n, C.lgray)
1045    if (isHit) {
1046      sparkle(c, px + 13, py - 1, true, C.yellow)
1047      c.set(px + 7, py - 3, C.yellow)
1048      c.set(px + 12, py + 3, C.orange)
1049    }
1050  }
1051  const frames: Frame[] = []
1052  for (const nail of [3, 2, 1]) {
1053    const up: Pose = { eyes: 'right', armR: 'up', mouth: 'none' }
1054    const mid: Pose = { eyes: 'right', armR: 'outUp', part: 1 }
1055    const hit: Pose = { eyes: 'closed', armR: 'out', mouth: 'open', dy: 1 }
1056    const rest: Pose = { eyes: 'right', armR: 'out', part: 1 }
1057    frames.push(
1058      frame(pet, up, undefined, swing('up', up, nail, false)),
1059      frame(pet, mid, undefined, swing('mid', mid, nail, false)),
1060      frame(pet, hit, undefined, swing('down', { ...hit, dy: 0 }, nail - 1, true)),
1061      frame(pet, rest, undefined, swing('down', rest, nail - 1, false)),
1062    )
1063  }
1064
1065  return frames
1066}
1067
1068function knit(pet: PetDef): Frame[] {
1069  return [0, 1, 2, 3, 4, 5].map(i => {
1070    const pose: Pose = { eyes: 'down', armL: 'hold', armR: 'hold', part: i % 2 }
1071    const [cx, cy] = place(pose, 8, 14)
1072    const shift = i % 2
1073
1074    return frame(pet, pose, undefined, c => {
1075      outlined(c, C.outline, l => {
1076        // A striped scarf that grows a row every other click.
1077        const rows = 2 + Math.floor(i / 2)
1078        for (let j = 0; j < rows; j++) l.rect(cx - 1, cy + 1 + j, 4, 1, j % 2 === 0 ? pet.yarn[0] : C.white)
1079        l.rect(28, 17, 5, 5, pet.yarn[0])
1080      })
1081      // Needles crossing in an X, clicking back and forth.
1082      for (let n = 0; n < 6; n++) {
1083        c.set(cx - 4 + n + shift, cy - 4 + n, C.lsteel)
1084        c.set(cx + 5 - n - shift, cy - 4 + n, C.lsteel)
1085      }
1086      c.set(cx - 4 + shift, cy - 4, C.gold)
1087      c.set(cx + 5 - shift, cy - 4, C.gold)
1088      c.stamp(28, 17, YARN[i % 3]!, { y: pet.yarn[0], Y: pet.yarn[1] })
1089      for (let x = cx + 3; x < 28; x++) c.set(x, cy + 2 + Math.round(Math.sin((x + i) / 2)), pet.yarn[1])
1090    })
1091  })
1092}
1093
1094function shell(pet: PetDef): Frame[] {
1095  return [0, 1, 2, 3, 4, 5].map(i => {
1096    const isLeft = i % 2 === 0
1097    const pose: Pose = { eyes: 'right', armL: isLeft ? 'tap' : 'rest', armR: isLeft ? 'rest' : 'tap', mouth: 'none', part: i % 2 }
1098
1099    return frame(
1100      pet,
1101      pose,
1102      c => {
1103        // The monitor, its output scrolling up.
1104        outlined(c, C.outline, l => {
1105          l.rect(27, 3, 18, 13, C.dgray)
1106          l.rect(28, 4, 16, 11, C.black)
1107          l.rect(34, 16, 4, 2, C.dgray)
1108          l.rect(31, 18, 10, 1, C.gray)
1109        })
1110        const tones = [C.green, C.blue, C.purple, C.yellow]
1111        for (let row = 0; row < 5; row++) {
1112          const [indent, len, tone] = CODE_LINES[(row + i) % CODE_LINES.length]!
1113          c.rect(29 + indent, 5 + row * 2, len, 1, tones[tone]!)
1114        }
1115        if (isLeft) c.rect(29, 13, 2, 1, C.white)
1116      },
1117      c => {
1118        outlined(c, C.outline, l => {
1119          l.rect(3, 20, 18, 3, C.dgray)
1120          for (let x = 4; x < 20; x += 2) {
1121            l.set(x, 20, C.lgray)
1122            l.set(x + 1, 21, C.lgray)
1123          }
1124        })
1125        const key = 4 + ((i * 5) % 15)
1126        c.set(key, 20 + (i % 2), C.green)
1127        // Keystrokes flying up.
1128        const [kx, ky] = paw(pose, isLeft ? 0 : 1)
1129        c.set(kx + (isLeft ? -1 : 1), ky - 3, C.yellow)
1130        c.set(kx + (isLeft ? -2 : 2), ky - 5, C.white)
1131      },
1132    )
1133  })
1134}
1135
1136function search(pet: PetDef): Frame[] {
1137  const dxs = [0, 1, 2, 2, 1, 0, 0, 0]
1138
1139  return dxs.map((dx, i) => {
1140    const pose: Pose = { dx, eyes: 'open', armL: 'lift', armR: 'lift', mouth: 'none', part: i % 2 }
1141    const isFound = i >= 2 && i <= 4
1142
1143    return frame(
1144      pet,
1145      pose,
1146      c => {
1147        // Far away: hills, a sun, and a bird drifting by.
1148        outlined(c, C.dgreen, l => {
1149          l.rect(30, 21, 18, 3, C.grass)
1150          l.rect(33, 19, 10, 2, C.grass)
1151          l.rect(36, 18, 4, 1, C.grass)
1152        })
1153        outlined(c, C.dgold, l => l.rect(42, 3, 3, 3, C.gold))
1154        const bx = 26 + i * 2
1155        c.set(bx, 8, C.dgray)
1156        c.set(bx + 2, 8, C.dgray)
1157        c.set(bx + 1, i % 2 === 0 ? 9 : 7, C.dgray)
1158      },
1159      c => {
1160        // Binoculars over both eyes.
1161        const [lx, ly] = place(pose, pet.eyes[0][0] - 1, pet.eyes[0][1])
1162        const [rx] = place(pose, pet.eyes[1][0] - 1, pet.eyes[1][1])
1163        outlined(c, C.outline, l => {
1164          l.rect(lx, ly, 4, 3, C.black)
1165          l.rect(rx, ly, 4, 3, C.black)
1166          l.rect(lx + 4, ly + 1, rx - lx - 4, 1, C.dgray)
1167        })
1168        for (const x of [lx, rx]) {
1169          c.rect(x + 1, ly + 1, 2, 1, C.blue)
1170          c.set(x + 1, ly + 1, i % 3 === 0 ? C.white : C.lblue)
1171        }
1172        if (isFound) sparkle(c, 38, 12, i === 3, C.yellow)
1173        else badge(c, 'question', 22, 0)
1174      },
1175    )
1176  })
1177}
1178
1179function pass(pet: PetDef): Frame[] {
1180  return Array.from({ length: 20 }, (_, i) => {
1181    const beat = i % 4
1182    let pose: Pose = { eyes: 'happy', mouth: 'open', part: i % 2 }
1183    if (pet.dance === 'sway') {
1184      pose = { ...pose, dx: [0, 2, 0, -2][beat]!, armL: beat < 2 ? 'up' : 'wave', armR: beat < 2 ? 'wave' : 'up' }
1185    }
1186    if (pet.dance === 'hop') {
1187      pose = { ...pose, dy: [0, -2, -3, -1][beat]!, armL: beat === 0 ? 'out' : 'up', armR: beat === 0 ? 'out' : 'up' }
1188    }
1189    if (pet.dance === 'flip') {
1190      pose = { ...pose, flip: (i >> 1) % 2 === 1, dy: beat % 2 === 0 ? 0 : -2, armL: beat < 2 ? 'wave' : 'out', armR: beat < 2 ? 'out' : 'wave' }
1191    }
1192
1193    return frame(pet, pose, undefined, c => {
1194      confetti(c, i)
1195      if (i % 4 < 3) badge(c, 'check', 30, 4)
1196    })
1197  })
1198}
1199
1200function fail(pet: PetDef): Frame[] {
hooks/chime.ts 70 lines
1// Soft chimes synthesized on the fly as WAV bytes, so the mod ships no audio
2// files: a few sine notes with a gentle overtone, each fading out.
3
4const RATE = 22050
5
6function wav(samples: Float32Array): Uint8Array {
7  const bytes = new Uint8Array(44 + samples.length * 2)
8  const v = new DataView(bytes.buffer)
9  const text = (o: number, s: string) => [...s].forEach((ch, i) => v.setUint8(o + i, ch.charCodeAt(0)))
10  text(0, 'RIFF')
11  v.setUint32(4, 36 + samples.length * 2, true)
12  text(8, 'WAVE')
13  text(12, 'fmt ')
14  v.setUint32(16, 16, true)
15  v.setUint16(20, 1, true)
16  v.setUint16(22, 1, true)
17  v.setUint32(24, RATE, true)
18  v.setUint32(28, RATE * 2, true)
19  v.setUint16(32, 2, true)
20  v.setUint16(34, 16, true)
21  text(36, 'data')
22  v.setUint32(40, samples.length * 2, true)
23  samples.forEach((s, i) => v.setInt16(44 + i * 2, Math.max(-1, Math.min(1, s)) * 0x7fff, true))
24
25  return bytes
26}
27
28function toBase64(bytes: Uint8Array): string {
29  const native = (bytes as Uint8Array & { toBase64?: () => string }).toBase64
30  if (typeof native === 'function') return native.call(bytes)
31  let s = ''
32  for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
33
34  return btoa(s)
35}
36
37function render(notes: readonly number[], gap: number, ring: number): string {
38  const length = Math.ceil((gap * (notes.length - 1) + ring) * RATE)
39  const out = new Float32Array(length)
40  notes.forEach((hz, n) => {
41    const start = Math.floor(n * gap * RATE)
42    for (let i = 0; start + i < length && i < ring * RATE; i++) {
43      const t = i / RATE
44      const attack = Math.min(1, t / 0.008)
45      const env = attack * Math.exp(-t * 6)
46      const tone = Math.sin(2 * Math.PI * hz * t) + 0.25 * Math.sin(4 * Math.PI * hz * t)
47      out[start + i]! += 0.16 * env * tone
48    }
49  })
50
51  return toBase64(wav(out))
52}
53
54let attention: string | undefined
55let done: string | undefined
56
57/** Two rising notes: the pet would like your attention. */
58export function attentionChime(): string {
59  attention ??= render([880, 1174.66], 0.14, 0.7)
60
61  return attention
62}
63
64/** A little three-note arpeggio: the turn is done. */
65export function doneChime(): string {
66  done ??= render([659.25, 830.61, 987.77], 0.11, 0.8)
67
68  return done
69}
70
types/index.d.ts 20 lines
1/** What the band is showing now: one reaction of the chosen pet. */
2export type PetShow = {
3  /** The reaction (`idle`, `read`, `wave`, ...). */
4  kind: string
5  /** Which take of the reaction, for those with several (bored). */
6  variant: number
7  /** When it began, milliseconds since the epoch; 0 for a free-running loop. */
8  startedAt: number
9  /** The pace override in milliseconds per frame; 0 keeps the reaction's own. */
10  frameMs: number
11  /** The caption beside the pet. */
12  label: string
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'desk-pet': { show: PetShow }
18  }
19}
20