SLOPSHOPPER

pet

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

newbandcommandtimer
v1.0.4MITupdated 2026-10-03gmorubio/claude-mods/pet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pet
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /pet ⎿ pet: The cat switches to: yarn. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

pet

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.

The cat, the dog and the otter

Pets

Cat: a black kitten with yellow eyes.

ActivityWhat it doesHow long
sleepCurled up in a bun, breathing, Z's drifting upWhile Claude is idle
sitSits and stares at you, blinks, flicks its tail3 minutes
groomLicks its paw and wipes its face3 minutes
yarnBats a ball of yarn around3 minutes
milkLaps milk from a bowl10 seconds
huntFollows a fly with its eyes, pounces, catches itUntil the fly is caught

Dog: a brown pup with floppy ears.

ActivityWhat it doesHow long
sleepCurled up, Z's drifting upWhile Claude is idle
lookLooks at you panting, tongue out, tail wagging3 minutes
boneLies down chewing a bone3 minutes
catchCatches a tennis ball in its mouthUntil it drops the ball
zoomiesSpins round in circles3 seconds
waterDrinks from a water bowl10 seconds

Otter: a round brown otter that never lets go of its rock.

ActivityWhat it doesHow long
sleepFloats on its back holding its rockWhile Claude is idle
swimJumps into its pond, swims back and forth, dives3 minutes
rockTosses its rock up and catches it3 minutes

Commands

CommandWhat it does
/pet cat, /pet dog, /pet otterSwitch pets (remembered between sessions)
/pet <activity>Jump to one of the current pet's activities, e.g. /pet zoomies
/petSwitch to a random activity
/pet show, /pet hideShow or hide the pet (it starts hidden in every session)
/pet layout wrapWhen other mods crowd the band, the pet moves to its own line (default)
/pet layout shrinkThe pet keeps its spot and other band content is cut short

Requirements

  • The Claude Code desktop app. The pet is drawn as an image, which the terminal can't show; in the terminal the mod stays out of the way.
  • A Claude Code build with function-hook mods (hooks/hooks.json with modules).

Install

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

How it's built

  • 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.

Privacy

pet makes no network requests and sends nothing anywhere. See the privacy policy.

License

MIT

Source 6 files
hooks/register.tsx 202 lines
1import 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}
202
hooks/cat.ts 457 lines
1// 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}
457
hooks/dog.ts 346 lines
1// 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}
346
hooks/otter.ts 285 lines
1// 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}
285
hooks/pixels.ts 208 lines
1// 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}
208
types/index.d.ts 17 lines
1export 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