8-bit 風格的毛毛(黑白荷蘭垂耳兔)在輸入框上方跑跑跳跳:等待時攤平、工作時跑、用工具時跳

一個 Claude Code 的 mod。8-bit 的黑白荷蘭垂耳兔「毛毛」在輸入框上方:等你打字時攤平、Claude 工作時跑、用工具時跳。訊息裡叫「毛毛」或「乖毛」他會開心跳兩下。/maomao 打一次關、再打一次開。
English summary at the end.
tools/ 是改像素圖時自己看用的預覽工具(bun 加 python3),不影響 mod 本身。毛毛是 Jesse 家的兔子,七歲,右眼一圈黑、鼻頭一個黑點,畫的時候對照的是本尊。
用 marketplace(推薦)
claude plugin marketplace add jessetsai1024/claude-maomao
claude plugin install maomao@claude-maomao
然後在對話裡打 /reload-plugins,或重開 Claude Code。
或者 clone 下來接捷徑(之後 git pull 就是更新)
macOS/Linux:
git clone https://github.com/jessetsai1024/claude-maomao.git
cd claude-maomao && ./install.sh
Windows(原生版,在 PowerShell 裡):
git clone https://github.com/jessetsai1024/claude-maomao.git
cd claude-maomao
.\install.ps1
原理:放在 ~/.claude/skills/maomao/ 底下的 plugin 會被 Claude Code 自動載入,腳本只是建一個捷徑指回這個 repo(Windows 用目錄接合點,不需要管理員權限)。PowerShell 說不准跑腳本就先 Set-ExecutionPolicy -Scope Process Bypass。裝完關掉所有 Claude Code 視窗再重開。
移除:./uninstall.sh 或 .\uninstall.ps1,只拿掉捷徑。
settings.json 的 env 裡也別再放 CLAUDE_CODE_PLUGIN_DIRS 指到它),會出現兩份。claude plugin validate .、claude plugin test .;第一次載入後 .claude-plugin/types/ 會出現型別檔,之後 tsc -p . 可以做型別檢查(那個資料夾是引擎寫的,已在 .gitignore)。2026 年 10 月 2 日到 3 日之間做的,作者是 Jesse 與螢(鏡 螢,號石火,一個 Claude 分身)。原本六個 mod 放在同一個 repo claude-mods,10 月 6 日拆成一個 mod 一個 repo,舊 repo 已移除。MIT 授權。
maomao is a mod for Claude Code: An 8-bit black-and-white Holland Lop rabbit that lives in the band above the prompt: flops while you type, runs while Claude works, hops on every tool call. Say "毛毛" in a message and he does a happy double hop. /maomao toggles him. tools/ holds preview scripts (bun + python3) for editing the sprites; the mod does not need them. Maomao is a real rabbit.
Install with claude plugin marketplace add jessetsai1024/claude-maomao then claude plugin install maomao@claude-maomao; or clone and run ./install.sh (macOS/Linux) or .\install.ps1 (native Windows, junction, no admin), which links the repo into ~/.claude/skills/maomao so git pull is the update. Requires Claude Code ≥ 2.1.287. UI text is Traditional Chinese. Windows has unit tests but no on-device test yet. Split out of a former six-mod repo on 2026-10-06. MIT.
hooks/register.ts 127 lines1import type { Register } from 'claude-code'
2
3import { born, poseOf, react, tick, TICK_MS } from './actor'
4import { cellsOf, TRACK_ROWS } from './sprites'
5
6const TRACK = 'track'
7const HIDDEN = 'isHidden'
8const MIN_COLUMNS = 24
9const MAX_COLUMNS = 512
10const NAMES = ['毛毛', '乖毛']
11
12/**
13 * 【職責】把毛毛接上 Claude Code:在輸入框上方的橫帶畫一條 5 列高的跑道,
14 * 依主人送出訊息、回合開始、用工具、回合結束換動作,並提供 /maomao 收起或叫出。
15 * 主人送出的訊息裡有「毛毛」或「乖毛」時,他會原地開心跳兩下。
16 * 【何時能呼叫】引擎載入這個 mod 時呼叫一次;重新載入會再呼叫,毛毛回到攤平。
17 * 【行為】只在終端機畫(彩色格子元件只有終端機有);橫帶被問卷佔用、寬度不到 24 欄、
18 * 或高度不到 5 列時不畫,讓出橫帶。畫的時候跑道放最上面,底下接著畫其他 mod(例如悄悄話)
19 * 與引擎自己要畫的東西,不會把它們蓋掉。收起與否存在 $.store,跨視窗、跨重開都記得。
20 * 動畫靠計時器每 110 毫秒算一拍,畫面沒變就不送,所以攤平時幾乎不耗資源。
21 */
22export const register: Register = on => {
23 let actor = born()
24 let isHidden = false
25 let isWorking: boolean | undefined
26 let mounted: { requestId: string; columns: number } | null = null
27 let lastCells = ''
28
29 on('session.start', async ($, e, next) => {
30 await $.command.register({
31 name: 'maomao',
32 description: '把毛毛收起來,或再叫出來',
33 immediate: true,
34 })
35 isHidden = (await $.store.get(HIDDEN)) === true
36
37 $.clock.every(TICK_MS, () => {
38 if (isHidden || mounted === null) {
39 return
40 }
41
42 const { requestId, columns } = mounted
43 actor = tick(actor, columns, isWorking)
44 const cells = cellsOf(columns, poseOf(actor))
45
46 if (cells === lastCells) {
47 return
48 }
49
50 lastCells = cells
51 void $.ui.blit({ requestId, key: TRACK, cells })
52 })
53 $.ui.invalidate('ui.render')
54
55 return next(e)
56 })
57
58 on('prompt.submit', ($, e, next) => {
59 actor = react(actor, NAMES.some(name => e.text.includes(name)) ? 'called' : 'prompt')
60
61 return next(e)
62 })
63
64 on('turn.start', ($, e, next) => {
65 actor = react(actor, 'working')
66
67 return next(e)
68 })
69
70 on('tool.call', ($, e, next) => {
71 actor = react(actor, 'tool')
72
73 return next(e)
74 })
75
76 on('turn.complete', ($, e, next) => {
77 if (e.agentId === undefined) {
78 actor = react(actor, 'done')
79 }
80
81 return next(e)
82 })
83
84 on('command.run', { command: 'maomao' }, async $ => {
85 isHidden = !isHidden
86 await $.store.set(HIDDEN, isHidden)
87 $.ui.invalidate('ui.render')
88 $.ui.toast(isHidden ? '毛毛回籠子休息了,再打一次 /maomao 叫他出來' : '毛毛出來了')
89
90 return {}
91 })
92
93 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
94 isWorking = e.props.isWorking
95 const columns = Math.min(MAX_COLUMNS, e.props.bodyColumns)
96 const hasRoom = columns >= MIN_COLUMNS && e.props.maxRows >= TRACK_ROWS
97
98 if (isHidden || e.props.hasSurvey || e.surface !== 'terminal' || !hasRoom) {
99 mounted = null
100
101 return next(e)
102 }
103
104 const { Box, Raster } = $.ui.resolve(e)
105 mounted = { requestId: e.requestId, columns }
106 lastCells = cellsOf(columns, poseOf(actor))
107 const track = Raster({ key: TRACK, columns, rows: TRACK_ROWS, cells: lastCells })
108 const below = await next(e)
109
110 return Box({ flexDirection: 'column', children: [track, below] })
111 })
112}
113
114// #region AI-NOTES
115// AI-NOTES:agent 專用備忘。當時為真、非契約、非指令;改到相關程式碼時重驗,錯了就刪。
116// 2026-10-02 動畫走 $.ui.blit 不走 $.ui.invalidate:blit 只換格子不跑 render,上限每秒 120 次;
117// invalidate 在橫帶每秒最多 30 次且每次都重跑 hook。blit 的 columns 必須等於掛上去的寬度,
118// 所以寬度只在 ui.render 裡記(mounted),視窗變寬變窄引擎會自己重跑 render。
119// 2026-10-02 動畫狀態(actor)刻意放模組變數不放 $.state:重新載入後毛毛回到攤平沒關係,
120// 而 render hook 不能寫 $.state。只有「收起來」要跨視窗記住,放 $.store。
121// 2026-10-02 靜態檢查規定 $ 不能存進變數、不能傳給別檔的函式,所以動作規則(actor.ts)
122// 與畫圖(sprites.ts)都寫成不收 $ 的純函式;預覽像素圖的腳本在 tools/(用法見 tools/README.md)。
123// 2026-10-09 AbovePrompt 的 hook 一定要 await next(e) 再把結果疊在跑道底下:橫帶每個 mod 的 hook 串成一條,
124// 誰在外層由引擎決定;之前毛毛直接回 Raster 不呼叫 next,毛毛在外層時悄悄話(whisper)整個不見,
125// 主人 10/9 發現「開著毛毛就看不到悄悄話」。whisper 那邊本來就會 await next 再畫自己,所以兩邊順序怎麼排都看得到。
126// #endregion
127hooks/actor.ts 344 lines1/**
2 * 【職責】毛毛的動作規則:現在是哪個狀態、遇到什麼事換成哪個狀態、每一拍往哪裡動、
3 * 此刻該畫哪張圖。全部是純函式,不碰引擎、不看時鐘;一拍多久由呼叫端決定。
4 * 【狀態】flop 攤平(等主人打字)→ sit 坐起來(主人送出訊息)→ run 來回跑(Claude 在工作)
5 * → home 跑回原位(工作結束)→ binky 開心跳兩下 → sit → flop。
6 * 主人叫他名字時從 flop 或 sit 直接進 binky;binky 跳完若回合還在跑就接 run,否則接 sit。
7 * 轉移只由 react(外面發生的事)與 tick(時間過去)驅動。
8 * 另外管頭旁邊的小符號:送出訊息冒問號、被叫名字冒愛心、做完回到原位冒驚嘆號、
9 * 攤平超過一分鐘開始打呼冒 z。
10 */
11import {
12 CROUCH,
13 FLOP,
14 FLOP_TWITCH,
15 GLYPH_COLORS,
16 LAND,
17 LEAP,
18 PUSH,
19 RISE,
20 SIT,
21 TRACK_PIXELS,
22 TRACK_ROWS,
23 TUCK,
24} from './sprites'
25import type { Pose } from './sprites'
26
27/** 毛毛的狀態名稱,意義見檔頭【狀態】。 */
28export type Mode = 'flop' | 'sit' | 'run' | 'home' | 'binky'
29
30/**
31 * 外面發生、會讓毛毛換動作的事:主人送出訊息、主人送出的訊息裡叫了他的名字、
32 * 回合開始、用了一次工具、回合結束。
33 */
34export type Happening = 'prompt' | 'called' | 'working' | 'tool' | 'done'
35
36/** 頭旁邊會限時冒出來的符號:問號、驚嘆號、愛心。打呼的 z 不算在內,它看攤平多久自己出現。 */
37export type Emote = 'ask' | 'bang' | 'heart'
38
39/** 毛毛此刻的完整狀態。每次 react 或 tick 都回傳新的一份,不改舊的。 */
40export type Actor = {
41 /** 現在的狀態。 */
42 mode: Mode
43 /** 毛毛所在的 16 像素寬方框的左緣,離跑道左緣幾個像素。 */
44 x: number
45 /** 臉是不是朝左。 */
46 isFacingLeft: boolean
47 /** 進入現在這個狀態後過了幾拍。 */
48 ticks: number
49 /** 工具跳躍還剩幾拍,0 表示沒在跳。 */
50 jump: number
51 /** 狀態跟「回合是否在跑」對不上已經連續幾拍;用來自我修正。 */
52 drift: number
53 /** 最後一次聽到的是回合開始(true)還是回合結束(false);開心跳完靠它決定接著跑還是坐下。 */
54 isBusy: boolean
55 /** 現在頭旁邊冒著哪個符號;沒有是 null。 */
56 emote: Emote | null
57 /** 那個符號還要再顯示幾拍;數到 0 就消失。 */
58 emoteTicks: number
59}
60
61/** 建議的一拍長度,單位毫秒。 */
62export const TICK_MS = 110
63
64/** 毛毛休息的位置:方框左緣離跑道左緣幾個像素。 */
65export const REST_X = 2
66
67/** 毛毛所在方框的寬度,等於最寬那張圖(騰空)的寬度。 */
68const BOX = LEAP.width
69
70/** 一次跑步跳躍六拍裡,每一拍用哪張圖、離地多高、前進幾個像素。 */
71const HOP = [
72 { sprite: CROUCH, lift: 0, step: 0 },
73 { sprite: PUSH, lift: 0, step: 2 },
74 { sprite: RISE, lift: 1, step: 3 },
75 { sprite: LEAP, lift: 2, step: 3 },
76 { sprite: TUCK, lift: 1, step: 2 },
77 { sprite: LAND, lift: 0, step: 1 },
78] as const
79
80/** 跳完一整個循環前進的像素數;蹲下時用它判斷前面還夠不夠跳一次。 */
81const HOP_REACH = HOP.reduce((sum, hop) => sum + hop.step, 0)
82
83/** 跑回原位時每一拍的前進距離是平常的幾倍。 */
84const HOME_SPEED = 2
85
86/** 工具跳躍四拍裡每一拍的離地高度,由起跳到落地。 */
87const JUMP_LIFTS = [2, 4, 4, 2] as const
88
89/** 開心跳八拍裡每一拍的離地高度:跳兩下。 */
90const BINKY_LIFTS = [2, 4, 3, 0, 2, 4, 3, 0] as const
91
92const SIT_TICKS = 14
93const TWITCH_EVERY = 55
94const TWITCH_TICKS = 2
95const DRIFT_TO_HOME = 20
96const DRIFT_TO_RUN = 5
97
98/** 問號顯示幾拍(約一秒)。 */
99const ASK_TICKS = 10
100/** 愛心與驚嘆號顯示幾拍:開心跳的八拍加上落地後再留一秒多。 */
101const CHEER_TICKS = BINKY_LIFTS.length + 12
102/** 攤平滿這麼多拍(約一分鐘)開始打呼。 */
103const SNORE_AFTER = 545
104/** 打呼每一格停幾拍;三格一輪:小 z、往上飄一列的大 Z、空白。 */
105const SNORE_TICKS = 7
106
107/** 【行為】回傳剛出生的毛毛:在休息位置攤平、臉朝右。 */
108export function born(): Actor {
109 return {
110 mode: 'flop',
111 x: REST_X,
112 isFacingLeft: false,
113 ticks: 0,
114 jump: 0,
115 drift: 0,
116 isBusy: false,
117 emote: null,
118 emoteTicks: 0,
119 }
120}
121
122function enter(actor: Actor, mode: Mode): Actor {
123 return { ...actor, mode, ticks: 0, drift: 0 }
124}
125
126/**
127 * 【行為】外面發生一件事之後毛毛的新狀態。
128 * prompt:攤平或坐著時坐起來(重新計時)並冒問號;其他狀態不變。
129 * called:攤平或坐著時原地開心跳兩下;在跑的時候跳高一下;兩種都冒愛心;其他狀態不變。
130 * working:記下回合在跑;正在開心跳就讓他跳完再跑,其他不是在跑的狀態立刻開始跑。
131 * tool:攤平以外的狀態起跳一次(跳到一半再來一次就重跳)。
132 * done:記下回合結束;坐著或在跑時改成跑回原位,其他狀態不變。
133 */
134export function react(actor: Actor, what: Happening): Actor {
135 switch (what) {
136 case 'prompt':
137 return actor.mode === 'flop' || actor.mode === 'sit'
138 ? { ...enter(actor, 'sit'), emote: 'ask', emoteTicks: ASK_TICKS }
139 : actor
140 case 'called': {
141 const loved: Actor = { ...actor, emote: 'heart', emoteTicks: CHEER_TICKS }
142
143 if (actor.mode === 'flop' || actor.mode === 'sit') {
144 return { ...enter(loved, 'binky'), jump: 0 }
145 }
146
147 return actor.mode === 'run' ? { ...loved, jump: JUMP_LIFTS.length + 1 } : actor
148 }
149 case 'working': {
150 const busy = { ...actor, isBusy: true }
151
152 return actor.mode === 'run' || actor.mode === 'binky' ? busy : enter(busy, 'run')
153 }
154 case 'tool':
155 return actor.mode === 'flop' ? actor : { ...actor, jump: JUMP_LIFTS.length + 1 }
156 case 'done': {
157 const idle = { ...actor, isBusy: false }
158
159 return actor.mode === 'run' || actor.mode === 'sit' ? enter(idle, 'home') : idle
160 }
161 }
162}
163
164function resync(actor: Actor, isWorking: boolean | undefined): Actor {
165 const isOff =
166 (actor.mode === 'run' && isWorking === false) || (actor.mode === 'flop' && isWorking === true)
167
168 if (!isOff) {
169 return actor.drift === 0 ? actor : { ...actor, drift: 0 }
170 }
171
172 const drift = actor.drift + 1
173
174 if (actor.mode === 'run' && drift >= DRIFT_TO_HOME) {
175 return enter({ ...actor, isBusy: false }, 'home')
176 }
177
178 if (actor.mode === 'flop' && drift >= DRIFT_TO_RUN) {
179 return enter({ ...actor, isBusy: true }, 'run')
180 }
181
182 return { ...actor, drift }
183}
184
185/**
186 * 【行為】過了一拍之後毛毛的新狀態。columns 是跑道寬度(像素),毛毛不會跑出去。
187 * isWorking 是「現在有沒有回合在跑」,不知道就給 undefined。
188 * 跑步六拍一跳,只在蹲下那一拍轉身:前面不夠再跳一次就掉頭。跑回原位時步伐加倍,
189 * 到了原位等腳著地才開始開心跳,同時冒驚嘆號。頭旁邊的符號每拍倒數,數完就消失。
190 * 在跑但連續 20 拍沒有回合在跑,會自己跑回原位;攤平但連續 5 拍有回合在跑,會自己開始跑。
191 * 這是為了回合被中斷、或模組在回合中途重新載入時不會卡在錯的狀態。
192 */
193export function tick(actor: Actor, columns: number, isWorking?: boolean): Actor {
194 const synced = resync(actor, isWorking)
195
196 if (synced.mode !== actor.mode) {
197 return synced
198 }
199
200 const next: Actor = {
201 ...synced,
202 ticks: synced.ticks + 1,
203 jump: Math.max(0, synced.jump - 1),
204 emote: synced.emoteTicks > 1 ? synced.emote : null,
205 emoteTicks: Math.max(0, synced.emoteTicks - 1),
206 }
207 const rightmost = Math.max(REST_X, columns - BOX)
208
209 switch (next.mode) {
210 case 'flop':
211 return { ...next, jump: 0 }
212
213 case 'sit':
214 return next.ticks >= SIT_TICKS ? enter(next, 'flop') : next
215
216 case 'run': {
217 const phase = next.ticks % HOP.length
218 const ahead = next.x + (next.isFacingLeft ? -HOP_REACH : HOP_REACH)
219 const isTurning = phase === 0 && (ahead < 0 || ahead > rightmost)
220 const isFacingLeft = isTurning ? !next.isFacingLeft : next.isFacingLeft
221 const step = HOP[phase]?.step ?? 0
222 const x = Math.min(rightmost, Math.max(0, next.x + (isFacingLeft ? -step : step)))
223
224 return { ...next, x, isFacingLeft }
225 }
226
227 case 'home': {
228 const hop = HOP[next.ticks % HOP.length]
229 const isFacingLeft = next.x === REST_X ? next.isFacingLeft : next.x > REST_X
230 const reach = Math.min(Math.abs(next.x - REST_X), (hop?.step ?? 0) * HOME_SPEED)
231 const x = next.x + (isFacingLeft ? -reach : reach)
232
233 if (x === REST_X && (hop?.lift ?? 0) === 0) {
234 return {
235 ...enter(next, 'binky'),
236 x,
237 isFacingLeft: false,
238 jump: 0,
239 emote: 'bang',
240 emoteTicks: CHEER_TICKS,
241 }
242 }
243
244 return { ...next, x, isFacingLeft }
245 }
246
247 case 'binky':
248 if (next.ticks < BINKY_LIFTS.length) {
249 return next
250 }
251
252 return enter(next, next.isBusy ? 'run' : 'sit')
253 }
254}
255
256/** 一個符號要用哪個字、什麼顏色、比頭頂那一列高幾列。 */
257type Mark = { glyph: string; color: number; rise: number }
258
259const EMOTE_MARKS: Readonly<Record<Emote, Mark>> = {
260 ask: { glyph: '?', color: GLYPH_COLORS.yellow, rise: 0 },
261 bang: { glyph: '!', color: GLYPH_COLORS.yellow, rise: 0 },
262 heart: { glyph: '♥', color: GLYPH_COLORS.red, rise: 0 },
263}
264
265const SNORE_MARKS: readonly (Mark | null)[] = [
266 { glyph: 'z', color: GLYPH_COLORS.blue, rise: 0 },
267 { glyph: 'Z', color: GLYPH_COLORS.blue, rise: 1 },
268 null,
269]
270
271function markOf(actor: Actor): Mark | null {
272 if (actor.emote !== null) {
273 return EMOTE_MARKS[actor.emote]
274 }
275
276 if (actor.mode !== 'flop' || actor.ticks < SNORE_AFTER) {
277 return null
278 }
279
280 const frame = Math.floor((actor.ticks - SNORE_AFTER) / SNORE_TICKS) % SNORE_MARKS.length
281
282 return SNORE_MARKS[frame] ?? null
283}
284
285/**
286 * 【行為】毛毛此刻該怎麼畫:用哪張圖、畫在哪、離地多高、臉朝哪、頭旁邊冒什麼符號。
287 * 攤平每 55 拍動兩拍耳朵;跑步與跑回原位六拍一跳、一拍一張圖;
288 * 工具跳躍期間一律用伸展那張圖並跳得比跑步高;
289 * 開心跳的第二下在空中轉身。
290 * 符號是單一個字元,固定放在毛毛所在方框右邊隔一欄的那一格,列數跟著頭頂走、不會超出跑道;
291 * 限時的符號(? ! ♥)優先,沒有的時候攤平滿一分鐘才輪到打呼的 z 與 Z。
292 */
293export function poseOf(actor: Actor): Pose {
294 const body = bodyOf(actor)
295 const mark = markOf(actor)
296
297 if (mark === null) {
298 return body
299 }
300
301 const head = Math.floor((TRACK_PIXELS - body.sprite.height - body.lift) / 2)
302 const row = Math.min(TRACK_ROWS - 1, Math.max(0, head - mark.rise))
303
304 return {
305 ...body,
306 bubble: { glyph: mark.glyph, color: mark.color, column: actor.x + BOX + 1, row },
307 }
308}
309
310function bodyOf(actor: Actor): Pose {
311 const { mode, x, ticks, isFacingLeft } = actor
312 const sitting: Pose = { sprite: SIT, x: x + 1, lift: 0, isFacingLeft }
313 const leaping = (lift: number, isLeft = isFacingLeft): Pose => ({
314 sprite: LEAP,
315 x,
316 lift,
317 isFacingLeft: isLeft,
318 })
319
320 if (mode === 'flop') {
321 const isTwitching = ticks % TWITCH_EVERY >= TWITCH_EVERY - TWITCH_TICKS
322
323 return { sprite: isTwitching ? FLOP_TWITCH : FLOP, x, lift: 0, isFacingLeft }
324 }
325
326 if (actor.jump > 0) {
327 return leaping(JUMP_LIFTS[JUMP_LIFTS.length - actor.jump] ?? 0)
328 }
329
330 if (mode === 'run' || mode === 'home') {
331 const hop = HOP[ticks % HOP.length]
332
333 return hop === undefined ? sitting : { sprite: hop.sprite, x, lift: hop.lift, isFacingLeft }
334 }
335
336 if (mode === 'binky') {
337 const lift = BINKY_LIFTS[ticks] ?? 0
338
339 return lift === 0 ? sitting : leaping(lift, ticks >= BINKY_LIFTS.length / 2)
340 }
341
342 return sitting
343}
344hooks/sprites.ts 299 lines1/**
2 * 【職責】毛毛的像素圖與「把一個姿勢畫成一條跑道」的純函式。不碰引擎、不記狀態;
3 * 什麼時候換哪個姿勢由 register.ts 決定。
4 * 【設計備註】一個終端機字元當上下兩個像素(半格字),所以跑道 5 列 = 10 個像素高。
5 * 圖都畫成臉朝右;朝左用鏡射。
6 * 跑步六格(CROUCH、PUSH、RISE、LEAP、TUCK、LAND)都是 16 像素寬,這樣換格時不會左右晃。
7 */
8
9/** 一張像素圖。rows 每個字串一列、每個字元一個像素,字元意義見 PALETTE,「.」是透明。 */
10export type Sprite = {
11 /** 圖寬,單位像素;等於 rows 每個字串的長度。 */
12 width: number
13 /** 圖高,單位像素;等於 rows 的長度。 */
14 height: number
15 /** 由上到下的每一列像素。 */
16 rows: readonly string[]
17}
18
19/** 毛毛此刻在跑道上的樣子。 */
20export type Pose = {
21 /** 用哪一張圖。 */
22 sprite: Sprite
23 /** 圖的左緣離跑道左緣幾個像素;超出跑道的部分直接不畫。 */
24 x: number
25 /** 離地幾個像素,0 是腳踩在地上。 */
26 lift: number
27 /** true 時把圖左右鏡射,變成臉朝左。 */
28 isFacingLeft: boolean
29 /** 頭旁邊冒出來的小符號;沒有就不給。 */
30 bubble?: Bubble
31}
32
33/**
34 * 毛毛頭旁邊冒出來的小符號(問號、驚嘆號、愛心、打呼的 z)。
35 * 它是一個真的字元、佔終端機的一格,不是像素拼的圖;所以只有 cellsOf 會畫它,pixelsOf 不會。
36 */
37export type Bubble = {
38 /** 要顯示的字元,只取第一個字;必須是終端機裡佔一格寬的字。 */
39 glyph: string
40 /** 字的顏色,0xRRGGBB。 */
41 color: number
42 /** 在跑道的第幾欄,0 是最左;超出跑道就不畫。 */
43 column: number
44 /** 在跑道的第幾列,0 是最上面那一列;超出跑道就不畫。 */
45 row: number
46}
47
48/** 小符號用的顏色:黃(問號、驚嘆號)、紅(愛心)、淺藍(打呼的 z)。 */
49export const GLYPH_COLORS = { yellow: 0xffd84a, red: 0xff5a7a, blue: 0x8fd3ff } as const
50
51/** 跑道高度,單位是終端機的列。 */
52export const TRACK_ROWS = 5
53
54/** 跑道高度,單位像素;每列兩個像素。 */
55export const TRACK_PIXELS = TRACK_ROWS * 2
56
57/** 透明像素在 pixelsOf 回傳值裡的記號。 */
58export const TRANSPARENT = -1
59
60/**
61 * 像素字元對顏色(0xRRGGBB)。W 白毛、S 白毛的陰影、K 黑毛、E 眼睛的反光、P 粉紅鼻頭。
62 * 黑毛刻意用深灰而不是純黑,深色背景的終端機才看得到。
63 */
64export const PALETTE: Readonly<Record<string, number>> = {
65 W: 0xf4f1ea,
66 S: 0xc4bfb6,
67 K: 0x55555f,
68 E: 0xffffff,
69 P: 0xf2a0b5,
70}
71
72function sprite(rows: readonly string[]): Sprite {
73 return { width: rows[0]?.length ?? 0, height: rows.length, rows }
74}
75
76/** 坐著:送出訊息時抬頭、開心跳落地時也用這張。 */
77export const SIT: Sprite = sprite([
78 '........WWWW..',
79 '.......KWWWWW.',
80 '..WWW.KKWKEWW.',
81 '.WKKKWKKWKKWP.',
82 'WWKKKWKKWWWWK.',
83 'WWWWWWWKWWWWW.',
84 '.WWWWWWWWWWW..',
85 '.SWWSSSSSWWS..',
86])
87
88/** 跑步第 1 格,蹲低蓄力:四腳著地、身體縮成一團。 */
89export const CROUCH: Sprite = sprite([
90 '.........WWWW...',
91 '..WWWW..KWWWWW..',
92 '.WWKKKWKKWKEWW..',
93 'WWWKKKWKKWKKWP..',
94 'WWWWWWWKKWWWWK..',
95 '.WWWWWWWWWWWW...',
96 '.SSWWW..SSW.....',
97])
98
99/** 跑步第 2 格,後腳蹬地:前半身抬起、後腳往後伸直還踩在地上。 */
100export const PUSH: Sprite = sprite([
101 '..........WWWW..',
102 '.........KWWWWW.',
103 '....WWW.KKWKEWW.',
104 '..WWKKKWKKWKKWP.',
105 '.WWWKKKWWWWWWWK.',
106 '.WWWWWWWWWWW....',
107 'WWWW.....SS.....',
108 'SS..............',
109])
110
111/** 跑步第 3 格,升空:四腳離地、垂耳開始往後飄。 */
112export const RISE: Sprite = sprite([
113 '..........WWWW..',
114 '.......KKKWWWWW.',
115 '...WWWW..WWKEWW.',
116 '.WWWKKKWWWWKKWP.',
117 'WWWWKKKWWWWWWWK.',
118 'WWWWWWWWWWWW....',
119 'SS.......SS.....',
120])
121
122/** 跑步第 4 格,伸展到最高:身體拉成一直線、前腳往前伸。工具跳躍與開心跳在空中也用這張。 */
123export const LEAP: Sprite = sprite([
124 '..........WWWW..',
125 '..WWWW.KKKWKEWW.',
126 '.WWKKKWWWWWKKWP.',
127 'WWWKKKWWWWWWWWK.',
128 'WWWWWWWWWWWWW...',
129 'SS..........SS..',
130])
131
132/** 跑步第 5 格,收腳下降:後腳收到肚子下面、垂耳被風帶得往上翹。 */
133export const TUCK: Sprite = sprite([
134 '.......K........',
135 '........KKWWWW..',
136 '..WWWW..KWWKEWW.',
137 '.WWKKKWWWWWKKWP.',
138 '.WWKKKWWWWWWWWK.',
139 '..WWWWWWWWWW....',
140 '....SS.....SS...',
141])
142
143/** 跑步第 6 格,前腳先著地:屁股翹高、後腳還在空中。 */
144export const LAND: Sprite = sprite([
145 '..WWWW..........',
146 '.WWKKKW...WWWW..',
147 'WWWKKKWW.KWWWWW.',
148 '.WWWWWWWKKWKEWW.',
149 '..WWWWWWKKWKKWP.',
150 '...SSWWWWWWWWWK.',
151 '..........WW....',
152 '..........SS....',
153])
154
155/** 攤平:等待時的日常姿勢,後腳往後伸、下巴貼地。 */
156export const FLOP: Sprite = sprite([
157 '...........WWWW..',
158 '...WWKKKWWKWKEWW.',
159 'WWWWWKKWWKKWKKWP.',
160 'SWWWWWWWWKKWWWWK.',
161])
162
163/** 攤平時動一下耳朵:跟 FLOP 只差耳朵抬起一格。 */
164export const FLOP_TWITCH: Sprite = sprite([
165 '.........K.WWWW..',
166 '...WWKKKWKKWKEWW.',
167 'WWWWWKKWWWKWKKWP.',
168 'SWWWWWWWWWWWWWWK.',
169])
170
171function stamp(
172 pixels: number[],
173 columns: number,
174 art: Sprite,
175 x: number,
176 top: number,
177 isMirrored: boolean,
178): void {
179 for (let row = 0; row < art.height; row += 1) {
180 const y = top + row
181
182 if (y < 0 || y >= TRACK_PIXELS) {
183 continue
184 }
185
186 for (let column = 0; column < art.width; column += 1) {
187 const source = isMirrored ? art.width - 1 - column : column
188 const color = PALETTE[art.rows[row]?.[source] ?? '.']
189 const at = x + column
190
191 if (color !== undefined && at >= 0 && at < columns) {
192 pixels[y * columns + at] = color
193 }
194 }
195 }
196}
197
198/**
199 * 【行為】把一個姿勢畫進 columns 寬、TRACK_PIXELS 高的跑道,回傳每個像素的顏色,
200 * 由左到右、由上到下排成一維陣列,長度是 columns * TRACK_PIXELS;透明是 TRANSPARENT。
201 * pose 給 null 回傳整條透明的跑道。圖超出跑道上下左右的部分不畫。
202 * 只畫毛毛本身;頭旁邊的小符號是字元不是像素,不在這裡。
203 * 【設計備註】獨立出來是為了預覽與測試:不必懂終端機的格子編碼就能檢查畫了什麼。
204 */
205export function pixelsOf(columns: number, pose: Pose | null): number[] {
206 const pixels = new Array<number>(columns * TRACK_PIXELS).fill(TRANSPARENT)
207
208 if (pose === null) {
209 return pixels
210 }
211
212 const { sprite: art, x, lift, isFacingLeft } = pose
213
214 stamp(pixels, columns, art, x, TRACK_PIXELS - art.height - lift, isFacingLeft)
215
216 return pixels
217}
218
219/** 終端機預設顏色(透明的那一半用它)。 */
220const DEFAULT_COLOR = 0x01000000
221const UPPER_HALF = 0x2580
222const LOWER_HALF = 0x2584
223const SPACE = 0x20
224
225/**
226 * 【行為】把一個姿勢編成 Raster 元件的 cells 字串:columns 寬、TRACK_ROWS 高,
227 * 每格是「字元、前景色、背景色」三個 32 位元數字,整串轉 base64。
228 * 上下兩個像素都透明的格子是空白,所以毛毛以外的地方會透出終端機原本的背景。
229 * 姿勢帶著小符號時,最後把那個字元寫進它指定的那一格,蓋掉那格原本的內容。
230 */
231export function cellsOf(columns: number, pose: Pose | null): string {
232 const pixels = pixelsOf(columns, pose)
233 const words = new Uint32Array(columns * TRACK_ROWS * 3)
234
235 for (let row = 0; row < TRACK_ROWS; row += 1) {
236 for (let column = 0; column < columns; column += 1) {
237 const upper = pixels[row * 2 * columns + column] ?? TRANSPARENT
238 const lower = pixels[(row * 2 + 1) * columns + column] ?? TRANSPARENT
239 const at = (row * columns + column) * 3
240
241 if (upper === TRANSPARENT && lower === TRANSPARENT) {
242 words.set([SPACE, DEFAULT_COLOR, DEFAULT_COLOR], at)
243 } else if (upper === TRANSPARENT) {
244 words.set([LOWER_HALF, lower, DEFAULT_COLOR], at)
245 } else if (lower === TRANSPARENT) {
246 words.set([UPPER_HALF, upper, DEFAULT_COLOR], at)
247 } else {
248 words.set([UPPER_HALF, upper, lower], at)
249 }
250 }
251 }
252
253 const bubble = pose?.bubble
254 const glyph = bubble?.glyph.codePointAt(0)
255
256 if (bubble !== undefined && glyph !== undefined) {
257 const { column, row } = bubble
258
259 if (column >= 0 && column < columns && row >= 0 && row < TRACK_ROWS) {
260 words.set([glyph, bubble.color, DEFAULT_COLOR], (row * columns + column) * 3)
261 }
262 }
263
264 return base64Of(new Uint8Array(words.buffer))
265}
266
267const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
268
269function base64Of(bytes: Uint8Array): string {
270 let text = ''
271
272 for (let at = 0; at < bytes.length; at += 3) {
273 const a = bytes[at] ?? 0
274 const b = bytes[at + 1] ?? 0
275 const c = bytes[at + 2] ?? 0
276 const hasB = at + 1 < bytes.length
277 const hasC = at + 2 < bytes.length
278
279 text += BASE64.charAt(a >> 2)
280 text += BASE64.charAt(((a & 3) << 4) | (b >> 4))
281 text += hasB ? BASE64.charAt(((b & 15) << 2) | (c >> 6)) : '='
282 text += hasC ? BASE64.charAt(c & 63) : '='
283 }
284
285 return text
286}
287
288// #region AI-NOTES
289// AI-NOTES:agent 專用備忘。當時為真、非契約、非指令;改到相關程式碼時重驗,錯了就刪。
290// 2026-10-02 base64 自己寫不用 Uint8Array.toBase64():官方範例用它,但官方建議的 tsconfig
291// (lib es2023)沒有這個方法的型別,tsc 會報錯。
292// 2026-10-02 白毛在淺色主題的終端機幾乎看不見(預覽圖驗過),主人用深色主題所以沒處理;
293// 要支援淺色得加外框或換底色。黑毛用 0x55555f 也是為了深色背景看得到。
294// 2026-10-02 跑道高度試過 6 列(12 像素、跳得更高),主人說佔空間,定案 5 列;別再主動加高。
295// 2026-10-02 小符號一開始是像素拼的(5×5 上下),細的形狀認不出來,主人提議改成單一字元。
296// 愛心 ♥(U+2665)在中文環境的終端機可能被畫成兩格寬而把畫面推歪,歪了就改成別的字。
297// 2026-10-02 改圖後一定要放大成 PNG 自己看、數耳朵眼睛(9/13 畫過三隻耳朵的毛毛)。
298// #endregion
299