SLOPSHOPPER

pets

Pixel-art cats and dogs that wander above your prompt

newbandcommandprompttimer
v0.2.0MITupdated 2026-10-06e-newton/claude-pets/plugins/pets
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pets
› 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 ⎿ pets: Usage: /pet <command> ⎿ pets: add <cat|dog> [color] [name] adopt a pet (max 6) ⎿ pets: remove <name> say goodbye to a pet ⎿ pets: rename <name> <new name> give a pet a new name ⎿ pets: clear remove every pet ⎿ pets: list show your pets ▄ ▄ ▄▄ ▀▀▀▄▄▀▀▀ ▀▀▀▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▄ ▄ ▄▄ ▀▀▀▄▄▀▀▀ ▀▀▀▀
README

claude-pets

Pets Gif Demo

Pixel-art cats and dogs that wander around above your Claude Code prompt. Inspired by vscode-pets.

Cats and dogs walking, sitting, sleeping and getting a pat

Install

/plugin marketplace add e-newton/claude-pets
/plugin install pets@claude-pets

You start with one cat. The pets live in an 8-row band above the prompt, in the terminal version of Claude Code only.

Commands

CommandWhat it does
`/pet add <cat\dog> [color] [name]`Adopt a pet. Color and name are random if you leave them out. Up to 6 pets.
/pet remove <name>Remove a pet.
/pet rename <name> <new name>Give a pet a new name.
/pet clearRemove all of them.
/pet listList your pets.
/pet pat [name]Pat a pet and a heart pops up.
/pet hide / /pet showHide or show the band.

Cats come in orange, black, gray and white. Dogs come in brown, golden, black and white. Your pets are saved between sessions.

What they do

Pets walk back and forth and sit down now and then (cats sit like a loaf). While Claude is working they run. After two minutes with nothing going on they fall asleep.

Development

The plugin lives in plugins/pets/. The art is drawn in code as character grids in hooks/cat.ts and hooks/dog.ts, with colors in hooks/palettes.ts.

claude plugin validate plugins/pets   # check the manifest and hooks
claude plugin test plugins/pets       # run the tests
python3 scripts/contact-sheet.py sheet.png 6   # render every pet and pose to a PNG

To try changes live, run claude --plugin-dir plugins/pets.

Linting and formatting

TypeScript is linted and formatted with Biome, and the Python script with Ruff. The plugin itself has no dependencies. package.json only holds these dev tools.

npm install
npm run lint        # Biome lint + format check
npm run fix         # apply Biome fixes and formatting
npm run typecheck   # tsc; needs the types Claude Code writes to plugins/pets/.claude-plugin/types/
uvx ruff check scripts && uvx ruff format scripts

The pixel-art files (cat.ts, dog.ts, legs.ts, palettes.ts) are excluded from formatting so the hand-aligned grids keep their shape. They are still linted.

CI (.github/workflows/ci.yml) runs Biome, Ruff, claude plugin validate --strict and claude plugin test on every push and pull request.

License

MIT

Source 10 files
hooks/register.tsx 519 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import type { PetsPet, PetsPosition, PetsSpecies, PetsStore } from '../types'
5import type { Placed } from './render'
6import { renderBand } from './render'
7import type { Mode, PetRuntime } from './sim'
8import { makeRng, maxX, pat, spawn, step, TICK_MS } from './sim'
9import type { Pose } from './sprites'
10import { BAND_ROWS, COLORS, getFrames } from './sprites'
11
12type Engine = EngineInterface
13
14const MAX_PETS = 6
15const MAX_PET_NAME_LENGTH = 24
16const MAX_BAND_COLUMNS = 512
17/** With no prompt activity for this long, the pets fall asleep. */
18const SLEEP_AFTER_TICKS = Math.round(120_000 / TICK_MS)
19/** After this many refused or failed blits in a row the timer stops; the next render restarts it. */
20const MAX_REFUSED_BLITS = 16
21/** How often pet positions are saved to `$.state`, in ticks (about once a second). */
22const SAVE_POSITIONS_EVERY_TICKS = 8
23const BAND_KEY = 'band'
24const POSES: readonly Pose[] = ['walk', 'run', 'sit', 'sleep']
25
26// biome-ignore format: keep the name list compact
27const PET_NAMES = [
28  'Mochi', 'Biscuit', 'Pumpkin', 'Waffles', 'Pickles', 'Noodle', 'Peanut', 'Muffin', 'Bean', 'Cookie',
29  'Sprout', 'Maple', 'Olive', 'Pixel', 'Clover', 'Nugget', 'Pretzel', 'Truffle', 'Ziggy', 'Daisy',
30  'Toast', 'Marble', 'Button', 'Cinnamon', 'Poppy', 'Ginger', 'Fudge', 'Tofu', 'Pebble', 'Sesame',
31]
32
33const USAGE_TEXT = [
34  'Usage: /pet <command>',
35  '  add <cat|dog> [color] [name]   adopt a pet (max 6)',
36  '  remove <name>                  say goodbye to a pet',
37  '  rename <name> <new name>       give a pet a new name',
38  '  clear                          remove every pet',
39  '  list                           show your pets',
40  '  hide | show                    hide or show the band',
41  '  pat [name]                     give a pet some love',
42].join('\n')
43
44// ---------------------------------------------------------------------------
45// Session state
46// ---------------------------------------------------------------------------
47
48/** Bumped whenever the roster or hidden flag changes, so the band (which reads it) redraws. */
49const revisionAtom = atom({ plugin: 'pets', key: 'revision' } as const, 0)
50/**
51 * Where each pet is, saved about once a second. A hot reload drops this module's state
52 * (the simulation included) and a pet would teleport to a random spot; `$.state`
53 * survives it, so a fresh load resumes the pets from here.
54 */
55const positionsAtom = atom({ plugin: 'pets', key: 'positions' } as const, {} as Record<string, PetsPosition>)
56
57/** Where the pets are drawn, and the size of the band they live in. */
58type Band = { requestId: string; columns: number }
59
60/** Everything this module remembers between events. A hot reload drops it, timers and all. */
61type Session = {
62  /** Each pet's simulation state, by pet id. */
63  runtimes: Map<string, PetRuntime>
64  /** The roster and hidden flag as last read from or written to `$.store`; null before the first load. */
65  store: PetsStore | null
66  /** The store read in flight, so concurrent callers share one read. */
67  pendingStoreLoad: Promise<PetsStore> | null
68  /** The band currently on screen; null while hidden or not yet drawn. */
69  band: Band | null
70  tickTimer: { cancel: () => void } | null
71  isClaudeWorking: boolean
72  /** Ticks since the last prompt activity, counted only while idle. */
73  idleTicks: number
74  ticksSinceSavingPositions: number
75  /** Blits refused or failed in a row. */
76  refusedBlits: number
77  /** Guards against a slow tick overlapping the next one. */
78  isTicking: boolean
79  isCommandRegistered: boolean
80}
81
82const newSession = (): Session => ({
83  runtimes: new Map(),
84  store: null,
85  pendingStoreLoad: null,
86  band: null,
87  tickTimer: null,
88  isClaudeWorking: false,
89  idleTicks: 0,
90  ticksSinceSavingPositions: 0,
91  refusedBlits: 0,
92  isTicking: false,
93  isCommandRegistered: false,
94})
95
96let session = newSession()
97
98const rng = makeRng(Math.floor(Math.random() * 2 ** 32))
99/** Not part of `session`: it must keep counting across a reset so ids stay unique. */
100let petIdCounter = 0
101
102// ---------------------------------------------------------------------------
103// Small helpers
104// ---------------------------------------------------------------------------
105
106const pick = <T,>(items: readonly T[], unitRandom: number): T =>
107  items[Math.floor(unitRandom * items.length) % items.length]!
108const capitalize = (text: string) => text.charAt(0).toUpperCase() + text.slice(1)
109const isSpecies = (text: string): text is PetsSpecies => text === 'cat' || text === 'dog'
110
111function isPet(value: unknown): value is PetsPet {
112  const pet = value as PetsPet | null
113  return (
114    typeof pet === 'object' &&
115    pet !== null &&
116    typeof pet.id === 'string' &&
117    isSpecies(pet.species) &&
118    typeof pet.color === 'string' &&
119    typeof pet.name === 'string'
120  )
121}
122
123const newPetId = () =>
124  `p${Date.now().toString(36)}${(petIdCounter++).toString(36)}${Math.floor(rng.next() * 1296).toString(36)}`
125
126/** A new pet; whatever the caller leaves out is picked at random (names avoiding ones in use). */
127function createPet(existing: readonly PetsPet[], species?: PetsSpecies, color?: string, name?: string): PetsPet {
128  const chosenSpecies = species ?? (rng.next() < 0.5 ? 'cat' : 'dog')
129  const namesInUse = new Set(existing.map(pet => pet.name.toLowerCase()))
130  const freeNames = PET_NAMES.filter(candidate => !namesInUse.has(candidate.toLowerCase()))
131  return {
132    id: newPetId(),
133    species: chosenSpecies,
134    color: color ?? pick(COLORS[chosenSpecies], rng.next()),
135    name: name ?? pick(freeNames.length > 0 ? freeNames : PET_NAMES, rng.next()),
136  }
137}
138
139// ---------------------------------------------------------------------------
140// Store: the roster and hidden flag, kept across sessions
141// ---------------------------------------------------------------------------
142
143/** Reads the roster from `$.store`; a new user gets one random cat, saved at once. */
144function loadStore($: Engine): Promise<PetsStore> {
145  session.pendingStoreLoad ??= (async () => {
146    const storedRoster = await $.store.get('roster')
147    let roster: PetsPet[]
148    if (Array.isArray(storedRoster)) {
149      roster = storedRoster.filter(isPet)
150    } else {
151      roster = [createPet([], 'cat')]
152      await $.store.set('roster', roster)
153    }
154    const hidden = (await $.store.get('hidden')) === true
155    session.store = { roster, hidden }
156    return session.store
157  })().finally(() => {
158    session.pendingStoreLoad = null
159  })
160  return session.pendingStoreLoad
161}
162
163async function saveStore($: Engine, next: PetsStore) {
164  await $.store.set('roster', next.roster)
165  await $.store.set('hidden', next.hidden)
166  session.store = next
167  await update($, revisionAtom, revision => (revision ?? 0) + 1)
168}
169
170// ---------------------------------------------------------------------------
171// Simulation: one PetRuntime per roster pet
172// ---------------------------------------------------------------------------
173
174/** What the pets are doing, from the user's activity. */
175function currentMode(): Mode {
176  if (session.isClaudeWorking) return 'working'
177  return session.idleTicks >= SLEEP_AFTER_TICKS ? 'sleeping' : 'idle'
178}
179
180function markUserActive() {
181  session.idleTicks = 0
182}
183
184function countFramesByPose(pet: PetsPet): Record<Pose, number> {
185  const counts = {} as Record<Pose, number>
186  for (const pose of POSES) counts[pose] = getFrames(pet.species, pet.color, pose).length
187  return counts
188}
189
190/**
191 * Keeps one runtime state per roster pet. A pet with a saved position (from before a
192 * hot reload) resumes there; a new pet appears at a random x.
193 */
194function syncRuntimes(roster: readonly PetsPet[], columns: number, savedPositions: Record<string, PetsPosition> = {}) {
195  const rosterIds = new Set(roster.map(pet => pet.id))
196  for (const id of [...session.runtimes.keys()]) if (!rosterIds.has(id)) session.runtimes.delete(id)
197  for (const pet of roster) {
198    if (session.runtimes.has(pet.id)) continue
199    const fresh = spawn(rng, columns, currentMode())
200    const saved = savedPositions[pet.id]
201    session.runtimes.set(pet.id, saved ? { ...fresh, x: Math.min(saved.x, maxX(columns)), dir: saved.dir } : fresh)
202  }
203}
204
205async function savePositions($: Engine) {
206  const positions: Record<string, PetsPosition> = {}
207  for (const [id, runtime] of session.runtimes) positions[id] = { x: runtime.x, dir: runtime.dir }
208  await update($, positionsAtom, () => positions)
209}
210
211/** The band as Raster cells, or null when there is nothing to draw it for. */
212function composeBandCells(): string | null {
213  const { band, store } = session
214  if (!band || !store) return null
215  const mode = currentMode()
216  const placed: Placed[] = []
217  for (const pet of store.roster) {
218    const runtime = session.runtimes.get(pet.id)
219    if (!runtime) continue
220    const frames = getFrames(pet.species, pet.color, runtime.pose)
221    const frame = frames[runtime.frameIndex % frames.length]
222    if (!frame) continue
223    placed.push({
224      frame,
225      x: Math.round(runtime.x),
226      facingLeft: runtime.dir < 0,
227      overlay: runtime.heartTicks > 0 ? 'heart' : mode === 'sleeping' ? 'zzz' : null,
228    })
229  }
230  return renderBand(band.columns, placed)
231}
232
233// ---------------------------------------------------------------------------
234// The band and its timer
235// ---------------------------------------------------------------------------
236
237function startTimer($: Engine) {
238  session.tickTimer ??= $.clock.every(TICK_MS, () => {
239    void tick($)
240  })
241}
242
243function stopTimer() {
244  session.tickTimer?.cancel()
245  session.tickTimer = null
246  session.refusedBlits = 0
247}
248
249function hideBand() {
250  stopTimer()
251  session.band = null
252}
253
254/** Stops the timer after too many blits in a row went wrong; a later render restarts it. */
255function noteBlitRefused() {
256  session.refusedBlits += 1
257  if (session.refusedBlits >= MAX_REFUSED_BLITS) stopTimer()
258}
259
260/** One timer tick: advance every pet, save positions now and then, and repaint the band. */
261async function tick($: Engine) {
262  const { band, store } = session
263  if (session.isTicking || !band || !store) return
264  session.isTicking = true
265  try {
266    const mode = currentMode()
267    if (mode === 'idle') session.idleTicks += 1
268    for (const pet of store.roster) {
269      const runtime = session.runtimes.get(pet.id) ?? spawn(rng, band.columns, mode)
270      session.runtimes.set(
271        pet.id,
272        step(runtime, { width: band.columns, mode, frameCounts: countFramesByPose(pet) }, rng),
273      )
274    }
275    session.ticksSinceSavingPositions += 1
276    if (session.ticksSinceSavingPositions >= SAVE_POSITIONS_EVERY_TICKS) {
277      session.ticksSinceSavingPositions = 0
278      await savePositions($)
279    }
280    // Re-read the band: a render during the awaits above may have resized it.
281    const cells = composeBandCells()
282    const currentBand = session.band
283    if (cells === null || !currentBand) return
284    const result = await $.ui.blit({
285      requestId: currentBand.requestId,
286      key: BAND_KEY,
287      cells,
288      columns: currentBand.columns,
289      rows: BAND_ROWS,
290    })
291    if (result?.deny) noteBlitRefused()
292    else session.refusedBlits = 0
293  } catch {
294    noteBlitRefused()
295  } finally {
296    session.isTicking = false
297  }
298}
299
300// ---------------------------------------------------------------------------
301// The /pet command
302// ---------------------------------------------------------------------------
303
304async function ensureCommandRegistered($: Engine) {
305  if (session.isCommandRegistered) return
306  session.isCommandRegistered = true
307  try {
308    await $.command.register({
309      name: 'pet',
310      description: 'Cats and dogs that wander above your prompt',
311      argumentHint: 'add|remove|rename|clear|list|hide|show|pat',
312    })
313  } catch {
314    session.isCommandRegistered = false
315  }
316}
317
318/**
319 * Splits `rename` arguments into the pet being renamed and the new name's words. Pet names can contain
320 * spaces, so this takes the longest leading run of words that names an existing pet.
321 */
322function splitRenameArgs<Pet>(args: string[], findPet: (name: string) => Pet | undefined) {
323  for (let currentNameWordCount = args.length; currentNameWordCount >= 1; currentNameWordCount--) {
324    const pet = findPet(args.slice(0, currentNameWordCount).join(' '))
325    if (pet) return { pet, newNameWords: args.slice(currentNameWordCount) }
326  }
327  return { pet: undefined, newNameWords: args }
328}
329
330/** Runs `/pet <argsText>` and returns the text to show. */
331async function runPetCommand($: Engine, argsText: string): Promise<{ text: string }> {
332  const words = argsText.trim().split(/\s+/).filter(Boolean)
333  const subcommand = (words[0] ?? '').toLowerCase()
334  const args = words.slice(1)
335  const store = await loadStore($)
336  const findPet = (name: string) => store.roster.find(pet => pet.name.toLowerCase() === name.toLowerCase())
337  const rosterNames = () => store.roster.map(pet => pet.name).join(', ')
338
339  switch (subcommand) {
340    case 'add': {
341      const species = (args[0] ?? '').toLowerCase()
342      if (!isSpecies(species)) {
343        return {
344          text: `Add a cat or a dog: /pet add <cat|dog> [color] [name]\nCat colors: ${COLORS.cat.join(', ')}\nDog colors: ${COLORS.dog.join(', ')}`,
345        }
346      }
347      if (store.roster.length >= MAX_PETS) {
348        return {
349          text: `You already have ${MAX_PETS} pets, which is the most that fit. Try /pet remove <name> first (${rosterNames()}).`,
350        }
351      }
352      let nameWords = args.slice(1)
353      let color: string | undefined
354      const firstWord = (nameWords[0] ?? '').toLowerCase()
355      if (COLORS[species].includes(firstWord)) {
356        color = firstWord
357        nameWords = nameWords.slice(1)
358      } else if (COLORS.cat.includes(firstWord) || COLORS.dog.includes(firstWord)) {
359        return {
360          text: `${capitalize(species)}s do not come in ${firstWord}. ${capitalize(species)} colors: ${COLORS[species].join(', ')}`,
361        }
362      }
363      const name = nameWords.join(' ').slice(0, MAX_PET_NAME_LENGTH).trim() || undefined
364      const sameName = name ? findPet(name) : undefined
365      if (sameName) return { text: `You already have a pet named ${sameName.name}. Pick another name.` }
366      const pet = createPet(store.roster, species, color, name)
367      await saveStore($, { ...store, roster: [...store.roster, pet] })
368      return {
369        text:
370          `Welcome, ${pet.name} the ${pet.color} ${pet.species}!` +
371          (store.hidden ? ' (Pets are hidden: /pet show to see them.)' : ''),
372      }
373    }
374    case 'remove': {
375      if (args.length === 0)
376        return { text: `Remove which pet? /pet remove <name>${store.roster.length ? ` (${rosterNames()})` : ''}` }
377      const pet = findPet(args.join(' '))
378      if (!pet)
379        return {
380          text: `No pet named "${args.join(' ')}".${store.roster.length ? ` Your pets: ${rosterNames()}.` : ' You have no pets yet.'}`,
381        }
382      await saveStore($, { ...store, roster: store.roster.filter(other => other.id !== pet.id) })
383      return { text: `Goodbye, ${pet.name}. They will be missed.` }
384    }
385    case 'rename': {
386      if (args.length === 0 || store.roster.length === 0)
387        return {
388          text: `Rename which pet? /pet rename <name> <new name>${store.roster.length ? ` (${rosterNames()})` : ''}`,
389        }
390      const { pet, newNameWords } = splitRenameArgs(args, findPet)
391      if (!pet) return { text: `No pet named "${args.join(' ')}". Your pets: ${rosterNames()}.` }
392      const newName = newNameWords.join(' ').slice(0, MAX_PET_NAME_LENGTH).trim()
393      if (!newName) return { text: `What should ${pet.name} be called? /pet rename ${pet.name} <new name>` }
394      const sameName = findPet(newName)
395      if (sameName && sameName.id !== pet.id)
396        return { text: `You already have a pet named ${sameName.name}. Pick another name.` }
397      await saveStore($, {
398        ...store,
399        roster: store.roster.map(other => (other.id === pet.id ? { ...other, name: newName } : other)),
400      })
401      return { text: `${pet.name} is now called ${newName}.` }
402    }
403    case 'clear': {
404      if (store.roster.length === 0) return { text: 'You have no pets to clear.' }
405      await saveStore($, { ...store, roster: [] })
406      return {
407        text: `Cleared ${store.roster.length} pet${store.roster.length === 1 ? '' : 's'}. /pet add <cat|dog> to adopt again.`,
408      }
409    }
410    case 'list': {
411      const lines = store.roster.map((pet, i) => `${i + 1}. ${pet.name} - ${pet.color} ${pet.species}`)
412      const heading = store.roster.length
413        ? `Your pets (${store.roster.length}/${MAX_PETS}):`
414        : 'You have no pets yet. /pet add <cat|dog> [color] [name]'
415      return {
416        text: [heading, ...lines, ...(store.hidden ? ['(hidden: /pet show to bring them back)'] : [])].join('\n'),
417      }
418    }
419    case 'hide': {
420      if (store.hidden) return { text: 'The pets are already hidden. /pet show brings them back.' }
421      await saveStore($, { ...store, hidden: true })
422      return { text: 'Pets hidden. /pet show brings them back.' }
423    }
424    case 'show': {
425      if (!store.hidden) return { text: 'The pets are already showing.' }
426      await saveStore($, { ...store, hidden: false })
427      return {
428        text: store.roster.length
429          ? 'Pets are back!'
430          : 'Pets are shown, but you have none yet. /pet add <cat|dog> [color] [name]',
431      }
432    }
433    case 'pat': {
434      if (store.roster.length === 0) return { text: 'You have no pets to pat. /pet add <cat|dog> [color] [name]' }
435      if (store.hidden) return { text: 'The pets are hidden, so they cannot be patted. /pet show first.' }
436      const pet = args.length ? findPet(args.join(' ')) : pick(store.roster, rng.next())
437      if (!pet) return { text: `No pet named "${args.join(' ')}". Your pets: ${rosterNames()}.` }
438      const runtime = session.runtimes.get(pet.id)
439      if (runtime) session.runtimes.set(pet.id, pat(runtime))
440      return { text: pet.species === 'cat' ? `${pet.name} purrs. <3` : `${pet.name} wags happily. <3` }
441    }
442    default:
443      return { text: subcommand ? `Unknown subcommand "${subcommand}".\n${USAGE_TEXT}` : USAGE_TEXT }
444  }
445}
446
447// ---------------------------------------------------------------------------
448// Hooks
449// ---------------------------------------------------------------------------
450
451export const register: Register = on => {
452  // `register` runs again on a hot reload (and once per test): start from a clean session.
453  session.tickTimer?.cancel()
454  session = newSession()
455
456  on('session.start', async ($, e, next) => {
457    await ensureCommandRegistered($)
458    await loadStore($)
459    return next(e)
460  })
461
462  on('prompt.submit', (_$, e, next) => {
463    session.isClaudeWorking = true
464    markUserActive()
465    return next(e)
466  }).catch((_$, e, next) => next(e))
467
468  on('turn.complete', (_$, e, next) => {
469    session.isClaudeWorking = false
470    markUserActive()
471    return next(e)
472  })
473
474  on('prompt.edit', (_$, e, next) => {
475    markUserActive()
476    return next(e)
477  })
478
479  // No click handling: presses on the band carry no coordinates, so a click cannot say
480  // which pet was hit. `/pet pat` stands in for it.
481  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
482    await read($, revisionAtom)
483    await ensureCommandRegistered($)
484    const { roster, hidden } = await loadStore($)
485
486    if (e.props.hasSurvey || hidden || roster.length === 0 || e.surface !== 'terminal') {
487      hideBand()
488      return next(e)
489    }
490
491    const columns = Math.max(1, Math.min(MAX_BAND_COLUMNS, Math.floor(e.props.bodyColumns)))
492    session.isClaudeWorking = e.props.isWorking
493    if (session.isClaudeWorking) markUserActive()
494    session.band = { requestId: e.requestId, columns }
495    // Only a fresh load (no pets placed yet) needs the saved positions.
496    const savedPositions = session.runtimes.size === 0 ? await read($, positionsAtom) : {}
497    syncRuntimes(roster, columns, savedPositions)
498    startTimer($)
499
500    const { Box, Raster } = $.ui.resolve(e)
501
502    return (
503      <Box width={columns} height={BAND_ROWS}>
504        <Raster
505          key={BAND_KEY}
506          columns={columns}
507          rows={BAND_ROWS}
508          cells={composeBandCells() ?? renderBand(columns, [])}
509        />
510      </Box>
511    )
512  })
513
514  on('command.run', { command: 'pet' }, async ($, e) => {
515    markUserActive()
516    return runPetCommand($, e.args)
517  })
518}
519
hooks/render.ts 254 lines
1// Composes placed pets into Raster `cells` (base64). Pure: no `$`, no DOM, no Node.
2//
3// Raster cells: base64 of little-endian u32 triplets [codePoint, fg, bg].
4// A color is 0x00RRGGBB, or DEFAULT_COLOR (0x01000000, bit 24 alone) for the
5// terminal's default fg/bg, which we use for transparent pixels.
6//
7// Each cell is a half-block glyph holding two vertically stacked pixels: the upper one
8// is the foreground of '▀', the lower one is its background (or the foreground of '▄'
9// when the upper pixel is clear).
10
11import type { Frame } from './sprites'
12import { BAND_ROWS, SPRITE_H, SPRITE_W } from './sprites'
13
14export type Overlay = 'heart' | 'zzz' | null
15export type Placed = { frame: Frame; x: number; facingLeft: boolean; overlay: Overlay }
16
17export const DEFAULT_COLOR = 0x01000000
18const HEART_COLOR = 0xe8344f
19const HEART_SHINE = 0xffb0bd
20const Z_COLOR = 0xa9cbff
21
22const UPPER_HALF_BLOCK = 0x2580
23const LOWER_HALF_BLOCK = 0x2584
24const SPACE = 0x20
25
26/** Marks a pixel nobody has drawn (distinct from every real color, DEFAULT_COLOR included). */
27const UNPAINTED = -1
28
29// ---- Overlay bitmaps ----
30// Drawn in the sprite's rightward orientation. '#' = main color, 'h' = highlight.
31// Every sprite frame leaves its top 3 pixel rows empty, so the heart's body lives in that
32// headroom; its single tip pixel (row 3) is only drawn where the sprite leaves it free.
33const HEART = ['.#.#.', '#h###', '.###.', '..#..']
34const HEART_COLORS: Record<string, number> = { '#': HEART_COLOR, h: HEART_SHINE }
35/** Column of the heart's tip pixel, which pokes into row 3 where the sprite may already have pixels. */
36const HEART_TIP_COLUMN = 2
37// A small z and a bigger Z, rising up and to the right above the head.
38const Z_SMALL = ['###', '..#', '.#.', '###']
39const Z_BIG = ['#####', '...#.', '..#..', '.#...', '#####']
40const Z_COLORS: Record<string, number> = { '#': Z_COLOR }
41
42const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
43
44/** Standard padded base64 (no btoa / Buffer / toBase64 needed). */
45export function base64(bytes: Uint8Array): string {
46  let encoded = ''
47  const length = bytes.length
48  for (let i = 0; i < length; i += 3) {
49    const first = bytes[i]!
50    const second = i + 1 < length ? bytes[i + 1]! : 0
51    const third = i + 2 < length ? bytes[i + 2]! : 0
52    encoded += BASE64_ALPHABET[first >> 2]! + BASE64_ALPHABET[((first & 3) << 4) | (second >> 4)]!
53    encoded += i + 1 < length ? BASE64_ALPHABET[((second & 15) << 2) | (third >> 6)]! : '='
54    encoded += i + 2 < length ? BASE64_ALPHABET[third & 63]! : '='
55  }
56  return encoded
57}
58
59/** Inverse of `renderBand`'s encoding, for tests and the preview script. */
60export function decodeCells(cells: string): Array<[codePoint: number, fg: number, bg: number]> {
61  const bytes: number[] = []
62  let bitBuffer = 0
63  let bitCount = 0
64  for (const character of cells) {
65    if (character === '=') break
66    bitBuffer = (bitBuffer << 6) | BASE64_ALPHABET.indexOf(character)
67    bitCount += 6
68    if (bitCount >= 8) {
69      bitCount -= 8
70      bytes.push((bitBuffer >> bitCount) & 255)
71    }
72  }
73  const wordAt = (i: number) => (bytes[i]! | (bytes[i + 1]! << 8) | (bytes[i + 2]! << 16) | (bytes[i + 3]! << 24)) >>> 0
74  const decoded: Array<[number, number, number]> = []
75  for (let i = 0; i + 11 < bytes.length; i += 12) decoded.push([wordAt(i), wordAt(i + 4), wordAt(i + 8)])
76  return decoded
77}
78
79/** Mirror a frame horizontally. */
80export function mirror(frame: Frame): Frame {
81  return frame.map(pixels => pixels.slice().reverse())
82}
83
84// ---- Overlay placement ----
85// Sprites face right, so the head is the right end of an unmirrored frame. All the
86// functions below take the frame AFTER mirroring (what is actually drawn).
87
88/** Column span of the head in a right-facing frame (the front quarter of the sprite). */
89const HEAD_FIRST_COLUMN = 13
90const HEAD_LAST_COLUMN = SPRITE_W - 1
91
92/**
93 * Where an overlay `width` px wide sits in the sprite box: centered above the head (the
94 * right end, or the left end when mirrored). `tipOffset` is an optional column of the
95 * overlay that must land on a free pixel in row 3; the position nearest the head center
96 * where that holds is chosen, so the heart's tip never has to overwrite the sprite.
97 */
98function overlayColumn(frame: Frame, facingLeft: boolean, width: number, tipOffset?: number): number {
99  const maxColumn = SPRITE_W - width
100  const centeredOnHead = Math.round((HEAD_FIRST_COLUMN + HEAD_LAST_COLUMN + 1) / 2 - width / 2)
101  const preferred = facingLeft ? SPRITE_W - width - centeredOnHead : centeredOnHead
102  const tipIsFree = (column: number) => tipOffset === undefined || frame[3]?.[column + tipOffset] == null
103  for (let distance = 0; distance <= SPRITE_W; distance++) {
104    for (const column of [preferred - distance, preferred + distance]) {
105      if (column >= 0 && column <= maxColumn && tipIsFree(column)) return column
106    }
107  }
108  return Math.min(maxColumn, Math.max(0, preferred))
109}
110
111/**
112 * Width of the front strip scanned by `headTopRow`. Narrower than the head so that a
113 * curled body behind it is ignored.
114 */
115const HEAD_SCAN_WIDTH = 5
116
117/** Topmost lit pixel row over the head columns; SPRITE_H when there is none. */
118function headTopRow(frame: Frame, facingLeft: boolean): number {
119  const firstColumn = facingLeft ? 0 : SPRITE_W - HEAD_SCAN_WIDTH
120  const lastColumn = facingLeft ? HEAD_SCAN_WIDTH - 1 : SPRITE_W - 1
121  for (let y = 0; y < SPRITE_H; y++) {
122    for (let x = firstColumn; x <= lastColumn; x++) {
123      if (frame[y]?.[x] != null) return y
124    }
125  }
126  return SPRITE_H
127}
128
129// ---- Painting ----
130
131/** Draws one pixel of the band, silently clipping anything outside it. */
132type PlotPixel = (x: number, y: number, color: number) => void
133
134/**
135 * Overlays never overwrite sprite pixels: a bitmap pixel is only drawn where the sprite
136 * leaves that pixel empty, so the pet's art always stays whole and readable.
137 */
138function paintBitmap(
139  bitmap: readonly string[],
140  frame: Frame,
141  spriteX: number,
142  column: number,
143  top: number,
144  colors: Record<string, number>,
145  plot: PlotPixel,
146) {
147  bitmap.forEach((line, y) => {
148    for (let x = 0; x < line.length; x++) {
149      const color = colors[line[x]!]
150      if (color !== undefined && frame[top + y]?.[column + x] == null) plot(spriteX + column + x, top + y, color)
151    }
152  })
153}
154
155function paintHeart(frame: Frame, facingLeft: boolean, spriteX: number, plot: PlotPixel) {
156  const column = overlayColumn(frame, facingLeft, HEART[0]!.length, HEART_TIP_COLUMN)
157  paintBitmap(HEART, frame, spriteX, column, 0, HEART_COLORS, plot)
158}
159
160/** Both letters stand on the head: the small z beside it, the big Z up and over. */
161function paintSleepZs(frame: Frame, facingLeft: boolean, spriteX: number, plot: PlotPixel) {
162  const headTop = headTopRow(frame, facingLeft)
163  const bigWidth = Z_BIG[0]!.length
164  const smallWidth = Z_SMALL[0]!.length
165  const bigColumn = overlayColumn(frame, facingLeft, bigWidth)
166  const smallColumn = facingLeft ? bigColumn + bigWidth + 1 : bigColumn - smallWidth - 1
167
168  /** Whether every pixel of the bitmap lands inside the sprite box on a free pixel. */
169  const fitsOnFreePixels = (bitmap: string[], column: number, top: number) =>
170    column >= 0 &&
171    column + bitmap[0]!.length <= SPRITE_W &&
172    top >= 0 &&
173    bitmap.every((line, y) => [...line].every((ch, x) => ch !== '#' || frame[top + y]?.[column + x] == null))
174
175  // Both letters must render whole: the big Z rises above the head (pushed down, not clipped,
176  // when the head is tall); the small z takes the nearest spot where all its pixels are free
177  // and that does not overlap the big Z.
178  const bigTop = Math.max(0, headTop - 8)
179  let smallTop = Math.max(bigTop + 1, headTop - 5)
180  let smallLeft = smallColumn
181  search: for (let up = 0; up <= smallTop; up++)
182    for (const shift of [0, -1, 1, -2, 2]) {
183      const column = smallColumn + shift
184      const clearsBigZ = column + smallWidth < bigColumn || column > bigColumn + bigWidth
185      if (fitsOnFreePixels(Z_SMALL, column, smallTop - up) && clearsBigZ) {
186        smallTop -= up
187        smallLeft = column
188        break search
189      }
190    }
191  paintBitmap(Z_SMALL, frame, spriteX, smallLeft, smallTop, Z_COLORS, plot)
192  paintBitmap(Z_BIG, frame, spriteX, bigColumn, bigTop, Z_COLORS, plot)
193}
194
195/** Pack the pixel rows into half-block cells, as the Raster element wants them. */
196function encodeCells(pixels: Int32Array, width: number): string {
197  const bytes = new Uint8Array(width * BAND_ROWS * 12)
198  let offset = 0
199  const writeWord = (value: number) => {
200    bytes[offset++] = value & 255
201    bytes[offset++] = (value >>> 8) & 255
202    bytes[offset++] = (value >>> 16) & 255
203    bytes[offset++] = (value >>> 24) & 255
204  }
205  for (let cellRow = 0; cellRow < BAND_ROWS; cellRow++) {
206    for (let column = 0; column < width; column++) {
207      const upper = pixels[2 * cellRow * width + column]!
208      const lower = pixels[(2 * cellRow + 1) * width + column]!
209      if (upper !== UNPAINTED) {
210        // fg = upper pixel, bg = lower pixel (default bg when the lower pixel is clear)
211        writeWord(UPPER_HALF_BLOCK)
212        writeWord(upper)
213        writeWord(lower === UNPAINTED ? DEFAULT_COLOR : lower)
214      } else if (lower !== UNPAINTED) {
215        writeWord(LOWER_HALF_BLOCK)
216        writeWord(lower)
217        writeWord(DEFAULT_COLOR)
218      } else {
219        writeWord(SPACE)
220        writeWord(DEFAULT_COLOR)
221        writeWord(DEFAULT_COLOR)
222      }
223    }
224  }
225  return base64(bytes)
226}
227
228/** Raster `cells` for a `columns` x BAND_ROWS band. Pets are painted in order, later ones on top. */
229export function renderBand(columns: number, pets: readonly Placed[]): string {
230  const width = Math.max(0, Math.floor(columns))
231  const pixels = new Int32Array(width * SPRITE_H).fill(UNPAINTED)
232
233  const plot: PlotPixel = (x, y, color) => {
234    if (x >= 0 && x < width && y >= 0 && y < SPRITE_H) pixels[y * width + x] = color
235  }
236
237  for (const pet of pets) {
238    const spriteX = Math.round(pet.x)
239    const frame = pet.facingLeft ? mirror(pet.frame) : pet.frame
240    for (let y = 0; y < SPRITE_H; y++) {
241      const spritePixels = frame[y]
242      if (!spritePixels) continue
243      for (let x = 0; x < SPRITE_W; x++) {
244        const color = spritePixels[x]
245        if (color != null) plot(spriteX + x, y, color)
246      }
247    }
248    if (pet.overlay === 'heart') paintHeart(frame, pet.facingLeft, spriteX, plot)
249    else if (pet.overlay === 'zzz') paintSleepZs(frame, pet.facingLeft, spriteX, plot)
250  }
251
252  return encodeCells(pixels, width)
253}
254
hooks/sim.ts 166 lines
1// Per-tick pet behaviour. Pure: no `$`, no clock; randomness comes from a seedable RNG.
2
3import type { Pose } from './sprites'
4import { SPRITE_W } from './sprites'
5
6export type Mode = 'working' | 'idle' | 'sleeping'
7
8export type PetRuntime = {
9  /** Left edge, in (float) columns. */
10  x: number
11  /** 1 = walking right, -1 = walking left. */
12  dir: 1 | -1
13  pose: Pose
14  frameIndex: number
15  /** Ticks until this pose is reconsidered (0 while running or sleeping: mode decides). */
16  poseTicksLeft: number
17  /** Ticks left to show the heart overlay. */
18  heartTicks: number
19  /** Cells per tick while walking (0.25 to 0.5). */
20  speed: number
21  /** Ticks since the last frame advance. */
22  frameTicks: number
23}
24
25/** A source of random numbers in [0, 1). */
26export type Rng = { next: () => number }
27
28export type StepContext = {
29  /** Band width in columns. */
30  width: number
31  mode: Mode
32  /** Frames available per pose; a missing or zero count is treated as 1. */
33  frameCounts: Readonly<Record<Pose, number>>
34}
35
36/** One simulation step and one repaint: about 8 frames per second. */
37export const TICK_MS = 125
38export const HEART_TICKS = 12
39export const RUN_SPEED = 1
40/** [min, max] of a random duration, in ticks. */
41type TickRange = readonly [min: number, max: number]
42/** How long a pet sits, in ticks (about 2 to 6 seconds). */
43const SIT_TICKS: TickRange = [16, 48]
44/** How long a pet walks before reconsidering, in ticks (about 2 to 8 seconds). */
45const WALK_TICKS: TickRange = [16, 64]
46/** Ticks between frame advances for each pose. */
47const FRAME_PERIOD: Record<Pose, number> = { walk: 4, run: 2, sit: 4, sleep: 6 }
48
49/** Chance per tick of turning around on a whim. Rare while running, so it doesn't look frantic. */
50const WALK_TURN_CHANCE = 0.015
51const RUN_TURN_CHANCE = 0.003
52/** Chance, when a walk ends, of sitting down instead of walking on. */
53const SIT_CHANCE = 0.4
54/** Chance of facing the other way when a new walk starts. */
55const REVERSE_ON_WALK_START_CHANCE = 0.3
56
57/** mulberry32: small, fast, seedable. */
58export function makeRng(seed: number): Rng {
59  let counter = seed >>> 0
60  return {
61    next() {
62      counter = (counter + 0x6d2b79f5) >>> 0
63      let mixed = counter
64      mixed = Math.imul(mixed ^ (mixed >>> 15), mixed | 1)
65      mixed ^= mixed + Math.imul(mixed ^ (mixed >>> 7), mixed | 61)
66      return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296
67    },
68  }
69}
70
71/** A random number in [min, max). */
72function between(rng: Rng, min: number, max: number): number {
73  return min + rng.next() * (max - min)
74}
75
76function randomTicks(rng: Rng, [min, max]: TickRange): number {
77  return Math.round(between(rng, min, max))
78}
79
80const reverse = (dir: 1 | -1): 1 | -1 => (dir === 1 ? -1 : 1)
81
82/** The rightmost left-edge a pet can have in a band `width` columns wide. */
83export function maxX(width: number): number {
84  return Math.max(0, width - SPRITE_W)
85}
86
87/** A new pet at a random column, facing a random way, in a pose that fits the mode. */
88export function spawn(rng: Rng, width: number, mode: Mode = 'idle'): PetRuntime {
89  const pose: Pose = mode === 'working' ? 'run' : mode === 'sleeping' ? 'sleep' : 'walk'
90  return {
91    x: Math.round(between(rng, 0, maxX(width))),
92    dir: rng.next() < 0.5 ? 1 : -1,
93    pose,
94    frameIndex: 0,
95    poseTicksLeft: pose === 'walk' ? randomTicks(rng, WALK_TICKS) : 0,
96    heartTicks: 0,
97    speed: between(rng, 0.25, 0.5),
98    frameTicks: 0,
99  }
100}
101
102/** Shows the heart. */
103export function pat(pet: PetRuntime): PetRuntime {
104  return { ...pet, heartTicks: HEART_TICKS }
105}
106
107function enterPose(pet: PetRuntime, pose: Pose, ticks: number): PetRuntime {
108  return { ...pet, pose, poseTicksLeft: ticks, frameIndex: 0, frameTicks: 0 }
109}
110
111/** One tick of behaviour. Returns the next state; the input is not mutated. */
112export function step(previous: PetRuntime, context: StepContext, rng: Rng): PetRuntime {
113  let pet: PetRuntime = { ...previous, heartTicks: Math.max(0, previous.heartTicks - 1) }
114
115  // The mode decides what the pose may be.
116  if (context.mode === 'sleeping') {
117    if (pet.pose !== 'sleep') pet = enterPose(pet, 'sleep', 0)
118  } else if (context.mode === 'working') {
119    if (pet.pose !== 'run') pet = enterPose(pet, 'run', 0)
120  } else if (pet.pose === 'sleep' || pet.pose === 'run') {
121    pet = enterPose(pet, 'walk', randomTicks(rng, WALK_TICKS))
122  }
123
124  const rightLimit = maxX(context.width)
125
126  if (pet.pose === 'walk' || pet.pose === 'run') {
127    const speed = pet.pose === 'run' ? RUN_SPEED : pet.speed
128    let x = pet.x + pet.dir * speed
129    let dir = pet.dir
130    if (x <= 0) {
131      x = 0
132      dir = 1
133    } else if (x >= rightLimit) {
134      x = rightLimit
135      dir = -1
136    }
137    pet = { ...pet, x, dir }
138    if (rng.next() < (pet.pose === 'run' ? RUN_TURN_CHANCE : WALK_TURN_CHANCE)) pet = { ...pet, dir: reverse(pet.dir) }
139  } else {
140    // Standing still, but a resize may have left the pet outside the band.
141    pet = { ...pet, x: Math.min(Math.max(0, pet.x), rightLimit) }
142  }
143
144  // Idle wandering: sit now and then, walk on afterwards.
145  if (context.mode === 'idle') {
146    if (pet.poseTicksLeft > 0) pet = { ...pet, poseTicksLeft: pet.poseTicksLeft - 1 }
147    if (pet.poseTicksLeft === 0) {
148      pet =
149        pet.pose === 'walk' && rng.next() < SIT_CHANCE
150          ? enterPose(pet, 'sit', randomTicks(rng, SIT_TICKS))
151          : enterPose(pet, 'walk', randomTicks(rng, WALK_TICKS))
152      if (pet.pose === 'walk' && rng.next() < REVERSE_ON_WALK_START_CHANCE) pet = { ...pet, dir: reverse(pet.dir) }
153    }
154  }
155
156  // Advance the animation frame.
157  const frameCount = Math.max(1, context.frameCounts[pet.pose] || 1)
158  let frameTicks = pet.frameTicks + 1
159  let frameIndex = pet.frameIndex
160  if (frameTicks >= FRAME_PERIOD[pet.pose]) {
161    frameTicks = 0
162    frameIndex += 1
163  }
164  return { ...pet, frameTicks, frameIndex: frameIndex % frameCount }
165}
166
hooks/sprites.ts 62 lines
1// The pets' sprites as colour Frames. Pure: no `$`, no DOM, no Node.
2//
3// Art is authored as string grids (cat.ts, dog.ts) and converted to Frames on first use.
4// Rows 0-2 of every frame are EMPTY: that headroom belongs to overlays (see render.ts).
5import { CAT_RUN, CAT_SIT, CAT_SLEEP, CAT_WALK } from './cat'
6import { DOG_RUN, DOG_SIT, DOG_SLEEP, DOG_WALK } from './dog'
7import type { Palette } from './palettes'
8import { COLORS, PALETTES } from './palettes'
9import type { Grid } from './pixel-art'
10import { PIXEL_LEGEND, SPRITE_H, SPRITE_W, TRANSPARENT } from './pixel-art'
11
12export { COLORS } from './palettes'
13export { BAND_ROWS, SPRITE_H, SPRITE_W } from './pixel-art'
14
15export type Species = 'cat' | 'dog'
16export type Pose = 'walk' | 'run' | 'sit' | 'sleep'
17
18/** A frame: SPRITE_H rows x SPRITE_W px, each 0xRRGGBB or null (transparent). Faces RIGHT. */
19export type Frame = ReadonlyArray<ReadonlyArray<number | null>>
20
21const GRIDS: Record<Species, Record<Pose, readonly Grid[]>> = {
22  cat: { walk: CAT_WALK, run: CAT_RUN, sit: CAT_SIT, sleep: CAT_SLEEP },
23  dog: { walk: DOG_WALK, run: DOG_RUN, sit: DOG_SIT, sleep: DOG_SLEEP },
24}
25
26/** The raw string grids, for tests. */
27export function getGrids(species: Species, pose: Pose): readonly Grid[] {
28  return GRIDS[species][pose]
29}
30
31/** Paint a grid with a palette. Characters the legend does not know come out transparent. */
32function toFrame(grid: Grid, palette: Palette): Frame {
33  const rows: (number | null)[][] = []
34  for (let y = 0; y < SPRITE_H; y++) {
35    const line = grid[y] ?? ''
36    const pixels: (number | null)[] = []
37    for (let x = 0; x < SPRITE_W; x++) {
38      const character = line[x] ?? TRANSPARENT
39      const colorName = character === TRANSPARENT ? undefined : PIXEL_LEGEND[character as keyof typeof PIXEL_LEGEND]
40      pixels.push(colorName === undefined ? null : palette[colorName])
41    }
42    rows.push(pixels)
43  }
44  return rows
45}
46
47const frameCache = new Map<string, readonly Frame[]>()
48
49/** The frames of a pose, painted in `color`. An unknown color falls back to the species' first. */
50export function getFrames(species: Species, color: string, pose: Pose): readonly Frame[] {
51  const palettes = PALETTES[species] ?? PALETTES.cat
52  const colorName = palettes[color] ? color : (COLORS[species]?.[0] ?? 'orange')
53  const cacheKey = `${species}/${colorName}/${pose}`
54  let frames = frameCache.get(cacheKey)
55  if (!frames) {
56    const grids = GRIDS[species]?.[pose] ?? GRIDS.cat.walk
57    frames = grids.map(grid => toFrame(grid, palettes[colorName]!))
58    frameCache.set(cacheKey, frames)
59  }
60  return frames
61}
62
hooks/cat.ts 138 lines
1// The cat's pixel art, by pose. Pure: no `$`, no DOM, no Node.
2//
3// Round head with pointed ears (pink insides), 2x2 eye with a glint, pink nose, cream
4// muzzle; slim body with a gentle back arch and tucked belly; S-curved tail.
5import { composeGrid, piece, row } from './pixel-art'
6import type { Grid, Piece } from './pixel-art'
7import {
8  RUN_EXTENDED, RUN_EXTENDED_COLUMNS, RUN_GATHERED, RUN_GATHERED_COLUMNS, WALK_GAITS, WALK_LEG_COLUMNS,
9  layerLimbs, withExtraTopRow,
10} from './legs'
11import type { Gait, LegColumns, LegSpec } from './legs'
12
13const HEAD: Grid = [
14  '.u....u.',
15  'upo..opu',
16  'ubbbbbbk',
17  'bbbbwebo',
18  'bbbbeebo',
19  'bbbllmmn',
20  '.oooooo.',
21]
22const HEAD_X = 11
23
24// ---- Walk and run ----
25
26/** Run body: tall (7 rows), stretched out. */
27const BODY: Grid = [
28  row(5, 'kuuuuk'),
29  row(3, 'ku', 'hhhh', 'bbbbbbbb'),
30  row(2, 'o', 'bbbbbbbbbbbb'),
31  row(2, 'o', 'bbbbbbbbbbbb'),
32  row(2, 'o', 'bbb', 'ssss', 'bbbbb'),
33  row(2, 'o', 'bbb', 'oooo', 'bbbbb'),
34  row(3, 'ooo', 4, 'ooooo'),
35]
36// Walk body: shallower (5 rows) and set one row lower so the legs are long and the head
37// stands above the back line.
38const BODY_WALK: Grid = [
39  row(5, 'kuuuuk'),
40  row(3, 'ku', 'hhhhhh', 'bbbbb'),
41  row(2, 'o', 'bbbbbbbbbbbbb'),
42  row(2, 'o', 'bbb', 'ssss', 'bbbb'),
43  row(3, 'ooooooooooooo'),
44]
45/** Run torso, dropped `bodyDrop` rows (the run stride bobs the body down by one). */
46const runTorso = (bodyDrop: number): Piece[] => [piece(0, 2 + bodyDrop, BODY), piece(HEAD_X, bodyDrop, HEAD)]
47const WALK_TORSO: Piece[] = [piece(0, 3, BODY_WALK), piece(HEAD_X, 0, HEAD)]
48
49const TAIL_WALK_A: Grid = [row(1, 'oo'), row('olbo'), row('obbo'), row('obbo'), row(1, 'obbo'), row(2, 'obbo')]
50const TAIL_WALK_B: Grid = [row(2, 'oo'), row(1, 'olbo'), row(1, 'obbo'), row('obbo'), row(1, 'obbo'), row(2, 'obbo')]
51const TAIL_RUN_EXTENDED: Grid = [row(1, 'oooo'), row('olbbb'), row(1, 'oooo')]
52const TAIL_RUN_GATHERED: Grid = [row(0, 'oo'), row('olbbo'), row(1, 'obbbo'), row(2, 'ooo')]
53
54/**
55 * The four legs of a gait, as [near front, near hind, far front, far hind].
56 * `hasLongLegs`: near legs start a row higher and are a row longer; far legs start under
57 * the belly line.
58 */
59function legsFor(gait: Gait, bodyDrop: number, columns: LegColumns, hasLongLegs = false): LegSpec[] {
60  const nearTop = hasLongLegs ? 7 : 8
61  const farTop = hasLongLegs ? nearTop + 1 : nearTop
62  const nearShape = (shape: Gait[number]) => (hasLongLegs ? withExtraTopRow(shape) : shape)
63  return [
64    { kind: 'front', isFar: false, x: columns[0], y: nearTop + bodyDrop, shape: nearShape(gait[0]) },
65    { kind: 'hind', isFar: false, x: columns[1], y: nearTop + bodyDrop, shape: nearShape(gait[1]) },
66    { kind: 'front', isFar: true, x: columns[2], y: farTop + bodyDrop, shape: gait[2] },
67    { kind: 'hind', isFar: true, x: columns[3], y: farTop + bodyDrop, shape: gait[3] },
68  ]
69}
70
71export const CAT_WALK: Grid[] = WALK_GAITS.map((gait, i) =>
72  composeGrid(layerLimbs(legsFor(gait, 0, WALK_LEG_COLUMNS, true), piece(0, 0, i % 2 ? TAIL_WALK_B : TAIL_WALK_A), WALK_TORSO)),
73)
74export const CAT_RUN: Grid[] = [
75  composeGrid(layerLimbs(legsFor(RUN_EXTENDED, 1, RUN_EXTENDED_COLUMNS), piece(0, 4, TAIL_RUN_EXTENDED), runTorso(1))),
76  composeGrid(layerLimbs(legsFor(RUN_GATHERED, 1, RUN_GATHERED_COLUMNS), piece(0, 3, TAIL_RUN_GATHERED), runTorso(1))),
77]
78
79// ---- Sit: the loaf ----
80// A compact bread-loaf body, paws tucked under (just cream toes at the front edge), the
81// head up and alert, the tail laid along the side with its tip poking out behind. Frame 2
82// blinks, twitches an ear and flicks the tail tip.
83const LOAF: Grid = [
84  row(5, 'kuuuuuuuk'),
85  row(3, 'ku', 'hhhhhhhhh', 'bk'),
86  row(2, 'ku', 'bbbbbbbbbbb', 'k'),
87  row(2, 'o', 'bbhhhhbbbbbbbbo'),
88  row(2, 'o', 'bbsbbbsbbbbbbbo'),
89  row(2, 'o', 'ddddddddddbbllo'),
90  row(3, 'ooooooooooooo'),
91]
92const LOAF_TAIL: Grid[] = [
93  [row(0, 'oo'), row(0, 'obd'), row(0, 'ooo')],
94  [row(0, 'oo'), row(0, 'obo'), row(0, 'obo'), row(0, 'obd'), row(0, 'ooo')],
95]
96/** The head with eyes shut and one ear twitched. */
97const HEAD_BLINK: Grid = HEAD.map((line, i) => (i === 3 ? 'bbbbbbbo' : i === 0 ? '.u......' : i === 1 ? 'upo..ouu' : line))
98
99export const CAT_SIT: Grid[] = [0, 1].map(frameIndex =>
100  composeGrid(
101    frameIndex ? piece(0, 8, LOAF_TAIL[1]!) : piece(0, 10, LOAF_TAIL[0]!),
102    piece(0, 6, LOAF),
103    piece(HEAD_X, 1, frameIndex ? HEAD_BLINK : HEAD),
104  ),
105)
106
107// ---- Sleep ----
108// A dome with the tail wrapped round the front: its lighter tip peeks out past the chin.
109const HEAD_SLEEP: Grid = [
110  '.u....u.',
111  'upo..opu',
112  'ubbbbbbk',
113  'bbbbbcbo',
114  'bbbbcbco',
115  'bbbllmmn',
116  '.oooooo.',
117]
118const CURL: Grid = [
119  row(6, 'kuuuk'),
120  row(4, 'ku', 'hhhhhh', 'uk'),
121  row(2, 'ko', 'bbbbbbbbbbb'),
122  row(1, 'ko', 'bbbbbbbbbbbb'),
123  row(1, 'o', 'bbsssbbbbbbbbbb'),
124  row(1, 'o', 'bbbsssbbbbbbbbb'),
125  row(1, 'o', 'bbbbbbbbbbbbbbb'),
126]
127const TAIL_WRAP: Grid = [row(1, 'o', 'ddddddddddddd', 'bb', 'lll'), row(2, 'ooooooooooooooooo')]
128
129/** Frame 2 breathes out: the dome loses its top row. */
130export const CAT_SLEEP: Grid[] = [0, 1].map(frameIndex =>
131  composeGrid(
132    piece(0, 5 + frameIndex, CURL.slice(frameIndex)),
133    piece(12, 5, HEAD_SLEEP),
134    piece(0, 11, TAIL_WRAP),
135    piece(19, 10, [row('l')]),
136  ),
137)
138
hooks/dog.ts 136 lines
1// The dog's pixel art, by pose. Pure: no `$`, no DOM, no Node.
2//
3// Long snout with a stop (forehead step) and a dark nose, a floppy dark ear hanging from
4// the crown, deep chest tapering to a tucked waist, tail held up in a curve.
5import { composeGrid, piece, row } from './pixel-art'
6import type { Grid, Piece } from './pixel-art'
7import {
8  RUN_EXTENDED, RUN_EXTENDED_COLUMNS, RUN_GATHERED, RUN_GATHERED_COLUMNS, STAND, WALK_GAITS, WALK_LEG_COLUMNS,
9  layerLimbs, legPieces, withExtraTopRow,
10} from './legs'
11import type { Gait, LegColumns, LegSpec } from './legs'
12
13const HEAD: Grid = [
14  '...kuuuk....',
15  '..kddbbbk...',
16  '.kdddbwek...',
17  '.odddbeebbnn',
18  '..oddbblllnn',
19  '...kobllmmk.',
20  '....kooook..',
21]
22const HEAD_X = 8
23
24// ---- Walk and run ----
25
26const BODY: Grid = [
27  row(7, 'kuuuuk'),
28  row(3, 'ku', 'hhhh', 'bbbbbbbbb'),
29  row(2, 'o', 'bbbbbbbbbbbb'),
30  row(2, 'o', 'bbb', 'ssss', 'bbbbb'),
31  row(3, 'oooooo', 'bbblll'),
32  row(9, 'oooooo'),
33  row(9, 'oooooo'),
34]
35// Walk body: level back, waist tucked in behind a deeper chest.
36const BODY_WALK: Grid = [
37  row(3, 'kuuuuuuuk'),
38  row(2, 'ku', 'hhhhhhhh', 'bbbbb'),
39  row(1, 'o', 'bbbbbbbbbbbbbb'),
40  row(1, 'o', 'bbbbssssbbbbbbb'),
41  row(2, 'oooooooo', 'bbblo'),
42  row(10, 'ooooo'),
43]
44/** Run torso, dropped `bodyDrop` rows (the run stride bobs the body down by one). */
45const runTorso = (bodyDrop: number): Piece[] => [piece(0, 2 + bodyDrop, BODY), piece(HEAD_X, bodyDrop, HEAD)]
46const WALK_TORSO: Piece[] = [piece(0, 3, BODY_WALK), piece(HEAD_X, 0, HEAD)]
47
48const TAIL_UP: Grid = [row(1, 'oo'), row('olbo'), row('obbo'), row('obbo'), row(1, 'obbo')]
49const TAIL_OUT: Grid = [row(0, 'oo'), row('olbbo'), row('obbbo'), row(1, 'oooo')]
50
51/**
52 * The four legs of a gait, as [near front, near hind, far front, far hind]. Hind legs
53 * start a row higher and are a row longer (the deep chest, the tucked waist).
54 */
55function legsFor(gait: Gait, bodyDrop: number, columns: LegColumns): LegSpec[] {
56  return [
57    { kind: 'front', isFar: false, x: columns[0], y: 8 + bodyDrop, shape: gait[0] },
58    { kind: 'hind', isFar: false, x: columns[1], y: 7 + bodyDrop, shape: withExtraTopRow(gait[1]) },
59    { kind: 'front', isFar: true, x: columns[2], y: 8 + bodyDrop, shape: gait[2] },
60    { kind: 'hind', isFar: true, x: columns[3], y: 7 + bodyDrop, shape: withExtraTopRow(gait[3]) },
61  ]
62}
63
64export const DOG_WALK: Grid[] = WALK_GAITS.map((gait, i) =>
65  composeGrid(layerLimbs(legsFor(gait, 0, WALK_LEG_COLUMNS), piece(i % 2, 0, TAIL_UP), WALK_TORSO)),
66)
67export const DOG_RUN: Grid[] = [
68  composeGrid(layerLimbs(legsFor(RUN_EXTENDED, 1, RUN_EXTENDED_COLUMNS), piece(0, 5, TAIL_OUT), runTorso(1))),
69  composeGrid(layerLimbs(legsFor(RUN_GATHERED, 1, RUN_GATHERED_COLUMNS), piece(0, 3, TAIL_UP), runTorso(1))),
70]
71
72// ---- Sit ----
73// Upright: chest up under the head, head over the front paws, a rounded haunch behind.
74const SIT_BODY: Grid = [
75  row(9, 'kuhbbbllo'),
76  row(7, 'kuuhbbbbllo'),
77  row(5, 'kuhhhbbbbbllo'),
78  row(4, 'o', 'bbbbbbbbbbllo'),
79  row(4, 'o', 'bbbbbbbbbbllo'),
80  row(4, 'oooooooooooooo'),
81]
82// Rounded haunch: a shaded oval under the lit back, with the hind paw out along the ground.
83const SIT_THIGH: Grid = [row(6, 's', 3, 's'), row(7, 'ssss', 'lll')]
84// The tail: lies along the ground, then lifts in a curve (wags).
85const SIT_TAIL: Grid[] = [
86  [row(2, 'dd'), row(0, 'dddd')],
87  [row(1, 'd'), row(1, 'dd'), row(2, 'dd'), row(0, 'dddd')],
88]
89const FAR_FRONT_PAW: LegSpec = { kind: 'front', isFar: true, x: 10, y: 8, shape: STAND }
90const NEAR_FRONT_PAW: LegSpec = { kind: 'front', isFar: false, x: 13, y: 8, shape: STAND }
91
92/** Frame 2 lifts the tail and shows a pink tongue. */
93export const DOG_SIT: Grid[] = [0, 1].map(frameIndex =>
94  composeGrid(
95    piece(0, frameIndex ? 9 : 11, SIT_TAIL[frameIndex]!),
96    piece(0, 7, SIT_BODY),
97    piece(0, 10, SIT_THIGH),
98    legPieces(FAR_FRONT_PAW),
99    piece(HEAD_X, 0, HEAD),
100    legPieces(NEAR_FRONT_PAW),
101    frameIndex ? piece(17, 5, [row('p')]) : [],
102  ),
103)
104
105// ---- Sleep ----
106const HEAD_SLEEP: Grid = [
107  '..kuuuuk..',
108  '.kddbbbbbk',
109  '.dddbccbbn',
110  '.dddblllln',
111  '..oo.llll.',
112]
113
114/** Frame 2 breathes: the back rises a row and the paws shift. */
115export const DOG_SLEEP: Grid[] = [0, 1].map(frameIndex =>
116  composeGrid(
117    piece(
118      0,
119      4,
120      frameIndex
121        ? [row(3, 'kuuuuk'), row(1, 'ku', 'hhhhhh', 'uk'), row(0, 'ko', 'bbbbbbbbbk'), row(0, 'o', 'bbbbbbbbbbbk')]
122        : [row(), row(3, 'kuuuuk'), row(1, 'ku', 'hhhhhh', 'uk'), row(0, 'ko', 'bbbbbbbbbk')],
123    ),
124    piece(0, 8, [
125      row(0, 'o', 'bbbbbbbbbbbbb'),
126      row(0, 'o', 'bbsssbbbbbbbbb'),
127      row(0, 'o', 'bsbbbsbbbbbbbb'),
128      frameIndex ? row(0, 'o', 'dddddddddbbb') : row(0, 'o', 'dddddddllbbb'),
129      row(0, 'oooooooooooo'),
130    ]),
131    frameIndex ? piece(7, 10, [row('ll')]) : [],
132    piece(10, 8, HEAD_SLEEP),
133    frameIndex ? piece(11, 8, [row('dd')]) : [],
134  ),
135)
136
hooks/palettes.ts 110 lines
1// Colour palettes for the pets. Pure: no `$`, no DOM, no Node.
2//
3// Each palette gives a name to every colour the pixel art can use (see PIXEL_LEGEND in
4// pixel-art.ts for the character that stands for each). Authors only pick the base
5// colours; the in-between shades are derived so the variants stay consistent.
6
7/** Every colour a pixel of the art can take, as 0xRRGGBB. */
8export type Palette = {
9  /** Dark line around the body, on the under side. */
10  outline: number
11  /** Outline on the lit top edge: much closer to the body colour. */
12  outlineLit: number
13  /** Soft corner pixel that rounds off a silhouette (anti-aliasing). */
14  softCorner: number
15  body: number
16  /** Top-of-back highlight. */
17  highlight: number
18  /** Muzzle, chest and paws. */
19  light: number
20  /** Dark shade: dog ear, back haunch, cat tail wrap. */
21  dark: number
22  eye: number
23  eyeGlint: number
24  /** Shut-eye line (sleeping): the eye colour unless that is too bright to read as closed. */
25  closedEye: number
26  nose: number
27  /** Inner ear and tongue. */
28  pink: number
29  /** Belly and chest shade. */
30  shade: number
31  /** Limbs on the far side of the body: muted so they recede. */
32  farLimb: number
33  farPaw: number
34  /** Mouth line. */
35  mouth: number
36}
37
38/** The colours every palette must supply; the rest are derived (or overridden). */
39type BaseColors = Pick<Palette, 'outline' | 'body' | 'light' | 'dark' | 'eye' | 'eyeGlint' | 'nose' | 'pink'>
40type PaletteSpec = BaseColors & Partial<Palette>
41
42/** Blend two 0xRRGGBB colours channel by channel: `amount` 0 gives `from`, 1 gives `to`. */
43function mixColors(from: number, to: number, amount: number): number {
44  const channel = (shift: number) =>
45    Math.round(((from >> shift) & 255) * (1 - amount) + ((to >> shift) & 255) * amount)
46  return (channel(16) << 16) | (channel(8) << 8) | channel(0)
47}
48
49/** Fill in the shades a spec leaves out; anything the spec states explicitly wins. */
50function completePalette(spec: PaletteSpec): Palette {
51  const farLimb = mixColors(spec.body, spec.dark, 0.8)
52  return {
53    outlineLit: mixColors(spec.outline, spec.body, 0.45),
54    softCorner: mixColors(spec.outline, spec.body, 0.7),
55    highlight: mixColors(spec.body, spec.light, 0.45),
56    shade: mixColors(spec.body, spec.dark, 0.55),
57    farLimb,
58    farPaw: mixColors(farLimb, spec.outline, 0.4),
59    mouth: mixColors(spec.outline, spec.dark, 0.5),
60    closedEye: spec.eye,
61    ...spec,
62  }
63}
64
65function completeAll(specs: Record<string, PaletteSpec>): Record<string, Palette> {
66  return Object.fromEntries(Object.entries(specs).map(([color, spec]) => [color, completePalette(spec)]))
67}
68
69const CAT_PALETTES: Record<string, Palette> = completeAll({
70  orange: { outline: 0x7a3a12, body: 0xf0922c, light: 0xffd59a, dark: 0xc86a1a, eye: 0x1a1a22, eyeGlint: 0xffffff, nose: 0xe8607e, pink: 0xf4a0a8 },
71  // Black: a dark outline under a body that is only a little lighter, plus a cool rim light on the lit edge.
72  black: {
73    outline: 0x0e0f14, body: 0x363948, light: 0x555a6e, dark: 0x242632,
74    outlineLit: 0x7c829c, softCorner: 0x1c1e28, highlight: 0x4a4e62, shade: 0x2a2c38,
75    farLimb: 0x2c2e3b, farPaw: 0x171821, mouth: 0x171821,
76    eye: 0xf2e04a, eyeGlint: 0xffffff, closedEye: 0x0e0f14, nose: 0xe8607e, pink: 0xb86a7c,
77  },
78  gray: { outline: 0x434955, body: 0x9aa1ad, light: 0xd0d5dd, dark: 0x757c89, eye: 0x1a1a22, eyeGlint: 0xffffff, nose: 0xe8607e, pink: 0xf0a8b4 },
79  // White: stronger outline + real shade tones so it holds up on a light background.
80  white: {
81    outline: 0x646b84, body: 0xe9ebf3, light: 0xffffff, dark: 0xaeb4c8,
82    shade: 0xd0d4e2, farLimb: 0xc0c5d6, farPaw: 0x949ab0,
83    eye: 0x23305a, eyeGlint: 0xffffff, nose: 0xf0708c, pink: 0xf8b4c0,
84  },
85})
86
87const DOG_PALETTES: Record<string, Palette> = completeAll({
88  brown: { outline: 0x40240e, body: 0xa06c3c, light: 0xe0b684, dark: 0x6a4020, eye: 0x15151c, eyeGlint: 0xffffff, nose: 0x15151c, pink: 0xff6f86 },
89  golden: { outline: 0x7e4e10, body: 0xe8b24c, light: 0xfae2a4, dark: 0xb67c20, eye: 0x15151c, eyeGlint: 0xffffff, nose: 0x15151c, pink: 0xff6f86 },
90  black: {
91    outline: 0x0e0f14, body: 0x383b4a, light: 0x5a5f74, dark: 0x1f212b,
92    outlineLit: 0x80869f, softCorner: 0x1c1e28, highlight: 0x4c5064, shade: 0x2b2d3a,
93    farLimb: 0x2d2f3c, farPaw: 0x171821, mouth: 0x171821,
94    eye: 0xe8b030, eyeGlint: 0xfff2b0, closedEye: 0x0e0f14, nose: 0xc8ccd8, pink: 0xff6f86,
95  },
96  white: {
97    outline: 0x646b84, body: 0xe9ebf3, light: 0xffffff, dark: 0xaeb4c8,
98    shade: 0xd0d4e2, farLimb: 0xc0c5d6, farPaw: 0x949ab0,
99    eye: 0x15151c, eyeGlint: 0xffffff, nose: 0x15151c, pink: 0xff6f86,
100  },
101})
102
103export const PALETTES = { cat: CAT_PALETTES, dog: DOG_PALETTES }
104
105/** The colour names offered to users, first one being the default. */
106export const COLORS: { cat: readonly string[]; dog: readonly string[] } = {
107  cat: ['orange', 'black', 'gray', 'white'],
108  dog: ['brown', 'golden', 'black', 'white'],
109}
110
hooks/pixel-art.ts 70 lines
1// Authoring helpers for the pixel-art string grids. Pure: no `$`, no DOM, no Node.
2//
3// A sprite is a SPRITE_H x SPRITE_W grid of characters facing RIGHT. The top
4// HEADROOM_ROWS rows of every sprite stay empty: that headroom belongs to the heart and
5// "zzz" overlays (see render.ts). So pieces (head, body, legs, tail) are authored on a
6// smaller ART_ROWS-high canvas and `composeGrid` adds the headroom back.
7
8export const SPRITE_W = 20
9export const SPRITE_H = 16
10export const BAND_ROWS = SPRITE_H / 2 // half-block glyphs: one terminal row holds two pixel rows
11export const HEADROOM_ROWS = 3
12const ART_ROWS = SPRITE_H - HEADROOM_ROWS
13
14/** A grid of art: one string per pixel row, one character per pixel. */
15export type Grid = readonly string[]
16
17/** What each character of the art means: the name of a field of `Palette`. '.' is transparent. */
18export const PIXEL_LEGEND = {
19  o: 'outline',
20  u: 'outlineLit',
21  k: 'softCorner',
22  b: 'body',
23  h: 'highlight',
24  l: 'light',
25  d: 'dark',
26  e: 'eye',
27  w: 'eyeGlint',
28  c: 'closedEye',
29  n: 'nose',
30  p: 'pink',
31  s: 'shade',
32  f: 'farLimb',
33  g: 'farPaw',
34  m: 'mouth',
35} as const
36
37export const TRANSPARENT = '.'
38
39/** Build a row: numbers are runs of transparent pixels, strings are pixels. Padded to SPRITE_W. */
40export const row = (...segments: (string | number)[]): string =>
41  segments
42    .map(segment => (typeof segment === 'number' ? TRANSPARENT.repeat(segment) : segment))
43    .join('')
44    .padEnd(SPRITE_W, TRANSPARENT)
45
46/** A small grid stamped onto the canvas with its top-left corner at (x, y). */
47export type Piece = { x: number; y: number; rows: Grid }
48export const piece = (x: number, y: number, rows: Grid): Piece => ({ x, y, rows })
49
50/**
51 * Stamp pieces onto an empty canvas in painter's order (later pieces cover earlier ones;
52 * a '.' in a piece is transparent and leaves what is below). Pixels outside the canvas
53 * are clipped. Returns the full SPRITE_H-row grid, headroom included.
54 */
55export function composeGrid(...pieces: (Piece | Piece[])[]): Grid {
56  const canvas: string[][] = Array.from({ length: ART_ROWS }, () => Array<string>(SPRITE_W).fill(TRANSPARENT))
57  for (const { x, y, rows } of pieces.flat())
58    rows.forEach((line, rowOffset) => {
59      for (let columnOffset = 0; columnOffset < line.length; columnOffset++) {
60        const pixel = line[columnOffset]!
61        const canvasX = x + columnOffset
62        const canvasY = y + rowOffset
63        const isOnCanvas = canvasY >= 0 && canvasY < ART_ROWS && canvasX >= 0 && canvasX < SPRITE_W
64        if (pixel !== TRANSPARENT && isOnCanvas) canvas[canvasY]![canvasX] = pixel
65      }
66    })
67  const headroom = Array.from({ length: HEADROOM_ROWS }, () => TRANSPARENT.repeat(SPRITE_W))
68  return [...headroom, ...canvas.map(pixels => pixels.join(''))]
69}
70
hooks/legs.ts 89 lines
1// Leg shapes and gaits shared by the cat and the dog. Pure: no `$`, no DOM, no Node.
2import { piece } from './pixel-art'
3import type { Piece } from './pixel-art'
4
5/**
6 * A leg shape is a list of [xOffset, row] from the top down: 2 px of fill inside an
7 * outline, a lighter paw that pokes forward, and a rounded bottom. The offset shifts the
8 * row sideways, which is what angles the leg.
9 */
10export type LegShape = ReadonlyArray<readonly [xOffset: number, row: string]>
11
12export const STAND: LegShape = [[0, 'obbo'], [0, 'obbo'], [0, 'obbo'], [0, 'obllo'], [1, '.ooo']]
13export const REACH: LegShape = [[0, 'obbo'], [0, 'obbo'], [1, 'obbo'], [2, 'obllo'], [3, '.ooo']]
14export const PUSH: LegShape = [[0, 'obbo'], [0, 'obbo'], [-1, 'obbo'], [-2, 'obllo'], [-1, '.ooo']]
15/** Knee up, paw tucked forward: 3 rows, so it hangs clear of the ground. */
16export const SWING: LegShape = [[0, 'obbo'], [1, 'obllo'], [1, '.ooo']]
17export const STRIDE_FORWARD: LegShape = [[0, 'obbo'], [1, 'obbo'], [2, 'obllo'], [3, '.ooo']]
18export const STRIDE_BACK: LegShape = [[0, 'obbo'], [-1, 'obbo'], [-2, 'obllo'], [-3, '.ooo']]
19/** Gathered stride: the paws come together under the body but stay near the ground. */
20export const GATHER: LegShape = [[0, 'obbo'], [0, 'obbo'], [1, 'obllo'], [1, '.ooo']]
21
22/** Hind legs get a haunch that swells one px backward over their top two rows. */
23const withHaunch = (shape: LegShape): LegShape =>
24  shape.map(([xOffset, legRow], i) =>
25    i < 2 && legRow === 'obbo' ? ([xOffset - 1, 'obbbo'] as const) : ([xOffset, legRow] as const),
26  )
27
28/**
29 * The same shape in the muted far-side tones, without an outline: the fill becomes
30 * `farLimb` ('f'), and the paw and rounded bottom become `farPaw` ('g').
31 */
32const asFarLeg = (shape: LegShape): LegShape =>
33  shape.map(
34    ([xOffset, legRow], i) =>
35      [
36        xOffset,
37        i === shape.length - 1 ? legRow.replace(/[ol]/g, 'g') : legRow.replace(/o/g, '.').replace(/[bl]/g, 'f'),
38      ] as const,
39  )
40
41/** One row longer at the top: the dog's chest hangs deeper than its waist. */
42export const withExtraTopRow = (shape: LegShape): LegShape => [shape[0]!, ...shape]
43
44export type LegSpec = {
45  kind: 'front' | 'hind'
46  isFar: boolean
47  /** Column of the leg's top-left corner, before the shape's own offsets. */
48  x: number
49  /** Row of the leg's top. */
50  y: number
51  shape: LegShape
52}
53
54/** A leg as one single-row piece per shape row. */
55export function legPieces({ kind, isFar, x, y, shape }: LegSpec): Piece[] {
56  let drawn = kind === 'hind' ? withHaunch(shape) : shape
57  if (isFar) drawn = asFarLeg(drawn)
58  return drawn.map(([xOffset, legRow], i) => piece(x + xOffset, y + i, [legRow]))
59}
60
61/** Far legs go behind the torso, near legs over it (a leg top replaces the belly outline). */
62export const layerLimbs = (legs: LegSpec[], tail: Piece, torso: Piece[]): Piece[] => [
63  tail,
64  ...legs.filter(leg => leg.isFar).flatMap(legPieces),
65  ...torso,
66  ...legs.filter(leg => !leg.isFar).flatMap(legPieces),
67]
68
69/** A gait frame: the shapes of the [near front, near hind, far front, far hind] legs. */
70export type Gait = [LegShape, LegShape, LegShape, LegShape]
71/** Leg columns, in the same [near front, near hind, far front, far hind] order. */
72export type LegColumns = [number, number, number, number]
73
74export const WALK_LEG_COLUMNS: LegColumns = [11, 3, 8, 6]
75
76// Four-beat walk, facing right. A foot's cycle is REACH (planted ahead) -> STAND (under the
77// body) -> PUSH (planted behind) -> SWING (lifted, carried forward): while planted it slides
78// LEFT relative to the body, which is what makes the body travel right. Do not reorder.
79export const WALK_GAITS: Gait[] = [
80  [REACH, PUSH, PUSH, REACH],
81  [STAND, SWING, SWING, STAND],
82  [PUSH, REACH, REACH, PUSH],
83  [SWING, STAND, STAND, SWING],
84]
85export const RUN_EXTENDED: Gait = [STRIDE_FORWARD, STRIDE_BACK, STRIDE_FORWARD, STRIDE_BACK]
86export const RUN_GATHERED: Gait = [GATHER, GATHER, GATHER, GATHER]
87export const RUN_EXTENDED_COLUMNS: LegColumns = [12, 5, 9, 7]
88export const RUN_GATHERED_COLUMNS: LegColumns = [10, 6, 8, 8]
89
types/index.d.ts 40 lines
1// The claude-pets contract: what the plugin keeps in `$.store` (across sessions)
2// and in `$.state` (for the session).
3
4export type PetsSpecies = 'cat' | 'dog'
5
6/** One pet of the roster. Positions are kept for the session only (see `positions`). */
7export type PetsPet = {
8  id: string
9  species: PetsSpecies
10  color: string
11  name: string
12}
13
14/**
15 * The keys of `$.store`:
16 *   roster: PetsPet[]   absent until first run, when one random cat is added
17 *   hidden: boolean     absent means shown
18 */
19export type PetsStore = {
20  roster: PetsPet[]
21  hidden: boolean
22}
23
24/** A pet's place in the band, saved to `$.state` about once a second. */
25export type PetsPosition = {
26  x: number
27  dir: 1 | -1
28}
29
30declare module 'claude-code' {
31  interface PluginState {
32    pets: {
33      /** Bumped whenever the roster or the hidden flag changes: the band reads it and redraws. */
34      revision: number
35      /** Where each pet was, by pet id, so a hot reload doesn't teleport them. */
36      positions: Record<string, PetsPosition>
37    }
38  }
39}
40