SLOPSHOPPER

bunny

兔兔: an animated rabbit pet docked beside the transcript, reacting to what Claude is doing

panespinnerguardcommandpromptnetwork
v0.1.0MITupdated 2026-10-26floatj/claude-code-mods-usagi-pet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bunny
│ ┃ 兔兔 ✕ › 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 │ ┃ │ ┃ › /bunny │ ┃ ⎿ bunny: 兔兔來了 ( ·ω· ) │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 兔兔
✦▄▀▄ ▄▀▄ ███ ███ ▄▀▀▀▀▀▀▀▄ ✧ █▀▀▀█▀▀▀█ ◆▀█████▀◆ █▀██▀██▀█ ▄▛▛███▜▜▄ ▀ ▀ 完成了! [ ♡ ] [ ▶ ]
README

兔兔 usagi pet

Claude Code 的側欄寵物 mod:兔耳、藍白女僕裝的 Q 版女僕「兔兔」,用彩色半格方塊像素畫成,會跟著 Claude 正在做的事演動畫。

  • 開新 session 時,從六種開機畫面裡隨機播一種:掌機開機、BIOS 自檢、兔子洞、召喚陣、讀取組裝、東方風的「少女祈禱中」(兔兔一開始是灰色外框圍著的深色剪影,彩色從腳往上逐漸載入)。
  • Claude 跑指令時她打筆電,讀檔時看書,改檔時寫字,搜尋時挖洞,上網時看地球,派出 subagent 時帶小兔子,用 MCP 工具時變魔法。
  • 思考比較久時會輪流換場景;回覆結束、開心幾秒之後就會睡著,送出訊息就醒來。
  • 睡著時旁邊有一扇窗(面板內容區 22 欄以上才畫得下):白天太陽、傍晚夕陽、晚上月亮;外面下雨或下雪時窗外會下雨,雷雨會打閃電(見下方「窗外的天氣」)。
  • 特別演出:送出訊息時偶爾會變身成魔法少女;一輪工作超過 2 分鐘才完成,會從三種演出裡隨機播一種:發射全力魔炮、cosplay 洛克人集氣射出「兔兔飛彈」,或 cosplay 魔理沙騎掃帚飛來發射「Master Spark」(最後一個工具出錯後不到 5 秒就結束、或還在播其他特別演出或惡搞小劇場時,三種都不會);睡著時被你的訊息叫醒,多半會拉起裙擺說「歡迎回來」,偶爾直接變身。
  • 惡搞小劇場:
  • Claude 改檔案時,偶爾(每次 5%)會有百變怪來訪,變成另一個兔兔。
  • 工具出錯時,偶爾(合計 17%)不是難過,而是 cosplay 成阿克婭表演噴水失敗大哭,或是謎擬Q 來訪、換上兔兔布偶裝,頭掉下來把她嚇昏。
  • 回覆完成時(一輪不到 2 分鐘;最後一個工具出錯後不到 5 秒就結束、或還在播其他特別演出或惡搞小劇場時都不會),依序判斷:
  • 15 秒內就做完的,有 20% 會 cosplay 成咲夜,停止時間丟飛刀解決 BUG,再端出紅茶。
  • 滿月前後兩天(依平均月相推算)的晚上 18:00 到隔天 6:00,有 15% 會 cosplay 成鈴仙,紅眼分身射彈幕,最後在月亮下搗年糕。
  • 上面都沒輪到的話,偶爾(7%)會 cosplay 成小丑跳「藍藍路」;也偶爾(7%)會 cosplay 成 Saber 放出「EX咖哩棒」,最後坐下來吃咖哩、舉起空盤要再來一碗。
  • 送出的訊息裡任何位置有「鈴仙」「優曇華」「月兔」「reisen」「udonge」「うどんげ」(英文不分大小寫)時,鈴仙會直接出現。
  • 思考開始的頭 15 秒內,spinner 出現跟音樂有關的字(例如 Vibing、Humming)時,會 cosplay 成初音甩蔥。
  • 可以摸摸她,也可以一次播放所有動畫。

安裝

需要支援 mod 的 Claude Code 版本(筆者用 v2.1.302)。

git clone https://github.com/floatj/claude-code-mods-usagi-pet ~/.claude/mods/bunny

先試玩:

claude --plugin-dir ~/.claude/mods/bunny

想每次都自動載入,在 ~/.claude/settings.json 的 env 加上(路徑換成你的家目錄,多個 mod 用 : 隔開):

"env": {
  "CLAUDE_CODE_PLUGIN_DIRS": "/Users/你的帳號/.claude/mods/bunny"
}

使用

  • /bunny:叫出兔兔面板。
  • 面板停靠在對話旁邊需要 fullscreen 版面,而且終端機至少 110 欄寬;開 session 時,144 欄以上會自動出現;用 /bunny 叫出來過一次之後(沒有手動關掉),110 欄以上就會自動出現。比 110 欄窄時,/bunny 會把她開在輸入框上方。
  • ♡ 按鈕或 p:摸摸她。
  • ▶ 按鈕或 r:依序播放所有動畫,字幕會顯示正在播第幾段;播放中按鈕變成 ■,再按一次停止。
  • 按鍵要在面板取得焦點時才有效。

窗外的天氣

睡著時的窗戶會依你所在地的天氣下雨或打雷,這需要連網:

  • 第一次查天氣前,先用你的 IP 位址查大概位置:先問 ipwho.is,查不到再問 GeoJS;兩個都查不到的話,下次查天氣時再試。座標只取到小數點後兩位(約 1 公里),只用來查天氣。
  • 再用這個座標向 Open-Meteo 查目前天氣:開 session 時查一次,之後每 30 分鐘一次。
  • 這些服務都會看到你的 IP 位址。斷網或查詢失敗時,窗戶維持上一次的天氣,一開始是晴天。
  • 目前沒有關閉這個功能的設定;不想連網的話,要自己拿掉 hooks/register.tsx 裡呼叫 readWeather 的地方。

修改

  • 造型、場景、顏色:hooks/scenes.ts
  • 心情和字幕:hooks/bunny.ts
  • 事件掛鉤和面板:hooks/register.tsx
  • 天氣查詢:hooks/weather.ts

檢查有沒有改壞:

claude plugin validate ~/.claude/mods/bunny
claude plugin test ~/.claude/mods/bunny

致謝

動畫的做法(依工具和 spinner 文字換場景、計時器推動畫格)參考了 Brian Huang 的 clawd-actor(MIT 授權),少數小函式沿用它的寫法,授權聲明附在 THIRD_PARTY_NOTICES.md。

東方 cosplay(魔理沙、咲夜、鈴仙)和「少女祈禱中」開機畫面是東方Project(上海アリス幻樂団)的二次創作,像素圖都是自己畫的,沒有使用原作素材。

授權

MIT

Source 5 files
hooks/register.tsx 294 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Mood } from '../types'
5import { captionOf, COLOR, INITIAL, setMood, step } from './bunny'
6import { FRAME_MS, frameRows, isMoonlitAt, MAX_STAGE, REEL, reelAt, sceneOf, skyAt, STAGE_ROWS } from './scenes'
7import { forecastOf, forecastUrl, LOCATORS, WEATHER_MS } from './weather'
8import type { Place, Weather } from './weather'
9
10const PANE = 'bunny'
11const TITLE = '兔兔'
12const COLUMNS = 22
13const ROWS = 12
14// Cells kept free between the rabbit's stage and the pane's right edge.
15const SHIFT_LEFT = 3
16// Asleep she breathes slowly: her frame moves on one clock period in this many.
17const SLEEP_EVERY = 8
18// A prompt now and then sets her transforming, a little more often one that
19// wakes her; a turn this long ends in one of three beams: the blast, the robot
20// boy's buster or the magician's spark.
21const TRANSFORM_CHANCE = 0.2
22const WAKE_TRANSFORM_CHANCE = 0.25
23const BLAST_AFTER_MS = 120_000
24const LONG_TURNS: readonly Mood[] = ['blast', 'buster', 'spark']
25// A turn done this quickly now and then has the maid stop time for it; on a
26// full moon's night the moon rabbit comes now and then, and a prompt naming her
27// brings her at once.
28const QUICK_MS = 15_000
29const WORLD_CHANCE = 0.2
30const LUNATIC_CHANCE = 0.15
31const LUNATIC_WORDS = /鈴仙|優曇華|月兔|reisen|udonge|うどんげ/i
32// Set pieces a tool call does not cut short.
33const SHOWS: readonly Mood[] = ['transform', 'blast', 'welcome', 'boot', 'ditto', 'mimikyu', 'ranranru', 'aqua', 'excalibur', 'buster', 'spark', 'world', 'lunatic']
34// Now and then an edit brings the shapeshifter; a failure, the goddess or the
35// ghost in its disguise; a finished turn, the clown's chant or the knight's sword.
36const EDITS = /^(Edit|Write|MultiEdit|NotebookEdit)$/
37const DITTO_CHANCE = 0.05
38const AQUA_CHANCE = 0.1
39const MIMIKYU_CHANCE = 0.07
40const RANRANRU_CHANCE = 0.07
41const EXCALIBUR_CHANCE = 0.07
42
43/** What a failed tool brings on: her sorrow, or now and then a parody of it. */
44const failure = (): Mood => {
45  const roll = Math.random()
46
47  return roll < AQUA_CHANCE ? 'aqua' : roll < AQUA_CHANCE + MIMIKYU_CHANCE ? 'mimikyu' : 'sad'
48}
49
50/** What a finished turn brings on: her joy, or now and then a parody. */
51const finale = (): Mood => {
52  const roll = Math.random()
53
54  return roll < RANRANRU_CHANCE ? 'ranranru' : roll < RANRANRU_CHANCE + EXCALIBUR_CHANCE ? 'excalibur' : 'happy'
55}
56
57/** How a turn that took `took` ms ends at `now`: a beam after a long one, else the maid, the moon rabbit, or the finale. */
58const ending = (took: number, now: number): Mood => {
59  if (took >= BLAST_AFTER_MS) {
60    return LONG_TURNS[Math.floor(Math.random() * LONG_TURNS.length)]!
61  }
62
63  if (took < QUICK_MS && Math.random() < WORLD_CHANCE) {
64    return 'world'
65  }
66
67  return isMoonlitAt(now) && Math.random() < LUNATIC_CHANCE ? 'lunatic' : finale()
68}
69// Painted over the whole body, so the pane is not the dock's gray.
70const BACKGROUND = '#000000'
71const pet = atom({ plugin: 'bunny', key: 'pet' } as const, INITIAL)
72
73// The spinner's word, read while it draws. State cannot be written during a
74// render, so it lives here; the frame clock's redraws pick it up.
75let word = ''
76// Where this session's rotation of scenes starts.
77let seed = 0
78
79// Her frame clock, and the periods it has counted.
80let frames: Timer | undefined
81let period = 0
82// When the turn running began.
83let turnAt = 0
84// The frame the play-all reel started on, while it runs.
85let reelFrom: number | undefined
86// The weather at her window, and where she is, found once a session.
87let weather: Weather = 'clear'
88let place: Place | undefined
89
90const locate = async ($: EngineInterface): Promise<Place | undefined> => {
91  for (const { url, read: readPlace } of LOCATORS) {
92    const found = await $.http
93      .fetch(url)
94      .then(res => (res.ok ? readPlace(JSON.parse(res.text)) : undefined))
95      .catch(() => undefined)
96
97    if (found) {
98      return found
99    }
100  }
101
102  return undefined
103}
104
105// Reads the weather afresh; on any failure it keeps what it had, clear to begin with.
106const readWeather = async ($: EngineInterface) => {
107  place ??= await locate($)
108
109  if (place) {
110    const res = await $.http.fetch(forecastUrl(place)).catch(() => undefined)
111    weather = (res?.ok ? forecastOf(res.text) : undefined) ?? weather
112  }
113}
114
115const wake = ($: EngineInterface) => {
116  frames ??= $.clock.every(FRAME_MS, () => void frame($))
117}
118
119const frame = async ($: EngineInterface) => {
120  period += 1
121
122  if (reelFrom === undefined && (await read($, pet)).mood === 'sleepy' && period % SLEEP_EVERY !== 0) {
123    return
124  }
125
126  const now = await $.clock.now()
127  await update($, pet, one => step(one, now))
128}
129
130export const register: Register = on => {
131  // Tool calls in flight: the bunny works until the last one answers.
132  let running = 0
133  on('session.start', async ($, e, next) => {
134    await $.command.register({
135      name: 'bunny',
136      description: '叫出兔兔 (open the bunny pane)',
137    })
138    void $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS, rows: ROWS })
139    seed = Math.floor(await $.clock.now()) % 997
140    // A new session boots her up; a reload, which finds her already going, does not.
141    const now = await $.clock.now()
142    await update($, pet, b => (b.tick === 0 ? setMood(b, 'boot', now) : b))
143    // A reload starts the module over, its timers gone with it.
144    frames = undefined
145    wake($)
146    void readWeather($)
147    $.clock.every(WEATHER_MS, () => void readWeather($))
148
149    return next(e)
150  })
151
152  on('command.run', { command: 'bunny' }, async $ => {
153    const opened = await $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS, rows: ROWS })
154
155    return { text: opened.isPlaced ? '兔兔來了 ( ·ω· )' : '兔兔現在出不來' }
156  })
157
158  on('prompt.submit', async ($, e, next) => {
159    const now = await $.clock.now()
160    turnAt = now
161    // Named, the moon rabbit comes; else now and then she transforms, else, woken,
162    // she greets you, and awake she starts.
163    const isAsleep = (await read($, pet)).mood === 'sleepy'
164    const isTransforming = Math.random() < (isAsleep ? WAKE_TRANSFORM_CHANCE : TRANSFORM_CHANCE)
165    const mood: Mood = LUNATIC_WORDS.test(e.text ?? '') ? 'lunatic' : isTransforming ? 'transform' : isAsleep ? 'welcome' : 'shock'
166    await update($, pet, b => ({ ...setMood(b, mood, now), isBusy: true, turnTick: b.tick }))
167    wake($)
168
169    return next(e)
170  })
171
172  on('tool.call', async ($, e, next) => {
173    running += 1
174    const startedAt = await $.clock.now()
175    const isDitto = EDITS.test(e.tool) && Math.random() < DITTO_CHANCE
176    await update($, pet, b =>
177      SHOWS.includes(b.mood) ? { ...b, tool: e.tool } : setMood(b, isDitto ? 'ditto' : 'working', startedAt, e.tool),
178    )
179    wake($)
180
181    try {
182      const ran = await next(e)
183      const isFailed = ran.deny !== undefined || ran.isError === true
184      const now = await $.clock.now()
185
186      if (isFailed) {
187        const mood = failure()
188        await update($, pet, b => setMood(b, mood, now))
189      } else if (running === 1) {
190        // Back to thinking, unless a set piece is still playing.
191        await update($, pet, b => (SHOWS.includes(b.mood) ? b : setMood(b, 'thinking', now)))
192      }
193
194      return ran
195    } finally {
196      running -= 1
197    }
198  })
199
200  on('turn.complete', async ($, e, next) => {
201    const now = await $.clock.now()
202    await update($, pet, b => ({
203      // A sorrow or a set piece still playing stays; else a beam after a long turn,
204      // now and then a parody, and otherwise her joy.
205      ...(b.mood === 'sad' || SHOWS.includes(b.mood) ? b : setMood(b, ending(now - turnAt, now), now)),
206      isBusy: false,
207    }))
208    wake($)
209
210    return next(e)
211  })
212
213  // Observe only: remember what the spinner says, and let the engine draw it.
214  on('ui.render', { component: 'Spinner' }, ($, e, next) => {
215    word = e.props.message ?? e.props.word
216
217    return next(e)
218  })
219
220  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
221    const { Box, Button, Text } = $.ui.resolve(e)
222    const { bodyColumns, scroll } = e.props
223
224    // A rabbit kept from before a reload may lack what was added since.
225    const b = { ...INITIAL, ...(await read($, pet)) }
226    const width = Math.max(1, Math.min(bodyColumns, MAX_STAGE))
227    const sky = skyAt(await $.clock.now())
228    // The reel, while it runs, plays in place of what she is doing; past its end it stops.
229    const reel = reelFrom === undefined ? undefined : reelAt(b.tick - reelFrom)
230    reelFrom = reel === undefined ? undefined : reelFrom
231    const scene = sceneOf(b, word, seed)
232    const stage = reel
233      ? frameRows(reel.scene, b.tick, width, reel.since, sky, weather)
234      : frameRows(scene, b.tick, width, b.tick - b.moodTick, sky, weather)
235    const color = reel ? 'magenta' : COLOR[b.mood]
236
237    return (
238      <Box
239        flexDirection="column"
240        alignItems="flex-end"
241        justifyContent="flex-end"
242        paddingRight={Math.max(0, Math.min(SHIFT_LEFT, bodyColumns - width))}
243        width={bodyColumns}
244        height={scroll.bodyRows}
245        backgroundColor={BACKGROUND}
246      >
247        <Box flexDirection="column" alignItems="center" width={width}>
248        <Box flexDirection="column" width={width} height={STAGE_ROWS}>
249          {stage.map(runs => (
250            <Text>
251              {runs.map(run => (
252                <Text color={run.color} backgroundColor={run.bg ?? BACKGROUND}>
253                  {run.text}
254                </Text>
255              ))}
256            </Text>
257          ))}
258        </Box>
259        <Text key="caption" color={color}>
260          {reel ? `▶ ${reel.index + 1}/${REEL.length} ${reel.label}` : captionOf(b, scene)}
261        </Text>
262        <Box gap={1}>
263          <Button
264            key="pet"
265            label="♡"
266            hotkey="p"
267            onPress={async () => {
268              const now = await $.clock.now()
269              await update($, pet, one => ({
270                ...setMood(one, 'love', now),
271                pets: one.pets + 1,
272              }))
273              wake($)
274            }}
275          />
276          <Button
277            key="reel"
278            label={reel ? '■' : '▶'}
279            hotkey="r"
280            onPress={async () => {
281              // Play every scene from the first, or stop the one playing.
282              reelFrom = reelFrom === undefined ? (await read($, pet)).tick : undefined
283              await update($, pet, one => ({ ...one }))
284              wake($)
285            }}
286          />
287        </Box>
288        {b.pets > 0 && <Text dimColor>♡ × {b.pets}</Text>}
289        </Box>
290      </Box>
291    )
292  })
293}
294
hooks/bunny.ts 170 lines
1import type { Bunny, Mood } from '../types'
2import type { SceneName } from './scenes'
3
4// An idle rabbit dozes off at once: idle is only the moment before sleep.
5export const SLEEP_AFTER_MS = 0
6
7// Moods that pass by themselves, and how long each lasts.
8const TTL: Partial<Record<Mood, number>> = {
9  happy: 6000,
10  shock: 3000,
11  sad: 5000,
12  love: 3000,
13  // Set pieces, each as long as its scene.
14  transform: 4800,
15  blast: 6300,
16  welcome: 3000,
17  // As long as the longest boot screen; a shorter one holds its last pose.
18  boot: 5100,
19  // Parodies, each as long as its scene.
20  ditto: 6600,
21  mimikyu: 8700,
22  ranranru: 4200,
23  aqua: 4800,
24  excalibur: 8400,
25  buster: 9600,
26  spark: 8400,
27  world: 7200,
28  lunatic: 8400,
29}
30
31export const INITIAL: Bunny = {
32  mood: 'idle',
33  since: 0,
34  tick: 0,
35  moodTick: 0,
36  turnTick: 0,
37  tool: '',
38  isBusy: false,
39  pets: 0,
40}
41
42export const setMood = (b: Bunny, mood: Mood, now: number, tool = ''): Bunny =>
43  b.mood === mood && b.tool === tool ? b : { ...b, mood, tool, since: now, moodTick: b.tick }
44
45/** One clock period: advance the frame, and let a mood run out. */
46export const step = (b: Bunny, now: number): Bunny => {
47  // A rabbit kept from before a reload may lack what was added since.
48  const next = { ...INITIAL, ...b, tick: b.tick + 1 }
49  const age = now - b.since
50  const ttl = TTL[b.mood]
51
52  if (ttl !== undefined && age > ttl) {
53    return { ...next, mood: b.isBusy ? 'thinking' : 'idle', since: now, moodTick: next.tick }
54  }
55
56  if (b.mood === 'idle' && age > SLEEP_AFTER_MS) {
57    return { ...next, mood: 'sleepy', since: now, moodTick: next.tick }
58  }
59
60  return next
61}
62
63export const COLOR: Record<Mood, string | undefined> = {
64  idle: undefined,
65  thinking: 'cyan',
66  working: 'yellow',
67  happy: 'green',
68  shock: 'red',
69  sad: 'blue',
70  sleepy: undefined,
71  love: 'magenta',
72  transform: 'magenta',
73  blast: 'magenta',
74  welcome: 'magenta',
75  boot: 'magenta',
76  ditto: 'magenta',
77  mimikyu: 'magenta',
78  ranranru: 'yellow',
79  aqua: 'blue',
80  excalibur: 'yellow',
81  buster: 'cyan',
82  spark: 'yellow',
83  world: 'blue',
84  lunatic: 'magenta',
85}
86
87const TOOL_LABEL: Record<string, string> = {
88  Bash: '跑指令中',
89  Read: '讀檔案中',
90  Edit: '改程式中',
91  Write: '寫檔案中',
92  Grep: '找東西中',
93  Glob: '找東西中',
94  WebFetch: '上網查中',
95  WebSearch: '上網查中',
96  Agent: '叫小幫手',
97}
98
99// Frames each dot of the thinking caption takes to appear.
100const DOT_FRAMES = 4
101
102/** Her caption; `scene`, the one drawn, tells the boot screens apart. */
103export const captionOf = (b: Bunny, scene?: SceneName): string => {
104  switch (b.mood) {
105    case 'idle':
106      return '發呆中'
107    case 'thinking':
108      return '思考中' + '.'.repeat((Math.floor(b.tick / DOT_FRAMES) % 3) + 1)
109    case 'working':
110      return TOOL_LABEL[b.tool] ?? `${b.tool || '工作'}中`
111    case 'happy':
112      return '完成了!'
113    case 'shock':
114      return '收到!'
115    case 'sad':
116      return '嗚…出錯了'
117    case 'sleepy':
118      return '睡著了'
119    case 'love':
120      return '♡'
121    case 'transform':
122      return '變身!'
123    case 'blast':
124      return '全力全開!'
125    case 'welcome':
126      return '歡迎回來♡'
127    case 'boot':
128      // The land of illusions' own loading screen has its own words.
129      return scene === 'bootPray' ? (b.tick - b.moodTick < 24 ? '少女祈禱中…' : '祈禱完成!') : '兔兔 啟動!'
130    case 'ditto':
131      return '有兩個兔兔?'
132    case 'mimikyu':
133      return '咦…是誰?'
134    case 'ranranru':
135      return '藍藍路☆'
136    case 'aqua':
137      return '嗚哇——'
138    case 'excalibur': {
139      // The wind, the blade let loose, the light, then her supper.
140      const p = b.tick - b.moodTick
141
142      return p < 8 ? '風王結界…' : p < 26 ? '解放——' : p < 40 ? 'EX咖哩棒!' : '再來一碗!'
143    }
144    case 'buster': {
145      // Ready, the charge, the shot and its bounce, the volley, and done.
146      const p = b.tick - b.moodTick
147
148      return p < 16 ? 'READY…' : p < 28 ? '集氣中…' : p < 34 ? '兔兔飛彈!' : p < 40 ? '欸?' : p < 52 ? '連射!' : '任務完成!'
149    }
150    case 'spark': {
151      // Her arrival, the charge, the beam, the smoke, and the books.
152      const p = b.tick - b.moodTick
153
154      return p < 8 ? '普通的魔法使來了' : p < 22 ? '戀符——' : p < 38 ? 'Master Spark!' : p < 46 ? '彈幕就是火力!' : '只是借走而已 DA☆ZE'
155    }
156    case 'world': {
157      // The watch, time stopped, the knives loosed, and tea.
158      const p = b.tick - b.moodTick
159
160      return p < 8 ? '女僕長登場' : p < 14 ? '幻世「The World」' : p < 28 ? '(停止的時間裡…)' : p < 34 ? '然後時間開始流動' : '紅茶泡好了,請慢用'
161    }
162    case 'lunatic': {
163      // The moon, her eyes, the illusion, its shots, its end, and rice cake.
164      const p = b.tick - b.moodTick
165
166      return p < 8 ? '月兔來了' : p < 14 ? '看著我的眼睛…' : p < 26 ? '幻波「赤眼催眠」' : p < 38 ? '狂氣的彈幕!' : p < 46 ? '…哪個才是真的?' : '還是搗年糕最療癒'
167    }
168  }
169}
170
hooks/scenes.ts 2953 lines
1import type { Bunny } from '../types'
2import type { Weather } from './weather'
3
4// The rabbit's stage, after clawd-actor's (github.com/BrianHuang813/clawd-actor): a scene
5// draws her and her props for one frame, and the frames follow what the turn is doing.
6// She is a rabbit-eared maid in blue and white. The stage is a canvas of half-cell
7// pixels, two to a cell (▀ ▄ █), so she is painted in several colors and hops a pixel
8// at a time.
9// Pure functions only, so the frames can be checked without the engine.
10
11export const STAGE_ROWS = 9
12export const MAX_STAGE = 30
13export const FRAME_MS = 150
14// Each act of a long turn runs a quarter of a minute.
15export const ACT_FRAMES = Math.round(15_000 / FRAME_MS)
16
17const PX_ROWS = STAGE_ROWS * 2
18// A scene's coordinates start a cell below the canvas's top: the cell above is
19// headroom for her ears when she hops.
20const SKY = 1
21// The pixel row under her feet, in a scene's coordinates.
22const FLOOR = (STAGE_ROWS - SKY) * 2
23// Her sprite's columns: nine of head and one each side for a raised hand or a lop ear.
24const SPRITE = 11
25const SPRITE_ROWS = 16
26// The columns she and a prop beside her take; on a narrower stage she is drawn alone.
27const ROOMY = 20
28const PROP_X = SPRITE
29
30/** The white of her ears and apron. */
31export const FUR_COLOR = '#f6f4ef'
32/** The laptop's own color, apart from the rabbit's. */
33export const LAPTOP_COLOR = '#9cc3f5'
34
35const GRAY = '#8a9096'
36const RED = '#ff5f5f'
37const YELLOW = '#ffd75f'
38const CYAN = '#5fd7ff'
39const PINK = '#ff8fa3'
40const MAGENTA = '#ff87d7'
41const GREEN = '#6fcf6f'
42const SCREEN = '#1b1b2f'
43
44// What each letter of a sprite paints; any other character is see-through.
45const INKS: Record<string, string> = {
46  F: FUR_COLOR,
47  P: '#ff9db0', // inner ear
48  T: '#cdd6ee', // hair
49  t: '#8f9cc4', // hair in shadow
50  S: '#ffdfc8', // skin
51  B: '#ff9fb2', // blush
52  K: '#2a2530', // eye
53  k: '#4f8ff7', // iris
54  X: '#1b2a48', // the night sky
55  J: '#8fd3ff', // the sky by day
56  e: '#cfe0ef', // under a cloud
57  M: '#a8558a', // high in the sky at dusk
58  Z: '#ff7a59', // the sky at dusk
59  z: '#ffb070', // low over the horizon at dusk, and the sun on the sea
60  m: '#4a3466', // the sea at dusk
61  A: '#5b9bf0', // dress
62  a: '#3565c0', // ribbon and shoes
63  C: '#c8ecff', // a tear
64  D: '#ffffff', // diamond earrings
65  d: '#ffffff', // a second star
66  V: '#a8dcf5', // gloves
67  H: '#ff4d6d', // heart
68  U: LAPTOP_COLOR,
69  u: '#5b8fd8',
70  Q: '#cfe2fb', // the keyboard
71  W: '#ececec',
72  w: '#aab0b6',
73  s: GRAY,
74  G: GREEN,
75  g: '#3f9f4f',
76  O: '#ff7a1a', // carrot
77  o: '#d95f0e',
78  N: '#9b6a3a', // wood and soil
79  n: '#6b4423',
80  L: '#5fafff', // water
81  l: '#2f6fd0',
82  Y: YELLOW,
83  R: RED,
84  I: '#4a4a55', // ink on paper
85}
86
87/** `isWorn` by her: a pixel painted over its cell later hides it. */
88type Glyph = { ch: string; color?: string; bg?: string; isWorn?: boolean }
89type Canvas = {
90  width: number
91  px: (string | undefined)[][]
92  /** A pixel's left and right halves, where they differ; `px` is how it looks whole. */
93  halves: (Half | undefined)[][]
94  glyphs: (Glyph | undefined)[][]
95}
96type Half = readonly [string | undefined, string | undefined]
97
98const canvasOf = (width: number): Canvas => ({
99  width,
100  px: Array.from({ length: PX_ROWS }, () => Array<string | undefined>(width).fill(undefined)),
101  halves: Array.from({ length: PX_ROWS }, () => Array<Half | undefined>(width).fill(undefined)),
102  glyphs: Array.from({ length: STAGE_ROWS }, () => Array<Glyph | undefined>(width).fill(undefined)),
103})
104
105const dot = (c: Canvas, x: number, y: number, color: string) => {
106  const line = c.px[y + SKY * 2]
107
108  if (line && x >= 0 && x < c.width) {
109    line[x] = color
110    c.halves[y + SKY * 2]![x] = undefined
111    const worn = c.glyphs[Math.floor(y / 2) + SKY]
112
113    if (worn?.[x]?.isWorn) {
114      worn[x] = undefined
115    }
116  }
117}
118
119/** Splits a painted pixel in two, drawn so where its cell can show it. */
120const split = (c: Canvas, x: number, y: number, left: string | undefined, right: string | undefined) => {
121  const line = c.halves[y + SKY * 2]
122
123  if (line && x >= 0 && x < c.width) {
124    line[x] = [left, right]
125  }
126}
127
128/** Paints a sprite's rows with their top left at pixel (x, y). */
129const paint = (c: Canvas, x: number, y: number, rows: readonly string[], inks = INKS) => {
130  rows.forEach((row, dy) => {
131    ;[...row].forEach((letter, dx) => {
132      const color = inks[letter]
133
134      if (color !== undefined) {
135        dot(c, x + dx, y + dy, color)
136      }
137    })
138  })
139}
140
141/**
142 * Writes characters at cell (x, row). Plain, a space is see-through and the
143 * character shows only where no pixel is painted; on a `bg` it covers its cell.
144 */
145const write = (c: Canvas, x: number, row: number, text: string, color: string, bg?: string) => {
146  const line = c.glyphs[row + SKY]
147
148  if (!line) {
149    return
150  }
151
152  ;[...text].forEach((ch, i) => {
153    const at = x + i
154
155    if ((ch !== ' ' || bg !== undefined) && at >= 0 && at < c.width) {
156      line[at] = { ch, color, bg }
157    }
158  })
159}
160
161const pick = <T,>(xs: readonly T[], t: number): T => xs[((t % xs.length) + xs.length) % xs.length] as T
162
163/** Back and forth over `span` columns. */
164const pingPong = (t: number, span: number) => {
165  if (span <= 0) {
166    return 0
167  }
168
169  const p = t % (span * 2)
170
171  return p < span ? p : span * 2 - p
172}
173
174const isGoingRight = (t: number, span: number) => span <= 0 || t % (span * 2) < span
175
176// ---- The rabbit ----
177
178type Ears = 'up' | 'left' | 'right' | 'out' | 'down' | 'none'
179type Eyes = 'open' | 'left' | 'right' | 'closed' | 'happy' | 'wide'
180type Feet = 'sit' | 'a' | 'b' | 'tuck'
181type Arms = 'down' | 'up' | 'left' | 'right' | 'reach' | 'forward'
182
183// Four rows of long ears above her headband; `left` and `right` lean that ear
184// outward, `out` lets both fall apart as she dozes.
185const EARS: Record<Exclude<Ears, 'down'>, readonly string[]> = {
186  up: ['..F.....F..', '.FPF...FPF.', '.FPF...FPF.', '.FPF...FPF.'],
187  left: ['.F......F..', 'FPF....FPF.', 'FPF....FPF.', '.FPF...FPF.'],
188  right: ['..F......F.', '.FPF....FPF', '.FPF....FPF', '.FPF...FPF.'],
189  out: ['...........', 'FF.......FF', 'FPF.....FPF', '.FPF...FPF.'],
190  // Under a hat.
191  none: [],
192}
193// Lop ears hang down beside her face instead, from the headband.
194const LOP = ['.FF.....FF.', 'FPF.....FPF', 'FP.......PF', 'FP.......PF', 'FF.......FF']
195// The headband, her fringe, the face, and her chin between two locks of hair.
196const HEAD = ['..FAFFFAF..', '.TTTTTTTTT.', '.TTSTSTSTT.', '.TSSSSSSST.', '.TBSSSSSBT.', '.tTSSSSSTt.']
197// Puffed sleeves, a collar and its ribbon.
198const SHOULDERS = '.tAAFaFAAt.'
199// The row her gloved hands are on, the apron's bib between them.
200const HANDS: Record<Arms, string> = {
201  down: '.tVAFFFAVt.',
202  up: '.t.AFFFA.t.',
203  left: '.VAAFFFAVt.',
204  right: '.tVAFFFAAV.',
205  // Both hands on their way up and out to her right, then pushed out: see `rabbit`.
206  reach: '.tVAFFFAAA.',
207  forward: '.tVAFFFAAAA',
208}
209const SKIRT = ['..AAFFFAA..', '.AAAFFFAAA.']
210// White stockings and blue shoes.
211const LEGS: Record<Feet, readonly string[]> = {
212  sit: ['...F...F...', '...a...a...'],
213  a: ['...F...F...', '..a...a....'],
214  b: ['...F...F...', '....a...a..'],
215  tuck: ['...a...a...'],
216}
217// Landed from a hop, or sat down: a row shorter, her skirt spread about her.
218const SQUASHED = ['.AAAFFFAAA.', 'AAAAFFFAAAA', '...a...a...']
219
220type Pose = {
221  x?: number
222  /** Pixels off the ground; negative sinks her into it. */
223  lift?: number
224  isSquashed?: boolean
225  /** Pixels her head hangs. */
226  nod?: number
227  ears?: Ears
228  eyes?: Eyes
229  feet?: Feet
230  arms?: Arms
231  /** Her colors, for an outfit of another day. */
232  inks?: Record<string, string>
233}
234
235// What the stroll is doing this frame; a pose that leaves a part alone follows it.
236type Stroll = { dx: number; isMoving: boolean; lift: number; isSquashed: boolean; dir: 1 | -1 }
237const STILL: Stroll = { dx: 0, isMoving: false, lift: 0, isSquashed: false, dir: 1 }
238
239type Shot = { c: Canvas; t: number; since: number; width: number; isRoomy: boolean; sky: Sky; weather: Weather; stroll: Stroll }
240
241// The face a scene leaves alone: a look around, a blink or two; on the move
242// she looks where she is going.
243const faceOf = ({ t, stroll }: Shot): Eyes => {
244  if (stroll.isMoving) {
245    return t % 20 === 0 ? 'closed' : stroll.dir > 0 ? 'right' : 'left'
246  }
247
248  const p = t % 56
249
250  if (p < 30) {
251    return p === 12 || p === 26 ? 'closed' : 'open'
252  }
253
254  if (p < 36) {
255    return 'left'
256  }
257
258  if (p < 42) {
259    return 'right'
260  }
261
262  return p === 48 || p === 50 ? 'closed' : 'open'
263}
264
265// An ear flicks now and then.
266const earsOf = (t: number): Ears => {
267  const p = t % 34
268
269  return p === 15 || p === 16 ? 'right' : p === 29 ? 'left' : 'up'
270}
271
272const eyes = (c: Canvas, x: number, y: number, look: Eyes, inks = INKS) => {
273  const shift = look === 'left' ? -1 : look === 'right' ? 1 : 0
274
275  for (const at of [x + 3 + shift, x + 7 + shift]) {
276    if (look === 'closed') {
277      // A line along the foot of her eye's cell, all skin whichever way the cells fall.
278      const line = c.glyphs[Math.floor(y / 2) + SKY]
279
280      if (line && at >= 0 && at < c.width) {
281        line[at] = { ch: '▂', color: INKS.K!, bg: INKS.S!, isWorn: true }
282      }
283    } else if (look === 'happy') {
284      dot(c, at, y, INKS.K!)
285    } else {
286      dot(c, at, y, INKS.K!)
287      dot(c, at, y + 1, inks.k!)
288
289      if (look === 'wide') {
290        dot(c, at, y - 1, INKS.K!)
291      }
292    }
293  }
294}
295
296const rabbit = (s: Shot, pose: Pose = {}) => {
297  const { c, t, stroll } = s
298  const x = pose.x ?? 0
299  const lift = pose.lift ?? stroll.lift
300  const isSquashed = pose.isSquashed ?? stroll.isSquashed
301  // Squashed she is a row shorter, so everything above her skirt comes down one.
302  const top = FLOOR - SPRITE_ROWS - lift + (isSquashed ? 1 : 0)
303  const head = top + (pose.nod ?? 0)
304  const ears = pose.ears ?? earsOf(t)
305  const arms = pose.arms ?? 'down'
306  const feet = pose.feet ?? (lift > 0 ? 'tuck' : stroll.isMoving ? pick(['a', 'b'] as const, t) : 'sit')
307  const inks = pose.inks ?? INKS
308
309  // Her dress first: a nodding head comes down over the collar.
310  paint(c, x, top + 10, [SHOULDERS, HANDS[arms], ...(isSquashed ? SQUASHED : [...SKIRT, ...LEGS[feet]])], inks)
311
312  if (!isSquashed) {
313    // Lace along her hem, teeth half a pixel wide either side of the apron.
314    for (const dx of [1, 2, 3]) {
315      split(c, x + dx, top + 13, inks.A!, inks.F!)
316      split(c, x + 10 - dx, top + 13, inks.F!, inks.A!)
317    }
318  }
319
320  if (arms === 'up') {
321    paint(c, x, top + 8, ['V.........V', 'V.........V'], inks)
322  }
323
324  if (arms === 'reach') {
325    // Both arms coming up, the gloves a row below where they will push out.
326    paint(c, x + 10, top + 11, ['V', 'V'], inks)
327  }
328
329  if (arms === 'forward') {
330    // The other arm along the first, both gloves out past her side.
331    paint(c, x + 10, top + 10, ['AV', '.V'], inks)
332  }
333
334  if (ears !== 'down') {
335    paint(c, x, head, EARS[ears], inks)
336  }
337
338  paint(c, x, head + 4, HEAD, inks)
339
340  // White diamond earrings on the shadowed hair beside her cheeks; lop ears or a carrot cover them.
341  for (const at of [x + 1, x + 9]) {
342    const line = c.glyphs[Math.floor((head + 8) / 2) + SKY]
343
344    if (line && at >= 0 && at < c.width) {
345      line[at] = { ch: '◆', color: INKS.D!, bg: INKS.t!, isWorn: true }
346    }
347  }
348
349  eyes(c, x, head + 7, pose.eyes ?? faceOf(s), inks)
350
351  if (ears === 'down') {
352    paint(c, x, head + 4, LOP, inks)
353  }
354}
355
356// A little rabbit, 5 x 7, hopping along the ground.
357const LITTLE = ['.F.F.', '.F.F.', '.F.F.', 'FFFFF', 'FKFKF', 'FFPFF', '.F.F.']
358const LITTLE_FUR = ['#ffaf5f', '#b9a58f', '#9aa3b2', '#f2b6c6']
359
360const little = (c: Canvas, x: number, fur: number, lift = 0) => {
361  paint(c, x, FLOOR - LITTLE.length - lift, LITTLE, { ...INKS, F: pick(LITTLE_FUR, fur) })
362}
363
364// ---- Props ----
365
366// What the laptop's screen shows while she types, two lines a frame.
367const SCREENS: readonly (readonly [string, string])[] = [
368  ['>_   ', '     '],
369  ['>ls_ ', '     '],
370  ['>ls  ', '▪▪ ▪▪'],
371  ['▪▪ ▪▪', '>_   '],
372  ['▪▪ ▪▪', '>git_'],
373  ['>git ', '▪▪▪ ▪'],
374  ['▪▪▪ ▪', '▪ ▪▪▪'],
375  ['▪ ▪▪▪', '>_   '],
376]
377// What it shows while nobody types: its maker's name.
378// Idle, the screen saver: her own face.
379const RESTING = ['     ', '(·ω·)'] as const
380
381/** Her laptop on a low table, a column clear of her; shut, just its lid on the keyboard. */
382const laptop = ({ c, isRoomy }: Shot, [top, bottom]: readonly [string, string], ink = GREEN, isShut = false, x = PROP_X) => {
383  if (!isRoomy) {
384    return
385  }
386
387  if (isShut) {
388    paint(c, x + 1, 12, ['uuuuuuu'])
389  } else {
390    paint(c, x + 1, 7, ['UUUUUUU', 'U.....U', 'U.....U', 'U.....U', 'U.....U', 'uuuuuuu'])
391    write(c, x + 2, 4, top.padEnd(5), ink, SCREEN)
392    write(c, x + 2, 5, bottom.padEnd(5), ink, SCREEN)
393  }
394
395  paint(c, x, 13, ['QQQQQQQQQ', 'NNNNNNNNN', '.n.....n.'])
396}
397
398export type Sky = 'day' | 'dusk' | 'night'
399
400// Her window. Its skies are drawn in half pixels, two letters to a pixel, a dot
401// the sky's own color; each cell of them holds no more than two colors.
402const WINDOW = ['FFFFFFFFF', ...Array.from({ length: 7 }, () => 'F.......F'), 'FFFFFFFFF']
403const SKIES: Record<Sky, { blank: string; rows: readonly string[] }> = {
404  // A sun, and a cloud going by under it.
405  day: {
406    blank: 'J',
407    rows: ['......YYYY....', '.....YYYYYY...', '.....YYYYYY...', '......YYYY....', '..DDDD........', '.DDDDDDDD.....', '..eeeeee......'],
408  },
409  // The sun going down into the sea, its light on the water.
410  dusk: {
411    blank: 'Z',
412    rows: ['MMMMMMMMMMMMMM', 'ZZZZZZZZZZZZZZ', 'ZZZZZYYYYZZZZZ', 'zzzYYYYYYYYzzz', 'zzYYYYYYYYYYzz', 'mmmzzzzzzzzmmm', 'mmmmmzzzzmmmmm'],
413  },
414  // A slim crescent, and two stars that twinkle.
415  night: {
416    blank: 'X',
417    rows: ['..............', '....YYYY....D.', '...YYY........', '..YYY.........', '..YYY.....d...', '...YYY........', '....YYYY......'],
418  },
419}
420
421/** Paints half-pixel `rows` from (x, y): each pair of letters one pixel, split where they differ. */
422const fine = (c: Canvas, x: number, y: number, rows: readonly string[], blank: string, inks = INKS) => {
423  rows.forEach((row, dy) => {
424    for (let dx = 0; dx * 2 < row.length; dx += 1) {
425      const [left, right] = [row[dx * 2], row[dx * 2 + 1]].map(one => inks[one === '.' ? blank : one!]!)
426      dot(c, x + dx, y + dy, left!)
427
428      if (left !== right) {
429        split(c, x + dx, y + dy, left!, right!)
430      }
431    }
432  })
433}
434
435// The hours her sky turns: day at six, dusk at five in the afternoon, night at seven.
436const DAWN = 6
437const DUSK_HOUR = 17
438const NIGHTFALL = 19
439
440/** The sky where she is at `ms` on the clock. */
441export const skyAt = (ms: number): Sky => {
442  const hour = new Date(ms).getHours()
443
444  return hour < DAWN || hour >= NIGHTFALL ? 'night' : hour >= DUSK_HOUR ? 'dusk' : 'day'
445}
446
447// A new moon to count from, the days from one to the next, and how many days
448// either side of full the moon still counts as full.
449const NEW_MOON_MS = Date.UTC(2000, 0, 6, 18, 14)
450const LUNATION_DAYS = 29.530588853
451const FULL_DAYS = 2
452const DAY_MS = 86_400_000
453// The hour the full moon's night begins; it ends at DAWN.
454const MOONRISE_HOUR = 18
455
456/** Whether `ms` falls on a night of the full moon, six in the evening to six in the morning. */
457export const isMoonlitAt = (ms: number) => {
458  const age = ((((ms - NEW_MOON_MS) / DAY_MS) % LUNATION_DAYS) + LUNATION_DAYS) % LUNATION_DAYS
459  const hour = new Date(ms).getHours()
460
461  return Math.abs(age - LUNATION_DAYS / 2) <= FULL_DAYS && (hour >= MOONRISE_HOUR || hour < DAWN)
462}
463
464// A star's colors as it twinkles: bright, warm, cool, dim.
465const TWINKLE = ['#ffffff', '#fff1a8', '#b8d8ff', '#56648a']
466
467// Rain at her window, by the time of day: the sky behind it, its clouds, the
468// rain, and, in a storm, the sky lit and the bolt.
469const RAINY: Record<Sky, { sky: string; cloud: string; rain: string; flash: string }> = {
470  day: { sky: '#8fa3b8', cloud: '#c9d1db', rain: '#e8f2ff', flash: '#e9eef7' },
471  // The day's rain, darkened and warmed a little.
472  dusk: { sky: '#6f6070', cloud: '#9a8d98', rain: '#d9cfe0', flash: '#cfc4d8' },
473  night: { sky: '#141b2b', cloud: '#2f3850', rain: '#8aa4c8', flash: '#5a6a8a' },
474}
475const BOLT = '#fff6a0'
476// The bolt's half-pixel column on each row below the clouds, zigzagging down.
477const ZIGZAG = [9, 7, 8, 6, 5]
478// In a storm the bolt strikes on one frame in this many.
479const STRIKE_EVERY = 7
480
481/** Rows of half pixels for a rainy window: clouds along the top, streaks falling slantwise below. */
482const rainRows = (t: number, isStrike: boolean): string[] => [
483  'C'.repeat(14),
484  isStrike ? 'W'.repeat(9) + 'L' + 'W'.repeat(4) : 'CC.CCC..CCCC.C',
485  ...ZIGZAG.map((bolt, i) =>
486    Array.from({ length: 14 }, (_, at) => {
487      if (isStrike) {
488        return at === bolt ? 'L' : 'W'
489      }
490
491      // Below the clouds' ragged edge the sky shows clear for a row, then rain.
492      return i >= 1 && (at + 2 * i + 2 * t) % 7 === 0 ? 'r' : '.'
493    }).join(''),
494  ),
495]
496
497const view = (c: Canvas, x: number, t: number, sky: Sky, weather: Weather = 'clear') => {
498  if (weather !== 'clear') {
499    const colors = RAINY[sky]
500    paint(c, x, 0, WINDOW.map(row => row.replaceAll('.', 'X')), { ...INKS, X: colors.sky })
501    const isStrike = weather === 'storm' && t % STRIKE_EVERY === 0
502    fine(c, x + 1, 1, rainRows(t, isStrike), 'X', { ...INKS, X: colors.sky, C: colors.cloud, r: colors.rain, W: colors.flash, L: BOLT })
503
504    return
505  }
506
507  const { blank, rows } = SKIES[sky]
508  paint(c, x, 0, WINDOW.map(row => row.replaceAll('.', blank)))
509  fine(c, x + 1, 1, rows, blank, sky === 'night' ? { ...INKS, D: pick(TWINKLE, t), d: pick(TWINKLE, t + 2) } : INKS)
510}
511
512const CARROT = ['.g.', 'GgG', 'OOO', 'OOO', 'oOo', '.O.', '.o.']
513
514/** A patch of soil along the ground from `x`. */
515const soil = (c: Canvas, x: number, columns: number) => {
516  for (let i = 0; i < columns; i += 1) {
517    dot(c, x + i, 14, (i * 5) % 3 === 0 ? INKS.n! : INKS.N!)
518    dot(c, x + i, 15, (i * 7) % 4 === 0 ? INKS.N! : INKS.n!)
519  }
520}
521
522// ---- Scenes ----
523
524type Scene = {
525  draw: (s: Shot) => void
526  /** Columns she and what she carries take: given, the frame strolls the stage. */
527  strolls?: number
528  /** Whether the scene lays itself out over the whole stage instead of being centered. */
529  isWide?: boolean
530}
531
532// Hopping along: up, over, down, land.
533const HOP = [0, 1, 2, 1] as const
534
535// ---- Set pieces ----
536
537/** Whitens every pixel already painted in her columns from row `from` up to `to`: her silhouette in a burst of light. */
538const bleach = (c: Canvas, x: number, from: number, to: number) => {
539  for (let y = from; y < to; y += 1) {
540    const line = c.px[y + SKY * 2]
541    const worn = c.glyphs[Math.floor(y / 2) + SKY]
542
543    for (let at = Math.max(0, x); at < Math.min(c.width, x + SPRITE); at += 1) {
544      if (line?.[at] !== undefined) {
545        line[at] = '#ffffff'
546        c.halves[y + SKY * 2]![at] = undefined
547      }
548
549      if (worn?.[at]?.isWorn) {
550        worn[at] = undefined
551      }
552    }
553  }
554}
555
556// Two ribbons, pink and blue, a few pixels long, circling her.
557const RIBBONS = ['#ff8fc8', '#7fc8ff']
558
559const ribbons = (c: Canvas, x: number, y: number, t: number, radius = 6) => {
560  RIBBONS.forEach((color, i) => {
561    for (let k = 0; k < 4; k += 1) {
562      const angle = t * 0.7 - k * 0.35 + i * Math.PI
563      dot(c, x + 5 + Math.round(Math.cos(angle) * radius), y + Math.round(Math.sin(angle) * (radius + 1)), color)
564    }
565  })
566}
567
568/** Wraps ribbon about her at rows `band - 2` to `band`: stripes running round her, loops out at her side. */
569const wrap = (c: Canvas, x: number, band: number, t: number) => {
570  for (let y = band - 2; y <= band; y += 1) {
571    for (let at = x; at < x + SPRITE; at += 1) {
572      dot(c, at, y, (at + y + t) % 4 < 2 ? RIBBONS[0]! : RIBBONS[1]!)
573    }
574  }
575
576  // Its loose ends, fluttering.
577  dot(c, x + SPRITE, band - 1 - (t % 2), RIBBONS[0]!)
578  dot(c, x + SPRITE + 1, band - 2 + (t % 2), RIBBONS[1]!)
579}
580
581/** Her maid's colors above row `from`, over a figure drawn in other colors below it. */
582const above = (s: Shot, pose: Pose, from: number) => {
583  const maid = canvasOf(s.c.width)
584  rabbit({ ...s, c: maid }, pose)
585
586  for (let y = -SKY * 2; y < from; y += 1) {
587    s.c.px[y + SKY * 2] = [...maid.px[y + SKY * 2]!]
588    s.c.halves[y + SKY * 2] = [...maid.halves[y + SKY * 2]!]
589  }
590}
591
592// Her magical girl's colors: hair of pink and lilac, a rose dress with gold, white gloves.
593const MAGICAL = {
594  ...INKS,
595  T: '#ffc8e6',
596  t: '#d98fc4',
597  A: '#ff5fae',
598  a: '#ffcf3f',
599  V: '#ffffff',
600  k: '#ff5fa8',
601}
602
603// Where the sparkles of her pose land, in turn.
604const BURST: readonly (readonly [number, number, string, string])[] = [
605  [18, 0, '✦', YELLOW],
606  [17, 3, '♡', PINK],
607  [19, 1, '✧', MAGENTA],
608  [18, 5, '♡', MAGENTA],
609  [13, 6, '✦', YELLOW],
610  [19, 6, '✧', PINK],
611]
612
613// Her wands, each from her raised right hand up and out, drawn from column 10 and
614// the given row below the top of her sprite; `g` glints. One is hers each time.
615const WANDS: readonly { dy: number; rows: readonly string[]; inks: Record<string, string> }[] = [
616  // A carrot, its leaves at the top.
617  { dy: 2, rows: ['.....GG', '....GG.', '...OO..', '..Oo...', '.oO....', '.O.....'], inks: { ...INKS, G: GREEN } },
618  // A little rabbit's head, its nose a gem, on a pink stick.
619  { dy: 1, rows: ['...F.F.', '...P.P.', '...F.F.', '..FFFFF', '..FKFKF', '..FFgFF', '.A.....'], inks: MAGICAL },
620  // A leek, as a certain singer carries: white where she holds it, its leaves split at the top.
621  {
622    dy: 0,
623    rows: ['......G.G', '......GG.', '.....GG..', '....eG...', '...We....', '..WW.....', '.WW......', 'W........'],
624    inks: { ...INKS, G: '#4cbf5a', e: '#b8f0a0', W: '#ffffff' },
625  },
626]
627
628const BEAM = '#ff6fd8'
629const GLOW = '#ff9fe8'
630// The beam's middle, between the rows of her pushed-out hands; how far it spreads
631// either side, and how fast with distance.
632const BEAM_AXIS = 10.5
633const BEAM_HALF = 5
634const BEAM_SPREAD = 0.3
635// Its rim's colors as it shimmers.
636const RIM = ['#ff6fd8', '#c77dff', '#7fc8ff', '#ff9fe8']
637// Its frames: fired, at full power from the third until FULL, half until HALF, a thread until SPENT.
638const FULL = 27
639const HALF = 30
640const SPENT = 32
641const HEART = ['.H.H.', 'HHHHH', 'HHHHH', '.HHH.', '..H..']
642
643// ---- Boot screens ----
644
645// A 3 x 5 pixel font for the logo.
646const FONT: Record<string, readonly string[]> = {
647  U: ['X.X', 'X.X', 'X.X', 'X.X', 'XXX'],
648  S: ['XXX', 'X..', 'XXX', '..X', 'XXX'],
649  A: ['XXX', 'X.X', 'XXX', 'X.X', 'X.X'],
650  G: ['XXX', 'X..', 'X.X', 'X.X', 'XXX'],
651  I: ['XXX', '.X.', '.X.', '.X.', 'XXX'],
652}
653const LOGO = 'USAGI'
654const logoRows = () =>
655  Array.from({ length: 5 }, (_, r) => [...LOGO].map(ch => FONT[ch]![r]).join('.'))
656
657/** Her pixels as they would be drawn standing at x: where each goes and its color. */
658const pixelsOf = (s: Shot, pose: Pose) => {
659  const temp = canvasOf(s.c.width)
660  rabbit({ ...s, c: temp }, pose)
661  const out: { x: number; y: number; color: string }[] = []
662  temp.px.forEach((line, row) => line.forEach((color, x) => color !== undefined && out.push({ x, y: row - SKY * 2, color })))
663  return out
664}
665
666// Her little head, for logos.
667const BADGE = ['.F.F.', '.P.P.', 'FFFFF', 'FKFKF', 'FFPFF']
668// Playing cards, thrown from the hole: where each flies and how it looks.
669const CARDS: readonly (readonly [number, string, string])[] = [[-1, '♠', '#ffffff'], [1, '♥', RED], [-3, '♦', RED], [3, '♣', '#ffffff']]
670// A gentleman's top hat, banded in pink.
671const HAT = ['.KKK.', '.KKK.', '.PPP.', 'KKKKK']
672const HAT_INKS = { ...INKS, K: '#6a5a9a', P: '#ff8fc8' }
673
674const hash = (n: number) => ((n * 2654435761) >>> 0) % 1000 / 1000
675
676// ---- Parodies ----
677
678const DITTO = { D: '#c39ae6', d: '#9a6fc4', K: '#2a2530' }
679const BLOB = ['....DDD....', '..DDDDDDD..', '.DDDDDDDDD.', '.DDKDDDKDD.', 'DDDDDDDDDDD', 'DDDKKKKKDDD', 'dDDDDDDDDDd']
680
681/** Moves pixels from one place to another, each on its own clock: 0 at `from`, 1 at `to`. */
682const morph = (c: Canvas, from: readonly { x: number; y: number }[], to: readonly { x: number; y: number; color: string }[], t: number, color: string) => {
683  to.forEach((one, i) => {
684    const start = from[i % from.length]!
685    const k = Math.min(1, Math.max(0, t * 1.4 - hash(i) * 0.4))
686    dot(c, Math.round(start.x + (one.x - start.x) * k), Math.round(start.y + (one.y - start.y) * k), k < 1 ? color : one.color)
687  })
688}
689
690const pointsOf = (x: number, y: number, rows: readonly string[], inks: Record<string, string>) =>
691  rows.flatMap((row, dy) => [...row].flatMap((k, dx) => (inks[k] ? [{ x: x + dx, y: y + dy, color: inks[k]! }] : [])))
692
693// Its usual disguise, a plush yellow electric mouse with a face drawn on by hand.
694const PIKA = [
695  '.K.....K...',
696  '.Y.....Y...',
697  '.YY...YY...',
698  '..YYYYY....',
699  '.YYYYYYY...',
700  '.YKYYYKY...',
701  '.RYYYYYR...',
702  '.YYKYKYY...',
703  '..YYYYY....',
704  '.yYYYYYy...',
705  '.yYYYYYYy..',
706  'yYYYYYYYY.K',
707  'yYYYYYYYYKK',
708  'yyYYYYYYy.K',
709  '.sssesesss.',
710  '.y.y.y.y.y.',
711]
712const PIKA_INKS = { Y: '#f2d24b', y: '#c4a53a', K: '#2a2530', R: '#e8604a', s: '#2a1f18', e: '#fff3b0' }
713// What it is under the cloth: a shadow with two bright eyes.
714const SHADOW = ['...sssss...', '..sssssss..', '.sssesesss.', '.sssssssss.', '..sssssss..', '.s.s.s.s.s.']
715const SHADOW_INKS = { s: '#1a1210', e: '#fff3b0' }
716
717// A hand-made disguise of her: cloth ears, one bent over, hair and dress in duller
718// cloth, a face stitched on, a ragged hem with a dark gap under it, and a tail.
719const MIMIC = [
720  '.F......F..',
721  '.FP....PF..',
722  '.FP...FP...',
723  '..F..FF....',
724  '..TTTTTTT..',
725  '.TTTTTTTTT.',
726  '.TSKSSSKST.',
727  '.TBSSSSSBT.',
728  '..TSKSKST..',
729  '.AAAFFFAA..',
730  '.AAAFFFAAA.',
731  'AAAAFFFAA.K',
732  'AAAAFFFAAKK',
733  'aAAAFFFAa.K',
734  '.sssesesss.',
735  '.a.a.a.a.a.',
736]
737const MIMIC_INKS = {
738  F: '#e6e1d6',
739  P: '#e6a0b0',
740  T: '#b8c2dc',
741  S: '#f3e2d2',
742  K: '#2a2530',
743  B: '#ef9aa8',
744  A: '#6f93d6',
745  a: '#4f6fb0',
746  s: '#1a1210',
747  e: '#fff3b0',
748}
749
750// Her costumes, as colors for her sprite.
751const MIKU = { ...INKS, T: '#39c5bb', t: '#1f8f88', A: '#3a3f4a', a: '#39c5bb', F: '#d8dce2', V: '#3a3f4a', k: '#39c5bb' }
752const AQUA = { ...INKS, T: '#8fd0ff', t: '#4a9ad8', A: '#3a7fd8', a: '#8fd0ff', V: '#ffffff' }
753const LEEK = WANDS[2]!
754// A certain knight king's: golden hair, a blue dress, silver armour on her chest
755// and shoulders.
756const SABER = { ...INKS, T: '#f5d36a', t: '#c9a23a', A: '#3a5fc8', a: '#2a3f8f', k: '#3fa58a', E: '#c8ccd6' }
757// Her sword: a blade of gold and pale light, a gold guard with a blue gem, a dark blue grip.
758const SWORD = { G: '#fff3b0', g: '#ffd75f', Y: '#ffd75f', b: '#3a5fc8', h: '#2a3f8f' }
759// The wind that hides it, and the light of its stroke from core to rim.
760const WIND = ['#e6f6ff', '#9fd8ff', '#ffffff']
761const RADIANCE = ['#fff3b0', '#ffd75f', '#ffb347']
762// Her frames: wind about it until UNVEIL, the blade shown by RAISED, the stroke at
763// SWING, the light over the ground until SHEATHED, her supper from SUPPER, and
764// from SECONDS the empty plate held up for more.
765const UNVEIL = 8
766const RAISED = 14
767const SWING = 22
768const SHEATHED = 36
769const SUPPER = 40
770const SECONDS = 52
771// A plate of curry and rice going down a spoonful at a time, and the plate empty.
772const CURRIES = [
773  ['..ccWW..', '.cccWWW.'],
774  ['...cW...', '.cccWWW.'],
775  ['........', '..ccWW..'],
776  ['........', '...cW...'],
777]
778const PLATE = ['pppppppp', '.ssssss.']
779const CURRY_INKS = { c: '#c8761e', W: '#ffffff', p: '#e8eef5', s: '#8a9096' }
780
781// A certain blue robot boy's suit, in his dark and light blue: D dark, C light,
782// d the cannon's mouth. Charging, it flashes light blue and then, charged, the
783// shot's own pale yellow.
784type Suit = { D: string; C: string; d: string }
785const SUIT: Suit = { D: '#0058f8', C: '#3cbcfc', d: '#0038a8' }
786const CHARGING_SUIT: Suit = { D: '#3cbcfc', C: '#bcf0fc', d: '#0058f8' }
787const CHARGED_SUIT: Suit = { D: '#fce0a8', C: '#ffffff', d: '#f8b800' }
788// His helmet over her head, her ears through it: a dome with a light panel down
789// its front, coming down about her face to round light ear pieces.
790const HELMET = ['..DDCCCDD..', '.DDDCCCDDD.', '.DDSSSSSDD.', 'CD.......DC', 'CD.......DC', '.D.......D.']
791// Under it, a light blue body, dark gloves and shorts, light thighs and big dark boots.
792const BODY: Record<'forward' | 'up', readonly string[]> = {
793  forward: ['.CCCCCCCCC.', '.DCCCCCCC..', '..DDDDDDD..', '.CCCDDDCCC.', '..DD...DD..', '.DDD...DDD.'],
794  up: ['.CCCCCCCCC.', '..CCCCCCC..', '..DDDDDDD..', '.CCCDDDCCC.', '..DD...DD..', '.DDD...DDD.'],
795}
796// The arm turned cannon, from her shoulder out past her side.
797const CANNON = ['DDDd', 'DDDd']
798const TELEPORT = '#3cbcfc'
799// The buster's shots, in its yellow: a small lemon, and charged, a big one with a tail.
800const LEMON = ['.YY.', 'YwwY', '.YY.']
801const BIG_LEMON = ['..YYY.', '.YwwwY', 'YwWWwY', '.YwwwY', '..YYY.']
802const SHOT_INKS = { Y: '#f8b800', w: '#fce0a8', W: '#ffffff' }
803const CHARGE = ['#fce0a8', '#bcf0fc', '#ffffff', '#f8b800']
804// A little hard-hat foe: its helmet, and under it a face and two feet.
805const MET_HAT = ['.YYY.', 'YYYYY', 'yyyyy']
806const MET_INKS = { Y: '#ffd23f', y: '#c49a1f', K: '#2a2530', W: '#ffffff', o: '#ff8a3a' }
807// Her frames: READY until BEAM_IN, beamed in by ARMED, aiming until CHARGING,
808// charging until FIRE, the shot flying until DEFLECT, bounced off the helmet
809// until VOLLEY, three pellets, one every SHOT_EVERY frames, then its end at
810// BURST_AT, her pose from VICTORY, and beamed out from BEAM_OUT.
811const BEAM_IN = 6
812const ARMED = 12
813const CHARGING = 16
814const FIRE = 28
815const DEFLECT = 34
816const VOLLEY = 40
817const SHOT_EVERY = 2
818const BURST_AT = 49
819const VICTORY = 52
820const BEAM_OUT = 56
821
822/** Her in the robot boy's suit, posed, in the suit's colors of the moment. */
823const robot = (s: Shot, pose: Pose & { arms: 'forward' | 'up' }, suit = SUIT) => {
824  const x = pose.x ?? 0
825  const top = FLOOR - SPRITE_ROWS
826  rabbit(s, { ...pose, lift: 0, isSquashed: false, inks: { ...INKS, T: suit.D, t: suit.D, A: suit.C, a: suit.D, V: suit.D } })
827  paint(s.c, x, top + 10, BODY[pose.arms], { ...INKS, ...suit })
828  paint(s.c, x, top + 4, HELMET, { ...INKS, ...suit })
829  if (pose.arms === 'forward') paint(s.c, x + 9, top + 10, CANNON, { ...INKS, ...suit })
830}
831
832/** A beam of light where she stands, from its `head` row down to its `foot`, `half` pixels either side of her middle. */
833const teleport = (c: Canvas, x: number, head: number, foot: number, half = 0) => {
834  for (let y = Math.max(-2, head); y < Math.min(FLOOR, foot); y += 1) {
835    // A white core down a blue streak; thin, it is all blue.
836    for (let dx = -half; dx <= half; dx += 1) dot(c, x + 5 + dx, y, dx === 0 && half > 0 ? '#ffffff' : TELEPORT)
837  }
838}
839
840// ---- Gensokyo ----
841
842// The shrine maiden's orb, red and white, as the loading screen turns it: a
843// red eye in the white, a white eye in the red.
844const ORB = ['..RRRR..', '.RRRRRW.', 'RRRRWRWW', 'RRRRRWWW', 'RRRWWWWW', 'RRWRWWWW', '.RWWWWW.', '..WWWW..']
845const ORB_INKS = { R: '#e8384f', W: '#ffffff' }
846
847/** The orb with its top left at (x, y), turned a quarter clockwise `turns` times. */
848const orb = (c: Canvas, x: number, y: number, turns: number) => {
849  let rows = ORB
850  for (let k = 0; k < ((turns % 4) + 4) % 4; k += 1) {
851    rows = rows.map((_, row) => rows.map(line => line[row]).reverse().join(''))
852  }
853  paint(c, x, y, rows, ORB_INKS)
854}
855
856// The loading screen's frames: praying until PRAYED, then done; and her, not yet loaded.
857const UNLOADED = '#33363d'
858const PRAYED = 24
859
860// The ordinary magician's: golden hair, a black dress under her white apron,
861// bare hands, golden eyes. Black is lightened to show on the stage's black.
862const MARISA = { ...INKS, T: '#f8e07a', t: '#c9a83a', A: '#46405e', a: '#46405e', V: '#ffdfc8', k: '#e8b830' }
863// Her witch's hat over her ears: its tip, still or blown back, then the cone, a white band and a wide brim.
864const HAT_TIPS = [
865  ['..KK.........', '...KKK.......'],
866  ['KK...........', '.KKKK........'],
867]
868const WITCH_HAT = ['....KKKK.....', '....KKKKK....', '...KKKKKKK...', '...WWWWWWW...', 'KKKKKKKKKKKKK']
869const WITCH_INKS = { K: '#46405e', W: '#ffffff' }
870// The little furnace she fires it from, eight-sided, its heart glowing.
871const HAKKERO = ['.hh.', 'hyyh', 'hyyh', '.hh.']
872// Her broom, its bristles behind her.
873const BROOM = ['yy............', 'YYYnnnnnnnnnnn', 'yy............']
874const BROOM_INKS = { Y: '#e8c860', y: '#c9a040', n: '#8b5a2b' }
875// A stack of borrowed books.
876const BOOKS = ['wLLLL', 'GGGGw', 'wRRRR']
877const BOOK_INKS = { L: '#5fafff', G: '#6fcf6f', R: '#ff5f5f', w: '#ececec' }
878const RAINBOW = ['#ff5f5f', '#ffaf5f', '#ffd75f', '#6fcf6f', '#5fd7ff', '#c77dff']
879// Her frames: flying in until LANDED, aiming until CHARGE, charging until FIRE,
880// the beam until SPENT, the smoke until LOOT, the books, and away on her broom from AWAY.
881const SPARK = { LANDED: 8, CHARGE: 14, FIRE: 22, SPENT: 38, LOOT: 46, AWAY: 52 }
882// The beam's middle, its widest half-height, and how fast it widens.
883const SPARK_AXIS = 10.5
884const SPARK_HALF = 6
885const SPARK_SPREAD = 0.9
886
887/** Her as the magician, hat and all; `tip` 1 blows its tip back. */
888const witch = (s: Shot, pose: Pose, tip = 0) => {
889  const x = pose.x ?? 0
890  const top = FLOOR - SPRITE_ROWS - (pose.lift ?? 0) + (pose.isSquashed ? 1 : 0)
891  rabbit(s, { lift: 0, isSquashed: false, ...pose, ears: 'none', inks: MARISA })
892  paint(s.c, x - 1, top - 2, [...HAT_TIPS[tip]!, ...WITCH_HAT], WITCH_INKS)
893}
894
895/** Stars trailing behind her, from `x` back. */
896const trail = (c: Canvas, x: number, row: number, p: number) => {
897  for (let k = 0; k < 3; k += 1) write(c, x - 2 - k * 3, row + ((p + k) % 2), pick(['✦', '✧'], p + k), pick(RAINBOW, p + k * 2))
898}
899
900// The maid of the scarlet mansion's: silver hair in two braids tied in green,
901// a navy dress, bare hands, a white frilled headdress.
902const SAKUYA = { ...INKS, T: '#b8bfcc', t: '#7f889c', A: '#30479a', a: '#30479a', V: '#ffdfc8' }
903const BRAID_BOW = '#3fb860'
904// Her pocket watch, its hand straight up once time stops.
905const WATCHES = [
906  ['.YY.', 'YWKY', 'YWWY', '.YY.'],
907  ['.YY.', 'YKWY', 'YWWY', '.YY.'],
908]
909// The bug her knives are for: a green beetle on wriggling legs.
910const BUGS = [
911  ['k...k', '.kRk.', 'RRRRR', 'RrRrR', 'k.k.k'],
912  ['.k.k.', '.kRk.', 'RRRRR', 'RrRrR', '.k.k.'],
913]
914const BUG_INKS = { k: '#aab0b6', R: '#6fcf6f', r: '#3f9f4f' }
915// A knife from handle to point, and where each one stops round the bug: degrees, pixels out.
916const KNIFE = ['#8a7aa0', '#c8ccd6', '#e8ecf2', '#ffffff']
917const KNIVES: readonly (readonly [number, number])[] = [[180, 5], [200, 8], [220, 5], [240, 8.5], [255, 5.5], [270, 9]]
918// A cup of tea on its saucer.
919const TEACUP = ['RRRR.', 'WWWWW', 'WWWW.', 'wwwwww']
920const TEA_INKS = { R: '#b0603a', W: '#ffffff', w: '#aab0b6' }
921// Her frames: the watch until STOP, the world gone gray until FLOW with a knife
922// thrown every second frame, the knives loosed, the bug's end by TEA, then her tea.
923const WORLD = { STOP: 8, THROW: 14, FLOW: 28, TEA: 34, BOW: 38 }
924
925// What shows behind the stopped world, where nothing is painted.
926const STOPPED_SKY = '#2a2833'
927
928/** Grays every color painted so far that `isIn` takes, the bare stage a dim gray: the world with its time stopped. */
929const stopped = (c: Canvas, isIn: (x: number, y: number) => boolean = () => true) => {
930  const gray = (color: string | undefined) => {
931    if (!color?.startsWith('#')) return color
932    const n = parseInt(color.slice(1), 16)
933    const l = Math.round(((n >> 16) * 0.3 + ((n >> 8) & 255) * 0.59 + (n & 255) * 0.11) * 0.75)
934    return `#${l.toString(16).padStart(2, '0').repeat(3)}`
935  }
936
937  c.px.forEach((line, row) =>
938    line.forEach((color, at) => {
939      if (!isIn(at, row - SKY * 2)) return
940      line[at] = gray(color) ?? STOPPED_SKY
941      const half = c.halves[row]![at]
942      if (half) c.halves[row]![at] = [gray(half[0]), gray(half[1])]
943    }),
944  )
945  c.glyphs.forEach((line, row) =>
946    line.forEach((one, at) => {
947      if (one && isIn(at, (row - SKY) * 2)) line[at] = { ...one, color: gray(one.color), bg: gray(one.bg) ?? STOPPED_SKY }
948    }),
949  )
950}
951
952/** A knife with its point at (x, y), pointing along `angle` radians. */
953const knife = (c: Canvas, x: number, y: number, angle: number) => {
954  KNIFE.forEach((color, k) => dot(c, Math.round(x - Math.cos(angle) * (3 - k)), Math.round(y - Math.sin(angle) * (3 - k)), color))
955}
956
957/** Her as the maid of the mansion: braids, their bows and her headdress over her own. */
958const maid = (s: Shot, pose: Pose) => {
959  const x = pose.x ?? 0
960  const top = FLOOR - SPRITE_ROWS + (pose.nod ?? 0)
961  rabbit(s, { lift: 0, isSquashed: false, ...pose, inks: SAKUYA })
962  paint(s.c, x, top + 4, ['..FFFFFFF..'], SAKUYA)
963  for (const at of [x + 1, x + 9]) {
964    paint(s.c, at, FLOOR - SPRITE_ROWS + 10, ['T', 'T'], SAKUYA)
965    dot(s.c, at, FLOOR - SPRITE_ROWS + 12, BRAID_BOW)
966  }
967}
968
969// The moon rabbit's: long lilac hair, a dark blazer over a white blouse and
970// red tie, a pink skirt, red eyes, one ear bent.
971const REISEN = { ...INKS, T: '#c9b0ec', t: '#9d82cc', A: '#363b5e', a: '#e8404a', V: '#ffdfc8', k: '#ff3a4a' }
972const REISEN_SKIRT = '#f0a0c0'
973// Her eyes alight, and the rings of their madness.
974const LUNATIC_RED = ['#ff3a4a', '#ff8a95']
975const RIPPLES = ['#ff5f8a', '#c77dff']
976// Her phantoms' outlines.
977const PHANTOM = ['#c77dff', '#8a5ad0']
978// Her shots, shaped like bullets, tail to point.
979const BULLET = ['#c0304a', '#ff5f7a', '#ffd0d8']
980// The full moon, and its shadows.
981const FULL_MOON = { M: '#fff3b0', m: '#e8d890' }
982const MOON = ['..MMM..', '.MMMmM.', 'MmMMMMM', 'MMMMMmM', 'MMmMMMM', '.MMMMM.', '..MMM..']
983// A mortar of rice cake, its cake, and the mallet: handle and head.
984const USU = ['NNNNNN', '.nNNn.', '.nNNn.', 'nnnnnn']
985const MOCHI = { up: ['.WWWW.', 'WWWWWW'], down: ['......', 'WWWWWW'] }
986const MOCHI_INKS = { N: '#9b6a3a', n: '#6b4423', W: '#ffffff', h: '#c9a070' }
987// Her frames: moonrise until GLARE, her eyes until MADNESS, the phantoms until
988// VOLLEY, their shots until BREAK, gone one by one until MOCHI, then rice cake.
989const LUNATIC = { GLARE: 8, MADNESS: 14, VOLLEY: 26, BREAK: 38, MOCHI: 46 }
990
991const mix = (a: string, b: string, k: number) => {
992  const [x, y] = [parseInt(a.slice(1), 16), parseInt(b.slice(1), 16)]
993  return `#${[16, 8, 0].map(at => Math.round(((x >> at) & 255) * (1 - k) + ((y >> at) & 255) * k).toString(16).padStart(2, '0')).join('')}`
994}
995
996/** Her as the moon rabbit, or, `faded`, a phantom of her; `isGlaring` lights her eyes. */
997const moonRabbit = (s: Shot, pose: Pose, faded = 0, isGlaring = false) => {
998  const x = pose.x ?? 0
999  const top = FLOOR - SPRITE_ROWS
1000  const inks = faded ? Object.fromEntries(Object.entries(REISEN).map(([key, color]) => [key, mix(color, '#5a2a6a', faded)])) : REISEN
1001  const skirt = faded ? mix(REISEN_SKIRT, '#5a2a6a', faded) : REISEN_SKIRT
1002  rabbit(s, { lift: 0, isSquashed: false, ears: 'right', ...pose, x, inks })
1003  // Her hair, long to her knees down either side, and the skirt.
1004  for (const at of [x, x + 10]) paint(s.c, at, top + 8, ['T', 'T', 't', 'T', 't', 't'], inks)
1005  paint(s.c, x, top + 12, ['..SSSSSSS..', '.SSSSSSSSS.'], { S: skirt })
1006
1007  if (isGlaring) {
1008    for (const at of [x + 3, x + 7]) {
1009      dot(s.c, at, top + 7, pick(LUNATIC_RED, s.since))
1010      dot(s.c, at, top + 8, LUNATIC_RED[0]!)
1011    }
1012  }
1013}
1014
1015/** Sways each row of a canvas sideways, `amp` pixels at most: the air bent by her eyes. */
1016const wobble = (c: Canvas, p: number, amp: number) => {
1017  const by = (row: number) => Math.round(Math.sin(row * 0.6 + p * 0.9) * amp)
1018  const shift = <T,>(line: T[], n: number) => line.map((_, at) => line[at - n] as T)
1019  c.px.forEach((line, row) => {
1020    c.px[row] = shift(line, by(row))
1021    c.halves[row] = shift(c.halves[row]!, by(row))
1022  })
1023  c.glyphs.forEach((line, row) => {
1024    c.glyphs[row] = shift(line, by(row * 2))
1025  })
1026}
1027
1028// Her dressed as the clown, 15 wide and the stage's full 18 tall, in three poses.
1029const CLOWN_W = 15
1030const CLOWN_HEAD = [
1031  '.....F...F.....',
1032  '....FP...PF....',
1033  '....FP...PF....',
1034  '...RRRRRRRRR...',
1035  '..RRRRRRRRRRR..',
1036  '.RRRRRRRRRRRRR.',
1037  '.RRWWWWWWWWWRR.',
1038  '.RRWWKWWWKWWRR.',
1039  '.RRWBWWWWWBWRR.',
1040  '..RRWWWWWWWRR..',
1041]
1042const CLOWN_LEGS = ['....YYYYYYY....', '.....YY.YY.....', '.....RW.WR.....', '....RRR.RRR....']
1043const CLOWN: Record<'cross' | 'open' | 'up', readonly string[]> = {
1044  // Arms crossed on her chest, a glove on each shoulder.
1045  cross: [...CLOWN_HEAD, '...ggYYYYYgg...', '....RWYYYWR....', '....YRWYWRY....', '....RWYYYWR....', ...CLOWN_LEGS],
1046  // Hands open by her chin, palms up, forearms down to her sides.
1047  open: [
1048    ...CLOWN_HEAD.slice(0, 7),
1049    'gRRWWKWWWKWWRRg',
1050    'ggRWBWWWWWBWRgg',
1051    '.RRRWWWWWWWRRR.',
1052    '.W..YYYYYYY..W.',
1053    '.R..YYYYYYY..R.',
1054    '..WRYYYYYYYRW..',
1055    '....YYYYYYY....',
1056    ...CLOWN_LEGS,
1057  ],
1058  // Both arms thrust straight up, striped, the gloves up past her ears.
1059  up: [
1060    '.g...F...F...g.',
1061    '.g..FP...PF..g.',
1062    '.R..FP...PF..R.',
1063    '.W.RRRRRRRRR.W.',
1064    '.RRRRRRRRRRRRR.',
1065    '.WRRRRRRRRRRRW.',
1066    '.RRWWWWWWWWWRR.',
1067    '.WRWWKWWWKWWRW.',
1068    '.RRWBWWWWWBWRR.',
1069    '.WRRWWWWWWWRRW.',
1070    '.RYYYYYYYYYYYR.',
1071    '....YYYYYYY....',
1072    '....YYYYYYY....',
1073    '....YYYYYYY....',
1074    ...CLOWN_LEGS,
1075  ],
1076}
1077const CLOWN_INKS = { F: '#f6f4ef', P: '#ff9db0', R: '#e8402e', W: '#ffffff', Y: '#ffd23f', g: '#ffe680', K: '#2a2530', B: '#ffb0b8' }
1078
1079// A visit: a visitor bounces in from the right, she starts, and she hops away
1080// into the left corner to peek, leaving it the stage. CORNER is where she peeks
1081// from, most of her off the stage.
1082const ARRIVE = 10
1083const STEP_ASIDE = 16
1084const CORNER = -6
1085
1086/** Draws her through a visit: beside it, startled, stepping aside, then peeking from the corner. */
1087const visited = (s: Shot, peek: { eyes?: Eyes; startle?: boolean; faint?: boolean } = {}) => {
1088  const p = s.since
1089  const left = Math.max(0, Math.floor((s.width - 2 * SPRITE - 1) / 2))
1090
1091  if (p < ARRIVE) {
1092    const isStartled = p >= 6
1093    rabbit(s, { x: left, lift: isStartled ? pick([1, 3, 2, 0], p - 6) : 0, isSquashed: false, eyes: isStartled ? 'wide' : 'open', ears: isStartled ? 'out' : 'right', arms: isStartled ? 'up' : 'down', feet: isStartled && p < 9 ? 'tuck' : 'sit' })
1094    if (isStartled) write(s.c, left + 5, -1, '!', YELLOW)
1095    return
1096  }
1097
1098  if (p < STEP_ASIDE) {
1099    const k = (p - ARRIVE + 1) / (STEP_ASIDE - ARRIVE)
1100    rabbit(s, { x: Math.round(left + (CORNER - left) * k), lift: pick([1, 2, 1, 0], p), isSquashed: false, eyes: 'left', ears: 'left', feet: 'tuck' })
1101    return
1102  }
1103
1104  if (peek.faint) {
1105    // Fainted where she stood, flat out.
1106    rabbit(s, { x: CORNER, lift: 0, isSquashed: true, nod: 1, eyes: 'closed', ears: 'down' })
1107    return
1108  }
1109
1110  rabbit(s, { x: CORNER, lift: 0, isSquashed: false, eyes: peek.startle ? 'wide' : (peek.eyes ?? 'right'), ears: peek.startle ? 'out' : 'up' })
1111  if (peek.startle) write(s.c, 0, -1, '!', YELLOW)
1112}
1113
1114/** Where a visitor stands: coming in from the right until she starts, then in the middle once she is aside. */
1115const visitorAt = (s: Shot) => {
1116  const p = s.since
1117  const left = Math.max(0, Math.floor((s.width - 2 * SPRITE - 1) / 2))
1118  const beside = Math.min(s.width - SPRITE, left + SPRITE + 1)
1119  const middle = Math.floor((s.width - SPRITE) / 2) + 2
1120
1121  if (p < ARRIVE) return Math.max(beside, s.width - Math.floor(p * 2.2))
1122  if (p < STEP_ASIDE) return Math.round(beside + (middle - beside) * ((p - ARRIVE + 1) / (STEP_ASIDE - ARRIVE)))
1123  return middle
1124}
1125
1126const SCENES = {
1127  // A shapeshifter comes to call: she starts and steps aside to the corner, it
1128  // wobbles, takes her shape with its own dotted face still on, she starts again
1129  // at the sight of herself, and it goes back to a blob and hops.
1130  ditto: {
1131    isWide: true,
1132    draw: s => {
1133      const { c } = s
1134      const p = s.since
1135      const x = visitorAt(s)
1136      const q = p - STEP_ASIDE
1137      visited(s, { startle: q >= 12 && q < 16 })
1138
1139      if (q < 6) {
1140        const wide = p % 2 === 0
1141        paint(c, x + (wide ? 0 : 1), 9 + (wide ? 1 : 0) + (q < 0 ? p % 2 : 0), wide ? BLOB.slice(1) : BLOB.map(row => row.slice(1, 10)), DITTO)
1142        return
1143      }
1144
1145      const blob = pointsOf(x, 9, BLOB, { ...DITTO })
1146      const her = pixelsOf(s, { x, lift: 0, isSquashed: false, eyes: 'open' })
1147
1148      if (q < 12) {
1149        morph(c, blob, her, (q - 5) / 6, DITTO.D)
1150        return
1151      }
1152
1153      if (q < 20) {
1154        // Her shape, its face: two dots and a line of a smile.
1155        rabbit(s, { x, lift: 0, isSquashed: false, eyes: 'closed', ears: 'up' })
1156        const top = FLOOR - SPRITE_ROWS
1157        paint(c, x + 2, top + 7, ['SSSSSSS', 'SSSSSSS', 'SSSSSSS'])
1158        dot(c, x + 3, top + 7, DITTO.K)
1159        dot(c, x + 7, top + 7, DITTO.K)
1160        paint(c, x + 4, top + 9, ['KKK'], DITTO)
1161        write(c, x + SPRITE, 1, q % 4 < 2 ? '?' : ' ', YELLOW)
1162        return
1163      }
1164
1165      if (q < 25) {
1166        morph(c, her, blob, (q - 19) / 5, DITTO.D)
1167        return
1168      }
1169
1170      paint(c, x, 9 - pick([0, 1, 2, 1], q), BLOB, DITTO)
1171    },
1172  },
1173
1174  // A ghost in its usual disguise comes to call: she starts and steps aside to
1175  // the corner. It looks her over, drops its disguise with a rustle, shadow and
1176  // eyes, and pulls on a hand-made one of her. It nods hello; its head snaps off
1177  // with a "poki" and hangs at its side; out of the neck peers a shadow with red
1178  // eyes, and she faints flat out; a little dark hand puts the head back on; it
1179  // tilts its head at her, and she comes round with a start.
1180  mimikyu: {
1181    isWide: true,
1182    draw: s => {
1183      const { c } = s
1184      const p = s.since
1185      const q = p - STEP_ASIDE
1186      const x = visitorAt(s)
1187
1188      if (q < 12) {
1189        visited(s, q >= 0 ? { eyes: 'right' } : {})
1190
1191        if (q < 4) {
1192          paint(c, x, 0, PIKA, PIKA_INKS)
1193          if (q >= 0) write(c, x + SPRITE, 1, '?', YELLOW)
1194          return
1195        }
1196
1197        // The old disguise slides off it, the new one comes down over it.
1198        paint(c, x, 10, SHADOW, SHADOW_INKS)
1199
1200        if (q < 8) {
hooks/weather.ts 63 lines
1// The weather outside her window, read over the network. Pure functions only;
2// the fetching is register.tsx's.
3
4export type Weather = 'clear' | 'rain' | 'storm'
5export type Place = { latitude: number; longitude: number }
6
7// How often the weather is read again.
8export const WEATHER_MS = 30 * 60_000
9
10const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
11
12/** A place to the nearest hundredth of a degree, about a kilometre: the town, not the street. */
13const placeAt = (latitude: unknown, longitude: unknown): Place | undefined => {
14  const [lat, lon] = [Number(latitude), Number(longitude)]
15
16  return Number.isFinite(lat) && Number.isFinite(lon) && (lat !== 0 || lon !== 0)
17    ? { latitude: Math.round(lat * 100) / 100, longitude: Math.round(lon * 100) / 100 }
18    : undefined
19}
20
21// Where she is, from the network address, by the first of these that answers.
22export const LOCATORS: readonly { url: string; read: (body: unknown) => Place | undefined }[] = [
23  {
24    url: 'https://ipwho.is/',
25    read: body => (isRecord(body) && body.success === true ? placeAt(body.latitude, body.longitude) : undefined),
26  },
27  {
28    url: 'https://get.geojs.io/v1/ip/geo.json',
29    read: body => (isRecord(body) ? placeAt(body.latitude, body.longitude) : undefined),
30  },
31]
32
33/** Open-Meteo's current weather at a place: free, and asks no key. */
34export const forecastUrl = ({ latitude, longitude }: Place) =>
35  `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=weather_code`
36
37/**
38 * The weather in a WMO weather code: drizzle, rain, snow and their showers are
39 * rain at her window; thunderstorms, a storm. Anything else is clear.
40 */
41export const weatherOf = (code: unknown): Weather => {
42  if (typeof code !== 'number') {
43    return 'clear'
44  }
45
46  if (code >= 95) {
47    return 'storm'
48  }
49
50  return (code >= 51 && code <= 67) || (code >= 71 && code <= 86) ? 'rain' : 'clear'
51}
52
53/** What Open-Meteo answered, read: the weather now, or nothing for an answer it cannot read. */
54export const forecastOf = (text: string): Weather | undefined => {
55  try {
56    const body: unknown = JSON.parse(text)
57
58    return isRecord(body) && isRecord(body.current) ? weatherOf(body.current.weather_code) : undefined
59  } catch {
60    return undefined
61  }
62}
63
types/index.d.ts 46 lines
1export type Mood =
2  | 'idle'
3  | 'thinking'
4  | 'working'
5  | 'happy'
6  | 'shock'
7  | 'sad'
8  | 'sleepy'
9  | 'love'
10  | 'transform'
11  | 'blast'
12  | 'welcome'
13  | 'boot'
14  | 'ditto'
15  | 'mimikyu'
16  | 'ranranru'
17  | 'aqua'
18  | 'excalibur'
19  | 'buster'
20  | 'spark'
21  | 'world'
22  | 'lunatic'
23
24export type Bunny = {
25  mood: Mood
26  /** Epoch ms the mood began. */
27  since: number
28  /** Animation frame counter, one per clock period. */
29  tick: number
30  /** The `tick` the mood began on: a passing mood's scene plays from it. */
31  moodTick: number
32  /** The `tick` the turn began on: a long turn's acts count from it. */
33  turnTick: number
34  /** The tool in flight while `working`. */
35  tool: string
36  /** Whether a turn is running, so a passing mood returns to `thinking`. */
37  isBusy: boolean
38  pets: number
39}
40
41declare module 'claude-code' {
42  interface PluginState {
43    bunny: { pet: Bunny }
44  }
45}
46