A little pixel cat that wanders above the Claude Code prompt, watches your usage limits and announces when work finishes; or switch packs for an adventurer who…

A tiny pixel-art cat that lives above your Claude Code prompt. It wanders around, watches your usage limits, and tells you when Claude finishes. Not a cat person? Switch packs and an adventurer mines, chops trees and fights zombies there instead.
‹Bash…›) and a prop to match: a book for reading files, a laptop it types on for edits, a terminal for shell commands, a magnifying glass for searches. Between tools it runs around.‹done! 42s›), plus a toast for turns longer than 20 seconds.>.< face when a tool call errors./cat shows yours./cat toggles it and prints your current limits and when the 5-hour window resets.In the Claude desktop app the whole lane is one SVG that animates itself (smooth gliding, leg and tail frames, blinks, floating hearts and z's), so the mod only redraws when the cat changes what it's doing. In the terminal the lane is a cell grid of colored half blocks, repainted in place 10 times a second.
Everything the hero is and does comes from a pack. Switch packs from ⚙ → Pack. Each pack remembers its own coat (or outfit), scene and hat, so switching back finds them as you left them; a pack you haven't worn yet starts from its defaults, keeping only what it shares with the one you left. Hats unlock at the same milestones in every pack, so a hat earned in one is waiting in the others.
The Adventurer pack is fan-made and inspired by Terraria. It is not affiliated with or endorsed by Re-Logic, and Terraria is a trademark of Re-Logic. All of its pixel art is original.
Each agent this session runs (a subagent, in the background or not, or a teammate while it is running) shows up behind the hero for as long as it works: a kitten trotting after the cat, or a summoned minion following the adventurer (an imp, a slime, a hornet, a spider or a raven, by its place in the line, so each agent keeps its creature). Flyers hover, the rest keep to the ground. They join in, too: kittens pounce with the cat, and summons charge the foe in a fight, with a damage number for every hit. When the hero turns they walk round to its back; near the edge of the lane, where there is no room behind it, they wait in front of it instead, and while it sleeps they settle past the foot of its bed. When an agent finishes, its follower hops and vanishes in a puff. Up to five are drawn, then "+N" (the terminal draws one for every 30 columns). Only this session's agents count; a workflow's agents are not counted. /cat shows how many are working.
Pixel Cat ships from the matthlh/claude-mods marketplace, together with Token Tycoon and Purple Dark. You need a Claude Code version that supports mods (October 2026 or later); the same steps work on macOS, Linux and Windows. At the Claude Code prompt, type:
/plugin marketplace add matthlh/claude-mods
/plugin install pixel-cat@matthlh
The cat shows up right away, and in every new session after that, desktop app included. Or from a shell:
claude plugin marketplace add matthlh/claude-mods
claude plugin install pixel-cat@matthlh
Update with claude plugin update pixel-cat@matthlh, remove with claude plugin uninstall pixel-cat@matthlh.
No cat? Mods are still rolling out. Add "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" to the env block of ~/.claude/settings.json and start a new session (quit the desktop app fully, not just the window).
To change the cat, run your own copy instead:
git clone https://github.com/matthlh/claude-mods ~/.claude/mods/claude-mods
claude --plugin-dir ~/.claude/mods/claude-mods/plugins/pixel-cat
To load the clone in every session, including the desktop app, put the plugin folder's full path in CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json; the top-level README has the per-OS paths.
The sprites are 12×10 grids of letters in hooks/packs/cat/sprites.ts:
| Letter | Color |
|---|---|
o | fur (Claude orange) |
d | shade |
H / K | eye shine / eye |
r | blush |
n | nose |
w | chest |
Edit the grids or the COATS to make a tuxedo, calico or black cat. In an interactive session, saving the file hot-reloads the mod.
Everything the cat is lives in one pack, hooks/packs/cat/: its sprites, coats, scenes, hats and activities. The lane machinery in hooks/engine/ reads a pack through the interface in hooks/packs/types.ts, so a new pack is one folder plus an import and an entry in admit([...]) in hooks/packs/index.ts. The engine owns what every pack shares: the time of day (night is 9pm–5am), blinks, the room above the sprite where hats go, and the four milestones that unlock a hat; a pack brings one hat for each. An activity is one entry in the pack's activities: its weights, how it moves, its pose, what it leads into, its lines, and its drawing on both surfaces. The engine names only two activity ids itself, bedtime's bed and perch; they are reserved, and hooks/packs/index.ts refuses a pack that defines either, or whose roles name an activity it doesn't have.
Check your changes with:
claude plugin validate ~/.claude/mods/claude-mods/plugins/pixel-cat
claude plugin test ~/.claude/mods/claude-mods/plugins/pixel-cat
npx -p typescript tsc -p ~/.claude/mods/claude-mods/plugins/pixel-cat
The last line type-checks the mod with the engine's strict settings (Claude Code writes them to .claude-plugin/types/ when it loads the mod; the plugin's tsconfig.json extends them, leaving out the frozen golden test). The tests draw the band on the desktop and terminal surfaces, press the buttons, and draw every activity of every pack and the bed in every scene, so a drawing the engine would refuse fails there instead of silently disappearing. They also follow every activity's then and fail on one that leads nowhere, since a typo there would otherwise become a plain wander.
hooks/packs/adventurer/ to hooks/packs/<yours>/. In its index.ts, rename the export (export const yours: Pack) and give it a new id, label and noun.hooks/engine/track.ts: a sprite, where it sits and how it moves over the leg, drawn the same on both surfaces.The Adventurer's own sprites, every pose, tool, foe, block and prop, are on one sheet to start from:
hooks/packs/index.ts: import { yours } from './<yours>/index' and add it to admit([cat, adventurer, yours]). claude plugin validate ~/.claude/mods/claude-mods/plugins/pixel-cat
claude plugin test ~/.claude/mods/claude-mods/plugins/pixel-cat
The tests pick up every registered pack on their own: each activity is drawn in every scene, at several moments through its leg, on both surfaces, and each of those desktop drawings has to stay under the 131,072-character limit.
hooks/register.tsx 668 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Follower, HeroState, Identity, Limits, Look, Mood, Motion, Phase, Prefs, Speed, Stats, ToolProp } from '../types'
5import { nextLeg, planLeg, strollPace } from './engine/brain'
6import { laneCells as drawCells } from './engine/cells'
7import { CREW_MAX, extendTrail, isWorking, muster, terminalCap, workingCount } from './engine/crew'
8import type { Trail } from './engine/crew'
9import { DESKTOP_PX_PER_COLUMN, HERO_COLS, LANE_H, LANE_ROWS, SLIDE_MS } from './engine/geometry'
10import { HAT_TIERS, hatLabel, hatsOf, unlockedHats } from './engine/hats'
11import { activityOf, coatOf, lookOf } from './engine/lane'
12import type { Extras } from './engine/lane'
13import { IN_BED, TO_BED, isWalking, legDir, posAt } from './engine/motion'
14import { laneSvg as drawSvg } from './engine/svg'
15import { phaseAt } from './engine/time'
16import { COATS } from './packs/cat/sprites'
17import { DEFAULT_PACK, PACKS, packFor } from './packs/index'
18import type { HatDef, Pack, Palette } from './packs/types'
19
20export type { Extras }
21
22// ── State ────────────────────────────────────────────────────────────────
23// The hooks only write state when something changes (a mood, a line, a new
24// leg of the walk). The frames in between are drawn by the surfaces: the
25// desktop SVG animates itself, and the terminal lane is repainted in place.
26
27// It says hello from the first frame, before session.start has run.
28const cat = atom({ plugin: 'pixel-cat', key: 'cat' } as const, {
29 mood: 'idle',
30 dir: 1,
31 say: DEFAULT_PACK.text.hello,
32 sayAt: 0,
33} as HeroState)
34const motion = atom({ plugin: 'pixel-cat', key: 'motion' } as const, {
35 from: 0.1,
36 to: 0.1,
37 t0: 0,
38 dur: 0,
39} as Motion)
40const limits = atom({ plugin: 'pixel-cat', key: 'limits' } as const, {
41 fiveHour: null,
42 sevenDay: null,
43 context: null,
44 fiveHourResets: null,
45} as Limits)
46const isHidden = atom({ plugin: 'pixel-cat', key: 'isHidden' } as const, false)
47// Saved prefs from before packs had no `pack`, and load as the default one.
48const DEFAULT_PREFS: Prefs = {
49 pack: DEFAULT_PACK.id,
50 coat: DEFAULT_PACK.defaults.coat,
51 speed: 'normal',
52 scene: DEFAULT_PACK.defaults.scene,
53 popups: true,
54 showUsage: true,
55 hat: 'none',
56}
57const prefs = atom({ plugin: 'pixel-cat', key: 'prefs' } as const, DEFAULT_PREFS)
58const isSettingsOpen = atom({ plugin: 'pixel-cat', key: 'isSettingsOpen' } as const, false)
59const identity = atom({ plugin: 'pixel-cat', key: 'identity' } as const, { marking: 'none', shiny: false } as Identity)
60const stats = atom({ plugin: 'pixel-cat', key: 'stats' } as const, { turns: 0, tools: 0 } as Stats)
61// This session's working agents. Session state only: every open session would
62// write it once a second, so it never goes near the store (prefs, hats, stats).
63const crew = atom({ plugin: 'pixel-cat', key: 'crew' } as const, [] as Follower[])
64
65const BRAIN_MS = 1000 // how often the hero decides what to do next
66const BLIT_MS = 100 // terminal repaint rate
67const NAP_AFTER_MS = 5 * 60_000
68const ANNOUNCE_AFTER_S = 20
69const HUNGRY_AT = 85 // context percent
70
71// The lane drawings, for the tests: the cat unless `pack` names another.
72export function laneSvg(c: HeroState, m: Motion, now: number, coat: Palette, scene: string, x: Extras & { pack?: string } = {}): string {
73 return drawSvg(packFor(x.pack), c, m, now, coat, scene, x)
74}
75
76export function laneCells(c: HeroState, m: Motion, now: number, coat: Palette, cols: number, scene: string, x: Extras & { pack?: string } = {}): string {
77 return drawCells(packFor(x.pack), c, m, now, coat, cols, scene, x)
78}
79
80// The cat's coats by name, for the tests.
81export const PALETTES = COATS
82
83// What the hero works with for each kind of tool Claude runs.
84function toolProp(name: string): ToolProp | null {
85 if (/^(Read|NotebookRead)$/.test(name)) return 'read'
86 if (/Edit|Write/.test(name)) return 'edit'
87 if (/^(Bash|BashOutput|Shell|PowerShell)$/.test(name)) return 'bash'
88 if (/Grep|Glob|Search|Web|Fetch|^LS$/i.test(name)) return 'search'
89 return null
90}
91
92// A look carried into a pack: kept where the pack has it, else its default.
93function fit(look: Look, pack: Pack): Look {
94 return {
95 coat: Object.hasOwn(pack.coats, look.coat) ? look.coat : pack.defaults.coat,
96 scene: Object.hasOwn(pack.scenes, look.scene) ? look.scene : pack.defaults.scene,
97 hat: pack.hats.some(h => h.id === look.hat) ? look.hat : 'none',
98 }
99}
100
101// Switching packs remembers the pack being left's coat, scene and hat, and
102// puts back the new pack's own as it was left. A pack never worn before
103// keeps whatever the old look has that it has too, and its defaults.
104export function withPack(p: Prefs, pack: Pack): Prefs {
105 const { looks, ...rest } = p
106 const mine: Look = { coat: p.coat, scene: p.scene, hat: p.hat }
107 const saved = looks?.[pack.id]
108 const others = Object.fromEntries(Object.entries(looks ?? {}).filter(([id]) => id !== pack.id))
109 return { ...rest, pack: pack.id, ...fit(saved ?? mine, pack), looks: { ...others, [p.pack]: mine } }
110}
111
112// The next item in a list after `v`, round to the first.
113function nextOf<T>(list: [T, ...T[]], v: T): T {
114 return list[(list.indexOf(v) + 1) % list.length] ?? list[0]
115}
116
117function title(w: string): string {
118 return w.charAt(0).toUpperCase() + w.slice(1)
119}
120
121// A random item of a list, or the fallback when it is empty.
122function pick<T>(list: readonly T[], fallback: T): T {
123 return list[Math.floor(Math.random() * list.length)] ?? fallback
124}
125
126const SPEED_CYCLE: [Speed, ...Speed[]] = ['chill', 'normal', 'zoomies']
127// A list's keys as a non-empty list, or the fallback when it has none.
128const keysOr = (o: object, fallback: string): [string, ...string[]] => {
129 const keys = Object.keys(o)
130 return keys.length ? (keys as [string, ...string[]]) : [fallback]
131}
132
133function pctColor(p: number | null): string | undefined {
134 if (p === null) return undefined
135 return p >= 90 ? 'red' : p >= 70 ? 'yellow' : 'green'
136}
137
138function fmtReset(iso: string | null): string {
139 if (!iso) return ''
140 const mins = Math.max(0, Math.round((Date.parse(iso) - Date.now()) / 60_000))
141 return mins >= 60 ? ` (resets ${Math.floor(mins / 60)}h${mins % 60}m)` : ` (resets ${mins}m)`
142}
143
144// Buttons must carry an onPress, but the work happens in the ui.press hooks
145// below, matched by key, so a press never depends on one drawing's closure.
146function ignorePress() {}
147
148async function activePack($: EngineInterface): Promise<Pack> {
149 return packFor((await read($, prefs)).pack)
150}
151
152async function toast($: EngineInterface, text: string) {
153 if ((await read($, prefs)).popups) $.ui.toast(text)
154}
155
156// Counts a finished task or a tool call; answers the active pack's hats that
157// just unlocked. Unlocks are the engine's tiers, shared by every pack, so the
158// other packs' hats for the same tier unlock with it: a switch finds them
159// already there, under the same hint /cat gives.
160async function bumpStats($: EngineInterface, fn: (s: Stats) => Stats): Promise<HatDef[]> {
161 const before = await read($, stats)
162 const after = fn(before)
163 await update($, stats, () => after)
164 await $.store.set('stats', after)
165 return hatsOf(await activePack($)).filter(h => !h.need(before) && h.need(after))
166}
167
168async function setPrefs($: EngineInterface, fn: (p: Prefs) => Prefs) {
169 await update($, prefs, fn)
170 await $.store.set('prefs', await read($, prefs))
171}
172
173// ── Hooks ────────────────────────────────────────────────────────────────
174
175export const register: Register = on => {
176 let lastActivity = 0
177 let turnStartedAt = 0
178 let sayUntil = 0
179 let moodUntil = 0
180 const warned = new Set<string>()
181 // Lines queued for later in a leg ("hey!! come back" after the bird escapes).
182 let cues: { at: number; text: string }[] = []
183 // Where the terminal lane is mounted, for in-place repaints.
184 let site: { requestId: string; cols: number } | null = null
185 // The last few legs drawn, and which way the hero faced on each: the
186 // followers' path, which they finish while their lag runs out. Seen by the
187 // drawings, so it needs no state of its own; lost on a reload, it costs a
188 // step at most.
189 let path: Trail[] = []
190 const trailOf = (c: HeroState, m: Motion, now: number): Trail[] => (path = extendTrail(path, m, legDir(m, c.dir), now))
191 // Assigned in session.start, where the timers live.
192 let wake: (mood: Mood, say: string | null, holdMs: number, prop?: ToolProp | null) => Promise<void> = async () => {}
193 let hungry = false
194
195 on('session.start', async ($, e, next) => {
196 const saved = (await $.store.get('prefs')) as Partial<Prefs> | undefined
197 if (saved) await update($, prefs, () => ({ ...DEFAULT_PREFS, ...saved }))
198 const pack = await activePack($)
199 // Registered once, so it names no pack: switching packs needs no restart.
200 await $.command.register({
201 name: 'cat',
202 description: 'Show or hide the pixel pet above the prompt (and see your usage limits)',
203 })
204 lastActivity = await $.clock.now()
205 sayUntil = lastActivity + 5000
206 // Restart the greeting's typewriter; in another pack, greet in its words.
207 // A later session.start says nothing new once the hello has expired.
208 await update($, cat, c => ({ ...c, say: c.say === DEFAULT_PACK.text.hello ? pack.text.hello : c.say, sayAt: lastActivity }))
209
210 // A new leg: what the hero does next, where to, and for how long. The
211 // brain is woken the moment it ends, so the next leg follows on without a
212 // gap (the terminal draws a leg's layers only while it runs).
213 const plan = async (now: number, mood: Mood, from: number, activity: string, chain?: number, phase?: Phase) => {
214 const p = await read($, prefs)
215 const { leg, dir } = planLeg(packFor(p.pack), now, mood, from, activity, p.speed, chain, phase)
216 await update($, motion, () => leg)
217 if (dir) await update($, cat, c => ({ ...c, dir }))
218 if (leg.dur > 0 && Number.isFinite(leg.dur)) {
219 $.clock.after(Math.max(1, Math.ceil(leg.t0 + leg.dur - (await $.clock.now())) + 1), () => void brain())
220 }
221 }
222
223 const say = async (now: number, text: string) => {
224 sayUntil = now + 2500
225 await update($, cat, x => ({ ...x, say: text, sayAt: now }))
226 }
227
228 // Once a second, and when a leg ends: expire lines and moods, nap, and
229 // pick the next leg. One at a time: a call while one runs runs again after.
230 let thinking = false
231 let again = false
232 const brain = async () => {
233 if (thinking) {
234 again = true
235 return
236 }
237 thinking = true
238 try {
239 do {
240 again = false
241 await think()
242 } while (again)
243 } finally {
244 thinking = false
245 }
246 }
247 const think = async () => {
248 const now = await $.clock.now()
249 const c = await read($, cat)
250 const m = await read($, motion)
251 const p = await read($, prefs)
252 const pack = packFor(p.pack)
253 const fiveHour = (await read($, limits)).fiveHour ?? 0
254 const isLow = fiveHour >= 80
255 const isOut = fiveHour >= 100
256 const here = posAt(m, now)
257 const ctx = (await read($, limits)).context ?? 0
258 const phase = phaseAt(new Date().getHours())
259 let mood = c.mood
260
261 if (c.say && now > sayUntil) await update($, cat, x => ({ ...x, say: null }))
262 const due = cues.filter(q => q.at <= now)
263 cues = cues.filter(q => q.at > now)
264 for (const q of due) await say(now, q.text)
265 if (now > moodUntil && (mood === 'done' || mood === 'oops' || mood === 'pet')) mood = 'sit'
266
267 // Bedtime: after a long quiet spell, or when the 5-hour limit runs
268 // out, the hero walks back to its bed (which slides in) and sleeps.
269 const isBedtime = now - lastActivity > NAP_AFTER_MS || (isOut && now > moodUntil)
270 if (mood !== 'working' && mood !== 'sleep' && isBedtime) {
271 cues = []
272 const dur = Math.max(SLIDE_MS + 200, (here / strollPace(p.speed)) * 1000)
273 sayUntil = now + 3000
274 await update($, cat, (x): HeroState => ({ ...x, mood: 'sleep', dir: -1, say: isOut ? pack.text.outOfJuice : pack.text.bedtime, sayAt: now }))
275 await update($, motion, () => ({ from: here, to: 0, t0: now, dur, activity: TO_BED }))
276 return
277 }
278 if (mood === 'sleep') {
279 // Arrived: hop up and curl up in bed.
280 if (m.activity === TO_BED && now >= m.t0 + m.dur) {
281 await update($, motion, () => ({ from: 0, to: 0, t0: now, dur: 0, activity: IN_BED }))
282 }
283 return
284 }
285
286 if (ctx < HUNGRY_AT) hungry = false
287 if (now >= m.t0 + m.dur) {
288 // From the moment the last leg ended, so a chained leg starts where
289 // the one before stopped (the brain is woken for it, a millisecond or
290 // so late). A leg that ended a while back, before this session or
291 // before a tick that came late, is followed from now instead, so the
292 // next one does not jump ahead.
293 const end = m.t0 + m.dur
294 const start = now - end <= 250 ? end : now
295 let activity = pack.roles.stroll
296 let chain: number | undefined
297 let legPhase = phase
298 if (mood !== 'working') {
299 const n = nextLeg(pack, m, { isHungry: ctx >= HUNGRY_AT && !hungry, isLow, isLate: phase === 'night', isSaying: !!c.say })
300 activity = n.activity
301 chain = n.chain
302 mood = n.mood
303 if (n.carriesOn && m.phase) legPhase = m.phase
304 if (n.saidHungry) hungry = true
305 if (n.line) await say(now, n.line)
306 for (const [ms, text] of n.cues) cues.push({ at: start + ms, text })
307 }
308 await plan(start, mood, posAt(m, start), activity, chain, legPhase)
309 }
310 if (mood !== c.mood) await update($, cat, x => ({ ...x, mood }))
311 }
312
313 // Terminal only: repaint the lane in place, ten times a second.
314 const repaint = async () => {
315 if (!site || (await read($, isHidden))) return
316 const now = await $.clock.now()
317 const p = await read($, prefs)
318 const pack = packFor(p.pack)
319 const c = await read($, cat)
320 const m = await read($, motion)
321 const extras: Extras = {
322 ctx: (await read($, limits)).context,
323 identity: await read($, identity),
324 hat: p.hat,
325 hour: new Date().getHours(),
326 crew: await read($, crew),
327 trail: trailOf(c, m, now),
328 }
329 const cells = drawCells(pack, c, m, now, coatOf(pack, p.coat), site.cols, p.scene, extras)
330 const res = await $.ui.blit({ requestId: site.requestId, key: 'lane', cells, columns: site.cols, rows: LANE_ROWS })
331 if ('deny' in res && res.deny) site = null
332 }
333
334 wake = async (mood: Mood, say: string | null, holdMs: number, prop?: ToolProp | null) => {
335 const now = await $.clock.now()
336 const { roles } = await activePack($)
337 lastActivity = now
338 cues = []
339 const wasAsleep = (await read($, cat)).mood === 'sleep'
340 if (say) sayUntil = now + Math.max(holdMs, 3000)
341 moodUntil = now + holdMs
342 const m = await read($, motion)
343 const here = posAt(m, now)
344 if (mood === 'working' && prop) {
345 // Sit down with the right prop, facing the side with room for it.
346 await update($, motion, () => ({ from: here, to: here, t0: now, dur: holdMs, activity: roles.work }))
347 await update($, cat, (c): HeroState => ({ ...c, dir: here < 0.5 ? 1 : -1 }))
348 } else if (mood === 'working') {
349 if (!isWalking(m, now) || m.activity !== roles.stroll) await plan(now, 'working', here, roles.stroll)
350 } else {
351 await update($, motion, () => ({ from: here, to: here, t0: now, dur: holdMs, activity: roles.rest }))
352 }
353 await update($, cat, c => ({
354 ...c,
355 mood,
356 say: say ?? c.say,
357 sayAt: say ? now : c.sayAt,
358 wokeAt: wasAsleep ? now : c.wokeAt,
359 prop: mood === 'working' ? (prop ?? null) : null,
360 }))
361 }
362
363 // This hero's own look, rolled once: a marking, and a 1 in 50 chance of
364 // shiny. Every pack shares it; lookOf maps the marking into each pack's own.
365 let who = (await $.store.get('identity')) as Identity | undefined
366 if (!who) {
367 who = { marking: pick(pack.markings, 'none'), shiny: Math.random() < 1 / 50 }
368 await $.store.set('identity', who)
369 }
370 await update($, identity, () => who as Identity)
371 const counts = (await $.store.get('stats')) as Stats | undefined
372 if (counts) await update($, stats, () => ({ turns: counts.turns ?? 0, tools: counts.tools ?? 0 }))
373
374 // Once a tick too: who is working, from this session's agent list.
375 let mustering = false
376 const roll = async () => {
377 if (mustering) return
378 mustering = true
379 try {
380 const agents = await $.agent.list()
381 const before = await read($, crew)
382 // On a narrow terminal, a place in sight goes to one waiting out of it.
383 const after = muster(before, agents, await $.clock.now(), site ? terminalCap(site.cols) : CREW_MAX)
384 if (after !== before) await update($, crew, () => after)
385 } catch {
386 // No list this tick (not bound yet, say): the crew stays as it was.
387 } finally {
388 mustering = false
389 }
390 }
391
392 $.clock.every(BRAIN_MS, () => {
393 void brain()
394 void roll()
395 })
396 $.clock.every(BLIT_MS, () => void repaint())
397
398 const usage = await $.session.usage()
399 const five = usage.rateLimits.find(r => r.kind === 'five_hour')
400 await update($, limits, () => ({
401 fiveHour: five?.percentUsed ?? null,
402 sevenDay: usage.rateLimits.find(r => r.kind === 'seven_day')?.percentUsed ?? null,
403 context: usage.context.percent ?? null,
404 fiveHourResets: five?.resetsAt ?? null,
405 }))
406
407 return next(e)
408 })
409
410 on('prompt.submit', async ($, e, next) => {
411 turnStartedAt = await $.clock.now()
412 await wake('working', 'on it!', 60 * 60_000)
413
414 return next(e)
415 })
416
417 on('tool.call', async ($, e, next) => {
418 const name = e.tool.replace(/^mcp__[^_]+(?:_[^_]+)*__/, '')
419 const prop = toolProp(name)
420 await wake('working', `${name}…`, 60 * 60_000, prop)
421 const pack = await activePack($)
422 for (const h of await bumpStats($, s => ({ ...s, tools: s.tools + 1 }))) {
423 await toast($, pack.toasts.hat(h.label))
424 }
425 const ran = await next(e)
426 if (ran.deny === undefined && ran.isError === true) {
427 await wake('oops', `${name} failed`, 4000)
428 await wake('working', null, 60 * 60_000, prop)
429 }
430
431 return ran
432 })
433
434 on('turn.complete', async ($, e, next) => {
435 const secs = Math.round(((await $.clock.now()) - turnStartedAt) / 1000)
436 const pack = await activePack($)
437 if (e.reason === 'answer') {
438 await wake('done', pack.text.done(secs), 8000)
439 for (const h of await bumpStats($, s => ({ ...s, turns: s.turns + 1 }))) {
440 await toast($, pack.toasts.hat(h.label))
441 await wake('pet', `new hat: ${h.label}!`, 4000)
442 }
443 if (secs >= ANNOUNCE_AFTER_S) await toast($, pack.toasts.finished(secs))
444 } else if (e.reason === 'aborted') {
445 await wake('sit', 'ok, stopped', 4000)
446 } else {
447 await wake('oops', pack.text.oops, 6000)
448 await toast($, pack.toasts.failed)
449 }
450
451 return next(e)
452 })
453
454 on('session.measure', async ($, e, next) => {
455 const pack = await activePack($)
456 const find = (kind: string) => e.rateLimits.find(r => r.kind === kind)
457 const five = find('five_hour')
458 const week = find('seven_day')
459 const before = (await read($, limits)).context
460 const after = e.context.percent ?? null
461 if (before !== null && after !== null && before - after >= 20) await wake('pet', pack.text.fed, 3000)
462 await update($, limits, () => ({
463 fiveHour: five?.percentUsed ?? null,
464 sevenDay: week?.percentUsed ?? null,
465 context: e.context.percent ?? null,
466 fiveHourResets: five?.resetsAt ?? null,
467 }))
468
469 for (const [label, pct] of [['5h', five?.percentUsed], ['7d', week?.percentUsed], ['context', e.context.percent]] as const) {
470 for (const at of [75, 90]) {
471 const id = `${label}-${at}`
472 if ((pct ?? 0) >= at && !warned.has(id)) {
473 warned.add(id)
474 await toast($, pack.toasts.usage(label, pct))
475 await wake('tired', `${label} at ${pct}%…`, 6000)
476 }
477 }
478 }
479
480 return next(e)
481 })
482
483 on('command.run', { command: 'cat' }, async $ => {
484 const pack = await activePack($)
485 const wasHidden = await read($, isHidden)
486 await update($, isHidden, () => !wasHidden)
487 const l = await read($, limits)
488 const who = await read($, identity)
489 const st = await read($, stats)
490 const owned = unlockedHats(pack, st).map(id => hatLabel(pack, id))
491 const nextHat = hatsOf(pack).find(h => !h.need(st))
492 const pct = (p: number | null) => (p === null ? 'n/a' : `${p}%`)
493 let working = workingCount(await read($, crew))
494 try {
495 working = (await $.agent.list()).filter(a => isWorking(a.status)).length
496 } catch {
497 // The last tick's count, then.
498 }
499 return {
500 text: [
501 wasHidden ? pack.text.shown : pack.text.hidden,
502 `5-hour limit: ${pct(l.fiveHour)}${fmtReset(l.fiveHourResets)}`,
503 `7-day limit: ${pct(l.sevenDay)}`,
504 `Context: ${pct(l.context)}`,
505 `Agents working: ${working} (counts this session's agents only)`,
506 '',
507 `Your ${pack.noun}: ${pack.text.look(lookOf(pack, who))}${who.shiny ? ', ✨ shiny (1 in 50)!' : ''}`,
508 `Tasks finished: ${st.turns}, tool calls: ${st.tools}`,
509 `Hats: ${owned.length ? owned.join(', ') : 'none yet'}${nextHat ? ` (next: ${nextHat.label} at ${nextHat.hint})` : ''}`,
510 ].join('\n'),
511 }
512 })
513
514 on('ui.press', { plugin: 'pixel-cat', element: 'pet' }, async $ => {
515 const { text } = await activePack($)
516 const asleep = (await read($, cat)).mood === 'sleep'
517 await wake('pet', pick(asleep ? text.petAsleep : text.pet, '♥'), 3000)
518 return { element: 'pet' }
519 })
520
521 on('ui.press', { plugin: 'pixel-cat', element: 'hide' }, async $ => {
522 await update($, isHidden, () => true)
523 return { element: 'hide' }
524 })
525
526 on('ui.press', { plugin: 'pixel-cat', element: 'settings' }, async $ => {
527 await update($, isSettingsOpen, open => !open)
528 return { element: 'settings' }
529 })
530
531 on('ui.press', { plugin: 'pixel-cat', element: 'set-pack' }, async $ => {
532 const pack = packFor(nextOf(keysOr(PACKS, DEFAULT_PACK.id), (await read($, prefs)).pack))
533 await setPrefs($, p => withPack(p, pack))
534 await wake('pet', pack.text.hello, 2500)
535 return { element: 'set-pack' }
536 })
537
538 on('ui.press', { plugin: 'pixel-cat', element: 'set-coat' }, async $ => {
539 const pack = await activePack($)
540 await setPrefs($, p => ({ ...p, coat: nextOf(keysOr(pack.coats, pack.defaults.coat), p.coat) }))
541 await wake('pet', 'new look!', 2500)
542 return { element: 'set-coat' }
543 })
544
545 on('ui.press', { plugin: 'pixel-cat', element: 'set-speed' }, async $ => {
546 await setPrefs($, p => ({ ...p, speed: nextOf(SPEED_CYCLE, p.speed) }))
547 return { element: 'set-speed' }
548 })
549
550 on('ui.press', { plugin: 'pixel-cat', element: 'set-scene' }, async $ => {
551 const pack = await activePack($)
552 await setPrefs($, p => ({ ...p, scene: nextOf(keysOr(pack.scenes, pack.defaults.scene), p.scene) }))
553 return { element: 'set-scene' }
554 })
555
556 on('ui.press', { plugin: 'pixel-cat', element: 'set-popups' }, async $ => {
557 await setPrefs($, p => ({ ...p, popups: !p.popups }))
558 return { element: 'set-popups' }
559 })
560
561 on('ui.press', { plugin: 'pixel-cat', element: 'set-hat' }, async $ => {
562 const pack = await activePack($)
563 const owned = unlockedHats(pack, await read($, stats))
564 if (owned.length === 0) {
565 const hint = pack.hats.length ? HAT_TIERS[0]?.hint : undefined
566 await wake('sit', hint ? `no hats yet! (${hint})` : 'no hats here!', 3500)
567 } else {
568 await setPrefs($, p => ({ ...p, hat: nextOf(['none', ...owned], owned.includes(p.hat) ? p.hat : 'none') }))
569 await wake('pet', 'fancy!', 2500)
570 }
571 return { element: 'set-hat' }
572 })
573
574 on('ui.press', { plugin: 'pixel-cat', element: 'set-usage' }, async $ => {
575 await setPrefs($, p => ({ ...p, showUsage: !p.showUsage }))
576 return { element: 'set-usage' }
577 })
578
579 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
580 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
581
582 const { Box, Text, Button, Raster, Svg } = $.ui.resolve(e) as any
583 const now = await $.clock.now()
584 const c = await read($, cat)
585 const m = await read($, motion)
586 const l = await read($, limits)
587 const pr = await read($, prefs)
588 const isSetting = await read($, isSettingsOpen)
589 const who = await read($, identity)
590 const st = await read($, stats)
591 const pack = packFor(pr.pack)
592 const extras: Extras = {
593 ctx: l.context,
594 identity: who,
595 hat: pr.hat,
596 hour: new Date().getHours(),
597 crew: await read($, crew),
598 trail: trailOf(c, m, now),
599 }
600 const usageNote = now < m.t0 + m.dur ? activityOf(pack, m.activity)?.usageNote : undefined
601 const coat = coatOf(pack, pr.coat)
602 const columns = e.viewport?.columns ?? 80
603
604 const laneW = Math.max(240, Math.round((columns - 2) * DESKTOP_PX_PER_COLUMN))
605 let lane
606 if (e.surface === 'terminal') {
607 const cols = Math.max(HERO_COLS + 1, Math.min(512, columns - 2))
608 site = { requestId: e.requestId, cols }
609 lane = <Raster key="lane" columns={cols} rows={LANE_ROWS} cells={drawCells(pack, c, m, now, coat, cols, pr.scene, extras)} />
610 } else {
611 lane = (
612 <Svg
613 source={drawSvg(pack, c, m, now, coat, pr.scene, { ...extras, laneW })}
614 alt={`Claude ${pack.noun} (${c.mood})${c.say ? `: ${c.say}` : ''}`}
615 width={laneW}
616 height={LANE_H}
617 isInteractive
618 />
619 )
620 }
621
622 const fmt = (label: string, p: number | null) =>
623 p === null ? null : (
624 <Text dimColor={p < 70} color={pctColor(p)}>
625 {label} {p}%{' '}
626 </Text>
627 )
628
629 return (
630 <Box flexDirection="column" paddingX={1}>
631 <Box flexDirection="row" width="100%">
632 {lane}
633 </Box>
634 <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={2}>
635 {isSetting ? (
636 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
637 {Object.keys(PACKS).length > 1 ? <Button key="set-pack" label={`Pack: ${pack.label}`} onPress={ignorePress} /> : null}
638 <Button key="set-coat" label={`${pack.coatLabel}: ${title(pr.coat)}`} onPress={ignorePress} />
639 <Button key="set-speed" label={`Speed: ${title(pr.speed)}`} onPress={ignorePress} />
640 <Button key="set-scene" label={`Scene: ${pack.scenes[pr.scene]?.label ?? title(pr.scene)}`} onPress={ignorePress} />
641 <Button key="set-hat" label={unlockedHats(pack, st).length ? `Hat: ${hatLabel(pack, pr.hat)}` : 'Hat: 🔒'} onPress={ignorePress} />
642 <Button key="set-popups" label={`Popups: ${pr.popups ? 'On' : 'Off'}`} onPress={ignorePress} />
643 <Button key="set-usage" label={`Usage: ${pr.showUsage ? 'On' : 'Off'}`} onPress={ignorePress} />
644 </Box>
645 ) : pr.showUsage && usageNote ? (
646 <Text dimColor wrap="truncate-end">
647 {usageNote}
648 </Text>
649 ) : pr.showUsage ? (
650 <Text wrap="truncate-end">
651 {fmt('5h', l.fiveHour)}
652 {fmt('7d', l.sevenDay)}
653 {fmt('ctx', l.context)}
654 </Text>
655 ) : (
656 <Text> </Text>
657 )}
658 <Box flexDirection="row" columnGap={1} flexShrink={0}>
659 <Button key="pet" label="Pet ♥" onPress={ignorePress} />
660 <Button key="settings" label={isSetting ? 'Done' : '⚙'} onPress={ignorePress} />
661 <Button key="hide" label="Hide" onPress={ignorePress} />
662 </Box>
663 </Box>
664 </Box>
665 )
666 })
667}
668hooks/engine/brain.ts 145 lines1import type { Mood, Motion, Phase, Speed } from '../../types'
2import type { Dir, Move, Pack } from '../packs/types'
3import { clamp01 } from './draw'
4import { activityOf, hasCompanion } from './lane'
5
6// Lane speed in lane-widths per second.
7const PACE: Record<Speed, number> = { chill: 0.03, normal: 0.06, zoomies: 0.18 }
8const RUN_PACE: Record<Speed, number> = { chill: 0.12, normal: 0.2, zoomies: 0.35 }
9const TIRED_PACE = 0.025
10
11export function strollPace(speed: Speed): number {
12 return PACE[speed]
13}
14
15// A random value from lo to hi.
16function between([lo, hi]: [number, number]): number {
17 return lo + Math.random() * (hi - lo)
18}
19
20// Anywhere, but a good stretch from here.
21function wander(from: number): number {
22 const to = Math.random()
23 if (Math.abs(to - from) >= 0.2) return to
24 return from < 0.5 ? from + 0.3 + Math.random() * 0.4 : from - 0.3 - Math.random() * 0.4
25}
26
27// Where a moving leg ends, from where it starts.
28function destination(move: Exclude<Move, { stay: unknown }> | undefined, from: number): number {
29 if (!move) return wander(from)
30 if ('spot' in move) return move.spot
31 if ('near' in move) return from < 0.5 ? from + between(move.near) : from - between(move.near)
32 // A short dash this way or that, turning back at the edges.
33 const sign = Math.random() < 0.5 ? 1 : -1
34 const to = from + sign * between(move.zip)
35 return to < 0.05 || to > 0.95 ? from - sign * between(move.zip) : to
36}
37
38// A new leg: what the hero does next, where to, and for how long; and which
39// way it then faces, when that changes. `phase`, when given, is the time of
40// day the leg belongs to (Motion.phase).
41export function planLeg(
42 pack: Pack,
43 now: number,
44 mood: Mood,
45 from: number,
46 activity: string,
47 speed: Speed,
48 chain?: number,
49 phase?: Phase,
50): { leg: Motion; dir?: Dir } {
51 const a = activityOf(pack, activity)
52 const move = a?.move
53 // Whether it lands, on any leg: rolled last, so an activity without a hit
54 // chance draws no extra dice.
55 const landed = () => (a?.hit === undefined ? undefined : Math.random() < a.hit)
56 if (move && 'stay' in move) {
57 const dur = typeof move.stay === 'number' ? move.stay : between(move.stay)
58 const hit = landed()
59 const leg: Motion = { from, to: from, t0: now, dur, activity, hit }
60 if (chain !== undefined) leg.chain = chain
61 if (phase !== undefined) leg.phase = phase
62 return { leg, dir: move.faceRoom ? (from < 0.5 ? 1 : -1) : undefined }
63 }
64 const base = mood === 'working' ? RUN_PACE[speed] : mood === 'tired' ? TIRED_PACE : PACE[speed]
65 const pace = mood === 'working' ? base : base * (a?.pace ?? 1)
66 // Keep toys and critters ahead of the hero on screen.
67 const dest = destination(move, from)
68 const to = hasCompanion(a) ? Math.max(0.12, Math.min(0.95, dest)) : clamp01(dest)
69 const dur = Math.max(800, (Math.abs(to - from) / pace) * 1000)
70 const hit = landed()
71 const leg: Motion = hit === undefined ? { from, to, t0: now, dur, activity, chain } : { from, to, t0: now, dur, activity, chain, hit }
72 if (phase !== undefined) leg.phase = phase
73 return { leg, dir: to > from ? 1 : -1 }
74}
75
76type Column = 'day' | 'night' | 'tired'
77
78// A weighted random activity for this column of weights.
79function weightedPick(pack: Pack, column: Column): string {
80 const options = Object.entries(pack.activities)
81 .map(([id, a]) => [id, a.weight?.[column] ?? 0] as const)
82 .filter(([, w]) => w > 0)
83 // Every weight column is optional: a pack with nothing for this one strolls.
84 if (options.length === 0) return pack.roles.stroll
85 let left = Math.random() * options.reduce((sum, [, w]) => sum + w, 0)
86 for (const [id, w] of options) {
87 left -= w
88 if (left <= 0) return id
89 }
90 return options[0]?.[0] ?? pack.roles.stroll
91}
92
93type Choice = { activity: string; chain?: number; line?: string; how: 'then' | 'hunger' | 'repeat' | 'pick' }
94
95function fresh(pack: Pack, column: Column): Choice {
96 const activity = weightedPick(pack, column)
97 const repeat = activityOf(pack, activity)?.repeat
98 return { activity, chain: repeat && repeat[0] + Math.floor(Math.random() * (repeat[1] - repeat[0] + 1)), how: 'pick' }
99}
100
101export type Next = {
102 activity: string
103 chain?: number
104 mood: Mood
105 line: string | null
106 cues: [number, string][]
107 /** the hunger line was said */
108 saidHungry: boolean
109 /** it carries on from the leg before (a follow-up or a repeat), so it keeps that leg's Motion.phase */
110 carriesOn: boolean
111}
112
113// What follows an idle leg. An activity may lead into another (its `then`)
114// or run again (its `repeat`); otherwise a fresh pick, weighted by the time
115// of day and the limit.
116export function nextLeg(pack: Pack, prev: Motion, s: { isHungry: boolean; isLow: boolean; isLate: boolean; isSaying: boolean }): Next {
117 const was = activityOf(pack, prev.activity)
118 const follow = (): Choice | null => {
119 const f = was?.then?.(prev)
120 return f ? { ...f, how: 'then' } : null
121 }
122 const left = prev.chain ?? 0
123 const next: Choice =
124 (was?.thenFirst && follow()) ||
125 (s.isHungry && { activity: pack.roles.rest, line: pack.text.hungry, how: 'hunger' }) ||
126 (was?.repeat && prev.activity && left > 0 && { activity: prev.activity, chain: left - 1, how: 'repeat' }) ||
127 (!was?.thenFirst && follow()) ||
128 fresh(pack, s.isLow ? 'tired' : s.isLate ? 'night' : 'day')
129 const a = activityOf(pack, next.activity)
130 // Now and then, say something about it; always, right after a follow-up.
131 let line = next.line ?? null
132 if (!line && a?.lines && next.how !== 'repeat' && (next.how === 'then' || (!s.isSaying && Math.random() < 0.5))) {
133 line = a.lines[Math.floor(Math.random() * a.lines.length)] ?? null
134 }
135 return {
136 activity: next.activity,
137 chain: next.chain,
138 mood: a?.mood ?? (s.isLow ? 'tired' : 'idle'),
139 line,
140 cues: a?.cues ?? [],
141 saidHungry: next.how === 'hunger',
142 carriesOn: next.how === 'then' || next.how === 'repeat',
143 }
144}
145hooks/engine/cells.ts 212 lines1import type { HeroState, Motion } from '../../types'
2import type { CellsCtx, Pack, Palette, Rows } from '../packs/types'
3import { crewCells, joinOf, joining, terminalCap } from './crew'
4import { faceRight } from './draw'
5import { HAT_PAD, HERO_COLS, LANE_PIX, LANE_ROWS, TERMINAL_DEFAULT } from './geometry'
6import { activityOf, bedShown, hasCompanion, isHappy, isInBed, lane, lookOf, moodColor, QUIET_COLOR, shut, wear } from './lane'
7import type { Extras, Lane } from './lane'
8import { posAt } from './motion'
9import { hourOf, phaseAt } from './time'
10
11// ── Terminal: the lane is one Raster, repainted in place ────────────────
12
13const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
14function base64(bytes: Uint8Array): string {
15 let out = ''
16 for (let i = 0; i < bytes.length; i += 3) {
17 const a = bytes[i] ?? 0, b = bytes[i + 1] ?? 0, c = bytes[i + 2] ?? 0
18 const n = (a << 16) | (b << 8) | c
19 out += B64.charAt((n >> 18) & 63) + B64.charAt((n >> 12) & 63)
20 out += i + 1 < bytes.length ? B64.charAt((n >> 6) & 63) : '='
21 out += i + 2 < bytes.length ? B64.charAt(n & 63) : '='
22 }
23 return out
24}
25
26// Only printable ASCII goes into Raster cells, so nothing is double width.
27function ascii(s: string): string {
28 return s.replace(/…/g, '...').replace(/♥/g, '<3').replace(/[^\x20-\x7e]/g, '')
29}
30
31function terminalLine(c: HeroState, now: number): string | null {
32 if (!c.say) return null
33 let line = ascii(c.say)
34 if (line.length > 28) line = line.slice(0, 25) + '...'
35 const typed = Math.max(1, Math.floor((now - (c.sayAt ?? 0)) / 40))
36 if (typed < line.length) return line.slice(0, typed)
37 if (line.endsWith('...')) line = line.slice(0, -3) + ['. ', '.. ', '...'][Math.floor(now / 300) % 3]
38 if (c.mood === 'pet') line += [' <3', ' <3<3'][Math.floor(now / 400) % 2]
39 if (c.mood === 'done') line = `${Math.floor(now / 300) % 2 ? '*' : '+'} ${line}`
40 return line
41}
42
43// The hero's sprite this frame, facing left, before it's dressed.
44function heroRows(pack: Pack, c: HeroState, l: Lane, now: number): Rows {
45 const { hero } = pack
46 if (l.posture === 'asleep') return hero.asleep
47 let rows: Rows
48 if (l.posture === 'walk') {
49 const stride = c.mood === 'working' ? hero.rush : l.act?.stride ?? hero.stride
50 rows = hero.walk[Math.floor(now / stride) % 2] ?? hero.walk[0]
51 } else {
52 const p = l.pose ?? hero.idle
53 rows = p.frames[Math.floor(now / (p.tick ?? 500)) % p.frames.length] ?? p.frames[0]
54 if (p.shut) return rows
55 }
56 return isHappy(c.mood) || now % 4000 > 3850 ? shut(hero, rows) : rows
57}
58
59export function laneCells(pack: Pack, c: HeroState, m: Motion, now: number, palette: Palette, cols: number, sceneId: string, x: Extras = {}): string {
60 const l = lane(pack, c, m, now, sceneId)
61 const { act, walking, busy, dir, scene } = l
62 const bg = scene.bg
63 // Paint pixels first (cols x 10), then pack two per cell with half blocks.
64 const pixels: (number | undefined)[] = new Array(cols * LANE_PIX)
65 const plot = (rows: Rows, x0: number, y0: number, colors: Palette) => {
66 rows.forEach((row, y) => {
67 for (let x = 0; x < row.length; x++) {
68 const color = colors[row.charAt(x)]
69 const px = x0 + x
70 const py = y0 + y
71 if (color !== undefined && px >= 0 && px < cols && py >= 0 && py < LANE_PIX) pixels[py * cols + px] = color
72 }
73 })
74 }
75
76 const elapsed = now - m.t0
77 const span = Math.max(0, cols - HERO_COLS - 1)
78 const shake = c.mood === 'oops' ? (Math.floor(now / 120) % 2 ? 1 : -1) : 0
79 let cx = Math.max(0, Math.min(span, Math.round(posAt(m, now) * span) + shake))
80 const face = (rows: Rows) => (dir === 1 ? faceRight(rows) : rows)
81
82 // The bed slides in from the left at bedtime.
83 const bed = scene.bed
84 const pw = bed.rows[0]?.length ?? 0
85 const ph = bed.rows.length
86 const shown = bedShown(c, m, now)
87 if (shown !== null) plot(bed.rows, Math.round(1 - (pw + 2) * (1 - shown)), LANE_PIX - ph - (bed.float ? 1 : 0), bed.pal)
88
89 let cy = walking && act?.leap === 'hop' && Math.floor(now / 225) % 2 ? 1 : 0
90 if (busy && !walking && act?.leap === 'pounce' && elapsed > 200) {
91 cx = Math.max(0, Math.min(span, cx + dir * 2)) // pounced
92 }
93 if (isInBed(c, m)) {
94 cx = 1 + Math.floor((pw - HERO_COLS) / 2)
95 cy = -Math.min(4, ph - 1 + (bed.float ? 1 : 0))
96 }
97 // The gauge at the right end shows how much context is left.
98 let laneEnd = cols
99 if (typeof x.ctx === 'number') {
100 const gauge = pack.gauge(x.ctx)
101 laneEnd = cols - (gauge.rows[0]?.length ?? 0) - 1
102 plot(gauge.rows, laneEnd, LANE_PIX - gauge.rows.length, gauge.pal)
103 }
104 // Layers, bottom to top, as on the desktop: what the activity brings
105 // beneath the hero (its stage, in lane columns, and something at its
106 // destination), the followers, then what is ahead of the hero, the hero,
107 // and what is over it. Before packs the terminal plotted the cat first and
108 // these on top, the reverse of the desktop. Beneath on both surfaces is
109 // deliberate; anything that should cover the hero belongs in `over`. The
110 // one drawing that changed: 'caught' under mood 'sleep' with a coat whose
111 // blush differs from the mouse's tail, where the tail now hides under the
112 // sleeping body. The brain never plans that; it can show for one repaint
113 // between the two state writes at bedtime.
114 const hour = hourOf(x.hour ?? 12)
115 const joiningNow = joining(pack, act, m, x.crew, terminalCap(cols))
116 const at = (x0: number, put = plot): CellsCtx => ({
117 leg: m,
118 hero: c,
119 now,
120 dir,
121 scene: l.sceneId,
122 hour,
123 phase: phaseAt(hour),
124 face,
125 x: x0,
126 ahead: (w, gap = 1) => (dir === 1 ? x0 + HERO_COLS + gap : x0 - w - gap),
127 col: p => Math.round(p * span),
128 plot: put,
129 joining: joiningNow,
130 })
131 if (busy && act) {
132 act.stage?.cells(at(0))
133 act.target?.cells(at(Math.round(m.to * span)))
134 }
135 // Asleep in bed, the followers stand past its foot and the z's.
136 const zx = dir === 1 || isInBed(c, m) ? cx + HERO_COLS : cx - 4
137 const tag = crewCells(
138 {
139 art: pack.crew,
140 crew: x.crew,
141 m,
142 trail: x.trail,
143 dir,
144 joinOf: leg => joinOf(pack, activityOf(pack, leg.activity), leg),
145 now,
146 asleep: isInBed(c, m),
147 room: { span, width: laneEnd - 1 }, // clear of the gauge
148 bed: Math.max(1 + pw, 1 + Math.floor((pw - HERO_COLS) / 2) + HERO_COLS + 3), // the bed's foot, the z's end
149 },
150 cols,
151 cx,
152 plot,
153 )
154 if (busy && act) act.draw?.cells(at(cx))
155 const id = lookOf(pack, x.identity)
156 const dressed = wear(pack.hero, heroRows(pack, c, l, now), id.marking, x.hat ?? 'none', l.posture === 'asleep')
157 plot(face(dressed), cx, cy - HAT_PAD, { ...palette, ...pack.hero.hatPal })
158 // Over the hero, moving with its hops.
159 if (busy && act?.over) act.over.cells(at(cx, (rows, x0, y0, pal) => plot(rows, x0, y0 + cy, pal)))
160
161 const grid = new Uint32Array(cols * LANE_ROWS * 3)
162 for (let y = 0; y < LANE_ROWS; y++) {
163 for (let x = 0; x < cols; x++) {
164 const top = pixels[y * 2 * cols + x]
165 const bottom = pixels[(y * 2 + 1) * cols + x]
166 const cell =
167 top === undefined && bottom === undefined ? [0x20, TERMINAL_DEFAULT, bg]
168 : top === undefined ? [0x2584, bottom ?? bg, bg]
169 : [0x2580, top, bottom ?? bg]
170 grid.set(cell, (y * cols + x) * 3)
171 }
172 }
173 const put = (x: number, y: number, ch: string, fg: number) => {
174 if (x < 0 || x >= cols || y < 0 || y >= LANE_ROWS) return
175 grid.set([ch.codePointAt(0) ?? 0x20, fg, bg], (y * cols + x) * 3)
176 }
177
178 if (tag) for (let i = 0; i < tag.text.length; i++) put(tag.x + i, tag.y, tag.text.charAt(i), QUIET_COLOR)
179
180 if (l.posture === 'asleep') {
181 const z = ['z', 'zZ', 'zZz', ' Zz', ' z'][Math.floor(now / 500) % 5] ?? 'z'
182 for (let i = 0; i < z.length; i++) put(zx + i, 1, z.charAt(i), QUIET_COLOR)
183 }
184
185 const line = l.posture === 'asleep' ? null : terminalLine(c, now)
186 if (line) {
187 const tint = moodColor(c.mood)
188 const bw = line.length + 4
189 const right = cx + HERO_COLS + 1
190 const fitsLeft = cx - 1 - bw >= 0
191 const isLeft = fitsLeft && (right + bw > cols || (hasCompanion(act) && busy && dir === 1))
192 const bx = isLeft ? cx - 1 - bw : right
193 const text = isLeft ? line : line.slice(0, Math.max(0, cols - right - 4))
194 const w = text.length + 4
195 put(bx, 1, '╭', tint)
196 put(bx + w - 1, 1, '╮', tint)
197 put(bx, 3, '╰', tint)
198 put(bx + w - 1, 3, '╯', tint)
199 for (let i = 1; i < w - 1; i++) {
200 put(bx + i, 1, '─', tint)
201 put(bx + i, 3, '─', tint)
202 }
203 put(bx, 2, isLeft ? '│' : '┤', tint)
204 put(bx + w - 1, 2, isLeft ? '├' : '│', tint)
205 put(isLeft ? bx + w : bx - 1, 2, '─', tint)
206 for (let i = 0; i < w - 2; i++) put(bx + 1 + i, 2, ' ', tint)
207 for (let i = 0; i < text.length; i++) put(bx + 2 + i, 2, text.charAt(i), tint)
208 }
209
210 return base64(new Uint8Array(grid.buffer))
211}
212hooks/engine/crew.ts 710 lines1import type { Follower, Motion } from '../../types'
2import type { Activity, Crew, CrewJoin, Dir, Pack, Palette, Rows } from '../packs/types'
3import { clamp01, cycleFrames, faceRight, fmt, frames, hex, paths, posePeriod, widthOf } from './draw'
4import { HEADROOM, HERO_COLS, HERO_H, HERO_W, LANE_PIX, LANE_ROWS, PX } from './geometry'
5import { IN_BED, posAt, sameLeg } from './motion'
6
7// The crew: one follower for each agent of this session that is working,
8// trailing the hero. The brain looks at $.agent.list() once a tick and keeps
9// a slot per agent (muster); both surfaces draw the followers from that and
10// the hero's path, the pack only says what they look like (Pack.crew).
11//
12// Each follower walks the hero's own path, `lag` ms later than the one ahead
13// of it, standing off behind the hero by its slot. The path is the trail:
14// the last few legs the hero walked, and which way it faced on each. Where a
15// leg asks a follower to stand somewhere else (the hero turned, or there is
16// no room behind it, or it went to bed), the follower carries on along the
17// old path until its lag is up, then walks round from wherever it is to its
18// new place, facing the way it walks. Slots never move while an agent works,
19// so nobody reshuffles: a gap stays where one left, until a new agent (or one
20// waiting out of sight beyond the last visible slot) fills it.
21
22/** A leg the hero walked, and which way it faced on it: the followers' path. */
23export type Trail = { leg: Motion; dir: Dir }
24
25/** The most followers drawn: on the desktop always, on the terminal on a wide enough lane. */
26export const CREW_MAX = 5
27/** The terminal draws one follower for each this many columns, up to CREW_MAX. */
28const COLS_PER_FOLLOWER = 30
29/** A follower fading in as its agent starts, and the puff it comes and goes in. */
30const ENTER_MS = 400
31const POOF_MS = 400
32/** The happy hop as its agent finishes, then the poof it goes in. */
33export const HOP_MS = 500
34export const LEAVE_MS = HOP_MS + POOF_MS
35/**
36 * How long a leg stays in the trail after it ended: more than twice the
37 * farthest follower's lag and longest walk-round (about 4 s), so a walk-round
38 * still under way, even one that started from the middle of another, never
39 * loses where it began.
40 */
41export const TRAIL_MS = 10_000
42const TRAIL_MAX = 24
43// Sprite px per ms a follower walks round to a new place.
44const SLIDE_PX_MS = 0.04
45const DEFAULT_LAG = 250
46const DEFAULT_GAP = 2
47const DEFAULT_STRIDE = 160
48const GROUND = HERO_H / PX
49// A flyer's bob: up and down a step every this many ms, a step out of time with the next one.
50const BOB_MS = 600
51// The happy hop: [fraction of HOP_MS, sprite px up], eased between.
52const HOP: [number, number][] = [[0, 0], [0.25, 2], [0.5, 0], [0.75, 1], [1, 0]]
53// Followers that stand in front of the hero, for want of room behind it,
54// stand this far past its front: clear of a prop or a foe ahead of it.
55const FRONT_CLEAR = HERO_COLS
56
57/** The statuses that count as working: the rest (idle, completed, failed, killed) send a follower home. */
58const WORKING_STATUSES: readonly string[] = ['pending', 'running', 'waiting']
59
60export function isWorking(status: string): boolean {
61 return WORKING_STATUSES.includes(status)
62}
63
64/** How many followers stand for an agent that is still working. */
65export function workingCount(crew: readonly Follower[] | undefined): number {
66 return (crew ?? []).filter(f => f.leavingAt === undefined).length
67}
68
69/** How many followers a terminal lane `cols` wide draws. */
70export function terminalCap(cols: number): number {
71 return Math.max(0, Math.min(CREW_MAX, Math.floor(cols / COLS_PER_FOLLOWER)))
72}
73
74/** The leg's join, if the pack has followers, the activity a join, and the join says yes to this leg. */
75export function joinOf(pack: Pack, act: Activity | undefined, m: Motion): CrewJoin | undefined {
76 const join = act?.crew
77 return pack.crew && join && (!join.only || join.only(m)) ? join : undefined
78}
79
80/**
81 * Ctx.joining: the slots of the followers an activity's join brings in on
82 * leg `m`, nearest first: those drawn (the first `cap` slots) and still at
83 * work. None when the pack has no followers, the activity no join, or the
84 * join says no to this leg.
85 */
86export function joining(pack: Pack, act: Activity | undefined, m: Motion, crew: readonly Follower[] | undefined, cap: number): number[] {
87 if (!joinOf(pack, act, m)) return []
88 return (crew ?? []).filter(f => f.slot < cap && f.leavingAt === undefined).map(f => f.slot).sort((a, b) => a - b)
89}
90
91/**
92 * The trail after the hero is seen on leg `m`, facing `dir`, at `now`: a new
93 * leg goes on the end (any at or after its start, replaced, come off), the
94 * same leg keeps its facing current, and legs that ended over TRAIL_MS ago
95 * are dropped. Answers `trail` itself when nothing changed.
96 */
97export function extendTrail(trail: readonly Trail[], m: Motion, dir: Dir, now: number): Trail[] {
98 const last = trail[trail.length - 1]
99 if (last && sameLeg(last.leg, m)) return last.dir === dir ? (trail as Trail[]) : [...trail.slice(0, -1), { leg: m, dir }]
100 const before = trail.filter(t => t.leg.t0 < m.t0)
101 const kept = before.filter((t, i) => (before[i + 1]?.leg.t0 ?? m.t0) >= now - TRAIL_MS)
102 return [...kept, { leg: m, dir }].slice(-TRAIL_MAX)
103}
104
105const sameCrew = (a: readonly Follower[], b: readonly Follower[]) =>
106 a.length === b.length &&
107 a.every((f, i) => {
108 const g = b[i]
109 return !!g && f.id === g.id && f.slot === g.slot && f.since === g.since && f.leavingAt === g.leavingAt
110 })
111
112/**
113 * The crew after a look at the agent list at `now`. A working agent with no
114 * follower gets one, in the lowest free slot; a follower whose agent stopped
115 * working (or left the list) starts leaving, and is gone LEAVE_MS later,
116 * freeing its slot; one whose agent works again comes back in the same slot.
117 * A freed slot within the `cap` drawn (CREW_MAX on the desktop, fewer on a
118 * narrow terminal) goes first to a follower waiting beyond them, the lowest
119 * first, so the places in sight fill before any out of it. Answers `crew`
120 * itself when nothing changed.
121 */
122export function muster(crew: readonly Follower[], agents: readonly { id: string; status: string }[], now: number, cap = CREW_MAX): Follower[] {
123 const on = new Set(agents.filter(a => isWorking(a.status)).map(a => a.id))
124 let out: Follower[] = []
125 for (const f of crew) {
126 if (f.leavingAt === undefined) out.push(on.has(f.id) ? f : { ...f, leavingAt: now })
127 else if (on.has(f.id)) out.push({ id: f.id, slot: f.slot, since: now })
128 else if (now < f.leavingAt + LEAVE_MS) out.push(f)
129 }
130 const used = new Set(out.map(f => f.slot))
131 const seen = Math.max(0, Math.min(CREW_MAX, cap))
132 for (let s = 0; s < seen; s++) {
133 if (used.has(s)) continue
134 const waiting = out.filter(f => f.slot >= seen && f.leavingAt === undefined).sort((a, b) => a.slot - b.slot)[0]
135 if (!waiting) break
136 used.delete(waiting.slot)
137 used.add(s)
138 out = out.map(f => (f === waiting ? { ...f, slot: s, since: now } : f))
139 }
140 const known = new Set(out.map(f => f.id))
141 for (const a of agents) {
142 if (!on.has(a.id) || known.has(a.id)) continue
143 let slot = 0
144 while (used.has(slot)) slot++
145 used.add(slot)
146 known.add(a.id)
147 out.push({ id: a.id, slot, since: now })
148 }
149 out.sort((a, b) => a.slot - b.slot)
150 return sameCrew(crew, out) ? (crew as Follower[]) : out
151}
152
153/**
154 * A CrewJoin for a lunge: the followers dash `reach` sprite px forward and
155 * back, the farthest point at `at` of the leg (a strike's moment), over
156 * `span` of the leg (default 0.16). `rise` lifts them that many px at the
157 * peak; a negative one dives, for flyers.
158 */
159export function lunge(at: number, reach: number, o: { span?: number; rise?: number; stagger?: number; together?: boolean; only?(leg: Motion): boolean } = {}): CrewJoin {
160 const span = o.span ?? 0.16
161 const a = clamp01(at - span / 2)
162 const b = Math.max(a + 0.001, clamp01(at + span / 2))
163 const peak = clamp01((clamp01(at) - a) / (b - a))
164 const join: CrewJoin = { during: [a, b], keys: [[0, 0, 0], [peak, reach, -(o.rise ?? 0)], [1, 0, 0]] }
165 if (o.stagger !== undefined) join.stagger = o.stagger
166 if (o.only) join.only = o.only
167 if (o.together) join.together = true
168 return join
169}
170
171// ── One follower's moves, the same on both surfaces ─────────────────────
172
173/** Every sprite one kind of follower draws, for its size. */
174export function crewSprites(art: Crew): Rows[] {
175 return [...art.move, ...art.idle.frames, ...(art.act?.frames ?? []), ...(art.cheer ? [art.cheer] : []), ...(art.rest?.frames ?? [])]
176}
177
178/**
179 * The art the follower in `slot` is drawn with: its kind (Crew.kinds) laid
180 * over the crew's own. While the hero sleeps (`asleep`), a crew with a rest
181 * pose shows it, on the ground.
182 */
183export function crewArt(art: Crew, slot: number, asleep = false): Crew {
184 const kind = art.kinds?.length ? art.kinds[slot % art.kinds.length] : undefined
185 const a: Crew = kind ? { ...art, ...kind } : art
186 if (!asleep || !a.rest) return a
187 const grounded: Crew = { ...a, idle: a.rest }
188 delete grounded.flying
189 return grounded
190}
191
192/** Every kind of follower a crew has: its kinds laid over it, or itself. */
193export function crewArts(art: Crew): Crew[] {
194 return art.kinds?.length ? art.kinds.map((_, i) => crewArt(art, i)) : [art]
195}
196
197/** The lane in sprite px: how far the hero's left edge travels, and how wide it is. */
198export type Room = { span: number; width: number }
199
200/** What both surfaces need to draw the crew at one moment. */
201export type CrewScene = {
202 art: Crew | undefined
203 crew: readonly Follower[] | undefined
204 m: Motion
205 /** the legs before this one, oldest first (any at or after m.t0 are left out) */
206 trail: readonly Trail[] | undefined
207 /** which way the hero faces now */
208 dir: Dir
209 /** a leg's join, if its followers join in on it (joinOf) */
210 joinOf(leg: Motion): CrewJoin | undefined
211 now: number
212 /** the hero is asleep in its bed: followers with a rest pose settle (Crew.rest) */
213 asleep?: boolean
214 /** the lane's size, to keep the followers in it; absent, the lane is taken to be endless */
215 room?: Room
216 /** sprite px from the hero's left edge at lane position 0 to the far side of its bed and its z's */
217 bed?: number
218}
219
220/** Where a leg asks a follower to stand: sprite px from the hero's left edge, and the way it faces. */
221type Place = { off: number; face: Dir }
222/** A walk-round: from where it was at `at` to a leg's place, `ms` long, facing the way it walks. */
223type Turn = { at: number; from: number; to: number; ms: number; face: Dir }
224
225type One = {
226 art: Crew
227 f: Follower
228 /** its place in the line: its slot */
229 j: number
230 /** ms behind the hero along its path */
231 lag: number
232 gap: number
233 fw: number
234 fh: number
235 m: Motion
236 /** the trail and this leg, oldest first */
237 legs: Trail[]
238 /** where it stands before the first walk-round, and the walk-rounds in order */
239 start: Place
240 turns: Turn[]
241 join?: CrewJoin
242 pal: Palette
243}
244
245/** Sprite px from the hero's left edge to the follower's, standing behind a hero that faces `side`. */
246function standOff(o: Pick<One, 'j' | 'fw' | 'gap'>, side: Dir): number {
247 return side === 1 ? -(o.j + 1) * (o.fw + o.gap) : HERO_COLS + o.gap + o.j * (o.fw + o.gap)
248}
249
250/**
251 * Where a leg asks it to stand. Behind the hero as a rule; by the bed while
252 * the hero sleeps in it; and on a leg on the spot that nobody joins in on,
253 * where the room behind the hero runs out (the hero faces the room, so its
254 * back is to the near edge), in front of it instead, past anything the hero
255 * is busy with, facing the hero, the ones that fit behind staying there.
256 */
257function placeOf(o: Pick<One, 'j' | 'fw' | 'gap'>, t: Trail, sc: CrewScene): Place {
258 const { leg, dir } = t
259 const step = o.fw + o.gap
260 if (sc.bed !== undefined && leg.activity === IN_BED) return { off: Math.max(standOff(o, -1), sc.bed + o.gap + o.j * step), face: -1 }
261 const behind: Place = { off: standOff(o, dir), face: dir }
262 const room = sc.room
263 if (!room || leg.from !== leg.to || sc.joinOf(leg)) return behind
264 const x = posAt(leg, leg.t0) * room.span
265 const inLane = (off: number) => x + off >= 0 && x + off + o.fw <= room.width
266 if (inLane(behind.off)) return behind
267 // How many slots fit behind, and so this one's place among those in front.
268 const fit = dir === 1 ? Math.floor(x / step) : Math.floor((room.width - x - HERO_COLS - o.gap - o.fw) / step) + 1
269 const k = o.j - Math.max(0, fit)
270 const front = dir === 1 ? HERO_COLS + FRONT_CLEAR + o.gap + k * step : -FRONT_CLEAR - (k + 1) * step
271 return inLane(front) ? { off: front, face: -dir as Dir } : behind
272}
273
274function offWith(start: Place, turns: readonly Turn[], t: number): number {
275 let turn: Turn | undefined
276 for (const u of turns) if (u.at <= t) turn = u
277 if (!turn) return start.off
278 return turn.from + (turn.to - turn.from) * (turn.ms > 0 ? clamp01((t - turn.at) / turn.ms) : 1)
279}
280
281function one(sc: CrewScene, f: Follower): One | null {
282 if (!sc.art) return null
283 const art = crewArt(sc.art, f.slot, sc.asleep)
284 const sprites = crewSprites(art)
285 const shape = { j: f.slot, gap: art.gap ?? DEFAULT_GAP, fw: Math.max(1, ...sprites.map(widthOf)) }
286 const lag = (f.slot + 1) * (art.lag ?? DEFAULT_LAG)
287 const legs: Trail[] = [...(sc.trail ?? []).filter(t => t.leg.t0 < sc.m.t0), { leg: sc.m, dir: sc.dir }]
288 const first = legs[0] ?? { leg: sc.m, dir: sc.dir }
289 const start = placeOf(shape, first, sc)
290 const turns: Turn[] = []
291 let was = start
292 for (const t of legs.slice(1)) {
293 const p = placeOf(shape, t, sc)
294 if (p.off === was.off && p.face === was.face) continue
295 const at = t.leg.t0 + lag
296 const from = offWith(start, turns, at)
297 turns.push({ at, from, to: p.off, ms: Math.abs(p.off - from) / SLIDE_PX_MS, face: p.face })
298 was = p
299 }
300 const join = sc.joinOf(sc.m)
301 return {
302 art,
303 f,
304 ...shape,
305 lag,
306 fh: Math.max(1, ...sprites.map(r => r.length)),
307 m: sc.m,
308 legs,
309 start,
310 turns,
311 ...(join ? { join } : {}),
312 pal: art.coats[f.slot % art.coats.length] ?? art.coats[0],
313 }
314}
315
316// The hero's lane position at `s`, on the leg it was on then.
317function heroAt(o: One, s: number): number {
318 let at: Motion | undefined
319 for (const t of o.legs) if (t.leg.t0 <= s) at = t.leg
320 return at ? posAt(at, s) : (o.legs[0]?.leg.from ?? o.m.from)
321}
322
323/** The follower's lane position at `t`: the hero's, its lag earlier. */
324function laneAt(o: One, t: number): number {
325 return heroAt(o, t - o.lag)
326}
327
328/** Sprite px from the hero's left edge to the follower's at `t`, walking round as legs ask. */
329function offAt(o: One, t: number): number {
330 return offWith(o.start, o.turns, t)
331}
332
333function turnAt(o: One, t: number): Turn | undefined {
334 let turn: Turn | undefined
335 for (const u of o.turns) if (u.at <= t) turn = u
336 return turn
337}
338
339/** Which way it faces at `t`: the way it walks round, then the way its place asks. */
340function sideAt(o: One, t: number): Dir {
341 const turn = turnAt(o, t)
342 if (!turn) return o.start.face
343 if (t < turn.at + turn.ms && turn.to !== turn.from) return turn.to > turn.from ? 1 : -1
344 return turn.face
345}
346
347function moving(o: One, t: number): boolean {
348 const s = t - o.lag
349 const walks = o.legs.some(({ leg }, i) => {
350 const end = Math.min(leg.t0 + leg.dur, o.legs[i + 1]?.leg.t0 ?? Infinity)
351 return leg.from !== leg.to && s >= leg.t0 && s < end
352 })
353 if (walks) return true
354 const turn = turnAt(o, t)
355 return !!turn && turn.to !== turn.from && t < turn.at + turn.ms
356}
357
358// When its part of the join starts, staggered down the line.
359const joinStart = (o: One) => o.m.t0 + (o.join?.stagger ?? 0) * o.j
360
361function joinWindow(o: One): [number, number] | null {
362 if (!o.join || !(o.m.dur > 0) || !Number.isFinite(o.m.dur)) return null
363 const [a, b] = o.join.during
364 const st = joinStart(o)
365 return b > a ? [st + clamp01(a) * o.m.dur, st + clamp01(b) * o.m.dur] : null
366}
367
368// How far it goes for each px of dx: further back, further, for a join that goes together.
369function reachOf(o: One): number {
370 if (!o.join?.together) return 1
371 const most = Math.max(0, ...(o.join.keys ?? []).map(k => Math.abs(k[1])))
372 return most > 0 ? (most + o.j * (o.fw + o.gap)) / most : 1
373}
374
375// Where the join puts it at `t`: [dx forward, dy down] in sprite px.
376function joinAt(o: One, t: number): [number, number] {
377 const w = joinWindow(o)
378 const keys = o.join?.keys
379 if (!w || !keys?.length || t < w[0] || t >= w[1]) return [0, 0]
380 const u = (t - w[0]) / (w[1] - w[0])
381 const ks = [...keys].sort((a, b) => a[0] - b[0])
382 let i = 0
383 while (i + 1 < ks.length && (ks[i + 1]?.[0] ?? Infinity) <= u) i++
384 const a = ks[i]
385 const b = ks[i + 1]
386 if (!a) return [0, 0]
387 const r = reachOf(o)
388 if (!b || u <= a[0]) return [a[1] * r, a[2]]
389 const k = (u - a[0]) / Math.max(1e-9, b[0] - a[0])
390 return [(a[1] + (b[1] - a[1]) * k) * r, a[2] + (b[2] - a[2]) * k]
391}
392
393type State = 'idle' | 'move' | 'act' | 'cheer' | 'gone'
394function stateAt(o: One, t: number): State {
395 const { leavingAt } = o.f
396 if (leavingAt !== undefined && t >= leavingAt + HOP_MS) return 'gone'
397 if (leavingAt !== undefined && t >= leavingAt) return 'cheer'
398 const w = joinWindow(o)
399 if (w && t >= w[0] && t < w[1]) return 'act'
400 return moving(o, t) ? 'move' : 'idle'
401}
402
403// Every moment after `now` where something about it changes, in order.
404function changes(o: One, now: number): number[] {
405 const out: number[] = []
406 const add = (t: number | undefined) => {
407 if (t !== undefined && Number.isFinite(t) && t > now) out.push(Math.round(t))
408 }
409 for (const { leg } of o.legs) {
410 add(leg.t0 + o.lag)
411 add(leg.t0 + leg.dur + o.lag)
412 }
413 for (const turn of o.turns) {
414 add(turn.at)
415 add(turn.at + turn.ms)
416 }
417 const w = joinWindow(o)
418 if (w) {
419 add(w[0])
420 add(w[1])
421 }
422 if (o.f.leavingAt !== undefined) {
423 add(o.f.leavingAt)
424 add(o.f.leavingAt + HOP_MS)
425 add(o.f.leavingAt + LEAVE_MS)
426 }
427 return [...new Set(out)].sort((a, b) => a - b)
428}
429
430// Sprite px up, `ms` into the happy hop.
431function hopAt(ms: number): number {
432 const u = ms / HOP_MS
433 for (let i = 1; i < HOP.length; i++) {
434 const [ta, ya] = HOP[i - 1] ?? [0, 0]
435 const [tb, yb] = HOP[i] ?? [1, 0]
436 if (u <= tb) return ya + (yb - ya) * clamp01((u - ta) / Math.max(1e-9, tb - ta))
437 }
438 return 0
439}
440
441// A flyer's bob at `t`: 1 on the up step.
442const bobUp = (o: One, t: number) => ((Math.floor(t / BOB_MS + o.j) % 2) + 2) % 2
443
444const PUFF: { frames: [Rows, ...Rows[]]; pal: Palette } = {
445 frames: [
446 ['.....', '..w..', '.www.', '..w..', '.....'],
447 ['.w.w.', 'w...w', '..y..', 'w...w', '.w.w.'],
448 ['w...w', '.....', '..y..', '.....', 'w...w'],
449 ],
450 pal: { w: 0xf2efe8, y: 0xf5c542 },
451}
452
453// Which followers are drawn: those in the first `cap` slots, until they are gone.
454function shown(crew: readonly Follower[], cap: number, now: number): Follower[] {
455 return crew.filter(f => f.slot < cap && (f.leavingAt === undefined || now < f.leavingAt + LEAVE_MS))
456}
457
458// Working, but beyond the slots drawn.
459function hiddenCount(crew: readonly Follower[], cap: number): number {
460 return crew.filter(f => f.slot >= cap && f.leavingAt === undefined).length
461}
462
463// Its left edge at `t`, in sprite px from the lane's.
464function xAt(o: One, t: number, span: number): number {
465 return laneAt(o, t) * span + offAt(o, t) + joinAt(o, t)[0] * sideAt(o, t)
466}
467
468// Working, drawn, and entirely outside the lane at `now`: counted in "+N" with those out of sight.
469function offLane(o: One, now: number, room: Room | undefined): boolean {
470 if (!room || o.f.leavingAt !== undefined) return false
471 const x = xAt(o, now, room.span)
472 return x + o.fw <= 0 || x >= room.width
473}
474
475// Where "+N" goes when no follower in the lane carries it: beside the hero
476// (left edge `heroX`, `heroW` wide, both in the lane's units), behind it,
477// unless only the front has room for a label `w` wide.
478function besideHero(dir: Dir, heroX: number, heroW: number, w: number, width: number | undefined): Dir {
479 const behind: Dir = dir === 1 ? -1 : 1
480 if (width === undefined) return behind
481 const fits = (s: Dir) => (s === -1 ? heroX - 1 - w >= 0 : heroX + heroW + 1 + w <= width)
482 return fits(behind) || !fits(-behind as Dir) ? behind : (-behind as Dir)
483}
484
485// ── Desktop ─────────────────────────────────────────────────────────────
486
487const msOf = (n: number) => `${Math.round(n)}ms`
488const FONT = 'font-family="ui-monospace, Menlo, monospace" font-size="9" font-weight="bold"'
489
490function label(n: number, x: number, y: number, anchor: 'start' | 'end', color: number, extra = ''): string {
491 return `<text x="${x}" y="${y}" text-anchor="${anchor}" ${FONT} fill="${hex(color)}"${extra}>+${n}</text>`
492}
493
494/**
495 * Every follower drawn, beneath the hero, in lane coordinates: each in an svg
496 * whose x is the hero's glide begun its lag later, so they trail. `pct` turns
497 * a lane position into the hero's left edge there, `cur` is the hero's
498 * position now. "+N" for the working ones out of sight (beyond CREW_MAX, or
499 * outside the lane) goes beside the last one in the lane, or beside the hero
500 * when none is.
501 */
502export function crewSvg(sc: CrewScene, pct: (p: number) => string, cur: number, labelColor: number): string {
503 const { art, crew, now } = sc
504 if (!art || !crew?.length) return ''
505 const ones = shown(crew, CREW_MAX, now).flatMap(f => one(sc, f) ?? [])
506 const away = ones.filter(o => offLane(o, now, sc.room))
507 const more = hiddenCount(crew, CREW_MAX) + away.length
508 const last = ones.filter(o => !away.includes(o)).reduce<One | undefined>((a, o) => (!a || o.j > a.j ? o : a), undefined)
509 const out = [...ones].sort((a, b) => b.j - a.j).map(o => followerSvg(o, now, pct, o === last ? more : 0, labelColor))
510 if (!last && more) {
511 const width = sc.room ? sc.room.width * PX : undefined
512 const side = besideHero(sc.dir, cur * (sc.room?.span ?? 0) * PX, HERO_W, `+${more}`.length * 6, width)
513 const x = side === -1 ? -4 : HERO_W + 4
514 out.push(`<svg x="${pct(cur)}" y="${HEADROOM}" width="${HERO_W}" height="${HERO_H}" overflow="visible">${label(more, x, HERO_H - 6, side === -1 ? 'end' : 'start', labelColor)}</svg>`)
515 }
516 return out.length ? `<g shape-rendering="crispEdges">${out.join('')}</g>` : ''
517}
518
519// One sprite standing on the follower's feet, as paths (compact: five of them share the frame's cap).
520const spriteSvg = (o: One, rows: Rows) => paths(rows, o.pal, PX, 0, (o.fh - rows.length) * PX)
521
522function poseSvg(o: One, frameList: Rows[], period: number): string {
523 return frameList.length > 1 ? cycleFrames(frameList.map(r => spriteSvg(o, r)), period) : spriteSvg(o, frameList[0] ?? [])
524}
525
526function followerSvg(o: One, now: number, pct: (p: number) => string, more: number, labelColor: number): string {
527 const { art, f } = o
528 const times = [now, ...changes(o, now)]
529 const end = times[times.length - 1] ?? now
530 const total = end - now
531 const kt = total > 0 ? `keyTimes="${times.map(t => fmt((t - now) / total, 6)).join(';')}"` : ''
532 const timing = `${kt} dur="${msOf(total)}" fill="freeze"`
533 // The state through the stretch from each moment to the next (after the last, for good).
534 const mid = (i: number) => {
535 const t = times[i] ?? now
536 const next = times[i + 1]
537 return next === undefined ? t + 1 : (t + next) / 2
538 }
539 // A value at each moment, eased (positions) or stepped (states); still when it never changes.
540 const anim = (values: string[], attr: string, discrete: boolean, transform?: string) => {
541 if (total <= 0 || values.every(v => v === values[0])) return ''
542 const head = transform ? `<animateTransform attributeName="transform" type="${transform}"` : `<animate attributeName="${attr}"`
543 return `${head} values="${values.join(';')}" ${discrete ? 'calcMode="discrete" ' : ''}${timing}/>`
544 }
545
546 // Along the hero's path, its lag behind.
547 const xs = times.map(t => pct(laneAt(o, t)))
548 const glide = anim(xs, 'x', false)
549 // Standing off from the hero, walking round to a new place.
550 const offs = times.map(t => `${fmt(offAt(o, t) * PX, 6)} 0`)
551 const off = `<g transform="translate(${offs[0]})">${anim(offs, '', false, 'translate')}`
552 // Joining in: forward the way it faces, and down.
553 let joinAnim = ''
554 const w = joinWindow(o)
555 if (w && o.join?.keys?.length && w[1] > now) {
556 const dur = o.m.dur
557 const st = joinStart(o)
558 const ks = [...o.join.keys].sort((a, b) => a[0] - b[0])
559 const [a, b] = o.join.during.map(clamp01) as [number, number]
560 const side = sideAt(o, w[0])
561 const pts: [number, number, number][] = []
562 if (a > 0) pts.push([0, 0, 0])
563 const r = reachOf(o)
564 for (const [t, dx, dy] of ks) pts.push([a + (b - a) * clamp01(t), dx * side * r, dy])
565 const lastKey = pts[pts.length - 1]
566 if (b < 1) {
567 if (lastKey && (lastKey[1] !== 0 || lastKey[2] !== 0)) pts.push([b, 0, 0])
568 pts.push([1, 0, 0])
569 }
570 if (pts[0]?.[0] !== 0) pts.unshift([0, 0, 0])
571 joinAnim = `<animateTransform attributeName="transform" type="translate" values="${pts.map(p => `${fmt(p[1] * PX, 6)} ${fmt(p[2] * PX, 6)}`).join(';')}" keyTimes="${pts.map(p => fmt(p[0], 6)).join(';')}" dur="${msOf(dur)}" begin="${msOf(st - now)}" fill="freeze"/>`
572 }
573 // Hovering, for a flyer, with a bob a step up and down, in step with the terminal's.
574 const height = art.flying?.height ?? 0
575 const bobPx = (art.flying?.bob ?? 1) * PX
576 const phase = ((now / BOB_MS + o.j) % 2 + 2) % 2
577 const bob = art.flying && bobPx
578 ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 ${-bobPx}" calcMode="discrete" dur="${msOf(2 * BOB_MS)}" begin="${msOf(-phase * BOB_MS)}" repeatCount="indefinite"/>`
579 : ''
580 const top = (GROUND - o.fh - height) * PX
581 // The happy hop as its agent finishes.
582 const hop = f.leavingAt !== undefined && now < f.leavingAt + HOP_MS
583 ? `<animateTransform attributeName="transform" type="translate" values="${HOP.map(([, y]) => `0 ${fmt(-y * PX)}`).join(';')}" keyTimes="${HOP.map(([t]) => fmt(t)).join(';')}" dur="${HOP_MS}ms" begin="${msOf(f.leavingAt - now)}"/>`
584 : ''
585 const fade = now < f.since + ENTER_MS ? `<animate attributeName="opacity" values="0;1" dur="${ENTER_MS}ms" begin="${msOf(f.since - now)}"/>` : ''
586
587 // What it shows: one group a state, each up while the state holds.
588 const states = times.map((_, i) => stateAt(o, mid(i)))
589 const group = (state: State, body: string) => {
590 if (!states.includes(state) && stateAt(o, now) !== state) return ''
591 const vals = states.map(s => (s === state ? '1' : '0'))
592 const start = stateAt(o, now) === state ? '1' : '0'
593 return `<g${start === '1' ? '' : ' opacity="0"'}>${anim([start, ...vals.slice(1)], 'opacity', true)}${body}</g>`
594 }
595 const stride = art.stride ?? DEFAULT_STRIDE
596 const idle = group('idle', poseSvg(o, art.idle.frames, posePeriod(art.idle)))
597 const walk = group('move', frames(spriteSvg(o, art.move[0]), spriteSvg(o, art.move[1]), (stride * 2) / 1000))
598 const act = o.join ? group('act', poseSvg(o, (art.act ?? art.idle).frames, posePeriod(art.act ?? art.idle))) : ''
599 const cheer = f.leavingAt !== undefined ? group('cheer', spriteSvg(o, art.cheer ?? art.idle.frames[0])) : ''
600 // Facing the way it walks or stands: rows face left as written.
601 const sides = times.map((_, i) => sideAt(o, mid(i)))
602 const flip = (s: Dir) => (s === 1 ? '-1 1' : '1 1')
603 const faceVals = [flip(sideAt(o, now)), ...sides.slice(1).map(flip)]
604 const cx = (o.fw * PX) / 2
605 const body = `<g transform="translate(${fmt(cx, 6)} 0)"><g transform="scale(${faceVals[0]})">${anim(faceVals, '', true, 'scale')}<g transform="translate(${fmt(-cx, 6)} 0)">${idle}${walk}${act}${cheer}</g></g></g>`
606
607 // The puff it comes and goes in, each frame up for its share.
608 const puff = art.poof ?? PUFF
609 const pw = widthOf(puff.frames[0])
610 const ph = puff.frames[0].length
611 const puffs: string[] = []
612 const window = (from: number) => {
613 const n = puff.frames.length
614 puff.frames.forEach((rows, i) => {
615 const b = from + (i * POOF_MS) / n
616 const d = POOF_MS / n
617 if (b + d <= now) return
618 puffs.push(`<g opacity="0"><set attributeName="opacity" to="1" begin="${msOf(b - now)}" dur="${msOf(d)}"/>${paths(rows, puff.pal, PX, Math.floor((o.fw - pw) / 2) * PX, (o.fh - ph) * PX)}</g>`)
619 })
620 }
621 window(f.since)
622 if (f.leavingAt !== undefined) window(f.leavingAt + HOP_MS)
623
624 // "+N" on its far side: behind it, the way it faces.
625 let tag = ''
626 if (more) {
627 const y = top + o.fh * PX - 3
628 const left = label(more, -3, y, 'end', labelColor)
629 const right = label(more, o.fw * PX + 3, y, 'start', labelColor)
630 const shows = (s: Dir) => [s === sideAt(o, now) ? '1' : '0', ...sides.slice(1).map(x => (x === s ? '1' : '0'))]
631 const wrap = (s: Dir, text: string) => {
632 const vals = shows(s)
633 if (vals.every(v => v === '0')) return ''
634 return `<g${vals[0] === '1' ? '' : ' opacity="0"'}>${anim(vals, 'opacity', true)}${text}</g>`
635 }
636 tag = wrap(1, left) + wrap(-1, right)
637 }
638
639 return `<svg x="${xs[0]}" y="${HEADROOM}" width="${HERO_W}" height="${HERO_H}" overflow="visible">${glide}${off}<g>${joinAnim}<g transform="translate(0 ${fmt(top, 6)})"><g>${bob}<g>${hop}<g>${fade}${body}</g></g>${puffs.join('')}</g></g></g>${tag}</g></svg>`
640}
641
642// ── Terminal ────────────────────────────────────────────────────────────
643
644/**
645 * Every follower drawn on a terminal lane `cols` wide, sampled at now: one
646 * for each COLS_PER_FOLLOWER columns, up to CREW_MAX. The lane's `room` is in
647 * columns (span: those the hero's left edge travels). Answers where "+N"
648 * goes, if anything is out of sight.
649 */
650export function crewCells(
651 sc: CrewScene & { room: Room },
652 cols: number,
653 heroX: number,
654 plot: (rows: Rows, x: number, y: number, pal: Palette) => void,
655): { x: number; y: number; text: string } | null {
656 const { art, crew, now, room } = sc
657 if (!art || !crew?.length) return null
658 const cap = terminalCap(cols)
659 const ones = shown(crew, cap, now).flatMap(f => one(sc, f) ?? [])
660 const away = ones.filter(o => offLane(o, now, room))
661 const more = hiddenCount(crew, cap) + away.length
662 let tag: { x: number; y: number; text: string } | null = null
663 let lastSlot = -1
664 for (const o of [...ones].sort((a, b) => b.j - a.j)) {
665 const { f, art: kind } = o
666 const side = sideAt(o, now)
667 const state = stateAt(o, now)
668 const x = Math.round(xAt(o, now, room.span))
669 const jy = joinAt(o, now)[1]
670 const height = kind.flying?.height ?? 0
671 const bob = kind.flying && bobUp(o, now) ? -(kind.flying.bob ?? 1) : 0
672 const hop = state === 'cheer' && f.leavingAt !== undefined ? -Math.round(hopAt(now - f.leavingAt)) : 0
673 const ground = GROUND - height + bob
674 const puff = kind.poof ?? PUFF
675 const puffAt = (from: number) => {
676 const rows = puff.frames[Math.floor(((now - from) / POOF_MS) * puff.frames.length)]
677 if (now < from || !rows) return
678 plot(rows, x + Math.floor((o.fw - widthOf(rows)) / 2), ground - Math.round((o.fh + rows.length) / 2), puff.pal)
679 }
680 if (state === 'gone') {
681 if (f.leavingAt !== undefined) puffAt(f.leavingAt + HOP_MS)
682 } else {
683 // Coming in, it shows once it is half faded in on the desktop, under its puff.
684 if (now >= f.since + ENTER_MS / 2) {
685 const pick = (list: Rows[], tick: number) => list[Math.floor(now / Math.max(1, tick)) % list.length] ?? list[0] ?? []
686 const rows =
687 state === 'cheer' ? kind.cheer ?? kind.idle.frames[0]
688 : state === 'act' ? pick((kind.act ?? kind.idle).frames, (kind.act ?? kind.idle).tick ?? 500)
689 : state === 'move' ? pick(kind.move, kind.stride ?? DEFAULT_STRIDE)
690 : pick(kind.idle.frames, kind.idle.tick ?? 500)
691 // Never above the lane's top row: a flyer's hop is cut short instead.
692 const y = Math.max(Math.min(0, LANE_PIX - rows.length), ground - rows.length + hop + Math.round(jy))
693 plot(side === 1 ? faceRight(rows) : rows, x, y, o.pal)
694 }
695 puffAt(f.since)
696 }
697 if (o.j > lastSlot && more && !away.includes(o)) {
698 lastSlot = o.j
699 const text = `+${more}`
700 tag = { x: side === 1 ? x - 1 - text.length : x + o.fw + 1, y: LANE_ROWS - 2, text }
701 }
702 }
703 if (!tag && more) {
704 const text = `+${more}`
705 const side = besideHero(sc.dir, heroX, HERO_COLS, text.length, room.width)
706 tag = { x: side === -1 ? heroX - 1 - text.length : heroX + HERO_COLS + 1, y: LANE_ROWS - 2, text }
707 }
708 return tag
709}
710hooks/engine/geometry.ts 23 lines1// Lane geometry shared by every pack. The hero is a 12 x 10 sprite.
2
3export const PX = 3 // CSS pixels per sprite pixel
4export const HERO_COLS = 12
5export const HERO_W = HERO_COLS * PX // 36
6export const HERO_H = 30
7// Room above the hero so hops and bounces don't clip its head.
8export const HEADROOM = 12
9export const LANE_H = HERO_H + HEADROOM
10// Hats sit on the hero's head, in rows padded on above the sprite.
11export const HAT_PAD = 4
12// The desktop reports its width in monospace columns; the SVG wants pixels.
13export const DESKTOP_PX_PER_COLUMN = 8.4
14export const MAX_X = 85 // the hero's left edge travels 0%..85% of the lane
15// Where the bed sits at bedtime, and how long it takes to slide in or out.
16export const BED_X = 4
17export const SLIDE_MS = 700
18// Terminal: the lane is LANE_ROWS cells tall, two sprite pixels per cell.
19export const LANE_ROWS = 5
20export const LANE_PIX = LANE_ROWS * 2
21// The terminal's own colour: a lane cell with no background of its own.
22export const TERMINAL_DEFAULT = 0x01000000
23hooks/engine/hats.ts 35 lines1import type { Stats } from '../../types'
2import type { HatDef, Pack } from '../packs/types'
3
4// What unlocks a hat is the engine's, the same in every pack: four tiers.
5// A pack brings one hat for each tier (Pack.hats, in this order), and its
6// art, so a tier unlocked in one pack is unlocked in all of them.
7export const HAT_TIERS: readonly { need(s: Stats): boolean; hint: string }[] = [
8 { need: s => s.turns >= 10, hint: '10 finished tasks' },
9 { need: s => s.tools >= 100, hint: '100 tool calls' },
10 { need: s => s.turns >= 150, hint: '150 finished tasks' },
11 { need: s => s.tools >= 500, hint: '500 tool calls' },
12]
13
14/** A pack's hats with what unlocks each one, in tier order. */
15export function hatsOf(pack: Pack): HatDef[] {
16 const out: HatDef[] = []
17 pack.hats.forEach((h, i) => {
18 const tier = HAT_TIERS[i]
19 if (tier) out.push({ ...h, need: tier.need, hint: tier.hint })
20 })
21 return out
22}
23
24/** The ids of a pack's hats that these counts have unlocked. */
25export function unlockedHats(pack: Pack, s: Stats): string[] {
26 return hatsOf(pack)
27 .filter(h => h.need(s))
28 .map(h => h.id)
29}
30
31/** The label a hat id goes by in a pack ('none' and unknown ids by their id). */
32export function hatLabel(pack: Pack, id: string): string {
33 return pack.hats.find(h => h.id === id)?.label ?? (id === 'none' ? 'None' : id)
34}
35hooks/engine/lane.ts 134 lines1import type { Follower, HeroState, Identity, Mood, Motion } from '../../types'
2import type { Activity, Dir, Hero, Pack, Palette, Pose, Rows, Scene } from '../packs/types'
3import type { Trail } from './crew'
4import { clamp01, closeEyes } from './draw'
5import { HAT_PAD, HERO_COLS, SLIDE_MS } from './geometry'
6import { IN_BED, RESERVED_ACTIVITIES, TO_BED, isWalking } from './motion'
7
8// What both surfaces need to know about the lane at one moment.
9
10export type Extras = {
11 ctx?: number | null
12 identity?: Identity
13 hat?: string
14 hour?: number
15 /** this session's working agents, drawn as the pack's followers (none when absent) */
16 crew?: readonly Follower[]
17 /** the legs the hero walked before this one (engine/crew.ts extendTrail), which followers are still finishing while their lag runs out */
18 trail?: readonly Trail[]
19 /** desktop: the lane's width in CSS px, to keep the followers in it */
20 laneW?: number
21}
22
23export type Lane = {
24 act: Activity | undefined
25 walking: boolean
26 /** the leg is still running */
27 busy: boolean
28 dir: Dir
29 posture: 'walk' | 'sit' | 'asleep'
30 /** the activity's own pose on the spot, while the leg runs */
31 pose: Pose | undefined
32 sceneId: string
33 scene: Scene
34}
35
36// Bedtime's legs never resolve to a pack's activity, even one that slipped
37// past packs/index.ts: the bed, not the pack, draws them.
38export function activityOf(pack: Pack, id: string | undefined): Activity | undefined {
39 return id !== undefined && Object.hasOwn(pack.activities, id) && !RESERVED_ACTIVITIES.includes(id) ? pack.activities[id] : undefined
40}
41
42// The identity is rolled once and shared by every pack, but markings belong
43// to a pack. One this pack lacks maps to one of its own, the same one every
44// time, so the hero keeps a look of its own in each pack.
45export function lookOf(pack: Pack, id: Identity | undefined): Identity {
46 // A saved identity is trusted to be well formed only so far: one rolled
47 // without a marking (or by an older version) reads as 'none'.
48 const who = id ? (typeof id.marking === 'string' ? id : { ...id, marking: 'none' }) : { marking: 'none', shiny: false }
49 const { markings } = pack
50 if (who.marking === 'none' || markings.includes(who.marking)) return who
51 if (markings.length === 0) return { ...who, marking: 'none' }
52 let h = 0
53 for (let i = 0; i < who.marking.length; i++) h = (h * 31 + who.marking.charCodeAt(i)) >>> 0
54 return { ...who, marking: markings[h % markings.length] ?? 'none' }
55}
56
57// The hero's rows dressed: HAT_PAD blank rows on top, the room a hat sits
58// in (both surfaces draw the result HAT_PAD rows higher), then the pack puts
59// on the marking and the hat.
60export function wear(hero: Hero, rows: Rows, marking: string, hat: string, asleep: boolean): Rows {
61 const pad: Rows = Array.from({ length: HAT_PAD }, () => '.'.repeat(HERO_COLS))
62 return hero.dress([...pad, ...rows], marking, hat, asleep)
63}
64
65// Shut eyes, the pack's own way or the engine's.
66export function shut(hero: Hero, rows: Rows): Rows {
67 return (hero.closeEyes ?? closeEyes)(rows)
68}
69
70// A pack's coat by id, or its default one.
71export function coatOf(pack: Pack, id?: string): Palette {
72 return (id !== undefined && Object.hasOwn(pack.coats, id) && pack.coats[id]) || pack.coats[pack.defaults.coat] || {}
73}
74
75// A pack's scene by id, or its default one.
76export function sceneOf(pack: Pack, sceneId: string): { id: string; scene: Scene } {
77 const id = Object.hasOwn(pack.scenes, sceneId) ? sceneId : pack.defaults.scene
78 const scene = pack.scenes[id]
79 if (!scene) throw new Error(`pack '${pack.id}' has no scene '${id}'`)
80 return { id, scene }
81}
82
83export function lane(pack: Pack, c: HeroState, m: Motion, now: number, sceneId: string): Lane {
84 const act = activityOf(pack, m.activity)
85 const walking = isWalking(m, now)
86 const busy = now < m.t0 + m.dur
87 const own = busy ? (typeof act?.pose === 'function' ? act.pose(c) : act?.pose) : undefined
88 const { id, scene } = sceneOf(pack, sceneId)
89 return {
90 act,
91 walking,
92 busy,
93 dir: m.to > m.from && walking ? 1 : m.to < m.from && walking ? -1 : c.dir,
94 posture: walking ? 'walk' : c.mood === 'sleep' || own === 'asleep' ? 'asleep' : 'sit',
95 pose: own === 'asleep' ? undefined : own,
96 sceneId: id,
97 scene,
98 }
99}
100
101// Activities with a toy or critter ahead of the hero, or at its destination.
102export function hasCompanion(a: Activity | undefined): boolean {
103 return !!(a?.draw || a?.target)
104}
105
106export function isHappy(mood: Mood): boolean {
107 return mood === 'done' || mood === 'pet' || mood === 'tired'
108}
109
110export function isInBed(c: HeroState, m: Motion): boolean {
111 return c.mood === 'sleep' && m.activity === IN_BED
112}
113
114// How far the bed has slid in (1) or out (0), or null when it's away.
115export function bedShown(c: HeroState, m: Motion, now: number): number | null {
116 if (c.mood === 'sleep' && m.activity === TO_BED) return clamp01((now - m.t0) / SLIDE_MS)
117 if (isInBed(c, m)) return 1
118 if (c.mood !== 'sleep' && c.wokeAt !== undefined && now - c.wokeAt < SLIDE_MS) return 1 - clamp01((now - c.wokeAt) / SLIDE_MS)
119 return null
120}
121
122const MOOD_COLOR: Partial<Record<Mood, number>> = {
123 done: 0x3fb950,
124 oops: 0xf85149,
125 tired: 0xd29922,
126 pet: 0xdb61a2,
127 working: 0x39c5cf,
128}
129export const QUIET_COLOR = 0x8b949e
130
131export function moodColor(mood: Mood): number {
132 return MOOD_COLOR[mood] ?? QUIET_COLOR
133}
134hooks/engine/motion.ts 38 lines1import type { Motion } from '../../types'
2import type { Dir } from '../packs/types'
3import { clamp01 } from './draw'
4
5// Bedtime's two legs, kept as activity ids so the state contract is
6// unchanged: walking back to the bed, then asleep in it. They are the only
7// ids the engine names, and they are reserved: no pack may define them
8// (packs/index.ts refuses one that does), so bedtime always draws the same.
9//
10// 'perch' is the cat's word for its bed, and the one cat name left in the
11// engine, on purpose: it is a stored Motion.activity (a saved lane asleep
12// reloads as one), and tests/golden.test.tsx, which is frozen, draws its
13// bedtime cases by passing 'bed' and 'perch' straight in. Renaming either
14// would break that contract for nothing: to the engine they are opaque ids,
15// and what a pack shows for them is its own Scene.bed and Hero.asleep.
16export const TO_BED = 'bed'
17export const IN_BED = 'perch'
18export const RESERVED_ACTIVITIES: readonly string[] = [TO_BED, IN_BED]
19
20export function posAt(m: Motion, now: number): number {
21 if (m.dur <= 0) return m.to
22 return m.from + (m.to - m.from) * clamp01((now - m.t0) / m.dur)
23}
24
25export function isWalking(m: Motion, now: number): boolean {
26 return m.from !== m.to && now < m.t0 + m.dur
27}
28
29/** The same leg: a state write that changed nothing about where the hero goes. */
30export function sameLeg(a: Motion, b: Motion): boolean {
31 return a.t0 === b.t0 && a.dur === b.dur && a.from === b.from && a.to === b.to && a.activity === b.activity
32}
33
34/** Which way the hero faces on a leg: the way it walks, or `dir` (HeroState.dir) on the spot. */
35export function legDir(m: Motion, dir: Dir): Dir {
36 return m.to > m.from ? 1 : m.to < m.from ? -1 : dir
37}
38hooks/engine/svg.ts 237 lines1import type { HeroState, Motion } from '../../types'
2import type { Bed, Draw, Pack, Palette, Rows, SvgCtx } from '../packs/types'
3import { CREW_MAX, crewSvg, joinOf, joining } from './crew'
4import { clamp01, cycleFrames, faceRight, frames, hex, posePeriod, rects } from './draw'
5import { BED_X, HAT_PAD, HEADROOM, HERO_H, HERO_W, LANE_H, MAX_X, PX, SLIDE_MS } from './geometry'
6import { activityOf, bedShown, hasCompanion, isHappy, isInBed, lane, lookOf, moodColor, QUIET_COLOR, shut, wear } from './lane'
7import type { Extras, Lane } from './lane'
8import { posAt } from './motion'
9import { hourOf, phaseAt } from './time'
10
11const widthOf = (rows: Rows) => rows[0]?.length ?? 0
12
13// ── Desktop: one self-animating SVG for the whole lane ──────────────────
14
15function xmlEscape(s: string): string {
16 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
17}
18
19function bubbleSvg(c: HeroState, onLeft: boolean, tint: number): string {
20 if (!c.say) return ''
21 const raw = c.say.length > 28 ? c.say.slice(0, 27) + '…' : c.say
22 const hasDots = raw.endsWith('…')
23 const text = hasDots ? raw.slice(0, -1) : raw
24 const sparkle = c.mood === 'done'
25 const chars = text.length + (hasDots ? 3 : 0) + (sparkle ? 2 : 0)
26 const w = Math.round(chars * 6.6 + 14)
27 const h = 18
28 const y = 6
29 const x = onLeft ? -w - 5 : HERO_W + 5
30 const color = hex(tint)
31 const tail = onLeft
32 ? `<path d="M${x + w} ${y + 7} l5 2 l-5 2 z" fill="${color}" fill-opacity="0.16"/>`
33 : `<path d="M${x} ${y + 7} l-5 2 l5 2 z" fill="${color}" fill-opacity="0.16"/>`
34 const dots = hasDots
35 ? [0, 1, 2]
36 .map(i => `<tspan opacity="0">.<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.2;0.8;1" dur="1.2s" begin="${i * 0.2}s" repeatCount="indefinite"/></tspan>`)
37 .join('')
38 : ''
39 const star = sparkle
40 ? `<tspan>✦<animate attributeName="opacity" values="1;0.3;1" dur="0.8s" repeatCount="indefinite"/></tspan> `
41 : ''
42 return `<g opacity="0">
43 <animate attributeName="opacity" from="0" to="1" dur="0.15s" fill="freeze"/>
44 <animateTransform attributeName="transform" type="translate" from="0 3" to="0 0" dur="0.18s" fill="freeze"/>
45 <rect x="${x}" y="${y}" width="${w}" height="${h}" rx="9" fill="${color}" fill-opacity="0.16" stroke="${color}" stroke-opacity="0.7"/>
46 ${tail}
47 <text x="${x + 7}" y="${y + 12.5}" font-family="ui-monospace, Menlo, monospace" font-size="11" fill="${color}">${star}${xmlEscape(text)}${dots}</text>
48 </g>`
49}
50
51function floaters(c: HeroState, isNapping: boolean): string {
52 // Little particles that drift up from the hero's head.
53 const drift = (glyph: string, x: number, delay: number, color: string, size: number) =>
54 `<text x="${x}" y="8" font-size="${size}" fill="${color}" opacity="0">${glyph}
55 <animate attributeName="y" values="8;-2" dur="1.6s" begin="${delay}s" repeatCount="indefinite"/>
56 <animate attributeName="opacity" values="0;1;0" dur="1.6s" begin="${delay}s" repeatCount="indefinite"/>
57 </text>`
58 if (isNapping) return drift('z', 24, 0, hex(QUIET_COLOR), 9) + drift('Z', 30, 0.8, hex(QUIET_COLOR), 11)
59 if (c.mood === 'pet') return drift('♥', 6, 0, '#db61a2', 9) + drift('♥', 26, 0.5, '#db61a2', 8)
60 return ''
61}
62
63// A shiny hero sparkles now and then.
64const SHINY = [[3, 2, 0], [31, 9, 1.3], [17, -6, 2.1]]
65 .map(([x, y, d]) => `<text x="${x}" y="${y}" font-size="7" fill="#f5c542" opacity="0">✦<animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.1;0.25;1" dur="3.2s" begin="${d}s" repeatCount="indefinite"/></text>`)
66 .join('')
67
68const BOB = '<animateTransform attributeName="transform" type="translate" values="0 0;0 -2;0 0" dur="3s" repeatCount="indefinite"/>'
69
70// The bed, sliding in at bedtime and out on waking, in lane coordinates.
71function bedSvg(c: HeroState, m: Motion, now: number, bed: Bed): string {
72 const shown = bedShown(c, m, now)
73 if (shown === null) return ''
74 const w = widthOf(bed.rows) * PX
75 const y = HEADROOM + HERO_H - bed.rows.length * PX - bed.float
76 const away = -(BED_X + w + 4)
77 const isLeaving = c.mood !== 'sleep'
78 const since = isLeaving ? (c.wokeAt ?? now) : m.t0
79 const slide = now - since < SLIDE_MS
80 ? `<animateTransform attributeName="transform" type="translate" from="${isLeaving ? 0 : away} 0" to="${isLeaving ? away : 0} 0" dur="${SLIDE_MS}ms" begin="${Math.round(since - now)}ms" fill="freeze"/>`
81 : ''
82 return `<g transform="translate(${BED_X} ${y})"><g>${slide}<g>${bed.float ? BOB : ''}<g shape-rendering="crispEdges">${rects(bed.rows, bed.pal)}</g></g></g></g>`
83}
84
85// The hero's body, dressed and facing the way it's going, with blinks.
86function heroSvg(pack: Pack, c: HeroState, l: Lane, pal: Palette, marking: string, hat: string, remaining: number): string {
87 const { hero } = pack
88 const asleep = l.posture === 'asleep'
89 const dressed = (rows: Rows) => {
90 const d = wear(hero, rows, marking, hat, asleep)
91 return l.dir === 1 ? faceRight(d) : d
92 }
93 const draw = (rows: Rows) => rects(dressed(rows), pal)
94 const eyes = (rows: Rows) => (isHappy(c.mood) ? shut(hero, rows) : rows)
95
96 let body: string
97 if (asleep) {
98 body = draw(hero.asleep)
99 } else if (l.posture === 'walk') {
100 const stride = c.mood === 'working' ? hero.rush : l.act?.stride ?? hero.stride
101 body = frames(draw(eyes(hero.walk[0])), draw(eyes(hero.walk[1])), (stride * 2) / 1000, remaining)
102 } else {
103 const p = l.pose ?? hero.idle
104 const look = p.shut ? (rows: Rows) => rows : eyes
105 const shown = p.frames.map(f => draw(look(f)))
106 body = shown.length > 1 ? cycleFrames(shown, posePeriod(p)) : shown.join('')
107 }
108 // Blinks: closed-eye pixels laid over the open eyes of the first frame
109 // for a moment (see Pose.shut for poses whose head moves).
110 if (!asleep && !isHappy(c.mood) && !l.pose?.shut) {
111 const open = l.posture === 'walk' ? hero.walk[0] : (l.pose ?? hero.idle).frames[0]
112 body += `<g opacity="0">${rects(dressed(shut(hero, open)), pal, dressed(open))}<animate attributeName="opacity" values="0;1;0" keyTimes="0;0.95;0.98" dur="4s" calcMode="discrete" repeatCount="indefinite"/></g>`
113 }
114 return body
115}
116
117// Pixel art on every layer: stage and target are put in a crispEdges group,
118// as draw and over already are (they sit in the hero's), so one drawing
119// helper serves any layer. A layer whose svg sets its own edges says so
120// (Draw.ownEdges) and is left as it is; an empty one stays empty.
121function crisp(layer: Draw, svg: string): string {
122 return !svg.trim() || layer.ownEdges ? svg : `<g shape-rendering="crispEdges">${svg}</g>`
123}
124
125export function laneSvg(pack: Pack, c: HeroState, m: Motion, now: number, palette: Palette, sceneId: string, x: Extras = {}): string {
126 const l = lane(pack, c, m, now, sceneId)
127 const { act, walking, busy, dir, scene } = l
128 const id = lookOf(pack, x.identity)
129 const cur = posAt(m, now)
130 const remaining = walking ? m.t0 + m.dur - now : 0
131 const pct = (p: number) => `${(clamp01(p) * MAX_X).toFixed(3)}%`
132 const at = (ms: number) => `${Math.round(m.t0 + ms - now)}ms`
133 const hour = hourOf(x.hour ?? 12)
134 const ctx: SvgCtx = {
135 leg: m,
136 hero: c,
137 now,
138 dir,
139 scene: l.sceneId,
140 hour,
141 phase: phaseAt(hour),
142 face: rows => (dir === 1 ? faceRight(rows) : rows),
143 PX,
144 walking,
145 remaining,
146 ahead: (w, gap = 3) => (dir === 1 ? HERO_W + gap : -w - gap),
147 at,
148 pct,
149 joining: joining(pack, act, m, x.crew, CREW_MAX),
150 }
151 const body = heroSvg(pack, c, l, { ...palette, ...pack.hero.hatPal }, id.marking, x.hat ?? 'none', remaining)
152
153 const isPounce = busy && !walking && act?.leap === 'pounce'
154 const bounce = c.mood === 'oops'
155 ? '<animateTransform attributeName="transform" type="translate" values="-2 0;2 0;-2 0" dur="0.12s" repeatCount="indefinite"/>'
156 : c.mood === 'done'
157 ? '<animateTransform attributeName="transform" type="translate" values="0 0;0 -4;0 0" dur="0.35s" repeatCount="3"/>'
158 : walking && act?.leap === 'hop'
159 ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 -7;0 0" keyTimes="0;0.4;1" dur="0.45s" repeatDur="${Math.round(remaining)}ms"/>`
160 : isPounce
161 ? `<animateTransform attributeName="transform" type="translate" values="0 0;${dir * 6} -7;${dir * 9} 0" keyTimes="0;0.5;1" dur="0.4s" begin="${at(0)}" fill="freeze"/>`
162 : ''
163 const glide = walking
164 ? `<animate attributeName="x" from="${pct(cur)}" to="${pct(m.to)}" dur="${Math.round(remaining)}ms" fill="freeze"/>`
165 : ''
166 const bubbleLeft = hasCompanion(act) && busy ? dir === 1 && cur > 0.2 : Math.max(cur, m.to) > 0.55
167 const isNapping = l.posture === 'asleep'
168 const gauge = typeof x.ctx === 'number' ? pack.gauge(x.ctx) : null
169 const target = busy && act?.target
170 ? `<svg x="${pct(m.to)}" y="${HEADROOM}" width="${HERO_W}" height="${HERO_H}" overflow="visible">${crisp(act.target, act.target.svg(ctx))}</svg>`
171 : ''
172 // A stage is in the lane's own frame, where pct() resolves against the lane.
173 const stage = busy && act?.stage
174 ? `<svg x="0" y="${HEADROOM}" width="100%" height="${HERO_H}" overflow="visible">${crisp(act.stage, act.stage.svg(ctx))}</svg>`
175 : ''
176 // Over the hero, moving with its hops and bounces.
177 // A layer with nothing for the desktop (a terminal-only one) adds nothing.
178 const overSvg = act?.over ? act.over.svg(ctx) : ''
179 const over = overSvg ? `<g transform="translate(0 ${HAT_PAD * PX})">${overSvg}</g>` : ''
180 const lifted = `<g transform="translate(0 ${-HAT_PAD * PX})"><g>${bounce}${body}${over}</g></g>`
181
182 // In bed: shifted onto it and lifted, after a little hop up.
183 let bedOpen = '<g><g><g>'
184 let floatX = 0
185 if (isInBed(c, m)) {
186 const p = scene.bed
187 const dx = BED_X + (widthOf(p.rows) * PX - HERO_W) / 2
188 const lift = (p.rows.length - 1) * PX + p.float
189 const hopUp = `<animateTransform attributeName="transform" type="translate" values="${-dx} ${lift};${-dx / 2} ${lift / 2 - 8};0 0" dur="0.35s" begin="${at(0)}" fill="freeze"/>`
190 bedOpen = `<g transform="translate(${dx} ${-lift})"><g>${p.float ? BOB : ''}<g>${hopUp}`
191 floatX = dx + 14 // z's drift beside the hero, not off the top of the lane
192 }
193
194 // The followers, beneath the hero and what is ahead of it, in lane
195 // coordinates. Asleep in bed, they stand past the bed's foot.
196 const crew = crewSvg(
197 {
198 art: pack.crew,
199 crew: x.crew,
200 m,
201 trail: x.trail,
202 dir,
203 joinOf: leg => joinOf(pack, activityOf(pack, leg.activity), leg),
204 now,
205 asleep: isInBed(c, m),
206 // The lane, less the gauge at its right end.
207 ...(x.laneW ? { room: { span: (x.laneW * MAX_X) / 100 / PX, width: (x.laneW - (gauge ? widthOf(gauge.rows) * PX + 8 + PX : 0)) / PX } } : {}),
208 bed: (BED_X + widthOf(scene.bed.rows) * PX) / PX,
209 },
210 pct,
211 cur,
212 QUIET_COLOR,
213 )
214
215 // color-scheme lets the frame follow the app's light or dark appearance,
216 // so a lane with no backdrop stays see-through instead of a white page.
217 return `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${LANE_H}" overflow="visible" style="background:transparent">
218 <style>:root{color-scheme:light dark;background:transparent}</style>
219 ${scene.backdrop(x.hour ?? 12)}
220 ${gauge ? `<svg x="100%" y="${HEADROOM}" overflow="visible"><g shape-rendering="crispEdges" transform="translate(${-widthOf(gauge.rows) * PX - 8} ${HERO_H - gauge.rows.length * PX})">${rects(gauge.rows, gauge.pal)}</g></svg>` : ''}
221 ${bedSvg(c, m, now, scene.bed)}
222 ${stage}${target}${crew}
223 <svg x="${pct(cur)}" y="${HEADROOM}" width="${HERO_W}" height="${HERO_H}" overflow="visible">${glide}
224 ${bedOpen}
225 <g shape-rendering="crispEdges">
226 ${act?.draw?.svg(ctx) ?? ''}
227 ${lifted}
228 </g>
229 ${floatX ? '' : floaters(c, isNapping)}
230 ${id.shiny ? SHINY : ''}
231 ${isNapping ? '' : bubbleSvg(c, bubbleLeft, moodColor(c.mood))}
232 </g></g></g>
233 ${floatX ? `<g transform="translate(${floatX} 0)">${floaters(c, isNapping)}</g>` : ''}
234 </svg>
235</svg>`
236}
237hooks/engine/time.ts 21 lines1// The time of day, one definition for every pack: the scene skies, the
2// night weights and anything an activity draws differently after dark.
3
4import type { Phase } from '../../types'
5
6export type { Phase }
7
8// A whole local hour, 0..23; anything that is not a number reads as noon.
9export function hourOf(hour: number): number {
10 return Number.isFinite(hour) ? ((Math.floor(hour) % 24) + 24) % 24 : 12
11}
12
13// Night from 21:00 to 4:59, dawn at 5 and 6, dusk from 18 to 20, else day.
14export function phaseAt(hour: number): Phase {
15 const h = hourOf(hour)
16 if (h >= 21 || h < 5) return 'night'
17 if (h < 7) return 'dawn'
18 if (h >= 18) return 'dusk'
19 return 'day'
20}
21hooks/packs/cat/sprites.ts 114 lines1import { closeEyes } from '../../engine/draw'
2import { HAT_PAD } from '../../engine/geometry'
3import type { Hero, Palette, Rows } from '../types'
4
5// Chibi pixel sprites, 12x10, facing left.
6// o fur, d shade, H eye shine, K eye, r blush, n nose, w chest.
7const HEAD = [
8 '.o.....o....',
9 '.oo...oo....',
10 'ooooooooo...',
11 'oHKoooHKo...',
12 'oKKoooKKo...',
13 'rooonooor...',
14]
15const WALK_A = [...HEAD, '.ooooooo...d', '..owwwooo.d.', '..oooooooo..', '..oo...oo...']
16const WALK_B = [...HEAD, '.ooooooo...d', '..owwwooo.d.', '..oooooooo..', '...oo.oo....']
17export const SIT = [...HEAD, '.ooooooo....', '.owwwooo.d..', '.oowwooood..', '..oo..oo....']
18const SIT_WAG = [...HEAD, '.ooooooo..d.', '.owwwooo.d..', '.oowwooood..', '..oo..oo....']
19// One front paw reaching out (for fishing and pinning a mouse).
20export const SIT_PAW = [...HEAD, '.ooooooo....', 'oowwwooo.d..', '.oowwooood..', '......oo....']
21// Grooming: eyes shut, a paw up at the mouth, the tongue flicking out.
22export const GROOM_A = closeEyes(SIT).map((r, y) => (y === 5 ? 'wwoonooor...' : y === 6 ? 'wooooooo....' : r))
23export const GROOM_B = GROOM_A.map((r, y) => (y === 5 ? 'wnoonooor...' : r))
24const LOAF = [
25 '............',
26 '............',
27 '............',
28 '............',
29 '.o.....o....',
30 '.oo...oo....',
31 'oooooooooo..',
32 'oddoooddoooo',
33 'rooonooroood',
34 '.oooooooooo.',
35]
36
37// Markings and hats, drawn into every sprite so each pose wears them.
38const HAT_ROWS: Record<string, Rows> = {
39 party: ['.Z.', '.P.', 'PQP', 'QPQ'],
40 beanie: ['..Z..', '.UUU.', 'UVUVU'],
41 wizard: ['..X..', '.XYX.', '.XXX.', 'XXXXX'],
42 crown: ['Y.Y.Y', 'YYYYY'],
43}
44const HAT_PAL: Palette = { P: 0xff6fae, Q: 0xffc2dc, Z: 0xffd54a, U: 0x4a7fd6, V: 0x8fb3ee, X: 0x7a4fd6, Y: 0xf5c542 }
45
46export const MARKINGS = ['none', 'blaze', 'socks', 'tip', 'spot']
47
48function setAt(row: string, x: number, ch: string): string {
49 return x < 0 || x >= row.length ? row : row.slice(0, x) + ch + row.slice(x + 1)
50}
51
52function mark(rows: Rows, m: string): Rows {
53 const out = [...rows]
54 if (m === 'blaze') {
55 const y = out.findIndex(r => r.includes('ooooooooo'))
56 const row = out[y]
57 if (row !== undefined) out[y] = setAt(row, 4, 'w')
58 } else if (m === 'socks') {
59 const y = out.length - 1
60 const row = out[y]
61 if (row !== undefined) out[y] = row.replace('oo', 'ww')
62 } else if (m === 'tip') {
63 const y = out.findIndex(r => r.lastIndexOf('d') >= 9)
64 const row = out[y]
65 if (row !== undefined) out[y] = setAt(row, row.lastIndexOf('d'), 'w')
66 } else if (m === 'spot') {
67 // Below the sprite's first row (the rows above it are the hat's room).
68 const y = out.findIndex((r, i) => i > HAT_PAD && !r.includes('K') && r.includes('w'))
69 const row = out[y]
70 if (row !== undefined && row[6] === 'o') out[y] = setAt(row, 6, 'd')
71 }
72 return out
73}
74
75// A sprite with its marking and hat; the engine has padded it HAT_PAD rows
76// on top, where the hat goes. Facing left, before any flip. Asleep, the
77// loaf keeps its hat on.
78function dress(rows: Rows, m: string, hat: string): Rows {
79 const out = mark(rows, m)
80 const h = HAT_ROWS[hat]
81 if (!h) return out
82 const bottom = rows.findIndex(r => r.includes('o')) + 1
83 const x0 = 4 - Math.floor((h[0]?.length ?? 0) / 2)
84 h.forEach((line, i) => {
85 const y = bottom - (h.length - 1) + i
86 const row = out[y]
87 if (row === undefined) return
88 let next = row
89 for (let x = 0; x < line.length; x++) if (line.charAt(x) !== '.') next = setAt(next, x0 + x, line.charAt(x))
90 out[y] = next
91 })
92 return out
93}
94
95export const HERO: Hero = {
96 walk: [WALK_A, WALK_B],
97 stride: 160,
98 rush: 100,
99 idle: { frames: [SIT, SIT_WAG], period: 1.2, tick: 600 },
100 asleep: LOAF.map(r => r.replace(/[HK]/g, 'd')),
101 dress,
102 hatPal: HAT_PAL,
103}
104
105const BASE: Palette = { H: 0xffffff, K: 0x1f1e1d, r: 0xf4a4a0, n: 0xe8737a, w: 0xfbefe4 }
106export const COATS = {
107 orange: { ...BASE, o: 0xd97757, d: 0x9e4f36 }, // Claude orange
108 tuxedo: { ...BASE, o: 0x2e2e33, d: 0x141416, K: 0xb8d86a, w: 0xf7f7f7 },
109 black: { ...BASE, o: 0x1f1f24, d: 0x050506, K: 0xf2c94c, w: 0x3a3a42, r: 0x8a4a55 },
110 grey: { ...BASE, o: 0x9aa0a6, d: 0x5f6368, w: 0xeceff1 },
111 cream: { ...BASE, o: 0xf1d6a8, d: 0xc49a5e },
112 sakura: { ...BASE, o: 0xf6b8c8, d: 0xd4869c, n: 0xd9566b },
113} satisfies Record<string, Palette>
114hooks/packs/index.ts 56 lines1import { crewArts, crewSprites } from '../engine/crew'
2import { HERO_COLS, LANE_PIX } from '../engine/geometry'
3import { HAT_TIERS } from '../engine/hats'
4import { RESERVED_ACTIVITIES } from '../engine/motion'
5import { adventurer } from './adventurer/index'
6import { cat } from './cat/index'
7import type { Pack } from './types'
8
9// What is wrong with a pack that the types cannot catch, or nothing.
10export function packProblems(pack: Pack): string[] {
11 const out: string[] = []
12 for (const id of RESERVED_ACTIVITIES) {
13 if (Object.hasOwn(pack.activities, id)) out.push(`defines '${id}', which is reserved for bedtime`)
14 }
15 for (const [role, id] of Object.entries(pack.roles)) {
16 if (!Object.hasOwn(pack.activities, id)) out.push(`roles.${role} is '${id}', which is not one of its activities`)
17 }
18 for (const [id, a] of Object.entries(pack.activities)) {
19 if (a.hit !== undefined && !(a.hit >= 0 && a.hit <= 1)) out.push(`activities.${id}.hit is ${a.hit}, not a chance from 0 to 1`)
20 const d = a.crew?.during
21 if (d && !(d[0] >= 0 && d[0] < d[1] && d[1] <= 1)) out.push(`activities.${id}.crew.during is [${d.join(', ')}], not a part of the leg from 0 to 1`)
22 }
23 for (const art of pack.crew ? crewArts(pack.crew) : []) {
24 const room = LANE_PIX - (art.flying ? art.flying.height + (art.flying.bob ?? 1) : 0)
25 if (crewSprites(art).some(r => r.length > room || r.some(row => row.length > HERO_COLS))) out.push(`crew sprites must be at most ${HERO_COLS} wide and ${room} tall`)
26 }
27 if (!Object.hasOwn(pack.scenes, pack.defaults.scene)) out.push(`defaults.scene '${pack.defaults.scene}' is not one of its scenes`)
28 if (!Object.hasOwn(pack.coats, pack.defaults.coat)) out.push(`defaults.coat '${pack.defaults.coat}' is not one of its coats`)
29 if (pack.hats.length > HAT_TIERS.length) out.push(`has ${pack.hats.length} hats, more than the ${HAT_TIERS.length} unlock tiers`)
30 const ids = pack.hats.map(h => h.id)
31 if (ids.includes('none') || new Set(ids).size !== ids.length) out.push(`hat ids must be unique and not 'none'`)
32 return out
33}
34
35// A pack with a problem is refused loudly here, rather than drawing the wrong
36// thing at bedtime or wandering off on a typo.
37function admit(packs: Pack[]): Record<string, Pack> {
38 const out: Record<string, Pack> = {}
39 for (const pack of packs) {
40 const problems = packProblems(pack)
41 if (problems.length) throw new Error(`pack '${pack.id}': ${problems.join('; ')}`)
42 out[pack.id] = pack
43 }
44 return out
45}
46
47// Every pack, by id. A new pack is one folder, plus an import and an entry in
48// admit([...]) here.
49export const PACKS: Record<string, Pack> = admit([cat, adventurer])
50
51export const DEFAULT_PACK = cat
52
53export function packFor(id: string | undefined): Pack {
54 return (id !== undefined && Object.hasOwn(PACKS, id) && PACKS[id]) || DEFAULT_PACK
55}
56