A pixel pet that lives in a Claude Code pane: it wanders, reacts to your session, and grows as you work

<h1 align="center"><img src="assets/banner.svg" width="880" alt="claude-pets"></h1>
A pixel pet that lives in a Claude Code pane. It wanders while you work, reacts to what your session does, and grows across sessions. Eight to choose from: <code>cat</code> · <code>chick</code> · <code>dog</code> · <code>slime</code> · <code>bunny</code> · <code>hamster</code> · <code>penguin</code> · <code>frog</code>.
[!NOTE] This is a Claude Code mod (a plugin of function hooks). The mod API is early access, so a new Claude Code release may break the pet until this plugin catches up.
/plugin marketplace add uppinote20/claude-pets
/plugin install pets@claude-pets
Then type /pet. To try it from a clone: claude --plugin-dir /path/to/claude-pets.
| Command | What it does | |
|---|---|---|
/pet | Let the pet out | |
/pet pat | Pat it | |
/pet name <name> | Name it (up to 20 characters) | |
/pet choose <species> | Bring out another pet (each species is its own pet, with its own name and level) | |
| `/pet size small\ | medium` | Pane height: 6 or 9 rows (default medium) |
/pet play | Pet Run: j jump · r again · Esc stop | |
/pet quest [stage] | Pet Quest: j jump (again while rising: higher) · r retry · n next | |
/pet status | Level, experience, nature, rhythm and counts | |
/pet bye | Close the pane (or ctrl+x x) |
The pane's ✕ is clickable in the fullscreen layout (/tui fullscreen) only; on the main screen the terminal reports no clicks.
It reacts to your session: notes while tools run, a jump when a turn finishes, a nap after two quiet minutes, a tear when a rate-limit window passes 80%. Everything it lives through is experience, kept across sessions:
| Event | XP |
|---|---|
| Tool call | 1 |
| Game snack (Pet Run, Pet Quest) | 1 |
| Pat | 2 |
| Finished turn | 5 |
| 1,000 output tokens | 1 |
| Gift, lucky pat | 5–100 |
It counts whenever the plugin is loaded, whether or not the pane is open. Only output tokens count: input and cache reads grow with the conversation's length, not with the work done.
Level n + 1 takes 25 × n² XP (Lv 5 at 400, Lv 10 at 2,025, Lv 50 at 60,025), with no cap.
| Stage | From | Looks |
|---|---|---|
| Baby | Lv 1 | As drawn |
| Teen | Lv 15 | A taller body, and its accessory (a ribbon, a flower, a scarf, a crown, a leaf, a knit hat) |
| Adult | Lv 40 | Evolves into the form of its nature (below), and twinkles. One in twenty takes the rare form instead |
<table> <tr> <td align="center"><img src="assets/run.gif" width="360" alt="Pet Run: the cat jumping bugs and catching snacks"><br><code>/pet play</code>: Pet Run</td> <td align="center"><img src="assets/quest.gif" width="320" alt="Pet Quest: the chick clearing stage 1"><br><code>/pet quest</code>: Pet Quest</td> </tr> </table>
Snacks from both games are experience. What a game earned stays with the pet that played it, even if you /pet choose another mid-game.
/pet play opens a runner in its own pane. The pet runs along the grass: jump the bugs (j, or the Jump button) and snap up the snacks floating over them. It gets faster as it goes. r runs again after a bug, Esc stops. Its best score is kept.
/pet quest opens the next stage of an auto-running side-scroller. The pet runs on its own; j jumps, and a second j while rising takes it higher, even off the top of the view (an arrow shows where it will land). Stomp the bugs from above, knock the gift boxes from below for snacks, hop the stumps and pits, and reach the snack bowl. A wall stops it until it jumps.
Eight stages, each opened by clearing the one before; /pet quest 3 replays one already open. Stages are tile maps in hooks/quest.ts, one character per 4×4 tile (all eight take about 7 KB), and a test searches every stage for a way through.
/pet status counts the snacks of both games together. Off the terminal both games draw as SVG, with buttons to tap.
What you do most becomes its nature, and the yard grows that way: a building site, a library corner, a garden or an arcade, tinted to match, under your local sky.
Its nature is the side of you it sees most, once it has seen enough:
| Nature | From | How it shows |
|---|---|---|
| worker | tool calls | holds up a laptop while tools run; as an adult, swings a pickaxe |
| scholar | turns and output | ambles and stops to read; as an adult, a graduation cap |
| sweetie | pats | pauses for a heart; as an adult, a flower pin |
| gamer | game snacks | runs faster and kicks a ball; as an adult, a headset |
| curious | (until one side stands out, or two tie) | sniffs around |
The nature follows its leaning: the shares of the four sides, which move once a day by 12% toward what that day brought. A long session changes nothing until the day is over, and old habits fade slowly: a new prop shows after a few days of a new habit, a new form after about two weeks. An adult whose leaning has clearly moved on (the new side well ahead, the old one faded) takes the new side's form; a toast says so. The rare form stays.
The yard shows the leaning. Each side fills it a prop at a time and tints the grass its way: a building site (cones, barriers, a warning sign), a library corner (a bookshelf, a lamp, a globe), a garden (tulips, a heart bush, a butterfly), an arcade (a cabinet, a trophy, a joystick). A mixed leaning makes a mixed yard.
It follows your local clock (read from date): the morning, midday and evening sun, and the moon and stars at night. Off the terminal, the card turns night blue.
Its rhythm comes from the hours your finished turns keep: night owl, early bird, daytimer or evening type. Nature and rhythm show in the stats and in /pet status, and the pet muses about them now and then.
The form is settled the moment it reaches Lv 40 (by any experience, games included) and kept from then on.
luck on): the celestial cat, the phoenix chick, the moon wolf, the king slime, the moon bunny, the golden hamster, the emperor penguin, the prince frog.✦ in the stats). A sparkle stone makes the pet that is out shiny; a second one is a jackpot.Turn it all off with the plugin's luck setting (/config).
| Size | Sprite | Terminal rows |
|---|---|---|
small | 8×8, at every stage | 6 |
medium | 12×12 | 9 |
The pane opens at that height; a height you drag it to yourself wins. With room (above a wide prompt, or docked), a stats panel sits beside or under the yard; otherwise the line above the pet shows the level.
| Surface | Drawn as |
|---|---|
| Terminal | Half-block cells (Raster) |
| Desktop, VS Code, mobile | An SVG card with a name tag, a speech bubble and a grass mound |
The pet runs wherever Claude Code runs, so the plugin has to be loaded by the session the app is looking at:
claude remote-control in the folder you work in, and open that session in the Claude Code app. Type /pet there..claude/settings.json enables (extraKnownMarketplaces and enabledPlugins), so add pets@claude-pets there and open that session in the app.A species is drawn twice in hooks/species.ts, one character per pixel, facing left: big at 12×12 and mini at 8×8.
big: {
rows: [
'.o........o.',
'.oo......oo.',
'.opoooooopo.',
'oooooooooooo',
'oowkoooowkoo', // eyes: 2×2, a white glint in the top corner
'ookkooookkoo',
'oppoommooppo', // blush and a small mouth
// ...12 rows of 12 characters
],
eyesShut: { 4: 'oooooooooooo' }, // the glint row turns to fur: a closed, happy line
tear: { 6: 'obpoommooppo' },
feetApart: { 11: '..oo....oo..' },
},
ink: { o: 0xffd787, w: 0xfffaf0, p: 0xffafd7, k: 0x5f4b4b, m: 0xd08770, b: 0x87d7ff },
. is empty; every other character needs a color in ink. Keep the shared face marks (w glint, k eyes, p blush, b tear). Supply by row index the rows that replace the eyes, the cheek and the feet. A species also needs a teen sprite, an accessory for each size, its head position (where gear sits), shiny colors and a rare form: copy an existing species as a start. Then add it to the species list above and regenerate assets/. A test checks every row's width and colors.
claude plugin validate .claude-plugin/plugin.json # manifest and hooks module
claude plugin test . # tests/*.test.ts
node scripts/sprites.mjs && node scripts/demo.mjs && node scripts/showcase.mjs # regenerate assets/ (Node 22.18+)
The drawing lives in hooks/scene.ts, which has no $, so tests draw exactly what the pane does. CI fails when assets/ is stale. Once Claude Code has loaded the plugin from this folder it lays its type declarations in .claude-plugin/types/, and tsc -p . type-checks the plugin.
MIT
hooks/register.tsx 1086 lines1/**
2 * The hooks module: state, the /pet command, session reactions and the Pane render.
3 * @handbook 1.1-mod-registration
4 * @handbook 1.2-state-layers
5 * @handbook 1.3-event-flow
6 * @handbook 2.1-defensive-store-read
7 * @handbook 2.2-grow-write-once
8 * @handbook 2.3-name-sanitizing
9 * @handbook 3.1-xp-level-curve
10 * @handbook 3.2-mood-state-machine
11 * @handbook 3.3-pet-command
12 * @handbook 3.4-pet-run
13 * @handbook 3.5-pet-quest
14 * @handbook 3.6-luck
15 * @handbook 3.7-nature-rhythm
16 * @handbook 4.2-pixel-pipeline
17 * @handbook 4.3-surface-branch
18 * @tested tests/pet.test.ts
19 */
20import { atom, read, update } from 'claude-code'
21import type { EngineInterface, Register } from 'claude-code'
22
23import type { Pet, PetProfile, PetSize, PetStats, QuestView, RunView } from '../types'
24import { DEFAULT_SIZE, SIZES, STAGE_LEVELS, cardWidthFor, lookOf, pixelsSvg, stageOf, minWidth, pack, paint, terminalRows, toCells, toSvg } from './scene'
25import { QUEST_HEIGHT, QUEST_TICK_MS, STAGES, advanceQuest, jumpQuest, newQuest, paintQuest, questScore } from './quest'
26import type { Quest } from './quest'
27import type { Form } from './scene'
28import { DAYPARTS, SIDES, daypartOf, driftLeaning, isKnown, lifetimeLeaning, musingOf, natureOf, reformOf, rhythmOf } from './nature'
29import type { Daypart, Nature, Side } from './nature'
30import { GIFT_CHANCE, LUCKY_PAT_CHANCE, RARE_CHANCE, SHINY_CHANCE, giftOf, worthOf } from './luck'
31import { RUN_HEIGHT, RUN_TICK_MS, advance, jump, newRun, paintRun, scoreOf } from './run'
32import type { Run } from './run'
33import { DEFAULT_SPECIES, SPECIES } from './species'
34import type { Species } from './species'
35
36const PANE = 'pets'
37const PLAY = 'pets-run'
38const PLAY_TITLE = 'Pet Run'
39const QUEST = 'pets-quest'
40const QUEST_TITLE = 'Pet Quest'
41/** The Pet Run course's width bounds, in pixels (terminal columns). */
42const RUN_MIN = 40
43const RUN_MAX = 80
44const TITLE = 'Pet'
45const PROFILE_KEY = 'profile'
46const NAME_LIMIT = 20
47const TICK_MS = 600
48const STEPS = 20
49const NAP_AFTER = 200
50const WORRY_PERCENT = 80
51/** The widest yard, in sprite pixels: room to roam above a wide prompt, and the SVG card's, where wider leaves the pet lost on it. */
52const MAX_YARD = 80
53/** The widest the SVG card grows to fill the slot, in sprite pixels. A tally line that needs more still widens it. */
54const MAX_CARD = 30
55/** The terminal's cell, in CSS pixels of a remote surface's code font, roughly. */
56const CELL_PX = 8
57const XP_PER_TURN = 5
58const XP_PER_PAT = 2
59const XP_PER_SNACK = 1
60const TOKENS_PER_XP = 1000
61const XP_CURVE = 25
62/** What follows `/pet`: the command's argument hint and its usage line are both built from this. */
63const VERBS = ['pat', 'name <name>', 'choose <species>', 'size <small|medium>', 'play', 'quest [stage]', 'status', 'bye'] as const
64const BAR_CELLS = 5
65/** The stats column beside the yard: its width, and the bar's inside it. */
66const PANEL = 28
67const PANEL_BAR = 20
68
69const PASTEL = { yellow: '#ffd787', pink: '#ffafd7', green: '#afd7af', ink: '#3a2a2a', gray: '#b2b2b2', dim: '#5f5f5f' } as const
70const NEWBORN: Pet = { x: 0, dir: 1, frame: 0, mood: 'walk', hold: 0, idle: 0 }
71const UNMET: PetStats = { name: '', pats: 0, tools: 0, turns: 0, tokens: 0, snacks: 0, best: 0, cleared: 0, gifts: 0, bonus: 0, shiny: false, hours: [0, 0, 0, 0], form: '', leaning: [], day: '', brought: [0, 0, 0, 0] }
72const STRANGER: PetProfile = { species: DEFAULT_SPECIES, size: DEFAULT_SIZE, pets: {} }
73
74const pet = atom({ plugin: 'pets', key: 'pet' } as const, NEWBORN)
75const profile = atom({ plugin: 'pets', key: 'profile' } as const, STRANGER)
76const isWorried = atom({ plugin: 'pets', key: 'isWorried' } as const, false)
77const runView = atom({ plugin: 'pets', key: 'run' } as const, { phase: 'ready', score: 0, snacks: 0 } as RunView)
78
79// Pet Run's course lives here rather than in $.state: it changes twenty times a second and the
80// terminal repaints it with $.ui.blit, without a redraw. `runView` carries what the text shows.
81let course: Run | null = null
82let ticker: { cancel: () => void } | null = null
83// Redraw on every tick when a surface without Raster (desktop, VS Code, mobile) is attached.
84let isRemote = false
85// The level last seen for the pet that is out, so a level-up is announced once, whether it came
86// from a turn, a pat or a run.
87let shownLevel = 1
88// The `luck` setting, as register last read it: the rare form is luck too.
89let isLuckOn = true
90const FORMS: readonly string[] = ['worker', 'scholar', 'sweetie', 'gamer', 'curious', 'rare'] satisfies readonly Form[]
91
92function isForm(value: string): value is Form {
93 return FORMS.includes(value)
94}
95
96/** An adult's form: as kept, else what it would become now. */
97function formOf(stats: PetStats): Form {
98 return isForm(stats.form) ? stats.form : natureOf(stats)
99}
100
101/** The leaning the yard shows: none until the pet knows you, then the kept one (or its life's). */
102function yardLeaning(stats: PetStats): readonly number[] {
103 if (!isKnown(stats)) {
104 return []
105 }
106
107 return stats.leaning.length === SIDES.length ? stats.leaning : lifetimeLeaning(stats)
108}
109
110/** `scholar`, or for the rare form its own name (`celestial`). */
111function formName(kind: Species, form: Form): string {
112 return form === 'rare' ? kind.rare.name : form
113}
114// The host's offset from UTC in minutes: the module's own clock is UTC, so `date` says.
115let utcOffset = 0
116// The engine's clock as of the last tick (tests move it with mock.clock).
117let clockNow = Date.now()
118
119/** The person's local date, `YYYY-MM-DD`: the leaning moves once a day. */
120function dayNow(): string {
121 return new Date(clockNow + utcOffset * 60_000).toISOString().slice(0, 10)
122}
123
124/**
125 * Tallies what something brought into the day's count; when the day has changed, first moves
126 * the leaning toward the day before and lets an adult follow it.
127 */
128function tally(stats: PetStats, brought: Readonly<Record<Side, number>>, today: string): PetStats {
129 const isNewDay = stats.day !== '' && stats.day !== today
130 const start = stats.leaning.length === SIDES.length ? stats.leaning : lifetimeLeaning(stats)
131 const leaning = isNewDay
132 ? driftLeaning(start, Object.fromEntries(SIDES.map((side, at) => [side, stats.brought[at] ?? 0])) as Record<Side, number>)
133 : start
134 const kept = isNewDay ? [0, 0, 0, 0] : stats.brought
135
136 return {
137 ...stats,
138 leaning,
139 form: isNewDay ? reformOf(stats.form, leaning) : stats.form,
140 day: today,
141 brought: SIDES.map((side, at) => (kept[at] ?? 0) + brought[side]),
142 }
143}
144
145/** The quarter of the day it is where the person is. */
146function daypartNow(): Daypart {
147 return daypartOf(new Date(clockNow + utcOffset * 60_000).getUTCHours())
148}
149// Pet Quest's stage in play, as Pet Run's course.
150let quest: Quest | null = null
151let questTicker: { cancel: () => void } | null = null
152const questView = atom({ plugin: 'pets', key: 'quest' } as const, { stage: 0, phase: 'ready', score: 0, snacks: 0 } as QuestView)
153
154/** The species of the pet that is out, in its shiny colors when it is one. */
155function speciesOf(who: PetProfile): Species {
156 const kind = SPECIES[who.species] ?? SPECIES[DEFAULT_SPECIES]!
157
158 return who.pets[who.species]?.shiny === true ? { ...kind, ink: { ...kind.ink, ...kind.shiny } } : kind
159}
160
161/** The pet that is out: a species never chosen before starts from nothing. */
162function statsOf(who: PetProfile): PetStats {
163 return who.pets[who.species] ?? UNMET
164}
165
166function withStats(who: PetProfile, change: (stats: PetStats) => PetStats): PetProfile {
167 return { ...who, pets: { ...who.pets, [who.species]: change(statsOf(who)) } }
168}
169
170function calledOf(who: PetProfile): string {
171 const { name } = statsOf(who)
172
173 return name === '' ? `The ${speciesOf(who).label}` : name
174}
175
176/** `초코 the cat`, or `The cat` while it has no name. */
177function titleOf(who: PetProfile): string {
178 const { name, shiny } = statsOf(who)
179 const label = `${shiny ? 'shiny ' : ''}${speciesOf(who).label}`
180
181 return name === '' ? `The ${label}` : `${name} the ${label}`
182}
183
184/**
185 * A tool call is 1, a pat 2, a finished turn 5, a game snack 1, every thousand output tokens 1,
186 * and whatever luck brought (gifts, lucky pats).
187 * Output only: input and cache reads grow with the conversation's length, not with the work done.
188 */
189function xpOf(stats: PetStats): number {
190 return (
191 stats.tools +
192 stats.turns * XP_PER_TURN +
193 stats.pats * XP_PER_PAT +
194 Math.floor(stats.tokens / TOKENS_PER_XP) +
195 stats.snacks * XP_PER_SNACK +
196 stats.bonus
197 )
198}
199
200/**
201 * Level 1 at birth, level `n + 1` once it has `XP_CURVE * n²` experience.
202 * Quadratic, so an evening reaches level 5 and a year of heavy use stays in the low hundreds.
203 */
204function levelOf(stats: PetStats): number {
205 return 1 + Math.floor(Math.sqrt(xpOf(stats) / XP_CURVE))
206}
207
208/** How far it is from this level to the next, 0 to 1. */
209function progressOf(stats: PetStats): number {
210 const level = levelOf(stats)
211 const from = XP_CURVE * (level - 1) ** 2
212 const to = XP_CURVE * level ** 2
213
214 return (xpOf(stats) - from) / (to - from)
215}
216
217function count(value: unknown): number {
218 return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0
219}
220
221function isRecord(value: unknown): value is Record<string, unknown> {
222 return typeof value === 'object' && value !== null && !Array.isArray(value)
223}
224
225function toStats(fields: Record<string, unknown>): PetStats {
226 return {
227 name: typeof fields.name === 'string' ? cleanName(fields.name) : '',
228 pats: count(fields.pats),
229 tools: count(fields.tools),
230 turns: count(fields.turns),
231 tokens: count(fields.tokens),
232 snacks: count(fields.snacks),
233 best: count(fields.best),
234 cleared: count(fields.cleared),
235 gifts: count(fields.gifts),
236 bonus: count(fields.bonus),
237 shiny: fields.shiny === true,
238 hours: DAYPARTS.map((_, at) => count(Array.isArray(fields.hours) ? fields.hours[at] : 0)),
239 form: typeof fields.form === 'string' && isForm(fields.form) ? fields.form : '',
240 leaning:
241 Array.isArray(fields.leaning) && fields.leaning.length === SIDES.length && fields.leaning.every(share => typeof share === 'number' && share >= 0 && share <= 1)
242 ? (fields.leaning as number[])
243 : [],
244 day: typeof fields.day === 'string' && /^\d{4}-\d\d-\d\d$/.test(fields.day) ? fields.day : '',
245 brought: SIDES.map((_, at) => (Array.isArray(fields.brought) && typeof fields.brought[at] === 'number' && fields.brought[at] >= 0 ? fields.brought[at] : 0)),
246 }
247}
248
249/** What `$.store` holds may be from another version, or nothing: read it field by field. */
250function toProfile(kept: unknown): PetProfile {
251 if (!isRecord(kept)) {
252 return STRANGER
253 }
254
255 const species = typeof kept.species === 'string' && kept.species in SPECIES ? kept.species : DEFAULT_SPECIES
256 const size = isSize(kept.size) ? kept.size : DEFAULT_SIZE
257
258 // 0.1.0 before per-pet levels kept one pet's fields beside `species`.
259 if (!isRecord(kept.pets)) {
260 return { species, size, pets: { [species]: toStats(kept) } }
261 }
262
263 const pets: Record<string, PetStats> = {}
264
265 for (const [kind, fields] of Object.entries(kept.pets)) {
266 if (kind in SPECIES && isRecord(fields)) {
267 pets[kind] = toStats(fields)
268 }
269 }
270
271 return { species, size, pets }
272}
273
274function isSize(value: unknown): value is PetSize {
275 return typeof value === 'string' && (SIZES as readonly string[]).includes(value)
276}
277
278function compact(tokens: number): string {
279 if (tokens >= 1e9) {
280 return `${(tokens / 1e9).toFixed(1)}B`
281 }
282 if (tokens >= 1e6) {
283 return `${(tokens / 1e6).toFixed(1)}M`
284 }
285
286 return tokens >= 1e3 ? `${(tokens / 1e3).toFixed(1)}K` : String(tokens)
287}
288
289function statusOf(who: PetProfile): string {
290 const stats = statsOf(who)
291 const level = levelOf(stats)
292 const others = Object.entries(who.pets)
293 .filter(([kind]) => kind !== who.species)
294 .map(([kind, other]) => `${other.name === '' ? kind : `${other.name} the ${kind}`} Lv ${levelOf(other)}`)
295
296 return [
297 `${titleOf(who)} · Lv ${level} · ${xpOf(stats)} xp (next at ${XP_CURVE * level * level})`,
298 `${stats.pats} pats, ${stats.turns} turns, ${stats.tools} tool calls, ${compact(stats.tokens)} output tokens`,
299 ...(stats.best === 0 ? [] : [`Pet Run best ${stats.best}`]),
300 ...(stats.cleared === 0 ? [] : [`Pet Quest ${stats.cleared}/${STAGES.length} stages cleared`]),
301 // Snacks from both games, one count.
302 ...(stats.snacks === 0 ? [] : [`${stats.snacks} snacks`]),
303 ...(stats.gifts === 0 ? [] : [`${stats.gifts} gifts found`]),
304 `${natureLine(stats)}`,
305 ...(others.length === 0 ? [] : [`Also: ${others.join(', ')}`]),
306 ].join(' · ')
307}
308
309/** Its nature, and its rhythm once it shows: `worker · night owl`. */
310function natureLine(stats: PetStats): string {
311 const rhythm = rhythmOf(stats.hours)
312
313 return `${natureOf(stats)}${rhythm === null ? '' : ` · ${rhythm}`}`
314}
315
316/** What a typed name is kept as: one line, no control characters, at most `NAME_LIMIT` characters. */
317function cleanName(typed: string): string {
318 return [...typed.replace(/\p{Cc}/gu, ' ').trim()].slice(0, NAME_LIMIT).join('')
319}
320
321function act(one: Pet, mood: Pet['mood'], hold: number): Pet {
322 return { ...one, mood, hold, idle: 0 }
323}
324
325/** One tick: a held mood runs out, a quiet session naps, else it paces. */
326/**
327 * One tick, by its nature: a scholar ambles and stops to read, a gamer runs, a sweetie
328 * pauses for a heart. Pauses keep counting toward its nap.
329 */
330function step(one: Pet, nature: Nature = 'curious'): Pet {
331 const frame = one.frame + 1
332
333 if (one.hold > 1) {
334 return { ...one, frame, hold: one.hold - 1 }
335 }
336 if (one.hold === 1) {
337 return { ...one, frame, hold: 0, mood: 'walk' }
338 }
339 if (one.mood === 'sleep') {
340 return { ...one, frame }
341 }
342 if (one.idle >= NAP_AFTER) {
343 return { ...one, frame, mood: 'sleep' }
344 }
345
346 const idle = one.idle + 1
347 if (nature === 'scholar' && frame % 40 === 0) {
348 return { ...one, frame, mood: 'work', hold: 6, idle }
349 }
350 if (nature === 'sweetie' && frame % 30 === 0) {
351 return { ...one, frame, mood: 'love', hold: 4, idle }
352 }
353 const pace = nature === 'scholar' ? (frame % 2 === 0 ? 1 : 0) : nature === 'gamer' && frame % 3 === 0 ? 2 : 1
354 const x = Math.min(STEPS, Math.max(0, one.x + one.dir * pace))
355 const dir = x === 0 ? 1 : x === STEPS ? -1 : one.dir
356
357 return { ...one, frame, x, dir, mood: 'walk', idle }
358}
359
360function saysOf(one: Pet, who: PetProfile, isSad: boolean): string {
361 const beat = one.frame % 2
362
363 switch (one.mood) {
364 case 'sleep':
365 return beat === 0 ? 'z' : 'z Z'
366 case 'happy':
367 return 'nice work!'
368 case 'work':
369 return beat === 0 ? '. .' : '. . .'
370 case 'love':
371 return `${speciesOf(who).purr} ×${statsOf(who).pats}`
372 case 'gift':
373 return 'a gift!'
374 case 'walk': {
375 if (isSad) {
376 return 'limits are close…'
377 }
378 // Now and then it muses, by its nature and the hour.
379 return one.frame % 40 < 6 && one.frame > 0 ? musingOf(natureOf(statsOf(who)), daypartNow(), Math.floor(one.frame / 40)) : ''
380 }
381 }
382}
383
384/** The bar beside the level: filled cells for the way to the next one. */
385function barOf(progress: number, cells = BAR_CELLS): [string, string] {
386 const filled = Math.min(cells, Math.max(0, Math.floor(progress * cells)))
387
388 return ['━'.repeat(filled), '━'.repeat(cells - filled)]
389}
390
391/** What it has lived through, as the stats rows show it: label, count, and what one is worth. */
392function tallyOf(stats: PetStats): [string, string, string][] {
393 return [
394 ['tool calls', String(stats.tools), '+1'],
395 ['turns', String(stats.turns), `+${XP_PER_TURN}`],
396 ['pats', String(stats.pats), `+${XP_PER_PAT}`],
397 ['output tokens', compact(stats.tokens), '1K +1'],
398 ]
399}
400
401/** The same in one line, for the SVG card. */
402function tallyLine(stats: PetStats): string {
403 return `${stats.tools} tools · ${stats.turns} turns · ${stats.pats} pats · ${compact(stats.tokens)} tokens`
404}
405
406/**
407 * Opens the pane, asking for the rows its size draws: the yard and the line above it.
408 * Resolves to what the reply should add: nothing once drawn, else why it waits undrawn
409 * (a surface that places no panes), so `/pet` never claims a pet nobody can see.
410 */
411async function openPane($: EngineInterface, who: PetProfile): Promise<string> {
412 const opened = await $.ui.open({ id: PANE, title: TITLE, rows: terminalRows(speciesOf(who), who.size) + 1 })
413
414 return opened.isPlaced ? '' : ` The pane is not on screen: ${opened.reason}`
415}
416
417/**
418 * Applies `change` to the pet that is out and keeps the result, with the species and size
419 * this session chose, across sessions.
420 * It reads the store first, so what another session saved meanwhile is added to, not overwritten.
421 */
422async function grow($: EngineInterface, change: (stats: PetStats) => PetStats): Promise<PetProfile> {
423 const { species, size } = await read($, profile)
424 // The form is settled the first time it is seen at Lv 40: its nature then, or by luck the rare one.
425 const settle = (stats: PetStats): PetStats =>
426 stats.form === '' && levelOf(stats) >= STAGE_LEVELS.adult
427 ? { ...stats, form: isLuckOn && Math.random() < RARE_CHANCE ? 'rare' : natureOf(stats) }
428 : stats
429 const now = withStats({ ...toProfile(await $.store.get(PROFILE_KEY)), species, size }, stats => settle(change(stats)))
430 await $.store.set(PROFILE_KEY, now)
431 await update($, profile, () => now)
432
433 return now
434}
435
436/**
437 * Grows the pet that is out and tallies what the change brought into today's leaning, as it is
438 * kept: pats, game snacks and turns each count the moment they are saved, so none is missed
439 * between turns or sessions. An adult that follows a new day's leaning says so.
440 */
441async function growTallied($: EngineInterface, change: (stats: PetStats) => PetStats, brought: Partial<Record<Side, number>>): Promise<PetProfile> {
442 let reformed = ''
443 const now = await grow($, stats => {
444 const tallied = tally(stats, { worker: 0, scholar: 0, sweetie: 0, gamer: 0, ...brought }, dayNow())
445 reformed = tallied.form !== stats.form ? tallied.form : ''
446
447 return change(tallied)
448 })
449 if (reformed !== '') {
450 await $.ui.toast(`${calledOf(now)} took after you: a ${reformed} ${speciesOf(now).label} now.`)
451 }
452
453 return now
454}
455
456/** The pet's own tick: a step by its nature. */
457async function tickPet($: EngineInterface): Promise<void> {
458 const nature = natureOf(statsOf(await read($, profile)))
459 clockNow = await $.clock.now()
460
461 await update($, pet, one => step(one, nature))
462}
463
464function viewOf(run: Run): RunView {
465 return { phase: run.phase, score: scoreOf(run), snacks: run.snacks }
466}
467
468/** Cells of the course for the terminal's Raster. */
469function runCells(run: Run, who: PetProfile): string {
470 return pack(toCells(paintRun(run, speciesOf(who), stageOf(levelOf(statsOf(who))), formOf(statsOf(who)))))
471}
472
473/**
474 * Keeps a finished (or abandoned) run: its snacks feed the pet, its score may be the best, and a
475 * level the snacks reach is announced like any other.
476 */
477async function keepRun($: EngineInterface, run: Run): Promise<PetProfile> {
478 const score = scoreOf(run)
479 const kept = await growTallied($, stats => ({ ...stats, snacks: stats.snacks + run.snacks, best: Math.max(stats.best, score) }), { gamer: run.snacks * XP_PER_SNACK })
480 await announce($, kept)
481
482 return kept
483}
484
485/**
486 * One tick of Pet Run: moves the course on, repaints it in place on the terminal, and
487 * updates the text when the score or the phase changes. A run that just ended is kept.
488 */
489async function tickRun($: EngineInterface): Promise<void> {
490 const run = course
491 if (run === null || run.phase !== 'running') {
492 return
493 }
494 const who = await read($, profile)
495 // Closed or started over while the profile was read: that course is no longer this one to move.
496 if (course !== run) {
497 return
498 }
499 const now = advance(run, speciesOf(who))
500 course = now
501
502 void $.ui.blit({ requestId: PLAY, key: 'run', cells: runCells(now, who) })
503 if (now.phase === 'over') {
504 await update($, runView, () => viewOf(now))
505 // Saved first: a toast that fails must not cost the run its snacks.
506 await keepRun($, now)
507 await $.ui.toast(`${calledOf(who)} ran ${scoreOf(now)} and ate ${now.snacks} snacks.`)
508 } else if (isRemote || now.tick % 10 === 0) {
509 await update($, runView, () => viewOf(now))
510 }
511}
512
513function questViewOf(stage: Quest): QuestView {
514 return { stage: stage.stage, phase: stage.phase, score: questScore(stage), snacks: stage.snacks }
515}
516
517function questCells(stage: Quest, who: PetProfile): string {
518 return pack(toCells(paintQuest(stage, speciesOf(who), stageOf(levelOf(statsOf(who))), formOf(statsOf(who)))))
519}
520
521/**
522 * Keeps what a stage gave: its snacks feed the pet, a cleared stage opens the next, and a level
523 * the snacks reach is announced like any other.
524 */
525async function keepQuest($: EngineInterface, stage: Quest): Promise<PetProfile> {
526 const kept = await growTallied(
527 $,
528 stats => ({
529 ...stats,
530 snacks: stats.snacks + stage.snacks,
531 cleared: stage.phase === 'clear' ? Math.max(stats.cleared, stage.stage + 1) : stats.cleared,
532 }),
533 { gamer: stage.snacks * XP_PER_SNACK },
534 )
535 await announce($, kept)
536
537 return kept
538}
539
540/**
541 * Keeps a run or a stage still going and sets it back to the start, before the pet that earned
542 * it stops being the one out.
543 */
544async function settleGames($: EngineInterface): Promise<void> {
545 const run = course
546 const stage = quest
547
548 if (run !== null && run.phase === 'running') {
549 course = newRun(run.width, Math.floor(Math.random() * 2 ** 31))
550 await update($, runView, () => viewOf(course ?? run))
551 await keepRun($, run)
552 }
553 if (stage !== null && stage.phase === 'running') {
554 quest = newQuest(stage.stage, stage.width)
555 await update($, questView, () => questViewOf(quest ?? stage))
556 await keepQuest($, stage)
557 }
558}
559
560/** One tick of Pet Quest, as `tickRun`: on, repainted in place, kept once it ends. */
561async function tickQuest($: EngineInterface): Promise<void> {
562 const stage = quest
563 if (stage === null || stage.phase !== 'running') {
564 return
565 }
566 const who = await read($, profile)
567 // Closed or started over while the profile was read: that stage is no longer this one to move.
568 if (quest !== stage) {
569 return
570 }
571 const now = advanceQuest(stage)
572 quest = now
573
574 void $.ui.blit({ requestId: QUEST, key: 'quest', cells: questCells(now, who) })
575 if (now.phase !== 'running') {
576 await update($, questView, () => questViewOf(now))
577 // As the run: saved before the toast.
578 await keepQuest($, now)
579 await $.ui.toast(
580 now.phase === 'clear'
581 ? `Stage ${now.stage + 1} clear! ${calledOf(who)} ate ${now.snacks} snacks.`
582 : `${calledOf(who)} will try stage ${now.stage + 1} again.`,
583 )
584 } else if (isRemote || now.tick % 10 === 0) {
585 await update($, questView, () => questViewOf(now))
586 }
587}
588
589/**
590 * Toasts a level reached since `shownLevel`, the level last seen: growing up and starting to shine
591 * say so. `shownLevel` moves before the toast is awaited, so a hook that runs meanwhile does not
592 * announce the same level again.
593 */
594async function announce($: EngineInterface, now: PetProfile): Promise<void> {
595 const level = levelOf(statsOf(now))
596 const shown = shownLevel
597 shownLevel = level
598
599 if (level > shown) {
600 const stage = stageOf(level)
601 const called = calledOf(now)
602
603 await $.ui.toast(
604 stage === stageOf(shown)
605 ? `${called} reached Lv ${level}!`
606 : stage === 'adult'
607 ? `${called} evolved into a ${formName(speciesOf(now), formOf(statsOf(now)))} ${speciesOf(now).label}! (Lv ${level})`
608 : `${called} grew into a teen! (Lv ${level})`,
609 )
610 }
611}
612
613export const register: Register = (on, options) => {
614 // Off, nothing is left to chance: no gifts, no lucky pats, no shiny pets, no rare forms.
615 const hasLuck = options.luck !== false
616 isLuckOn = hasLuck
617 const lucky = (chance: number) => hasLuck && Math.random() < chance
618
619 // Tool calls since the last save: the store is written once a turn, not on every call.
620 let unsavedTools = 0
621
622 on('session.start', async ($, e, next) => {
623 await $.command.register({
624 name: 'pet',
625 description: 'Let your pixel pet out',
626 argumentHint: `[${VERBS.map(form => form.split(' ')[0]).join('|')}]`,
627 })
628 const kept = toProfile(await $.store.get(PROFILE_KEY))
629 await update($, profile, () => kept)
630 // The pet out is met here when nothing was kept of it yet (the very first cat), so it gets the
631 // same one roll at being shiny that `/pet choose` gives a pet met for the first time.
632 const met = kept.species in kept.pets ? kept : await grow($, stats => (lucky(SHINY_CHANCE) ? { ...stats, shiny: true } : stats))
633 shownLevel = levelOf(statsOf(met))
634 try {
635 const { exitCode, stdout } = await $.process.run(['date', '+%z'], { timeoutMs: 2000 })
636 const offset = /^([+-])(\d\d)(\d\d)/.exec(stdout.trim())
637 if (exitCode === 0 && offset !== null) {
638 utcOffset = (offset[1] === '-' ? -1 : 1) * (Number(offset[2]) * 60 + Number(offset[3]))
639 }
640 } catch {
641 // No `date` (Windows): the hours are UTC's.
642 }
643 $.clock.every(TICK_MS, () => void tickPet($))
644
645 return next(e)
646 })
647
648 on('command.run', { command: 'pet' }, async ($, e) => {
649 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
650 const who = await read($, profile)
651 const called = calledOf(who)
652
653 switch (verb) {
654 case '':
655 return { text: `${called} is out.${await openPane($, who)}` }
656 case 'pat': {
657 const isLucky = lucky(LUCKY_PAT_CHANCE)
658 await update($, pet, one => act(one, 'love', 8))
659 const now = await growTallied($, stats => ({ ...stats, pats: stats.pats + 1, bonus: stats.bonus + (isLucky ? XP_PER_PAT * 2 : 0) }), { sweetie: XP_PER_PAT })
660 await announce($, now)
661
662 return { text: `${called} is pleased.${isLucky ? ` Lucky pat! +${XP_PER_PAT * 3} xp` : ''}${await openPane($, now)}` }
663 }
664 case 'name': {
665 const given = cleanName(rest.join(' '))
666
667 if (given === '') {
668 return { text: `${called} is listening. Name it with /pet name <name>.` }
669 }
670
671 const unseen = await openPane($, await grow($, stats => ({ ...stats, name: given })))
672
673 return { text: `${called} is now ${given}.${unseen}` }
674 }
675 case 'choose': {
676 const wanted = rest[0] ?? ''
677
678 if (!(wanted in SPECIES)) {
679 return { text: `Choose one of: ${Object.keys(SPECIES).join(', ')}` }
680 }
681
682 // What a game in progress earned belongs to the pet that played it.
683 await settleGames($)
684 // A pet met for the first time may be a rare shiny.
685 const isNew = !(wanted in who.pets)
686 const isShiny = isNew && lucky(SHINY_CHANCE)
687 await update($, profile, last => ({ ...last, species: wanted }))
688 const now = await grow($, stats => (isShiny ? { ...stats, shiny: true } : stats))
689 shownLevel = levelOf(statsOf(now))
690
691 return { text: `${calledOf(now)} is out.${isShiny ? ` A shiny ${wanted}! It is rare: one in ${Math.round(1 / SHINY_CHANCE)}.` : ''}${await openPane($, now)}` }
692 }
693 case 'size': {
694 const wanted = rest[0] ?? ''
695
696 if (!isSize(wanted)) {
697 return { text: `${called} is ${who.size}. Choose one of: ${SIZES.join(', ')}` }
698 }
699
700 await update($, profile, last => ({ ...last, size: wanted }))
701 const unseen = await openPane($, await grow($, stats => stats))
702
703 return { text: `${called} is ${wanted} now.${unseen}` }
704 }
705 case 'status':
706 return { text: statusOf(who) }
707 case 'play': {
708 // A run still going is kept before a new one takes its place, as closing the pane would.
709 const going = course
710 ticker?.cancel()
711 ticker = null
712 course = newRun(course?.width ?? RUN_MIN, Math.floor(Math.random() * 2 ** 31))
713 if (going !== null && going.phase === 'running') {
714 await keepRun($, going)
715 }
716 isRemote = (await $.session.surfaces()).some(surface => surface !== 'terminal')
717 await update($, runView, () => viewOf(course ?? newRun(RUN_MIN)))
718 const opened = await $.ui.open({ id: PLAY, title: PLAY_TITLE, rows: RUN_HEIGHT / 2 + 2, focus: true, closeOnEscape: true })
719 // Only a placed pane can be closed, and closing is what stops the clock.
720 if (opened.isPlaced) {
721 ticker = $.clock.every(RUN_TICK_MS, () => void tickRun($))
722 }
723
724 return {
725 text: opened.isPlaced
726 ? `${called} is ready to run: j to jump, r to start over, Esc to stop.`
727 : `${called} is ready to run, but the pane is not on screen: ${opened.reason}`,
728 }
729 }
730 case 'quest': {
731 const cleared = statsOf(who).cleared
732 const open = Math.min(cleared, STAGES.length - 1)
733 const asked = rest[0] === undefined ? open + 1 : Number(rest[0])
734
735 if (!Number.isInteger(asked) || asked < 1 || asked > open + 1) {
736 return { text: `Stages open: 1 to ${open + 1} of ${STAGES.length}.` }
737 }
738 // A stage still going is kept before another takes its place, as closing the pane would.
739 const going = quest
740 questTicker?.cancel()
741 questTicker = null
742 quest = newQuest(asked - 1, quest?.width ?? RUN_MIN)
743 if (going !== null && going.phase === 'running') {
744 await keepQuest($, going)
745 }
746 isRemote = (await $.session.surfaces()).some(surface => surface !== 'terminal')
747 await update($, questView, () => questViewOf(quest ?? newQuest(0, RUN_MIN)))
748 const opened = await $.ui.open({ id: QUEST, title: QUEST_TITLE, rows: QUEST_HEIGHT / 2 + 2, focus: true, closeOnEscape: true })
749 // Only a placed pane can be closed, and closing is what stops the clock.
750 if (opened.isPlaced) {
751 questTicker = $.clock.every(QUEST_TICK_MS, () => void tickQuest($))
752 }
753
754 return {
755 text: opened.isPlaced
756 ? `Stage ${asked}: j to start and jump (again while rising to go higher), r to retry, n for the next stage, Esc to stop.`
757 : `Stage ${asked} is ready, but the pane is not on screen: ${opened.reason}`,
758 }
759 }
760 case 'bye':
761 await $.ui.close({ id: PANE })
762
763 return { text: `${called} went back inside.` }
764 default:
765 return { text: `Usage: ${['/pet', ...VERBS.map(form => `/pet ${form}`)].join(', ')}` }
766 }
767 })
768
769 on('tool.call', async ($, e, next) => {
770 unsavedTools += 1
771 await update($, pet, one => act(one, 'work', 8))
772 await update($, profile, who => withStats(who, stats => ({ ...stats, tools: stats.tools + 1 })))
773
774 return next(e)
775 })
776
777 // A subagent's turn adds its tokens; only the main conversation's counts as a turn.
778 on('turn.complete', async ($, e, next) => {
779 const isMain = e.agentId === undefined
780 const tools = unsavedTools
781 const tokens = e.usage?.output_tokens ?? 0
782 unsavedTools = 0
783
784 // Now and then a finished turn of the main conversation turns up a gift.
785 const gift = isMain && lucky(GIFT_CHANCE) ? giftOf(Math.random()) : null
786 const part = daypartNow()
787 let worth = { xp: 0, makesShiny: false }
788 // The turn adds its tools and its own work to the day's tally (pats and snacks count as they are kept).
789 const now = await growTallied(
790 $,
791 stats => {
792 worth = gift === null ? worth : worthOf(gift, stats.shiny)
793
794 return {
795 ...stats,
796 tools: stats.tools + tools,
797 turns: stats.turns + (isMain ? 1 : 0),
798 tokens: stats.tokens + tokens,
799 gifts: stats.gifts + (gift === null ? 0 : 1),
800 bonus: stats.bonus + worth.xp,
801 shiny: stats.shiny || worth.makesShiny,
802 hours: isMain ? stats.hours.map((count, at) => (DAYPARTS[at] === part ? count + 1 : count)) : stats.hours,
803 }
804 },
805 { worker: tools, scholar: (isMain ? XP_PER_TURN : 0) + tokens / TOKENS_PER_XP },
806 )
807
808 if (gift !== null) {
809 await update($, pet, one => act(one, 'gift', 12))
810 await $.ui.toast(
811 worth.makesShiny
812 ? `${calledOf(now)} found a sparkle stone and turned shiny!`
813 : `${calledOf(now)} found ${gift.kind === 'xp' ? gift.name : 'another sparkle stone'}! +${worth.xp} xp`,
814 )
815 } else if (isMain) {
816 await update($, pet, one => act(one, 'happy', 10))
817 }
818 await announce($, now)
819
820 return next(e)
821 })
822
823 // A rate-limit window running out is the one thing that makes it cry.
824 on('session.measure', async ($, e, next) => {
825 const isClose = e.rateLimits.some(limit => limit.percentUsed > WORRY_PERCENT)
826 await update($, isWorried, () => isClose)
827
828 return next(e)
829 })
830
831 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
832 const [one, who, isSad] = await Promise.all([read($, pet), read($, profile), read($, isWorried)])
833 const kind = speciesOf(who)
834 const stats = statsOf(who)
835 const says = saysOf(one, who, isSad)
836 const { unit } = lookOf(who.size)
837 const fewest = minWidth(kind, who.size)
838
839 if (e.surface === 'terminal') {
840 const { Box, Text, Raster } = $.ui.resolve(e)
841 // Stats go beside the yard above a wide prompt, under it in the tall, narrow dock,
842 // and nowhere when neither has room: then the level line carries them.
843 const isDocked = e.props.placement === 'dock'
844 const isBeside = !isDocked && e.props.bodyColumns >= fewest + PANEL + 4
845 const room = isBeside ? e.props.bodyColumns - PANEL - 4 : e.props.bodyColumns - 2
846 const columns = Math.max(fewest, Math.min(MAX_YARD, room))
847 const scene = paint(one, kind, who.size, columns, STEPS, { isSad, stage: stageOf(levelOf(stats)), form: formOf(stats), nature: natureOf(stats), daypart: daypartNow(), leaning: yardLeaning(stats) })
848 const cells = toCells(scene.pixels)
849 const width = cells[0]?.length ?? 0
850 const level = levelOf(stats)
851 const stage = stageOf(level)
852 const hasPanel = isDocked || isBeside
853 const progress = progressOf(stats)
854 const [filled, empty] = barOf(progress)
855 const [wide, rest] = barOf(progress, PANEL_BAR)
856 const yard = <Raster key="pet" columns={width} rows={cells.length} cells={pack(cells)} />
857 const panel = (
858 <Box flexDirection="column" width={PANEL} paddingLeft={isBeside ? 2 : 0}>
859 <Text color={PASTEL.gray} dimColor>{`${kind.label.toUpperCase()}${stats.shiny ? ' ✦' : ''}${stage === 'baby' ? '' : stage === 'teen' ? ' · TEEN' : ` · ${formName(kind, formOf(stats)).toUpperCase()}`}`}</Text>
860 <Box>
861 <Text color={PASTEL.pink} bold>{`Lv ${level}`}</Text>
862 <Text color={PASTEL.gray}>{` ${xpOf(stats)} / ${XP_CURVE * level * level} xp`}</Text>
863 </Box>
864 <Box>
865 <Text color={PASTEL.pink}>{wide}</Text>
866 <Text color={PASTEL.dim}>{rest}</Text>
867 </Box>
868 {who.size !== 'small' && <Text color={PASTEL.pink} dimColor>{natureLine(stats)}</Text>}
869 {who.size === 'small' ? (
870 <>
871 <Text color={PASTEL.gray}>{`${stats.tools} tools · ${stats.turns} turns`}</Text>
872 <Text color={PASTEL.gray}>{`${stats.pats} pats · ${compact(stats.tokens)} tokens`}</Text>
873 </>
874 ) : (
875 tallyOf(stats).map(([label, value, worth]) => (
876 <Box key={label}>
877 <Text color={PASTEL.gray}>{label.padEnd(14)}</Text>
878 <Text>{value.padStart(6)}</Text>
879 <Text color={PASTEL.green}>{` ${worth}`}</Text>
880 </Box>
881 ))
882 )}
883 </Box>
884 )
885
886 return (
887 <Box flexDirection="column">
888 <Box>
889 {stats.name !== '' && (
890 <Text backgroundColor={PASTEL.pink} color={PASTEL.ink} bold>
891 {` ${stats.name} `}
892 </Text>
893 )}
894 {!hasPanel && <Text color={PASTEL.gray}>{` Lv ${level} `}</Text>}
895 {!hasPanel && <Text color={PASTEL.pink}>{filled}</Text>}
896 {!hasPanel && <Text color={PASTEL.dim}>{empty}</Text>}
897 {says !== '' && <Text color={PASTEL.yellow}>{` ${says}`}</Text>}
898 </Box>
899 {isBeside ? (
900 <Box>
901 {yard}
902 {panel}
903 </Box>
904 ) : (
905 yard
906 )}
907 {isDocked && <Box marginTop={1}>{panel}</Box>}
908 </Box>
909 )
910 }
911
912 // Desktop, VS Code and mobile draw an SVG card: half blocks in a proportional line
913 // height leave seams between rows, and an SVG scales to the slot's width.
914 const { Svg } = $.ui.resolve(e)
915 const tally = tallyLine(stats)
916 const across = Math.max(fewest, cardWidthFor(who.size, tally), Math.min(MAX_CARD, Math.floor((e.props.bodyColumns * CELL_PX) / unit) - 4))
917 const scene = paint(one, kind, who.size, across, STEPS, { isSad, withGrass: false, stage: stageOf(levelOf(stats)), form: formOf(stats), nature: natureOf(stats), daypart: daypartNow(), leaning: yardLeaning(stats) })
918 const caption = { name: stats.name, level: levelOf(stats), progress: progressOf(stats), says, tally, daypart: daypartNow() }
919 const alt = `${titleOf(who)}, Lv ${levelOf(stats)}${says === '' ? '' : `: ${says}`}`
920
921 return <Svg source={toSvg(scene, who.size, caption)} alt={alt} />
922 })
923
924 // Closing Pet Run stops its clock; a run still going is kept as it stands.
925 on('ui.close', { id: PLAY }, async ($, e, next) => {
926 ticker?.cancel()
927 ticker = null
928 if (course !== null && course.phase === 'running') {
929 await keepRun($, course)
930 }
931 course = null
932
933 return next(e)
934 })
935
936 on('ui.render', { component: 'Pane', requestId: PLAY }, async ($, e) => {
937 const who = await read($, profile)
938 const view = await read($, runView)
939 const fits = Math.max(RUN_MIN, Math.min(RUN_MAX, e.props.bodyColumns - 2))
940
941 // A course not yet started takes the pane's width; one under way keeps its own.
942 if (course === null || (course.phase === 'ready' && course.width !== fits)) {
943 course = newRun(fits, course?.seed ?? 1)
944 }
945 const run = course
946 // A run just over counts before it is kept, so both surfaces show the new best at once.
947 const best = Math.max(statsOf(who).best, view.phase === 'over' ? view.score : 0)
948 const hint = view.phase === 'ready' ? 'press j to start' : view.phase === 'over' ? 'ouch! r to run again' : ''
949 const onJump = () => {
950 if (course !== null) {
951 const before = course.phase
952 course = jump(course)
953 if (course.phase !== before) {
954 void update($, runView, () => viewOf(course ?? run))
955 }
956 }
957 }
958 const onRestart = () => {
959 if (course !== null && course.phase !== 'running') {
960 course = newRun(course.width, Math.floor(Math.random() * 2 ** 31))
961 void update($, runView, () => viewOf(course ?? run))
962 }
963 }
964 if (e.surface === 'terminal') {
965 const { Box, Text, Raster, Button } = $.ui.resolve(e)
966
967 return (
968 <Box flexDirection="column">
969 <Box>
970 <Text color={PASTEL.pink} bold>{`${calledOf(who)} `}</Text>
971 <Text color={PASTEL.gray}>{`score ${view.score} best ${best} snacks ${view.snacks}`}</Text>
972 {hint !== '' && <Text color={PASTEL.yellow}>{` ${hint}`}</Text>}
973 </Box>
974 <Raster key="run" columns={run.width} rows={RUN_HEIGHT / 2} cells={runCells(run, who)} />
975 <Box gap={1}>
976 <Button key="jump" hotkey="j" plain onPress={onJump}>Jump</Button>
977 <Button key="restart" hotkey="r" plain onPress={onRestart}>Again</Button>
978 </Box>
979 </Box>
980 )
981 }
982
983 const { Box, Text, Svg, Button } = $.ui.resolve(e)
984
985 return (
986 <Box flexDirection="column">
987 <Text>{`${calledOf(who)} · score ${view.score} · best ${best} · snacks ${view.snacks}${hint === '' ? '' : ` · ${hint}`}`}</Text>
988 <Svg source={pixelsSvg(paintRun(run, speciesOf(who), stageOf(levelOf(statsOf(who))), formOf(statsOf(who))), 6)} alt={`Pet Run, score ${view.score}`} />
989 <Box gap={1}>
990 <Button key="jump" hotkey="j" onPress={onJump}>Jump</Button>
991 <Button key="restart" hotkey="r" onPress={onRestart}>Again</Button>
992 </Box>
993 </Box>
994 )
995 })
996
997 on('ui.close', { id: QUEST }, async ($, e, next) => {
998 questTicker?.cancel()
999 questTicker = null
1000 if (quest !== null && quest.phase === 'running') {
1001 await keepQuest($, quest)
1002 }
1003 quest = null
1004
1005 return next(e)
1006 })
1007
1008 on('ui.render', { component: 'Pane', requestId: QUEST }, async ($, e) => {
1009 const who = await read($, profile)
1010 const view = await read($, questView)
1011 const fits = Math.max(RUN_MIN, Math.min(RUN_MAX, e.props.bodyColumns - 2))
1012
1013 if (quest === null || (quest.phase === 'ready' && quest.width !== fits)) {
1014 quest = newQuest(quest?.stage ?? view.stage, fits)
1015 }
1016 const stage = quest
1017 const hasNext = view.phase === 'clear' && stage.stage + 1 < STAGES.length
1018 const hint =
1019 view.phase === 'ready'
1020 ? 'press j to start'
1021 : view.phase === 'over'
1022 ? 'oops! r to retry'
1023 : view.phase === 'clear'
1024 ? hasNext
1025 ? 'clear! n for the next stage'
1026 : 'all stages clear!'
1027 : ''
1028 const onJump = () => {
1029 if (quest !== null) {
1030 const before = quest.phase
1031 quest = jumpQuest(quest)
1032 if (quest.phase !== before) {
1033 void update($, questView, () => questViewOf(quest ?? stage))
1034 }
1035 }
1036 }
1037 const restart = (index: number) => {
1038 if (quest !== null && quest.phase !== 'running') {
1039 quest = newQuest(index, quest.width)
1040 void update($, questView, () => questViewOf(quest ?? stage))
1041 }
1042 }
1043 const onRetry = () => restart(quest?.stage ?? 0)
1044 const onNext = () => {
1045 if (quest !== null && quest.phase === 'clear' && quest.stage + 1 < STAGES.length) {
1046 restart(quest.stage + 1)
1047 }
1048 }
1049 const line = `stage ${view.stage + 1}/${STAGES.length} score ${view.score} snacks ${view.snacks}`
1050
1051 if (e.surface === 'terminal') {
1052 const { Box, Text, Raster, Button } = $.ui.resolve(e)
1053
1054 return (
1055 <Box flexDirection="column">
1056 <Box>
1057 <Text color={PASTEL.pink} bold>{`${calledOf(who)} `}</Text>
1058 <Text color={PASTEL.gray}>{line}</Text>
1059 {hint !== '' && <Text color={PASTEL.yellow}>{` ${hint}`}</Text>}
1060 </Box>
1061 <Raster key="quest" columns={stage.width} rows={QUEST_HEIGHT / 2} cells={questCells(stage, who)} />
1062 <Box gap={1}>
1063 <Button key="jump" hotkey="j" plain onPress={onJump}>Jump</Button>
1064 <Button key="retry" hotkey="r" plain onPress={onRetry}>Retry</Button>
1065 <Button key="next" hotkey="n" plain onPress={onNext}>Next</Button>
1066 </Box>
1067 </Box>
1068 )
1069 }
1070
1071 const { Box, Text, Svg, Button } = $.ui.resolve(e)
1072
1073 return (
1074 <Box flexDirection="column">
1075 <Text>{`${calledOf(who)} · ${line}${hint === '' ? '' : ` · ${hint}`}`}</Text>
1076 <Svg source={pixelsSvg(paintQuest(stage, speciesOf(who), stageOf(levelOf(statsOf(who))), formOf(statsOf(who))), 5)} alt={`Pet Quest stage ${view.stage + 1}`} />
1077 <Box gap={1}>
1078 <Button key="jump" hotkey="j" onPress={onJump}>Jump</Button>
1079 <Button key="retry" hotkey="r" onPress={onRetry}>Retry</Button>
1080 <Button key="next" hotkey="n" onPress={onNext}>Next</Button>
1081 </Box>
1082 </Box>
1083 )
1084 })
1085}
1086hooks/scene.ts 718 lines1/**
2 * The yard as pixels, and the two ways it is drawn: half-block cells on the terminal,
3 * an SVG card on the surfaces that draw `Svg` (desktop, VS Code, mobile).
4 *
5 * Pure: no `$`, so a test's render is exactly what the pane draws.
6 *
7 * @handbook 4.2-pixel-pipeline
8 * @handbook 4.3-surface-branch
9 * @tested tests/pet.test.ts
10 */
11import type { Pet } from '../types'
12import type { Daypart, Nature } from './nature'
13import type { Accessory, Head, Species, Sprite } from './species'
14
15/** The four sides of a nature, in the order `PetStats.leaning` keeps them. Here, not in nature.ts, so scene.ts imports nothing at run time and scripts can load it. */
16export const SIDES = ['worker', 'scholar', 'sweetie', 'gamer'] as const
17export type Side = (typeof SIDES)[number]
18
19export type Size = 'small' | 'medium'
20
21export const SIZES: readonly Size[] = ['small', 'medium']
22export const DEFAULT_SIZE: Size = 'medium'
23
24/** How far a pet has grown: a baby, a teen with its accessory, an adult in its form. */
25export type Stage = 'baby' | 'teen' | 'adult'
26
27/** What an adult became at Lv 40: its nature then, or one time in twenty the rare form. */
28export type Form = Nature | 'rare'
29
30/** The levels each stage begins at. */
31export const STAGE_LEVELS = { teen: 15, adult: 40 } as const
32
33export function stageOf(level: number): Stage {
34 return level >= STAGE_LEVELS.adult ? 'adult' : level >= STAGE_LEVELS.teen ? 'teen' : 'baby'
35}
36
37type Look = {
38 sprite: 'mini' | 'big'
39 /** Pixel rows above the sprite: room to bounce and for the heart. */
40 headroom: number
41 /** Pixel rows of grass below it. */
42 ground: number
43 /** CSS pixels per sprite pixel on the surfaces that draw `Svg`. */
44 unit: number
45 /** The card's text size, in CSS pixels: what `cardWidthFor` measures and `toSvg` draws. */
46 font: number
47}
48
49const LOOKS: Readonly<Record<Size, Look>> = {
50 small: { sprite: 'mini', headroom: 1, ground: 1, unit: 6, font: 10 },
51 medium: { sprite: 'big', headroom: 2, ground: 2, unit: 8, font: 12 },
52}
53
54export const PALETTE = {
55 yellow: 0xffd787,
56 pink: 0xffafd7,
57 heart: 0xff87af,
58 sparkle: 0xffe98a,
59 grass: 0xafd7af,
60 tuft: 0xc9e8c4,
61 grassDeep: 0x8fc98f,
62 shadow: 0x6fa86f,
63 petal: 0xfff3dc,
64} as const
65
66const HEART = ['hh.hh', 'hhhhh', '.hhh.', '..h..'] as const
67/** The happy sparkle, and a star's twinkle. */
68const SPARKLE = ['.s.', 'sws', '.s.'] as const
69const GIFT = ['y.y..', '.y...', 'ppypp', 'ppypp', 'ppypp'] as const
70
71/** What each nature holds up while a tool runs. */
72const PROPS: Readonly<Record<Nature, { rows: readonly string[]; ink: Readonly<Record<string, number>> } | null>> = {
73 // An adult worker swings its pickaxe instead.
74 worker: { rows: ['kkkkk', 'kbbbk', 'kbbbk', 'kkkkk', '.kkk.'], ink: { k: 0x8a8a9a, b: 0x87d7ff } },
75 scholar: { rows: ['bbwbb', 'bbwbb', 'bbwbb'], ink: { b: 0x6fa8dc, w: 0xfffaf0 } },
76 sweetie: { rows: ['h.h', 'hhh', '.h.'], ink: { h: 0xff87af } },
77 gamer: { rows: ['.....', 'kkkkk', 'kgkrk', 'kkkkk'], ink: { k: 0x8a8a9a, g: 0x7cc576, r: 0xff6b8a } },
78 curious: null,
79}
80
81const SKY: Readonly<Record<Daypart, { rows: readonly string[]; ink: Readonly<Record<string, number>> }>> = {
82 night: { rows: ['.mm', 'm..', 'm..', '.mm'], ink: { m: 0xfff3b0 } },
83 morning: { rows: ['.s.', 'sss', '.s.'], ink: { s: 0xffc46b } },
84 day: { rows: ['.ss.', 'ssss', 'ssss', '.ss.'], ink: { s: 0xffe36e } },
85 evening: { rows: ['.ss.', 'ssss'], ink: { s: 0xff9a76 } },
86}
87const STARS = [3, 11, 19, 27, 35] as const
88
89type Prop = { rows: readonly string[]; ink: Readonly<Record<string, number>>; lift?: number }
90
91/**
92 * What fills the yard as a side of its nature grows, big and small: a building site, a
93 * library corner, a garden, an arcade. Each side's props, in the order they appear.
94 */
95const CONE = { o: 0xff7a2a, O: 0xd9531a, w: 0xfffaf0, W: 0xd8d4cc, k: 0x4a4a58 } as const
96
97const YARD: Readonly<Record<(typeof SIDES)[number], { big: readonly Prop[]; mini: readonly Prop[] }>> = {
98 worker: {
99 big: [
100 // A traffic cone: a narrow tip, two white bands, a dark square base.
101 { rows: ['...o...', '..ooO..', '..wwW..', '.oooOO.', '.wwwwW.', 'kkkkkkk'], ink: CONE },
102 { rows: ['wowow', 'wowow', 'k...k', 'k...k'], ink: { w: 0xfffaf0, o: 0xff8a3d, k: 0x8a8a9a } },
103 { rows: ['..gg..', '.gGgg.', 'gGgggG'], ink: { g: 0xb0b0c0, G: 0x80808f } },
104 { rows: ['yyyyy', 'ykyky', 'yyyyy', '..k..', '..k..'], ink: { y: 0xffd447, k: 0x5f5f5f } },
105 ],
106 mini: [
107 { rows: ['..o..', '.wwW.', '.ooO.', 'kkkkk'], ink: CONE },
108 { rows: ['.gg', 'gGg'], ink: { g: 0xb0b0c0, G: 0x80808f } },
109 ],
110 },
111 scholar: {
112 big: [
113 { rows: ['bbbbbb', 'brguyb', 'bbbbbb', 'byrgub', 'bbbbbb'], ink: { b: 0x8a5a3a, r: 0xe06b6b, g: 0x7cc576, u: 0x6fa8dc, y: 0xffd447 } },
114 { rows: ['.yyy.', 'yyyyy', '..k..', '..k..', '.kkk.'], ink: { y: 0xffe9a0, k: 0x8a8a9a } },
115 { rows: ['.ugu.', 'uggug', '.ugu.', '..k..', '.kkk.'], ink: { u: 0x6fa8dc, g: 0x7cc576, k: 0x8a8a9a } },
116 { rows: ['rrrr', 'gggg', 'uuuu'], ink: { r: 0xe06b6b, g: 0x7cc576, u: 0x6fa8dc } },
117 ],
118 mini: [
119 { rows: ['bbbb', 'brgb', 'bbbb'], ink: { b: 0x8a5a3a, r: 0xe06b6b, g: 0x7cc576 } },
120 { rows: ['yyy', '.k.', 'kkk'], ink: { y: 0xffe9a0, k: 0x8a8a9a } },
121 ],
122 },
123 sweetie: {
124 big: [
125 { rows: ['p.p', 'ppp', '.g.', 'gg.', '.g.'], ink: { p: 0xff87af, g: 0x7cc576 } },
126 { rows: ['gg.gg', 'gpgpg', 'ggggg', '.ggg.'], ink: { g: 0x6fbf6f, p: 0xff87af } },
127 { rows: ['.p.', 'pyp', '.p.', 'ttt', '.t.'], ink: { p: 0xffafd7, y: 0xffe36e, t: 0xd0805a } },
128 { rows: ['p.p', '.k.', 'p.p'], ink: { p: 0xffafd7, k: 0x5f4b4b }, lift: 6 },
129 ],
130 mini: [
131 { rows: ['p.p', 'ppp', '.g.'], ink: { p: 0xff87af, g: 0x7cc576 } },
132 { rows: ['.p.', 'pyp', '.g.'], ink: { p: 0xffafd7, y: 0xffe36e, g: 0x7cc576 } },
133 ],
134 },
135 gamer: {
136 big: [
137 { rows: ['kkkk', 'kuuk', 'kuuk', 'kkkk', 'krbk', 'kkkk'], ink: { k: 0x6a5aa8, u: 0x7fe0ff, r: 0xff6b8a, b: 0xffd447 } },
138 { rows: ['yyyyy', '.yyy.', '..y..', '.yyy.'], ink: { y: 0xffd447 } },
139 { rows: ['.y.', 'yYy', '.y.'], ink: { y: 0xffd447, Y: 0xfff3b0 }, lift: 5 },
140 { rows: ['.r.', '.k.', 'kkk'], ink: { r: 0xff6b8a, k: 0x6a5aa8 } },
141 ],
142 mini: [
143 { rows: ['kkk', 'kuk', 'krk'], ink: { k: 0x6a5aa8, u: 0x7fe0ff, r: 0xff6b8a } },
144 { rows: ['yyy', '.y.', 'yyy'], ink: { y: 0xffd447 } },
145 ],
146 },
147}
148
149/** What the grass turns toward as a side takes over: dirt, a wooden floor, lush green, an arcade carpet. */
150const GROUND_TINT: Readonly<Record<(typeof SIDES)[number], number>> = {
151 worker: 0xc9a27a,
152 scholar: 0xb58a64,
153 sweetie: 0x9fe08f,
154 gamer: 0x8f7fd0,
155}
156
157/** Where the largest of `values` is, or -1 when it is shared: a tie singles nothing out. */
158export function topOf(values: readonly number[]): number {
159 const best = Math.max(...values)
160 const at = values.indexOf(best)
161
162 return values.indexOf(best, at + 1) === -1 ? at : -1
163}
164
165function blend(from: number, to: number, amount: number): number {
166 const mix = (shift: number) => Math.round(((from >> shift) & 0xff) * (1 - amount) + ((to >> shift) & 0xff) * amount)
167
168 return (mix(16) << 16) | (mix(8) << 8) | mix(0)
169}
170
171/**
172 * How many props each side gets of `slots`, by its share of the leaning (largest remainder),
173 * so the yard fills one prop at a time as a side grows, and empties the same way as it fades.
174 */
175export function yardOf(leaning: readonly number[], slots: number): number[] {
176 const wanted = SIDES.map((_, at) => (leaning[at] ?? 0) * slots)
177 const counts = wanted.map(Math.floor)
178 const left = slots - counts.reduce((sum, count) => sum + count, 0)
179 const order = wanted.map((want, at) => [want - Math.floor(want), at] as const).sort((a, b) => b[0] - a[0])
180
181 for (let i = 0; i < left && i < order.length; i += 1) {
182 const at = order[i]?.[1] ?? 0
183 if ((wanted[at] ?? 0) > 0) {
184 counts[at] = (counts[at] ?? 0) + 1
185 }
186 }
187
188 return counts
189}
190
191/** Pixels laid over the sprite, facing left, from its top left. */
192export type Overlay = { rows: readonly string[]; x: number; y: number; ink: Readonly<Record<string, number>> }
193
194/** The marks that are eyes, which gear must never cover. */
195const EYE_MARKS = new Set(['k', 'e', 'w'])
196
197const GEAR_INK = {
198 k: 0x4f5584, // cap, headset band
199 h: 0x8a90b8, // the cap's top and the headset's band, catching the light
200 y: 0xffd447, // tassel
201 r: 0xff6b8a, // headset cups
202 g: 0xc0c4d0, // pickaxe head
203 b: 0x9a6a44, // pickaxe handle
204 p: 0xffafd7, // hairpin petals
205 c: 0xffe36e, // hairpin heart
206} as const
207
208/**
209 * What its nature puts on it, by where its head is: the worker's pickaxe held out in front
210 * (raised and lowered while tools run), the scholar's cap, the gamer's headset, the
211 * sweetie's flower pin. Nothing for the curious.
212 */
213export function gearOf(nature: Nature, head: Head, isBig: boolean, isWorking: boolean, frame: number, rows: readonly string[] = []): Overlay[] {
214 const middle = Math.round((head.left + head.right) / 2)
215
216 switch (nature) {
217 case 'worker': {
218 const swing = isWorking && frame % 2 === 1 ? -1 : 0
219 const rows = isBig ? ['.ggg.', 'gg.gg', 'g.b.g', '..b..', '..b..'] : ['ggg', 'g.g', '.b.']
220
221 return [{ rows, x: head.left - (isBig ? 4 : 3), y: head.eye + swing + (isBig ? 1 : 0), ink: GEAR_INK }]
222 }
223 case 'scholar': {
224 // The mini gets the board alone, two rows above its eyes.
225 const rows = isBig ? ['...h...', 'hhhhhhh', '.kkkkky', '......y'] : ['.hhh.', 'hhhhh']
226 const width = rows[1]?.length ?? 0
227
228 return [{ rows, x: middle - Math.floor(width / 2), y: head.top - 2, ink: GEAR_INK }]
229 }
230 case 'gamer': {
231 // Cups on the sides of the head, inside its edge unless an eye is there, then just outside.
232 const cup = isBig ? 2 : 1
233 const eyes = rows[head.eye] ?? ''
234 const isEyeAt = (from: number) => [...eyes.slice(Math.max(0, from), from + cup)].some(mark => EYE_MARKS.has(mark))
235 const isInside = !isEyeAt(head.left) && !isEyeAt(head.right - cup + 1)
236 const left = isInside ? head.left : head.left - cup
237 const across = (isInside ? head.right - head.left + 1 : head.right - head.left + 1 + cup * 2) - cup * 2
238 const band = '.'.repeat(cup) + 'h'.repeat(across) + '.'.repeat(cup)
239 const side = '.'.repeat(cup - 1) + 'h' + '.'.repeat(across) + 'h' + '.'.repeat(cup - 1)
240 const cups = 'r'.repeat(cup) + '.'.repeat(across) + 'r'.repeat(cup)
241 const reach = Math.max(0, head.eye - head.top)
242 const lines = [band, ...Array.from({ length: reach }, () => side), cups, ...(isBig ? [cups] : [])]
243
244 return [{ rows: lines, x: left, y: head.top - 1, ink: GEAR_INK }]
245 }
246 case 'sweetie':
247 return [{ rows: isBig ? ['p.p', '.c.', 'p.p'] : ['pc'], x: head.right - (isBig ? 3 : 2), y: head.top - 1, ink: GEAR_INK }]
248 case 'curious':
249 return []
250 }
251}
252
253/** An overlay's rows and left edge for the way the pet faces: mirrored with it. */
254function facing(overlay: Overlay, spriteWidth: number, dir: Pet['dir']): Overlay {
255 if (dir !== 1) {
256 return overlay
257 }
258 const across = overlay.rows[0]?.length ?? 0
259
260 return { ...overlay, rows: overlay.rows.map(row => [...row].reverse().join('')), x: spriteWidth - overlay.x - across }
261}
262const BALL = ['rw', 'wr'] as const
263const GIFT_INK = { p: 0xff9ec4, y: 0xffe36e } as const
264/** Columns kept free beside the sprite for the heart or sparkle. */
265const BADGE = 6
266
267export type Pixels = (number | null)[][]
268
269/** What `paint` drew: the pixels, where the sprite sits in them, and the shade its leaning gives the grass. */
270export type Scene = { pixels: Pixels; left: number; top: number; spriteWidth: number; ground: number; shade: (color: number) => number }
271
272export function lookOf(size: Size): Look {
273 return LOOKS[size]
274}
275
276/** The big sprite for a stage: the baby, the teen, or an adult's own drawing where it has one. */
277export function bodyOf(kind: Species, stage: Stage, form: Form): Sprite {
278 if (stage === 'baby') {
279 return kind.big
280 }
281 if (stage === 'adult' && form !== 'rare') {
282 return kind.adults?.[form] ?? kind.teen
283 }
284
285 return kind.teen
286}
287
288function spriteOf(kind: Species, size: Size): Sprite {
289 return kind[LOOKS[size].sprite]
290}
291
292/** Pixel rows of the yard: headroom, sprite and grass. Even, so it folds into whole cells. */
293export function sceneHeight(kind: Species, size: Size): number {
294 const look = LOOKS[size]
295 const rows = look.headroom + spriteOf(kind, size).rows.length + look.ground
296
297 return rows + (rows % 2)
298}
299
300/** Terminal rows of the yard at `size`. */
301export function terminalRows(kind: Species, size: Size): number {
302 return sceneHeight(kind, size) / 2
303}
304
305/** The narrowest yard, in sprite pixels: the sprite and its badge. */
306export function minWidth(kind: Species, size: Size): number {
307 return (spriteOf(kind, size).rows[0]?.length ?? 0) + BADGE
308}
309
310/** The sprite's pixel rows for this tick: eyes, tear and feet by mood, flipped to face its way. */
311export function spriteRows(one: Pet, sprite: Sprite, isSad: boolean): string[] {
312 const rows: string[] = [...sprite.rows]
313 const isBlinking = one.mood === 'walk' && one.frame % 9 === 0
314 const swap = (with_: Readonly<Record<number, string>>) => {
315 for (const [at, row] of Object.entries(with_)) {
316 rows[Number(at)] = row
317 }
318 }
319
320 if (one.mood === 'sleep' || one.mood === 'happy' || one.mood === 'love' || one.mood === 'gift' || isBlinking) {
321 swap(sprite.eyesShut)
322 }
323 if (one.mood === 'walk' && isSad) {
324 swap(sprite.tear)
325 }
326 if (one.mood !== 'sleep' && one.frame % 2 === 1) {
327 swap(sprite.feetApart)
328 }
329
330 // Sprites are drawn facing left; mirrored when it walks right.
331 return one.dir === 1 ? rows.map(row => [...row].reverse().join('')) : rows
332}
333
334/** How the yard is painted beyond the pet and its size. */
335export type PaintOptions = {
336 /** Its tear when a rate limit is nearly used up. */
337 isSad?: boolean
338 /** The SVG card draws its own grass, so it asks for none. */
339 withGrass?: boolean
340 stage?: Stage
341 /** An adult's form; ignored before Lv 40. */
342 form?: Form
343 /** What it carries while it works, and what stands in its corner of the yard. */
344 nature?: Nature
345 /** The sun, the evening sun, or the moon and stars, by the local hour. */
346 daypart?: Daypart
347 /** Its leaning (worker, scholar, sweetie, gamer shares): how much of the yard each side fills. */
348 leaning?: readonly number[]
349}
350
351/** The accessory's rows and left edge for the way the pet faces: mirrored with it. */
352export function worn(accessory: Accessory, spriteWidth: number, dir: Pet['dir']): { rows: string[]; x: number } {
353 if (dir !== 1) {
354 return { rows: [...accessory.rows], x: accessory.x }
355 }
356 const across = accessory.rows[0]?.length ?? 0
357
358 return { rows: accessory.rows.map(row => [...row].reverse().join('')), x: spriteWidth - accessory.x - across }
359}
360
361/**
362 * The yard, `width` pixels across: the pet at step `one.x` of `steps` along it, what it wears
363 * at its stage, its heart or sparkle, and a strip of grass with two flowers and its shadow.
364 */
365/** The form an adult's gear is drawn for: its own, unless it is rare or its drawing has gear. */
366function dressedAsOf(sprite: Sprite, stage: Stage, form: Form): Nature {
367 return stage === 'adult' && form !== 'rare' && sprite.hasOwnGear !== true ? form : 'curious'
368}
369
370/**
371 * What a pet wears over its sprite, facing its way and placed from the sprite's top left: a
372 * teen's accessory, an adult's gear in its form (a cap, a headset or a pin takes the place of a
373 * head accessory), and on the big sprite a rare adult's mark. The pane and both games draw it
374 * from here, so a pet looks the same wherever it is.
375 */
376export function dressingOf(kind: Species, sprite: Sprite, isBig: boolean, stage: Stage, form: Form, dir: Pet['dir'], isWorking: boolean, frame: number): Overlay[] {
377 const spriteWidth = sprite.rows[0]?.length ?? 0
378 const isRare = isBig && stage === 'adult' && form === 'rare'
379 const dressedAs = dressedAsOf(sprite, stage, form)
380 const gear = gearOf(dressedAs, sprite.head ?? kind.head[isBig ? 'big' : 'mini'], isBig, isWorking, frame, sprite.rows)
381 const isHeadCovered = sprite.hasOwnGear === true || dressedAs === 'scholar' || dressedAs === 'gamer' || dressedAs === 'sweetie'
382 const onTop: Overlay[] = []
383
384 if (isRare) {
385 const { rows, x } = worn(kind.rare.overlay, spriteWidth, dir)
386
387 onTop.push({ rows, x, y: kind.rare.overlay.y, ink: kind.rare.overlay.ink })
388 } else if (stage !== 'baby' && !(isHeadCovered && kind.accessory.slot === 'head')) {
389 // An adult drawn on its own sits its accessory where the teen does.
390 const accessory = (isBig ? sprite.accessory ?? kind.teen.accessory : undefined) ?? kind.accessory[isBig ? 'big' : 'mini']
391 const { rows, x } = worn(accessory, spriteWidth, dir)
392
393 onTop.push({ rows, x, y: accessory.y, ink: accessory.ink })
394 }
395
396 return [...onTop, ...gear.map(overlay => facing(overlay, spriteWidth, dir))]
397}
398
399export function paint(one: Pet, kind: Species, size: Size, width: number, steps: number, options: PaintOptions = {}): Scene {
400 const { isSad = false, withGrass = true, stage = 'baby', nature = 'curious', daypart = 'day', form = 'curious', leaning = [] } = options
401 const look = LOOKS[size]
402 // The small pane keeps the mini sprite at every stage; the big one grows and takes its form.
403 const isBig = look.sprite === 'big'
404 const isRare = isBig && stage === 'adult' && form === 'rare'
405 const sprite = isBig ? bodyOf(kind, stage, form) : spriteOf(kind, size)
406 const ink = { ...kind.ink, ...(sprite.ink ?? {}), ...(isRare ? kind.rare.ink : {}) }
407 const height = sceneHeight(kind, size)
408 const spriteWidth = sprite.rows[0]?.length ?? 0
409 const pixels: Pixels = Array.from({ length: height }, () => Array.from({ length: width }, () => null))
410 const put = (x: number, y: number, color: number) => {
411 const line = pixels[y]
412
413 if (line !== undefined && x >= 0 && x < width) {
414 line[x] = color
415 }
416 }
417 const stamp = (rows: readonly string[], ink: Readonly<Record<string, number>>, left: number, top: number) => {
418 rows.forEach((row, y) => {
419 ;[...row].forEach((mark, x) => {
420 const color = ink[mark]
421
422 if (color !== undefined) {
423 put(left + x, top + y, color)
424 }
425 })
426 })
427 }
428
429 // Grass: a tufted top row over a deeper one, a pink and a yellow flower.
430 const grassTop = height - look.ground
431 // The side it leans to most tints the grass, more the further it leans; a tie tints nothing.
432 const leading = topOf(leaning)
433 const tint = leading < 0 ? 0 : Math.max(0, Math.min(0.6, ((leaning[leading] ?? 0) - 0.3) * 1.2))
434 const shade = (color: number) => (tint > 0 ? blend(color, GROUND_TINT[SIDES[leading] ?? 'worker'], tint) : color)
435 if (withGrass) {
436 for (let x = 0; x < width; x += 1) {
437 put(x, grassTop, shade(x % 4 === 1 ? PALETTE.tuft : PALETTE.grass))
438 for (let y = grassTop + 1; y < height; y += 1) {
439 put(x, y, shade(PALETTE.grassDeep))
440 }
441 }
442 put(2, grassTop, PALETTE.pink)
443 put(width - 6, grassTop, PALETTE.yellow)
444 }
445
446 // The sky in the top right corner, and at night a few stars that twinkle.
447 const sky = SKY[daypart]
448 stamp(sky.rows, sky.ink, width - (sky.rows[0]?.length ?? 0) - 1, 0)
449 if (daypart === 'night') {
450 STARS.forEach((x, at) => {
451 if ((one.frame + at) % 5 !== 0 && x < width - 6) {
452 put(x, at % 2, 0xfffaf0)
453 }
454 })
455 }
456
457 // The props of its leaning, in slots spread along the yard, behind the pet.
458 const slots = isBig ? 4 : 2
459 const counts = yardOf(leaning, slots)
460 const props = SIDES.flatMap((side, at) => YARD[side][isBig ? 'big' : 'mini'].slice(0, counts[at] ?? 0))
461 props.forEach((prop, slot) => {
462 const across = prop.rows[0]?.length ?? 0
463 const x = Math.round(((slot + 0.5) / slots) * (width - 8)) + 1 - Math.floor(across / 2)
464
465 stamp(prop.rows, prop.ink, x, grassTop - prop.rows.length - (prop.lift ?? 0))
466 })
467
468 const left = Math.round((one.x / steps) * Math.max(0, width - spriteWidth - BADGE))
469 const isBouncy = one.mood === 'walk' || one.mood === 'happy' || one.mood === 'gift'
470 const rest = grassTop - sprite.rows.length
471 const top = isBouncy && one.frame % 2 === 1 ? rest - 1 : rest
472
473 // Its shadow on the grass, narrower than it, so the feet stand on something.
474 if (withGrass) {
475 for (let x = left + 2; x < left + spriteWidth - 2; x += 1) {
476 put(x, grassTop, PALETTE.shadow)
477 }
478 }
479 stamp(spriteRows(one, sprite, isSad), ink, left, top)
480 const dressedAs = dressedAsOf(sprite, stage, form)
481 for (const overlay of dressingOf(kind, sprite, isBig, stage, form, one.dir, one.mood === 'work', one.frame)) {
482 stamp(overlay.rows, overlay.ink, left + overlay.x, top + overlay.y)
483 }
484 // An adult twinkles beside its head every other tick, high then low, unless a heart or a
485 // sparkle already shows there. The head is on the side it faces; with no room there, at the
486 // yard's left edge, it takes the badge columns on the right.
487 if (stage === 'adult' && (one.mood === 'walk' || one.mood === 'work' || one.mood === 'sleep') && one.frame % 2 === 0) {
488 const ahead = left - SPARKLE[0].length - 1
489 const x = one.dir === -1 && ahead >= 0 ? ahead : left + spriteWidth + 1
490
491 stamp(SPARKLE, { s: PALETTE.sparkle, w: PALETTE.petal }, x, one.frame % 4 === 0 ? top : top + 3)
492 }
493
494 if (one.mood === 'love') {
495 stamp(HEART, { h: PALETTE.heart }, left + spriteWidth + 1, Math.max(0, top - 1 + (one.frame % 2)))
496 }
497 const prop = PROPS[nature]
498 if (one.mood === 'work' && prop !== null && dressedAs !== 'worker') {
499 stamp(prop.rows, prop.ink, left + spriteWidth + 1, Math.max(0, top + 1))
500 }
501 // A gamer kicks a ball along ahead of it.
502 if (nature === 'gamer' && one.mood === 'walk') {
503 const ahead = one.dir === 1 ? left + spriteWidth + 1 : left - 3
504 stamp(one.frame % 2 === 0 ? BALL : [BALL[1], BALL[0]], { r: 0xff6b8a, w: 0xfffaf0 }, ahead, grassTop - 2)
505 }
506 if (one.mood === 'gift') {
507 // The gift it found, bobbing beside it.
508 stamp(GIFT, GIFT_INK, left + spriteWidth + 1, Math.max(0, top - 1 + (one.frame % 2)))
509 }
510 if (one.mood === 'happy') {
511 const isLeft = one.frame % 2 === 0
512
513 stamp(SPARKLE, { s: PALETTE.sparkle, w: PALETTE.petal }, isLeft ? left - 3 : left + spriteWidth, isLeft ? top + 1 : top - 1)
514 }
515
516 return { pixels, left, top, spriteWidth, ground: look.ground, shade }
517}
518
519export type Cell = { glyph: string; fg: number | null; bg: number | null }
520
521/** Folds each pair of pixel rows into one row of half-block cells. */
522export function toCells(pixels: Pixels): Cell[][] {
523 const lines: Cell[][] = []
524
525 for (let y = 0; y + 1 < pixels.length; y += 2) {
526 const upper = pixels[y] ?? []
527 const lower = pixels[y + 1] ?? []
528
529 lines.push(
530 upper.map((top, x) => {
531 const bottom = lower[x] ?? null
532
533 if (top === null) {
534 return bottom === null ? { glyph: ' ', fg: null, bg: null } : { glyph: '▄', fg: bottom, bg: null }
535 }
536
537 return top === bottom ? { glyph: '█', fg: top, bg: null } : { glyph: '▀', fg: top, bg: bottom }
538 }),
539 )
540 }
541
542 return lines
543}
544
545const DEFAULT_COLOR = 0x01000000
546
547/** `Raster`'s packing: little-endian u32 triplets `[codePoint, foreground, background]`, base64. */
548export function pack(lines: Cell[][]): string {
549 const words = new Uint32Array(
550 lines.flat().flatMap(cell => [cell.glyph.codePointAt(0) ?? 0x20, cell.fg ?? DEFAULT_COLOR, cell.bg ?? DEFAULT_COLOR]),
551 )
552
553 // The environment has Uint8Array.prototype.toBase64; the es2023 lib does not declare it yet.
554 const bytes = new Uint8Array(words.buffer) as Uint8Array & { toBase64(): string }
555
556 return bytes.toBase64()
557}
558
559export function hex(color: number): string {
560 return `#${color.toString(16).padStart(6, '0')}`
561}
562
563/** What the SVG card shows besides the yard: the name tag, what the pet says, and a line of counts. */
564export type Caption = { name: string; level: number; progress: number; says: string; tally: string; daypart?: Daypart }
565
566const CARD = { fill: '#fffaf3', edge: '#f3e2d2', ink: '#6b4f4f', tag: '#f48fb1', bubble: '#ffffff', grass: '#b5dcae', shadow: '#8fbf8a' } as const
567
568/** The card's backdrop and the tally's color through the day: cream, a warm morning, peach, night blue. */
569const CARD_SKY: Readonly<Record<Daypart, { fill: string; edge: string; tally: string }>> = {
570 morning: { fill: '#fff6e6', edge: '#f3e2c8', tally: CARD.ink },
571 day: { fill: CARD.fill, edge: CARD.edge, tally: CARD.ink },
572 evening: { fill: '#ffe8de', edge: '#f2cfc0', tally: CARD.ink },
573 night: { fill: '#2b3050', edge: '#3d4470', tally: '#d8dcf0' },
574}
575
576function escapeXml(text: string): string {
577 return text.replace(/[&<>"']/g, mark => `&#${mark.codePointAt(0)};`)
578}
579
580/** A rough width for `text` at `fontSize`: wide (CJK, Hangul) characters take 1 em, the rest 0.6 em. */
581function textWidth(text: string, fontSize: number): number {
582 return [...text].reduce((sum, mark) => sum + (/[ᄀ-ᅟ⺀-가-힣豈--⦆]/u.test(mark) ? 1 : 0.6), 0) * fontSize
583}
584
585/** The narrowest yard, in sprite pixels, whose card fits `line` under the grass. */
586export function cardWidthFor(size: Size, line: string): number {
587 const { unit, font } = LOOKS[size]
588
589 return Math.ceil(textWidth(line, font) / unit) + 2
590}
591
592/**
593 * The scene as an SVG card, as the remote surfaces draw it: crisp pixels on a cream card,
594 * a pink name tag with the level and its progress, and a speech bubble over the pet.
595 */
596export function toSvg(scene: Scene, size: Size, caption: Caption): string {
597 const { unit, font } = LOOKS[size]
598 const pad = unit * 2
599 const band = font * 2 + 8
600 const cols = scene.pixels[0]?.length ?? 0
601 const width = cols * unit + pad * 2
602 const foot = font + 10
603 const height = band + scene.pixels.length * unit + pad + foot
604 const parts: string[] = []
605 const dots: string[] = []
606 const text = (x: number, y: number, body: string, attrs: string) =>
607 `<text x="${x}" y="${y}" font-family="ui-rounded, 'SF Pro Rounded', system-ui, sans-serif" font-size="${font}" ${attrs}>${escapeXml(body)}</text>`
608
609 const backdrop = CARD_SKY[caption.daypart ?? 'day']
610 parts.push(`<rect x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="${unit * 3}" fill="${backdrop.fill}" stroke="${backdrop.edge}"/>`)
611
612 // A rounded mound of grass with a scalloped top, three flowers, and the pet's shadow.
613 const groundTop = band + (scene.pixels.length - scene.ground) * unit
614 const bump = unit * 1.1
615 // The card draws its own grass, in the shade its leaning gives the pane's.
616 const grass = hex(scene.shade(Number.parseInt(CARD.grass.slice(1), 16)))
617 const mound: string[] = [`<rect x="${pad}" y="${groundTop}" width="${cols * unit}" height="${scene.ground * unit + unit}" rx="${unit}" fill="${grass}"/>`]
618 for (let x = pad + bump; x < pad + cols * unit - bump / 2; x += bump * 2) {
619 mound.push(`<circle cx="${x}" cy="${groundTop + 1}" r="${bump}" fill="${grass}"/>`)
620 }
621 const flower = (x: number, petal: string) =>
622 `<circle cx="${x}" cy="${groundTop + unit * 0.6}" r="${unit * 0.7}" fill="${petal}"/><circle cx="${x}" cy="${groundTop + unit * 0.6}" r="${unit * 0.28}" fill="#ffd36e"/>`
623 mound.push(flower(pad + unit * 3, hex(PALETTE.pink)), flower(pad + cols * unit * 0.62, '#ffffff'), flower(pad + cols * unit - unit * 4, hex(PALETTE.pink)))
624 mound.push(
625 `<ellipse cx="${pad + (scene.left + scene.spriteWidth / 2) * unit}" cy="${groundTop + unit * 0.4}" rx="${(scene.spriteWidth / 2 - 1) * unit}" ry="${unit * 0.7}" fill="${CARD.shadow}"/>`,
626 )
627 parts.push(...mound)
628
629 // Pixels, each row merged into runs of one color so the markup stays small.
630 scene.pixels.forEach((row, y) => {
631 let x = 0
632
633 while (x < row.length) {
634 const color = row[x]
635 let end = x + 1
636
637 while (end < row.length && row[end] === color) {
638 end += 1
639 }
640 if (color !== null && color !== undefined) {
641 dots.push(`<rect x="${pad + x * unit}" y="${band + y * unit}" width="${(end - x) * unit}" height="${unit}" fill="${hex(color)}"/>`)
642 }
643 x = end
644 }
645 })
646 parts.push(`<g shape-rendering="crispEdges">${dots.join('')}</g>`)
647
648 // The tag: name and level, and a thin bar of the way to the next level.
649 const label = caption.name === '' ? `Lv ${caption.level}` : `${caption.name} Lv ${caption.level}`
650 const tagWidth = textWidth(label, font) + 16
651 const tagHeight = font + 8
652 parts.push(`<rect x="${pad}" y="6" width="${tagWidth}" height="${tagHeight}" rx="${tagHeight / 2}" fill="${CARD.tag}"/>`)
653 parts.push(text(pad + 8, 6 + font + 1, label, 'fill="#ffffff" font-weight="700"'))
654 const barLeft = pad + tagWidth + 6
655 const barWidth = Math.min(48, Math.max(0, width - barLeft - pad))
656 if (barWidth >= 16) {
657 parts.push(`<rect x="${barLeft}" y="${6 + tagHeight / 2 - 2}" width="${barWidth}" height="4" rx="2" fill="${CARD.edge}"/>`)
658 parts.push(
659 `<rect x="${barLeft}" y="${6 + tagHeight / 2 - 2}" width="${Math.round(barWidth * Math.min(1, Math.max(0, caption.progress)))}" height="4" rx="2" fill="${CARD.tag}"/>`,
660 )
661 }
662
663 // The bubble, centred over the pet and kept inside the card.
664 if (caption.says !== '') {
665 const bubbleWidth = textWidth(caption.says, font) + 14
666 const bubbleHeight = font + 8
667 const centre = pad + (scene.left + scene.spriteWidth / 2) * unit
668 const x = Math.min(width - pad - bubbleWidth, Math.max(pad, centre - bubbleWidth / 2))
669 const y = Math.max(tagHeight + 10, band + scene.top * unit - bubbleHeight - 6)
670 const tail = Math.min(x + bubbleWidth - 8, Math.max(x + 8, centre))
671
672 parts.push(`<path d="M${tail - 4} ${y + bubbleHeight - 1} L${tail} ${y + bubbleHeight + 5} L${tail + 4} ${y + bubbleHeight - 1} Z" fill="${CARD.bubble}" stroke="${CARD.edge}"/>`)
673 parts.push(`<rect x="${x}" y="${y}" width="${bubbleWidth}" height="${bubbleHeight}" rx="${bubbleHeight / 2}" fill="${CARD.bubble}" stroke="${CARD.edge}"/>`)
674 parts.push(`<rect x="${tail - 3.5}" y="${y + bubbleHeight - 2}" width="7" height="2" fill="${CARD.bubble}"/>`)
675 parts.push(text(x + 7, y + font + 1, caption.says, `fill="${CARD.ink}" font-weight="600"`))
676 }
677
678 // The counts under the grass, centred.
679 parts.push(text(width / 2, height - pad / 2 - 4, caption.tally, `fill="${backdrop.tally}" fill-opacity="0.7" text-anchor="middle"`))
680
681 return [
682 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
683 ...parts,
684 '</svg>',
685 ].join('')
686}
687
688/** Pixels as an SVG on a dark rounded backdrop, `unit` CSS pixels each: Pet Run off the terminal. */
689export function pixelsSvg(pixels: Pixels, unit: number): string {
690 const width = (pixels[0]?.length ?? 0) * unit
691 const height = pixels.length * unit
692 const dots: string[] = []
693
694 pixels.forEach((row, y) => {
695 let x = 0
696
697 while (x < row.length) {
698 const color = row[x]
699 let end = x + 1
700
701 while (end < row.length && row[end] === color) {
702 end += 1
703 }
704 if (color !== null && color !== undefined) {
705 dots.push(`<rect x="${x * unit}" y="${y * unit}" width="${(end - x) * unit}" height="${unit}" fill="${hex(color)}"/>`)
706 }
707 x = end
708 }
709 })
710
711 return [
712 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
713 `<rect width="${width}" height="${height}" rx="${unit * 2}" fill="#1e1e2e"/>`,
714 `<g shape-rendering="crispEdges">${dots.join('')}</g>`,
715 '</svg>',
716 ].join('')
717}
718hooks/quest.ts 518 lines1/**
2 * Pet Quest: an auto-running side-scroller. The pet runs on its own; a press jumps, a second
3 * press while rising jumps higher. Stomp the bugs, snap up the snacks, knock the gift boxes
4 * from below, hop the stumps, and reach the snack bowl.
5 *
6 * Stages are tile maps, one character per 4×4 tile, as the sprites are pixels: the eight
7 * below are plain data. Pure, like run.ts: `advanceQuest` and `paintQuest` are all there is.
8 *
9 * @handbook 3.5-pet-quest
10 * @tested tests/pet.test.ts
11 */
12import type { Pet } from '../types'
13import { PALETTE, dressingOf, spriteRows } from './scene'
14import type { Form, Pixels, Stage } from './scene'
15import type { Species } from './species'
16
17export const QUEST_TICK_MS = 50
18const TILE = 4
19/** Rows of tiles in every stage; the view is this many tiles high. */
20export const QUEST_ROWS = 7
21export const QUEST_HEIGHT = QUEST_ROWS * TILE
22const SPEED = 1
23const GRAVITY = 0.45
24const FALL_MAX = 4
25const LIFT = 3.4
26const BOOST = 1.5
27const BOUNCE = 2.8
28const FOE_SPEED = 0.4
29/** The pet's box inside its 8×8 sprite. */
30const BOX = { x: 1, y: 1, w: 6, h: 7 } as const
31const SIDE = 8
32const SNACK_POINTS = 10
33const STOMP_POINTS = 20
34
35/**
36 * `.` sky, `#` ground, `=` brick, `g` a gift box holding a snack (`u` once it is opened), `T` a tree stump,
37 * `c` a snack, `b` a bug (it walks, turning at walls and edges), `F` the goal column
38 * (the snack bowl stands at its foot).
39 */
40export const STAGES: readonly (readonly string[])[] = [
41 // 1
42 [
43 '.............c......................................................................',
44 '................................................................................F...',
45 '............g...........................g=g.....................................F...',
46 '......................cc.......cc...===.............................#...........F...',
47 '................................................TT.....ccc.........##...........F...',
48 '..................TT......b.................b...TT............b...###...........F...',
49 '##############################...###################################################',
50 ],
51 // 2
52 [
53 '.............................c..................................................................',
54 '............................................................................................F...',
55 '............................g.............cc....g......................ccc.............#....F...',
56 '.....................cc...=====...#.#.........=====...........TT......................##....F...',
57 '............TT...................##.##..................TT....TT.....................###....F...',
58 '............TT..b...............###b###............b....TT....TT..b.............b...####....F...',
59 '####################...##################...##########################....######################',
60 ],
61 // 3
62 [
63 '............................................................................................................',
64 '....................................................................g...................................F...',
65 '...............................ccc....g=g......................................cccc.=....#..............F...',
66 '...............cc...................=======..........cc.....TT....====......==..........##..............F...',
67 '......................TT....................................TT.........................###.#............F...',
68 '..........b...........TT..b..................b..b...........TT..b.......b.............####b##...........F...',
69 '##############...#############....##################....######################.....#########################',
70 ],
71 // 4
72 [
73 '..............................................................................................................',
74 '.........................c...............c.................c...............c.................c............F...',
75 '.................c..........................g.........................g...................................F...',
76 '........................TT....cc........TT.....cc.........TT..............TT....cc..........TT............F...',
77 '................TT......TT........TT....TT................TT......TT......TT........TT......TT............F...',
78 '..........TT....TT..b...TT....b...TT....TT....b.....TT....TT..b...TT..b...TT....b...TT..b...TT............F...',
79 '##############################################################################################################',
80 ],
81 // 5
82 [
83 '................................................................................................................',
84 '............................................................................................................F...',
85 '....................g........cc.........g.........c..............ccc........g.........c.....................F...',
86 '..............cc............===................c.==.............====...............c.==.....................F...',
87 '..............==...............==.............==..................................==........................F...',
88 '......................b...............b.................b.b...............b...................b.............F...',
89 '############......########........##########........##########........##########........########################',
90 ],
91 // 6
92 [
93 '........................................................................................................',
94 '..........................................................................g.........................F...',
95 '................c...c.....g.....c...c...............g.....c...c.................c...c...c...........F...',
96 '..........................................====......................................................F...',
97 '......................................................................TT............................F...',
98 '..............b...b...b.......b...b...b.................b...b...b.....TT......b...b...b...b.........F...',
99 '################################################...#####################################################',
100 ],
101 // 7
102 [
103 '.................................c....................c........................c....................................',
104 '................c...............................................................................................F...',
105 '.....................c..g........##........c..g........#..........c...g........##...........c........#..........F...',
106 '................##..............####..................##.#....................####..................##..........F...',
107 '...............####............######................###.##..................######................###..........F...',
108 '..............######......b...########.b........b...####.###.b..........b...########..b...........####...b......F...',
109 '####################...###################...###################....######################....######################',
110 ],
111 // 8
112 [
113 '........................................................................................................................................',
114 '...............................c....................g......cc..............c...........................c....g..............#........F...',
115 '...............cc.....................cc........g..........==.................g..........c......................cc........##........F...',
116 '...............==.............TT.....===......#...........................TT..........c.==............TT.......===.......###........F...',
117 '........................TT....TT.............##.....................TT....TT.........==.........TT....TT................####........F...',
118 '..........b..........b..TT..b.TT..b.........###...b...b.........b...TT..b.TT....b.............b.TT..b.TT..b............#####..b.....F...',
119 '##############....##################.....#################....######################......####################.....#####################',
120 ],
121]
122
123const SOLID = new Set(['#', '=', 'g', 'u', 'T'])
124
125type Foe = { x: number; y: number; dir: -1 | 1; isAlive: boolean }
126
127export type QuestPhase = 'ready' | 'running' | 'clear' | 'over'
128
129export type Quest = {
130 stage: number
131 phase: QuestPhase
132 /** The stage's tiles as they stand: snacks eaten and blocks knocked are gone. */
133 tiles: string[]
134 /** The view's width in pixels. */
135 width: number
136 x: number
137 y: number
138 rise: number
139 isGrounded: boolean
140 hasBoosted: boolean
141 foes: Foe[]
142 snacks: number
143 stomps: number
144 tick: number
145}
146
147export function newQuest(stage: number, width: number): Quest {
148 const map = STAGES[stage] ?? STAGES[0] ?? []
149 const foes: Foe[] = []
150 const tiles = map.map((row, ty) =>
151 [...row]
152 .map((mark, tx) => {
153 if (mark === 'b') {
154 foes.push({ x: tx * TILE, y: ty * TILE, dir: -1, isAlive: true })
155
156 return '.'
157 }
158
159 return mark
160 })
161 .join(''),
162 )
163 // Feet on the ground of the first column.
164 const groundRow = tiles.findIndex(row => SOLID.has(row[1] ?? '.'))
165
166 return {
167 stage,
168 phase: 'ready',
169 tiles,
170 width,
171 x: TILE,
172 y: (groundRow < 0 ? QUEST_ROWS - 1 : groundRow) * TILE - SIDE,
173 rise: 0,
174 isGrounded: true,
175 hasBoosted: false,
176 foes,
177 snacks: 0,
178 stomps: 0,
179 tick: 0,
180 }
181}
182
183export function questScore(quest: Quest): number {
184 return quest.snacks * SNACK_POINTS + quest.stomps * STOMP_POINTS + (quest.phase === 'clear' ? 100 : 0)
185}
186
187function tileAt(tiles: readonly string[], tx: number, ty: number): string {
188 if (tx < 0) {
189 return '#'
190 }
191
192 return tiles[ty]?.[tx] ?? '.'
193}
194
195function isSolidAt(tiles: readonly string[], px: number, py: number): boolean {
196 return SOLID.has(tileAt(tiles, Math.floor(px / TILE), Math.floor(py / TILE)))
197}
198
199/** Tiles under a box, as `[tx, ty]`. */
200function tilesUnder(x: number, y: number, w: number, h: number): [number, number][] {
201 const under: [number, number][] = []
202
203 for (let ty = Math.floor(y / TILE); ty <= Math.floor((y + h - 0.01) / TILE); ty += 1) {
204 for (let tx = Math.floor(x / TILE); tx <= Math.floor((x + w - 0.01) / TILE); tx += 1) {
205 under.push([tx, ty])
206 }
207 }
208
209 return under
210}
211
212function hitsSolid(tiles: readonly string[], x: number, y: number, w: number, h: number): boolean {
213 return tilesUnder(x, y, w, h).some(([tx, ty]) => SOLID.has(tileAt(tiles, tx, ty)))
214}
215
216/** A press: starts the stage, jumps from the ground, or once per jump lifts it higher while rising. */
217export function jumpQuest(quest: Quest): Quest {
218 if (quest.phase === 'ready') {
219 return { ...quest, phase: 'running', rise: LIFT, isGrounded: false }
220 }
221 if (quest.phase !== 'running') {
222 return quest
223 }
224 if (quest.isGrounded) {
225 return { ...quest, rise: LIFT, isGrounded: false, hasBoosted: false }
226 }
227 if (!quest.hasBoosted && quest.rise > 0) {
228 return { ...quest, rise: quest.rise + BOOST, hasBoosted: true }
229 }
230
231 return quest
232}
233
234function setTile(tiles: string[], tx: number, ty: number, mark: string): void {
235 const row = tiles[ty]
236
237 if (row !== undefined && tx >= 0 && tx < row.length) {
238 tiles[ty] = row.slice(0, tx) + mark + row.slice(tx + 1)
239 }
240}
241
242/** One tick: run, fall or rise against the tiles, eat, knock, stomp, and end at a pit, a bug or the bowl. */
243export function advanceQuest(quest: Quest): Quest {
244 if (quest.phase !== 'running') {
245 return quest
246 }
247
248 const tiles = [...quest.tiles]
249 let { x, y, rise, isGrounded, hasBoosted, snacks, stomps } = quest
250
251 // Across: the pet runs until a wall stops it.
252 const ahead = x + SPEED
253 if (!hitsSolid(tiles, ahead + BOX.x, y + BOX.y, BOX.w, BOX.h)) {
254 x = ahead
255 }
256
257 // Down and up.
258 rise = Math.max(-FALL_MAX, rise - GRAVITY)
259 const next = y - rise
260 if (rise <= 0) {
261 if (hitsSolid(tiles, x + BOX.x, next + BOX.y, BOX.w, BOX.h)) {
262 // Land on top of the tile under its feet.
263 y = Math.floor((next + BOX.y + BOX.h) / TILE) * TILE - BOX.y - BOX.h
264 rise = 0
265 isGrounded = true
266 hasBoosted = false
267 } else {
268 y = next
269 isGrounded = false
270 }
271 } else {
272 const head = tilesUnder(x + BOX.x, next + BOX.y, BOX.w, 0.5).filter(([tx, ty]) => SOLID.has(tileAt(tiles, tx, ty)))
273
274 if (head.length > 0) {
275 for (const [tx, ty] of head) {
276 if (tileAt(tiles, tx, ty) === 'g') {
277 setTile(tiles, tx, ty, 'u')
278 snacks += 1
279 }
280 }
281 y = (Math.floor((next + BOX.y) / TILE) + 1) * TILE - BOX.y
282 rise = 0
283 } else {
284 y = next
285 }
286 isGrounded = false
287 }
288
289 // Snacks it touches.
290 for (const [tx, ty] of tilesUnder(x + BOX.x, y + BOX.y, BOX.w, BOX.h)) {
291 if (tileAt(tiles, tx, ty) === 'c') {
292 setTile(tiles, tx, ty, '.')
293 snacks += 1
294 }
295 }
296
297 // Bugs near the view walk; a fall onto one stomps it, any other touch ends the run.
298 let phase: QuestPhase = 'running'
299 const foes = quest.foes.map(foe => {
300 if (!foe.isAlive || foe.x > x + quest.width) {
301 return foe
302 }
303 const step = foe.x + foe.dir * FOE_SPEED
304 const front = foe.dir === 1 ? step + TILE : step
305 const isBlocked = isSolidAt(tiles, front, foe.y + 1) || !isSolidAt(tiles, front, foe.y + TILE + 1)
306
307 return isBlocked ? { ...foe, dir: (foe.dir === 1 ? -1 : 1) as Foe['dir'] } : { ...foe, x: step }
308 })
309 const pet = { x: x + BOX.x, y: y + BOX.y, w: BOX.w, h: BOX.h }
310 for (const [i, foe] of foes.entries()) {
311 if (!foe.isAlive) {
312 continue
313 }
314 const isTouching = pet.x < foe.x + TILE && foe.x < pet.x + pet.w && pet.y < foe.y + TILE && foe.y + 1 < pet.y + pet.h
315 if (!isTouching) {
316 continue
317 }
318 // A stomp: falling, with its feet above the bug's back as the tick began.
319 if (rise < 0 && quest.y + BOX.y + BOX.h <= foe.y + 2) {
320 // A copy: the foe may still be the one the last tick's quest holds.
321 foes[i] = { ...foe, isAlive: false }
322 stomps += 1
323 rise = BOUNCE
324 isGrounded = false
325 } else {
326 phase = 'over'
327 }
328 }
329
330 if (y > QUEST_HEIGHT) {
331 phase = 'over'
332 }
333 const goal = tiles[QUEST_ROWS - 2]?.indexOf('F') ?? -1
334 if (phase === 'running' && goal >= 0 && pet.x + pet.w >= goal * TILE) {
335 phase = 'clear'
336 }
337
338 return { ...quest, tiles, x, y, rise, isGrounded, hasBoosted, foes, snacks, stomps, phase, tick: quest.tick + 1 }
339}
340
341/** Where the view starts: the pet a third of the way in, never past either end of the stage. */
342export function cameraOf(quest: Quest): number {
343 const length = (quest.tiles[0]?.length ?? 0) * TILE
344
345 return Math.round(Math.max(0, Math.min(length - quest.width, quest.x - quest.width / 3)))
346}
347
348const INK = {
349 dirt: 0x9c7a5b,
350 dirtDark: 0x7d6049,
351 brick: 0xd9946a,
352 mortar: 0xa86f4c,
353 gift: 0xff9ec4,
354 ribbon: 0xffe36e,
355 opened: 0xa88a8f,
356 bark: 0xa0704c,
357 barkDark: 0x7a5236,
358 rings: 0xe2bf8f,
359 bowl: 0x87b7ff,
360 bowlDark: 0x5f8fd8,
361 heart: 0xff87af,
362 marker: 0xffafd7,
363 snack: 0xffc46b,
364 cloud: 0x3c3c50,
365} as const
366
367const BUG = ['a..a', 'bbbb', 'bkbk', 'b..b'] as const
368const BUG_INK = { a: 0x5f4b5f, b: 0xb48ead, k: 0x3a2a3a } as const
369const CLOUD = ['..ccc...', '.cccccc.', 'cccccccc'] as const
370
371/** One tile's 4×4 pixels, by what is around it. */
372function tilePixels(tiles: readonly string[], tx: number, ty: number, tick: number): (number | null)[][] | null {
373 const mark = tileAt(tiles, tx, ty)
374 const above = tileAt(tiles, tx, ty - 1)
375 const fill = (color: number): (number | null)[][] => Array.from({ length: TILE }, () => Array.from({ length: TILE }, () => color))
376
377 switch (mark) {
378 case '#': {
379 const tile = fill(INK.dirt)
380 tile[2]![(tx * 3) % 4] = INK.dirtDark
381 if (!SOLID.has(above)) {
382 tile[0] = [PALETTE.grass, (tx % 3 === 0 ? PALETTE.tuft : PALETTE.grass), PALETTE.grass, PALETTE.grass]
383 tile[1] = [PALETTE.grassDeep, PALETTE.grassDeep, PALETTE.grassDeep, PALETTE.grassDeep]
384 }
385
386 return tile
387 }
388 case '=': {
389 const tile = fill(INK.brick)
390 tile[3] = [INK.mortar, INK.mortar, INK.mortar, INK.mortar]
391 tile[1]![tx % 2 === 0 ? 1 : 3] = INK.mortar
392
393 return tile
394 }
395 case 'g': {
396 // A gift box: pink, a yellow ribbon each way; the bow twinkles now and then.
397 const tile = fill(INK.gift)
398 tile[1] = [INK.ribbon, INK.ribbon, INK.ribbon, INK.ribbon]
399 for (const row of tile) {
400 row[2] = INK.ribbon
401 }
402 tile[0]![2] = Math.floor(tick / 6) % 4 === 0 ? 0xffffff : INK.ribbon
403
404 return tile
405 }
406 case 'u': {
407 const tile = fill(INK.opened)
408 tile[0] = [null, null, null, null]
409
410 return tile
411 }
412 case 'T': {
413 // A tree stump: bark down the sides, rings on top.
414 const isLeft = tileAt(tiles, tx - 1, ty) !== 'T'
415 const row: number[] = isLeft ? [INK.barkDark, INK.bark, INK.bark, INK.bark] : [INK.bark, INK.bark, INK.bark, INK.barkDark]
416 const tile = Array.from({ length: TILE }, (_, y) => (y % 2 === 1 ? row.map((color, x) => (x === 2 ? INK.barkDark : color)) : [...row]))
417 if (above !== 'T') {
418 tile[0] = Array.from({ length: TILE }, (_, x) => (x === (isLeft ? 3 : 0) ? INK.bark : INK.rings))
419 }
420
421 return tile
422 }
423 case 'c': {
424 const bob = Math.floor(tick / 5) % 2
425 const tile: (number | null)[][] = Array.from({ length: TILE }, () => Array.from({ length: TILE }, () => null))
426 tile[bob]![1] = tile[bob + 1]![0] = tile[bob + 1]![1] = tile[bob + 1]![2] = tile[bob + 2]![1] = INK.snack
427
428 return tile
429 }
430 case 'F': {
431 // The goal: a snack bowl at the foot of the column, a heart bobbing over it.
432 const tile: (number | null)[][] = Array.from({ length: TILE }, () => Array.from({ length: TILE }, () => null))
433 const below = tileAt(tiles, tx, ty + 1)
434 if (below !== 'F') {
435 tile[0] = [INK.snack, INK.snack, INK.snack, INK.snack]
436 tile[1] = [INK.bowl, INK.bowl, INK.bowl, INK.bowl]
437 tile[2] = [null, INK.bowlDark, INK.bowlDark, null]
438 tile[3] = [null, INK.bowl, INK.bowl, null]
439 } else if (tileAt(tiles, tx, ty + 2) !== 'F') {
440 const bob = Math.floor(tick / 6) % 2
441 tile[bob]![0] = tile[bob]![2] = tile[bob + 1]![0] = tile[bob + 1]![1] = tile[bob + 1]![2] = tile[bob + 2]![1] = INK.heart
442 }
443
444 return tile
445 }
446 default:
447 return null
448 }
449}
450
451/** The view as pixels, `quest.width` across and `QUEST_HEIGHT` down. */
452export function paintQuest(quest: Quest, kind: Species, stage: Stage, form: Form = 'curious'): Pixels {
453 const width = quest.width
454 const camera = cameraOf(quest)
455 const pixels: Pixels = Array.from({ length: QUEST_HEIGHT }, () => Array.from({ length: width }, () => null))
456 const put = (x: number, y: number, color: number | null | undefined) => {
457 const line = pixels[Math.round(y)]
458 const at = Math.round(x) - camera
459
460 if (line !== undefined && color !== null && color !== undefined && at >= 0 && at < width) {
461 line[at] = color
462 }
463 }
464 const stamp = (rows: readonly string[], ink: Readonly<Record<string, number>>, left: number, top: number) => {
465 rows.forEach((row, y) => {
466 ;[...row].forEach((mark, x) => put(left + x, top + y, ink[mark]))
467 })
468 }
469
470 // Clouds far off move at a quarter of the camera.
471 for (const at of [10, 52, 97, 140]) {
472 stamp(CLOUD, { c: INK.cloud }, at + (camera * 3) / 4, 1 + (at % 4))
473 }
474
475 const first = Math.floor(camera / TILE)
476 for (let ty = 0; ty < QUEST_ROWS; ty += 1) {
477 for (let tx = first; tx <= first + Math.ceil(width / TILE); tx += 1) {
478 tilePixels(quest.tiles, tx, ty, quest.tick)?.forEach((row, y) => row.forEach((color, x) => put(tx * TILE + x, ty * TILE + y, color)))
479 }
480 }
481
482 for (const foe of quest.foes) {
483 if (foe.isAlive) {
484 // Legs swap as it walks.
485 const rows = Math.floor(foe.x) % 2 === 0 ? BUG : [BUG[0], BUG[1], BUG[2], '.bb.']
486
487 stamp(rows, BUG_INK, Math.round(foe.x), foe.y)
488 }
489 }
490
491 const sprite = kind.mini
492 const one: Pet = {
493 x: 0,
494 dir: 1,
495 frame: quest.isGrounded && quest.phase === 'running' ? Math.floor(quest.tick / 3) : 0,
496 mood: quest.phase === 'over' ? 'sleep' : quest.phase === 'clear' ? 'happy' : 'walk',
497 hold: 0,
498 idle: 0,
499 }
500 const top = Math.round(quest.y)
501 stamp(spriteRows(one, sprite, false), kind.ink, Math.round(quest.x), top)
502 // Off the top of the view: a little arrow where it will come down.
503 if (top + BOX.y < 0) {
504 const at = Math.round(quest.x) + 3
505
506 put(at, 0, INK.marker)
507 put(at + 1, 0, INK.marker)
508 put(at - 1, 1, INK.marker)
509 put(at + 2, 1, INK.marker)
510 }
511 // Dressed as the pane dresses it: a teen's accessory, an adult's gear.
512 for (const overlay of dressingOf(kind, sprite, false, stage, form, 1, false, one.frame)) {
513 stamp(overlay.rows, overlay.ink, Math.round(quest.x) + overlay.x, top + overlay.y)
514 }
515
516 return pixels
517}
518hooks/nature.ts 144 lines1/**
2 * What kind of pet it is becoming: its nature, read off what it has lived through, and its
3 * rhythm, read off the hours its sessions keep. Pure: counts in, words out.
4 *
5 * @handbook 3.7-nature-rhythm
6 * @tested tests/pet.test.ts
7 */
8import type { PetStats } from '../types'
9import { SIDES, topOf } from './scene'
10import type { Side } from './scene'
11
12/** `curious` until it knows you, or while no one side of you stands out. */
13export type Nature = 'worker' | 'scholar' | 'sweetie' | 'gamer' | 'curious'
14
15/** The four quarters of a day by local hour: 0–6, 6–12, 12–18, 18–24. */
16export type Daypart = 'night' | 'morning' | 'day' | 'evening'
17
18export type Rhythm = 'night owl' | 'early bird' | 'daytimer' | 'evening type'
19
20/** Experience it takes before a nature shows, and the share one side must have. */
21const KNOWN_AFTER = 60
22const STANDS_OUT = 0.4
23/** Finished turns it takes before a rhythm shows, and the share one quarter must have. */
24const RHYTHM_AFTER = 20
25const RHYTHM_SHARE = 0.35
26
27export const DAYPARTS: readonly Daypart[] = ['night', 'morning', 'day', 'evening']
28const RHYTHMS: Readonly<Record<Daypart, Rhythm>> = {
29 night: 'night owl',
30 morning: 'early bird',
31 day: 'daytimer',
32 evening: 'evening type',
33}
34
35export function daypartOf(hour: number): Daypart {
36 return DAYPARTS[Math.min(3, Math.max(0, Math.floor(hour / 6)))] ?? 'day'
37}
38
39/**
40 * Each side's weight, in experience as `xpOf` counts it: tools for the worker, turns and
41 * output for the scholar, pats for the sweetie, game snacks for the gamer.
42 */
43export function sidesOf(stats: PetStats): Record<Exclude<Nature, 'curious'>, number> {
44 return {
45 worker: stats.tools,
46 scholar: stats.turns * 5 + Math.floor(stats.tokens / 1000),
47 sweetie: stats.pats * 2,
48 gamer: stats.snacks,
49 }
50}
51
52export { SIDES } from './scene'
53export type { Side } from './scene'
54
55/**
56 * How far one day moves the leaning toward what that day brought: 12%. Days, not turns, so a
57 * long session changes nothing until the day is over, and a heavy day counts as one day:
58 * a new prop shows after a few days of a new habit, a new form after about two weeks.
59 */
60const DRIFT = 0.12
61
62/** A weighting as shares that sum to 1, or all zero when there is nothing to share. */
63function shares(weights: readonly number[]): number[] {
64 const total = weights.reduce((sum, weight) => sum + weight, 0)
65
66 return weights.map(weight => (total > 0 ? weight / total : 0))
67}
68
69/** The leaning a pet starts from, before any turn has moved it: its whole life's shares. */
70export function lifetimeLeaning(stats: PetStats): number[] {
71 const sides = sidesOf(stats)
72
73 return shares(SIDES.map(side => sides[side]))
74}
75
76/**
77 * The leaning after a day: each side moves a little toward what the day brought (its tool
78 * calls, its turns and their output, its pats and game snacks). Old habits fade, so a pet that
79 * changes how it is kept changes with it, slowly.
80 */
81export function driftLeaning(leaning: readonly number[], brought: Readonly<Record<Side, number>>): number[] {
82 const now = shares(SIDES.map(side => brought[side]))
83
84 if (now.every(share => share === 0)) {
85 return [...leaning]
86 }
87 if (leaning.every(share => share === 0)) {
88 return now
89 }
90
91 return SIDES.map((_, at) => (leaning[at] ?? 0) * (1 - DRIFT) + (now[at] ?? 0) * DRIFT)
92}
93
94/** Whether it has seen enough of you for a nature to show. */
95export function isKnown(stats: PetStats): boolean {
96 return Object.values(sidesOf(stats)).reduce((sum, weight) => sum + weight, 0) >= KNOWN_AFTER
97}
98
99/** Its nature now: the side its leaning favors, once it knows you and one side stands out. */
100export function natureOf(stats: PetStats): Nature {
101 const sides = Object.values(sidesOf(stats))
102 const total = sides.reduce((sum, weight) => sum + weight, 0)
103 const leaning = stats.leaning.length === SIDES.length ? stats.leaning : lifetimeLeaning(stats)
104 const top = topOf(leaning)
105
106 return total >= KNOWN_AFTER && (leaning[top] ?? 0) >= STANDS_OUT ? SIDES[top] ?? 'curious' : 'curious'
107}
108
109/**
110 * An adult's form after a day: it keeps its form until its leaning has clearly moved on (the
111 * new side well ahead, the old one faded), then takes the new side's. The rare form stays.
112 */
113export function reformOf(form: string, leaning: readonly number[]): string {
114 const at = SIDES.indexOf(form as Side)
115 // A tie has not clearly moved on anywhere.
116 const top = topOf(leaning)
117 const isFaded = at < 0 ? true : (leaning[at] ?? 0) <= 0.2
118
119 return form !== 'rare' && form !== '' && top !== at && (leaning[top] ?? 0) >= 0.45 && isFaded ? SIDES[top] ?? form : form
120}
121
122export function rhythmOf(hours: readonly number[]): Rhythm | null {
123 const total = hours.reduce((sum, count) => sum + count, 0)
124 const top = topOf(hours)
125 const part = DAYPARTS[top]
126
127 return part !== undefined && total >= RHYTHM_AFTER && (hours[top] ?? 0) / total >= RHYTHM_SHARE ? RHYTHMS[part] : null
128}
129
130/** A line it says now and then while it wanders, by its nature and the time of day. */
131export function musingOf(nature: Nature, daypart: Daypart, beat: number): string {
132 const lines: Readonly<Record<Nature, readonly string[]>> = {
133 worker: ['ship it!', 'one more test…', 'refactor time'],
134 scholar: ['hmm, interesting', 'reading the docs', 'let me think…'],
135 sweetie: ['pat me?', '♡', 'you are doing great'],
136 gamer: ['wanna play?', 'new high score?', '/pet play!'],
137 curious: ['what is that?', 'la la la', 'sniff sniff'],
138 }
139 const late = daypart === 'night' ? ['it is late…', 'stars are out'] : daypart === 'morning' ? ['good morning!'] : []
140 const pool = [...lines[nature], ...late]
141
142 return pool[beat % pool.length] ?? ''
143}
144hooks/luck.ts 51 lines1/**
2 * What luck brings: a gift now and then after a finished turn, a lucky pat, and the rare shiny
3 * pet. Pure: the hooks module rolls `Math.random()` and hands the numbers in, so tests can too.
4 *
5 * @handbook 3.6-luck
6 * @tested tests/pet.test.ts
7 */
8
9/** A finished turn of the main conversation finds a gift this often. */
10export const GIFT_CHANCE = 0.1
11/** A pat is lucky this often, worth three times as much. */
12export const LUCKY_PAT_CHANCE = 0.1
13/** A pet met for the first time is shiny this often. */
14export const SHINY_CHANCE = 1 / 32
15/** The biggest gift, and what a second sparkle stone is worth instead. */
16const JACKPOT_XP = 100
17/** An evolution at Lv 40 takes the rare form this often. */
18export const RARE_CHANCE = 1 / 20
19
20export type Gift = { kind: 'xp'; name: string; xp: number } | { kind: 'stone' }
21
22/**
23 * Which gift a roll `r` in [0, 1) finds: mostly small, now and then a treasure, rarely a
24 * jackpot, and one time in a hundred the sparkle stone that makes the pet shiny.
25 */
26export function giftOf(r: number): Gift {
27 if (r < 0.6) {
28 return { kind: 'xp', name: 'a cookie', xp: 5 }
29 }
30 if (r < 0.85) {
31 return { kind: 'xp', name: 'a toy', xp: 15 }
32 }
33 if (r < 0.95) {
34 return { kind: 'xp', name: 'a treasure', xp: 40 }
35 }
36 if (r < 0.99) {
37 return { kind: 'xp', name: 'the jackpot', xp: JACKPOT_XP }
38 }
39
40 return { kind: 'stone' }
41}
42
43/** What a gift is worth to a pet that already is (or is not) shiny: a second stone is a jackpot. */
44export function worthOf(gift: Gift, isShiny: boolean): { xp: number; makesShiny: boolean } {
45 if (gift.kind === 'xp') {
46 return { xp: gift.xp, makesShiny: false }
47 }
48
49 return isShiny ? { xp: JACKPOT_XP, makesShiny: false } : { xp: 0, makesShiny: true }
50}
51hooks/run.ts 229 lines1/**
2 * Pet Run: the pet runs along the grass, jumps the bugs and snaps up the snacks above them.
3 *
4 * Pure, like scene.ts: `advance` moves one tick on, `paintRun` draws it as pixels. The hooks
5 * module owns the clock, the keys and the pane; tests drive the same functions.
6 *
7 * @handbook 3.4-pet-run
8 * @tested tests/pet.test.ts
9 */
10import type { Pet } from '../types'
11import { PALETTE, dressingOf, spriteRows } from './scene'
12import type { Form, Pixels, Stage } from './scene'
13import type { Species } from './species'
14
15/** Ticks a second the hooks module runs it at. */
16export const RUN_TICK_MS = 50
17/** Pixel rows: sky above two rows of grass. */
18export const RUN_HEIGHT = 22
19const GROUND = 2
20const PET_X = 4
21const GRAVITY = 0.6
22const LIFT = -3.6
23const SPEED_START = 1
24const SPEED_MAX = 2.6
25/** Distance the speed takes to climb one step of 0.1. */
26const SPEED_EVERY = 120
27const SNACK_POINTS = 10
28
29const BUG = ['.a.a.', 'bbbbb', 'bkbkb', '.b.b.'] as const
30const BUG_INK = { a: 0x5f4b5f, b: 0xb48ead, k: 0x3a2a3a } as const
31const SNACK = ['.c.', 'ccc', '.c.'] as const
32const SNACK_INK = { c: 0xffc46b } as const
33const CLOUD = ['..ccc...', '.cccccc.', 'cccccccc'] as const
34const CLOUD_INK = { c: 0x3c3c50 } as const
35
36export type Phase = 'ready' | 'running' | 'over'
37
38type Thing = { kind: 'bug' | 'snack'; x: number; y: number }
39
40export type Run = {
41 phase: Phase
42 width: number
43 /** Ticks since the run began: the score, and what animates. */
44 tick: number
45 /** The pet's top above its place on the grass, in pixels, and its upward speed. */
46 lift: number
47 rise: number
48 things: Thing[]
49 /** Pixels until the next bug. */
50 gap: number
51 snacks: number
52 seed: number
53}
54
55/** A small seeded random: the same seed runs the same course, so tests can replay it. */
56function random(seed: number): [number, number] {
57 let t = (seed + 0x6d2b79f5) | 0
58 t = Math.imul(t ^ (t >>> 15), t | 1)
59 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
60
61 return [((t ^ (t >>> 14)) >>> 0) / 4294967296, (seed + 0x6d2b79f5) | 0]
62}
63
64export function newRun(width: number, seed = 1): Run {
65 return { phase: 'ready', width, tick: 0, lift: 0, rise: 0, things: [], gap: width, snacks: 0, seed }
66}
67
68export function scoreOf(run: Run): number {
69 return Math.floor(run.tick / 2) + run.snacks * SNACK_POINTS
70}
71
72export function speedOf(run: Run): number {
73 return Math.min(SPEED_MAX, SPEED_START + Math.floor(run.tick / SPEED_EVERY) / 10)
74}
75
76/** A jump: starts a ready run, leaves the grass in a running one, and does nothing midair. */
77export function jump(run: Run): Run {
78 if (run.phase === 'over') {
79 return run
80 }
81 if (run.phase === 'ready') {
82 return { ...run, phase: 'running', rise: -LIFT }
83 }
84
85 return run.lift === 0 ? { ...run, rise: -LIFT } : run
86}
87
88function overlaps(a: { x: number; y: number; w: number; h: number }, b: { x: number; y: number; w: number; h: number }): boolean {
89 return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
90}
91
92/**
93 * One tick: the pet rises and falls, the course scrolls by the speed, a new bug comes when
94 * the gap closes (a snack sometimes floats over it), a snack touched is eaten, a bug ends it.
95 */
96export function advance(run: Run, kind: Species): Run {
97 if (run.phase !== 'running') {
98 return run
99 }
100
101 const speed = speedOf(run)
102 const sprite = kind.mini
103 const side = sprite.rows.length
104 const groundTop = RUN_HEIGHT - GROUND
105 let { seed, gap } = run
106 let roll: number
107
108 const rise = run.rise - GRAVITY
109 const lift = Math.max(0, run.lift + rise)
110 const things = run.things.map(thing => ({ ...thing, x: thing.x - speed })).filter(thing => thing.x > -6)
111
112 gap -= speed
113 if (gap <= 0) {
114 things.push({ kind: 'bug', x: run.width, y: groundTop - BUG.length })
115 ;[roll, seed] = random(seed)
116 if (roll < 0.45) {
117 // Over the bug, at the height a jump passes through.
118 things.push({ kind: 'snack', x: run.width + 1, y: groundTop - side - 6 - Math.floor(roll * 6) })
119 }
120 ;[roll, seed] = random(seed)
121 gap = Math.round((26 + roll * 30) * (0.7 + speed * 0.3))
122 }
123
124 // The pet's box, a pixel in from each side so a graze is forgiven.
125 const pet = { x: PET_X + 1, y: groundTop - side - lift + 1, w: side - 2, h: side - 2 }
126 let snacks = run.snacks
127 let isHit = false
128 const kept = things.filter(thing => {
129 const box =
130 thing.kind === 'bug'
131 ? { x: thing.x, y: thing.y + 1, w: BUG[0].length, h: BUG.length - 1 }
132 : { x: thing.x, y: thing.y, w: SNACK[0].length, h: SNACK.length }
133
134 if (!overlaps(pet, box)) {
135 return true
136 }
137 if (thing.kind === 'snack') {
138 snacks += 1
139
140 return false
141 }
142 isHit = true
143
144 return true
145 })
146
147 return {
148 ...run,
149 phase: isHit ? 'over' : 'running',
150 tick: run.tick + 1,
151 lift,
152 rise: lift === 0 ? 0 : rise,
153 things: kept,
154 gap,
155 snacks,
156 seed,
157 }
158}
159
160/** The run as pixels, `run.width` across and `RUN_HEIGHT` down: clouds, grass, things, the pet. */
161export function paintRun(run: Run, kind: Species, stage: Stage, form: Form = 'curious'): Pixels {
162 const width = run.width
163 const pixels: Pixels = Array.from({ length: RUN_HEIGHT }, () => Array.from({ length: width }, () => null))
164 const put = (x: number, y: number, color: number) => {
165 const line = pixels[y]
166 const at = Math.round(x)
167
168 if (line !== undefined && at >= 0 && at < width) {
169 line[at] = color
170 }
171 }
172 const stamp = (rows: readonly string[], ink: Readonly<Record<string, number>>, left: number, top: number) => {
173 rows.forEach((row, y) => {
174 ;[...row].forEach((mark, x) => {
175 const color = ink[mark]
176
177 if (color !== undefined) {
178 put(left + x, top + y, color)
179 }
180 })
181 })
182 }
183 const travelled = run.tick * speedOf(run)
184
185 // Clouds drift at a fifth of the speed; the grass's tufts scroll with the course.
186 for (const at of [6, 31, 57]) {
187 const x = (((at - travelled / 5) % (width + 10)) + width + 10) % (width + 10) - 8
188
189 stamp(CLOUD, CLOUD_INK, x, 2 + (at % 3))
190 }
191 const groundTop = RUN_HEIGHT - GROUND
192 for (let x = 0; x < width; x += 1) {
193 const mark = (x + Math.floor(travelled)) % 6
194
195 put(x, groundTop, mark === 0 ? PALETTE.tuft : mark === 3 ? PALETTE.pink : PALETTE.grass)
196 put(x, groundTop + 1, PALETTE.grassDeep)
197 }
198
199 for (const thing of run.things) {
200 if (thing.kind === 'bug') {
201 stamp(BUG, BUG_INK, thing.x, thing.y)
202 } else {
203 // Snacks bob a pixel.
204 stamp(SNACK, SNACK_INK, thing.x, thing.y + (Math.floor(run.tick / 4) % 2))
205 }
206 }
207
208 // The pet faces right, running: feet step every other tick, eyes shut and a tear once it is over.
209 const sprite = kind.mini
210 const side = sprite.rows.length
211 const one: Pet = {
212 x: 0,
213 dir: 1,
214 frame: run.phase === 'running' && run.lift === 0 ? Math.floor(run.tick / 2) : 0,
215 mood: run.phase === 'over' ? 'sleep' : 'walk',
216 hold: 0,
217 idle: 0,
218 }
219 const top = groundTop - side - Math.round(run.lift)
220
221 stamp(spriteRows(one, sprite, run.phase === 'over'), kind.ink, PET_X, top)
222 // Dressed as the pane dresses it: a teen's accessory, an adult's gear.
223 for (const overlay of dressingOf(kind, sprite, false, stage, form, 1, false, one.frame)) {
224 stamp(overlay.rows, overlay.ink, PET_X + overlay.x, top + overlay.y)
225 }
226
227 return pixels
228}
229hooks/species.ts 703 lines1/**
2 * A species is drawn twice, one character per pixel, facing left: `big` at 12×12 and
3 * `mini` at 8×8 for the small pane. Two pixel rows make one terminal row of half blocks,
4 * so the big sprite is 6 rows tall and the mini one 4.
5 *
6 * To add one: draw both sprites' `rows`, give every character a color in `ink` (`.` is
7 * empty), and supply the rows that replace the eyes, the cheek row and the feet.
8 *
9 * @handbook 4.1-species-sprite-data
10 * @tested tests/pet.test.ts
11 */
12export type Sprite = {
13 rows: readonly string[]
14 /** Colors of its own, laid over the species' (an adult form's clothes). */
15 ink?: Readonly<Record<string, number>>
16 /** Where its head and accessory sit, when not where the baby's do. */
17 head?: Head
18 accessory?: Accessory
19 /** Drawn in its own gear (a hood): no cap or headset over it. */
20 hasOwnGear?: boolean
21 /** Rows replaced while it blinks, sleeps or is pleased, by index. */
22 eyesShut: Readonly<Record<number, string>>
23 /** Rows replaced when a rate limit is nearly used up, by index. */
24 tear: Readonly<Record<number, string>>
25 /** Rows replaced on every other tick so it steps, by index. */
26 feetApart: Readonly<Record<number, string>>
27}
28
29/**
30 * What a grown pet wears: pixels laid over the sprite (drawn facing left, `.` see-through),
31 * `x` and `y` from the sprite's top left, in colors of their own.
32 */
33export type Accessory = {
34 rows: readonly string[]
35 x: number
36 y: number
37 ink: Readonly<Record<string, number>>
38}
39
40/**
41 * A sprite's head, facing left: `top` the row its crown starts on between any ears, `eye`
42 * the eyes' row, `left` and `right` its widest columns there.
43 */
44export type Head = { top: number; eye: number; left: number; right: number }
45
46export type Species = {
47 label: string
48 ink: Readonly<Record<string, number>>
49 big: Sprite
50 mini: Sprite
51 /** The teen, from Lv 15: taller, with a body of its own. */
52 teen: Sprite
53 /** The rare adult, one evolution in twenty: the teen in colors of its own, with a mark of its own. */
54 rare: { name: string; ink: Readonly<Record<string, number>>; overlay: Accessory }
55 /** Adults drawn for one nature or another; any other nature's adult is the teen in its gear. */
56 adults?: Partial<Record<'worker' | 'scholar' | 'sweetie' | 'gamer' | 'curious', Sprite>>
57 /** The rare shiny's colors, laid over `ink`. */
58 shiny: Readonly<Record<string, number>>
59 /** Worn from the grown stage on, at each sprite size; `head` ones give way to a cap or a headset. */
60 accessory: { slot: 'head' | 'body'; big: Accessory; mini: Accessory }
61 /** Where its head is in each sprite, for what its nature puts on it. */
62 head: { big: Head; mini: Head }
63 purr: string
64}
65
66const RIBBON = { r: 0xff6b8a, R: 0xd94f6e } as const
67const GOLD = { y: 0xffd447 } as const
68const BLOOM = { f: 0xffffff, Y: 0xffd447 } as const
69const SCARF = { s: 0x6fa8dc, S: 0x4f86bd } as const
70const LEAF = { g: 0x7cc576 } as const
71
72export const DEFAULT_SPECIES = 'cat'
73
74export const SPECIES: Readonly<Record<string, Species>> = {
75 cat: {
76 label: 'cat',
77 ink: { o: 0xffd787, w: 0xfffaf0, c: 0xfff3dc, p: 0xffafd7, k: 0x5f4b4b, m: 0xd08770, b: 0x87d7ff },
78 big: {
79 rows: [
80 '.o........o.',
81 '.oo......oo.',
82 '.opoooooopo.',
83 'oooooooooooo',
84 'oowkoooowkoo',
85 'ookkooookkoo',
86 'oppoommooppo',
87 '.oooooooooo.',
88 '..ooccccoo..',
89 '..occcccco.o',
90 '..occccccooo',
91 '...oo..oo...',
92 ],
93 eyesShut: { 4: 'oooooooooooo' },
94 tear: { 6: 'obpoommooppo' },
95 feetApart: { 11: '..oo....oo..' },
96 },
97 mini: {
98 rows: [
99 'o......o',
100 'oo....oo',
101 'oooooooo',
102 'okooooko',
103 'poommoop',
104 '.oooooo.',
105 '.occccoo',
106 '..o..o..',
107 ],
108 eyesShut: { 3: 'oooooooo' },
109 tear: { 4: 'pbommoop' },
110 feetApart: { 7: '.o....o.' },
111 },
112 shiny: { o: 0xd9c2ff, m: 0xa98ad8 },
113 teen: {
114 rows: [
115 '.o......o...',
116 '.oo....oo...',
117 '.opoooopo...',
118 'ooooooooo...',
119 'owkooowko...',
120 'okkoookko...',
121 'oppomoppo...',
122 '.ooooooo....',
123 '..occco...o.',
124 '.oocccoo.oo.',
125 '..occcoooo..',
126 '..oo..oo....',
127 ],
128 eyesShut: { 4: 'ooooooooo...' },
129 tear: { 6: 'obpomoppo...' },
130 feetApart: { 11: '.oo....oo...' },
131 head: { top: 2, eye: 4, left: 0, right: 8 },
132 accessory: { rows: ['r...r', 'rrRrr', 'r...r'], x: 5, y: 0, ink: RIBBON },
133 },
134 rare: { name: 'celestial', ink: { o: 0xfff1d0, p: 0xffc9e3, c: 0xffffff }, overlay: { rows: ['.yyyy.', 'y....y'], x: 1, y: -2, ink: { y: 0xffe36e } } },
135 adults: {
136 worker: {
137 rows: [
138 '.o......o...',
139 '.oo....oo...',
140 '.opoooopo...',
141 'ooooooooo...',
142 'owkooowko...',
143 'okkoookko...',
144 'oppomoppo...',
145 '.ooooooo....',
146 '..bOOOb...o.',
147 '.obOOObo.oo.',
148 '..bOOObooo..',
149 '..bb..bb....',
150 ],
151 eyesShut: { 4: 'ooooooooo...' },
152 tear: { 6: 'obpomoppo...' },
153 feetApart: { 11: '.bb....bb...' },
154 ink: { b: 0x5b7fb8, O: 0x7fa6e0 },
155 },
156 scholar: {
157 rows: [
158 '.o......o...',
159 '.oo....oo...',
160 '.opoooopo...',
161 'ooooooooo...',
162 'gwkgggwkg...',
163 'gkkgogkkg...',
164 'oppomoppo...',
165 '.ooooooo....',
166 '..rrwrr...o.',
167 '.orrwrro.oo.',
168 '..rrrrrooo..',
169 '..oo..oo....',
170 ],
171 eyesShut: { 4: 'googggoog...' },
172 tear: { 6: 'obpomoppo...' },
173 feetApart: { 11: '.oo....oo...' },
174 ink: { g: 0xd9b26a, r: 0xb85a5a },
175 },
176 gamer: {
177 rows: [
178 '.h......h...',
179 '.hh....hh...',
180 '.hhhhhhhh...',
181 'hhooooooh...',
182 'hwkooowkh...',
183 'hkkoookkh...',
184 'hppomopph...',
185 '.hooooohh...',
186 '..hhhhh...o.',
187 '.ohhwhho.oo.',
188 '..hhhhhooo..',
189 '..oo..oo....',
190 ],
191 eyesShut: { 4: 'hoooooooh...' },
192 tear: { 6: 'hbpomopph...' },
193 feetApart: { 11: '.oo....oo...' },
194 ink: { h: 0x7a6fb8 },
195 hasOwnGear: true,
196 },
197 sweetie: {
198 rows: [
199 '.o......o...',
200 '.oo....oo...',
201 '.opoooopo...',
202 'ooooooooo...',
203 'owkooowko...',
204 'okkoookko...',
205 'oppomoppo...',
206 'fooooooof...',
207 '..oHcHo...o.',
208 '.ooHHHoo.oo.',
209 '..ocHcoooo..',
210 '..oo..oo....',
211 ],
212 eyesShut: { 4: 'ooooooooo...' },
213 tear: { 6: 'obpomoppo...' },
214 feetApart: { 11: '.oo....oo...' },
215 ink: { H: 0xff87af, f: 0xfff3dc },
216 },
217 },
218 head: { big: { top: 2, eye: 4, left: 0, right: 11 }, mini: { top: 2, eye: 3, left: 0, right: 7 } },
219 accessory: {
220 slot: 'head',
221 big: { rows: ['r...r', 'rrRrr', 'r...r'], x: 7, y: 0, ink: RIBBON },
222 mini: { rows: ['r.r', 'rRr'], x: 5, y: 0, ink: RIBBON },
223 },
224 purr: 'purr',
225 },
226 chick: {
227 label: 'chick',
228 ink: { o: 0xffe98a, w: 0xfffaf0, l: 0xfff6c9, p: 0xffafd7, k: 0x5f4b4b, y: 0xffaf5f, b: 0x87d7ff },
229 big: {
230 rows: [
231 '.....oo.....',
232 '....oo......',
233 '...oooooo...',
234 '..oooooooo..',
235 '.oowkoowkoo.',
236 '.ookkookkoo.',
237 '.oppoyyoppo.',
238 'looooooooool',
239 '.oooooooooo.',
240 '..oooooooo..',
241 '...oooooo...',
242 '...yy..yy...',
243 ],
244 eyesShut: { 4: '.oooooooooo.' },
245 tear: { 6: '.obpoyyoppo.' },
246 feetApart: { 11: '..yy....yy..' },
247 },
248 mini: {
249 rows: [
250 '...oo...',
251 '..oooo..',
252 '.oooooo.',
253 '.okooko.',
254 '.poyyop.',
255 'looooool',
256 '.oooooo.',
257 '..y..y..',
258 ],
259 eyesShut: { 3: '.oooooo.' },
260 tear: { 4: '.boyyop.' },
261 feetApart: { 7: '.y....y.' },
262 },
263 shiny: { o: 0xbfeaff, l: 0xe6f8ff },
264 teen: {
265 rows: [
266 '...o.o......',
267 '...ooo......',
268 '..oooooo....',
269 '.oooooooo...',
270 'oowkoowkoo..',
271 'ookkookkoo..',
272 'oppoyyoppo..',
273 '.oooooooo...',
274 'l.oooooo.l..',
275 'llooooooll..',
276 '..oooooo....',
277 '..yy..yy....',
278 ],
279 eyesShut: { 4: 'oooooooooo..' },
280 tear: { 6: 'obpoyyoppo..' },
281 feetApart: { 11: '.yy....yy...' },
282 head: { top: 2, eye: 4, left: 0, right: 9 },
283 accessory: { rows: ['.f.', 'fYf', '.f.'], x: 6, y: 0, ink: BLOOM },
284 },
285 rare: { name: 'phoenix', ink: { o: 0xff9a5c, l: 0xffc46b, y: 0xffd447 }, overlay: { rows: ['.r.r', 'rrrr', '.rr.'], x: 3, y: -3, ink: { r: 0xff5f5f } } },
286 head: { big: { top: 2, eye: 4, left: 1, right: 10 }, mini: { top: 1, eye: 3, left: 1, right: 6 } },
287 accessory: {
288 slot: 'head',
289 big: { rows: ['.f.', 'fYf', '.f.'], x: 7, y: 0, ink: BLOOM },
290 mini: { rows: ['.f.', 'fYf'], x: 5, y: 0, ink: BLOOM },
291 },
292 purr: 'cheep',
293 },
294 dog: {
295 label: 'dog',
296 ink: { o: 0xe8c39e, d: 0xa8754f, w: 0xfffaf0, c: 0xfff3dc, p: 0xffafd7, k: 0x5f4b4b, m: 0xd08770, b: 0x87d7ff },
297 big: {
298 rows: [
299 '...oooooo...',
300 '..oooooooo..',
301 'ddoooooooodd',
302 'ddoooooooodd',
303 'ddwkoooowkdd',
304 'ddkkooookkdd',
305 'dppocmmcoppd',
306 '.oooccccooo.',
307 '..oooooooo..',
308 '..occcccco.o',
309 '..occccccooo',
310 '...oo..oo...',
311 ],
312 eyesShut: { 4: 'ddoooooooodd' },
313 tear: { 6: 'dbpocmmcoppd' },
314 feetApart: { 11: '..oo....oo..' },
315 },
316 mini: {
317 rows: [
318 '.oooooo.',
319 'dooooood',
320 'dkooookd',
321 'dpcmmcpd',
322 '.oooooo.',
323 '.oooooo.',
324 '.occccoo',
325 '..o..o..',
326 ],
327 eyesShut: { 2: 'dooooood' },
328 tear: { 3: 'dbcmmcpd' },
329 feetApart: { 7: '.o....o.' },
330 },
331 shiny: { o: 0xf7f2ea, d: 0x8a8a9a },
332 teen: {
333 rows: [
334 '..oooooo....',
335 '.oooooooo...',
336 'ddoooooodd..',
337 'ddwkoowkdd..',
338 'ddkkookkdd..',
339 'dopcmmcpod..',
340 '.oocccooo...',
341 '..oooooo....',
342 '.ooccccoo.o.',
343 '..occccooo..',
344 '..oo..oo....',
345 '..oo..oo....',
346 ],
347 eyesShut: { 3: 'ddoooooodd..' },
348 tear: { 5: 'dobcmmcpod..' },
349 feetApart: { 11: '.oo....oo...' },
350 head: { top: 0, eye: 3, left: 0, right: 9 },
351 accessory: { rows: ['ssssss', '.sS...'], x: 2, y: 7, ink: SCARF },
352 },
353 rare: { name: 'moon wolf', ink: { o: 0xc3cde0, d: 0x6b7894, c: 0xeef2fa }, overlay: { rows: ['.y.', 'yyy'], x: 4, y: 1, ink: { y: 0xfff3b0 } } },
354 head: { big: { top: 0, eye: 4, left: 0, right: 11 }, mini: { top: 0, eye: 2, left: 0, right: 7 } },
355 accessory: {
356 slot: 'body',
357 big: { rows: ['ssssssss', '.sS.....'], x: 2, y: 8, ink: SCARF },
358 mini: { rows: ['ssssss'], x: 1, y: 4, ink: SCARF },
359 },
360 purr: 'woof',
361 },
362 slime: {
363 label: 'slime',
364 ink: { o: 0x9fe0a8, l: 0xe0f7e3, d: 0x6fbf7f, w: 0xfffaf0, p: 0xffafd7, k: 0x4b5f4f, m: 0x5a9a64, b: 0x87d7ff },
365 big: {
366 rows: [
367 '............',
368 '............',
369 '....oooo....',
370 '..oooooooo..',
371 '.oloooooooo.',
372 'oloooooooooo',
373 'oowkoooowkoo',
374 'ookkooookkoo',
375 'oppoommooppo',
376 'oooooooooooo',
377 'oooooooooooo',
378 '.dddddddddd.',
379 ],
380 eyesShut: { 6: 'oooooooooooo' },
381 tear: { 8: 'obpoommooppo' },
382 feetApart: { 11: 'dddddddddddd' },
383 },
384 mini: {
385 rows: [
386 '........',
387 '..oooo..',
388 '.oloooo.',
389 'oloooooo',
390 'okooooko',
391 'poommoop',
392 'oooooooo',
393 '.dddddd.',
394 ],
395 eyesShut: { 4: 'oooooooo' },
396 tear: { 5: 'pbommoop' },
397 feetApart: { 7: 'dddddddd' },
398 },
399 shiny: { o: 0xffb8dc, l: 0xffe6f2, d: 0xe48ab8, m: 0xc06a98 },
400 teen: {
401 rows: [
402 '.....o......',
403 '....oo......',
404 '...oooooo...',
405 '..oloooooo..',
406 '.oloooooooo.',
407 '.oloooooooo.',
408 'oowkoooowkoo',
409 'ookkooookkoo',
410 'oppoommooppo',
411 'oooooooooooo',
412 'oooooooooooo',
413 '.dddddddddd.',
414 ],
415 eyesShut: { 6: 'oooooooooooo' },
416 tear: { 8: 'obpoommooppo' },
417 feetApart: { 11: 'dddddddddddd' },
418 head: { top: 2, eye: 6, left: 0, right: 11 },
419 accessory: { rows: ['y.y.y', 'yyyyy'], x: 6, y: 0, ink: GOLD },
420 },
421 rare: { name: 'king', ink: { o: 0xb9a2ff, l: 0xe2d8ff, d: 0x8a6fe0, m: 0x6a50b8 }, overlay: { rows: ['y.y.y.y', 'yyyyyyy', 'yRyRyRy'], x: 3, y: -1, ink: { y: 0xffd447, R: 0xff6b8a } } },
422 head: { big: { top: 2, eye: 6, left: 0, right: 11 }, mini: { top: 1, eye: 4, left: 0, right: 7 } },
423 accessory: {
424 slot: 'head',
425 big: { rows: ['y.y.y', 'yyyyy'], x: 4, y: 0, ink: GOLD },
426 mini: { rows: ['y..y', 'yyyy'], x: 2, y: 0, ink: GOLD },
427 },
428 purr: 'blub',
429 },
430 bunny: {
431 label: 'bunny',
432 ink: { o: 0xece4f4, w: 0xffffff, c: 0xffffff, p: 0xffafd7, k: 0x5f4b5f, m: 0xd08790, b: 0x87d7ff },
433 big: {
434 rows: [
435 '..oo....oo..',
436 '..op....po..',
437 '..op....po..',
438 '..oo....oo..',
439 '.oooooooooo.',
440 'oowkoooowkoo',
441 'ookkooookkoo',
442 'oppoommooppo',
443 '.oooooooooo.',
444 '..occccccocc',
445 '..occccccocc',
446 '...oo..oo...',
447 ],
448 eyesShut: { 5: 'oooooooooooo' },
449 tear: { 7: 'obpoommooppo' },
450 feetApart: { 11: '..oo....oo..' },
451 },
452 mini: {
453 rows: [
454 '.op..po.',
455 '.op..po.',
456 '.oooooo.',
457 'okooooko',
458 'poommoop',
459 '.oooooo.',
460 '.occccoc',
461 '..o..o..',
462 ],
463 eyesShut: { 3: 'oooooooo' },
464 tear: { 4: 'pbommoop' },
465 feetApart: { 7: '.o....o.' },
466 },
467 shiny: { o: 0xffe6a0 },
468 teen: {
469 rows: [
470 '..oo..oo....',
471 '..op..po....',
472 '..op..po....',
473 '.oooooooo...',
474 'oowkoowkoo..',
475 'ookkookkoo..',
476 'oppommoppo..',
477 '.oooooooo...',
478 '..occccocc..',
479 '.oocccoocc..',
480 '..occcco....',
481 '..oo..oo....',
482 ],
483 eyesShut: { 4: 'oooooooooo..' },
484 tear: { 6: 'obpommoppo..' },
485 feetApart: { 11: '.oo....oo...' },
486 head: { top: 3, eye: 4, left: 0, right: 9 },
487 accessory: { rows: ['r.r', 'rRr'], x: 5, y: 0, ink: RIBBON },
488 },
489 rare: { name: 'moon', ink: { o: 0xdcdcf4, c: 0xf6f6ff }, overlay: { rows: ['.mm', 'm..', 'm..', '.mm'], x: 9, y: -1, ink: { m: 0xfff3b0 } } },
490 head: { big: { top: 4, eye: 5, left: 0, right: 11 }, mini: { top: 2, eye: 3, left: 0, right: 7 } },
491 accessory: {
492 slot: 'head',
493 big: { rows: ['r...r', 'rrRrr', 'r...r'], x: 4, y: 1, ink: RIBBON },
494 mini: { rows: ['rRr'], x: 3, y: 2, ink: RIBBON },
495 },
496 purr: 'boop',
497 },
498 hamster: {
499 label: 'hamster',
500 ink: { o: 0xf5b971, w: 0xfffaf0, c: 0xfff3dc, p: 0xffafd7, k: 0x5f4b4b, m: 0xd08770, b: 0x87d7ff },
501 big: {
502 rows: [
503 '............',
504 '..oo....oo..',
505 '.opoooooopo.',
506 '.oooooooooo.',
507 'oowkoooowkoo',
508 'ookkooookkoo',
509 'oppccmmccppo',
510 'ooccccccccoo',
511 'occcccccccco',
512 'occcccccccco',
513 '.ooccccccoo.',
514 '..pp....pp..',
515 ],
516 eyesShut: { 4: 'oooooooooooo' },
517 tear: { 6: 'obpccmmccppo' },
518 feetApart: { 11: '.pp......pp.' },
519 },
520 mini: {
521 rows: [
522 '.o....o.',
523 'oooooooo',
524 'okooooko',
525 'pccmmccp',
526 'occcccco',
527 'occcccco',
528 '.oooooo.',
529 '..p..p..',
530 ],
531 eyesShut: { 2: 'oooooooo' },
532 tear: { 3: 'bccmmccp' },
533 feetApart: { 7: '.p....p.' },
534 },
535 shiny: { o: 0xd6d8e8 },
536 teen: {
537 rows: [
538 '..oo....oo..',
539 '.opoooooopo.',
540 '.oooooooooo.',
541 '.owkoooowko.',
542 '.okkooookko.',
543 'oppccmmccppo',
544 '.occcccccco.',
545 '..occcccco..',
546 '.poccccccop.',
547 '..occcccco..',
548 '...oo..oo...',
549 '...pp..pp...',
550 ],
551 eyesShut: { 3: '.oooooooooo.' },
552 tear: { 5: 'obpccmmccppo' },
553 feetApart: { 11: '..pp....pp..' },
554 head: { top: 1, eye: 3, left: 1, right: 10 },
555 accessory: { rows: ['..gg', '.gg.', 'gg..'], x: 8, y: -1, ink: LEAF },
556 },
557 rare: { name: 'golden', ink: { o: 0xffcf4d, c: 0xfff1c0 }, overlay: { rows: ['.s.', 'sws', '.s.'], x: 9, y: -2, ink: { s: 0xffe36e, w: 0xffffff } } },
558 head: { big: { top: 2, eye: 4, left: 0, right: 11 }, mini: { top: 1, eye: 2, left: 0, right: 7 } },
559 accessory: {
560 slot: 'head',
561 big: { rows: ['..gg', '.gg.', 'gg..'], x: 8, y: 0, ink: LEAF },
562 mini: { rows: ['.g', 'g.'], x: 6, y: 0, ink: LEAF },
563 },
564 purr: 'squeak',
565 },
566 penguin: {
567 label: 'penguin',
568 ink: { o: 0x4f5b82, c: 0xfffaf0, e: 0x2e2e3a, p: 0xffafd7, y: 0xffaf5f, b: 0x87d7ff },
569 big: {
570 rows: [
571 '...oooooo...',
572 '..oooooooo..',
573 '.oooooooooo.',
574 '.ooccooccoo.',
575 '.occcccccco.',
576 '.occeccecco.',
577 '.oceecceeco.',
578 '.ocpcyycpco.',
579 'oocccccccooo',
580 'ooccccccccoo',
581 '.oocccccco..',
582 '...yy..yy...',
583 ],
584 eyesShut: { 5: '.occcccccco.' },
585 tear: { 7: '.ocbcyycpco.' },
586 feetApart: { 11: '..yy....yy..' },
587 },
588 mini: {
589 rows: [
590 '..oooo..',
591 '.oooooo.',
592 '.occcco.',
593 'ocecceco',
594 'opcyycpo',
595 'occcccco',
596 '.occcco.',
597 '..y..y..',
598 ],
599 eyesShut: { 3: 'occcccco' },
600 tear: { 4: 'obcyycpo' },
601 feetApart: { 7: '.y....y.' },
602 },
603 shiny: { o: 0x8f64bf },
604 teen: {
605 rows: [
606 '...oooooo...',
607 '..oooooooo..',
608 '.ooccooccoo.',
609 '.occeccecco.',
610 '.oceecceeco.',
611 '.ocpcyycpco.',
612 'oocccccccoo.',
613 'oocccccccoo.',
614 '.occcccccco.',
615 '.occcccccco.',
616 '..occcccco..',
617 '...yy..yy...',
618 ],
619 eyesShut: { 3: '.occcccccco.' },
620 tear: { 5: '.ocbcyycpco.' },
621 feetApart: { 11: '..yy....yy..' },
622 head: { top: 0, eye: 4, left: 1, right: 10 },
623 accessory: { rows: ['...ww...', '.rrrrrr.', 'RrRrRrRr'], x: 2, y: -2, ink: { r: 0xff6b8a, R: 0xd94f6e, w: 0xfffaf0 } },
624 },
625 rare: { name: 'emperor', ink: { o: 0x2f3a66, p: 0xffc46b, y: 0xffb03b }, overlay: { rows: ['yy......yy', '.y......y.'], x: 1, y: 3, ink: { y: 0xffd447 } } },
626 head: { big: { top: 0, eye: 5, left: 1, right: 10 }, mini: { top: 0, eye: 3, left: 0, right: 7 } },
627 accessory: {
628 slot: 'head',
629 big: { rows: ['...ww...', '.rrrrrr.', 'RrRrRrRr'], x: 2, y: -2, ink: { r: 0xff6b8a, R: 0xd94f6e, w: 0xfffaf0 } },
630 mini: { rows: ['.ww.', 'RrRr'], x: 2, y: -1, ink: { r: 0xff6b8a, R: 0xd94f6e, w: 0xfffaf0 } },
631 },
632 purr: 'peep',
633 },
634 frog: {
635 label: 'frog',
636 ink: { o: 0x8fd18a, w: 0xfffaf0, c: 0xf0f7d4, p: 0xffafd7, k: 0x3f5a3f, m: 0x4f8a4a, b: 0x87d7ff },
637 big: {
638 rows: [
639 '............',
640 '.oooo..oooo.',
641 '.owko..owko.',
642 '.okko..okko.',
643 'oooooooooooo',
644 'oppooooooppo',
645 'oooommmmoooo',
646 '.oooooooooo.',
647 '..occcccco..',
648 '.ooccccccoo.',
649 '.ooccccccoo.',
650 '.ooo....ooo.',
651 ],
652 eyesShut: { 2: '.oooo..oooo.' },
653 tear: { 5: 'obpooooooppo' },
654 feetApart: { 11: 'ooo......ooo' },
655 },
656 mini: {
657 rows: [
658 '.oo..oo.',
659 '.ko..ok.',
660 'oooooooo',
661 'pommmmop',
662 '.oooooo.',
663 '.occcco.',
664 '.oooooo.',
665 'oo....oo',
666 ],
667 eyesShut: { 1: '.oo..oo.' },
668 tear: { 3: 'bommmmop' },
669 feetApart: { 7: '.oo..oo.' },
670 },
671 shiny: { o: 0x93c6ff, m: 0x4f7fc0 },
672 teen: {
673 rows: [
674 '.oooo..oooo.',
675 '.owko..owko.',
676 '.okko..okko.',
677 '.oooooooooo.',
678 'oppooooooppo',
679 '.oommmmmmoo.',
680 '..oooooooo..',
681 '...occcco...',
682 '.oooccccooo.',
683 'o..occcco..o',
684 '...oo..oo...',
685 '..ooo..ooo..',
686 ],
687 eyesShut: { 1: '.oooo..oooo.' },
688 tear: { 4: 'obpooooooppo' },
689 feetApart: { 11: '.ooo....ooo.' },
690 head: { top: 0, eye: 1, left: 1, right: 10 },
691 accessory: { rows: ['y.y.y', 'yyyyy'], x: 4, y: -2, ink: GOLD },
692 },
693 rare: { name: 'prince', ink: { o: 0x5fc9b8, m: 0x2f8a7c, c: 0xd8f7f0 }, overlay: { rows: ['y.y.y', 'yyyyy', 'yRyRy'], x: 4, y: -3, ink: { y: 0xffd447, R: 0x6fa8dc } } },
694 head: { big: { top: 1, eye: 2, left: 1, right: 10 }, mini: { top: 0, eye: 1, left: 1, right: 6 } },
695 accessory: {
696 slot: 'head',
697 big: { rows: ['y.y.y', 'yyyyy'], x: 4, y: 0, ink: GOLD },
698 mini: { rows: ['yy'], x: 3, y: 0, ink: GOLD },
699 },
700 purr: 'ribbit',
701 },
702}
703types/index.d.ts 76 lines1/**
2 * The state contract: what the `pets` atoms and `$.store` hold.
3 * @handbook 1.2-state-layers
4 */
5export type Pet = {
6 x: number
7 dir: 1 | -1
8 frame: number
9 mood: 'walk' | 'sleep' | 'happy' | 'work' | 'love' | 'gift'
10 hold: number
11 idle: number
12}
13
14/** What one pet has lived through. Each species is its own pet, with its own name and level. */
15export type PetStats = {
16 name: string
17 pats: number
18 tools: number
19 turns: number
20 /** Output tokens of the turns it watched. */
21 tokens: number
22 /** Snacks eaten in Pet Run and Pet Quest together. */
23 snacks: number
24 /** Pet Run's best score. */
25 best: number
26 /** Pet Quest stages cleared, counted from the first. */
27 cleared: number
28 /** Gifts found, the experience they and lucky pats brought, and whether it is a rare shiny. */
29 gifts: number
30 bonus: number
31 shiny: boolean
32 /** Finished turns by local quarter of the day (0–6, 6–12, 12–18, 18–24): its rhythm. */
33 hours: number[]
34 /** What it became at Lv 40: its nature then, or 'rare'; empty before. */
35 form: string
36 /**
37 * Its leaning, worker / scholar / sweetie / gamer, shares that follow what recent turns
38 * brought: it shapes the yard, its nature, and an adult's form over time.
39 */
40 leaning: number[]
41 /** The local day being tallied (YYYY-MM-DD), and what it has brought so far to each side, in leaning order. */
42 day: string
43 brought: number[]
44}
45
46/** What the Pet Quest pane's text shows. */
47export type QuestView = {
48 stage: number
49 phase: 'ready' | 'running' | 'clear' | 'over'
50 score: number
51 snacks: number
52}
53
54/** What the Pet Run pane's text shows: the game's own state lives in the hooks module. */
55export type RunView = {
56 phase: 'ready' | 'running' | 'over'
57 score: number
58 snacks: number
59}
60
61/** How tall the pane draws the pet: `small` is the 8×8 sprite, `medium` the 12×12 one. A stored size no longer offered reads as the default. */
62export type PetSize = 'small' | 'medium'
63
64/** What is kept across sessions: which pet is out, how big it is drawn, and every pet met so far by species. */
65export type PetProfile = {
66 species: string
67 size: PetSize
68 pets: Record<string, PetStats>
69}
70
71declare module 'claude-code' {
72 interface PluginState {
73 'pets': { pet: Pet; profile: PetProfile; isWorried: boolean; run: RunView; quest: QuestView }
74 }
75}
76