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

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.

Pick an axolotl, quokka, tiny robot, dog or cat. Each one has its own take on every reaction.
| When | Your pet |
|---|---|
| A turn starts | wakes up and stretches |
| Claude reads files | reads a tiny book |
| Claude edits code | hammers (quokka, robot, dog) or knits (axolotl, cat) |
| Claude runs a shell command | types furiously at a mini keyboard |
| Claude searches or fetches the web | peers through binoculars |
| Tests pass | dances in a shower of confetti |
| Tests fail | hides behind a rock, then peeks out |
| A turn runs 5+ minutes | gets bored: yawns, plays with yarn, builds a sandcastle |
| Claude needs your input | waves until you respond |
| A turn finishes | brings you a little trophy or envelope |
| Context compaction | tidies its room, stacking boxes |
| Nothing happening | breathes 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:
/desk-pet show brings it back. A terminal opens the panel by itself only when it's at least 144 columns wide.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
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.
npm test, pytest, go test, cargo test, vitest, jest, rspec and similar). A non-zero exit counts as a failure.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.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.
| Hook | What it reads | Why |
|---|---|---|
session.start | nothing | starts the animation, opens the side panel and registers /desk-pet |
prompt.submit | nothing, not even your prompt's text | stops the "needs input" wave as soon as you reply |
turn.start, turn.complete | whether the turn ended with an answer, and whether it was a subagent's | wake-up, boredom after 5 minutes, the end-of-turn gift |
tool.call | the 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 failed | picks the reaction and its caption, and spots test runs and whether they passed |
session.compact | whether it was a subagent's, or a background precompute | the tidying-up reaction |
classic.Notification | the notification's type only (permission_prompt or elicitation_dialog) | starts the "needs input" wave |
classic.PermissionDenied | nothing | stops the wave when a prompt is refused |
command.run | what you typed after /desk-pet | the command |
ui.render | the surface and the space available | draws 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.
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.
MIT
hooks/register.tsx 804 lines1import { 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}
804hooks/art.ts 1516 lines1// 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 lines1// 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}
70types/index.d.ts 20 lines1/** 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