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…

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

(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 つ増える |
| Bash | 1.2 秒だけ 3 倍速でダッシュ |
| WebFetch / WebSearch / MCP | 水面へ浮上 |
| エラー | 赤くなって震え、2 秒沈んで復帰 |
魚の上には EDIT AUTH.TS のようにツール名と引数が 1.5 秒出ます。 水位はコンテキスト使用率に連動して減り(session.measure)、compact で満水に戻ります。ターン中は水中が少し明るくなります。
<Image> を 1 枚置き、$.clock.every(66ms) ごとに hooks 側でフレームを合成 → PNG にエンコード → $.ui.blit({ png }) で差し替えます(既定 12fps、/aquarium fps N か plugin の設定 fps で変更)。Kitty graphics protocol の送信は Claude Code 本体が行います。CompressionStream もないため、PNG のデコード(スプライト読込)とエンコード(固定 Huffman + LZ77 の deflate)は hooks/aquarium/{inflate,deflate,png}.ts の純 TypeScript 実装です。80×12 セルのフレームは約 20〜25 KB、合成 + エンコードで 8 ms ほどです。$.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 が作り直される)。
音、魚同士の当たり判定、スプライトの実行時生成。
hooks/register.tsx 404 lines1/* @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}
404hooks/aquarium/events.ts 118 lines1// 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}
118hooks/aquarium/png.ts 210 lines1// 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}
210hooks/aquarium/render.ts 230 lines1// 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}
230hooks/aquarium/sprites.ts 114 lines1// 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}
114hooks/aquarium/world.ts 421 lines1// 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}
421hooks/aquarium/deflate.ts 146 lines1// 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}
146hooks/aquarium/inflate.ts 155 lines1// 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}
155hooks/aquarium/font.ts 95 lines1// 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