SLOPSHOPPER

retrovis-mod

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

newbandguardprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · retrovis-mod
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ❚❚ idle ♪ 10 color: [ classic ] mode: [ plasma ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ❚❚ idle ♪ 10 color: [ classic ] mode: [ plasma ] ⟨Claude Code's own drawing⟩
README

retrovis-mod

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.

plasma mode: a color field with equalizer bars and peak markers

cats mode: eight pixel cats in party hats dancing on a rooftop

session eventeffect
prompt submittedbig kick, low bands jump
tool callkick on a band chosen by the tool's name
turn runningbars pulse on a beat, plasma brightens and speeds up
turn donefull-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.

Height

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.

Install

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.

Straight from the repo

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.

Develop

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 $.

Source 3 files
hooks/register.tsx 116 lines
1/* @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}
116
hooks/vis.ts 297 lines
1// 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}
297
hooks/cats.ts 279 lines
1// 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