SLOPSHOPPER

agent-aquarium

A Claude Mod (function-hooks plugin): /aquarium draws the session as a fish tank in a pane with kitty graphics — the main loop is the parent fish, each…

newpaneguardcommandtimeragents
v0.1.0MITupdated 2026-09-22nogu66/claude-code/agent-aquarium
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-aquarium
│ ┃ aquarium ✕ › fix the failing auth test and add an audit log call │ ┃ aquarium · /aquarium on │ ● agent-aquarium: agent-aquarium: could not load the sprites: Error: │ ⏺ 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 │ │ › /aquarium │ ⎿ agent-aquarium: aquarium: the sprites under assets/ could not be │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · aquarium
aquarium · /aquarium on
README

Agent Aquarium

Claude Code の作業状態を、ターミナル内の水槽としてリアルタイムに描く Claude Mod(function hooks プラグイン)です。 メインループが親魚、サブエージェントが稚魚になり、ツール呼び出しがそのまま魚の動きになります。 観測専用で、ツールの実行結果には一切手を加えません。

preview

(bun run tools/preview.ts が書き出した 140×28 セル時の 1 フレーム。動く様子は assets/preview.gif)

使い方

cd agent-aquarium
claude --plugin-dir .        # .claude/settings.json が CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 を立てる
コマンド動き
/aquarium水槽の表示 ON/OFF(pane 12 行)
/aquarium full大きい pane(28 行、fullscreen ではドックに 140 桁)
/aquarium off閉じる。次回セッションで自動再表示もしない
/aquarium status描画モード・サイズ・フレーム数・PNG サイズ
/aquarium fps Nフレームレート(2〜20、既定 12)。端末がもたつくなら下げる
`/aquarium mode kitty\raster`描画方式を固定(引数なしで自動に戻す)

一度 ON にすると、次のセッション開始時にも自動で開きます(pane を手で閉じるか /aquarium off で解除)。

何がどう動くか

対象見た目
親魚(メインループ)錦鯉風 160×80px。水槽の中央を回遊
稚魚(サブエージェント)96×48px。agent.spawn と $.agent.list() で誕生、turn.complete で退場。最大 12 匹、超過分は「+N」バッジ
稚魚の色Explore = 青、general-purpose = 橙、3 種目 = 緑、4 種目以降 = 紫
ツール動き
Read / Grep / Glob水面から餌が落ち、食べに行く
Edit / Write泡を吐き、砂に巣が 1 つ増える
Bash1.2 秒だけ 3 倍速でダッシュ
WebFetch / WebSearch / MCP水面へ浮上
エラー赤くなって震え、2 秒沈んで復帰

魚の上には EDIT AUTH.TS のようにツール名と引数が 1.5 秒出ます。 水位はコンテキスト使用率に連動して減り(session.measure)、compact で満水に戻ります。ターン中は水中が少し明るくなります。

描画方式

  • pane に keyed な <Image> を 1 枚置き、$.clock.every(66ms) ごとに hooks 側でフレームを合成 → PNG にエンコード → $.ui.blit({ png }) で差し替えます(既定 12fps、/aquarium fps N か plugin の設定 fps で変更)。Kitty graphics protocol の送信は Claude Code 本体が行います。
  • hooks 環境には zlib も CompressionStream もないため、PNG のデコード(スプライト読込)とエンコード(固定 Huffman + LZ77 の deflate)は hooks/aquarium/{inflate,deflate,png}.ts の純 TypeScript 実装です。80×12 セルのフレームは約 20〜25 KB、合成 + エンコードで 8 ms ほどです。
  • engine が Pane を再描画するとき(スピナー更新やリサイズ)は、最後に blit した画像をそのまま返します。再描画のたびに新しいフレームを返すと、フレームクロックと競合して画像がちらつくためです。
  • 画像を出せない端末では $.ui.blit が拒否されるので、3 回続いたら <Raster> の半ブロック(▀)描画に自動で切り替えます。
  • /aquarium off・pane を閉じる・セッション終了で pane を閉じ、画像の placement は本体側で消えます。

Client モジュール(描画スレッド)は Image/Raster を描けない仕様(ClientElements)なので、アニメーションループは hooks モジュール側に置いています。hooks モジュールは本体とは別の実行環境で動くため、プロンプト入力は塞ぎません。

構成

hooks/register.tsx          イベント配線だけ(session.start / command.run / ui.render(Pane) / ui.close /
                            tool.call / agent.spawn / turn.start / turn.complete / session.measure /
                            session.compact / session.end)
hooks/aquarium/world.ts     水槽の状態と物理(純関数)
hooks/aquarium/events.ts    ツール → アクション、ラベル、エージェント一覧との同期
hooks/aquarium/render.ts    合成(RGBA キャンバス)と半ブロック化
hooks/aquarium/sprites.ts   manifest.json からコマ矩形を引く、縮小
hooks/aquarium/png.ts       PNG デコード / エンコード、base64
hooks/aquarium/inflate.ts   deflate 展開
hooks/aquarium/deflate.ts   deflate 圧縮(固定 Huffman)
hooks/aquarium/font.ts      3×5 ピクセルフォント
assets/                     生成済みスプライト(tools/gen_sprites.py で再生成)
tools/preview.ts            合成結果を PNG に書き出す(Claude Code なしで確認)
tests/                      純ロジックのテスト(bun test)

開発

bun test                                      # 純ロジックのテスト
bun run tools/preview.ts                      # docs/ にフレームを書き出す
claude plugin validate .claude-plugin/plugin.json   # フック登録と $ 呼び出しの一覧
claude plugin validate .                      # marketplace manifest

型チェックはセッション内で /plugin-types を実行して .claude/types/ を生成してから tsc -p tsconfig.json。 ヘッドレスでも生成できます: claude -p "/plugin-types .claude/types"。 編集はホットリロードされます(register が作り直される)。

やらないこと

音、魚同士の当たり判定、スプライトの実行時生成。

Source 9 files
hooks/register.tsx 404 lines
1/* @jsx h */
2import type { EngineInterface, Register, Timer } from 'claude-code'
3import { agentDone, applyTool, birth, syncAgents } from './aquarium/events.ts'
4import { decodePng, encodePng, fromBase64, toBase64, type Rgba } from './aquarium/png.ts'
5import { createCanvas, drawWorld, toHalfBlocks, type Canvas } from './aquarium/render.ts'
6import { buildSprites, isManifest, scaleForHeight, type RawAssets, type Sprites } from './aquarium/sprites.ts'
7import { createWorld, drawnBabies, overflowCount, PARENT_ID, resizeWorld, setLight, setWaterTarget, step, waterLevelFor, type World } from './aquarium/world.ts'
8
9// Agent Aquarium: the session as a fish tank in a pane. The main loop is the parent fish,
10// each subagent a baby, each tool call an animation. This module only wires the session's
11// events to the pure world (./aquarium/world.ts, events.ts) and the compositor
12// (render.ts); it is observation only, every tool.call hook returns what next(e) gave it.
13//
14// Drawing: a keyed <Image> in the pane, swapped `fps` times a second by $.ui.blit with a PNG the
15// compositor made (the terminal shows it through the kitty graphics protocol). A terminal
16// without images refuses the blit, and the pane falls back to a <Raster> of half blocks.
17
18const PANE = 'aquarium'
19const KEY = 'tank'
20const CELL_W = 8
21const CELL_H = 16
22const DEFAULT_FPS = 12
23const MIN_FPS = 2
24const MAX_FPS = 20
25const AGENT_POLL_MS = 2000
26const MAX_CELLS = 255
27const FALLBACK_AFTER = 3
28const ROWS_NORMAL = 12
29const ROWS_FULL = 28
30const COLUMNS_FULL = 140
31
32type Mode = 'kitty' | 'raster'
33
34const state = {
35  active: false,
36  full: false,
37  mode: 'kitty' as Mode,
38  forcedMode: undefined as Mode | undefined,
39  cols: 0,
40  rows: 0,
41  raw: undefined as RawAssets | undefined,
42  loading: undefined as Promise<RawAssets | undefined> | undefined,
43  sprites: undefined as Sprites | undefined,
44  world: undefined as World | undefined,
45  canvas: undefined as Canvas | undefined,
46  ticker: undefined as Timer | undefined,
47  poller: undefined as Timer | undefined,
48  busy: false,
49  mounted: false,
50  denies: 0,
51  frames: 0,
52  lastBytes: 0,
53  encodeMs: 0,
54  loggedTickError: false,
55  fps: DEFAULT_FPS,
56  // the picture last handed to the pane: a re-render returns it unchanged, so only blits move the tank
57  last: undefined as { mode: Mode; cols: number; rows: number; png?: string; cells?: string } | undefined,
58}
59
60const log = ($: EngineInterface, text: string, debug = false) => $.ui.log(`agent-aquarium: ${text}`, debug ? { to: 'debug' } : undefined)
61
62async function readPng($: EngineInterface, path: string): Promise<Rgba> {
63  const { base64 } = await $.fs.read(path, { as: 'bytes' })
64  return decodePng(fromBase64(base64))
65}
66
67// the sheets, decoded once per session; a failure is logged and leaves the tank closed
68async function loadAssets($: EngineInterface): Promise<RawAssets | undefined> {
69  if (state.raw) return state.raw
70  if (state.loading) return state.loading
71  state.loading = (async () => {
72    try {
73      const dir = `${$.plugin.root}/assets`
74      const manifest: unknown = JSON.parse(await $.fs.read(`${dir}/manifest.json`))
75      if (!isManifest(manifest)) throw new Error('manifest.json has not the expected shape')
76      const sheets: Record<string, Rgba> = {}
77      for (const [key, meta] of Object.entries(manifest.sheets)) sheets[key] = await readPng($, `${dir}/${meta.file}`)
78      const props = await readPng($, `${dir}/${manifest.props.file}`)
79      const seaweed = await readPng($, `${dir}/${manifest.seaweed.file}`)
80      state.raw = { manifest, sheets, props, seaweed }
81      return state.raw
82    } catch (err) {
83      log($, `could not load the sprites: ${err}`)
84      return undefined
85    } finally {
86      state.loading = undefined
87    }
88  })()
89  return state.loading
90}
91
92// fits the world and the sprites to the pane's body; a new size resizes, a new scale rebuilds the sheets
93function applyView(cols: number, rows: number): void {
94  cols = Math.max(20, Math.min(MAX_CELLS, cols))
95  rows = Math.max(5, Math.min(MAX_CELLS, rows))
96  const w = cols * CELL_W
97  const h = rows * CELL_H
98  const scale = scaleForHeight(h)
99  const now = Date.now()
100  if (state.raw && (!state.sprites || state.sprites.scale !== scale)) state.sprites = buildSprites(state.raw, scale)
101  if (!state.world) state.world = createWorld(w, h, scale, now)
102  else if (state.world.w !== w || state.world.h !== h || state.world.scale !== scale) resizeWorld(state.world, w, h, scale)
103  if (!state.canvas || state.canvas.w !== w || state.canvas.h !== h) state.canvas = createCanvas(w, h)
104  state.cols = cols
105  state.rows = rows
106}
107
108function renderFrame(now: number): Canvas | undefined {
109  if (!state.world || !state.sprites || !state.canvas) return undefined
110  step(state.world, now)
111  drawWorld(state.canvas, state.world, state.sprites, now)
112  return state.canvas
113}
114
115function pngSource(canvas: Canvas): { png: string } {
116  const t0 = Date.now()
117  const png = encodePng(canvas.data, canvas.w, canvas.h)
118  state.encodeMs = Date.now() - t0
119  state.lastBytes = png.length
120  return { png: toBase64(png) }
121}
122
123function rasterCells(canvas: Canvas): string {
124  return toBase64(toHalfBlocks(canvas, state.cols, state.rows))
125}
126
127// one frame: advance, draw, hand the pane its next picture. A refused blit while the pane is
128// mounted means the terminal shows no images: switch to half blocks and redraw once.
129async function tick($: EngineInterface): Promise<void> {
130  if (!state.active || !state.mounted || state.busy) return
131  state.busy = true
132  try {
133    const canvas = renderFrame(Date.now())
134    if (!canvas) return
135    const png = state.mode === 'kitty' ? pngSource(canvas).png : undefined
136    const cells = state.mode === 'kitty' ? undefined : rasterCells(canvas)
137    const result = png !== undefined
138      ? await $.ui.blit({ requestId: PANE, key: KEY, source: { png } })
139      : await $.ui.blit({ requestId: PANE, key: KEY, cells: cells ?? '' })
140    if (result.deny === undefined) {
141      state.denies = 0
142      state.frames++
143      state.last = { mode: state.mode, cols: state.cols, rows: state.rows, png, cells }
144      return
145    }
146    state.denies++
147    if (state.denies === 1) log($, `blit refused: ${result.deny}`, true)
148    if (state.denies >= FALLBACK_AFTER && state.mode === 'kitty' && state.forcedMode === undefined) {
149      state.mode = 'raster'
150      state.denies = 0
151      log($, 'this terminal shows no images here; drawing the tank with half blocks (/aquarium mode kitty to retry)')
152      $.ui.invalidate('ui.render')
153    }
154  } catch (err) {
155    if (!state.loggedTickError) {
156      state.loggedTickError = true
157      log($, `frame failed: ${err}`, true)
158    }
159  } finally {
160    state.busy = false
161  }
162}
163
164async function pollAgents($: EngineInterface): Promise<void> {
165  if (!state.active || !state.world) return
166  try {
167    const agents = await $.agent.list()
168    syncAgents(state.world, agents, Date.now())
169  } catch (err) {
170    log($, `agent list failed: ${err}`, true)
171  }
172}
173
174const clampFps = (fps: number) => Math.max(MIN_FPS, Math.min(MAX_FPS, Math.round(fps)))
175
176function startTimers($: EngineInterface): void {
177  state.ticker ??= $.clock.every(Math.round(1000 / state.fps), () => { void tick($) })
178  state.poller ??= $.clock.every(AGENT_POLL_MS, () => { void pollAgents($) })
179}
180
181function setFps($: EngineInterface, fps: number): void {
182  state.fps = clampFps(fps)
183  if (state.ticker) {
184    state.ticker.cancel()
185    state.ticker = undefined
186    startTimers($)
187  }
188}
189
190function stopTimers(): void {
191  state.ticker?.cancel()
192  state.poller?.cancel()
193  state.ticker = undefined
194  state.poller = undefined
195}
196
197async function openTank($: EngineInterface, full: boolean): Promise<string> {
198  const raw = await loadAssets($)
199  if (!raw) return 'aquarium: the sprites under assets/ could not be read; see the transcript line above'
200  state.full = full
201  state.active = true
202  state.mounted = false
203  state.denies = 0
204  state.mode = state.forcedMode ?? 'kitty'
205  await $.ui.open(full ? { id: PANE, title: 'aquarium', rows: ROWS_FULL, columns: COLUMNS_FULL } : { id: PANE, title: 'aquarium', rows: ROWS_NORMAL })
206  $.ui.invalidate('ui.render')
207  startTimers($)
208  await $.store.set('autoOpen', true).catch(() => undefined)
209  void pollAgents($)
210  return `aquarium ${full ? 'full' : 'on'} · /aquarium off closes · /aquarium status`
211}
212
213async function closeTank($: EngineInterface, remember: boolean): Promise<void> {
214  state.active = false
215  state.mounted = false
216  stopTimers()
217  if (remember) await $.store.set('autoOpen', false).catch(() => undefined)
218  await $.ui.close({ id: PANE }).catch(() => undefined)
219}
220
221function statusText(): string {
222  const w = state.world
223  const fish = w ? drawnBabies(w).length : 0
224  const extra = w ? overflowCount(w) : 0
225  return [
226    `aquarium ${state.active ? (state.full ? 'full' : 'on') : 'off'} · mode ${state.mode}${state.forcedMode ? ' (forced)' : ''} · ${state.fps} fps`,
227    `pane ${state.cols}x${state.rows} cells · canvas ${w?.w ?? 0}x${w?.h ?? 0} px · sprites x${state.sprites?.scale ?? 0}`,
228    `babies ${fish}${extra ? ` (+${extra} waiting)` : ''} · nests ${w?.nests.length ?? 0} · water ${Math.round((w?.water ?? 1) * 100)}%`,
229    `frames ${state.frames} · last png ${Math.round(state.lastBytes / 1024)} KB in ${state.encodeMs} ms`,
230  ].join('\n')
231}
232
233export const register: Register = (on, options) => {
234  if (typeof options.fps === 'number') state.fps = clampFps(options.fps)
235
236  on('session.start', async ($, e, next) => {
237    const r = await next(e)
238    await $.command.register({
239      name: 'aquarium',
240      description: 'A fish tank of this session: the main loop, its subagents and their tool calls (agent-aquarium)',
241      argumentHint: '[on | off | full | status | fps N | mode kitty|raster]',
242      immediate: true,
243    }).catch(err => log($, `/aquarium not registered: ${err}`))
244    const surfaces = await $.session.surfaces().catch(() => [] as readonly string[])
245    if (!surfaces.includes('terminal')) return r
246    const fps = await $.store.get('fps').catch(() => undefined)
247    if (typeof fps === 'number') state.fps = clampFps(fps)
248    const autoOpen = await $.store.get('autoOpen').catch(() => undefined)
249    if (autoOpen === true) {
250      // reopen where it was left on; the surface may hold it until the terminal is wide enough
251      void openTank($, false).catch(err => log($, `could not reopen: ${err}`, true))
252    }
253    return r
254  })
255
256  on('command.run', { command: 'aquarium' }, async ($, e) => {
257    const [word = '', rest = ''] = e.args.trim().toLowerCase().split(/\s+/, 2)
258    if (word === 'status') return { text: statusText() }
259    if (word === 'fps') {
260      const n = Number(rest)
261      if (!Number.isFinite(n)) return { text: `aquarium: ${state.fps} fps · /aquarium fps N sets it (${MIN_FPS}-${MAX_FPS})` }
262      setFps($, n)
263      await $.store.set('fps', state.fps).catch(() => undefined)
264      return { text: `aquarium: ${state.fps} fps` }
265    }
266    if (word === 'mode') {
267      if (rest === 'kitty' || rest === 'raster') {
268        state.forcedMode = rest
269        state.mode = rest
270        state.denies = 0
271        $.ui.invalidate('ui.render')
272        return { text: `aquarium: drawing mode ${rest}` }
273      }
274      state.forcedMode = undefined
275      return { text: 'aquarium: /aquarium mode kitty|raster forces a drawing mode; the automatic choice is back' }
276    }
277    // a bare /aquarium closes only a tank that is on screen: one reopened at session.start may still be waiting unplaced
278    if (word === 'off' || word === 'stop' || word === 'close' || (word === '' && state.active && state.mounted && !state.full)) {
279      await closeTank($, true)
280      return { text: 'aquarium off' }
281    }
282    if (word === 'full') return { text: await openTank($, true) }
283    if (word === '' || word === 'on' || word === 'normal' || word === 'small') return { text: await openTank($, false) }
284    return { text: `aquarium: no "${word}" · on, off, full, status, fps N, mode kitty|raster` }
285  })
286
287  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
288    if (e.surface !== 'terminal') {
289      const { Text } = $.ui.resolve(e)
290      return <Text dimColor>agent-aquarium draws in the terminal</Text>
291    }
292    const { Box, Image, Raster, Text } = $.ui.resolve(e)
293    if (!state.active || !state.raw) return <Text dimColor>aquarium · /aquarium on</Text>
294    const cols = e.props.bodyColumns > 0 ? e.props.bodyColumns : (e.viewport?.columns ?? 80)
295    const rows = e.props.scroll.bodyRows > 0 ? e.props.scroll.bodyRows : Math.max(8, Math.floor((e.viewport?.rows ?? 30) / 3))
296    applyView(cols, rows)
297    state.mounted = true
298    // the engine re-runs this hook on its own occasions (a spinner, a resize, a new row); the same
299    // picture as the last blit keeps those re-runs from swapping the image under the frame clock
300    const last = state.last
301    const reuse = last !== undefined && last.mode === state.mode && last.cols === state.cols && last.rows === state.rows
302    let png: string | undefined = reuse ? last.png : undefined
303    let cells: string | undefined = reuse ? last.cells : undefined
304    if ((state.mode === 'kitty' && png === undefined) || (state.mode === 'raster' && cells === undefined)) {
305      const canvas = renderFrame(Date.now())
306      if (!canvas) return <Text dimColor>aquarium · loading</Text>
307      if (state.mode === 'kitty') png = pngSource(canvas).png
308      else cells = rasterCells(canvas)
309      state.last = { mode: state.mode, cols: state.cols, rows: state.rows, png, cells }
310    }
311    if (state.mode === 'kitty' && png !== undefined) {
312      return (
313        <Box flexDirection="column">
314          <Image key={KEY} source={{ png }} columns={state.cols} rows={state.rows} alt="aquarium: this terminal shows no images; /aquarium mode raster draws it with blocks" />
315        </Box>
316      )
317    }
318    return (
319      <Box flexDirection="column">
320        <Raster key={KEY} columns={state.cols} rows={state.rows} cells={cells ?? ''} />
321      </Box>
322    )
323  })
324
325  // the person closed the pane (its mark, or ctrl+x x): stop drawing, and do not reopen next session
326  on('ui.close', { id: PANE }, async ($, e, next) => {
327    const r = await next(e)
328    if (state.active) {
329      state.active = false
330      state.mounted = false
331      stopTimers()
332      if (e.origin.kind === 'person') await $.store.set('autoOpen', false).catch(() => undefined)
333    }
334    return r
335  })
336
337  // observation only: the call runs first, and only its outcome (which tool, error or not) moves a fish
338  on('tool.call', async ($, e, next) => {
339    const r = await next(e)
340    if (state.active && state.world) {
341      try {
342        const isError = r.deny !== undefined || r.isError === true
343        const fishId = e.agentId ?? PARENT_ID
344        if (e.tool !== 'Agent' || e.agentId !== undefined) {
345          const known = state.world.fish.some(f => f.id === fishId)
346          applyTool(state.world, fishId, e.tool, e as unknown as Record<string, unknown>, isError, Date.now())
347          if (!known && e.agentId !== undefined) void pollAgents($)
348        }
349      } catch (err) {
350        log($, `tool event failed: ${err}`, true)
351      }
352    }
353    return r
354  }).catch(($, e, next) => next(e))
355
356  // a subagent started: born at once, in the colour of its type
357  on('agent.spawn', async ($, e, next) => {
358    const r = await next(e)
359    if (state.active && state.world && r.agentId !== undefined) {
360      birth(state.world, r.agentId, e.subagentType, Date.now())
361      log($, `born ${r.agentId} (${e.subagentType}) · babies ${drawnBabies(state.world).length}, waiting ${overflowCount(state.world)}`, true)
362    }
363    return r
364  }).catch(($, e, next) => next(e))
365
366  on('turn.start', async ($, e, next) => {
367    if (state.world) setLight(state.world, true)
368    return next(e)
369  })
370
371  // the main loop's turn ended: lights down; a subagent's: it swims off
372  on('turn.complete', async ($, e, next) => {
373    const r = await next(e)
374    if (state.world) {
375      if (e.agentId !== undefined) {
376        agentDone(state.world, e.agentId, Date.now())
377        log($, `left ${e.agentId} · babies ${drawnBabies(state.world).length}, waiting ${overflowCount(state.world)}`, true)
378      } else setLight(state.world, false)
379    }
380    return r
381  })
382
383  // the context window's fill is the water level
384  on('session.measure', async ($, e, next) => {
385    const r = await next(e)
386    if (state.world && e.changed.includes('context')) setWaterTarget(state.world, waterLevelFor(e.context.percent))
387    return r
388  })
389
390  // a compaction refills the tank
391  on('session.compact', async ($, e, next) => {
392    const r = await next(e)
393    if (state.world && r.skip === undefined && e.trigger !== 'precompute') setWaterTarget(state.world, 1)
394    return r
395  })
396
397  // no picture may outlive the session: close the pane so the surface deletes its placements
398  on('session.end', async ($, e, next) => {
399    const r = await next(e)
400    if (state.active) await closeTank($, false).catch(() => undefined)
401    return r
402  })
403}
404
hooks/aquarium/events.ts 118 lines
1// From the session's events to the tank: which tool means which action, what the speech
2// bubble says, and how the subagent list becomes births and exits. Pure functions.
3import { addBaby, colorForType, exitFish, findFish, PARENT_ID, setBubble, setError, startAction, type Action, type FishColor, type World } from './world.ts'
4
5export type ToolAction = Action | 'none'
6
7export type AgentRow = { id: string; type: string; status: string; parentId?: string }
8
9const EAT = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead'])
10const NEST = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
11const DASH = new Set(['Bash', 'BashOutput', 'PowerShell'])
12const SURFACE = new Set(['WebFetch', 'WebSearch'])
13
14export function actionFor(tool: string): ToolAction {
15  if (EAT.has(tool)) return 'eat'
16  if (NEST.has(tool)) return 'nest'
17  if (DASH.has(tool)) return 'dash'
18  if (SURFACE.has(tool) || tool.startsWith('mcp__')) return 'surface'
19  return 'none'
20}
21
22const MAX_LABEL = 22
23
24const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined)
25const basename = (p: string) => p.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? p
26const host = (u: string) => {
27  try { return new URL(u).hostname.replace(/^www\./, '') } catch { return u }
28}
29
30/** The tool's name for the bubble: `mcp__server__tool` shows as `tool`. */
31export function shortToolName(tool: string): string {
32  if (tool.startsWith('mcp__')) {
33    const parts = tool.split('__')
34    return parts[parts.length - 1] || tool
35  }
36  return tool
37}
38
39/** `Edit auth.ts`, `Grep TODO`, `Bash bun test`: the tool and its one telling argument, cut to fit a bubble. */
40export function labelFor(tool: string, input: Record<string, unknown>): string {
41  let arg: string | undefined
42  switch (tool) {
43    case 'Read': case 'Edit': case 'Write': case 'MultiEdit':
44      arg = str(input.file_path); if (arg) arg = basename(arg); break
45    case 'NotebookEdit': case 'NotebookRead':
46      arg = str(input.notebook_path); if (arg) arg = basename(arg); break
47    case 'Grep': case 'Glob':
48      arg = str(input.pattern); break
49    case 'Bash': case 'PowerShell':
50      arg = str(input.description) ?? str(input.command)?.replace(/\s+/g, ' '); break
51    case 'WebFetch':
52      arg = str(input.url); if (arg) arg = host(arg); break
53    case 'WebSearch':
54      arg = str(input.query); break
55    case 'Agent': case 'Task':
56      arg = str(input.subagent_type) ?? str(input.description); break
57    case 'Skill':
58      arg = str(input.skill); break
59    default:
60      arg = str(input.file_path) ?? str(input.path) ?? str(input.query) ?? str(input.url)
61      if (arg && (input.file_path !== undefined || input.path !== undefined)) arg = basename(arg)
62  }
63  const name = shortToolName(tool)
64  const text = arg ? `${name} ${arg}` : name
65  return text.length > MAX_LABEL ? `${text.slice(0, MAX_LABEL - 1)}~` : text
66}
67
68/**
69 * One finished tool call: the fish for `fishId` (the parent, or a baby made on the spot for
70 * a subagent the list has not named yet) gets the bubble and the action, or the error.
71 */
72export function applyTool(world: World, fishId: string, tool: string, input: Record<string, unknown>, isError: boolean, now: number, rand = Math.random): void {
73  let fish = findFish(world, fishId)
74  if (!fish) {
75    if (fishId === PARENT_ID) return
76    fish = addBaby(world, fishId, 'green', now, rand)
77  }
78  setBubble(fish, labelFor(tool, input), now)
79  if (isError) {
80    setError(world, fish, now)
81    return
82  }
83  const action = actionFor(tool)
84  if (action !== 'none') startAction(world, fish, action, now, rand)
85}
86
87/** A subagent started: a baby in the colour of its type. */
88export function birth(world: World, agentId: string, type: string, now: number, rand = Math.random): void {
89  const color: FishColor = colorForType(world, type)
90  const fish = addBaby(world, agentId, color, now, rand)
91  fish.color = color
92}
93
94/**
95 * Reconciles the tank with `$.agent.list()`: a running agent without a fish is born, a fish
96 * whose agent is listed and no longer running leaves. Fish the list does not know stay.
97 */
98export function syncAgents(world: World, agents: readonly AgentRow[], now: number, rand = Math.random): void {
99  const listed = new Map(agents.map(a => [a.id, a] as const))
100  for (const a of agents) {
101    if (a.status !== 'running') continue
102    const color = colorForType(world, a.type)
103    const fish = findFish(world, a.id)
104    if (!fish) addBaby(world, a.id, color, now, rand)
105    else if (fish.kind === 'baby') fish.color = color
106  }
107  for (const fish of world.fish) {
108    if (fish.kind !== 'baby' || fish.gone || fish.state === 'exit') continue
109    const row = listed.get(fish.id)
110    if (row && row.status !== 'running') exitFish(world, fish.id, now)
111  }
112}
113
114/** A subagent's run ended (its `turn.complete` carries its id): it swims off. */
115export function agentDone(world: World, agentId: string, now: number): void {
116  exitFish(world, agentId, now)
117}
118
hooks/aquarium/png.ts 210 lines
1// PNG read and write over the plain-TypeScript inflate/deflate: 8-bit RGB or RGBA, no
2// interlace, which is what the sprite sheets are and what a frame needs to be.
3import { deflateFixed } from './deflate.ts'
4import { inflate } from './inflate.ts'
5
6export type Rgba = { width: number; height: number; data: Uint8Array }
7
8const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
9
10const CRC_TABLE = new Uint32Array(256)
11for (let n = 0; n < 256; n++) {
12  let c = n
13  for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
14  CRC_TABLE[n] = c >>> 0
15}
16
17export function crc32(bytes: Uint8Array, start = 0, end = bytes.length): number {
18  let c = 0xffffffff
19  for (let i = start; i < end; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8)
20  return (c ^ 0xffffffff) >>> 0
21}
22
23export function adler32(bytes: Uint8Array): number {
24  let a = 1
25  let b = 0
26  const n = bytes.length
27  for (let i = 0; i < n;) {
28    // 5552 is the most bytes before the sums may overflow 32 bits
29    const end = Math.min(n, i + 5552)
30    for (; i < end; i++) {
31      a += bytes[i]
32      b += a
33    }
34    a %= 65521
35    b %= 65521
36  }
37  return ((b << 16) | a) >>> 0
38}
39
40const readU32 = (b: Uint8Array, i: number) => ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0
41const writeU32 = (b: Uint8Array, i: number, v: number) => {
42  b[i] = (v >>> 24) & 0xff
43  b[i + 1] = (v >>> 16) & 0xff
44  b[i + 2] = (v >>> 8) & 0xff
45  b[i + 3] = v & 0xff
46}
47
48function paeth(a: number, b: number, c: number): number {
49  const p = a + b - c
50  const pa = Math.abs(p - a)
51  const pb = Math.abs(p - b)
52  const pc = Math.abs(p - c)
53  return pa <= pb && pa <= pc ? a : pb <= pc ? b : c
54}
55
56/** Decodes an 8-bit RGB or RGBA, non-interlaced PNG to RGBA pixels. */
57export function decodePng(bytes: Uint8Array): Rgba {
58  for (let i = 0; i < 8; i++) if (bytes[i] !== SIGNATURE[i]) throw new Error('png: not a PNG file')
59  let width = 0
60  let height = 0
61  let colorType = -1
62  let bitDepth = 0
63  let interlace = 0
64  const idat: Uint8Array[] = []
65  let idatLen = 0
66  let pos = 8
67  while (pos + 8 <= bytes.length) {
68    const len = readU32(bytes, pos)
69    const type = String.fromCharCode(bytes[pos + 4], bytes[pos + 5], bytes[pos + 6], bytes[pos + 7])
70    const dataStart = pos + 8
71    const dataEnd = dataStart + len
72    if (dataEnd + 4 > bytes.length) throw new Error(`png: truncated ${type} chunk`)
73    if (type === 'IHDR') {
74      width = readU32(bytes, dataStart)
75      height = readU32(bytes, dataStart + 4)
76      bitDepth = bytes[dataStart + 8]
77      colorType = bytes[dataStart + 9]
78      interlace = bytes[dataStart + 12]
79    } else if (type === 'IDAT') {
80      idat.push(bytes.subarray(dataStart, dataEnd))
81      idatLen += len
82    } else if (type === 'IEND') {
83      break
84    }
85    pos = dataEnd + 4
86  }
87  if (width === 0 || height === 0) throw new Error('png: no IHDR')
88  if (bitDepth !== 8 || (colorType !== 6 && colorType !== 2)) throw new Error(`png: unsupported format (bit depth ${bitDepth}, color type ${colorType})`)
89  if (interlace !== 0) throw new Error('png: interlaced images are not supported')
90  const bpp = colorType === 6 ? 4 : 3
91  const stride = width * bpp
92  const zlib = new Uint8Array(idatLen)
93  let off = 0
94  for (const part of idat) {
95    zlib.set(part, off)
96    off += part.length
97  }
98  // skip the 2-byte zlib header; the adler32 trailer is left unchecked
99  const raw = inflate(zlib.subarray(2), (stride + 1) * height)
100  if (raw.length < (stride + 1) * height) throw new Error('png: image data is short')
101  const data = new Uint8Array(width * height * 4)
102  const line = new Uint8Array(stride)
103  const prevLine = new Uint8Array(stride)
104  for (let y = 0; y < height; y++) {
105    const rowStart = y * (stride + 1)
106    const filter = raw[rowStart]
107    line.set(raw.subarray(rowStart + 1, rowStart + 1 + stride))
108    switch (filter) {
109      case 0: break
110      case 1: for (let i = bpp; i < stride; i++) line[i] = (line[i] + line[i - bpp]) & 0xff; break
111      case 2: for (let i = 0; i < stride; i++) line[i] = (line[i] + prevLine[i]) & 0xff; break
112      case 3: for (let i = 0; i < stride; i++) line[i] = (line[i] + (((i >= bpp ? line[i - bpp] : 0) + prevLine[i]) >> 1)) & 0xff; break
113      case 4: for (let i = 0; i < stride; i++) line[i] = (line[i] + paeth(i >= bpp ? line[i - bpp] : 0, prevLine[i], i >= bpp ? prevLine[i - bpp] : 0)) & 0xff; break
114      default: throw new Error(`png: bad filter ${filter} on row ${y}`)
115    }
116    if (bpp === 4) data.set(line, y * width * 4)
117    else {
118      let di = y * width * 4
119      for (let i = 0; i < stride; i += 3) {
120        data[di++] = line[i]
121        data[di++] = line[i + 1]
122        data[di++] = line[i + 2]
123        data[di++] = 255
124      }
125    }
126    prevLine.set(line)
127  }
128  return { width, height, data }
129}
130
131/** Encodes RGBA pixels as a PNG (8-bit RGBA, Up-filtered rows, one fixed-Huffman deflate block). */
132export function encodePng(data: Uint8Array, width: number, height: number): Uint8Array {
133  const stride = width * 4
134  if (data.length < stride * height) throw new Error('png: pixel buffer is short')
135  const raw = new Uint8Array((stride + 1) * height)
136  for (let y = 0; y < height; y++) {
137    const ro = y * (stride + 1)
138    const so = y * stride
139    if (y === 0) {
140      raw[ro] = 0
141      raw.set(data.subarray(so, so + stride), ro + 1)
142    } else {
143      raw[ro] = 2
144      const po = so - stride
145      for (let i = 0; i < stride; i++) raw[ro + 1 + i] = (data[so + i] - data[po + i]) & 0xff
146    }
147  }
148  const body = deflateFixed(raw)
149  const zlib = new Uint8Array(body.length + 6)
150  zlib[0] = 0x78
151  zlib[1] = 0x01
152  zlib.set(body, 2)
153  writeU32(zlib, body.length + 2, adler32(raw))
154
155  const ihdr = new Uint8Array(13)
156  writeU32(ihdr, 0, width)
157  writeU32(ihdr, 4, height)
158  ihdr[8] = 8
159  ihdr[9] = 6
160  const out = new Uint8Array(8 + (12 + 13) + (12 + zlib.length) + 12)
161  out.set(SIGNATURE, 0)
162  let p = 8
163  const chunk = (type: string, payload: Uint8Array) => {
164    writeU32(out, p, payload.length)
165    for (let i = 0; i < 4; i++) out[p + 4 + i] = type.charCodeAt(i)
166    out.set(payload, p + 8)
167    writeU32(out, p + 8 + payload.length, crc32(out, p + 4, p + 8 + payload.length))
168    p += 12 + payload.length
169  }
170  chunk('IHDR', ihdr)
171  chunk('IDAT', zlib)
172  chunk('IEND', new Uint8Array(0))
173  return out
174}
175
176const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
177
178/** Standard padded base64; the environment's `toBase64` when it has one. */
179export function toBase64(bytes: Uint8Array): string {
180  const native = (bytes as unknown as { toBase64?: () => string }).toBase64
181  if (typeof native === 'function') return native.call(bytes)
182  let s = ''
183  const n = bytes.length
184  for (let i = 0; i < n; i += 3) {
185    const a = bytes[i]
186    const b = i + 1 < n ? bytes[i + 1] : 0
187    const c = i + 2 < n ? bytes[i + 2] : 0
188    s += B64[a >> 2] + B64[((a & 3) << 4) | (b >> 4)] + (i + 1 < n ? B64[((b & 15) << 2) | (c >> 6)] : '=') + (i + 2 < n ? B64[c & 63] : '=')
189  }
190  return s
191}
192
193export function fromBase64(text: string): Uint8Array {
194  const native = (Uint8Array as unknown as { fromBase64?: (s: string) => Uint8Array }).fromBase64
195  if (typeof native === 'function') return native.call(Uint8Array, text)
196  const clean = text.replace(/[^A-Za-z0-9+/]/g, '')
197  const out = new Uint8Array(Math.floor((clean.length * 3) / 4))
198  let o = 0
199  for (let i = 0; i + 1 < clean.length; i += 4) {
200    const a = B64.indexOf(clean[i])
201    const b = B64.indexOf(clean[i + 1])
202    const c = i + 2 < clean.length ? B64.indexOf(clean[i + 2]) : -1
203    const d = i + 3 < clean.length ? B64.indexOf(clean[i + 3]) : -1
204    out[o++] = (a << 2) | (b >> 4)
205    if (c >= 0) out[o++] = ((b & 15) << 4) | (c >> 2)
206    if (d >= 0) out[o++] = ((c & 3) << 6) | d
207  }
208  return out.subarray(0, o)
209}
210
hooks/aquarium/render.ts 230 lines
1// The compositor: the world and the sprites to one RGBA canvas, and that canvas to the
2// half-block cells of a Raster for a terminal without images. Pure pixel work, no $.
3import { forEachTextPixel, measureText } from './font.ts'
4import { frameRect, type Sheet, type Sprites } from './sprites.ts'
5import { drawnBabies, fishSize, floorY, overflowCount, parentOf, spriteFrame, waterlineY, type Fish, type World } from './world.ts'
6
7export type Canvas = { w: number; h: number; data: Uint8Array; u32: Uint32Array }
8
9export function createCanvas(w: number, h: number): Canvas {
10  const data = new Uint8Array(w * h * 4)
11  return { w, h, data, u32: new Uint32Array(data.buffer) }
12}
13
14// little-endian RGBA as one u32: 0xAABBGGRR
15const pack = (r: number, g: number, b: number, a = 255) => (((a & 0xff) << 24) | ((b & 0xff) << 16) | ((g & 0xff) << 8) | (r & 0xff)) >>> 0
16const lerp = (a: number, b: number, t: number) => a + (b - a) * t
17
18const WATER_TOP = [0x28, 0x78, 0xb4]
19const WATER_BOTTOM = [0x08, 0x1e, 0x46]
20const AIR = [0x07, 0x0d, 0x1a]
21const SAND = [0xc8, 0xaf, 0x7d]
22const SAND_DARK = [0xa8, 0x8e, 0x5f]
23const WATERLINE = [0xa8, 0xe0, 0xff]
24
25export function fillRect(c: Canvas, x: number, y: number, w: number, h: number, r: number, g: number, b: number, a = 255): void {
26  const x0 = Math.max(0, Math.round(x))
27  const y0 = Math.max(0, Math.round(y))
28  const x1 = Math.min(c.w, Math.round(x + w))
29  const y1 = Math.min(c.h, Math.round(y + h))
30  if (a >= 255) {
31    const v = pack(r, g, b)
32    for (let yy = y0; yy < y1; yy++) c.u32.fill(v, yy * c.w + x0, yy * c.w + x1)
33    return
34  }
35  const ia = 255 - a
36  for (let yy = y0; yy < y1; yy++) {
37    let i = (yy * c.w + x0) * 4
38    for (let xx = x0; xx < x1; xx++, i += 4) {
39      c.data[i] = (r * a + c.data[i] * ia + 127) / 255
40      c.data[i + 1] = (g * a + c.data[i + 1] * ia + 127) / 255
41      c.data[i + 2] = (b * a + c.data[i + 2] * ia + 127) / 255
42      c.data[i + 3] = 255
43    }
44  }
45}
46
47/** Alpha-blends a sheet rectangle onto the canvas at (dx, dy), clipped to the canvas. */
48export function blit(c: Canvas, s: Sheet, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number): void {
49  dx = Math.round(dx)
50  dy = Math.round(dy)
51  const x0 = Math.max(0, -dx)
52  const y0 = Math.max(0, -dy)
53  const x1 = Math.min(sw, c.w - dx)
54  const y1 = Math.min(sh, c.h - dy)
55  const d = c.data
56  const src = s.data
57  for (let y = y0; y < y1; y++) {
58    let si = ((sy + y) * s.w + sx + x0) * 4
59    let di = ((dy + y) * c.w + dx + x0) * 4
60    for (let x = x0; x < x1; x++, si += 4, di += 4) {
61      const a = src[si + 3]
62      if (a === 0) continue
63      if (a === 255) {
64        d[di] = src[si]
65        d[di + 1] = src[si + 1]
66        d[di + 2] = src[si + 2]
67        d[di + 3] = 255
68      } else {
69        const ia = 255 - a
70        d[di] = (src[si] * a + d[di] * ia + 127) / 255
71        d[di + 1] = (src[si + 1] * a + d[di + 1] * ia + 127) / 255
72        d[di + 2] = (src[si + 2] * a + d[di + 2] * ia + 127) / 255
73        d[di + 3] = 255
74      }
75    }
76  }
77}
78
79function drawText(c: Canvas, text: string, x: number, y: number, scale: number, r: number, g: number, b: number): void {
80  const v = pack(r, g, b)
81  forEachTextPixel(text, Math.round(x), Math.round(y), scale, (px, py) => {
82    if (px >= 0 && py >= 0 && px < c.w && py < c.h) c.u32[py * c.w + px] = v
83  })
84}
85
86function drawBackground(c: Canvas, world: World): void {
87  const top = waterlineY(world)
88  const floor = floorY(world)
89  const light = world.light
90  for (let y = 0; y < c.h; y++) {
91    let v: number
92    if (y >= floor) {
93      v = pack(SAND[0], SAND[1], SAND[2])
94    } else if (y < top) {
95      v = pack(AIR[0], AIR[1], AIR[2])
96    } else if (y === top || (y === top + 1 && world.scale >= 1)) {
97      v = pack(WATERLINE[0], WATERLINE[1], WATERLINE[2])
98    } else {
99      const t = y / Math.max(1, floor)
100      const r = lerp(WATER_TOP[0], WATER_BOTTOM[0], t)
101      const g = lerp(WATER_TOP[1], WATER_BOTTOM[1], t)
102      const b = lerp(WATER_TOP[2], WATER_BOTTOM[2], t)
103      const k = 0.22 * light
104      v = pack(lerp(r, 255, k), lerp(g, 255, k), lerp(b, 255, k))
105    }
106    c.u32.fill(v, y * c.w, (y + 1) * c.w)
107  }
108  // grains in the sand
109  const dark = pack(SAND_DARK[0], SAND_DARK[1], SAND_DARK[2])
110  for (let y = floor + 1; y < c.h; y++) {
111    for (let x = (y * 7) % 11; x < c.w; x += 11) if (((x * 13 + y * 7) & 3) === 0) c.u32[y * c.w + x] = dark
112  }
113}
114
115function drawFish(c: Canvas, world: World, sprites: Sprites, fish: Fish, now: number): void {
116  const { state, index } = spriteFrame(fish, now)
117  const key = `${fish.color}_${fish.dir > 0 ? 'right' : 'left'}`
118  const rect = frameRect(sprites, key, state, index)
119  const sheet = sprites.sheets[key]
120  if (!rect || !sheet) return
121  blit(c, sheet, rect.x, rect.y, rect.w, rect.h, fish.x - rect.w / 2, fish.y - rect.h / 2)
122}
123
124function drawBubbleText(c: Canvas, world: World, fish: Fish, text: string): void {
125  const { h } = fishSize(world, fish)
126  const scale = world.scale >= 1 ? 2 : 1
127  const tw = measureText(text, scale)
128  const th = 5 * scale
129  const pad = 2 * scale
130  const bw = tw + pad * 2
131  const bh = th + pad * 2
132  let bx = Math.round(fish.x - bw / 2)
133  bx = Math.max(1, Math.min(c.w - bw - 1, bx))
134  let by = Math.round(fish.y - h / 2 - bh - 3 * scale)
135  if (by < 1) by = Math.round(fish.y + h / 2 + 3 * scale)
136  fillRect(c, bx, by, bw, bh, 255, 255, 255, 215)
137  fillRect(c, bx + 1, by - 1, bw - 2, 1, 255, 255, 255, 215)
138  fillRect(c, bx + 1, by + bh, bw - 2, 1, 255, 255, 255, 215)
139  // the tail toward the fish
140  const tx = Math.round(Math.max(bx + 2, Math.min(bx + bw - 3, fish.x)))
141  fillRect(c, tx, by + bh + 1, scale, scale, 255, 255, 255, 215)
142  drawText(c, text, bx + pad, by + pad, scale, 0x0b, 0x1e, 0x3a)
143}
144
145function drawBadge(c: Canvas, world: World, count: number): void {
146  const parent = parentOf(world)
147  if (!parent) return
148  const { w, h } = fishSize(world, parent)
149  const scale = world.scale >= 1 ? 2 : 1
150  const text = `+${count}`
151  const tw = measureText(text, scale)
152  const pad = 2 * scale
153  const bx = Math.round(Math.min(c.w - tw - pad * 2 - 1, parent.x + w / 2 - 4 * scale))
154  const by = Math.round(Math.max(1, parent.y - h / 2 - 8 * scale))
155  fillRect(c, bx, by, tw + pad * 2, 5 * scale + pad * 2, 0xff, 0xb3, 0x3c, 235)
156  drawText(c, text, bx + pad, by + pad, scale, 0x1a, 0x10, 0x05)
157}
158
159/** Draws the whole tank for `now` into the canvas. */
160export function drawWorld(c: Canvas, world: World, sprites: Sprites, now: number): void {
161  drawBackground(c, world)
162  const floor = floorY(world)
163
164  const weed = sprites.seaweed
165  const wf = sprites.seaweedFrame
166  for (const s of world.seaweed) {
167    const frame = (Math.floor(now / 250) + s.phase) % wf.frames
168    blit(c, weed, frame * wf.w, 0, wf.w, wf.h, s.x - wf.w / 2, floor - wf.h + 3 * world.scale)
169  }
170
171  const nest = sprites.propRects.nest
172  if (nest) for (const x of world.nests) blit(c, sprites.props, nest.x, nest.y, nest.w, nest.h, x - nest.w / 2, floor - nest.h + 2 * world.scale)
173
174  const food = sprites.propRects.food
175  if (food) for (const f of world.food) blit(c, sprites.props, food.x, food.y, food.w, food.h, f.x - food.w / 2, f.y - food.h / 2)
176
177  const parent = parentOf(world)
178  if (parent) drawFish(c, world, sprites, parent, now)
179  for (const baby of drawnBabies(world)) drawFish(c, world, sprites, baby, now)
180
181  for (const b of world.bubbles) {
182    const rect = sprites.propRects[`bubble_${b.size}`]
183    if (rect) blit(c, sprites.props, rect.x, rect.y, rect.w, rect.h, b.x - rect.w / 2, b.y - rect.h / 2)
184  }
185
186  for (const fish of world.fish) if (!fish.gone && !fish.overflow && fish.bubble) drawBubbleText(c, world, fish, fish.bubble.text)
187  const extra = overflowCount(world)
188  if (extra > 0) drawBadge(c, world, extra)
189}
190
191const DEFAULT_COLOR = 0x01000000
192const UPPER_HALF = 0x2580
193
194/**
195 * The canvas as `columns * rows` half-block cells: each cell's glyph is ▀, its foreground the
196 * mean colour of the cell's upper half, its background the lower half's. Packed as RasterProps wants.
197 */
198export function toHalfBlocks(c: Canvas, columns: number, rows: number): Uint8Array {
199  const cw = Math.max(1, Math.floor(c.w / columns))
200  const ch = Math.max(2, Math.floor(c.h / rows))
201  const half = Math.floor(ch / 2)
202  const words = new Uint32Array(columns * rows * 3)
203  const mean = (x0: number, y0: number, w: number, h: number): number => {
204    let r = 0
205    let g = 0
206    let b = 0
207    let n = 0
208    for (let y = y0; y < y0 + h && y < c.h; y++) {
209      let i = (y * c.w + x0) * 4
210      for (let x = x0; x < x0 + w && x < c.w; x++, i += 4) {
211        r += c.data[i]
212        g += c.data[i + 1]
213        b += c.data[i + 2]
214        n++
215      }
216    }
217    if (n === 0) return DEFAULT_COLOR
218    return ((Math.round(r / n) << 16) | (Math.round(g / n) << 8) | Math.round(b / n)) >>> 0
219  }
220  let o = 0
221  for (let row = 0; row < rows; row++) {
222    for (let col = 0; col < columns; col++) {
223      words[o++] = UPPER_HALF
224      words[o++] = mean(col * cw, row * ch, cw, half)
225      words[o++] = mean(col * cw, row * ch + half, cw, ch - half)
226    }
227  }
228  return new Uint8Array(words.buffer, words.byteOffset, words.byteLength)
229}
230
hooks/aquarium/sprites.ts 114 lines
1// The sprite sheets as the compositor uses them: decoded once at 1:1, then boxed down to
2// the tank's sprite scale. Frame rectangles come from assets/manifest.json, never from here.
3import type { Rgba } from './png.ts'
4
5export type Manifest = {
6  cols: number
7  states: Record<string, { row: number; frames: number }>
8  sheets: Record<string, { file: string; w: number; h: number }>
9  props: { file: string; rects: Record<string, [number, number, number, number]> }
10  seaweed: { file: string; w: number; h: number; frames: number }
11}
12
13/** Everything decoded at 1:1: keyed as the manifest keys them. */
14export type RawAssets = { manifest: Manifest; sheets: Record<string, Rgba>; props: Rgba; seaweed: Rgba }
15
16export type Sheet = { w: number; h: number; data: Uint8Array }
17export type Rect = { x: number; y: number; w: number; h: number }
18
19/** The sheets at one scale, with the frame and prop rectangles scaled to match. */
20export type Sprites = {
21  scale: number
22  sheets: Record<string, Sheet>
23  frame: Record<string, { w: number; h: number }>
24  props: Sheet
25  propRects: Record<string, Rect>
26  seaweed: Sheet
27  seaweedFrame: { w: number; h: number; frames: number }
28  states: Record<string, { row: number; frames: number }>
29  cols: number
30}
31
32export function isManifest(value: unknown): value is Manifest {
33  const m = value as Manifest
34  return typeof m === 'object' && m !== null && typeof m.cols === 'number' && typeof m.states === 'object' && typeof m.sheets === 'object' && typeof m.props === 'object' && typeof m.seaweed === 'object'
35}
36
37/** Shrinks an image by an integer factor with a box filter weighted by alpha, so edges keep their colour. */
38export function downscale(src: Rgba, factor: number): Sheet {
39  if (factor <= 1) return { w: src.width, h: src.height, data: src.data }
40  const w = Math.floor(src.width / factor)
41  const h = Math.floor(src.height / factor)
42  const out = new Uint8Array(w * h * 4)
43  const s = src.data
44  const area = factor * factor
45  for (let y = 0; y < h; y++) {
46    for (let x = 0; x < w; x++) {
47      let sa = 0
48      let sr = 0
49      let sg = 0
50      let sb = 0
51      for (let dy = 0; dy < factor; dy++) {
52        let i = ((y * factor + dy) * src.width + x * factor) * 4
53        for (let dx = 0; dx < factor; dx++, i += 4) {
54          const a = s[i + 3]
55          sa += a
56          sr += s[i] * a
57          sg += s[i + 1] * a
58          sb += s[i + 2] * a
59        }
60      }
61      const o = (y * w + x) * 4
62      if (sa > 0) {
63        out[o] = Math.round(sr / sa)
64        out[o + 1] = Math.round(sg / sa)
65        out[o + 2] = Math.round(sb / sa)
66        out[o + 3] = Math.round(sa / area)
67      }
68    }
69  }
70  return { w, h, data: out }
71}
72
73/** The sprite scale for a tank of `h` pixels: full-size sprites once the tank is tall enough for them. */
74export function scaleForHeight(h: number): number {
75  return h >= 300 ? 1 : 0.5
76}
77
78export function buildSprites(raw: RawAssets, scale: number): Sprites {
79  const factor = Math.max(1, Math.round(1 / scale))
80  const sheets: Record<string, Sheet> = {}
81  const frame: Record<string, { w: number; h: number }> = {}
82  for (const [key, meta] of Object.entries(raw.manifest.sheets)) {
83    const img = raw.sheets[key]
84    if (!img) continue
85    sheets[key] = downscale(img, factor)
86    frame[key] = { w: Math.floor(meta.w / factor), h: Math.floor(meta.h / factor) }
87  }
88  const propRects: Record<string, Rect> = {}
89  for (const [name, [x, y, w, h]] of Object.entries(raw.manifest.props.rects)) {
90    propRects[name] = { x: Math.floor(x / factor), y: Math.floor(y / factor), w: Math.max(1, Math.floor(w / factor)), h: Math.max(1, Math.floor(h / factor)) }
91  }
92  const sw = raw.manifest.seaweed
93  return {
94    scale: 1 / factor,
95    sheets,
96    frame,
97    props: downscale(raw.props, factor),
98    propRects,
99    seaweed: downscale(raw.seaweed, factor),
100    seaweedFrame: { w: Math.floor(sw.w / factor), h: Math.floor(sw.h / factor), frames: sw.frames },
101    states: raw.manifest.states,
102    cols: raw.manifest.cols,
103  }
104}
105
106/** The source rectangle of one frame of a sheet: `(col * w, row * h, w, h)` as the manifest notes. */
107export function frameRect(sprites: Sprites, sheetKey: string, state: string, index: number): Rect | undefined {
108  const size = sprites.frame[sheetKey]
109  const st = sprites.states[state]
110  if (!size || !st) return undefined
111  const col = Math.max(0, Math.min(st.frames - 1, index)) % sprites.cols
112  return { x: col * size.w, y: st.row * size.h, w: size.w, h: size.h }
113}
114
hooks/aquarium/world.ts 421 lines
1// The tank's state and its physics: plain data and pure functions, no $ and no drawing.
2// Positions are pixels of the canvas (floats), time is milliseconds, speeds are pixels a second.
3
4export type FishColor = 'parent' | 'blue' | 'orange' | 'green' | 'purple'
5export type FishState = 'swim' | 'eat' | 'dash' | 'surface' | 'error' | 'spawn' | 'exit'
6export type SpriteState = 'swim' | 'eat' | 'dash' | 'error' | 'spawn' | 'exit'
7export type Action = 'eat' | 'nest' | 'dash' | 'surface'
8
9export type Fish = {
10  id: string
11  kind: 'parent' | 'baby'
12  color: FishColor
13  x: number
14  y: number
15  vx: number
16  vy: number
17  dir: 1 | -1
18  state: FishState
19  stateAt: number
20  stateUntil: number
21  sinkAt?: number
22  target?: { x: number; y: number }
23  foodId?: number
24  wander?: { x: number; y: number; until: number }
25  bubble?: { text: string; until: number }
26  phase: number
27  bornAt: number
28  overflow: boolean
29  gone: boolean
30}
31
32export type Food = { id: number; x: number; y: number; vy: number; landedAt?: number; eaten: boolean }
33export type Bubble = { x: number; y: number; size: 'l' | 'm' | 's'; vy: number; phase: number }
34
35export type World = {
36  w: number
37  h: number
38  scale: number
39  sandH: number
40  now: number
41  fish: Fish[]
42  food: Food[]
43  bubbles: Bubble[]
44  nests: number[]
45  water: number
46  waterTarget: number
47  light: number
48  lightTarget: number
49  seaweed: { x: number; phase: number }[]
50  typeColors: Record<string, FishColor>
51  nextAmbient: number
52  nextFoodId: number
53}
54
55export const PARENT_ID = 'main'
56export const MAX_BABIES = 12
57export const MAX_NESTS = 24
58export const SPRITE = { parent: { w: 160, h: 80 }, baby: { w: 96, h: 48 } } as const
59export const FRAME_MS: Record<SpriteState, number> = { swim: 120, eat: 150, dash: 70, error: 150, spawn: 150, exit: 220 }
60export const DURATION = { eat: 3000, chew: 500, dash: 1200, surface: 2200, errorShake: 600, errorSink: 2000, spawn: 600, exit: 2600, bubble: 1500, foodRest: 1200 } as const
61
62export const clamp = (v: number, lo: number, hi: number) => (v < lo ? lo : v > hi ? hi : v)
63
64export function fishSize(world: World, fish: Fish): { w: number; h: number } {
65  const s = SPRITE[fish.kind]
66  return { w: s.w * world.scale, h: s.h * world.scale }
67}
68export const floorY = (world: World) => world.h - world.sandH
69export const waterlineY = (world: World) => Math.round(floorY(world) * (1 - world.water))
70
71/** Water level for a context fill: full at 0%, down to 30% of the tank at 100%. */
72export function waterLevelFor(percent: number | undefined): number {
73  const p = clamp(percent ?? 0, 0, 100)
74  return 1 - 0.7 * (p / 100)
75}
76
77function seaweedFor(w: number): { x: number; phase: number }[] {
78  const spots = [{ x: 0.08, phase: 0 }, { x: 0.86, phase: 1 }, { x: 0.58, phase: 2 }]
79  return spots.slice(0, w >= 400 ? 3 : 2).map(s => ({ x: Math.round(s.x * w), phase: s.phase }))
80}
81
82function makeFish(id: string, kind: Fish['kind'], color: FishColor, x: number, y: number, now: number, phase: number): Fish {
83  return { id, kind, color, x, y, vx: 0, vy: 0, dir: 1, state: 'swim', stateAt: now, stateUntil: 0, phase, bornAt: now, overflow: false, gone: false }
84}
85
86export function createWorld(w: number, h: number, scale: number, now: number): World {
87  const sandH = Math.max(6, Math.round(h * 0.09))
88  const world: World = {
89    w, h, scale, sandH, now,
90    fish: [], food: [], bubbles: [], nests: [],
91    water: 1, waterTarget: 1, light: 0, lightTarget: 0,
92    seaweed: seaweedFor(w), typeColors: {}, nextAmbient: now + 800, nextFoodId: 1,
93  }
94  world.fish.push(makeFish(PARENT_ID, 'parent', 'parent', w / 2, (h - sandH) / 2, now, 0))
95  return world
96}
97
98/** Fits the world to a new canvas: every position scales with it, the sand and weeds are re-seated. */
99export function resizeWorld(world: World, w: number, h: number, scale: number): void {
100  const fx = w / world.w
101  const fy = h / world.h
102  for (const f of world.fish) {
103    f.x *= fx
104    f.y *= fy
105    f.wander = undefined
106    if (f.target) f.target = { x: f.target.x * fx, y: f.target.y * fy }
107  }
108  for (const f of world.food) { f.x *= fx; f.y *= fy }
109  for (const b of world.bubbles) { b.x *= fx; b.y *= fy }
110  world.nests = world.nests.map(x => x * fx)
111  world.w = w
112  world.h = h
113  world.scale = scale
114  world.sandH = Math.max(6, Math.round(h * 0.09))
115  world.seaweed = seaweedFor(w)
116}
117
118export const parentOf = (world: World): Fish | undefined => world.fish.find(f => f.id === PARENT_ID)
119export const findFish = (world: World, id: string): Fish | undefined => world.fish.find(f => f.id === id && !f.gone)
120export const drawnBabies = (world: World): Fish[] => world.fish.filter(f => f.kind === 'baby' && !f.overflow && !f.gone)
121export const overflowCount = (world: World): number => world.fish.filter(f => f.kind === 'baby' && f.overflow && !f.gone).length
122
123/** Which colour a subagent type wears: Explore blue, general-purpose orange, then green, then purple for the rest. */
124export function colorForType(world: World, type: string): FishColor {
125  const known = world.typeColors[type]
126  if (known) return known
127  const color: FishColor = type === 'Explore' ? 'blue' : type === 'general-purpose' ? 'orange' : Object.keys(world.typeColors).filter(t => t !== 'Explore' && t !== 'general-purpose').length === 0 ? 'green' : 'purple'
128  world.typeColors[type] = color
129  return color
130}
131
132/** A baby for a subagent id: born beside the parent with the spawn animation, or held back past MAX_BABIES. */
133export function addBaby(world: World, id: string, color: FishColor, now: number, rand = Math.random): Fish {
134  const existing = findFish(world, id)
135  if (existing) return existing
136  const parent = parentOf(world)
137  const px = parent?.x ?? world.w / 2
138  const py = parent?.y ?? world.h / 2
139  const fish = makeFish(id, 'baby', color, px + (rand() - 0.5) * 80 * world.scale, py + (rand() - 0.5) * 40 * world.scale, now, rand() * Math.PI * 2)
140  fish.dir = parent?.dir ?? 1
141  if (drawnBabies(world).length >= MAX_BABIES) {
142    fish.overflow = true
143  } else {
144    fish.state = 'spawn'
145    fish.stateAt = now
146    fish.stateUntil = now + DURATION.spawn
147  }
148  world.fish.push(fish)
149  return fish
150}
151
152/** Sends a fish off the nearest edge with the exit animation; a held-back one just leaves. */
153export function exitFish(world: World, id: string, now: number): void {
154  const fish = findFish(world, id)
155  if (!fish || fish.kind !== 'baby' || fish.state === 'exit') return
156  if (fish.overflow) {
157    fish.gone = true
158    return
159  }
160  const { w } = fishSize(world, fish)
161  fish.state = 'exit'
162  fish.stateAt = now
163  fish.stateUntil = now + DURATION.exit
164  fish.target = { x: fish.x < world.w / 2 ? -w : world.w + w, y: fish.y }
165  fish.foodId = undefined
166  fish.bubble = undefined
167}
168
169export function setBubble(fish: Fish, text: string, now: number): void {
170  fish.bubble = { text, until: now + DURATION.bubble }
171}
172
173function spitBubbles(world: World, fish: Fish, rand: () => number): void {
174  const { w, h } = fishSize(world, fish)
175  const mouthX = fish.x + fish.dir * w * 0.45
176  const sizes: Bubble['size'][] = ['s', 'm', 'l']
177  for (let i = 0; i < 3; i++) {
178    world.bubbles.push({ x: mouthX + (rand() - 0.5) * 6 * world.scale, y: fish.y - h * 0.1 - i * 4 * world.scale, size: sizes[i], vy: (22 + rand() * 12) * world.scale, phase: rand() * Math.PI * 2 })
179  }
180}
181
182/** Puts a fish into the animation a tool call asks for; spawn, exit and error are not interrupted. */
183export function startAction(world: World, fish: Fish, action: Action, now: number, rand = Math.random): void {
184  if (fish.gone || fish.overflow || fish.state === 'spawn' || fish.state === 'exit' || fish.state === 'error') return
185  const { w } = fishSize(world, fish)
186  switch (action) {
187    case 'eat': {
188      const id = world.nextFoodId++
189      const x = clamp(fish.x + (rand() - 0.5) * world.w * 0.35, 8, world.w - 8)
190      world.food.push({ id, x, y: waterlineY(world) + 2, vy: 10 * world.scale, eaten: false })
191      fish.state = 'eat'
192      fish.stateAt = now
193      fish.stateUntil = now + DURATION.eat
194      fish.foodId = id
195      break
196    }
197    case 'nest': {
198      spitBubbles(world, fish, rand)
199      world.nests.push(clamp(fish.x + (rand() - 0.5) * 60 * world.scale, w * 0.3, world.w - w * 0.3))
200      if (world.nests.length > MAX_NESTS) world.nests.shift()
201      break
202    }
203    case 'dash': {
204      fish.state = 'dash'
205      fish.stateAt = now
206      fish.stateUntil = now + DURATION.dash
207      const far = fish.x < world.w / 2 ? world.w - w : w
208      fish.wander = { x: far, y: clamp(fish.y + (rand() - 0.5) * world.h * 0.3, 0, world.h), until: now + DURATION.dash }
209      break
210    }
211    case 'surface': {
212      fish.state = 'surface'
213      fish.stateAt = now
214      fish.stateUntil = now + DURATION.surface
215      fish.target = { x: clamp(fish.x + (rand() - 0.5) * 40 * world.scale, w / 2, world.w - w / 2), y: 0 }
216      break
217    }
218  }
219}
220
221/** The error animation: a shake in place, then a sink, then back to swimming. */
222export function setError(world: World, fish: Fish, now: number): void {
223  if (fish.gone || fish.overflow || fish.state === 'spawn' || fish.state === 'exit') return
224  fish.state = 'error'
225  fish.stateAt = now
226  fish.sinkAt = now + DURATION.errorShake
227  fish.stateUntil = now + DURATION.errorShake + DURATION.errorSink
228  fish.foodId = undefined
229  fish.target = undefined
230  fish.vx = 0
231  fish.vy = 0
232}
233
234export function setWaterTarget(world: World, level: number): void {
235  world.waterTarget = clamp(level, 0.2, 1)
236}
237export function setLight(world: World, on: boolean): void {
238  world.lightTarget = on ? 1 : 0
239}
240
241/** The sheet row and frame index a fish shows now. */
242export function spriteFrame(fish: Fish, now: number): { state: SpriteState; index: number } {
243  const elapsed = Math.max(0, now - fish.stateAt)
244  switch (fish.state) {
245    case 'spawn': return { state: 'spawn', index: Math.min(3, Math.floor(elapsed / FRAME_MS.spawn)) }
246    case 'exit': return { state: 'exit', index: Math.min(3, Math.floor((elapsed / DURATION.exit) * 4)) }
247    case 'error': return { state: 'error', index: fish.sinkAt !== undefined && now >= fish.sinkAt ? 3 : Math.floor(elapsed / FRAME_MS.error) % 4 }
248    case 'eat': return { state: 'eat', index: Math.floor(elapsed / FRAME_MS.eat) % 4 }
249    case 'dash': return { state: 'dash', index: Math.floor(elapsed / FRAME_MS.dash) % 4 }
250    default: return { state: 'swim', index: Math.floor((now + fish.phase * 1000) / FRAME_MS.swim) % 6 }
251  }
252}
253
254function speedOf(world: World, fish: Fish): number {
255  const base = clamp(world.w * 0.09, 24, 90)
256  return fish.kind === 'parent' ? base : base * 1.35
257}
258
259// steers toward a point; returns true once within reach
260function steer(fish: Fish, tx: number, ty: number, speed: number, dt: number, reach: number): boolean {
261  const dx = tx - fish.x
262  const dy = ty - fish.y
263  const d = Math.hypot(dx, dy)
264  if (d < 0.5) {
265    fish.vx *= 0.7
266    fish.vy *= 0.7
267    return true
268  }
269  const k = Math.min(1, 4 * dt)
270  fish.vx += ((dx / d) * speed - fish.vx) * k
271  fish.vy += ((dy / d) * speed - fish.vy) * k
272  return d <= reach
273}
274
275function pickWander(world: World, fish: Fish, minY: number, maxY: number, now: number, rand: () => number): void {
276  const { w } = fishSize(world, fish)
277  let x: number
278  let y: number
279  if (fish.kind === 'parent') {
280    x = world.w * (0.2 + rand() * 0.6)
281    y = minY + (maxY - minY) * (0.25 + rand() * 0.5)
282  } else {
283    const parent = parentOf(world)
284    const cx = parent?.x ?? world.w / 2
285    const cy = parent?.y ?? (minY + maxY) / 2
286    x = cx + (rand() - 0.5) * world.w * 0.55
287    y = cy + (rand() - 0.5) * (maxY - minY) * 0.7
288  }
289  fish.wander = { x: clamp(x, w / 2, world.w - w / 2), y: clamp(y, minY, maxY), until: now + 1800 + rand() * 3200 }
290}
291
292/** Advances the tank to `now`. Deterministic given `rand`. */
293export function step(world: World, now: number, rand = Math.random): void {
294  const dt = clamp((now - world.now) / 1000, 0, 0.2)
295  world.now = now
296  world.water += clamp(world.waterTarget - world.water, -0.2 * dt, 0.2 * dt)
297  world.light += clamp(world.lightTarget - world.light, -2.5 * dt, 2.5 * dt)
298  const top = waterlineY(world)
299  const floor = floorY(world)
300
301  for (const fish of world.fish) {
302    if (fish.gone || fish.overflow) continue
303    const { w: sw, h: sh } = fishSize(world, fish)
304    const minY = top + sh / 2 + 2
305    const maxY = floor - sh / 2
306    const speed = speedOf(world, fish)
307    const reach = 6 * world.scale + speed * dt
308
309    switch (fish.state) {
310      case 'spawn':
311        fish.vx = 0
312        fish.vy = -4 * world.scale
313        if (now >= fish.stateUntil) { fish.state = 'swim'; fish.stateAt = now }
314        break
315      case 'exit':
316        if (fish.target) steer(fish, fish.target.x, fish.target.y, speed * 1.6, dt, reach)
317        if (now >= fish.stateUntil || fish.x < -sw || fish.x > world.w + sw) fish.gone = true
318        break
319      case 'error':
320        if (fish.sinkAt !== undefined && now < fish.sinkAt) {
321          fish.vx = 0
322          fish.vy = 0
323        } else {
324          fish.vx *= 0.9
325          fish.vy = 26 * world.scale
326        }
327        if (now >= fish.stateUntil) { fish.state = 'swim'; fish.stateAt = now; fish.vy = 0; fish.sinkAt = undefined }
328        break
329      case 'eat': {
330        const food = fish.foodId === undefined ? undefined : world.food.find(f => f.id === fish.foodId && !f.eaten)
331        if (food) {
332          if (steer(fish, food.x, food.y, speed * 1.5, dt, reach)) {
333            food.eaten = true
334            fish.foodId = undefined
335            fish.stateUntil = now + DURATION.chew
336          }
337        } else if (now >= fish.stateUntil) {
338          fish.state = 'swim'
339          fish.stateAt = now
340          fish.foodId = undefined
341        } else if (fish.foodId !== undefined) {
342          // the pellet is gone: give up on it
343          fish.foodId = undefined
344          fish.stateUntil = now + DURATION.chew
345        } else {
346          fish.vx *= 0.9
347          fish.vy *= 0.9
348        }
349        break
350      }
351      case 'surface':
352        if (fish.target) steer(fish, fish.target.x, minY, speed * 1.2, dt, reach)
353        if (now >= fish.stateUntil) { fish.state = 'swim'; fish.stateAt = now; fish.target = undefined }
354        break
355      case 'dash':
356      case 'swim': {
357        const dash = fish.state === 'dash'
358        if (!fish.wander || now > fish.wander.until) pickWander(world, fish, minY, maxY, now, rand)
359        const target = fish.wander
360        if (target) {
361          const reached = steer(fish, target.x, target.y, dash ? speed * 3 : speed, dt, reach)
362          if (reached) pickWander(world, fish, minY, maxY, now, rand)
363        }
364        fish.vy += Math.cos(now / 450 + fish.phase) * 14 * world.scale * dt
365        if (dash && now >= fish.stateUntil) { fish.state = 'swim'; fish.stateAt = now; fish.wander = undefined }
366        break
367      }
368    }
369
370    fish.x += fish.vx * dt
371    fish.y += fish.vy * dt
372    if (fish.state !== 'exit') fish.x = clamp(fish.x, sw / 2, world.w - sw / 2)
373    fish.y = clamp(fish.y, minY, maxY)
374    if (Math.abs(fish.vx) > 2) fish.dir = fish.vx > 0 ? 1 : -1
375    if (fish.bubble && now >= fish.bubble.until) fish.bubble = undefined
376  }
377
378  // a fish that left frees a place for one that was held back
379  world.fish = world.fish.filter(f => !f.gone)
380  let room = MAX_BABIES - drawnBabies(world).length
381  for (const f of world.fish) {
382    if (room <= 0) break
383    if (f.kind === 'baby' && f.overflow) {
384      f.overflow = false
385      f.state = 'spawn'
386      f.stateAt = now
387      f.stateUntil = now + DURATION.spawn
388      const parent = parentOf(world)
389      f.x = clamp((parent?.x ?? world.w / 2) + (rand() - 0.5) * 60 * world.scale, 0, world.w)
390      f.y = clamp(parent?.y ?? world.h / 2, top, floor)
391      room--
392    }
393  }
394
395  for (const food of world.food) {
396    if (food.eaten) continue
397    if (food.landedAt === undefined) {
398      food.vy += 30 * world.scale * dt
399      food.y += food.vy * dt
400      if (food.y >= floor - 3 * world.scale) {
401        food.y = floor - 3 * world.scale
402        food.landedAt = now
403      }
404    } else if (now - food.landedAt > DURATION.foodRest) {
405      food.eaten = true
406    }
407  }
408  world.food = world.food.filter(f => !f.eaten)
409
410  for (const b of world.bubbles) {
411    b.y -= b.vy * dt
412    b.x += Math.sin(now / 300 + b.phase) * 10 * world.scale * dt
413  }
414  world.bubbles = world.bubbles.filter(b => b.y > top + 1)
415  if (now >= world.nextAmbient) {
416    const sizes: Bubble['size'][] = ['s', 's', 'm']
417    world.bubbles.push({ x: 4 + rand() * (world.w - 8), y: floor, size: sizes[Math.floor(rand() * sizes.length)], vy: (18 + rand() * 14) * world.scale, phase: rand() * Math.PI * 2 })
418    world.nextAmbient = now + 700 + rand() * 1600
419  }
420}
421
hooks/aquarium/deflate.ts 146 lines
1// Deflate (RFC 1951) in plain TypeScript: one fixed-Huffman block over a greedy LZ77
2// with a hash chain. Every frame goes to the terminal as a PNG through $.ui.blit, and the
3// hooks environment has no zlib or CompressionStream, so the encoder lives here. Frames are
4// mostly flat water (and, after PNG's Up filter, mostly zeros), where long matches at
5// distance 1 do the work; fixed codes are enough and cheap to emit.
6
7const LEN_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258]
8const LEN_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0]
9const DIST_BASE = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577]
10const DIST_EXTRA = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13]
11
12const WSIZE = 32768
13const WMASK = WSIZE - 1
14const HBITS = 15
15const MAX_CHAIN = 8
16const MIN_MATCH = 3
17const MAX_MATCH = 258
18
19// length (3..258) -> length code, extra bit count, extra bit value
20const LEN_CODE = new Uint16Array(259)
21const LEN_EB = new Uint8Array(259)
22const LEN_EV = new Uint16Array(259)
23for (let i = 0; i < 29; i++) {
24  const end = i === 28 ? 259 : LEN_BASE[i + 1]
25  for (let l = LEN_BASE[i]; l < end; l++) {
26    LEN_CODE[l] = 257 + i
27    LEN_EB[l] = LEN_EXTRA[i]
28    LEN_EV[l] = l - LEN_BASE[i]
29  }
30}
31
32function reverseBits(code: number, len: number): number {
33  let r = 0
34  for (let i = 0; i < len; i++) {
35    r = (r << 1) | (code & 1)
36    code >>= 1
37  }
38  return r
39}
40
41// fixed literal/length codes (RFC 1951 3.2.6), stored bit-reversed for an LSB-first writer
42const LIT_RCODE = new Uint16Array(288)
43const LIT_LEN = new Uint8Array(288)
44for (let sym = 0; sym < 288; sym++) {
45  let code: number
46  let len: number
47  if (sym < 144) { code = 0x30 + sym; len = 8 }
48  else if (sym < 256) { code = 0x190 + (sym - 144); len = 9 }
49  else if (sym < 280) { code = sym - 256; len = 7 }
50  else { code = 0xc0 + (sym - 280); len = 8 }
51  LIT_RCODE[sym] = reverseBits(code, len)
52  LIT_LEN[sym] = len
53}
54const DIST_RCODE = new Uint8Array(30)
55for (let i = 0; i < 30; i++) DIST_RCODE[i] = reverseBits(i, 5)
56
57function distCode(d: number): number {
58  for (let i = 29; i >= 0; i--) if (d >= DIST_BASE[i]) return i
59  return 0
60}
61
62/** Compresses to a raw deflate stream: one final fixed-Huffman block. */
63export function deflateFixed(src: Uint8Array): Uint8Array {
64  const n = src.length
65  let out = new Uint8Array(n + (n >> 3) + 64)
66  let outPos = 0
67  let bitBuf = 0
68  let bitCnt = 0
69  const grow = () => {
70    const bigger = new Uint8Array(out.length * 2)
71    bigger.set(out)
72    out = bigger
73  }
74  const put = (v: number, bits: number) => {
75    bitBuf |= v << bitCnt
76    bitCnt += bits
77    while (bitCnt >= 8) {
78      if (outPos >= out.length) grow()
79      out[outPos++] = bitBuf & 0xff
80      bitBuf >>>= 8
81      bitCnt -= 8
82    }
83  }
84
85  put(1, 1) // BFINAL
86  put(1, 2) // BTYPE = 01, fixed Huffman
87
88  const head = new Int32Array(1 << HBITS).fill(-1)
89  const prev = new Int32Array(WSIZE)
90  const hash = (p: number) => Math.imul((src[p] << 16) | (src[p + 1] << 8) | src[p + 2], 0x9e3779b1) >>> (32 - HBITS)
91  const insert = (p: number) => {
92    if (p + 2 >= n) return
93    const hv = hash(p)
94    prev[p & WMASK] = head[hv]
95    head[hv] = p
96  }
97
98  let i = 0
99  while (i < n) {
100    let bestLen = 0
101    let bestDist = 0
102    if (i + 2 < n) {
103      const maxLen = Math.min(MAX_MATCH, n - i)
104      let cand = head[hash(i)]
105      let chain = MAX_CHAIN
106      while (cand >= 0 && chain-- > 0 && i - cand <= WSIZE) {
107        if (src[cand + bestLen] === src[i + bestLen] && src[cand] === src[i]) {
108          let l = 0
109          while (l < maxLen && src[cand + l] === src[i + l]) l++
110          if (l > bestLen) {
111            bestLen = l
112            bestDist = i - cand
113            if (l === maxLen) break
114          }
115        }
116        const nxt = prev[cand & WMASK]
117        if (nxt >= cand) break // the ring slot was reused by a newer position
118        cand = nxt
119      }
120    }
121    if (bestLen >= MIN_MATCH) {
122      const lc = LEN_CODE[bestLen]
123      put(LIT_RCODE[lc], LIT_LEN[lc])
124      if (LEN_EB[bestLen] > 0) put(LEN_EV[bestLen], LEN_EB[bestLen])
125      const dc = distCode(bestDist)
126      put(DIST_RCODE[dc], 5)
127      if (DIST_EXTRA[dc] > 0) put(bestDist - DIST_BASE[dc], DIST_EXTRA[dc])
128      // a long match (a run of water) is hashed at its head only: the next run finds it there
129      const hashed = bestLen > 32 ? 4 : bestLen
130      for (let k = 0; k < hashed; k++) insert(i + k)
131      i += bestLen
132    } else {
133      const sym = src[i]
134      put(LIT_RCODE[sym], LIT_LEN[sym])
135      insert(i)
136      i++
137    }
138  }
139  put(LIT_RCODE[256], LIT_LEN[256])
140  if (bitCnt > 0) {
141    if (outPos >= out.length) grow()
142    out[outPos++] = bitBuf & 0xff
143  }
144  return out.subarray(0, outPos)
145}
146
hooks/aquarium/inflate.ts 155 lines
1// Inflate (RFC 1951) in plain TypeScript. The hooks environment has no zlib and no
2// DecompressionStream, and the sprite sheets are PNGs, so this is how they are read.
3// Stored, fixed-Huffman and dynamic-Huffman blocks; the canonical-code walk is tinf's.
4
5const LEN_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258]
6const LEN_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0]
7const DIST_BASE = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577]
8const DIST_EXTRA = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13]
9const CLEN_ORDER = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]
10
11type Tree = { count: Uint16Array; symbol: Uint16Array }
12
13function buildTree(lengths: ArrayLike<number>, n: number): Tree {
14  const count = new Uint16Array(16)
15  const symbol = new Uint16Array(n)
16  for (let i = 0; i < n; i++) count[lengths[i]]++
17  count[0] = 0
18  const offs = new Uint16Array(16)
19  for (let i = 1; i < 16; i++) offs[i] = offs[i - 1] + count[i - 1]
20  for (let i = 0; i < n; i++) if (lengths[i] !== 0) symbol[offs[lengths[i]]++] = i
21  return { count, symbol }
22}
23
24let fixed: [Tree, Tree] | undefined
25function fixedTrees(): [Tree, Tree] {
26  if (!fixed) {
27    const lit = new Uint8Array(288)
28    for (let i = 0; i < 288; i++) lit[i] = i < 144 ? 8 : i < 256 ? 9 : i < 280 ? 7 : 8
29    const dist = new Uint8Array(30).fill(5)
30    fixed = [buildTree(lit, 288), buildTree(dist, 30)]
31  }
32  return fixed
33}
34
35/** Decompresses a raw deflate stream (no zlib or gzip wrapper). */
36export function inflate(src: Uint8Array, sizeHint = src.length * 4): Uint8Array {
37  let out = new Uint8Array(Math.max(1024, sizeHint))
38  let outLen = 0
39  let pos = 0
40  let bitBuf = 0
41  let bitCnt = 0
42
43  const bits = (n: number): number => {
44    while (bitCnt < n) {
45      if (pos >= src.length) throw new Error('inflate: unexpected end of data')
46      bitBuf |= src[pos++] << bitCnt
47      bitCnt += 8
48    }
49    const v = bitBuf & ((1 << n) - 1)
50    bitBuf >>>= n
51    bitCnt -= n
52    return v
53  }
54  const decode = (t: Tree): number => {
55    let code = 0
56    let first = 0
57    let index = 0
58    for (let len = 1; len < 16; len++) {
59      code |= bits(1)
60      const c = t.count[len]
61      if (code - c < first) return t.symbol[index + (code - first)]
62      index += c
63      first += c
64      first <<= 1
65      code <<= 1
66    }
67    throw new Error('inflate: bad huffman code')
68  }
69  const ensure = (n: number) => {
70    if (outLen + n <= out.length) return
71    let cap = out.length * 2
72    while (cap < outLen + n) cap *= 2
73    const bigger = new Uint8Array(cap)
74    bigger.set(out.subarray(0, outLen))
75    out = bigger
76  }
77
78  for (;;) {
79    const final = bits(1)
80    const type = bits(2)
81    if (type === 0) {
82      // stored: the leftover bits (fewer than 8, all of the current byte) are dropped
83      bitBuf = 0
84      bitCnt = 0
85      if (pos + 4 > src.length) throw new Error('inflate: truncated stored block')
86      const len = src[pos] | (src[pos + 1] << 8)
87      const nlen = src[pos + 2] | (src[pos + 3] << 8)
88      pos += 4
89      if ((len ^ 0xffff) !== nlen) throw new Error('inflate: stored block length mismatch')
90      if (pos + len > src.length) throw new Error('inflate: truncated stored block')
91      ensure(len)
92      out.set(src.subarray(pos, pos + len), outLen)
93      outLen += len
94      pos += len
95    } else if (type === 1 || type === 2) {
96      let lit: Tree
97      let dist: Tree
98      if (type === 1) [lit, dist] = fixedTrees()
99      else {
100        const hlit = bits(5) + 257
101        const hdist = bits(5) + 1
102        const hclen = bits(4) + 4
103        const cl = new Uint8Array(19)
104        for (let i = 0; i < hclen; i++) cl[CLEN_ORDER[i]] = bits(3)
105        const clTree = buildTree(cl, 19)
106        const lengths = new Uint8Array(hlit + hdist)
107        for (let i = 0; i < hlit + hdist;) {
108          const sym = decode(clTree)
109          if (sym < 16) lengths[i++] = sym
110          else if (sym === 16) {
111            if (i === 0) throw new Error('inflate: repeat with no previous length')
112            const prev = lengths[i - 1]
113            let n = 3 + bits(2)
114            while (n-- > 0) lengths[i++] = prev
115          } else if (sym === 17) {
116            let n = 3 + bits(3)
117            while (n-- > 0) lengths[i++] = 0
118          } else {
119            let n = 11 + bits(7)
120            while (n-- > 0) lengths[i++] = 0
121          }
122        }
123        lit = buildTree(lengths.subarray(0, hlit), hlit)
124        dist = buildTree(lengths.subarray(hlit), hdist)
125      }
126      for (;;) {
127        const sym = decode(lit)
128        if (sym < 256) {
129          ensure(1)
130          out[outLen++] = sym
131        } else if (sym === 256) {
132          break
133        } else {
134          const li = sym - 257
135          if (li >= 29) throw new Error('inflate: bad length code')
136          const len = LEN_BASE[li] + bits(LEN_EXTRA[li])
137          const di = decode(dist)
138          if (di >= 30) throw new Error('inflate: bad distance code')
139          const d = DIST_BASE[di] + bits(DIST_EXTRA[di])
140          if (d > outLen) throw new Error('inflate: distance before start')
141          ensure(len)
142          for (let k = 0; k < len; k++) {
143            out[outLen] = out[outLen - d]
144            outLen++
145          }
146        }
147      }
148    } else {
149      throw new Error('inflate: reserved block type')
150    }
151    if (final) break
152  }
153  return out.subarray(0, outLen)
154}
155
hooks/aquarium/font.ts 95 lines
1// A 3x5 pixel font for the speech bubbles and the "+N" badge: capitals, digits and the
2// punctuation a file name or a tool label needs. Lower case draws as capitals.
3
4const GLYPHS: Record<string, [string, string, string, string, string]> = {
5  A: ['.#.', '#.#', '###', '#.#', '#.#'],
6  B: ['##.', '#.#', '##.', '#.#', '##.'],
7  C: ['.##', '#..', '#..', '#..', '.##'],
8  D: ['##.', '#.#', '#.#', '#.#', '##.'],
9  E: ['###', '#..', '##.', '#..', '###'],
10  F: ['###', '#..', '##.', '#..', '#..'],
11  G: ['.##', '#..', '#.#', '#.#', '.##'],
12  H: ['#.#', '#.#', '###', '#.#', '#.#'],
13  I: ['###', '.#.', '.#.', '.#.', '###'],
14  J: ['..#', '..#', '..#', '#.#', '.#.'],
15  K: ['#.#', '#.#', '##.', '#.#', '#.#'],
16  L: ['#..', '#..', '#..', '#..', '###'],
17  M: ['#.#', '###', '###', '#.#', '#.#'],
18  N: ['##.', '#.#', '#.#', '#.#', '#.#'],
19  O: ['.#.', '#.#', '#.#', '#.#', '.#.'],
20  P: ['##.', '#.#', '##.', '#..', '#..'],
21  Q: ['.#.', '#.#', '#.#', '.#.', '..#'],
22  R: ['##.', '#.#', '##.', '#.#', '#.#'],
23  S: ['.##', '#..', '.#.', '..#', '##.'],
24  T: ['###', '.#.', '.#.', '.#.', '.#.'],
25  U: ['#.#', '#.#', '#.#', '#.#', '.#.'],
26  V: ['#.#', '#.#', '#.#', '.#.', '.#.'],
27  W: ['#.#', '#.#', '###', '###', '#.#'],
28  X: ['#.#', '#.#', '.#.', '#.#', '#.#'],
29  Y: ['#.#', '#.#', '.#.', '.#.', '.#.'],
30  Z: ['###', '..#', '.#.', '#..', '###'],
31  '0': ['###', '#.#', '#.#', '#.#', '###'],
32  '1': ['.#.', '##.', '.#.', '.#.', '###'],
33  '2': ['##.', '..#', '.#.', '#..', '###'],
34  '3': ['##.', '..#', '.#.', '..#', '##.'],
35  '4': ['#.#', '#.#', '###', '..#', '..#'],
36  '5': ['###', '#..', '##.', '..#', '##.'],
37  '6': ['.##', '#..', '###', '#.#', '###'],
38  '7': ['###', '..#', '.#.', '.#.', '.#.'],
39  '8': ['###', '#.#', '###', '#.#', '###'],
40  '9': ['###', '#.#', '###', '..#', '##.'],
41  '.': ['...', '...', '...', '...', '.#.'],
42  ',': ['...', '...', '...', '.#.', '#..'],
43  '-': ['...', '...', '###', '...', '...'],
44  _: ['...', '...', '...', '...', '###'],
45  '/': ['..#', '..#', '.#.', '#..', '#..'],
46  '\\': ['#..', '#..', '.#.', '..#', '..#'],
47  ':': ['...', '.#.', '...', '.#.', '...'],
48  '+': ['...', '.#.', '###', '.#.', '...'],
49  '*': ['#.#', '.#.', '###', '.#.', '#.#'],
50  '#': ['#.#', '###', '#.#', '###', '#.#'],
51  '?': ['##.', '..#', '.#.', '...', '.#.'],
52  '!': ['.#.', '.#.', '.#.', '...', '.#.'],
53  '(': ['.#.', '#..', '#..', '#..', '.#.'],
54  ')': ['.#.', '..#', '..#', '..#', '.#.'],
55  '[': ['##.', '#..', '#..', '#..', '##.'],
56  ']': ['.##', '..#', '..#', '..#', '.##'],
57  '<': ['..#', '.#.', '#..', '.#.', '..#'],
58  '>': ['#..', '.#.', '..#', '.#.', '#..'],
59  '=': ['...', '###', '...', '###', '...'],
60  '@': ['.#.', '#.#', '###', '#..', '.##'],
61  '~': ['...', '.#.', '#.#', '...', '...'],
62  "'": ['.#.', '.#.', '...', '...', '...'],
63  '"': ['#.#', '#.#', '...', '...', '...'],
64  ' ': ['...', '...', '...', '...', '...'],
65}
66
67export const GLYPH_W = 3
68export const GLYPH_H = 5
69export const ADVANCE = 4
70
71export function glyphOf(ch: string): [string, string, string, string, string] {
72  return GLYPHS[ch.toUpperCase()] ?? GLYPHS['?']
73}
74
75/** Width in pixels of `text` drawn at `scale`. */
76export function measureText(text: string, scale = 1): number {
77  if (text.length === 0) return 0
78  return (text.length * ADVANCE - 1) * scale
79}
80
81/** Calls `plot(x, y)` for every lit pixel of `text` at (x0, y0), each glyph pixel `scale` wide and tall. */
82export function forEachTextPixel(text: string, x0: number, y0: number, scale: number, plot: (x: number, y: number) => void): void {
83  for (let i = 0; i < text.length; i++) {
84    const g = glyphOf(text[i])
85    const gx = x0 + i * ADVANCE * scale
86    for (let r = 0; r < GLYPH_H; r++) {
87      const row = g[r]
88      for (let c = 0; c < GLYPH_W; c++) {
89        if (row[c] !== '#') continue
90        for (let sy = 0; sy < scale; sy++) for (let sx = 0; sx < scale; sx++) plot(gx + c * scale + sx, y0 + r * scale + sy)
91      }
92    }
93  }
94}
95