SLOPSHOPPER

Pixipet

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

newpanebandspinnerguardcommand
v0.3.1MITupdated 2026-10-05VibeMage/claude-mod-pet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixipet
│ ┃ Pet ✕ › fix the failing auth test and add an audit log c╭────────────────╮ │ ┃ Pip · baby · 1 min o: ⚙ Sett │ pixipet │ │ ┃ 「Nice work! Claude was busy for 42s and use ⏺ Read(src/auth.ts) │ 🥚 Pip │ │ ┃ ⎿ Read 6 lines │ hatched! │ │ ┃ ⏺ Update(src/auth.ts) ╰────────────────╯ │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ▄▄▄▄▄▄▄▄▄ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /pet │ ┃ │ ┃ │ ┃ Food ████████░░ 80 │ ┃ Mood ████████░░ 82 │ ┃ Energy █████████░ 87 │ ┃ XP 11 · 49 to grow │ ┃ │ ┃ What Claude is doing │ ┃ ○ just finished a turn │ ┃ │ ┃ Log │ ┃ 01:53 Pip woke up ⟨Claude Code's own drawing⟩ Pip · baby · XP 11/60 ▄▀▀▀▀▀▀▀▀▀▄ Food ██████░░ Mood ███████░ Energy ███████░ ▀▀▀▀▀▀▀▀▀▀▀ ○ Claude just finished a turn ▄▀▀▀▄ ▄▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀ 「Nice work! Claude was busy for 42s and used 9 ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀ tools」 ▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄ ▀▀▀▀▀▀▀ 1: 🍓Feed 2: 🎾Play ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ Pip · baby · XP 11/60 ▄▀▀▀▀▀▀▀▀▀▄ Food ██████░░ Mood ███████░ Energy ███████░ ▀▀▀▀▀▀▀▀▀▀▀ ○ Claude just finished a turn ▄▀▀▀▄ ▄▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀ 「Nice work! Claude was busy for 42s and used 9 ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀ tools」 ▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄ ▀▀▀▀▀▀▀ 1: 🍓Feed 2:
Pane · Pet
Pip · baby · 1 min o: ⚙ Settings 「Nice work! Claude was busy for 42s and used…」 ▄▄▄▄▄▄▄▄▄ Food ████████░░ 80 Mood ████████░░ 82 Energy █████████░ 87 XP 11 · 49 to grow What Claude is doing ○ just finished a turn Log 01:53 Pip woke up 01:53 Claude's Bash failed, Pip is a little wor… 01:53 🥚 Pip hatched! 01:53 Claude finished a turn: 42s, 9 tools f: 🍓Feed p: 🎾Play ctrl+x tab to focus, then the letter keys · ctr…
README

English | 简体中文

pixipet

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

Stages, moods and bubbles, rendered from hooks/pixels.ts

What it does

  • Watches Claude. Every tool call becomes a line the pet understands: reading 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.
  • Grows with your work. Each tool call is 1 XP, each finished turn 2 XP. Egg → baby (10) → child (60) → teen (200) → adult (500), with a toast at every evolution.
  • Needs care. Food, mood and energy drift down over time; it poops now and then. Feed, play, clean, put it to bed, or let it keep its own hours: it dozes off when tired (energy under 20) or late at night (midnight to 6, energy under 50) and gets up rested after the night; woken by hand, it stays up half an hour. Three piles, or going hungry and sad at once, make it sick: it turns green, can't play, and needs its medicine (a 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.
  • Keeps you company. While Claude is idle it chats: late-night reminders to sleep, a nudge to drink water, cheers after a long turn, worry when a tool fails.
  • Bubbles show Claude's work. A magnifier for reads and searches, a pencil for edits, a terminal for shell commands, a globe for the web, a list for plans, a mini pet for each subagent. A flashing red ! when Claude is asking you something or waiting on a plan approval.
  • It reacts. Hops and a ✓ when a turn lands, a sweat drop when a tool fails, munching when fed, hearts when played with, Zs when asleep, a flash when it evolves. It wanders around while Claude is idle and comes home when Claude starts working.
  • Speaks Chinese and English. It follows Claude Code's language setting; /pet lang zh|en picks one.
  • Where to see it: a pixel band above the prompt (or under it), and a pane (/pet); with the band hidden, the status line carries its stats instead. On desktop and other surfaces without pixels it falls back to text.

Install

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.

Commands

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
/petOpen the pet pane (with the pane focused, ctrl+x tab: f feed, p play, c clean, s sleep/wake)
/pet feed · play · clean · sleep · healCare without opening the pane; heal gives medicine to a sick pet
/pet statusOne-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 confirmStart over from a new egg

What it reads, stores and sends

Everything stays on your machine. The pet makes no network requests and runs no commands.

  • Reads: the tool calls and turns of the session (tool name, file names, a Bash command's description or the command itself, a search pattern, a fetched URL's host) to show what Claude is doing; Claude Code's theme and language settings. It reads no environment variables and no credentials.
  • Stores: the pet's stats and your choices (band, buttons, keys, skin, species, language) in its own plugin store under ~/.claude/plugins/store/. Nothing about your code or prompts is stored.
  • Shows: a band above the prompt, a pane, a status line and toasts. It never changes or blocks a tool call, a prompt or Claude's answer.
  • Hooks: 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.

How it works

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 .

Develop

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.

License

MIT

Source 17 files
hooks/register.tsx 1139 lines
1import { 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}
1139
hooks/pet.ts 235 lines
1// 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}
235
hooks/pixels.ts 635 lines
1// 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}
635
hooks/species/index.ts 21 lines
1import 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}
21
hooks/sprites.ts 104 lines
1// 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}
104
hooks/text.ts 452 lines
1// 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}
452
hooks/species/types.ts 47 lines
1// 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}
47
hooks/species/sprig.ts 153 lines
1import 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}
153
hooks/species/mallow.ts 152 lines
1import 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}
152
hooks/species/dewdrop.ts 152 lines
1import 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}
152
hooks/species/plumcap.ts 152 lines
1import 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}
152
hooks/species/luma.ts 153 lines
1import 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