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

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.
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.
| When Claude is… | Clawd… |
|---|---|
| Thinking | mines the block ahead: one thought, one block, cracking for as long as the thought lasts |
| Writing | walks 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 file | reads a book on a lectern |
| Editing a file | works at a crafting table |
| Writing a new file | places a block |
| Running a command | fires up a furnace |
| Running tests | shoots arrows at a target |
Running git commit / git push | stashes loot in a chest / launches a firework |
| Using a Skill | casts a spell at an enchanting table |
| Starting a subagent | gets a wolf companion |
| Waiting for permission | holds up a "?" sign |
| Hitting an error | gets visited by a creeper that blows up |
| Finishing a turn | puts up a torch |
| Sitting idle for a minute | sleeps 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.
| Command | What it does |
|---|---|
/mc | Switch between the band and a side pane |
/mc band / /mc pane / /mc hide | Show as a band / show in a pane / hide |
/mc stats | The journey so far and advancements |
/mc demo | Play every animation in turn |
/mc new | Start a new world from a new seed |
/mc commands you run, which the model reads on the next turn. /mc stats is about 300–400 tokens.▀) 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.claude plugin validate plugins/clawdcraft
claude plugin test plugins/clawdcraft
claude --plugin-dir plugins/clawdcraft
The code lives in plugins/clawdcraft/hooks/:
| File | What's in it |
|---|---|
register.tsx | The hooks and the /mc command |
world.ts | The world and its state transitions |
scene.ts | What to draw at a given moment |
render-svg.ts | The desktop renderer |
render-raster.ts | The terminal renderer |
sprites.ts | The pixel art |
MIT
hooks/register.tsx 720 lines1import { 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}
720hooks/achievements.ts 77 lines1export 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}
77hooks/activity.ts 134 lines1import 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}
134hooks/render-raster.ts 241 lines1// 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}
241hooks/render-svg.ts 223 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>')
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}
223hooks/scene.ts 817 lines1import 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}
817hooks/world.ts 601 lines1import 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}
601hooks/sprites.ts 870 lines1// 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}
870types/index.d.ts 99 lines1export 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