A pixel-art Claude crab above the prompt that works, thinks, cheers and sleeps with the session, with a small crab in its own color for each subagent

A pixel-art Claude crab lives in the band above the prompt and goes through your session with you. Each subagent gets a small crab of its own.


| Mood | When | What it does |
|---|---|---|
| idle | between turns | blinks, glances left and right |
| working | while Claude works | types on a laptop with its logo glowing |
| thinking | while thinking streams | raises a claw beside a growing thought bubble |
| happy | for 5 seconds after an answer | jumps with its claws up, sparks flying |
| sleeping | after a minute with nothing to do | slumps, eyes shut, zs rising |
Every subagent gets a small crab, left of the big one, that lives through the same moods for its own agent:
Up to eight are drawn, and any more are counted as +N.
When a subagent messages the main conversation, or sends back its final report, a small envelope flies in an arc from its crab to the big one.
Each kind of agent has its own color, so you can tell at a glance what is running:
| Agent type | Color |
|---|---|
| general-purpose | blue |
| Explore | green |
| Plan | purple |
| claude-code-guide | yellow |
| statusline-setup | teal |
| claude | pink |
| a fork | the crab's own orange |
Any other type, such as one of your own agents or a plugin's, gets the next free color from a spare palette. That color is remembered across sessions, so the type keeps it.
The crab draws in the terminal only.
claude plugin marketplace add ziedgithub/claude-code-mods && claude plugin install crab-buddy@zied-mods
Then restart Claude Code.
See the repository README for running it from a clone.
hooks/register.tsx 291 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import { agentMoodFor, CHEER_FRAMES, CLAUDE_COLOR, drawScene, FRAME_MS, IDLE, MAX_MINIS, moodFor, PERSONA_ROWS } from './crab'
4import type { Activity, AgentActivity, Flight, MiniPhase, Scene } from './crab'
5
6// The band draws its collapse mark `[-]` over the last cells of its first row
7const BAND_MARK_COLUMNS = 4
8const PERSONA_KEY = 'persona'
9const CREW_POLL_MS = 1000
10const AGENT_COLORS_KEY = 'agentColors'
11// A width change asked for a redraw that has not come yet is asked again after this many frames
12const REDRAW_RETRY_FRAMES = 4
13// A message flies on a clock of its own, smoother than the poses', for FLIGHT_STEPS of it
14const FLIGHT_MS = 50
15const FLIGHT_STEPS = 24
16// A report delivered this soon after the agent's own send is that same message
17const SENT_RECENTLY_FRAMES = 8
18
19// The small crab of each built-in agent type has its own color, the same on every machine;
20// a fork, a copy of the conversation, takes the crab's own
21export const AGENT_COLORS: Readonly<Record<string, number>> = {
22 'general-purpose': 0x58a6ff,
23 Explore: 0x3fb950,
24 Plan: 0xbc8cff,
25 'claude-code-guide': 0xe3b341,
26 'statusline-setup': 0x39c5bb,
27 claude: 0xf778ba,
28 fork: CLAUDE_COLOR,
29}
30// Any other type takes the first of these no type holds yet, kept in the store for good
31export const SPARE_AGENT_COLORS: readonly number[] = [
32 0xff7b72, 0x56d4dd, 0xa5d65a, 0xdb61a2, 0x79c0ff, 0xd29922, 0xf0f6fc, 0x8b949e, 0x2ea043, 0x9e6a03,
33]
34
35const isColorRecord = (value: unknown): value is Record<string, number> =>
36 typeof value === 'object' && value !== null && Object.values(value).every(color => typeof color === 'number')
37
38const hashOf = (text: string): number => {
39 let hash = 0
40 for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0
41 return hash
42}
43
44// A built-in type's own color, else the one kept for it, else the first spare no type holds;
45// once the spares run out, one picked by the name
46export const pickAgentColor = (type: string, kept: Readonly<Record<string, number>>): number => {
47 const known = AGENT_COLORS[type] ?? kept[type]
48 if (known !== undefined) return known
49 const taken = new Set([...Object.values(AGENT_COLORS), ...Object.values(kept)])
50 const free = SPARE_AGENT_COLORS.find(color => !taken.has(color))
51 return free ?? SPARE_AGENT_COLORS[hashOf(type) % SPARE_AGENT_COLORS.length] ?? CLAUDE_COLOR
52}
53
54// What an agent's status means for its small crab: walking, standing, a last hop, or gone
55export const crewPhase = (status: string): MiniPhase | null => {
56 if (status === 'pending' || status === 'running') return 'running'
57 if (status === 'waiting' || status === 'idle') return 'waiting'
58 return status === 'completed' ? 'done' : null
59}
60
61// The crab thinks from the first piece of thinking until the model writes, calls a tool or
62// stops; the stream's own bookkeeping items leave it as it was
63export const isThinkingChunk = (kind: string, wasThinking: boolean): boolean => {
64 if (kind === 'thinking') return true
65 return kind === 'engine' ? wasThinking : false
66}
67
68// Passes the response on as it streams, seeing each chunk on the way
69async function* tap<T, R>(stream: AsyncGenerator<T, R>, see: (chunk: T) => Promise<void>): AsyncGenerator<T, R> {
70 for (;;) {
71 const step = await stream.next()
72 if (step.done === true) return step.value
73 await see(step.value)
74 yield step.value
75 }
76}
77
78// The crab above the prompt is repainted in place on each frame of the clock: a blit, not
79// a redraw. Its site is known once the band is drawn, and gone while a dialog takes it
80let frameTick = 0
81let activity: Activity = IDLE
82let personaSite: string | null = null
83let paintedCells: string | null = null
84let paintedColumns = 0
85let redrawAskedAt: number | null = null
86
87// The subagents' small crabs, in the order they started. `isListed` once `$.agent.list()`
88// showed the agent, whose leaving the list then ends it; a workflow's agents are never
89// listed and end with their `turn.complete` alone
90type CrewMember = AgentActivity & { color: number; isListed: boolean }
91const crew = new Map<string, CrewMember>()
92let keptColors: Record<string, number> = {}
93
94// A new type's color is written at once, over what another session may have kept meanwhile
95const colorOf = async ($: EngineInterface, type: string): Promise<number> => {
96 if (AGENT_COLORS[type] !== undefined || keptColors[type] !== undefined) return pickAgentColor(type, keptColors)
97 const stored = await $.store.get(AGENT_COLORS_KEY)
98 if (isColorRecord(stored)) keptColors = { ...stored, ...keptColors }
99 const color = pickAgentColor(type, keptColors)
100 keptColors = { ...keptColors, [type]: color }
101 await $.store.set(AGENT_COLORS_KEY, keptColors)
102 return color
103}
104
105const enlist = async ($: EngineInterface, id: string, type: string, phase: MiniPhase, isListed: boolean): Promise<void> => {
106 const color = await colorOf($, type)
107 if (!crew.has(id)) crew.set(id, { color, phase, isThinking: false, since: frameTick, isListed })
108}
109
110// The messages in the air, from the small crab of `agentId`, and when each agent last sent
111type InFlight = { agentId: string; color: number; step: number }
112let flights: InFlight[] = []
113let flightTimer: Timer | null = null
114const sentAt = new Map<string, number>()
115
116const fly = async ($: EngineInterface): Promise<void> => {
117 flights = flights.map(flight => ({ ...flight, step: flight.step + 1 })).filter(flight => flight.step <= FLIGHT_STEPS)
118 if (flights.length === 0) {
119 flightTimer?.cancel()
120 flightTimer = null
121 }
122 await paint($)
123}
124
125const launch = ($: EngineInterface, agentId: string): void => {
126 const member = crew.get(agentId)
127 if (member === undefined) return
128 sentAt.set(agentId, frameTick)
129 flights = [...flights, { agentId, color: member.color, step: 0 }]
130 flightTimer ??= $.clock.every(FLIGHT_MS, () => void fly($))
131}
132
133// An answer is the agent's report to the main loop: unless the agent just sent it itself, it
134// flies over before the small crab cheers and leaves; an interrupted or failed agent just leaves
135const finish = ($: EngineInterface, id: string, isAnswered: boolean): void => {
136 const member = crew.get(id)
137 if (member === undefined || member.phase === 'done') return
138 if (!isAnswered) {
139 crew.delete(id)
140 return
141 }
142 const sent = sentAt.get(id)
143 if (sent === undefined || frameTick - sent >= SENT_RECENTLY_FRAMES) launch($, id)
144 crew.set(id, { ...member, phase: 'done', isThinking: false, since: frameTick })
145}
146
147const moveTo = (id: string, member: CrewMember, phase: MiniPhase): void => {
148 if (member.phase !== phase) crew.set(id, { ...member, phase, isThinking: false, since: frameTick })
149}
150
151// The list says which agents still run or wait; one marked done stays done until a step of
152// its own says it runs again
153const syncCrew = async ($: EngineInterface): Promise<void> => {
154 const agents = await $.agent.list()
155 const listed = new Set(agents.map(agent => agent.id))
156 for (const agent of agents) {
157 const phase = crewPhase(agent.status)
158 const member = crew.get(agent.id)
159 if (member === undefined) {
160 if (phase === 'running' || phase === 'waiting') await enlist($, agent.id, agent.type, phase, true)
161 } else if (phase === null || phase === 'done') {
162 finish($, agent.id, phase === 'done')
163 } else if (member.phase !== 'done') {
164 const listed = { ...member, isListed: true }
165 crew.set(agent.id, listed)
166 moveTo(agent.id, listed, phase)
167 }
168 }
169 for (const [id, member] of crew) {
170 if (member.isListed && !listed.has(id) && member.phase !== 'done') crew.delete(id)
171 }
172}
173
174export const sceneOf = (mood: Scene['mood'], members: readonly CrewMember[], tick: number): Scene => ({
175 mood,
176 minis: members.slice(0, MAX_MINIS).map(member => ({ color: member.color, mood: agentMoodFor(member, tick) })),
177 hidden: Math.max(0, members.length - MAX_MINIS),
178 flights: [],
179})
180
181// A message whose sender's small crab has left is dropped with it
182export const flightsOf = (ids: readonly string[], inFlight: readonly InFlight[]): Flight[] =>
183 inFlight.flatMap(flight => {
184 const from = Math.min(ids.indexOf(flight.agentId), MAX_MINIS)
185 return from < 0 ? [] : [{ from, color: flight.color, progress: flight.step / FLIGHT_STEPS }]
186 })
187
188const currentScene = (): Scene => ({
189 ...sceneOf(moodFor(activity, frameTick), [...crew.values()], frameTick),
190 flights: flightsOf([...crew.keys()], flights),
191})
192
193const animate = async ($: EngineInterface): Promise<void> => {
194 frameTick++
195 for (const [id, member] of crew) {
196 if (member.phase === 'done' && frameTick - member.since >= CHEER_FRAMES) crew.delete(id)
197 }
198 await paint($)
199}
200
201const paint = async ($: EngineInterface): Promise<void> => {
202 const { columns, cells } = drawScene(currentScene(), frameTick)
203 if (personaSite === null || cells === paintedCells) return
204 // A crab more or fewer changes the width, which takes a redraw rather than a blit
205 if (columns !== paintedColumns) {
206 if (redrawAskedAt === null || frameTick - redrawAskedAt >= REDRAW_RETRY_FRAMES) {
207 redrawAskedAt = frameTick
208 $.ui.invalidate('ui.render')
209 }
210 return
211 }
212 const result = await $.ui.blit({ requestId: personaSite, key: PERSONA_KEY, cells })
213 if (!('deny' in result)) paintedCells = cells
214}
215
216export const register: Register = on => {
217 on('session.start', async ($, e, next) => {
218 $.clock.every(FRAME_MS, () => void animate($))
219 $.clock.every(CREW_POLL_MS, () => void syncCrew($))
220 return next(e)
221 })
222
223 // A subagent's message flies from its small crab to the crab, unless it goes to another
224 // of the subagents
225 on('session.send', async ($, e, next) => {
226 const result = await next(e)
227 if (result.isDelivered && e.agentId !== undefined && !crew.has(e.to)) launch($, e.agentId)
228 return result
229 })
230
231 // A subagent's small crab appears as it starts, in its type's color
232 on('agent.spawn', async ($, e, next) => {
233 const result = await next(e)
234 if (result.agentId !== undefined) await enlist($, result.agentId, e.subagentType, 'running', false)
235 return result
236 })
237
238 // A subagent's run raises no `turn.start`: the crab works for the main loop only
239 on('turn.start', async ($, e, next) => {
240 activity = { ...activity, isWorking: true, isThinking: false }
241 return next(e)
242 })
243
244 on('turn.complete', async ($, e, next) => {
245 if (e.agentId === undefined) {
246 activity = { isWorking: false, isThinking: false, endedAt: frameTick, isCheering: e.reason === 'answer' }
247 } else {
248 finish($, e.agentId, e.reason === 'answer')
249 }
250 return next(e)
251 })
252
253 on('turn.step', async function* ($, e, next) {
254 // A subagent's small crab works on each of its steps and thinks while its thinking
255 // streams; one the crew lacks, started before a reload, is looked up in the list
256 if (e.agentId !== undefined) {
257 const id = e.agentId
258 const member = crew.get(id)
259 if (member === undefined) await syncCrew($)
260 else moveTo(id, member, 'running')
261 return yield* tap(next(e), async chunk => {
262 const current = crew.get(id)
263 if (current === undefined || current.phase !== 'running') return
264 const isThinking = isThinkingChunk(chunk.kind, current.isThinking)
265 if (isThinking !== current.isThinking) crew.set(id, { ...current, isThinking })
266 })
267 }
268 return yield* tap(next(e), async chunk => {
269 const isThinking = isThinkingChunk(chunk.kind, activity.isThinking)
270 if (isThinking !== activity.isThinking) activity = { ...activity, isThinking }
271 })
272 })
273
274 // The band above the prompt, empty unless a survey takes it; drawn on the right, clear of
275 // its collapse mark. The crab is drawn in the terminal only
276 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
277 if (e.props.hasSurvey || e.surface !== 'terminal') return next(e)
278 const { Box, Raster } = $.ui.resolve(e)
279 const { columns, cells } = drawScene(currentScene(), frameTick)
280 personaSite = e.requestId
281 paintedCells = cells
282 paintedColumns = columns
283 redrawAskedAt = null
284 return (
285 <Box justifyContent="flex-end" paddingRight={BAND_MARK_COLUMNS}>
286 <Raster key={PERSONA_KEY} columns={columns} rows={PERSONA_ROWS} cells={cells} />
287 </Box>
288 )
289 })
290}
291hooks/crab.ts 357 lines1// The Claude Code crab drawn in half blocks: each terminal cell holds two stacked pixels,
2// so its 16 by 8 pixels fit 16 columns by 4 rows, shaded with a lighter top and left edge
3// and a darker right and underside. The columns past it hold the glyphs of its moods (the
4// `z` of sleep, the sparks of a finished answer, the bubble of a thought). Ahead of it
5// stands a small crab for each subagent, in the color of its agent type, living its
6// agent's moods as the crab lives the main loop's, its glyphs over its head
7export type Mood = 'idle' | 'working' | 'thinking' | 'happy' | 'sleeping'
8export type Activity = { isWorking: boolean; isThinking: boolean; endedAt: number; isCheering: boolean }
9// A subagent at work, waiting for a message, or done; `since` is when it became so
10export type MiniPhase = 'running' | 'waiting' | 'done'
11export type AgentActivity = { phase: MiniPhase; isThinking: boolean; since: number }
12export type Mini = { color: number; mood: Mood }
13// A message on its way from a small crab to the crab: `from` is the sender's place among
14// the small crabs, one past the drawn ones flying from their count; `progress` runs 0 to 1
15export type Flight = { from: number; color: number; progress: number }
16// `hidden`: the small crabs past MAX_MINIS, counted as `+N` instead of drawn
17export type Scene = { mood: Mood; minis: readonly Mini[]; hidden: number; flights: readonly Flight[] }
18
19export const FRAME_MS = 250
20export const PERSONA_ROWS = 4
21export const MAX_MINIS = 8
22const PIXEL_ROWS = PERSONA_ROWS * 2
23const CRAB_COLUMNS = 16
24const MARK_COLUMNS = 4
25const MINI_COLUMNS = 7
26const MINI_ROWS = 4
27// The terminal row just over a small crab's head, where its glyph shows
28const MINI_GLYPH_ROW = PERSONA_ROWS - MINI_ROWS / 2 - 1
29const MINI_GAP = 1
30// Each small crab keeps its own time, so they neither blink nor type in step
31const MINI_TICK_SHIFT = 7
32const HAPPY_FRAMES = 20
33// A done subagent's small crab cheers as long as the crab does, then leaves
34export const CHEER_FRAMES = HAPPY_FRAMES
35const SLEEP_FRAMES = 240
36const BLINK_EVERY = 16
37const GLANCE_EVERY = 64
38const GLANCE_FRAMES = 8
39const Z_FRAMES = 3
40const GLOW_FRAMES = 4
41const BUBBLE_FRAMES = 2
42
43const DEFAULT_COLOR = 0x01000000
44// The `claude` color of Claude Code's theme, the one of its welcome crab
45export const CLAUDE_COLOR = 0xd77757
46const Z_COLOR = 0x8b949e
47const SPARK_COLOR = 0xe3b341
48const BUBBLE_COLOR = 0xd2a8ff
49const COUNT_COLOR = 0x8b949e
50const PAPER_COLOR = 0xf0f6fc
51// A white envelope, its flap in the sender's color
52const ENVELOPE = ['PFP', 'PPP']
53const ENVELOPE_COLUMNS = 3
54// It leaves from over the sender's head and arcs this many pixels higher on its way
55const ENVELOPE_TOP = PIXEL_ROWS - MINI_ROWS - ENVELOPE.length
56const FLIGHT_ARC = 2
57
58type Palette = Readonly<Record<string, number>>
59
60const mixColor = (from: number, to: number, amount: number): number => {
61 let mixed = 0
62 for (const shift of [16, 8, 0]) {
63 const a = (from >> shift) & 0xff
64 const b = (to >> shift) & 0xff
65 mixed |= Math.round(a + (b - a) * amount) << shift
66 }
67 return mixed
68}
69
70// `E` eyes, `W` their glint, `K` shut eyes, `P` blush, and the laptop: `L` lid, `G` and `N`
71// its logo's two glows, `B` base
72const FIXED: Palette = {
73 E: 0x1c1c22,
74 W: 0xffffff,
75 K: 0x4b2318,
76 P: 0xff9696,
77 L: 0x8b949e,
78 G: 0xf0f6fc,
79 N: 0x58a6ff,
80 B: 0x6e7681,
81}
82
83// `H` highlight, `O` the color itself, `S` shadow, `D` the darkest, for feet and shut eyes
84export const paletteFor = (color: number): Palette => ({
85 ...FIXED,
86 H: mixColor(color, 0xffffff, 0.28),
87 O: color,
88 S: mixColor(color, 0x000000, 0.25),
89 D: mixColor(color, 0x000000, 0.5),
90})
91
92const CRAB_PALETTE = paletteFor(CLAUDE_COLOR)
93
94const HEAD = ['....HHHHHHHH....', '...HOOOOOOOOS...']
95const STANDING = ['SOOHOOOOOOOOSOOS', '...SSSSSSSSSS...', '....S.S..S.S....', '....D.D..D.D....']
96const GLINTING_EYES = '...HOWEOOWEOS...'
97const EYES_AHEAD = [GLINTING_EYES, '...HOEEOOEEOS...']
98const EYES_LEFT = ['...HWEOOWEOOS...', '...HEEOOEEOOS...']
99const EYES_RIGHT = ['...HOOWEOOWES...', '...HOOEEOOEES...']
100const EYES_SHUT = ['...HOOOOOOOOS...', '...HOKKOOKKOS...']
101
102// One claw up at its head, eyes turned to the bubble
103const PONDERING = [
104 '....HHHHHHHH.O..',
105 '...HOOOOOOOOSO..',
106 '...HOOWEOOWESO..',
107 '...HOOEEOOEESS..',
108 'SOOHOOOOOOOOS...',
109 '...SSSSSSSSSS...',
110 '....S.S..S.S....',
111 '....D.D..D.D....',
112]
113
114const JUMPING = [
115 'O..HHHHHHHHHH..O',
116 'OO.HOOOOOOOOS.OO',
117 '.OOHOWEOOWEOSOO.',
118 '...HOEEOOEEOS...',
119 '...HPOOOOOOPS...',
120 '...SSSSSSSSSS...',
121 '...S.S....S.S...',
122 '................',
123]
124const LANDED = [...HEAD, ...EYES_AHEAD, 'SOOHPOOOOOOPSOOS', ...STANDING.slice(1)]
125
126const ASLEEP = [
127 '................',
128 '....HHHHHHHH....',
129 '...HOOOOOOOOS...',
130 '...HOOOOOOOOS...',
131 '...HOKKOOKKOS...',
132 '.SOHOOOOOOOOSOS.',
133 '...SSSSSSSSSS...',
134 '...DD.D..D.DD...',
135]
136
137// The small crab, 7 by 4, too small for glints: the same poses drawn plainer
138const MINI_HEAD = '.HHHHH.'
139const MINI_BODY = ['SOOOOOS', '.D...D.']
140const MINI_EYES_AHEAD = '.HEOES.'
141const MINI_EYES_LEFT = '.EOEOS.'
142const MINI_EYES_RIGHT = '.HOEOE.'
143const MINI_EYES_SHUT = '.HDODS.'
144const MINI_PONDERING = ['.HHHHHO', '.HOEOEO', 'SOOOOS.', '.D...D.']
145const MINI_CHEERING = ['OHHHHHO', 'OHEOESO', '.POOOP.', '.D...D.']
146const MINI_LANDED = [MINI_HEAD, MINI_EYES_AHEAD, 'SPOOOPS', '.D...D.']
147const MINI_ASLEEP = ['.......', MINI_HEAD, 'SHDODSS', '.DSSSD.']
148
149type Mark = { row: number; column: number; glyph: string; color: number }
150type Pose = { sprite: readonly string[]; marks: readonly Mark[] }
151
152export const IDLE: Activity = { isWorking: false, isThinking: false, endedAt: -SLEEP_FRAMES, isCheering: false }
153
154// A subagent's small crab works, thinks, cheers once done, and falls asleep after a minute
155// of waiting, as the crab does for the main loop
156export const agentMoodFor = (agent: AgentActivity, tick: number): Mood => {
157 if (agent.phase === 'running') return agent.isThinking ? 'thinking' : 'working'
158 if (agent.phase === 'done') return 'happy'
159 return tick - agent.since >= SLEEP_FRAMES ? 'sleeping' : 'idle'
160}
161
162// A finished answer cheers for a moment; a quiet session falls asleep after a minute
163export const moodFor = (activity: Activity, tick: number): Mood => {
164 if (activity.isWorking) return activity.isThinking ? 'thinking' : 'working'
165 const quiet = tick - activity.endedAt
166 if (activity.isCheering && quiet < HAPPY_FRAMES) return 'happy'
167 return quiet >= SLEEP_FRAMES ? 'sleeping' : 'idle'
168}
169
170// Blinks now and then, and looks left, then later right, as if around the room
171type Gaze = 'ahead' | 'left' | 'right' | 'shut'
172
173const gazeAt = (tick: number): Gaze => {
174 if (tick % BLINK_EVERY === 0) return 'shut'
175 const glance = tick % GLANCE_EVERY
176 if (glance >= 20 && glance < 20 + GLANCE_FRAMES) return 'left'
177 return glance >= 44 && glance < 44 + GLANCE_FRAMES ? 'right' : 'ahead'
178}
179
180const EYES: Record<Gaze, readonly string[]> = { ahead: EYES_AHEAD, left: EYES_LEFT, right: EYES_RIGHT, shut: EYES_SHUT }
181
182const idle = (tick: number): Pose => ({ sprite: [...HEAD, ...EYES[gazeAt(tick)], ...STANDING], marks: [] })
183
184// Behind a laptop seen from the back, one claw up and one down in turn, as when typing,
185// the logo on the lid pulsing between two glows
186const working = (tick: number): Pose => {
187 const isLeftUp = tick % 2 === 0
188 const logo = Math.floor(tick / GLOW_FRAMES) % 2 === 0 ? 'GG' : 'NN'
189 return {
190 sprite: [
191 ...HEAD,
192 GLINTING_EYES,
193 isLeftUp ? '.OOHOEEOOEEOS...' : '...HOEEOOEEOSOS.',
194 isLeftUp ? '..OLLLLLLLLLLSS.' : '.OOLLLLLLLLLLS..',
195 `...LLLL${logo}LLLL...`,
196 '...LLLLLLLLLL...',
197 '..BBBBBBBBBBBB..',
198 ],
199 marks: [],
200 }
201}
202
203// A bubble beside its raised claw grows one dot at a time, holds whole, then starts over
204const thinking = (tick: number): Pose => {
205 const phase = Math.floor(tick / BUBBLE_FRAMES) % 4
206 const bubble: Mark[] = [
207 { row: 2, column: 16, glyph: '.', color: BUBBLE_COLOR },
208 { row: 1, column: 17, glyph: 'o', color: BUBBLE_COLOR },
209 { row: 0, column: 18, glyph: 'O', color: BUBBLE_COLOR },
210 ]
211 return { sprite: PONDERING, marks: bubble.slice(0, Math.min(phase, 2) + 1) }
212}
213
214// Jumps with its claws up and its cheeks flushed, sparks flying
215const happy = (tick: number): Pose => {
216 const isUp = tick % 2 === 1
217 return {
218 sprite: isUp ? JUMPING : LANDED,
219 marks: [isUp ? { row: 0, column: 16, glyph: '*', color: SPARK_COLOR } : { row: 1, column: 17, glyph: '+', color: SPARK_COLOR }],
220 }
221}
222
223// Slumped, eyes shut, claws down, and a `z` rising one step at a time before the next starts low
224const sleeping = (tick: number): Pose => {
225 const phase = Math.floor(tick / Z_FRAMES) % 4
226 const zs: Mark[] = [
227 { row: 2, column: 16, glyph: 'z', color: Z_COLOR },
228 { row: 1, column: 17, glyph: 'z', color: Z_COLOR },
229 { row: 0, column: 18, glyph: 'Z', color: Z_COLOR },
230 ]
231 return { sprite: ASLEEP, marks: phase === 3 ? [] : zs.slice(0, phase + 1) }
232}
233
234const POSES: Record<Mood, (tick: number) => Pose> = { idle, working, thinking, happy, sleeping }
235
236// The small crab's poses; its one glyph a pose shows sits in the row over its head
237const MINI_GAZE: Record<Gaze, string> = {
238 ahead: MINI_EYES_AHEAD,
239 left: MINI_EYES_LEFT,
240 right: MINI_EYES_RIGHT,
241 shut: MINI_EYES_SHUT,
242}
243
244const MINI_POSES: Record<Mood, (tick: number) => Pose> = {
245 idle: tick => ({ sprite: [MINI_HEAD, MINI_GAZE[gazeAt(tick)], ...MINI_BODY], marks: [] }),
246 working: tick => {
247 const isLeftUp = tick % 2 === 0
248 const logo = Math.floor(tick / GLOW_FRAMES) % 2 === 0 ? 'G' : 'N'
249 return {
250 sprite: [MINI_HEAD, isLeftUp ? 'OHEOES.' : '.HEOESS', isLeftUp ? `.LL${logo}LLS` : `OLL${logo}LL.`, 'BBBBBBB'],
251 marks: [],
252 }
253 },
254 thinking: tick => {
255 const glyph = '.oOO'.charAt(Math.floor(tick / BUBBLE_FRAMES) % 4)
256 return { sprite: MINI_PONDERING, marks: [{ row: MINI_GLYPH_ROW, column: 6, glyph, color: BUBBLE_COLOR }] }
257 },
258 // Claws up and down in turn, too small to leave the ground
259 happy: tick => {
260 const isUp = tick % 2 === 1
261 return {
262 sprite: isUp ? MINI_CHEERING : MINI_LANDED,
263 marks: [{ row: MINI_GLYPH_ROW, column: isUp ? 6 : 0, glyph: isUp ? '*' : '+', color: SPARK_COLOR }],
264 }
265 },
266 sleeping: tick => {
267 const glyph = 'zZ'.charAt(Math.floor(tick / Z_FRAMES) % 3)
268 return { sprite: MINI_ASLEEP, marks: glyph === '' ? [] : [{ row: MINI_GLYPH_ROW, column: 5, glyph, color: Z_COLOR }] }
269 },
270}
271
272const countText = (hidden: number): string => (hidden > 0 ? `+${hidden}` : '')
273
274// The small crabs and their count take the left, a gap, then the crab and its glyphs
275export const sceneColumns = (minis: number, hidden: number): number => {
276 const count = countText(hidden)
277 const crew = minis * (MINI_COLUMNS + MINI_GAP) + (count === '' ? 0 : count.length + MINI_GAP)
278 return crew + CRAB_COLUMNS + MARK_COLUMNS
279}
280
281type Canvas = { columns: number; pixels: (number | null)[][]; marks: Mark[] }
282
283const stamp = (canvas: Canvas, sprite: readonly string[], palette: Palette, left: number, top: number): void => {
284 sprite.forEach((line, y) => {
285 for (let x = 0; x < line.length; x++) {
286 const color = palette[line.charAt(x)]
287 const row = canvas.pixels[top + y]
288 if (color !== undefined && row !== undefined && left + x < canvas.columns) row[left + x] = color
289 }
290 })
291}
292
293export const composeScene = (scene: Scene, tick: number): Canvas => {
294 const columns = sceneColumns(scene.minis.length, scene.hidden)
295 const canvas: Canvas = {
296 columns,
297 pixels: Array.from({ length: PIXEL_ROWS }, () => Array<number | null>(columns).fill(null)),
298 marks: [],
299 }
300 let left = 0
301 scene.minis.forEach((mini, place) => {
302 const pose = MINI_POSES[mini.mood](tick + place * MINI_TICK_SHIFT)
303 stamp(canvas, pose.sprite, paletteFor(mini.color), left, PIXEL_ROWS - MINI_ROWS)
304 for (const mark of pose.marks) canvas.marks.push({ ...mark, column: left + mark.column })
305 left += MINI_COLUMNS + MINI_GAP
306 })
307 const count = countText(scene.hidden)
308 for (let i = 0; i < count.length; i++) canvas.marks.push({ row: PERSONA_ROWS - 1, column: left + i, glyph: count.charAt(i), color: COUNT_COLOR })
309 const countLeft = left
310 if (count !== '') left += count.length + MINI_GAP
311 const pose = POSES[scene.mood](tick)
312 stamp(canvas, pose.sprite, CRAB_PALETTE, left, 0)
313 for (const mark of pose.marks) canvas.marks.push({ ...mark, column: left + mark.column })
314 for (const flight of scene.flights) {
315 const start = flight.from < scene.minis.length ? flight.from * (MINI_COLUMNS + MINI_GAP) + (MINI_COLUMNS - ENVELOPE_COLUMNS) / 2 : countLeft
316 const { column, row } = flightPoint(start, left, flight.progress)
317 stamp(canvas, ENVELOPE, { P: PAPER_COLOR, F: flight.color }, column, row)
318 }
319 return canvas
320}
321
322// Eased along the row so it sets off and lands gently, lifted most halfway
323export const flightPoint = (start: number, end: number, progress: number): { column: number; row: number } => {
324 const t = Math.min(1, Math.max(0, progress))
325 const eased = t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t)
326 return { column: Math.round(start + (end - start) * eased), row: Math.round(ENVELOPE_TOP - FLIGHT_ARC * 4 * t * (1 - t)) }
327}
328
329// `▀` paints the top pixel with the foreground and the bottom one with the background; a
330// lone bottom pixel takes `▄`, since a default foreground would draw the empty top
331const halfBlock = (top: number | null, bottom: number | null): [number, number, number] => {
332 if (top === null && bottom === null) return [0x20, DEFAULT_COLOR, DEFAULT_COLOR]
333 if (top === null) return [0x2584, bottom ?? DEFAULT_COLOR, DEFAULT_COLOR]
334 return [0x2580, top, bottom ?? DEFAULT_COLOR]
335}
336
337// The Raster's `cells`: row-major `[codePoint, foreground, background]` u32 triplets in base64
338export const encodeCanvas = (canvas: Canvas): string => {
339 const words: number[] = []
340 for (let row = 0; row < PERSONA_ROWS; row++) {
341 for (let column = 0; column < canvas.columns; column++) {
342 const mark = canvas.marks.find(m => m.row === row && m.column === column)
343 if (mark !== undefined) {
344 words.push(mark.glyph.charCodeAt(0), mark.color, DEFAULT_COLOR)
345 } else {
346 words.push(...halfBlock(canvas.pixels[row * 2]?.[column] ?? null, canvas.pixels[row * 2 + 1]?.[column] ?? null))
347 }
348 }
349 }
350 return new Uint8Array(Uint32Array.from(words).buffer).toBase64()
351}
352
353export const drawScene = (scene: Scene, tick: number): { columns: number; cells: string } => {
354 const canvas = composeScene(scene, tick)
355 return { columns: canvas.columns, cells: encodeCanvas(canvas) }
356}
357