Classic media-player visualizer above the prompt: a plasma field and equalizer bars driven by the session (prompts, tool calls, turns). Needs function hooks…

A Claude Code function-hooks mod that draws a classic media-player visualizer (plasma field + equalizer bars + peak markers) in the band above the prompt. No daemon, no external process: the animation runs inside the hooks module at ~30 fps and the "music" is the session itself.


| session event | effect |
|---|---|
| prompt submitted | big kick, low bands jump |
| tool call | kick on a band chosen by the tool's name |
| turn running | bars pulse on a beat, plasma brightens and speeds up |
| turn done | full-width sweep, then everything fades to a slow idle drift |
The status row under the band reads:
▶ Bash ♪ 3 color: [ classic ] mode: [ plasma ]
The play/pause glyph is whether a turn is running, then the last tool called (or prompt / idle), ♪ N the number of tool calls so far, and two pickers. Focus the band (click it, or ctrl+x tab), Tab or the arrows onto a picker and press Enter to cycle it; the focused picker is drawn inverted by the engine, which is why the name is the button and not an arrow. Choices are kept across sessions.
mode picks the visualization:
plasma: a flowing color field with equalizer bars and peak markers. The left picker is then color, the bar gradient (classic, fire, ice, neon, lime, mono).cats: a row of pixel cats dancing on a rooftop at night, RealPlayer's Annabel the Sheep with cats: a moon, stars that twinkle more as the energy rises, a skyline whose windows light up with it, roof tiles that pulse on the beat. The groove is dumb and it never stops: the whole row bounces on every beat, up a pixel on one beat and down into a squat on the next, necks sway past each other in a slow wave, tails swing and eyes blink. Each cat owns a slice of the spectrum, so a tool call picks one out for most of a second and it does a special move, either a jump clear off the roof or a look-away with its tail swapped to the other side (a prompt picks the first cat, a finished turn sets all of them off). After about half the finished turns a mouse runs the roof from right to left, in front of their feet, and every head turns to follow it. Leave them alone and they sit down. One cat per breed, never repeated: orange tabby, calico, black, siamese, grey tabby, tuxedo, white, tortoiseshell; as many as the width fits, spread out evenly on a wide band. The left picker is then outfit: plain, party hat, headphones, sunglasses, bow tie, scarf, crown.One /config row (plugin retrovis-mod): Band height, the number of terminal rows the band takes (2 to 16, default 8). A change reloads the module. Right after the install the CLI points out that this option has no value set; ignore that if you like, since the default of 8 rows applies anyway. To pick a number, pass --config rows=N when you install, or visit /plugin configure retrovis-mod@xavierforge-mods whenever.
claude plugin marketplace add xavierforge/xavierforge-mods
claude plugin install retrovis-mod@xavierforge-mods --scope local # this project only
claude plugin install retrovis-mod@xavierforge-mods --scope user # or every project
The env flag that function hooks need, the restart that has to follow, and what to check when the band never shows up are all in the root README.
Cloned the repo instead of installing it? Then skip all of that, set the flag for just this shell, and hand the engine the mod folder directly, from the repo root:
export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
claude --plugin-dir ./mods/retrovis-mod
The terminal-only caveat still holds.
claude plugin validate ./mods/retrovis-mod # what the engine sees
claude plugin test ./mods/retrovis-mod # mounts the band under the engine
cd mods/retrovis-mod && npx -p typescript@5 tsc -p tsconfig.json
Both run from the repo root, where .claude/types holds the generated declarations this mod's tsconfig.json compiles against. hooks/vis.ts is pure (state, kicks, plasma frame, cell packing), hooks/cats.ts holds the cat sprites, breeds, outfits and stage, and hooks/register.tsx is the only file that touches $.
hooks/register.tsx 116 lines1/* @jsx h */
2// retrovis-mod: a classic media-player visualizer above the prompt. The
3// animation runs in this module on a 33 ms clock; session events kick it.
4// No variable here is named `h`.
5import type { Register } from 'claude-code'
6import { MODES, OUTFITS, THEMES, createState, cycleLeft, cycleMode, kick, leftPicker, renderFrame, step } from './vis.ts'
7
8const MIN_COLS = 20
9const MAX_COLS = 512
10const FRAME_MS = 33
11
12type Size = { cols: number; rows: number }
13
14export const register: Register = (on, options) => {
15 const bandRows = Math.max(2, Math.min(16, Math.round(Number(options.rows) || 8)))
16
17 const vis = createState()
18 let bandId: string | undefined
19 let size: Size = { cols: 80, rows: bandRows }
20 let lastTick = Date.now()
21 let inflight = false
22
23 // fixed-width columns so the pickers never move when the text changes
24 const TRACK_W = 14
25 const LEFT_W = Math.max(...THEMES.map(t => t.name.length), ...OUTFITS.map(o => o.length))
26 const MODE_W = Math.max(...MODES.map(m => m.length))
27 const status = () => (vis.working ? '▶ ' : '❚❚') + ` ${vis.track.slice(0, TRACK_W).padEnd(TRACK_W)} ♪ ${String(vis.hits).padEnd(4)}`
28 const centered = (n: string, w: number) => {
29 const pad = w - n.length
30 return ' '.repeat(Math.floor(pad / 2)) + n + ' '.repeat(Math.ceil(pad / 2))
31 }
32 const leftLabel = () => { const p = leftPicker(vis); return centered(p.values[p.index], LEFT_W) }
33 const modeLabel = () => centered(MODES[vis.mode], MODE_W)
34
35 on('session.start', async ($, e, next) => {
36 const [theme, outfit, mode] = await Promise.all([$.store.get('theme'), $.store.get('outfit'), $.store.get('mode')])
37 const t = THEMES.findIndex(x => x.name === theme); if (t >= 0) vis.theme = t
38 const o = OUTFITS.indexOf(outfit as (typeof OUTFITS)[number]); if (o >= 0) vis.outfit = o
39 const m = MODES.indexOf(mode as (typeof MODES)[number]); if (m >= 0) vis.mode = m
40 $.clock.every(FRAME_MS, async () => {
41 const now = Date.now()
42 const dt = Math.min(0.25, (now - lastTick) / 1000)
43 lastTick = now
44 step(vis, dt)
45 if (!bandId || inflight) return
46 inflight = true
47 try {
48 const r = await $.ui.blit({ requestId: bandId, key: 'vis', cells: renderFrame(vis, size.cols, size.rows), columns: size.cols, rows: size.rows })
49 if (r.deny && !/mount|size/i.test(r.deny)) $.ui.log(`retrovis-mod: blit denied: ${r.deny}`)
50 } catch {
51 // the site is being redrawn; the next tick will find the new mount
52 } finally {
53 inflight = false
54 }
55 })
56 return next(e)
57 })
58
59 // session events are the "music"
60 on('prompt.submit', async ($, e, next) => {
61 kick(vis, 'prompt')
62 $.ui.invalidate('ui.render')
63 return next(e)
64 })
65 on('turn.start', async ($, e, next) => {
66 kick(vis, 'turn')
67 return next(e)
68 })
69 on('tool.call', async ($, e, next) => {
70 kick(vis, 'tool', e.tool)
71 $.ui.invalidate('ui.render')
72 return next(e)
73 })
74 on('turn.complete', async ($, e, next) => {
75 kick(vis, 'done')
76 $.ui.invalidate('ui.render')
77 return next(e)
78 })
79
80 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
81 if (e.surface !== 'terminal' || e.props.hasSurvey) {
82 bandId = undefined
83 return next(e)
84 }
85 vis.working = e.props.isWorking
86 const cols = Math.max(MIN_COLS, Math.min(MAX_COLS, e.props.bodyColumns))
87 const rows = Math.max(2, Math.min(bandRows, e.props.maxRows - 3))
88 size = { cols, rows }
89 bandId = e.requestId
90 const { Box, Raster, Text, Button } = $.ui.resolve(e)
91 // two pickers, each one Button (Enter cycles forward); the choices are kept across sessions.
92 // The focus ring inverts whatever it rests on, so the name is the element: an inverted
93 // name reads as "selected", where an inverted arrow read as a glitch.
94 const save = () => {
95 $.store.set('theme', THEMES[vis.theme].name).catch(() => {})
96 $.store.set('outfit', OUTFITS[vis.outfit]).catch(() => {})
97 $.store.set('mode', MODES[vis.mode]).catch(() => {})
98 $.ui.invalidate('ui.render')
99 }
100 const left = leftPicker(vis)
101 return (
102 <Box flexDirection="column">
103 <Raster key="vis" columns={cols} rows={rows} cells={renderFrame(vis, cols, rows)} />
104 <Box flexDirection="row">
105 <Text dimColor={!vis.working} color={vis.working ? 'cyan' : undefined}>{status()}</Text>
106 <Text dimColor>{` ${left.label}: `}</Text>
107 <Button key="left" label={leftLabel()} onPress={() => { cycleLeft(vis, 1); save() }} />
108 <Text dimColor>{' mode: '}</Text>
109 <Button key="mode" label={modeLabel()} onPress={() => { cycleMode(vis, 1); save() }} />
110 </Box>
111 {await next(e)}
112 </Box>
113 )
114 })
115}
116hooks/vis.ts 297 lines1// Pure visualizer state and frame renderer: no `$`, no DOM, so it can be
2// unit-tested outside the engine. Everything the engine sees is one base64
3// Raster (`renderFrame`); everything that drives it is `kick` and `step`.
4
5import { CAT_COLORS, HIT, MOUSE_RUN, OUTFITS, drawCats, hash2 } from './cats.ts'
6
7export const N_BANDS = 64
8export const MODES = ['plasma', 'cats'] as const
9export { OUTFITS }
10const HALF_BLOCK = 0x2580 // ▀: fg paints the top pixel, bg the bottom
11const PLASMA_COLORS = 24 // palette entries 0..23 cycle the hue wheel
12const BAR_COLORS = 7 // palette entries 24..30, the theme's gradient bottom -> top
13const PEAK_COLOR = 31 // white
14const PALETTE_SIZE = 32 // 32 x 32 = 1024 fg/bg pairs, the Raster's palette limit
15
16export type Kind = 'prompt' | 'tool' | 'turn' | 'done'
17
18/** Equalizer color themes: RGB stops from the bar's foot to its tip. */
19export const THEMES: ReadonlyArray<{ name: string; stops: readonly number[] }> = [
20 { name: 'classic', stops: [0x00e000, 0xf0f000, 0xff2020] },
21 { name: 'fire', stops: [0x600000, 0xff4000, 0xffc000, 0xffffa0] },
22 { name: 'ice', stops: [0x0030a0, 0x00a0ff, 0xa0f0ff, 0xffffff] },
23 { name: 'neon', stops: [0xff00a0, 0x8000ff, 0x00e0ff] },
24 { name: 'lime', stops: [0x104000, 0x40c000, 0xc0ff40] },
25 { name: 'mono', stops: [0x404040, 0xa0a0a0, 0xffffff] },
26]
27
28export type VisState = {
29 bands: Float64Array // 0..1 bar energy
30 peaks: Float64Array // 0..1 peak-hold markers
31 hold: Float64Array // seconds a band still counts as "hit" (a cat whose slice holds one does a special move)
32 phase: Float64Array // per-band phase so the "music" is not in lockstep
33 level: number // 0..1 global energy, drives speed and brightness
34 t: number // plasma time (seconds, scaled by energy)
35 hue: number // 0..1 palette rotation
36 beat: number // seconds since the last beat while working
37 beats: number // beats counted so far: the cats bounce up on even ones, squat on odd ones
38 hits: number // events seen this session
39 mouse: number // seconds since a mouse walked onto the roof, or -1 when there is none
40 idle: number // seconds since the last event: the cats sit down
41 working: boolean
42 track: string // what the status row shows as "now playing"
43 theme: number // index into THEMES (plasma mode's bar colors)
44 outfit: number // index into OUTFITS (cats mode's costume)
45 mode: number // index into MODES
46}
47
48export function createState(): VisState {
49 const phase = new Float64Array(N_BANDS)
50 for (let i = 0; i < N_BANDS; i++) phase[i] = (i * 0.618033988749895) % 1 * Math.PI * 2
51 return {
52 bands: new Float64Array(N_BANDS),
53 peaks: new Float64Array(N_BANDS),
54 hold: new Float64Array(N_BANDS),
55 phase,
56 level: 0,
57 t: 0,
58 hue: 0,
59 beat: 0,
60 beats: 0,
61 hits: 0,
62 mouse: -1,
63 idle: 0,
64 working: false,
65 track: 'idle',
66 theme: 0,
67 outfit: 0,
68 mode: 0,
69 }
70}
71
72function hashName(s: string): number {
73 let h = 2166136261
74 for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619)
75 return h >>> 0
76}
77
78function bump(bands: Float64Array, center: number, width: number, amp: number): void {
79 for (let i = 0; i < N_BANDS; i++) {
80 const d = (i - center) / width
81 const v = amp * Math.exp(-d * d)
82 if (v > bands[i]) bands[i] = Math.min(1, v)
83 }
84}
85
86/** A session event lands: a kick to the global level and to some bands.
87 * Both prompt and tool events pick a random band, seeded deterministically by
88 * the event counter, across the whole spectrum, so every cat gets hit over time. */
89export function kick(s: VisState, kind: Kind, tool?: string): void {
90 s.idle = 0 // anything at all wakes the cats up
91 switch (kind) {
92 case 'prompt': {
93 s.hits += 1
94 s.level = Math.min(1, s.level + 0.5)
95 const at = Math.floor(hash2(s.hits, 11) * N_BANDS) // any band, so any cat
96 bump(s.bands, at, N_BANDS * 0.18, 0.9)
97 s.hold[at] = HIT
98 s.track = 'prompt'
99 break
100 }
101 case 'tool': {
102 const name = tool ?? 'tool'
103 s.hits += 1
104 s.level = Math.min(1, s.level + 0.3)
105 const center = Math.floor(hash2(s.hits, 13) * N_BANDS) // any band, so any cat
106 bump(s.bands, center, 4 + (hashName(name + '#') % 5), 0.95)
107 s.hold[center] = HIT
108 s.track = name
109 break
110 }
111 case 'turn':
112 s.level = Math.min(1, s.level + 0.2)
113 s.working = true
114 break
115 case 'done':
116 s.level = Math.min(1, s.level + 0.4)
117 for (let i = 0; i < N_BANDS; i++) s.bands[i] = Math.max(s.bands[i], 0.55 + 0.35 * Math.sin(i * 0.3))
118 s.hold.fill(HIT) // every cat cheers
119 if (hash2(7, s.hits) < 0.5) s.mouse = 0 // and half the time a mouse turns up
120 s.working = false
121 s.track = 'idle'
122 break
123 }
124}
125
126/** Advance the animation by `dt` seconds. */
127export function step(s: VisState, dt: number): void {
128 const baseline = s.working ? 0.32 : 0.05
129 s.level += (baseline - s.level) * (1 - Math.exp(-dt / 1.2))
130 s.t += dt * (0.35 + 1.8 * s.level)
131 s.hue = (s.hue + dt * (0.02 + 0.08 * s.level)) % 1
132
133 // a beat every half second while working, so the bars dance between events
134 let pulse = 0
135 if (s.working) {
136 s.beat += dt
137 if (s.beat >= 0.5) { s.beat -= 0.5; s.beats += 1 }
138 pulse = Math.exp(-s.beat * 9) * (0.35 + 0.4 * s.level)
139 }
140 s.idle = s.working ? 0 : s.idle + dt
141 if (s.mouse >= 0) {
142 s.mouse += dt
143 if (s.mouse > MOUSE_RUN) s.mouse = -1 // it made it off the far end
144 }
145
146 const attack = 1 - Math.exp(-dt / 0.05)
147 const release = 1 - Math.exp(-dt / 0.28)
148 for (let i = 0; i < N_BANDS; i++) {
149 const wobble = 0.5 + 0.5 * Math.sin(s.t * 2.1 + s.phase[i]) * Math.sin(s.t * 0.7 + i * 0.21)
150 const target = Math.min(1, s.level * (0.25 + 0.45 * wobble) + pulse * wobble)
151 const b = s.bands[i]
152 s.bands[i] = Math.min(1, target > b ? b + (target - b) * attack : b + (target - b) * release)
153 // peaks hold for a moment then fall
154 if (s.bands[i] >= s.peaks[i]) s.peaks[i] = s.bands[i]
155 else s.peaks[i] = Math.max(s.bands[i], s.peaks[i] - dt * 0.35)
156 s.hold[i] = Math.max(0, s.hold[i] - dt)
157 }
158}
159
160function hsv(h: number, sat: number, val: number): number {
161 const i = Math.floor(h * 6)
162 const f = h * 6 - i
163 const p = val * (1 - sat), q = val * (1 - f * sat), t = val * (1 - (1 - f) * sat)
164 let r: number, g: number, b: number
165 switch (i % 6) {
166 case 0: r = val; g = t; b = p; break
167 case 1: r = q; g = val; b = p; break
168 case 2: r = p; g = val; b = t; break
169 case 3: r = p; g = q; b = val; break
170 case 4: r = t; g = p; b = val; break
171 default: r = val; g = p; b = q
172 }
173 return ((r * 255) << 16) | ((g * 255) << 8) | (b * 255)
174}
175
176function lerpStops(stops: readonly number[], f: number): number {
177 const seg = f * (stops.length - 1)
178 const i = Math.min(stops.length - 2, Math.floor(seg))
179 const t = seg - i
180 const a = stops[i], b = stops[i + 1]
181 const ch = (shift: number) => Math.round(((a >> shift) & 0xff) * (1 - t) + ((b >> shift) & 0xff) * t)
182 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
183}
184
185/** What the left picker chooses in the current mode, and every value it can take. */
186export function leftPicker(s: VisState): { label: string; values: readonly string[]; index: number } {
187 return MODES[s.mode] === 'cats'
188 ? { label: 'outfit', values: OUTFITS, index: s.outfit }
189 : { label: 'color', values: THEMES.map(t => t.name), index: s.theme }
190}
191export function cycleLeft(s: VisState, by: number): void {
192 if (MODES[s.mode] === 'cats') s.outfit = (s.outfit + by + OUTFITS.length) % OUTFITS.length
193 else s.theme = (s.theme + by + THEMES.length) % THEMES.length
194}
195export function cycleMode(s: VisState, by: number): void {
196 s.mode = (s.mode + by + MODES.length) % MODES.length
197}
198
199/** The bar color at `f` (0 foot, 1 tip) of the current theme, as `#rrggbb`. */
200export function themeColor(s: VisState, f: number): string {
201 return '#' + lerpStops(THEMES[s.theme % THEMES.length].stops, f).toString(16).padStart(6, '0')
202}
203
204/** The 32-entry palette for this frame: plasma hues dimmed by energy, bar gradient, peak white. */
205function palette(s: VisState): Uint32Array {
206 const pal = new Uint32Array(PALETTE_SIZE)
207 const val = 0.22 + 0.6 * s.level
208 for (let i = 0; i < PLASMA_COLORS; i++) pal[i] = hsv((i / PLASMA_COLORS + s.hue) % 1, 0.85, val)
209 const stops = THEMES[s.theme % THEMES.length].stops
210 for (let i = 0; i < BAR_COLORS; i++) pal[PLASMA_COLORS + i] = lerpStops(stops, i / (BAR_COLORS - 1))
211 pal[PEAK_COLOR] = 0xffffff
212 return pal
213}
214
215function toBase64(bytes: Uint8Array): string {
216 const anyBytes = bytes as unknown as { toBase64?: () => string }
217 if (typeof anyBytes.toBase64 === 'function') return anyBytes.toBase64()
218 let bin = ''
219 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
220 return btoa(bin)
221}
222
223const CATS_BG_COLORS = 8
224const CATS_PAL = { bg: 0, floorDark: CATS_BG_COLORS, floorLit: CATS_BG_COLORS + 1, cats: CATS_BG_COLORS + 2 }
225if (CATS_PAL.cats + CAT_COLORS.length > PALETTE_SIZE) throw new Error('retrovis: cats palette overflows 32 colors')
226
227/** The cats-mode palette: the skyline silhouette, a night-sky gradient, two roof tiles, then every cat color. */
228function catsPalette(s: VisState): Uint32Array {
229 const pal = new Uint32Array(PALETTE_SIZE)
230 pal[0] = 0x07060e // buildings
231 const lift = 0.25 * s.level // the sky warms a little with the energy
232 for (let i = 1; i < CATS_BG_COLORS; i++) {
233 const f = (i - 1) / (CATS_BG_COLORS - 2) // 0 top .. 1 horizon
234 pal[i] = lerpStops([0x05041a, 0x140c3a, 0x2a1450, 0x4a1e5a], Math.min(1, f + lift * 0.4))
235 }
236 const pulse = s.working ? Math.exp(-s.beat * 9) * 0.5 : 0
237 pal[CATS_PAL.floorDark] = 0x2a1a14
238 pal[CATS_PAL.floorLit] = lerpStops([0x4a2a1c, 0x8a4a2c], Math.min(1, s.level * 0.6 + pulse))
239 for (let i = 0; i < CAT_COLORS.length; i++) pal[CATS_PAL.cats + i] = CAT_COLORS[i]
240 return pal
241}
242
243/** One frame as Raster `cells`: `cols` x `rows` cells, 2 pixels per cell. */
244export function renderFrame(s: VisState, cols: number, rows: number): string {
245 const W = cols, H = rows * 2
246 const pix = new Uint8Array(W * H) // palette index per pixel
247 const pal = MODES[s.mode] === 'cats' ? catsPalette(s) : palette(s)
248 if (MODES[s.mode] === 'cats') {
249 drawCats(s, pix, W, H, CATS_PAL, CATS_BG_COLORS)
250 return pack(pix, pal, W, rows)
251 }
252
253 // plasma: four moving sines, quantized to the hue ring
254 const cx = W * (0.5 + 0.35 * Math.sin(s.t * 0.23)), cy = H * (0.5 + 0.4 * Math.cos(s.t * 0.17))
255 for (let y = 0; y < H; y++) {
256 const sy = Math.sin(y * 0.22 - s.t * 0.6)
257 for (let x = 0; x < W; x++) {
258 const dx = x - cx, dy = (y - cy) * 2
259 const v = Math.sin(x * 0.09 + s.t * 0.9) + sy + Math.sin(x * 0.06 + y * 0.13 + s.t * 0.4) + Math.sin(Math.sqrt(dx * dx + dy * dy) * 0.07 - s.t * 1.1)
260 pix[y * W + x] = Math.floor(((v + 4) / 8) * PLASMA_COLORS) % PLASMA_COLORS
261 }
262 }
263
264 // bars: width 2, gap 1, spread over the band's 64 energies
265 const nBars = Math.max(1, Math.floor((W + 1) / 3))
266 for (let b = 0; b < nBars; b++) {
267 const band = Math.floor((b * N_BANDS) / nBars)
268 const h = Math.round(s.bands[band] * H)
269 const peak = Math.round(s.peaks[band] * H)
270 const x0 = b * 3
271 for (let k = 0; k < 2 && x0 + k < W; k++) {
272 const x = x0 + k
273 for (let yUp = 0; yUp < h; yUp++) {
274 const y = H - 1 - yUp
275 pix[y * W + x] = PLASMA_COLORS + Math.min(BAR_COLORS - 1, Math.floor((yUp / H) * BAR_COLORS))
276 }
277 if (peak > 0 && peak > h) pix[(H - peak) * W + x] = PEAK_COLOR
278 }
279 }
280
281 return pack(pix, pal, W, rows)
282}
283
284/** Packs palette-indexed pixels (2 per cell, top and bottom) into Raster cells. */
285function pack(pix: Uint8Array, pal: Uint32Array, W: number, rows: number): string {
286 const words = new Uint32Array(W * rows * 3)
287 for (let r = 0; r < rows; r++) {
288 for (let c = 0; c < W; c++) {
289 const o = (r * W + c) * 3
290 words[o] = HALF_BLOCK
291 words[o + 1] = pal[pix[(2 * r) * W + c]]
292 words[o + 2] = pal[pix[(2 * r + 1) * W + c]]
293 }
294 }
295 return toBase64(new Uint8Array(words.buffer))
296}
297hooks/cats.ts 279 lines1// The "cats" mode: a row of pixel cats dancing on a rooftop at night, with a
2// moon, twinkling stars and a lit skyline behind them, RealPlayer's
3// Annabel-the-Sheep style. Pure: palette indexes into `pix`, nothing else.
4import type { VisState } from './vis.ts'
5
6export const OUTFITS = ['plain', 'party hat', 'headphones', 'sunglasses', 'bow tie', 'scarf', 'crown'] as const
7
8// Sprites, 12 wide and at most 12 rows; letters are color keys, `.` is transparent:
9// B body, S stripes, P patches (a second marking color), I inner ear, E eyes, N nose, W muzzle, F feet.
10// A shorter sprite is drawn lower down, so every one of them has its bottom row on the same floor row.
11const CAT_DOWN = [
12 '..B......B..',
13 '.BIB....BIB.',
14 '.BBBBBBPPPB.',
15 'BBSBBBBPPSBB',
16 'BBEBBBBBBEBB',
17 'BBBBBNNBBBBB',
18 '.BBBWWWWBBB.',
19 '..BBBBBBBB..',
20 '.BBBBBBPPBB.',
21 '.BSBBSBPPSB.',
22 '.BBBBBBBBBB.',
23 '.FF..FF..FF.',
24]
25// the off-beat half of the bounce: the same head, the body squashed one row
26// shorter and spilling out sideways, the feet still on the floor row
27const CAT_SQUAT = [
28 '..B......B..',
29 '.BIB....BIB.',
30 '.BBBBBBPPPB.',
31 'BBSBBBBPPSBB',
32 'BBEBBBBBBEBB',
33 'BBBBBNNBBBBB',
34 '.BBBWWWWBBB.',
35 '..BBBBBBBB..',
36 'BBBBBBBPPBBB',
37 'BBSBBSBPPSBB',
38 '.FF..FF..FF.',
39]
40// the look-away: CAT_DOWN's silhouette with nothing but fur on it (the tail
41// mirrors to the other side, so the cat reads as turned and not just still)
42const CAT_BACK = [
43 '..B......B..',
44 '.BBB....BBB.',
45 '.BBBBBBPPPB.',
46 'BBSBBBBPPSBB',
47 'BBSBBBBBBSBB',
48 'BBBBBBBBBBBB',
49 '.BBBBBBBBBB.',
50 '..BBBBBBBB..',
51 '.BBBBBBPPBB.',
52 '.BSBBSBPPSB.',
53 '.BBBBBBBBBB.',
54 '.FF..FF..FF.',
55]
56// idling: the same head two rows lower, haunches spread on the floor row and
57// two front paws between them
58const CAT_SIT = [
59 '..B......B..',
60 '.BIB....BIB.',
61 '.BBBBBBPPPB.',
62 'BBSBBBBPPSBB',
63 'BBEBBBBBBEBB',
64 'BBBBBNNBBBBB',
65 '.BBBWWWWBBB.',
66 '..BBBBBBBB..',
67 '.BBBBBBPPBB.',
68 'BBBB.FF.BBBB',
69]
70export const SPRITE_W = 12
71export const SPRITE_H = 12
72export const MOUSE_RUN = 2.5 // seconds a mouse takes to cross the roof
73export const HIT = 1.5 // seconds a hit keeps a cat busy (vis.ts sets hold to this)
74const JUMP_T = 0.8 // the jump itself is over in the first part of it
75const SIT_AFTER = 10 // seconds of quiet before the cats sit down
76const TAILS: ReadonlyArray<ReadonlyArray<readonly [number, number]>> = [
77 [[12, 10], [13, 9], [13, 8]],
78 [[12, 10], [13, 10], [14, 9]],
79]
80
81// outfit overlays: [dx, dy, colorKey]; dy may be negative (above the head).
82// Color keys (see OUTFIT_COLORS): R red, G gold, K near-black, M grey, D dark grey.
83// K is nearly the night sky and nearly a black cat, so it is only ever a lens or a
84// fill that sits inside a lighter frame; anything whose shape has to read against
85// the sky or a dark breed (the headphone band and cups, the sunglasses frame) uses
86// M, D or R instead.
87type Px = readonly [number, number, string]
88const OUTFIT_PX: Record<(typeof OUTFITS)[number], readonly Px[]> = {
89 plain: [],
90 'party hat': [
91 [5, -3, 'G'], [6, -3, 'G'],
92 [4, -2, 'R'], [5, -2, 'R'], [6, -2, 'R'], [7, -2, 'R'],
93 [3, -1, 'G'], [4, -1, 'G'], [5, -1, 'G'], [6, -1, 'G'], [7, -1, 'G'], [8, -1, 'G'],
94 ],
95 headphones: [
96 ...Array.from({ length: 10 }, (_, i): Px => [i + 1, -1, 'M']), // band, grey against the sky
97 [-1, 3, 'R'], [0, 3, 'R'], [-1, 4, 'R'], [0, 4, 'R'], // ear cups, red against a dark breed
98 [11, 3, 'R'], [12, 3, 'R'], [11, 4, 'R'], [12, 4, 'R'],
99 ],
100 // lenses dark, bridge and temples in a lighter frame so they read on a black cat
101 sunglasses: [
102 [1, 4, 'D'], [2, 4, 'K'], [3, 4, 'K'], [4, 4, 'K'], [5, 4, 'D'],
103 [6, 4, 'D'], [7, 4, 'K'], [8, 4, 'K'], [9, 4, 'K'], [10, 4, 'D'],
104 ],
105 'bow tie': [[3, 7, 'R'], [4, 7, 'R'], [5, 7, 'G'], [6, 7, 'G'], [7, 7, 'R'], [8, 7, 'R']],
106 scarf: [
107 ...Array.from({ length: 10 }, (_, i): Px => [i + 1, 7, 'R']),
108 [8, 8, 'R'], [9, 8, 'R'], [9, 9, 'R'],
109 ],
110 crown: [[3, -2, 'G'], [6, -2, 'G'], [9, -2, 'G'], ...Array.from({ length: 7 }, (_, i): Px => [i + 3, -1, 'G']), [6, -1, 'R']],
111}
112
113// how far the tallest outfit reaches above the head, plus the one pixel the beat
114// bounce lifts it: the room a cat needs above SPRITE_H for every frame to fit
115const OUTFIT_OVERHANG = Math.max(0, ...Object.values(OUTFIT_PX).flatMap(px => px.map(([, dy]) => -dy)))
116const HEAD_ROOM = OUTFIT_OVERHANG + 1
117
118// breeds: color per sprite key; ordered so neighbours contrast. One cat per breed, never repeated.
119const ORANGE = 0xe8862a, DK_ORANGE = 0xb35a10, BLACK = 0x2b2b30, CHARCOAL = 0x3c3c44, WHITE = 0xf4f4f4, SNOW = 0xdcdcdc
120const GREY = 0x8e93a0, DK_GREY = 0x5f6472, CREAM = 0xeadbc8, BROWN = 0x5a3a2a, PINK = 0xf4a6c0, ROSE = 0xe07090, PLUM = 0xb05070
121const GREEN = 0x3cb44b, YELLOW = 0xf5d000, BLUE = 0x3a8dff
122const BREEDS: ReadonlyArray<Record<string, number>> = [
123 { B: ORANGE, S: DK_ORANGE, P: ORANGE, I: PINK, E: GREEN, N: ROSE, W: 0xfff1d6, F: ORANGE }, // orange tabby
124 { B: WHITE, S: ORANGE, P: BLACK, I: PINK, E: YELLOW, N: ROSE, W: WHITE, F: WHITE }, // calico: white, orange and black
125 { B: BLACK, S: BLACK, P: BLACK, I: PLUM, E: YELLOW, N: PLUM, W: CHARCOAL, F: BLACK }, // black
126 { B: CREAM, S: BROWN, P: CREAM, I: BROWN, E: BLUE, N: BROWN, W: CREAM, F: BROWN }, // siamese: dark points
127 { B: GREY, S: DK_GREY, P: GREY, I: PINK, E: GREEN, N: ROSE, W: 0xd8dae0, F: GREY }, // grey tabby
128 { B: BLACK, S: BLACK, P: BLACK, I: PINK, E: GREEN, N: ROSE, W: WHITE, F: WHITE }, // tuxedo: white muzzle and feet
129 { B: WHITE, S: SNOW, P: WHITE, I: PINK, E: BLUE, N: ROSE, W: WHITE, F: WHITE }, // white
130 { B: BLACK, S: ORANGE, P: DK_ORANGE, I: PLUM, E: YELLOW, N: PLUM, W: CHARCOAL, F: BLACK }, // tortoiseshell
131]
132const OUTFIT_COLORS: Record<string, number> = { R: 0xe03030, G: 0xffc800, K: 0x141418, M: GREY, D: DK_GREY }
133
134// one fixed palette for every cat color, so indexes are stable across frames
135export const CAT_COLORS: number[] = []
136const colorIndex = new Map<number, number>()
137function intern(c: number): number {
138 let i = colorIndex.get(c)
139 if (i === undefined) { i = CAT_COLORS.length; CAT_COLORS.push(c); colorIndex.set(c, i) }
140 return i
141}
142const BREED_IDX = BREEDS.map(b => { const m: Record<string, number> = {}; for (const k in b) m[k] = intern(b[k]); return m })
143const OUTFIT_IDX: Record<string, number> = {}
144for (const k in OUTFIT_COLORS) OUTFIT_IDX[k] = intern(OUTFIT_COLORS[k])
145
146/** The palette index of one of the cat colors (they double as star, moon and window colors). */
147export function catColor(c: number): number {
148 const i = colorIndex.get(c)
149 if (i === undefined) throw new Error('retrovis: not a cat color')
150 return i
151}
152const STAR = catColor(0xf4f4f4), STAR_DIM = catColor(0xdcdcdc), MOON = catColor(0xfff1d6), WINDOW = catColor(0xf5d000)
153const MOUSE = catColor(GREY), MOUSE_TAIL = catColor(DK_GREY)
154
155/** A stable 0..1 hash of two integers: the one source of randomness in here. */
156export function hash2(a: number, b: number): number {
157 let h = Math.imul(a * 73856093 ^ b * 19349663, 0x9e3779b1)
158 h ^= h >>> 15
159 return (h >>> 0) / 4294967296
160}
161
162export type CatsPalette = { bg: number; floorDark: number; floorLit: number; cats: number }
163
164/** Draws the rooftop, the sky and the cats into `pix` (palette indexes), given where each palette block starts. */
165export function drawCats(s: VisState, pix: Uint8Array, W: number, H: number, pal: CatsPalette, bgColors: number): void {
166 const floorY = H - 1
167 // sky: a vertical gradient, darkest at the top (bg 1..); bg 0 is the skyline's silhouette
168 const skyH = Math.max(1, floorY)
169 for (let y = 0; y < floorY; y++) {
170 const band = 1 + Math.min(bgColors - 2, Math.floor((y / skyH) * (bgColors - 1)))
171 for (let x = 0; x < W; x++) pix[y * W + x] = pal.bg + band
172 }
173 // stars: fixed positions in the upper sky, twinkling on their own clocks and brighter with the energy
174 const nStars = Math.floor((W * skyH) / 28)
175 for (let k = 0; k < nStars; k++) {
176 const x = Math.floor(hash2(k, 1) * W), y = Math.floor(hash2(k, 2) * skyH * 0.7)
177 const tw = Math.sin(s.t * (1.5 + hash2(k, 3) * 2) + hash2(k, 4) * 6.28)
178 if (tw > 0.75 - s.level) pix[y * W + x] = pal.cats + (tw > 0.9 ? STAR : STAR_DIM)
179 }
180 // moon: a disc at the right edge, in the strip the cats leave free, centred on a pixel
181 // (no half offset) so both axes are symmetric. A half-block pixel is a little taller than
182 // it is wide on most terminal fonts once line spacing is counted, so the disc is stretched
183 // sideways by MOON_ASPECT: 9 wide by 7 tall at full height, rows 5/7/9/9/9/7/5.
184 const MOON_W = 12, MOON_ASPECT = 1.2
185 const mx = W - MOON_W / 2, mr = Math.max(1.5, Math.min(3.5, skyH / 4)), my = Math.floor(mr)
186 const mrx = mr * MOON_ASPECT
187 for (let y = 0; y < skyH; y++) for (let x = Math.max(0, W - MOON_W); x < W; x++) {
188 const dx = x - mx, dy = y - my
189 if ((dx * dx) / (mrx * mrx) + (dy * dy) / (mr * mr) <= 1) pix[y * W + x] = pal.cats + MOON
190 }
191 // skyline: building silhouettes of varied height, with windows that light up with the energy
192 const maxBuild = Math.max(2, Math.floor(skyH * 0.45))
193 for (let x = 0; x < W; x++) {
194 const b = Math.floor(x / 7)
195 const h = 1 + Math.floor(hash2(b, 9) * maxBuild)
196 for (let y = floorY - h; y < floorY; y++) {
197 const wx = x % 7, wy = (floorY - 1 - y)
198 const window = wx % 3 === 1 && wy % 2 === 0 && wy < h - 1
199 const lit = window && hash2(x, y) < 0.15 + 0.7 * s.level
200 pix[y * W + x] = lit ? pal.cats + WINDOW : pal.bg
201 }
202 }
203 // one cat per breed, as many as fit; on a wide band they spread out evenly instead of repeating
204 const minSpacing = SPRITE_W + 4
205 const room = W > 2 * minSpacing + MOON_W ? W - MOON_W : W // leave the moon's strip free when there is room
206 const n = Math.max(1, Math.min(BREED_IDX.length, Math.floor(room / minSpacing)))
207 const spacing = Math.floor(room / n)
208 const margin = Math.floor((room - n * spacing) / 2) + Math.floor((spacing - SPRITE_W - 2) / 2)
209 const outfit = OUTFIT_PX[OUTFITS[s.outfit % OUTFITS.length]]
210 // the cats keep their feet on the roof while the band is tall enough for them.
211 // The groove never stops while a turn runs: the whole row bounces on every beat,
212 // lifting a pixel on one beat and squatting on the next, necks sway in a slow
213 // wave, eyes blink and tails swing. A cat whose slice was hit does one of two
214 // special moves on top of that, a jump or a look-away; left alone long enough
215 // they all sit down.
216 const bounce = s.working && s.beat < 0.16
217 const squat = bounce && s.beats % 2 === 1
218 const hop = bounce && s.beats % 2 === 0 ? 1 : 0
219 const sitting = s.idle > SIT_AFTER
220 // a mouse crosses right to left; while it is out there every head follows it
221 const mouseX = s.mouse >= 0 ? Math.round(W + 3 - (s.mouse / MOUSE_RUN) * (W + 6)) : undefined
222 const NECK = 7 // the last head row: head and neck sway, the body below them does not
223 for (let i = 0; i < n; i++) {
224 const breed = BREED_IDX[i]
225 // each cat listens to its own slice of the spectrum, so every hit lands on some cat
226 const from = Math.floor((i * s.bands.length) / n), to = Math.floor(((i + 1) * s.bands.length) / n)
227 let hold = 0
228 for (let b = from; b < to; b++) hold = Math.max(hold, s.hold[b])
229 const pj = Math.min(1, (HIT - hold) / JUMP_T) // 0..1 through the jump, done well before the hit ends
230 const jump = hold > 0 && hash2(i, s.hits) < 0.5 // the other half turn away instead
231 const turn = hold > 0 && !jump && hold < HIT - 0.1 && hold > 0.1 // back turned for about 1.3 s
232 const x0 = margin + i * spacing + 1
233 // head first: on a tall band the feet land on the roof, on a short one the cat sinks
234 // behind the parapet (the roof is drawn last) so the head and its outfit still fit
235 const y0 = Math.max(floorY - SPRITE_H, HEAD_ROOM)
236 const put = (dx: number, dy: number, idx: number) => {
237 const x = x0 + dx, y = y0 + dy
238 if (x >= 0 && x < W && y >= 0 && y < H) pix[y * W + x] = pal.cats + idx
239 }
240 // the jump is a parabola, clamped (with the beat's own pixel counted in) so a
241 // head and its hat never clip off a short band
242 const jumpMax = Math.max(0, Math.min(3, y0 - OUTFIT_OVERHANG - hop))
243 const lift = hop + (jump ? Math.round(jumpMax * 4 * pj * (1 - pj)) : 0)
244 const sprite = sitting ? CAT_SIT : turn ? CAT_BACK : squat ? CAT_SQUAT : CAT_DOWN
245 // every sprite's bottom row lands on the same floor row; a turned cat has no
246 // squat of its own, so it takes the off-beat as a one-pixel dip instead
247 const base = SPRITE_H - sprite.length + (turn && squat ? 1 : 0)
248 const sway = mouseX !== undefined ? Math.sign(mouseX - (x0 + SPRITE_W / 2))
249 : Math.round(Math.sin(s.t * 1.5 + i * 1.1))
250 const blink = (s.t + i * 0.7) % 4.3 < 0.12 // closed eyes are body-colored
251 for (let r = 0; r < sprite.length; r++) {
252 const row = sprite[r]
253 const shift = r <= NECK ? sway : 0
254 for (let c = 0; c < SPRITE_W; c++) {
255 const key = row[c]
256 if (key === '.') continue
257 put(c + shift, base + r - lift, breed[key === 'E' && blink ? 'B' : key])
258 }
259 }
260 for (const [dx, dy] of TAILS[Math.sin(s.t * 2 + i) > 0 ? 0 : 1]) put(turn ? SPRITE_W - 1 - dx : dx, base + dy - lift, breed.B)
261 // a turned cat keeps whatever sits above its head and loses what sat on its face
262 for (const [dx, dy, k] of outfit) if (!turn || dy < 0) put(dx + sway, base + dy - lift, OUTFIT_IDX[k])
263 }
264
265 // the mouse, drawn after the cats so it passes in front of their feet
266 if (mouseX !== undefined) {
267 for (let dy = 0; dy < 2; dy++) for (let dx = 0; dx < 4; dx++) {
268 const x = mouseX + dx, y = floorY - 2 + dy
269 if (x >= 0 && x < W && y >= 0) pix[y * W + x] = pal.cats + MOUSE
270 }
271 const tx = mouseX + 4 // the tail trails behind it, to the right
272 if (tx >= 0 && tx < W && floorY >= 1) pix[(floorY - 1) * W + tx] = pal.cats + MOUSE_TAIL
273 }
274
275 // roof: tiles in two shades, alternating every 4 px. Drawn last, so it hides
276 // whatever a sunk cat put on the floor row: they stand behind a parapet.
277 for (let x = 0; x < W; x++) pix[floorY * W + x] = Math.floor(x / 4) % 2 === 0 ? pal.floorLit : pal.floorDark
278}
279