Clawd, Claude Code's crab, goes on an adventure above your prompt: your session's work becomes his walks, battles and rest stops, and he levels up as you use…

Clawd, Claude Code's crab, goes on an adventure in the band above your prompt. Your session plays the game: reading is walking, an edit is a battle, a failing test is the enemy's turn, a passing run is the finisher, subagents join his party, and a finished turn is a victory with loot named after the file you worked on. Between turns he walks off to rest: the pier, a campfire, the inn. He levels up the more you use Claude Code, and new zones open as he does.
<img src="docs/readme/battle.svg" alt="An edit brings a goblin down from the trees and Clawd strikes it with CLAW STRIKE; a failing test is the enemy's turn (COUNTER ✗2) and he's knocked back; a passing run is the ALL-OUT ATTACK that finishes it. The HUD sits bottom-right: level, EXP, HP for context left and MP for usage left.">
Unofficial fan mod. Clawd belongs to Anthropic. Sibling of clawd-bar.
claude plugin marketplace add KaiC5504/clawd-rpg
claude plugin install clawd-rpg@clawd-rpg
Then start a new Claude Code session, or run /reload-plugins in the one you have open. If you also use clawd-bar, hide its band with /clawd so the two don't stack.
Built and tested on Claude Code 2.1.294, in Windows Terminal at about 180 columns. /rpg doctor checks the setup.
Update: claude plugin marketplace update clawd-rpg, then claude plugin update clawd-rpg@clawd-rpg, then restart. Uninstall: claude plugin uninstall clawd-rpg@clawd-rpg. What changed is in the changelog.
| Command | What it does |
|---|---|
/rpg | Hide the band, or bring it back (remembered across sessions) |
/rpg stats | His level and EXP, the zone and how close its boss is, what's open, and every foe he's met |
/rpg demo | Plays a scripted day on the road above the prompt: every beat, zone and rest spot. Run it again to stop. It never touches your progress |
/rpg doctor | Is the band up and painting, is the save healthy, is clawd-bar's band on too |
| Claude Code | On the road |
|---|---|
| Thinking, reading, searching | He walks through the zone; many calls in a few seconds and he hurries |
| An edit, a new file, a command, an MCP tool | A foe drops in and each call is a skill: CLAW STRIKE, SCRIBE SLASH, SHELL SHOCK, LINK BEAM. Two to four hits win the battle |
| A failing test or a tool error | The enemy's turn: COUNTER ✗N, he's knocked back, and an elite stands for the failure |
| A passing test run | ALL-OUT ATTACK finishes the foe |
| A subagent starts | A mini Clawd joins the party: Explore is a scout, Plan a mage, general-purpose a knight. Its edits are its own attacks, and when it's done its class attack lands |
| The turn ends | Victory: a chest, loot named after the file you edited most, EXP |
| Waiting for you | He walks to the pier and fishes |
| A minute idle | On to the zone's rest spot: a campfire, the Dungeon's crystal room, Neon City's ramen stall |
Ten minutes idle, or /compact | The inn: asleep in bed, HP refilling |
| Claude needs you | He turns to you with a ! |
| You interrupt | He skids to a stop and the foe flees |
| Usage limit used up | MP is empty and he trudges |
| A task list | The quest line in the top-left: ☐ 2/5 fix login |
<img src="docs/readme/party.svg" alt="Subagents drop in behind Clawd as mini Clawds in his orange, a knight with a helmet and shield and a mage with a hat and staff, each named as it joins; when the mage's subagent finishes, its ARCANE BOLT flies at the foe.">
EXP comes from deeds, not tokens: a battle won +10, an elite (a failing test, then fixed) +25, a boss or a turn with a party +60, any finished turn +5. Each level needs 50 + 25 × level.
Every zone has a meter of 12 battles. When it's full, the next turn that fights is a boss fight. Beat the boss and a gate stands on the road; he walks through it into the next zone he has unlocked, and once he has seen them all the road comes back round to the forest.
| Zone | Opens | Foes | Boss | Rest spot |
|---|---|---|---|---|
| Enchanted Forest | Lv.1 | goblins, mushrooms | Treant | campfire |
| Dungeon | Lv.3 | slimes, skeletons | Merge Hydra | crystal room |
| Neon City | Lv.6 | drones, glitch bugs | Mech | ramen stall |
Progress is shared by every session and repo.
<img src="docs/readme/boss.svg" alt="The Treant fills the band with its health along the top and ★ BOSS · TREANT in the corner; it falls, the chest gives Treant Heartwood and LEVEL UP flashes over the HUD, then Clawd walks through a stone gate with a purple portal into the Dungeon.">
<img src="docs/readme/rest.svg" alt="Between turns: Clawd fishes at the end of a pier under the moon (waiting for you…), turns to you with a ! when Claude needs you, then after a minute sits on a log by the campfire with the tent on the right (idle · warming up).">
<img src="docs/readme/zones.svg" alt="The Dungeon's brick halls and torches with a slime; the crystal room where he sits by the floating save crystal; Neon City's towers and rain with a glitch bug; the ramen stall where he sits at the counter with a steaming bowl; and the inn, asleep under a red blanket by the fireplace (z Z · HP refilling).">
Five rows of half-block pixels across the whole terminal, redrawn about six times a second. The HUD sits bottom-right: Lv.7 ▰▰▰▱▱ HP █████ MP ████ ⚑ repo/branch. HP is the context left and MP the 5-hour usage left. As the terminal narrows the HUD drops the repo, then MP; below 100 columns the band shows only Clawd and the place he's in, and below 40 it hides.
The desktop app's Code tab and the phone app play short loops of the same scene.
Clawd is Anthropic's. This is an unofficial fan project, not made or endorsed by Anthropic. Every picture here is drawn by the plugin's own code (bun run tools/readme-art.ts).
hooks/register.tsx 460 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { encodeCells } from './rpg/cells'
5import { DEMO, DEMO_MS, demoScene } from './rpg/demo'
6import { DESKTOP_COLS, DESKTOP_FRAME_MS, desktopKey, desktopLoop, resumeAt } from './rpg/desktop'
7import { NO_STORY, beatOf, passGate, step, withLevelUp } from './rpg/director'
8import type { HookPayload, Story } from './rpg/director'
9import { HIDDEN_BELOW, frame } from './rpg/frame'
10import type { Scene } from './rpg/frame'
11import { ROWS } from './rpg/grid'
12import { FRESH, bossDue, gain, loadProgress } from './rpg/progress'
13import type { Award, Progress } from './rpg/progress'
14import { doctorText, statsText } from './rpg/report'
15import type { DoctorFacts } from './rpg/report'
16import { FRAME_MS, GATE_AFTER, GATE_AHEAD, gateAt, gateClear, goalOf, moveRoad, reanchor, zoneAt } from './rpg/road'
17import type { Motion } from './rpg/road'
18import { statsFrom } from './rpg/stats'
19import type { Usage } from './rpg/stats'
20import type { RpgStats } from '../types'
21
22const RASTER_KEY = 'rpg'
23// The width the band was last drawn at, for placing a gate before the band is up.
24const USUAL_WIDTH = 179
25const STATS_MS = 1000
26// The desktop's band scales to the window; this is about how wide a column draws there.
27const DESKTOP_COL_PX = 8
28const DESKTOP_MAX_PX = 1400
29
30const isHidden = atom({ plugin: 'clawd-rpg', key: 'isHidden' } as const, false)
31const stats = atom({ plugin: 'clawd-rpg', key: 'stats' } as const, statsFrom(null, '', FRESH))
32const story = atom({ plugin: 'clawd-rpg', key: 'story' } as const, NO_STORY)
33
34// Module state: a hot reload starts it over, while the atoms live on in $.state.
35let isReady = false
36let place = ''
37let progress: Progress = FRESH
38let road = { distance: 0, isWorking: false, frames: 0, width: 0 }
39let painting: { requestId: string; width: number; painted: string } | null = null
40let frameTimer: { cancel: () => void } | null = null
41// A frame still on its way to the terminal: the next tick skips rather than piling blits up.
42let isBlitting = false
43// When /rpg demo started, while it plays, and the band's frames since. The band plays the demo by
44// frames, as the live road walks: the host answers each frame a little late, and a scene timed by
45// the clock would step 1, 1, 2 and hitch.
46let demoStartedAt: number | null = null
47let demoFrames = 0
48// For /rpg doctor: the terminal band's last width and when its last frame went out.
49let lastWidth: number | null = null
50let lastBlitAt: number | null = null
51// The desktop's loop as built, and what it shows, so a redraw neither rebuilds nor restarts it.
52let desktop: { key: string; svg: string; ms: number; startedAt: number } | null = null
53let isOnDesktop = false
54// The key a redraw was last asked for, so a surface that stopped drawing isn't asked every second.
55let askedFor = ''
56
57function stopPainting(): void {
58 frameTimer?.cancel()
59 frameTimer = null
60 painting = null
61}
62
63// A story saved by an older build may lack fields this one reads.
64const storyNow = async ($: EngineInterface): Promise<Story> => ({ ...NO_STORY, ...(await read($, story)) })
65
66async function refreshStats($: EngineInterface): Promise<void> {
67 const usage = (await $.session.usage().then(u => u, () => null)) as Usage | null
68 const next = statsFrom(usage, place, progress)
69 if (JSON.stringify(next) !== JSON.stringify(await read($, stats))) await update($, stats, () => next)
70}
71
72async function refreshPlace($: EngineInterface): Promise<void> {
73 const branch = await $.process.run(['git', 'branch', '--show-current'], { timeoutMs: 5000 }).catch(() => null)
74 const folder = (await $.session.cwd().catch(() => '')).replace(/\\/g, '/').split('/').pop() ?? ''
75 const name = branch?.exitCode === 0 && branch.stdout.trim() ? `${folder}/${branch.stdout.trim()}` : folder
76 if (name !== place) {
77 place = name
78 await refreshStats($)
79 }
80}
81
82// Progress lives in the store, shared by every session: it is read fresh before each change so
83// two sessions earning EXP at once don't overwrite each other. null: a newer build saved it, so
84// this session plays on in memory and leaves the save alone.
85async function loadSaved($: EngineInterface): Promise<Progress | null> {
86 const { progress: loaded, backup, isNewer } = loadProgress(await $.store.get('progress'))
87 if (isNewer) return null
88 if (backup !== undefined) {
89 // The first backup is the one most likely to hold real progress.
90 if ((await $.store.get('progressBackup')) === undefined) await $.store.set('progressBackup', backup)
91 await $.store.set('progress', loaded)
92 }
93 return loaded
94}
95
96async function saveWith($: EngineInterface, change: (p: Progress) => Progress): Promise<void> {
97 const saved = await loadSaved($)
98 progress = change(saved ?? progress)
99 if (saved) await $.store.set('progress', progress)
100}
101
102async function earn($: EngineInterface, awards: Award[]): Promise<number> {
103 let levelsUp = 0
104 await saveWith($, p => {
105 for (const a of awards) {
106 const r = gain(p, a)
107 p = r.progress
108 levelsUp += r.levelsUp
109 }
110 return p
111 })
112 return levelsUp
113}
114
115async function ensureReady($: EngineInterface): Promise<void> {
116 if (isReady) return
117 isReady = true
118 if ((await $.store.get('isHidden')) === true) await update($, isHidden, () => true)
119 progress = (await loadSaved($)) ?? FRESH
120 road.distance = progress.roadPos
121 $.clock.every(STATS_MS, () => void refreshStats($).then(() => walkUnseen($)).then(() => refreshDesktop($)))
122 await refreshPlace($).catch(() => undefined)
123 await refreshStats($)
124}
125
126// Hooks for parallel tool calls run side by side: each event steps the story only once the one
127// before it has landed, or both would step from the same story and one would be lost.
128let queue: Promise<void> = Promise.resolve()
129
130function observe($: EngineInterface, event: string, payload: HookPayload): Promise<void> {
131 const run = queue.then(() => observeNow($, event, payload))
132 queue = run.catch(() => undefined)
133 return run
134}
135
136async function observeNow($: EngineInterface, event: string, payload: HookPayload): Promise<void> {
137 await ensureReady($)
138 // Another session may have levelled him up or cleared a zone since.
139 if (event === 'UserPromptSubmit') progress = (await loadSaved($)) ?? progress
140 const now = await $.clock.now()
141 const before = await storyNow($)
142 const roadAt = Math.floor(road.distance)
143 // Where he stands on the road is where a trip to the rest spots sets out from; the zone and a
144 // full zone meter come from his progress.
145 const { story: next, awards } = step(before, event, { ...payload, roadAt, zone: progress.zone, boss: bossDue(progress) }, now)
146 let told = next
147 if (awards.length > 0) {
148 const levelsUp = await earn($, awards)
149 if (levelsUp > 0) told = withLevelUp(told, progress.level, now)
150 await refreshStats($)
151 }
152 // A cleared zone: he walks out through a gate, never a cut.
153 if ((event === 'TurnEnded' || event === 'UserPromptSubmit') && told.zone !== progress.zone) {
154 const from = event === 'TurnEnded' && told.trip ? told.trip.origin + GATE_AFTER : roadAt + GATE_AHEAD
155 told = passGate(told, progress.zone, gateAt(told, now, road.width || USUAL_WIDTH, from))
156 } else if (event === 'TurnEnded') {
157 told = gateClear(told, now, road.width || USUAL_WIDTH)
158 }
159 if (told !== before) await update($, story, () => told)
160 if (event === 'TurnEnded' && Math.floor(road.distance) !== progress.roadPos) {
161 await saveWith($, p => ({ ...p, roadPos: Math.floor(road.distance) }))
162 }
163}
164
165// Read once per frame, so the road and the picture are of the same moment.
166type Live = { story: Story; stats: RpgStats }
167const liveNow = async ($: EngineInterface): Promise<Live> => {
168 const [st, s] = await Promise.all([storyNow($), read($, stats)])
169 return { story: st, stats: s }
170}
171
172// He walks while Claude works and nothing stands in his way, and between turns heads for a rest spot.
173// `advance`: this is a frame of the timer, so the road moves; a redraw only looks.
174// The motion's `distance` is where to draw him.
175function motionNow(live: Live, width: number, now: number, advance: boolean): Motion {
176 const frames = advance ? ++road.frames : road.frames
177 const distance = road.width ? reanchor(live.story, now, road.distance, road.width, width) : road.distance
178 const motion = moveRoad(live.story, now, distance, width, frames, road.isWorking, live.stats.mp <= 0)
179 if (!advance) return { ...motion, distance }
180 road.distance = motion.distance
181 road.width = width
182 return motion
183}
184
185function cellsNow(live: Live, width: number, now: number, motion: Motion): string {
186 if (demoStartedAt !== null) return encodeCells(frame(demoScene(demoFrames * FRAME_MS, width).scene))
187 return encodeCells(frame({ width, t: now, distance: motion.distance, isWalking: motion.isWalking, ...live, trudge: live.stats.mp <= 0, legMs: motion.legMs }))
188}
189
190async function paintFrame($: EngineInterface): Promise<void> {
191 const p = painting
192 if (!p || isBlitting) return
193 isBlitting = true
194 try {
195 const now = await $.clock.now()
196 if (demoStartedAt !== null) demoFrames++
197 const live = await liveNow($)
198 // The demo plays over the session: the road waits where he is.
199 const cells = cellsNow(live, p.width, now, motionNow(live, p.width, now, demoStartedAt === null))
200 if (cells === p.painted) return
201 p.painted = cells
202 const blitted = await $.ui.blit({ requestId: p.requestId, key: RASTER_KEY, cells })
203 lastBlitAt = now
204 // Off the screen for now (a dialog or the command menu over it): keep trying. When the band
205 // comes back Claude Code doesn't always draw it again, so waiting for a draw would freeze it.
206 if (blitted.deny !== undefined) p.painted = ''
207 } finally {
208 isBlitting = false
209 }
210}
211
212type BandEvent = Parameters<EngineInterface['ui']['resolve']>[0] & {
213 requestId: string
214 props: { hasSurvey: boolean; isWorking: boolean; bodyColumns: number }
215}
216
217// The desktop has no frame timer: it plays the scene as a loop, the live session's or the demo's.
218// Between turns it shows him at his rest spot rather than on the way there.
219async function desktopScenes($: EngineInterface, now: number): Promise<(i: number) => Scene> {
220 if (demoStartedAt !== null) {
221 const from = now - demoStartedAt
222 return i => demoScene(from + i * DESKTOP_FRAME_MS, DESKTOP_COLS).scene
223 }
224 const { story: st, stats: s } = await liveNow($)
225 return i => {
226 const t = now + i * DESKTOP_FRAME_MS
227 const goal = road.isWorking ? null : goalOf(st, t, DESKTOP_COLS)
228 const isWalking = road.isWorking && beatOf(st, t) === 'walk'
229 const distance = goal ? goal.from : Math.floor(road.distance) + (isWalking ? i : 0)
230 return { width: DESKTOP_COLS, t, distance, isWalking, stats: s, story: st, trudge: s.mp <= 0 }
231 }
232}
233
234const desktopKeyOf = (first: Scene) => `${desktopKey(first)}|${JSON.stringify(first.story)}|${JSON.stringify(first.stats)}`
235
236const ZONE_NAMES = { forest: 'the Enchanted Forest', dungeon: 'the Dungeon', neon: 'Neon City' } as const
237
238function altOf(s: Scene): string {
239 const zone = ZONE_NAMES[zoneAt(s.story, s.distance + 50)]
240 const beat = beatOf(s.story, s.t)
241 if (beat === 'walk') return `Clawd walks through ${zone}`
242 if (beat === 'idle') return `Clawd rests between turns, in ${zone}`
243 return `Clawd fights in ${zone}`
244}
245
246// The 1 s timer: a desktop loop whose scene moved on (a victory over, a rest spot reached, the demo's
247// next scene) is redrawn; nothing else would redraw it.
248async function refreshDesktop($: EngineInterface): Promise<void> {
249 if (!isOnDesktop || !desktop) return
250 const now = await $.clock.now()
251 const key = desktopKeyOf((await desktopScenes($, now))(0))
252 if (key === desktop.key || key === askedFor) return
253 askedFor = key
254 $.ui.invalidate('ui.render')
255}
256
257// With no terminal band painting, nothing else moves the road: a second's worth of frames at a time,
258// so the desktop's scenes (and where a trip or a gate starts) keep up with the session.
259// A second is 12.5 frames: 12 and 13 by turns.
260let unseenSeconds = 0
261async function walkUnseen($: EngineInterface): Promise<void> {
262 if (painting || !isOnDesktop) return
263 const now = await $.clock.now()
264 const frames = Math.floor(STATS_MS / FRAME_MS) + (unseenSeconds++ % 2)
265 const live = await liveNow($)
266 for (let i = 0; i < frames; i++) motionNow(live, DESKTOP_COLS, now, true)
267}
268
269async function drawBand($: EngineInterface, e: BandEvent, next: (e: BandEvent) => unknown) {
270 const width = Math.min(512, e.props.bodyColumns)
271 if (e.surface !== 'terminal') return drawRemote($, e, next)
272 lastWidth = width
273 if (e.props.hasSurvey || width < HIDDEN_BELOW || (await read($, isHidden))) {
274 stopPainting()
275 return next(e)
276 }
277 road.isWorking = e.props.isWorking
278 const now = await $.clock.now()
279 const live = await liveNow($)
280 const cells = cellsNow(live, width, now, motionNow(live, width, now, false))
281 if (!painting || painting.requestId !== e.requestId || painting.width !== width) {
282 stopPainting()
283 painting = { requestId: e.requestId, width, painted: cells }
284 frameTimer = $.clock.every(FRAME_MS, () => void paintFrame($))
285 } else {
286 painting.painted = cells
287 }
288 const { Raster } = $.ui.resolve(e as never) as never as { Raster: any }
289 return <Raster key={RASTER_KEY} columns={width} rows={ROWS} cells={cells} />
290}
291
292// The desktop app and the phone draw the band as a looping Svg; their redraws never touch the
293// terminal's band.
294async function drawRemote($: EngineInterface, e: BandEvent, next: (e: BandEvent) => unknown) {
295 if (e.props.hasSurvey || (await read($, isHidden))) {
296 isOnDesktop = false
297 return next(e)
298 }
299 if (!painting) road.isWorking = e.props.isWorking
300 const now = await $.clock.now()
301 const sceneAt = await desktopScenes($, now)
302 const first = sceneAt(0)
303 const key = desktopKeyOf(first)
304 if (!desktop || desktop.key !== key) desktop = { key, ...desktopLoop(sceneAt), startedAt: now }
305 isOnDesktop = true
306 const px = Math.min(DESKTOP_MAX_PX, Math.max(1, e.props.bodyColumns) * DESKTOP_COL_PX)
307 const { Svg } = $.ui.resolve(e as never) as never as { Svg: any }
308 const source = resumeAt(desktop.svg, (now - desktop.startedAt) % desktop.ms)
309 return <Svg key="rpg-svg" source={source} alt={altOf(first)} width={px} height={Math.round((px * 10) / DESKTOP_COLS)} isInteractive />
310}
311
312async function toggle($: EngineInterface): Promise<string> {
313 const hide = !(await read($, isHidden))
314 await update($, isHidden, () => hide)
315 await $.store.set('isHidden', hide)
316 return hide ? "Clawd's adventure is hidden. /rpg brings it back." : 'Clawd is back on the road.'
317}
318
319async function demo($: EngineInterface): Promise<string> {
320 if (demoStartedAt !== null) {
321 demoStartedAt = null
322 $.ui.invalidate('ui.render')
323 return 'Demo stopped: back to your session.'
324 }
325 if (await read($, isHidden)) return "Clawd's adventure is hidden: /rpg brings it back, then /rpg demo."
326 demoStartedAt = await $.clock.now()
327 demoFrames = 0
328 $.ui.invalidate('ui.render')
329 const narrow = lastWidth !== null && lastWidth < HIDDEN_BELOW ? ` Widen the terminal past ${HIDDEN_BELOW} columns to see it.` : ''
330 return `Playing a day on the road above the prompt: ${DEMO.length} scenes, about ${Math.round(DEMO_MS / 1000)} s, on a loop. /rpg demo again stops it.${narrow}`
331}
332
333// The save as the store has it now, or what this session plays on with when it can't be read.
334async function savedNow($: EngineInterface): Promise<Progress> {
335 return (await loadSaved($).catch(() => null)) ?? progress
336}
337
338async function doctorFacts($: EngineInterface): Promise<DoctorFacts> {
339 const now = await $.clock.now()
340 let save: DoctorFacts['save'] = 'fine'
341 try {
342 const loaded = loadProgress(await $.store.get('progress'))
343 if (loaded.isNewer) save = 'newer'
344 else if (loaded.backup !== undefined || (await $.store.get('progressBackup')) !== undefined) save = 'backup'
345 } catch {
346 save = 'broken'
347 }
348 const settings: Record<string, unknown> = await $.settings.read().catch(() => ({}))
349 const enabled = typeof settings.enabledPlugins === 'object' && settings.enabledPlugins !== null ? (settings.enabledPlugins as Record<string, unknown>) : {}
350 const p = await savedNow($)
351 return {
352 hidden: await read($, isHidden),
353 width: lastWidth,
354 painting: painting !== null,
355 lastBlitMs: lastBlitAt === null ? null : now - lastBlitAt,
356 save,
357 level: p.level,
358 zone: p.zone,
359 clawdBar: Object.entries(enabled).some(([name, on]) => name.startsWith('clawd-bar@') && on === true),
360 }
361}
362
363async function runCommand($: EngineInterface, args: string): Promise<string> {
364 await quietly(ensureReady($))
365 const word = args.trim().toLowerCase()
366 if (word === 'stats') return statsText(await savedNow($))
367 if (word === 'doctor') return doctorText(await doctorFacts($))
368 if (word === 'demo') return demo($)
369 return toggle($)
370}
371
372type Call = { tool?: string; tool_use_id?: string; agentId?: string; consent?: unknown }
373type CallResult = { deny?: string; isError?: boolean; text?: string; result?: unknown }
374
375// Every tool call, the main loop's and a subagent's (`agentId`), before it runs. A command's result
376// comes straight from the call too; PostToolUse brings the same, and the story settles it once.
377async function onCall($: EngineInterface, e: Call, next: (e: Call) => Promise<CallResult>): Promise<CallResult> {
378 const { tool, tool_use_id: toolUseId, consent: _consent, agentId, ...input } = e
379 const by = agentId === undefined ? {} : { agent_id: agentId }
380 await quietly(observe($, 'PreToolUse', { tool_name: tool, tool_input: input, tool_use_id: toolUseId, ...by }))
381 const ran = await next(e)
382 if ((tool === 'Bash' || tool === 'PowerShell') && ran.deny === undefined) {
383 const id = { tool_use_id: toolUseId ?? '' }
384 const payload = ran.isError ? { ...id, error: ran.text ?? '' } : { ...id, tool_response: ran.result }
385 await quietly(observe($, ran.isError ? 'PostToolUseFailure' : 'PostToolUse', payload))
386 }
387 return ran
388}
389
390const quietly = (work: Promise<void>) => work.catch(() => undefined)
391
392export const register: Register = on => {
393 on('session.start', async ($, e, next) => {
394 await $.command.register({
395 name: 'rpg',
396 description: "Show or hide Clawd's adventure above the prompt · /rpg stats: his level, zone and bestiary · /rpg demo plays every scene · /rpg doctor checks the setup",
397 argumentHint: '[stats|demo|doctor]',
398 })
399 await quietly(ensureReady($))
400 return next(e)
401 })
402 on('command.run', { command: 'rpg' }, async ($, e) => ({ text: await runCommand($, e.args ?? '') }))
403 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => drawBand($, e as never, next as never) as never)
404 on('tool.call', ($, e, next) => onCall($, e as never, next as never) as never)
405
406 // A render hook may not write state, so the story moves on session events. The turn ends here and
407 // not at classic.Stop, which a user's own Stop hook can block to keep Claude working.
408 on('turn.complete', async ($, e, next) => {
409 const result = await next(e)
410 if (e.agentId === undefined) await quietly(observe($, 'TurnEnded', { reason: e.reason }))
411 return result
412 })
413 on('classic.SessionStart', async ($, e, next) => {
414 await quietly(observe($, 'SessionStart', e as never))
415 return next(e)
416 })
417 // Claude switches branches mid-session, so the HUD's place is re-read at every prompt.
418 on('classic.UserPromptSubmit', async ($, e, next) => {
419 await quietly(observe($, 'UserPromptSubmit', e as never).then(() => refreshPlace($)))
420 return next(e)
421 })
422 on('classic.PostToolUse', async ($, e, next) => {
423 await quietly(observe($, 'PostToolUse', e as never))
424 return next(e)
425 })
426 on('classic.PostToolUseFailure', async ($, e, next) => {
427 await quietly(observe($, 'PostToolUseFailure', e as never))
428 return next(e)
429 })
430 // A compaction between turns sends him to the inn; the director ignores one mid-turn.
431 on('classic.PreCompact', async ($, e, next) => {
432 await quietly(observe($, 'Compact', e as never))
433 return next(e)
434 })
435 on('classic.TaskCreated', async ($, e, next) => {
436 await quietly(observe($, 'TaskCreated', e as never))
437 return next(e)
438 })
439 on('classic.TaskCompleted', async ($, e, next) => {
440 await quietly(observe($, 'TaskCompleted', e as never))
441 return next(e)
442 })
443 on('classic.SubagentStart', async ($, e, next) => {
444 await quietly(observe($, 'SubagentStart', e as never))
445 return next(e)
446 })
447 on('classic.SubagentStop', async ($, e, next) => {
448 await quietly(observe($, 'SubagentStop', e as never))
449 return next(e)
450 })
451 on('classic.Notification', async ($, e, next) => {
452 await quietly(observe($, 'Notification', e as never))
453 return next(e)
454 })
455 on('classic.Elicitation', async ($, e, next) => {
456 await quietly(observe($, 'Elicitation', e as never))
457 return next(e)
458 })
459}
460hooks/rpg/cells.ts 47 lines1import { EMPTY, ROWS } from './grid'
2import type { Grid } from './grid'
3
4export const DEFAULT_COLOR = 0x01000000
5const UPPER = 0x2580
6const LOWER = 0x2584
7const SPACE = 0x20
8
9// Raster words: [codePoint, fg, bg] per cell, row by row.
10export function toWords(g: Grid): Uint32Array {
11 const words = new Uint32Array(g.w * ROWS * 3)
12 let i = 0
13 const cell = (cp: number, fg: number, bg: number) => {
14 words[i++] = cp
15 words[i++] = fg
16 words[i++] = bg
17 }
18 for (let row = 0; row < ROWS; row++) {
19 for (let col = 0; col < g.w; col++) {
20 const top = g.px[2 * row * g.w + col]!
21 const bottom = g.px[(2 * row + 1) * g.w + col]!
22 const text = g.text.get(row * g.w + col)
23 if (text) cell(text.cp, text.fg, top !== EMPTY ? top : bottom !== EMPTY ? bottom : DEFAULT_COLOR)
24 else if (top === EMPTY && bottom === EMPTY) cell(SPACE, DEFAULT_COLOR, DEFAULT_COLOR)
25 else if (bottom === EMPTY) cell(UPPER, top, DEFAULT_COLOR)
26 else if (top === EMPTY) cell(LOWER, bottom, DEFAULT_COLOR)
27 else cell(UPPER, top, bottom)
28 }
29 }
30 return words
31}
32
33const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
34
35function base64(bytes: Uint8Array): string {
36 let out = ''
37 for (let i = 0; i < bytes.length; i += 3) {
38 const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
39 out += BASE64[(n >> 18) & 63]! + BASE64[(n >> 12) & 63]!
40 out += i + 1 < bytes.length ? BASE64[(n >> 6) & 63]! : '='
41 out += i + 2 < bytes.length ? BASE64[n & 63]! : '='
42 }
43 return out
44}
45
46export const encodeCells = (g: Grid) => base64(new Uint8Array(toWords(g).buffer))
47hooks/rpg/demo.ts 241 lines1import type { RpgStats, ZoneId } from '../../types'
2import { NO_STORY, VICTORY_MS, passGate, step, withLevelUp } from './director'
3import type { HookPayload, Story } from './director'
4import type { Scene } from './frame'
5import { DOZE_MS, GATE_AFTER, goalOf } from './road'
6import { LEG_STEP_MS } from './sprites/clawd'
7
8type Event = [string, HookPayload]
9
10// The demo's own clock, far from any real session's, so nothing it shows is mistaken for one.
11const T0 = 2_000_000_000
12const WALK_AT = 4000
13const STATS: RpgStats = { level: 7, exp: 0.62, hp: 0.82, mp: 0.64, place: 'clawd-rpg/main' }
14const LEVELLED: RpgStats = { ...STATS, level: 8, exp: 0.08 }
15
16// Events a second apart from `from`; `at` is when the last one happened.
17function tell(events: Event[], from: number, story: Story = NO_STORY): { story: Story; at: number } {
18 let at = from
19 for (const [event, payload] of events) story = step(story, event, payload, (at += 1000)).story
20 return { story, at }
21}
22
23const start = (zone: ZoneId): Event => ['SessionStart', { source: 'startup', zone, roadAt: 0 }]
24const prompt = (boss = false): Event => ['UserPromptSubmit', { boss }]
25const edit = (id: string, file = 'scenes.ts'): Event => ['PreToolUse', { tool_name: 'Edit', tool_input: { file_path: `src/${file}` }, tool_use_id: id }]
26const testRun = (id: string): Event => ['PreToolUse', { tool_name: 'Bash', tool_input: { command: 'npm test' }, tool_use_id: id }]
27const result = (id: string, stdout: string): Event => ['PostToolUse', { tool_use_id: id, tool_response: { stdout, stderr: '' } }]
28const joins = (id: string, type: string): Event => ['SubagentStart', { agent_id: id, agent_type: type }]
29const leaves = (id: string, type: string): Event => ['SubagentStop', { agent_id: id, agent_type: type }]
30const ended = (roadAt: number, reason = 'answer'): Event => ['TurnEnded', { reason, roadAt }]
31const task = (id: string, subject: string): Event => ['TaskCreated', { task_id: id, task_subject: subject }]
32const taskDone = (id: string, subject: string): Event => ['TaskCompleted', { task_id: id, task_subject: subject }]
33
34// `make` builds a scene's story once for a width; `into` is how far into the scene a frame is.
35export type DemoScene = { name: string; ms: number; make: (from: number, width: number) => (into: number) => Scene }
36
37const walking = (story: Story, t0: number, width: number, stats = STATS) => (into: number): Scene => ({
38 width,
39 t: t0 + into,
40 distance: WALK_AT + Math.floor(into / 160),
41 isWalking: true,
42 stats,
43 story,
44})
45
46const standing = (story: Story, t0: number, width: number, stats = STATS) => (into: number): Scene => ({ width, t: t0 + into, distance: WALK_AT, isWalking: false, stats, story })
47
48// At the spot his trip has reached by `t0` (the pier, the zone's own spot, the inn).
49function restingAt(story: Story, t0: number, width: number, stats = STATS) {
50 const distance = goalOf(story, t0, width)?.from ?? 0
51 return (into: number): Scene => ({ width, t: t0 + into, distance, isWalking: false, stats, story })
52}
53
54function fight(zone: ZoneId, from: number) {
55 return tell([start(zone), prompt(), edit('e1')], from)
56}
57
58function bossFight(from: number) {
59 return tell([start('forest'), prompt(true), edit('e1'), edit('e2')], from)
60}
61
62function wonBoss(from: number) {
63 const won = tell([start('forest'), prompt(true), edit('e1'), edit('e2'), ended(WALK_AT)], from)
64 return { story: withLevelUp(won.story, 8, won.at), at: won.at }
65}
66
67function afterTurn(zone: ZoneId, from: number) {
68 return tell([start(zone), prompt(), ended(0)], from)
69}
70
71export const DEMO: readonly DemoScene[] = [
72 {
73 name: 'Walking: Claude reads and thinks',
74 ms: 4000,
75 make: (from, width) => {
76 const { story, at } = tell([start('forest'), prompt(), task('1', 'read the spec'), task('2', 'fix the login form'), task('3', 'run the tests'), taskDone('1', 'read the spec')], from)
77 return walking(story, at, width)
78 },
79 },
80 {
81 name: 'An edit: CLAW STRIKE',
82 ms: 3200,
83 make: (from, width) => {
84 const { story, at } = fight('forest', from)
85 return standing(story, at, width)
86 },
87 },
88 {
89 name: 'A failing test: the enemy turn',
90 ms: 2400,
91 make: (from, width) => {
92 const { story, at } = tell([start('forest'), prompt(), edit('e1'), testRun('t1'), result('t1', 'Tests 2 failed | 4 passed (6)')], from)
93 return standing(story, at, width)
94 },
95 },
96 {
97 name: 'Tests pass: ALL-OUT ATTACK',
98 ms: 2800,
99 make: (from, width) => {
100 const events = [start('forest'), prompt(), edit('e1'), testRun('t1'), result('t1', 'Tests 2 failed (2)'), edit('e2'), testRun('t2'), result('t2', 'Tests 6 passed (6)')]
101 const { story, at } = tell(events, from)
102 return standing(story, at, width)
103 },
104 },
105 {
106 name: 'Subagents join the party',
107 ms: 3600,
108 make: (from, width) => {
109 const { story, at } = tell([start('forest'), prompt(), joins('a1', 'general-purpose'), joins('a2', 'Plan'), joins('a3', 'Explore')], from)
110 return walking(story, at - 2000, width)
111 },
112 },
113 {
114 name: "A subagent's work lands: ARCANE BOLT",
115 ms: 2400,
116 make: (from, width) => {
117 const { story, at } = tell([start('forest'), prompt(), joins('a1', 'general-purpose'), joins('a2', 'Plan'), edit('e1'), leaves('a2', 'Plan')], from)
118 return standing(story, at, width)
119 },
120 },
121 {
122 name: 'An interrupt: the foe flees',
123 ms: 1800,
124 make: (from, width) => {
125 const { story, at } = tell([start('forest'), prompt(), edit('e1'), ended(WALK_AT, 'aborted')], from)
126 return standing(story, at, width)
127 },
128 },
129 {
130 name: 'Waiting for you: the pier',
131 ms: 4000,
132 make: (from, width) => {
133 const { story, at } = afterTurn('forest', from)
134 return restingAt(story, at + VICTORY_MS + 1000, width)
135 },
136 },
137 {
138 name: 'Claude needs you',
139 ms: 2000,
140 make: (from, width) => {
141 const after = afterTurn('forest', from)
142 const { story, at } = tell([['Notification', {}]], after.at + VICTORY_MS, after.story)
143 return restingAt(story, at, width)
144 },
145 },
146 {
147 name: 'A minute idle: the campfire',
148 ms: 3600,
149 make: (from, width) => {
150 const { story, at } = afterTurn('forest', from)
151 return restingAt(story, at + DOZE_MS + 1000, width)
152 },
153 },
154 {
155 name: 'The zone meter is full: the Treant',
156 ms: 4000,
157 make: (from, width) => {
158 const { story, at } = bossFight(from)
159 return standing(story, at - 1000, width)
160 },
161 },
162 {
163 name: 'Victory, loot and a level',
164 ms: 4400,
165 make: (from, width) => {
166 const { story, at } = wonBoss(from)
167 return standing(story, at, width)
168 },
169 },
170 {
171 name: 'Zone clear: through the gate',
172 ms: 4000,
173 make: (from, width) => {
174 const { story, at } = wonBoss(from)
175 const crossed = passGate(story, 'dungeon', WALK_AT + GATE_AFTER)
176 return (into: number): Scene => ({ width, t: at + VICTORY_MS + into, distance: WALK_AT + Math.floor(into / 80), isWalking: true, stats: LEVELLED, story: crossed, legMs: LEG_STEP_MS / 2 })
177 },
178 },
179 {
180 name: 'The Dungeon',
181 ms: 3200,
182 make: (from, width) => {
183 const { story, at } = fight('dungeon', from)
184 return standing(story, at, width, LEVELLED)
185 },
186 },
187 {
188 name: "The Dungeon's crystal room",
189 ms: 3600,
190 make: (from, width) => {
191 const { story, at } = afterTurn('dungeon', from)
192 return restingAt(story, at + DOZE_MS + 1000, width, LEVELLED)
193 },
194 },
195 {
196 name: 'Neon City',
197 ms: 3200,
198 make: (from, width) => {
199 const { story, at } = fight('neon', from)
200 return standing(story, at, width, LEVELLED)
201 },
202 },
203 {
204 name: "Neon City's ramen stall",
205 ms: 3600,
206 make: (from, width) => {
207 const { story, at } = afterTurn('neon', from)
208 return restingAt(story, at + DOZE_MS + 1000, width, LEVELLED)
209 },
210 },
211 {
212 name: 'A compaction: the inn',
213 ms: 4000,
214 make: (from, width) => {
215 const { story, at } = tell([start('neon'), prompt(), ended(0), ['Compact', {}]], from)
216 return restingAt(story, at + VICTORY_MS, width, { ...LEVELLED, hp: 0.95 })
217 },
218 },
219]
220
221export const DEMO_MS = DEMO.reduce((n, d) => n + d.ms, 0)
222
223// Stories are immutable, so each scene's is built once per width rather than every frame.
224const made = new Map<string, (into: number) => Scene>()
225function sceneAt(i: number, width: number): (into: number) => Scene {
226 const key = `${i}:${width}`
227 let at = made.get(key)
228 if (!at) made.set(key, (at = DEMO[i]!.make(T0 + i * 10_000_000, width)))
229 return at
230}
231
232// The demo `ms` in (looping), at a width: the scene's name and what to draw.
233export function demoScene(ms: number, width: number): { name: string; scene: Scene } {
234 let into = ((ms % DEMO_MS) + DEMO_MS) % DEMO_MS
235 for (const [i, d] of DEMO.entries()) {
236 if (into < d.ms) return { name: d.name, scene: sceneAt(i, width)(into) }
237 into -= d.ms
238 }
239 return { name: DEMO[0]!.name, scene: sceneAt(0, width)(0) }
240}
241hooks/rpg/desktop.ts 32 lines1import { CALL_MS, beatOf } from './director'
2import { frame } from './frame'
3import type { Scene } from './frame'
4import { CLAWD_COL, restOf, zoneAt } from './road'
5import { SVG_MAX_CHARS, svgLoop } from './svg'
6
7// The desktop draws the band at a fixed width, scaled to fit, as short loops it plays on its own.
8export const DESKTOP_COLS = 120
9export const DESKTOP_FRAME_MS = 160
10const MOST_FRAMES = 12
11
12// As many frames as fit under the Svg cap; `sceneAt(i)` is the scene of frame i.
13export function desktopLoop(sceneAt: (i: number) => Scene): { svg: string; ms: number } {
14 const grids = Array.from({ length: MOST_FRAMES }, (_, i) => frame(sceneAt(i)))
15 for (let n = MOST_FRAMES; n > 1; n--) {
16 const svg = svgLoop(grids.slice(0, n), DESKTOP_FRAME_MS)
17 if (svg.length <= SVG_MAX_CHARS) return { svg, ms: n * DESKTOP_FRAME_MS }
18 }
19 return { svg: svgLoop(grids.slice(0, 1), DESKTOP_FRAME_MS), ms: DESKTOP_FRAME_MS }
20}
21
22// What a loop shows: while this holds, the loop already built still plays it.
23export function desktopKey(s: Scene): string {
24 const { story, t } = s
25 const rest = restOf(story, t, s.distance, s.width)
26 const called = story.calledAt !== null && t >= story.calledAt && t - story.calledAt < CALL_MS
27 return [beatOf(story, t), rest?.kind ?? '', rest?.phase ?? '', zoneAt(story, s.distance + CLAWD_COL), story.foe?.kind ?? '', (story.party ?? []).length, called ? '!' : '', s.isWalking ? 'walking' : ''].join('|')
28}
29
30// The surface starts an Svg over when it redraws; a rebuilt one picks up `ms` into its loop.
31export const resumeAt = (svg: string, ms: number) => svg.replace('<style>', `<style>*{animation-delay:-${Math.round(ms)}ms!important}`)
32hooks/rpg/director.ts 305 lines1import type { Foe, FoeKind, Member, PartyClass, QuestTask, Story, Trip, Work, WorkKind, ZoneId } from '../../types'
2import { base, classifyCall, settleCall, str } from '../plumbing/work'
3import { noise } from './noise'
4import { EXP } from './progress'
5import type { Award } from './progress'
6
7export type { Foe, FoeKind, Member, PartyClass, QuestTask, Story, Trip, ZoneId }
8export type HookPayload = Record<string, unknown>
9export type Beat = 'walk' | 'idle' | 'encounter' | 'enemyTurn' | 'finisher' | 'victory' | 'flee'
10
11export const FOE_ENTER_MS = 800
12export const SKILL_MS = 1400
13export const DOWN_MS = 1000
14export const COUNTER_MS = 2000
15export const FINISHER_MS = 2400
16export const VICTORY_MS = 4400
17export const FLEE_MS = 1600
18export const CALL_MS = 6000
19export const LEVEL_UP_MS = 3000
20
21export const ROSTERS: Record<ZoneId, readonly FoeKind[]> = {
22 forest: ['goblin', 'shroom'],
23 dungeon: ['slime', 'skeleton'],
24 neon: ['drone', 'bug'],
25}
26export const BOSSES: Record<ZoneId, FoeKind> = { forest: 'treant', dungeon: 'hydra', neon: 'mech' }
27export const BOSS_HP = 6
28const ZONES = Object.keys(ROSTERS) as ZoneId[]
29
30export const SKILLS: Partial<Record<WorkKind, string>> = {
31 edit: 'CLAW STRIKE',
32 write: 'SCRIBE SLASH',
33 shell: 'SHELL SHOCK',
34 install: 'SHELL SHOCK',
35 mcp: 'LINK BEAM',
36}
37export const CLASS_SKILLS: Record<PartyClass, string> = { knight: 'SHIELD BASH', mage: 'ARCANE BOLT', scout: 'QUICK SHOT' }
38
39const CLASSES: Readonly<Record<string, PartyClass>> = { Explore: 'scout', Plan: 'mage', 'general-purpose': 'knight' }
40const ANY_CLASS = ['knight', 'mage', 'scout'] as const
41
42// Any other agent type gets a class of its own, the same one every time.
43export function classOf(agentType: string): PartyClass {
44 const known = CLASSES[agentType]
45 if (known) return known
46 return ANY_CLASS[[...agentType].reduce((h, ch) => (h * 31 + ch.codePointAt(0)!) % 1_000_003, 7) % ANY_CLASS.length]!
47}
48
49const TROPHY: Record<FoeKind, string> = {
50 goblin: 'Goblin Fang',
51 shroom: 'Glow Cap',
52 slime: 'Slime Jelly',
53 skeleton: 'Old Bone',
54 drone: 'Drone Rotor',
55 bug: 'Glitch Shard',
56 treant: 'Treant Heartwood',
57 hydra: 'Hydra Scale',
58 mech: 'Mech Core',
59}
60const FOUND: Record<ZoneId, string> = { forest: 'Forest Herb', dungeon: 'Torch Stub', neon: 'Neon Token' }
61const LOOT_WORDS = ['Blade', 'Tome', 'Charm', 'Sigil'] as const
62
63export const NO_STORY: Story = {
64 turn: { active: false, at: 0, files: {}, gained: 0, foes: 0, recent: [], boss: false, bossDown: null },
65 foe: null,
66 skill: null,
67 down: null,
68 counter: null,
69 victory: null,
70 fled: null,
71 levelUp: null,
72 calledAt: null,
73 calls: {},
74 trip: null,
75 trail: null,
76 tasks: [],
77 zone: 'forest',
78 gate: null,
79 party: [],
80}
81
82const zoneOf = (value: unknown): ZoneId => (ZONES.includes(value as ZoneId) ? (value as ZoneId) : 'forest')
83
84function spawn(s: Story, now: number): Foe {
85 const zone = s.zone ?? 'forest'
86 if (s.turn.boss && !s.turn.bossDown) return { kind: BOSSES[zone], hp: BOSS_HP, maxHp: BOSS_HP, elite: false, at: now, hitAt: 0, boss: true }
87 const roster = ROSTERS[zone]
88 const seed = s.turn.at / 997 + s.turn.foes * 7.3
89 const kind = roster[Math.floor(noise(seed) * roster.length)]!
90 const maxHp = 2 + Math.floor(noise(seed + 1) * 3)
91 return { kind, hp: maxHp, maxHp, elite: false, at: now, hitAt: 0 }
92}
93
94// The file edited most this turn names the loot; a turn without edits keeps a trophy from the fight.
95export function lootName(files: Record<string, number>, lastFoe: FoeKind | null, zone: ZoneId = 'forest'): string {
96 const top = Object.entries(files).sort((a, b) => b[1] - a[1])[0]?.[0]
97 if (!top) return lastFoe ? TROPHY[lastFoe] : FOUND[zone]
98 const word = LOOT_WORDS[[...top].reduce((n, ch) => n + ch.codePointAt(0)!, 0) % LOOT_WORDS.length]!
99 return `${word} of ${top}`
100}
101
102function defeat(s: Story, foe: Foe, now: number, finisher: boolean, awards: Award[]): Story {
103 const down = { kind: foe.kind, at: now, finisher }
104 if (foe.boss) {
105 awards.push({ kind: 'raid', foe: foe.kind, boss: true, zone: s.zone ?? 'forest' })
106 return { ...s, foe: null, down, turn: { ...s.turn, gained: s.turn.gained + EXP.raid, bossDown: foe.kind } }
107 }
108 const kind = foe.elite ? 'elite' : 'battle'
109 awards.push({ kind, foe: foe.kind })
110 return { ...s, foe: null, down, turn: { ...s.turn, gained: s.turn.gained + EXP[kind] } }
111}
112
113// A blow on the foe in the way, or on a fresh one if nothing stands there yet.
114function hit(s: Story, skill: NonNullable<Story['skill']>, now: number, awards: Award[]): Story {
115 const fresh = s.foe === null
116 const foe = s.foe ?? spawn(s, now)
117 const next: Story = { ...s, skill, turn: { ...s.turn, foes: s.turn.foes + (fresh ? 1 : 0) } }
118 // An elite stands for a failing test: only a passing run finishes it.
119 const hp = foe.elite ? Math.max(1, foe.hp - 1) : foe.hp - 1
120 if (hp <= 0) return defeat(next, foe, now, false, awards)
121 return { ...next, foe: { ...foe, hp, hitAt: now } }
122}
123
124// `by`: a party member made the call, so the blow is its class skill.
125function attack(s: Story, work: Work, path: string, now: number, awards: Award[], by: Member | null): Story {
126 const name = SKILLS[work.kind]
127 if (!name) return s
128 const file = base(path)
129 const files = path && (work.kind === 'edit' || work.kind === 'write') ? { ...s.turn.files, [file]: (s.turn.files[file] ?? 0) + 1 } : s.turn.files
130 const skill = by ? { name: CLASS_SKILLS[by.cls], at: now, party: true as const, by: by.id } : { name, at: now }
131 return hit({ ...s, turn: { ...s.turn, files } }, skill, now, awards)
132}
133
134function settle(s: Story, payload: HookPayload, failed: boolean, now: number, awards: Award[]): Story {
135 const id = str(payload.tool_use_id)
136 const open = s.calls[id]
137 if (!open) return s
138 const { [id]: _, ...calls } = s.calls
139 const work = settleCall(open, payload, failed, now)
140 const next = { ...s, calls }
141 if (work.kind !== 'tests') return failed ? { ...next, counter: { n: 1, at: now } } : next
142 const result = work.result!
143 if (!result.ok || (result.failed ?? 0) > 0) {
144 const foe = next.foe ?? spawn(next, now)
145 return {
146 ...next,
147 counter: { n: Math.max(1, result.failed ?? 0), at: now },
148 foe: { ...foe, elite: true, hp: Math.max(foe.hp, 2) },
149 turn: { ...next.turn, foes: next.turn.foes + (next.foe ? 0 : 1) },
150 }
151 }
152 return next.foe ? defeat(next, next.foe, now, true, awards) : next
153}
154
155function endTurn(s: Story, reason: string, now: number, awards: Award[]): Story {
156 if (!s.turn.active) return s
157 const ended: Story = { ...s, calls: {}, turn: { ...s.turn, active: false } }
158 if (reason !== 'answer') return { ...ended, foe: null, fled: s.foe ? { kind: s.foe.kind, at: now } : null }
159 let won = ended
160 if (won.foe && !won.foe.elite) won = defeat(won, won.foe, now, false, awards)
161 else if (won.foe) won = { ...won, foe: null, fled: { kind: won.foe.kind, at: now } }
162 // A turn the party fought in is a raid.
163 if ((s.party ?? []).length > 0) {
164 awards.push({ kind: 'raid' })
165 won = { ...won, turn: { ...won.turn, gained: won.turn.gained + EXP.raid } }
166 }
167 awards.push({ kind: 'turn' })
168 const gained = won.turn.gained + EXP.turn
169 const loot = won.turn.bossDown ? TROPHY[won.turn.bossDown] : lootName(won.turn.files, won.down?.kind ?? null, s.zone ?? 'forest')
170 return { ...won, turn: { ...won.turn, gained }, victory: { at: now, loot, gained } }
171}
172
173const num = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
174const restFrom = (roadAt: unknown, now: number, zone: ZoneId): Trip => ({ origin: num(roadAt), at: now, compactAt: null, leftAt: null, zone })
175
176const taskStatus = (value: unknown): QuestTask['status'] => (value === 'completed' || value === 'in_progress' ? value : 'pending')
177
178// TodoWrite hands over the whole list; TaskUpdate moves one task along, or deletes it.
179function plan(tasks: QuestTask[], tool: string, input: HookPayload): QuestTask[] {
180 if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
181 return (input.todos as HookPayload[]).map((todo, i) => ({ id: `todo-${i}`, subject: str(todo.content), status: taskStatus(todo.status) }))
182 }
183 if (tool !== 'TaskUpdate') return tasks
184 const id = str(input.taskId)
185 if (input.status === 'deleted') return tasks.filter(task => task.id !== id)
186 return tasks.map(task => (task.id !== id ? task : { ...task, subject: str(input.subject) || task.subject, status: input.status === undefined ? task.status : taskStatus(input.status) }))
187}
188
189function created(tasks: QuestTask[], id: string, subject: string): QuestTask[] {
190 return !id || tasks.some(task => task.id === id) ? tasks : [...tasks, { id, subject, status: 'pending' }]
191}
192
193function completed(tasks: QuestTask[], id: string, subject: string): QuestTask[] {
194 if (!tasks.some(task => task.id === id)) return [...tasks, { id, subject, status: 'completed' }]
195 return tasks.map(task => (task.id === id ? { ...task, status: 'completed' } : task))
196}
197
198function joined(s: Story, id: string, agentType: string, now: number): Story {
199 const party = s.party ?? []
200 return party.some(m => m.id === id) ? s : { ...s, party: [...party, { id, cls: classOf(agentType), at: now, doneAt: null }] }
201}
202
203// Session events in, the story out, plus the deeds that earn EXP.
204export function step(s: Story, event: string, payload: HookPayload, now: number): { story: Story; awards: Award[] } {
205 const awards: Award[] = []
206 const story = ((): Story => {
207 switch (event) {
208 // A compaction happens mid-turn: the fight goes on through it.
209 case 'SessionStart': {
210 if (payload.source === 'compact') return s
211 const zone = zoneOf(payload.zone)
212 return { ...NO_STORY, zone, trip: restFrom(payload.roadAt, now, zone) }
213 }
214 case 'UserPromptSubmit':
215 return {
216 ...NO_STORY,
217 levelUp: s.levelUp,
218 tasks: s.tasks,
219 trip: s.trip && { ...s.trip, leftAt: s.trip.leftAt ?? now },
220 trail: s.trail,
221 zone: s.zone ?? 'forest',
222 gate: s.gate ?? null,
223 turn: { active: true, at: now, files: {}, gained: 0, foes: 0, recent: [], boss: payload.boss === true, bossDown: null },
224 }
225 case 'PreToolUse': {
226 // Background work after the turn has ended has no turn to close its fight.
227 if (!s.turn.active) return s
228 const input = typeof payload.tool_input === 'object' && payload.tool_input !== null ? (payload.tool_input as HookPayload) : {}
229 const id = str(payload.tool_use_id)
230 const tool = str(payload.tool_name)
231 const work = classifyCall(tool, input, id, now)
232 const recent = [...(s.turn.recent ?? []), now].slice(-3)
233 const agent = str(payload.agent_id)
234 // A subagent's own todo list isn't the quest.
235 const opened: Story = { ...s, calls: { ...s.calls, [id]: work }, tasks: agent ? s.tasks : plan(s.tasks, tool, input), turn: { ...s.turn, recent } }
236 const path = str(input.file_path) || str(input.notebook_path)
237 if (!agent) return attack(opened, work, path, now, awards, null)
238 // One left running from an earlier turn never joined this one: its calls aren't this fight's.
239 const member = (s.party ?? []).find(m => m.id === agent)
240 return member ? attack(opened, work, path, now, awards, member) : opened
241 }
242 case 'PostToolUse':
243 return settle(s, payload, false, now, awards)
244 case 'PostToolUseFailure':
245 return settle(s, payload, true, now, awards)
246 case 'TurnEnded': {
247 const ended = endTurn(s, str(payload.reason), now, awards)
248 return ended === s ? s : { ...ended, trip: restFrom(payload.roadAt, now, s.zone ?? 'forest'), trail: s.trip }
249 }
250 // A compaction between turns sends him to the inn; one mid-turn leaves the fight be.
251 case 'Compact':
252 return !s.turn.active && s.trip && s.trip.leftAt === null && s.trip.compactAt === null ? { ...s, trip: { ...s.trip, compactAt: now } } : s
253 case 'TaskCreated':
254 return { ...s, tasks: created(s.tasks, str(payload.task_id), str(payload.task_subject)) }
255 case 'TaskCompleted':
256 return { ...s, tasks: completed(s.tasks, str(payload.task_id), str(payload.task_subject)) }
257 case 'SubagentStart': {
258 const id = str(payload.agent_id)
259 if (!s.turn.active || !id) return s
260 return joined(s, id, str(payload.agent_type), now)
261 }
262 // Its work done, the member's class attack lands.
263 case 'SubagentStop': {
264 const id = str(payload.agent_id)
265 const member = (s.party ?? []).find(m => m.id === id)
266 if (!s.turn.active || !member || member.doneAt !== null) return s
267 const party = s.party.map(m => (m.id === id ? { ...m, doneAt: now } : m))
268 return hit({ ...s, party }, { name: CLASS_SKILLS[member.cls], at: now, party: true, by: id }, now, awards)
269 }
270 case 'Notification':
271 case 'Elicitation':
272 return { ...s, calledAt: now }
273 default:
274 return s
275 }
276 })()
277 return { story, awards }
278}
279
280// The road past `x` is the zone `to`; a trip not yet left rests at that zone's spots.
281export function passGate(s: Story, to: ZoneId, x: number): Story {
282 return { ...s, zone: to, gate: { x, from: s.zone ?? 'forest' }, trip: s.trip && s.trip.leftAt === null ? { ...s.trip, zone: to } : s.trip }
283}
284
285// `☐ 2/5 fix login`: the task list as a quest, led by the task in progress, or the next one up.
286export function questOf(s: Story): string | null {
287 const tasks = s.tasks ?? []
288 const done = tasks.filter(task => task.status === 'completed').length
289 const next = tasks.find(task => task.status === 'in_progress') ?? tasks.find(task => task.status === 'pending')
290 return next ? `☐ ${done}/${tasks.length} ${next.subject}` : null
291}
292
293export const withLevelUp = (s: Story, level: number, now: number): Story => ({ ...s, levelUp: { level, at: now } })
294
295const within = (at: number | undefined, ms: number, now: number) => at !== undefined && now >= at && now - at < ms
296
297export function beatOf(s: Story, now: number): Beat {
298 if (within(s.fled?.at, FLEE_MS, now)) return 'flee'
299 if (within(s.victory?.at, VICTORY_MS, now)) return 'victory'
300 if (s.down?.finisher && within(s.down.at, FINISHER_MS, now)) return 'finisher'
301 if (within(s.counter?.at, COUNTER_MS, now)) return 'enemyTurn'
302 if (s.foe || within(s.down?.at, DOWN_MS, now)) return 'encounter'
303 return s.turn.active ? 'walk' : 'idle'
304}
305hooks/rpg/frame.ts 317 lines1import type { FoeKind, RestKind, RpgStats, ZoneId } from '../../types'
2import {
3 CALL_MS,
4 COUNTER_MS,
5 DOWN_MS,
6 FINISHER_MS,
7 FLEE_MS,
8 FOE_ENTER_MS,
9 LEVEL_UP_MS,
10 SKILL_MS,
11 VICTORY_MS,
12 beatOf,
13 questOf,
14} from './director'
15import type { Beat, Story } from './director'
16import { EMPTY, at, grid, line, rect, sprite, write } from './grid'
17import type { Grid } from './grid'
18import { drawHud, drawable, hudLeft } from './hud'
19import { easeOut, lerp, on, through } from './noise'
20import { CAMP } from './places/camp'
21import { CRYSTAL } from './places/crystal'
22import { DUNGEON } from './places/dungeon'
23import { FOREST } from './places/forest'
24import { GATE_W, gateCol } from './places/gate'
25import { INN } from './places/inn'
26import { NEON } from './places/neon'
27import { PIER } from './places/pier'
28import type { Place } from './places/place'
29import { RAMEN } from './places/ramen'
30import { CLAWD_COL, COMPACT_BELOW, PACK_MS, SIGN_BEFORE, camOf, clawdCol, placeWidth, restOf, spotsOnRoad, zoneAt } from './road'
31import type { Rest, Spot } from './road'
32import { LEG_STEP_MS, POSES, asleep, cheering, clawd, hurting, idling, sitting, trudging, walking } from './sprites/clawd'
33import type { Pose } from './sprites/clawd'
34import { drawFoe, drawHp, foeTop, foeWidth } from './sprites/foes'
35import { bang, banner, burst, chest, classAttack, cloud, dust, sparks, sword } from './sprites/fx'
36import { MINI_REACH, mini } from './sprites/mini'
37
38export { CLAWD_COL, COMPACT_BELOW, COMPACT_COL, clawdCol } from './road'
39export const HIDDEN_BELOW = 40
40// Clawd swings from this far left of the foe, so the sword's arc lands on it.
41const REACH = 22
42const PARTY_TEXT = 0x8fb8ff
43const DROP_MS = 400
44
45// Non-ASCII characters the road may write; each must be one cell wide (tests check them).
46export const ROAD_GLYPHS = ['→', '☐', '…', '★'] as const
47
48const REST_PLACES: Record<RestKind, Place> = { pier: PIER, camp: CAMP, crystal: CRYSTAL, ramen: RAMEN, inn: INN }
49const ZONE_PLACES: Record<ZoneId, Place> = { forest: FOREST, dungeon: DUNGEON, neon: NEON }
50const SIGNS: Record<RestKind, string> = { pier: '→ Pier', camp: '→ Camp', crystal: '→ Crystal', ramen: '→ Ramen', inn: '→ Inn' }
51const ZONE_NAMES: Record<ZoneId, string> = { forest: 'FOREST', dungeon: 'DUNGEON', neon: 'NEON CITY' }
52const BOSS_NAMES: Partial<Record<FoeKind, string>> = { treant: 'TREANT', hydra: 'MERGE HYDRA', mech: 'MECH' }
53// The inn's blanket covers him from this pixel row down.
54const TUCKED_FROM = 6
55const FISH_MS = 7600
56
57// `legMs`: how long each of his steps takes at the pace the road moves.
58export type Scene = { width: number; t: number; distance: number; isWalking: boolean; stats: RpgStats; story: Story; trudge?: boolean; legMs?: number }
59
60// Where a foe `w` wide stands: at most 36 columns past Clawd, its right edge 6 clear of the HUD
61// (so an ordinary foe stands 14 clear of it).
62export const foeX = (width: number, stats: RpgStats, w = 8) => Math.min(CLAWD_COL + 36, hudLeft(width, stats) - 6 - Math.max(8, w))
63
64// How wide the foe in this fight is, while it stands, falls or flees.
65const fightW = (story: Story) => foeWidth(story.foe?.kind ?? story.down?.kind ?? story.fled?.kind ?? 'goblin')
66const fightX = (s: Scene) => foeX(s.width, s.stats, fightW(s.story))
67
68const isCompact = (s: Scene) => s.width < COMPACT_BELOW
69// The sword comes down for the last 45% of each 480 ms swing; the first swing waits for the foe to land.
70const swingFrom = (story: Story) => Math.max(story.skill?.at ?? 0, (story.foe?.at ?? 0) + FOE_ENTER_MS)
71const isSwinging = (story: Story, t: number) => story.skill !== null && !story.skill.party && t >= swingFrom(story) && t - swingFrom(story) < SKILL_MS * 0.8
72const swingDown = (story: Story, t: number) => (t - swingFrom(story)) % 480 >= 264
73
74// `tucked`: in bed, under the inn's blanket from TUCKED_FROM down. `counter`: on the ramen stool,
75// the counter over his lap.
76export type ClawdPlace = { x: number; y: number; pose: Pose; strike: boolean; tucked?: boolean; counter?: boolean }
77
78function resting(rest: Rest, x: number, t: number, called: boolean): ClawdPlace | null {
79 if (rest.phase === 'travel') return null
80 if (rest.phase === 'pack') {
81 if (rest.kind === 'pier') return { x, y: 1, pose: POSES.stand, strike: false }
82 const up = t - rest.since >= PACK_MS / 2
83 if (rest.kind === 'inn' && !up) return { x, y: -1, pose: asleep(t), strike: false, tucked: true }
84 if (rest.kind === 'ramen' && !up) return { x, y: 1, pose: POSES.sit, strike: false, counter: true }
85 return { x, y: 1, pose: up ? POSES.stretch : POSES.sit, strike: false }
86 }
87 if (rest.kind === 'inn') return { x, y: -1, pose: called ? { sit: true, top: 8 } : asleep(t), strike: false, tucked: true }
88 // Leaning out on the stool would put his eyes behind the counter.
89 if (rest.kind === 'ramen') return { x, y: 1, pose: POSES.sit, strike: false, counter: true }
90 if (called) return { x, y: 1, pose: POSES.stand, strike: false }
91 return { x, y: 1, pose: rest.kind === 'pier' ? idling(t) : sitting(t), strike: false }
92}
93
94// Where Clawd is and how he stands, or null while the ALL-OUT ATTACK cloud hides him.
95export function clawdAt(s: Scene): ClawdPlace | null {
96 const { t, story } = s
97 const beat = beatOf(story, t)
98 const home = clawdCol(s.width)
99 const walk = s.trudge ? trudging(t, s.legMs) : walking(t, s.legMs)
100 const called = story.calledAt !== null && t - story.calledAt < CALL_MS
101 const rest = beat === 'idle' || beat === 'walk' ? restOf(story, t, s.distance, s.width) : null
102 const still = rest && resting(rest, home, t, called)
103 if (still) return still
104 const standing = { x: home, y: 1, pose: called ? POSES.stand : s.isWalking ? walk : idling(t), strike: false }
105 if (isCompact(s)) {
106 if (beat === 'victory') return { x: home, y: 1, pose: cheering(t), strike: false }
107 if (beat === 'enemyTurn') return { x: home, y: 1, pose: hurting(t), strike: false }
108 return standing
109 }
110 const fx = fightX(s)
111 const stand = Math.min(CLAWD_COL, fx - REACH)
112 switch (beat) {
113 case 'encounter': {
114 const x = Math.round(lerp(CLAWD_COL, stand, through(t, story.foe?.at ?? story.down?.at ?? t, 400)))
115 if (isSwinging(story, t)) return { x: fx - REACH, y: 1, pose: swingDown(story, t) ? POSES.strike : POSES.swing, strike: true }
116 return { x, y: 1, pose: POSES.right, strike: false }
117 }
118 case 'enemyTurn':
119 return { x: stand - (t - story.counter!.at < COUNTER_MS / 2 ? 3 : 0), y: 1, pose: hurting(t), strike: false }
120 case 'finisher':
121 return t - story.down!.at < FINISHER_MS * 0.7 ? null : { x: stand, y: 1, pose: cheering(t), strike: false }
122 case 'victory':
123 return { x: CLAWD_COL, y: 1, pose: cheering(t), strike: false }
124 case 'flee':
125 return { x: stand, y: 1, pose: { fx: 1, armL: 8, armR: 10 }, strike: false }
126 default:
127 return standing
128 }
129}
130
131function drawFight(g: Grid, s: Scene, beat: Beat, before: number): void {
132 const { t, story } = s
133 const fx = fightX(s)
134 const mid = fx + Math.floor(fightW(story) / 2) - 1
135 const foe = story.foe
136 if (foe) {
137 const y = Math.round(lerp(-8, foeTop(foe.kind), easeOut(through(t, foe.at, FOE_ENTER_MS))))
138 const flash = (isSwinging(story, t) && swingDown(story, t)) || (story.skill?.party === true && t - foe.hitAt < 240 && t >= foe.hitAt)
139 drawFoe(g, foe.kind, fx, y, t, { flash, elite: foe.elite && !foe.boss })
140 // A boss fills the band, so its health runs along the top.
141 drawHp(g, fx, foe.boss ? 0 : y - (foe.elite ? 3 : 2), foe.hp, foe.maxHp)
142 }
143 const down = story.down
144 if (down && beat === 'encounter' && t - down.at < DOWN_MS) burst(g, mid, 6, (t - down.at) / 180, [0xffffff, 0xb6f09c, 0x6fbf4e])
145 if (beat === 'finisher' && down) {
146 const q = through(t, down.at, FINISHER_MS)
147 if (q < 0.7) cloud(g, fx, t)
148 else burst(g, mid, 5, (t - down.at - FINISHER_MS * 0.7) / 120, [0xffffff, 0xff9f8a, 0xd6453d], 12)
149 banner(g, 'ALL-OUT ATTACK!!', q / 0.7, 0xe5202e, undefined, before)
150 }
151 if (beat === 'flee' && story.fled) {
152 const q = through(t, story.fled.at, FLEE_MS)
153 drawFoe(g, story.fled.kind, fx + Math.round(q * 6), Math.round(foeTop(story.fled.kind) - q * 14), t)
154 }
155 if (story.skill && !story.skill.party && beat === 'encounter') banner(g, story.skill.name, (t - story.skill.at) / SKILL_MS, 0xe5202e, undefined, before)
156 if (beat === 'enemyTurn' && story.counter) banner(g, `COUNTER ✗${story.counter.n}`, (t - story.counter.at) / COUNTER_MS, 0x000000, 0xff4b4b, before)
157}
158
159// Up to three of the party walk behind him, drop in as they join, cheer the win, and play their
160// class attack when it lands. `anchor`: Clawd's column. What a member does is called out over its
161// head, not in a cut-in, which would cover the party: the latest thing done, if any. Returns whether
162// anything was called out.
163function drawParty(g: Grid, s: Scene, beat: Beat, anchor: number): boolean {
164 const { t, story } = s
165 const shown = (story.party ?? []).slice(-3)
166 const fx = fightX(s)
167 let said: { label: string; x: number; at: number } | null = null
168 for (const [i, m] of shown.entries()) {
169 const x = anchor - (MINI_REACH + 1) * (shown.length - i)
170 const y = Math.round(lerp(-8, 4, easeOut(through(t, m.at, DROP_MS))))
171 const acting = story.skill?.by === m.id && t >= story.skill.at && t - story.skill.at < SKILL_MS
172 const step = s.isWalking && beat === 'walk' && on(t + i * 160, LEG_STEP_MS)
173 mini(g, x, y, m.cls, { cheer: beat === 'victory', step, glow: acting })
174 if (acting && story.skill) classAttack(g, m.cls, x, fx, t - story.skill.at)
175 // Named once it has landed: dropping in, it passes through the label's row.
176 const landed = m.at + DROP_MS
177 const news = acting && story.skill ? { label: story.skill.name, at: story.skill.at } : t >= landed && t - landed < SKILL_MS ? { label: `${m.cls.toUpperCase()} JOINS!`, at: landed } : null
178 if (news && (!said || news.at >= said.at)) said = { ...news, x }
179 }
180 if (said) write(g, Math.max(0, Math.min(said.x, anchor - 2 - said.label.length)), 0, said.label, PARTY_TEXT)
181 return said !== null
182}
183
184function caption(g: Grid, text: string, fg: number, before: number): void {
185 const room = before - 6
186 const chars = [...text]
187 write(g, 4, 0, chars.length > room ? chars.slice(0, room - 1).join('') + '…' : text, fg)
188}
189
190const spotsOn = (s: Scene): Spot[] => spotsOnRoad(s.story, s.t, s.width)
191
192// Text from any source (a place's sign scrolling past, a caption) never covers his pixels.
193function clearOff(g: Grid, me: ClawdPlace): void {
194 const solo = grid(g.w)
195 clawd(solo, me.x, me.y, me.pose)
196 for (const key of [...g.text.keys()]) {
197 const x = key % g.w
198 const row = Math.floor(key / g.w)
199 if (at(solo, x, row * 2) !== EMPTY || at(solo, x, row * 2 + 1) !== EMPTY) g.text.delete(key)
200 }
201}
202
203function drawRoad(g: Grid, s: Scene, spots: Spot[], cam: number, pw: number): void {
204 const { t, story } = s
205 for (let x = 0; x < s.width; x++) {
206 const wx = cam + x
207 const spot = spots.find(p => wx >= p.from && wx < p.from + pw)
208 if (spot) REST_PLACES[spot.kind].col(g, x, wx - spot.from, t, cam, pw)
209 else ZONE_PLACES[zoneAt(story, wx)].col(g, x, wx, t, cam, Number.POSITIVE_INFINITY)
210 }
211 for (const spot of [...spots].reverse()) {
212 const ox = spot.from - cam
213 if (ox < s.width && ox + pw > 0) REST_PLACES[spot.kind].props?.(g, ox, t, pw)
214 }
215 const gate = story.gate
216 if (gate) for (let lx = 0; lx < GATE_W; lx++) gateCol(g, gate.x - cam + lx, lx, t)
217}
218
219// A signpost just before each rest spot, and the next zone's name over its gate; names stay clear
220// of Clawd and out of the compact view.
221function drawSigns(g: Grid, s: Scene, spots: Spot[], cam: number, me: ClawdPlace | null): void {
222 const clear = (from: number, end: number) => !me || end < me.x - 1 || from > me.x + 16
223 for (const spot of spots) {
224 const x = spot.from - SIGN_BEFORE - cam
225 if (x < -4 || x > s.width) continue
226 rect(g, x, 6, 1, 4, 0x6b4a2b)
227 rect(g, x - 2, 5, 6, 2, 0x8a5a2b)
228 const label = SIGNS[spot.kind]
229 const end = x - 1 + label.length
230 if (!isCompact(s) && clear(x - 1, end) && x - 1 >= 0 && end <= s.width) write(g, x - 1, 1, label, 0xe9e6df)
231 }
232 const gate = s.story.gate
233 if (!gate || isCompact(s)) return
234 const name = ZONE_NAMES[s.story.zone ?? 'forest']
235 const x = gate.x - cam + Math.floor((GATE_W - name.length) / 2)
236 if (x >= 0 && x + name.length <= s.width && clear(x, x + name.length)) write(g, x, 0, name, 0xc58bff)
237}
238
239// At the pier: rod out, the line to a bobbing float, and now and then a fish pulled in.
240function fishing(g: Grid, x: number, t: number): void {
241 line(g, x + 15, 4, x + 20, 0, 0xa0714a)
242 const k = t % FISH_MS
243 const float = x + 34
244 const dip = k > 4800 && k < 5600
245 const y = dip ? 9 : 7 + (Math.floor(t / 1200) % 2 === 1 ? 0 : -1)
246 line(g, x + 20, 0, float, y, 0x6b7390, 2)
247 if (dip) {
248 g.px[7 * g.w + float - 1] = 0xf4f4f4
249 g.px[7 * g.w + float + 1] = 0xf4f4f4
250 } else {
251 sprite(g, float, y - 1, ['R', 'W'], { R: 0xff4b4b, W: 0xf4f4f4 })
252 }
253 if (k > 5600 && k < 7000) {
254 const q = through(k, 5600, 1400)
255 sprite(g, Math.round(lerp(float, x + 18, q)), Math.round(lerp(7, 0, q)), ['GGG', 'GGTT'], { G: 0xc8d0e8, T: 0x9aa0b2 })
256 }
257}
258
259const REST_CAPTIONS: Record<Exclude<RestKind, 'inn'>, [string, number]> = {
260 pier: ['waiting for you…', 0x7fd1ff],
261 camp: ['idle · warming up', 0xffb27a],
262 crystal: ['idle · by the save crystal', 0x7fd1ff],
263 ramen: ['idle · slurp…', 0xffb27a],
264}
265
266function restCaption(s: Scene, rest: Rest | null): [string, number] | null {
267 if (!rest || rest.phase !== 'rest') return null
268 if (rest.kind !== 'inn') return REST_CAPTIONS[rest.kind]
269 return [s.story.trip?.compactAt !== null ? 'z Z · HP refilling' : 'z Z', 0x9be38a]
270}
271
272export function frame(s: Scene): Grid {
273 const g = grid(s.width)
274 const { t, story } = s
275 const cam = camOf(s.distance, s.width)
276 const pw = placeWidth(s.width)
277 const spots = spotsOn(s)
278 drawRoad(g, s, spots, cam, pw)
279 const beat = beatOf(story, t)
280 const me = clawdAt(s)
281 drawSigns(g, s, spots, cam, me)
282 const rest = beat === 'idle' || beat === 'walk' ? restOf(story, t, s.distance, s.width) : null
283 const called = story.calledAt !== null && t - story.calledAt < CALL_MS
284 const anchor = me?.x ?? Math.min(CLAWD_COL, fightX(s) - REACH)
285 const calledOut = !isCompact(s) && (story.turn.active || beat === 'victory') && drawParty(g, s, beat, anchor)
286 if (!isCompact(s)) drawFight(g, s, beat, anchor)
287 if (me) {
288 clawd(g, me.x, me.y, me.pose)
289 if (me.strike) sword(g, me.x, 1, me.pose === POSES.strike)
290 if (beat === 'enemyTurn') sparks(g, me.x, 1, t)
291 if (beat === 'flee') dust(g, me.x, t)
292 if (rest?.phase === 'rest' && rest.kind === 'pier' && !called) fishing(g, me.x, t)
293 if (me.tucked) INN.front?.(g, me.x - CLAWD_COL, t, pw)
294 if (me.counter) RAMEN.front?.(g, me.x - CLAWD_COL, t, pw)
295 if (called) bang(g, me.x, t)
296 }
297 if (beat === 'victory' && story.victory && !isCompact(s)) {
298 const fx = fightX(s)
299 chest(g, Math.max(CLAWD_COL + 18, fx - 4), 4, t - story.victory.at > 400, t)
300 if (t - story.victory.at > 600) caption(g, `◆ ${drawable(story.victory.loot).join('')} · +${story.victory.gained} EXP`, 0xffd54f, me?.x ?? CLAWD_COL)
301 } else if (!isCompact(s) && me && !calledOut) {
302 const boss = story.foe?.boss ? BOSS_NAMES[story.foe.kind] : undefined
303 const quest = questOf(story)
304 // The task's own words may hold characters the band can't draw one cell wide.
305 const said: [string, number] | null = boss
306 ? [`★ BOSS · ${boss}`, 0xffd54f]
307 : (restCaption(s, rest) ?? (quest ? [`☐ ${drawable(quest.slice(2)).join('')}`, 0xd7c9a0] : null))
308 if (said) caption(g, said[0], said[1], me.x)
309 }
310 drawHud(g, s.stats)
311 if (me) clearOff(g, me)
312 if (story.levelUp && t >= story.levelUp.at && t - story.levelUp.at < LEVEL_UP_MS && !isCompact(s)) {
313 write(g, hudLeft(s.width, s.stats), 3, `LEVEL UP! Lv.${story.levelUp.level}`, 0xffd54f)
314 }
315 return g
316}
317hooks/rpg/grid.ts 48 lines1export const ROWS = 5
2export const PX_H = ROWS * 2
3export const EMPTY = -1
4
5export type Grid = { w: number; px: Int32Array; text: Map<number, { cp: number; fg: number }> }
6
7export function grid(w: number): Grid {
8 return { w, px: new Int32Array(w * PX_H).fill(EMPTY), text: new Map() }
9}
10
11export function put(g: Grid, x: number, y: number, c: number): void {
12 const px = Math.round(x)
13 const py = Math.round(y)
14 if (px < 0 || py < 0 || px >= g.w || py >= PX_H) return
15 g.px[py * g.w + px] = c
16}
17
18export const at = (g: Grid, x: number, y: number): number => (x < 0 || y < 0 || x >= g.w || y >= PX_H ? EMPTY : g.px[y * g.w + x]!)
19
20export function rect(g: Grid, x: number, y: number, w: number, h: number, c: number): void {
21 for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) put(g, x + i, y + j, c)
22}
23
24// A straight line of pixels, every `every`th one (2 gives a dotted fishing line).
25export function line(g: Grid, x0: number, y0: number, x1: number, y1: number, c: number, every = 1): void {
26 const n = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1)
27 for (let i = 0; i <= n; i += every) put(g, x0 + ((x1 - x0) * i) / n, y0 + ((y1 - y0) * i) / n, c)
28}
29
30export function sprite(g: Grid, x: number, y: number, rows: readonly string[], pal: Readonly<Record<string, number>>): void {
31 rows.forEach((row, j) => {
32 for (let i = 0; i < row.length; i++) {
33 const c = pal[row[i]!]
34 if (c !== undefined) put(g, x + i, y + j, c)
35 }
36 })
37}
38
39// Text takes whole cells and wins over the pixels there; cells.ts gives it their colour as background.
40export function write(g: Grid, col: number, row: number, s: string, fg: number): void {
41 let i = 0
42 for (const ch of s) {
43 const c = col + i++
44 if (c < 0 || c >= g.w || row < 0 || row >= ROWS) continue
45 g.text.set(row * g.w + c, { cp: ch.codePointAt(0)!, fg })
46 }
47}
48hooks/rpg/progress.ts 86 lines1import type { ZoneId } from '../../types'
2
3export type { ZoneId }
4
5export type AwardKind = 'battle' | 'elite' | 'raid' | 'turn'
6// `boss`: the raid was the boss of `zone`, which clears it, unless another session already has.
7export type Award = { kind: AwardKind; foe?: string; boss?: true; zone?: ZoneId }
8
9// `exp` is what he has towards the next level, not a lifetime total.
10export type Progress = {
11 v: 1
12 exp: number
13 level: number
14 zone: ZoneId
15 zoneMeter: number
16 unlocked: ZoneId[]
17 bestiary: Record<string, number>
18 roadPos: number
19}
20
21export const EXP: Record<AwardKind, number> = { battle: 10, elite: 25, raid: 60, turn: 5 }
22export const UNLOCK_LEVEL: Record<ZoneId, number> = { forest: 1, dungeon: 3, neon: 6 }
23const ZONES = Object.keys(UNLOCK_LEVEL) as ZoneId[]
24// Battles won in a zone before its boss turn.
25export const ZONE_BATTLES = 12
26
27export const toNext = (level: number) => 50 + 25 * level
28
29export const FRESH: Progress = { v: 1, exp: 0, level: 1, zone: 'forest', zoneMeter: 0, unlocked: ['forest'], bestiary: {}, roadPos: 0 }
30
31const num = (value: unknown, min: number, fallback: number) => (typeof value === 'number' && Number.isFinite(value) && value >= min ? value : fallback)
32const unlockedAt = (level: number) => ZONES.filter(z => level >= UNLOCK_LEVEL[z])
33
34// Stored progress may come from an older build, another hand, or nothing at all: every field
35// falls back on its own, and anything that isn't a save is kept aside, not overwritten. A newer
36// build's save is never touched: a session still running this build would wipe it.
37export function loadProgress(raw: unknown): { progress: Progress; backup?: unknown; isNewer?: true } {
38 if (raw === undefined || raw === null) return { progress: FRESH }
39 const v = typeof raw === 'object' ? (raw as { v?: unknown }).v : undefined
40 if (typeof v === 'number' && v > 1) return { progress: FRESH, isNewer: true }
41 if (v !== 1) return { progress: FRESH, backup: raw }
42 const r = raw as Record<string, unknown>
43 const level = Math.floor(num(r.level, 1, 1))
44 const zone = ZONES.includes(r.zone as ZoneId) ? (r.zone as ZoneId) : 'forest'
45 const bestiary: Record<string, number> = {}
46 if (typeof r.bestiary === 'object' && r.bestiary !== null) {
47 for (const [k, n] of Object.entries(r.bestiary)) if (typeof n === 'number' && n > 0) bestiary[k] = Math.floor(n)
48 }
49 return {
50 progress: {
51 v: 1,
52 exp: Math.min(num(r.exp, 0, 0), toNext(level) - 1),
53 level,
54 zone: unlockedAt(level).includes(zone) ? zone : 'forest',
55 zoneMeter: Math.min(Math.floor(num(r.zoneMeter, 0, 0)), ZONE_BATTLES),
56 unlocked: unlockedAt(level),
57 bestiary,
58 roadPos: num(r.roadPos, 0, 0),
59 },
60 }
61}
62
63export function gain(p: Progress, award: Award): { progress: Progress; levelsUp: number } {
64 let exp = p.exp + EXP[award.kind]
65 let level = p.level
66 while (exp >= toNext(level)) {
67 exp -= toNext(level)
68 level++
69 }
70 const won = award.kind === 'battle' || award.kind === 'elite'
71 const bestiary = award.foe ? { ...p.bestiary, [award.foe]: (p.bestiary[award.foe] ?? 0) + 1 } : p.bestiary
72 const next: Progress = { ...p, exp, level, unlocked: unlockedAt(level), zoneMeter: won ? Math.min(ZONE_BATTLES, p.zoneMeter + 1) : p.zoneMeter, bestiary }
73 const clears = award.boss && (award.zone ?? p.zone) === p.zone
74 return { progress: clears ? { ...next, zone: nextZone(next), zoneMeter: 0 } : next, levelsUp: level - p.level }
75}
76
77export const bossDue = (p: Progress) => p.zoneMeter >= ZONE_BATTLES
78
79// The unlocked zone after his, in road order, back to the forest after the last.
80export function nextZone(p: Progress): ZoneId {
81 const open = unlockedAt(p.level)
82 return open[(open.indexOf(p.zone) + 1) % open.length] ?? 'forest'
83}
84
85export const expFraction = (p: Progress) => p.exp / toNext(p.level)
86hooks/rpg/report.ts 68 lines1import type { ZoneId } from '../../types'
2import { BOSSES } from './director'
3import { COMPACT_BELOW } from './road'
4import { HIDDEN_BELOW } from './frame'
5import { UNLOCK_LEVEL, ZONE_BATTLES, bossDue, toNext } from './progress'
6import type { Progress } from './progress'
7
8const ZONE_NAMES: Record<ZoneId, string> = { forest: 'Enchanted Forest', dungeon: 'Dungeon', neon: 'Neon City' }
9const BOSS_NAMES: Record<string, string> = { treant: 'Treant', hydra: 'Merge Hydra', mech: 'Mech' }
10const ZONE_ORDER: ZoneId[] = ['forest', 'dungeon', 'neon']
11
12// What /rpg stats says: his level, the zone and its boss, what's open, and what he's met.
13export function statsText(p: Progress): string {
14 const boss = BOSS_NAMES[BOSSES[p.zone]]!
15 const meter = bossDue(p) ? `the ${boss} waits: the next turn that fights is a boss fight` : `${p.zoneMeter}/${ZONE_BATTLES} battles to the ${boss}`
16 const open = ZONE_ORDER.filter(z => p.level >= UNLOCK_LEVEL[z])
17 const next = ZONE_ORDER.find(z => p.level < UNLOCK_LEVEL[z])
18 const met = Object.entries(p.bestiary).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
19 return [
20 `Clawd · Lv.${p.level} · ${p.exp}/${toNext(p.level)} EXP to Lv.${p.level + 1}`,
21 `Zone: ${ZONE_NAMES[p.zone]} · ${meter}`,
22 `Zones open: ${open.map(z => ZONE_NAMES[z]).join(', ')}${next ? ` · ${ZONE_NAMES[next]} opens at Lv.${UNLOCK_LEVEL[next]}` : ''}`,
23 `Bestiary: ${met.length ? met.map(([kind, n]) => `${kind} ×${n}`).join(', ') : 'nothing met yet'}`,
24 ].join('\n')
25}
26
27// `width`: the band's last drawn width, null before it has been drawn; `lastBlitMs`: how long ago the
28// last frame went out.
29export type DoctorFacts = {
30 hidden: boolean
31 width: number | null
32 painting: boolean
33 lastBlitMs: number | null
34 save: 'fine' | 'backup' | 'newer' | 'broken'
35 level: number
36 zone: ZoneId
37 clawdBar: boolean
38}
39
40function view(width: number): string {
41 if (width < HIDDEN_BELOW) return `too narrow: the band hides below ${HIDDEN_BELOW}`
42 if (width < COMPACT_BELOW) return 'compact view: Clawd and the place, no fights'
43 return 'full view'
44}
45
46const SAVES: Record<DoctorFacts['save'], (f: DoctorFacts) => string> = {
47 fine: f => `fine (Lv.${f.level}, ${ZONE_NAMES[f.zone]})`,
48 backup: () => "an unreadable save was kept as 'progressBackup' and a new one started",
49 newer: () => 'written by a newer clawd-rpg; this session plays on without saving',
50 broken: () => "the plugin store can't be read; progress lives only in this session",
51}
52
53// What /rpg doctor says: is the band up and painting, is the save healthy, is clawd-bar's band there too.
54export function doctorText(f: DoctorFacts): string {
55 let band = 'hidden (/rpg brings it back)'
56 if (!f.hidden && f.width === null) band = 'shown, not drawn yet this session'
57 else if (!f.hidden && f.width !== null) {
58 const frames = f.painting ? `painting frames${f.lastBlitMs === null ? '' : ` (last one ${(f.lastBlitMs / 1000).toFixed(1)} s ago)`}` : 'not painting (it starts at the next redraw)'
59 band = `shown, last drawn ${f.width} columns wide (${view(f.width)}), ${frames}`
60 }
61 return [
62 'clawd-rpg doctor',
63 `· Band: ${band}`,
64 `· Save: ${SAVES[f.save](f)}`,
65 `· clawd-bar: ${f.clawdBar ? 'also enabled, and its band stacks with this one: /clawd hides it' : 'not enabled, so no second band'}`,
66 ].join('\n')
67}
68hooks/rpg/road.ts 165 lines1import type { RestKind, Story, Trip, ZoneId } from '../../types'
2import { CALL_MS, beatOf } from './director'
3import { GATE_W } from './places/gate'
4import { LEG_STEP_MS } from './sprites/clawd'
5
6export const CLAWD_COL = 50
7export const COMPACT_COL = 4
8// Below this a fight can't fit left of the HUD, so the band shows Clawd and the place only.
9export const COMPACT_BELOW = 100
10
11export const clawdCol = (width: number) => (width < COMPACT_BELOW ? COMPACT_COL : CLAWD_COL)
12
13// clawd-bar's DOZE_AFTER_MS and SLEEP_AFTER_MS: a minute idle he moves on to the campfire, ten to the inn.
14export const DOZE_MS = 60_000
15export const SLEEP_MS = 600_000
16// Reeling in, or getting up, before he walks back to work.
17export const PACK_MS = 1200
18// Three tool calls inside this window and he hurries.
19export const HURRY_WINDOW_MS = 8000
20
21// 12.5 frames a second: a hurried walk moves one pixel a frame, and his quickest steps (four
22// poses in 320 ms) show every pose; at 160 ms they alias and his legs freeze or flap.
23export const FRAME_MS = 80
24
25// Paces in road pixels per four frames: whole pixels only, since a fraction scrolls unevenly.
26const WALK = 2
27const HURRY = 4
28
29// Each zone's own rest spot, where he goes after a minute idle.
30export const NOOK: Record<ZoneId, RestKind> = { forest: 'camp', dungeon: 'crystal', neon: 'ramen' }
31// A signpost stands this far before each rest spot.
32export const SIGN_BEFORE = 12
33
34const schedule = (trip: Trip): [RestKind, number][] => [
35 ['pier', 0],
36 [NOOK[trip.zone ?? 'forest'], DOZE_MS],
37 ['inn', SLEEP_MS],
38]
39
40// The rest spots a trip has reached by `now`, in order. A compaction goes straight to the inn, and
41// the inn is always the last stop.
42export function stopsOf(trip: Trip, now: number): RestKind[] {
43 const until = trip.leftAt ?? now
44 const due: [RestKind, number][] = schedule(trip).map(([kind, after]) => [kind, trip.at + after])
45 if (trip.compactAt !== null) due.push(['inn', trip.compactAt])
46 due.sort((a, b) => a[1] - b[1])
47 const stops: RestKind[] = []
48 for (const [kind, at] of due) {
49 if (at > until || stops.includes(kind)) continue
50 stops.push(kind)
51 if (kind === 'inn') break
52 }
53 return stops
54}
55
56// A stopped scene fills the band, so a rest spot is one band wide. In the compact view Clawd stands
57// further left, so the spot stretches to keep its props where he sits.
58export const placeWidth = (width: number) => width + CLAWD_COL - clawdCol(width)
59
60// The road column at the band's left edge, when the camera is at `distance`.
61export const camOf = (distance: number, width: number) => Math.floor(distance) + CLAWD_COL - clawdCol(width)
62
63export type Spot = { kind: RestKind; from: number }
64
65export function spotsOf(trip: Trip, now: number, width: number): Spot[] {
66 const pw = placeWidth(width)
67 return stopsOf(trip, now).map((kind, i) => ({ kind, from: trip.origin + (i + 1) * pw }))
68}
69
70// Where the road takes him while he rests: the last spot his trip has reached.
71export function goalOf(story: Story, now: number, width: number): Spot | null {
72 const trip = story.trip
73 if (!trip || trip.leftAt !== null) return null
74 return spotsOf(trip, now, width).at(-1) ?? null
75}
76
77// The rest spots on the road, the current trip's first. A last-trip spot that overlaps one of
78// them is dropped whole, so its props never show inside the place he's at.
79export function spotsOnRoad(story: Story, now: number, width: number): Spot[] {
80 const pw = placeWidth(width)
81 const mine = story.trip ? spotsOf(story.trip, now, width) : []
82 const old = story.trail ? spotsOf(story.trail, now, width) : []
83 return [...mine, ...old.filter(p => mine.every(q => p.from + pw <= q.from || q.from + pw <= p.from))]
84}
85
86export function placeAt(story: Story, wx: number, now: number, width: number): Spot | null {
87 const pw = placeWidth(width)
88 return spotsOnRoad(story, now, width).find(p => wx >= p.from && wx < p.from + pw) ?? null
89}
90
91// The zone a road pixel is in: behind the last gate, the zone he came from.
92export const zoneAt = (story: Story, wx: number): ZoneId => (story.gate && wx < story.gate.x ? story.gate.from : (story.zone ?? 'forest'))
93
94// A gate stands this far past where a turn ended (just ahead of him, before the pier), or, when
95// the zone changed while he was away, this far past where he picks up the walk.
96export const GATE_AFTER = 66
97export const GATE_AHEAD = 90
98
99// Where a gate can stand at or past `x`: never inside a rest spot or over its signpost.
100export function gateAt(story: Story, now: number, width: number, x: number): number {
101 const pw = placeWidth(width)
102 let at = x
103 for (const spot of spotsOnRoad(story, now, width).sort((a, b) => a.from - b.from)) {
104 if (at + GATE_W > spot.from - SIGN_BEFORE - 3 && at < spot.from + pw) at = spot.from + pw + 2
105 }
106 return at
107}
108
109// A turn ended short of a gate placed earlier: it moves to just past him, so the new trip's spots
110// never stand on it.
111export function gateClear(story: Story, now: number, width: number): Story {
112 const { gate, trip } = story
113 if (!gate || !trip || gate.x < trip.origin) return story
114 return { ...story, gate: { ...gate, x: gateAt({ ...story, gate: null }, now, width, trip.origin + GATE_AFTER) } }
115}
116
117export type Rest = { kind: RestKind; phase: 'travel' | 'rest' | 'pack'; since: number }
118
119// What resting looks like now: on his way to a spot, there, or packing up after a prompt.
120export function restOf(story: Story, now: number, distance: number, width: number): Rest | null {
121 const trip = story.trip
122 if (!trip) return null
123 const d = Math.floor(distance)
124 if (trip.leftAt !== null) {
125 const left = spotsOf(trip, trip.leftAt, width).at(-1)
126 return left && d === left.from && now - trip.leftAt < PACK_MS ? { kind: left.kind, phase: 'pack', since: trip.leftAt } : null
127 }
128 const goal = goalOf(story, now, width)
129 if (!goal) return null
130 return { kind: goal.kind, phase: d === goal.from ? 'rest' : 'travel', since: trip.at }
131}
132
133// The band changed width under him. Spots stand a band apart, so they all move; if he was at one,
134// resting or packing, put him on the same spot at the new width instead of walking him there.
135export function reanchor(story: Story, now: number, distance: number, from: number, to: number): number {
136 const rest = from === to ? null : restOf(story, now, distance, from)
137 if (!rest || rest.phase === 'travel') return distance
138 const spot = spotsOf(story.trip!, story.trip!.leftAt ?? now, to).find(p => p.kind === rest.kind)
139 return spot?.from ?? distance
140}
141
142const isHurried = (story: Story, now: number) => {
143 const recent = story.turn.recent ?? []
144 return recent.length >= 3 && now - recent[recent.length - 3]! <= HURRY_WINDOW_MS
145}
146
147export type Motion = { distance: number; isWalking: boolean; legMs: number }
148
149// One frame of the road: how far the camera moves, given the story and whether Claude is working.
150export function moveRoad(story: Story, now: number, distance: number, width: number, frame: number, isWorking: boolean, trudge: boolean): Motion {
151 const still: Motion = { distance, isWalking: false, legMs: LEG_STEP_MS }
152 const beat = beatOf(story, now)
153 if (beat !== 'walk' && beat !== 'idle') return still
154 if (story.calledAt !== null && now - story.calledAt < CALL_MS) return still
155 if (restOf(story, now, distance, width)?.phase === 'pack') return still
156 const goal = isWorking ? null : goalOf(story, now, width)
157 if (!isWorking && !goal) return still
158 // Past his goal (a resize the timer hasn't caught up with): back onto it, never stranded in the forest.
159 if (goal && distance >= goal.from) return { ...still, distance: goal.from }
160 let rate = goal || isHurried(story, now) ? HURRY : WALK
161 if (trudge) rate /= 2
162 const step = Math.floor(((frame + 1) * rate) / 4) - Math.floor((frame * rate) / 4)
163 return { distance: goal ? Math.min(goal.from, distance + step) : distance + step, isWalking: true, legMs: (LEG_STEP_MS * WALK) / rate }
164}
165hooks/rpg/stats.ts 12 lines1import type { RpgStats } from '../../types'
2import { expFraction } from './progress'
3import type { Progress } from './progress'
4
5export type Usage = { context?: { percent?: number }; rateLimits?: { kind: string; percentUsed: number }[] }
6
7export function statsFrom(usage: Usage | null, place: string, progress: Progress): RpgStats {
8 const ctx = usage?.context?.percent ?? 0
9 const fiveHour = usage?.rateLimits?.find(l => l.kind === 'five_hour')?.percentUsed ?? 0
10 return { level: progress.level, exp: expFraction(progress), hp: 1 - ctx / 100, mp: 1 - fiveHour / 100, place }
11}
12hooks/rpg/sprites/clawd.ts 106 lines1import { put, rect } from '../grid'
2import type { Grid } from '../grid'
3
4export const ORANGE = 0xde886d
5export const EYE = 0x000000
6export const CLAWD_W = 15
7export const CLAWD_H = 9
8export const LEG_STEP_MS = 320
9
10export type Pose = {
11 armL?: number
12 armR?: number
13 top?: number
14 dx?: number
15 // Which pair of legs is off the ground: 'odd' is his first and third from the left, 'even' the others.
16 lift?: 'odd' | 'even'
17 face?: 'eyes' | 'shut' | 'happy'
18 fx?: number
19 fy?: number
20 fh?: number
21 hy?: number
22 sit?: boolean
23}
24
25// Ported from clawd-bar 0.3.0's hooks/sprites.ts with the shadow row dropped. Pose numbers keep
26// 0.3.0's grid (rows 6..14), so `y` here is the top of his head and row n draws at y + n - 6.
27export function clawd(g: Grid, x: number, y: number, o: Pose = {}): void {
28 const dx = o.dx ?? 0
29 if (o.sit) {
30 const top = o.top ?? 9
31 rect(g, x + 2, y + top - 6, 11, 15 - top, ORANGE)
32 rect(g, x, y + 7, 2, 2, ORANGE)
33 rect(g, x + 13, y + 7, 2, 2, ORANGE)
34 rect(g, x + 4, y + top - 3, 2, 1, EYE)
35 rect(g, x + 9, y + top - 3, 2, 1, EYE)
36 return
37 }
38 const top = o.top ?? 6
39 for (const [i, lx] of [3, 5, 9, 11].entries()) rect(g, x + lx, y + 7, 1, o.lift === (i % 2 === 0 ? 'odd' : 'even') ? 1 : 2, ORANGE)
40 rect(g, x + 2 + dx, y + top - 6, 11, 13 - top, ORANGE)
41 rect(g, x + dx, y + (o.armL ?? 9) - 6, 2, 2, ORANGE)
42 rect(g, x + 13 + dx, y + (o.armR ?? 9) - 6, 2, 2, ORANGE)
43 const face = o.face ?? 'eyes'
44 if (face === 'eyes') {
45 const fx = o.fx ?? 0
46 const fy = o.fy ?? 0
47 const fh = o.fh ?? 2
48 rect(g, x + 4 + fx + dx, y + 2 + fy, 1, fh, EYE)
49 rect(g, x + 10 + fx + dx, y + 2 + fy, 1, fh, EYE)
50 } else if (face === 'shut') {
51 rect(g, x + 4 + dx, y + 3, 2, 1, EYE)
52 rect(g, x + 9 + dx, y + 3, 2, 1, EYE)
53 } else {
54 const hy = o.hy ?? 0
55 for (const [ex, ey] of [[3, 3], [4, 2], [5, 3], [9, 3], [10, 2], [11, 3]] as const) put(g, x + ex + dx, y + ey + hy, EYE)
56 }
57}
58
59export const POSES = {
60 stand: {},
61 right: { fx: 1 },
62 blink: { fy: 1, fh: 1 },
63 ponder: { fx: 1, fy: -1 },
64 cheer: { armL: 8, armR: 8, face: 'happy' },
65 bounce: { armL: 10, armR: 10, top: 7, face: 'happy', hy: 1 },
66 slump: { armL: 10, armR: 10, face: 'shut' },
67 swing: { armR: 7, fx: 1, fy: 1 },
68 strike: { armR: 10, fx: 1, fy: 1 },
69 squeeze: { armL: 10, armR: 10, top: 8, fy: 1, fh: 1 },
70 stretch: { armL: 7, armR: 7, fy: -1 },
71 sit: { sit: true, top: 9 },
72 sitOut: { sit: true, top: 10 },
73} as const satisfies Record<string, Pose>
74
75// Each step plants all four feet for half of `legMs`, then lifts one pair for the other half.
76const stride = (t: number, legMs: number): Pose['lift'] => ([undefined, 'odd', undefined, 'even'] as const)[Math.floor((2 * t) / legMs) % 4]
77
78// A waddle: the arm on the side of the lifted legs swings up, the other down.
79// `legMs`: how long each step takes; he hurries with quicker steps.
80export const walking = (t: number, legMs = LEG_STEP_MS): Pose => {
81 const lift = stride(t, legMs)
82 if (lift === 'odd') return { fx: 1, lift, armL: 8, armR: 10 }
83 if (lift === 'even') return { fx: 1, lift, armL: 10, armR: 8 }
84 return { fx: 1 }
85}
86
87// Out of usage: half-pace steps, arms hanging.
88export const trudging = (t: number, legMs = 2 * LEG_STEP_MS): Pose => ({ fx: 1, fy: 1, armL: 10, armR: 10, lift: stride(t, legMs) })
89
90export const cheering = (t: number): Pose => (Math.floor(t / 480) % 2 === 1 ? POSES.bounce : POSES.cheer)
91
92// Knocked back: eyes shut, arms down, shaking on the spot.
93export const hurting = (t: number): Pose => ({ armL: 10, armR: 10, face: 'shut', dx: Math.floor(t / 160) % 2 === 1 ? 1 : -1 })
94
95export const idling = (t: number): Pose => {
96 if (t % 5200 < 160) return POSES.blink
97 const glance = t % 10400
98 return glance > 6800 && glance < 8800 ? POSES.right : POSES.stand
99}
100
101// By the campfire: sitting, leaning out now and then.
102export const sitting = (t: number): Pose => (Math.floor(t / 2600) % 2 === 1 ? POSES.sitOut : POSES.sit)
103
104// In bed: sitting low, breathing slowly; the inn's blanket covers him from row 6 down.
105export const asleep = (t: number): Pose => ({ sit: true, top: Math.floor(t / 2600) % 2 === 1 ? 10 : 9 })
106