SLOPSHOPPER

clawdcraft

Watch Claude work in a 2D Minecraft world: Clawd fishes while fetching data, mines while searching, and a creeper blows up when something fails.

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawdcraft
│ ┃ ClawdCraft ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ clawdcraft │ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Read(src/auth.ts) │ 🏆 Advancement made! Aw Man: Your first │ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Read 6 lines │ creeper explosion │ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, re╭────────────────────────────────────────────╮ │ ┃ ⏺ Bash(bun test) │ clawdcraft │ │ ┃ ⎿ 3 pass, 1 fail │ 🏆 Advancement made! Fireworks: Your first │ │ ┃ │ git push │ │ ┃ ● Done. refresh now re╰────────────────────────────────────────────╯ │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /mc │ ┃ ⎿ clawdcraft: ClawdCraft opened in a pane. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ClawdCraft
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ tes ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
README

ClawdCraft

A Claude Code mod that shows what Claude is doing as a little 2D Minecraft world. Clawd, the Claude mascot, lives in a band above the prompt with a tag over his head saying what he is up to. Every time Claude thinks, he mines the block in front of him, and the block breaks when the thought ends. While Claude writes, he walks on. He stops only to do a job: fishing while Claude fetches data, reading at a lectern, firing up a furnace for a command. When something fails, a creeper walks up and blows up.

Install

In Claude Code (the terminal, or the Code tab of the desktop app):

/plugin marketplace add CemalMertDal/clawdcraft
/plugin install clawdcraft@clawdcraft

Or from a shell:

claude plugin marketplace add CemalMertDal/clawdcraft
claude plugin install clawdcraft@clawdcraft

Then start a new session and type /mc demo. Every animation plays in turn, which takes about 50 seconds.

To update, run claude plugin marketplace update clawdcraft, then claude plugin update clawdcraft@clawdcraft.

What Clawd does

When Claude is…Clawd…
Thinkingmines the block ahead: one thought, one block, cracking for as long as the thought lasts
Writingwalks on
Fetching data (WebFetch, WebSearch, MCP tools, curl)goes fishing at a pond
Searching (Grep, Glob)mines with a pickaxe and finds a diamond
Reading a filereads a book on a lectern
Editing a fileworks at a crafting table
Writing a new fileplaces a block
Running a commandfires up a furnace
Running testsshoots arrows at a target
Running git commit / git pushstashes loot in a chest / launches a firework
Using a Skillcasts a spell at an enchanting table
Starting a subagentgets a wolf companion
Waiting for permissionholds up a "?" sign
Hitting an errorgets visited by a creeper that blows up
Finishing a turnputs up a torch
Sitting idle for a minutesleeps by a campfire as night falls

Under the grass and dirt lies stone with coal, iron, gold and diamond in it. As Clawd goes, the biome changes every 64 blocks: plains, forest, desert, snowy taiga, mushroom fields, and the Nether on long journeys. The journey and 14 advancements carry over between sessions.

Commands

CommandWhat it does
/mcSwitch between the band and a side pane
/mc band / /mc pane / /mc hideShow as a band / show in a pane / hide
/mc statsThe journey so far and advancements
/mc demoPlay every animation in turn
/mc newStart a new world from a new seed

Notes

  • No model tokens. The mod never calls the model and adds nothing to the system prompt or the conversation. The one exception is the output of the /mc commands you run, which the model reads on the next turn. /mc stats is about 300–400 tokens.
  • Offline. Everything is drawn on your machine and no network requests are made. The journey and advancements are kept in Claude Code's plugin store.
  • Desktop and terminal. The desktop app gets animated SVG. The terminal gets pixel art made of half-block (▀) characters in an 11-row band: the world at half size with a dimmed ground so it sits quietly on a dark background, and Clawd drawn with his own small sprite so his face stays clear.
  • Early-access API. The mod is built on Claude Code's function-hooks (mods) API, which may change between releases. Tested with Claude Code 2.1.286.

Development

claude plugin validate plugins/clawdcraft
claude plugin test plugins/clawdcraft
claude --plugin-dir plugins/clawdcraft

The code lives in plugins/clawdcraft/hooks/:

FileWhat's in it
register.tsxThe hooks and the /mc command
world.tsThe world and its state transitions
scene.tsWhat to draw at a given moment
render-svg.tsThe desktop renderer
render-raster.tsThe terminal renderer
sprites.tsThe pixel art

License

MIT

Source 9 files
hooks/register.tsx 720 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, Register } from 'claude-code'
3
4import type { ActivityKind, ViewMode, WorldState } from '../types'
5import type { AchievementId, Life } from './achievements'
6import { ACHIEVEMENTS, asLife, toastText } from './achievements'
7import type { Classified } from './activity'
8import { ACTIVITY_LABEL, classify } from './activity'
9import { downsample2, drawMiniClawd, packCells, rasterize } from './render-raster'
10import { renderSvg } from './render-svg'
11import { composeScene } from './scene'
12import type { Outcome } from './world'
13import {
14  BIOME_LABEL,
15  BLOCK,
16  IDLE_NIGHT_MS,
17  biomeAt,
18  colOf,
19  endTool,
20  goSleep,
21  initialWorld,
22  posAt,
23  prune,
24  recover,
25  refreshWalk,
26  setActivity,
27  startTool,
28  turnEnd,
29  turnStart,
30} from './world'
31
32const PANE = 'clawdcraft'
33const RASTER_KEY = 'world'
34/** CSS pixels per world pixel on the vector surfaces, and how far ahead a walk is drawn. */
35const SVG_SCALE = 3
36const SVG_LOOKAHEAD_MS = 120_000
37/** Terminal animation: one frame per this many ms. */
38const FRAME_MS = 125
39/** The terminal draws the world at half size, and its band is this many rows tall. */
40const TERMINAL_BAND_ROWS = 11
41/** How much of their brightness the grass and dirt keep on the terminal's black. */
42const TERMINAL_GROUND_SHADE = 0.66
43
44const WORLD_REF = { plugin: 'clawdcraft', key: 'world' } as const
45const VIEW_REF = { plugin: 'clawdcraft', key: 'view' } as const
46const world = atom(WORLD_REF, initialWorld(1, 0, 0))
47const view = atom(VIEW_REF, 'band')
48
49/** What the timers and the event hooks reach the engine through, made in session.start. */
50type Io = {
51  now: () => Promise<number>
52  read: () => Promise<WorldState>
53  write: (change: (w: WorldState) => WorldState) => Promise<WorldState>
54  setView: (v: ViewMode) => Promise<ViewMode>
55  blit: (requestId: string, cells: string, columns: number, rows: number) => Promise<boolean>
56  toast: (text: string) => void
57  status: (text: string | undefined) => void
58  log: (text: string) => void
59  storeGet: (key: string) => Promise<unknown>
60  storeSet: (key: string, value: unknown) => Promise<void>
61  openPane: () => Promise<unknown>
62  closePane: () => Promise<void>
63  after: (ms: number, fn: () => void) => { cancel: () => void }
64}
65
66// The module's own memory; a reload starts it over and session.start refills it.
67let io: Io | null = null
68let cache: WorldState | null = null
69let life: Life | null = null
70let isLifeDirty = false
71let lastSave = 0
72let chain: Promise<unknown> = Promise.resolve()
73let isTicking = false
74let demo: { cancel: () => void }[] = []
75/** The terminal Rasters on screen, by site, at the size each was drawn. */
76const mounts = new Map<string, { columns: number; rows: number }>()
77
78const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, Math.floor(n)))
79
80function debug(err: unknown) {
81  try {
82    io?.log(`clawdcraft: ${err instanceof Error ? err.message : String(err)}`)
83  } catch {
84    // nothing to tell it to
85  }
86}
87
88/**
89 * Applies `change` to the world, one change at a time; `null` writes
90 * nothing (so the drawings stay as they are).
91 */
92function mutate(change: (w: WorldState, now: number) => WorldState | null): Promise<void> {
93  const d = io
94  if (!d) {
95    return Promise.resolve()
96  }
97  const run = chain
98    .then(async () => {
99      const now = await d.now()
100      const current = await d.read()
101      if (change(current, now) === null) {
102        cache = current
103        return
104      }
105      cache = await d.write(w => change(w, now) ?? w)
106    })
107    .catch(debug)
108  chain = run
109
110  return run
111}
112
113// ---------- the life across sessions ----------
114
115function randomSeed(): number {
116  return Math.floor(Math.random() * 0x7fffffff)
117}
118
119async function saveLife(now: number) {
120  if (!io || !life || !isLifeDirty) {
121    return
122  }
123  isLifeDirty = false
124  lastSave = now
125  await io.storeSet('life', life)
126}
127
128function unlock(id: AchievementId) {
129  if (!life || life.unlocked.includes(id)) {
130    return
131  }
132  life.unlocked = [...life.unlocked, id]
133  isLifeDirty = true
134  io?.toast(toastText(id))
135}
136
137function counted(fn: (l: Life) => void) {
138  if (life) {
139    fn(life)
140    isLifeDirty = true
141  }
142}
143
144function onToolStart(cls: Classified) {
145  counted(l => {
146    l.tools += 1
147    if (cls.kind === 'read') {
148      l.reads += 1
149    }
150  })
151  unlock('stone-age')
152  if (cls.tag === 'fetch') {
153    unlock('gone-fishing')
154  }
155  if (cls.tag === 'skill') {
156    unlock('apprentice')
157  }
158  if (cls.tag === 'agent') {
159    unlock('best-friend')
160  }
161  if ((life?.reads ?? 0) >= 100) {
162    unlock('bookworm')
163  }
164  const w = cache
165  if (w) {
166    if (w.tools + 1 >= 50) {
167      unlock('diamonds')
168    }
169    if (cls.tag === 'agent' && w.wolves.filter(f => !f.leftAt).length + 1 >= 3) {
170      unlock('pack')
171    }
172  }
173}
174
175function onToolEnd(cls: Classified, outcome: Outcome) {
176  if (outcome === 'error') {
177    onExplosion()
178    return
179  }
180  if (outcome !== 'ok') {
181    return
182  }
183  if (cls.success === 'catch') {
184    counted(l => {
185      l.fish += 1
186    })
187  }
188  if (cls.tag === 'push') {
189    unlock('fireworks')
190  }
191  if ((cache?.streak ?? 0) + 1 >= 25) {
192    unlock('survivor')
193  }
194}
195
196function onExplosion() {
197  counted(l => {
198    l.explosions += 1
199  })
200  unlock('aw-man')
201  if ((life?.explosions ?? 0) >= 10) {
202    unlock('creeper-hunter')
203  }
204}
205
206// ---------- timers ----------
207
208/** Where the terminal writes the tag over Clawd's head, in cells. */
209type CellTag = { text: string; column: number; row: number }
210
211/**
212 * A frame for a Raster `columns` x `rows`: the world drawn at twice that and
213 * halved, Clawd drawn on top with his own small sprite; and where his tag goes.
214 */
215function terminalFrame(w: WorldState, now: number, columns: number, rows: number): { cells: string; tag?: CellTag } {
216  const W = columns * 2
217  const H = rows * 4
218  const mask = new Uint8Array(W * H)
219  const scene = composeScene(w, now, W, H, 0)
220  const full = rasterize(scene, now, { mask, groundShade: TERMINAL_GROUND_SHADE, isClawdSkipped: true })
221  const half = downsample2(full, mask, W, H)
222  drawMiniClawd(scene, now, half, columns, rows * 2)
223  const t = scene.tag
224  // The tag sits 2 world pixels over Clawd's head, and he is 10 tall: his feet are at t.y + 12.
225  // Halved, the 7-pixel mini sprite stands on them; the tag goes in the cell row above its top.
226  const miniTop = (t ? t.y + 12 : 0) / 2 - 7
227  const tag = t
228    ? { text: ` ${t.text} `, column: Math.round(t.x / 2 - 1.5), row: Math.max(0, Math.floor(miniTop / 2) - 1) }
229    : undefined
230
231  return { cells: packCells(half, columns, rows * 2, columns, rows), tag }
232}
233
234function terminalCells(w: WorldState, now: number, columns: number, rows: number): string {
235  return terminalFrame(w, now, columns, rows).cells
236}
237
238async function tick() {
239  const w = cache
240  const d = io
241  if (isTicking || mounts.size === 0 || !w || !d) {
242    return
243  }
244  isTicking = true
245  try {
246    const now = await d.now()
247    for (const [requestId, m] of [...mounts]) {
248      const isTaken = await d.blit(requestId, terminalCells(w, now, m.columns, m.rows), m.columns, m.rows)
249      if (!isTaken) {
250        mounts.delete(requestId)
251      }
252    }
253  } catch (err) {
254    debug(err)
255  } finally {
256    isTicking = false
257  }
258}
259
260async function housekeep() {
261  const w = cache
262  const d = io
263  if (!w || !d) {
264    return
265  }
266  const now = await d.now()
267  const steps = [recover, goSleep, refreshWalk, prune]
268  if (steps.some(step => step(w, now) !== null)) {
269    const isFallingAsleep = goSleep(w, now) !== null
270    await mutate((cur, t) => {
271      let n = cur
272      let isChanged = false
273      for (const step of steps) {
274        const r = step(n, t)
275        if (r) {
276          n = r
277          isChanged = true
278        }
279      }
280
281      return isChanged ? n : null
282    })
283    if (isFallingAsleep) {
284      counted(l => {
285        l.nights += 1
286      })
287      unlock('sweet-dreams')
288    }
289  }
290  const pos = posAt(w, now)
291  if (life && Math.abs(life.distance - pos) >= BLOCK) {
292    life.distance = pos
293    isLifeDirty = true
294  }
295  if (pos >= 1000 * BLOCK) {
296    unlock('long-road')
297  }
298  if (biomeAt(w.seed, colOf(pos)) === 'nether') {
299    unlock('nether')
300  }
301  if (now - lastSave > 30_000) {
302    await saveLife(now)
303  }
304}
305
306// ---------- drawing ----------
307
308function describe(w: WorldState, now: number): string {
309  const pos = posAt(w, now)
310
311  return `Clawd is ${ACTIVITY_LABEL[w.activity]} in the ${BIOME_LABEL[biomeAt(w.seed, colOf(pos))]} (block ${colOf(pos)})`
312}
313
314/** The Raster's size for a site, remembered so the timer can repaint it. */
315function rasterProps(w: WorldState, now: number, requestId: string, columns: number, rowsAvailable: number, isBand: boolean) {
316  const cols = clamp(columns, 16, 512)
317  const rows = isBand
318    ? clamp(Math.min(TERMINAL_BAND_ROWS, rowsAvailable - 1), 4, TERMINAL_BAND_ROWS)
319    : clamp(rowsAvailable, 4, 20)
320  mounts.set(requestId, { columns: cols, rows })
321  const frame = terminalFrame(w, now, cols, rows)
322
323  return { raster: { key: RASTER_KEY, columns: cols, rows, cells: frame.cells }, tag: frame.tag }
324}
325
326/** The terminal's drawing: the world, and Clawd's tag laid over it in a pale box. */
327function terminalTree(els: Elements['terminal'], props: ReturnType<typeof rasterProps>) {
328  const { Box, Raster, Text } = els
329  const { raster, tag } = props
330
331  return (
332    <Box width={raster.columns} height={raster.rows}>
333      <Raster {...raster} />
334      {tag && (
335        <Box position="absolute" top={tag.row} left={Math.max(0, tag.column - Math.floor(tag.text.length / 2))}>
336          <Text color="#1b1b1b" backgroundColor="#e8e8e8">
337            {tag.text}
338          </Text>
339        </Box>
340      )}
341    </Box>
342  )
343}
344
345function svgProps(w: WorldState, now: number, columns: number, isBand: boolean) {
346  const H = isBand ? 56 : 72
347  const W = Math.ceil(clamp(columns * 8, 240, 2400) / SVG_SCALE)
348  const scene = composeScene(w, now, W, H, SVG_LOOKAHEAD_MS)
349
350  return {
351    source: renderSvg(scene, now, { scale: SVG_SCALE }),
352    alt: describe(w, now),
353    width: W * SVG_SCALE,
354    height: H * SVG_SCALE,
355    isInteractive: true as const,
356  }
357}
358
359// ---------- the /mc command ----------
360
361async function setView(v: ViewMode) {
362  if (!io) {
363    return
364  }
365  await io.setView(v)
366  await io.storeSet('view', v)
367  if (v === 'pane') {
368    await io.openPane()
369  } else {
370    await io.closePane()
371  }
372}
373
374function stats(w: WorldState, now: number): string {
375  const l = life
376  const pos = posAt(w, now)
377  const ids = Object.keys(ACHIEVEMENTS) as AchievementId[]
378  const got = ids.filter(id => l?.unlocked.includes(id))
379  const lines = [
380    `ClawdCraft: Clawd is ${ACTIVITY_LABEL[w.activity]} in the ${BIOME_LABEL[biomeAt(w.seed, colOf(pos))]}, at block ${colOf(pos)}.`,
381    `This session: ${w.tools} tools, ${w.explosions} creeper explosions, current error-free streak ${w.streak}.`,
382  ]
383  if (l) {
384    lines.push(`All time: ${l.tools} tools, ${l.fish} fish, ${l.reads} reads, ${l.explosions} explosions, ${l.nights} nights.`)
385  }
386  lines.push(`Advancements (${got.length}/${ids.length}):`)
387  for (const id of ids) {
388    const a = ACHIEVEMENTS[id]
389    lines.push(`  ${got.includes(id) ? '✓' : '·'} ${a.name}: ${a.desc}`)
390  }
391
392  return lines.join('\n')
393}
394
395const HELP = [
396  'ClawdCraft commands:',
397  '  /mc          switch between the band and the side pane',
398  '  /mc band     show the world in a band above the prompt',
399  '  /mc pane     show the world in a side pane',
400  '  /mc hide     hide it',
401  '  /mc stats    the journey so far and advancements',
402  '  /mc demo     play every animation in turn',
403  '  /mc new      start a new world from a new seed',
404].join('\n')
405
406const C = (kind: ActivityKind, success?: Classified['success'], tag?: Classified['tag']): Classified => ({
407  kind,
408  success,
409  tag,
410})
411
412function stopDemo() {
413  for (const timer of demo) {
414    timer.cancel()
415  }
416  if (demo.length > 0) {
417    io?.status(undefined)
418  }
419  demo = []
420}
421
422const DEMO: [number, string, (w: WorldState, now: number) => WorldState | null][] = [
423  [0, 'Claude thinks: Clawd mines the block ahead', (w, t) => turnStart(w, t)],
424  [2600, 'the thought ends: the block breaks, Clawd walks on', (w, t) => setActivity(w, 'walk', t)],
425  [3500, 'WebFetch: fetching data = fishing', (w, t) => startTool(w, 'demo-1', C('fish'), t)],
426  [6500, 'caught a fish!', (w, t) => endTool(w, 'demo-1', C('fish', 'catch'), 'ok', t)],
427  [6800, 'thinking again: a new block', (w, t) => setActivity(w, 'think', t)],
428  [8000, 'Grep: searching = mining', (w, t) => startTool(w, 'demo-2', C('mine'), t)],
429  [10500, 'found a diamond!', (w, t) => endTool(w, 'demo-2', C('mine', 'diamond'), 'ok', t)],
430  [11500, 'Read: reading a file = a book on the lectern', (w, t) => startTool(w, 'demo-3', C('read'), t)],
431  [
432    13500,
433    'Edit: editing = the crafting table',
434    (w, t) => startTool(endTool(w, 'demo-3', C('read'), 'ok', t), 'demo-4', C('craft'), t),
435  ],
436  [16000, 'item crafted', (w, t) => endTool(w, 'demo-4', C('craft', 'item'), 'ok', t)],
437  [16500, 'Write: a new file = placing a block', (w, t) => startTool(w, 'demo-5', C('build'), t)],
438  [
439    18000,
440    'one more block',
441    (w, t) => startTool(endTool(w, 'demo-5', C('build', 'block'), 'ok', t), 'demo-6', C('build'), t),
442  ],
443  [19500, 'the build grows', (w, t) => endTool(w, 'demo-6', C('build', 'block'), 'ok', t)],
444  [20000, 'Bash: a command = the furnace', (w, t) => startTool(w, 'demo-7', C('smelt'), t)],
445  [23000, 'command done', (w, t) => endTool(w, 'demo-7', C('smelt'), 'ok', t)],
446  [23500, 'Skill: the enchanting table', (w, t) => startTool(w, 'demo-8', C('magic'), t)],
447  [26500, 'spell cast!', (w, t) => endTool(w, 'demo-8', C('magic', 'sparkle'), 'ok', t)],
448  [27000, 'Agent: a subagent = a wolf companion', (w, t) => startTool(w, 'demo-9', C('walk', undefined, 'agent'), t)],
449  [28500, 'tests = arrows at a target', (w, t) => startTool(w, 'demo-10', C('test'), t)],
450  [31000, 'bullseye!', (w, t) => endTool(w, 'demo-10', C('test', 'bullseye'), 'ok', t)],
451  [32000, 'the wolf is done and brings a bone', (w, t) => endTool(w, 'demo-9', C('walk', undefined, 'agent'), 'ok', t)],
452  [33000, 'waiting for permission', (w, t) => setActivity(w, 'wait', t)],
453  [35000, 'a command failed…', (w, t) => startTool(w, 'demo-11', C('smelt'), t)],
454  [35600, 'here comes a creeper!', (w, t) => endTool(w, 'demo-11', C('smelt'), 'error', t)],
455  [40000, 'git push = fireworks', (w, t) => startTool(w, 'demo-12', C('rocket'), t)],
456  [41500, 'launched!', (w, t) => endTool(w, 'demo-12', C('rocket', 'firework'), 'ok', t)],
457  [44000, 'the turn is over, a torch goes up', (w, t) => turnEnd(w, 'answer', 30_000, t)],
458  [46500, 'night falls, Clawd sleeps', (w, t) => setActivity({ ...w, idleSince: t - IDLE_NIGHT_MS - 9000 }, 'sleep', t)],
459  [52000, '', () => null],
460]
461
462function runDemo() {
463  stopDemo()
464  const d = io
465  if (!d) {
466    return
467  }
468  for (const [at, label, step] of DEMO) {
469    demo.push(
470      d.after(Math.max(1, at), () => {
471        d.status(label ? `ClawdCraft demo: ${label}` : undefined)
472        void mutate(step)
473      }),
474    )
475  }
476}
477
478// ---------- hooks ----------
479
480export const register: Register = on => {
481  on('session.start', async ($, e, next) => {
482    io = {
483      now: () => $.clock.now(),
484      read: () => read($, world),
485      write: change => update($, world, change),
486      setView: v => update($, view, () => v),
487      blit: async (requestId, cells, columns, rows) =>
488        (await $.ui.blit({ requestId, key: RASTER_KEY, cells, columns, rows })).deny === undefined,
489      toast: text => $.ui.toast(text, { timeoutMs: 6000 }),
490      status: text => $.ui.status(text),
491      log: text => $.ui.log(text, { to: 'debug' }),
492      storeGet: key => $.store.get(key),
493      storeSet: (key, value) => $.store.set(key, value),
494      openPane: () => $.ui.open({ id: PANE, title: 'ClawdCraft', rows: 12 }),
495      closePane: () => $.ui.close({ id: PANE }),
496      after: (ms, fn) => $.clock.after(ms, fn),
497    }
498    try {
499      await $.command.register({
500        name: 'mc',
501        description: "ClawdCraft: Claude's Minecraft world (band, pane, hide, stats, demo)",
502        argumentHint: '[band|pane|hide|stats|demo|new]',
503      })
504    } catch (err) {
505      debug(err)
506    }
507    try {
508      const stored = await $.store.get('life')
509      life = life ?? asLife(stored, randomSeed())
510      if (!stored) {
511        await $.store.set('life', life)
512      }
513      const l = life
514      const now = await $.clock.now()
515      const current = await $.state.get(WORLD_REF)
516      if (current.version === 0) {
517        await mutate(() => initialWorld(l.seed, l.distance, now))
518      } else {
519        cache = current.value ?? null
520      }
521      const shown = await $.state.get(VIEW_REF)
522      if (shown.version === 0) {
523        const kept = await $.store.get('view')
524        if (kept === 'band' || kept === 'pane' || kept === 'hidden') {
525          await update($, view, () => kept)
526        }
527      }
528      if ((await read($, view)) === 'pane') {
529        void $.ui.open({ id: PANE, title: 'ClawdCraft', rows: 12 }).catch(debug)
530      }
531    } catch (err) {
532      debug(err)
533    }
534    $.clock.every(FRAME_MS, () => void tick())
535    $.clock.every(2000, () => void housekeep())
536
537    return next(e)
538  })
539
540  on('session.end', async ($, e, next) => {
541    try {
542      await saveLife(0)
543    } catch (err) {
544      debug(err)
545    }
546
547    return next(e)
548  })
549
550  on('turn.start', ($, e, next) => {
551    stopDemo()
552    void mutate((w, now) => turnStart(w, now))
553
554    return next(e)
555  })
556
557  // Each model request is a thought: Clawd mines one block from the moment it is sent
558  // until the answer's first words, when the block breaks and he walks on.
559  on('turn.step', async function* ($, e, next) {
560    const stream = next(e)
561    if (e.agentId !== undefined) {
562      return yield* stream
563    }
564    void mutate((w, now) =>
565      !w.isWorking || w.running.length > 0 || w.activity === 'think' || w.activity === 'hurt'
566        ? null
567        : setActivity(w, 'think', now),
568    )
569    let isWriting = false
570    for await (const chunk of stream) {
571      if (!isWriting && chunk.kind === 'text') {
572        isWriting = true
573        void mutate((w, now) => (w.activity === 'think' ? setActivity(w, 'walk', now) : null))
574      }
575      yield chunk
576    }
577
578    return await stream.result
579  })
580
581  on('tool.call', async ($, e, next) => {
582    if (e.agentId !== undefined) {
583      const w = cache
584      if (w && w.wolves.length > 0) {
585        void mutate((cur, now) => (now - cur.wolfBusyAt > 1200 ? { ...cur, wolfBusyAt: now } : null))
586      }
587
588      return next(e)
589    }
590    let cls: Classified = { kind: 'craft' }
591    try {
592      cls = classify(e.tool, e as unknown as Record<string, unknown>)
593      onToolStart(cls)
594    } catch (err) {
595      debug(err)
596    }
597    const id = e.tool_use_id
598    void mutate((w, now) => startTool(w, id, cls, now))
599    const ran = await next(e)
600    const outcome: Outcome = ran.deny !== undefined ? 'denied' : ran.isError === true ? 'error' : 'ok'
601    try {
602      onToolEnd(cls, outcome)
603    } catch (err) {
604      debug(err)
605    }
606    void mutate((w, now) => endTool(w, id, cls, outcome, now))
607
608    return ran
609  })
610
611  on('classic.PermissionRequest', ($, e, next) => {
612    void mutate((w, now) => (w.activity === 'wait' ? null : setActivity(w, 'wait', now)))
613
614    return next(e)
615  })
616
617  on('turn.complete', ($, e, next) => {
618    if (e.agentId === undefined) {
619      const reason = e.reason
620      const duration = e.durationMs
621      if (reason === 'error') {
622        onExplosion()
623      }
624      void mutate((w, now) => turnEnd(w, reason, duration, now))
625    }
626
627    return next(e)
628  })
629
630  on('session.compact', async ($, e, next) => {
631    if (e.agentId !== undefined) {
632      return next(e)
633    }
634    void mutate((w, now) => setActivity(w, 'compost', now))
635    const done = await next(e)
636    void mutate((w, now) => (w.activity === 'compost' ? setActivity(w, w.isWorking ? 'walk' : 'idle', now) : null))
637
638    return done
639  })
640
641  on('ui.close', async ($, e, next) => {
642    if (e.id === PANE && e.origin === 'person') {
643      await update($, view, () => 'band')
644      await $.store.set('view', 'band')
645    }
646
647    return next(e)
648  })
649
650  on('command.run', { command: 'mc' }, async ($, e) => {
651    const arg = e.args.trim().toLowerCase()
652    const current = await read($, view)
653    switch (arg) {
654      case '':
655        await setView(current === 'pane' ? 'band' : 'pane')
656        return { text: current === 'pane' ? 'ClawdCraft moved to the band.' : 'ClawdCraft opened in a pane.' }
657      case 'band':
658        await setView('band')
659        return { text: 'ClawdCraft is above the prompt.' }
660      case 'pane':
661        await setView('pane')
662        return { text: 'ClawdCraft opened in a pane.' }
663      case 'hide':
664        await setView('hidden')
665        return { text: 'ClawdCraft is hidden. Bring it back with /mc band.' }
666      case 'stats':
667        return { text: stats(await read($, world), await $.clock.now()) }
668      case 'demo':
669        if (current === 'hidden') {
670          await setView('band')
671        }
672        runDemo()
673        return {
674          text: 'Demo started: for about 50 seconds every animation plays in turn. The status line says which one is playing.',
675        }
676      case 'new': {
677        const seed = randomSeed()
678        counted(l => {
679          l.seed = seed
680          l.distance = 0
681        })
682        await mutate((_, now) => initialWorld(seed, 0, now))
683        return { text: 'A new world has been created.' }
684      }
685      default:
686        return { text: HELP }
687    }
688  })
689
690  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
691    if (e.props.hasSurvey || (await read($, view)) !== 'band') {
692      return next(e)
693    }
694    const w = await read($, world)
695    cache = w
696    const now = await $.clock.now()
697    if (e.surface === 'terminal') {
698      return terminalTree($.ui.resolve(e), rasterProps(w, now, e.requestId, e.props.bodyColumns, e.props.maxRows, true))
699    }
700    const { Svg } = $.ui.resolve(e)
701
702    return <Svg {...svgProps(w, now, e.props.bodyColumns, true)} />
703  })
704
705  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
706    const w = await read($, world)
707    cache = w
708    const now = await $.clock.now()
709    if (e.surface === 'terminal') {
710      return terminalTree(
711        $.ui.resolve(e),
712        rasterProps(w, now, e.requestId, e.props.bodyColumns, e.props.scroll.bodyRows, false),
713      )
714    }
715    const { Svg } = $.ui.resolve(e)
716
717    return <Svg {...svgProps(w, now, e.props.bodyColumns, false)} />
718  })
719}
720
hooks/achievements.ts 77 lines
1export type AchievementId =
2  | 'stone-age'
3  | 'gone-fishing'
4  | 'aw-man'
5  | 'diamonds'
6  | 'apprentice'
7  | 'best-friend'
8  | 'pack'
9  | 'fireworks'
10  | 'long-road'
11  | 'nether'
12  | 'sweet-dreams'
13  | 'survivor'
14  | 'creeper-hunter'
15  | 'bookworm'
16
17export const ACHIEVEMENTS: Record<AchievementId, { name: string; desc: string }> = {
18  'stone-age': { name: 'Stone Age', desc: 'Used your first tool' },
19  'gone-fishing': { name: 'Gone Fishing', desc: 'Fetched your first data' },
20  'aw-man': { name: 'Aw Man', desc: 'Your first creeper explosion' },
21  diamonds: { name: 'Diamonds!', desc: '50 tools in one session' },
22  apprentice: { name: "Sorcerer's Apprentice", desc: 'Cast your first Skill' },
23  'best-friend': { name: 'Best Friends Forever', desc: 'Your first wolf companion (subagent)' },
24  pack: { name: 'Wolf Pack', desc: '3 wolves at once' },
25  fireworks: { name: 'Fireworks', desc: 'Your first git push' },
26  'long-road': { name: 'The Long Road', desc: 'Walked 1000 blocks in total' },
27  nether: { name: 'Welcome to the Nether', desc: 'Reached the Nether biome' },
28  'sweet-dreams': { name: 'Sweet Dreams', desc: 'Slept through your first night' },
29  survivor: { name: 'Survivor', desc: '25 tools in a row without an error' },
30  'creeper-hunter': { name: 'Creeper Hunter', desc: '10 explosions in total' },
31  bookworm: { name: 'Bookworm', desc: 'Read 100 files in total' },
32}
33
34/** What is kept across sessions, under the store's `life` key. */
35export type Life = {
36  seed: number
37  /** World pixels walked, all sessions. */
38  distance: number
39  unlocked: AchievementId[]
40  tools: number
41  fish: number
42  reads: number
43  explosions: number
44  nights: number
45}
46
47export function freshLife(seed: number): Life {
48  return { seed, distance: 0, unlocked: [], tools: 0, fish: 0, reads: 0, explosions: 0, nights: 0 }
49}
50
51/** Reads a stored value back as a Life, filling what an older version lacked. */
52export function asLife(value: unknown, seed: number): Life {
53  const base = freshLife(seed)
54  if (!value || typeof value !== 'object') {
55    return base
56  }
57  const v = value as Partial<Life>
58  const num = (n: unknown, fallback: number) => (typeof n === 'number' && Number.isFinite(n) ? n : fallback)
59
60  return {
61    seed: num(v.seed, seed),
62    distance: num(v.distance, 0),
63    unlocked: Array.isArray(v.unlocked) ? v.unlocked.filter((id): id is AchievementId => id in ACHIEVEMENTS) : [],
64    tools: num(v.tools, 0),
65    fish: num(v.fish, 0),
66    reads: num(v.reads, 0),
67    explosions: num(v.explosions, 0),
68    nights: num(v.nights, 0),
69  }
70}
71
72export function toastText(id: AchievementId): string {
73  const a = ACHIEVEMENTS[id]
74
75  return `🏆 Advancement made! ${a.name}: ${a.desc}`
76}
77
hooks/activity.ts 134 lines
1import type { ActivityKind, EffectKind } from '../types'
2
3/** What a tool call looks like in the world, and what its success shows. */
4export type Classified = {
5  kind: ActivityKind
6  success?: EffectKind
7  /** Which achievement-worthy thing this is, beyond its kind. */
8  tag?: 'fetch' | 'push' | 'commit' | 'test' | 'skill' | 'agent'
9}
10
11const FETCH_RE = /(^|[\s;&|(])(curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod|http|https|xh)\b|\bgh\s+api\b/i
12const PUSH_RE = /\bgit\s+push\b/i
13const COMMIT_RE = /\bgit\s+commit\b/i
14const TEST_RE =
15  /\b(jest|vitest|pytest|mocha|rspec|phpunit|ctest)\b|\b(cargo|go|dotnet|deno|bun|swift|mvn|gradle)\s+test\b|\b(npm|pnpm|yarn)\s+(run\s+)?test\b|\bclaude\s+plugin\s+test\b/i
16const SEARCH_RE = /^\s*(grep|rg|ag|find|fd|ls|dir|tree|where|which|select-string|get-childitem|gci)\b/i
17const READ_RE = /^\s*(cat|head|tail|less|more|type|bat|get-content|gc)\b/i
18
19/** Maps a shell command to an activity. */
20export function classifyCommand(command: string): Classified {
21  if (PUSH_RE.test(command)) {
22    return { kind: 'rocket', success: 'firework', tag: 'push' }
23  }
24  if (COMMIT_RE.test(command)) {
25    return { kind: 'chest', success: 'item', tag: 'commit' }
26  }
27  if (TEST_RE.test(command)) {
28    return { kind: 'test', success: 'bullseye', tag: 'test' }
29  }
30  if (FETCH_RE.test(command)) {
31    return { kind: 'fish', success: 'catch', tag: 'fetch' }
32  }
33  if (SEARCH_RE.test(command)) {
34    return { kind: 'mine', success: 'diamond' }
35  }
36  if (READ_RE.test(command)) {
37    return { kind: 'read' }
38  }
39
40  return { kind: 'smelt' }
41}
42
43/** Maps a tool call (`tool` and its arguments) to an activity. */
44export function classify(tool: string, input: Record<string, unknown>): Classified {
45  switch (tool) {
46    case 'WebFetch':
47    case 'WebSearch':
48    case 'ReadMcpResource':
49    case 'ListMcpResources':
50      return { kind: 'fish', success: 'catch', tag: 'fetch' }
51    case 'Grep':
52    case 'Glob':
53    case 'LS':
54    case 'ToolSearch':
55      return { kind: 'mine', success: 'diamond' }
56    case 'Read':
57    case 'NotebookRead':
58      return { kind: 'read' }
59    case 'Edit':
60    case 'MultiEdit':
61    case 'NotebookEdit':
62      return { kind: 'craft', success: 'item' }
63    case 'Write':
64      return { kind: 'build', success: 'block' }
65    case 'Bash':
66    case 'PowerShell':
67      return classifyCommand(typeof input.command === 'string' ? input.command : '')
68    case 'Skill':
69      return { kind: 'magic', success: 'sparkle', tag: 'skill' }
70    case 'TodoWrite':
71    case 'TaskCreate':
72    case 'TaskUpdate':
73    case 'TaskList':
74    case 'TaskGet':
75    case 'EnterPlanMode':
76    case 'ExitPlanMode':
77      return { kind: 'plan' }
78    case 'Agent':
79    case 'Task':
80      return { kind: 'walk', tag: 'agent' }
81    case 'AskUserQuestion':
82      return { kind: 'wait' }
83  }
84  if (tool.startsWith('mcp__')) {
85    return { kind: 'fish', success: 'catch', tag: 'fetch' }
86  }
87
88  return { kind: 'craft', success: 'item' }
89}
90
91/** The tag over Clawd's head: what Claude is doing, in a word; none while resting. */
92export const TAG: Record<ActivityKind, string | null> = {
93  idle: null,
94  walk: 'working…',
95  think: 'thinking…',
96  fish: 'fetching…',
97  mine: 'searching…',
98  read: 'reading…',
99  craft: 'editing…',
100  build: 'writing…',
101  smelt: 'running…',
102  test: 'testing…',
103  chest: 'committing…',
104  rocket: 'pushing…',
105  magic: 'casting…',
106  plan: 'planning…',
107  wait: 'waiting for you…',
108  compost: 'compacting…',
109  hurt: 'ouch!',
110  sleep: 'zzz',
111}
112
113/** What each activity is called in `/mc stats` and the drawing's alt text. */
114export const ACTIVITY_LABEL: Record<ActivityKind, string> = {
115  idle: 'resting',
116  walk: 'walking',
117  think: 'thinking',
118  fish: 'fishing',
119  mine: 'mining',
120  read: 'reading a book',
121  craft: 'crafting',
122  build: 'building',
123  smelt: 'firing up the furnace',
124  test: 'shooting at a target',
125  chest: 'stashing loot in a chest',
126  rocket: 'preparing a firework',
127  magic: 'casting a spell',
128  plan: 'writing on a sign',
129  wait: 'waiting for permission',
130  compost: 'composting',
131  hurt: 'recovering from a creeper',
132  sleep: 'sleeping',
133}
134
hooks/render-raster.ts 241 lines
1// The terminal's renderer: the scene drawn into a pixel buffer, shrunk 2:1
2// so it sits about as large as the desktop's, then two pixels per cell (the
3// upper half block's foreground over its background).
4
5import type { Scene } from './scene'
6import { frameAt, sample } from './scene'
7import { blend, miniOf, sprite } from './sprites'
8import type { Tile } from './world'
9
10const UPPER_HALF = 0x2580
11
12export type RasterOptions = {
13  /** Multiplies the ground's colors (grass, dirt, trees, plants): below 1 darkens them. */
14  groundShade?: number
15  /** Filled with 1 where something was drawn over the sky, for `downsample2`. */
16  mask?: Uint8Array
17  /** Leaves Clawd out, for `drawMiniClawd` to draw after halving. */
18  isClawdSkipped?: boolean
19}
20
21export function rasterize(scene: Scene, now: number, opts: RasterOptions = {}): Uint32Array {
22  const { W, H } = scene
23  const buf = new Uint32Array(W * H)
24  for (let y = 0; y < H; y++) {
25    const c = blend(scene.skyTop, scene.skyBottom, H > 1 ? y / (H - 1) : 0)
26    buf.fill(c, y * W, (y + 1) * W)
27  }
28  const mask = opts.mask
29  const shade = opts.groundShade ?? 1
30  const ground = (c: number) => (shade === 1 ? c : blend(c, 0x000000, 1 - shade))
31  const put = (i: number, c: number) => {
32    buf[i] = c
33    if (mask) {
34      mask[i] = 1
35    }
36  }
37  const cam = Math.floor(scene.camX)
38  const drawTiles = (tiles: readonly Tile[]) => {
39    for (const t of tiles) {
40      const tex = sprite(t.tex)
41      const x0 = Math.max(0, t.x - cam)
42      const x1 = Math.min(W, t.x + t.w - cam)
43      const y0 = Math.max(0, t.y)
44      const y1 = Math.min(H, t.y + t.h)
45      for (let y = y0; y < y1; y++) {
46        const ty = (y - t.y) % tex.h
47        for (let x = x0; x < x1; x++) {
48          const wx = x + cam
49          const c = tex.px[ty * tex.w + (((wx % tex.w) + tex.w) % tex.w)] ?? -1
50          if (c >= 0) {
51            put(y * W + x, ground(c))
52          }
53        }
54      }
55    }
56  }
57
58  let i = 0
59  const actors = scene.actors
60  const drawActorsBelow = (z: number) => {
61    for (; i < actors.length && (actors[i]?.z ?? 0) < z; i++) {
62      const a = actors[i]
63      if (!a || (a.isClawd && opts.isClawdSkipped)) {
64        continue
65      }
66      const m = sample(a.motion, now)
67      if (m.o < 0.5) {
68        continue
69      }
70      const ox = Math.round((a.space === 'world' ? a.x - cam : a.x) + m.x)
71      const oy = Math.round(a.y + m.y)
72      if (a.rect) {
73        const x1 = Math.min(W, ox + a.rect.w)
74        const y1 = Math.min(H, oy + a.rect.h)
75        for (let y = Math.max(0, oy); y < y1; y++) {
76          for (let x = Math.max(0, ox); x < x1; x++) {
77            put(y * W + x, a.rect.color)
78          }
79        }
80        continue
81      }
82      const name = frameAt(a, now)
83      if (!name) {
84        continue
85      }
86      const s = sprite(name)
87      const tint = a.isGround ? ground : (c: number) => c
88      for (let sy = 0; sy < s.h; sy++) {
89        const y = oy + sy
90        if (y < 0 || y >= H) {
91          continue
92        }
93        for (let sx = 0; sx < s.w; sx++) {
94          const x = ox + sx
95          if (x < 0 || x >= W) {
96            continue
97          }
98          const c = s.px[sy * s.w + (a.flip ? s.w - 1 - sx : sx)] ?? -1
99          if (c >= 0) {
100            put(y * W + x, tint(c))
101          }
102        }
103      }
104    }
105  }
106
107  drawActorsBelow(10)
108  drawTiles(scene.tiles)
109  drawTiles(scene.structures)
110  drawActorsBelow(Number.POSITIVE_INFINITY)
111
112  return buf
113}
114
115/**
116 * Draws Clawd into a halved buffer with his own small sprite (9x7), standing
117 * where the full-size one would: a halved Clawd loses his face.
118 */
119export function drawMiniClawd(scene: Scene, now: number, buf: Uint32Array, w: number, h: number) {
120  for (const a of scene.actors) {
121    const name = a.isClawd ? frameAt(a, now) : undefined
122    const mini = name ? miniOf(name) : undefined
123    if (!name || !mini) {
124      continue
125    }
126    const m = sample(a.motion, now)
127    if (m.o < 0.5) {
128      continue
129    }
130    const full = sprite(name)
131    const s = sprite(mini)
132    // Right edges line up, so the wider mini sprite never covers what stands in front of him.
133    const ox = Math.round((a.x + full.w + m.x) / 2 - s.w)
134    const oy = Math.round((a.y + full.h + m.y) / 2 - s.h)
135    for (let sy = 0; sy < s.h; sy++) {
136      for (let sx = 0; sx < s.w; sx++) {
137        const x = ox + sx
138        const y = oy + sy
139        const c = s.px[sy * s.w + (a.flip ? s.w - 1 - sx : sx)] ?? -1
140        if (c >= 0 && x >= 0 && x < w && y >= 0 && y < h) {
141          buf[y * w + x] = c
142        }
143      }
144    }
145  }
146}
147
148const luma = (c: number) => ((c >> 16) & 255) * 0.3 + ((c >> 8) & 255) * 0.59 + (c & 255) * 0.11
149
150/**
151 * Halves a buffer each way. Each 2x2 block keeps what was drawn over the
152 * sky if anything was, so thin things (a fishing line, a star) survive;
153 * among those its most common color, and on a tie the darker one, so an
154 * eye beside a cheek stays an eye.
155 */
156export function downsample2(buf: Uint32Array, mask: Uint8Array, W: number, H: number): Uint32Array {
157  const w = W >> 1
158  const h = H >> 1
159  const out = new Uint32Array(w * h)
160  const picks: number[] = []
161  for (let y = 0; y < h; y++) {
162    for (let x = 0; x < w; x++) {
163      const i = 2 * y * W + 2 * x
164      const block = [i, i + 1, i + W, i + W + 1]
165      const drawn = block.filter(j => mask[j] === 1)
166      const from = drawn.length > 0 ? drawn : block
167      picks.length = 0
168      for (const j of from) {
169        picks.push(buf[j] ?? 0)
170      }
171      let best = picks[0] ?? 0
172      let bestCount = 0
173      for (const c of picks) {
174        let count = 0
175        for (const d of picks) {
176          if (d === c) {
177            count++
178          }
179        }
180        if (count > bestCount || (count === bestCount && luma(c) < luma(best))) {
181          best = c
182          bestCount = count
183        }
184      }
185      out[y * w + x] = drawn.length > 0 ? best : (buf[i] ?? 0)
186    }
187  }
188
189  return out
190}
191
192/** Packs a buffer `columns` wide and `rows * 2` tall as a Raster's `cells`. */
193export function packCells(buf: Uint32Array, W: number, H: number, columns: number, rows: number): string {
194  const words = new Uint32Array(columns * rows * 3)
195  for (let r = 0; r < rows; r++) {
196    const top = 2 * r
197    const bottom = Math.min(H - 1, top + 1)
198    for (let c = 0; c < columns; c++) {
199      const n = (r * columns + c) * 3
200      const x = Math.min(W - 1, c)
201      words[n] = UPPER_HALF
202      words[n + 1] = buf[top * W + x] ?? 0
203      words[n + 2] = buf[bottom * W + x] ?? 0
204    }
205  }
206
207  return base64(new Uint8Array(words.buffer))
208}
209
210const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
211
212export function base64(bytes: Uint8Array): string {
213  const native = (bytes as Uint8Array & { toBase64?: () => string }).toBase64
214  if (typeof native === 'function') {
215    return native.call(bytes)
216  }
217  const parts: string[] = []
218  let chunk = ''
219  let i = 0
220  for (; i + 2 < bytes.length; i += 3) {
221    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
222    chunk +=
223      ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]! + ALPHABET[(n >> 6) & 63]! + ALPHABET[n & 63]!
224    if (chunk.length > 8192) {
225      parts.push(chunk)
226      chunk = ''
227    }
228  }
229  const rest = bytes.length - i
230  if (rest === 1) {
231    const n = (bytes[i] ?? 0) << 16
232    chunk += `${ALPHABET[(n >> 18) & 63]}${ALPHABET[(n >> 12) & 63]}==`
233  } else if (rest === 2) {
234    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8)
235    chunk += `${ALPHABET[(n >> 18) & 63]}${ALPHABET[(n >> 12) & 63]}${ALPHABET[(n >> 6) & 63]}=`
236  }
237  parts.push(chunk)
238
239  return parts.join('')
240}
241
hooks/render-svg.ts 223 lines
1// The desktop's renderer: the scene as one SVG whose CSS animations play it
2// forward by themselves. Every animation is delayed by how long ago it
3// started (negative), so a redraw resumes it instead of restarting it.
4
5import type { Actor, Motion, Scene } from './scene'
6import type { SpriteName, TextureName } from './sprites'
7import { hex, sprite } from './sprites'
8
9export type SvgOptions = {
10  /** CSS pixels per world pixel. */
11  scale: number
12  /** Draw sprites in place rather than through `<use>`. */
13  isInline?: boolean
14}
15
16const r2 = (n: number) => Math.round(n * 100) / 100
17const pct = (p: number) => `${r2(p * 100)}%`
18
19/** A sprite's pixels as one path per color. */
20function paths(name: SpriteName): string {
21  const s = sprite(name)
22  const byColor = new Map<number, string[]>()
23  for (let y = 0; y < s.h; y++) {
24    let x = 0
25    while (x < s.w) {
26      const c = s.px[y * s.w + x] ?? -1
27      let end = x + 1
28      while (end < s.w && (s.px[y * s.w + end] ?? -1) === c) {
29        end++
30      }
31      if (c >= 0) {
32        const runs = byColor.get(c)
33        const run = `M${x} ${y}h${end - x}v1h-${end - x}z`
34        if (runs) {
35          runs.push(run)
36        } else {
37          byColor.set(c, [run])
38        }
39      }
40      x = end
41    }
42  }
43
44  return [...byColor].map(([c, runs]) => `<path fill="${hex(c)}" d="${runs.join('')}"/>`).join('')
45}
46
47/** Each drawing names its ids and classes apart, should two share a page. */
48let drawings = 0
49
50export function renderSvg(scene: Scene, now: number, opts: SvgOptions): string {
51  const { W, H } = scene
52  const ns = `c${(drawings++ % 46656).toString(36)}`
53  const css: string[] = []
54  const kfDone = new Set<string>()
55  const textures = new Set<TextureName>()
56  const sprites = new Set<SpriteName>()
57  let next = 0
58
59  const animate = (m: Motion): string => {
60    const name = `${ns}m${next++}`
61    const frames = m.keys.map(key => `${pct(key.p)}{transform:translate(${r2(key.x)}px,${r2(key.y)}px);opacity:${r2(key.o)}}`)
62    const elapsed = now - m.start
63    const delay = m.loop ? -(((elapsed % m.dur) + m.dur) % m.dur) : -elapsed
64    css.push(
65      `@keyframes ${name}{${frames.join('')}}`,
66      `.${name}{animation:${name} ${Math.round(m.dur)}ms linear ${Math.round(delay)}ms ${m.loop ? 'infinite' : '1'} both}`,
67    )
68
69    return name
70  }
71
72  // One class per motion: Clawd, his bubble and his wolves share one walk.
73  const named = new Map<Motion, string>()
74  const animateOnce = (m: Motion): string => {
75    const hit = named.get(m)
76    if (hit) {
77      return hit
78    }
79    const name = animate(m)
80    named.set(m, name)
81
82    return name
83  }
84
85  const draw = (a: Actor, name: SpriteName): string => {
86    sprites.add(name)
87    const body = opts.isInline ? paths(name) : `<use href="#${ns}s_${name}"/>`
88
89    return a.flip ? `<g transform="translate(${sprite(name).w},0) scale(-1,1)">${body}</g>` : body
90  }
91
92  const frames = (a: Actor): string => {
93    const list = a.frames ?? []
94    const first = list[0]
95    if (!first) {
96      return ''
97    }
98    if (list.length === 1 || !a.fps) {
99      return draw(a, first)
100    }
101    const n = list.length
102    const period = (n / a.fps) * 1000
103    const isLoop = a.frameLoop !== false
104    const elapsed = now - (a.frameStart ?? 0)
105    const delay = isLoop ? -(((elapsed % period) + period) % period) : -elapsed
106    const segments: { name: SpriteName; from: number; to: number }[] = []
107    list.forEach((name, i) => {
108      const last = segments[segments.length - 1]
109      if (last && last.name === name) {
110        last.to = (i + 1) / n
111      } else {
112        segments.push({ name, from: i / n, to: (i + 1) / n })
113      }
114    })
115
116    return segments
117      .map(seg => {
118        const kf = `${ns}v${Math.round(seg.from * 1000)}_${Math.round(seg.to * 1000)}`
119        if (!kfDone.has(kf)) {
120          kfDone.add(kf)
121          const steps = [
122            seg.from > 0 ? '0%{opacity:0}' : '',
123            `${pct(seg.from)}{opacity:1}`,
124            seg.to < 1 ? `${pct(seg.to)}{opacity:0}` : '',
125            `100%{opacity:${seg.to >= 1 ? 1 : 0}}`,
126          ]
127          css.push(`@keyframes ${kf}{${steps.join('')}}`)
128        }
129        const cls = `${ns}f${next++}`
130        css.push(
131          `.${cls}{animation:${kf} ${Math.round(period)}ms step-end ${Math.round(delay)}ms ${isLoop ? 'infinite' : '1'} both}`,
132        )
133
134        return `<g class="${cls}">${draw(a, seg.name)}</g>`
135      })
136      .join('')
137  }
138
139  const actor = (a: Actor): string => {
140    let inner = a.rect
141      ? `<rect width="${r2(a.rect.w)}" height="${r2(a.rect.h)}" fill="${hex(a.rect.color)}"/>`
142      : frames(a)
143    if (!inner) {
144      return ''
145    }
146    if (a.motion) {
147      inner = `<g class="${animateOnce(a.motion)}">${inner}</g>`
148    }
149
150    return `<g transform="translate(${r2(a.x)},${r2(a.y)})">${inner}</g>`
151  }
152
153  // The camera: still, or with the pans the walk ahead will make.
154  const openCam = scene.camera
155    ? `<g class="${animateOnce(scene.camera)}">`
156    : `<g transform="translate(${r2(-scene.camX)},0)">`
157
158  const tile = (t: { x: number; y: number; w: number; h: number; tex: TextureName }) => {
159    textures.add(t.tex)
160
161    return `<rect x="${t.x}" y="${t.y}" width="${t.w}" height="${t.h}" fill="url(#${ns}t_${t.tex})"/>`
162  }
163
164  const body: string[] = []
165  const actors = scene.actors
166  let i = 0
167  for (; i < actors.length && (actors[i]?.z ?? 0) < 10; i++) {
168    const a = actors[i]
169    if (a) {
170      body.push(actor(a))
171    }
172  }
173  body.push(openCam, ...scene.tiles.map(tile), ...scene.structures.map(tile))
174  let isInCam = true
175  for (; i < actors.length; i++) {
176    const a = actors[i]
177    if (!a) {
178      continue
179    }
180    const wantsCam = a.space === 'world'
181    if (wantsCam !== isInCam) {
182      body.push(wantsCam ? openCam : '</g>')
183      isInCam = wantsCam
184    }
185    body.push(actor(a))
186  }
187  if (isInCam) {
188    body.push('</g>')
189  }
190  if (scene.tag) {
191    // A pale box over Clawd's head with what he is doing, as a sign would read.
192    const { text, x, y } = scene.tag
193    const w = text.length * 3 + 4
194    const safe = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
195    body.push(
196      `<g transform="translate(${r2(x - w / 2)},${r2(y - 7)})">` +
197        `<rect width="${w}" height="7" fill="#ececec" stroke="#2b2b2b" stroke-width="0.5"/>` +
198        `<text x="${w / 2}" y="5.2" font-family="monospace" font-size="5" fill="#1b1b1b" text-anchor="middle">${safe}</text></g>`,
199    )
200  }
201
202  const defs: string[] = [
203    `<linearGradient id="${ns}sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${hex(scene.skyTop)}"/><stop offset="1" stop-color="${hex(scene.skyBottom)}"/></linearGradient>`,
204  ]
205  for (const t of textures) {
206    const s = sprite(t)
207    defs.push(
208      `<pattern id="${ns}t_${t}" width="${s.w}" height="${s.h}" patternUnits="userSpaceOnUse">${paths(t)}</pattern>`,
209    )
210  }
211  if (!opts.isInline) {
212    for (const s of sprites) {
213      defs.push(`<g id="${ns}s_${s}">${paths(s)}</g>`)
214    }
215  }
216
217  return (
218    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W * opts.scale}" height="${H * opts.scale}" shape-rendering="crispEdges">` +
219    `<defs>${defs.join('')}</defs><style>${css.join('')}</style>` +
220    `<rect width="${W}" height="${H}" fill="url(#${ns}sky)"/>${body.join('')}</svg>`
221  )
222}
223
hooks/scene.ts 817 lines
1import type { ActivityKind, MarkKind, WorldState } from '../types'
2import type { SpriteName } from './sprites'
3import { blend, sprite } from './sprites'
4import type { Biome, Tile } from './world'
5import {
6  BLOCK,
7  BOOM_MS,
8  EFFECT_MS,
9  biomeAt,
10  buildTerrain,
11  cameraAt,
12  colOf,
13  dayTimeAt,
14  groundFn,
15  hash,
16  layoutFor,
17  markIndex,
18  nightness,
19  posAt,
20  wallAt,
21} from './world'
22import { TAG } from './activity'
23
24/** One keyframe: at phase `p`, offset by `x`, `y`, at opacity `o`. */
25export type Key = { p: number; x: number; y: number; o: number }
26
27/** Keyframes over `dur` ms from `start` (epoch ms), once or looping. */
28export type Motion = { keys: Key[]; dur: number; start: number; loop: boolean }
29
30/**
31 * Something drawn: a sprite (cycling `frames` at `fps`) or a plain `rect`,
32 * in world pixels or screen pixels, moved by `motion`.
33 */
34export type Actor = {
35  space: 'world' | 'screen'
36  x: number
37  y: number
38  frames?: SpriteName[]
39  fps?: number
40  frameStart?: number
41  frameLoop?: boolean
42  rect?: { w: number; h: number; color: number }
43  flip?: boolean
44  motion?: Motion
45  /** Part of the ground (grass tufts, flowers): shaded with it where the ground is dimmed. */
46  isGround?: boolean
47  /** Clawd himself: the terminal draws his own small sprite here instead of halving this one. */
48  isClawd?: boolean
49  z: number
50}
51
52/** The word over Clawd's head, centred on `x` with its bottom at `y` (screen world pixels). */
53export type Tag = { text: string; x: number; y: number }
54
55export type Scene = {
56  W: number
57  H: number
58  /** Camera's world x at `now`; `camera`, when it will move, its keyframes (x = -camera). */
59  camX: number
60  camera?: Motion
61  tag?: Tag
62  now: number
63  skyTop: number
64  skyBottom: number
65  tiles: Tile[]
66  structures: Tile[]
67  actors: Actor[]
68  biome: Biome
69}
70
71const k = (p: number, x = 0, y = 0, o = 1): Key => ({ p, x, y, o })
72
73/** How long a thought takes to crack a block halfway; cracks slow as they go, never quite done. */
74export const CRACK_HALF_MS = 6000
75
76/** The crack stage `ms` into a thought: quick at first, then ever slower, holding at 9. */
77export function crackStage(ms: number): number {
78  const done = 1 - Math.exp((-Math.max(0, ms) * Math.LN2) / CRACK_HALF_MS)
79
80  return Math.min(9, Math.floor(done * 10))
81}
82
83/** Crack frames at 2 fps over two minutes, the last held: what both renderers play. */
84function crackFrames(): SpriteName[] {
85  return Array.from({ length: 240 }, (_, i) => `crack${crackStage(i * 500)}` as SpriteName)
86}
87
88/** A few of a block's own colors, for the chips it throws. */
89function blockColors(tex: SpriteName): number[] {
90  const px = sprite(tex).px
91
92  return [px[9] ?? 0x8c8c8c, px[27] ?? 0x6a6a6a, px[45] ?? 0xa8a8a8]
93}
94
95/** A motion that waits `delay` ms after `start`, hidden or held on its first key. */
96function delayed(start: number, delay: number, dur: number, keys: Key[], isHiddenBefore = true): Motion {
97  if (delay <= 0) {
98    return { keys, dur, start, loop: false }
99  }
100  const total = delay + dur
101  const d = delay / total
102  const first = keys[0] ?? k(0)
103  const lead = isHiddenBefore ? { ...first, o: 0 } : first
104
105  return {
106    keys: [
107      { ...lead, p: 0 },
108      { ...lead, p: Math.max(0, d - 0.0005) },
109      ...keys.map(key => ({ ...key, p: d + key.p * (1 - d) })),
110    ],
111    dur: total,
112    start,
113    loop: false,
114  }
115}
116
117const loop = (keys: Key[], dur: number, start = 0): Motion => ({ keys, dur, start, loop: true })
118
119const MARK_ACTIVITY: Partial<Record<MarkKind, ActivityKind>> = {
120  lectern: 'read',
121  crafting: 'craft',
122  furnace: 'smelt',
123  target: 'test',
124  chest: 'chest',
125  enchant: 'magic',
126  sign: 'plan',
127  composter: 'compost',
128  campfire: 'sleep',
129  plank: 'build',
130}
131
132function markLook(kind: MarkKind, isActive: boolean): { frames: SpriteName[]; fps?: number } {
133  switch (kind) {
134    case 'lectern':
135      return isActive ? { frames: ['lectern0', 'lectern1'], fps: 1.5 } : { frames: ['lectern0'] }
136    case 'crafting':
137      return { frames: ['crafting'] }
138    case 'furnace':
139      return isActive ? { frames: ['furnace_lit0', 'furnace_lit1'], fps: 5 } : { frames: ['furnace'] }
140    case 'target':
141      return { frames: ['target'] }
142    case 'chest':
143      return { frames: [isActive ? 'chest_open' : 'chest'] }
144    case 'enchant':
145      return { frames: ['enchant'] }
146    case 'sign':
147      return { frames: ['sign_lines'] }
148    case 'composter':
149      return { frames: ['composter'] }
150    case 'campfire':
151      return { frames: ['campfire0', 'campfire1'], fps: 4 }
152    case 'torch':
153      return { frames: ['torch0', 'torch1'], fps: 3 }
154    default:
155      return { frames: ['tex_plank'] }
156  }
157}
158
159function skyColors(t: number, night: number, biome: Biome): [number, number] {
160  if (biome === 'nether') {
161    return [0x2a0808, 0x6b1e1e]
162  }
163  const glow = Math.max(0, 1 - Math.min(Math.abs(t - 0.5), t, 1 - t) / 0.07)
164  const top = blend(blend(0x6fa8ff, 0x070b22, night), 0x4a5fa8, glow * 0.5)
165  const bottom = blend(blend(0xbfdcff, 0x1b2350, night), 0xf2a15a, glow * 0.75)
166
167  return [top, bottom]
168}
169
170const IDLE_FRAMES: SpriteName[] = [
171  'clawd_idle',
172  'clawd_idle',
173  'clawd_idle',
174  'clawd_idle',
175  'clawd_idle',
176  'clawd_idle',
177  'clawd_idle',
178  'clawd_blink',
179]
180const WALK_FRAMES: SpriteName[] = ['clawd_idle', 'clawd_step']
181
182/**
183 * The world at `now` as a list of things to draw. `lookaheadMs` > 0 asks for
184 * terrain and the camera's glide far enough ahead for a renderer that
185 * animates the walk by itself (the vector one); the cell renderer draws each
186 * frame anew.
187 */
188export function composeScene(w: WorldState, now: number, W: number, H: number, lookaheadMs = 0): Scene {
189  const L = layoutFor(W, H)
190  const pos = posAt(w, now)
191  const camX = cameraAt(w, now, L)
192  const ahead = w.speed > 0 ? (w.speed * lookaheadMs) / 1000 : 0
193  const from = Math.floor((camX - 24) / BLOCK)
194  const to = Math.ceil((camX + W + ahead + 24) / BLOCK)
195  const index = markIndex(w.marks, now)
196  const ground = groundFn(L)
197  const { tiles, structures, decor } = buildTerrain(w.seed, L, from, to, index)
198  const biome = biomeAt(w.seed, colOf(pos))
199  const t = dayTimeAt(w, now)
200  const night = biome === 'nether' ? 0 : nightness(t)
201  const [skyTop, skyBottom] = skyColors(t, night, biome)
202  const actors: Actor[] = []
203  const add = (a: Actor) => actors.push(a)
204
205  // Clawd keeps his spot on screen; while he walks the world glides past at one pace.
206  const screenX = pos - camX
207  const camera: Motion | undefined =
208    ahead > 0
209      ? { keys: [k(0, -camX), k(1, -cameraAt(w, now + lookaheadMs, L))], dur: lookaheadMs, start: now, loop: false }
210      : undefined
211
212  /** Particles thrown out from one point, once. */
213  const burst = (o: {
214    x: number
215    y: number
216    n: number
217    dist: number
218    colors: number[]
219    size?: number
220    start: number
221    delay?: number
222    dur: number
223    up?: number
224    fall?: number
225    salt: number
226    z?: number
227  }) => {
228    for (let i = 0; i < o.n; i++) {
229      const a = (i / o.n) * Math.PI * 2 + (hash(o.salt, i, 5) - 0.5) * 0.7
230      const d = o.dist * (0.55 + 0.45 * hash(o.salt, i, 6))
231      const dx = Math.cos(a) * d
232      const dy = Math.sin(a) * d - (o.up ?? 0)
233      const size = o.size ?? 2
234      add({
235        space: 'world',
236        x: o.x - size / 2,
237        y: o.y - size / 2,
238        rect: { w: size, h: size, color: o.colors[i % o.colors.length] ?? 0xffffff },
239        motion: delayed(o.start, o.delay ?? 0, o.dur, [k(0), k(0.55, dx * 0.85, dy * 0.85), k(1, dx, dy + (o.fall ?? 0), 0)]),
240        z: o.z ?? 50,
241      })
242    }
243  }
244
245  /** Particles rising from one point, forever. */
246  const stream = (o: {
247    x: number
248    y: number
249    n: number
250    dx: number
251    dy: number
252    colors: number[]
253    size?: number
254    dur: number
255    z?: number
256  }) => {
257    for (let i = 0; i < o.n; i++) {
258      const jitter = (hash(7, i, 9) - 0.5) * 4
259      add({
260        space: 'world',
261        x: o.x + jitter,
262        y: o.y,
263        rect: { w: o.size ?? 1, h: o.size ?? 1, color: o.colors[i % o.colors.length] ?? 0xffffff },
264        motion: loop([k(0, 0, 0, 0), k(0.15, 0, o.dy * 0.15), k(1, o.dx + jitter, o.dy, 0)], o.dur, (i / o.n) * o.dur),
265        z: o.z ?? 48,
266      })
267    }
268  }
269
270  // ---- sky ----
271  const skyH = Math.max(6, L.groundBase - 6)
272  if (biome !== 'nether') {
273    const isDay = t < 0.5
274    const u = isDay ? t / 0.5 : (t - 0.5) / 0.5
275    add({
276      space: 'screen',
277      x: Math.round(4 + u * (W - 12)),
278      y: Math.round(1 + (1 - Math.sin(Math.PI * u)) * skyH * 0.6),
279      frames: [isDay ? 'sun' : 'moon'],
280      z: 2,
281    })
282    if (night > 0.3) {
283      for (let i = 0; i < 18; i++) {
284        const dur = 1800 + i * 97
285        add({
286          space: 'screen',
287          x: Math.floor(hash(w.seed, i, 41) * W),
288          y: Math.floor(hash(w.seed, i, 42) * skyH),
289          rect: { w: 1, h: 1, color: i % 3 === 0 ? 0xfff4c0 : 0xffffff },
290          motion: loop([k(0), k(0.5, 0, 0, 0.2), k(1)], dur, -hash(w.seed, i, 43) * dur),
291          z: 1,
292        })
293      }
294    }
295    if (night < 0.7) {
296      const color = blend(0xffffff, 0x5a6080, night)
297      for (let i = 0; i < 3; i++) {
298        const cw = 14 + Math.floor(hash(w.seed, i, 51) * 14)
299        const cy = 2 + Math.floor(hash(w.seed, i, 52) * Math.max(1, skyH * 0.35))
300        const dur = 70_000 + i * 23_000
301        const drift = loop([k(0), k(1, -(W + cw + 6))], dur, -hash(w.seed, i, 53) * dur)
302        add({ space: 'screen', x: W, y: cy, rect: { w: cw, h: 3, color }, motion: drift, z: 3 })
303        add({ space: 'screen', x: W + 4, y: cy - 2, rect: { w: cw - 8, h: 2, color }, motion: drift, z: 3 })
304      }
305    }
306  }
307
308  // ---- ground decorations and what Claude left behind ----
309  for (const d of decor) {
310    add({ space: 'world', x: d.x, y: d.y, frames: [d.sprite], isGround: true, z: 15 })
311  }
312  for (const m of w.marks) {
313    if (m.at > now || m.i < from - 2 || m.i > to + 2 || m.kind === 'pond' || m.kind === 'crater') {
314      continue
315    }
316    const isActive = MARK_ACTIVITY[m.kind] === w.activity && m.i === w.anchor
317    const look = markLook(m.kind, isActive)
318    const s = sprite(look.frames[0] ?? 'tex_plank')
319    const lift = m.kind === 'plank' ? (m.h ?? 0) * BLOCK : 0
320    add({
321      space: 'world',
322      x: m.i * BLOCK + Math.floor((BLOCK - s.w) / 2),
323      y: ground(m.i) - s.h - lift,
324      frames: look.frames,
325      fps: look.fps,
326      motion: now - m.at < 400 ? { keys: [k(0, 0, -3, 0), k(1)], dur: 400, start: m.at, loop: false } : undefined,
327      z: 20,
328    })
329  }
330
331  // ---- Clawd ----
332  const gy = ground(colOf(pos))
333  const cy = gy - 10
334  const clawd: Actor = { space: 'screen', x: screenX - 6, y: cy, frames: IDLE_FRAMES, fps: 3, isClawd: true, z: 40 }
335  const ax = w.anchor * BLOCK
336  const ag = ground(w.anchor)
337  switch (w.activity) {
338    case 'walk':
339      clawd.frames = WALK_FRAMES
340      clawd.fps = 6
341      break
342    case 'think': {
343      // One thought, one block: a two-block wall ahead cracks for as long as the thought lasts.
344      const tex = wallAt(w.seed, w.anchor)
345      const cracks = crackFrames()
346      for (const y of [ag - 2 * BLOCK, ag - BLOCK]) {
347        add({ space: 'world', x: ax, y, frames: [tex], z: 21 })
348        add({ space: 'world', x: ax, y, frames: cracks, fps: 2, frameLoop: false, frameStart: w.activityAt, z: 22 })
349      }
350      add({ space: 'world', x: pos + 4, y: cy - 4, frames: ['pick_up', 'pick_down'], fps: 3, z: 45 })
351      stream({ x: ax + 1, y: ag - 9, n: 3, dx: -5, dy: -4, colors: blockColors(tex), dur: 600 })
352      break
353    }
354    case 'sleep':
355      clawd.frames = ['clawd_sleep']
356      for (let i = 0; i < 2; i++) {
357        add({
358          space: 'world',
359          x: pos + 2,
360          y: cy + 2,
361          frames: ['zzz'],
362          motion: loop([k(0, 0, 0, 0), k(0.15, 1, -2), k(0.8, 5, -9), k(1, 6, -11, 0)], 2600, i * 1300),
363          z: 46,
364        })
365      }
366      break
367    case 'hurt': {
368      const before = Math.round(BOOM_MS / 200)
369      clawd.frames = [...Array<SpriteName>(before).fill('clawd_idle'), 'clawd_hurt', 'clawd_hurt', 'clawd_hurt', 'clawd_idle']
370      clawd.fps = 5
371      clawd.frameLoop = false
372      clawd.frameStart = w.activityAt
373      clawd.motion = delayed(
374        w.activityAt,
375        BOOM_MS,
376        1600,
377        [k(0), k(0.18, -7, -6), k(0.35, -10), k(0.7, -10), k(1)],
378        false,
379      )
380      break
381    }
382    case 'fish': {
383      const rodX = pos + 6
384      const rodY = cy - 2
385      const waterY = ground(colOf(rodX + 7))
386      add({ space: 'world', x: rodX, y: rodY, frames: ['rod'], z: 45 })
387      add({
388        space: 'world',
389        x: rodX + 7,
390        y: rodY + 1,
391        rect: { w: 1, h: Math.max(1, waterY - rodY - 1), color: 0xe8e8e8 },
392        z: 44,
393      })
394      add({
395        space: 'world',
396        x: rodX + 6,
397        y: waterY - 1,
398        frames: ['bobber'],
399        motion: loop([k(0), k(0.5, 0, 1), k(1)], 1400, w.activityAt),
400        z: 46,
401      })
402      break
403    }
404    case 'mine':
405      add({ space: 'world', x: ax, y: ag - BLOCK, frames: ['tex_ore'], z: 21 })
406      add({ space: 'world', x: pos + 4, y: cy - 4, frames: ['pick_up', 'pick_down'], fps: 4, z: 45 })
407      stream({ x: ax + 1, y: ag - 5, n: 3, dx: -5, dy: -4, colors: [0x8c8c8c, 0x6a6a6a], dur: 500 })
408      break
409    case 'craft':
410      stream({ x: ax + 4, y: ag - 9, n: 3, dx: 0, dy: -5, colors: [0xffffff, 0xffd23f], dur: 700 })
411      break
412    case 'build':
413      break
414    case 'smelt':
415      stream({ x: ax + 3, y: ag - 9, n: 4, dx: 3, dy: -14, colors: [0xc9c9c9, 0x8c8c8c], size: 2, dur: 2000 })
416      break
417    case 'test': {
418      add({ space: 'world', x: pos + 6, y: cy - 1, frames: ['bow'], z: 45 })
419      const sx = pos + 8
420      const sy = cy + 2
421      const dx = ax - 8 - sx
422      const dy = ag - 5 - sy
423      add({
424        space: 'world',
425        x: sx,
426        y: sy,
427        frames: ['arrow'],
428        motion: loop([k(0), k(0.35, dx, dy), k(0.6, dx, dy), k(0.61, dx, dy, 0), k(1, 0, 0, 0)], 1300, w.activityAt),
429        z: 46,
430      })
431      break
432    }
433    case 'chest':
434      stream({ x: ax + 4, y: ag - 8, n: 2, dx: 0, dy: -6, colors: [0x5fe3e0, 0xffd23f], dur: 900 })
435      break
436    case 'rocket':
437      clawd.frames = ['clawd_cheer', 'clawd_idle']
438      clawd.fps = 2
439      add({ space: 'world', x: ax + 2, y: ag - 8, frames: ['rocket'], z: 21 })
440      add({
441        space: 'world',
442        x: ax + 3,
443        y: ag - 1,
444        rect: { w: 1, h: 1, color: 0xffd23f },
445        motion: loop([k(0), k(0.5, 0, 0, 0), k(1)], 300),
446        z: 22,
447      })
448      break
449    case 'magic':
450      clawd.frames = ['clawd_idle', 'clawd_cheer']
451      clawd.fps = 1.5
452      add({
453        space: 'world',
454        x: ax,
455        y: ag - 6 - 5,
456        frames: ['book'],
457        motion: loop([k(0), k(0.5, 0, -2), k(1)], 1800),
458        z: 22,
459      })
460      for (let i = 0; i < 4; i++) {
461        const ox = (i % 2 === 0 ? -1 : 1) * (8 + i * 2)
462        const oy = -4 + i * 3
463        add({
464          space: 'world',
465          x: ax + 2 + ox,
466          y: ag - 10 + oy,
467          frames: [(['glyph0', 'glyph1', 'glyph2', 'glyph3'] as const)[i] ?? 'glyph0'],
468          motion: loop([k(0, 0, 0, 0), k(0.2), k(1, -ox, -oy, 0)], 1600, (i / 4) * 1600),
469          z: 47,
470        })
471      }
472      break
473    case 'plan':
474      break
475    case 'wait':
476      add({
477        space: 'world',
478        x: pos - 4,
479        y: cy - 11,
480        frames: ['sign_q'],
481        motion: loop([k(0), k(0.5, 0, -1), k(1)], 800),
482        z: 46,
483      })
484      break
485    case 'compost':
486      stream({ x: ax + 4, y: ag - 8, n: 3, dx: 0, dy: -7, colors: [0x5da130, 0x8ed16b], dur: 1100 })
487      break
488  }
489  add(clawd)
490
491  // ---- wolves (subagents) ----
492  const isBusy = now - w.wolfBusyAt < 1500
493  w.wolves.forEach((f, i) => {
494    const off = -16 - 13 * i
495    const wx = pos + off
496    const isLeaving = f.leftAt !== undefined
497    const isWalking = w.speed > 0 || isLeaving
498    const wolf: Actor = {
499      space: 'screen',
500      x: screenX + off - 5,
501      y: ground(colOf(wx)) - 7,
502      frames: isWalking ? ['wolf_walk0', 'wolf_walk1'] : isBusy ? ['wolf_walk0', 'wolf_sit'] : ['wolf_sit'],
503      fps: isWalking ? 6 : 4,
504      z: 35,
505    }
506    if (f.leftAt !== undefined) {
507      wolf.flip = true
508      wolf.motion = { keys: [k(0), k(1, -40, 0, 0)], dur: 1600, start: f.leftAt, loop: false }
509    } else if (now - f.at < 600) {
510      wolf.motion = { keys: [k(0, 0, 0, 0), k(1)], dur: 600, start: f.at, loop: false }
511    }
512    add(wolf)
513    if (isBusy && i === 0 && !isLeaving) {
514      stream({ x: wx, y: ground(colOf(wx)) - 1, n: 3, dx: -4, dy: -4, colors: [0x8b5e3c, 0x6b4529], dur: 500 })
515    }
516  })
517
518  // ---- one-off effects ----
519  for (const e of w.effects) {
520    const age = now - e.at
521    if (age < 0 || age > EFFECT_MS) {
522      continue
523    }
524    const eg = ground(colOf(e.x))
525    const salt = Math.floor(e.at) % 100_000
526    switch (e.kind) {
527      case 'explode': {
528        const cy0 = ground(colOf(e.x + 3)) - 11
529        const flash = (on: number[]): Key[] => {
530          const keys: Key[] = [k(0, 30, 0, 0), k(0.55, 0, 0, 0)]
531          for (let i = 0; i + 1 < on.length; i += 2) {
532            const a = on[i] ?? 0
533            const b = on[i + 1] ?? 0
534            keys.push(k(a, 0, 0, 0), k(a + 0.0005, 0, 0, 1), k(b, 0, 0, 1), k(b + 0.0005, 0, 0, 0))
535          }
536          keys.push(k(1, 0, 0, 0))
537
538          return keys
539        }
540        add({
541          space: 'world',
542          x: e.x,
543          y: cy0,
544          frames: ['creeper'],
545          motion: { keys: [k(0, 30), k(0.55), k(0.999), k(1, 0, 0, 0)], dur: BOOM_MS, start: e.at, loop: false },
546          z: 42,
547        })
548        add({
549          space: 'world',
550          x: e.x,
551          y: cy0,
552          frames: ['creeper_flash'],
553          motion: { keys: flash([0.6, 0.68, 0.76, 0.84, 0.9, 0.998]), dur: BOOM_MS, start: e.at, loop: false },
554          z: 43,
555        })
556        for (const [dx, dy, fw, fh, color] of [
557          [-8, -2, 22, 14, 0xffffff],
558          [-4, -6, 14, 22, 0xffffff],
559          [-2, 0, 10, 10, 0xfff3b0],
560        ] as const) {
561          add({
562            space: 'world',
563            x: e.x + dx,
564            y: cy0 + dy,
565            rect: { w: fw, h: fh, color },
566            motion: delayed(e.at, BOOM_MS, 450, [k(0, 0, 0, 1), k(1, 0, 0, 0)]),
567            z: 55,
568          })
569        }
570        burst({
571          x: e.x + 3,
572          y: cy0 + 5,
573          n: 14,
574          dist: 20,
575          colors: [0x9a9a9a, 0x555555, 0xdddddd],
576          size: 3,
577          start: e.at,
578          delay: BOOM_MS,
579          dur: 900,
580          up: 4,
581          salt,
582        })
583        burst({
584          x: e.x + 3,
585          y: cy0 + 9,
586          n: 4,
587          dist: 12,
588          colors: [0x8b5e3c, 0x5da130],
589          size: 2,
590          start: e.at,
591          delay: BOOM_MS,
592          dur: 800,
593          up: 8,
594          fall: 10,
595          salt: salt + 1,
596        })
597        break
598      }
599      case 'catch':
600        add({
601          space: 'world',
602          x: e.x - 4,
603          y: eg - 4,
604          frames: ['fish'],
605          flip: true,
606          motion: {
607            keys: [k(0, 0, 0, 1), k(0.45, -6, -14), k(0.85, -14, -6), k(1, -16, -4, 0)],
608            dur: 1100,
609            start: e.at,
610            loop: false,
611          },
612          z: 50,
613        })
614        burst({ x: e.x, y: eg, n: 4, dist: 5, colors: [0x7aa8f5, 0xffffff], start: e.at, dur: 450, up: 4, salt })
615        burst({
616          x: e.x - 8,
617          y: eg - 8,
618          n: 3,
619          dist: 6,
620          colors: [0xb8f04a, 0xe8ff6a],
621          start: e.at,
622          delay: 700,
623          dur: 800,
624          up: 6,
625          salt: salt + 2,
626        })
627        break
628      case 'diamond':
629        add({
630          space: 'world',
631          x: e.x - 3,
632          y: eg - BLOCK - 6,
633          frames: ['diamond'],
634          motion: {
635            keys: [k(0, 0, 4, 0), k(0.15, 0, -2), k(0.5, 0, -5), k(0.85, 0, -4), k(1, 0, -6, 0)],
636            dur: 1600,
637            start: e.at,
638            loop: false,
639          },
640          z: 50,
641        })
642        burst({ x: e.x, y: eg - 8, n: 4, dist: 7, colors: [0xffffff, 0x5fe3e0], size: 1, start: e.at, dur: 700, salt })
643        break
644      case 'item':
645        add({
646          space: 'world',
647          x: e.x - 3,
648          y: eg - BLOCK - 8,
649          frames: ['pick_up'],
650          motion: { keys: [k(0, 0, 3, 0), k(0.2, 0, 0), k(0.8, 0, -3), k(1, 0, -5, 0)], dur: 1400, start: e.at, loop: false },
651          z: 50,
652        })
653        break
654      case 'block':
655        burst({ x: e.x, y: eg - 4, n: 5, dist: 7, colors: [0xb38b55, 0x8a6a3f], start: e.at, dur: 500, salt })
656        break
657      case 'bullseye':
658        burst({ x: e.x, y: eg - 4, n: 6, dist: 8, colors: [0xd93a1e, 0xffffff], start: e.at, dur: 650, salt })
659        add({
660          space: 'world',
661          x: e.x - 2,
662          y: eg - 16,
663          frames: ['heart'],
664          motion: { keys: [k(0, 0, 0, 0), k(0.2), k(1, 0, -6, 0)], dur: 1200, start: e.at, loop: false },
665          z: 50,
666        })
667        break
668      case 'firework': {
669        const rise = -(eg - 14)
670        add({
671          space: 'world',
672          x: e.x - 1,
673          y: eg - 8,
674          frames: ['rocket'],
675          motion: { keys: [k(0), k(0.98, 0, rise), k(1, 0, rise, 0)], dur: 1100, start: e.at, loop: false },
676          z: 50,
677        })
678        burst({
679          x: e.x,
680          y: 6,
681          n: 18,
682          dist: 13,
683          colors: [0xff5555, 0xffd23f, 0x55ff99, 0x55aaff, 0xff77ff],
684          start: e.at,
685          delay: 1100,
686          dur: 1100,
687          fall: 5,
688          salt,
689        })
690        break
691      }
692      case 'sparkle':
693        for (let i = 0; i < 8; i++) {
694          const a = (i / 8) * Math.PI * 2
695          add({
696            space: 'world',
697            x: e.x - 1,
698            y: eg - 12,
699            frames: [(['glyph0', 'glyph1', 'glyph2', 'glyph3'] as const)[i % 4] ?? 'glyph0'],
700            motion: {
701              keys: [k(0, 0, 0, 0), k(0.15), k(1, Math.cos(a) * 12, Math.sin(a) * 8 - 8, 0)],
702              dur: 1300,
703              start: e.at,
704              loop: false,
705            },
706            z: 50,
707          })
708        }
709        break
710      case 'pearl':
711        burst({
712          x: e.x,
713          y: eg - 5,
714          n: 12,
715          dist: 12,
716          colors: [0x8e44ad, 0x2a9d8f, 0xd2a6ee],
717          start: e.at,
718          dur: 900,
719          up: 3,
720          salt,
721        })
722        break
723      case 'flag':
724        burst({ x: e.x, y: eg - 8, n: 4, dist: 5, colors: [0xffd23f, 0xff8c1a], size: 1, start: e.at, dur: 700, up: 4, salt })
725        break
726      case 'bone':
727        add({
728          space: 'world',
729          x: e.x - 3,
730          y: eg - 3,
731          frames: ['bone'],
732          motion: { keys: [k(0, 0, -6), k(0.3), k(0.8), k(1, 0, 0, 0)], dur: 1500, start: e.at, loop: false },
733          z: 50,
734        })
735        add({
736          space: 'world',
737          x: e.x - 2,
738          y: eg - 14,
739          frames: ['heart'],
740          motion: { keys: [k(0, 0, 0, 0), k(0.2), k(1, 0, -6, 0)], dur: 1200, start: e.at, loop: false },
741          z: 50,
742        })
743        break
744      case 'howl':
745        burst({ x: e.x, y: eg - 4, n: 8, dist: 8, colors: [0xffffff, 0xc9c9c9], size: 2, start: e.at, dur: 600, salt })
746        break
747      case 'break': {
748        const colors = blockColors(wallAt(w.seed, colOf(e.x)))
749        burst({ x: e.x, y: eg - 8, n: 10, dist: 10, colors, size: 2, start: e.at, dur: 700, up: 4, fall: 8, salt })
750        break
751      }
752    }
753  }
754
755  actors.sort((a, b) => a.z - b.z)
756
757  const text = TAG[w.activity]
758  const tag: Tag | undefined = text ? { text, x: screenX, y: cy - 2 } : undefined
759
760  return {
761    W,
762    H,
763    camX,
764    camera,
765    tag,
766    now,
767    skyTop,
768    skyBottom,
769    tiles,
770    structures,
771    actors,
772    biome,
773  }
774}
775
776// ---------- sampling, shared by both renderers ----------
777
778/** Offset and opacity of a motion at `now`. */
779export function sample(m: Motion | undefined, now: number): { x: number; y: number; o: number } {
780  if (!m || m.keys.length === 0 || m.dur <= 0) {
781    return { x: 0, y: 0, o: 1 }
782  }
783  let p = (now - m.start) / m.dur
784  p = m.loop ? p - Math.floor(p) : Math.max(0, Math.min(1, p))
785  const keys = m.keys
786  let a = keys[0] ?? k(0)
787  if (p <= a.p) {
788    return { x: a.x, y: a.y, o: a.o }
789  }
790  for (let i = 1; i < keys.length; i++) {
791    const b = keys[i] ?? a
792    if (p <= b.p) {
793      const f = b.p > a.p ? (p - a.p) / (b.p - a.p) : 1
794
795      return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f, o: a.o + (b.o - a.o) * f }
796    }
797    a = b
798  }
799
800  return { x: a.x, y: a.y, o: a.o }
801}
802
803/** Which frame an actor shows at `now`. */
804export function frameAt(a: Actor, now: number): SpriteName | undefined {
805  const frames = a.frames
806  if (!frames || frames.length === 0) {
807    return undefined
808  }
809  if (frames.length === 1 || !a.fps) {
810    return frames[0]
811  }
812  let i = Math.floor(((now - (a.frameStart ?? 0)) / 1000) * a.fps)
813  i = a.frameLoop === false ? Math.max(0, Math.min(frames.length - 1, i)) : ((i % frames.length) + frames.length) % frames.length
814
815  return frames[i]
816}
817
hooks/world.ts 601 lines
1import type { ActivityKind, EffectKind, MarkKind, WorldEffect, WorldMark, WorldState } from '../types'
2import type { Classified } from './activity'
3import type { SpriteName, TextureName } from './sprites'
4import { sprite } from './sprites'
5
6export const BLOCK = 8
7/** World pixels per second while walking or thinking: one pace, so the scene never lurches. */
8export const WALK_SPEED = 12
9/** A whole day while Claude works; night falls after this long idle. */
10export const DAY_MS = 20 * 60_000
11export const IDLE_NIGHT_MS = 60_000
12export const HURT_MS = 3000
13/** When the creeper goes off, after the error. */
14export const BOOM_MS = 2000
15export const EFFECT_MS = 6000
16export const BIOME_SPAN = 64
17export const NETHER_FROM = 1500
18
19export type Biome = 'plains' | 'forest' | 'desert' | 'taiga' | 'mushroom' | 'nether'
20
21export const BIOME_LABEL: Record<Biome, string> = {
22  plains: 'Plains',
23  forest: 'Forest',
24  desert: 'Desert',
25  taiga: 'Snowy Taiga',
26  mushroom: 'Mushroom Fields',
27  nether: 'Nether',
28}
29
30const CYCLE: Biome[] = ['plains', 'forest', 'desert', 'taiga', 'mushroom']
31
32const SURFACE: Record<Biome, [TextureName, TextureName]> = {
33  plains: ['tex_grass', 'tex_dirt'],
34  forest: ['tex_grass', 'tex_dirt'],
35  desert: ['tex_sand', 'tex_sandstone'],
36  taiga: ['tex_snow', 'tex_dirt'],
37  mushroom: ['tex_mycelium', 'tex_dirt'],
38  nether: ['tex_nylium', 'tex_netherrack'],
39}
40
41/** The mark an activity leaves where it happened. */
42const MARK_OF: Partial<Record<ActivityKind, MarkKind>> = {
43  fish: 'pond',
44  read: 'lectern',
45  craft: 'crafting',
46  smelt: 'furnace',
47  test: 'target',
48  chest: 'chest',
49  magic: 'enchant',
50  plan: 'sign',
51  compost: 'composter',
52  build: 'plank',
53  sleep: 'campfire',
54}
55
56export function initialWorld(seed: number, distance: number, now: number): WorldState {
57  return {
58    seed,
59    distanceAt: distance,
60    movedAt: now,
61    speed: 0,
62    activity: 'idle',
63    activityAt: now,
64    anchor: Math.floor(distance / BLOCK) + 2,
65    running: [],
66    effects: [],
67    marks: [],
68    wolves: [],
69    wolfBusyAt: 0,
70    isWorking: false,
71    idleSince: now,
72    dayAt: 0.1,
73    dayMovedAt: now,
74    tools: 0,
75    streak: 0,
76    explosions: 0,
77  }
78}
79
80// ---------- noise ----------
81
82/** A stable pseudo-random number in [0, 1) for a seed and coordinates. */
83export function hash(seed: number, a: number, b = 0): number {
84  let h = (seed ^ Math.imul(a | 0, 0x27d4eb2d) ^ Math.imul((b | 0) + 0x9e37, 0x165667b1)) >>> 0
85  h = Math.imul(h ^ (h >>> 15), 0x85ebca6b) >>> 0
86  h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0
87  h = (h ^ (h >>> 16)) >>> 0
88
89  return h / 4294967296
90}
91
92export function biomeAt(seed: number, i: number): Biome {
93  const seg = Math.floor(i / BIOME_SPAN)
94  if (seg <= 0) {
95    return 'plains'
96  }
97  if (i >= NETHER_FROM && hash(seed, seg, 77) < 0.25) {
98    return 'nether'
99  }
100
101  return CYCLE[Math.floor(hash(seed, seg, 11) * CYCLE.length)] ?? 'plains'
102}
103
104// ---------- layout ----------
105
106export type Layout = {
107  /** Scene size in world pixels. */
108  W: number
109  H: number
110  /** The ground's top: one flat plane everywhere. */
111  groundBase: number
112  /** Where Clawd stands on screen, from the left edge. */
113  lo: number
114}
115
116export function layoutFor(W: number, H: number): Layout {
117  const groundDepth = H >= 40 ? 24 : 8
118
119  return { W, H, groundBase: H - groundDepth, lo: Math.round(Math.max(24, Math.min(W * 0.3, 120))) }
120}
121
122export const colOf = (px: number) => Math.floor(px / BLOCK)
123
124/** Marks by the columns they touch (a pond three, a crater two). */
125export function markIndex(marks: readonly WorldMark[], now: number): Map<number, WorldMark[]> {
126  const index = new Map<number, WorldMark[]>()
127  for (const m of marks) {
128    if (m.at > now) {
129      continue
130    }
131    const span = m.kind === 'pond' ? 3 : m.kind === 'crater' ? 2 : 1
132    for (let d = 0; d < span; d++) {
133      const list = index.get(m.i + d)
134      if (list) {
135        list.push(m)
136      } else {
137        index.set(m.i + d, [m])
138      }
139    }
140  }
141
142  return index
143}
144
145/** What lies `row` blocks down (2 and below): stone, or netherrack, with the odd ore. */
146export function deepAt(seed: number, biome: Biome, i: number, row: number): TextureName {
147  const r = hash(seed, i, 40 + row)
148  if (biome === 'nether') {
149    return r < 0.06 ? 'tex_gold' : 'tex_netherrack'
150  }
151  if (r < 0.05) {
152    return 'tex_coal'
153  }
154  if (r < 0.08) {
155    return 'tex_iron'
156  }
157  if (r < 0.095) {
158    return 'tex_gold'
159  }
160
161  return r < 0.105 ? 'tex_ore' : 'tex_stone'
162}
163
164/** The block Clawd mines while thinking at column `i`: the biome's wood or ground, sometimes stone or ore. */
165export function wallAt(seed: number, i: number): TextureName {
166  const biome = biomeAt(seed, i)
167  const r = hash(seed, i, 61)
168  if (biome === 'nether') {
169    return r < 0.15 ? 'tex_gold' : 'tex_netherrack'
170  }
171  if (r < 0.12) {
172    return 'tex_stone'
173  }
174  if (r < 0.17) {
175    return 'tex_coal'
176  }
177  if (r < 0.2) {
178    return 'tex_iron'
179  }
180  if (r < 0.22) {
181    return 'tex_gold'
182  }
183  if (r < 0.235) {
184    return 'tex_ore'
185  }
186  switch (biome) {
187    case 'desert':
188      return r < 0.6 ? 'tex_sandstone' : 'tex_sand'
189    case 'taiga':
190      return r < 0.6 ? 'tex_log' : 'tex_snow'
191    case 'mushroom':
192      return r < 0.6 ? 'tex_stem' : 'tex_cap'
193    case 'forest':
194      return r < 0.45 ? 'tex_birch' : 'tex_log'
195    default:
196      return r < 0.7 ? 'tex_log' : 'tex_dirt'
197  }
198}
199
200/** The y of the ground's top at column `i`: the same everywhere, the world is flat. */
201export function groundFn(L: Layout): (i: number) => number {
202  return () => L.groundBase
203}
204
205// ---------- terrain ----------
206
207/** A run of one texture, in world pixels. */
208export type Tile = { x: number; y: number; w: number; h: number; tex: TextureName }
209/** A small sprite standing on the ground, in world pixels. */
210export type Decor = { x: number; y: number; sprite: SpriteName }
211
212export function buildTerrain(
213  seed: number,
214  L: Layout,
215  from: number,
216  to: number,
217  index: Map<number, WorldMark[]>,
218): { tiles: Tile[]; structures: Tile[]; decor: Decor[] } {
219  const ground = groundFn(L)
220  const tiles: Tile[] = []
221  const structures: Tile[] = []
222  const decor: Decor[] = []
223  // The last tile laid in each row, which the next column extends when it matches.
224  const open = new Map<number, Tile>()
225  const lay = (x: number, y: number, h: number, tex: TextureName) => {
226    const prev = open.get(y)
227    if (prev && prev.tex === tex && prev.h === h && prev.x + prev.w === x) {
228      prev.w += BLOCK
229    } else {
230      const t = { x, y, w: BLOCK, h, tex }
231      tiles.push(t)
232      open.set(y, t)
233    }
234  }
235
236  for (let c = from; c <= to; c++) {
237    const biome = biomeAt(seed, c)
238    const gy = ground(c)
239    const marks = index.get(c)
240    const isPond = marks?.some(m => m.kind === 'pond') ?? false
241    const isCrater = marks?.some(m => m.kind === 'crater') ?? false
242    const [surface, filler] = SURFACE[biome]
243    let top: TextureName = surface
244    if (isPond) {
245      top = 'tex_water'
246    } else if (isCrater) {
247      top = 'tex_crater'
248    } else if (biome === 'nether' && hash(seed, c, 31) < 0.08) {
249      top = 'tex_lava'
250    }
251    const x = c * BLOCK
252    // Surface, one block of filler, then stone (netherrack in the Nether) with ores.
253    for (let y = gy, row = 0; y < L.H; y += BLOCK, row++) {
254      const h = Math.min(BLOCK, L.H - y)
255      lay(x, y, h, row === 0 ? top : row === 1 ? filler : deepAt(seed, biome, c, row))
256    }
257
258    const isClear = !index.has(c - 1) && !marks && !index.has(c + 1) && top === surface
259    if (!isClear) {
260      continue
261    }
262    const r = hash(seed, c, 21)
263    const r2 = hash(seed, c, 22)
264    const put = (name: SpriteName) => {
265      const s = sprite(name)
266      decor.push({ x: x + Math.floor((BLOCK - s.w) / 2), y: gy - s.h, sprite: name })
267    }
268    switch (biome) {
269      case 'plains':
270        if (c % 5 === 0 && r < 0.35) {
271          tree(structures, x, gy, 'tex_log', 'tex_leaves', 3)
272        } else if (r2 < 0.3) {
273          put('tallgrass')
274        } else if (r2 < 0.38) {
275          put('flower_red')
276        } else if (r2 < 0.45) {
277          put('flower_yellow')
278        }
279        break
280      case 'forest':
281        if (c % 3 === 0 && r < 0.6) {
282          const isBirch = hash(seed, c, 23) < 0.35
283          tree(structures, x, gy, isBirch ? 'tex_birch' : 'tex_log', 'tex_leaves', isBirch ? 4 : 3)
284        } else if (r2 < 0.35) {
285          put('tallgrass')
286        }
287        break
288      case 'desert':
289        if (c % 4 === 0 && r < 0.35) {
290          const h = r2 < 0.5 ? 2 : 3
291          structures.push({ x, y: gy - h * BLOCK, w: BLOCK, h: h * BLOCK, tex: 'tex_cactus' })
292        } else if (r2 < 0.12) {
293          put('deadbush')
294        }
295        break
296      case 'taiga':
297        if (c % 4 === 0 && r < 0.55) {
298          spruce(structures, x, gy)
299        }
300        break
301      case 'mushroom':
302        if (c % 6 === 0 && r < 0.45) {
303          structures.push({ x, y: gy - 2 * BLOCK, w: BLOCK, h: 2 * BLOCK, tex: 'tex_stem' })
304          structures.push({ x: x - BLOCK, y: gy - 3 * BLOCK, w: 3 * BLOCK, h: BLOCK, tex: 'tex_cap' })
305        } else if (r2 < 0.18) {
306          put('mushroom_small')
307        }
308        break
309      case 'nether':
310        if (r2 < 0.15) {
311          put('fungus')
312        }
313        break
314    }
315  }
316
317  return { tiles, structures, decor }
318}
319
320function tree(out: Tile[], x: number, gy: number, log: TextureName, leaves: TextureName, trunk: number) {
321  out.push({ x, y: gy - trunk * BLOCK, w: BLOCK, h: trunk * BLOCK, tex: log })
322  out.push({ x: x - BLOCK, y: gy - (trunk + 1) * BLOCK, w: 3 * BLOCK, h: 2 * BLOCK, tex: leaves })
323  out.push({ x, y: gy - (trunk + 2) * BLOCK, w: BLOCK, h: BLOCK, tex: leaves })
324}
325
326function spruce(out: Tile[], x: number, gy: number) {
327  out.push({ x, y: gy - 2 * BLOCK, w: BLOCK, h: 2 * BLOCK, tex: 'tex_log' })
328  out.push({ x: x - BLOCK, y: gy - 3 * BLOCK, w: 3 * BLOCK, h: BLOCK, tex: 'tex_spruce' })
329  out.push({ x, y: gy - 5 * BLOCK, w: BLOCK, h: 2 * BLOCK, tex: 'tex_spruce' })
330}
331
332// ---------- time ----------
333
334export function posAt(w: WorldState, now: number): number {
335  return w.distanceAt + (w.speed * Math.max(0, now - w.movedAt)) / 1000
336}
337
338const frac = (t: number) => t - Math.floor(t)
339
340/** Time of day, 0..1: 0 sunrise, 0.25 noon, 0.5 sunset, 0.75 midnight. */
341export function dayTimeAt(w: WorldState, now: number): number {
342  if (w.isWorking) {
343    return frac(w.dayAt + Math.max(0, now - w.dayMovedAt) / DAY_MS)
344  }
345  const base = w.dayAt
346  const idle = now - w.idleSince
347  if (idle <= IDLE_NIGHT_MS || base >= 0.55) {
348    return base
349  }
350  const k = Math.min(1, (idle - IDLE_NIGHT_MS) / 8000)
351
352  return base + (0.75 - base) * k
353}
354
355/** How dark it is, 0 (day) to 1 (night). */
356export function nightness(t: number): number {
357  if (t < 0.05) {
358    return Math.max(0, 0.5 - t / 0.1)
359  }
360  if (t <= 0.45) {
361    return 0
362  }
363  if (t < 0.55) {
364    return (t - 0.45) / 0.1
365  }
366  if (t <= 0.95) {
367    return 1
368  }
369
370  return 1 - (t - 0.95) / 0.1
371}
372
373// ---------- transitions ----------
374
375function commit(w: WorldState, now: number): WorldState {
376  return {
377    ...w,
378    distanceAt: posAt(w, now),
379    movedAt: now,
380    dayAt: dayTimeAt(w, now),
381    dayMovedAt: now,
382  }
383}
384
385/**
386 * The camera's world x at time `t`: it follows Clawd at one steady pace,
387 * keeping him at the same spot on screen, and stands when he stands.
388 */
389export function cameraAt(w: WorldState, t: number, L: Layout): number {
390  return posAt(w, t) - L.lo
391}
392
393function addEffect(effects: readonly WorldEffect[], kind: EffectKind, x: number, at: number): WorldEffect[] {
394  return [...effects.filter(e => at - e.at < EFFECT_MS), { kind, at, x }].slice(-24)
395}
396
397export function setActivity(w: WorldState, kind: ActivityKind, now: number): WorldState {
398  let c = commit(w, now)
399  const pos = c.distanceAt
400  // Clawd walks while Claude writes; he stands to think (mining) and to work a tool.
401  const speed = kind === 'walk' ? WALK_SPEED : 0
402  let anchor = c.anchor
403  // A thought that ends breaks the block it was mining, and the way on is clear.
404  if (c.activity === 'think' && kind !== 'think') {
405    c = { ...c, effects: addEffect(c.effects, 'break', c.anchor * BLOCK + 4, now) }
406  }
407  if (kind === 'think') {
408    anchor = colOf(pos + 7) + 1
409  }
410  let marks = c.marks
411  const markKind = MARK_OF[kind]
412  if (markKind) {
413    anchor = kind === 'fish' ? colOf(pos + 10) : kind === 'test' ? colOf(pos) + 4 : colOf(pos) + 2
414    const near = [...marks].reverse().find(m => m.kind === markKind && Math.abs(m.i - anchor) <= 3)
415    if (markKind === 'plank') {
416      const stack = near ? marks.filter(m => m.kind === 'plank' && m.i === near.i).length : 0
417      const i = near && stack < 3 ? near.i : anchor
418      anchor = i
419      marks = [...marks, { kind: 'plank', i, at: now, h: near && stack < 3 ? stack : 0 }]
420    } else if (near) {
421      anchor = near.i
422    } else {
423      marks = [...marks, { kind: markKind, i: anchor, at: now }]
424    }
425  } else if (kind === 'mine' || kind === 'rocket') {
426    anchor = colOf(pos) + 2
427  }
428
429  return { ...c, activity: kind, activityAt: now, speed, anchor, marks }
430}
431
432/** Where a success effect plays, in world pixels. */
433function effectX(w: WorldState, kind: EffectKind): number {
434  switch (kind) {
435    case 'catch':
436      return w.distanceAt + 13
437    case 'firework':
438      return w.anchor * BLOCK + 2
439    default:
440      return w.anchor * BLOCK + 4
441  }
442}
443
444function resume(w: WorldState, now: number): WorldState {
445  const last = w.running[w.running.length - 1]
446  if (last) {
447    return setActivity(w, last.kind, now)
448  }
449
450  return setActivity(w, w.isWorking ? 'walk' : 'idle', now)
451}
452
453export function startTool(w: WorldState, id: string, cls: Classified, now: number): WorldState {
454  const n: WorldState = {
455    ...w,
456    isWorking: true,
457    running: [...w.running.filter(r => r.id !== id), { id, kind: cls.kind, at: now }],
458    tools: w.tools + 1,
459  }
460  if (cls.tag === 'agent') {
461    const pos = posAt(n, now)
462    const wolves = [...n.wolves, { id, at: now }]
463
464    const spawned = { ...n, wolves, effects: addEffect(n.effects, 'howl', pos - 18, now) }
465
466    // Clawd waits while the wolf runs its errand: nothing scrolls while it digs.
467    return isHurt(spawned, now) ? spawned : setActivity(spawned, 'idle', now)
468  }
469
470  return isHurt(n, now) ? n : setActivity(n, cls.kind, now)
471}
472
473/** While the creeper's blast plays out, new work waits for `recover`. */
474function isHurt(w: WorldState, now: number): boolean {
475  return w.activity === 'hurt' && now - w.activityAt < HURT_MS
476}
477
478export type Outcome = 'ok' | 'error' | 'denied'
479
480export function endTool(w: WorldState, id: string, cls: Classified, outcome: Outcome, now: number): WorldState {
481  const c = commit(w, now)
482  let n: WorldState = { ...c, running: c.running.filter(r => r.id !== id) }
483  if (cls.tag === 'agent') {
484    n = {
485      ...n,
486      wolves: n.wolves.map(f => (f.id === id && !f.leftAt ? { ...f, leftAt: now } : f)),
487      effects: addEffect(n.effects, 'bone', n.distanceAt - 16, now),
488    }
489  }
490  if (outcome === 'error') {
491    return explode(n, now)
492  }
493  if (outcome === 'ok') {
494    n = { ...n, streak: n.streak + 1 }
495    if (cls.success) {
496      n = { ...n, effects: addEffect(n.effects, cls.success, effectX(n, cls.success), now) }
497    }
498  }
499  if (isHurt(n, now)) {
500    return n
501  }
502
503  return resume(n, now)
504}
505
506/** A creeper walks up and goes off; Clawd stands still while it does. */
507export function explode(w: WorldState, now: number): WorldState {
508  const pos = posAt(w, now)
509  const x = pos + 14
510  const n: WorldState = {
511    ...w,
512    streak: 0,
513    explosions: w.explosions + 1,
514    effects: addEffect(w.effects, 'explode', x, now),
515    marks: [...w.marks, { kind: 'crater', i: colOf(x + 3), at: now + BOOM_MS }],
516  }
517
518  return setActivity(n, 'hurt', now)
519}
520
521export function recover(w: WorldState, now: number): WorldState | null {
522  if (w.activity !== 'hurt' || now - w.activityAt < HURT_MS) {
523    return null
524  }
525
526  return resume(w, now)
527}
528
529export function turnStart(w: WorldState, now: number): WorldState {
530  const c = commit(w, now)
531  let n: WorldState = { ...c, isWorking: true }
532  if (nightness(c.dayAt) > 0.5 || c.activity === 'sleep') {
533    n = { ...n, dayAt: 0.02, dayMovedAt: now, effects: addEffect(n.effects, 'sunrise', c.distanceAt, now) }
534  }
535
536  return setActivity(n, 'think', now)
537}
538
539export type TurnEnd = 'answer' | 'aborted' | 'error' | 'refusal'
540
541export function turnEnd(w: WorldState, reason: TurnEnd, durationMs: number, now: number): WorldState {
542  const c = commit(w, now)
543  const pos = c.distanceAt
544  let n: WorldState = {
545    ...c,
546    running: [],
547    isWorking: false,
548    idleSince: now,
549    wolves: c.wolves.map(f => (f.leftAt ? f : { ...f, leftAt: now })),
550  }
551  if (reason === 'answer') {
552    n = {
553      ...n,
554      marks: [...n.marks, { kind: 'torch', i: colOf(pos) + 1, at: now }],
555      effects: addEffect(n.effects, 'flag', (colOf(pos) + 1) * BLOCK + 1, now),
556    }
557    if (durationMs > 120_000) {
558      n = { ...n, anchor: colOf(pos) + 2, effects: addEffect(n.effects, 'firework', (colOf(pos) + 2) * BLOCK + 2, now) }
559    }
560  } else if (reason === 'aborted') {
561    n = { ...n, effects: addEffect(n.effects, 'pearl', pos, now) }
562  } else if (reason === 'error') {
563    return explode(n, now)
564  }
565
566  return setActivity(n, 'idle', now)
567}
568
569export function goSleep(w: WorldState, now: number): WorldState | null {
570  if (w.isWorking || w.activity === 'sleep' || w.activity === 'hurt' || now - w.idleSince < IDLE_NIGHT_MS) {
571    return null
572  }
573
574  return setActivity(w, 'sleep', now)
575}
576
577/** Commits a long walk, so the drawn walk (two minutes ahead) never runs out. */
578export function refreshWalk(w: WorldState, now: number): WorldState | null {
579  if (w.speed <= 0 || now - w.movedAt < 100_000) {
580    return null
581  }
582
583  return commit(w, now)
584}
585
586/** Drops what has played out; only while Clawd stands, so a redraw never interrupts a walk. */
587export function prune(w: WorldState, now: number): WorldState | null {
588  if (w.speed > 0) {
589    return null
590  }
591  const col = colOf(posAt(w, now))
592  const effects = w.effects.filter(e => now - e.at < EFFECT_MS)
593  const wolves = w.wolves.filter(f => !f.leftAt || now - f.leftAt < 2000)
594  const marks = w.marks.filter(m => m.i >= col - 100).slice(-80)
595  if (effects.length === w.effects.length && wolves.length === w.wolves.length && marks.length === w.marks.length) {
596    return null
597  }
598
599  return { ...w, effects, wolves, marks }
600}
601
hooks/sprites.ts 870 lines
1// Pixel art, one character per pixel, '.' transparent. Every sprite and
2// block texture of the world is here; both renderers decode the same data.
3
4const PALETTE: Record<string, number> = {
5  // Clawd
6  o: 0xd97757, O: 0xa9502f, p: 0xeda082, K: 0x1b1b1b, W: 0xffffff,
7  // creeper
8  g: 0x5bae3c, G: 0x3c7a26, h: 0x8ed16b,
9  // wolf
10  w: 0xe8e4de, x: 0xb9b0a4, n: 0x2b2b2b, r: 0xc0392b,
11  // grass and dirt
12  a: 0x5da130, A: 0x47852a, b: 0x7cc35b, d: 0x8b5e3c, D: 0x6b4529, e: 0xa47550,
13  // stone
14  s: 0x8c8c8c, S: 0x6a6a6a, t: 0xa8a8a8,
15  // sand
16  y: 0xe3d59a, Y: 0xcbbb7a, z: 0xf1e7b8,
17  // snow
18  q: 0xf6f9ff, Q: 0xd4e2f0,
19  // wood
20  l: 0x6b4f2e, L: 0x4b371f, k: 0xb38b55, m: 0x8a6a3f, '7': 0x5a3a1e,
21  // leaves
22  f: 0x3f8a2e, F: 0x2d6b20, i: 0x58a83e,
23  // birch
24  u: 0xe9e5d9, U: 0x3d3d3d,
25  // spruce
26  j: 0x2f5e3a, J: 0x234a2c,
27  // cactus
28  c: 0x4f8f2c, C: 0x2f6a1b,
29  // water and lava
30  v: 0x3f76e4, V: 0x2e5cc2, B: 0x7aa8f5, R: 0xe0561c, T: 0xf7a338,
31  // nether
32  N: 0x7a2e2e, M: 0x581d1d, X: 0x9c4242, H: 0xb02a3a,
33  // mycelium
34  P: 0x7d6a82, Z: 0xa08db0,
35  // ores, magic, fire, smoke
36  E: 0x5fe3e0, '6': 0x2a9d8f, I: 0xd8af93,
37  '1': 0xffd23f, '2': 0xff8c1a, '3': 0xd93a1e, '4': 0x8e44ad, '5': 0xd2a6ee,
38  '8': 0xc9c9c9, '9': 0x555555, '0': 0x24202a,
39}
40
41const RAW = {
42  // ---- block textures (8x8) ----
43  tex_grass: [
44    'abaaAaba',
45    'aAabaaAa',
46    'AadaAaad',
47    'ddAdddAd',
48    'dddDdded',
49    'deddddDd',
50    'dDddeddd',
51    'dddDdddD',
52  ],
53  tex_dirt: [
54    'ddeddDdd',
55    'dDdddded',
56    'dddDdddd',
57    'eddddDdd',
58    'ddDdeddd',
59    'dddddddD',
60    'dedDdded',
61    'Dddddedd',
62  ],
63  tex_stone: [
64    'sstsssSs',
65    'sSssssss',
66    'ssssStss',
67    'tssSssss',
68    'ssssssSs',
69    'sSstssss',
70    'sssssSst',
71    'stsSssss',
72  ],
73  tex_sand: [
74    'yyzyyYyy',
75    'yYyyyyzy',
76    'yyyyYyyy',
77    'zyyyyyyY',
78    'yyYyzyyy',
79    'yyyyyYyy',
80    'yzyYyyyz',
81    'Yyyyyyyy',
82  ],
83  tex_sandstone: [
84    'zzzzzzzz',
85    'yyyyyyyy',
86    'YyYYyYyY',
87    'yyyyyyyy',
88    'yyyyyyyy',
89    'yYyyyYyy',
90    'yyyyyyyy',
91    'YYYYYYYY',
92  ],
93  tex_snow: [
94    'qqqqqqqq',
95    'qQqqqqQq',
96    'dqdQqdqd',
97    'ddddqddd',
98    'dDdddded',
99    'deddddDd',
100    'dDddeddd',
101    'dddDdddD',
102  ],
103  tex_mycelium: [
104    'PZPPZPPZ',
105    'PPZPPPZP',
106    'dPdZPdPd',
107    'dddPdddd',
108    'dDdddded',
109    'deddddDd',
110    'dDddeddd',
111    'dddDdddD',
112  ],
113  tex_nylium: [
114    'HHXHHHXH',
115    'HHHHXHHH',
116    'NHNHNNHN',
117    'NNNMNNNX',
118    'NMNNNXNN',
119    'NNXNNNNM',
120    'MNNNMNNN',
121    'NNNXNNMN',
122  ],
123  tex_netherrack: [
124    'NNXNNMNN',
125    'NMNNNNNX',
126    'NNNMXNNN',
127    'XNNNNNMN',
128    'NNMNNXNN',
129    'NNNNMNNN',
130    'MNXNNNNM',
131    'NNNNNXNN',
132  ],
133  tex_water: [
134    'BvvBvvvv',
135    'vvvvvBvv',
136    'vVvvvvvV',
137    'vvvVvvvv',
138    'vvvvvvVv',
139    'Vvvvvvvv',
140    'vvVvvVvv',
141    'vvvvvvvV',
142  ],
143  tex_lava: [
144    'TRRTRRRR',
145    'RRRRTRRT',
146    'RTRRRRRR',
147    'RRRRRTRR',
148    'TRRRRRRT',
149    'RRTRRRRR',
150    'RRRRTRRR',
151    'RTRRRRTR',
152  ],
153  tex_log: [
154    'lLllLlll',
155    'lLlllLll',
156    'llLllLlL',
157    'lLllLlll',
158    'lLlllLll',
159    'LllLlLll',
160    'lLllLllL',
161    'llLllLll',
162  ],
163  tex_birch: [
164    'uuuuUuuu',
165    'uUuuuuuu',
166    'uuuuuuUU',
167    'uuuuuuuu',
168    'UUuuuuuu',
169    'uuuuuUuu',
170    'uuuuuuuu',
171    'uuUUuuuu',
172  ],
173  tex_leaves: [
174    'fFfifFff',
175    'iffFffiF',
176    'fFfffFff',
177    'ffiFfffi',
178    'Fffffiff',
179    'ffFiffFf',
180    'fiffFfff',
181    'fFfffifF',
182  ],
183  tex_spruce: [
184    'jJjjqjJj',
185    'jjJjjjjq',
186    'Jjjjjjjj',
187    'jjqjJjjJ',
188    'jJjjjjqj',
189    'jjjJjjjj',
190    'qjjjjJjj',
191    'jJjjjjjJ',
192  ],
193  tex_cactus: [
194    'CcCccCcC',
195    'CcccccCC',
196    'cccCcccc',
197    'CccccccC',
198    'CcCcccCC',
199    'ccccCccc',
200    'CccccccC',
201    'CcccCcCc',
202  ],
203  tex_plank: [
204    'kkkkkkkm',
205    'kmkkkkkk',
206    'mmmmmmmm',
207    'kkkmkkkk',
208    'kkkkkkkm',
209    'mmmmmmmm',
210    'kkkkkmkk',
211    'mkkkkkkk',
212  ],
213  tex_cap: [
214    '33W33333',
215    '3333W333',
216    'W3333333',
217    '333W33W3',
218    '33333333',
219    '3W33333W',
220    '3333W333',
221    '33333333',
222  ],
223  tex_stem: [
224    'uuuuuuuu',
225    'uuQuuuuu',
226    'uuuuuuQu',
227    'uuuuuuuu',
228    'uQuuuuuu',
229    'uuuuQuuu',
230    'uuuuuuuu',
231    'uuuQuuuu',
232  ],
233  tex_coal: [
234    'sstsssSs',
235    'sKKsssss',
236    'sKKsSKss',
237    'tssSsKKs',
238    'ssssssSs',
239    'sSKKssss',
240    'ssKKsSst',
241    'stsSssss',
242  ],
243  tex_iron: [
244    'sstsssSs',
245    'sIIsssss',
246    'sIesSIss',
247    'tssSsIIs',
248    'ssssssSs',
249    'sSIIssss',
250    'ssIesSst',
251    'stsSssss',
252  ],
253  tex_gold: [
254    'sstsssSs',
255    's11sssss',
256    's1zsS1ss',
257    'tssSs11s',
258    'ssssssSs',
259    'sS11ssss',
260    'ss1zsSst',
261    'stsSssss',
262  ],
263  tex_crater: [
264    '0D9dD90D',
265    'D9DdD0dD',
266    'dDdD9dDd',
267    'ddDdeddd',
268    'dDdddDdd',
269    'deddddDd',
270    'dDddeddd',
271    'dddDdddD',
272  ],
273  tex_ore: [
274    'sstsssSs',
275    'sEEsssss',
276    'sEWsSEss',
277    'tssSsEEs',
278    'ssssssSs',
279    'sSEEssss',
280    'ssEWsSst',
281    'stsSssss',
282  ],
283
284  // ---- Clawd (12x10) ----
285  clawd_idle: [
286    '..oooooooo..',
287    '..oooooooo..',
288    '..oKooooKo..',
289    '..oKooooKo..',
290    'oooooooooooo',
291    'oooooooooooo',
292    '..oooooooo..',
293    '..OOOOOOOO..',
294    '..o.o..o.o..',
295    '..o.o..o.o..',
296  ],
297  clawd_step: [
298    '..oooooooo..',
299    '..oooooooo..',
300    '..oKooooKo..',
301    '..oKooooKo..',
302    'oooooooooooo',
303    'oooooooooooo',
304    '..oooooooo..',
305    '..OOOOOOOO..',
306    '..o.o..o.o..',
307    '....o....o..',
308  ],
309  clawd_blink: [
310    '..oooooooo..',
311    '..oooooooo..',
312    '..oooooooo..',
313    '..oKooooKo..',
314    'oooooooooooo',
315    'oooooooooooo',
316    '..oooooooo..',
317    '..OOOOOOOO..',
318    '..o.o..o.o..',
319    '..o.o..o.o..',
320  ],
321  clawd_cheer: [
322    'o.oooooooo.o',
323    'oooooooooooo',
324    '..oKooooKo..',
325    '..oKooooKo..',
326    '..oooooooo..',
327    '..oooooooo..',
328    '..oooooooo..',
329    '..OOOOOOOO..',
330    '..o.o..o.o..',
331    '..o.o..o.o..',
332  ],
333  clawd_sleep: [
334    '............',
335    '............',
336    '............',
337    '..oooooooo..',
338    '..oKKooKKo..',
339    'oooooooooooo',
340    'oooooooooooo',
341    '..oooooooo..',
342    '..OOOOOOOO..',
343    '.OO......OO.',
344  ],
345
346  // ---- Clawd for the terminal (9x7): drawn at its own size, not halved ----
347  mini_clawd_idle: [
348    '.ooooooo.',
349    '.oKoooKo.',
350    '.oKoooKo.',
351    'ooooooooo',
352    '.ooooooo.',
353    '.OOOOOOO.',
354    '.o.o.o.o.',
355  ],
356  mini_clawd_step: [
357    '.ooooooo.',
358    '.oKoooKo.',
359    '.oKoooKo.',
360    'ooooooooo',
361    '.ooooooo.',
362    '.OOOOOOO.',
363    '..o.o.o.o',
364  ],
365  mini_clawd_blink: [
366    '.ooooooo.',
367    '.ooooooo.',
368    '.oKoooKo.',
369    'ooooooooo',
370    '.ooooooo.',
371    '.OOOOOOO.',
372    '.o.o.o.o.',
373  ],
374  mini_clawd_cheer: [
375    'o.ooooo.o',
376    'ooKoooKoo',
377    '.oKoooKo.',
378    '.ooooooo.',
379    '.ooooooo.',
380    '.OOOOOOO.',
381    '.o.o.o.o.',
382  ],
383  mini_clawd_sleep: [
384    '.........',
385    '.........',
386    '.ooooooo.',
387    '.oKKoKKo.',
388    'ooooooooo',
389    '.OOOOOOO.',
390    'OO.....OO',
391  ],
392
393  // ---- creeper (6x11) ----
394  creeper: [
395    'ghgggG',
396    'KKgGKK',
397    'KKghKK',
398    'gGKKgg',
399    'gKKKKg',
400    'gKghKg',
401    '.gGhg.',
402    '.gggG.',
403    '.Ghgg.',
404    'gg..gG',
405    'Gg..gg',
406  ],
407
408  // ---- wolf (10x7, facing right) ----
409  wolf_walk0: [
410    '......x.x.',
411    '......wwww',
412    'x.....wKwn',
413    '.xwwwwwrww',
414    '.wwwwwwr..',
415    '.w.w..w.w.',
416    '.w.w..w.w.',
417  ],
418  wolf_walk1: [
419    '......x.x.',
420    '......wwww',
421    'x.....wKwn',
422    '.xwwwwwrww',
423    '.wwwwwwr..',
424    '.w.w..w.w.',
425    '..w.w..w.w',
426  ],
427  wolf_sit: [
428    '......x.x.',
429    '......wwww',
430    '......wKwn',
431    '....wwwrww',
432    '..xwwwwr..',
433    '.xwwwwww..',
434    '..ww.w.w..',
435  ],
436
437  // ---- items ----
438  diamond: [
439    '..EEE..',
440    '.EWEEE.',
441    'EEEEEE6',
442    '.EEEE6.',
443    '..EE6..',
444    '...6...',
445  ],
446  fish: [
447    '..vvvv.v',
448    '.vKvBvvv',
449    '.vvvvvvv',
450    '..vvvv.v',
451  ],
452  bone: [
453    'W.....W',
454    'WWWWWWW',
455    'W.....W',
456  ],
457  arrow: [
458    '8.......',
459    '8kkkkkss',
460    '8.......',
461  ],
462  pick_up: [
463    '.sssss.',
464    's..m..s',
465    '...m...',
466    '...m...',
467    '...m...',
468    '...m...',
469    '...m...',
470  ],
471  pick_down: [
472    '..sss..',
473    '.....s.',
474    '....m.s',
475    '...m...',
476    '..m....',
477    '.m.....',
478    'm......',
479  ],
480  rod: [
481    '.......m',
482    '......m.',
483    '.....m..',
484    '....m...',
485    '...m....',
486    '..m.....',
487    '.m......',
488    'm.......',
489  ],
490  bobber: [
491    '33',
492    'WW',
493  ],
494  bow: [
495    '8m..',
496    '8.m.',
497    '8..m',
498    '8..m',
499    '8..m',
500    '8..m',
501    '8.m.',
502    '8m..',
503  ],
504  rocket: [
505    '.3.',
506    '3W3',
507    '333',
508    '3W3',
509    '333',
510    '.m.',
511    '.m.',
512    '.m.',
513  ],
514  book: [
515    '.WW.WW.',
516    '3WWkWW3',
517    '333k333',
518  ],
519  heart: [
520    '33.33',
521    '33333',
522    '.333.',
523    '..3..',
524  ],
525
526  // ---- props left in the world ----
527  crafting: [
528    '77kkkk77',
529    'kmmmmmmk',
530    '7Sk77kS7',
531    '7Sk77kS7',
532    '77k77k77',
533    '7kkkkkk7',
534    '7k7777k7',
535    '7kkkkkk7',
536  ],
537  furnace: [
538    'SsssssSs',
539    'sSssSsss',
540    's000000s',
541    's0SSSS0s',
542    's000000s',
543    'sssSssss',
544    'sSsssssS',
545    'SsssSsss',
546  ],
547  furnace_lit0: [
548    'SsssssSs',
549    'sSssSsss',
550    's000000s',
551    's012210s',
552    's022220s',
553    'sssSssss',
554    'sSsssssS',
555    'SsssSsss',
556  ],
557  furnace_lit1: [
558    'SsssssSs',
559    'sSssSsss',
560    's000000s',
561    's021120s',
562    's023320s',
563    'sssSssss',
564    'sSsssssS',
565    'SsssSsss',
566  ],
567  chest: [
568    'mmmmmmmm',
569    'mkkkkkkm',
570    'mkkkkkkm',
571    'mmmsSmmm',
572    'mkkSskkm',
573    'mkkkkkkm',
574    'mmmmmmmm',
575  ],
576  chest_open: [
577    'mmmmmmm.',
578    'mkkkkkm.',
579    '.mmmmmmm',
580    'm0E0010m',
581    'mkkSskkm',
582    'mkkkkkkm',
583    'mmmmmmmm',
584  ],
585  target: [
586    'WWWWWWWW',
587    'W333333W',
588    'W3WWWW3W',
589    'W3W33W3W',
590    'W3W33W3W',
591    'W3WWWW3W',
592    'W333333W',
593    'WWWWWWWW',
594  ],
595  lectern0: [
596    '...WWu..',
597    '..7777k.',
598    '.7777k..',
599    '...7k...',
600    '...7k...',
601    '...7k...',
602    '..777k..',
603    '.777777k',
604  ],
605  lectern1: [
606    '..uWW...',
607    '..7777k.',
608    '.7777k..',
609    '...7k...',
610    '...7k...',
611    '...7k...',
612    '..777k..',
613    '.777777k',
614  ],
615  enchant: [
616    'E333333E',
617    '03000030',
618    '00000000',
619    '00400400',
620    '00000000',
621    '04000040',
622  ],
623  sign_q: [
624    'mmmmmmmm',
625    'mkkKKkkm',
626    'mkkkkKkm',
627    'mkkkKkkm',
628    'mkkkkkkm',
629    'mkkkKkkm',
630    'mmmmmmmm',
631    '...mm...',
632    '...mm...',
633    '...mm...',
634  ],
635  sign_lines: [
636    'mmmmmmmm',
637    'mKKkKKkm',
638    'mkkkkkkm',
639    'mKkKKKkm',
640    'mkkkkkkm',
641    'mKKKkKkm',
642    'mmmmmmmm',
643    '...mm...',
644    '...mm...',
645    '...mm...',
646  ],
647  composter: [
648    'kmkmkmkm',
649    'kddDdddk',
650    'kdDdddDk',
651    'kmkmkmkm',
652    'k......k',
653    'kmkmkmkm',
654    'k......k',
655    'kmkmkmkm',
656  ],
657  campfire0: [
658    '...1....',
659    '..11.1..',
660    '..121...',
661    '.12221..',
662    '.23332..',
663    '.L2332l.',
664    'LLlllLLl',
665    'lLLlLLlL',
666  ],
667  campfire1: [
668    '....1...',
669    '..1.11..',
670    '...121..',
671    '..12221.',
672    '..23332.',
673    '.L2332l.',
674    'LLlllLLl',
675    'lLLlLLlL',
676  ],
677  torch0: [
678    '.1.',
679    '121',
680    '.m.',
681    '.m.',
682    '.m.',
683    '.m.',
684    '.m.',
685  ],
686  torch1: [
687    '.2.',
688    '1T1',
689    '.m.',
690    '.m.',
691    '.m.',
692    '.m.',
693    '.m.',
694  ],
695
696  // ---- sky ----
697  sun: [
698    '11111111',
699    '1zzzzzz1',
700    '1zWWWWz1',
701    '1zWWWWz1',
702    '1zWWWWz1',
703    '1zWWWWz1',
704    '1zzzzzz1',
705    '11111111',
706  ],
707  moon: [
708    'QQQQQQQQ',
709    'QqqqqQqQ',
710    'QqQqqqqQ',
711    'QqqqqqqQ',
712    'QqqqQqqQ',
713    'QQqqqqqQ',
714    'QqqqqqQQ',
715    'QQQQQQQQ',
716  ],
717
718  // ---- speech and particles ----
719  bubble1: [
720    '.WWWWWWWW.',
721    'WWWWWWWWWW',
722    'WW9WWWWWWW',
723    'WWWWWWWWWW',
724    '.WWWWWWWW.',
725    '..WW......',
726    '.W........',
727  ],
728  bubble2: [
729    '.WWWWWWWW.',
730    'WWWWWWWWWW',
731    'WW9WW9WWWW',
732    'WWWWWWWWWW',
733    '.WWWWWWWW.',
734    '..WW......',
735    '.W........',
736  ],
737  bubble3: [
738    '.WWWWWWWW.',
739    'WWWWWWWWWW',
740    'WW9WW9WW9W',
741    'WWWWWWWWWW',
742    '.WWWWWWWW.',
743    '..WW......',
744    '.W........',
745  ],
746  zzz: [
747    'WWWW',
748    '..W.',
749    '.W..',
750    'WWWW',
751  ],
752  glyph0: ['5.5', '.5.', '5.5'],
753  glyph1: ['55.', '5.5', '.55'],
754  glyph2: ['5..', '555', '..5'],
755  glyph3: ['.5.', '555', '.5.'],
756
757  // ---- ground decorations ----
758  flower_red: ['.3.', '313', '.3.', '.a.', '.a.'],
759  flower_yellow: ['.1.', '121', '.1.', '.a.', '.a.'],
760  tallgrass: ['..a..', 'a.a.b', '.aAa.', 'bAaAa', 'aAAAa'],
761  deadbush: ['m...m', '.m.m.', '..m..', '.mm..', '..m..'],
762  mushroom_small: ['333', '3W3', '.u.'],
763  fungus: ['.HHH.', 'HH1HH', '..T..', '..T..'],
764} as const satisfies Record<string, readonly string[]>
765
766type Tinted = 'clawd_hurt' | 'mini_clawd_hurt' | 'creeper_flash'
767/** A block's cracks as it is mined, stage 0 (a scratch) to 9 (about to break). */
768export type CrackName = `crack${0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}`
769export type SpriteName = keyof typeof RAW | Tinted | CrackName
770export type TextureName = Extract<SpriteName, `tex_${string}`>
771
772/** Decoded pixels: `px[y * w + x]` is 0xRRGGBB, or -1 where transparent. */
773export type Pixels = { w: number; h: number; px: Int32Array }
774
775/** Sprites made from others by blending every pixel toward one color. */
776const TINTED: Record<Tinted, [keyof typeof RAW, number, number]> = {
777  clawd_hurt: ['clawd_idle', 0xff2a2a, 0.55],
778  mini_clawd_hurt: ['mini_clawd_idle', 0xff2a2a, 0.55],
779  creeper_flash: ['creeper', 0xffffff, 0.8],
780}
781
782/** The order cracks spread across an 8x8 block, from its middle outward. */
783const CRACK_PATH: readonly [number, number][] = [
784  [3, 3], [4, 4], [4, 3], [2, 2], [5, 5], [1, 2], [5, 2], [6, 1], [2, 5], [1, 6],
785  [6, 6], [0, 1], [7, 0], [3, 5], [2, 6], [0, 7], [6, 4], [7, 4], [5, 6], [6, 7],
786  [1, 3], [0, 4], [4, 1], [4, 0], [3, 6], [7, 6], [2, 0], [1, 0],
787]
788
789function crack(stage: number): Pixels {
790  const px = new Int32Array(64).fill(-1)
791  const shown = Math.round((CRACK_PATH.length * 0.7 * (stage + 1)) / 10)
792  for (const [x, y] of CRACK_PATH.slice(0, shown)) {
793    px[y * 8 + x] = 0x1a1618
794  }
795
796  return { w: 8, h: 8, px }
797}
798
799/** The mini sprite drawn for a full-size one on the terminal, if there is one. */
800export function miniOf(name: SpriteName): SpriteName | undefined {
801  const mini = `mini_${name}`
802
803  return mini in RAW || mini === 'mini_clawd_hurt' ? (mini as SpriteName) : undefined
804}
805
806const decoded = new Map<SpriteName, Pixels>()
807
808export function blend(a: number, b: number, k: number): number {
809  const r = ((a >> 16) & 255) + ((((b >> 16) & 255) - ((a >> 16) & 255)) * k)
810  const g = ((a >> 8) & 255) + ((((b >> 8) & 255) - ((a >> 8) & 255)) * k)
811  const bl = (a & 255) + (((b & 255) - (a & 255)) * k)
812
813  return (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(bl)
814}
815
816function decodeRaw(rows: readonly string[]): Pixels {
817  const h = rows.length
818  const w = rows[0]?.length ?? 0
819  const px = new Int32Array(w * h)
820  for (let y = 0; y < h; y++) {
821    const row = rows[y] ?? ''
822    for (let x = 0; x < w; x++) {
823      const ch = row[x] ?? '.'
824      px[y * w + x] = ch === '.' ? -1 : (PALETTE[ch] ?? 0xff00ff)
825    }
826  }
827
828  return { w, h, px }
829}
830
831export function sprite(name: SpriteName): Pixels {
832  const hit = decoded.get(name)
833  if (hit) {
834    return hit
835  }
836  let out: Pixels
837  if (name.startsWith('crack')) {
838    out = crack(Number(name.slice(5)))
839  } else if (name === 'clawd_hurt' || name === 'mini_clawd_hurt' || name === 'creeper_flash') {
840    const [base, color, k] = TINTED[name]
841    const src = sprite(base)
842    const px = new Int32Array(src.px.length)
843    for (let n = 0; n < px.length; n++) {
844      const c = src.px[n] ?? -1
845      px[n] = c < 0 ? -1 : blend(c, color, k)
846    }
847    out = { w: src.w, h: src.h, px }
848  } else {
849    out = decodeRaw(RAW[name as keyof typeof RAW])
850  }
851  decoded.set(name, out)
852
853  return out
854}
855
856/** Every raw sprite's name, for tests and checks. */
857export const RAW_NAMES = Object.keys(RAW) as (keyof typeof RAW)[]
858
859export function rawRows(name: keyof typeof RAW): readonly string[] {
860  return RAW[name]
861}
862
863export function hasColor(ch: string): boolean {
864  return ch === '.' || ch in PALETTE
865}
866
867export function hex(color: number): string {
868  return `#${color.toString(16).padStart(6, '0')}`
869}
870
types/index.d.ts 99 lines
1export type ViewMode = 'band' | 'pane' | 'hidden'
2
3export type ActivityKind =
4  | 'idle'
5  | 'walk'
6  | 'think'
7  | 'fish'
8  | 'mine'
9  | 'read'
10  | 'craft'
11  | 'build'
12  | 'smelt'
13  | 'test'
14  | 'chest'
15  | 'rocket'
16  | 'magic'
17  | 'plan'
18  | 'wait'
19  | 'compost'
20  | 'hurt'
21  | 'sleep'
22
23export type EffectKind =
24  | 'explode'
25  | 'catch'
26  | 'diamond'
27  | 'item'
28  | 'block'
29  | 'bullseye'
30  | 'firework'
31  | 'sparkle'
32  | 'pearl'
33  | 'flag'
34  | 'bone'
35  | 'howl'
36  | 'sunrise'
37  | 'break'
38
39export type MarkKind =
40  | 'pond'
41  | 'lectern'
42  | 'crafting'
43  | 'furnace'
44  | 'target'
45  | 'chest'
46  | 'enchant'
47  | 'sign'
48  | 'composter'
49  | 'campfire'
50  | 'torch'
51  | 'crater'
52  | 'plank'
53
54/** A one-off animation, `x` in world pixels. */
55export type WorldEffect = { kind: EffectKind; at: number; x: number }
56
57/** Something left in the world at block column `i`; `h` stacks planks. */
58export type WorldMark = { kind: MarkKind; i: number; at: number; h?: number }
59
60export type RunningTool = { id: string; kind: ActivityKind; at: number }
61
62export type Wolf = { id: string; at: number; leftAt?: number }
63
64/**
65 * Everything is stamped with times, so a drawing at any moment is
66 * `render(state, now)`: position is `distanceAt + speed * (now - movedAt)`.
67 */
68export type WorldState = {
69  seed: number
70  /** World pixels walked, at `movedAt`. */
71  distanceAt: number
72  movedAt: number
73  /** World pixels per second. */
74  speed: number
75  activity: ActivityKind
76  activityAt: number
77  /** Block column the current activity's prop stands on. */
78  anchor: number
79  running: RunningTool[]
80  effects: WorldEffect[]
81  marks: WorldMark[]
82  wolves: Wolf[]
83  wolfBusyAt: number
84  isWorking: boolean
85  idleSince: number
86  /** Time of day 0..1 (0 sunrise, 0.5 sunset), at `dayMovedAt`. */
87  dayAt: number
88  dayMovedAt: number
89  tools: number
90  streak: number
91  explosions: number
92}
93
94declare module 'claude-code' {
95  interface PluginState {
96    clawdcraft: { world: WorldState; view: ViewMode }
97  }
98}
99