SLOPSHOPPER

pixel-cat

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…

newbandguardcommandtoastprompt
★ 5v0.1.0MITupdated 2026-10-09matthlh/claude-mods/plugins/pixel-cat
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixel-cat
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cat ⎿ pixel-cat: Cat hidden (run /cat again to bring it back). ⎿ pixel-cat: 5-hour limit: 31% (resets NaNm) ⎿ pixel-cat: 7-day limit: n/a ⎿ pixel-cat: Context: 49% ⎿ pixel-cat: Agents working: 0 (counts this session's agents only) ⎿ pixel-cat: ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Pixel Cat 🐱

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.

Claude cat poses: walk, walk 2, sit, happy, sleep

What it does

  • Wanders back and forth above the prompt, sits down now and then, and blinks.
  • Works alongside Claude, with a speech bubble showing the current tool (‹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.
  • Announces when a turn finishes (‹done! 42s›), plus a toast for turns longer than 20 seconds.
  • Reacts to failures with a >.< face when a tool call errors.
  • Food bowl = context: a bowl at the right end empties as the conversation fills up. Near the limit the cat mentions it; when the conversation is compacted, the bowl refills ("nom nom!").
  • Tracks your limits: shows your 5-hour, 7-day and context usage in green, yellow or red. It gets sleepy at 80% of the 5-hour limit and sends a toast at 75% and 90%.
  • Keeps itself busy while you're idle, mostly calmly (strolling, sitting, grooming itself, napping), with play now and then:
  • chases a ball of yarn, follows a butterfly, hops around, and takes little cat naps
  • hunts a mouse, and sometimes pins it under a paw (before it wriggles free and runs off)
  • stalks a bird, pounces ("nom?!"), and watches it fly away
  • goes fishing: a fishbowl, a fish tank or a river, depending on the scene, and sometimes catches one
  • chases a laser pointer dot back and forth until it vanishes ("where'd it go?")
  • mischief: finds a mug on a table, taps it… and knocks it off ("*CRASH*", "oops :3"), or sits right on top of your usage stats until you pet it
  • at night (9pm–5am, when the scenes turn dark) it mostly naps

Props for each tool, hats, a shiny cat, a mug about to be knocked off, and the cat sitting on your stats

The cat chasing yarn, hunting a mouse, following a butterfly, working and done

Each scene: fishing at a fishbowl, stalking a bird, chasing a laser dot, fishing at a fish tank

  • Goes to bed on its perch after 5 minutes of inactivity, or when your 5-hour limit runs out. The perch slides in from the left (a cat bed, a tree stump, a cloud or a cat tree, depending on the scene), the cat walks over, hops up and curls up. On your next prompt it wakes and the perch slides away.

The four perches: a cat bed, a tree stump, a cloud and a cat tree

  • Speech bubbles that type themselves out, with animated dots, hearts and sparkles.
  • Little animations in between: tail wags, ear twitches, a shake when something fails, and drifting z's while it naps.
  • Your own cat: each install rolls a marking (a white blaze, socks, a tail tip or a spot) and has a 1 in 50 chance of being ✨ shiny, with sparkles. /cat shows yours.
  • Hats to unlock: a party hat at 10 finished tasks, a beanie at 100 tool calls, a crown at 150 tasks and a wizard hat at 500 tool calls. You get a popup when one unlocks; wear it from ⚙ → Hat.
  • Time of day: the Grass scene gets dawn, dusk and starry-night skies, the Cozy room gets a window showing the sky outside (with the moon at night), and Clear shows a few stars at night.
  • Pet ♥ button (it purrs, with floating hearts, and wakes up if it was asleep) and Hide button.
  • ⚙ Settings, saved across sessions:
  • Coat: Orange, Tuxedo, Black, Grey, Cream or Sakura

The six coats

  • Speed: Chill, Normal or Zoomies
  • Scene: Clear (follows your light/dark theme), Grass, Night or Cozy. The scene also picks the perch and the fishing spot.
  • Popups: on or off
  • Hat: any you've unlocked
  • Usage: show or hide the limit numbers
  • /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.

Packs

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.

  • Cat: everything above.
  • Adventurer: mines ore out of blocks as cracks spread across them, chops a tree until it falls, builds a little wall or a staircase block by block, and crafts a chair or a blade at a workbench, furnace or anvil. Zombies shamble in, far more often at night (a slime in the forest by day), and get the sword or, 40% of the time, the bow, with a coin for the trouble. A bunny hops by now and then. While Claude works it reads (Read), hammers at the anvil (edits), works the furnace (shell commands) or searches by lantern or torch. The food bowl becomes a potion that drains as the context fills. Six outfits (Starter, Miner, Mage, Ranger, Knight, Gold), four scenes (Forest, Cavern, Night, Cabin) each with its own bed, and its own hats: a feather, a horned helm, a crown and a wizard hat.

The adventurer mining, chopping, building, crafting, fighting a zombie and a slime with its summons, working and asleep

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.

Your agents, on screen

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.

The five summons, each in its two idle frames and its attack, and beside the hero at lane size

Install

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).

From a clone

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.

Customize

The sprites are 12×10 grids of letters in hooks/packs/cat/sprites.ts:

LetterColor
ofur (Claude orange)
dshade
H / Keye shine / eye
rblush
nnose
wchest

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.

Make your own pack

  1. Copy 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.
  2. Redraw the sprites, outfits and scenes, and change the activities. Each activity is drawn with the track helper in 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:

The adventurer's poses, outfits, tools, foes, drops, blocks, props and damage numbers

  1. Register it in hooks/packs/index.ts: import { yours } from './<yours>/index' and add it to admit([cat, adventurer, yours]).
  2. Check it:
   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.

Source 28 files
hooks/register.tsx 668 lines
1import { 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}
668
hooks/engine/brain.ts 145 lines
1import 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}
145
hooks/engine/cells.ts 212 lines
1import 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}
212
hooks/engine/crew.ts 710 lines
1import 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}
710
hooks/engine/geometry.ts 23 lines
1// 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
23
hooks/engine/hats.ts 35 lines
1import 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}
35
hooks/engine/lane.ts 134 lines
1import 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}
134
hooks/engine/motion.ts 38 lines
1import 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}
38
hooks/engine/svg.ts 237 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
237
hooks/engine/time.ts 21 lines
1// 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}
21
hooks/packs/cat/sprites.ts 114 lines
1import { 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>
114
hooks/packs/index.ts 56 lines
1import { 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