SLOPSHOPPER

maomao

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

newbandguardcommandtoastprompt
v0.1.0MITupdated 2026-10-09jessetsai1024/claude-maomao
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · maomao
› fix the failing auth test and add an audit log call ╭────────────────────────────╮ │ maomao │ ⏺ Read(src/auth.ts) │ 毛毛回籠子休息了,再打一次 /maomao 叫他出來 │ ⎿ Read 6 lines ╰────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /maomao ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩
README

毛毛:8-bit 垂耳兔在輸入框上面

一個 Claude Code 的 mod。8-bit 的黑白荷蘭垂耳兔「毛毛」在輸入框上方:等你打字時攤平、Claude 工作時跑、用工具時跳。訊息裡叫「毛毛」或「乖毛」他會開心跳兩下。/maomao 打一次關、再打一次開。

English summary at the end.

tools/ 是改像素圖時自己看用的預覽工具(bun 加 python3),不影響 mod 本身。毛毛是 Jesse 家的兔子,七歲,右眼一圈黑、鼻頭一個黑點,畫的時候對照的是本尊。

需要什麼

  • Claude Code 2.1.287 以上(mod 功能 2026-10-01 起預設開放)。
  • 不用 Node、不用裝套件。mod 跑在 Claude Code 自己的引擎裡。

安裝

用 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,只拿掉捷徑。

注意

  • 不要同時用兩種方式載入同一個 mod(marketplace 裝了就不要再接捷徑;settings.json 的 env 裡也別再放 CLAUDE_CODE_PLUGIN_DIRS 指到它),會出現兩份。
  • Windows 還沒實機跑過。路徑處理有單元測試,但作者手邊沒有 Windows 機器。有問題請開 issue,附 Claude Code 版本和畫面。
  • 想改:直接改檔案,存檔後 Claude Code 會熱重載。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 授權。


English

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.

Source 3 files
hooks/register.ts 127 lines
1import 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
127
hooks/actor.ts 344 lines
1/**
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}
344
hooks/sprites.ts 299 lines
1/**
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