An orange-and-white cat chases a butterfly across seven rows above the prompt for as long as Claude works, bounding after it, leaping when it dips low, and…

Claude 在跑的時候,提示列上方多七列:一隻橘白貓在那裡追蝴蝶。蝴蝶亂飛,貓一路彈跳著追; 蝴蝶俯衝下來就跳起來撲,撲空就地滾成一團,爬起來再追。背後一排樹、腳邊一地草,貓往哪邊衝, 整片空地就往反方向滑過去。Claude 一答完,貓就收工。
(下圖是實際畫面的字元還原,一格一像素;真的跑起來是彩色的 —— 橘白貓、黃蝴蝶、綠樹綠草、褐色地面)
▒▒▒ ✦ ✦ ▒▒▒
▒▒▒ ▒▒▒▒▒ ✧ ▒▒▒▒▒
▒▒▒▒▒ ▒▒▒▒▒▒▒ ✦ ✦ ▒▒▒▒▒▒
▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒ ▒▒▒▒▒
▒▒▒▒▒ ▒▒▒▒▒ ┃
┃ ┃ █ █ ┃
┃ ██ ┃ ████ ┃
┃ ▓██████▓█●██ ┃
┃ ██░░░░██░░▪ ┃
┃ █ ┃░░ ██ ┃
┃ ▒ ▒ █ ▒ ┃ █ ▒ ┃
┃┃┃ ▒ ▒ ▒▒▒ ▒ ▒▒▒┃┃ ▒ ▒ ┃┃┃▒
━───────────────────────────━─────────━─━━━──━────────────
沒有分段,就是一直追。動作是連續的,由當下的時間直接算出來:
| 狀態 | 何時 | spinner |
|---|---|---|
bound | 平常,一路彈跳著追 | Chasing |
reach | 蝴蝶俯衝到構得到的高度時,跳起來揮前爪 | Pouncing |
tumble | 撲空落地,滾成一團 | Tumbling |
貓跑的是蝴蝶零點六個追逐秒前的路線,而且過彎會因為慣性衝過頭再拉回來 —— 這兩件事加起來才像在追, 而不是黏在蝴蝶下面。彈跳的高度跟當下速度成正比:跑得快就跳得高,停下來就只是小碎步。
Raster 目前只有 terminal surface 有)~/.claude/settings.json{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
}
claude plugin marketplace add /path/to/claude-maru-run
claude plugin install claude-maru-run@claude-maru-run
開發時不必安裝,直接掛目錄即可(存檔會熱重載):
claude --plugin-dir /path/to/claude-maru-run
/maru| 指令 | 作用 | ||
|---|---|---|---|
/maru | 顯示目前設定 | ||
/maru on \ | off | 開關這隻貓 | |
/maru slow \ | normal \ | fast | 整場追逐的快慢(×0.65 / ×1 / ×1.6) |
/maru delay <秒> | Claude 跑滿幾秒後貓才出現,0 表示立刻 | ||
/maru spinner on \ | off | 讓 spinner 的動詞跟著貓走 | |
/maru help | 指令一覽 |
設定存在 plugin 自己的 $.store,跨 session 保留。
整條 band 是一個 Raster 元素:每格帶自己的前景/背景色,內容是 base64 的 [codePoint, fg, bg] u32 三元組。每格畫 ▀,前景是上半個像素、背景是下半個,垂直解析度因此翻倍 —— 七列的 band 其實是 十四個像素高。貓佔其中六個像素,頭才跟背分得開:耳朵、眼睛、嘴各有自己的一列。
背景不是貼在那裡的。空地比 band 寬,樹跟草各自記在自己的世界座標上,畫的時候才換算成鏡頭當下看到的 位置;鏡頭跟著貓走三成,每層再依遠近打折 —— 腳邊的草和地上的碎石跟著鏡頭全速滑,遠處的樹只滑鏡頭的三成。 所以貓往右衝的時候,草刷地往左退,樹慢慢地挪,景深就出來了。哪裡長樹、哪裡長草是世界座標的雜湊決定的: 同一個位置永遠長同一叢,滑過去也看不出重複的花樣。
動畫不走 render pass —— turn.start 起一個 $.clock.every(40ms) 的計時器,每幀用 $.ui.blit 直接 重畫那個已掛載的 Raster,transcript 不會被洗版,重畫成本也不進 model context。只有換動作、要換 spinner 的字時才呼叫一次 $.ui.invalidate。
蝴蝶的路線是三個頻率合不起來的正弦疊加,加上每五個追逐秒一次的俯衝;貓的位置由蝴蝶的路線往回推 0.6 個追逐秒再加上慣性項。整串都是時間的純函式,所以每一幀都能從時鐘單獨算出來,也能單獨測。
上面的「追逐秒」不是真的秒:TEMPO 把整場的時鐘放慢到 0.62 倍(normal 下),所以一個追逐秒大約是 一秒六,俯衝差不多八秒來一次。放慢是等比的,追逐的形狀一點都沒變 —— 只有彈跳高度是照速度算的, 另外用 HOP_FROM_SPEED 補回來。
| 檔案 | 內容 |
|---|---|
hooks/register.tsx | hooks module:/maru、band、spinner、每幀的 blit |
hooks/cat/story.ts | 蝴蝶的飛行、貓的追逐與彈跳、撲與滾的時機 |
hooks/cat/frames.ts | 貓的三個側視姿勢:伸展、收腳、舉爪 |
hooks/cat/sprite.ts | 滾成一團時的球貓:以貓自己的座標系描述五官,任意角度取樣 |
hooks/cat/props.ts | 蝴蝶,以及空地的三層背景:樹、草、地面,各層的視差 |
hooks/cat/art.ts | 字串 sprite 的格式、繪製、左右鏡射 |
hooks/cat/pixels.ts | 像素緩衝、半格字元合成、Raster 的 base64 打包 |
hooks/cat/palette.ts | 所有顏色,以及 sprite 用的字母對照 |
hooks/cat/config.ts | 設定與 /maru 的參數解析 |
claude plugin test .
純函式(追逐、貓、背景分層、打包、設定)與引擎層(band 有沒有畫出 Raster、有沒有在 blit、spinner 有沒有被換字、 /maru off 有沒有生效)都有覆蓋。
概念取自 Mindful-Claude:用 function hooks 把等待時間變成 畫面。那邊是呼吸,這邊是一隻追不到蝴蝶的貓。
MIT
hooks/register.tsx 134 lines1/* @jsx h */
2import type { Register, Timer } from 'claude-code'
3import { applyCommand, DEFAULTS, readConfig, type Config } from './cat/config.ts'
4import { packCells } from './cat/pixels.ts'
5import { BAND_ROWS, MIN_ROWS, renderScene } from './cat/scene.ts'
6
7// The hooks module. While a turn runs it draws the band above the prompt as one Raster and
8// paints the lot into it with $.ui.blit, which repaints a mounted Raster without a render
9// pass, so the animation costs the transcript nothing. /maru changes the settings, kept in
10// $.store.
11
12const FRAME_MS = 40
13const RASTER_KEY = 'maru'
14
15let config: Config = DEFAULTS
16/** The running turn, from the moment the cat set off. */
17let turn: { startedAt: number } | undefined
18/** The band this plugin last drew, which is what blit may repaint. */
19let mount: { requestId: string; columns: number; rows: number } | undefined
20let frames: Timer | undefined
21/** The word the spinner reads, as the last frame left it. */
22let word: string | undefined
23
24const log = ($: { ui: { log: (text: string) => void } }, what: string) => (err: unknown) =>
25 $.ui.log(`claude-maru-run: ${what}: ${err}`)
26
27export const register: Register = on => {
28 /** The band's size for a render, or nothing when it has no room for the cat. */
29 const bandSize = (props: { bodyColumns: number; maxRows: number }): { columns: number; rows: number } | undefined => {
30 const columns = Math.min(512, Math.floor(props.bodyColumns))
31 const rows = Math.min(BAND_ROWS, Math.floor(props.maxRows))
32 return columns >= 16 && rows >= MIN_ROWS ? { columns, rows } : undefined
33 }
34
35 on('session.start', async ($, e, next) => {
36 const r = await next(e)
37 const saved = await $.store.get('config').catch(log($, 'store read failed'))
38 config = readConfig(saved)
39 await $.command.register({
40 name: 'maru',
41 description: 'An orange-and-white cat playing in a vacant lot above the prompt while Claude works: on, off, slow, normal, fast, delay, spinner (claude-maru-run)',
42 argumentHint: '[on | off | slow | normal | fast | delay <s> | spinner on|off | help]',
43 immediate: true,
44 }).catch(log($, '/maru not registered'))
45 return r
46 })
47
48 on('command.run', { command: 'maru' }, async ($, e) => {
49 const applied = applyCommand(config, e.args)
50 if (applied.config !== config) {
51 config = applied.config
52 await $.store.set('config', config).catch(log($, 'store write failed'))
53 $.ui.invalidate('ui.render')
54 }
55 return { text: applied.text }
56 })
57
58 on('turn.start', async ($, e, next) => {
59 turn = { startedAt: await $.clock.now() }
60 word = undefined
61 frames?.cancel()
62 // every frame after this one is a blit into the mounted Raster: no redraw, no transcript
63 frames = $.clock.every(FRAME_MS, () => {
64 const at = turn
65 const band = mount
66 if (!at || !band || !config.enabled) return
67 $.clock.now().then(now => {
68 const elapsedMs = now - at.startedAt - config.delay * 1000
69 if (elapsedMs < 0) return
70 const { requestId, columns, rows } = band
71 const scene = renderScene({ columns, rows, elapsedMs, speed: config.speed })
72 $.ui.blit({ requestId, key: RASTER_KEY, columns, rows, cells: packCells(scene.cells) })
73 .catch(log($, 'blit failed'))
74 if (scene.word !== word) {
75 word = scene.word
76 if (config.spinner) $.ui.invalidate('ui.render')
77 }
78 }).catch(log($, 'frame failed'))
79 })
80 // the band is drawn from the delay on: wake the render hook when it has passed
81 if (config.delay > 0) $.clock.after(config.delay * 1000, () => $.ui.invalidate('ui.render'))
82 $.ui.invalidate('ui.render')
83 return next(e)
84 })
85
86 on('turn.complete', async ($, e, next) => {
87 const r = await next(e)
88 frames?.cancel()
89 frames = undefined
90 turn = undefined
91 mount = undefined
92 word = undefined
93 $.ui.invalidate('ui.render')
94 return r
95 })
96
97 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
98 // a Raster is the terminal's alone, and a survey keeps the band
99 if (e.surface !== 'terminal' || !config.enabled || !e.props.isWorking || e.props.hasSurvey) {
100 mount = undefined
101 return next(e)
102 }
103 const band = bandSize(e.props)
104 if (!band) {
105 mount = undefined
106 return next(e)
107 }
108 const now = await $.clock.now()
109 // a turn this module saw start, or one already running when it loaded
110 const at = turn ?? (turn = { startedAt: now })
111 const elapsedMs = now - at.startedAt - config.delay * 1000
112 if (elapsedMs < 0) {
113 mount = undefined
114 return next(e)
115 }
116 const scene = renderScene({ columns: band.columns, rows: band.rows, elapsedMs, speed: config.speed })
117 word = scene.word
118 mount = { requestId: e.requestId, ...band }
119 const { Box, Raster } = $.ui.resolve(e)
120 return (
121 <Box flexDirection="column">
122 <Raster key={RASTER_KEY} columns={band.columns} rows={band.rows} cells={packCells(scene.cells)} />
123 {await next(e)}
124 </Box>
125 )
126 })
127
128 // the spinner's word becomes whatever the cat is up to: `✻ Pouncing… (12s)`
129 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
130 if (!config.enabled || !config.spinner || !turn || !word) return next(e)
131 return next({ ...e, props: { ...e.props, word } })
132 })
133}
134hooks/cat/config.ts 78 lines1// The plugin's settings and the /maru command that changes them. Pure: `claude plugin test`
2// covers it.
3
4import { isSpeed, rateOf, SPEEDS, type Speed } from './story.ts'
5
6export type Config = {
7 enabled: boolean
8 speed: Speed
9 /** Seconds Claude works before the cat appears; 0 shows it at once. */
10 delay: number
11 /** Whether the spinner's word says what the cat is doing. */
12 spinner: boolean
13}
14
15export const DEFAULTS: Config = { enabled: true, speed: 'normal', delay: 0, spinner: true }
16
17/** A config from what the store held, field by field, defaults for the rest. */
18export function readConfig(saved: unknown): Config {
19 const s = (typeof saved === 'object' && saved !== null ? saved : {}) as Record<string, unknown>
20 return {
21 enabled: typeof s.enabled === 'boolean' ? s.enabled : DEFAULTS.enabled,
22 speed: isSpeed(s.speed) ? s.speed : DEFAULTS.speed,
23 delay: typeof s.delay === 'number' && s.delay >= 0 && Number.isFinite(s.delay) ? s.delay : DEFAULTS.delay,
24 spinner: typeof s.spinner === 'boolean' ? s.spinner : DEFAULTS.spinner,
25 }
26}
27
28export function statusLine(c: Config): string {
29 return `maru: ${c.enabled ? 'on' : 'off'} · ${c.speed} (\u00d7${rateOf(c.speed)}) · delay ${c.delay}s · spinner ${c.spinner ? 'on' : 'off'}`
30}
31
32export const HELP = [
33 '/maru status',
34 '/maru on | off show or hide the cat while Claude works',
35 `/maru ${SPEEDS.join(' | ')} how fast the chase runs`,
36 '/maru delay <s> seconds Claude works before the cat appears (0 = at once)',
37 '/maru spinner on|off let the spinner say what the cat is doing',
38].join('\n')
39
40/** Applies one `/maru` invocation; returns the new config and the transcript line. */
41export function applyCommand(config: Config, args: string): { config: Config; text: string } {
42 const words = args.trim().toLowerCase().split(/\s+/).filter(Boolean)
43 const [head, arg] = words
44 if (!head || head === 'status') return { config, text: statusLine(config) }
45 if (head === 'help') return { config, text: HELP }
46 if (head === 'on' || head === 'off') {
47 const next: Config = { ...config, enabled: head === 'on' }
48 return { config: next, text: statusLine(next) }
49 }
50 if (isSpeed(head)) {
51 const next: Config = { ...config, speed: head }
52 return { config: next, text: statusLine(next) }
53 }
54 if (head === 'speed') {
55 if (isSpeed(arg)) {
56 const next: Config = { ...config, speed: arg }
57 return { config: next, text: statusLine(next) }
58 }
59 return { config, text: `maru: speed is one of ${SPEEDS.join(', ')}` }
60 }
61 if (head === 'delay') {
62 const delay = Number(arg)
63 if (arg !== undefined && Number.isFinite(delay) && delay >= 0) {
64 const next: Config = { ...config, delay }
65 return { config: next, text: statusLine(next) }
66 }
67 return { config, text: 'maru: delay takes a number of seconds, 0 or more' }
68 }
69 if (head === 'spinner') {
70 if (arg === 'on' || arg === 'off') {
71 const next: Config = { ...config, spinner: arg === 'on' }
72 return { config: next, text: statusLine(next) }
73 }
74 return { config, text: 'maru: spinner on or off' }
75 }
76 return { config, text: `maru: no setting called "${head}"\n${HELP}` }
77}
78hooks/cat/pixels.ts 92 lines1// The band as pixels and then as Raster cells. Two pixels share one terminal cell: the cell
2// draws `▀`, its foreground the upper pixel and its background the lower one, so the picture
3// has twice the rows the band does and a pixel is about square. Pure: `claude plugin test`
4// covers it.
5
6import { DEFAULT_COLOR, TRANSPARENT } from './palette.ts'
7
8/** A picture, `width` by `height` pixels, each a colour or TRANSPARENT. */
9export type Pixels = {
10 readonly width: number
11 readonly height: number
12 readonly data: Int32Array
13}
14
15export const createPixels = (width: number, height: number): Pixels => ({
16 width,
17 height,
18 data: new Int32Array(width * height).fill(TRANSPARENT),
19})
20
21export function setPixel(px: Pixels, x: number, y: number, color: number): void {
22 if (x < 0 || y < 0 || x >= px.width || y >= px.height) return
23 px.data[y * px.width + x] = color
24}
25
26export function getPixel(px: Pixels, x: number, y: number): number {
27 if (x < 0 || y < 0 || x >= px.width || y >= px.height) return TRANSPARENT
28 return px.data[y * px.width + x] ?? TRANSPARENT
29}
30
31/** One Raster cell: a printable width-1 character and its two colours. */
32export type Cell = { ch: string; fg: number; bg: number }
33
34export const blankCell = (): Cell => ({ ch: ' ', fg: DEFAULT_COLOR, bg: DEFAULT_COLOR })
35
36const UPPER = '▀' // ▀
37const LOWER = '▄' // ▄
38
39/**
40 * The cell two stacked pixels make: `▀` when both are drawn, the single half block when one
41 * is, a space when neither. `height` must be even; row `r` of cells takes pixel rows `2r`
42 * and `2r+1`.
43 */
44export function cellsFromPixels(px: Pixels): Cell[] {
45 const rows = px.height >> 1
46 const cells: Cell[] = []
47 for (let r = 0; r < rows; r++) {
48 for (let x = 0; x < px.width; x++) {
49 const top = getPixel(px, x, r * 2)
50 const bottom = getPixel(px, x, r * 2 + 1)
51 if (top === TRANSPARENT && bottom === TRANSPARENT) cells.push(blankCell())
52 else if (bottom === TRANSPARENT) cells.push({ ch: UPPER, fg: top, bg: DEFAULT_COLOR })
53 else if (top === TRANSPARENT) cells.push({ ch: LOWER, fg: bottom, bg: DEFAULT_COLOR })
54 else cells.push({ ch: UPPER, fg: top, bg: bottom })
55 }
56 }
57 return cells
58}
59
60const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
61
62/** Standard padded base64 of `bytes`; the environment has no Buffer and no atob. */
63export function toBase64(bytes: Uint8Array): string {
64 let out = ''
65 for (let i = 0; i < bytes.length; i += 3) {
66 const a = bytes[i] ?? 0
67 const b = bytes[i + 1] ?? 0
68 const c = bytes[i + 2] ?? 0
69 const left = bytes.length - i
70 out += B64[a >> 2]
71 out += B64[((a & 3) << 4) | (b >> 4)]
72 out += left > 1 ? B64[((b & 15) << 2) | (c >> 6)] : '='
73 out += left > 2 ? B64[c & 63] : '='
74 }
75 return out
76}
77
78/**
79 * The `cells` a Raster takes: little-endian u32 triplets `[codePoint, foreground, background]`,
80 * row-major, in base64. A character outside the BMP, or wider than one cell, is refused by the
81 * surface, so only the characters this plugin draws reach it.
82 */
83export function packCells(cells: readonly Cell[]): string {
84 const words = new Uint32Array(cells.length * 3)
85 cells.forEach((cell, i) => {
86 words[i * 3] = cell.ch.codePointAt(0) ?? 0x20
87 words[i * 3 + 1] = cell.fg
88 words[i * 3 + 2] = cell.bg
89 })
90 return toBase64(new Uint8Array(words.buffer))
91}
92hooks/cat/scene.ts 67 lines1// The band, frame by frame: the lot the chase runs through, the butterfly, and the cat after
2// it. The eye follows the cat part of the way, so the trees and the grass slide past it. The
3// whole band is one Raster, so a frame is one `$.ui.blit` and nothing else redraws. Pure:
4// `claude plugin test` covers it.
5
6import { drawSprite } from './art.ts'
7import { CAT_WIDTH, GATHER, LEAP, REACH } from './frames.ts'
8import { cellsFromPixels, createPixels, type Cell, type Pixels } from './pixels.ts'
9import { BUTTERFLY, drawGrass, drawGround, drawTrees } from './props.ts'
10import { drawCat as drawCurledCat } from './sprite.ts'
11import { chaseAt, stageFor, wordFor, type Chase, type Speed } from './story.ts'
12
13/** Rows the band takes: all of them are picture. */
14export const BAND_ROWS = 7
15/** The fewest rows worth drawing the chase in: below this the cat loses its ears. */
16export const MIN_ROWS = 4
17/** The curled cat's radius while it tumbles. */
18const TUMBLE_RADIUS = 2.6
19/** How much of the cat's run the eye follows; the lot slides the rest of the way past it. */
20const CAMERA_FOLLOW = 0.3
21
22function drawCat(px: Pixels, chase: Chase, groundY: number): void {
23 if (chase.act === 'tumble') {
24 drawCurledCat(px, chase.x + CAT_WIDTH / 2, groundY - 1 - TUMBLE_RADIUS, TUMBLE_RADIUS, chase.angle)
25 return
26 }
27 const sprite = chase.act === 'reach' ? REACH : chase.airborne ? LEAP : GATHER
28 drawSprite(px, sprite, chase.x, chase.y, { flip: chase.facing === -1 })
29}
30
31export type SceneInput = {
32 /** The band's width in cells (`e.props.bodyColumns`). */
33 columns: number
34 /** The band's height in cells; a short band keeps the ground and loses the sky. */
35 rows: number
36 /** How long the turn has been running. */
37 elapsedMs: number
38 speed: Speed
39}
40
41export type Scene = {
42 /** Every cell of the band, row-major, `columns * rows` of them. */
43 cells: Cell[]
44 /** The word for the spinner this frame. */
45 word: string
46}
47
48/** The band as it looks `elapsedMs` into the turn. */
49export function renderScene(input: SceneInput): Scene {
50 const columns = Math.max(1, Math.min(512, Math.floor(input.columns)))
51 const rows = Math.max(1, Math.min(256, Math.floor(input.rows)))
52 const px = createPixels(columns, rows * 2)
53 const stage = stageFor(px.width, px.height)
54 const chase = chaseAt(input.elapsedMs, stage, input.speed)
55
56 const camera = chase.x * CAMERA_FOLLOW
57
58 drawTrees(px, stage.groundY, camera)
59 drawGround(px, stage.groundY, camera)
60 drawGrass(px, stage.groundY, camera)
61 drawCat(px, chase, stage.groundY)
62 // the butterfly stays in front: the cat never gets between it and the eye
63 drawSprite(px, BUTTERFLY[chase.butterfly.frame] ?? BUTTERFLY[0]!, chase.butterfly.x, chase.butterfly.y)
64
65 return { cells: cellsFromPixels(px), word: wordFor(chase.act) }
66}
67hooks/cat/story.ts 192 lines1// The chase: where the butterfly is and where the cat is, at any moment. Both are pure
2// functions of the time, so any frame can be drawn from the clock alone and every part of it
3// can be tested on its own.
4//
5// The butterfly wanders the band on two sines that never line up, and swoops low every few
6// seconds. The cat runs the butterfly's own path a moment behind it — which is what chasing
7// looks like — bounding as it goes, and when the butterfly comes down within reach it leaps,
8// misses, and tumbles.
9
10import { CAT_HEIGHT, CAT_WIDTH } from './frames.ts'
11import { BUTTERFLY_HEIGHT, BUTTERFLY_WIDTH } from './props.ts'
12
13export type Speed = 'slow' | 'normal' | 'fast'
14
15export const SPEEDS: readonly Speed[] = ['slow', 'normal', 'fast']
16
17export const isSpeed = (value: unknown): value is Speed =>
18 typeof value === 'string' && (SPEEDS as readonly string[]).includes(value)
19
20/** How fast the whole chase runs; everything else is timed off it. */
21const RATE: Record<Speed, number> = { slow: 0.65, normal: 1, fast: 1.6 }
22
23export const rateOf = (speed: Speed): number => RATE[speed]
24
25/**
26 * The pace of the whole thing at `normal`. Every part of the chase is a function of `t`, so
27 * this stretches the lot evenly — the same chase, at a pace the eye can follow rather than one
28 * that reads as fast forward.
29 */
30const TEMPO = 0.62
31
32/** How often the butterfly comes down within reach, in seconds of chase time. */
33const SWOOP_PERIOD = 5
34/** One bound of the cat, and how high the fastest bound takes it. */
35const HOP_PERIOD = 0.34
36const HOP_LOW = 0.7
37const HOP_HIGH = 2.4
38/** How much of its speed a bound is worth: a slower chase would bound lower without it. */
39const HOP_FROM_SPEED = 0.1
40/** Shapes the bound: above one, the arc is sharper and the landing is a real beat. */
41const HOP_SHAPE = 1.7
42/** How high the leap at the butterfly goes: just enough for its paws to reach. */
43const LEAP_HEIGHT = 3
44/** How far behind the butterfly the cat runs, and how far behind while it closes in. */
45const LAG_FAR = 0.6
46const LAG_NEAR = 0.12
47/** How far the cat carries past a turn before it can pull round: what makes it a chase. */
48const OVERSHOOT = 0.22
49/** The most it ever carries, in pixels, so a wide band does not fling it into the edge. */
50const OVERSHOOT_MAX = 6
51/** Where in a swoop the cat leaps, and where it lands in a heap. */
52const REACH_FROM = 0.17
53const REACH_TO = 0.28
54const TUMBLE_TO = 0.36
55
56/** What the cat is doing; each one draws a different pose. */
57export type Act = 'bound' | 'reach' | 'tumble'
58
59export type Stage = {
60 width: number
61 height: number
62 /** The row the ground is drawn on; the cat stands on top of it. */
63 groundY: number
64}
65
66export const stageFor = (width: number, height: number): Stage => ({
67 width,
68 height,
69 groundY: height - 1,
70})
71
72const clamp = (value: number, low: number, high: number): number =>
73 Math.max(low, Math.min(high, value))
74
75/** Smooth 0 to 1 across `[from, to]`, flat outside it. */
76function ramp(value: number, from: number, to: number): number {
77 const t = clamp((value - from) / (to - from), 0, 1)
78 return t * t * (3 - 2 * t)
79}
80
81/** How far into the current swoop, 0 to 1: it comes down at a quarter and climbs away after. */
82export const swoopPhase = (t: number): number => ((t / SWOOP_PERIOD) % 1 + 1) % 1
83
84/** The butterfly at `t` seconds: its top left corner, and how low the swoop has brought it. */
85export function butterflyAt(t: number, stage: Stage): { x: number; y: number; swoop: number; frame: number } {
86 const span = Math.max(1, (stage.width - BUTTERFLY_WIDTH) / 2 - 2)
87 const drift = 0.55 * Math.sin(t * 0.82) + 0.3 * Math.sin(t * 1.73 + 0.8) + 0.15 * Math.sin(t * 3.4 + 2.1)
88 const swoop = Math.max(0, Math.sin(swoopPhase(t) * Math.PI * 2)) ** 3
89 // low enough for the cat to have a go at it, never so low it lands
90 const low = stage.groundY - CAT_HEIGHT - BUTTERFLY_HEIGHT + 1
91 const y = low * swoop + Math.sin(t * 5.1) * 0.7
92 return {
93 x: clamp(stage.width / 2 + span * drift, 0, Math.max(0, stage.width - BUTTERFLY_WIDTH)),
94 y: clamp(y, 0, Math.max(0, stage.groundY - BUTTERFLY_HEIGHT)),
95 swoop,
96 frame: Math.floor(t * 9) % 2,
97 }
98}
99
100/** Where the cat would be at `t`: on the butterfly's path, a moment behind it. */
101function catX(t: number, stage: Stage): number {
102 const phase = swoopPhase(t)
103 // it closes the gap as the butterfly comes down, and drops back once it has gone
104 const near = ramp(phase, REACH_FROM - 0.09, REACH_FROM) * (1 - ramp(phase, TUMBLE_TO, TUMBLE_TO + 0.1))
105 const lag = LAG_FAR + (LAG_NEAR - LAG_FAR) * near
106 const ahead = butterflyAt(t - lag, stage).x
107 // the cat carries its own speed past every turn the butterfly makes, then has to come back
108 const carry = clamp((ahead - butterflyAt(t - lag - 0.1, stage).x) / 0.1 * OVERSHOOT, -OVERSHOOT_MAX, OVERSHOOT_MAX)
109 return clamp(ahead + carry - (CAT_WIDTH - BUTTERFLY_WIDTH) / 2, 0, Math.max(0, stage.width - CAT_WIDTH))
110}
111
112export type Chase = {
113 act: Act
114 /** The top left of the cat's sprite. */
115 x: number
116 y: number
117 facing: 1 | -1
118 /** True while the cat is off the ground: the stretched frame is drawn then. */
119 airborne: boolean
120 /** How far the cat has rolled, for the tumble. */
121 angle: number
122 butterfly: { x: number; y: number; frame: number }
123 /** How many swoops have gone by. */
124 lap: number
125}
126
127/** The whole picture at `elapsedMs` into the turn. */
128export function chaseAt(elapsedMs: number, stage: Stage, speed: Speed): Chase {
129 const t = Math.max(0, elapsedMs) / 1000 * rateOf(speed) * TEMPO
130 const phase = swoopPhase(t)
131 const butterfly = butterflyAt(t, stage)
132 const x = catX(t, stage)
133 const was = catX(t - 0.14, stage)
134 const speedX = (x - was) / 0.14
135 // going somewhere, it faces the way it is going; slowing, or swiping, it faces the butterfly
136 const towards: 1 | -1 = butterfly.x + BUTTERFLY_WIDTH / 2 >= x + CAT_WIDTH / 2 ? 1 : -1
137 const facing: 1 | -1 = Math.abs(speedX) < 0.6 ? towards : speedX > 0 ? 1 : -1
138 const standY = stage.groundY - CAT_HEIGHT
139
140 if (phase >= REACH_FROM && phase < REACH_TO) {
141 // the leap: one arc that takes the cat up under the butterfly
142 const through = (phase - REACH_FROM) / (REACH_TO - REACH_FROM)
143 return {
144 act: 'reach',
145 x,
146 y: standY - Math.sin(Math.PI * through) * LEAP_HEIGHT,
147 facing: towards,
148 airborne: true,
149 angle: 0,
150 butterfly,
151 lap: Math.floor(t / SWOOP_PERIOD),
152 }
153 }
154
155 if (phase >= REACH_TO && phase < TUMBLE_TO) {
156 const through = (phase - REACH_TO) / (TUMBLE_TO - REACH_TO)
157 return {
158 act: 'tumble',
159 x,
160 y: standY,
161 facing,
162 airborne: false,
163 angle: -facing * through * 7,
164 butterfly,
165 lap: Math.floor(t / SWOOP_PERIOD),
166 }
167 }
168
169 // bounding after it: the faster it runs, the higher it bounds
170 const height = HOP_LOW + Math.min(HOP_HIGH - HOP_LOW, Math.abs(speedX) * HOP_FROM_SPEED)
171 const hop = height * Math.abs(Math.sin((Math.PI * t) / HOP_PERIOD)) ** HOP_SHAPE
172 return {
173 act: 'bound',
174 x,
175 y: standY - hop,
176 facing,
177 airborne: hop > height * 0.45,
178 angle: 0,
179 butterfly,
180 lap: Math.floor(t / SWOOP_PERIOD),
181 }
182}
183
184/** The word the spinner reads while the cat is at it. */
185const WORDS: Record<Act, string> = {
186 bound: 'Chasing',
187 reach: 'Pouncing',
188 tumble: 'Tumbling',
189}
190
191export const wordFor = (act: Act): string => WORDS[act]
192hooks/cat/palette.ts 52 lines1// Everything the band is drawn in, as a Raster takes it: 0x00RRGGBB, or DEFAULT for the
2// terminal's own. The alphabet is what the sprites in art are written with.
3
4export const DEFAULT_COLOR = 0x01000000
5
6/** Nothing drawn here: the pixel keeps the terminal's background. */
7export const TRANSPARENT = -1
8
9// the cat
10export const FUR = 0xe8853c
11export const FUR_STRIPE = 0xc2661f
12export const WHITE = 0xf6f1e8
13export const EYE = 0x2b2018
14export const PINK = 0xe5a0a4
15
16// the vacant lot
17export const GROUND = 0x6b5a45
18export const GROUND_DARK = 0x4a3d2e
19export const TRUNK = 0x7a5533
20export const TRUNK_DARK = 0x573a22
21export const LEAF = 0x5c9a46
22export const LEAF_DARK = 0x3d6f31
23export const GRASS = 0x7fb35a
24export const CONCRETE = 0xb9b2a6
25export const CONCRETE_DARK = 0x8c857a
26export const PIPE_HOLE = 0x2f2b26
27
28// the butterfly
29export const WING = 0xf2d05a
30export const WING_DARK = 0xc99a2e
31
32/** The letters a sprite is written with, and the colour each one paints. */
33export const INK: Readonly<Record<string, number>> = {
34 o: FUR,
35 '#': FUR_STRIPE,
36 w: WHITE,
37 e: EYE,
38 n: PINK,
39 t: TRUNK,
40 T: TRUNK_DARK,
41 g: LEAF,
42 G: LEAF_DARK,
43 s: GRASS,
44 c: CONCRETE,
45 C: CONCRETE_DARK,
46 h: PIPE_HOLE,
47 d: GROUND,
48 D: GROUND_DARK,
49 b: WING,
50 B: WING_DARK,
51}
52hooks/cat/art.ts 33 lines1// Sprites as text: a picture is rows of letters from the palette's INK, `.` for nothing, and
2// the drawing puts one letter in one pixel. Frames are written facing right and mirrored for
3// the walk back. Pure: `claude plugin test` covers it.
4
5import { INK, TRANSPARENT } from './palette.ts'
6import { setPixel, type Pixels } from './pixels.ts'
7
8/** A picture, one string a row; every row of one sprite is the same width. */
9export type Sprite = readonly string[]
10
11export const spriteWidth = (sprite: Sprite): number => Math.max(0, ...sprite.map(row => row.length))
12
13/** The colour a letter paints, or TRANSPARENT for `.` and anything unwritten. */
14export const inkOf = (letter: string): number => INK[letter] ?? TRANSPARENT
15
16export type DrawOptions = {
17 /** Draws the sprite mirrored, for a cat walking the other way. */
18 flip?: boolean
19}
20
21/** Draws `sprite` with its top left corner at (`x`, `y`), one letter to a pixel. */
22export function drawSprite(px: Pixels, sprite: Sprite, x: number, y: number, options: DrawOptions = {}): void {
23 const width = spriteWidth(sprite)
24 sprite.forEach((row, dy) => {
25 for (let dx = 0; dx < row.length; dx++) {
26 const color = inkOf(row[dx] ?? '.')
27 if (color === TRANSPARENT) continue
28 setPixel(px, Math.round(x) + (options.flip ? width - 1 - dx : dx), Math.round(y) + dy, color)
29 }
30 })
31}
32
33hooks/cat/frames.ts 41 lines1// The cat, pose by pose, side on and facing right: the chase the other way mirrors them.
2// Letters are the palette's INK, `.` is nothing. Every row of one frame is the same width.
3//
4// Six pixels tall, which is one more than the head needs to come off the back: the ears, the
5// eye and the muzzle each get their own row now, and the legs get one to themselves.
6
7import type { Sprite } from './art.ts'
8
9export const CAT_WIDTH = 13
10export const CAT_HEIGHT = 6
11
12/** Mid-bound, off the ground: the body reaches and the legs stream out behind. */
13export const LEAP: Sprite = [
14 '.........o.o.',
15 'oo.......oooo',
16 '.#oooooo#oeoo',
17 '..oowwwwoowwn',
18 '.o...ww...oo.',
19 'o..........o.',
20]
21
22/** Coming down, or gathering for the next bound: the legs fold under the body. */
23export const GATHER: Sprite = [
24 '.........o.o.',
25 'o........oooo',
26 '.o#oooo#ooeoo',
27 '..ooowwwoowwn',
28 '...oowwwwoo..',
29 '...oo....oo..',
30]
31
32/** Stretched up mid-leap, front paws swiping over its head; the ears go flat. */
33export const REACH: Sprite = [
34 '..........ww.',
35 '........woooo',
36 '..o..oooooeon',
37 '.#ooooooowww.',
38 '..ooo...oo...',
39 '..oo.....o...',
40]
41hooks/cat/props.ts 175 lines1// What the cat chases, and the lot it chases through: the trees behind it, the grass underfoot
2// and the ground itself. The lot is a world wider than the band — everything in it is placed by
3// its world position and drawn where the eye currently is, so it slides past as the cat runs.
4// How far a layer slides is how near it is: the trees barely move, the grass keeps up.
5
6import { drawSprite, spriteWidth, type Sprite } from './art.ts'
7import type { Pixels } from './pixels.ts'
8
9/** The butterfly, wings up and wings out; it never stops flapping. */
10export const BUTTERFLY: readonly Sprite[] = [
11 [
12 'b.b',
13 '.B.',
14 'b.b',
15 ],
16 [
17 '...',
18 'bBb',
19 '.b.',
20 ],
21]
22
23export const BUTTERFLY_WIDTH = 3
24export const BUTTERFLY_HEIGHT = 3
25
26/** How far each layer slides for one pixel the eye moves: 1 is underfoot, 0 is the horizon. */
27const TREE_PARALLAX = 0.3
28const GROUND_PARALLAX = 1
29
30/** A tree: the crown, and which of its columns the trunk comes down from. */
31type Tree = { crown: Sprite; trunkAt: number }
32
33/**
34 * The crowns along the back of the lot: four of them, none the same width or shade. The flat
35 * dark one reads as furthest off. Each one gets its own trunk length, so two of the same crown
36 * are still two different trees.
37 */
38const TREES: readonly Tree[] = [
39 {
40 crown: [
41 '...GGG...',
42 '..GgggG..',
43 '.GgggggG.',
44 'GggggggGG',
45 '.GgggggG.',
46 '..GgggG..',
47 ],
48 trunkAt: 4,
49 },
50 {
51 crown: [
52 '..GGG..',
53 '.GgggG.',
54 'GgggggG',
55 '.GgggG.',
56 ],
57 trunkAt: 3,
58 },
59 {
60 crown: [
61 '.GGG.',
62 'GGGGG',
63 'GGGGG',
64 '.GGG.',
65 ],
66 trunkAt: 2,
67 },
68 {
69 crown: [
70 '..GGG...',
71 '.GgggG..',
72 'GgggggG.',
73 '.GggggGG',
74 '..GgggG.',
75 ],
76 trunkAt: 3,
77 },
78]
79
80/** How far apart the trees stand on average, and how far off that beat each one may stand. */
81const TREE_SPACING = 24
82const TREE_WANDER = 11
83/** How tall a tree stands altogether: every one of them carries its crown clear of the cat. */
84const TREE_LOW = 10
85const TREE_HIGH = 13
86
87/** Tufts of grass, from bare ground through a blade or two to a clump. */
88const TUFTS: readonly Sprite[] = [
89 ['.'],
90 ['.'],
91 ['s'],
92 ['g'],
93 ['s', 'g'],
94 ['g', 'G'],
95 ['.s.', 'GsG'],
96 ['s.s', '.g.'],
97]
98
99/** How thick the grass grows, before each tuft is nudged off the beat. */
100const TUFT_SPACING = 5
101
102/** The widest of a layer's sprites: how far off the left edge one can start and still show. */
103const widest = (sprites: readonly Sprite[]): number => Math.max(...sprites.map(spriteWidth))
104
105/**
106 * A settled scatter, 0 to 1: the same place in the world always grows the same thing, but the
107 * lot never falls into a pattern the eye can pick up as it slides by.
108 */
109function scatter(i: number): number {
110 let h = Math.imul(i ^ 0x9e3779b9, 0x85ebca6b)
111 h ^= h >>> 13
112 h = Math.imul(h, 0xc2b2ae35)
113 return (h >>> 0) / 0x1_0000_0000
114}
115
116/** One of `choices`, settled by the scatter at `i`. */
117const pick = <T>(choices: readonly T[], i: number): T =>
118 choices[Math.min(choices.length - 1, Math.floor(scatter(i) * choices.length))]!
119
120/**
121 * Walks the world positions of one layer that fall on the band, `i` counting from the
122 * leftmost. `shift` is how far the world has slid under the eye at this layer's depth.
123 */
124function alongTheBand(
125 px: Pixels,
126 spacing: number,
127 shift: number,
128 widest: number,
129 place: (i: number, x: number) => void,
130): void {
131 const first = Math.floor((shift - widest) / spacing)
132 const last = Math.ceil((shift + px.width) / spacing)
133 for (let i = first; i <= last; i++) place(i, i * spacing - shift)
134}
135
136/** Draws one tree with its foot on the ground, `height` pixels from its roots to its crown. */
137function drawTree(px: Pixels, tree: Tree, x: number, groundY: number, height: number): void {
138 const trunk = Math.max(2, height - tree.crown.length)
139 drawSprite(px, tree.crown, x, groundY - height)
140 const foot = groundY - 1
141 for (let y = foot - trunk + 1; y <= foot; y++) {
142 // it flares out where it goes into the ground
143 drawSprite(px, [y === foot ? 'TTT' : '.T.'], x + tree.trunkAt - 1, y)
144 }
145}
146
147/** Draws the trees along the back of the lot, as the eye at `camera` sees them. */
148export function drawTrees(px: Pixels, groundY: number, camera: number): void {
149 const margin = widest(TREES.map(tree => tree.crown)) + TREE_WANDER
150 alongTheBand(px, TREE_SPACING, camera * TREE_PARALLAX, margin, (i, x) => {
151 // well off the beat, and no two the same height: a stand of trees, not a row of fence posts
152 const wander = Math.floor(scatter(i + 101) * (TREE_WANDER * 2 + 1)) - TREE_WANDER
153 const height = TREE_LOW + Math.floor(scatter(i + 41) * (TREE_HIGH - TREE_LOW + 1))
154 // the band only ever shows a handful of trees: this stretch of the scatter gives that
155 // handful all four crowns, with no two neighbours alike
156 drawTree(px, pick(TREES, i + 5), x + wander, groundY, height)
157 })
158}
159
160/** Draws the grass the cat runs through, the near layer that keeps up with it. */
161export function drawGrass(px: Pixels, groundY: number, camera: number): void {
162 alongTheBand(px, TUFT_SPACING, camera * GROUND_PARALLAX, widest(TUFTS), (i, x) => {
163 const tuft = pick(TUFTS, i)
164 drawSprite(px, tuft, x + Math.floor(scatter(i + 7) * 3), groundY - tuft.length)
165 })
166}
167
168/** Draws the ground the chase runs over, with grit in it that slides by as the cat runs. */
169export function drawGround(px: Pixels, groundY: number, camera: number): void {
170 const shift = Math.round(camera * GROUND_PARALLAX)
171 for (let x = 0; x < px.width; x++) {
172 drawSprite(px, [scatter(x + shift) < 0.14 ? 'D' : 'd'], x, groundY)
173 }
174}
175hooks/cat/sprite.ts 89 lines1// The cat itself: a curled orange-and-white cat drawn into the pixel picture at any angle, so
2// rolling is one sprite turned a little more each frame rather than a strip of frames.
3//
4// Every feature is stated in the cat's own frame, a disc of radius 1 with the head up: the
5// drawing walks the pixels the cat can cover, turns each one back into that frame, and asks
6// which feature it lands on. Pure: `claude plugin test` covers it.
7
8import { EYE, FUR, FUR_STRIPE, PINK, TRANSPARENT, WHITE } from './palette.ts'
9import { setPixel, type Pixels } from './pixels.ts'
10
11/** How far past the body the ears and the tail reach, as a factor of the radius. */
12const REACH = 1.38
13
14type Point = readonly [number, number]
15
16const dist2 = (x: number, y: number, p: Point): number => (x - p[0]) ** 2 + (y - p[1]) ** 2
17
18/** A point inside the triangle an ear makes, narrowing from `base` to `tip`. */
19function inEar(x: number, y: number, base: Point, tip: Point, halfWidth: number): boolean {
20 const ax = tip[0] - base[0]
21 const ay = tip[1] - base[1]
22 const len2 = ax * ax + ay * ay
23 const t = ((x - base[0]) * ax + (y - base[1]) * ay) / len2
24 if (t < 0 || t > 1) return false
25 const px = base[0] + ax * t
26 const py = base[1] + ay * t
27 return Math.hypot(x - px, y - py) <= halfWidth * (1 - t * 0.85)
28}
29
30const EAR_L = { base: [-0.5, 0.68] as Point, tip: [-0.72, 1.28] as Point }
31const EAR_R = { base: [0.5, 0.68] as Point, tip: [0.72, 1.28] as Point }
32const EYE_L: Point = [-0.36, 0.46]
33const EYE_R: Point = [0.36, 0.46]
34const MUZZLE: Point = [0, 0.16]
35const NOSE: Point = [0, 0.3]
36const BELLY: Point = [0, -0.5]
37const PAW_L: Point = [-0.44, -0.86]
38const PAW_R: Point = [0.44, -0.86]
39
40/**
41 * The colour of the cat at one point of its own frame, or TRANSPARENT where the cat is not.
42 * `x` is right and `y` is up, both in radii, the head at `y = 1`.
43 */
44export function furAt(x: number, y: number): number {
45 const r2 = x * x + y * y
46 if (r2 > 1) {
47 // outside the body: an ear, or the tail curled round the right of the ball
48 if (inEar(x, y, EAR_L.base, EAR_L.tip, 0.3) || inEar(x, y, EAR_R.base, EAR_R.tip, 0.3)) {
49 const inner = inEar(x, y, EAR_L.base, EAR_L.tip, 0.15) || inEar(x, y, EAR_R.base, EAR_R.tip, 0.15)
50 return inner && y < 1.12 ? PINK : FUR
51 }
52 const r = Math.sqrt(r2)
53 if (Math.abs(r - 1.19) <= 0.23) {
54 const a = Math.atan2(y, x)
55 if (a >= -2.25 && a <= 0.45) return a <= -1.85 ? WHITE : FUR
56 }
57 return TRANSPARENT
58 }
59 // the face
60 if (dist2(x, y, EYE_L) <= 0.014 || dist2(x, y, EYE_R) <= 0.014) return EYE
61 if (dist2(x, y, NOSE) <= 0.006) return PINK
62 if (dist2(x, y, MUZZLE) <= 0.075) return WHITE
63 // the white front: belly, chest and the paws tucked into it
64 if (dist2(x, y, PAW_L) <= 0.01 || dist2(x, y, PAW_R) <= 0.01) return PINK
65 if (dist2(x, y, BELLY) <= 0.29) return WHITE
66 // the tabby's bands, spokes around the curled body: they are what the eye sees turning
67 if (r2 > 0.5 && Math.sin(Math.atan2(y, x) * 7) > 0.35) return FUR_STRIPE
68 return FUR
69}
70
71/**
72 * Draws the cat centred on (`cx`, `cy`) with body radius `radius`, turned `angle` radians
73 * clockwise. Pixels are square: one pixel across is one column, one pixel down is half a row.
74 */
75export function drawCat(px: Pixels, cx: number, cy: number, radius: number, angle: number): void {
76 const cos = Math.cos(angle)
77 const sin = Math.sin(angle)
78 const reach = Math.ceil(radius * REACH) + 1
79 for (let y = Math.floor(cy - reach); y <= Math.ceil(cy + reach); y++) {
80 for (let x = Math.floor(cx - reach); x <= Math.ceil(cx + reach); x++) {
81 // the pixel in screen terms, y up, then turned back into the cat's own frame
82 const sx = (x - cx) / radius
83 const sy = (cy - y) / radius
84 const color = furAt(sx * cos - sy * sin, sx * sin + sy * cos)
85 if (color !== TRANSPARENT) setPixel(px, x, y, color)
86 }
87 }
88}
89