SLOPSHOPPER

claude-maru-run

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…

newbandspinnercommandtimer
v1.0.0MITupdated 2026-09-15lemonlatte/claude-maru-run
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claude-maru-run
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /maru ⎿ claude-maru-run: maru: on · normal (×1) · delay 0s · spinner on ✻ Chasing… ▄▄▄ ▄▄▄ ▀▀▀▀▄ ▄▀▀▀▄ ▄▀▀▀▀▀▄ ▄▀▀▀▀▀▄ ▀▀▀▀▀▀▀▄ ▄▄▄ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▄▄▄ ▄▄▄ ▀▀▀▀▄ ▄▀▀▀▄ ▄▀▀▀▀▀▄ ▄▀▀▀▀▀▄ ▀▀▀▀▀▀▀▄ ▄▄▄ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ⟨Claude Code's own drawing⟩
README

claude-maru-run

Claude 在跑的時候,提示列上方多七列:一隻橘白貓在那裡追蝴蝶。蝴蝶亂飛,貓一路彈跳著追; 蝴蝶俯衝下來就跳起來撲,撲空就地滾成一團,爬起來再追。背後一排樹、腳邊一地草,貓往哪邊衝, 整片空地就往反方向滑過去。Claude 一答完,貓就收工。

(下圖是實際畫面的字元還原,一格一像素;真的跑起來是彩色的 —— 橘白貓、黃蝴蝶、綠樹綠草、褐色地面)

                                 ▒▒▒    ✦ ✦           ▒▒▒
        ▒▒▒                     ▒▒▒▒▒    ✧           ▒▒▒▒▒
       ▒▒▒▒▒                   ▒▒▒▒▒▒▒  ✦ ✦         ▒▒▒▒▒▒
      ▒▒▒▒▒▒▒                   ▒▒▒▒▒▒▒              ▒▒▒▒▒
       ▒▒▒▒▒                     ▒▒▒▒▒                 ┃
         ┃                        ┃    █ █             ┃
         ┃                    ██  ┃    ████            ┃
         ┃                     ▓██████▓█●██            ┃
         ┃                      ██░░░░██░░▪            ┃
         ┃                     █  ┃░░   ██             ┃
         ┃   ▒         ▒      █ ▒ ┃      █    ▒        ┃
        ┃┃┃  ▒  ▒     ▒▒▒  ▒   ▒▒▒┃┃      ▒   ▒       ┃┃┃▒
━───────────────────────────━─────────━─━━━──━────────────

這隻貓在幹嘛

沒有分段,就是一直追。動作是連續的,由當下的時間直接算出來:

狀態何時spinner
bound平常,一路彈跳著追Chasing
reach蝴蝶俯衝到構得到的高度時,跳起來揮前爪Pouncing
tumble撲空落地,滾成一團Tumbling

貓跑的是蝴蝶零點六個追逐秒前的路線,而且過彎會因為慣性衝過頭再拉回來 —— 這兩件事加起來才像在追, 而不是黏在蝴蝶下面。彈跳的高度跟當下速度成正比:跑得快就跳得高,停下來就只是小碎步。

需求

  • Claude Code 2.1.269 以上
  • 互動式終端機(Raster 目前只有 terminal surface 有)
  • 開啟 function hooks(早期功能):~/.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.tsxhooks 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

Source 10 files
hooks/register.tsx 134 lines
1/* @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}
134
hooks/cat/config.ts 78 lines
1// 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}
78
hooks/cat/pixels.ts 92 lines
1// 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}
92
hooks/cat/scene.ts 67 lines
1// 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}
67
hooks/cat/story.ts 192 lines
1// 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]
192
hooks/cat/palette.ts 52 lines
1// 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}
52
hooks/cat/art.ts 33 lines
1// 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
33
hooks/cat/frames.ts 41 lines
1// 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]
41
hooks/cat/props.ts 175 lines
1// 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}
175
hooks/cat/sprite.ts 89 lines
1// 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