Pixel-art Clawd plays one of 17 random scenes in a tiny band under the spinner while Claude works

English · Türkçe
Small, focused mods for Claude Code, built on its function-hook plugin API. Each folder is one plugin; the repo root is a plugin marketplace, so you install only the mods you want.
AskUserQuestion, an idle prompt).size: large in /config brings back the 32×2 half-block band (preview). The large band animates with $.ui.blit, so the rest of the band is not redrawn every frame.%34; 34% 336k came out garbled in the status line.git status with core.fsmonitor off, so a cloned repo's config cannot start a program every 15 s.todo-pane, agents-pane, git-band, time-tracker, session-journal.| Mod | What it does |
|---|---|
usage-meter | Status line: context fill bar with tokens/window, a live turn timer, the last turn's tokens, session cost and 5-hour rate-limit usage. |
clear-reminder | Status line counting prompts since the last /clear (🟢 mesaj ▰▰▱ 7/20); turns yellow at 17 and red at 20, with a toast. |
sys-meter | Status line with CPU load, RAM usage and temperature (🟢 cpu ▰▱▱ %14 · ram ▰▰▰ %78 24.6/31.6G · 🌡 69°C), refreshed every 5 s. Windows only; the temperature is the ACPI thermal zone, readable without admin rights, not the CPU package sensor. |
git-band | One line above the prompt: branch, changed-file count, ahead/behind. Refreshes every 15 s and after each turn. |
todo-pane | Live pane of Claude's todo list (TodoWrite or TaskCreate/TaskUpdate) with a progress bar. Opens by itself; /todos reopens it. |
agents-pane | Live pane of subagents with status, elapsed time and a Stop button for each running one. /subagents reopens it. |
session-diff | /session-diff lists every file changed this session with +/− counts; press a file to read its diff (b back, k/j page). |
| Mod | What it does |
|---|---|
prompt-score | Toasts a quality score for every prompt you type (clarity, context, specificity, verifiability) with one hint for the weakest part. Local rules, no API calls. Hints are in Turkish. |
time-tracker | Adds up how long Claude works per project per day; /zaman shows the last 7 days. |
session-journal | Appends one line per turn to ~/.claude/journal/<date>.md; /gunluk adds a Haiku-written summary of the session. |
| Mod | What it does |
|---|---|
clawd-spinner | A tiny 16×1 band under the spinner (block octants; set size to large in /config for a 32×2 half-block band if your font lacks octants) where pixel-art Clawd plays one of 17 scenes, picked at random for every command (UFO, cooking, fishing, rain, music, laptop, rocket, growing plant, falling blocks, sleeping…). Time-of-day backdrop: morning coffee, a night sky, Friday-evening confetti. |
| Mod | What it does |
|---|---|
ask-alert | When Claude waits for you (a permission prompt from an ask rule, an AskUserQuestion, an idle prompt, an MCP input request) it raises a Windows notification, a toast in the terminal and plays Windows Notify System Generic.wav. mode in /plugin configure: both (default), toast or sound. Windows only. |
done-sound | Plays a random mp3 from its sounds/ folder when Claude finishes answering, never the same twice in a row (Windows, through PowerShell's MediaPlayer). |
At a Claude Code prompt:
/plugin install usage-meter --marketplace kad1r/claude-mods
Answer y to add the marketplace, then pick a scope. Repeat with any mod name from the tables above. /reload-plugins picks up updates.
done-sound: no sound files are in the repo. Put one or more mp3s in done-sound/sounds/ inside the installed plugin folder; each finished answer plays a random one, never the same twice in a row.
ask-alert on 2.1.300.session-journal writes the first line of your prompts to disk in plain text. Keep that in mind if you paste secrets into prompts./gunluk makes one Haiku request each time you run it.ask-alert does not fire in bypassPermissions mode for permission prompts, since none are shown. Where an organization manages Claude Code, its Notification hook runs only if the organization lists the plugin; the AskUserQuestion alert still works.scene.ts, the status-line and pane text from each mod's functions), not cropped from a live session. The Windows notification in ask-alert.svg is drawn to look like the real one.Each mod is three files: .claude-plugin/plugin.json, hooks/hooks.json and a hooks module in hooks/. Mods that keep values in $.state also have types/index.d.ts.
claude plugin validate ./<mod>
claude plugin test ./<mod>
To run from a local clone, add it as a marketplace (claude plugin marketplace add <path>), install from it, and run /reload-plugins after edits; the plugin is read straight from the folder.
<a id="türkçe"></a>
English · Türkçe
Claude Code için küçük, tek işe odaklı mod'lar. Hepsi Claude Code'un function-hook plugin API'si ile yazıldı. Her klasör bir plugin; repo kökü bir plugin marketplace'i, yani sadece istediğin mod'ları kurarsın.
AskUserQuestion, boşta kalan prompt) Windows bildirimi ve ses./config içinde size: large seçilirse 32×2'lik yarım-blok bant geri gelir (önizleme). Büyük bant $.ui.blit ile oynuyor, yani her karede bandın geri kalanı yeniden çizilmiyor.%34 biçiminde yazıyor; 34% 336k durum satırında bozuk görünüyordu.git status'u core.fsmonitor kapalı çalıştırıyor; klonlanan bir reponun ayarı 15 saniyede bir program başlatamaz.todo-pane, agents-pane, git-band, time-tracker, session-journal.| Mod | Ne yapar |
|---|---|
usage-meter | Durum satırı: context doluluk çubuğu ve token/pencere, canlı turn sayacı, son turn'ün token'ı, oturum maliyeti, 5 saatlik limit kullanımı. |
clear-reminder | Son /clear'dan beri yazdığın mesajları sayan durum satırı (🟢 mesaj ▰▰▱ 7/20); 17'de sarı, 20'de kırmızı olur ve toast gösterir. |
sys-meter | CPU yükü, RAM kullanımı ve sıcaklığı gösteren durum satırı (🟢 cpu ▰▱▱ %14 · ram ▰▰▰ %78 24.6/31.6G · 🌡 69°C), 5 saniyede bir yenilenir. Sadece Windows; sıcaklık admin yetkisi gerektirmeyen ACPI thermal zone değeridir, CPU paket sensörü değil. |
git-band | Prompt'un üstünde tek satır: branch, değişen dosya sayısı, ahead/behind. 15 saniyede bir ve her turn sonunda yenilenir. |
todo-pane | Claude'un yapılacaklar listesini (TodoWrite veya TaskCreate/TaskUpdate) ilerleme çubuğuyla gösteren canlı panel. Kendiliğinden açılır; /todos tekrar açar. |
agents-pane | Subagent'ları durum ve süreyle gösteren canlı panel; çalışan her birinin yanında Durdur butonu. /subagents tekrar açar. |
session-diff | /session-diff bu oturumda değişen dosyaları +/− sayılarıyla listeler; bir dosyaya basınca diff'ini gösterir (b geri, k/j sayfa). |
| Mod | Ne yapar |
|---|---|
prompt-score | Yazdığın her prompt'a kalite puanı verir (netlik, bağlam, kapsam, doğrulanabilirlik) ve en zayıf kısım için tek bir ipucu gösterir. Yerel kurallar, API çağrısı yok. |
time-tracker | Claude'un proje bazında günlük çalıştığı süreyi toplar; /zaman son 7 günü gösterir. |
session-journal | Her turn için ~/.claude/journal/<tarih>.md dosyasına bir satır ekler; /gunluk oturumun Haiku ile yazılmış özetini ekler. |
| Mod | Ne yapar |
|---|---|
clawd-spinner | Spinner'ın altında 16×1'lik minik bir bant (blok octant karakterleri; fontun bunları çizmiyorsa /config'ten size ayarını large yap, 32×2 yarım-blok bant gelir): piksel Clawd her komutta rastgele seçilen 17 sahneden birini oynar (UFO, yemek, balık tutma, yağmur, müzik, laptop, roket, büyüyen çiçek, düşen bloklar, uyku…). Arka plan saate göre değişir: sabah kahve, gece gökyüzü, cuma akşamı konfeti. |
| Mod | Ne yapar |
|---|---|
ask-alert | Claude seni beklediğinde (ask kuralından gelen izin isteği, AskUserQuestion, boşta kalan prompt, MCP'nin bilgi isteği) Windows bildirimi ve terminal içi toast gösterir, Windows Notify System Generic.wav çalar. /plugin configure içinde mode: both (varsayılan), toast veya sound. Sadece Windows. |
done-sound | Claude cevabı bitirince sounds/ klasöründen rastgele bir mp3 çalar, aynısı art arda gelmez (Windows, PowerShell MediaPlayer ile). |
Claude Code prompt'unda:
/plugin install usage-meter --marketplace kad1r/claude-mods
Marketplace'i eklemek için y de, sonra scope seç. Tablolardaki diğer mod adlarıyla tekrarla. Güncellemeler için /reload-plugins.
done-sound: Ses dosyaları repoda yok. Kurulan plugin klasöründe done-sound/sounds/ altına bir veya daha fazla mp3 koy; her cevap bitiminde rastgele biri çalar, aynısı art arda gelmez.
ask-alert 2.1.300 üzerinde.session-journal, prompt'larının ilk satırını diske düz metin olarak yazar. Prompt'a secret yapıştırıyorsan bunu göz önünde tut./gunluk her çalıştığında bir Haiku isteği yapar.ask-alert, bypassPermissions modunda izin uyarısı vermez, çünkü izin ekranı çıkmaz. Claude Code'u organizasyon yönetiyorsa Notification hook'u ancak organizasyon plugin'i listelerse çalışır; AskUserQuestion uyarısı yine çalışır.scene.ts'den, durum satırı ve panel metinleri mod fonksiyonlarından); canlı oturumdan kırpılmadı. ask-alert.svg içindeki Windows bildirimi gerçeğine benzetilerek çizildi.Her mod üç dosyadan oluşur: .claude-plugin/plugin.json, hooks/hooks.json ve hooks/ altında bir hooks modülü. $.state kullananlarda ayrıca types/index.d.ts vardır.
claude plugin validate ./<mod>
claude plugin test ./<mod>
Yerel bir klondan çalıştırmak için klonu marketplace olarak ekle (claude plugin marketplace add <yol>), oradan kur ve değişikliklerden sonra /reload-plugins çalıştır; plugin doğrudan klasörden okunur.
hooks/register.tsx 100 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import { cells, columnsFor, frame, moodAt, octantSpans, pickScene, rowsFor } from './scene'
4import type { Mood, Scene, Size } from './scene'
5
6const KEY = 'clawd'
7// The small band redraws its text each frame, so it runs a little slower.
8const FRAME_MS: Record<Size, number> = { small: 160, large: 125 }
9
10// One animation at a time, for the main loop's turn.
11let turnId: string | undefined
12let scene: Scene | undefined
13let mood: Mood = 'none'
14let tick = 0
15let timer: Timer | undefined
16// The band the large Raster is mounted in, learned when the band draws it.
17let bandId: string | undefined
18// `small` (16 x 1 cells) unless the person picked `large` (32 x 2) in /config.
19let size: Size = 'small'
20
21const canvas = () => frame(scene ?? 'walk', tick, mood)
22
23function stop() {
24 timer?.cancel()
25 timer = undefined
26}
27
28// Large: repaints the mounted Raster alone, the rest of the band stays as drawn;
29// a refused blit means the Raster is gone (turn over, band hidden).
30// Small: its octant glyphs lie beyond what a Raster takes, so the band redraws.
31async function step($: EngineInterface) {
32 tick += 1
33 if (size === 'small') {
34 $.ui.invalidate('ui.render')
35 return
36 }
37 if (bandId === undefined) return
38 const done = await $.ui.blit({ requestId: bandId, key: KEY, cells: cells(canvas()) }).catch(() => ({ deny: 'failed' }))
39 if ('deny' in done && done.deny !== undefined) stop()
40}
41
42export const register: Register = (on, options) => {
43 size = options['size'] === 'large' ? 'large' : 'small'
44
45 on('turn.start', ($, e, next) => {
46 stop()
47 turnId = e.turnId
48 tick = 0
49 // A random scene for every command, never the same one twice in a row.
50 scene = pickScene(scene, Math.random())
51 mood = moodAt(new Date())
52 timer = $.clock.every(FRAME_MS[size], () => step($))
53 return next(e)
54 })
55
56 // A subagent's turn ends inside the main one; only the main turn's end stops Clawd.
57 on('turn.complete', ($, e, next) => {
58 if (e.agentId === undefined && (turnId === undefined || e.turnId === turnId)) {
59 stop()
60 turnId = undefined
61 $.ui.invalidate('ui.render')
62 }
63 return next(e)
64 })
65
66 // Wraps what other plugins draw above the prompt (git-band, ...) instead of replacing it.
67 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
68 const below = await next(e)
69 const columns = columnsFor(size)
70 const rows = rowsFor(size)
71 const hasRoom = e.props.bodyColumns >= columns && e.props.maxRows > rows + 2
72 if (!e.props.isWorking || e.props.hasSurvey || e.surface !== 'terminal' || !hasRoom) {
73 return below
74 }
75
76 const { Box, Raster, Text } = $.ui.resolve(e)
77 bandId = e.requestId
78
79 const band =
80 size === 'small' ? (
81 <Box key={KEY}>
82 {octantSpans(canvas()).map(s => (
83 <Text color={s.color} backgroundColor={s.background}>
84 {s.text}
85 </Text>
86 ))}
87 </Box>
88 ) : (
89 <Raster key={KEY} columns={columns} rows={rows} cells={cells(canvas())} />
90 )
91
92 return (
93 <Box flexDirection="column">
94 {band}
95 {below}
96 </Box>
97 )
98 })
99}
100hooks/scene.ts 368 lines1import { OCTANT } from './octants'
2
3// Pixel scenes for a small band under the spinner: BAND_W x H pixels. The
4// `small` size packs 2 x 4 pixels into each terminal cell (block octants), so the
5// band is 16 x 1 cells; `large` packs 1 x 2 (half blocks), 32 x 2 cells.
6// Clawd keeps his full 7 x 4 shape and stands at CLAWD_X in every scene but
7// `walk`; each scene's props stand to his right, the time-of-day backdrop at
8// the band's right end. Every turn shows one scene, picked at random.
9
10export const BAND_W = 32
11export const H = 4
12export const ROWS = H / 2
13
14export type Size = 'small' | 'large'
15
16export const columnsFor = (size: Size): number => (size === 'small' ? BAND_W / 2 : BAND_W)
17export const rowsFor = (size: Size): number => (size === 'small' ? H / 4 : ROWS)
18const NONE = -1
19const DEFAULT_COLOR = 0x01000000
20
21const PALETTE: Record<string, number> = {
22 O: 0xd97757, // Clawd orange
23 D: 0xb85c3e, // Clawd shade (blink)
24 K: 0x1a1a1a, // eyes
25 G: 0x8a8a96, // UFO hull
26 W: 0xeeeeee, // white
27 Y: 0xffd84d, // yellow
28 B: 0x8b6a3e, // wood
29 P: 0xe8e4d8, // paper
30 L: 0xb8b2a6, // light gray
31 S: 0x6b6b70, // steel
32 C: 0xfff3a0, // beam
33 M: 0xf4efc4, // moon
34 F: 0x5a3a22, // coffee
35 R: 0xe5484d, // confetti red
36 U: 0x4c8dff, // confetti blue
37 V: 0xb36bff, // confetti purple
38 N: 0x46c46e, // confetti green
39}
40
41export const SCENES = [
42 'walk',
43 'ufo',
44 'cook',
45 'think',
46 'build',
47 'board',
48 'jump',
49 'rain',
50 'fishing',
51 'music',
52 'laptop',
53 'ball',
54 'rocket',
55 'plant',
56 'bug',
57 'blocks',
58 'sleep',
59] as const
60
61export type Scene = (typeof SCENES)[number]
62
63// A new scene for every turn, never the one just shown. `roll` is in [0, 1).
64export const pickScene = (previous: Scene | undefined, roll: number): Scene => {
65 const choices = SCENES.filter(s => s !== previous)
66 return choices[Math.min(choices.length - 1, Math.floor(roll * choices.length))] ?? 'walk'
67}
68
69// Time-of-day backdrop: coffee in the morning, moon and stars at night,
70// confetti after 17:00 on Friday.
71export type Mood = 'morning' | 'night' | 'party' | 'none'
72
73export const moodAt = (d: Date): Mood => {
74 const h = d.getHours()
75 if (d.getDay() === 5 && h >= 17) return 'party'
76 if (h >= 6 && h < 11) return 'morning'
77 if (h >= 20 || h < 6) return 'night'
78 return 'none'
79}
80
81export type Canvas = { width: number; px: Int32Array }
82
83const sprite = (c: Canvas, x: number, y: number, rows: readonly string[]) => {
84 rows.forEach((row, dy) => {
85 for (let dx = 0; dx < row.length; dx++) {
86 const color = PALETTE[row.charAt(dx)]
87 const px = x + dx
88 const py = y + dy
89 if (color !== undefined && px >= 0 && px < c.width && py >= 0 && py < H) {
90 c.px[py * c.width + px] = color
91 }
92 }
93 })
94}
95
96const dot = (c: Canvas, x: number, y: number, key: string) => sprite(c, x, y, [key])
97
98const pingpong = (t: number, span: number) => {
99 if (span <= 0) return 0
100 const p = t % (span * 2)
101 return p < span ? p : span * 2 - p
102}
103
104const CLAWD_W = 7
105const CLAWD_X = 1
106const PROP_X = CLAWD_X + CLAWD_W + 2
107
108const clawd = (c: Canvas, x: number, t: number, isWalking: boolean, isAsleep = false) => {
109 const isBlinking = isAsleep || t % 24 === 0
110 const legs = isWalking && t % 2 === 1 ? 'O O O O' : ' O O O '
111 sprite(c, x, 0, [' OOOOO ', isBlinking ? ' ODODO ' : ' OKOKO ', 'OOOOOOO', legs])
112}
113
114const Z = ['WWW', ' W ', 'WWW']
115// Landing spot and color of each block in the `blocks` scene, in drop order.
116const BLOCKS: ReadonlyArray<[number, number, string]> = [
117 [0, 3, 'R'],
118 [3, 3, 'U'],
119 [6, 3, 'N'],
120 [1, 2, 'Y'],
121 [4, 2, 'V'],
122]
123const BLOCK_FALL = 4
124
125const draw: Record<Scene, (c: Canvas, t: number) => void> = {
126 walk: (c, t) => clawd(c, pingpong(t, c.width - CLAWD_W), t, true),
127
128 jump: (c, t) => {
129 clawd(c, CLAWD_X, t, true)
130 const isUp = t % 4 < 2
131 dot(c, PROP_X, isUp ? 0 : 2, 'Y')
132 dot(c, PROP_X + 3, isUp ? 2 : 0, 'Y')
133 },
134
135 ufo: (c, t) => {
136 clawd(c, CLAWD_X, t, false)
137 const x = PROP_X + pingpong(t, 4)
138 sprite(c, x, 0, ['GGGGGG', t % 2 === 0 ? 'GYGGYG' : 'GGYGGY'])
139 if (t % 4 < 2) sprite(c, x + 2, 2, ['CC', 'CC'])
140 },
141
142 cook: (c, t) => {
143 clawd(c, CLAWD_X, t, false)
144 sprite(c, PROP_X, 1, ['S SSS S', 'SSSSSSS', ' SSSSS '])
145 for (let k = 0; k < 3; k++) dot(c, PROP_X + 1 + k * 2 + ((t + k) % 2), 0, 'W')
146 },
147
148 think: (c, t) => {
149 clawd(c, CLAWD_X, t, false)
150 const step = t % 12
151 if (step >= 3) dot(c, PROP_X - 1, 2, 'W')
152 if (step >= 6) dot(c, PROP_X, 1, 'W')
153 if (step >= 9) sprite(c, PROP_X + 1, 0, [' WWWWW ', 'WWKKWWW', ' WWWWW '])
154 },
155
156 build: (c, t) => {
157 clawd(c, CLAWD_X, t, false)
158 sprite(c, PROP_X + 1, 2, ['SSSSSS', ' SSSS '])
159 if (t % 4 >= 2) {
160 sprite(c, PROP_X, 1, ['BBBSS'])
161 dot(c, PROP_X + 6 + (t % 2), 0, 'Y')
162 } else {
163 sprite(c, PROP_X, 0, ['SS', 'B '])
164 }
165 },
166
167 board: (c, t) => {
168 clawd(c, CLAWD_X, t, false)
169 const lit = Math.floor(t / 6) % 3
170 const item = (i: number) => 'BP' + (i === lit ? 'Y' : 'L') + 'LLLLB'
171 sprite(c, PROP_X, 0, [item(0), item(1), item(2), 'BBBBBBBB'])
172 },
173
174 rain: (c, t) => {
175 clawd(c, CLAWD_X, t, false)
176 sprite(c, PROP_X, 0, [' LLLL ', 'LLLLLL'])
177 for (let k = 0; k < 3; k++) dot(c, PROP_X + 1 + k * 2, 2 + ((t + k) % 2), 'U')
178 },
179
180 fishing: (c, t) => {
181 clawd(c, CLAWD_X, t, false)
182 dot(c, PROP_X - 2, 1, 'B')
183 dot(c, PROP_X - 1, 0, 'B')
184 dot(c, PROP_X, 1, 'L')
185 dot(c, PROP_X, t % 4 < 2 ? 3 : 2, 'R')
186 sprite(c, PROP_X + 3 + pingpong(t, 3), [3, 2, 1, 2][t % 4] ?? 3, ['UU'])
187 },
188
189 music: (c, t) => {
190 clawd(c, CLAWD_X, t, false)
191 for (let k = 0; k < 2; k++) {
192 const y = 2 - ((t + k * 2) % 3)
193 sprite(c, PROP_X + k * 4 + (k === 0 ? 0 : 1), y, [' Y', 'YY'])
194 }
195 },
196
197 laptop: (c, t) => {
198 clawd(c, CLAWD_X, t, false)
199 sprite(c, PROP_X, 0, ['SSSSSS', 'SUUUUS', 'SUUUUS', 'LLLLLLL'])
200 if (t % 2 === 0) dot(c, PROP_X + 1 + (Math.floor(t / 2) % 4), 1 + (Math.floor(t / 8) % 2), 'W')
201 },
202
203 ball: (c, t) => {
204 clawd(c, CLAWD_X, t, false)
205 sprite(c, PROP_X + 1, [3, 2, 1, 0, 1, 2][t % 6] ?? 3, ['RR'])
206 },
207
208 rocket: (c, t) => {
209 clawd(c, CLAWD_X, t, false)
210 const x = PROP_X + (t % 14)
211 const flame = t % 2 === 0 ? 'Y' : 'R'
212 sprite(c, x, 1, [flame + 'GGGM', 'R' + 'GGG'])
213 },
214
215 plant: (c, t) => {
216 clawd(c, CLAWD_X, t, false)
217 const stage = Math.floor(t / 4) % 5
218 sprite(c, PROP_X + 1, 3, ['BBB'])
219 if (stage >= 1) dot(c, PROP_X + 2, 2, 'N')
220 if (stage >= 2) sprite(c, PROP_X + 1, 1, ['NNN'])
221 if (stage >= 3) dot(c, PROP_X + 2, 0, stage === 4 ? 'R' : 'N')
222 },
223
224 bug: (c, t) => {
225 clawd(c, CLAWD_X, t, false)
226 const step = t % 10
227 if (step < 9) sprite(c, PROP_X + 8 - step, 2, ['NNN', 'N N'])
228 else sprite(c, PROP_X - 1, 2, ['Y Y', ' Y '])
229 },
230
231 blocks: (c, t) => {
232 clawd(c, CLAWD_X, t, false)
233 const cycle = t % (BLOCKS.length * BLOCK_FALL + BLOCK_FALL)
234 const dropping = Math.floor(cycle / BLOCK_FALL)
235 BLOCKS.forEach(([dx, y, color], i) => {
236 if (i < dropping) sprite(c, PROP_X + dx, y, [color + color])
237 else if (i === dropping) sprite(c, PROP_X + dx, Math.min(y, cycle % BLOCK_FALL), [color + color])
238 })
239 },
240
241 sleep: (c, t) => {
242 clawd(c, CLAWD_X, t, false, true)
243 if (t % 8 < 6) sprite(c, PROP_X - 1, 1, Z)
244 if (t % 8 >= 3) sprite(c, PROP_X + 3, 0, Z)
245 },
246}
247
248// Stable pseudo-random per column, so stars and confetti do not jump around.
249const hash = (x: number) => {
250 let v = Math.imul(x ^ 0x9e3779b9, 0x85ebca6b)
251 v ^= v >>> 13
252 return (Math.imul(v, 0xc2b2ae35) ^ (v >>> 16)) >>> 0
253}
254
255const CONFETTI = ['R', 'Y', 'U', 'V', 'N', 'W']
256const BACKDROP_X = PROP_X + 10
257
258const backdrop: Record<Mood, (c: Canvas, t: number) => void> = {
259 none: () => {},
260
261 morning: (c, t) => {
262 const x = c.width - 5
263 sprite(c, x, 1, ['WWW ', 'WFWW', 'WWW '])
264 dot(c, x + (t % 2), 0, 'L')
265 sprite(c, x - 5, 0, [' Y ', 'YYY', ' Y '])
266 },
267
268 night: (c, t) => {
269 sprite(c, c.width - 4, 0, [' MM', 'M ', ' MM'])
270 for (let x = BACKDROP_X; x < c.width - 5; x++) {
271 const v = hash(x)
272 if (v % 3 !== 0) continue
273 if ((Math.floor(t / 4) + v) % 5 !== 0) dot(c, x, v % H, v % 2 === 0 ? 'Y' : 'W')
274 }
275 },
276
277 party: (c, t) => {
278 for (let x = BACKDROP_X; x < c.width; x++) {
279 const v = hash(x)
280 if (v % 2 !== 0) continue
281 dot(c, x, (Math.floor(t / (1 + (v % 3))) + v) % H, CONFETTI[v % CONFETTI.length] ?? 'Y')
282 }
283 },
284}
285
286export const frame = (scene: Scene, t: number, mood: Mood = 'none', width = BAND_W): Canvas => {
287 const c: Canvas = { width, px: new Int32Array(width * H).fill(NONE) }
288 // The walk crosses the whole band, so it gets no backdrop to walk through.
289 if (scene !== 'walk') backdrop[mood](c, t)
290 draw[scene](c, t)
291 return c
292}
293
294const toBase64 = (bytes: Uint8Array): string => {
295 let s = ''
296 for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i] ?? 0)
297 return btoa(s)
298}
299
300// Two pixel rows per terminal row: '▀' with the top pixel as foreground, the bottom as background.
301export const cells = (c: Canvas): string => {
302 const w = c.width
303 const words = new Uint32Array(w * ROWS * 3)
304 for (let r = 0; r < ROWS; r++) {
305 for (let x = 0; x < w; x++) {
306 const top = c.px[r * 2 * w + x] ?? NONE
307 const bottom = c.px[(r * 2 + 1) * w + x] ?? NONE
308 const i = (r * w + x) * 3
309 if (top === NONE && bottom === NONE) {
310 words.set([0x20, DEFAULT_COLOR, DEFAULT_COLOR], i)
311 } else if (top === NONE) {
312 words.set([0x2584, bottom, DEFAULT_COLOR], i)
313 } else {
314 words.set([0x2580, top, bottom === NONE ? DEFAULT_COLOR : bottom], i)
315 }
316 }
317 }
318 return toBase64(new Uint8Array(words.buffer))
319}
320
321// Two by four pixels per cell, drawn as text: Raster cells hold BMP characters only
322// and the octants lie beyond it. A cell has one foreground color: the one most of
323// its pixels have, and only those pixels are drawn. The background is the
324// terminal's, unless every pixel is painted, when the next color fills the rest.
325// A darker pixel inside Clawd (his eyes) shows as a gap, as on the half-block band.
326export type Span = { text: string; color?: string; background?: string }
327
328const hex = (v: number) => '#' + (v & 0xffffff).toString(16).padStart(6, '0')
329
330// One glyph per cell with its colors, left to right.
331export const octantCells = (c: Canvas): { glyph: number; fg?: number; bg?: number }[] => {
332 const out: { glyph: number; fg?: number; bg?: number }[] = []
333 for (let col = 0; col < Math.ceil(c.width / 2); col++) {
334 const counts = new Map<number, number>()
335 let empty = 0
336 const at = (k: number) => {
337 const x = col * 2 + (k % 2)
338 return x < c.width ? (c.px[Math.floor(k / 2) * c.width + x] ?? NONE) : NONE
339 }
340 for (let k = 0; k < 8; k++) {
341 const p = at(k)
342 if (p === NONE) empty += 1
343 else counts.set(p, (counts.get(p) ?? 0) + 1)
344 }
345 const ranked = [...counts].sort((a, b) => b[1] - a[1])
346 const fg = ranked[0]?.[0]
347 const bg = empty === 0 ? ranked[1]?.[0] : undefined
348 let bits = 0
349 for (let k = 0; k < 8; k++) if (fg !== undefined && at(k) === fg) bits |= 1 << k
350 out.push(bits === 0 ? { glyph: 0x20 } : { glyph: OCTANT[bits] ?? 0x2588, fg, bg })
351 }
352 return out
353}
354
355// Neighbouring cells of the same colors share one span.
356export const octantSpans = (c: Canvas): Span[] => {
357 const spans: Span[] = []
358 for (const cell of octantCells(c)) {
359 const color = cell.fg === undefined ? undefined : hex(cell.fg)
360 const background = cell.bg === undefined ? undefined : hex(cell.bg)
361 const last = spans[spans.length - 1]
362 const ch = String.fromCodePoint(cell.glyph)
363 if (last !== undefined && last.color === color && last.background === background) last.text += ch
364 else spans.push({ text: ch, color, background })
365 }
366 return spans
367}
368hooks/octants.ts 39 lines1// Glyph for each 2 x 4 pixel pattern of a terminal cell (Unicode 16 block octants
2// plus the older block elements that already draw some patterns). Index bit k is
3// pixel k: 0-1 the top row (left, right), 2-3 the second, 4-5 the third, 6-7 the
4// bottom. Generated from the Unicode character names; every pattern has a glyph.
5export const OCTANT: readonly number[] = [
6 0x20, 0x1cea8, 0x1ceab, 0x1fb82, 0x1cd00, 0x2598, 0x1cd01, 0x1cd02,
7 0x1cd03, 0x1cd04, 0x259d, 0x1cd05, 0x1cd06, 0x1cd07, 0x1cd08, 0x2580,
8 0x1cd09, 0x1cd0a, 0x1cd0b, 0x1cd0c, 0x1fbe6, 0x1cd0d, 0x1cd0e, 0x1cd0f,
9 0x1cd10, 0x1cd11, 0x1cd12, 0x1cd13, 0x1cd14, 0x1cd15, 0x1cd16, 0x1cd17,
10 0x1cd18, 0x1cd19, 0x1cd1a, 0x1cd1b, 0x1cd1c, 0x1cd1d, 0x1cd1e, 0x1cd1f,
11 0x1fbe7, 0x1cd20, 0x1cd21, 0x1cd22, 0x1cd23, 0x1cd24, 0x1cd25, 0x1cd26,
12 0x1cd27, 0x1cd28, 0x1cd29, 0x1cd2a, 0x1cd2b, 0x1cd2c, 0x1cd2d, 0x1cd2e,
13 0x1cd2f, 0x1cd30, 0x1cd31, 0x1cd32, 0x1cd33, 0x1cd34, 0x1cd35, 0x1fb85,
14 0x1cea3, 0x1cd36, 0x1cd37, 0x1cd38, 0x1cd39, 0x1cd3a, 0x1cd3b, 0x1cd3c,
15 0x1cd3d, 0x1cd3e, 0x1cd3f, 0x1cd40, 0x1cd41, 0x1cd42, 0x1cd43, 0x1cd44,
16 0x2596, 0x1cd45, 0x1cd46, 0x1cd47, 0x1cd48, 0x258c, 0x1cd49, 0x1cd4a,
17 0x1cd4b, 0x1cd4c, 0x259e, 0x1cd4d, 0x1cd4e, 0x1cd4f, 0x1cd50, 0x259b,
18 0x1cd51, 0x1cd52, 0x1cd53, 0x1cd54, 0x1cd55, 0x1cd56, 0x1cd57, 0x1cd58,
19 0x1cd59, 0x1cd5a, 0x1cd5b, 0x1cd5c, 0x1cd5d, 0x1cd5e, 0x1cd5f, 0x1cd60,
20 0x1cd61, 0x1cd62, 0x1cd63, 0x1cd64, 0x1cd65, 0x1cd66, 0x1cd67, 0x1cd68,
21 0x1cd69, 0x1cd6a, 0x1cd6b, 0x1cd6c, 0x1cd6d, 0x1cd6e, 0x1cd6f, 0x1cd70,
22 0x1cea0, 0x1cd71, 0x1cd72, 0x1cd73, 0x1cd74, 0x1cd75, 0x1cd76, 0x1cd77,
23 0x1cd78, 0x1cd79, 0x1cd7a, 0x1cd7b, 0x1cd7c, 0x1cd7d, 0x1cd7e, 0x1cd7f,
24 0x1cd80, 0x1cd81, 0x1cd82, 0x1cd83, 0x1cd84, 0x1cd85, 0x1cd86, 0x1cd87,
25 0x1cd88, 0x1cd89, 0x1cd8a, 0x1cd8b, 0x1cd8c, 0x1cd8d, 0x1cd8e, 0x1cd8f,
26 0x2597, 0x1cd90, 0x1cd91, 0x1cd92, 0x1cd93, 0x259a, 0x1cd94, 0x1cd95,
27 0x1cd96, 0x1cd97, 0x2590, 0x1cd98, 0x1cd99, 0x1cd9a, 0x1cd9b, 0x259c,
28 0x1cd9c, 0x1cd9d, 0x1cd9e, 0x1cd9f, 0x1cda0, 0x1cda1, 0x1cda2, 0x1cda3,
29 0x1cda4, 0x1cda5, 0x1cda6, 0x1cda7, 0x1cda8, 0x1cda9, 0x1cdaa, 0x1cdab,
30 0x2582, 0x1cdac, 0x1cdad, 0x1cdae, 0x1cdaf, 0x1cdb0, 0x1cdb1, 0x1cdb2,
31 0x1cdb3, 0x1cdb4, 0x1cdb5, 0x1cdb6, 0x1cdb7, 0x1cdb8, 0x1cdb9, 0x1cdba,
32 0x1cdbb, 0x1cdbc, 0x1cdbd, 0x1cdbe, 0x1cdbf, 0x1cdc0, 0x1cdc1, 0x1cdc2,
33 0x1cdc3, 0x1cdc4, 0x1cdc5, 0x1cdc6, 0x1cdc7, 0x1cdc8, 0x1cdc9, 0x1cdca,
34 0x1cdcb, 0x1cdcc, 0x1cdcd, 0x1cdce, 0x1cdcf, 0x1cdd0, 0x1cdd1, 0x1cdd2,
35 0x1cdd3, 0x1cdd4, 0x1cdd5, 0x1cdd6, 0x1cdd7, 0x1cdd8, 0x1cdd9, 0x1cdda,
36 0x2584, 0x1cddb, 0x1cddc, 0x1cddd, 0x1cdde, 0x2599, 0x1cddf, 0x1cde0,
37 0x1cde1, 0x1cde2, 0x259f, 0x1cde3, 0x2586, 0x1cde4, 0x1cde5, 0x2588,
38]
39