Pixel-art cats and dogs that wander above your prompt


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

/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.
| Command | What 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 clear | Remove all of them. | |
/pet list | List your pets. | |
/pet pat [name] | Pat a pet and a heart pops up. | |
/pet hide / /pet show | Hide 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.
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.
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.
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.
MIT
hooks/register.tsx 519 lines1import 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}
519hooks/render.ts 254 lines1// 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}
254hooks/sim.ts 166 lines1// 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}
166hooks/sprites.ts 62 lines1// 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}
62hooks/cat.ts 138 lines1// 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)
138hooks/dog.ts 136 lines1// 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)
136hooks/palettes.ts 110 lines1// 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}
110hooks/pixel-art.ts 70 lines1// 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}
70hooks/legs.ts 89 lines1// 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]
89types/index.d.ts 40 lines1// 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