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

Claude Code 的側欄寵物 mod:兔耳、藍白女僕裝的 Q 版女僕「兔兔」,用彩色半格方塊像素畫成,會跟著 Claude 正在做的事演動畫。
需要支援 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:叫出兔兔面板。/bunny 叫出來過一次之後(沒有手動關掉),110 欄以上就會自動出現。比 110 欄窄時,/bunny 會把她開在輸入框上方。♡ 按鈕或 p:摸摸她。▶ 按鈕或 r:依序播放所有動畫,字幕會顯示正在播第幾段;播放中按鈕變成 ■,再按一次停止。睡著時的窗戶會依你所在地的天氣下雨或打雷,這需要連網:
hooks/register.tsx 裡呼叫 readWeather 的地方。hooks/scenes.tshooks/bunny.tshooks/register.tsxhooks/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(上海アリス幻樂団)的二次創作,像素圖都是自己畫的,沒有使用原作素材。
hooks/register.tsx 294 lines1import { 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}
294hooks/bunny.ts 170 lines1import 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}
170hooks/scenes.ts 2953 lines1import 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 lines1// 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}¤t=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}
63types/index.d.ts 46 lines1export 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