A pixel pet that lives in Claude Code: it watches what Claude is doing, grows with Claude's work and keeps you company

English | 简体中文
A pixel pet that lives inside Claude Code, in the spirit of the handheld virtual pets of the '90s. It watches what Claude is doing, grows up on Claude's work, and keeps you company while you code.
A pixel creature drawn with half-block cells (two pixels per terminal cell), animated at ~5 fps straight into the terminal. Original art. The default species is mallow, a peach fox kit with petal ears and a tail that grows fluffier; /pet species switches to sprig (a cream chick with a leaf sprout), dewdrop (a dewdrop sprite in a curly drop hat), plumcap (a plum mushroom whose cap grows wavy), luma (a fuzzy moth with crescent-moon wings), mossroll (a snail carrying a mossy shell), inkfold (an origami cat folded from graphite paper), kilnby (a terracotta pup with a glazed bib), tuck (a shy ghost living in an old quilt) or mochi (the first design, a round orange blob).

foo.ts, searching TODO, running npm test, delegating to a subagent, browsing claude.com. The pane shows the current activity, how long it has run and how many tools this turn used.5: 💊Medicine button appears while it is sick); cleaning alone doesn't cure it. Time away is capped (three piles at most, no stat below 15), so coming back is never cruel.language setting; /pet lang zh|en picks one./pet); with the band hidden, the status line carries its stats instead. On desktop and other surfaces without pixels it falls back to text.claude plugin marketplace add VibeMage/claude-mod-pet
claude plugin install pixipet@claude-mod-pet
Then start a new session (or run /reload-plugins) and type /pet.
To try it for one session without installing: git clone https://github.com/VibeMage/claude-mod-pet && claude --plugin-dir ./claude-mod-pet.
Type /pet and everything is in its pane: care on the front, and ⚙ Settings (press o) for species, placement, band, buttons, digit keys, skin, language, name and starting over, each a row of choices you click or Tab to. /pet settings opens straight there. The subcommands below are shortcuts for the same things.
| Command | |||
|---|---|---|---|
/pet | Open the pet pane (with the pane focused, ctrl+x tab: f feed, p play, c clean, s sleep/wake) | ||
/pet feed · play · clean · sleep · heal | Care without opening the pane; heal gives medicine to a sick pet | ||
/pet status | One-line stats | ||
/pet name <name> | Rename the pet | ||
| `/pet band [full\ | mini\ | hidden]` | The band above the prompt: pixel scene (8 rows), one line, or off. Without an argument it cycles |
| `/pet place [above\ | below]` | Put the band above the prompt (the default) or under it; the digit keys work only above it | |
| `/pet buttons [on\ | off]` | Show or hide the band's care buttons (1: 🍓Feed 2: 🎾Play 3: 🧼Clean 4: 🌙Sleep, each a soft-coloured chip); hiding them saves a row and turns the digit keys off too | |
| `/pet keys [on\ | off]` | Digits in an empty prompt press the band's buttons: 1 feed, 2 play, 3 clean, 4 sleep. On by default; off gives the digits back to typing | |
/pet species [id] | Switch species: mallow, sprig, dewdrop, plumcap, luma, mossroll, inkfold, kilnby, tuck, mochi; without an id, list them | ||
| `/pet skin [color\ | lcd]` | Colour pixels, or a retro handheld LCD screen in four greens | |
| `/pet lang [zh\ | en\ | auto]` | The pet's language; auto follows Claude Code's language setting, English when it has none |
/pet reset confirm | Start over from a new egg |
Everything stays on your machine. The pet makes no network requests and runs no commands.
theme and language settings. It reads no environment variables and no credentials.~/.claude/plugins/store/. Nothing about your code or prompts is stored.tool.call, turn.start and turn.complete to follow what Claude does (each passed on unchanged); prompt.submit only to let a sleeping pet roll over when you write to Claude (the prompt is passed on unchanged); command.run to answer its own /pet command and nothing else; session.start / session.end to load and save the pet; ui.render for the band, the hint line and its pane.A Claude Code mod (function hooks plugin). tool.call, turn.start and turn.complete feed the activity; $.clock.every drives the animation (frames go to a Raster through $.ui.blit, no redraw) and the minute-by-minute life; $.state holds what the drawings read, $.store keeps the pet between sessions. Colours are the iOS system palette, light or dark with the Claude Code theme.
hooks/register.tsx hooks, pane, band, commands
hooks/pet.ts pure pet logic (stats, stages, care, tool → activity)
hooks/pixels.ts pixel art: the creature, icons, bubbles, half-block packing
hooks/species/ the species as pixel bitmaps and their contract
hooks/text.ts every word in Chinese and English
hooks/sprites.ts text fallback for surfaces without pixels, and bars
types/index.d.ts the $.state contract
tests/pet.test.ts claude plugin test .
claude plugin validate --strict .
claude plugin test .
npx -y tsx scripts/preview.ts all # renders docs/species/<id>.png for every species
The concept art in docs/concepts/ was generated with an image model (the prompts are in docs/concepts/prompts*.json, and the images keep their C2PA content credentials); the pixel bitmaps were drawn from it by hand-editing letters in hooks/species/.
New species are welcome: docs/design-brief.md is the brief the current ones were drawn from, and hooks/species/types.ts is the contract a test holds every species to.
MIT
hooks/register.tsx 1139 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Activity, BandMode, LangWanted, LogEntry, Pet, Place, Skin, View } from '../types'
5import {
6 type Care,
7 activityOf,
8 ageText,
9 care,
10 decay,
11 describe,
12 isBusy,
13 moodOf,
14 newPet,
15 nextStageIn,
16 onTool,
17 onToolError,
18 onTurn,
19 pick,
20 revive,
21 short,
22 stageOf,
23} from './pet'
24import { type Icon, type Scene, heightOf, iconFor, render, toCells } from './pixels'
25import { SPECIES, speciesById } from './species'
26import { bar, face, ground, sprite } from './sprites'
27import { type Lang, TEXT, careLine, langOf } from './text'
28
29const PANE = 'pet'
30const STORE_KEY = 'pet'
31const RASTER = 'scene'
32const TICK_MS = 180
33
34const IDLE: Activity = { kind: 'idle', detail: '', since: 0, turnTools: 0 }
35
36const petAtom = atom({ plugin: 'pixipet', key: 'pet' } as const, newPet(0))
37const activityAtom = atom({ plugin: 'pixipet', key: 'activity' } as const, IDLE)
38const logAtom = atom({ plugin: 'pixipet', key: 'log' } as const, [] as LogEntry[])
39const speechAtom = atom({ plugin: 'pixipet', key: 'speech' } as const, null)
40const bandModeAtom = atom({ plugin: 'pixipet', key: 'bandMode' } as const, 'full' as BandMode)
41const skinAtom = atom({ plugin: 'pixipet', key: 'skin' } as const, 'color' as Skin)
42const hasKeysAtom = atom({ plugin: 'pixipet', key: 'hasKeys' } as const, true)
43const DEFAULT_SPECIES = 'mallow'
44const speciesAtom = atom({ plugin: 'pixipet', key: 'species' } as const, DEFAULT_SPECIES)
45const langAtom = atom({ plugin: 'pixipet', key: 'lang' } as const, 'zh' as Lang)
46const hasButtonsAtom = atom({ plugin: 'pixipet', key: 'hasButtons' } as const, true)
47const placeAtom = atom({ plugin: 'pixipet', key: 'place' } as const, 'above' as Place)
48const viewAtom = atom({ plugin: 'pixipet', key: 'view' } as const, 'pet' as View)
49const isConfirmingResetAtom = atom({ plugin: 'pixipet', key: 'isConfirmingReset' } as const, false)
50const langWantedAtom = atom({ plugin: 'pixipet', key: 'langWanted' } as const, 'auto' as LangWanted)
51
52type Engine = EngineInterface
53
54/** iOS system colours, light or dark with the Claude Code theme. */
55type Palette = {
56 pet: string; green: string; red: string; orange: string; teal: string; indigo: string; yellow: string; text: string; sub: string
57 /** Soft backgrounds for the care buttons, one per action. */
58 chip: Record<Care, string>
59}
60
61const CUPERTINO: Record<'light' | 'dark', Palette> = {
62 light: {
63 pet: '#d30f45', green: '#248a3d', red: '#d70015', orange: '#c93400', teal: '#0071a4', indigo: '#6155f5', yellow: '#b25000', text: '#1d1d1f', sub: '#6e6e73',
64 chip: { feed: '#ffe0e6', play: '#dff3d8', clean: '#dbedfc', sleep: '#e7e4ff', heal: '#fff1cc' },
65 },
66 dark: {
67 pet: '#ff375f', green: '#4ad968', red: '#ff666a', orange: '#ffa056', teal: '#00d2e0', indigo: '#6d7cff', yellow: '#ffd600', text: '#f5f5f7', sub: '#98989f',
68 chip: { feed: '#4a2630', play: '#233f29', clean: '#1f3550', sleep: '#302b55', heal: '#4a3c18' },
69 },
70}
71
72// Read once per load: the render hooks run often.
73let cached: Palette | undefined
74
75async function palette($: Engine): Promise<Palette> {
76 if (cached !== undefined) return cached
77 try {
78 cached = String((await $.settings.read()).theme ?? 'dark').includes('light') ? CUPERTINO.light : CUPERTINO.dark
79 } catch {
80 cached = CUPERTINO.dark
81 }
82 return cached
83}
84
85// ---- The animation's own state: module variables, which a reload resets ----
86
87/** What the scene draws from, kept in step with every write below. */
88const snap = { pet: newPet(0), activity: IDLE, skin: 'color' as Skin, species: DEFAULT_SPECIES, lang: 'zh' as Lang }
89
90/** The words in the pet's language. */
91const tx = () => TEXT[snap.lang]
92
93let tick = 0
94/** Where the pet stands, 0 (left) to 1 (right) of whichever scene draws it. */
95let pos = 0.2
96let target = 0.2
97let facing: 'left' | 'right' = 'right'
98
99type Effect = 'hop' | 'eat' | 'heart' | 'done' | 'sweat' | 'flash' | 'star'
100const effects = new Map<Effect, { start: number; until: number }>()
101const HOP = [0, 1, 3, 4, 4, 3, 1, 0]
102
103function play(effect: Effect, ticks: number) {
104 effects.set(effect, { start: tick, until: tick + ticks })
105}
106
107function playing(effect: Effect) {
108 const e = effects.get(effect)
109 return e !== undefined && e.until > tick ? e : undefined
110}
111
112/** Tool calls waiting on the person (a question, a plan), and subagents running. */
113const asking = new Set<string>()
114const agents = new Set<string>()
115
116/** The sites the scene is mounted in: the band's id and size, the pane's size. */
117let band: { id: string; columns: number; rows: number } | null = null
118let pane: { columns: number; rows: number } | null = null
119const sent = new Map<string, string>()
120let refusedAt = -Infinity
121
122// ---- State writes ----
123
124async function changePet($: Engine, fn: (pet: Pet) => Pet): Promise<Pet> {
125 let after: Pet | undefined
126 await update($, petAtom, pet => (after = fn(pet)))
127 snap.pet = after!
128 return after!
129}
130
131async function save($: Engine) {
132 await $.store.set(STORE_KEY, await read($, petAtom))
133}
134
135/** Fills `{feed}`, `{play}`, `{clean}` and `{sleep}` with how to do that right now. */
136async function hints($: Engine, text: string): Promise<string> {
137 if (!text.includes('{')) return text
138 const takesDigits =
139 (await read($, bandModeAtom)) !== 'hidden' &&
140 (await read($, placeAtom)) === 'above' &&
141 (await read($, hasButtonsAtom)) &&
142 (await read($, hasKeysAtom))
143 const digits: Record<Care, string> = { feed: '1', play: '2', clean: '3', sleep: '4', heal: '5' }
144 return text.replace(/\{(feed|play|clean|sleep|heal)\}/g, (_, what: Care) =>
145 tx().how(takesDigits ? digits[what] : null, `/pet ${what}`),
146 )
147}
148
149async function say($: Engine, text: string, seconds = 8) {
150 text = await hints($, text)
151 const now = await $.clock.now()
152 await update($, speechAtom, () => ({ text, until: now + seconds * 1000 }))
153}
154
155async function log($: Engine, text: string) {
156 const at = await $.clock.now()
157 await update($, logAtom, list => [...list, { at, text }].slice(-30))
158}
159
160/**
161 * The status line carries the pet only while the band is hidden: with the
162 * band showing it would say the same thing twice.
163 */
164async function refreshStatus($: Engine) {
165 if ((await read($, bandModeAtom)) !== 'hidden') {
166 $.ui.status(undefined)
167 return
168 }
169 const pet = snap.pet
170 const t = tx()
171 const mood = moodOf(pet, snap.activity.kind)
172 const stats = `${t.short.fullness} ${Math.round(pet.fullness)} ${t.short.happiness} ${Math.round(pet.happiness)} ${t.short.energy} ${Math.round(pet.energy)}`
173 $.ui.status(`${face(mood, 0)} ${pet.name} ${stats} · Claude ${describe(snap.activity, snap.lang)}`)
174}
175
176async function setActivity($: Engine, change: Partial<Activity>) {
177 const now = await $.clock.now()
178 const before = snap.activity
179 let after: Activity | undefined
180 await update($, activityAtom, a => (after = { ...a, ...change, since: change.kind !== a.kind ? now : a.since }))
181 snap.activity = after!
182 if (change.kind !== undefined && change.kind !== before.kind) await refreshStatus($)
183}
184
185/** Applies care, says the answer, plays its animation, and persists. */
186async function doCare($: Engine, what: Care) {
187 const before = snap.pet
188 const cared = care(before, what)
189 // Woken by hand, it stays up a while before dozing off again
190 const pet = before.isAsleep && !cared.pet.isAsleep ? { ...cared.pet, wokeAt: await $.clock.now() } : cared.pet
191 const line = cared.line
192 await changePet($, () => pet)
193 if (pet !== before) {
194 if (what === 'feed') play('eat', 14)
195 if (what === 'play') {
196 play('hop', 16)
197 play('heart', 16)
198 }
199 if (what === 'clean') play('star', 10)
200 if (what === 'heal') play('star', 14)
201 }
202 await say($, careLine(snap.lang, line))
203 await save($)
204 await refreshStatus($)
205}
206
207/** Evolution check after XP changed. */
208async function checkEvolve($: Engine, before: Pet, after: Pet) {
209 const from = stageOf(before.xp)
210 const to = stageOf(after.xp)
211 if (from === to) return
212 const text = from === 'egg' ? tx().hatched(after.name) : tx().grew(after.name, tx().stage[to])
213 $.ui.toast(text, { timeoutMs: 6000 })
214 play('flash', 14)
215 play('star', 20)
216 await log($, text)
217 await say($, from === 'egg' ? tx().hello(after.name) : tx().grownUp, 12)
218 await save($)
219}
220
221// ---- The scene ----
222
223function scene(w: number, height: number, hasGround: boolean): Scene {
224 const pet = snap.pet
225 const kind = snap.activity.kind
226 const stage = stageOf(pet.xp)
227 const mood = moodOf(pet, kind)
228 const margin = 7
229 // The poop piles sit at the right edge: the pet keeps clear of them
230 const piles = pet.poop > 0 ? 3 + pet.poop * 7 : 0
231 const x = Math.round(margin + pos * Math.max(0, w - piles - 2 * margin))
232 const hop = playing('hop')
233 const room = Math.max(0, height - (hasGround ? 2 : 1) - heightOf(stage, snap.species))
234 const lift = hop ? Math.min(room, HOP[(tick - hop.start) % HOP.length] ?? 0) : 0
235 const eating = playing('eat') !== undefined
236
237 let icon: Icon | null = null
238 if (asking.size > 0) icon = 'alert'
239 else if (playing('heart')) icon = 'heart'
240 else if (playing('star')) icon = 'star'
241 else if (playing('done')) icon = 'done'
242 else if (eating) icon = 'berry'
243 else if (isBusy(kind)) icon = iconFor(kind)
244 else if (mood === 'hungry') icon = 'berry'
245 else if (mood === 'sick') icon = 'sick'
246 if (stage === 'egg' && icon !== 'alert') icon = isBusy(kind) ? iconFor(kind) : null
247
248 return {
249 stage,
250 mood: eating ? 'happy' : mood,
251 tick,
252 x,
253 ground: hasGround ? height - 2 : height - 1,
254 lift,
255 facing,
256 isWalking: Math.abs(target - pos) > 0.01 && lift === 0,
257 isEating: eating,
258 xp: pet.xp,
259 isFlashing: playing('flash') !== undefined,
260 species: snap.species,
261 skin: snap.skin,
262 poop: pet.poop,
263 icon,
264 isAlert: icon === 'alert',
265 agents: agents.size,
266 isSweating: playing('sweat') !== undefined,
267 hasGround,
268 }
269}
270
271/** Moves the pet: it wanders while Claude is idle and goes home while Claude works. */
272function wander() {
273 tick += 1
274 const pet = snap.pet
275 if (stageOf(pet.xp) === 'egg' || pet.isAsleep || playing('eat')) return
276 if (isBusy(snap.activity.kind) || asking.size > 0) target = 0.15
277 else if (Math.random() < 0.015) target = Math.random()
278 const d = target - pos
279 if (Math.abs(d) > 0.01) {
280 facing = d < 0 ? 'left' : 'right'
281 pos += Math.sign(d) * Math.min(Math.abs(d), 0.012)
282 }
283}
284
285function cellsFor(columns: number, rows: number, hasGround: boolean) {
286 return toCells(render(columns, rows * 2, scene(columns, rows * 2, hasGround)))
287}
288
289/** Sends a frame only when it differs from the last one the site has. */
290function blit($: Engine, requestId: string, columns: number, rows: number, cells: string) {
291 if (sent.get(requestId) === cells) return
292 sent.set(requestId, cells)
293 void $.ui.blit({ requestId, key: RASTER, columns, rows, cells }).then(result => {
294 if (result.deny === undefined) return
295 sent.delete(requestId)
296 if (requestId === PANE) pane = null
297 else band = null
298 // A resize remounts the site: ask for a redraw, at most once a second
299 if (tick - refusedAt < 6) return
300 refusedAt = tick
301 $.ui.invalidate('ui.render')
302 })
303}
304
305function paint($: Engine) {
306 if (band !== null) blit($, band.id, band.columns, band.rows, cellsFor(band.columns, band.rows, false))
307 if (pane !== null) blit($, PANE, pane.columns, pane.rows, cellsFor(pane.columns, pane.rows, true))
308}
309
310type Elements = ReturnType<EngineInterface['ui']['resolve']>
311
312/**
313 * The four care actions as chips: a soft colour each, the hotkey in the
314 * accent colour, pink under the pointer. A keyed Box per chip scopes its hover.
315 */
316function careButtons(
317 Box: Elements['Box'],
318 Button: Elements['Button'],
319 c: Palette,
320 t: (typeof TEXT)[Lang],
321 pet: Pet,
322 hotkey: (what: Care) => { hotkey?: string },
323 onPress: (what: Care) => void,
324) {
325 const chips: [Care, string][] = [
326 ['feed', t.feed],
327 ['play', t.play],
328 ['clean', t.clean],
329 ['sleep', pet.isAsleep ? t.wake : t.sleep],
330 ]
331 // Medicine only while it is sick
332 if (pet.isSick) chips.push(['heal', t.heal])
333 return (
334 <Box flexDirection="row" gap={1} flexWrap="wrap">
335 {chips.map(([what, label]) => (
336 <Box key={`care-${what}`} backgroundColor={c.chip[what]} paddingX={1}>
337 <Button key={what} plain hover={{ color: c.pet }} {...hotkey(what)} label={label} onPress={() => onPress(what)} />
338 </Box>
339 ))}
340 </Box>
341 )
342}
343
344type PaneSite = Parameters<EngineInterface['ui']['resolve']>[0]
345
346/**
347 * The settings page: each setting a row of choices, the current one marked
348 * and tinted; the species a picker; the name a text field; a reset that asks
349 * twice. Every choice applies at once and is kept between sessions.
350 */
351async function drawSettings($: Engine, e: PaneSite, cols: number, c: Palette) {
352 const t = tx()
353 const o = t.settings
354 const els = $.ui.resolve(e)
355 const { Box, Text, Button } = els
356 const [band, place, hasButtons, hasKeys, skin, species, langWanted, isConfirming] = await Promise.all([
357 read($, bandModeAtom),
358 read($, placeAtom),
359 read($, hasButtonsAtom),
360 read($, hasKeysAtom),
361 read($, skinAtom),
362 read($, speciesAtom),
363 read($, langWantedAtom),
364 read($, isConfirmingResetAtom),
365 ])
366
367 // One row: the label, then a chip per choice
368 const row = <T extends string | boolean>(
369 key: string,
370 label: string,
371 choices: readonly (readonly [T, string])[],
372 current: T,
373 pick: (value: T) => unknown,
374 ) => (
375 <Box key={`row-${key}`} flexDirection="row">
376 <Box width={9} flexShrink={0}>
377 <Text color={c.sub}>{label}</Text>
378 </Box>
379 <Box flexDirection="row" gap={1} flexWrap="wrap">
380 {choices.map(([value, name]) => {
381 const isOn = value === current
382 return (
383 <Box key={`${key}-${String(value)}`} backgroundColor={isOn ? c.chip.sleep : undefined} paddingX={1}>
384 <Button
385 key={`set-${key}-${String(value)}`}
386 plain
387 dimColor={!isOn}
388 hover={{ color: c.pet }}
389 label={`${isOn ? '●' : '○'} ${name}`}
390 onPress={() => pick(value)}
391 />
392 </Box>
393 )
394 })}
395 </Box>
396 </Box>
397 )
398
399 const speciesRow =
400 'Select' in els ? (
401 <Box key="row-species" flexDirection="row">
402 <Box width={9} flexShrink={0}>
403 <Text color={c.sub}>{o.species}</Text>
404 </Box>
405 <els.Select
406 key="set-species"
407 value={species}
408 options={speciesIds().map(id => ({ value: id, label: speciesName(id) }))}
409 onSelect={id => setSpecies($, id)}
410 />
411 </Box>
412 ) : (
413 row('species', o.species, speciesIds().map(id => [id, speciesName(id)] as const), species, id => setSpecies($, id))
414 )
415
416 const nameRow =
417 'Input' in els ? (
418 <Box key="row-name" flexDirection="row">
419 <Box width={9} flexShrink={0}>
420 <Text color={c.sub}>{o.name}</Text>
421 </Box>
422 <els.Input
423 key="set-name"
424 value={snap.pet.name}
425 placeholder={o.namePlaceholder}
426 onSubmit={value => {
427 const name = short(value, 12)
428 if (name !== '') return rename($, name)
429 }}
430 />
431 </Box>
432 ) : null
433
434 return (
435 <Box flexDirection="column" width={cols} gap={0}>
436 <Box justifyContent="space-between" marginBottom={1}>
437 <Text bold color={c.pet}>
438 {o.title}
439 </Text>
440 <Box key="back-to-pet">
441 <Button
442 key="back"
443 plain
444 hotkey="b"
445 hover={{ color: c.pet }}
446 label={o.back}
447 onPress={async () => {
448 await update($, isConfirmingResetAtom, () => false)
449 await update($, viewAtom, () => 'pet')
450 }}
451 />
452 </Box>
453 </Box>
454 {speciesRow}
455 {row('place', o.place, [['above', o.above], ['below', o.below]] as const, place, v => setPlace($, v))}
456 {row('band', o.band, [['full', o.full], ['mini', o.mini], ['hidden', o.hidden]] as const, band, v => setBand($, v))}
457 {row('buttons', o.buttons, [[true, o.show], [false, o.hide]] as const, hasButtons, v => setButtons($, v))}
458 {row('keys', o.keys, [[true, o.enabled], [false, o.disabled]] as const, hasKeys, v => setKeys($, v))}
459 {row('skin', o.skin, [['color', o.color], ['lcd', o.lcd]] as const, skin, v => setSkin($, v))}
460 {row('lang', o.lang, [['zh', '中文'], ['en', 'English'], ['auto', o.auto]] as const, langWanted, v => setLang($, v))}
461 {nameRow}
462 <Box key="row-reset" flexDirection="row" marginTop={1}>
463 <Box width={9} flexShrink={0}>
464 <Text color={c.sub}>{o.reset}</Text>
465 </Box>
466 {isConfirming ? (
467 <Box flexDirection="row" gap={1}>
468 <Text color={c.red}>{o.resetSure} </Text>
469 <Box key="reset-yes" backgroundColor={c.chip.feed} paddingX={1}>
470 <Button
471 key="reset-confirm"
472 plain
473 hover={{ color: c.red }}
474 label={o.resetYes}
475 onPress={async () => {
476 await resetPet($)
477 await update($, isConfirmingResetAtom, () => false)
478 await update($, viewAtom, () => 'pet')
479 }}
480 />
481 </Box>
482 <Box key="reset-no" paddingX={1}>
483 <Button key="reset-cancel" plain label={o.cancel} onPress={() => update($, isConfirmingResetAtom, () => false)} />
484 </Box>
485 </Box>
486 ) : (
487 <Box key="reset-ask" paddingX={1}>
488 <Button key="reset" plain dimColor hover={{ color: c.red }} label={o.resetAsk} onPress={() => update($, isConfirmingResetAtom, () => true)} />
489 </Box>
490 )}
491 </Box>
492 <Text dimColor wrap="wrap">
493 {o.hint}
494 </Text>
495 </Box>
496 )
497}
498
499/** A species' display name in the pet's language. */
500function speciesName(id: string): string {
501 if (id === 'mochi') return tx().mochi.name
502 const sp = speciesById(id)
503 if (sp === undefined) return id
504 return snap.lang === 'zh' ? sp.name : (sp.nameEn ?? sp.id[0]!.toUpperCase() + sp.id.slice(1))
505}
506
507function speciesBlurb(id: string): string {
508 const sp = speciesById(id)
509 return sp === undefined ? '' : snap.lang === 'zh' ? sp.description : (sp.descriptionEn ?? sp.description)
510}
511
512/**
513 * The language: `zh` or `en` as the person chose it, or for `auto` (and
514 * nothing chosen) Claude Code's `language` setting, then English.
515 */
516async function resolveLang($: Engine, wanted: unknown): Promise<Lang> {
517 if (wanted === 'zh' || wanted === 'en') return wanted
518 try {
519 const fromSettings = langOf(String((await $.settings.read()).language ?? ''))
520 if (fromSettings !== undefined) return fromSettings
521 } catch {
522 // No settings to read: English
523 }
524 return 'en'
525}
526
527// ---- Settings: one function each, shared by the commands and the settings view ----
528
529const BAND_MODES: readonly BandMode[] = ['full', 'mini', 'hidden']
530const PLACES: readonly Place[] = ['above', 'below']
531const SKINS: readonly Skin[] = ['color', 'lcd']
532
533/** Every species id, the default first and mochi last. */
534function speciesIds(): string[] {
535 return [DEFAULT_SPECIES, ...SPECIES.map(sp => sp.id).filter(id => id !== DEFAULT_SPECIES), 'mochi']
536}
537
538async function setBand($: Engine, mode: BandMode) {
539 await update($, bandModeAtom, () => mode)
540 await $.store.set('bandMode', mode)
541 if (mode !== 'full') band = null
542 await refreshStatus($)
543 return mode
544}
545
546async function setPlace($: Engine, place: Place) {
547 await update($, placeAtom, () => place)
548 await $.store.set('place', place)
549 band = null
550 sent.clear()
551 return place
552}
553
554async function setButtons($: Engine, isOn: boolean) {
555 await update($, hasButtonsAtom, () => isOn)
556 await $.store.set('hasButtons', isOn)
557 return isOn
558}
559
560async function setKeys($: Engine, isOn: boolean) {
561 await update($, hasKeysAtom, () => isOn)
562 await $.store.set('hasKeys', isOn)
563 return isOn
564}
565
566async function setSkin($: Engine, skin: Skin) {
567 await update($, skinAtom, () => skin)
568 snap.skin = skin
569 await $.store.set('skin', skin)
570 sent.clear()
571 return skin
572}
573
574async function setSpecies($: Engine, id: string) {
575 await update($, speciesAtom, () => id)
576 snap.species = id
577 await $.store.set('species', id)
578 sent.clear()
579 return id
580}
581
582/** `auto` follows Claude Code's language setting, else English. */
583async function setLang($: Engine, wanted: 'zh' | 'en' | 'auto') {
584 await $.store.set('lang', wanted)
585 await update($, langWantedAtom, () => wanted)
586 snap.lang = await resolveLang($, wanted)
587 await update($, langAtom, () => snap.lang)
588 await $.command.register({ name: 'pet', description: tx().command })
589 // Retitle the pane only if it is open; never open it unasked
590 if (pane !== null) {
591 try {
592 await $.ui.open({ id: PANE, title: tx().paneTitle })
593 } catch {
594 // A retitle refused leaves the old title: nothing to undo
595 }
596 }
597 await refreshStatus($)
598 return snap.lang
599}
600
601async function rename($: Engine, name: string) {
602 await changePet($, p => ({ ...p, name }))
603 await save($)
604 await say($, tx().myName(name))
605 await refreshStatus($)
606}
607
608async function resetPet($: Engine) {
609 const now = await $.clock.now()
610 await changePet($, p => newPet(now, p.name))
611 await update($, logAtom, () => [])
612 await save($)
613 await log($, tx().newEgg)
614 await refreshStatus($)
615}
616
617export const register: Register = on => {
618 on('session.start', async ($, e, next) => {
619 const now = await $.clock.now()
620 // A hot reload keeps $.state; a new session starts from the store.
621 const held = await $.state.get({ plugin: 'pixipet', key: 'pet' })
622 const saved = await $.store.get(STORE_KEY)
623 // The choice is stored from the first session on: a pet saved with no
624 // choice beside it was raised before the pet spoke English, in Chinese
625 let wanted = await $.store.get('lang')
626 if (wanted === undefined) {
627 wanted = saved !== undefined ? 'zh' : 'auto'
628 await $.store.set('lang', wanted)
629 }
630 snap.lang = await resolveLang($, wanted)
631 await update($, langAtom, () => snap.lang)
632 if (wanted === 'zh' || wanted === 'en' || wanted === 'auto') await update($, langWantedAtom, () => wanted)
633 const pet =
634 held.value !== undefined ? decay(held.value, now) : decay(revive(saved, now, tx().defaultName), now, true)
635 await changePet($, () => pet)
636 await setActivity($, { ...IDLE, since: now })
637 if (held.value === undefined) {
638 await log($, tx().wokeUp(pet.name))
639 const mode = await $.store.get('bandMode')
640 if (mode === 'full' || mode === 'mini' || mode === 'hidden') await update($, bandModeAtom, () => mode)
641 const skin = await $.store.get('skin')
642 if (skin === 'color' || skin === 'lcd') await update($, skinAtom, () => skin)
643 const species = await $.store.get('species')
644 if (typeof species === 'string' && (species === 'mochi' || speciesById(species))) {
645 await update($, speciesAtom, () => species)
646 }
647 const place = await $.store.get('place')
648 if (place === 'above' || place === 'below') await update($, placeAtom, () => place)
649 const hasButtons = await $.store.get('hasButtons')
650 if (typeof hasButtons === 'boolean') await update($, hasButtonsAtom, () => hasButtons)
651 const hasKeys = await $.store.get('hasKeys')
652 if (typeof hasKeys === 'boolean') await update($, hasKeysAtom, () => hasKeys)
653 }
654 snap.skin = await read($, skinAtom)
655 snap.species = await read($, speciesAtom)
656 await save($)
657
658 await $.command.register({ name: 'pet', description: tx().command })
659
660 // The animation: frames go straight to the Rasters, no redraw
661 $.clock.every(TICK_MS, () => {
662 wander()
663 paint($)
664 })
665
666 // Life: decay once a minute, chatter now and then while Claude is idle.
667 $.clock.every(60_000, async () => {
668 const t = await $.clock.now()
669 const before = snap.pet
670 const after = await changePet($, p => decay(p, t))
671 const kind = snap.activity.kind
672 const mood = moodOf(after, kind)
673 if (after.poop > before.poop) await log($, tx().pooped(after.name))
674 if (after.isAsleep && !before.isAsleep) {
675 await log($, tx().dozedOff(after.name))
676 await say($, pick(tx().dozing), 10)
677 } else if (!after.isAsleep && before.isAsleep) {
678 await log($, tx().rested(after.name))
679 await say($, pick(tx().morning), 10)
680 play('hop', 8)
681 }
682 if (mood === 'hungry' && moodOf(before, kind) !== 'hungry') {
683 $.ui.toast(await hints($, tx().hungry(after.name)))
684 }
685 const speech = await read($, speechAtom)
686 if (!isBusy(kind) && (speech === null || speech.until < t) && Math.random() < 0.3) {
687 await say($, tx().chatter(after, mood, new Date(t).getHours()), 15)
688 }
689 await save($)
690 await refreshStatus($)
691 })
692
693 await refreshStatus($)
694 // After a reload the sites were drawn by the old module: draw them again to learn their ids
695 $.ui.invalidate('ui.render')
696 return next(e)
697 })
698
699 on('session.end', async ($, e, next) => {
700 await save($)
701 return next(e)
702 })
703
704 // ---- Claude linkage: what is Claude doing right now? ----
705
706 on('turn.start', async ($, e, next) => {
707 // A subagent's run raises no turn.start; this is the main loop.
708 await setActivity($, { kind: 'thinking', detail: '', turnTools: 0 })
709 return next(e)
710 })
711
712 on('tool.call', async ($, e, next) => {
713 const tool = String(e.tool)
714 const { kind, detail } = activityOf(tool, e as unknown as Record<string, unknown>)
715 const id = e.tool_use_id
716 const isMain = e.agentId === undefined
717 // A subagent's tools count for the pet but do not take over the headline.
718 if (isMain) await setActivity($, { kind, detail, turnTools: snap.activity.turnTools + 1 })
719 if (isMain && (kind === 'asking' || kind === 'approving')) asking.add(id)
720 // A subagent shows from its first tool call until its turn ends
721 if (!isMain) agents.add(e.agentId)
722
723 const before = snap.pet
724 const after = await changePet($, onTool)
725 await checkEvolve($, before, after)
726
727 try {
728 const ran = await next(e)
729 if (ran.deny === undefined && ran.isError === true) {
730 await changePet($, onToolError)
731 play('sweat', 14)
732 if (isMain) await log($, tx().toolFailed(tool, after.name))
733 }
734 return ran
735 } finally {
736 asking.delete(id)
737 }
738 })
739
740 on('turn.complete', async ($, e, next) => {
741 if (e.agentId !== undefined) agents.delete(e.agentId)
742 if (e.agentId === undefined) {
743 const a = snap.activity
744 const before = snap.pet
745 const after = await changePet($, p => onTurn(p, e.reason))
746 await checkEvolve($, before, after)
747 const secs = Math.round(e.durationMs / 1000)
748 asking.clear()
749 if (e.reason === 'answer') {
750 await setActivity($, { kind: 'done', detail: '' })
751 play('done', 20)
752 play('hop', 8)
753 await log($, tx().turnDone(secs, a.turnTools))
754 if (a.turnTools >= 5 || secs >= 60) {
755 await say($, tx().longTurn(secs, a.turnTools))
756 } else if (!after.isAsleep) {
757 await say($, tx().okay, 5)
758 }
759 } else if (e.reason === 'aborted') {
760 await setActivity($, { kind: 'idle', detail: '' })
761 await log($, tx().aborted)
762 } else {
763 await setActivity($, { kind: 'error', detail: '' })
764 play('sweat', 20)
765 await log($, tx().turnError)
766 await say($, tx().cheer)
767 }
768 await save($)
769 }
770 return next(e)
771 })
772
773 on('prompt.submit', async ($, e, next) => {
774 // The pet notices you talking to Claude.
775 if (snap.pet.isAsleep && Math.random() < 0.2) await say($, tx().rolledOver, 4)
776 return next(e)
777 })
778
779 on('ui.close', async ($, e, next) => {
780 if (e.id === PANE) {
781 pane = null
782 sent.delete(PANE)
783 }
784 return next(e)
785 })
786
787 // ---- Commands ----
788
789 on('command.run', { command: 'pet' }, async ($, e) => {
790 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
791 const asked = rest[0]
792 const cares: Record<string, Care> = {
793 feed: 'feed',
794 play: 'play',
795 clean: 'clean',
796 sleep: 'sleep',
797 wake: 'sleep',
798 heal: 'heal',
799 medicine: 'heal',
800 喂: 'feed',
801 玩: 'play',
802 洗: 'clean',
803 睡: 'sleep',
804 药: 'heal',
805 }
806 const what = cares[verb]
807 if (what !== undefined) {
808 await doCare($, what)
809 const speech = await read($, speechAtom)
810 return { text: tx().says(snap.pet.name, speech?.text ?? '') }
811 }
812 // A setting named without a value turns to the next one
813 const following = <T,>(order: readonly T[], now: T, value: unknown): T =>
814 order.includes(value as T) ? (value as T) : (order[(order.indexOf(now) + 1) % order.length] as T)
815 const onOff = (value: unknown, now: boolean) => (value === 'on' ? true : value === 'off' ? false : !now)
816 switch (verb) {
817 case 'name': {
818 const name = short(rest.join(' '), 12)
819 if (name === '') return { text: tx().nameUsage }
820 await rename($, name)
821 return { text: tx().renamed(name) }
822 }
823 case 'band':
824 return { text: tx().band(await setBand($, following(BAND_MODES, await read($, bandModeAtom), asked))) }
825 case 'place':
826 return { text: tx().place(await setPlace($, following(PLACES, await read($, placeAtom), asked))) }
827 case 'buttons':
828 return { text: tx().buttons(await setButtons($, onOff(asked, await read($, hasButtonsAtom)))) }
829 case 'keys':
830 return { text: tx().keys(await setKeys($, onOff(asked, await read($, hasKeysAtom)))) }
831 case 'skin':
832 return { text: tx().skin(await setSkin($, following(SKINS, await read($, skinAtom), asked))) }
833 case 'lang':
834 if (asked !== 'zh' && asked !== 'en' && asked !== 'auto') return { text: tx().langUsage }
835 return { text: tx().lang(await setLang($, asked)) }
836 case 'species': {
837 if (asked === undefined || !speciesIds().includes(asked)) {
838 const t = tx()
839 return { text: t.speciesUsage(speciesIds().map(id => t.speciesLine(id, speciesName(id), speciesBlurb(id), id === DEFAULT_SPECIES))) }
840 }
841 await setSpecies($, asked)
842 return { text: tx().became(snap.pet.name, speciesName(asked)) }
843 }
844 case 'reset':
845 if (asked !== 'confirm') return { text: tx().resetConfirm }
846 await resetPet($)
847 return { text: tx().eggReady }
848 case 'status':
849 return { text: tx().status(snap.pet, tx().stage[stageOf(snap.pet.xp)], describe(snap.activity, snap.lang)) }
850 }
851 // /pet opens the pane: care on its front, every setting behind ⚙ (/pet settings opens there)
852 await update($, viewAtom, () => (verb === 'settings' ? 'settings' : 'pet'))
853 await update($, isConfirmingResetAtom, () => false)
854 const opened = await $.ui.open({ id: PANE, title: tx().paneTitle, focus: true })
855 if (!opened.isPlaced) return { text: tx().tooNarrow }
856 return {}
857 })
858
859 // ---- Drawing ----
860
861 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
862 const pet = await read($, petAtom)
863 const activity = await read($, activityAtom)
864 const speech = await read($, speechAtom)
865 const logs = await read($, logAtom)
866 snap.skin = await read($, skinAtom)
867 snap.species = await read($, speciesAtom)
868 snap.lang = await read($, langAtom)
869 const t = TEXT[snap.lang]
870 snap.pet = pet
871 snap.activity = activity
872 const now = await $.clock.now()
873 const c = await palette($)
874
875 const stage = stageOf(pet.xp)
876 const mood = moodOf(pet, activity.kind)
877 const toNext = nextStageIn(pet.xp)
878 const busy = isBusy(activity.kind)
879 const cols = Math.min(e.props.bodyColumns, 48)
880 const spoken = speech !== null && speech.until > now ? speech.text : ''
881 const room = Math.max(0, (e.viewport?.rows ?? 30) - 28)
882
883 const { Box, Text, Button } = $.ui.resolve(e)
884 if ((await read($, viewAtom)) === 'settings') {
885 pane = null
886 return await drawSettings($, e, cols, c)
887 }
888 let picture
889 if (e.surface === 'terminal') {
890 const { Raster } = $.ui.resolve(e)
891 const rows = 12
892 pane = { columns: cols, rows }
893 const cells = cellsFor(cols, rows, true)
894 sent.set(PANE, cells)
895 picture = <Raster key={RASTER} columns={cols} rows={rows} cells={cells} />
896 } else {
897 pane = null
898 const [stink, piles] = ground(pet.poop, 0)
899 picture = (
900 <Box flexDirection="row">
901 <Box flexDirection="column">
902 {sprite(stage, mood, 0).map((l, i) => (
903 <Text key={`s${i}`} color={mood === 'sick' ? c.green : c.text}>
904 {l}
905 </Text>
906 ))}
907 </Box>
908 <Box flexDirection="column" justifyContent="flex-end">
909 <Text color={c.sub}>{stink}</Text>
910 <Text color={c.yellow}>{piles}</Text>
911 </Box>
912 </Box>
913 )
914 }
915
916 return (
917 <Box flexDirection="column" width={cols}>
918 <Box justifyContent="space-between">
919 <Text bold color={c.pet}>
920 {pet.name}
921 <Text dimColor>
922 {' '}
923 · {t.stage[stage]} · {ageText(pet, now, snap.lang)}
924 </Text>
925 </Text>
926 <Box key="open-settings">
927 <Button
928 key="settings"
929 plain
930 hotkey="o"
931 hover={{ color: c.pet }}
932 label={t.settings.open}
933 onPress={() => update($, viewAtom, () => 'settings')}
934 />
935 </Box>
936 </Box>
937 <Text color={c.yellow} wrap="truncate">
938 {spoken ? `「${short(spoken, cols - 4)}」` : ' '}
939 </Text>
940 {picture}
941
942 <Box flexDirection="column" marginTop={1}>
943 <Text>
944 {t.fullness} <Text color={pet.fullness < 25 ? c.red : c.green}>{bar(pet.fullness, 10)}</Text> {Math.round(pet.fullness)}
945 </Text>
946 <Text>
947 {t.happiness} <Text color={pet.happiness < 30 ? c.red : c.pet}>{bar(pet.happiness, 10)}</Text>{' '}
948 {Math.round(pet.happiness)}
949 </Text>
950 <Text>
951 {t.energy} <Text color={pet.energy < 15 ? c.red : c.teal}>{bar(pet.energy, 10)}</Text> {Math.round(pet.energy)}
952 </Text>
953 <Text dimColor>
954 {t.xp} {pet.xp}
955 {toNext === null ? t.grown : t.toGrow(toNext)}
956 </Text>
957 </Box>
958
959 <Box flexDirection="column" marginTop={1}>
960 <Text bold>{t.doing}</Text>
961 <Text color={activity.kind === 'asking' || activity.kind === 'approving' ? c.red : busy ? c.teal : activity.kind === 'error' ? c.red : c.sub} wrap="wrap">
962 {busy ? '● ' : '○ '}
963 {describe(activity, snap.lang)}
964 {busy ? t.turnTools(activity.turnTools) : ''}
965 </Text>
966 </Box>
967
968 {logs.length > 0 && room > 0 && (
969 <Box flexDirection="column" marginTop={1}>
970 <Text bold>{t.log}</Text>
971 {logs.slice(-Math.min(room, 5)).map((l, i) => (
972 <Text key={`l${i}`} dimColor wrap="truncate">
973 {clock(l.at)} {l.text}
974 </Text>
975 ))}
976 </Box>
977 )}
978
979 <Box marginTop={1}>
980 {careButtons(Box, Button, c, t, pet, what => ({ hotkey: what[0]! }), what => doCare($, what))}
981 </Box>
982 {e.surface === 'terminal' && (
983 <Text dimColor wrap="truncate">
984 {e.props.isFocused
985 ? t.focused
986 : t.unfocused}
987 </Text>
988 )}
989 </Box>
990 )
991 })
992
993 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
994 if (e.props.hasSurvey || (await read($, placeAtom)) !== 'above') return next(e)
995 const theirs = await next(e)
996 const ours = await drawBand($, e, e.props.bodyColumns, e.props.maxRows, true)
997 if (ours === undefined) return theirs
998 // Share the band with any other mod drawing there
999 if (!theirs) return ours
1000 const { Box } = $.ui.resolve(e)
1001 return (
1002 <Box flexDirection="column">
1003 {theirs}
1004 {ours}
1005 </Box>
1006 )
1007 })
1008
1009 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
1010 if ((await read($, placeAtom)) !== 'below') return next(e)
1011 const theirs = await next(e)
1012 // The hint line has no measure of its own: the surface's width, less a margin
1013 const ours = await drawBand($, e, Math.max(20, (e.viewport?.columns ?? 80) - 6), 12, false)
1014 if (ours === undefined) return theirs
1015 const { Box } = $.ui.resolve(e)
1016 return (
1017 <Box flexDirection="column">
1018 {ours}
1019 {theirs}
1020 </Box>
1021 )
1022 })
1023}
1024
1025type BandSite = Parameters<EngineInterface['ui']['resolve']>[0] & { requestId: string }
1026
1027/**
1028 * The pet's band, above the prompt or under it: the pixel scene with the
1029 * stats beside it, or one line of stats with the buttons under it. Digits
1030 * press the buttons only above the prompt, where the engine arms them.
1031 */
1032async function drawBand($: Engine, e: BandSite, width: number, maxRows: number, takesDigits: boolean) {
1033 const mode = await read($, bandModeAtom)
1034 if (mode === 'hidden') {
1035 band = null
1036 return undefined
1037 }
1038 const pet = await read($, petAtom)
1039 const activity = await read($, activityAtom)
1040 const speech = await read($, speechAtom)
1041 snap.skin = await read($, skinAtom)
1042 snap.species = await read($, speciesAtom)
1043 snap.lang = await read($, langAtom)
1044 const t = TEXT[snap.lang]
1045 snap.pet = pet
1046 snap.activity = activity
1047 const now = await $.clock.now()
1048 const c = await palette($)
1049 const mood = moodOf(pet, activity.kind)
1050 const busy = isBusy(activity.kind)
1051 const spoken = speech !== null && speech.until > now ? `「${speech.text}」` : ''
1052 const toNext = nextStageIn(pet.xp)
1053 const rows = 8
1054 const isFull = mode === 'full' && e.surface === 'terminal' && maxRows >= rows && width >= 64
1055
1056 const hasKeys = takesDigits && (await read($, hasKeysAtom))
1057 const hasButtons = await read($, hasButtonsAtom)
1058 const { Box, Text, Button } = $.ui.resolve(e)
1059 const actColor = activity.kind === 'asking' || activity.kind === 'approving' ? c.red : busy ? c.teal : c.sub
1060
1061 // A bare digit in an empty prompt presses a band Button: care without leaving the prompt
1062 const digits: Record<Care, string> = { feed: '1', play: '2', clean: '3', sleep: '4', heal: '5' }
1063 const buttons = careButtons(Box, Button, c, t, pet, what => (hasKeys ? { hotkey: digits[what] } : {}), what =>
1064 doCare($, what),
1065 )
1066
1067 if (isFull && e.surface === 'terminal') {
1068 const { Raster } = $.ui.resolve(e)
1069 const columns = width >= 90 ? 44 : Math.max(26, width - 46)
1070 band = { id: e.requestId, columns, rows }
1071 const cells = cellsFor(columns, rows, false)
1072 sent.set(e.requestId, cells)
1073 return (
1074 <Box flexDirection="row">
1075 <Raster key={RASTER} columns={columns} rows={rows} cells={cells} />
1076 <Box flexDirection="column" justifyContent="flex-end" paddingLeft={1} width={Math.min(52, width - columns - 1)}>
1077 <Text wrap="truncate">
1078 <Text bold color={c.pet}>
1079 {pet.name}
1080 </Text>
1081 <Text color={c.sub}>
1082 {' '}
1083 · {t.stage[stageOf(pet.xp)]} · {t.xp} {pet.xp}
1084 {toNext === null ? '' : `/${pet.xp + toNext}`}
1085 </Text>
1086 </Text>
1087 <Text wrap="truncate">
1088 <Text color={c.sub}>{t.short.fullness} </Text>
1089 <Text color={pet.fullness < 25 ? c.red : c.green}>{bar(pet.fullness)}</Text>
1090 <Text color={c.sub}> {t.short.happiness} </Text>
1091 <Text color={pet.happiness < 30 ? c.red : c.pet}>{bar(pet.happiness)}</Text>
1092 <Text color={c.sub}> {t.short.energy} </Text>
1093 <Text color={pet.energy < 15 ? c.red : c.teal}>{bar(pet.energy)}</Text>
1094 </Text>
1095 <Text color={actColor} wrap="wrap">
1096 {busy ? '● ' : '○ '}Claude {describe(activity, snap.lang)}
1097 {busy && activity.turnTools > 0 ? t.tools(activity.turnTools) : ''}
1098 </Text>
1099 <Text color={c.yellow} wrap="wrap">
1100 {spoken || ' '}
1101 </Text>
1102 {hasButtons && buttons}
1103 </Box>
1104 </Box>
1105 )
1106 }
1107
1108 // One line of stats that never gives way, Claude's activity after it, the buttons under it
1109 band = null
1110 return (
1111 <Box flexDirection="column" width={width}>
1112 <Box flexDirection="row">
1113 <Box flexShrink={0} flexDirection="row">
1114 <Text color={c.pet} bold>
1115 {stageOf(pet.xp) === 'egg' ? '( . )' : face(mood, 0)}{' '}
1116 </Text>
1117 <Text>{pet.name} </Text>
1118 <Text color={c.sub}>{t.short.fullness}</Text>
1119 <Text color={pet.fullness < 25 ? c.red : c.green}>{bar(pet.fullness, 5)} </Text>
1120 <Text color={c.sub}>{t.short.happiness}</Text>
1121 <Text color={pet.happiness < 30 ? c.red : c.pet}>{bar(pet.happiness, 5)} </Text>
1122 <Text color={c.sub}>{t.short.energy}</Text>
1123 <Text color={pet.energy < 15 ? c.red : c.teal}>{bar(pet.energy, 5)} </Text>
1124 {pet.poop > 0 && <Text color={c.yellow}>{'@'.repeat(pet.poop)} </Text>}
1125 </Box>
1126 <Text color={spoken ? c.yellow : actColor} wrap="truncate">
1127 {spoken || `│ Claude ${describe(activity, snap.lang)}`}
1128 </Text>
1129 </Box>
1130 {hasButtons && buttons}
1131 </Box>
1132 )
1133}
1134
1135function clock(at: number): string {
1136 const d = new Date(at)
1137 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
1138}
1139hooks/pet.ts 235 lines1// Pure pet logic: no `$`, so the tests can call it directly.
2import type { Activity, ActivityKind, Pet, Stage } from '../types'
3import { type Lang, type Line, TEXT } from './text'
4
5export const MINUTE = 60_000
6
7/** XP needed to reach each stage. */
8const STAGES: readonly (readonly [Stage, number])[] = [
9 ['egg', 0],
10 ['baby', 10],
11 ['child', 60],
12 ['teen', 200],
13 ['adult', 500],
14]
15
16export function stageOf(xp: number): Stage {
17 let stage: Stage = 'egg'
18 for (const [name, need] of STAGES) if (xp >= need) stage = name
19 return stage
20}
21
22/** XP still needed for the next stage, or null at the last one. */
23export function nextStageIn(xp: number): number | null {
24 for (const [, need] of STAGES) if (xp < need) return need - xp
25 return null
26}
27
28const clamp = (n: number) => Math.max(0, Math.min(100, n))
29
30export function newPet(now: number, name = TEXT.zh.defaultName): Pet {
31 return {
32 name,
33 bornAt: now,
34 xp: 0,
35 fullness: 80,
36 happiness: 80,
37 energy: 90,
38 poop: 0,
39 isAsleep: false,
40 isSick: false,
41 wokeAt: 0,
42 lastTickAt: now,
43 toolsSeen: 0,
44 turnsSeen: 0,
45 }
46}
47
48/** Fills fields an older saved pet lacks. */
49export function revive(saved: unknown, now: number, name?: string): Pet {
50 if (saved === null || typeof saved !== 'object') return newPet(now, name)
51 return { ...newPet(now, name), ...(saved as Partial<Pet>) }
52}
53
54/** Deep night: a pet goes to bed early then, and does not get up before 6. */
55export const isNight = (hour: number) => hour < 6
56
57/** How long a pet you woke stays up before it may doze off again. */
58const STAYS_UP = 30 * MINUTE
59
60/**
61 * Time passing, ten minutes at a time, so a long absence plays out as it
62 * would have: the pet tires, dozes off, sleeps until rested, poops. Time away
63 * is capped at 8 hours and never drops a stat under 15, so coming back is
64 * never cruel. `hourOf` reads the local hour of a time (a parameter for tests).
65 */
66export function decay(
67 pet: Pet,
68 now: number,
69 isOffline = false,
70 hourOf: (at: number) => number = at => new Date(at).getHours(),
71): Pet {
72 const minutes = Math.max(0, (now - pet.lastTickAt) / MINUTE)
73 if (minutes < 1) return pet
74 const floor = isOffline ? 15 : 0
75 // Never below the floor, unless it already was
76 const keep = (was: number, n: number) => clamp(Math.max(n, Math.min(was, floor)))
77 const egg = stageOf(pet.xp) === 'egg'
78 // At most four piles, and three for time away
79 const most = isOffline ? Math.max(pet.poop, 3) : 4
80
81 let left = Math.min(minutes, 8 * 60)
82 let at = now - left * MINUTE
83 let p: Pet = { ...pet }
84 while (left > 0) {
85 const m = Math.min(10, left)
86 left -= m
87 at += m * MINUTE
88 if (!egg) {
89 p.fullness = keep(p.fullness, p.fullness - m * 0.4)
90 p.happiness = keep(p.happiness, p.happiness - m * ((p.poop > 0 ? 0.5 : 0.25) + (p.isSick ? 0.3 : 0)))
91 }
92 p.energy = p.isAsleep ? clamp(p.energy + m * 2) : keep(p.energy, p.energy - m * 0.2)
93 if (egg) continue
94 // Roughly one pile every 45 minutes awake
95 if (!p.isAsleep && Math.random() < m / 45) p.poop = Math.min(most, p.poop + 1)
96 // Three piles, or hungry and sad at once, make it sick until it takes medicine
97 if (p.poop >= 3 || (p.fullness < 15 && p.happiness < 15)) p.isSick = true
98 // Its own bedtime: tired, or sleepy late at night; up again once rested, after the night
99 const night = isNight(hourOf(at))
100 if (p.isAsleep) {
101 if (p.energy >= 100 && !night) p = { ...p, isAsleep: false }
102 } else if (at - (p.wokeAt ?? 0) >= STAYS_UP && (p.energy < 20 || (night && p.energy < 50))) {
103 p = { ...p, isAsleep: true }
104 }
105 }
106 return { ...p, lastTickAt: now }
107}
108
109export type Care = 'feed' | 'play' | 'clean' | 'sleep' | 'heal'
110
111/** What the person did, and which line the pet answers with (hooks/text.ts has the words). */
112export function care(pet: Pet, what: Care): { pet: Pet; line: Line } {
113 if (stageOf(pet.xp) === 'egg' && what !== 'clean') return { pet, line: 'egg' }
114 if (pet.isAsleep && what !== 'sleep') return { pet, line: 'asleep' }
115 switch (what) {
116 case 'feed':
117 if (pet.fullness >= 95) return { pet: { ...pet, happiness: clamp(pet.happiness - 3) }, line: 'full' }
118 return { pet: { ...pet, fullness: clamp(pet.fullness + 25), happiness: clamp(pet.happiness + 3) }, line: 'fed' }
119 case 'play':
120 if (pet.isSick) return { pet, line: 'sickPlay' }
121 if (pet.energy < 15) return { pet, line: 'tired' }
122 return {
123 pet: {
124 ...pet,
125 happiness: clamp(pet.happiness + 20),
126 energy: clamp(pet.energy - 10),
127 fullness: clamp(pet.fullness - 5),
128 },
129 line: 'played',
130 }
131 case 'clean':
132 if (pet.poop === 0) return { pet, line: 'clean' }
133 return { pet: { ...pet, poop: 0, happiness: clamp(pet.happiness + 8) }, line: 'cleaned' }
134 case 'heal':
135 // The medicine tastes bitter
136 if (!pet.isSick) return { pet, line: 'healthy' }
137 return { pet: { ...pet, isSick: false, happiness: clamp(pet.happiness - 5) }, line: 'healed' }
138 case 'sleep':
139 return pet.isAsleep ? { pet: { ...pet, isAsleep: false }, line: 'wake' } : { pet: { ...pet, isAsleep: true }, line: 'night' }
140 }
141}
142
143/** Claude used a tool: the pet learns by watching. */
144export function onTool(pet: Pet): Pet {
145 return { ...pet, xp: pet.xp + 1, toolsSeen: pet.toolsSeen + 1, energy: clamp(pet.energy - 0.3) }
146}
147
148/** A turn ended. */
149export function onTurn(pet: Pet, reason: string): Pet {
150 const turnsSeen = pet.turnsSeen + 1
151 if (reason === 'answer') return { ...pet, turnsSeen, xp: pet.xp + 2, happiness: clamp(pet.happiness + 3) }
152 if (reason === 'error' || reason === 'refusal') return { ...pet, turnsSeen, happiness: clamp(pet.happiness - 4) }
153 return { ...pet, turnsSeen }
154}
155
156export function onToolError(pet: Pet): Pet {
157 return { ...pet, happiness: clamp(pet.happiness - 1) }
158}
159
160export type Mood = 'happy' | 'normal' | 'sad' | 'sick' | 'sleep' | 'focus' | 'hungry'
161
162export function moodOf(pet: Pet, activity: ActivityKind): Mood {
163 if (pet.isAsleep) return 'sleep'
164 if (pet.isSick) return 'sick'
165 if (pet.fullness < 25) return 'hungry'
166 if (pet.happiness < 30 || pet.energy < 15) return 'sad'
167 if (isBusy(activity)) return 'focus'
168 if (pet.happiness >= 70) return 'happy'
169 return 'normal'
170}
171
172export const isBusy = (kind: ActivityKind) =>
173 kind !== 'idle' && kind !== 'done' && kind !== 'error'
174
175export function describe(a: Activity, lang: Lang): string {
176 const verb = TEXT[lang].verb[a.kind]
177 return a.detail ? `${verb} ${a.detail}` : verb
178}
179
180/** Maps a tool call to what Claude is doing, from the tool name and its input. */
181export function activityOf(tool: string, input: Record<string, unknown>): { kind: ActivityKind; detail: string } {
182 const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
183 const base = (p: string) => p.split('/').filter(Boolean).pop() ?? p
184 switch (tool) {
185 case 'Read':
186 case 'NotebookRead':
187 return { kind: 'reading', detail: base(str('file_path') || str('notebook_path')) }
188 case 'Grep':
189 case 'Glob':
190 return { kind: 'searching', detail: short(str('pattern'), 48) }
191 case 'Edit':
192 case 'MultiEdit':
193 case 'Write':
194 case 'NotebookEdit':
195 return { kind: 'writing', detail: base(str('file_path') || str('notebook_path')) }
196 case 'Bash':
197 return { kind: 'running', detail: short(str('description') || str('command'), 72) }
198 case 'Agent':
199 case 'Task':
200 return { kind: 'delegating', detail: short(str('description'), 60) }
201 case 'WebFetch':
202 return { kind: 'browsing', detail: hostOf(str('url')) }
203 case 'WebSearch':
204 return { kind: 'browsing', detail: short(str('query'), 60) }
205 case 'AskUserQuestion':
206 return { kind: 'asking', detail: '' }
207 case 'ExitPlanMode':
208 return { kind: 'approving', detail: '' }
209 case 'TodoWrite':
210 case 'TaskCreate':
211 case 'TaskUpdate':
212 return { kind: 'planning', detail: '' }
213 default:
214 return { kind: 'working', detail: tool.startsWith('mcp__') ? tool.split('__').slice(1).join('/') : tool }
215 }
216}
217
218function hostOf(url: string): string {
219 const m = /^https?:\/\/([^/]+)/.exec(url)
220 return m?.[1] ?? short(url, 24)
221}
222
223export function short(s: string, n: number): string {
224 const one = s.replace(/\s+/g, ' ').trim()
225 return one.length > n ? one.slice(0, n - 1) + '…' : one
226}
227
228export function pick<T>(list: readonly T[]): T {
229 return list[Math.floor(Math.random() * list.length)] as T
230}
231
232export function ageText(pet: Pet, now: number, lang: Lang): string {
233 return TEXT[lang].age((now - pet.bornAt) / MINUTE)
234}
235hooks/pixels.ts 635 lines1// Pixel art for the terminal: the scene is drawn into a canvas of colors and
2// packed into Raster cells, two pixels per cell with half blocks (▀: the top
3// pixel is the foreground, the bottom one the background).
4//
5// All art is original: a round Claude-orange creature that hatches from an
6// egg, grows feet, a sprout and at last a flower.
7import type { ActivityKind, Skin, Stage } from '../types'
8import type { Mood } from './pet'
9import { speciesById } from './species'
10import type { Species } from './species/types'
11
12
13type Ink =
14 | 'outline' | 'body' | 'light' | 'shade' | 'eye' | 'white' | 'blush'
15 | 'leaf' | 'stem' | 'petal' | 'pollen' | 'red' | 'blue' | 'green' | 'gray'
16 | 'ink' | 'paper' | 'cream' | 'spot' | 'poop' | 'poopDark'
17 | 'sickBody' | 'sickLight' | 'sickShade' | 'ground'
18
19type Palette = Record<Ink, number> & { bg: number | null }
20
21const PALETTES: Record<Skin, Palette> = {
22 color: {
23 bg: null,
24 outline: 0x3b2219,
25 body: 0xd97757,
26 light: 0xf2a582,
27 shade: 0xb5573a,
28 eye: 0x24160f,
29 white: 0xffffff,
30 blush: 0xf4858e,
31 leaf: 0x6cc24a,
32 stem: 0x3f8a2e,
33 petal: 0xff8fb1,
34 pollen: 0xffcc00,
35 red: 0xff3b30,
36 blue: 0x5ac8fa,
37 green: 0x34c759,
38 gray: 0x8e8e93,
39 ink: 0x3a3a3c,
40 paper: 0xf5f5f7,
41 cream: 0xf5ead6,
42 spot: 0xd97757,
43 poop: 0x9a6a45,
44 poopDark: 0x5e3a24,
45 sickBody: 0xa3b86c,
46 sickLight: 0xc4d690,
47 sickShade: 0x7f9450,
48 ground: 0x6e8f4e,
49 },
50 // A Tamagotchi screen: four greens, the background filled in.
51 lcd: (() => {
52 const bg = 0xc5cfa0
53 const light = 0xa3b07e
54 const mid = 0x6f7d55
55 const dark = 0x27321f
56 return {
57 bg,
58 outline: dark, body: light, light: bg, shade: mid, eye: dark, white: bg, blush: mid,
59 leaf: mid, stem: dark, petal: mid, pollen: dark, red: dark, blue: mid, green: dark,
60 gray: mid, ink: dark, paper: bg, cream: bg, spot: mid, poop: mid, poopDark: dark,
61 sickBody: mid, sickLight: light, sickShade: dark, ground: mid,
62 }
63 })(),
64}
65
66export class Canvas {
67 readonly px: Int32Array
68 constructor(readonly w: number, readonly height: number) {
69 this.px = new Int32Array(w * height).fill(-1)
70 }
71 get(x: number, y: number): number {
72 if (x < 0 || y < 0 || x >= this.w || y >= this.height) return -1
73 return this.px[y * this.w + x] ?? -1
74 }
75 set(x: number, y: number, c: number) {
76 x = Math.round(x)
77 y = Math.round(y)
78 if (x < 0 || y < 0 || x >= this.w || y >= this.height) return
79 this.px[y * this.w + x] = c
80 }
81}
82
83/** Stamps a bitmap: each letter names an ink in `key`, '.' is clear. */
84function stamp(c: Canvas, x: number, y: number, rows: readonly string[], key: Record<string, number>, flip = false) {
85 rows.forEach((row, dy) => {
86 for (let dx = 0; dx < row.length; dx++) {
87 const ch = row[flip ? row.length - 1 - dx : dx]
88 if (ch === undefined || ch === '.') continue
89 const color = key[ch]
90 if (color !== undefined) c.set(x + dx, y + dy, color)
91 }
92 })
93}
94
95// ---- Icons for the speech bubble, 7×7 ----
96
97export type Icon =
98 | 'think' | 'read' | 'write' | 'run' | 'web' | 'plan' | 'agent' | 'work'
99 | 'done' | 'alert' | 'heart' | 'berry' | 'star' | 'sick'
100
101const ICONS: Record<Exclude<Icon, 'think'>, readonly string[]> = {
102 read: ['.kkk...', 'k...k..', 'k.w.k..', 'k...k..', '.kkkk..', '....kk.', '.....kk'],
103 write: ['.....rr', '....yrr', '...yyk.', '..yyk..', '.yyk...', 'kkk....', 'k......'],
104 run: ['kkkkkkk', 'kkkkkkk', 'kgkkkkk', 'kkgkkkk', 'kgkkkkk', 'kkkkggk', 'kkkkkkk'],
105 web: ['..bbb..', '.bgbgb.', 'bbgbbgb', 'bgggggb', 'bbgbbgb', '.bgbgb.', '..bbb..'],
106 plan: ['g.kkkk.', '.......', 'g.kkkk.', '.......', 'k.kkk..', '.......', 'k.kkkk.'],
107 agent: ['.......', '..ooo..', '.obbbo.', '.ok.ko.', '.obbbo.', '..o.o..', '.......'],
108 work: ['...k...', '.k.k.k.', '..kkk..', 'kkk.kkk', '..kkk..', '.k.k.k.', '...k...'],
109 done: ['.......', '......g', '.....gg', 'g...gg.', 'gg.gg..', '.ggg...', '..g....'],
110 alert: ['..ww...', '..ww...', '..ww...', '..ww...', '.......', '..ww...', '.......'],
111 heart: ['.rr.rr.', 'rrrrrrr', 'rrrrrrr', 'rrrrrrr', '.rrrrr.', '..rrr..', '...r...'],
112 berry: ['...l...', '..ll...', '.rrrr..', 'rrwrrr.', 'rrrrrr.', 'rrrrrr.', '.rrrr..'],
113 star: ['...y...', '...y...', 'yyyyyyy', '.yyyyy.', '..yyy..', '.yy.yy.', 'y.....y'],
114 sick: ['.......', '.kk.kk.', '..k.k..', '.......', '.kkkkk.', 'k.....k', '.......'],
115}
116
117export function iconFor(kind: ActivityKind): Icon | null {
118 switch (kind) {
119 case 'thinking':
120 return 'think'
121 case 'reading':
122 case 'searching':
123 return 'read'
124 case 'writing':
125 return 'write'
126 case 'running':
127 return 'run'
128 case 'browsing':
129 return 'web'
130 case 'planning':
131 return 'plan'
132 case 'delegating':
133 return 'agent'
134 case 'working':
135 return 'work'
136 case 'asking':
137 case 'approving':
138 return 'alert'
139 default:
140 return null
141 }
142}
143
144function iconKey(p: Palette): Record<string, number> {
145 return { k: p.ink, w: p.white, r: p.red, y: p.pollen, g: p.green, b: p.blue, l: p.stem, o: p.outline }
146}
147
148function drawIcon(c: Canvas, x: number, y: number, icon: Icon, p: Palette, tick: number) {
149 if (icon === 'think') {
150 // Three dots filling in one by one
151 const n = Math.floor(tick / 3) % 4
152 for (let i = 0; i < 3; i++) {
153 const isFilled = i < n
154 c.set(x + 1 + i * 2, y + 4, isFilled ? p.ink : p.gray)
155 }
156 return
157 }
158 const key = iconKey(p)
159 // The agent icon's face is the pet's own
160 if (icon === 'agent') key.b = p.body
161 stamp(c, x, y, ICONS[icon], key)
162}
163
164/** A rounded speech bubble 11×10 with a tail pointing down-left or down-right. */
165function drawBubble(c: Canvas, x: number, y: number, tailLeft: boolean, edge: number, fill: number) {
166 const w = 11
167 const height = 9
168 for (let dy = 0; dy < height; dy++) {
169 for (let dx = 0; dx < w; dx++) {
170 const corner = (dx === 0 || dx === w - 1) && (dy === 0 || dy === height - 1)
171 if (corner) continue
172 const border = dx === 0 || dy === 0 || dx === w - 1 || dy === height - 1
173 c.set(x + dx, y + dy, border ? edge : fill)
174 }
175 }
176 const tx = tailLeft ? x + 2 : x + w - 3
177 c.set(tx, y + height - 1, fill)
178 c.set(tx + (tailLeft ? 1 : -1), y + height - 1, fill)
179 c.set(tx - (tailLeft ? 1 : -1), y + height - 1, edge)
180 c.set(tx - (tailLeft ? 1 : -1), y + height, edge)
181 c.set(tx - (tailLeft ? 2 : -2), y + height + 1, edge)
182 c.set(tx, y + height, edge)
183}
184
185// ---- The creature ----
186
187type Shape = { rx: number; ry: number; feet: boolean; top: 'none' | 'sprout' | 'flower'; eye: 1 | 2 }
188
189const SHAPES: Record<Exclude<Stage, 'egg'>, Shape> = {
190 baby: { rx: 5, ry: 4.2, feet: false, top: 'none', eye: 2 },
191 child: { rx: 5.3, ry: 4.4, feet: true, top: 'none', eye: 2 },
192 teen: { rx: 6.2, ry: 5, feet: true, top: 'sprout', eye: 2 },
193 adult: { rx: 7, ry: 4.9, feet: true, top: 'flower', eye: 2 },
194}
195
196/** Pixels from the ground to the top of the pet: what a scene must fit. */
197export function heightOf(stage: Stage, species = 'mochi'): number {
198 if (stage === 'egg') return 11
199 const sp = speciesById(species)
200 if (sp !== undefined) return sp.stages[stage].body.length
201 const s = SHAPES[stage]
202 return Math.ceil(s.ry * 2) + (s.feet ? 2 : 0) + (s.top === 'flower' ? 4 : s.top === 'sprout' ? 3 : 0)
203}
204
205export type Pose = {
206 stage: Stage
207 mood: Mood
208 tick: number
209 /** Center x in pixels; feet stand on `ground`. */
210 x: number
211 ground: number
212 /** Pixels off the ground. */
213 lift: number
214 facing: 'left' | 'right'
215 isWalking: boolean
216 isEating: boolean
217 /** XP toward hatching, for the egg's cracks. */
218 xp: number
219 /** White flash while evolving. */
220 isFlashing: boolean
221 /** Which species draws it: `mochi` (drawn in code) or one of hooks/species. */
222 species: string
223}
224
225/** Draws the pet; returns its bounding box for the bubble and the effects. */
226function drawPet(c: Canvas, pose: Pose, p: Palette): { left: number; right: number; top: number; headY: number } {
227 const { stage, mood, tick } = pose
228 if (stage === 'egg') return drawEgg(c, pose, p)
229 const sp = speciesById(pose.species)
230 if (sp !== undefined) return drawSpecies(c, sp, pose, p)
231
232 const shape = SHAPES[stage]
233 const sick = mood === 'sick'
234 const body = pose.isFlashing && tick % 2 === 0 ? p.white : sick ? p.sickBody : p.body
235 const light = pose.isFlashing && tick % 2 === 0 ? p.white : sick ? p.sickLight : p.light
236 const shade = pose.isFlashing && tick % 2 === 0 ? p.paper : sick ? p.sickShade : p.shade
237
238 // Breathing, squash on landing, flatter asleep
239 let rx = shape.rx
240 let ry = shape.ry
241 const breathe = Math.floor(tick / 5) % 2 === 1
242 if (mood === 'sleep') {
243 rx *= 1.1
244 ry *= 0.85
245 } else if (pose.lift === 0 && breathe && !pose.isWalking) {
246 rx += 0.25
247 ry -= 0.3
248 }
249
250 const feetH = shape.feet ? 2 : 0
251 const cx = pose.x
252 const bottom = pose.ground - feetH - pose.lift
253 const cy = bottom - ry + 0.5
254
255 // Feet first, so the body overlaps them; walking lifts one at a time
256 if (shape.feet) {
257 const step = pose.isWalking ? Math.floor(tick / 2) % 2 : -1
258 for (const [i, fx] of [cx - rx * 0.45, cx + rx * 0.45].entries()) {
259 const up = step === i ? 1 : 0
260 const fy = pose.ground - pose.lift - 1 - up
261 const x0 = Math.round(fx) - 1
262 c.set(x0, fy, shade)
263 c.set(x0 + 1, fy, shade)
264 c.set(x0 + 2, fy, shade)
265 c.set(x0, fy + 1, p.outline)
266 c.set(x0 + 1, fy + 1, p.outline)
267 c.set(x0 + 2, fy + 1, p.outline)
268 c.set(x0 - 1, fy, p.outline)
269 c.set(x0 + 3, fy, p.outline)
270 }
271 }
272
273 // The body: an ellipse, outlined, lit from the upper left
274 const inside = (x: number, y: number) => {
275 const nx = (x + 0.5 - cx) / rx
276 const ny = (y + 0.5 - cy) / ry
277 return nx * nx + ny * ny <= 1
278 }
279 let top = Infinity
280 let left = Infinity
281 let right = -Infinity
282 for (let y = Math.floor(cy - ry) - 1; y <= Math.ceil(cy + ry) + 1; y++) {
283 for (let x = Math.floor(cx - rx) - 1; x <= Math.ceil(cx + rx) + 1; x++) {
284 if (!inside(x, y)) continue
285 top = Math.min(top, y)
286 left = Math.min(left, x)
287 right = Math.max(right, x)
288 const edge = !inside(x - 1, y) || !inside(x + 1, y) || !inside(x, y - 1) || !inside(x, y + 1)
289 if (edge) {
290 c.set(x, y, p.outline)
291 continue
292 }
293 const nx = (x + 0.5 - cx) / rx
294 const ny = (y + 0.5 - cy) / ry
295 const d = nx * nx + ny * ny
296 const lit = -nx * 0.6 - ny * 0.8
297 c.set(x, y, d > 0.45 && lit > 0.45 ? light : d > 0.5 && lit < -0.5 ? shade : body)
298 }
299 }
300 // A glint on the upper left
301 c.set(cx - rx * 0.5, cy - ry * 0.55, p.white)
302
303 // Top: a sprout or a flower that sways
304 const sway = Math.floor(tick / 6) % 2 === 0 ? 0 : 1
305 const tx = Math.round(cx) - 2
306 if (shape.top === 'sprout') {
307 stamp(c, tx + sway, top - 3, ['ll.ll', '.lsl.'], { l: p.leaf, s: p.stem })
308 c.set(tx + 2, top - 1, p.stem)
309 top -= 3
310 } else if (shape.top === 'flower') {
311 stamp(c, tx + sway, top - 4, ['.p.p.', 'ppypp', '.psp.'], { p: p.petal, y: p.pollen, s: p.stem })
312 c.set(tx + 2, top - 1, p.stem)
313 c.set(tx + 1, top - 1, p.leaf)
314 top -= 4
315 }
316
317 // Look the way it walks
318 const look = pose.isWalking ? (pose.facing === 'left' ? -1 : 1) : 0
319 const ey = Math.round(cy - ry * 0.15)
320 const exL = Math.round(cx - rx * 0.4) + look
321 const exR = Math.round(cx + rx * 0.4) - 1 + look
322 // Overlays land only on the body, never past the outline
323 const onBody = (x: number, y: number) => {
324 const under = c.get(x, y)
325 return under !== -1 && under !== p.outline
326 }
327 drawFace(c, [[exL, ey], [exR, ey]], [Math.round(cx) - 1 + look, ey + 3], pose, p, onBody)
328 return { left, right, top, headY: Math.round(cy - ry * 0.4) }
329}
330
331/** Eyes (2×2, top-left anchors), blush and mouth for the mood, drawn where `canDraw` allows. */
332function drawFace(
333 c: Canvas,
334 eyes: readonly (readonly [number, number])[],
335 mouth: readonly [number, number],
336 pose: Pose,
337 p: Palette,
338 canDraw: (x: number, y: number) => boolean,
339) {
340 const { mood, tick } = pose
341 const blink = mood !== 'sleep' && tick % 25 === 24
342 const dot = (x: number, y: number, color: number) => {
343 x = Math.round(x)
344 y = Math.round(y)
345 if (canDraw(x, y)) c.set(x, y, color)
346 }
347 const ey = eyes[0]?.[1] ?? 0
348
349 for (const [ex, ey] of eyes) {
350 if (mood === 'sleep' || blink) {
351 dot(ex, ey + 1, p.eye)
352 dot(ex + 1, ey + 1, p.eye)
353 } else if (mood === 'happy') {
354 dot(ex, ey + 1, p.eye)
355 dot(ex + 1, ey, p.eye)
356 dot(ex + 2, ey + 1, p.eye)
357 } else if (mood === 'sick') {
358 dot(ex, ey, p.eye)
359 dot(ex + 1, ey + 1, p.eye)
360 dot(ex + 2, ey, p.eye)
361 } else if (mood === 'focus') {
362 // Half-closed, determined
363 dot(ex, ey, p.shade)
364 dot(ex + 1, ey, p.shade)
365 dot(ex, ey + 1, p.eye)
366 dot(ex + 1, ey + 1, p.eye)
367 } else {
368 dot(ex, ey, p.white)
369 dot(ex, ey + 1, p.eye)
370 dot(ex + 1, ey, p.eye)
371 dot(ex + 1, ey + 1, p.eye)
372 if (mood === 'sad') dot(ex, ey + 2, p.blue)
373 }
374 }
375
376 // Blush when content
377 if (mood === 'happy' || mood === 'normal' || mood === 'focus') {
378 const first = eyes[0]
379 const last = eyes[eyes.length - 1]
380 if (first !== undefined) dot(first[0] - 1, ey + 2, p.blush)
381 if (last !== undefined) dot(last[0] + 2, ey + 2, p.blush)
382 }
383
384 const [mx, my] = mouth
385 if (pose.isEating) {
386 const open = Math.floor(tick / 2) % 2 === 0
387 dot(mx, my, p.eye)
388 dot(mx + 1, my, p.eye)
389 if (open) {
390 dot(mx, my + 1, p.red)
391 dot(mx + 1, my + 1, p.eye)
392 }
393 } else if (mood === 'happy') {
394 dot(mx - 1, my, p.eye)
395 dot(mx, my + 1, p.eye)
396 dot(mx + 1, my + 1, p.eye)
397 dot(mx + 2, my, p.eye)
398 } else if (mood === 'sad' || mood === 'sick') {
399 dot(mx - 1, my + 1, p.eye)
400 dot(mx, my, p.eye)
401 dot(mx + 1, my, p.eye)
402 dot(mx + 2, my + 1, p.eye)
403 } else if (mood === 'hungry') {
404 dot(mx, my, p.eye)
405 dot(mx + 1, my, p.eye)
406 dot(mx, my + 1, p.eye)
407 dot(mx + 1, my + 1, p.eye)
408 } else if (mood === 'sleep') {
409 dot(mx + 1, my, p.eye)
410 } else {
411 dot(mx, my, p.eye)
412 dot(mx + 1, my, p.eye)
413 }
414}
415
416const SICK = 0x9db86c
417
418function mix(a: number, b: number, t: number): number {
419 const ch = (n: number, k: number) => (n >> k) & 255
420 const m = (k: number) => Math.round(ch(a, k) * (1 - t) + ch(b, k) * t) << k
421 return m(16) | m(8) | m(0)
422}
423
424/** A species color as the skin shows it: as is, or one of the LCD's greens by brightness. */
425function shown(color: number, p: Palette): number {
426 if (p.bg === null) return color
427 const lum = 0.299 * ((color >> 16) & 255) + 0.587 * ((color >> 8) & 255) + 0.114 * (color & 255)
428 return lum < 70 ? p.outline : lum < 140 ? p.shade : lum < 205 ? p.body : p.light
429}
430
431/** A bitmap species: mirrored when facing left, its alt frame for breathing and steps. */
432function drawSpecies(c: Canvas, sp: Species, pose: Pose, p: Palette) {
433 const { tick, mood } = pose
434 if (pose.stage === 'egg') return drawEgg(c, pose, p)
435 const st = sp.stages[pose.stage]
436 const useAlt =
437 st.alt !== undefined && mood !== 'sleep' && (pose.isWalking ? Math.floor(tick / 2) % 2 === 1 : Math.floor(tick / 5) % 2 === 1)
438 const rows = useAlt && st.alt !== undefined ? st.alt : st.body
439 const height = rows.length
440 const w = rows[0]?.length ?? 0
441 const flip = pose.facing === 'left'
442 const left = Math.round(pose.x - w / 2)
443 const top = pose.ground - pose.lift - height + 1
444 const flash = pose.isFlashing && tick % 2 === 0
445 const letterAt = (bx: number, by: number) => rows[by]?.[flip ? w - 1 - bx : bx]
446
447 for (let by = 0; by < height; by++) {
448 for (let bx = 0; bx < w; bx++) {
449 const l = letterAt(bx, by)
450 if (l === undefined || l === '.') continue
451 const raw = sp.colors[l]
452 if (raw === undefined) continue
453 const color = flash ? p.white : shown(mood === 'sick' ? mix(raw, SICK, 0.45) : raw, p)
454 c.set(left + bx, top + by, color)
455 }
456 }
457
458 const mirror = (x: number) => (flip ? w - 2 - x : x)
459 const eyes = st.eyes.map(([x, y]) => [left + mirror(x), top + y] as const)
460 const mouth = [left + mirror(st.mouth[0]), top + st.mouth[1]] as const
461 const onFace = (x: number, y: number) => {
462 const l = letterAt(x - left, y - top)
463 return l !== undefined && l !== '.' && sp.face.includes(l)
464 }
465 drawFace(c, eyes, mouth, pose, p, onFace)
466 return { left, right: left + w - 1, top, headY: top + 2 }
467}
468
469function drawEgg(c: Canvas, pose: Pose, p: Palette) {
470 // A wobble now and then
471 const phase = pose.tick % 30
472 const wob = phase < 8 ? [0, 1, 0, -1, 0, 1, 0, -1][phase] ?? 0 : 0
473 const cx = pose.x + wob
474 const rx = 5
475 const ry = 6.2
476 const cy = pose.ground - ry * 0.85 + 0.5
477 // Narrower toward the top: an egg, not an ellipse
478 const inside = (x: number, y: number) => {
479 const ny = (y + 0.5 - cy) / ry
480 const nx = (x + 0.5 - cx) / (rx * (ny < 0 ? 1 + ny * 0.35 : 1))
481 return nx * nx + ny * ny * (ny > 0 ? 1.4 : 1) <= 1
482 }
483 let top = Infinity
484 let left = Infinity
485 let right = -Infinity
486 for (let y = Math.floor(cy - ry) - 1; y <= Math.ceil(cy + ry); y++) {
487 for (let x = Math.floor(cx - rx) - 1; x <= Math.ceil(cx + rx) + 1; x++) {
488 if (!inside(x, y)) continue
489 top = Math.min(top, y)
490 left = Math.min(left, x)
491 right = Math.max(right, x)
492 const edge = !inside(x - 1, y) || !inside(x + 1, y) || !inside(x, y - 1) || !inside(x, y + 1)
493 const lit = -(x + 0.5 - cx) / rx - (y + 0.5 - cy) / ry
494 c.set(x, y, edge ? p.outline : lit < -0.9 ? p.shade : p.cream)
495 }
496 }
497 const sx = Math.round(cx)
498 const sy = Math.round(cy)
499 for (const [dx, dy] of [[-2, -1], [-1, -1], [-2, 0], [1, 2], [2, 2], [2, 1], [1, -3], [-1, 3]] as const) {
500 if (c.get(sx + dx, sy + dy) === p.cream) c.set(sx + dx, sy + dy, p.spot)
501 }
502 c.set(sx - 2, sy - 4, p.white)
503 c.set(sx - 3, sy - 3, p.white)
504 if (pose.xp >= 6) {
505 // A crack across the middle, longer as it nears hatching
506 // A zigzag crack, longer as it nears hatching
507 const len = pose.xp >= 8 ? 4 : 2
508 for (let i = -len; i <= len; i++) {
509 const y = sy - 2 + (Math.abs(i) % 2)
510 if (c.get(sx + i, y) !== -1 && c.get(sx + i, y) !== p.outline) c.set(sx + i, y, p.outline)
511 }
512 }
513 return { left, right, top, headY: sy - 2 }
514}
515
516// ---- The scene ----
517
518export type Scene = Pose & {
519 skin: Skin
520 poop: number
521 /** What the bubble shows; null for no bubble. */
522 icon: Icon | null
523 isAlert: boolean
524 /** Subagents running: a mini pet each, hopping by the pet. */
525 agents: number
526 isSweating: boolean
527 /** Draw a ground line (the pane does, the band does not). */
528 hasGround: boolean
529}
530
531export function render(w: number, height: number, s: Scene): Canvas {
532 const p = PALETTES[s.skin]
533 const c = new Canvas(w, height)
534 if (p.bg !== null) c.px.fill(p.bg)
535
536 if (s.hasGround) {
537 for (let x = 0; x < w; x++) {
538 c.set(x, height - 1, p.ground)
539 if ((x * 7 + 3) % 11 === 0) c.set(x, height - 2, p.leaf)
540 }
541 }
542
543 // Poop piles from the right edge, stink lines wavering
544 for (let i = 0; i < s.poop; i++) {
545 const x = w - 8 - i * 7
546 if (x < 0) break
547 stamp(c, x, s.ground - 3, ['..p..', '.ppd.', 'pdppp'], { p: p.poop, d: p.poopDark })
548 const wave = Math.floor(s.tick / 3 + i) % 2
549 c.set(x + 1 + wave, s.ground - 5, p.gray)
550 c.set(x + 2 - wave, s.ground - 6, p.gray)
551 c.set(x + 3 + wave, s.ground - 7, p.gray)
552 }
553
554 // Subagents: mini pets hopping on the far side
555 for (let i = 0; i < Math.min(s.agents, 4); i++) {
556 const side = s.x > w / 2 ? -1 : 1
557 const ax = Math.round(s.x + side * (11 + i * 5))
558 const hop = Math.floor(s.tick / 2 + i) % 3 === 0 ? 1 : 0
559 stamp(c, ax - 1, s.ground - 3 - hop, ['.ooo.', 'obbbo', 'oekeo', '.ooo.'], {
560 o: p.outline, b: p.light, e: p.body, k: p.eye,
561 })
562 }
563
564 const box = drawPet(c, s, p)
565
566 const asleep = s.mood === 'sleep'
567 // Asleep, the pet shows only its Zs; a call for the person still gets through
568 const icon = asleep && !s.isAlert ? null : s.icon
569 const bw = 11
570 const bubbleRight = box.right + 2 + bw <= w
571 const showBubble = icon !== null && (!s.isAlert || s.tick % 4 < 3)
572
573 // Sleep: Zs drifting up from the head, on the side the bubble does not take
574 if (asleep) {
575 const t = s.tick % 16
576 const zRight = icon === null || !bubbleRight
577 const drift = Math.floor(t / 6)
578 const zx = zRight ? box.right + 1 + drift : box.left - 5 - drift
579 const zy = Math.max(0, box.top + 1 - Math.floor(t / 4))
580 stamp(c, zx, zy, ['zzzz', '..z.', '.z..', 'zzzz'], { z: p.gray })
581 if (t > 8) stamp(c, zRight ? zx + 4 : zx - 3, zy - 4, ['zzz', '..z', '.z.', 'zzz'], { z: p.gray })
582 }
583
584 if (s.isSweating && !asleep) {
585 // On the head's side away from the bubble
586 const sx = icon !== null && bubbleRight ? box.left : box.right - 1
587 stamp(c, sx, box.top, ['.b', 'bb', 'bb'], { b: p.blue })
588 }
589
590 // The bubble: right of the head where it fits, else left
591 if (showBubble && icon !== null) {
592 const bx = bubbleRight ? box.right + 2 : Math.max(0, box.left - 2 - bw)
593 const by = Math.max(0, Math.min(box.top - 4, height - 12))
594 drawBubble(c, bx, by, bubbleRight, s.isAlert ? p.red : p.ink, s.isAlert ? p.red : p.paper)
595 drawIcon(c, bx + 2, by + 1, icon, p, s.tick)
596 }
597 return c
598}
599
600const DEFAULT = 0x01000000
601const UPPER = 0x2580
602const LOWER = 0x2584
603
604/** Packs the canvas into RasterProps `cells`: `w` columns, `ceil(height/2)` rows. */
605export function toCells(c: Canvas): string {
606 const rows = Math.ceil(c.height / 2)
607 const words = new Uint32Array(c.w * rows * 3)
608 let i = 0
609 for (let r = 0; r < rows; r++) {
610 for (let x = 0; x < c.w; x++) {
611 const top = c.get(x, r * 2)
612 const bottom = c.get(x, r * 2 + 1)
613 if (top === -1 && bottom === -1) {
614 words[i++] = 0x20
615 words[i++] = DEFAULT
616 words[i++] = DEFAULT
617 } else if (top === -1) {
618 words[i++] = LOWER
619 words[i++] = bottom
620 words[i++] = DEFAULT
621 } else {
622 words[i++] = UPPER
623 words[i++] = top
624 words[i++] = bottom === -1 ? DEFAULT : bottom
625 }
626 }
627 }
628 const bytes = new Uint8Array(words.buffer)
629 let text = ''
630 for (let k = 0; k < bytes.length; k += 0x8000) {
631 text += String.fromCharCode(...bytes.subarray(k, k + 0x8000))
632 }
633 return btoa(text)
634}
635hooks/species/index.ts 21 lines1import type { Species } from './types'
2import { sprig } from './sprig'
3import { mallow } from './mallow'
4import { dewdrop } from './dewdrop'
5import { plumcap } from './plumcap'
6import { luma } from './luma'
7import { mossroll } from './mossroll'
8import { inkfold } from './inkfold'
9import { kilnby } from './kilnby'
10import { tuck } from './tuck'
11
12/**
13 * Every species but the default. `mochi`, the round orange one, is drawn in
14 * code (hooks/pixels.ts) and is not listed here.
15 */
16export const SPECIES: readonly Species[] = [sprig, mallow, dewdrop, plumcap, luma, mossroll, inkfold, kilnby, tuck]
17
18export function speciesById(id: string): Species | undefined {
19 return SPECIES.find(s => s.id === id)
20}
21hooks/sprites.ts 104 lines1// Pixel-ish ASCII sprites. Every line of a sprite is padded to one width so
2// the pet can bob left and right without the rest of the pane moving.
3import type { Stage } from '../types'
4import type { Mood } from './pet'
5
6/** Eyes are three cells, mouths one. */
7const EYES: Record<Mood, string> = {
8 happy: '^ ^',
9 normal: 'o o',
10 sad: 'T T',
11 sick: '@ @',
12 sleep: '- -',
13 focus: '• •',
14 hungry: 'o o',
15}
16
17const MOUTH: Record<Mood, string> = {
18 happy: 'w',
19 normal: '_',
20 sad: 'n',
21 sick: '~',
22 sleep: '.',
23 focus: '-',
24 hungry: 'O',
25}
26
27// `E` is replaced by the eyes, `M` by the mouth.
28const BODIES: Record<Exclude<Stage, 'egg'>, readonly string[]> = {
29 baby: [
30 ' ',
31 ' .---. ',
32 ' ( E ) ',
33 ' ( M ) ',
34 " '---' ",
35 ],
36 child: [
37 ' /\\_/\\ ',
38 ' ( E ) ',
39 ' ( M ) ',
40 ' /| |\\ ',
41 ' ^^ ^^ ',
42 ],
43 teen: [
44 ' ,/\\_/\\, ',
45 ' ( E ) ',
46 ' ( M ) ',
47 ' /| |\\ ',
48 ' (_| |_) ',
49 ],
50 adult: [
51 ' .-"""-. ',
52 ' / \\ ',
53 ' | E | ',
54 '<| M |>',
55 " '-.___.-' ",
56 ],
57}
58
59const EGG: readonly (readonly string[])[] = [
60 [' ', ' .-. ', ' / \\ ', ' | . . | ', " '---' "],
61 [' ', ' .-. ', ' / ` \\ ', ' | . .| ', " '---' "],
62]
63
64const width = (lines: readonly string[]) => Math.max(...lines.map(l => l.length))
65
66/**
67 * The pet's lines for this frame: blinking every fourth frame, bobbing one
68 * cell sideways on odd frames, a Z bubble while asleep.
69 */
70export function sprite(stage: Stage, mood: Mood, frame: number): string[] {
71 let lines: string[]
72 if (stage === 'egg') {
73 lines = [...(EGG[frame % 2] ?? EGG[0]!)]
74 } else {
75 const blink = mood !== 'sleep' && frame % 4 === 3
76 const eyes = blink ? '- -' : EYES[mood]
77 lines = BODIES[stage].map(l => l.replace('E', eyes).replace('M', MOUTH[mood]))
78 }
79 const w = width(lines) + 1
80 const shift = mood === 'sleep' || mood === 'sick' ? 0 : frame % 2
81 lines = lines.map(l => (' '.repeat(shift) + l).padEnd(w + 1))
82 // A row above the head for the Z bubble, blank while awake.
83 const bubble = mood === 'sleep' ? (frame % 2 ? ' z Z' : ' Z z ') : ''
84 return [bubble.padEnd(w + 1), ...lines]
85}
86
87/** The ground row: one `@` pile per poop, with a stink line above. */
88export function ground(poop: number, frame: number): [string, string] {
89 if (poop === 0) return ['', '']
90 const stink = frame % 2 ? ' ~ ' : '~ '
91 return [stink.repeat(poop), ' @ '.repeat(poop)]
92}
93
94/** A one-glyph face for the band and the status line. */
95export function face(mood: Mood, frame: number): string {
96 if (mood !== 'sleep' && frame % 4 === 3) return '(- -)'
97 return `(${EYES[mood]})`
98}
99
100export function bar(value: number, cells = 8): string {
101 const full = Math.round((Math.max(0, Math.min(100, value)) / 100) * cells)
102 return '█'.repeat(full) + '░'.repeat(cells - full)
103}
104hooks/text.ts 452 lines1// Every word the pet shows, in Chinese and English.
2import type { ActivityKind, BandMode, Pet, Place, Skin, Stage } from '../types'
3import type { Mood } from './pet'
4
5export type Lang = 'zh' | 'en'
6
7/** What the pet answers a care action with; text picks the words. */
8export type Line =
9 | 'egg' | 'asleep' | 'full' | 'fed' | 'tired' | 'played' | 'clean' | 'cleaned' | 'wake' | 'night'
10 | 'healed' | 'healthy' | 'sickPlay'
11
12/** Reads a language from Claude Code's `language` setting (`chinese`, `zh-CN`, `english`). */
13export function langOf(value: string | undefined): Lang | undefined {
14 if (value === undefined || value === '') return undefined
15 const v = value.toLowerCase()
16 if (/^(zh|cn)|chinese|中文|简体|繁體|繁体/.test(v)) return 'zh'
17 return 'en'
18}
19
20const pick = <T,>(list: readonly T[]): T => list[Math.floor(Math.random() * list.length)] as T
21
22type Text = {
23 defaultName: string
24 stage: Record<Stage, string>
25 verb: Record<ActivityKind, string>
26 care: Record<Line, readonly string[]>
27 chatter: (pet: Pet, mood: Mood, hour: number) => string
28 age: (minutes: number) => string
29 hatched: (name: string) => string
30 grew: (name: string, stage: string) => string
31 hello: (name: string) => string
32 grownUp: string
33 wokeUp: (name: string) => string
34 pooped: (name: string) => string
35 dozedOff: (name: string) => string
36 dozing: readonly string[]
37 rested: (name: string) => string
38 morning: readonly string[]
39 hungry: (name: string) => string
40 toolFailed: (tool: string, name: string) => string
41 turnDone: (secs: number, tools: number) => string
42 longTurn: (secs: number, tools: number) => string
43 okay: string
44 aborted: string
45 turnError: string
46 cheer: string
47 rolledOver: string
48 command: string
49 nameUsage: string
50 renamed: (name: string) => string
51 myName: (name: string) => string
52 band: (mode: BandMode) => string
53 place: (place: Place) => string
54 speciesUsage: (lines: string[]) => string
55 speciesLine: (id: string, name: string, description: string, isDefault: boolean) => string
56 mochi: { name: string; description: string }
57 became: (pet: string, species: string) => string
58 keys: (isOn: boolean) => string
59 /** How to do a care action now: a digit while the band takes them, else the command. */
60 how: (digit: string | null, command: string) => string
61 /** A care command's answer: the pet's name and what it says. */
62 says: (name: string, line: string) => string
63 buttons: (isOn: boolean) => string
64 skin: (skin: Skin) => string
65 lang: (lang: Lang) => string
66 langUsage: string
67 resetConfirm: string
68 newEgg: string
69 eggReady: string
70 status: (pet: Pet, stage: string, doing: string) => string
71 paneTitle: string
72 tooNarrow: string
73 fullness: string
74 happiness: string
75 energy: string
76 xp: string
77 grown: string
78 toGrow: (n: number) => string
79 doing: string
80 turnTools: (n: number) => string
81 tools: (n: number) => string
82 log: string
83 feed: string
84 play: string
85 clean: string
86 sleep: string
87 wake: string
88 heal: string
89 focused: string
90 unfocused: string
91 /** The settings page in the pane. */
92 settings: {
93 open: string
94 title: string
95 back: string
96 species: string
97 place: string
98 band: string
99 buttons: string
100 keys: string
101 skin: string
102 lang: string
103 name: string
104 namePlaceholder: string
105 reset: string
106 resetAsk: string
107 resetSure: string
108 resetYes: string
109 cancel: string
110 above: string
111 below: string
112 full: string
113 mini: string
114 hidden: string
115 show: string
116 hide: string
117 enabled: string
118 disabled: string
119 color: string
120 lcd: string
121 auto: string
122 hint: string
123 }
124 /** The band's short meter labels. */
125 short: { fullness: string; happiness: string; energy: string }
126}
127
128const zh: Text = {
129 defaultName: '小克',
130 stage: { egg: '蛋', baby: '幼年', child: '童年', teen: '少年', adult: '成年' },
131 verb: {
132 idle: '在发呆',
133 thinking: '在思考',
134 reading: '在读',
135 searching: '在搜索',
136 writing: '在写',
137 running: '在运行',
138 delegating: '派了小助手去',
139 browsing: '在上网查',
140 planning: '在列计划',
141 working: '在用',
142 asking: '在等你回答',
143 approving: '在等你审批计划',
144 done: '刚完成一轮',
145 error: '遇到了点问题',
146 },
147 care: {
148 egg: ['……(蛋轻轻晃了一下,让 Claude 多干点活就能孵化)'],
149 asleep: ['Zzz……(睡着了,{sleep}叫醒)'],
150 full: ['吃不下了啦!'],
151 fed: ['好吃!', '嗷呜~', '再来一口!', '谢谢投喂 (*^ω^*)'],
152 tired: ['好累……想睡觉'],
153 played: ['耶!', '再玩一次!', '抓到你啦~', '嘿嘿嘿'],
154 clean: ['已经很干净啦'],
155 cleaned: ['清爽!✧'],
156 healed: ['苦苦的……不过好多了!', '咕咚,感觉好多了~', '谢谢你照顾我 (。•ᴗ•。)'],
157 healthy: ['我很健康,不用吃药啦'],
158 sickPlay: ['生病了,玩不动……({heal}给我吃药)'],
159 wake: ['早上好~'],
160 night: ['晚安……'],
161 },
162 chatter: (pet, mood, hour) => {
163 if (mood === 'hungry') return pick(['肚子饿了……({feed}喂我)', '咕噜咕噜~'])
164 if (mood === 'sick') return pick(['不太舒服……({heal}吃药)', pet.poop > 0 ? '呜……好臭({clean}打扫,{heal}吃药)' : '呜……'])
165 if (mood === 'sad') return pick(['陪我玩一会嘛({play})', '有点无聊……'])
166 if (pet.poop > 0) return '那边好像有点臭……'
167 if (hour >= 0 && hour < 6) return pick(['这么晚还在写代码吗?早点睡呀', '夜深了,记得休息~'])
168 if (hour >= 6 && hour < 10) return pick(['早上好!今天写点什么?', '先喝杯咖啡吧 ☕'])
169 if (hour >= 12 && hour < 14) return '午饭吃了吗?'
170 return pick([
171 '我在这儿陪着你~',
172 '今天也辛苦啦',
173 '要不要起来伸个懒腰?',
174 '喝口水吧',
175 `我已经看 Claude 用了 ${pet.toolsSeen} 次工具了!`,
176 '嘿嘿',
177 ])
178 },
179 age: minutes =>
180 minutes < 60 ? `${Math.max(1, Math.round(minutes))} 分钟` : minutes < 48 * 60 ? `${Math.round(minutes / 60)} 小时` : `${Math.round(minutes / 1440)} 天`,
181 hatched: name => `🥚 ${name} 孵化了!`,
182 grew: (name, stage) => `✨ ${name} 长大了:${stage}!`,
183 hello: name => `你好呀!我是${name}~`,
184 grownUp: '我长大啦!',
185 wokeUp: name => `${name} 醒来了`,
186 pooped: name => `${name} 拉了一坨便便`,
187 dozedOff: name => `${name} 困了,自己睡着了`,
188 dozing: ['好困……我先睡啦', '呼……晚安', '眼皮好重……'],
189 rested: name => `${name} 睡饱了,自己醒了`,
190 morning: ['睡饱啦~', '伸个懒腰,早上好!', '精神满满!'],
191 hungry: name => `${name} 饿了,{feed}喂一下吧`,
192 toolFailed: (tool, name) => `Claude 的 ${tool} 出错了,${name} 有点担心`,
193 turnDone: (secs, tools) => `Claude 完成一轮:${secs}s,${tools} 次工具`,
194 longTurn: (secs, tools) => `辛苦啦!Claude 刚刚忙了 ${secs} 秒,用了 ${tools} 次工具`,
195 okay: '好啦~',
196 aborted: '这一轮被打断了',
197 turnError: 'Claude 这一轮出错了',
198 cheer: '别灰心,再试一次!',
199 rolledOver: '(翻了个身)',
200 command: '电子宠物:打开面板,照顾和设置都在里面',
201 nameUsage: '用法:/pet name <名字>',
202 renamed: name => `宠物改名为 ${name}`,
203 myName: name => `我叫${name}啦!`,
204 band: mode =>
205 `输入框上方:${{ full: '像素宠物', mini: '一行精简', hidden: '隐藏' }[mode]}(/pet band full|mini|hidden)`,
206 place: place =>
207 place === 'above'
208 ? '宠物放在输入框上方(/pet place below 移到下方)'
209 : '宠物移到了输入框下方。数字快捷键只在上方生效,在下方请用鼠标点按钮或 /pet feed 等命令(/pet place above 移回上方)',
210 speciesUsage: lines => `用法:/pet species <id>\n${lines.join('\n')}`,
211 speciesLine: (id, name, description, isDefault) => `${id}(${name}${isDefault ? ',默认' : ''}):${description}`,
212 mochi: { name: '团子', description: '最早的橙色圆团子' },
213 became: (pet, species) => `${pet} 变成了${species}`,
214 keys: isOn =>
215 isOn
216 ? '数字快捷键已开启:输入框为空时按 1 喂食 · 2 玩耍 · 3 清洁 · 4 睡觉'
217 : '数字快捷键已关闭:数字照常输入,按钮仍可用鼠标点(/pet keys on 重新开启)',
218 how: (digit, command) => (digit === null ? `${command} ` : `按 ${digit} `),
219 says: (name, line) => `${name}:${line}`,
220 buttons: isOn =>
221 isOn ? '小栏按钮已显示' : '小栏按钮已隐藏,数字快捷键也随之停用(/pet buttons on 恢复,也可以用 /pet feed 等命令)',
222 skin: skin => (skin === 'lcd' ? '换上了拓麻歌子液晶屏皮肤' : '换回了彩色皮肤'),
223 lang: lang => (lang === 'zh' ? '已切换为中文' : 'Switched to English'),
224 langUsage: '用法:/pet lang zh|en|auto(auto 跟随 Claude Code 的 language 设置)',
225 resetConfirm: '这会让宠物重新从蛋开始。确定的话输入:/pet reset confirm',
226 newEgg: '一颗新蛋出现了',
227 eggReady: '新的蛋已就位 🥚',
228 status: (pet, stage, doing) =>
229 `${pet.name}(${stage})温饱 ${Math.round(pet.fullness)} · 心情 ${Math.round(pet.happiness)} · ` +
230 `精力 ${Math.round(pet.energy)} · 经验 ${pet.xp} · Claude ${doing}`,
231 paneTitle: '电子宠物',
232 tooNarrow: '终端太窄,放不下宠物面板,拉宽一点再试 /pet',
233 fullness: '温饱',
234 happiness: '心情',
235 energy: '精力',
236 xp: '经验',
237 grown: ' · 已完全长大',
238 toGrow: n => ` · 再 ${n} 点长大`,
239 doing: 'Claude 正在做什么',
240 turnTools: n => ` · 本轮 ${n} 次工具`,
241 tools: n => ` · ${n} 次工具`,
242 log: '日志',
243 feed: '🍓喂食',
244 play: '🎾玩耍',
245 clean: '🧼清洁',
246 sleep: '🌙睡觉',
247 wake: '🌞叫醒',
248 heal: '💊吃药',
249 focused: 'Tab 切换按钮 · Enter 按下 · Esc 回到输入框',
250 unfocused: 'ctrl+x tab 选中面板后按字母键 · ctrl+x x 关闭',
251 short: { fullness: '温饱', happiness: '心情', energy: '精力' },
252 settings: {
253 open: '⚙ 设置',
254 title: '⚙ 设置',
255 back: '← 返回',
256 species: '物种',
257 place: '位置',
258 band: '小栏',
259 buttons: '按钮',
260 keys: '数字键',
261 skin: '皮肤',
262 lang: '语言',
263 name: '名字',
264 namePlaceholder: '输入名字后回车',
265 reset: '重新开始',
266 resetAsk: '从蛋开始…',
267 resetSure: '确定吗?现在的宠物会变回一颗蛋',
268 resetYes: '确定',
269 cancel: '取消',
270 above: '输入框上方',
271 below: '输入框下方',
272 full: '像素',
273 mini: '精简',
274 hidden: '隐藏',
275 show: '显示',
276 hide: '隐藏',
277 enabled: '开',
278 disabled: '关',
279 color: '彩色',
280 lcd: '液晶',
281 auto: '自动',
282 hint: '点选项立即生效并保存。Tab 移动 · Enter 选择 · b 返回 · Esc 关闭面板',
283 },
284}
285
286const en: Text = {
287 defaultName: 'Pip',
288 stage: { egg: 'egg', baby: 'baby', child: 'child', teen: 'teen', adult: 'adult' },
289 verb: {
290 idle: 'is idle',
291 thinking: 'is thinking',
292 reading: 'is reading',
293 searching: 'is searching',
294 writing: 'is writing',
295 running: 'is running',
296 delegating: 'sent a helper to',
297 browsing: 'is looking up',
298 planning: 'is planning',
299 working: 'is using',
300 asking: 'is waiting for your answer',
301 approving: 'is waiting for plan approval',
302 done: 'just finished a turn',
303 error: 'hit a snag',
304 },
305 care: {
306 egg: ['… (the egg wobbles: let Claude work a bit more and it will hatch)'],
307 asleep: ['Zzz… (asleep: {sleep} to wake me)'],
308 full: ["I'm full!"],
309 fed: ['Yum!', 'Nom nom~', 'One more bite!', 'Thanks for the snack (*^ω^*)'],
310 tired: ['So tired… sleepy'],
311 played: ['Yay!', 'Again, again!', 'Got you~', 'Hehehe'],
312 clean: ['Already squeaky clean'],
313 cleaned: ['So fresh! ✧'],
314 healed: ['Bitter… but I feel better!', 'Gulp. Much better~', 'Thanks for looking after me (。•ᴗ•。)'],
315 healthy: ["I'm healthy, no medicine for me"],
316 sickPlay: ['Too sick to play… ({heal} for my medicine)'],
317 wake: ['Good morning~'],
318 night: ['Good night…'],
319 },
320 chatter: (pet, mood, hour) => {
321 if (mood === 'hungry') return pick(['Hungry… ({feed} to feed me)', '*tummy rumbles*'])
322 if (mood === 'sick') return pick(['Not feeling great… ({heal} for medicine)', pet.poop > 0 ? 'Ugh… it stinks ({clean} to clean, {heal} for medicine)' : 'Ugh…'])
323 if (mood === 'sad') return pick(['Play with me a little? ({play})', 'Kinda bored…'])
324 if (pet.poop > 0) return 'Something smells over there…'
325 if (hour >= 0 && hour < 6) return pick(['Still coding this late? Get some sleep', "It's late, take a rest~"])
326 if (hour >= 6 && hour < 10) return pick(['Morning! What are we building today?', 'Coffee first ☕'])
327 if (hour >= 12 && hour < 14) return 'Had lunch yet?'
328 return pick([
329 "I'm right here with you~",
330 'You are doing great today',
331 'Time for a stretch?',
332 'Drink some water',
333 `I've watched Claude use ${pet.toolsSeen} tools so far!`,
334 'Hehe',
335 ])
336 },
337 age: minutes =>
338 minutes < 60 ? `${Math.max(1, Math.round(minutes))} min` : minutes < 48 * 60 ? `${Math.round(minutes / 60)} h` : `${Math.round(minutes / 1440)} days`,
339 hatched: name => `🥚 ${name} hatched!`,
340 grew: (name, stage) => `✨ ${name} grew up: ${stage}!`,
341 hello: name => `Hi there! I'm ${name}~`,
342 grownUp: 'I grew up!',
343 wokeUp: name => `${name} woke up`,
344 pooped: name => `${name} pooped`,
345 dozedOff: name => `${name} got sleepy and dozed off`,
346 dozing: ['So sleepy… nap time', 'Zzz… good night', 'My eyelids are heavy…'],
347 rested: name => `${name} slept well and woke up`,
348 morning: ['All rested~', '*stretches* Good morning!', 'Full of energy!'],
349 hungry: name => `${name} is hungry: {feed} to feed`,
350 toolFailed: (tool, name) => `Claude's ${tool} failed, ${name} is a little worried`,
351 turnDone: (secs, tools) => `Claude finished a turn: ${secs}s, ${tools} tools`,
352 longTurn: (secs, tools) => `Nice work! Claude was busy for ${secs}s and used ${tools} tools`,
353 okay: 'Done~',
354 aborted: 'That turn was interrupted',
355 turnError: 'Claude hit an error this turn',
356 cheer: "Don't give up, try again!",
357 rolledOver: '(rolls over)',
358 command: 'Your pet: open its pane, where care and settings live',
359 nameUsage: 'Usage: /pet name <name>',
360 renamed: name => `Your pet is now called ${name}`,
361 myName: name => `I'm ${name} now!`,
362 band: mode =>
363 `Above the prompt: ${{ full: 'pixel pet', mini: 'one line', hidden: 'hidden' }[mode]} (/pet band full|mini|hidden)`,
364 place: place =>
365 place === 'above'
366 ? 'The pet sits above the prompt (/pet place below to move it under)'
367 : 'The pet moved under the prompt. Digit keys work only above it: click the buttons or use /pet feed and friends here (/pet place above to move it back)',
368 speciesUsage: lines => `Usage: /pet species <id>\n${lines.join('\n')}`,
369 speciesLine: (id, name, description, isDefault) => `${id} (${name}${isDefault ? ', default' : ''}): ${description}`,
370 mochi: { name: 'Mochi', description: 'the first design, a round orange blob' },
371 became: (pet, species) => `${pet} is now a ${species}`,
372 keys: isOn =>
373 isOn
374 ? 'Digit keys on: in an empty prompt press 1 feed · 2 play · 3 clean · 4 sleep'
375 : 'Digit keys off: digits type as usual, the buttons still take clicks (/pet keys on to turn back on)',
376 how: (digit, command) => (digit === null ? command : `press ${digit}`),
377 says: (name, line) => `${name}: ${line}`,
378 buttons: isOn =>
379 isOn
380 ? 'Band buttons shown'
381 : 'Band buttons hidden, and the digit keys with them (/pet buttons on to bring them back, or use /pet feed and friends)',
382 skin: skin => (skin === 'lcd' ? 'Switched to the Tamagotchi LCD skin' : 'Back to the colour skin'),
383 lang: lang => (lang === 'zh' ? '已切换为中文' : 'Switched to English'),
384 langUsage: "Usage: /pet lang zh|en|auto (auto follows Claude Code's language setting)",
385 resetConfirm: 'This starts your pet over from an egg. To go ahead: /pet reset confirm',
386 newEgg: 'A new egg appeared',
387 eggReady: 'A fresh egg is ready 🥚',
388 status: (pet, stage, doing) =>
389 `${pet.name} (${stage}) food ${Math.round(pet.fullness)} · mood ${Math.round(pet.happiness)} · ` +
390 `energy ${Math.round(pet.energy)} · xp ${pet.xp} · Claude ${doing}`,
391 paneTitle: 'Pet',
392 tooNarrow: 'The terminal is too narrow for the pet pane: widen it and try /pet again',
393 fullness: 'Food ',
394 happiness: 'Mood ',
395 energy: 'Energy',
396 xp: 'XP',
397 grown: ' · fully grown',
398 toGrow: n => ` · ${n} to grow`,
399 doing: 'What Claude is doing',
400 turnTools: n => ` · ${n} tools this turn`,
401 tools: n => ` · ${n} tools`,
402 log: 'Log',
403 feed: '🍓Feed',
404 play: '🎾Play',
405 clean: '🧼Clean',
406 sleep: '🌙Sleep',
407 wake: '🌞Wake',
408 heal: '💊Medicine',
409 focused: 'Tab moves · Enter presses · Esc back to the prompt',
410 unfocused: 'ctrl+x tab to focus, then the letter keys · ctrl+x x closes',
411 short: { fullness: 'Food', happiness: 'Mood', energy: 'Energy' },
412 settings: {
413 open: '⚙ Settings',
414 title: '⚙ Settings',
415 back: '← Back',
416 species: 'Species',
417 place: 'Place',
418 band: 'Band',
419 buttons: 'Buttons',
420 keys: 'Digits',
421 skin: 'Skin',
422 lang: 'Language',
423 name: 'Name',
424 namePlaceholder: 'Type a name, then Enter',
425 reset: 'Start over',
426 resetAsk: 'From an egg…',
427 resetSure: 'Sure? Your pet turns back into an egg',
428 resetYes: 'Yes',
429 cancel: 'Cancel',
430 above: 'Above',
431 below: 'Below',
432 full: 'Pixels',
433 mini: 'One line',
434 hidden: 'Hidden',
435 show: 'Show',
436 hide: 'Hide',
437 enabled: 'On',
438 disabled: 'Off',
439 color: 'Colour',
440 lcd: 'LCD',
441 auto: 'Auto',
442 hint: 'Choices apply and save at once. Tab moves · Enter picks · b back · Esc closes',
443 },
444}
445
446export const TEXT: Record<Lang, Text> = { zh, en }
447
448/** A care answer in the pet's language. */
449export function careLine(lang: Lang, line: Line): string {
450 return pick(TEXT[lang].care[line])
451}
452hooks/species/types.ts 47 lines1// The contract a species of pet fills in. A species is pixel art: one bitmap
2// per stage (a second one for its idle animation), and where the face goes.
3// The engine draws the eyes and mouth on top for every mood, mirrors the
4// bitmap when the pet walks left, tints it when sick, and maps it to the
5// green LCD skin by brightness, so a species needs none of that.
6
7export type SpeciesStage = {
8 /**
9 * Rows of palette letters, '.' clear, all rows one width. The bottom row
10 * stands on the ground. At most 22 wide and 15 tall, so the stage fits the
11 * band's 16-pixel scene with a pixel to hop.
12 */
13 body: readonly string[]
14 /** Optional second frame, same size: breathing, a wag, a bob. */
15 alt?: readonly string[]
16 /** Top-left pixel of each 2×2 eye, as [x, y] in `body`; usually two. */
17 eyes: readonly (readonly [number, number])[]
18 /** Left pixel of the 2-wide mouth, as [x, y] in `body`, below the eyes. */
19 mouth: readonly [number, number]
20}
21
22export type Species = {
23 /** Lowercase id, what `/pet species <id>` takes. */
24 id: string
25 /** Display name in Chinese. */
26 name: string
27 /** One line about the design, in Chinese. */
28 description: string
29 /** English display name; the id, capitalised, when absent. */
30 nameEn?: string
31 /** The same line in English. */
32 descriptionEn?: string
33 /**
34 * Palette: letter → 0xRRGGBB. Letters are the species' own; '.' is clear.
35 * Leave the eyes and mouth out of the bitmaps: the engine draws them.
36 */
37 colors: Readonly<Record<string, number>>
38 /** The letters the face may be drawn over (the skin around the eyes and mouth). */
39 face: string
40 stages: {
41 baby: SpeciesStage
42 child: SpeciesStage
43 teen: SpeciesStage
44 adult: SpeciesStage
45 }
46}
47hooks/species/sprig.ts 153 lines1import type { Species } from './types'
2
3// Concept: docs/concepts/sprig.png. H is the upper-left highlight;
4// O is the warm outline. Facial features are supplied entirely by the engine.
5export const sprig: Species = {
6 id: 'sprig',
7 name: '芽啾',
8 description: '一颗会啾啾的小芽,长成顶着双叶、摇着短尾的奶油小鸟。',
9 nameEn: 'Sprig',
10 descriptionEn: 'A chirping little sprout that grows into a cream bird with two leaves on top and a stubby tail.',
11 colors: {
12 O: 0x49362f,
13 H: 0xfff2ce,
14 B: 0xf2c572,
15 S: 0xaa7450,
16 L: 0x98c993,
17 P: 0xefaa85,
18 },
19 face: 'H',
20 stages: {
21 baby: {
22 body: [
23 '......OO.....',
24 '.....OLO.....',
25 '...OOOBOOO...',
26 '..OHHHHHHHOO.',
27 '.OHHHHHHHHHBO',
28 '.OHHHHHHHHHBO',
29 '.OHHHHHHHHHBO',
30 '..OHHHHHHHBOO',
31 '...OPSOSPSO..',
32 '...OOO.OOO...',
33 ],
34 alt: [
35 '.....OO......',
36 '.....OLO.....',
37 '...OOOBOOO...',
38 '..OHHHHHHHOO.',
39 '.OHHHHHHHHHBO',
40 '.OHHHHHHHHHBO',
41 '.OHHHHHHHHHBO',
42 '..OHHHHHHHBOO',
43 '...OPSOSPSO..',
44 '...OOO.OOO...',
45 ],
46 eyes: [[3,4],[7,4]],
47 mouth: [5,6],
48 },
49 child: {
50 body: [
51 '....OO..OO.....',
52 '...OLLOOLLO....',
53 '....OOLLOO.....',
54 '...OOOBBOOO....',
55 '..OHHHHHHHBO...',
56 '.OHHHHHHHHHBO..',
57 '.OHHHHHHHHHBO..',
58 'OBHHHHHHHHHBBO.',
59 'OBSHHHHHHHHBSO.',
60 '.OOBPSOOSPBOO..',
61 '...OOO..OOO....',
62 ],
63 alt: [
64 '...OO....OO....',
65 '...OLOO.OLLO...',
66 '....OOLOOOO....',
67 '...OOOBBOOO....',
68 '..OHHHHHHHBO...',
69 '.OHHHHHHHHHBO..',
70 '.OHHHHHHHHHBO..',
71 'OBHHHHHHHHHBBO.',
72 'OBSHHHHHHHHBSO.',
73 '.OOBPSOOSPBOO..',
74 '...OOO..OOO....',
75 ],
76 eyes: [[3,5],[7,5]],
77 mouth: [5,7],
78 },
79 teen: {
80 body: [
81 '....OO...OO........',
82 '...OLLO.OLLO.......',
83 '....OLLOLLO........',
84 '.....OOLOO.........',
85 '....OOBBOOO........',
86 '...OHHHHHHHOO......',
87 '..OHHHHHHHHHBO.....',
88 '..OHHHHHHHHHBO.OO..',
89 '.OBHHHHHHHHHBBOBO..',
90 '.OBSHHHHHHHHHBSOO..',
91 '..OBBHHHHHBBSSO....',
92 '...OBPSOOSPBOO.....',
93 '....OOO..OOO.......',
94 ],
95 alt: [
96 '....OO...OO........',
97 '...OLLO.OLLO.......',
98 '....OLLOLLO........',
99 '.....OOLOO.........',
100 '....OOBBOOO........',
101 '...OHHHHHHHOO......',
102 '..OHHHHHHHHHBO.....',
103 '..OHHHHHHHHHBO.....',
104 '.OBHHHHHHHHHBBOOO..',
105 '.OBSHHHHHHHHBBSOO..',
106 '..OBBHHHHHBBSSO....',
107 '...OBPSOOSPBOO.....',
108 '....OOO..OOO.......',
109 ],
110 eyes: [[4,6],[8,6]],
111 mouth: [6,8],
112 },
113 adult: {
114 body: [
115 '....OOO...OOO.......',
116 '...OLLLO.OLLLO......',
117 '....OLLLOLLLO.......',
118 '.....OOOLOOO........',
119 '.....OOOBOOO........',
120 '....OHHHHHHHOO......',
121 '...OBHHHHHHHHBO.....',
122 '..OBHHHHHHHHHBBO.OO.',
123 '..OBHHHHHHHHHBBBOHO.',
124 '.OBBHHHHHHHHHBBSBO..',
125 '.OBSBHHHHHHHHBSSBO..',
126 '..OSBBHHHHHHBBSSO...',
127 '...OBBBSSSSBBBBO....',
128 '....OOPOOOOPOOO.....',
129 '.....OOO..OOO.......',
130 ],
131 alt: [
132 '...OOO.....OOO......',
133 '...OLLOO..OLLLO.....',
134 '....OLLLOOLLOO......',
135 '.....OOOLOOO........',
136 '.....OOOBOOO........',
137 '....OHHHHHHHOO......',
138 '...OBHHHHHHHHBO.....',
139 '..OBHHHHHHHHHBBO....',
140 '..OBHHHHHHHHHBBBOOO.',
141 '.OBBHHHHHHHHHBBSBHO.',
142 '.OBSBHHHHHHHHBSSBO..',
143 '..OSBBHHHHHHBBSSO...',
144 '...OBBBSSSSBBBBO....',
145 '....OOPOOOOPOOO.....',
146 '.....OOO..OOO.......',
147 ],
148 eyes: [[5,7],[9,7]],
149 mouth: [7,9],
150 },
151 },
152}
153hooks/species/mallow.ts 152 lines1import type { Species } from './types'
2
3// Concept: docs/concepts/mallow.png. H is the upper-left highlight;
4// O is the warm outline. Facial features are supplied entirely by the engine.
5export const mallow: Species = {
6 id: 'mallow',
7 name: '桃绒狐',
8 description: '花瓣耳的小绒团,越长大,奶油尾尖的蓬松卷尾越爱轻轻摇。',
9 nameEn: 'Mallow',
10 descriptionEn: 'A fluffy kit with petal ears; the older it gets, the more its cream-tipped curly tail wags.',
11 colors: {
12 O: 0x49303e,
13 H: 0xffefdb,
14 B: 0xf6b5a4,
15 S: 0xb97886,
16 T: 0x87566e,
17 },
18 face: 'H',
19 stages: {
20 baby: {
21 body: [
22 '..OO.....OO.....',
23 '.OBBO...OBBO....',
24 '.OBTBOOOBTBO....',
25 '.OBHHHHHHHBO....',
26 'OBHHHHHHHHHBO...',
27 'OBHHHHHHHHHBOOO.',
28 '.OHHHHHHHHHOHHO.',
29 '..OHHHHHHHOOBTO.',
30 '..OBBHHBBBOBTO..',
31 '...OOOOOOOOOO...',
32 ],
33 alt: [
34 '..OO.....OO.....',
35 '.OBBO...OBBO....',
36 '.OBTBOOOBTBO....',
37 '.OBHHHHHHHBO....',
38 'OBHHHHHHHHHBO...',
39 'OBHHHHHHHHHBO...',
40 '.OHHHHHHHHHOOOO.',
41 '..OHHHHHHHOHHHO.',
42 '..OBBHHBBBOBTO..',
43 '...OOOOOOOOOO...',
44 ],
45 eyes: [[3,4],[7,4]],
46 mouth: [5,6],
47 },
48 child: {
49 body: [
50 '..OO......OO......',
51 '.OBBO....OBBO.....',
52 '.OBTBO..OBTBO.....',
53 '.OBTBBOOBBTBO.....',
54 '..OBHHHHHHBO......',
55 '.OBHHHHHHHHBO.OO..',
56 'OBHHHHHHHHHHOOHHO.',
57 '.OBHHHHHHHHBOHBTO.',
58 '..OHHHHHHHHOBTTO..',
59 '..OBBHHHBBBOBTOO..',
60 '...OOOOOOOOOOO....',
61 ],
62 alt: [
63 '..OO......OO......',
64 '.OBBO....OBBO.....',
65 '.OBTBO..OBTBO.....',
66 '.OBTBBOOBBTBO.....',
67 '..OBHHHHHHBO......',
68 '.OBHHHHHHHHBO.....',
69 'OBHHHHHHHHHHOOOO..',
70 '.OBHHHHHHHHBOHHHO.',
71 '..OHHHHHHHHOHBTTO.',
72 '..OBBHHHBBBOBTOO..',
73 '...OOOOOOOOOOO....',
74 ],
75 eyes: [[4,5],[8,5]],
76 mouth: [6,7],
77 },
78 teen: {
79 body: [
80 '..OO.......OO.......',
81 '.OBBO.....OBBO......',
82 '.OBTBO...OBTBO......',
83 '.OBTTBO.OBTTBO......',
84 '..OBBBOOOBBBO.......',
85 '..OBHHHHHHHBO.......',
86 '.OBHHHHHHHHHBO..OO..',
87 'OBBHHHHHHHHHBBOOHHO.',
88 '.OBHHHHHHHHHBOHHBTO.',
89 '..OHHHHHHHHHOBBTTTO.',
90 '...OBBHHHBBBOBTTOO..',
91 '...OBBHHHBBBOTTO....',
92 '....OOOOOOOOOOO.....',
93 ],
94 alt: [
95 '..OO.......OO.......',
96 '.OBBO.....OBBO......',
97 '.OBTBO...OBTBO......',
98 '.OBTTBO.OBTTBO......',
99 '..OBBBOOOBBBO.......',
100 '..OBHHHHHHHBO.......',
101 '.OBHHHHHHHHHBO......',
102 'OBBHHHHHHHHHBBOOOO..',
103 '.OBHHHHHHHHHBOHHHHO.',
104 '..OHHHHHHHHHOBBTTTO.',
105 '...OBBHHHBBBOBTTOO..',
106 '...OBBHHHBBBOTTO....',
107 '....OOOOOOOOOOO.....',
108 ],
109 eyes: [[4,6],[8,6]],
110 mouth: [6,8],
111 },
112 adult: {
113 body: [
114 '..OO........OO.......',
115 '.OBBO......OBBO......',
116 '.OBHBO....OBHBO......',
117 '.OBTTBO..OBTTBO......',
118 '..OBTBBOOBBTBO.......',
119 '..OBBBHHBBBBO........',
120 '..OBHHHHHHHHBO..OO...',
121 '.OBHHHHHHHHHHBOOHHO..',
122 'OBBHHHHHHHHHHBBOHHHO.',
123 '.OBHHHHHHHHHHBOHBTTO.',
124 '..OHHHHHHHHHHOBTTTTO.',
125 '...OBBHHHHBBBOBTTTO..',
126 '...OBSHHHHBSBOBTTO...',
127 '...OBBSHHSBBBOOOO....',
128 '....OOOOOOOOOO.......',
129 ],
130 alt: [
131 '..OO........OO.......',
132 '.OBBO......OBBO......',
133 '.OBHBO....OBHBO......',
134 '.OBTTBO..OBTTBO......',
135 '..OBTBBOOBBTBO.......',
136 '..OBBBHHBBBBO........',
137 '..OBHHHHHHHHBO.......',
138 '.OBHHHHHHHHHHBO.OOO..',
139 'OBBHHHHHHHHHHBBOHHHO.',
140 '.OBHHHHHHHHHHBOHHBTO.',
141 '..OHHHHHHHHHHOBTTTTO.',
142 '...OBBHHHHBBBOBTTTO..',
143 '...OBSHHHHBSBOBTTO...',
144 '...OBBSHHSBBBOOOO....',
145 '....OOOOOOOOOO.......',
146 ],
147 eyes: [[5,7],[9,7]],
148 mouth: [7,9],
149 },
150 },
151}
152hooks/species/dewdrop.ts 152 lines1import type { Species } from './types'
2
3// Concept: docs/concepts/dewdrop.png. H is the upper-left highlight;
4// O is the warm outline. Facial features are supplied entirely by the engine.
5export const dewdrop: Species = {
6 id: 'dewdrop',
7 name: '滴露灵',
8 description: '软软的小露珠长出歪歪水滴帽,踩着三瓣水花慢悠悠地晃。',
9 nameEn: 'Dewdrop',
10 descriptionEn: 'A soft little dewdrop that grows a lopsided drop hat and bobs along on three-petal splashes.',
11 colors: {
12 O: 0x45344c,
13 H: 0xe5fff3,
14 F: 0xbef1df,
15 B: 0x67c7c1,
16 S: 0x437d87,
17 },
18 face: 'F',
19 stages: {
20 baby: {
21 body: [
22 '......O......',
23 '.....OHO.....',
24 '...OOHBOOO...',
25 '..OHFFFFFFOO.',
26 '.OFFFFFFFFFBO',
27 '.OFFFFFFFFFBO',
28 '.OFFFFFFFFFBO',
29 '.OBFFFFFFFFBO',
30 '..OBSBOBBSOO.',
31 '...OOO.OOO...',
32 ],
33 alt: [
34 '.....O.......',
35 '.....OOO.....',
36 '...OOHBOOO...',
37 '..OHFFFFFFOO.',
38 '.OFFFFFFFFFBO',
39 '.OFFFFFFFFFBO',
40 '.OFFFFFFFFFBO',
41 '.OBFFFFFFFFBO',
42 '.OOBSBOBBBOOO',
43 '...OOO.OOO...',
44 ],
45 eyes: [[3,4],[7,4]],
46 mouth: [5,6],
47 },
48 child: {
49 body: [
50 '.....OOO.......',
51 '....OHBBO......',
52 '....OHBO.......',
53 '...OHBBOOO.....',
54 '..OHFFFFFFOO...',
55 '.OFFFFFFFFFBO..',
56 '.OFFFFFFFFFBO..',
57 'OHFFFFFFFFFBBO.',
58 'OSBFFFFFFFFBSO.',
59 '.OBBSOBBOBSBO..',
60 '..OOO.OO.OOO...',
61 ],
62 alt: [
63 '....OOO........',
64 '...OHBBO.......',
65 '...OHBBO.......',
66 '...OHBBOOO.....',
67 '..OHFFFFFFOO...',
68 '.OFFFFFFFFFBO..',
69 '.OFFFFFFFFFBO..',
70 'OBFFFFFFFFFBHO.',
71 '.OBFFFFFFFFBO..',
72 '.OBBSOBBOBSBO..',
73 '..OOO.OO.OOO...',
74 ],
75 eyes: [[3,5],[7,5]],
76 mouth: [5,7],
77 },
78 teen: {
79 body: [
80 '.....OOOO.........',
81 '....OHHBBO........',
82 '...OHOOBBBO.......',
83 '...OO.OBBBO.......',
84 '.....OHHBBBO......',
85 '....OHFFFFFFO.....',
86 '...OFFFFFFFFFO....',
87 '..OBFFFFFFFFFBO...',
88 '.OHBFFFFFFFFFBBO..',
89 '.OSBBFFFFFFFFBSO..',
90 '..OBBFFFFFFFFBO...',
91 '...OBSBOBSOBSO....',
92 '....OOO.OO.OO.....',
93 ],
94 alt: [
95 '....OOOO..........',
96 '...OHHBBOO........',
97 '...OHOOBBBO.......',
98 '....O.OBBBO.......',
99 '.....OHHBBBO......',
100 '....OHFFFFFFO.....',
101 '...OFFFFFFFFFO....',
102 '..OBFFFFFFFFFBO...',
103 '.OBBFFFFFFFFFBHO..',
104 '..OBBFFFFFFFFBO...',
105 '..OBBFFFFFFFFBO...',
106 '...OBSBOBSOBSO....',
107 '....OOO.OO.OO.....',
108 ],
109 eyes: [[5,6],[9,6]],
110 mouth: [7,8],
111 },
112 adult: {
113 body: [
114 '.....OOOOO.........',
115 '....OHHHBBO........',
116 '...OHBOBBBBO.......',
117 '...OHO.OBBBBO......',
118 '....O..OHBBBO......',
119 '......OHHBBBBO.....',
120 '.....OHFFFFFFBO....',
121 '....OFFFFFFFFFBO...',
122 '...OBFFFFFFFFFBBO..',
123 '..OHBFFFFFFFFFBBBO.',
124 '..OSBBFFFFFFFFBBSO.',
125 '...OBBFFFFFFFFBBO..',
126 '...OBBSSBBBBSSBOO..',
127 '....OBBOBBOBBBO....',
128 '.....OO.OO.OOO.....',
129 ],
130 alt: [
131 '....OOOOO..........',
132 '...OHHHBBO.........',
133 '..OHBOOBBBO........',
134 '..OHO..OBBBOO......',
135 '...O...OHBBBO......',
136 '......OHHBBBBO.....',
137 '.....OHFFFFFFBO....',
138 '....OFFFFFFFFFBO...',
139 '...OBFFFFFFFFFBBO..',
140 '..OBBFFFFFFFFFBBHO.',
141 '...OBBFFFFFFFFBBO..',
142 '...OBBFFFFFFFFBBO..',
143 '...OBBSSBBBBSSBOO..',
144 '....OBBOBBOBBBO....',
145 '.....OO.OO.OOO.....',
146 ],
147 eyes: [[6,7],[10,7]],
148 mouth: [8,9],
149 },
150 },
151}
152hooks/species/plumcap.ts 152 lines1import type { Species } from './types'
2
3// Concept: docs/concepts/plumcap.png. The face is deliberately blank;
4// the engine supplies all eyes, blush and mouths in both animation frames.
5export const plumcap: Species = {
6 id: 'plumcap',
7 name: '绒伞菇',
8 description: '顶着紫莓绒伞的小蘑菇,长大后伞沿舒展成软软的波浪。',
9 nameEn: 'Plumcap',
10 descriptionEn: 'A little mushroom under a plush plum cap that grows into a softly scalloped umbrella.',
11 colors: {
12 O: 0x48273f,
13 B: 0xc779ae,
14 S: 0x874969,
15 F: 0xeacbf1,
16 H: 0xffe8f9,
17 },
18 face: 'F',
19 stages: {
20 baby: {
21 body: [
22 '.....OOOOO.....',
23 '...OOBBBBBOO...',
24 '.OOBHHBBBBBBOO.',
25 'OBBHBBBBBBBBBBO',
26 'OOSSSSSSSSSSSOO',
27 '..OFFFFFFFFFO..',
28 '..OFFFFFFFFFO..',
29 '..OFFFFFFFFFO..',
30 '..OFFFFFFFFFO..',
31 '...OOOOOOOOO...',
32 ],
33 alt: [
34 '......OOOOO....',
35 '....OOBBBBBOO..',
36 '..OOBHHBBBBBBOO',
37 'OOBHBBBBBBBBBBO',
38 'OOSSSSSSSSSSSOO',
39 '..OFFFFFFFFFO..',
40 '..OFFFFFFFFFO..',
41 '..OFFFFFFFFFO..',
42 '..OFFFFFFFFFO..',
43 '...OOOOOOOOO...',
44 ],
45 eyes: [[4, 5], [8, 5]],
46 mouth: [6, 7],
47 },
48 child: {
49 body: [
50 '......OOOOO......',
51 '....OOBBBBBOO....',
52 '..OOBHHBBBBBBOO..',
53 '.OBBHBBBBBBBHBBO.',
54 'OBBBBBBBBBBBBBBBO',
55 'OOOSSSSSSSSSSSOOO',
56 '...OFFFFFFFFFO...',
57 '...OFFFFFFFFFO...',
58 '...OFFFFFFFFFO...',
59 '...OFFFFFFFFFO...',
60 '....OOOOOOOOO....',
61 ],
62 alt: [
63 '.......OOOOO.....',
64 '.....OOBBBBBOO...',
65 '...OOBHHBBBBBBOO.',
66 '..OBBHBBBBBBBHBBO',
67 'OOBBBBBBBBBBBBBBO',
68 'OOOSSSSSSSSSSSOOO',
69 '...OFFFFFFFFFO...',
70 '...OFFFFFFFFFO...',
71 '...OFFFFFFFFFO...',
72 '...OFFFFFFFFFO...',
73 '....OOOOOOOOO....',
74 ],
75 eyes: [[5, 6], [9, 6]],
76 mouth: [7, 8],
77 },
78 teen: {
79 body: [
80 '.......OOOOO.......',
81 '.....OOBBBBBOO.....',
82 '...OOBBBBBBBHHOO...',
83 '..OBBHHBBBBBBBBBO..',
84 '.OBBHBBBBBBBBBBBBO.',
85 'OBBBBBBBBBBBBBBBBBO',
86 'OSSSSSSSSSSSSSSSSSO',
87 '.OOOFFFFFFFFFFFOOO.',
88 '....OFFFFFFFFFO....',
89 '....OFFFFFFFFFO....',
90 '...OFFFFFFFFFFFO...',
91 '....OFFFFFFFFFO....',
92 '.....OOOOOOOOO.....',
93 ],
94 alt: [
95 '........OOOOO......',
96 '......OOBBBBBOO....',
97 '....OOBBBBBBBHHOO..',
98 '...OBBHHBBBBBBBBBO.',
99 '..OBBHBBBBBBBBBBBBO',
100 'OOBBBBBBBBBBBBBBBBO',
101 'OSSSSSSSSSSSSSSSSSO',
102 '.OOOFFFFFFFFFFFOOO.',
103 '....OFFFFFFFFFO....',
104 '....OFFFFFFFFFO....',
105 '...OFFFFFFFFFFFO...',
106 '....OFFFFFFFFFO....',
107 '.....OOOOOOOOO.....',
108 ],
109 eyes: [[6, 8], [10, 8]],
110 mouth: [8, 10],
111 },
112 adult: {
113 body: [
114 '.......OOOOOOO.......',
115 '.....OOBBBBBBBOO.....',
116 '....OBBBBBBBHHBBO....',
117 '...OBBHHBBBBBBBBBO...',
118 '..OBBBHBBBBBBBBBBBO..',
119 '.OBBHBBBBBBBBBBBBBBO.',
120 'OBBBBBBBBBBBBBBBBBBBO',
121 'OSSOSSSSSSSSSSSSSOSSO',
122 '.OO.OFFFFFFFFFFFO.OO.',
123 '.....OFFFFFFFFFO.....',
124 '.....OFFFFFFFFFO.....',
125 '....OFFFFFFFFFFFO....',
126 '....OFFFFFFFFFFFO....',
127 '.....OFFFFFFFFFO.....',
128 '......OOOOOOOOO......',
129 ],
130 alt: [
131 '........OOOOOOO......',
132 '......OOBBBBBBBOO....',
133 '.....OBBBBBBBHHBBO...',
134 '....OBBHHBBBBBBBBBO..',
135 '...OBBBHBBBBBBBBBBBO.',
136 '..OBBHBBBBBBBBBBBBBBO',
137 'OOBBBBBBBBBBBBBBBBBBO',
138 'OSSOSSSSSSSSSSSSSOSSO',
139 '.OO.OFFFFFFFFFFFO.OO.',
140 '.....OFFFFFFFFFO.....',
141 '.....OFFFFFFFFFO.....',
142 '....OFFFFFFFFFFFO....',
143 '....OFFFFFFFFFFFO....',
144 '.....OFFFFFFFFFO.....',
145 '......OOOOOOOOO......',
146 ],
147 eyes: [[7, 9], [11, 9]],
148 mouth: [9, 11],
149 },
150 },
151}
152hooks/species/luma.ts 153 lines1import type { Species } from './types'
2
3// Concept: docs/concepts/luma.png. The face is deliberately blank;
4// the engine supplies all eyes, blush and mouths in both animation frames.
5export const luma: Species = {
6 id: 'luma',
7 name: '月绒蛾',
8 description: '银蓝小绒虫慢慢展开月牙纹圆翅,像一只会扑扇的睡前蝴蝶结。',
9 nameEn: 'Luma',
10 descriptionEn: 'A silver-blue fluff grows rounded, moon-marked wings and flutters like a tiny bedtime bow.',
11 colors: {
12 O: 0x3f334e,
13 W: 0x7583c9,
14 S: 0x535d91,
15 B: 0xb4bbde,
16 F: 0xcbd7f0,
17 H: 0xf4f4ff,
18 },
19 face: 'F',
20 stages: {
21 baby: {
22 body: [
23 '...OO.....OO...',
24 '....OO...OO....',
25 '....OBOOOBO....',
26 '..OOFFFFFFBOO..',
27 '.OHFFFFFFFFBHO.',
28 'OWSFFFFFFFFBSWO',
29 'OWSFFFFFFFFBSWO',
30 '.OSFFFFFFFFBSO.',
31 '..OBBBOOBBBBO..',
32 '...OOO..OOOO...',
33 ],
34 alt: [
35 '...OO.....OO...',
36 '....OO...OO....',
37 '....OBOOOBO....',
38 '..OOFFFFFFBOO..',
39 '.OHFFFFFFFFBHO.',
40 '.OSFFFFFFFFBSO.',
41 '.OSFFFFFFFFBSO.',
42 '.OSFFFFFFFFBSO.',
43 '..OBBBOOBBBBO..',
44 '...OOO..OOOO...',
45 ],
46 eyes: [[4, 4], [8, 4]],
47 mouth: [6, 6],
48 },
49 child: {
50 body: [
51 '....OO.....OO....',
52 '.....OO...OO.....',
53 '.OOO.OBOOOBO.OOO.',
54 'OWHHOFFFFFFBOHHWO',
55 'OWHWFFFFFFFFBWHWO',
56 'OWSWFFFFFFFFBWSWO',
57 'OWSWFFFFFFFFBWSWO',
58 '.OSWFFFFFFFFBWSO.',
59 '..OWBBBBBBBBBWO..',
60 '...OOBBBOBBBOO...',
61 '.....OOO.OOO.....',
62 ],
63 alt: [
64 '....OO.....OO....',
65 '.....OO...OO.....',
66 '.OOO.OBOOOBO.OOO.',
67 '.OHHOFFFFFFBOHHO.',
68 '.OHWFFFFFFFFBWHO.',
69 '.OSWFFFFFFFFBWSO.',
70 '.OSWFFFFFFFFBWSO.',
71 '.OSWFFFFFFFFBWSO.',
72 '..OWBBBBBBBBBWO..',
73 '...OOBBBOBBBOO...',
74 '.....OOO.OOO.....',
75 ],
76 eyes: [[5, 4], [9, 4]],
77 mouth: [7, 6],
78 },
79 teen: {
80 body: [
81 '.....OO.....OO.....',
82 '.OOO..OO...OO..OOO.',
83 'OWWWO.OBOOOBO.OWWWO',
84 'OWHHWOBBBBBBBOWHHWO',
85 'OWHWSBFFFFFFBBSWHWO',
86 'OWSWSFFFFFFFFBSWSWO',
87 '.OSWSFFFFFFFFBSWSO.',
88 '.OSWSFFFFFFFFBSWSO.',
89 '..OWSFFFFFFFFBSWO..',
90 '.OWWWWSBFFFBSWWWWO.',
91 '.OWWWWSBBFBBSWWWWO.',
92 '..OOOOSBBOBBSOOOO..',
93 '......OOO.OOO......',
94 ],
95 alt: [
96 '.....OO.....OO.....',
97 '.OOO..OO...OO..OOO.',
98 'OWWWO.OBOOOBO.OWWWO',
99 '.OHHWOBBBBBBBOWHHO.',
100 '.OHWSBFFFFFFBBSWHO.',
101 '.OSWSFFFFFFFFBSWSO.',
102 '.OSWSFFFFFFFFBSWSO.',
103 '.OSWSFFFFFFFFBSWSO.',
104 '..OWSFFFFFFFFBSWO..',
105 '.OWWWWSBFFFBSWWWWO.',
106 '.OWWWWSBBFBBSWWWWO.',
107 '..OOOOSBBOBBSOOOO..',
108 '......OOO.OOO......',
109 ],
110 eyes: [[6, 5], [10, 5]],
111 mouth: [8, 7],
112 },
113 adult: {
114 body: [
115 '.OOO..OO.....OO..OOO.',
116 'OWWWO..OO...OO..OWWWO',
117 'OWWWWO.OBOOOBO.OWWWWO',
118 'OWHHWWOBBBBBBBOWWHHWO',
119 'OWHWWSBFFFFFFBBSWWHWO',
120 'OWSWWSFFFFFFFFBSWWSWO',
121 'OWSWWSFFFFFFFFBSWWSWO',
122 '.OSWWSFFFFFFFFBSWWSO.',
123 '..OWWSFFFFFFFFBSWWO..',
124 '...OWWWSBFFFBSWWWO...',
125 '.OOWWWWSBBFBBSWWWWOO.',
126 '.OWWWWWSBBBBBSWWWWWO.',
127 '..OWWWWSBBBBBSWWWWO..',
128 '...OOOOSBBOBBSOOOO...',
129 '.......OOO.OOO.......',
130 ],
131 alt: [
132 '.OOO..OO.....OO..OOO.',
133 'OWWWO..OO...OO..OWWWO',
134 'OWWWWO.OBOOOBO.OWWWWO',
135 '.OHHWWOBBBBBBBOWWHHO.',
136 '.OHWWSBFFFFFFBBSWWHO.',
137 '.OSWWSFFFFFFFFBSWWSO.',
138 '.OSWWSFFFFFFFFBSWWSO.',
139 '.OSWWSFFFFFFFFBSWWSO.',
140 '..OWWSFFFFFFFFBSWWO..',
141 '...OWWWSBFFFBSWWWO...',
142 '.OOWWWWSBBFBBSWWWWOO.',
143 '.OWWWWWSBBBBBSWWWWWO.',
144 '..OWWWWSBBBBBSWWWWO..',
145 '...OOOOSBBOBBSOOOO...',
146 '.......OOO.OOO.......',
147 ],
148 eyes: [[7, 5], [11, 5]],
149 mouth: [9, 7],
150 },
151 },
152}
153