A virtual pet that lives above your Claude Code prompt and grows from the work Claude does: it evolves by what Claude does most, gets sick from tool errors…

A virtual pet that lives above your Claude Code prompt and feeds on the work you and Claude do together. The more Claude does, the faster it grows; the kind of work decides what species it becomes; tool errors make it sick, and green tests make it well again
(^ω^) 啾啾 · 幼年期 飽 ████░ 樂 █████ 精 ███░░ ● 42
[ 餵食 ] [ 玩耍 ] [ 睡覺 ] [ 展開側邊欄 ] [ 收起 ]
The interface is in Traditional Chinese
This is a function-hooks mod, an early access feature. Turn it on in ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
Then add the marketplace and install:
claude plugin marketplace add mukiwu/muki-ai-plugins
claude plugin install hyday-pet@muki-ai-plugins
Restart Claude Code or run /reload-plugins
Claude Code only; npx skills add does not install this plugin
Each tool call counts as one of three kinds of work: editing files, running commands, or looking things up (MCP tools count as looking things up)
On becoming a child, the work it saw most as a baby decides the species:
| Most common work | Species |
|---|---|
| Editing files | Mole (crafter) |
| Running commands | Otter (sailor) |
| Looking things up | Owl (explorer) |
| Nothing above 45% | Chimera (generalist) |
On becoming an adult it branches once more by the work it saw as a child, giving a swimming mole, a pure-bred mole, and so on: 16 outcomes in all
go test and the like) removes 20; while sick, any other success removes 4/pet open, or the button on the band, opens a pane with:
In the fullscreen layout (/tui fullscreen) it docks beside the transcript; on the classic layout it sits above the prompt. The classic layout does not report mouse clicks, so focus the pane with ctrl+x then tab and use the hotkeys shown on the buttons
| Earned by | Coins |
|---|---|
| Claude finishing a turn | 2 |
| A passing test run | 5 |
| Winning rock paper scissors, guessing the number | 3, 8 |
| Hatching, becoming a child, becoming an adult | 10, 30, 80 |
Premium food, fed on purchase:
| Food | Price | Effect |
|---|---|---|
| Strawberry cake | 25 | Fullness +40, mood +15 |
| Energy drink | 30 | Energy +40 |
| Organic salad | 35 | Fullness +30, germs -15, no poop |
| Deluxe bento | 60 | Fullness to max, mood and energy +10 |
Color styles: pink and mono are free; mint and sky cost 150, gold 400, neon 800, rainbow 1200 and galaxy 1500. Each one has a live swatch in the shop, and the changing styles flow with the animation
Birthday, coins, how many turns it has watched, errors and green tests seen, times sick, mini game record, and a log of hatching, evolving, falling sick and shopping
| Command | What it does |
|---|---|
/pet | Show status |
/pet open, /pet close | Open or close the side pane |
/pet feed, play, sleep, wake, clean, heal | Care actions; Chinese words work too, like /pet 餵 |
/pet shop, /pet buy 蛋糕 | List the shop, buy something |
/pet skin, /pet skin mint | Cycle through owned colors, or pick one |
/pet detail | Detail page |
/pet name 小咪 | Rename it |
/pet hide, /pet show | Hide or bring back the band above the prompt |
/pet reset confirm | Give up this pet and start over with a new egg |
The pet lives in this plugin's own store, so every project and every open session share one pet. Each change reads the latest save first, so it never splits into two
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir plugins/hyday-pet
claude plugin validate plugins/hyday-pet
claude plugin test plugins/hyday-pet
To restyle the pet, edit the ASCII art in hooks/sprites.ts; widths are padded for you. Keep the capital letters L, R and M out of the art: they are replaced with the eyes and mouth
hooks/register.tsx 564 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Game, Pet, Skin, View } from '../types'
5import { cannotPlay, guess, GUESS_TRIES, HANDS, playRps, startGuess } from './games'
6import type { Hand } from './games'
7import { act, afterTool, ageDays, bar, coinsOf, detail, face, finishTurn, gainXp, germsOf, growth, hatch, mood, stage, summary, tick, titleOf, warnings, workShare } from './pet'
8import type { Action } from './pet'
9import { buyFood, buySkin, findFood, FOOD_ORDER, FOODS, owns, ownedSkins } from './shop'
10import type { FoodId } from './shop'
11import { accentColor, drawFrame, inkOn, previewColor, lineColor, SICK_COLOR, SKIN_ORDER, SKINS, skinOf, statColor } from './sprites'
12
13const DEFAULT_NAME = '啾啾'
14const TICK_MS = 60 * 1000
15const FRAME_MS = 500
16const PANE = 'pet'
17
18const petAtom = atom({ plugin: 'hyday-pet', key: 'pet' } as const, null)
19const isHidden = atom({ plugin: 'hyday-pet', key: 'isHidden' } as const, false)
20const isPaneOpen = atom({ plugin: 'hyday-pet', key: 'isPaneOpen' } as const, false)
21const isWorking = atom({ plugin: 'hyday-pet', key: 'isWorking' } as const, false)
22const frameAtom = atom({ plugin: 'hyday-pet', key: 'frame' } as const, 0)
23const gameAtom = atom({ plugin: 'hyday-pet', key: 'game' } as const, null)
24const viewAtom = atom({ plugin: 'hyday-pet', key: 'view' } as const, 'home')
25
26const ACTIONS: Record<string, Action> = {
27 feed: 'feed', 餵: 'feed', 餵食: 'feed',
28 play: 'play', 玩: 'play',
29 sleep: 'sleep', 睡: 'sleep', 睡覺: 'sleep',
30 wake: 'wake', 醒: 'wake', 叫醒: 'wake',
31 clean: 'clean', 清: 'clean', 清理: 'clean',
32 heal: 'heal', 藥: 'heal', 吃藥: 'heal', 餵藥: 'heal',
33}
34
35// 先讀 $.store(可能有別的 session 剛寫過),補上經過的時間,再套用變化,最後寫回兩邊
36const commit = async ($: EngineInterface, change: (pet: Pet) => Pet = p => p) => {
37 const now = await $.clock.now()
38 const stored = (await $.store.get('pet')) as Pet | undefined
39 // 舊存檔沒有已擁有的顏色清單,第一次讀到就把當時正在用的顏色記成已擁有
40 const loaded = stored ?? hatch(DEFAULT_NAME, now)
41 const before = loaded.ownedSkins ? loaded : { ...loaded, ownedSkins: ownedSkins(loaded) }
42 const after = change(tick(before, now))
43 await $.store.set('pet', after)
44 await update($, petAtom, () => after)
45 for (const line of warnings(before, after)) $.ui.toast(line)
46 return after
47}
48
49const perform = async ($: EngineInterface, action: Action | ((pet: Pet) => Action)) => {
50 let say = ''
51 await commit($, pet => {
52 const out = act(pet, typeof action === 'function' ? action(pet) : action)
53 say = out.say
54 return out.pet
55 })
56 return say
57}
58
59const setHidden = async ($: EngineInterface, value: boolean) => {
60 await $.store.set('hidden', value)
61 await update($, isHidden, () => value)
62}
63
64const findSkin = (word: string): Skin | undefined =>
65 SKIN_ORDER.find(skin => skin === word || SKINS[skin].label === word)
66
67// 指定顏色:買過就換上,沒買過就說價錢;不指定就在買過的裡面輪流換
68const setSkin = async ($: EngineInterface, skin?: Skin) => {
69 let say = ''
70 await commit($, p => {
71 if (skin && !owns(p, skin)) {
72 say = `${SKINS[skin].label}還沒買,商店賣 ${SKINS[skin].price} 金幣(/pet buy ${SKINS[skin].label})`
73 return p
74 }
75 const owned = SKIN_ORDER.filter(s => owns(p, s))
76 const next = skin ?? owned[(owned.indexOf(skinOf(p)) + 1) % owned.length] ?? 'pink'
77 say = `換成${SKINS[next].label}了`
78 return { ...p, skin: next }
79 })
80 return say
81}
82
83const sell = async ($: EngineInterface, item: { food: FoodId } | { skin: Skin }) => {
84 let say = ''
85 await commit($, p => {
86 const sale = 'food' in item ? buyFood(p, item.food) : buySkin(p, item.skin)
87 say = sale.say
88 return sale.pet
89 })
90 return say
91}
92
93const goTo = ($: EngineInterface, view: View) => update($, viewAtom, () => view)
94
95const randomInt = (max: number) => Math.floor(Math.random() * max)
96
97// 小遊戲:開局前先確認寵物有沒有力氣玩
98const startGame = async ($: EngineInterface, kind: Game['kind']) => {
99 const pet = await commit($)
100 const reason = cannotPlay(pet)
101 if (reason) {
102 $.ui.toast(reason)
103 return
104 }
105 const game: Game =
106 kind === 'rps'
107 ? { kind: 'rps', line: `跟 ${pet.name} 猜拳,出什麼?` }
108 : startGuess(pet, randomInt(9) + 1)
109 await update($, gameAtom, () => game)
110}
111
112const playHand = async ($: EngineInterface, mine: Hand) => {
113 let game: Game | null = null
114 let reason: string | undefined
115 await commit($, pet => {
116 reason = cannotPlay(pet)
117 if (reason) return pet
118 const out = playRps(pet, mine, randomInt(3) as Hand)
119 game = out.game
120 return out.pet
121 })
122 if (reason) $.ui.toast(reason)
123 if (game) await update($, gameAtom, () => game)
124}
125
126const playGuess = async ($: EngineInterface, n: number) => {
127 const current = await read($, gameAtom)
128 if (current?.kind !== 'guess') return
129 let game: Game = current
130 await commit($, pet => {
131 const out = guess(pet, current, n)
132 game = out.game
133 return out.pet
134 })
135 await update($, gameAtom, () => game)
136}
137
138const leaveGame = ($: EngineInterface) => update($, gameAtom, () => null)
139
140// 側邊欄開著才推動畫格,關掉就停掉計時器
141let animation: Timer | undefined
142
143const stopAnimation = () => {
144 animation?.cancel()
145 animation = undefined
146}
147
148// 先開側邊欄再記狀態:沒放出來(例如視窗太窄)就跳通知說原因,橫條也不會躲起來
149const openPane = async ($: EngineInterface, isAsked = true) => {
150 const pet = await read($, petAtom)
151 const opened = await $.ui.open({ id: PANE, title: pet?.name ?? DEFAULT_NAME, columns: 32, ...(isAsked ? { focus: true } : {}) })
152 animation ??= $.clock.every(FRAME_MS, () => void update($, frameAtom, n => n + 1))
153 await $.store.set('paneOpen', true)
154 await update($, isPaneOpen, () => true)
155 if (!opened.isPlaced && isAsked) $.ui.toast(`側邊欄放不下:${opened.reason}`)
156 return opened
157}
158
159// 側邊欄真的畫在畫面上才算數,開了但在等位置的不算
160const isPaneShown = async ($: EngineInterface) =>
161 (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced && pane.isShown)
162
163const markClosed = async ($: EngineInterface, isRemembered: boolean) => {
164 stopAnimation()
165 await update($, isPaneOpen, () => false)
166 if (isRemembered) await $.store.set('paneOpen', false)
167}
168
169// 自己呼叫 $.ui.close 時,自己的 ui.close hook 不一定收得到,所以先把狀態清掉
170const closePane = async ($: EngineInterface) => {
171 await markClosed($, true)
172 await $.ui.close({ id: PANE })
173}
174
175export const register: Register = on => {
176 on('session.start', async ($, e, next) => {
177 await $.command.register({
178 name: 'pet',
179 description: '照顧你的電子雞:餵食、玩耍、睡覺、清理,或打開側邊欄',
180 argumentHint: 'open | shop | buy <東西> | detail | feed | play | sleep | wake | clean | heal | skin <顏色> | name <名字> | hide | show',
181 })
182 const hidden = (await $.store.get('hidden')) === true
183 await update($, isHidden, () => hidden)
184 await commit($)
185 $.clock.every(TICK_MS, () => void commit($))
186 if ((await $.store.get('paneOpen')) === true) void openPane($, false)
187
188 return next(e)
189 })
190
191 on('command.run', { command: 'pet' }, async ($, e) => {
192 const [word = '', ...rest] = e.args.trim().split(/\s+/)
193 const now = await $.clock.now()
194
195 if (word === '' || word === 'status') {
196 const pet = await commit($)
197 return { text: summary(pet, now) }
198 }
199 if (word === 'open' || word === '開') {
200 const opened = await openPane($)
201 return {
202 text: opened.isPlaced
203 ? '側邊欄打開了(全螢幕模式下會停在對話旁邊)'
204 : '側邊欄已經開了,但這個視窗太窄放不下,拉寬一點就會出現',
205 }
206 }
207 if (word === 'close' || word === '關') {
208 await closePane($)
209 return { text: '側邊欄收起來了,/pet open 可以再打開' }
210 }
211 if (word === 'hide' || word === 'show') {
212 await setHidden($, word === 'hide')
213 return { text: word === 'hide' ? '收起來了,/pet show 可以叫回來' : '牠回來了' }
214 }
215 if (word === 'skin' || word === '顏色') {
216 const wanted = rest[0]
217 const skin = wanted ? findSkin(wanted) : undefined
218 if (wanted && !skin) {
219 return { text: `沒有 ${wanted} 這個顏色,可以選:${SKIN_ORDER.map(s => `${s}(${SKINS[s].label})`).join('、')}` }
220 }
221 return { text: await setSkin($, skin) }
222 }
223 if (word === 'shop' || word === '商店') {
224 const pet = await commit($)
225 const foods = FOOD_ORDER.map(id => ` ${FOODS[id].label} ${FOODS[id].price} 金幣 ${FOODS[id].effect}`)
226 const skins = SKIN_ORDER.filter(s => SKINS[s].price > 0).map(
227 s => ` ${SKINS[s].label} ${owns(pet, s) ? '已擁有' : `${SKINS[s].price} 金幣`}`,
228 )
229 return {
230 text: [`你有 ${coinsOf(pet)} 金幣`, '高級食物(買了直接餵)', ...foods, '顏色樣式', ...skins, '用 /pet buy 蛋糕 這樣買'].join('\n'),
231 }
232 }
233 if (word === 'buy' || word === '買') {
234 const wanted = rest.join('')
235 const food = findFood(wanted)
236 const skin = findSkin(wanted)
237 if (food) return { text: await sell($, { food }) }
238 if (skin) return { text: await sell($, { skin }) }
239 return { text: wanted ? `商店沒有賣 ${wanted},打 /pet shop 看看有什麼` : '要買什麼?打 /pet shop 看看有什麼' }
240 }
241 if (word === 'detail' || word === '細節') {
242 const pet = await commit($)
243 const d = detail(pet, 15)
244 return { text: [...d.facts, '', '大事記', ...d.log.map(line => ` ${line}`)].join('\n') }
245 }
246 if (word === 'name') {
247 const name = rest.join(' ').trim().slice(0, 12)
248 if (!name) return { text: '要取什麼名字?例如 /pet name 小咪' }
249 await commit($, pet => ({ ...pet, name }))
250 if (await read($, isPaneOpen)) await $.ui.open({ id: PANE, title: name })
251 return { text: `從現在起牠叫 ${name}` }
252 }
253 if (word === 'reset') {
254 if (rest[0] !== 'confirm') return { text: '這會丟掉現在這隻,重新拿一顆蛋。確定的話打 /pet reset confirm' }
255 await $.store.delete('pet')
256 await commit($, () => hatch(DEFAULT_NAME, now))
257 return { text: '拿到一顆新的蛋 🥚' }
258 }
259
260 const action = ACTIONS[word]
261 if (!action) return { text: `看不懂 ${word},可以用 open、close、shop、buy、detail、feed、play、sleep、wake、clean、heal、skin、name、hide、show` }
262 return { text: await perform($, action) }
263 })
264
265 on('ui.close', async ($, e, next) => {
266 const closed = await next(e)
267 if (e.id === PANE) await markClosed($, e.origin.kind !== 'unload')
268 return closed
269 })
270
271 on('prompt.submit', async ($, e, next) => {
272 await update($, isWorking, () => true)
273 return next(e)
274 })
275
276 // Claude 做事,寵物就長經驗:每次工具呼叫 +1,每輪結束 +3
277 // 也記下做的是哪種工作(決定物種),報錯會長病菌
278 on('tool.call', async ($, e, next) => {
279 const ran = await next(e)
280 if (ran.deny !== undefined) return ran
281 const outcome = {
282 tool: String(e.tool),
283 isError: ran.isError === true,
284 command: e.tool === 'Bash' ? e.command : undefined,
285 }
286 void commit($, pet => gainXp(afterTool(pet, outcome), 1))
287 return ran
288 })
289
290 on('turn.complete', async ($, e, next) => {
291 await update($, isWorking, () => false)
292 void commit($, finishTurn)
293 return next(e)
294 })
295
296 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
297 const { Box, Button, Text } = $.ui.resolve(e)
298 const pet = await read($, petAtom)
299 if (!pet) return <Text dimColor>寵物還在路上…</Text>
300
301 const frame = await read($, frameAtom)
302 const working = await read($, isWorking)
303 const game = await read($, gameAtom)
304 const view = await read($, viewAtom)
305 const now = await $.clock.now()
306 const skin = skinOf(pet)
307 const art = drawFrame(pet, working, frame)
308 const s = stage(pet)
309 const g = growth(pet)
310 const germs = germsOf(pet)
311 const share = workShare(pet)
312 const say = (p: Promise<string>) => void p.then(text => $.ui.toast(text))
313 const isBrowsing = game === null && view !== 'home'
314
315 const meter = (label: string, n: number, color: string | undefined, tail: string) => (
316 <Text wrap="truncate">
317 <Text dimColor>{label} </Text>
318 <Text color={color}>{bar(n, 10)}</Text>
319 <Text dimColor> {tail}</Text>
320 </Text>
321 )
322 const stat = (label: string, n: number) => meter(label, n, statColor(n), String(Math.round(n)).padStart(3))
323
324 const home = (
325 <Box flexDirection="column" alignItems="center">
326 {s !== 'egg' && (
327 <Box flexWrap="wrap" justifyContent="center" columnGap={1}>
328 <Button key="p-feed" hotkey="1" label="餵食" variant="primary" onPress={() => say(perform($, 'feed'))} />
329 <Button key="p-play" hotkey="2" label="玩耍" onPress={() => say(perform($, 'play'))} />
330 <Button
331 key="p-sleep"
332 hotkey="3"
333 label={pet.isAsleep ? '叫醒' : '睡覺'}
334 onPress={() => say(perform($, p => (p.isAsleep ? 'wake' : 'sleep')))}
335 />
336 {pet.poops > 0 && <Button key="p-clean" hotkey="4" label="清理" onPress={() => say(perform($, 'clean'))} />}
337 {germs > 0 && <Button key="p-heal" hotkey="m" label="吃藥" onPress={() => say(perform($, 'heal'))} />}
338 </Box>
339 )}
340 <Box columnGap={1}>
341 <Button key="p-shop" hotkey="s" label="商店" onPress={() => void goTo($, 'shop')} />
342 <Button key="p-detail" hotkey="i" label="細節" onPress={() => void goTo($, 'detail')} />
343 </Box>
344 {s !== 'egg' && (
345 <Box columnGap={1}>
346 <Button key="p-rps" hotkey="g" label="猜拳" onPress={() => void startGame($, 'rps')} />
347 <Button key="p-guess" hotkey="n" label="猜數字" onPress={() => void startGame($, 'guess')} />
348 </Box>
349 )}
350 <Box columnGap={1}>
351 <Button key="p-skin" hotkey="c" label={`換色:${SKINS[skin].label}`} dimColor onPress={() => say(setSkin($))} />
352 <Button key="p-close" role="dismiss" label="收起" dimColor onPress={() => void closePane($)} />
353 </Box>
354 </Box>
355 )
356
357 const rps = (
358 <Box flexDirection="column" alignItems="center">
359 <Text wrap="wrap">{game?.line ?? ''}</Text>
360 <Box columnGap={1}>
361 {HANDS.map((hand, i) => (
362 <Button key={`rps-${i}`} hotkey={String(i + 1)} label={hand} onPress={() => void playHand($, i as Hand)} />
363 ))}
364 </Box>
365 <Button key="game-back" hotkey="b" label="不玩了" dimColor onPress={() => void leaveGame($)} />
366 </Box>
367 )
368
369 const back = <Button key="view-back" hotkey="b" label="返回" dimColor onPress={() => void goTo($, 'home')} />
370
371 // 每個字用那個顏色當背景,會變化的顏色逐字換色
372 const preview = (text: string, sk: Skin) => (
373 <Text wrap="truncate">
374 {[...text].map((ch, i) => {
375 const bg = previewColor(sk, i, frame) ?? '#888888'
376 return (
377 <Text backgroundColor={bg} color={inkOn(bg)}>
378 {ch}
379 </Text>
380 )
381 })}
382 </Text>
383 )
384
385 const shopView = (
386 <Box flexDirection="column" alignItems="flex-start">
387 <Text bold>商店 · 你有 {coinsOf(pet)} 金幣</Text>
388 <Text> </Text>
389 <Text dimColor>高級食物,買了直接餵</Text>
390 {FOOD_ORDER.map((id, i) => (
391 <Box columnGap={1}>
392 <Button
393 key={`buy-${id}`}
394 hotkey={String(i + 1)}
395 label={`${FOODS[id].label} ${String(FOODS[id].price).padStart(2)}`}
396 dimColor={coinsOf(pet) < FOODS[id].price}
397 onPress={() => say(sell($, { food: id }))}
398 />
399 <Text dimColor wrap="truncate">{FOODS[id].effect}</Text>
400 </Box>
401 ))}
402 <Text> </Text>
403 <Text dimColor>顏色樣式</Text>
404 {SKIN_ORDER.filter(sk => SKINS[sk].price > 0).map((sk, i) => {
405 const isOwned = owns(pet, sk)
406 return (
407 <Box columnGap={1}>
408 <Button
409 key={`skin-${sk}`}
410 hotkey={String((i + 5) % 10)}
411 label={sk === skin ? '使用中' : isOwned ? ' 換上 ' : ' 購買 '}
412 variant={sk === skin ? 'primary' : undefined}
413 dimColor={!isOwned && coinsOf(pet) < SKINS[sk].price}
414 onPress={() => say(sell($, { skin: sk }))}
415 />
416 {preview(` ${SKINS[sk].label} `, sk)}
417 {!isOwned && <Text dimColor>{SKINS[sk].price} 金幣</Text>}
418 </Box>
419 )
420 })}
421 <Text> </Text>
422 {back}
423 </Box>
424 )
425
426 const info = detail(pet)
427 const detailView = (
428 <Box flexDirection="column" alignItems="flex-start">
429 {info.facts.map(line => (
430 <Text wrap="truncate">{line}</Text>
431 ))}
432 <Text> </Text>
433 <Text bold>大事記</Text>
434 {info.log.length === 0 && <Text dimColor>還沒有發生什麼事</Text>}
435 {info.log.map(line => (
436 <Text dimColor wrap="truncate">
437 {line}
438 </Text>
439 ))}
440 <Text> </Text>
441 {back}
442 </Box>
443 )
444
445 const guessing = game?.kind === 'guess' ? game : undefined
446 const guessView = guessing && (
447 <Box flexDirection="column" alignItems="center">
448 <Text wrap="wrap">{guessing.line}</Text>
449 {!guessing.isOver && <Text dimColor>{'●'.repeat(guessing.triesLeft)}{'○'.repeat(GUESS_TRIES - guessing.triesLeft)}</Text>}
450 {!guessing.isOver && (
451 <Box flexWrap="wrap" justifyContent="center" columnGap={1}>
452 {[1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => (
453 <Button key={`guess-${n}`} hotkey={String(n)} label={String(n)} onPress={() => void playGuess($, n)} />
454 ))}
455 </Box>
456 )}
457 <Box columnGap={1}>
458 {guessing.isOver && (
459 <Button key="guess-again" hotkey="r" label="再玩一次" variant="primary" onPress={() => void startGame($, 'guess')} />
460 )}
461 <Button key="game-back" hotkey="b" label="不玩了" dimColor onPress={() => void leaveGame($)} />
462 </Box>
463 </Box>
464 )
465
466 return (
467 <Box flexDirection="column" alignItems="center" paddingX={1}>
468 <Text wrap="truncate">
469 <Text bold color={accentColor(skin, frame)}>{pet.name}</Text>
470 <Text color="#ffd866"> ● {coinsOf(pet)}</Text>
471 </Text>
472 <Text dimColor wrap="truncate">
473 {titleOf(pet)} · {ageDays(pet, now)} 天大
474 </Text>
475 <Text color={pet.isSick ? SICK_COLOR : accentColor(skin, frame)}>{art.effect || ' '}</Text>
476 <Box flexDirection="column" alignItems="flex-start">
477 {art.body.map((line, row) => (
478 <Text bold color={pet.isSick ? SICK_COLOR : lineColor(skin, row, frame)}>
479 {line || ' '}
480 </Text>
481 ))}
482 </Box>
483 <Text>{pet.poops > 0 ? '💩'.repeat(Math.min(pet.poops, 5)) : ' '}</Text>
484 <Text italic dimColor>
485 {art.caption}
486 </Text>
487 <Text> </Text>
488 {isBrowsing ? null : <Box flexDirection="column" alignItems="center">
489 {s !== 'egg' && stat('飽足', pet.hunger)}
490 {s !== 'egg' && stat('心情', pet.joy)}
491 {s !== 'egg' && stat('精神', pet.energy)}
492 {s !== 'egg' && meter('病菌', germs, germs >= 60 ? '#ff6188' : germs >= 30 ? '#ffd866' : '#78dce8', pet.isSick ? '生病' : String(Math.round(germs)).padStart(3))}
493 {g ? meter('成長', g.percent, accentColor(skin, frame) ?? 'cyan', `差 ${g.left}`) : <Text dimColor>已經完全長大了</Text>}
494 {s !== 'adult' && (
495 <Text dimColor wrap="truncate">
496 改檔 {share.build}% · 指令 {share.run}% · 查找 {share.explore}%
497 </Text>
498 )}
499 </Box>}
500 <Text> </Text>
501 {game !== null ? (game.kind === 'rps' ? rps : guessView) : view === 'shop' ? shopView : view === 'detail' ? detailView : home}
502 </Box>
503 )
504 })
505
506 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
507 const pet = await read($, petAtom)
508 const isQuiet =
509 e.props.hasSurvey || pet === null || (await read($, isHidden)) || ((await read($, isPaneOpen)) && (await isPaneShown($)))
510 if (isQuiet || pet === null) {
511 return next(e)
512 }
513
514 const { Box, Button, Text } = $.ui.resolve(e)
515 const isWide = e.props.bodyColumns >= 72
516 const s = stage(pet)
517 const skin = skinOf(pet)
518 const tone = mood(pet) < 30 || pet.hunger < 20 ? '#ff6188' : lineColor(skin, 0, 0)
519 const say = (p: Promise<string>) => void p.then(text => $.ui.toast(text))
520
521 return (
522 <Box flexDirection="column">
523 <Text wrap="truncate">
524 <Text color={tone} bold>
525 {face(pet, e.props.isWorking)}
526 </Text>
527 <Text color={accentColor(skin, 0)}> {pet.name}</Text>
528 <Text dimColor> · {titleOf(pet)}</Text>
529 {pet.isSick && <Text color="#ff6188"> 生病中</Text>}
530 {s !== 'egg' && <Text color="#ffd866"> ● {coinsOf(pet)}</Text>}
531 {s !== 'egg' && <Text dimColor> 飽 </Text>}
532 {s !== 'egg' && <Text color={statColor(pet.hunger)}>{bar(pet.hunger)}</Text>}
533 {s !== 'egg' && <Text dimColor> 樂 </Text>}
534 {s !== 'egg' && <Text color={statColor(pet.joy)}>{bar(pet.joy)}</Text>}
535 {s !== 'egg' && <Text dimColor> 精 </Text>}
536 {s !== 'egg' && <Text color={statColor(pet.energy)}>{bar(pet.energy)}</Text>}
537 {s === 'egg' && <Text dimColor> 孵化中 {pet.xp}/10</Text>}
538 {pet.poops > 0 && <Text> {'💩'.repeat(Math.min(pet.poops, 5))}</Text>}
539 </Text>
540 <Box columnGap={1}>
541 {s !== 'egg' && <Button key="feed" hotkey="1" label="餵食" onPress={() => say(perform($, 'feed'))} />}
542 {s !== 'egg' && <Button key="play" hotkey="2" label="玩耍" onPress={() => say(perform($, 'play'))} />}
543 {s !== 'egg' && (
544 <Button
545 key="sleep"
546 hotkey="3"
547 label={pet.isAsleep ? '叫醒' : '睡覺'}
548 onPress={() => say(perform($, p => (p.isAsleep ? 'wake' : 'sleep')))}
549 />
550 )}
551 {s !== 'egg' && pet.poops > 0 && (
552 <Button key="clean" hotkey="4" label="清理" onPress={() => say(perform($, 'clean'))} />
553 )}
554 {s !== 'egg' && pet.isSick && (
555 <Button key="heal" hotkey="m" label="吃藥" variant="primary" onPress={() => say(perform($, 'heal'))} />
556 )}
557 <Button key="open" hotkey="o" label="展開側邊欄" dimColor onPress={() => void openPane($)} />
558 {isWide && <Button key="hide" label="收起" dimColor onPress={() => void setHidden($, true)} />}
559 </Box>
560 </Box>
561 )
562 })
563}
564hooks/games.ts 73 lines1import type { Game, Pet } from '../types'
2import { addLog, bump, earn, gainXp, stage } from './pet'
3
4const clamp = (n: number) => Math.max(0, Math.min(100, n))
5
6// 玩不了的時候回傳原因
7export const cannotPlay = (pet: Pet): string | undefined => {
8 if (stage(pet) === 'egg') return `${pet.name} 還是一顆蛋`
9 if (pet.isAsleep) return `${pet.name} 在睡覺`
10 if (pet.isSick) return `${pet.name} 生病了,不想玩`
11 if (pet.energy < 10) return `${pet.name} 太累了,讓牠睡一下吧`
12 return undefined
13}
14
15// 每玩一局都會耗體力、肚子會餓,心情、經驗和金幣看輸贏
16const reward = (pet: Pet, joy: number, xp: number, coins: number, isWin: boolean): Pet => {
17 const played = bump(earn(pet, coins), 'games')
18 const tired = {
19 ...(isWin ? bump(played, 'wins') : played),
20 joy: clamp(pet.joy + joy),
21 energy: clamp(pet.energy - 6),
22 hunger: clamp(pet.hunger - 3),
23 }
24 // 經驗走 gainXp,玩遊戲剛好跨過門檻也會進化
25 return gainXp(tired, xp)
26}
27
28export const HANDS = ['石頭', '剪刀', '布'] as const
29export type Hand = 0 | 1 | 2
30
31// 0 石頭贏 1 剪刀,1 剪刀贏 2 布,2 布贏 0 石頭
32export const judge = (mine: Hand, theirs: Hand): 'win' | 'lose' | 'draw' =>
33 mine === theirs ? 'draw' : (mine + 1) % 3 === theirs ? 'win' : 'lose'
34
35export const playRps = (pet: Pet, mine: Hand, theirs: Hand): { pet: Pet; game: Game } => {
36 const result = judge(mine, theirs)
37 const verdict = { win: '你贏了!金幣 +3', lose: `${pet.name} 贏了,得意洋洋`, draw: '平手,再一次' }[result]
38 const line = `你出${HANDS[mine]},${pet.name} 出${HANDS[theirs]},${verdict}`
39 // 寵物贏了也開心,只是你贏的時候牠學到比較多
40 const next =
41 result === 'win' ? reward(pet, 15, 2, 3, true) : result === 'lose' ? reward(pet, 10, 1, 1, false) : reward(pet, 8, 1, 1, false)
42 return { pet: next, game: { kind: 'rps', line } }
43}
44
45export const GUESS_TRIES = 3
46
47export const startGuess = (pet: Pet, secret: number): Game => ({
48 kind: 'guess',
49 secret,
50 triesLeft: GUESS_TRIES,
51 line: `${pet.name} 心裡想了一個 1 到 9 的數字,你有 ${GUESS_TRIES} 次機會`,
52 isOver: false,
53})
54
55export const guess = (pet: Pet, game: Extract<Game, { kind: 'guess' }>, n: number): { pet: Pet; game: Game } => {
56 if (game.isOver) return { pet, game }
57 if (n === game.secret) {
58 return {
59 pet: addLog(reward(pet, 25, 3, 8, true), '猜數字猜中了'),
60 game: { ...game, isOver: true, line: `猜中了!就是 ${n},${pet.name} 開心得轉圈圈,金幣 +8` },
61 }
62 }
63 const triesLeft = game.triesLeft - 1
64 if (triesLeft === 0) {
65 return {
66 pet: reward(pet, 8, 1, 1, false),
67 game: { ...game, triesLeft, isOver: true, line: `沒猜到,答案是 ${game.secret},${pet.name} 偷笑` },
68 }
69 }
70 const hint = n < game.secret ? '再大一點' : '再小一點'
71 return { pet, game: { ...game, triesLeft, line: `不是 ${n},${hint}(還剩 ${triesLeft} 次)` } }
72}
73hooks/pet.ts 368 lines1import type { Lineage, Pet, Stats, Work } from '../types'
2
3const HOUR = 60 * 60 * 1000
4
5// 每小時的變化量
6const DECAY = {
7 hunger: 8,
8 joy: 5,
9 energyAwake: 4,
10 energyAsleep: 20,
11 hungerAsleep: 4,
12}
13
14// 生病時數值掉得比較快
15const SICK_DECAY = 1.5
16
17// 病菌:報錯加、成功減,跨過門檻就生病或康復
18const GERMS = {
19 onError: 10,
20 onSuccess: 2,
21 onSuccessWhileSick: 4,
22 onTestPass: 20,
23 onMedicine: 15,
24 sickAt: 60,
25 curedAt: 20,
26}
27
28const clamp = (n: number) => Math.max(0, Math.min(100, n))
29
30// 金幣:常用的小東西一天內買得到,最貴的顏色要存一兩週
31export const COINS = {
32 perTurn: 2,
33 perTestPass: 5,
34 hatch: 10,
35 child: 30,
36 adult: 80,
37}
38
39const NO_STATS: Stats = {
40 turns: 0,
41 tools: 0,
42 errors: 0,
43 testPasses: 0,
44 games: 0,
45 wins: 0,
46 sickTimes: 0,
47 foods: 0,
48 coinsEarned: 0,
49}
50
51const LOG_LIMIT = 30
52
53export const coinsOf = (pet: Pet) => pet.coins ?? 0
54export const statsOf = (pet: Pet): Stats => ({ ...NO_STATS, ...pet.stats })
55
56export const earn = (pet: Pet, coins: number): Pet => {
57 const stats = statsOf(pet)
58 return { ...pet, coins: coinsOf(pet) + coins, stats: { ...stats, coinsEarned: stats.coinsEarned + coins } }
59}
60
61export const bump = (pet: Pet, key: keyof Stats, by = 1): Pet => {
62 const stats = statsOf(pet)
63 return { ...pet, stats: { ...stats, [key]: stats[key] + by } }
64}
65
66// 大事記用寵物最後一次更新的時間當作發生時間
67export const addLog = (pet: Pet, text: string): Pet => ({
68 ...pet,
69 log: [...(pet.log ?? []), { at: pet.lastTick, text }].slice(-LOG_LIMIT),
70})
71
72const NO_WORK: Work = { build: 0, run: 0, explore: 0 }
73
74export const hatch = (name: string, now: number): Pet => ({
75 name,
76 bornAt: now,
77 lastTick: now,
78 hunger: 80,
79 joy: 80,
80 energy: 80,
81 xp: 0,
82 poops: 0,
83 feeds: 0,
84 isAsleep: false,
85 work: NO_WORK,
86 germs: 0,
87 isSick: false,
88 coins: 0,
89 stats: NO_STATS,
90 log: [{ at: now, text: `拿到一顆蛋,取名${name}` }],
91})
92
93// 依經過時間扣數值;睡飽了自己醒來
94export const tick = (pet: Pet, now: number): Pet => {
95 const hours = Math.max(0, now - pet.lastTick) / HOUR
96 if (hours === 0) return pet
97
98 const sick = pet.isSick ? SICK_DECAY : 1
99 const messy = 1 + pet.poops * 0.5
100 const energy = pet.isAsleep
101 ? pet.energy + DECAY.energyAsleep * hours
102 : pet.energy - DECAY.energyAwake * hours
103 const next: Pet = {
104 ...pet,
105 lastTick: now,
106 hunger: clamp(pet.hunger - (pet.isAsleep ? DECAY.hungerAsleep : DECAY.hunger) * sick * hours),
107 joy: clamp(pet.joy - DECAY.joy * messy * sick * hours),
108 energy: clamp(energy),
109 }
110 return next.isAsleep && next.energy >= 100 ? { ...next, isAsleep: false } : next
111}
112
113export type Action = 'feed' | 'play' | 'sleep' | 'wake' | 'clean' | 'heal'
114
115export type Outcome = { pet: Pet; say: string }
116
117export const act = (pet: Pet, action: Action): Outcome => {
118 const n = pet.name
119 if (stage(pet) === 'egg' && action !== 'clean') {
120 return { pet, say: `${n} 還是一顆蛋,讓 Claude 多做點事,牠就會孵出來` }
121 }
122 if (pet.isAsleep && action !== 'wake') {
123 return { pet, say: `${n} 在睡覺,先別吵牠(/pet wake 可以叫醒)` }
124 }
125
126 switch (action) {
127 case 'feed': {
128 if (pet.hunger >= 95) {
129 return { pet: { ...pet, joy: clamp(pet.joy - 5) }, say: `${n} 吃不下了,還被你塞得有點不高興` }
130 }
131 const feeds = pet.feeds + 1
132 const poops = feeds % 3 === 0 ? pet.poops + 1 : pet.poops
133 const fed = { ...pet, hunger: clamp(pet.hunger + 25), feeds, poops }
134 return { pet: fed, say: poops > pet.poops ? `${n} 吃飽了,順便大了一坨 💩` : `${n} 吃得很開心` }
135 }
136 case 'play': {
137 if (pet.isSick) return { pet, say: `${n} 生病了,不想玩` }
138 if (pet.energy < 15) return { pet, say: `${n} 太累了,讓牠睡一下吧` }
139 return {
140 pet: { ...pet, joy: clamp(pet.joy + 20), energy: clamp(pet.energy - 12), hunger: clamp(pet.hunger - 5) },
141 say: `${n} 玩得好開心`,
142 }
143 }
144 case 'sleep':
145 return { pet: { ...pet, isAsleep: true }, say: `${n} 睡著了 zZ` }
146 case 'wake':
147 if (!pet.isAsleep) return { pet, say: `${n} 本來就醒著` }
148 return { pet: { ...pet, isAsleep: false, joy: clamp(pet.joy - 5) }, say: `${n} 被叫醒,有點起床氣` }
149 case 'clean':
150 if (pet.poops === 0) return { pet, say: '沒有東西要清' }
151 return { pet: { ...pet, poops: 0, joy: clamp(pet.joy + 5) }, say: `清乾淨了,${n} 舒服多了` }
152 case 'heal': {
153 if (germsOf(pet) === 0) return { pet, say: `${n} 很健康,不用吃藥` }
154 const healed = withGerms({ ...pet, joy: clamp(pet.joy - 10) }, germsOf(pet) - GERMS.onMedicine)
155 return { pet: healed, say: healed.isSick ? `${n} 吃了藥,苦著臉,還要再休養` : `${n} 吃了藥,好苦,但舒服多了` }
156 }
157 }
158}
159
160// Claude 每做一件事,寵物就吸收一點經驗;跨進成長期、成熟期時決定物種
161export const gainXp = (pet: Pet, amount: number): Pet => {
162 const next = { ...pet, xp: pet.xp + amount }
163 const from = stage(pet)
164 const to = stage(next)
165 if (from === to) return next
166 if (to === 'baby') return addLog(earn(next, COINS.hatch), '孵出來了')
167 if (to === 'child') {
168 const grown = { ...next, species: dominant(workOf(pet)), work: NO_WORK }
169 return addLog(earn(grown, COINS.child), `長成${SPECIES[grown.species].name}(${SPECIES[grown.species].family})`)
170 }
171 const adult = { ...next, trait: dominant(workOf(pet)), work: NO_WORK }
172 return addLog(earn(adult, COINS.adult), `長大成${speciesLabel(adult) ?? '成熟期'}`)
173}
174
175// 一輪對話結束:經驗 +3、金幣 +2
176export const finishTurn = (pet: Pet): Pet => bump(earn(gainXp(pet, 3), COINS.perTurn), 'turns')
177
178// 把工具歸到三種工作,不屬於任何一種就回傳 undefined
179export const classify = (tool: string): keyof Work | undefined => {
180 if (['Edit', 'Write', 'NotebookEdit', 'MultiEdit'].includes(tool)) return 'build'
181 if (['Bash', 'PowerShell', 'Monitor'].includes(tool)) return 'run'
182 if (['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'LSP', 'Agent'].includes(tool)) return 'explore'
183 if (tool.startsWith('mcp__')) return 'explore'
184 return undefined
185}
186
187const TEST_COMMAND = /\b(test|tests|vitest|jest|pytest|mocha|rspec|phpunit|ctest)\b|\b(go|cargo|bun|deno|swift|dotnet|mix) test\b|\bnpm t\b/
188
189export const isTestCommand = (command: string) => TEST_COMMAND.test(command)
190
191export type ToolOutcome = { tool: string; isError: boolean; command?: string }
192
193// 一次工具呼叫的結果:記工作量,報錯長病菌,成功消一點,測試綠燈消很多
194export const afterTool = (pet: Pet, outcome: ToolOutcome): Pet => {
195 const kind = classify(outcome.tool)
196 const work = workOf(pet)
197 const tallied = bump(pet, 'tools')
198 const counted = kind ? { ...tallied, work: { ...work, [kind]: work[kind] + 1 } } : tallied
199 if (stage(pet) === 'egg') return counted
200
201 const germs = germsOf(pet)
202 if (outcome.isError) return withGerms(bump(counted, 'errors'), germs + GERMS.onError)
203 const isTestPass = outcome.tool === 'Bash' && outcome.command !== undefined && isTestCommand(outcome.command)
204 if (isTestPass) return withGerms(bump(earn(counted, COINS.perTestPass), 'testPasses'), germs - GERMS.onTestPass)
205 return withGerms(counted, germs - (pet.isSick ? GERMS.onSuccessWhileSick : GERMS.onSuccess))
206}
207
208export const withGerms = (pet: Pet, germs: number): Pet => {
209 const g = clamp(germs)
210 const isSick = pet.isSick ? g > GERMS.curedAt : g >= GERMS.sickAt
211 const next = { ...pet, germs: g, isSick }
212 if (isSick && !pet.isSick) return addLog(bump(next, 'sickTimes'), '生病了')
213 if (!isSick && pet.isSick) return addLog(next, '病好了')
214 return next
215}
216
217export const germsOf = (pet: Pet) => pet.germs ?? 0
218export const workOf = (pet: Pet): Work => pet.work ?? NO_WORK
219
220// 佔超過 45% 的那種工作勝出,不然就是什麼都做的雜學系
221export const dominant = (work: Work): Lineage => {
222 const total = work.build + work.run + work.explore
223 if (total < 5) return 'mixed'
224 const [top, count] = (Object.entries(work) as [keyof Work, number][]).sort((a, b) => b[1] - a[1])[0] ?? ['build', 0]
225 return count / total >= 0.45 ? top : 'mixed'
226}
227
228export const SPECIES: Record<Lineage, { name: string; family: string; adultTrait: string }> = {
229 build: { name: '鼴鼠', family: '工匠系', adultTrait: '會蓋房子的' },
230 run: { name: '水獺', family: '水手系', adultTrait: '會游泳的' },
231 explore: { name: '貓頭鷹', family: '探險系', adultTrait: '會飛的' },
232 mixed: { name: '奇美拉', family: '雜學系', adultTrait: '什麼都會一點的' },
233}
234
235// 物種的完整名字,例如:會游泳的鼴鼠、純種水獺
236export const speciesLabel = (pet: Pet): string | undefined => {
237 if (!pet.species) return undefined
238 const base = SPECIES[pet.species].name
239 if (!pet.trait) return base
240 return pet.trait === pet.species ? `純種${base}` : `${SPECIES[pet.trait].adultTrait}${base}`
241}
242
243export type Stage = 'egg' | 'baby' | 'child' | 'adult'
244
245export const stage = (pet: Pet): Stage =>
246 pet.xp < 10 ? 'egg' : pet.xp < 150 ? 'baby' : pet.xp < 800 ? 'child' : 'adult'
247
248// 離下一個階段還差多少,成熟期之後回傳 null
249export const growth = (pet: Pet): { percent: number; left: number } | null => {
250 const steps = [0, 10, 150, 800]
251 const to = steps.find(n => n > pet.xp)
252 if (to === undefined) return null
253 const from = steps[steps.indexOf(to) - 1] ?? 0
254 return { percent: ((pet.xp - from) / (to - from)) * 100, left: to - pet.xp }
255}
256
257export const STAGE_LABEL: Record<Stage, string> = {
258 egg: '蛋',
259 baby: '幼年期',
260 child: '成長期',
261 adult: '成熟期',
262}
263
264// 階段加物種,例如:成長期・水獺
265export const titleOf = (pet: Pet) => {
266 const label = speciesLabel(pet)
267 return label ? `${STAGE_LABEL[stage(pet)]}・${label}` : STAGE_LABEL[stage(pet)]
268}
269
270export const mood = (pet: Pet) =>
271 Math.round(clamp((pet.hunger + pet.joy + pet.energy) / 3 - pet.poops * 10))
272
273export const face = (pet: Pet, isWorking: boolean): string => {
274 const s = stage(pet)
275 if (s === 'egg') return isWorking ? '( ⊙ )' : '( )'
276 if (pet.isAsleep) return '(-ω-) zZ'
277 if (pet.isSick) return '(×﹏×)'
278 if (pet.hunger < 20) return '(´﹃`)'
279 const m = mood(pet)
280 if (m < 30) return '(;ω;)'
281 if (isWorking) return s === 'adult' ? '(•̀ω•́)و' : '(・ω・)ノ'
282 if (m >= 75) return s === 'baby' ? '(^ω^)' : 'ヾ(^ω^)ノ'
283 return '(・ω・)'
284}
285
286// 數值跨過門檻時要提醒一次
287export const warnings = (before: Pet, after: Pet): string[] => {
288 const out: string[] = []
289 const n = after.name
290 const from = stage(before)
291 const to = stage(after)
292 if (from !== to) {
293 if (from === 'egg') out.push(`${n} 孵出來了!`)
294 else if (to === 'child' && after.species) {
295 const sp = SPECIES[after.species]
296 out.push(`${n} 長成了${sp.name}(${sp.family})!`)
297 } else if (to === 'adult') out.push(`${n} 長大了,變成${speciesLabel(after) ?? STAGE_LABEL[to]}`)
298 else out.push(`${n} 長大了,進入${STAGE_LABEL[to]}`)
299 }
300 if (!before.isSick && after.isSick) out.push(`${n} 生病了,讓測試變綠、少出點錯,或用 /pet heal 餵藥`)
301 if (before.isSick && !after.isSick) out.push(`${n} 病好了!`)
302 if (before.hunger >= 20 && after.hunger < 20) out.push(`${n} 肚子餓了`)
303 if (before.joy >= 20 && after.joy < 20) out.push(`${n} 覺得好無聊`)
304 if (before.isAsleep && !after.isAsleep) out.push(`${n} 睡飽起床了`)
305 return out
306}
307
308export const bar = (n: number, width = 5) => {
309 const full = Math.round((n / 100) * width)
310 return '█'.repeat(full) + '░'.repeat(width - full)
311}
312
313export const ageDays = (pet: Pet, now: number) => Math.floor((now - pet.bornAt) / (24 * HOUR))
314
315// 這個階段的工作比例,給摘要和側邊欄顯示
316export const workShare = (pet: Pet) => {
317 const w = workOf(pet)
318 const total = w.build + w.run + w.explore || 1
319 return {
320 build: Math.round((w.build / total) * 100),
321 run: Math.round((w.run / total) * 100),
322 explore: Math.round((w.explore / total) * 100),
323 }
324}
325
326export const summary = (pet: Pet, now: number) => {
327 const share = workShare(pet)
328 return [
329 `${face(pet, false)} ${pet.name}(${titleOf(pet)},${ageDays(pet, now)} 天大,經驗 ${pet.xp})`,
330 `飽足 ${bar(pet.hunger)} ${Math.round(pet.hunger)} 心情 ${bar(pet.joy)} ${Math.round(pet.joy)} 精神 ${bar(pet.energy)} ${Math.round(pet.energy)}`,
331 `病菌 ${bar(germsOf(pet))} ${Math.round(germsOf(pet))}${pet.isSick ? '(生病中)' : ''}`,
332 stage(pet) === 'adult' ? '' : `這階段的工作:改檔 ${share.build}%、跑指令 ${share.run}%、查資料 ${share.explore}%`,
333 pet.poops > 0 ? `地上有 ${pet.poops} 坨 💩,用 /pet clean 清掉` : '',
334 ]
335 .filter(Boolean)
336 .join('\n')
337}
338
339const pad = (n: number) => String(n).padStart(2, '0')
340
341// 毫秒時間轉成 10/02 14:30,用這台電腦的時區
342export const stamp = (at: number) => {
343 const d = new Date(at)
344 return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
345}
346
347export const birthday = (pet: Pet) => {
348 const d = new Date(pet.bornAt)
349 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
350}
351
352// 細節頁:統計數字和最近的大事記,指令和側邊欄共用
353export const detail = (pet: Pet, recent = 8) => {
354 const st = statsOf(pet)
355 return {
356 facts: [
357 `生日 ${birthday(pet)}`,
358 `金幣 ${coinsOf(pet)}(總共賺過 ${st.coinsEarned})`,
359 `陪 Claude 做完 ${st.turns} 輪對話、用了 ${st.tools} 次工具`,
360 `看過 ${st.errors} 次報錯、${st.testPasses} 次測試綠燈`,
361 `生過 ${st.sickTimes} 次病`,
362 `玩了 ${st.games} 局小遊戲、贏 ${st.wins} 局`,
363 `吃過 ${st.foods} 份高級食物`,
364 ],
365 log: (pet.log ?? []).slice(-recent).reverse().map(entry => `${stamp(entry.at)} ${entry.text}`),
366 }
367}
368hooks/shop.ts 74 lines1import type { Pet, Skin } from '../types'
2import { addLog, bump, coinsOf, germsOf, stage, withGerms } from './pet'
3import { SKIN_ORDER, SKINS, skinOf } from './sprites'
4
5const clamp = (n: number) => Math.max(0, Math.min(100, n))
6
7export type FoodId = 'cake' | 'drink' | 'salad' | 'bento'
8
9// 高級食物買了就直接餵,效果比一般餵食好;只有沙拉不會讓牠想大便
10export const FOODS: Record<FoodId, { label: string; price: number; effect: string; feed: (pet: Pet) => Pet }> = {
11 cake: {
12 label: '草莓蛋糕',
13 price: 25,
14 effect: '飽足 +40、心情 +15',
15 feed: pet => ({ ...pet, hunger: clamp(pet.hunger + 40), joy: clamp(pet.joy + 15) }),
16 },
17 drink: {
18 label: '能量飲料',
19 price: 30,
20 effect: '精神 +40',
21 feed: pet => ({ ...pet, energy: clamp(pet.energy + 40), hunger: clamp(pet.hunger + 5) }),
22 },
23 salad: {
24 label: '有機沙拉',
25 price: 35,
26 effect: '飽足 +30、病菌 -15、不會大便',
27 feed: pet => withGerms({ ...pet, hunger: clamp(pet.hunger + 30) }, germsOf(pet) - 15),
28 },
29 bento: {
30 label: '豪華便當',
31 price: 60,
32 effect: '飽足全滿、心情和精神 +10',
33 feed: pet => ({ ...pet, hunger: 100, joy: clamp(pet.joy + 10), energy: clamp(pet.energy + 10) }),
34 },
35}
36
37export const FOOD_ORDER: FoodId[] = ['cake', 'drink', 'salad', 'bento']
38
39// 舊存檔沒有買過的紀錄,就當成只有免費的和當時正在用的那個
40export const ownedSkins = (pet: Pet): Skin[] => {
41 const free = SKIN_ORDER.filter(skin => SKINS[skin].price === 0)
42 return [...new Set([...free, ...(pet.ownedSkins ?? []), skinOf(pet)])]
43}
44
45export const owns = (pet: Pet, skin: Skin) => ownedSkins(pet).includes(skin)
46
47export type Sale = { pet: Pet; say: string }
48
49const short = (pet: Pet, price: number) => `金幣不夠,還差 ${price - coinsOf(pet)}`
50
51export const buyFood = (pet: Pet, id: FoodId): Sale => {
52 const food = FOODS[id]
53 if (stage(pet) === 'egg') return { pet, say: `${pet.name} 還是一顆蛋,吃不了東西` }
54 if (pet.isAsleep) return { pet, say: `${pet.name} 在睡覺,醒來再吃` }
55 if (coinsOf(pet) < food.price) return { pet, say: short(pet, food.price) }
56 // 沙拉以外都算一次餵食,每三次會大一坨
57 const paid = { ...pet, coins: coinsOf(pet) - food.price }
58 const feeds = id === 'salad' ? pet.feeds : pet.feeds + 1
59 const poops = id !== 'salad' && feeds % 3 === 0 ? pet.poops + 1 : pet.poops
60 const fed = bump(food.feed({ ...paid, feeds, poops }), 'foods')
61 return { pet: addLog(fed, `吃了${food.label}`), say: `${pet.name} 吃了${food.label},好幸福` }
62}
63
64export const buySkin = (pet: Pet, skin: Skin): Sale => {
65 const { label, price } = SKINS[skin]
66 if (owns(pet, skin)) return { pet: { ...pet, skin }, say: `換成${label}了` }
67 if (coinsOf(pet) < price) return { pet, say: short(pet, price) }
68 const bought = { ...pet, coins: coinsOf(pet) - price, ownedSkins: [...ownedSkins(pet), skin], skin }
69 return { pet: addLog(bought, `買了${label}顏色`), say: `買下${label},馬上換上了` }
70}
71
72export const findFood = (word: string): FoodId | undefined =>
73 FOOD_ORDER.find(id => id === word || FOODS[id].label === word || FOODS[id].label.endsWith(word))
74hooks/sprites.ts 220 lines1import type { Lineage, Pet, Skin } from '../types'
2import { mood, stage } from './pet'
3
4// 把一段 ASCII 圖拆成行,去掉頭尾空行,每行補到一樣寬,整塊靠左對齊才不會歪
5const art = (raw: string): string[] => {
6 const lines = raw.split('\n')
7 while (lines[0]?.trim() === '') lines.shift()
8 while (lines.at(-1)?.trim() === '') lines.pop()
9 const width = Math.max(...lines.map(line => line.length))
10 return lines.map(line => line.padEnd(width))
11}
12
13// 畫面裡的 L、R 是左右眼,M 是嘴巴,畫的時候換成表情;圖裡其他地方不要用這三個大寫字母
14const BABY = art(String.raw`
15 .-"""-.
16 / \
17 | L R |
18 | M |
19 \ /
20 '-...-'
21`)
22
23// 成長期依物種長出不同的樣子
24const CHILD: Record<Lineage, string[]> = {
25 // 鼴鼠:小眼睛、大鼻子、挖土的爪子
26 build: art(String.raw`
27 _.----._
28 / L R \
29 | (M) |
30 /| '----' |\
31 (_/\________/\_)
32 ^^ ^^
33`),
34 // 水獺:鬍鬚、圓肚子、一條尾巴泡在水裡
35 run: art(String.raw`
36 .-"""-.
37 _/ L R \_
38 =- .M. -=
39 \ '---' /
40 / \__
41 (_/'---'\_~~~~
42`),
43 // 貓頭鷹:尖耳朵、大圓眼、收起來的翅膀
44 explore: art(String.raw`
45 /\_______/\
46 ( (L) (R) )
47 \ M /
48 /'"""""""'\
49 ( \ / )
50 '"^^---^^"'
51`),
52 // 奇美拉:貓臉、背上一片小翅膀
53 mixed: art(String.raw`
54 /\_/\ _
55 ( L R ) / )
56 =( M )= _/ /
57 ) (_/ /
58 (__)-(__)'
59`),
60}
61
62// 成熟期在頭上多一個標誌:安全帽、水泡、羽冠、星星
63const CROWN: Record<Lineage, string[]> = {
64 build: art(String.raw`
65 _____
66 _|_____|_
67`),
68 run: art(String.raw`
69 o . O
70`),
71 explore: art(String.raw`
72 \ | /
73`),
74 mixed: art(String.raw`
75 * . *
76`),
77}
78
79const EGG = [
80 art(String.raw`
81 .--.
82 / \
83 | |
84 | |
85 \____/
86`),
87 art(String.raw`
88 .--.
89 / /\ \
90 | / \/|
91 | |
92 \____/
93`),
94] as const
95
96export type Mode = 'idle' | 'working' | 'sleep' | 'sick' | 'hungry' | 'sad'
97
98export const modeOf = (pet: Pet, isWorking: boolean): Mode => {
99 if (pet.isAsleep) return 'sleep'
100 if (pet.isSick) return 'sick'
101 if (pet.hunger < 20) return 'hungry'
102 if (mood(pet) < 30) return 'sad'
103 return isWorking ? 'working' : 'idle'
104}
105
106const EXPRESSION: Record<Mode, { eye: string; mouth: string }> = {
107 idle: { eye: 'o', mouth: 'w' },
108 working: { eye: '^', mouth: 'v' },
109 sleep: { eye: '-', mouth: '.' },
110 sick: { eye: 'x', mouth: '~' },
111 hungry: { eye: 'o', mouth: 'O' },
112 sad: { eye: 'T', mouth: 'n' },
113}
114
115const SPARKLES = [' ✦ ✧ ', ' ✧ ✦ ', ' ✦ ✧ ', ' ✦ ✧ ']
116const FEVER = [' + + ', ' + + ', ' + + ', ' + + ']
117const SNORE = [' z ', ' Z ', ' z ', ' Z']
118
119const bodyOf = (pet: Pet): string[] => {
120 const s = stage(pet)
121 if (s === 'baby' || s === 'egg') return BABY
122 const species = pet.species ?? 'mixed'
123 if (s === 'child') return CHILD[species]
124 // 頭飾和身體寬度不同,一起再補一次寬
125 return art([...CROWN[species], ...CHILD[species]].join('\n'))
126}
127
128export type Frame = { effect: string; body: string[]; caption: string }
129
130// frame 每半秒加 1,所有動畫都從它算出來
131export const drawFrame = (pet: Pet, isWorking: boolean, frame: number): Frame => {
132 const s = stage(pet)
133 const mode = modeOf(pet, isWorking)
134 const bounce = mode === 'working' ? frame % 2 === 1 : Math.floor(frame / 2) % 2 === 1
135
136 if (s === 'egg') {
137 const wobble = isWorking && frame % 2 === 1
138 const body = [...EGG[pet.xp >= 5 ? 1 : 0]].map(line => (wobble ? ` ${line.slice(0, -1)}` : line))
139 return { effect: '', body, caption: pet.xp >= 5 ? '蛋殼裂開了…' : '在蛋裡睡覺' }
140 }
141
142 const blink = mode === 'idle' && frame % 9 === 0
143 const { eye, mouth } = blink ? { eye: '-', mouth: EXPRESSION.idle.mouth } : EXPRESSION[mode]
144 const lines = bodyOf(pet).map(line => line.replace('L', eye).replace('R', eye).replace('M', mouth))
145 // 彈跳:身體上移一行,把空行補到下面
146 const body = mode === 'sleep' || mode === 'sad' || mode === 'sick' || !bounce ? ['', ...lines] : [...lines, '']
147
148 const effect =
149 mode === 'working' ? (SPARKLES[frame % SPARKLES.length] ?? '')
150 : mode === 'sleep' ? (SNORE[frame % SNORE.length] ?? '')
151 : mode === 'sick' ? (FEVER[frame % FEVER.length] ?? '')
152 : ''
153
154 const caption = {
155 idle: '悠哉中',
156 working: '正在幫 Claude 加油!',
157 sleep: '呼…呼…',
158 sick: '生病了…測試變綠牠就會好',
159 hungry: '肚子好餓…',
160 sad: '有點寂寞…',
161 }[mode]
162
163 return { effect, body, caption }
164}
165
166// price 是商店售價,0 是一開始就有的
167export const SKINS: Record<Skin, { label: string; price: number; body?: string; accent?: string }> = {
168 pink: { label: '粉紅', price: 0, body: '#ff9ecf', accent: '#ffd1e8' },
169 mono: { label: '單色', price: 0 },
170 mint: { label: '薄荷', price: 150, body: '#7be0c3', accent: '#c7f5e8' },
171 sky: { label: '天空', price: 150, body: '#79b8ff', accent: '#c8e1ff' },
172 gold: { label: '金色', price: 400, body: '#ffcc66', accent: '#fff0c2' },
173 neon: { label: '霓虹', price: 800 },
174 rainbow: { label: '彩虹', price: 1200 },
175 galaxy: { label: '星河', price: 1500 },
176}
177
178export const SKIN_ORDER: Skin[] = ['pink', 'mono', 'mint', 'sky', 'gold', 'neon', 'rainbow', 'galaxy']
179
180const RAINBOW = ['#ff6188', '#fc9867', '#ffd866', '#a9dc76', '#78dce8', '#ab9df2']
181const NEON = ['#ff2ec4', '#00f0ff']
182const GALAXY = ['#3b1e7a', '#5b3fbf', '#7597de', '#b9a6ff', '#e6dcff', '#b9a6ff', '#7597de', '#5b3fbf']
183
184export const skinOf = (pet: Pet): Skin => pet.skin ?? 'pink'
185
186// 第 row 行在第 frame 格要用的顏色
187// 彩虹每行不同色、往下流;霓虹整隻在兩色之間閃;星河是深紫到淡紫的漸層慢慢流動
188export const lineColor = (skin: Skin, row: number, frame: number) => {
189 if (skin === 'rainbow') return RAINBOW[(row + frame) % RAINBOW.length]
190 if (skin === 'neon') return NEON[Math.floor(frame / 2) % NEON.length]
191 if (skin === 'galaxy') return GALAXY[(row + Math.floor(frame / 2)) % GALAXY.length]
192 return SKINS[skin].body
193}
194
195export const accentColor = (skin: Skin, frame: number) => {
196 if (skin === 'rainbow') return RAINBOW[frame % RAINBOW.length]
197 if (skin === 'neon') return NEON[(Math.floor(frame / 2) + 1) % NEON.length]
198 if (skin === 'galaxy') return GALAXY[(Math.floor(frame / 2) + 4) % GALAXY.length]
199 return SKINS[skin].accent
200}
201
202// 商店預覽:第 i 個字的背景色,會變化的顏色也跟著動畫流動
203export const previewColor = (skin: Skin, i: number, frame: number): string | undefined => {
204 if (skin === 'rainbow') return RAINBOW[(i + frame) % RAINBOW.length]
205 if (skin === 'neon') return NEON[(i + Math.floor(frame / 2)) % NEON.length]
206 if (skin === 'galaxy') return GALAXY[(i + Math.floor(frame / 2)) % GALAXY.length]
207 return SKINS[skin].body
208}
209
210// 背景亮就用深色字,背景暗就用淺色字
211export const inkOn = (background: string) => {
212 const n = Number.parseInt(background.slice(1), 16)
213 const luminance = 0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)
214 return luminance > 140 ? '#1b1b1b' : '#f5f5f5'
215}
216
217export const SICK_COLOR = '#a9dc76'
218
219export const statColor = (n: number) => (n >= 60 ? '#a9dc76' : n >= 30 ? '#ffd866' : '#ff6188')
220types/index.d.ts 73 lines1export type Skin = 'pink' | 'mint' | 'sky' | 'gold' | 'mono' | 'rainbow' | 'neon' | 'galaxy'
2
3// build 是改檔案(陸),run 是跑指令(水),explore 是查資料(空),mixed 是什麼都做
4export type Lineage = 'build' | 'run' | 'explore' | 'mixed'
5
6export type Work = { build: number; run: number; explore: number }
7
8export type Pet = {
9 name: string
10 bornAt: number
11 lastTick: number
12 // 0 到 100,越高越好
13 hunger: number
14 joy: number
15 energy: number
16 xp: number
17 poops: number
18 feeds: number
19 isAsleep: boolean
20 // 以下欄位舊存檔沒有,讀的時候補預設值
21 skin?: Skin
22 // 目前這個階段累積的工作量,進入成長期、成熟期時拿來決定物種
23 work?: Work
24 // 進入成長期時決定的物種
25 species?: Lineage
26 // 進入成熟期時,依成長期的工作再分化一次
27 trait?: Lineage
28 // 0 到 100,工具報錯會增加,累積太多就生病
29 germs?: number
30 isSick?: boolean
31 coins?: number
32 // 買過的顏色樣式;舊存檔只有免費的和當時正在用的
33 ownedSkins?: Skin[]
34 stats?: Stats
35 log?: LogEntry[]
36}
37
38export type Stats = {
39 turns: number
40 tools: number
41 errors: number
42 testPasses: number
43 games: number
44 wins: number
45 sickTimes: number
46 foods: number
47 coinsEarned: number
48}
49
50// 細節頁的大事記,at 是毫秒時間
51export type LogEntry = { at: number; text: string }
52
53export type View = 'home' | 'shop' | 'detail'
54
55export type Game =
56 | { kind: 'rps'; line: string }
57 | { kind: 'guess'; secret: number; triesLeft: number; line: string; isOver: boolean }
58
59declare module 'claude-code' {
60 interface PluginState {
61 'hyday-pet': {
62 pet: Pet | null
63 isHidden: boolean
64 isPaneOpen: boolean
65 isWorking: boolean
66 frame: number
67 // null 是寵物首頁,其他是正在玩的小遊戲
68 game: Game | null
69 view: View
70 }
71 }
72}
73