A pixel art pet above the prompt in the Claude Code desktop app: a cat, a dog or an otter that sleeps while Claude is idle and plays while it works

A little pixel art pet that lives in the band above the prompt in the Claude Code desktop app. It starts hidden in every session: type /pet show to bring it out and /pet hide to tuck it away. It sleeps while Claude is idle, wakes up when Claude starts working, and moves between its activities, with animated transitions, until the turn ends.

Cat: a black kitten with yellow eyes.
| Activity | What it does | How long |
|---|---|---|
sleep | Curled up in a bun, breathing, Z's drifting up | While Claude is idle |
sit | Sits and stares at you, blinks, flicks its tail | 3 minutes |
groom | Licks its paw and wipes its face | 3 minutes |
yarn | Bats a ball of yarn around | 3 minutes |
milk | Laps milk from a bowl | 10 seconds |
hunt | Follows a fly with its eyes, pounces, catches it | Until the fly is caught |
Dog: a brown pup with floppy ears.
| Activity | What it does | How long |
|---|---|---|
sleep | Curled up, Z's drifting up | While Claude is idle |
look | Looks at you panting, tongue out, tail wagging | 3 minutes |
bone | Lies down chewing a bone | 3 minutes |
catch | Catches a tennis ball in its mouth | Until it drops the ball |
zoomies | Spins round in circles | 3 seconds |
water | Drinks from a water bowl | 10 seconds |
Otter: a round brown otter that never lets go of its rock.
| Activity | What it does | How long |
|---|---|---|
sleep | Floats on its back holding its rock | While Claude is idle |
swim | Jumps into its pond, swims back and forth, dives | 3 minutes |
rock | Tosses its rock up and catches it | 3 minutes |
| Command | What it does |
|---|---|
/pet cat, /pet dog, /pet otter | Switch pets (remembered between sessions) |
/pet <activity> | Jump to one of the current pet's activities, e.g. /pet zoomies |
/pet | Switch to a random activity |
/pet show, /pet hide | Show or hide the pet (it starts hidden in every session) |
/pet layout wrap | When other mods crowd the band, the pet moves to its own line (default) |
/pet layout shrink | The pet keeps its spot and other band content is cut short |
hooks/hooks.json with modules).In your shell:
claude plugin marketplace add gmorubio/claude-mods
claude plugin install pet@claude-mods
Or both at once from inside a Claude Code session:
/plugin install pet --marketplace gmorubio/claude-mods
Or load it from a local checkout for one session:
claude --plugin-dir /path/to/claude-mods/pet
hooks/pixels.ts: the shared pixel engine (palette, shapes, SVG output, scenes and transitions).hooks/cat.ts, hooks/dog.ts, hooks/otter.ts: one file per pet, with its frames, activities and timings.hooks/register.tsx: the mod itself: the /pet command, the sleep/wake schedule and drawing the band.To add a pet, write a new file that exports a Species and add it to PETS in register.tsx.
pet makes no network requests and sends nothing anywhere. See the privacy policy.
hooks/register.tsx 202 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import type { PetSpecies } from '../types'
4import { cat } from './cat.ts'
5import { dog } from './dog.ts'
6import { otter } from './otter.ts'
7import { H, W, activityOf, durationOf, frameAt, lengthOf, svgFor, type Species } from './pixels.ts'
8
9// The pets to pick from with /pet cat, /pet dog and /pet otter. While Claude works the pet
10// is awake, moving between its activities; while Claude is idle it sleeps.
11const PETS: Record<PetSpecies, Species> = { cat, dog, otter }
12const isSpecies = (name: string): name is PetSpecies => name in PETS
13
14const speciesRef = { plugin: 'pet', key: 'species' } as const
15const sceneRef = { plugin: 'pet', key: 'scene' } as const
16const isHidden = { plugin: 'pet', key: 'isHidden' } as const
17const layoutRef = { plugin: 'pet', key: 'layout' } as const
18
19let species: PetSpecies = 'cat'
20let scene = 'sleep'
21let sceneStartedAt = 0
22let pending: Timer | undefined
23let ticker: Timer | undefined
24let bandId: string | undefined
25let shownFrame = ''
26let isWorking = false
27
28const pet = () => PETS[species]
29
30// What the other band mods drew, if anything: only a real element can be
31// nested; anything else (the app's own empty band) is left out.
32const DRAWABLE = ['Box', 'Text', 'Button', 'Input', 'Select', 'Link', 'Code', 'Markdown', 'Svg', 'Raster', 'Image', 'Client']
33const isDrawable = (node: unknown) =>
34 typeof node === 'object' && node !== null && DRAWABLE.includes((node as { type?: string }).type ?? '')
35
36// A random awake activity other than the current one.
37const pickOther = (now: string) => {
38 const others = pet().awake.filter(one => one !== now)
39 return others[Math.floor(Math.random() * others.length)]
40}
41
42async function show($: EngineInterface, next: string) {
43 scene = next
44 sceneStartedAt = performance.now()
45 await $.state.set(sceneRef, next)
46}
47
48// Play the transition into `to` and hold it. Sleep lasts until Claude starts
49// working; an awake activity lasts its time, then the pet moves on to
50// another one, or back to sleep if Claude has gone idle meanwhile.
51function switchTo($: EngineInterface, to: string) {
52 pending?.cancel()
53 const from = activityOf(scene)
54 const settle = () => {
55 void show($, to)
56 if (to === 'sleep') return
57 pending = $.clock.after(lengthOf(pet(), to), () => switchTo($, isWorking ? pickOther(to) : 'sleep'))
58 }
59 if (from === to) {
60 settle()
61 return
62 }
63 const transition = `${from}>${to}`
64 void show($, transition)
65 pending = $.clock.after(durationOf(pet(), transition), settle)
66}
67
68// Swap in another pet, straight into what it should be doing now.
69async function adopt($: EngineInterface, next: PetSpecies) {
70 pending?.cancel()
71 species = next
72 await $.state.set(speciesRef, next)
73 await $.store.set('species', next)
74 scene = 'sleep'
75 switchTo($, isWorking ? pickOther('sleep') : 'sleep')
76}
77
78export const register: Register = on => {
79 on('session.start', async ($, e, next) => {
80 await $.command.register({
81 name: 'pet',
82 description:
83 'Show the pet (/pet show), or: /pet hide | cat | dog | otter | sleep | <activity> | layout [wrap | shrink]',
84 })
85 // The pet and the layout the person last picked, kept between sessions.
86 const savedSpecies = String((await $.store.get('species')) ?? 'cat')
87 species = isSpecies(savedSpecies) ? savedSpecies : 'cat'
88 await $.state.set(speciesRef, species)
89 const savedLayout = await $.store.get('layout')
90 await $.state.set(layoutRef, savedLayout === 'shrink' ? 'shrink' : 'wrap')
91 // The pet starts hidden in every session; /pet show brings it out.
92 await $.state.set(isHidden, true)
93 isWorking = false
94 scene = 'sleep'
95 switchTo($, 'sleep')
96
97 // Animate: redraw the band whenever the frame changes.
98 ticker?.cancel()
99 ticker = $.clock.every(100, () => {
100 const frame = frameAt(pet(), scene, performance.now() - sceneStartedAt)
101 if (bandId && frame !== shownFrame) $.ui.invalidate('ui.render')
102 })
103
104 return next(e)
105 })
106
107 // Claude starts working: the pet wakes up, unless it already is.
108 on('turn.start', ($, e, next) => {
109 isWorking = true
110 if (activityOf(scene) === 'sleep') switchTo($, pickOther('sleep'))
111
112 return next(e)
113 })
114
115 // The main conversation's turn ends, however it ended: back to sleep.
116 // Subagents' turns end on their own and are left out.
117 on('turn.complete', ($, e, next) => {
118 if (!e.agentId) {
119 isWorking = false
120 switchTo($, 'sleep')
121 }
122
123 return next(e)
124 })
125
126 on('command.run', { command: 'pet' }, async ($, e) => {
127 const arg = e.args.trim()
128 if (isSpecies(arg)) {
129 await adopt($, arg)
130 return { text: `Your pet is now the ${arg}.` }
131 }
132 if (arg === 'hide' || arg === 'show') {
133 await $.state.set(isHidden, arg === 'hide')
134 return { text: arg === 'hide' ? 'The pet is hidden.' : 'The pet is back.' }
135 }
136 if (arg === 'layout' || arg === 'layout wrap' || arg === 'layout shrink') {
137 const asked = arg.split(' ')[1]
138 const now =
139 asked === 'wrap' || asked === 'shrink'
140 ? asked
141 : (await $.state.get(layoutRef)).value === 'shrink' ? 'wrap' : 'shrink'
142 await $.state.set(layoutRef, now)
143 await $.store.set('layout', now)
144 return {
145 text: now === 'shrink'
146 ? 'Layout: shrink. The pet keeps its spot; other band content is cut short.'
147 : 'Layout: wrap. The pet drops to its own line when the band is crowded.',
148 }
149 }
150 if (arg === 'sleep' || pet().awake.includes(arg)) {
151 switchTo($, arg)
152 return { text: `The ${species} switches to: ${arg}.` }
153 }
154 if (arg) {
155 return { text: `The ${species} can: sleep, ${pet().awake.join(', ')}. Pets: ${Object.keys(PETS).join(', ')}.` }
156 }
157 const next = pickOther(activityOf(scene))
158 switchTo($, next)
159 return { text: `The ${species} switches to: ${next}.` }
160 })
161
162 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
163 // The pet lives in the desktop app only; elsewhere the band is left as is.
164 if (e.surface !== 'desktop' || e.props.hasSurvey || (await $.state.get(isHidden)).value) {
165 bandId = undefined
166 return next(e)
167 }
168 // Reading the pet and the scene subscribes the band, so each change of
169 // either redraws it.
170 await $.state.get(speciesRef)
171 await $.state.get(sceneRef)
172 bandId = e.requestId
173 shownFrame = frameAt(pet(), scene, performance.now() - sceneStartedAt)
174
175 // What the other band mods drew goes on the left, the pet on the right,
176 // and the pet never shrinks. When both don't fit: in 'wrap' the row
177 // wraps and the pet drops to a line of its own, still in the right
178 // corner; in 'shrink' the left side gets what is left and is cut short.
179 const below = await next(e)
180 const others = isDrawable(below) ? below : null
181 const isShrink = (await $.state.get(layoutRef)).value === 'shrink'
182
183 const { Box, Svg } = $.ui.resolve(e)
184
185 return (
186 <Box width="100%" alignItems="flex-end" flexWrap={isShrink ? 'nowrap' : 'wrap'}>
187 <Box flexGrow={1} flexShrink={1} flexDirection="column" overflow={isShrink ? 'hidden' : 'visible'}>
188 {others}
189 </Box>
190 <Box flexGrow={isShrink ? 0 : 1} flexShrink={0} justifyContent="flex-end">
191 <Svg
192 source={svgFor(pet().frames[shownFrame], 2)}
193 alt={pet().alt[activityOf(scene)] ?? `A ${species}`}
194 width={W * 2}
195 height={H * 2}
196 />
197 </Box>
198 </Box>
199 )
200 })
201}
202hooks/cat.ts 457 lines1// The cat: a black kitten with yellow eyes. Awake, it sits and stares,
2// grooms, plays with yarn, drinks milk and hunts flies.
3
4import {
5 CAT_COAT,
6 H,
7 W,
8 blank,
9 ellipse,
10 fill,
11 inEllipse,
12 mod,
13 stamp,
14 stampZs,
15 type Grid,
16 type Species,
17 type Steps,
18} from './pixels.ts'
19
20// The head, 19 wide: ears, a tabby forehead, eyes, muzzle and chin.
21const EARS = [
22 '..o.............o..',
23 '.olo...........olo.',
24 '.ocpo.........opco.',
25 'ocppcooooooooocppco',
26 'occccldcdcdcdlcccco',
27]
28const EYES_OPEN = [
29 'oclooooccdccoooolco',
30 'ocoghegocccoghegoco',
31 'ocogeegocccogeegoco',
32 '.oooggoocccooggooo.',
33]
34const EYES_LOOK = [
35 'oclooooccdccoooolco',
36 'ocoghggocccoghggoco',
37 'ocoeeggocccoeeggoco',
38 '.oooegoocccooegooo.',
39]
40const EYES_LEFT = [
41 'oclooooccdccoooolco',
42 'ocoeeggocccoeeggoco',
43 'ocoeeggocccoeeggoco',
44 '.oooggoocccooggooo.',
45]
46const EYES_RIGHT = [
47 'oclooooccdccoooolco',
48 'ocoggeeocccoggeeoco',
49 'ocoggeeocccoggeeoco',
50 '.oooggoocccooggooo.',
51]
52const EYES_UP = [
53 'oclooooccdccoooolco',
54 'ocogeegocccogeegoco',
55 'ocoggggocccoggggoco',
56 '.oooggoocccooggooo.',
57]
58const EYES_HAPPY = [
59 'oclcccdccdccdccclco',
60 'occcoocccccccooccco',
61 'occoccocccccoccocco',
62 '.olccccccccccccclo.',
63]
64const EYES_HALF = [
65 'oclooooccdccoooolco',
66 'ocoooooocccooooooco',
67 'ocogeegocccogeegoco',
68 '.oooggoocccooggooo.',
69]
70const EYES_SHUT = [
71 'oclcccdccdccdccclco',
72 'occccccccccccccccco',
73 'ocoddddocccoddddoco',
74 '.oloooocccccoooolo.',
75]
76const NOSE = '..olccwwpppwwcclo..'
77const MOUTH = [
78 '...olcwwwowwwclo...',
79 '....olwwowowwlo....',
80]
81const TONGUE = [
82 '...olcwwwowwwclo...',
83 '....olwwopowwlo....',
84 '.....olwwpwwlo.....',
85]
86const CHIN = [
87 '.....olwwwwwlo.....',
88 '......ooooooo......',
89]
90
91const head = (eyes: readonly string[], mouth: readonly string[] = MOUTH) => {
92 const rows = [...EARS, ...eyes, NOSE, ...MOUTH, ...CHIN]
93 // The tongue's rows sit over the mouth and chin.
94 mouth.forEach((row, i) => (rows[EARS.length + eyes.length + 1 + i] = row))
95 return rows
96}
97
98const WHISKERS_L = ['yyyyy', '.....', '..yyyyy']
99const WHISKERS_R = ['..yyyyy', '.......', 'yyyyy..']
100
101const BODY_SIT = [
102 '...ooooooooooooo...',
103 '.oollllllllllllloo.',
104 'occccccccccccccccco',
105 'occccwwwwwwwwwcccco',
106 'ocdccvwwwwwwwvccdco',
107 'occdcvwwwwwwwvcdcco',
108 'ocdccsvwwwwwvsccdco',
109 'occccsvwwwwwvscccco',
110 'osccowwwovowwwocsso',
111 'osccowpwovowpwocsso',
112 '.ooooooooooooooooo.',
113]
114
115const TAIL_DOWN = [
116 '....oo',
117 '...oco',
118 '...oco',
119 '..ocdo',
120 '..oco.',
121 '.ocdo.',
122 'occso.',
123 'ooso..',
124 'ooo...',
125]
126const TAIL_UP = [
127 '.ooo..',
128 'oclco.',
129 'ooodo.',
130 '..oco.',
131 '..ocdo',
132 '..ocso',
133 '.ocdo.',
134 'ocso..',
135 'ooo...',
136]
137
138const PAW = [
139 '.ooo.',
140 'owwwo',
141 'owwwo',
142 'opwpo',
143 '.oco.',
144 '.occo',
145 '.occo',
146]
147// Raised to the eye, the foreleg reaches all the way down to the shoulder.
148const PAW_LONG = [...PAW, '.occo', '.occo', '.occo', '.occo']
149
150// Batting at the yarn: poised up high, then swiping down onto the ball.
151const BAT_PAW_UP = [
152 '.ooo...',
153 'owwwo..',
154 'opwpo..',
155 '.occo..',
156 '..oco..',
157 '..occo.',
158 '...oco.',
159 '....oco',
160]
161const BAT_PAW_DOWN = [
162 '....ooo',
163 '...occo',
164 '..occo.',
165 '.owwo..',
166 'owwwo..',
167 'opwo...',
168]
169
170// A ball of yarn, about ten pixels across, resting on the floor at `cx`:
171// strands wound one way, a band of strands crossing them, a highlight up
172// top, a shadow below and a loose thread trailing off to the right.
173// `turn` shifts the strands, so stepping it makes the ball roll.
174const yarnBall = (g: Grid, cx: number, turn: number) => {
175 const r = 5.6
176 const cy = H - 0.6 - r
177 const inside = (x: number, y: number) => Math.hypot(x - cx, y - cy) <= r
178 for (let y = 0; y < H; y++) {
179 for (let x = 0; x < W; x++) {
180 if (!inside(x, y)) continue
181 const u = x - cx
182 const v = y - cy
183 if (!inside(x - 1, y) || !inside(x + 1, y) || !inside(x, y - 1) || !inside(x, y + 1)) {
184 g[y][x] = 'o'
185 continue
186 }
187 // Strands are arcs, as if wound round a tilted axis; across the middle
188 // a band of them is wound the other way.
189 const isCrossing = Math.abs(u * 0.8 + v + 0.5) < 1.7
190 const arc = isCrossing ? Math.hypot(u - r * 1.7, v - r * 1.1) : Math.hypot(u + r * 1.7, v - r * 0.9)
191 const isStrand = mod(Math.round(arc * 0.75 + turn), 2) === 0
192 const isLit = Math.hypot(u + r * 0.4, v + r * 0.45) < r * 0.5
193 const isShaded = u * 0.6 + v > r * 0.62
194 g[y][x] = isShaded ? (isStrand ? 'P' : 'R') : isStrand ? (isLit ? 'r' : 'R') : isLit ? 'q' : 'r'
195 }
196 }
197 stamp(g, ['...rq..', 'qr...rq'], Math.round(cx + 3), H - 2)
198}
199
200// A fly, its wings beating up and level.
201const FLY_UP = ['W.W', 'WfW', '.f.']
202const FLY_LEVEL = ['...', 'WfW', 'WfW']
203
204// The pounce: a foreleg swept from the shoulder down onto the floor in front.
205const PAW_POUNCE = [
206 '......ooo',
207 '.....occo',
208 '....occo.',
209 '...occo..',
210 '..occo...',
211 '.owwwo...',
212 'owwwwo...',
213 'opwpwo...',
214 '.oooo....',
215]
216
217const BOWL = [
218 '.ommmmmmmmmmmo.',
219 'okmmmmmmmmmmmko',
220 'obbbbbbbbbbbbbo',
221 '.obbnnnnnnnbbo.',
222 '..ooooooooooo..',
223]
224
225// The head's left edge, and the canvas row of its ear tips while sitting.
226const X = 6
227const Y = 1
228
229type Extra = [rows: readonly string[], x: number, y: number]
230
231const withWhiskers = (g: Grid, y: number) => {
232 stamp(g, WHISKERS_L, X - 2, y + 9)
233 stamp(g, WHISKERS_R, X + 14, y + 9)
234}
235
236const sitting = (
237 eyes: readonly string[],
238 tail: readonly string[],
239 mouth = MOUTH,
240 extras: Extra[] = [],
241) => {
242 const g = blank()
243 stamp(g, BODY_SIT, X, Y + 12)
244 stamp(g, tail, X + 18, Y + 14)
245 // Whiskers go under the head so only their tips outside the cheeks show.
246 withWhiskers(g, Y)
247 stamp(g, head(eyes, mouth), X, Y)
248 extras.forEach(([rows, x, y]) => stamp(g, rows, x, y))
249 return g
250}
251
252const grooming = (
253 pawX: number,
254 pawY: number,
255 mouth: readonly string[],
256 paw: readonly string[] = PAW,
257 eyes: readonly string[] = EYES_SHUT,
258) => sitting(eyes, TAIL_DOWN, mouth, [[paw, pawX, pawY]])
259
260// Sitting by the yarn ball, maybe with a paw up or swiping down onto it.
261const playing = (
262 eyes: readonly string[],
263 tail: readonly string[],
264 ballX: number,
265 turn: number,
266 paw?: Extra,
267) => {
268 const g = sitting(eyes, tail)
269 yarnBall(g, ballX, turn)
270 if (paw) stamp(g, ...paw)
271 return g
272}
273
274// Crouched low with the head down, from half way (top 4) to the bowl (top 7).
275const crouching = (
276 top: number,
277 eyes: readonly string[],
278 mouth: readonly string[] = MOUTH,
279 bowlX?: number,
280 drop = false,
281) => {
282 const g = blank()
283 const ry = 5.5 + (7 - top) * 0.5
284 ellipse(g, X + 9, H - 0.5 - ry, 13 - (7 - top) * 0.6, ry, CAT_COAT)
285 stamp(g, head(eyes, mouth), X, top)
286 if (bowlX !== undefined) stamp(g, BOWL, bowlX, H - 5)
287 if (drop) stamp(g, ['m'], X + 10, H - 7)
288 return g
289}
290
291const sittingWithBowl = (bowlX: number) => sitting(EYES_LOOK, TAIL_DOWN, MOUTH, [[BOWL, bowlX, H - 5]])
292
293// Curled up in a bun: a round body with a curl of sheen on its back, the
294// tail wrapped along its front and the head tucked in on the left, resting
295// on the tail. Lying down, the head starts higher and further in.
296const sleeping = (
297 breath: number,
298 zs: [number, number][],
299 isTwitching = false,
300 headX = 1,
301 headY = H - 14,
302 eyes: readonly string[] = EYES_SHUT,
303) => {
304 const g = blank()
305 const cx = 20
306 const rx = 10.5
307 const ry = 7.4 + 0.6 * breath
308 const cy = H - 0.4 - ry
309 const body = inEllipse(cx, cy, rx, ry)
310 fill(g, body, CAT_COAT)
311
312 // A curl of sheen around the back, the way a coiled cat's fur catches light.
313 for (let y = 0; y < H; y++) {
314 for (let x = 0; x < W; x++) {
315 const d = Math.hypot((x - cx) / rx, (y - cy) / ry)
316 if (g[y][x] === 'c' && Math.abs(d - 0.62) < 0.09 && (y < cy || x > cx + 2)) {
317 g[y][x] = 'd'
318 }
319 }
320 }
321
322 // The tail: the band between the body's edge and the same edge lifted,
323 // along the bottom, from the right side round to under the chin.
324 const lifted = inEllipse(cx, cy - 3.6, rx + 0.5, ry)
325 fill(g, (x, y) => body(x, y) && !lifted(x, y) && y > cy && x > 12, CAT_COAT)
326
327 // The same head as when awake, tucked in on the left; on a twitch the
328 // right ear folds down.
329 const face = head(eyes)
330 if (isTwitching) {
331 face[0] = face[0].slice(0, 16) + '...'
332 face[1] = face[1].slice(0, 15) + 'ooo.'
333 }
334 stamp(g, face, headX, headY)
335
336 stampZs(g, zs)
337 return g
338}
339
340// Where the fly buzzes, as it circles the kitten's head and comes down to land
341// on the floor in front of it, and which way the kitten's eyes follow it.
342const FLY_PATH: [x: number, y: number][] = [
343 [29, 4], [27, 1], [24, 0], [20, 0], [15, 0], [14, 1], [15, 0], [13, 1], [14, 0],
344 [9, 0], [5, 1], [2, 3], [1, 6], [2, 9], [0, 12], [3, 14], [1, 17], [2, 20],
345]
346const FLY_LANDED: [number, number] = [2, 21]
347const eyesFor = ([x, y]: [number, number]) =>
348 y >= 12 ? EYES_LOOK : x > 21 ? EYES_RIGHT : x < 9 ? EYES_LEFT : EYES_UP
349
350const withFly = (g: Grid, [x, y]: [number, number], isUp: boolean) => {
351 stamp(g, isUp ? FLY_UP : FLY_LEVEL, x, y)
352 return g
353}
354
355const huntFrames = Object.fromEntries([
356 ...FLY_PATH.map((at, i): [string, Grid] => [
357 `fly${i}`,
358 withFly(sitting(eyesFor(at), i % 4 < 2 ? TAIL_UP : TAIL_DOWN), at, i % 2 === 0),
359 ]),
360 ['flyLandedA', withFly(sitting(EYES_LOOK, TAIL_UP), FLY_LANDED, true)],
361 ['flyLandedB', withFly(sitting(EYES_LOOK, TAIL_DOWN), FLY_LANDED, false)],
362 ['pounce', sitting(EYES_LOOK, TAIL_UP, MOUTH, [[PAW_POUNCE, 0, 14]])],
363 ['caught', sitting(EYES_HAPPY, TAIL_UP, MOUTH, [[PAW_POUNCE, 0, 14]])],
364])
365
366const FRAMES: Record<string, Grid> = {
367 ...huntFrames,
368 sleepA: sleeping(0, [[21, 8], [22, 4]]),
369 sleepB: sleeping(1, [[22, 7], [23, 3]]),
370 sleepC: sleeping(0, [[21, 8], [22, 4], [26, 0]], true),
371 sit: sitting(EYES_OPEN, TAIL_DOWN),
372 sitTail: sitting(EYES_OPEN, TAIL_UP),
373 blinkHalf: sitting(EYES_HALF, TAIL_DOWN),
374 blink: sitting(EYES_SHUT, TAIL_DOWN),
375 lickA: grooming(X + 10, Y + 10, TONGUE),
376 lickB: grooming(X + 10, Y + 11, MOUTH),
377 wipe: grooming(X + 2, Y + 4, MOUTH, PAW_LONG),
378 sipA: crouching(7, EYES_SHUT, MOUTH, X + 2),
379 sipB: crouching(7, EYES_SHUT, TONGUE, X + 2, true),
380 yarnLook: playing(EYES_LOOK, TAIL_UP, 5.6, 0),
381 yarnReach: playing(EYES_LOOK, TAIL_UP, 5.6, 0, [BAT_PAW_UP, 0, 3]),
382 yarnBat: playing(EYES_LOOK, TAIL_DOWN, 5, 1, [BAT_PAW_DOWN, 3, 7]),
383 yarnRoll: playing(EYES_LOOK, TAIL_DOWN, 4.2, 2),
384 yarnBack: playing(EYES_LOOK, TAIL_UP, 5, 1),
385
386 // In-between frames for the transitions.
387 pawLow: grooming(X + 10, Y + 15, MOUTH, PAW, EYES_HALF),
388 pawMid: grooming(X + 10, Y + 13, MOUTH),
389 bowlFar: sittingWithBowl(-10),
390 bowlNear: sittingWithBowl(-3),
391 bowlClose: sittingWithBowl(3),
392 crouchBowl: crouching(4, EYES_HALF, MOUTH, X + 2),
393 crouchHigh: crouching(4, EYES_HALF),
394 crouchLow: crouching(7, EYES_HALF),
395 lieDown: sleeping(0, [], false, 3, 9, EYES_HALF),
396 settle: sleeping(0, []),
397 ballFar: playing(EYES_OPEN, TAIL_DOWN, -4, 0),
398 ballNear: playing(EYES_LOOK, TAIL_UP, 1.5, 1),
399}
400
401// Each activity loops through [frame, milliseconds] steps.
402const SEQUENCES: Record<string, Steps> = {
403 hunt: [], // filled in below, once its frames exist
404 sleep: [['sleepA', 1400], ['sleepB', 1400], ['sleepA', 1400], ['sleepC', 1400]],
405 sit: [['sit', 2200], ['sitTail', 500], ['sit', 1600], ['blinkHalf', 70], ['blink', 140], ['blinkHalf', 70], ['sit', 1800], ['sitTail', 500]],
406 groom: [['lickA', 350], ['lickB', 350], ['lickA', 350], ['lickB', 350], ['wipe', 700], ['lickB', 400]],
407 milk: [['sipA', 300], ['sipB', 300]],
408 yarn: [
409 ['yarnLook', 600], ['yarnReach', 250], ['yarnBat', 200], ['yarnRoll', 250], ['yarnBack', 250],
410 ['yarnLook', 500], ['yarnReach', 200], ['yarnBat', 200], ['yarnRoll', 300], ['yarnBack', 300],
411 ],
412}
413
414// The hunt plays once, start to finish: the fly circles, lands, the kitten
415// wiggles, pounces, is pleased with itself and licks its paw.
416SEQUENCES.hunt = [
417 ...FLY_PATH.map((_, i): [string, number] => [`fly${i}`, i >= 4 && i <= 8 ? 220 : 260]),
418 ['flyLandedA', 500],
419 ...Array.from({ length: 6 }, (_, i): [string, number] => [i % 2 ? 'flyLandedB' : 'flyLandedA', 150]),
420 ['pounce', 250],
421 ['caught', 1100],
422 ['lickA', 300],
423 ['lickB', 300],
424 ['lickA', 300],
425 ['sit', 400],
426]
427
428// How each activity is entered from sitting; leaving it plays this backwards.
429const ENTER: Record<string, Steps> = {
430 sit: [],
431 groom: [['blinkHalf', 120], ['pawLow', 180], ['pawMid', 180]],
432 milk: [['bowlFar', 160], ['bowlNear', 160], ['bowlClose', 160], ['crouchBowl', 220]],
433 sleep: [['crouchHigh', 220], ['crouchLow', 220], ['lieDown', 280], ['settle', 320]],
434 yarn: [['ballFar', 160], ['ballNear', 160]],
435 hunt: [],
436}
437
438const ALT: Record<string, string> = {
439 sleep: 'A black kitten curled up asleep',
440 sit: 'A black kitten sitting and staring at you',
441 groom: 'A black kitten licking its paw',
442 milk: 'A black kitten drinking milk from a bowl',
443 yarn: 'A black kitten playing with a ball of yarn',
444 hunt: 'A black kitten hunting a fly',
445}
446
447export const cat: Species = {
448 frames: FRAMES,
449 neutral: 'sit',
450 sequences: SEQUENCES,
451 enter: ENTER,
452 awake: ['sit', 'groom', 'yarn', 'milk', 'hunt'],
453 oneShot: ['hunt'],
454 lengthMs: { sit: 3 * 60_000, groom: 3 * 60_000, yarn: 3 * 60_000, milk: 10_000 },
455 alt: ALT,
456}
457hooks/dog.ts 346 lines1// The dog: a brown pup with floppy ears and a tan muzzle. Awake, it looks at
2// you panting, chews a bone, plays catch, gets the zoomies and drinks water.
3
4import {
5 DOG_COAT,
6 H,
7 W,
8 blank,
9 ellipse,
10 fill,
11 inEllipse,
12 inSegment,
13 mirror,
14 stamp,
15 stampZs,
16 type Grid,
17 type Inside,
18 type Species,
19 type Steps,
20} from './pixels.ts'
21
22// The head, 19 wide: a round crown, floppy ears down both sides, eyes, a tan
23// muzzle with a black nose, and a mouth shut or open.
24const CROWN = [
25 '....OOOOOOOOOOO....',
26 '..OOGGGGGGGGGGGOO..',
27 '.OFFODDDDDDDDDOFFO.',
28 'OFFFODDDDDDDDDOFFFO',
29]
30const EYES_OPEN = ['OFFFODheDDDheDOFFFO', 'OFFFODeeDDDeeDOFFFO']
31const EYES_UP = ['OFFFODheDDDheDOFFFO', 'OFFFODDDDDDDDDOFFFO']
32const EYES_HALF = ['OFFFODOODDDOODOFFFO', 'OFFFODeeDDDeeDOFFFO']
33const EYES_SHUT = ['OFFFODDDDDDDDDOFFFO', 'OFFFODOODDDOODOFFFO']
34const EYES_HAPPY = ['OFFFODOODDDOODOFFFO', 'OFFFOODDODODDOOFFFO']
35const MUZZLE = [
36 'OFFFODDEEEEEDDOFFFO',
37 'OFFFODEENNNEEDOFFFO',
38 '.OFFOEEEENEEEEOFFO.',
39]
40const MOUTH_SHUT = [
41 '.OFFOEEEOEOEEEOFFO.',
42 '..OFOOEEEEEEEOOFO..',
43 '...OO.OOEEEOO.OO...',
44 '........OOO........',
45]
46const MOUTH_OPEN = [
47 '.OFFOEEOOOOOEEOFFO.',
48 '..OFOOEOOOOOEOOFO..',
49 '...OO.OOEEEOO.OO...',
50 '........OOO........',
51]
52// Panting, the tongue hanging out, at two lengths.
53const TONGUE_LONG = [
54 '.OFFOEEOOOOOEEOFFO.',
55 '..OFOOEOpppOEOOFO..',
56 '...OO.OOOpOOO.OO...',
57 '........OpO........',
58 '........OpO........',
59 '.........O.........',
60]
61const TONGUE_SHORT = [
62 '.OFFOEEOOOOOEEOFFO.',
63 '..OFOOEOpppOEOOFO..',
64 '...OO.OOOpOOO.OO...',
65 '........OpO........',
66 '.........O.........',
67]
68
69const head = (eyes: readonly string[], mouth: readonly string[] = MOUTH_SHUT) => [
70 ...CROWN,
71 ...eyes,
72 ...MUZZLE,
73 ...mouth,
74]
75
76const BODY_SIT = [
77 '...OOOOOOOOOOOOO...',
78 '..OGGGGGGGGGGGGGO..',
79 '.ODDDDDDDDDDDDDDDO.',
80 'ODDDDDEEEEEEEDDDDDO',
81 'ODDDDEEEEEEEEEDDDDO',
82 'ODSDDEEEEEEEEEDDSDO',
83 'ODSDDVEEEEEEEVDDSDO',
84 'ODSDDOVEEEEEVODDSDO',
85 'OSSDOEEEOEOEEEODSSO',
86 'OSSDOEEEOEOEEEODSSO',
87 '.OOOOOOOOOOOOOOOOO.',
88]
89
90// The tail, wagging between up-and-out and straight up.
91const WAG_OUT = ['....OO', '...ODO', '..ODO.', '.ODO..', 'ODO...', 'OO....']
92const WAG_UP = ['.OO..', 'ODO..', 'ODO..', 'ODO..', 'ODO..', 'OO...']
93
94const BALL = ['..OOO..', '.OTTUO.', 'OUTTTUO', 'OTUTTTO', 'OTTUTTO', '.OTTUO.', '..OOO..']
95
96const BONE = [
97 '.OO.....OO.',
98 'OKKOOOOOKKO',
99 'OKKKKKKKKJO',
100 'OKJOOOOOJJO',
101 '.OO.....OO.',
102]
103
104const WATER_BOWL = [
105 '.oaaaaaaaaaao.',
106 'okAAAAAAAAAAko',
107 'obbbbbbbbbbbbo',
108 '.obbnnnnnnbbo.',
109 '..oooooooooo..',
110]
111
112// The head's left edge, and the canvas row of its crown while sitting.
113const X = 6
114const Y = 1
115
116type Extra = [rows: readonly string[], x: number, y: number]
117
118const sitting = (
119 eyes: readonly string[],
120 tail: readonly string[],
121 mouth: readonly string[] = MOUTH_SHUT,
122 extras: Extra[] = [],
123 lift = 0,
124) => {
125 const g = blank()
126 stamp(g, BODY_SIT, X, Y + 12 - lift)
127 stamp(g, tail, X + 18, Y + 15 - lift)
128 stamp(g, head(eyes, mouth), X, Y - lift)
129 extras.forEach(([rows, x, y]) => stamp(g, rows, x, y))
130 return g
131}
132
133// Lying down with the head low, from half way (top 4) to the floor (top 7),
134// the tail wagging over the back.
135const crouching = (
136 top: number,
137 eyes: readonly string[],
138 mouth: readonly string[] = MOUTH_SHUT,
139 extras: Extra[] = [],
140 tail?: readonly string[],
141) => {
142 const g = blank()
143 const ry = 5.5 + (7 - top) * 0.5
144 const rx = 13 - (7 - top) * 0.6
145 ellipse(g, X + 9, H - 0.5 - ry, rx, ry, DOG_COAT)
146 if (tail) stamp(g, tail, Math.round(X + 9 + rx - 3), Math.round(H - 0.5 - ry * 2) - 3)
147 stamp(g, head(eyes, mouth), X, top)
148 extras.forEach(([rows, x, y]) => stamp(g, rows, x, y))
149 return g
150}
151
152// Curled up asleep like the cat: a round body, the tail wrapped along its
153// front and the head tucked in on the left.
154const sleeping = (
155 breath: number,
156 zs: [number, number][],
157 headX = 1,
158 headY = H - 13,
159 eyes: readonly string[] = EYES_SHUT,
160) => {
161 const g = blank()
162 const cx = 20
163 const rx = 10.5
164 const ry = 7.4 + 0.6 * breath
165 const cy = H - 0.4 - ry
166 const body = inEllipse(cx, cy, rx, ry)
167 fill(g, body, DOG_COAT)
168 const lifted = inEllipse(cx, cy - 3.6, rx + 0.5, ry)
169 fill(g, (x, y) => body(x, y) && !lifted(x, y) && y > cy && x > 12, DOG_COAT)
170 stamp(g, head(eyes), headX, headY)
171 stampZs(g, zs)
172 return g
173}
174
175// The zoomies: the dog spins round in tight circles, seen from the front,
176// the side, behind and the other side in turn. The side and back views share
177// the sitting dog's chunky build: a big head, a round body, short thick legs.
178
179// Paints the inside of a shape (its outline left as drawn) with one letter.
180const tint = (g: Grid, inside: Inside, letter: string) => {
181 for (let py = 0; py < H; py++) {
182 for (let px = 0; px < W; px++) {
183 if (inside(px, py) && g[py][px] !== 'O') g[py][px] = letter
184 }
185 }
186}
187const EAR_COAT = { edge: 'O', light: 'F', shade: 'F', stripe: 'F', base: 'F' }
188
189// Running seen from the side, facing right, its left edge at `x`. `stride` 0
190// has the legs reaching out, 1 has them gathered under.
191const runningSide = (x: number, stride: 0 | 1) => {
192 const g = blank()
193 const legs: Inside[] =
194 stride === 0
195 ? [inSegment(x + 14, 18, x + 16.5, 22.2, 1.5), inSegment(x + 7, 18, x + 4.5, 22.2, 1.5)]
196 : [inSegment(x + 14, 18, x + 12.5, 22.3, 1.5), inSegment(x + 7, 18, x + 9, 22.3, 1.5)]
197 const parts: Inside[] = [
198 inEllipse(x + 10.5, 16.2, 7.5, 4.6),
199 inEllipse(x + 18, 10.5, 5.4, 5),
200 inEllipse(x + 22.6, 12.4, 3, 2.1),
201 ...legs,
202 ]
203 fill(g, (px, py) => parts.some(part => part(px, py)), DOG_COAT)
204 tint(g, inEllipse(x + 22.6, 12.4, 3, 2.1), 'E')
205 tint(g, inEllipse(x + 15.5, 18, 3.2, 1.8), 'E')
206 // The tail, wagging up behind, and the floppy ear over the side of the head.
207 const tail = stride === 0 ? inSegment(x + 3.5, 14, x + 0.5, 9.5, 1.2) : inSegment(x + 3.5, 14, x + 1.5, 9, 1.2)
208 fill(g, tail, DOG_COAT)
209 fill(g, inEllipse(x + 15.8, 11.4, 2, 3.8), EAR_COAT)
210 stamp(g, ['he', 'ee'], x + 19, 8)
211 stamp(g, ['NN'], x + 24, 11)
212 stamp(g, ['p', 'p'], x + 22, 14)
213 return g
214}
215
216// Running seen from behind, centred: the back of the head with both ears up
217// behind the round rump, the hind legs, and the tail swinging out to a side.
218const runningBack = (stride: 0 | 1) => {
219 const g = blank()
220 const cx = 16
221 fill(g, inEllipse(cx, 7.5, 5.2, 4.4), DOG_COAT)
222 fill(g, inEllipse(cx - 5, 9, 1.9, 3.4), EAR_COAT)
223 fill(g, inEllipse(cx + 5, 9, 1.9, 3.4), EAR_COAT)
224 const legs: Inside[] =
225 stride === 0
226 ? [inSegment(cx - 4, 19, cx - 4.5, 22.3, 1.6), inSegment(cx + 4, 19, cx + 4.5, 21.2, 1.6)]
227 : [inSegment(cx - 4, 19, cx - 4.5, 21.2, 1.6), inSegment(cx + 4, 19, cx + 4.5, 22.3, 1.6)]
228 fill(g, (px, py) => inEllipse(cx, 16, 7.2, 5.2)(px, py) || legs.some(leg => leg(px, py)), DOG_COAT)
229 const side = stride === 0 ? -1 : 1
230 fill(g, inSegment(cx + side * 2, 12.5, cx + side * 8.5, 6.5, 1.3), DOG_COAT)
231 return g
232}
233
234// Dust kicked up at the feet, behind the dog.
235const DUST = ['.QQ..', 'QQQQ.', '.QQ.Q']
236const withDust = (g: Grid, x: number) => {
237 stamp(g, DUST, x, H - 3)
238 return g
239}
240
241// One lap's four views, a stride each, each nudged so the lap reads as a
242// small circle: in front, off to the right, further back, off to the left.
243const lap = (stride: 0 | 1): Grid[] => [
244 sitting(EYES_HAPPY, stride ? WAG_UP : WAG_OUT, TONGUE_LONG, [], stride),
245 withDust(runningSide(4, stride), 0),
246 withDust(runningBack(stride), stride ? 24 : 4),
247 withDust(mirror(runningSide(4, stride)), 26),
248]
249
250// Where the ball flies in a throw, from the top right into the dog's mouth.
251const THROW: [number, number][] = [
252 [29, 9], [27, 4], [25, 1], [22, 1], [20, 4], [18, 8],
253]
254const CAUGHT_AT: [number, number] = [X + 6, Y + 8]
255
256const FRAMES: Record<string, Grid> = {
257 sit: sitting(EYES_OPEN, WAG_UP),
258 pantA: sitting(EYES_OPEN, WAG_OUT, TONGUE_LONG),
259 pantB: sitting(EYES_OPEN, WAG_UP, TONGUE_SHORT),
260 pantBlink: sitting(EYES_SHUT, WAG_OUT, TONGUE_LONG),
261 sleepA: sleeping(0, [[21, 8], [22, 4]]),
262 sleepB: sleeping(1, [[22, 7], [23, 3]]),
263 sleepC: sleeping(0, [[21, 8], [22, 4], [26, 0]]),
264 chewA: crouching(6, EYES_SHUT, MOUTH_OPEN, [[BONE, X + 4, 15]], WAG_OUT),
265 chewB: crouching(7, EYES_HALF, MOUTH_OPEN, [[BONE, X + 4, 16]], WAG_UP),
266 lapA: crouching(7, EYES_SHUT, MOUTH_SHUT, [[WATER_BOWL, X + 2, H - 5]]),
267 lapB: crouching(7, EYES_SHUT, TONGUE_SHORT, [[WATER_BOWL, X + 2, H - 5], [['a'], X + 9, H - 7]]),
268 ...Object.fromEntries(
269 THROW.map(([bx, by], i): [string, Grid] => [
270 `throw${i}`,
271 sitting(EYES_UP, i % 2 ? WAG_OUT : WAG_UP, i >= THROW.length - 2 ? MOUTH_OPEN : MOUTH_SHUT, [[BALL, bx, by]]),
272 ]),
273 ),
274 caughtA: sitting(EYES_HAPPY, WAG_OUT, MOUTH_OPEN, [[BALL, CAUGHT_AT[0], CAUGHT_AT[1] - 1]], 1),
275 caughtB: sitting(EYES_HAPPY, WAG_UP, MOUTH_OPEN, [[BALL, CAUGHT_AT[0], CAUGHT_AT[1]]]),
276 dropA: sitting(EYES_OPEN, WAG_OUT, MOUTH_OPEN, [[BALL, X + 6, H - 7]]),
277 dropB: sitting(EYES_OPEN, WAG_UP, TONGUE_SHORT, [[BALL, X + 15, H - 7]]),
278 dropC: sitting(EYES_OPEN, WAG_OUT, TONGUE_SHORT, [[BALL, X + 23, H - 7]]),
279 ...Object.fromEntries(
280 [...lap(0), ...lap(1)].map((grid, i): [string, Grid] => [`spin${i}`, grid]),
281 ),
282
283 // In-between frames for the transitions.
284 ready: sitting(EYES_UP, WAG_OUT, TONGUE_SHORT, [], 1),
285 bowlFar: sitting(EYES_OPEN, WAG_UP, MOUTH_SHUT, [[WATER_BOWL, -10, H - 5]]),
286 bowlNear: sitting(EYES_OPEN, WAG_UP, MOUTH_SHUT, [[WATER_BOWL, -3, H - 5]]),
287 bowlClose: sitting(EYES_OPEN, WAG_UP, MOUTH_SHUT, [[WATER_BOWL, 3, H - 5]]),
288 crouchBowl: crouching(4, EYES_HALF, MOUTH_SHUT, [[WATER_BOWL, X + 2, H - 5]]),
289 boneFar: sitting(EYES_OPEN, WAG_OUT, MOUTH_SHUT, [[BONE, -8, H - 5]]),
290 boneNear: sitting(EYES_OPEN, WAG_UP, TONGUE_SHORT, [[BONE, -1, H - 5]]),
291 crouchBone: crouching(4, EYES_OPEN, MOUTH_OPEN, [[BONE, X + 4, 14]], WAG_OUT),
292 crouchHigh: crouching(4, EYES_HALF),
293 crouchLow: crouching(7, EYES_HALF),
294 lieDown: sleeping(0, [], 3, 9, EYES_HALF),
295 settle: sleeping(0, []),
296}
297
298const SEQUENCES: Record<string, Steps> = {
299 sleep: [['sleepA', 1400], ['sleepB', 1400], ['sleepA', 1400], ['sleepC', 1400]],
300 look: [
301 ['pantA', 260], ['pantB', 260], ['pantA', 260], ['pantB', 260], ['pantA', 260], ['pantB', 260],
302 ['pantA', 260], ['pantBlink', 160], ['pantA', 260], ['pantB', 260],
303 ],
304 bone: [['chewA', 320], ['chewB', 320], ['chewA', 320], ['chewB', 320], ['chewA', 600], ['chewB', 320]],
305 water: [['lapA', 300], ['lapB', 300]],
306 // Plays once: the throw, the catch, a happy bounce, then the ball dropped
307 // and rolling away.
308 catch: [
309 ['sit', 500],
310 ...THROW.map((_, i): [string, number] => [`throw${i}`, 160]),
311 ['caughtA', 220], ['caughtB', 220], ['caughtA', 220], ['caughtB', 220], ['caughtA', 220], ['caughtB', 400],
312 ['dropA', 250], ['dropB', 250], ['dropC', 250], ['pantA', 300],
313 ],
314 // Plays once: three seconds spinning round in circles, six laps.
315 zoomies: Array.from({ length: 24 }, (_, i): [string, number] => [`spin${i % 8}`, 125]),
316}
317
318const ENTER: Record<string, Steps> = {
319 look: [],
320 sleep: [['crouchHigh', 220], ['crouchLow', 220], ['lieDown', 280], ['settle', 320]],
321 bone: [['boneFar', 160], ['boneNear', 160], ['crouchBone', 220]],
322 water: [['bowlFar', 160], ['bowlNear', 160], ['bowlClose', 160], ['crouchBowl', 220]],
323 catch: [],
324 zoomies: [['ready', 250]],
325}
326
327const ALT: Record<string, string> = {
328 sleep: 'A brown dog curled up asleep',
329 look: 'A brown dog looking at you, panting and wagging its tail',
330 bone: 'A brown dog chewing a bone',
331 water: 'A brown dog drinking water',
332 catch: 'A brown dog catching a ball',
333 zoomies: 'A brown dog running in circles',
334}
335
336export const dog: Species = {
337 frames: FRAMES,
338 neutral: 'sit',
339 sequences: SEQUENCES,
340 enter: ENTER,
341 awake: ['look', 'bone', 'catch', 'zoomies', 'water'],
342 oneShot: ['catch', 'zoomies'],
343 lengthMs: { look: 3 * 60_000, bone: 3 * 60_000, water: 10_000 },
344 alt: ALT,
345}
346hooks/otter.ts 285 lines1// The otter: a round brown otter with a cream face and belly that never lets
2// go of its favorite rock. Asleep, it floats on its back holding the rock;
3// awake, it swims in its pond or plays with the rock.
4
5import {
6 H,
7 OTTER_COAT,
8 W,
9 blank,
10 fill,
11 inEllipse,
12 inSegment,
13 mirror,
14 stamp,
15 stampZs,
16 type Grid,
17 type Inside,
18 type Species,
19 type Steps,
20} from './pixels.ts'
21
22// Paints the inside of a shape (its outline left as drawn) with one letter.
23const tint = (g: Grid, inside: Inside, letter: string) => {
24 for (let y = 0; y < H; y++) {
25 for (let x = 0; x < W; x++) {
26 if (inside(x, y) && g[y][x] !== 'I') g[y][x] = letter
27 }
28 }
29}
30
31const ROCK = ['.III.', 'IXYYI', 'IYYZI', '.III.']
32const PAW = ['III', 'IMI', '.I.']
33
34// The pond: a surface of light ripples from row `top` down, deep streaks
35// below; `shift` moves the ripples so the water moves.
36const pond = (g: Grid, top: number, shift: number) => {
37 for (let y = top; y < H; y++) {
38 for (let x = 0; x < W; x++) {
39 g[y][x] =
40 y === top
41 ? (x + shift) % 6 < 2
42 ? 'a'
43 : 'A'
44 : (x * 2 + y * 3 + shift * 2) % 13 === 0
45 ? 'j'
46 : 'A'
47 }
48 }
49}
50
51// The head, 17 wide, drawn by hand: small round ears on a wide flat crown,
52// big wide-set eyes, a big cream muzzle with puffy whisker pads, a broad nose.
53const CROWN = [
54 '.III.........III.',
55 'IMMBIIIIIIIIIBMMI',
56 'IMBLLLLLLLLLLLBMI',
57 '.IBBBBBBBBBBBBBI.',
58]
59const EYES = {
60 open: ['IBBheeBBBBBheeBBI', 'IBBeeeBBBBBeeeBBI', 'IBBeeeBBBBBeeeBBI'],
61 up: ['IBBheeBBBBBheeBBI', 'IBBeeeBBBBBeeeBBI', 'IBBBBBBBBBBBBBBBI'],
62 happy: ['IBBBIBBBBBBBIBBBI', 'IBBIBIBBBBBIBIBBI', 'IBBBBBBBBBBBBBBBI'],
63 shut: ['IBBBBBBBBBBBBBBBI', 'IBBBBBBBBBBBBBBBI', 'IBBIIIBBBBBIIIBBI'],
64 half: ['IBBIIIBBBBBIIIBBI', 'IBBeeeBBBBBeeeBBI', 'IBBeeeBBBBBeeeBBI'],
65}
66type Eyes = keyof typeof EYES
67const MUZZLE = [
68 'IBBBBCCCCCCCBBBBI',
69 'IBBBCCCNNNCCCBBBI',
70 '.IBCCXCCNCCXCCBI.',
71 '.IBCCCCICICCCCBI.',
72 '..IICCCCCCCCCII..',
73 '....IIIIIIIII....',
74]
75const head = (eyes: Eyes) => [...CROWN, ...EYES[eyes], ...MUZZLE]
76
77// Sitting up, facing you: the head over a tall round body, the tail curled
78// along the ground. `paws` and `rock` place them by their top left corners;
79// the rock is left out when `rock` is null.
80const sitting = (
81 eyes: Eyes,
82 paws: [number, number, number, number],
83 rock: [number, number] | null,
84 lift = 0,
85) => {
86 const g = blank()
87 const cx = 15
88 const parts: Inside[] = [
89 inEllipse(cx, 16.5 - lift, 6.6, 7),
90 inSegment(cx + 4, 22.5, cx + 13, 21.5, 1.7),
91 inSegment(cx + 13, 21.5, cx + 15, 19, 1.3),
92 ]
93 fill(g, (x, y) => parts.some(part => part(x, y)), OTTER_COAT)
94 tint(g, inEllipse(cx, 18 - lift, 4.2, 4.8), 'C')
95 // Whiskers either side of the muzzle.
96 stamp(g, ['yyy', '...', '.yy'], cx - 10, 10 - lift)
97 stamp(g, ['yyy', '...', 'yy.'], cx + 8, 10 - lift)
98 stamp(g, head(eyes), cx - 8, 1 - lift)
99 if (rock) stamp(g, ROCK, rock[0], rock[1] - lift)
100 stamp(g, PAW, paws[0], paws[1] - lift)
101 stamp(g, PAW, paws[2], paws[3] - lift)
102 return g
103}
104
105// The paws holding the rock to the chest, and raised up after a toss.
106const PAWS_HOLD: [number, number, number, number] = [10, 14, 18, 14]
107const PAWS_UP: [number, number, number, number] = [8, 9, 20, 9]
108const ROCK_HELD: [number, number] = [13, 14]
109
110// Floating on its back in the pond, seen from the front like the sitting
111// otter: eyes shut, paws holding the rock on its belly, little feet poking
112// out of the water, ripples round it. `bob` lifts it a row.
113const floating = (bob: number, shift: number, eyes: Eyes, zs: [number, number][]) => {
114 const g = blank()
115 const y = -bob
116 const cx = 15
117 const parts: Inside[] = [
118 inEllipse(cx, 16 + y, 7, 6),
119 inEllipse(cx - 5.5, 18.6 + y, 1.7, 1.9),
120 inEllipse(cx + 5.5, 18.6 + y, 1.7, 1.9),
121 ]
122 fill(g, (x, py) => parts.some(part => part(x, py)), OTTER_COAT)
123 tint(g, inEllipse(cx, 16.5 + y, 4.6, 4.2), 'C')
124 stamp(g, ['yyy', '...', '.yy'], cx - 10, 10 + y)
125 stamp(g, ['yyy', '...', 'yy.'], cx + 8, 10 + y)
126 stamp(g, head(eyes), cx - 8, 1 + y)
127 stamp(g, ROCK, cx - 2, 14 + y)
128 stamp(g, PAW, cx - 5, 14 + y)
129 stamp(g, PAW, cx + 3, 14 + y)
130 pond(g, 19, shift)
131 // The feet sticking up out of the water, the tail tip beside them, and
132 // ripples round the otter.
133 stamp(g, ['.II.', 'IMMI', 'IMMI'], cx - 5, 17 + y)
134 stamp(g, ['.II.', 'IMMI', 'IMMI'], cx + 2, 17 + y)
135 fill(g, inSegment(cx + 8, 19.5, cx + 13, 17.5 + y, 1.4), OTTER_COAT)
136 stamp(g, ['.aa', 'a..'], cx - 11, 18)
137 stamp(g, ['aa.', '..a'], cx + 9, 18)
138 stampZs(g, zs)
139 return g
140}
141
142// Swimming, seen from the front: the head above the water at `cx`, the body
143// a dark shape under the surface with its paws paddling, ripples round the
144// neck and a wake behind as it moves. `heading` is -1 left, 1 right, 0 still;
145// `depth` sinks the head below the surface for a dive.
146const swimming = (cx: number, bob: number, shift: number, heading: number, eyes: Eyes = 'open', depth = 0) => {
147 const g = blank()
148 const top = 2 + bob + depth
149 stamp(g, ['yyy', '...', '.yy'], cx - 10, top + 9)
150 stamp(g, ['yyy', '...', 'yy.'], cx + 8, top + 9)
151 stamp(g, head(eyes), cx - 8, top)
152 const water = 14
153 pond(g, water, shift)
154 // The body under the water, and its paws splashing in turn either side.
155 const body = inEllipse(cx, 19.5, 6.5, 4.2)
156 for (let y = water + 1; y < H; y++) {
157 for (let x = 0; x < W; x++) if (body(x, y)) g[y][x] = 'j'
158 }
159 stamp(g, bob ? ['i.', '.a'] : ['.i', 'a.'], cx - 8, water + 1)
160 stamp(g, bob ? ['.i', 'a.'] : ['i.', '.a'], cx + 7, water + 1)
161 stamp(g, ['aa', '..a'], cx - 10, water - 1)
162 stamp(g, ['.aa', 'a..'], cx + 8, water - 1)
163 if (heading) {
164 const behind = heading > 0 ? cx - 15 : cx + 11
165 stamp(g, heading > 0 ? ['a.a.aa', '.a.a..'] : ['aa.a.a', '..a.a.'], behind, water)
166 }
167 return g
168}
169
170// Diving: the tail flips up out of the water where the otter went under,
171// then rings spread where it was.
172const diving = (cx: number, step: 0 | 1, shift: number) => {
173 const g = blank()
174 pond(g, 14, shift)
175 if (step === 0) {
176 fill(g, (x, y) => inSegment(cx, 15, cx + 2.5, 10, 2.2)(x, y) || inSegment(cx + 2.5, 10, cx + 4.5, 7, 1.3)(x, y), OTTER_COAT)
177 stamp(g, ['i.aaa.i', '.aaaaa.'], cx - 3, 13)
178 } else {
179 stamp(g, ['...aaaa...', '.aa....aa.', 'a........a'], cx - 5, 12)
180 }
181 return g
182}
183
184// Jumping into the pond from its edge: crouched at the water, leaping up,
185// arcing down head first, the splash.
186const leaping = (step: 0 | 1 | 2 | 3) => {
187 if (step === 0) {
188 const g = sitting('up', PAWS_HOLD, ROCK_HELD, 0)
189 pond(g, 21, 0)
190 return g
191 }
192 const g = blank()
193 if (step === 1) {
194 fill(g, (x, y) => inSegment(8, 15, 15, 9, 2.8)(x, y) || inEllipse(17.5, 7.5, 3.6, 3.2)(x, y) || inSegment(8, 15, 3, 17, 1.2)(x, y), OTTER_COAT)
195 tint(g, inSegment(9, 15.5, 15, 10.5, 1.3), 'C')
196 stamp(g, ['he'], 17, 6)
197 stamp(g, ['N'], 20, 7)
198 } else if (step === 2) {
199 fill(g, (x, y) => inSegment(13, 9, 20, 13, 2.8)(x, y) || inEllipse(22, 15, 3.4, 3)(x, y) || inSegment(13, 9, 9, 6, 1.2)(x, y), OTTER_COAT)
200 tint(g, inSegment(13, 10.5, 19.5, 14, 1.3), 'C')
201 stamp(g, ['ee'], 22, 14)
202 }
203 pond(g, 17, step)
204 if (step === 3) stamp(g, ['i...i...i', '.i.i.i.i.', '..iiiii..', 'iiaaaaaii'], 17, 13)
205 return g
206}
207
208// Where the swimming otter's head is, and which way it is heading: across
209// to the right, a pause, back to the left, a pause.
210const SWIM_PATH: [cx: number, heading: number][] = [
211 [9, 1], [11, 1], [13, 1], [15, 1], [17, 1], [19, 1], [21, 0], [21, 0],
212 [19, -1], [17, -1], [15, -1], [13, -1], [11, -1], [9, 0], [9, 0],
213]
214
215const FRAMES: Record<string, Grid> = {
216 sit: sitting('open', PAWS_HOLD, ROCK_HELD),
217 blink: sitting('shut', PAWS_HOLD, ROCK_HELD),
218 sleepA: floating(0, 0, 'shut', [[24, 6], [25, 2]]),
219 sleepB: floating(1, 2, 'shut', [[25, 5], [26, 1]]),
220 sleepC: floating(0, 4, 'shut', [[24, 6], [25, 2], [27, -3]]),
221
222 // Playing with the rock: held, tossed up past its nose, caught, clacked.
223 rockHold: sitting('open', PAWS_HOLD, ROCK_HELD),
224 rockToss1: sitting('up', PAWS_UP, [13, 3], 1),
225 rockToss2: sitting('up', PAWS_UP, [13, 0], 1),
226 rockToss3: sitting('up', PAWS_UP, [13, 4]),
227 rockCatch: sitting('happy', PAWS_HOLD, ROCK_HELD),
228 rockClack: sitting('happy', [11, 15, 17, 15], [13, 16]),
229
230 // Swimming back and forth across the pond, then ducking under: the head
231 // sinks, the tail flips up, rings spread, and it pops back up.
232 ...Object.fromEntries(
233 SWIM_PATH.map(([cx, heading], i): [string, Grid] => [`swim${i}`, swimming(cx, i % 2, i, heading)]),
234 ),
235 swimBlink: swimming(15, 0, 3, 0, 'shut'),
236 sink: swimming(15, 0, 4, 0, 'shut', 4),
237 dive0: diving(15, 0, 5),
238 dive1: diving(15, 1, 6),
239 surface: swimming(15, 1, 7, 0, 'shut', 2),
240
241 // In-between frames for the transitions.
242 edge: leaping(0),
243 leapUp: leaping(1),
244 leapDown: leaping(2),
245 splash: leaping(3),
246 lieBack: floating(1, 0, 'half', []),
247}
248
249const SEQUENCES: Record<string, Steps> = {
250 sleep: [['sleepA', 1500], ['sleepB', 1500], ['sleepA', 1500], ['sleepC', 1500]],
251 swim: [
252 ...SWIM_PATH.map((_, i): [string, number] => [`swim${i}`, 340]),
253 ['swim10', 300], ['swimBlink', 160], ['swim10', 300],
254 ['sink', 260], ['dive0', 400], ['dive1', 500], ['surface', 300],
255 ],
256 rock: [
257 ['rockHold', 900], ['blink', 140], ['rockHold', 500],
258 ['rockToss1', 160], ['rockToss2', 260], ['rockToss3', 160], ['rockCatch', 600],
259 ['rockClack', 220], ['rockCatch', 220], ['rockClack', 220], ['rockCatch', 600],
260 ],
261}
262
263const ENTER: Record<string, Steps> = {
264 rock: [],
265 swim: [['edge', 300], ['leapUp', 200], ['leapDown', 200], ['splash', 300], ['dive1', 300], ['surface', 300]],
266 sleep: [['edge', 300], ['lieBack', 500]],
267}
268
269const ALT: Record<string, string> = {
270 sleep: 'An otter asleep, floating on its back holding its rock',
271 swim: 'An otter swimming in its pond',
272 rock: 'An otter playing with its rock',
273}
274
275export const otter: Species = {
276 frames: FRAMES,
277 neutral: 'sit',
278 sequences: SEQUENCES,
279 enter: ENTER,
280 awake: ['swim', 'rock'],
281 oneShot: [],
282 lengthMs: { swim: 3 * 60_000, rock: 3 * 60_000 },
283 alt: ALT,
284}
285hooks/pixels.ts 208 lines1// The pets' shared pixel engine: frames are W x H grids of palette letters,
2// '.' being transparent, stamped together from small parts; a species lists
3// its frames and how its activities play them.
4
5export const W = 32
6export const H = 24
7
8export const PALETTE: Record<string, number> = {
9 // The cat.
10 o: 0x0a080c, // outline
11 c: 0x25212a, // fur
12 l: 0x4b4556, // fur highlight
13 d: 0x2f2a36, // fur sheen
14 s: 0x16131a, // fur shadow
15 w: 0x343039, // muzzle, chest, paws
16 v: 0x2b2730, // muzzle shadow
17 g: 0xf5cf3a, // irises
18 y: 0xa6a3ae, // whiskers
19 // The dog.
20 O: 0x2b1a0e, // outline
21 D: 0x8e5a2e, // fur
22 G: 0xb07740, // fur highlight
23 F: 0x5e3818, // ears
24 S: 0x6f4422, // fur shadow
25 E: 0xd2a06a, // muzzle, chest, paws
26 V: 0xb7864f, // muzzle shadow
27 N: 0x1a1210, // nose
28 T: 0xd4e157, // tennis ball
29 U: 0x9aab2c, // tennis ball seam
30 K: 0xf0e6d2, // bone
31 J: 0xc9b896, // bone shadow
32 A: 0x4f9fd6, // water
33 a: 0xa8d8f8, // water highlight
34 Q: 0xb9ab98, // dust
35 // The otter.
36 I: 0x24170e, // outline
37 B: 0x6b4a32, // fur
38 L: 0x8f6a4a, // fur highlight
39 M: 0x4e3422, // fur shadow
40 C: 0xe8d3b0, // face, belly
41 Y: 0x8c939c, // rock
42 Z: 0x5f666f, // rock shadow
43 X: 0xb8bec6, // rock highlight
44 j: 0x3a7fb4, // deep water
45 i: 0xdff1ff, // splash
46 // Shared.
47 e: 0x0a080c, // pupils
48 h: 0xffffff, // eye glints
49 p: 0xe48aa0, // ears, nose, tongue, toe beans
50 b: 0x6a9fd8, // bowl
51 n: 0x4a78b0, // bowl shadow
52 k: 0xa9cdf2, // bowl rim
53 m: 0xfffdf8, // milk
54 z: 0x9aa7c7, // the z's
55 r: 0xd9566b, // yarn
56 R: 0xa53a50, // yarn shadow
57 q: 0xf29aa8, // yarn highlight
58 P: 0x7a2236, // yarn deep shadow
59 f: 0x8a8f9c, // fly
60 W: 0xc4e0ff, // fly wings
61}
62
63export type Grid = string[][]
64
65export const blank = (): Grid =>
66 Array.from({ length: H }, () => Array.from({ length: W }, () => '.'))
67
68export const stamp = (grid: Grid, rows: readonly string[], x: number, y: number) => {
69 rows.forEach((row, dy) => {
70 ;[...row].forEach((ch, dx) => {
71 const gx = x + dx
72 const gy = y + dy
73 if (ch !== '.' && gx >= 0 && gx < W && gy >= 0 && gy < H) {
74 grid[gy][gx] = ch
75 }
76 })
77 })
78}
79
80export const mirror = (grid: Grid): Grid => grid.map(row => [...row].reverse())
81
82export const mod = (n: number, m: number) => ((n % m) + m) % m
83
84export type Inside = (x: number, y: number) => boolean
85
86export const inEllipse = (cx: number, cy: number, rx: number, ry: number): Inside => (x, y) =>
87 ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 <= 1
88
89// The letters a shape is filled with: its outline, the highlight along its
90// top, the shadow along its bottom, its stripes or sheen, and the fur.
91export type Coat = { edge: string; light: string; shade: string; stripe: string; base: string }
92
93export const CAT_COAT: Coat = { edge: 'o', light: 'l', shade: 's', stripe: 'd', base: 'c' }
94export const DOG_COAT: Coat = { edge: 'O', light: 'G', shade: 'S', stripe: 'D', base: 'D' }
95export const OTTER_COAT: Coat = { edge: 'I', light: 'L', shade: 'M', stripe: 'B', base: 'B' }
96
97// Fills a shape with an outline, a highlight along its top, a shadow along
98// its bottom and, optionally, stripes every fourth column from `stripeFrom`.
99export const fill = (grid: Grid, inside: Inside, coat: Coat, stripeFrom?: number) => {
100 for (let y = 0; y < H; y++) {
101 for (let x = 0; x < W; x++) {
102 if (!inside(x, y)) continue
103 const edge = !inside(x - 1, y) || !inside(x + 1, y) || !inside(x, y - 1) || !inside(x, y + 1)
104 grid[y][x] = edge
105 ? coat.edge
106 : !inside(x, y - 2)
107 ? coat.light
108 : !inside(x, y + 2)
109 ? coat.shade
110 : stripeFrom !== undefined && Math.round(x - stripeFrom) % 4 === 0
111 ? coat.stripe
112 : coat.base
113 }
114 }
115}
116
117// A filled, outlined, shaded ellipse with stripes across its upper half.
118export const ellipse = (grid: Grid, cx: number, cy: number, rx: number, ry: number, coat: Coat) => {
119 fill(grid, inEllipse(cx, cy, rx, ry), coat, cx)
120 for (let y = Math.ceil(cy + 1); y < H; y++) {
121 for (let x = 0; x < W; x++) if (grid[y][x] === coat.stripe) grid[y][x] = coat.base
122 }
123}
124
125// A thick line from (x1, y1) to (x2, y2), as a shape `fill` can take.
126export const inSegment = (x1: number, y1: number, x2: number, y2: number, radius: number): Inside => (x, y) => {
127 const dx = x2 - x1
128 const dy = y2 - y1
129 const t = Math.max(0, Math.min(1, ((x - x1) * dx + (y - y1) * dy) / (dx * dx + dy * dy)))
130 return Math.hypot(x - (x1 + t * dx), y - (y1 + t * dy)) <= radius
131}
132
133export const Z_BIG = ['zzzzz', '...z.', '..z..', '.z...', 'zzzzz']
134export const Z_MID = ['zzzz', '..z.', '.z..', 'zzzz']
135export const Z_SMALL = ['z']
136
137export const stampZs = (grid: Grid, zs: [number, number][]) =>
138 zs.forEach(([zx, zy], i) => stamp(grid, [Z_SMALL, Z_MID, Z_BIG][i], zx, zy))
139
140// The SVG for one frame: runs of same-colored pixels as rects, no background.
141export const svgFor = (grid: Grid, scale: number) => {
142 const rects: string[] = []
143 grid.forEach((row, y) => {
144 let x = 0
145 while (x < W) {
146 const ch = row[x]
147 let end = x + 1
148 while (end < W && row[end] === ch) end++
149 if (ch !== '.') {
150 const color = '#' + PALETTE[ch].toString(16).padStart(6, '0')
151 rects.push(`<rect x="${x}" y="${y}" width="${end - x}" height="1" fill="${color}"/>`)
152 }
153 x = end
154 }
155 })
156 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W * scale}" height="${H * scale}" shape-rendering="crispEdges">${rects.join('')}</svg>`
157}
158
159export type Steps = [frame: string, ms: number][]
160
161// A pet: its frames, and how its activities play them. Every species sleeps
162// ('sleep'); the others are its awake activities. An awake activity either
163// loops for its `lengthMs`, or is in `oneShot` and plays once, start to end.
164// Moving between two activities plays the first one's `enter` backwards, its
165// `neutral` frame, then the second one's `enter`.
166export type Species = {
167 frames: Record<string, Grid>
168 neutral: string
169 sequences: Record<string, Steps>
170 enter: Record<string, Steps>
171 awake: readonly string[]
172 oneShot: readonly string[]
173 lengthMs: Record<string, number>
174 alt: Record<string, string>
175}
176
177// A scene is an activity ('yarn') or a transition between two ('sit>yarn').
178export const isTransition = (scene: string) => scene.includes('>')
179
180export const activityOf = (scene: string) => scene.split('>').pop() ?? 'sleep'
181
182const stepsOf = (pet: Species, scene: string): Steps => {
183 if (!isTransition(scene)) return pet.sequences[scene] ?? pet.sequences.sleep
184 const [from, to] = scene.split('>')
185 return [...[...(pet.enter[from] ?? [])].reverse(), [pet.neutral, 200], ...(pet.enter[to] ?? [])]
186}
187
188export const durationOf = (pet: Species, scene: string) =>
189 stepsOf(pet, scene).reduce((sum, [, ms]) => sum + ms, 0)
190
191// How long an awake activity lasts before the pet moves on.
192export const lengthOf = (pet: Species, activity: string) =>
193 pet.oneShot.includes(activity) ? durationOf(pet, activity) : (pet.lengthMs[activity] ?? 60_000)
194
195// The frame a scene shows `elapsedMs` after it began: an activity loops, a
196// transition or a one-shot activity plays once and holds its last frame.
197export const frameAt = (pet: Species, scene: string, elapsedMs: number) => {
198 const steps = stepsOf(pet, scene)
199 const total = durationOf(pet, scene)
200 const isOnce = isTransition(scene) || pet.oneShot.includes(scene)
201 let t = isOnce ? Math.min(elapsedMs, total - 1) : elapsedMs % total
202 for (const [name, ms] of steps) {
203 if (t < ms) return name
204 t -= ms
205 }
206 return steps[steps.length - 1][0]
207}
208types/index.d.ts 17 lines1export type PetSpecies = 'cat' | 'dog' | 'otter'
2
3declare module 'claude-code' {
4 interface PluginState {
5 pet: {
6 // Which pet is shown.
7 species: PetSpecies
8 // An activity ('yarn') or a transition between two ('sit>yarn').
9 scene: string
10 isHidden: boolean
11 // How the pet shares a crowded band: drop to its own line ('wrap') or
12 // keep its spot and cut the other mods' content short ('shrink').
13 layout: 'wrap' | 'shrink'
14 }
15 }
16}
17