Clawd's pixel world above your prompt in the Claude desktop app, in five scenes (open space among them, where everyone floats) that follow your local time…

A pixel house for Clawd above your prompt in the Claude desktop app's Code tab. Clawd walks to the room of whatever Claude is doing, while his crew plays in the game room. Under the house you get the figures you'd normally check the status line for. It costs you no tokens: Clawd never calls a model and puts nothing into your conversation.

The house. It's a cross-section of five rooms, inspired by ClawLibrary's pixel archive for OpenClaw:
| Room | Clawd goes there when Claude… |
|---|---|
| Library | reads or searches files |
| Workshop | edits files or thinks; your deadlines hang on the board and the calendar |
| Server room | runs a command |
| Lookout | searches or fetches the web (the window follows your local time: day, dusk, starry night) |
| Game room | is idle: the crew lives here |
Five scenes. Switch them with the Scene button on the band, or with /clawd scene beach. Every scene keeps the same five zones, so Clawd and the crew behave the same anywhere:

The crew. Three Clawds in blue, green and purple beanies:

The scene shows what's really happening. You can read the session from the scene alone, in every scene:
npm test, pytest, go test, cargo test or the like passes, Clawd cheers; when it fails, he sweats.
A life of their own. The crew keeps its own day by your clock: a picnic lunch at noon and tea at three, even while Claude works. After fifty minutes of work without a break, Clawd stretches and nudges you to do the same. Now and then (a few times a day) a rare sight turns up for a minute and a half: a shooting star across a dark sky, a sparrow on the house's window sill, a whale spouting off the beach, a UFO past the space station's window, a deer peeking out at the camp. Seeing them counts toward a trophy.

Make them yours. /clawd name Pip names the main Clawd, and /clawd name 1 Juniper a crew member; /clawd cap 1 red gives them a beanie of your colour. Each project remembers the scene you last chose for it. /clawd birthday 10/31 puts everyone in party hats, with confetti, on your birthday.
A git safety net. The band shows the project's branch, how far it is ahead of or behind its upstream, and how many files aren't committed; the figure turns yellow after half an hour of uncommitted work and red after two hours or twenty files. When Claude's edits have sat uncommitted for an hour, Clawd stamps a "Time to commit" between turns, with a word on how many files and unpushed commits are waiting.
What this session changed. /clawd changes (or Changes in the /clawd pane) lists every file Claude edited or wrote this session, with the lines it added and removed and a dot on this turn's, and the commands it ran with ✓ or ✗. Handy before you commit.
Clawd in the conversation. In the Desktop app, a tool's row shows Clawd at his laptop while it runs; once done, a small Clawd (pleased, or worried if it failed) sits by what it did, with an arrow of his own that opens the command and the first lines of its output.
Neighbors. Running more than one session? The other sessions' Clawds come to visit. Up to two busy neighbors walk in from the right to the room of whatever their Claude is doing, in caps of their own and with their project on their bubble, and walk back out when they stop or their session ends. The band lists the sessions next door. They talk through the plugin's own store on your machine; nothing leaves it.
Trophies. 78 goals in 21 families, most in four tiers, from a first commit to a hundred days in a row, a million tool calls or a three-hour turn. Every family you reach hangs a medal under the game room's bunting. Some bring the main Clawd a hat (a party hat, a crown, a halo, a wizard hat…), a pal who walks the floor (a cat, an owl, a little crab), or a golden stamp and seal for your commits and pushes. /clawd trophies shows where you stand on each; /clawd hat and /clawd pal choose what he wears and who walks with him. The days you already worked count from the start.

| Trophy | For | Bronze | Silver | Gold | Legendary |
|---|---|---|---|---|---|
| Streak | Days in a row with work | 3 d | 7 d + party hat | 30 d + crown | 100 d + cat |
| Days at work | Days with work, in all | 10 d | 50 d | 200 d | 365 d + halo |
| Commits | Commits | 1 | 100 | 1,000 + golden stamp | 5,000 |
| Pushes | Pushes | 1 | 50 | 500 + golden seal | 2,000 |
| Tests passed | Test runs passed | 1 | 100 | 1,000 + mortarboard | 10,000 |
| All green | Test runs passed in a row, with no failure between | 5 | 25 | 100 | 500 |
| Tool calls | Tool calls | 1,000 | 10,000 | 100,000 + headphones | 1,000,000 |
| Files edited | Files edited | 100 | 1,000 | 10,000 | 50,000 |
| Tidy-ups | Times the conversation was compacted | 1 | 10 | 50 + wizard hat | 200 |
| Subagents sent | Subagents sent out | 10 | 100 | 1,000 + captain's cap | 5,000 |
| Night owl | Turns begun between midnight and 5 a.m. | 1 | 25 | 100 + owl | 500 |
| Early bird | Turns begun between 5 and 7 a.m. | 1 | 25 | 100 + flower | 300 |
| Marathon day | Most work in a single day | 4 h | 8 h | 12 h | 16 h |
| Long haul | Longest single turn | 10 min | 30 min | 60 min | 180 min |
| Busiest day | Most tool calls in one day | 300 | 1,000 | 3,000 | 10,000 |
| Pets | Times you petted Clawd | 10 | 100 | 1,000 + little crab | 10,000 |
| PRs merged | Pull requests merged | 1 | 25 | 100 | 500 |
| Globetrotter | Scenes lived in | — | 4 + explorer's hat | — | — |
| Holiday shift | Holidays worked through (Lunar New Year, Halloween, Christmas) | 1 | 2 | 3 | — |
| Rare sights | Rare sights seen (a shooting star, each scene’s visitor) | 1 | 3 | 5 | — |
| Comeback | Passing a test run after three or more failures in a row | 1 | 10 | 50 | — |
Seasons and holidays. The scenes follow the calendar wherever you are, with no network:
Australia/Sydney, America/Sao_Paulo) tells Clawd which side you're on.
Light and air. Every room is lit by its own lamps and screens: soft pools of light, a glow on each screen, the day coming in at the window, a beam of daylight down from the house's window, open skies blended smooth with a halo round the sun or the moon, the warm cast of dusk, rooms gone dim after dark with their lamps still on, screens and racks that breathe, and a soft shadow under every Clawd. The air moves too: dust drifting in the window light, stars twinkling, glints on the sea, smoke off the campfire, fireflies at the camp after sundown.
Hover and switch. A room under the pointer lights up and shows a card: what the room is for and what it holds right now (Claude's memory in the library, your deadlines on the board, git in the server room, the view outside, the medals in the game room), with the light switch. One switch puts out the whole place: every room goes dim, the screens go dark and the lamps stop glowing, in every scene and every session. The band has the switch too, and /clawd lights off works from the keyboard. A Clawd under the pointer gets hearts and a card saying what he's doing, with a pat.
With reduced motion turned on in your system settings, the Desktop scene holds still: no loops, no drifting clouds, leaves or motes, no flicker.
<img src="docs/compacting.gif" alt="While the conversation is compacted, Clawd stomps a pile of pages into a bundle" width="310" align="right">
A Clawd spinner. The Thinking… line becomes a small Clawd who thinks in dots, then sits down at a laptop and types away while Claude works, beside a clock that counts every second. While the conversation is compacted he stomps a pile of pages down into a neat bundle. A tool's row in the conversation shows him at the laptop too while the tool runs. Between turns he stands by on the hint line under the prompt: waving when it's your turn, asleep late at night, with how long the last reply took and today's total. These two lines stay in English in either language, beside Claude Code's own words.
Clawd's day. /clawd recap (or Today in the /clawd pane) shows a card of the day so far: how long Clawd worked alongside Claude, turns, tool calls, files edited, commands, tests passed, commits, pushes and tidy-ups, where the day went room by room, the week, and your streak of days in a row. It counts every session and project on the machine, and it's made to be screenshotted and shared.

Live figures under the house: model, context fill, 5-hour and 7-day plan usage, session cost, and your latest message: how long Claude has been on it (or took on the last one), and the tool calls, edits and commands it made there. Each turns yellow past 50% and red past 80%, and past 70% a plan window says when it starts over. Folded, the band keeps one line of it: what Clawd is doing, the context, the 5-hour window, this reply, git and the next deadline.
Plan limits. At 80%, 95% and 100% of your 5-hour or 7-day window, Clawd tells you once (a toast, and on his bubble): how much is gone and when it starts over, and nearly out with work uncommitted, to commit first. Once a window, however many sessions you have open.
This session's summary. /clawd summary (or Summary in the /clawd pane) shows a card of the session so far: how long Clawd worked alongside Claude, replies, tool calls, the files changed with their +/− lines, commands (and how many failed), tests, commits, pushes, cost, and the files changed most. When the session ends you get it in a toast, and the next session in the same project opens with it.
Deadline radar. /deadline add 12/24 Launch puts a countdown on the band and the wall calendar, and pins a note on the board, coloured by how near it is. With under 3 days left Clawd sweats; under 24 hours the calendar flashes red and he panics.
English and 繁體中文. It follows your system language. Switch any time with the button on the band or /clawd lang en|zh|auto.
You need the Claude desktop app's Code tab with mods (v2.1.286+; tested on 2.1.288). Clawd lives there: in a terminal session the mod draws nothing and Claude Code looks as it always does, though its work still counts toward your day and your trophies.
claude plugin marketplace add Tunai-0511/clawd-sidekick
claude plugin install clawd-sidekick@clawd-sidekick
Or from inside a session:
/plugin install clawd-sidekick --marketplace Tunai-0511/clawd-sidekick
Run /reload-plugins in an open session, or start a new one.
Third-party marketplaces don't auto-update by default, and a marketplace can't switch that on for you. Turn it on once: run /plugin, open Marketplaces, choose clawd-sidekick, and select Enable auto-update. New versions then download in the background and load the next time you start Claude Code. Without it, run claude plugin update clawd-sidekick@clawd-sidekick when you want the latest.
| Command | What it does |
|---|---|
/clawd | Open the Clawd Sidekick pane: big Clawd, all your deadlines, Today and Trophies |
/clawd changes | Every file Claude changed this session (+/− lines) and every command it ran (✓/✗) |
/clawd recap | Today's card: time worked, the figures, the rooms, the week and your streak |
/clawd summary | This session's card: time worked, replies, files changed, commands, commits, cost |
/clawd lights off · on | Put every light out, or back on (off library and the like switch one room) |
/clawd trophies | Every trophy family: your tier, your progress, what the next tier brings |
/clawd hat <name> · auto · none | Put on a hat you've earned (auto: the finest) |
/clawd pal <name> · auto · none | Choose the pal who walks the floor |
/clawd name <name> · name 1 <name> · name reset | Name the main Clawd or a crew member (1–3) |
/clawd cap 1 red | Give a crew member (1–3) a beanie: blue, green, purple, red, yellow, teal or pink |
/clawd birthday 10/31 · off | Party hats and confetti on your birthday |
/clawd scene house · beach · space · forest · next | Move the Clawds to another scene |
/clawd season winter · auto | Set the season by hand, or follow the date |
/clawd holiday christmas · lunar · halloween · none · auto | Set the decorations by hand, or follow the date |
/clawd hide · /clawd show | Fold the band to one line, or unfold it |
/clawd lang en · zh · auto | Switch language (auto follows the system) |
/deadline | List deadlines; also add 12/24 Name, add 2027-03-01 09:00 Name, add tomorrow Name, rm N |
These are in /config, under the plugin:
| Setting | Default | |
|---|---|---|
language | auto | auto, en or zh-TW |
A mod runs with your permissions, so here is everything this one reaches (claude plugin validate . lists the same):
/clawd or /deadline command leaves only its own one-line record, as any slash command does.date +%z and readlink /etc/localtime once at start, for your time zone and which way the seasons run; defaults read -g AppleLanguages once, on macOS, when the language is auto and no LANG is set; and in the project's folder, every 30 seconds and after Claude edits or runs something, git status --porcelain -b and git log -1 --format=%ct, read-only, for the git safety net.LANG, LC_ALL, LC_MESSAGES and TZ.hooks/themes.ts draws the five scenes procedurally on a 256 × 28 canvas (hooks/pixels.ts). hooks/scene.ts adds the Clawds (floating, in open space) and the crew's places in each game.hooks/scene-svg.ts turns it into one SVG, drawn as an image so a new drawing replaces the last without a blink. The still scene is drawn once. Each of the scene's moving parts loops as a SMIL flipbook of only the pixels that change, and the game being played is a layer of its own, so their periods never multiply. Walking is animateTransform. Over the pixels goes the light (hooks/light.ts): per room, a dim veil masked by radial pools around its lights, a blur for their glow, and gradients for depth. What the pointer finds is the band's own: unseen strips over each room and Clawd that, hovered, show a native card and an image of what lights up. color-scheme: light dark on the root keeps the frame transparent on any theme. When the system asks for reduced motion, every loop holds its first frame and nothing drifts.hooks/decor.ts adds the holidays and the falling petals and leaves; hooks/seasons.ts works out the season and the holiday; hooks/events.ts turns a finished command into a moment (a test run, a commit, a push, a pull request).hooks/i18n.ts holds every string in both languages.claude --plugin-dir . # load this folder for one session; edits hot-reload
claude plugin validate .
claude plugin test .
Type declarations for your Claude Code build appear in .claude-plugin/types/ the first time the folder loads. After that, npx -p typescript tsc -p . type-checks the mod.
A scene is one entry in THEMES (hooks/themes.ts), and its type, ThemeArt, makes every part required: the drawing, its moving parts, the light and the toy that answer the pointer, the sky, the signs, memory (the thing in the library zone that shows how full the context is), and glows, isIndoor and hasDaylight (what gives off light in each room, and how the scene dims). Leave one out and the mod doesn't type-check. Then the tests in what happens shows in every scene run your scene through every memory level and every moment (asking, stamping, sending, tidying, cheering, sweating), and fail if any of them doesn't show or the SVG outgrows its limit.
Inspired by ClawLibrary. Clawd is Anthropic's Claude Code mascot; this is an unofficial fan project, not affiliated with or endorsed by Anthropic.
MIT License.
hooks/register.tsx 2022 lines1// Clawd 副駕 (clawd-sidekick): the human's half of pairing with Claude.
2//
3// Above the prompt is Clawd's house: the main Clawd walks to the room of
4// whatever Claude is doing, and a crew of three plays in the game room
5// (volleyball while Claude works, something else while it waits, asleep at
6// night) until a subagent calls one of them to work. Under it, the figures
7// the status line knows. Clawd keeps the list of things only you can do
8// (read off Claude's replies) and frets as your deadlines close in.
9
10import { atom, read, update } from 'claude-code'
11import type { Elements, EngineInterface, Register, RenderElement } from 'claude-code'
12
13import type { Activity, Changes, Day, Deadline, Doing, Egg, Game, GitState, Hat, Holiday, Life, Neighbor, Pal, Pose, RoomId, SceneActor, SceneProps, Season, Theme, Tier, TimeOfDay, Trophies, Usage } from '../types'
14import { countdown, formatDue, newId, parseDeadline, parseOffset, urgency, URGENCY_COLOR } from './deadline'
15import { langOf, say, type Lang } from './i18n'
16import { actorTip, actorX, eggOf, layoutFor, ROOMS, SH, SPOT_X, STEP, SW, type Spot } from './scene'
17import { actorHoverSvg, doneClawdSvg, miniClawdSvg, roomHoverSvg, sceneSvg, squashClawdSvg } from './scene-svg'
18import { isSwitched, roomSpans } from './light'
19import { H, W } from './sprite'
20import { THEME_ORDER, THEMES } from './themes'
21import { clawdSvg } from './svg'
22import { EDITING, fileChangeOf, NO_CHANGES, recordCommand, recordFile, relativePath, totals } from './changes'
23import { isTestRun, momentOf, type GitOperation } from './events'
24import { gitColor, isDirty, needsCommit, parseStatus } from './git'
25import { dateOf, dayBefore, duration, emptyDay, favoriteRoom, poseOf, recapLine, recapSvg, ROOM_IDS, sessionLine, sessionSvg, streakOf, type SessionSummary } from './recap'
26import { holidayOf, seasonOf, zoneOf } from './seasons'
27import {
28 emptyLife,
29 FAMILIES,
30 goldenOf,
31 hatFor,
32 hatsOf,
33 lifeFromDays,
34 medalsOf,
35 NO_TROPHIES,
36 palFor,
37 palsOf,
38 reached,
39 standing,
40 TROPHY_TOTAL,
41 type FamilyId,
42} from './trophies'
43
44type Engine = EngineInterface
45
46const PANE = 'clawd'
47const RECAP = 'clawd-recap'
48const CHANGES_PANE = 'clawd-changes'
49const TROPHY_PANE = 'clawd-trophies'
50const SESSION_PANE = 'clawd-session'
51
52/** What the spinner says the turn is doing: in English whatever Clawd speaks, beside Claude Code's own `Working…`. */
53const ORANGE = '#D97757'
54/** CSS pixels a desktop cell is taken to be, to size the house's frame. */
55const CELL_PX = 8
56
57const activity = atom({ plugin: 'clawd-sidekick', key: 'activity' } as const, { pose: 'idle', label: '', since: 0 })
58const deadlinesAtom = atom({ plugin: 'clawd-sidekick', key: 'deadlines' } as const, [])
59const collapsedAtom = atom({ plugin: 'clawd-sidekick', key: 'isCollapsed' } as const, false)
60const petsAtom = atom({ plugin: 'clawd-sidekick', key: 'pets' } as const, 0)
61const nowAtom = atom({ plugin: 'clawd-sidekick', key: 'now' } as const, 0)
62const offsetAtom = atom({ plugin: 'clawd-sidekick', key: 'offset' } as const, 480)
63const projectAtom = atom({ plugin: 'clawd-sidekick', key: 'project' } as const, '')
64const gameAtom = atom({ plugin: 'clawd-sidekick', key: 'game' } as const, 'pong')
65const langAtom = atom({ plugin: 'clawd-sidekick', key: 'lang' } as const, 'en')
66const themeAtom = atom({ plugin: 'clawd-sidekick', key: 'theme' } as const, 'house')
67const zoneAtom = atom({ plugin: 'clawd-sidekick', key: 'zone' } as const, '')
68const todayAtom = atom({ plugin: 'clawd-sidekick', key: 'today' } as const, null)
69const trophiesAtom = atom({ plugin: 'clawd-sidekick', key: 'trophies' } as const, NO_TROPHIES)
70const neighborsAtom = atom({ plugin: 'clawd-sidekick', key: 'neighbors' } as const, [])
71const eggAtom = atom({ plugin: 'clawd-sidekick', key: 'egg' } as const, null)
72const birthdayAtom = atom({ plugin: 'clawd-sidekick', key: 'birthday' } as const, '')
73const namesAtom = atom({ plugin: 'clawd-sidekick', key: 'names' } as const, {})
74const gitAtom = atom({ plugin: 'clawd-sidekick', key: 'git' } as const, null)
75const changesAtom = atom({ plugin: 'clawd-sidekick', key: 'changes' } as const, NO_CHANGES)
76const openRowsAtom = atom({ plugin: 'clawd-sidekick', key: 'openRows' } as const, [])
77const compactingAtom = atom({ plugin: 'clawd-sidekick', key: 'compacting' } as const, false)
78const seasonPickAtom = atom({ plugin: 'clawd-sidekick', key: 'seasonPick' } as const, 'auto')
79const holidayPickAtom = atom({ plugin: 'clawd-sidekick', key: 'holidayPick' } as const, 'auto')
80const darkRoomsAtom = atom({ plugin: 'clawd-sidekick', key: 'darkRooms' } as const, [])
81const sessionAtom = atom({ plugin: 'clawd-sidekick', key: 'session' } as const, { startedAt: 0, totals: emptyDay('') })
82
83/**
84 * What features since removed kept on this machine: the sound and band
85 * setting, the to-do list, the weather and the place it was for. Nothing
86 * reads them any more, so they are deleted when a session starts.
87 */
88const RETIRED_KEYS = ['band', 'todos', 'weather', 'weatherNoticed'] as const
89
90const home = (id: string, cap: string | null, x: number, doing: Doing): SceneActor => ({ id, cap, fromX: x, toX: x, departAt: 0, doing, label: '' })
91
92const HOUSE: SceneActor[] = [
93 home('main', null, SPOT_X.library, 'read'),
94 home('c1', 'blue', SPOT_X.arcade, 'arcade'),
95 home('c2', 'green', SPOT_X.pongL, 'pong'),
96 home('c3', 'purple', SPOT_X.pongR, 'pong'),
97]
98
99const actorsAtom = atom({ plugin: 'clawd-sidekick', key: 'actors' } as const, HOUSE)
100const usageAtom = atom({ plugin: 'clawd-sidekick', key: 'usage' } as const, {
101 model: '',
102 contextPercent: null,
103 fiveHour: null,
104 sevenDay: null,
105 fiveHourResetsAt: null,
106 sevenDayResetsAt: null,
107 usd: null,
108 turnStartedAt: 0,
109 tools: 0,
110 edits: 0,
111 runs: 0,
112})
113
114/** Where each of Clawd's poses takes him in the house; null stays put. */
115const PLACE: Record<Pose, { spot: Spot | null; doing: Doing }> = {
116 idle: { spot: 'library', doing: 'read' },
117 think: { spot: 'code', doing: 'think' },
118 type: { spot: 'bash', doing: 'type' },
119 write: { spot: 'code', doing: 'code' },
120 read: { spot: 'library', doing: 'read' },
121 search: { spot: 'library', doing: 'read' },
122 web: { spot: 'web', doing: 'web' },
123 agent: { spot: 'code', doing: 'think' },
124 wait: { spot: null, doing: 'wait' },
125 cheer: { spot: null, doing: 'cheer' },
126 panic: { spot: null, doing: 'panic' },
127 oops: { spot: null, doing: 'oops' },
128 sleep: { spot: 'library', doing: 'sleep' },
129 itemget: { spot: 'board', doing: 'pin' },
130 love: { spot: null, doing: 'love' },
131 ask: { spot: null, doing: 'ask' },
132 stamp: { spot: null, doing: 'stamp' },
133 mail: { spot: null, doing: 'mail' },
134 tidy: { spot: 'library', doing: 'tidy' },
135 stretch: { spot: null, doing: 'stretch' },
136}
137
138const str = (value: unknown): string => (typeof value === 'string' ? value : '')
139const baseName = (path: string): string => path.split('/').filter(Boolean).pop() ?? path
140const clip = (text: string, size: number): string => (text.length > size ? `${text.slice(0, size - 1)}…` : text)
141
142/** What a tool call looks like to Clawd: his pose and his line. */
143function describe(tool: string, input: Record<string, unknown>): { pose: Pose; label: string } {
144 const w = say(lang)
145 const path = str(input.file_path) || str(input.notebook_path) || str(input.path)
146 switch (tool) {
147 case 'Bash':
148 // Claude says what a command is for; that reads better than the command itself.
149 return { pose: 'type', label: str(input.description) !== '' ? clip(str(input.description), 28) : w.run(clip(str(input.command).split('\n')[0] ?? '', 28)) }
150 case 'Edit':
151 case 'MultiEdit':
152 case 'Write':
153 case 'NotebookEdit':
154 return { pose: 'write', label: w.edit(baseName(path)) }
155 case 'Read':
156 return { pose: 'read', label: w.read(baseName(path)) }
157 case 'Grep':
158 case 'Glob':
159 return { pose: 'search', label: w.find(clip(str(input.pattern), 24)) }
160 case 'WebSearch':
161 return { pose: 'web', label: w.search(clip(str(input.query), 24)) }
162 case 'WebFetch':
163 return { pose: 'web', label: w.fetch(str(input.url).replace(/^https?:\/\//, '').split('/')[0] ?? '') }
164 case 'Agent':
165 case 'Task':
166 return { pose: 'agent', label: w.dispatch(clip(str(input.description), 20)) }
167 case 'AskUserQuestion':
168 return { pose: 'wait', label: w.asks }
169 case 'ExitPlanMode':
170 return { pose: 'wait', label: w.plan }
171 default: {
172 const short = tool.startsWith('mcp__') ? (tool.split('__').pop() ?? tool) : tool
173 return { pose: 'think', label: w.use(clip(short, 24)) }
174 }
175 }
176}
177
178const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
179
180/** A crew member stands a little aside from the main Clawd, inside the same room. */
181function crewX(id: string, spot: Spot): number {
182 const x = SPOT_X[spot] + ({ c1: 9, c2: -9, c3: 5 }[id] ?? 0)
183 const room = ROOMS.find(r => SPOT_X[spot] >= r.x && SPOT_X[spot] < r.x + r.w)
184 return room === undefined ? x : Math.max(room.x, Math.min(room.x + room.w - 16, x))
185}
186
187/** `a` heading for `toX` (null: where he is going already) to do `doing` there. */
188function moveActor(a: SceneActor, toX: number | null, doing: Doing, label: string, now: number): SceneActor {
189 const target = toX ?? a.toX
190 if (target === a.toX) return { ...a, doing, label }
191 return { ...a, fromX: actorX(a, now), toX: target, departAt: now, doing, label }
192}
193
194/** "claude-opus-5-5[1m]" → "Opus 5.5". */
195function modelName(model: string): string {
196 const m = model.match(/(opus|sonnet|haiku|fable)[- ]?(\d+)(?:[-.](\d+))?/i)
197 if (m === null) return model
198 const name = m[1]!.charAt(0).toUpperCase() + m[1]!.slice(1).toLowerCase()
199 return `${name} ${m[2]}${m[3] ? `.${m[3]}` : ''}`
200}
201
202/** The person's hour of the day. */
203const hourOf = (now: number, offset: number): number => new Date(now + offset * 60_000).getUTCHours()
204
205function timeOfDay(now: number, offset: number): TimeOfDay {
206 const hour = hourOf(now, offset)
207 if (hour >= 7 && hour < 17) return 'day'
208 if (hour >= 17 && hour < 19) return 'dusk'
209 return 'night'
210}
211
212/** Volleyball while Claude works; asleep late at night; otherwise a new game every ninety seconds. */
213function chooseGame(now: number, offset: number): Game {
214 const hour = hourOf(now, offset)
215 const minute = new Date(now + offset * 60_000).getUTCMinutes()
216 // The crew keeps its own day: lunch at noon, tea at three, whatever Claude is up to.
217 if (hour === 12) return 'lunch'
218 if (hour === 15 && minute < 30) return 'tea'
219 if (isWorking) return 'volley'
220 if (hour >= 23 || hour < 7) return 'sleep'
221 return (['pong', 'rope', 'tower'] as const)[Math.floor(now / 90_000) % 3] ?? 'pong'
222}
223
224// Module state: what a reload may forget.
225
226let settings = { language: 'auto' }
227let lang: Lang = 'en'
228let isWorking = false
229let lastUsageAt = 0
230let lastTurnMs = 0
231let ticks = 0
232/** When the run of work began (turns less than five minutes apart), when the last turn ended, and the last stretch. */
233let busySince = 0
234let lastTurnEndAt = 0
235let lastStretchAt = 0
236/** When the tree last went from clean to dirty (0 while clean), the last git read, and the last nudge to commit. */
237let dirtySince = 0
238let lastGitAt = 0
239let lastNudgeAt = 0
240const BUSY_GAP = 5 * 60_000
241const BREAK_AFTER = 50 * 60_000
242const STRETCH_EVERY = 10 * 60_000
243/** This session's id, under which it tells the others what it is up to. */
244let sessionId = ''
245let sharedAs = ''
246let sharedAt = 0
247/** When each tool call still running began, for its row's clock. */
248const toolStarted = new Map<string, number>()
249
250/** What a running tool's row says it is doing: in English, like the desktop's own rows. */
251const TOOL_WORDS: Readonly<Record<string, string>> = {
252 Bash: 'Running a command',
253 Read: 'Reading a file',
254 Edit: 'Editing a file',
255 MultiEdit: 'Editing a file',
256 Write: 'Writing a file',
257 NotebookEdit: 'Editing a notebook',
258 Grep: 'Searching',
259 Glob: 'Finding files',
260 WebFetch: 'Fetching a page',
261 WebSearch: 'Searching the web',
262 Agent: 'Running a subagent',
263 Task: 'Running a subagent',
264}
265
266// ── Clawd and his crew ──────────────────────────────────────────────────
267
268async function act($: Engine, pose: Pose, label: string): Promise<number> {
269 const since = await $.clock.now()
270 await update($, activity, () => ({ pose, label, since }))
271 const place = PLACE[pose]
272 await update($, actorsAtom, actors =>
273 actors.map(a => (a.cap === null ? moveActor(a, place.spot === null ? null : SPOT_X[place.spot], place.doing, label, since) : a)),
274 )
275 return since
276}
277
278/** A pose for `ms`, then back to `after` unless something else took over meanwhile. */
279async function flash($: Engine, pose: Pose, label: string, ms: number, after: () => Promise<Activity>): Promise<void> {
280 const since = await act($, pose, label)
281 $.clock.after(ms, () => {
282 void (async () => {
283 if ((await read($, activity)).since !== since) return
284 const next = await after()
285 await act($, next.pose, next.label)
286 })()
287 })
288}
289
290async function settle($: Engine): Promise<Activity> {
291 const w = say(lang)
292 return isWorking ? { pose: 'think', label: w.thinking, since: 0 } : { pose: 'wait', label: w.yourTurn, since: 0 }
293}
294
295/** Puts every free crew member at his place in the game of the moment. */
296async function arrangeCrew($: Engine): Promise<void> {
297 const now = await $.clock.now()
298 const game = chooseGame(now, await read($, offsetAtom))
299 await update($, gameAtom, () => game)
300 await update($, actorsAtom, actors => {
301 const free = actors.filter(a => a.cap !== null && a.agentKey === undefined && a.neighbor === undefined).sort((p, q) => p.id.localeCompare(q.id))
302 const { slots } = layoutFor(game, free.length)
303 return actors.map(a => {
304 const slot = slots[free.indexOf(a)]
305 if (slot === undefined || (a.toX === slot.x && a.doing === slot.doing)) return a
306 return moveActor(a, slot.x, slot.doing, '', now)
307 })
308 })
309}
310
311/** A subagent borrows the first free player from the game room. */
312async function assignCrew($: Engine, agentKey: string, label: string): Promise<void> {
313 const now = await $.clock.now()
314 await update($, actorsAtom, actors => {
315 const free = actors.filter(a => a.cap !== null && a.agentKey === undefined && a.neighbor === undefined).sort((p, q) => p.id.localeCompare(q.id))[0]
316 if (free === undefined) return actors
317 return actors.map(a => (a.id === free.id ? { ...moveActor(a, crewX(a.id, 'code'), 'think', label, now), agentKey } : a))
318 })
319 await arrangeCrew($)
320}
321
322/** A subagent's tool call walks its crew member to that room. */
323async function routeCrew($: Engine, agentId: string, pose: Pose, label: string): Promise<void> {
324 const now = await $.clock.now()
325 await update($, actorsAtom, actors => {
326 const mine = actors.find(a => a.agentId === agentId) ?? actors.find(a => a.agentKey !== undefined && a.agentId === undefined)
327 if (mine === undefined) return actors
328 const place = PLACE[pose]
329 const spot = place.spot ?? 'code'
330 const doing = place.doing === 'wait' ? 'think' : place.doing
331 return actors.map(a => (a.id === mine.id ? { ...moveActor(a, crewX(a.id, spot), doing, clip(label, 14), now), agentId } : a))
332 })
333}
334
335/** The subagent is done: its player goes back to the game. */
336async function releaseCrew($: Engine, agentKey: string): Promise<void> {
337 await update($, actorsAtom, actors =>
338 actors.map(a => {
339 if (a.agentKey !== agentKey) return a
340 const { agentKey: _key, agentId: _id, ...rest } = { ...a, label: '' }
341 return rest
342 }),
343 )
344 await arrangeCrew($)
345}
346
347/** A pat on the head: hearts, and back to what he was doing. */
348// ── The day, for /clawd recap ───────────────────────────────────────────
349
350const ROOM_OF_SPOT: Partial<Record<Spot, RoomId>> = { library: 'library', board: 'codelab', code: 'codelab', bash: 'terminal', web: 'web', arcade: 'game' }
351
352const COUNTERS = ['turns', 'workMs', 'tools', 'edits', 'runs', 'testsPassed', 'testsFailed', 'commits', 'pushes', 'prsOpened', 'prsMerged', 'compactions', 'helpers', 'pets'] as const
353
354/**
355 * Today's record from the store (another session's work counts too),
356 * changed and kept; what it adds goes to the lifetime totals too, with
357 * anything only those keep (`lifeChange`), and the trophies are checked.
358 */
359async function logDay($: Engine, change: (day: Day) => void, lifeChange?: (life: Life) => void): Promise<void> {
360 const date = dateOf(await $.clock.now(), await read($, offsetAtom))
361 const kept = (await $.store.get(`day:${date}`)) as Day | undefined
362 const day: Day = kept === undefined ? emptyDay(date) : { ...emptyDay(date), ...kept, rooms: { ...emptyDay(date).rooms, ...kept.rooms } }
363 const before = { ...day }
364 change(day)
365 await $.store.set(`day:${date}`, day)
366 await update($, todayAtom, () => day)
367 // This session keeps the same figures, for its summary.
368 await update($, sessionAtom, s => {
369 const totals: Day = { ...s.totals, rooms: { ...s.totals.rooms } }
370 change(totals)
371 return { ...s, totals }
372 })
373 await changeLife($, life => {
374 for (const key of COUNTERS) life[key] += day[key] - before[key]
375 if (before.turns + before.tools === 0 && day.turns + day.tools > 0 && life.lastDay !== date) {
376 life.days++
377 life.streak = life.lastDay === dayBefore(date) ? life.streak + 1 : 1
378 life.bestStreak = Math.max(life.bestStreak, life.streak)
379 life.lastDay = date
380 }
381 life.bestDayMs = Math.max(life.bestDayMs, day.workMs)
382 life.bestDayTools = Math.max(life.bestDayTools, day.tools)
383 life.longestTurnMs = Math.max(life.longestTurnMs, day.longestMs)
384 lifeChange?.(life)
385 })
386}
387
388// ── Trophies ────────────────────────────────────────────────────────────
389
390/** The lifetime totals; the first time, rebuilt from the days kept so far. */
391async function loadLife($: Engine): Promise<Life> {
392 const kept = (await $.store.get('life')) as Life | undefined
393 if (kept !== undefined) return { ...emptyLife(), ...kept }
394 const days: Day[] = []
395 for (const key of await $.store.keys()) {
396 if (!key.startsWith('day:')) continue
397 const day = (await $.store.get(key)) as Day | undefined
398 if (day !== undefined) days.push({ ...emptyDay(key.slice(4)), ...day })
399 }
400 const life = lifeFromDays(days)
401 life.pets = Number((await $.store.get('pets')) ?? 0)
402 life.scenes = [await read($, themeAtom)]
403 await $.store.set('life', life)
404 return life
405}
406
407/** Changes the lifetime totals from what the store holds now, then sees what that reached. */
408async function changeLife($: Engine, change: (life: Life) => void): Promise<void> {
409 const life = await loadLife($)
410 change(life)
411 await $.store.set('life', life)
412 await checkTrophies($, life)
413}
414
415/** Keeps every trophy `life` newly reached, says so, and has Clawd cheer. */
416async function checkTrophies($: Engine, life: Life): Promise<void> {
417 const kept = ((await $.store.get('trophies')) as Trophies | undefined) ?? (await read($, trophiesAtom))
418 const fresh = reached(life).filter(id => kept.unlocked[id] === undefined)
419 if (fresh.length === 0) {
420 await update($, trophiesAtom, () => kept)
421 return
422 }
423 const now = await $.clock.now()
424 const next: Trophies = { ...kept, unlocked: { ...kept.unlocked } }
425 for (const id of fresh) next.unlocked[id] = now
426 await $.store.set('trophies', next)
427 await update($, trophiesAtom, () => next)
428 const w = say(lang)
429 if (fresh.length > 1) {
430 $.ui.toast(w.unlockedMany(fresh.length), { timeoutMs: 8000 })
431 return
432 }
433 const [family, tier] = (fresh[0] ?? '').split(':') as [FamilyId, Tier]
434 const kind = FAMILIES.find(f => f.id === family)
435 const spec = kind?.tiers.find(t => t.tier === tier)
436 const reward = spec?.reward === undefined ? null : 'hat' in spec.reward ? w.hats[spec.reward.hat] : 'pal' in spec.reward ? w.pals[spec.reward.pal] : w.goldens[spec.reward.golden]
437 const name = `${w.families[family]}・${w.tiers[tier]}`
438 const what = kind === undefined || spec === undefined ? '' : w.familyWhat[family](w.amountLong(kind.unit, spec.target))
439 $.ui.toast(w.unlockedWhat(name, what, reward), { timeoutMs: 10_000 })
440 await flash($, 'cheer', `🏆 ${name}`, 3500, () => settle($))
441}
442
443/** A hat or a pal by its name in either language, or `auto` / `none`. */
444function pickOf<T extends string>(text: string, names: readonly Record<T, string>[]): T | 'auto' | 'none' | undefined {
445 const wanted = text.trim().toLowerCase()
446 if (wanted === 'auto' || wanted === '') return 'auto'
447 if (wanted === 'none' || wanted === 'off') return 'none'
448 for (const table of names) {
449 for (const [id, name] of Object.entries(table) as [T, string][]) if (id.toLowerCase() === wanted || name.toLowerCase() === wanted) return id
450 }
451 return undefined
452}
453
454async function setTrophyPick($: Engine, change: (t: Trophies) => Trophies): Promise<Trophies> {
455 const kept = ((await $.store.get('trophies')) as Trophies | undefined) ?? (await read($, trophiesAtom))
456 const next = change(kept)
457 await $.store.set('trophies', next)
458 await update($, trophiesAtom, () => next)
459 return next
460}
461
462/** Today's figures as the store has them, for the idle line, without writing anything. */
463async function loadToday($: Engine): Promise<void> {
464 const date = dateOf(await $.clock.now(), await read($, offsetAtom))
465 const kept = (await $.store.get(`day:${date}`)) as Day | undefined
466 await update($, todayAtom, () => (kept === undefined ? null : { ...emptyDay(date), ...kept, rooms: { ...emptyDay(date).rooms, ...kept.rooms } }))
467}
468
469/** Today and the six days before it, oldest first, and the streak of days with work. */
470async function readWeek($: Engine): Promise<{ today: Day; week: (Day | undefined)[]; streak: number }> {
471 const date = dateOf(await $.clock.now(), await read($, offsetAtom))
472 const days: (Day | undefined)[] = []
473 let at = date
474 for (let i = 0; i < 60; i++) {
475 const day = (await $.store.get(`day:${at}`)) as Day | undefined
476 days.push(day === undefined ? undefined : { ...emptyDay(at), ...day, rooms: { ...emptyDay(at).rooms, ...day.rooms } })
477 if (i >= 7 && (day === undefined || day.turns + day.tools === 0)) break
478 at = dayBefore(at)
479 }
480 return { today: days[0] ?? emptyDay(date), week: days.slice(0, 7).reverse(), streak: streakOf(days) }
481}
482
483function onPet($: Engine, id: string): void {
484 void (async () => {
485 const pets = await update($, petsAtom, n => n + 1)
486 await $.store.set('pets', pets)
487 await logDay($, day => {
488 day.pets++
489 })
490 if (id !== 'main') {
491 await update($, actorsAtom, actors => actors.map(a => (a.id === id && a.agentKey === undefined ? { ...a, doing: 'love' } : a)))
492 if (id.startsWith('n:')) return
493 $.clock.after(2000, () => void arrangeCrew($))
494 return
495 }
496 const lines = say(lang).petLines(pets)
497 const current = await read($, activity)
498 await flash($, 'love', lines[pets % lines.length] ?? '♥', 2000, async () => current)
499 })()
500}
501
502// ── The status line figures ─────────────────────────────────────────────
503
504async function refreshUsage($: Engine, isForced = false): Promise<void> {
505 const now = await $.clock.now()
506 if (!isForced && now - lastUsageAt < 5000) return
507 lastUsageAt = now
508 try {
509 const usage = await $.session.usage()
510 const model = await $.session.model()
511 const window = (kind: string): number | null => usage.rateLimits.find(r => r.kind === kind)?.percentUsed ?? null
512 const resets = (kind: string): number | null => {
513 const at = Date.parse(usage.rateLimits.find(r => r.kind === kind)?.resetsAt ?? '')
514 return Number.isNaN(at) ? null : at
515 }
516 const next = await update($, usageAtom, (u): Usage => ({
517 ...u,
518 model: modelName(model),
519 contextPercent: usage.context.percent ?? null,
520 fiveHour: window('five_hour'),
521 sevenDay: window('seven_day'),
522 fiveHourResetsAt: resets('five_hour'),
523 sevenDayResetsAt: resets('seven_day'),
524 usd: usage.cost?.usd ?? null,
525 }))
526 await guardUsage($, next)
527 } catch {
528 // The figures wait for the next tick.
529 }
530}
531
532// ── The plan's limits ────────────────────────────────────────────────────
533
534/** Where a window's use earns a word from Clawd: most of it gone, nearly all, all. */
535const LIMIT_STEPS = [100, 95, 80] as const
536
537/**
538 * Past 80%, 95% and 100% of the 5-hour or the 7-day window, Clawd says so
539 * once (in a toast, and on his bubble): how much is gone, when it starts
540 * over and, nearly out with work uncommitted, to commit first. Once a window
541 * across every session: each step's word is kept in the store by window.
542 */
543async function guardUsage($: Engine, usage: Usage): Promise<void> {
544 const windows = [
545 ['five_hour', usage.fiveHour, usage.fiveHourResetsAt],
546 ['seven_day', usage.sevenDay, usage.sevenDayResetsAt],
547 ] as const
548 for (const [window, percent, resetsAt] of windows) {
549 if (percent === null) continue
550 const step = LIMIT_STEPS.find(s => percent >= s)
551 if (step === undefined) continue
552 const key = `${window}:${resetsAt ?? 'unknown'}:${step}`
553 const warned = ((await $.store.get('usageWarned')) as string[] | undefined) ?? []
554 if (warned.includes(key)) continue
555 await $.store.set('usageWarned', [...warned, key].slice(-20))
556 const now = await $.clock.now()
557 const offset = await read($, offsetAtom)
558 const git = await read($, gitAtom)
559 const w = say(lang)
560 const left = resetsAt === null ? '?' : w.span(Math.max(1, Math.round((resetsAt - now) / 60_000)))
561 const at = resetsAt === null ? '?' : formatClock(resetsAt, offset, now)
562 $.ui.toast(w.usageWarn(window, step, left, at, git !== null && isDirty(git)), { timeoutMs: 10_000 })
563 await flash($, step >= 95 ? 'panic' : 'oops', w.usageBubble(window, step), 6000, () => settle($))
564 }
565}
566
567/** A moment by the person's clock: '15:40', with the date when it is not today. */
568function formatClock(at: number, offset: number, now: number): string {
569 const local = new Date(at + offset * 60_000)
570 const clock = local.toISOString().slice(11, 16)
571 const isToday = local.toISOString().slice(0, 10) === new Date(now + offset * 60_000).toISOString().slice(0, 10)
572 return isToday ? clock : `${local.getUTCMonth() + 1}/${local.getUTCDate()} ${clock}`
573}
574
575/** How long until a window starts over, for the band: shown once most of it is gone. */
576function resetNote(percent: number | null, resetsAt: number | null, now: number, talk: Lang): string {
577 if (percent === null || percent < 70 || resetsAt === null || resetsAt <= now) return ''
578 const w = say(talk)
579 return ` · ${w.resetsIn(w.span(Math.round((resetsAt - now) / 60_000)))}`
580}
581
582async function count($: Engine, field: 'tools' | 'edits' | 'runs'): Promise<void> {
583 await update($, usageAtom, (u): Usage => ({ ...u, [field]: u[field] + 1 }))
584}
585
586// ── Persistence: $.store outlives the session, $.state draws ─────────────
587
588async function load($: Engine): Promise<void> {
589 const stored = async <T,>(key: string, fallback: T): Promise<T> => ((await $.store.get(key)) as T | undefined) ?? fallback
590 const deadlines = await stored<Deadline[]>('deadlines', [])
591 const isCollapsed = await stored<boolean>('isCollapsed', false)
592 const pets = await stored<number>('pets', 0)
593 const theme = await stored<string>('theme', 'house')
594 const seasonPick = await stored<Season | 'auto'>('seasonPick', 'auto')
595 const holidayPick = await stored<Holiday | 'auto'>('holidayPick', 'auto')
596 await update($, seasonPickAtom, () => seasonPick)
597 await update($, holidayPickAtom, () => holidayPick)
598 await update($, themeAtom, (): Theme => (THEME_ORDER as readonly string[]).includes(theme) ? (theme as Theme) : 'house')
599 await update($, deadlinesAtom, () => deadlines)
600 await update($, collapsedAtom, () => isCollapsed)
601 await update($, petsAtom, () => pets)
602 const dark = ((await $.store.get('darkRooms')) as RoomId[] | undefined) ?? []
603 await update($, darkRoomsAtom, () => dark.filter(id => (ROOM_IDS as readonly string[]).includes(id)))
604 const names = ((await $.store.get('names')) as Record<string, string> | undefined) ?? {}
605 const birthday = String((await $.store.get('birthday')) ?? '')
606 await update($, namesAtom, () => ({ ...names }))
607 await update($, birthdayAtom, () => birthday)
608 await paintCaps($)
609}
610
611// ── Making the Clawds the person's own ─────────────────────────────────
612
613/** The beanie colours a crew member can wear. */
614const CAP_COLORS = ['blue', 'green', 'purple', 'red', 'yellow', 'teal', 'pink'] as const
615const CREW_IDS = ['c1', 'c2', 'c3'] as const
616
617/** What a finished call's row opens to: its command or file, then the first lines of what came back. */
618function rowDetail(tool: string, input: Record<string, unknown>, output: unknown, words: ReturnType<typeof say>): string[] {
619 const lines: string[] = []
620 const command = str(input.command)
621 if (command !== '') lines.push(`$ ${command.split('\n')[0] ?? ''}`)
622 const path = str(input.file_path) || str(input.notebook_path) || str(input.path) || str(input.url) || str(input.pattern) || str(input.query)
623 if (command === '' && path !== '') lines.push(path)
624 const record = (typeof output === 'object' && output !== null ? output : {}) as Record<string, unknown>
625 const change = fileChangeOf(tool, input, output)
626 if (change !== undefined) lines.push(`+${change.added} −${change.removed}`)
627 const text = typeof output === 'string' ? output : [str(record.stdout), str(record.stderr)].filter(t => t !== '').join('\n')
628 if (text !== '') lines.push(...text.split('\n').slice(0, 12).map(line => clip(line, 160)))
629 else if (change === undefined) lines.push(words.rowNoOutput)
630 return lines
631}
632
633/** Opens or closes a finished tool row. */
634async function toggleRow($: Engine, id: string): Promise<void> {
635 await update($, openRowsAtom, rows => (rows.includes(id) ? rows.filter(r => r !== id) : [...rows, id].slice(-50)))
636}
637
638/** Puts the caps the person chose on the crew. */
639async function paintCaps($: Engine): Promise<void> {
640 const caps = ((await $.store.get('caps')) as Record<string, string> | undefined) ?? {}
641 await update($, actorsAtom, actors => actors.map(a => (caps[a.id] !== undefined && a.neighbor === undefined && a.cap !== null ? { ...a, cap: caps[a.id]! } : a)))
642}
643
644/** Which Clawd a word names: '' or 'main' the main one, 1–3 or a cap colour a crew member. */
645async function whoOf($: Engine, word: string): Promise<string | undefined> {
646 const w = word.toLowerCase()
647 if (w === '' || w === 'main' || w === '主') return 'main'
648 if (/^[123]$/.test(w)) return `c${w}`
649 const actors = await read($, actorsAtom)
650 return actors.find(a => a.cap === w && a.neighbor === undefined && CREW_IDS.includes(a.id as 'c1'))?.id
651}
652
653/** The scene each project last chose, else the one chosen last anywhere. */
654async function sceneFor($: Engine, project: string): Promise<Theme> {
655 const scenes = ((await $.store.get('scenes')) as Record<string, string> | undefined) ?? {}
656 const fallback = String((await $.store.get('theme')) ?? 'house')
657 const theme = scenes[project] ?? fallback
658 return (THEME_ORDER as readonly string[]).includes(theme) ? (theme as Theme) : 'house'
659}
660
661async function changeDeadlines($: Engine, change: (deadlines: Deadline[]) => Deadline[]): Promise<Deadline[]> {
662 const current = ((await $.store.get('deadlines')) as Deadline[] | undefined) ?? (await read($, deadlinesAtom))
663 const next = change(current.map(deadline => ({ ...deadline }))).sort((a, b) => a.due - b.due)
664 await $.store.set('deadlines', next)
665 await update($, deadlinesAtom, () => next)
666 return next
667}
668
669async function setCollapsed($: Engine, isCollapsed: boolean): Promise<void> {
670 await $.store.set('isCollapsed', isCollapsed)
671 await update($, collapsedAtom, () => isCollapsed)
672}
673
674// ── Where on Earth: the time zone, for which way the seasons run ─────────
675
676/** The system's IANA time zone: TZ, the /etc/localtime link, or Debian's /etc/timezone. */
677async function systemZone($: Engine): Promise<string | undefined> {
678 const tz = await $.env.get('TZ')
679 if (tz !== undefined && zoneOf(tz) !== undefined) return zoneOf(tz)
680 try {
681 const link = await $.process.run(['readlink', '/etc/localtime'], { timeoutMs: 3000 })
682 if (link.exitCode === 0 && zoneOf(link.stdout) !== undefined) return zoneOf(link.stdout)
683 } catch {
684 // No readlink here: try the file Debian keeps.
685 }
686 try {
687 return zoneOf(await $.fs.read('/etc/timezone'))
688 } catch {
689 return undefined
690 }
691}
692
693/** Moves the Clawds to another scene; `next` takes the one after the current. */
694// ── What the pointer finds over the Desktop house ─────────────────────────
695
696/**
697 * Drawn by the band itself, so nothing in the picture reloads: over each
698 * room an unseen strip that, under the pointer, lights the room up and shows
699 * a card (what the room is for, what it holds now, its light switch), and
700 * over each Clawd standing still a strip with hearts and his card (what he
701 * is doing, and a pat). The band lays out in columns of eight pixels; the
702 * picture spans them all, so a scene pixel is `width / SW` columns.
703 */
704function houseZones($: Engine, ui: Elements['vscode'], s: SceneProps, o: { width: number; height: number; now: number; git: GitState | null }): RenderElement[] {
705 const { Box, Text, Button, Svg } = ui
706 const w = say(s.lang)
707 const art = THEMES[s.theme]
708 const cells = (x: number): number => Math.max(0, Math.min(o.width, Math.round((x * o.width) / SW)))
709 const pixels = (cell: number): number => (cell * SW) / o.width
710 const card = (lines: RenderElement[], buttons: RenderElement[], left: number, zoneWidth: number): RenderElement => {
711 const cardWidth = Math.min(o.width, Math.max(zoneWidth, 40))
712 // A card stays inside the band: anchored left, unless that would run off the right edge.
713 const side = left + cardWidth > o.width ? { right: 0 } : { left: 0 }
714 return (
715 <Box position="absolute" top={0} {...side} width={cardWidth} display="none" hover={{ display: 'flex' }} flexDirection="column" backgroundColor="#1F1E1D" borderStyle="round" borderColor={ORANGE} paddingX={1}>
716 {lines}
717 {buttons.length === 0 ? null : (
718 <Box flexDirection="row" gap={1}>
719 {buttons}
720 </Box>
721 )}
722 </Box>
723 )
724 }
725 const glow = (source: string, zoneWidth: number): RenderElement => (
726 <Box position="absolute" top={0} left={0} display="none" hover={{ display: 'flex' }}>
727 <Svg source={source} alt="" width={zoneWidth * CELL_PX} height={o.height} />
728 </Box>
729 )
730 const zones: RenderElement[] = []
731 for (const span of roomSpans()) {
732 const left = cells(span.x0)
733 const zoneWidth = cells(span.x1) - left
734 if (zoneWidth <= 0) continue
735 const name = w.rooms[s.theme][span.id]
736 const info: Record<RoomId, string> = {
737 library: w.memoryTip(s.theme, s.memory),
738 codelab: s.notes.length === 0 ? w.calendarEmpty : w.boardTitle(s.notes.slice(0, 4).map(n => n.text)),
739 terminal: o.git === null ? '' : w.gitLine(o.git.branch, o.git.ahead, o.git.behind, o.git.changed + o.git.untracked),
740 web: s.theme === 'space' ? w.outsideSpace : s.theme === 'cosmos' ? w.outsideCosmos : w.outside[s.time],
741 game: s.medals.length === 0 ? w.toys[s.theme] : `${w.toys[s.theme]}\n${w.medalsTip(s.trophyCount[0], s.trophyCount[1])}`,
742 }
743 const hasSwitch = art.glows.some(g => g.room === span.id && isSwitched(g))
744 const lines = [
745 <Text color={ORANGE} bold>
746 {name}
747 </Text>,
748 <Text dimColor wrap="wrap">
749 {w.roomPurpose[span.id]}
750 </Text>,
751 ...(info[span.id] === '' ? [] : [<Text wrap="wrap">{info[span.id]}</Text>]),
752 ]
753 // One switch for the whole place, from any room that has lights.
754 const buttons = hasSwitch ? [<Button key={`light-${span.id}`} label={isAllDark(s.dark) ? w.lightOn : w.lightOff} onPress={() => toggleLights($)} />] : []
755 zones.push(
756 <Box key={`room-${span.id}`} position="absolute" top={0} bottom={0} left={left} width={zoneWidth}>
757 {glow(roomHoverSvg(s, pixels(left), pixels(zoneWidth)), zoneWidth)}
758 {card(lines, buttons, left, zoneWidth)}
759 </Box>,
760 )
761 }
762 // A Clawd on his way somewhere has his strip where he is going.
763 for (const a of s.actors) {
764 const left = cells(a.toX - 4)
765 const zoneWidth = cells(a.toX + 20) - left
766 if (zoneWidth <= 0) continue
767 const lines = [<Text wrap="wrap">{actorTip(a, s)}</Text>]
768 const buttons = a.neighbor === undefined ? [<Button key={`pet-${a.id}`} label={w.pet} onPress={() => onPet($, a.id)} />] : []
769 zones.push(
770 <Box key={`clawd-${a.id}`} position="absolute" top={0} bottom={0} left={left} width={zoneWidth}>
771 {glow(actorHoverSvg(a.toX, pixels(left), pixels(zoneWidth)), zoneWidth)}
772 {card(lines, buttons, left, zoneWidth)}
773 </Box>,
774 )
775 }
776 return zones
777}
778
779/** A pane, where it can be drawn: Clawd's live in the Desktop app, so a terminal alone gets a word instead. */
780async function openPane($: Engine, id: string, title: string): Promise<void> {
781 const surfaces = await $.session.surfaces()
782 if (surfaces.length > 0 && surfaces.every(s => s === 'terminal')) {
783 $.ui.toast(say(lang).desktopOnly)
784 return
785 }
786 await $.ui.open({ id, title })
787}
788
789// ── Lights ───────────────────────────────────────────────────────────────
790
791/** Switches rooms' lights on or off, in every scene, and keeps it. */
792async function setLights($: Engine, rooms: readonly RoomId[], isOn: boolean): Promise<void> {
793 const dark = await update($, darkRoomsAtom, list => (isOn ? list.filter(id => !rooms.includes(id)) : [...new Set([...list, ...rooms])]))
794 await $.store.set('darkRooms', dark)
795}
796
797const isAllDark = (dark: readonly RoomId[]): boolean => ROOM_IDS.every(id => dark.includes(id))
798
799/** The one switch: every room off, or, once they all are, every room on. */
800async function toggleLights($: Engine): Promise<void> {
801 await setLights($, ROOM_IDS, isAllDark(await read($, darkRoomsAtom)))
802}
803
804// ── The session's summary ────────────────────────────────────────────────
805
806/** This session so far: its figures, the files it changed and the commands that failed, and its cost. */
807async function summaryOf($: Engine): Promise<SessionSummary> {
808 const [session, changes, usage, offset] = await Promise.all([read($, sessionAtom), read($, changesAtom), read($, usageAtom), read($, offsetAtom)])
809 const root = await $.session.root()
810 const sum = totals(changes)
811 return {
812 totals: session.totals,
813 startedAt: session.startedAt,
814 offset,
815 files: changes.files.map(f => ({ path: relativePath(f.path, root), added: f.added, removed: f.removed })),
816 added: sum.added,
817 removed: sum.removed,
818 failed: sum.failed,
819 usd: usage.usd,
820 }
821}
822
823async function setTheme($: Engine, choice: Theme | 'next'): Promise<Theme> {
824 const current = await read($, themeAtom)
825 const theme = choice === 'next' ? (THEME_ORDER[(THEME_ORDER.indexOf(current) + 1) % THEME_ORDER.length] ?? 'house') : choice
826 await $.store.set('theme', theme)
827 const project = await read($, projectAtom)
828 if (project !== '') {
829 const scenes = ((await $.store.get('scenes')) as Record<string, string> | undefined) ?? {}
830 await $.store.set('scenes', { ...scenes, [project]: theme })
831 }
832 await update($, themeAtom, () => theme)
833 await changeLife($, life => {
834 if (!life.scenes.includes(theme)) life.scenes.push(theme)
835 })
836 return theme
837}
838
839// ── The clock: deadlines, dozing off, the game rotation ─────────────────
840
841async function checkClock($: Engine): Promise<void> {
842 const now = await $.clock.now()
843 await update($, nowAtom, () => now)
844 await arrangeCrew($)
845 await lookUp($, now)
846 await greetBirthday($, now)
847 await readGit($)
848 await nudgeCommit($, now)
849 if (isWorking && busySince > 0 && now - busySince >= BREAK_AFTER && now - lastStretchAt >= STRETCH_EVERY) {
850 if (lastStretchAt < busySince) $.ui.toast(say(lang).breakToast(Math.floor((now - busySince) / 60_000)), { timeoutMs: 10_000 })
851 lastStretchAt = now
852 await flash($, 'stretch', say(lang).takeBreak, 8000, () => settle($))
853 }
854 const deadlines = await read($, deadlinesAtom)
855 const due = deadlines.find(d => !d.hasAlarmed && urgency(d.due, now) === 'urgent')
856 if (due !== undefined) {
857 await changeDeadlines($, list => list.map(d => (d.id === due.id ? { ...d, hasAlarmed: true } : d)))
858 $.ui.toast(say(lang).alarm(due.title, countdown(due.due, now, lang)), { timeoutMs: 8000 })
859 await flash($, 'panic', say(lang).alarm(clip(due.title, 12), countdown(due.due, now, lang)), 5000, () => settle($))
860 return
861 }
862 if (isWorking) return
863 const current = await read($, activity)
864 if (current.pose === 'wait' && now - current.since > 90_000) await act($, 'idle', '')
865 else if (current.pose === 'idle' && now - current.since > 240_000) await act($, 'sleep', say(lang).sleepy)
866}
867
868/**
869 * A command's answer as a toast rather than a transcript line: a line would
870 * stay in the conversation for the model to read on every later turn.
871 */
872function answer($: Engine, text: string): { text?: string } {
873 $.ui.toast(text, { timeoutMs: 6000 })
874 return {}
875}
876
877// ── The git safety net ───────────────────────────────────────────────────
878
879/** Reads the project's git state, at most every five seconds unless forced; null outside a repository. */
880async function readGit($: Engine, isForced = false): Promise<GitState | null> {
881 const now = await $.clock.now()
882 if (!isForced && now - lastGitAt < 5_000) return read($, gitAtom)
883 lastGitAt = now
884 let git: GitState | null = null
885 try {
886 const cwd = await $.session.root()
887 const status = await $.process.run(['git', 'status', '--porcelain=v1', '-b', '--untracked-files=normal'], { cwd, timeoutMs: 3000 })
888 const parsed = status.exitCode === 0 ? parseStatus(status.stdout) : undefined
889 if (parsed !== undefined) {
890 const last = await $.process.run(['git', 'log', '-1', '--format=%ct'], { cwd, timeoutMs: 3000 })
891 git = { ...parsed, lastCommitAt: last.exitCode === 0 ? Number(last.stdout.trim()) * 1000 || 0 : 0 }
892 }
893 } catch {
894 git = null
895 }
896 if (git === null || !isDirty(git)) dirtySince = 0
897 else if (dirtySince === 0) dirtySince = now
898 if (JSON.stringify(git) !== JSON.stringify(await read($, gitAtom))) await update($, gitAtom, () => git)
899 return git
900}
901
902/** Between turns: an hour's edits not committed brings Clawd's stamp and a word, at most once an hour. */
903async function nudgeCommit($: Engine, now: number): Promise<void> {
904 const git = await read($, gitAtom)
905 const changes = await read($, changesAtom)
906 if (isWorking || git === null || changes.files.length === 0 || !needsCommit(git, dirtySince, now) || now - lastNudgeAt < 60 * 60_000) return
907 lastNudgeAt = now
908 const minutes = Math.floor((now - Math.max(dirtySince, git.lastCommitAt)) / 60_000)
909 $.ui.toast(say(lang).commitNudge(git.changed + git.untracked, minutes, git.ahead), { timeoutMs: 10_000 })
910 await flash($, 'stamp', say(lang).commitLabel, 5000, () => settle($))
911}
912
913// ── Rare sights and birthdays ────────────────────────────────────────────
914
915/** Shows the rare sight of the moment, if there is one, says so once, and counts it seen. */
916async function lookUp($: Engine, now: number): Promise<void> {
917 const [offset, theme, current] = await Promise.all([read($, offsetAtom), read($, themeAtom), read($, eggAtom)])
918 const egg: Egg | null = eggOf(now, timeOfDay(now, offset), theme)
919 if (egg === current) return
920 await update($, eggAtom, () => egg)
921 if (egg === null) return
922 const w = say(lang)
923 $.ui.toast(egg === 'star' ? w.eggStar : w.eggCritter[theme], { timeoutMs: 8000 })
924 const seen = egg === 'star' ? 'star' : `critter:${theme}`
925 await changeLife($, life => {
926 if (!life.eggs.includes(seen)) life.eggs.push(seen)
927 })
928}
929
930const monthDay = (now: number, offset: number): string => dateOf(now, offset).slice(5)
931
932/** On the person's birthday, once a day: party hats on, and a word to say so. */
933async function greetBirthday($: Engine, now: number): Promise<void> {
934 const [birthday, offset, names] = await Promise.all([read($, birthdayAtom), read($, offsetAtom), read($, namesAtom)])
935 if (birthday === '' || monthDay(now, offset) !== birthday) return
936 const today = dateOf(now, offset)
937 if ((await $.store.get('birthdayGreeted')) === today) return
938 await $.store.set('birthdayGreeted', today)
939 $.ui.toast(say(lang).birthdayToast(names.main ?? 'Clawd'), { timeoutMs: 10_000 })
940}
941
942// ── Neighbors: the other sessions on this machine ────────────────────────
943
944type Presence = Neighbor & { at: number }
945
946/** How long a session's word holds before it counts as gone, and before its record is cleared. */
947const HEARD_MS = 45_000
948const GONE_MS = 10 * 60_000
949const VISITOR_CAPS = ['red', 'yellow', 'teal', 'pink'] as const
950const WORK_SPOTS: readonly Spot[] = ['library', 'board', 'code', 'bash', 'web']
951
952/** Tells the other sessions what this one is up to: when it changes, and every 15 seconds. */
953async function sharePresence($: Engine): Promise<void> {
954 if (sessionId === '') return
955 const [doing, project] = await Promise.all([read($, activity), read($, projectAtom)])
956 const now = await $.clock.now()
957 const me: Presence = { id: sessionId, project, pose: doing.pose, label: doing.label, isWorking, at: now }
958 const said = JSON.stringify({ ...me, at: 0 })
959 if (said === sharedAs && now - sharedAt < 15_000) return
960 sharedAs = said
961 sharedAt = now
962 await $.store.set(`presence:${sessionId}`, me)
963}
964
965/** Hears from the other sessions, clears the long-gone, and has the busy ones visit. */
966async function lookAround($: Engine): Promise<void> {
967 if (sessionId === '') return
968 const now = await $.clock.now()
969 const heard: Neighbor[] = []
970 for (const key of await $.store.keys()) {
971 if (!key.startsWith('presence:') || key === `presence:${sessionId}`) continue
972 const p = (await $.store.get(key)) as Presence | undefined
973 if (p === undefined) continue
974 if (now - p.at > GONE_MS) await $.store.delete(key)
975 else if (now - p.at <= HEARD_MS) heard.push({ id: p.id, project: p.project, pose: p.pose, label: p.label, isWorking: p.isWorking })
976 }
977 heard.sort((a, b) => a.id.localeCompare(b.id))
978 if (JSON.stringify(heard) !== JSON.stringify(await read($, neighborsAtom))) await update($, neighborsAtom, () => heard)
979 await placeVisitors($, heard)
980}
981
982/** The free work spot nearest `x`, clear of everyone already standing at one. */
983function freeSpot(x: number, taken: readonly number[]): number {
984 const spots = WORK_SPOTS.map(spot => SPOT_X[spot]).sort((a, b) => Math.abs(a - x) - Math.abs(b - x))
985 return spots.find(spot => taken.every(t => Math.abs(t - spot) >= 14)) ?? x
986}
987
988/** A visitor's cap: one of the visitors' colours the crew is not wearing. */
989function capOf(id: string, worn: readonly (string | null)[]): string {
990 const free = VISITOR_CAPS.filter(cap => !worn.includes(cap))
991 const choices = free.length > 0 ? free : VISITOR_CAPS
992 return choices[[...id].reduce((sum, ch) => sum + ch.charCodeAt(0), 0) % choices.length] ?? 'red'
993}
994
995/**
996 * Up to two busy neighbors walk in from the right to the room of what they
997 * are doing (another if it is taken), their project on their bubble; when
998 * they stop, or their session ends, they walk back out.
999 */
1000async function placeVisitors($: Engine, heard: readonly Neighbor[]): Promise<void> {
1001 const now = await $.clock.now()
1002 const busy = heard.filter(n => n.isWorking).slice(0, 2)
1003 const words = say(lang)
1004 const current = await read($, actorsAtom)
1005 const next = (() => {
1006 const locals = current.filter(a => a.neighbor === undefined)
1007 const taken = locals.filter(a => a.cap === null || a.agentKey !== undefined).map(a => a.toX)
1008 const visitors: SceneActor[] = []
1009 for (const n of busy) {
1010 const id = `n:${n.id}`
1011 const place = PLACE[n.pose]
1012 const x = freeSpot(SPOT_X[place.spot ?? 'code'], taken)
1013 taken.push(x)
1014 const label = clip(`${n.project}・${n.label || words.doings[place.doing]}`, 18)
1015 const was = current.find(a => a.id === id)
1016 visitors.push(was === undefined ? { id, cap: capOf(n.id, locals.map(a => a.cap)), fromX: SW + 2, toX: x, departAt: now, doing: place.doing, label, neighbor: n.project } : moveActor(was, x, place.doing, label, now))
1017 }
1018 for (const was of current.filter(a => a.neighbor !== undefined && !visitors.some(v => v.id === a.id))) {
1019 if (was.toX < SW) visitors.push(moveActor(was, SW + 2, 'idle', '', now))
1020 else if (actorX(was, now) < SW) visitors.push(was)
1021 }
1022 return [...locals, ...visitors]
1023 })()
1024 if (JSON.stringify(next) !== JSON.stringify(current)) await update($, actorsAtom, () => next)
1025}
1026
1027/** Every five seconds: the neighbors, the turn clock while working, the rest every thirty. */
1028async function tickClock($: Engine): Promise<void> {
1029 ticks += 1
1030 await sharePresence($)
1031 await lookAround($)
1032 if (ticks % 6 === 0) await checkClock($)
1033 else if (isWorking) await update($, nowAtom, () => Date.now())
1034 if (isWorking || ticks % 6 === 0) await refreshUsage($)
1035}
1036
1037/** The nearest deadline still ahead says how Clawd feels. */
1038function isNervous(deadlines: readonly Deadline[], now: number): boolean {
1039 return deadlines.some(d => ['near', 'urgent'].includes(urgency(d.due, now)))
1040}
1041
1042// ── Language ────────────────────────────────────────────────────────────
1043
1044/** What the system asks for: the locale variables, then macOS's preferred languages. */
1045async function systemLang($: Engine): Promise<Lang> {
1046 const vars = [await $.env.get('LC_ALL'), await $.env.get('LC_MESSAGES'), await $.env.get('LANG')]
1047 const set = vars.filter((v): v is string => v !== undefined && v !== '' && !/^(c|posix)(\.|$)/i.test(v))
1048 if (set.length > 0) return langOf(set)
1049 try {
1050 const { exitCode, stdout } = await $.process.run(['/usr/bin/defaults', 'read', '-g', 'AppleLanguages'], { timeoutMs: 3000 })
1051 if (exitCode === 0) return langOf(stdout.replace(/[()"\s]/g, ' ').split(/[ ,]+/))
1052 } catch {
1053 // Not a Mac: English.
1054 }
1055 return 'en'
1056}
1057
1058/** The person's choice (/clawd lang), else the setting, else the system. */
1059async function chooseLang($: Engine): Promise<Lang> {
1060 let chosen = (await $.store.get('lang')) as string | undefined
1061 if (chosen === undefined && ((await $.store.get('todos')) !== undefined || (await $.store.get('pets')) !== undefined)) {
1062 // Clawd spoke only Chinese before 0.4: keep him so for those who met him then.
1063 chosen = 'zh'
1064 await $.store.set('lang', chosen)
1065 }
1066 const wanted = chosen ?? settings.language
1067 if (wanted === 'zh' || wanted === 'zh-TW') return 'zh'
1068 if (wanted === 'en') return 'en'
1069 return systemLang($)
1070}
1071
1072async function setLang($: Engine, choice: 'zh' | 'en' | 'auto'): Promise<Lang> {
1073 await $.store.set('lang', choice)
1074 lang = await chooseLang($)
1075 await update($, langAtom, () => lang)
1076 const next = await settle($)
1077 await act($, next.pose, next.label)
1078 return lang
1079}
1080
1081export const register: Register = (on, options) => {
1082 settings = {
1083 language: typeof options.language === 'string' ? options.language : 'auto',
1084 }
1085
1086 // ── Drawing ─────────────────────────────────────────────────────────────
1087
1088 on('ui.message', async ($, e, next) => {
1089 const data = (e.data ?? {}) as { type?: string; id?: string }
1090 if (data.type === 'pet') onPet($, data.id ?? 'main')
1091 return next(e)
1092 })
1093
1094 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1095 // Clawd lives in the Desktop app; the terminal keeps Claude Code's own.
1096 if (e.props.hasSurvey || e.surface === 'terminal') return next(e)
1097 const [doing, deadlines, isCollapsed, actors, usage, game, offset, talk, theme, zone, seasonPick, holidayPick, trophies, neighbors, egg, birthday, names, git, dark] = await Promise.all([
1098 read($, activity),
1099 read($, deadlinesAtom),
1100 read($, collapsedAtom),
1101 read($, actorsAtom),
1102 read($, usageAtom),
1103 read($, gameAtom),
1104 read($, offsetAtom),
1105 read($, langAtom),
1106 read($, themeAtom),
1107 read($, zoneAtom),
1108 read($, seasonPickAtom),
1109 read($, holidayPickAtom),
1110 read($, trophiesAtom),
1111 read($, neighborsAtom),
1112 read($, eggAtom),
1113 read($, birthdayAtom),
1114 read($, namesAtom),
1115 read($, gitAtom),
1116 read($, darkRoomsAtom),
1117 read($, nowAtom),
1118 ])
1119 const w = say(talk)
1120 const now = await $.clock.now()
1121 const ahead = deadlines.filter(d => d.due > now - 86_400_000)
1122 const nearest = ahead[0]
1123 const { Box, Text, Button } = $.ui.resolve(e)
1124 const width = e.props.bodyColumns
1125
1126 const level = (percent: number | null): string | undefined =>
1127 percent === null ? undefined : percent >= 80 ? '#E5484D' : percent >= 50 ? '#F5C542' : '#4CB363'
1128 const bar = (percent: number): string => {
1129 const filled = Math.round(percent / 20)
1130 return '▰'.repeat(filled) + '▱'.repeat(5 - filled)
1131 }
1132 const stat = (label: string, value: string, color?: string): RenderElement => (
1133 <Text>
1134 <Text dimColor>{`${label} `}</Text>
1135 <Text color={color}>{value}</Text>
1136 </Text>
1137 )
1138 const elapsed = isWorking ? now - usage.turnStartedAt : lastTurnMs
1139 const clockText = `${Math.floor(elapsed / 60_000)}:${String(Math.floor((elapsed % 60_000) / 1000)).padStart(2, '0')}`
1140 // Before the first reply there is nothing to time.
1141 const hasReply = isWorking || lastTurnMs > 0
1142 const limit = (label: string, percent: number | null, resetsAt: number | null): RenderElement | null =>
1143 percent === null ? null : stat(label, `${Math.round(percent)}%${resetNote(percent, resetsAt, now, talk)}`, level(percent))
1144 const gitText = git === null ? null : (
1145 <Text color={gitColor(git, dirtySince, now)} dimColor={gitColor(git, dirtySince, now) === undefined}>
1146 {w.gitLine(git.branch, git.ahead, git.behind, git.changed + git.untracked)}
1147 </Text>
1148 )
1149 const deadlineText =
1150 nearest === undefined ? null : (
1151 <Text color={URGENCY_COLOR[urgency(nearest.due, now)]} wrap="truncate-end">
1152 {w.due(clip(nearest.title, 10), countdown(nearest.due, now, talk))}
1153 </Text>
1154 )
1155
1156 const main = actors.find(a => a.cap === null)
1157 const langButton = <Button key="lang" label={w.otherLanguage} onPress={() => setLang($, talk === 'zh' ? 'en' : 'zh')} />
1158 if (isCollapsed) {
1159 const now_ = doing.label && doing.label !== w.sleepy ? doing.label : w.doings[main?.doing ?? 'idle']
1160 const label = <Text color={ORANGE} bold wrap="truncate-end">{`${names.main ?? 'Clawd'} · ${now_}`}</Text>
1161 const figures = [
1162 usage.contextPercent === null ? null : stat('ctx', `${Math.round(usage.contextPercent)}%`, level(usage.contextPercent)),
1163 limit('5h', usage.fiveHour, usage.fiveHourResetsAt),
1164 hasReply ? stat(isWorking ? w.turn : w.lastTurn, clockText) : null,
1165 gitText,
1166 ]
1167 const expand = <Button key="expand" label={w.expand} variant="primary" onPress={() => setCollapsed($, false)} />
1168 // Folded, the band keeps what matters at a glance: what he's doing, the context and the plan, this reply, git, the next deadline.
1169 const { Svg } = $.ui.resolve(e)
1170 return (
1171 <Box flexDirection="row" columnGap={2} alignItems="center" flexWrap="wrap">
1172 <Svg source={miniClawdSvg(main?.doing ?? 'idle')} alt="Clawd" width={36} height={24} />
1173 {label}
1174 {figures}
1175 {deadlineText}
1176 <Box flexGrow={1} />
1177 {expand}
1178 {langButton}
1179 </Box>
1180 )
1181 }
1182
1183 const holiday = holidayPick !== 'auto' ? holidayPick : holidayOf(now, offset)
1184 const scene: SceneProps = {
1185 actors,
1186 // The board pins every deadline ahead, nearest first; the hour is fine enough.
1187 notes: ahead.slice(0, 8).map(d => ({ text: `${d.title} · ${countdown(d.due, now, talk, true)}`, urgency: urgency(d.due, now) })),
1188 days: nearest === undefined ? null : Math.max(0, Math.floor((nearest.due - now) / 86_400_000)),
1189 urgency: nearest === undefined ? 'none' : urgency(nearest.due, now),
1190 game,
1191 time: timeOfDay(now, offset),
1192 // To the hour only: a minute's change would redraw the desktop house and start its loops over.
1193 deadline: nearest === undefined ? '' : `${nearest.title} · ${formatDue(nearest.due, offset)} · ${countdown(nearest.due, now, talk, true)}`,
1194 lang: talk,
1195 theme,
1196 memory: usage.contextPercent === null ? null : Math.min(10, Math.floor(usage.contextPercent / 10)),
1197 isTired: (usage.fiveHour ?? 0) >= 80,
1198 season: seasonPick !== 'auto' ? seasonPick : seasonOf(now, offset, zone),
1199 holiday,
1200 medals: medalsOf(trophies.unlocked),hooks/deadline.ts 109 lines1// Deadline radar: parsing "/deadline add 12/24 Report" and saying how close it is.
2//
3// Times are kept as UTC milliseconds; `offset` is the person's zone in
4// minutes east of UTC, read from the host once (the module may run in UTC).
5
6import { say, type Lang } from './i18n'
7
8const MINUTE = 60_000
9const HOUR = 60 * MINUTE
10const DAY = 24 * HOUR
11
12export type ParsedDeadline = { due: number; title: string } | { error: string }
13
14/** "+0800" → 480. */
15export function parseOffset(zone: string): number | undefined {
16 const m = zone.trim().match(/^([+-])(\d{2}):?(\d{2})$/)
17 if (m === null) return undefined
18 const minutes = Number(m[2]) * 60 + Number(m[3])
19 return m[1] === '-' ? -minutes : minutes
20}
21
22/** The local calendar day of `ms`: year, month (1-12), day. */
23function localDay(ms: number, offset: number): { y: number; mo: number; d: number } {
24 const local = new Date(ms + offset * MINUTE)
25 return { y: local.getUTCFullYear(), mo: local.getUTCMonth() + 1, d: local.getUTCDate() }
26}
27
28const toUtc = (y: number, mo: number, d: number, hh: number, mm: number, offset: number): number =>
29 Date.UTC(y, mo - 1, d, hh, mm) - offset * MINUTE
30
31const RELATIVE: Record<string, number> = { 今天: 0, 明天: 1, 後天: 2, today: 0, tomorrow: 1 }
32
33export function parseDeadline(input: string, now: number, offset: number, lang: Lang): ParsedDeadline {
34 const text = input.trim()
35 const words = say(lang)
36 const relative = text.match(/^(今天|明天|後天|today|tomorrow|\+(\d{1,3})d)(?:\s+(\d{1,2}):(\d{2}))?\s+(.+)$/i)
37 if (relative !== null) {
38 const days = RELATIVE[relative[1]!.toLowerCase()] ?? Number(relative[2])
39 const today = localDay(now, offset)
40 const hh = relative[3] === undefined ? 23 : Number(relative[3])
41 const mm = relative[4] === undefined ? 59 : Number(relative[4])
42 return { due: toUtc(today.y, today.mo, today.d + days, hh, mm, offset), title: relative[5]!.trim() }
43 }
44 const m = text.match(/^(?:(\d{4})[-/.])?(\d{1,2})[-/.](\d{1,2})(?:\s+(\d{1,2}):(\d{2}))?\s+(.+)$/)
45 if (m === null) return { error: words.usage }
46 const mo = Number(m[2])
47 const d = Number(m[3])
48 const hh = m[4] === undefined ? 23 : Number(m[4])
49 const mm = m[5] === undefined ? 59 : Number(m[5])
50 if (mo < 1 || mo > 12 || d < 1 || d > 31 || hh > 23 || mm > 59) return { error: `${words.badDate} ${words.usage}` }
51 let y = m[1] === undefined ? localDay(now, offset).y : Number(m[1])
52 let due = toUtc(y, mo, d, hh, mm, offset)
53 if (m[1] === undefined && due < now - DAY) {
54 y += 1
55 due = toUtc(y, mo, d, hh, mm, offset)
56 }
57 return { due, title: m[6]!.trim() }
58}
59
60export type Urgency = 'far' | 'near' | 'urgent' | 'over'
61
62export function urgency(due: number, now: number): Urgency {
63 const left = due - now
64 if (left < 0) return 'over'
65 if (left < DAY) return 'urgent'
66 if (left < 3 * DAY) return 'near'
67 return 'far'
68}
69
70/**
71 * "剩 5 天" / "5 days left", "剩 6 小時 20 分" / "6 h 20 min left", "過了 2 天" / "2 days ago".
72 * `isRough` stops at the hour ("剩 6 小時"), for text that should not change every minute.
73 */
74export function countdown(due: number, now: number, lang: Lang, isRough = false): string {
75 const words = say(lang)
76 const left = due - now
77 const abs = Math.abs(left)
78 const days = Math.floor(abs / DAY)
79 const hours = Math.floor((abs % DAY) / HOUR)
80 const minutes = Math.floor((abs % HOUR) / MINUTE)
81 if (left < 0) return days > 0 ? words.ago.days(days) : words.ago.hours(hours)
82 if (days >= 2) return words.left.days(days)
83 if (days === 1) return words.left.dayHours(hours)
84 if (isRough) return hours >= 1 ? words.left.hours(hours) : words.left.underHour
85 if (hours >= 1) return words.left.hoursMinutes(hours, minutes)
86 return words.left.minutes(minutes)
87}
88
89/** "12/24 23:59" in the person's zone. */
90export function formatDue(due: number, offset: number): string {
91 const local = new Date(due + offset * MINUTE)
92 const pad = (n: number): string => String(n).padStart(2, '0')
93 return `${local.getUTCMonth() + 1}/${local.getUTCDate()} ${pad(local.getUTCHours())}:${pad(local.getUTCMinutes())}`
94}
95
96export const URGENCY_COLOR: Record<Urgency, string> = {
97 far: '#8A8F98',
98 near: '#F5C542',
99 urgent: '#E5484D',
100 over: '#5C6068',
101}
102
103/** A short id for a new deadline: when it was made, and a little of its title. */
104export function newId(now: number, salt: string): string {
105 let h = 0
106 for (let i = 0; i < salt.length; i++) h = (Math.imul(h, 31) + salt.charCodeAt(i)) | 0
107 return `${now.toString(36)}${(Math.abs(h) % 1296).toString(36)}`
108}
109hooks/i18n.ts 947 lines1// Every word Clawd says, in Traditional Chinese and English.
2//
3// `say(lang)` answers one table; each entry is a string or a small function
4// of what goes in it, so word order stays each language's own.
5
6import type { Hat, Pal, Tier } from '../types'
7import type { FamilyId, Unit } from './trophies'
8
9export type Lang = 'zh' | 'en'
10
11export type RoomId = 'library' | 'codelab' | 'terminal' | 'web' | 'game'
12
13export type Theme = 'house' | 'beach' | 'space' | 'forest' | 'cosmos'
14
15
16type SeasonWord = 'spring' | 'summer' | 'autumn' | 'winter'
17type HolidayWord = 'none' | 'lunarNewYear' | 'halloween' | 'christmas'
18
19type Doings =
20 | 'idle'
21 | 'read'
22 | 'think'
23 | 'code'
24 | 'type'
25 | 'web'
26 | 'arcade'
27 | 'pong'
28 | 'volley'
29 | 'turn'
30 | 'jump'
31 | 'tower'
32 | 'clap'
33 | 'wait'
34 | 'sleep'
35 | 'pin'
36 | 'panic'
37 | 'love'
38 | 'oops'
39 | 'cheer'
40 | 'ask'
41 | 'stamp'
42 | 'mail'
43 | 'tidy'
44 | 'stretch'
45 | 'eat'
46 | 'sip'
47
48export type Strings = {
49 // What Clawd is doing, from a tool call
50 run: (command: string) => string
51 edit: (file: string) => string
52 read: (file: string) => string
53 find: (pattern: string) => string
54 search: (query: string) => string
55 fetch: (host: string) => string
56 dispatch: (task: string) => string
57 helper: string
58 asks: string
59 plan: string
60 use: (tool: string) => string
61 thinking: string
62 yourTurn: string
63 interrupted: string
64 turnError: string
65 approve: string
66 failed: (what: string) => string
67 // What happened, acted out
68 testsPassed: string
69 testsFailed: string
70 committed: (sha: string) => string
71 pushed: (branch: string) => string
72 prOpened: (n: number) => string
73 prMerged: (n: number) => string
74 compacting: string
75 compacted: string
76 /** The library zone's hover: Claude's memory, `level` tenths full or not known yet. */
77 memoryTip: (theme: Theme, level: number | null) => string
78 sleepy: string
79 petLines: (count: number) => string[]
80 alarm: (title: string, left: string) => string
81 // The band
82 hello: string
83 due: (title: string, left: string) => string
84 turn: string
85 lastTurn: string
86 /** The prompt's hint line while nothing runs: Clawd standing by. */
87 idle: string
88 todayWorked: (duration: string) => string
89 tools: string
90 edited: string
91 files: (count: number) => string
92 ran: string
93 commands: (count: number) => string
94 list: string
95 pet: string
96 hide: string
97 expand: string
98 otherLanguage: string
99 houseAlt: (doing: string) => string
100 readingInLibrary: string
101 // The pane
102 paneTitle: string
103 complete: string
104 deadlines: string
105 remove: string
106 petted: (count: number) => string
107 // Commands
108 describeClawd: string
109 describeDeadline: string
110 hintClawd: string
111 hintDeadline: string
112 folded: string
113 unfolded: string
114 // The day's recap card
115 recapTitle: string
116 recapPaneTitle: string
117 recapButton: string
118 recapDate: (date: string) => string
119 recapQuiet: string
120 recapWorked: (duration: string) => string
121 /** The words over the day's working time, set large beneath them. */
122 recapWorkedLead: string
123 recapFavorite: (room: string) => string
124 recapDuration: (hours: number, minutes: number) => string
125 recapTurns: (n: number) => string
126 recapTools: (n: number) => string
127 recapEdits: (n: number) => string
128 recapRuns: (n: number) => string
129 recapTests: string
130 recapCommits: (n: number) => string
131 recapPushes: (n: number) => string
132 recapTidies: (n: number) => string
133 recapWhere: string
134 recapWeek: string
135 recapStreak: (days: number) => string
136 recapLine: (d: { turns: number; work: string; tools: number; edits: number; passed: number; failed: number; commits: number; pushes: number; streak: number }) => string
137 // The git safety net and this session's changes
138 uncommitted: (n: number) => string
139 commitLabel: string
140 commitNudge: (files: number, minutes: number, ahead: number) => string
141 changesTitle: string
142 changesButton: string
143 changesSummary: (files: number, added: number, removed: number, commands: number, failed: number) => string
144 changesNone: string
145 changesFiles: string
146 changesCommands: string
147 changesThisTurn: string
148 gitLine: (branch: string, ahead: number, behind: number, dirty: number) => string
149 notARepo: string
150 rowInterrupted: string
151 rowNoOutput: string
152 // Daily life, rare sights, birthdays, names
153 takeBreak: string
154 breakToast: (minutes: number) => string
155 eggStar: string
156 eggCritter: Record<Theme, string>
157 birthdayToast: (name: string) => string
158 recapTitleOf: (name: string) => string
159 crewNumber: (n: number) => string
160 nameSet: (who: string, name: string) => string
161 nameReset: string
162 nameUsage: string
163 capSet: (who: string, cap: string) => string
164 capUsage: string
165 birthdaySet: (date: string) => string
166 birthdayOff: string
167 birthdayUsage: string
168 // Neighbors: the other sessions on this machine
169 neighborClawd: (project: string) => string
170 neighborsLabel: string
171 // Trophies
172 tiers: Record<Tier, string>
173 families: Record<FamilyId, string>
174 /** What a trophy asks, in a sentence, with the tier's target in it ("7 天"). */
175 familyWhat: Record<FamilyId, (target: string) => string>
176 /** A target as the sentences read it: "7 days", "8 hours", "1,000". */
177 amountLong: (unit: Unit, value: number) => string
178 nextTier: (tier: string, what: string, reward: string | null) => string
179 unlockedWhat: (trophy: string, what: string, reward: string | null) => string
180 hats: Record<Hat, string>
181 pals: Record<Pal, string>
182 goldens: Record<'stamp' | 'seal', string>
183 /** A progress figure in its unit: a count, days, hours or minutes. */
184 amount: (unit: Unit, value: number) => string
185 unlockedMany: (count: number) => string
186 trophiesTitle: string
187 trophiesButton: string
188 trophiesCount: (got: number, total: number) => string
189 trophyNext: (progress: string, target: string) => string
190 trophyMax: string
191 medalsTip: (got: number, total: number) => string
192 walking: (pal: string) => string
193 hatsHeading: string
194 palsHeading: string
195 noneYet: string
196 hatUsage: (have: string) => string
197 hatSet: (hat: string) => string
198 hatOff: string
199 palUsage: (have: string) => string
200 palSet: (pal: string) => string
201 palOff: string
202 locked: (thing: string) => string
203 speaks: string
204 langUsage: string
205 removed: (what: string) => string
206 deadlineAdded: (title: string, date: string, left: string) => string
207 noSuchDeadline: string
208 noDeadlines: string
209 // Deadlines
210 usage: string
211 badDate: string
212 left: {
213 days: (d: number) => string
214 dayHours: (h: number) => string
215 hoursMinutes: (h: number, m: number) => string
216 minutes: (m: number) => string
217 hours: (h: number) => string
218 underHour: string
219 }
220 ago: { days: (d: number) => string; hours: (h: number) => string }
221 // The scenes
222 themes: Record<Theme, string>
223 scene: (name: string) => string
224 sceneSet: (name: string) => string
225 sceneUsage: string
226 rooms: Record<Theme, Record<RoomId, string>>
227 roomPurpose: Record<RoomId, string>
228 roomTip: (name: string, purpose: string) => string
229 doings: Record<Doings, string>
230 caps: Record<string, string>
231 mainClawd: string
232 crewClawd: (cap: string) => string
233 forSubagent: (task: string) => string
234 gettingReady: string
235 boardTitle: (deadlines: readonly string[]) => string
236 noDeadline: string
237 calendarEmpty: string
238 calendar: (deadline: string) => string
239 toys: Record<Theme, string>
240 outside: Record<'day' | 'dusk' | 'night', string>
241 outsideSpace: string
242 outsideCosmos: string
243 // Seasons, holidays
244 seasons: Record<SeasonWord, string>
245 holidays: Record<HolidayWord, string>
246 seasonSet: (season: string) => string
247 seasonAuto: (season: string) => string
248 seasonUsage: string
249 holidaySet: (holiday: string) => string
250 holidayAuto: (holiday: string) => string
251 holidayUsage: string
252 // Lights, the hover cards, the plan's limits, the session's summary
253 desktopOnly: string
254 lightOff: string
255 lightOn: string
256 lightsSet: (rooms: string, isOn: boolean) => string
257 lightsUsage: string
258 allRooms: string
259 span: (minutes: number) => string
260 resetsIn: (left: string) => string
261 usageWarn: (window: 'five_hour' | 'seven_day', step: number, left: string, at: string, isDirty: boolean) => string
262 usageBubble: (window: 'five_hour' | 'seven_day', step: number) => string
263 sessionTitle: string
264 sessionSince: (clock: string) => string
265 sessionButton: string
266 sessionReplies: (n: number) => string
267 sessionFiles: (n: number) => string
268 sessionCommands: (n: number) => string
269 sessionFailed: (n: number) => string
270 sessionCost: string
271 sessionTop: string
272 sessionQuiet: string
273 sessionLine: (d: { work: string; turns: number; tools: number; files: number; added: number; removed: number; runs: number; failed: number; commits: number; pushes: number; usd: string | null }) => string
274 lastSession: (project: string, line: string) => string
275}
276
277const zh: Strings = {
278 run: command => `跑 ${command}`,
279 edit: file => `改 ${file}`,
280 read: file => `讀 ${file}`,
281 find: pattern => `找 ${pattern}`,
282 search: query => `查 ${query}`,
283 fetch: host => `看 ${host}`,
284 dispatch: task => `派分身:${task}`,
285 helper: '分身',
286 asks: '有事想問你',
287 plan: '計畫好了,等你點頭',
288 use: tool => `用 ${tool}`,
289 thinking: '想一下…',
290 yourTurn: '輪到你了',
291 interrupted: '被打斷了',
292 turnError: '這回合出錯了',
293 approve: '需要你批准',
294 failed: what => `${what} 出錯了`,
295 testsPassed: '測試通過!',
296 testsFailed: '測試沒過…',
297 committed: sha => `commit 好了 ${sha}`,
298 pushed: branch => `推上 ${branch} 了`,
299 prOpened: n => `開了 PR #${n}`,
300 prMerged: n => `PR #${n} 合併了!`,
301 compacting: '整理記憶中…',
302 compacted: '記憶整理好了',
303 memoryTip: (theme, level) => {
304 const what = { house: '書架', beach: '書堆', space: '資料水晶', forest: '書堆', cosmos: '漂浮的書' }[theme]
305 return level === null
306 ? `${what}=Claude 的記憶(context),還不知道用了多少`
307 : `${what}=Claude 的記憶(context),大約 ${level * 10}% 滿;快滿時 Clawd 會整理一次`
308 },
309 sleepy: 'zzz…',
310 petLines: count => ['嘿嘿~', '好癢!', '再摸一下', '♥', `被摸了 ${count} 次`],
311 alarm: (title, left) => `${title} ${left}!`,
312 hello: '我是 Clawd,你的副駕',
313 due: (title, left) => `截止 ${title} ${left}`,
314 turn: '這次回覆',
315 lastTurn: '上次回覆',
316 idle: '待機中',
317 todayWorked: duration => `今天 ${duration}`,
318 tools: '工具',
319 edited: '改',
320 files: count => `${count} 檔`,
321 ran: '跑',
322 commands: count => `${count} 指令`,
323 list: '面板',
324 pet: '♥ 摸摸',
325 hide: '收合',
326 expand: '展開',
327 otherLanguage: 'EN',
328 houseAlt: doing => `Clawd 小屋:${doing}`,
329 readingInLibrary: 'Clawd 在書庫看書',
330 paneTitle: 'Clawd 副駕',
331 complete: '完成',
332 deadlines: '截止日',
333 remove: '移除',
334 petted: count => `被摸了 ${count} 次`,
335 describeClawd: 'Clawd 副駕:打開面板(/clawd recap 今日戰報、/clawd trophies 成就、/clawd scene 換場景、/clawd hide 收合、/clawd lang en 換英文)',
336 describeDeadline: '截止日雷達:越接近 Clawd 越慌',
337 hintClawd: '[changes|recap|trophies|hat|pal|name|cap|birthday|scene 名稱|season|holiday|hide|show|lang]',
338 hintDeadline: '[add 12/24 名稱|rm N]',
339 folded: 'Clawd 收成一行了,/clawd show 叫他回來。',
340 unfolded: 'Clawd 回來了。',
341 recapTitle: 'Clawd 的一天',
342 recapPaneTitle: 'Clawd 的一天',
343 recapButton: '今日戰報',
344 recapDate: date => {
345 const d = new Date(`${date}T00:00:00Z`)
346 return `${d.getUTCMonth() + 1} 月 ${d.getUTCDate()} 日(${'日一二三四五六'[d.getUTCDay()]})`
347 },
348 recapQuiet: '今天還沒開工',
349 recapWorked: duration => `陪 Claude 工作了 ${duration}`,
350 recapWorkedLead: '陪 Claude 工作了',
351 recapFavorite: room => `最常待在${room}`,
352 recapDuration: (h, m) => (h > 0 ? `${h} 小時 ${m} 分` : `${m} 分鐘`),
353 recapTurns: () => '個回合',
354 recapTools: () => '次工具',
355 recapEdits: () => '個檔改過',
356 recapRuns: () => '個指令',
357 recapTests: '測試過',
358 recapCommits: () => '次 commit',
359 recapPushes: () => '次 push',
360 recapTidies: () => '次整理記憶',
361 recapWhere: '時間都去哪了',
362 recapWeek: '這週',
363 recapStreak: days => (days > 0 ? `連續 ${days} 天` : '今天開工吧'),
364 recapLine: d =>
365 `今天 ${d.turns} 個回合、工作了 ${d.work}:用了 ${d.tools} 次工具、改了 ${d.edits} 個檔,測試 ${d.passed} 過 ${d.failed} 沒過,${d.commits} 次 commit、${d.pushes} 次 push。連續 ${d.streak} 天。`,
366 uncommitted: n => `${n} 個未提交`,
367 commitLabel: '該 commit 了',
368 commitNudge: (files, minutes, ahead) => `有 ${files} 個檔案改了 ${minutes} 分鐘還沒 commit${ahead > 0 ? `,另外 ${ahead} 個 commit 還沒 push` : ''}。存個檔吧`,
369 changesTitle: '這個 session 改了什麼',
370 changesButton: '改了什麼',
371 changesSummary: (files, added, removed, commands, failed) => `改了 ${files} 個檔(+${added} −${removed})· 跑了 ${commands} 個指令${failed > 0 ? `(${failed} 個失敗)` : ''}`,
372 changesNone: '這個 session 還沒改任何東西',
373 changesFiles: '檔案',
374 changesCommands: '指令',
375 changesThisTurn: '● 這一回合',
376 gitLine: (branch, ahead, behind, dirty) => `⎇ ${branch}${ahead > 0 ? ` ↑${ahead}` : ''}${behind > 0 ? ` ↓${behind}` : ''}${dirty > 0 ? ` · ${dirty} 個未提交` : ' · 都已提交'}`,
377 notARepo: '這個資料夾不是 git repo',
378 rowInterrupted: '已中斷',
379 rowNoOutput: '(沒有輸出)',
380 takeBreak: '休息一下吧',
381 breakToast: minutes => `已經連續工作 ${minutes} 分鐘了,起來伸展一下吧`,
382 eggStar: '✨ 有流星!快看天空',
383 eggCritter: { house: '🐦 窗台上來了一隻小麻雀', beach: '🐋 遠方有鯨魚在噴水', space: '🛸 觀測窗外飛過一架 UFO', forest: '🦌 樹叢後面有隻鹿在偷看', cosmos: '🧑🚀 有個太空人從遠方飄過去了' },
384 birthdayToast: name => `🎂 生日快樂!${name}和夥伴們都戴上了派對帽`,
385 recapTitleOf: name => `${name}的一天`,
386 crewNumber: n => `夥伴 ${n}`,
387 nameSet: (who, name) => `${who}現在叫「${name}」了。`,
388 nameReset: '名字都改回預設了。',
389 nameUsage: '用法:/clawd name 名字(主 Clawd)、/clawd name 1 名字(夥伴 1–3),或 /clawd name reset',
390 capSet: (who, cap) => `${who}換上了${cap}。`,
391 capUsage: '用法:/clawd cap 1 red(夥伴 1–3;顏色有 blue、green、purple、red、yellow、teal、pink)',
392 birthdaySet: date => `記住了,你的生日是 ${date}。那天大家會戴派對帽慶祝。`,
393 birthdayOff: '生日設定拿掉了。',
394 birthdayUsage: '用法:/clawd birthday 10/31,或 /clawd birthday off',
395 neighborClawd: project => `鄰居 Clawd(${project})`,
396 neighborsLabel: '鄰居',
397 tiers: { bronze: '銅', silver: '銀', gold: '金', legend: '傳說' },
398 families: {
399 streak: '連續開工',
400 days: '開工天數',
401 commits: 'Commit',
402 pushes: 'Push',
403 tests: '測試通過',
404 green: '測試全綠',
405 tools: '工具呼叫',
406 edits: '改檔',
407 tidy: '整理記憶',
408 helpers: '派出子代理',
409 night: '夜貓子',
410 dawn: '早起的鳥',
411 marathon: '工作馬拉松',
412 longTurn: '長回合',
413 busyDay: '最忙的一天',
414 pets: '摸摸 Clawd',
415 prs: 'PR 合併',
416 scenes: '環遊四景',
417 holidays: '節日也上工',
418 comeback: '逆轉勝',
419 eggs: '稀有景象',
420 },
421 familyWhat: {
422 streak: t => `連續 ${t}都有開工`,
423 days: t => `累計 ${t}有開工`,
424 commits: t => `累計 commit ${t} 次`,
425 pushes: t => `累計 push ${t} 次`,
426 tests: t => `測試累計通過 ${t} 次`,
427 green: t => `測試連續通過 ${t} 次,中間沒有失敗`,
428 tools: t => `Claude 累計用了 ${t} 次工具`,
429 edits: t => `累計改了 ${t} 次檔案`,
430 tidy: t => `對話被壓縮(整理記憶)${t} 次`,
431 helpers: t => `累計派出 ${t} 個子代理`,
432 night: t => `在半夜 0 點到 5 點開始了 ${t} 個回合`,
433 dawn: t => `在早上 5 點到 7 點開始了 ${t} 個回合`,
434 marathon: t => `一天之內工作滿 ${t}`,
435 longTurn: t => `單一回合跑了 ${t}`,
436 busyDay: t => `一天之內用了 ${t} 次工具`,
437 pets: t => `摸了 Clawd ${t} 次`,
438 prs: t => `合併了 ${t} 個 PR`,
439 scenes: t => `${t} 個場景都住過`,
440 holidays: t => `在 ${t} 個節日工作過(農曆新年、萬聖節、聖誕節)`,
441 comeback: t => `測試連續失敗 3 次以上之後又通過,${t} 次`,
442 eggs: t => `看過 ${t} 種稀有景象(流星,或各場景的訪客)`,
443 },
444 amountLong: (unit, value) => {
445 const n = unit === 'hours' ? Math.floor(value * 10) / 10 : Math.floor(value)
446 return unit === 'days' ? `${n} 天` : unit === 'hours' ? `${n} 小時` : unit === 'minutes' ? `${n} 分鐘` : n.toLocaleString('en')
447 },
448 nextTier: (tier, what, reward) => `下一級(${tier}):${what}${reward === null ? '' : `,可得${reward}`}`,
449 unlockedWhat: (trophy, what, reward) => `🏆 ${trophy}:${what}${reward === null ? '' : `,獲得${reward}`}`,
450
451 hats: { party: '派對帽', crown: '王冠', halo: '光環', wizard: '巫師帽', captain: '船長帽', flower: '小花', explorer: '探險帽', graduation: '學士帽', headphones: '耳機' },
452 pals: { cat: '貓咪', owl: '貓頭鷹', crab: '小螃蟹' },
453 goldens: { stamp: '金色印章', seal: '金色封蠟' },
454 amount: (unit, value) => {
455 const n = unit === 'hours' ? Math.floor(value * 10) / 10 : Math.floor(value)
456 return unit === 'days' ? `${n} 天` : unit === 'hours' ? `${n} 小時` : unit === 'minutes' ? `${n} 分` : n.toLocaleString('en')
457 },
458 unlockedMany: count => `🏆 解鎖了 ${count} 個成就!打 /clawd trophies 看看`,
459 trophiesTitle: '成就',
460 trophiesButton: '成就',
461 trophiesCount: (got, total) => `已解鎖 ${got} / ${total}`,
462 trophyNext: (progress, target) => `${progress} / ${target}`,
463 trophyMax: '全部完成!',
464 medalsTip: (got, total) => `獎牌:成就 ${got} / ${total}(/clawd trophies)`,
465 walking: pal => `${pal}陪著`,
466 hatsHeading: '帽子',
467 palsHeading: '夥伴',
468 noneYet: '還沒有,解鎖成就就會拿到',
469 hatUsage: have => `用法:/clawd hat 名稱、auto(最好的那頂)或 none。你有:${have}`,
470 hatSet: hat => `主 Clawd 戴上了${hat}。`,
471 hatOff: '主 Clawd 把帽子拿下來了。',
472 palUsage: have => `用法:/clawd pal 名稱、auto 或 none。你有:${have}`,
473 palSet: pal => `${pal}開始在場景裡散步了。`,
474 palOff: '夥伴回家休息了。',
475 locked: thing => `還沒解鎖${thing}。打 /clawd trophies 看要達成什麼。`,
476 speaks: 'Clawd 改說中文了。',
477 langUsage: '用法:/clawd lang zh、/clawd lang en、/clawd lang auto(跟系統語言)',
478 removed: what => `刪掉了:${what}`,
479 deadlineAdded: (title, date, left) => `記下截止日:${title},${date}(${left})`,
480 noSuchDeadline: '沒有這一個。/deadline 看清單。',
481 noDeadlines: '還沒有截止日。',
482 usage: '用法:/deadline add 12/24 報告、/deadline add 2027-03-01 09:00 發表會、/deadline add 明天 繳費',
483 badDate: '看不懂這個日期。',
484 left: {
485 days: d => `剩 ${d} 天`,
486 dayHours: h => `剩 1 天 ${h} 小時`,
487 hoursMinutes: (h, m) => `剩 ${h} 小時 ${m} 分`,
488 minutes: m => `剩 ${m} 分`,
489 hours: h => `剩 ${h} 小時`,
490 underHour: '剩不到 1 小時',
491 },
492 ago: { days: d => `過了 ${d} 天`, hours: h => `過了 ${h} 小時` },
493 themes: { house: '小屋', beach: '海灘', space: '太空站', forest: '森林營地', cosmos: '宇宙' },
494 scene: name => `場景:${name}`,
495 sceneSet: name => `Clawd 們搬到${name}了。`,
496 sceneUsage: '用法:/clawd scene house(小屋)、beach(海灘)、space(太空站)、forest(森林營地)、cosmos(宇宙),或 /clawd scene next 換下一個',
497 rooms: {
498 house: { library: '書庫', codelab: '工作室', terminal: '機房', web: '瞭望台', game: '遊戲間' },
499 beach: { library: '遮陽傘', codelab: '沙灘書桌', terminal: '救生塔', web: '燈塔觀景台', game: '沙灘球場' },
500 space: { library: '資料艙', codelab: '實驗艙', terminal: '反應爐', web: '觀測窗', game: '娛樂艙' },
501 forest: { library: '帳篷', codelab: '木桌', terminal: '無線電小屋', web: '樹屋', game: '營火空地' },
502 cosmos: { library: '漂流書庫', codelab: '軌道書桌', terminal: '衛星', web: '望遠鏡', game: '零重力球場' },
503 },
504 roomPurpose: {
505 library: 'Claude 讀檔、搜尋的時候會來這裡',
506 codelab: '改程式、想事情的地方;你的待辦和截止日也在這',
507 terminal: '跑指令的地方',
508 web: '上網查資料的地方',
509 game: 'Claude 工作時大家打排球,閒著時輪流玩桌球、跳繩、疊積木,深夜就睡覺',
510 },
511 roomTip: (name, purpose) => `${name}:${purpose}`,
512 doings: {
513 idle: '在發呆',
514 read: '在書庫看書',
515 think: '在想事情',
516 code: '在改程式',
517 type: '在跑指令',
518 web: '在看網路',
519 arcade: '在打電動',
520 pong: '在打桌球',
521 volley: '在打排球',
522 turn: '在甩跳繩',
523 jump: '在跳繩',
524 tower: '在疊積木',
525 clap: '在旁邊加油',
526 wait: '在等你',
527 sleep: '睡著了',
528 pin: '在貼便利貼',
529 panic: '慌了!',
530 love: '被摸得很開心',
531 oops: '出錯了',
532 cheer: '好開心',
533 ask: '舉牌等你批准',
534 stamp: '在 commit 上蓋章',
535 mail: '把 push 寄出去',
536 tidy: '在整理記憶',
537 stretch: '在伸懶腰',
538 eat: '在吃午餐',
539 sip: '在喝下午茶',
540 },
541 caps: { blue: '藍帽', green: '綠帽', purple: '紫帽', red: '紅帽', yellow: '黃帽', teal: '青帽', pink: '粉紅帽' },
542 mainClawd: '主 Clawd',
543 crewClawd: cap => `${cap} Clawd`,
544 forSubagent: task => `替子代理工作:${task}`,
545 gettingReady: '準備中',
546 boardTitle: deadlines => (deadlines.length === 0 ? '布告欄:還沒有截止日(/deadline add 12/24 名稱)' : `布告欄:截止日\n${deadlines.map(d => `· ${d}`).join('\n')}`),
547 noDeadline: '沒有截止日',
548 calendarEmpty: '日曆:還沒有截止日(/deadline add 12/24 名稱)',
549 calendar: deadline => `日曆:${deadline}`,
550 toys: {
551 house: '大型電玩:有人在玩的時候別擋到螢幕',
552 beach: '沙堡:裡面住著一隻小螃蟹',
553 space: '全像電玩:有人在玩的時候別擋到螢幕',
554 forest: '營火:小心燙',
555 cosmos: '漂浮電玩:玩的時候要抓好,不然會飄走',
556 },
557 outside: { day: '窗外:白天', dusk: '窗外:黃昏', night: '窗外:晚上' },
558 outsideSpace: '窗外:無邊的宇宙',
559 outsideCosmos: '四面八方都是星星,遠方有一顆帶環的行星',
560 seasons: { spring: '春天', summer: '夏天', autumn: '秋天', winter: '冬天' },
561 holidays: { none: '平常日', lunarNewYear: '農曆新年', halloween: '萬聖節', christmas: '聖誕節' },
562 seasonSet: season => `季節固定成${season}。/clawd season auto 改回跟著日期。`,
563 seasonAuto: season => `季節跟著日期走,現在是${season}。`,
564 seasonUsage: '用法:/clawd season spring、summer、autumn、winter,或 auto 跟著日期',
565 holidaySet: holiday => `節日佈置固定成${holiday}。/clawd holiday auto 改回跟著日期。`,
566 holidayAuto: holiday => `節日跟著日期走,現在是${holiday}。`,
567 holidayUsage: '用法:/clawd holiday lunar(農曆新年)、halloween、christmas、none(不佈置),或 auto 跟著日期',
568 desktopOnly: 'Clawd 副駕住在 Claude 桌面版裡,在這裡打開就看得到。',
569 lightOff: '💡 關燈',
570 lightOn: '💡 開燈',
571 lightsSet: (rooms, isOn) => `${rooms}${isOn ? '開燈了' : '關燈了'}`,
572 lightsUsage: '用法:/clawd lights on 或 off,後面可以接房間(library、codelab、terminal、web、game),不接就是全部',
573 allRooms: '全部房間',
574 span: minutes => {
575 const days = Math.floor(minutes / 1440)
576 const hours = Math.floor((minutes % 1440) / 60)
577 const mins = minutes % 60
578 if (days > 0) return `${days} 天${hours > 0 ? ` ${hours} 小時` : ''}`
579 if (hours > 0) return `${hours} 小時${mins > 0 ? ` ${mins} 分` : ''}`
580 return `${Math.max(1, mins)} 分`
581 },
582 resetsIn: left => `${left}後重置`,
583 usageWarn: (window, step, left, at, isDirty) => {
584 const name = window === 'five_hour' ? '5 小時額度' : '7 天額度'
585 if (step >= 100) return `${name}用完了,${at}(${left}後)重置。`
586 const tip = step >= 95 ? (isDirty ? '快用完了,先 commit 手上的改動吧。' : '快用完了,留給最要緊的事。') : ''
587 return `${name}已用 ${step}%,${at}(${left}後)重置。${tip}`
588 },
589 usageBubble: (window, step) => `${window === 'five_hour' ? '5h' : '7d'} 額度 ${step}%`,
590 sessionTitle: '這次 session',
591 sessionSince: clock => `${clock} 開始`,
592 sessionButton: '結算',
593 sessionReplies: () => '次回覆',
594 sessionFiles: () => '個檔',
595 sessionCommands: () => '個指令',
596 sessionFailed: n => `${n} 個失敗`,
597 sessionCost: '花費',
598 sessionTop: '改最多的檔',
599 sessionQuiet: '這次 session 還沒做什麼事',
600 sessionLine: d =>
601 `這次 session:${d.work}、${d.turns} 次回覆、${d.tools} 次工具、改 ${d.files} 個檔(+${d.added} −${d.removed})、跑 ${d.runs} 個指令${d.failed > 0 ? `(${d.failed} 個失敗)` : ''}、${d.commits} 次 commit、${d.pushes} 次 push${d.usd === null ? '' : `、花費 $${d.usd}`}。`,
602 lastSession: (project, line) => `上次在 ${project}:${line}`,
603}
604
605const en: Strings = {
606 run: command => `Running ${command}`,
607 edit: file => `Editing ${file}`,
608 read: file => `Reading ${file}`,
609 find: pattern => `Looking for ${pattern}`,
610 search: query => `Searching ${query}`,
611 fetch: host => `Visiting ${host}`,
612 dispatch: task => `Sending a helper: ${task}`,
613 helper: 'helper',
614 asks: 'Has a question for you',
615 plan: 'Plan ready, waiting for your nod',
616 use: tool => `Using ${tool}`,
617 thinking: 'Thinking…',
618 yourTurn: 'Your turn',
619 interrupted: 'Interrupted',
620 turnError: 'That turn hit an error',
621 approve: 'Needs your OK',
622 failed: what => `${what} failed`,
623 testsPassed: 'Tests pass!',
624 testsFailed: 'Tests failed…',
625 committed: sha => `Committed ${sha}`,
626 pushed: branch => `Pushed to ${branch}`,
627 prOpened: n => `Opened PR #${n}`,
628 prMerged: n => `PR #${n} merged!`,
629 compacting: 'Tidying the memory…',
630 compacted: 'Memory tidied',
631 memoryTip: (theme, level) => {
632 const what = { house: 'The shelves', beach: 'The book pile', space: 'The data crystals', forest: 'The book pile', cosmos: 'The floating books' }[theme]
633 return level === null
634 ? `${what} = Claude's memory (context), not measured yet`
635 : `${what} = Claude's memory (context), about ${level * 10}% full; when it fills up, Clawd tidies it`
636 },
637 sleepy: 'zzz…',
638 petLines: count => ['Hehe~', 'That tickles!', 'Again!', '♥', `Petted ${count} times`],
639 alarm: (title, left) => `${title}: ${left}!`,
640 hello: "I'm Clawd, your sidekick",
641 due: (title, left) => `Due: ${title} ${left}`,
642 turn: 'this reply',
643 lastTurn: 'last reply',
644 idle: 'Standing by',
645 todayWorked: duration => `${duration} today`,
646 tools: 'tools',
647 edited: 'edited',
648 files: count => (count === 1 ? '1 file' : `${count} files`),
649 ran: 'ran',
650 commands: count => (count === 1 ? '1 command' : `${count} commands`),
651 list: 'Panel',
652 pet: '♥ Pet',
653 hide: 'Hide',
654 expand: 'Expand',
655 otherLanguage: '中文',
656 houseAlt: doing => `Clawd's house: ${doing}`,
657 readingInLibrary: 'Clawd is reading in the library',
658 paneTitle: 'Clawd Sidekick',
659 complete: 'Done',
660 deadlines: 'Deadlines',
661 remove: 'Remove',
662 petted: count => (count === 1 ? 'Petted once' : `Petted ${count} times`),
663 describeClawd: 'Clawd Sidekick: open the pane (/clawd recap shows your day, /clawd trophies your trophies, /clawd scene changes the scene, /clawd hide folds the band, /clawd lang zh switches to Chinese)',
664 describeDeadline: 'Deadline radar: the closer it gets, the more Clawd frets',
665 hintClawd: '[changes|recap|trophies|hat|pal|name|cap|birthday|scene name|season|holiday|hide|show|lang]',
666 hintDeadline: '[add 12/24 name|rm N]',
667 folded: 'Clawd folded into one line. /clawd show brings him back.',
668 unfolded: 'Clawd is back.',
669 recapTitle: "Clawd's day",
670 recapPaneTitle: "Clawd's day",
671 recapButton: 'Today',
672 recapDate: date => {
673 const d = new Date(`${date}T00:00:00Z`)
674 const day = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getUTCDay()]
675 const month = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getUTCMonth()]
676 return `${day}, ${month} ${d.getUTCDate()}`
677 },
678 recapQuiet: 'No work yet today',
679 recapWorked: duration => `Worked alongside Claude for ${duration}`,
680 recapWorkedLead: 'Worked with Claude for',
681 recapFavorite: room => `Mostly in the ${room.toLowerCase()}`,
682 recapDuration: (h, m) => (h > 0 ? `${h} h ${m} min` : `${m} min`),
683 recapTurns: n => (n === 1 ? 'turn' : 'turns'),
684 recapTools: n => (n === 1 ? 'tool call' : 'tool calls'),
685 recapEdits: n => (n === 1 ? 'file edited' : 'files edited'),
686 recapRuns: n => (n === 1 ? 'command' : 'commands'),
687 recapTests: 'tests passed',
688 recapCommits: n => (n === 1 ? 'commit' : 'commits'),
689 recapPushes: n => (n === 1 ? 'push' : 'pushes'),
690 recapTidies: n => (n === 1 ? 'tidy-up' : 'tidy-ups'),
691 recapWhere: 'Where the day went',
692 recapWeek: 'This week',
693 recapStreak: days => (days === 1 ? '1-day streak' : days > 0 ? `${days}-day streak` : 'Start a streak'),
694 recapLine: d =>
695 `Today: ${d.turns} turns over ${d.work}, ${d.tools} tool calls, ${d.edits} files edited, tests ${d.passed} passed and ${d.failed} failed, ${d.commits} commits, ${d.pushes} pushes. ${d.streak}-day streak.`,
696 uncommitted: n => `${n} uncommitted`,
697 commitLabel: 'Time to commit',
698 commitNudge: (files, minutes, ahead) => `${files} files changed ${minutes} minutes ago and still not committed${ahead > 0 ? `, and ${ahead} commits not pushed` : ''}. Save your work?`,
699 changesTitle: 'What this session changed',
700 changesButton: 'Changes',
701 changesSummary: (files, added, removed, commands, failed) => `${files} files changed (+${added} −${removed}) · ${commands} commands run${failed > 0 ? ` (${failed} failed)` : ''}`,
702 changesNone: 'Nothing changed in this session yet',
703 changesFiles: 'Files',
704 changesCommands: 'Commands',
705 changesThisTurn: '● this turn',
706 gitLine: (branch, ahead, behind, dirty) => `⎇ ${branch}${ahead > 0 ? ` ↑${ahead}` : ''}${behind > 0 ? ` ↓${behind}` : ''}${dirty > 0 ? ` · ${dirty} uncommitted` : ' · all committed'}`,
707 notARepo: 'Not a git repository',
708 rowInterrupted: 'interrupted',
709 rowNoOutput: '(no output)',
710 takeBreak: 'Time for a break',
711 breakToast: minutes => `${minutes} minutes at it without a break: time to get up and stretch`,
712 eggStar: '✨ A shooting star! Look at the sky',
713 eggCritter: { house: '🐦 A sparrow is hopping along the window sill', beach: '🐋 A whale is spouting far out at sea', space: '🛸 A UFO is gliding past the observatory window', forest: '🦌 A deer is peeking out from the bushes', cosmos: '🧑🚀 An astronaut is drifting by in the distance' },
714 birthdayToast: name => `🎂 Happy birthday! ${name} and the crew have their party hats on`,
715 recapTitleOf: name => `${name}'s day`,
716 crewNumber: n => `Crew ${n}`,
717 nameSet: (who, name) => `${who} is now called "${name}".`,
718 nameReset: 'Every name is back to the default.',
719 nameUsage: 'Usage: /clawd name <name> (the main Clawd), /clawd name 1 <name> (crew 1–3), or /clawd name reset',
720 capSet: (who, cap) => `${who} has a ${cap.toLowerCase()} on.`,
721 capUsage: 'Usage: /clawd cap 1 red (crew 1–3; colours: blue, green, purple, red, yellow, teal, pink)',
722 birthdaySet: date => `Got it: your birthday is ${date}. Everyone will wear party hats that day.`,
723 birthdayOff: 'Birthday forgotten.',
724 birthdayUsage: 'Usage: /clawd birthday 10/31, or /clawd birthday off',
725 neighborClawd: project => `Neighbor Clawd (${project})`,
726 neighborsLabel: 'neighbors',
727 tiers: { bronze: 'Bronze', silver: 'Silver', gold: 'Gold', legend: 'Legendary' },
728 families: {
729 streak: 'Streak',
730 days: 'Days at work',
731 commits: 'Commits',
732 pushes: 'Pushes',
733 tests: 'Tests passed',
734 green: 'All green',
735 tools: 'Tool calls',
736 edits: 'Files edited',
737 tidy: 'Tidy-ups',
738 helpers: 'Subagents sent',
739 night: 'Night owl',
740 dawn: 'Early bird',
741 marathon: 'Marathon day',
742 longTurn: 'Long haul',
743 busyDay: 'Busiest day',
744 pets: 'Pets',
745 prs: 'PRs merged',
746 scenes: 'Globetrotter',
747 holidays: 'Holiday shift',
748 comeback: 'Comeback',
749 eggs: 'Rare sights',
750 },
751 familyWhat: {
752 streak: t => `Work on ${t} in a row`,
753 days: t => `Work on ${t} in all`,
754 commits: t => `Make ${t} commits`,
755 pushes: t => `Push ${t} times`,
756 tests: t => `Pass ${t} test runs`,
757 green: t => `Pass ${t} test runs in a row, with no failure between`,
758 tools: t => `Have Claude make ${t} tool calls`,
759 edits: t => `Edit files ${t} times`,
760 tidy: t => `Have the conversation compacted ${t} times`,
761 helpers: t => `Send out ${t} subagents`,
762 night: t => `Start ${t} turns between midnight and 5 a.m.`,
763 dawn: t => `Start ${t} turns between 5 and 7 a.m.`,
764 marathon: t => `Work ${t} in a single day`,
765 longTurn: t => `Have one turn run for ${t}`,
766 busyDay: t => `Make ${t} tool calls in a single day`,
767 pets: t => `Pet Clawd ${t} times`,
768 prs: t => `Merge ${t} pull requests`,
769 scenes: t => `Live in all ${t} scenes`,
770 holidays: t => `Work through ${t} holidays (Lunar New Year, Halloween, Christmas)`,
771 comeback: t => `Pass a test run after three or more failures in a row, ${t} times`,
772 eggs: t => `See ${t} kinds of rare sight (a shooting star, or a scene's visitor)`,
773 },
774 amountLong: (unit, value) => {
775 const n = unit === 'hours' ? Math.floor(value * 10) / 10 : Math.floor(value)
776 if (unit === 'days') return n === 1 ? '1 day' : `${n} days`
777 if (unit === 'hours') return n === 1 ? '1 hour' : `${n} hours`
778 if (unit === 'minutes') return n === 1 ? '1 minute' : `${n} minutes`
779 return n.toLocaleString('en')
780 },
781 nextTier: (tier, what, reward) => `Next (${tier}): ${what}${reward === null ? '' : `, for a ${reward}`}`,
782 unlockedWhat: (trophy, what, reward) => `🏆 ${trophy}: ${what}${reward === null ? '' : `, and a ${reward}`}`,
783
784 hats: { party: 'party hat', crown: 'crown', halo: 'halo', wizard: 'wizard hat', captain: "captain's cap", flower: 'flower', explorer: "explorer's hat", graduation: 'mortarboard', headphones: 'headphones' },
785 pals: { cat: 'cat', owl: 'owl', crab: 'little crab' },
786 goldens: { stamp: 'golden stamp', seal: 'golden seal' },
787 amount: (unit, value) => {
788 const n = unit === 'hours' ? Math.floor(value * 10) / 10 : Math.floor(value)
789 return unit === 'days' ? `${n} d` : unit === 'hours' ? `${n} h` : unit === 'minutes' ? `${n} min` : n.toLocaleString('en')
790 },
791 unlockedMany: count => `🏆 ${count} trophies unlocked! See them with /clawd trophies`,
792 trophiesTitle: 'Trophies',
793 trophiesButton: 'Trophies',
794 trophiesCount: (got, total) => `${got} of ${total} unlocked`,
795 trophyNext: (progress, target) => `${progress} / ${target}`,
796 trophyMax: 'All done!',
797 medalsTip: (got, total) => `Medals: ${got} of ${total} trophies (/clawd trophies)`,
798 walking: pal => `with the ${pal}`,
799 hatsHeading: 'Hats',
800 palsHeading: 'Pals',
801 noneYet: 'None yet: trophies bring them',
802 hatUsage: have => `Usage: /clawd hat name, auto (the finest) or none. You have: ${have}`,
803 hatSet: hat => `Clawd puts on the ${hat}.`,
804 hatOff: 'Clawd takes his hat off.',
805 palUsage: have => `Usage: /clawd pal name, auto or none. You have: ${have}`,
806 palSet: pal => `The ${pal} is out for a walk.`,
807 palOff: 'The pal heads home for a rest.',
808 locked: thing => `The ${thing} isn't unlocked yet. /clawd trophies shows what it takes.`,
809 speaks: 'Clawd speaks English now.',
810 langUsage: 'Usage: /clawd lang en, /clawd lang zh, /clawd lang auto (follow the system language)',
811 removed: what => `Removed: ${what}`,
812 deadlineAdded: (title, date, left) => `Deadline noted: ${title}, ${date} (${left})`,
813 noSuchDeadline: 'No such deadline. /deadline shows the list.',
814 noDeadlines: 'No deadlines yet.',
815 usage: 'Usage: /deadline add 12/24 Report, /deadline add 2027-03-01 09:00 Launch, /deadline add tomorrow Pay the rent',
816 badDate: "Can't read that date.",
817 left: {
818 days: d => `${d} days left`,
819 dayHours: h => `1 day ${h} h left`,
820 hoursMinutes: (h, m) => `${h} h ${m} min left`,
821 minutes: m => `${m} min left`,
822 hours: h => `${h} h left`,
823 underHour: 'under an hour left',
824 },
825 ago: { days: d => (d === 1 ? '1 day ago' : `${d} days ago`), hours: h => `${h} h ago` },
826 themes: { house: 'House', beach: 'Beach', space: 'Space station', forest: 'Forest camp', cosmos: 'Cosmos' },
827 scene: name => `Scene: ${name}`,
828 sceneSet: name => `The Clawds moved to the ${name.toLowerCase()}.`,
829 sceneUsage: 'Usage: /clawd scene house, beach, space, forest or cosmos, or /clawd scene next for the next one',
830 rooms: {
831 house: { library: 'Library', codelab: 'Workshop', terminal: 'Server room', web: 'Lookout', game: 'Game room' },
832 beach: { library: 'Umbrella', codelab: 'Beach desk', terminal: 'Lifeguard tower', web: 'Lighthouse view', game: 'Beach court' },
833 space: { library: 'Archive pod', codelab: 'Lab', terminal: 'Reactor', web: 'Observatory', game: 'Rec deck' },
834 forest: { library: 'Tent', codelab: 'Log desk', terminal: 'Radio hut', web: 'Treehouse', game: 'Campfire' },
835 cosmos: { library: 'Drifting library', codelab: 'Orbital desk', terminal: 'Satellite', web: 'Telescope', game: 'Zero-g court' },
836 },
837 roomPurpose: {
838 library: 'where Clawd goes when Claude reads and searches files',
839 codelab: 'editing and thinking; your to-dos and deadlines live here too',
840 terminal: 'where commands run',
841 web: 'browsing the web',
842 game: 'volleyball while Claude works; ping-pong, jump rope or blocks while it waits; asleep late at night',
843 },
844 roomTip: (name, purpose) => `${name}: ${purpose}`,
845 doings: {
846 idle: 'daydreaming',
847 read: 'reading in the library',
848 think: 'thinking',
849 code: 'editing code',
850 type: 'running a command',
851 web: 'browsing the web',
852 arcade: 'playing the arcade',
853 pong: 'playing ping-pong',
854 volley: 'playing volleyball',
855 turn: 'turning the rope',
856 jump: 'jumping rope',
857 tower: 'stacking blocks',
858 clap: 'cheering them on',
859 wait: 'waiting for you',
860 sleep: 'asleep',
861 pin: 'pinning a note',
862 panic: 'panicking!',
863 love: 'loving the pets',
864 oops: 'hit an error',
865 cheer: 'happy',
866 ask: 'holding up a sign for your OK',
867 stamp: 'stamping a commit',
868 mail: 'sending a push off',
869 tidy: 'tidying the memory',
870 stretch: 'stretching',
871 eat: 'having lunch',
872 sip: 'having tea',
873 },
874 caps: { blue: 'Blue-cap', green: 'Green-cap', purple: 'Purple-cap', red: 'Red-cap', yellow: 'Yellow-cap', teal: 'Teal-cap', pink: 'Pink-cap' },
875 mainClawd: 'Clawd',
876 crewClawd: cap => `${cap} Clawd`,
877 forSubagent: task => `working for a subagent: ${task}`,
878 gettingReady: 'getting ready',
879 boardTitle: deadlines => (deadlines.length === 0 ? 'Board: no deadlines yet (/deadline add 12/24 name)' : `Board: deadlines\n${deadlines.map(d => `· ${d}`).join('\n')}`),
880 noDeadline: 'no deadlines',
881 calendarEmpty: 'Calendar: no deadlines yet (/deadline add 12/24 name)',
882 calendar: deadline => `Calendar: ${deadline}`,
883 toys: {
884 house: "Arcade: don't block the screen while someone's playing",
885 beach: 'Sandcastle: a little crab lives inside',
886 space: "Holo-arcade: don't block the screen while someone's playing",
887 forest: "Campfire: careful, it's hot",
888 cosmos: 'Floating arcade: hold on while you play, or off it drifts',
889 },
890 outside: { day: 'Outside: daytime', dusk: 'Outside: dusk', night: 'Outside: night' },
891 outsideSpace: 'Outside: the endless dark',
892 outsideCosmos: 'Stars in every direction, and a ringed planet far off',
893 seasons: { spring: 'spring', summer: 'summer', autumn: 'autumn', winter: 'winter' },
894 holidays: { none: 'an ordinary day', lunarNewYear: 'Lunar New Year', halloween: 'Halloween', christmas: 'Christmas' },
895 seasonSet: season => `The season is set to ${season}. /clawd season auto follows the date again.`,
896 seasonAuto: season => `The season follows the date: it's ${season}.`,
897 seasonUsage: 'Usage: /clawd season spring, summer, autumn, winter, or auto to follow the date',
898 holidaySet: holiday => `Decorations are set to ${holiday}. /clawd holiday auto follows the date again.`,
899 holidayAuto: holiday => `Holidays follow the date: today is ${holiday}.`,
900 holidayUsage: 'Usage: /clawd holiday lunar (Lunar New Year), halloween, christmas, none, or auto to follow the date',
901 desktopOnly: 'Clawd Sidekick lives in the Claude desktop app: open it there to see this.',
902 lightOff: '💡 Lights off',
903 lightOn: '💡 Lights on',
904 lightsSet: (rooms, isOn) => `Lights ${isOn ? 'on' : 'off'}: ${rooms}`,
905 lightsUsage: 'Usage: /clawd lights on or off, then a room (library, codelab, terminal, web, game), or none for all of them',
906 allRooms: 'every room',
907 span: minutes => {
908 const days = Math.floor(minutes / 1440)
909 const hours = Math.floor((minutes % 1440) / 60)
910 const mins = minutes % 60
911 if (days > 0) return `${days}d${hours > 0 ? ` ${hours}h` : ''}`
912 if (hours > 0) return `${hours}h${mins > 0 ? ` ${mins}m` : ''}`
913 return `${Math.max(1, mins)}m`
914 },
915 resetsIn: left => `resets in ${left}`,
916 usageWarn: (window, step, left, at, isDirty) => {
917 const name = window === 'five_hour' ? 'Your 5-hour limit' : 'Your 7-day limit'
918 if (step >= 100) return `${name} is used up. It resets at ${at} (in ${left}).`
919 const tip = step >= 95 ? (isDirty ? ' Nearly out: commit what you have first.' : ' Nearly out: keep it for what matters most.') : ''
920 return `${name} is ${step}% used. It resets at ${at} (in ${left}).${tip}`
921 },
922 usageBubble: (window, step) => `${window === 'five_hour' ? '5h' : '7d'} limit ${step}%`,
923 sessionTitle: 'This session',
924 sessionSince: clock => `since ${clock}`,
925 sessionButton: 'Summary',
926 sessionReplies: n => (n === 1 ? 'reply' : 'replies'),
927 sessionFiles: n => (n === 1 ? 'file' : 'files'),
928 sessionCommands: n => (n === 1 ? 'command' : 'commands'),
929 sessionFailed: n => `${n} failed`,
930 sessionCost: 'cost',
931 sessionTop: 'Most changed',
932 sessionQuiet: 'Nothing done in this session yet',
933 sessionLine: d =>
934 `This session: ${d.work}, ${d.turns} replies, ${d.tools} tool calls, ${d.files} files changed (+${d.added} −${d.removed}), ${d.runs} commands${d.failed > 0 ? ` (${d.failed} failed)` : ''}, ${d.commits} commits, ${d.pushes} pushes${d.usd === null ? '' : `, $${d.usd}`}.`,
935 lastSession: (project, line) => `Last time in ${project}: ${line}`,
936}
937
938const STRINGS: Record<Lang, Strings> = { zh, en }
939
940export const say = (lang: Lang): Strings => STRINGS[lang]
941
942/** The language a list of locales ("zh-Hant-TW", "en_US.UTF-8") asks for first; English when none is Chinese. */
943export function langOf(locales: readonly string[]): Lang {
944 const first = locales.map(l => l.trim().toLowerCase()).find(l => l !== '' && l !== 'c' && l !== 'posix')
945 return first?.startsWith('zh') ? 'zh' : 'en'
946}
947hooks/scene.ts 1070 lines1// Clawd's house: a cross-section of five rooms, after the pixel library
2// OpenClaw's lobsters live in. The main Clawd walks to the room of whatever
3// Claude is doing; three crew Clawds play in the game room (volleyball while
4// Claude works; ping-pong, jump rope or a block tower while it waits; asleep
5// under blankets at night) until a subagent calls one of them to work. The
6// window shows the person's time of day.
7//
8// Pure drawing: the terminal client composes frames from it at its own frame
9// rate, and scene-svg.ts turns the same frames into an animated SVG.
10
11import type { Doing, Egg, Game, Hat, Pal, SceneActor, SceneProps, Theme, TimeOfDay } from '../types'
12import { say, type Lang, type RoomId } from './i18n'
13import { blank, C, FEET, FLOOR, hash, px, rect, type Box, type Grid } from './pixels'
14import { decorate, drawFalling } from './decor'
15import { drawScene, THEMES } from './themes'
16
17export type { Doing, Game, SceneActor, SceneProps, Theme, TimeOfDay }
18export { blank, SCENE_PALETTE, SH, STEP, SW, type Box, type Grid } from './pixels'
19
20/** How fast a Clawd walks, in pixels a second. */
21export const SPEED = 30
22
23// ── The zones every scene keeps ───────────────────────────────────────────
24
25export const ROOMS = [
26 { id: 'library', x: 2, w: 42 },
27 { id: 'codelab', x: 46, w: 46 },
28 { id: 'terminal', x: 94, w: 40 },
29 { id: 'web', x: 136, w: 36 },
30 { id: 'game', x: 174, w: 80 },
31] as const
32
33/** Where a Clawd stands for each errand: the left edge of his 16-pixel body. */
34export const SPOT_X = {
35 library: 15,
36 board: 48,
37 code: 68,
38 bash: 99,
39 web: 141,
40 arcade: 187,
41 pongL: 204,
42 pongR: 238,
43} as const
44
45export type Spot = keyof typeof SPOT_X
46
47// ── The game room ──────────────────────────────────────────────────────────
48
49/** One player's place in a game: where he stands, what he does, his part. */
50type Slot = { x: number; doing: Doing; role: string }
51
52const ARCADE: Slot = { x: SPOT_X.arcade, doing: 'arcade', role: 'arcade' }
53const PONG_L: Slot = { x: SPOT_X.pongL, doing: 'pong', role: 'L' }
54const PONG_R: Slot = { x: SPOT_X.pongR, doing: 'pong', role: 'R' }
55
56/** Each game's places for one, two and three free players. */
57const LAYOUTS: Record<Game, readonly (readonly Slot[] | undefined)[]> = {
58 pong: [[ARCADE], [PONG_L, PONG_R], [ARCADE, PONG_L, PONG_R]],
59 volley: [
60 [ARCADE],
61 [
62 { x: 196, doing: 'volley', role: 'L1' },
63 { x: 234, doing: 'volley', role: 'R' },
64 ],
65 [
66 { x: 188, doing: 'volley', role: 'L1' },
67 { x: 203, doing: 'volley', role: 'L2' },
68 { x: 234, doing: 'volley', role: 'R' },
69 ],
70 ],
71 rope: [
72 [ARCADE],
73 undefined,
74 [
75 { x: 186, doing: 'turn', role: 'L' },
76 { x: 212, doing: 'jump', role: 'J' },
77 { x: 238, doing: 'turn', role: 'R' },
78 ],
79 ],
80 tower: [
81 [ARCADE],
82 [
83 { x: 202, doing: 'tower', role: 'L' },
84 { x: 226, doing: 'tower', role: 'R' },
85 ],
86 [
87 { x: 186, doing: 'clap', role: 'C' },
88 { x: 202, doing: 'tower', role: 'L' },
89 { x: 226, doing: 'tower', role: 'R' },
90 ],
91 ],
92 lunch: [
93 [{ x: 207, doing: 'eat', role: 'E' }],
94 [
95 { x: 194, doing: 'eat', role: 'E' },
96 { x: 222, doing: 'eat', role: 'E' },
97 ],
98 [
99 { x: 186, doing: 'eat', role: 'E' },
100 { x: 207, doing: 'eat', role: 'E' },
101 { x: 228, doing: 'eat', role: 'E' },
102 ],
103 ],
104 tea: [
105 [{ x: 207, doing: 'sip', role: 'T' }],
106 [
107 { x: 194, doing: 'sip', role: 'T' },
108 { x: 222, doing: 'sip', role: 'T' },
109 ],
110 [
111 { x: 186, doing: 'sip', role: 'T' },
112 { x: 207, doing: 'sip', role: 'T' },
113 { x: 228, doing: 'sip', role: 'T' },
114 ],
115 ],
116 sleep: [
117 [{ x: 207, doing: 'sleep', role: 'S' }],
118 [
119 { x: 194, doing: 'sleep', role: 'S' },
120 { x: 222, doing: 'sleep', role: 'S' },
121 ],
122 [
123 { x: 186, doing: 'sleep', role: 'S' },
124 { x: 207, doing: 'sleep', role: 'S' },
125 { x: 228, doing: 'sleep', role: 'S' },
126 ],
127 ],
128}
129
130/** The places `game` gives `players` free crew; a game short of players falls back to ping-pong. */
131export function layoutFor(game: Game, players: number): { game: Game; slots: readonly Slot[] } {
132 if (players <= 0) return { game, slots: [] }
133 const n = Math.min(3, players)
134 const slots = LAYOUTS[game][n - 1]
135 if (slots !== undefined) return { game, slots }
136 return { game: 'pong', slots: LAYOUTS.pong[n - 1] ?? [] }
137}
138
139/** What the game room is playing right now, and whether every player has arrived. */
140export type Play = { game: Game; slots: readonly Slot[]; isOn: boolean }
141
142export function playOf(s: SceneProps, now: number): Play {
143 const crew = s.actors.filter(a => a.cap !== null && a.agentKey === undefined && a.neighbor === undefined)
144 const { game, slots } = layoutFor(s.game, crew.length)
145 const isOn =
146 slots.length > 0 &&
147 slots.every(slot => crew.some(a => a.toX === slot.x && a.doing === slot.doing && !isWalking(a, now)))
148 return { game, slots, isOn }
149}
150
151const roleOf = (play: Play, a: SceneActor): string | undefined =>
152 a.agentKey === undefined && a.neighbor === undefined && a.cap !== null ? play.slots.find(s => s.x === a.toX && s.doing === a.doing)?.role : undefined
153
154type Point = { x: number; y: number }
155
156/** The volleyball at tick `t`, and who hits it when. */
157function volley(play: Play, t: number): { ball: Point; hitter: string } {
158 const by = (role: string): number => (play.slots.find(s => s.role === role)?.x ?? 0) + 7
159 const legs =
160 play.slots.length === 3
161 ? [
162 { from: 'R', to: 'L1', peak: 3 },
163 { from: 'L1', to: 'L2', peak: 7 },
164 { from: 'L2', to: 'R', peak: 3 },
165 ]
166 : [
167 { from: 'L1', to: 'R', peak: 3 },
168 { from: 'R', to: 'L1', peak: 3 },
169 ]
170 const span = 24 / legs.length
171 const k = t % 24
172 const leg = legs[Math.floor(k / span)] ?? legs[0]!
173 const u = (k % span) / span
174 const x0 = by(leg.from)
175 const x1 = by(leg.to)
176 return { ball: { x: Math.round(x0 + (x1 - x0) * u), y: Math.round(12 - (12 - leg.peak) * Math.sin(Math.PI * u)) }, hitter: leg.from }
177}
178
179const ROPE_MID = [6, 8, 13, 19, 24, 19, 13, 8]
180
181function rope(g: Grid, t: number): void {
182 const mid = ROPE_MID[t % 8] ?? 6
183 let last: Point | undefined
184 for (let x = 202; x <= 237; x++) {
185 const u = (x - 202) / 35
186 const y = Math.round(19 + (mid - 19) * 4 * u * (1 - u))
187 if (last !== undefined) {
188 const step = Math.sign(y - last.y)
189 for (let yy = last.y; yy !== y; yy += step) px(g, x, yy, C.red)
190 }
191 px(g, x, y, C.red)
192 last = { x, y }
193 }
194}
195
196const BLOCKS = [C.red, C.yellow, C.blue, C.green, C.purple, C.pink]
197
198function tower(g: Grid, t: number): void {
199 const k = t % 48
200 if (k >= 42) {
201 ;[
202 [208, 23],
203 [213, 24],
204 [221, 23],
205 [227, 24],
206 [216, 22],
207 [233, 24],
208 ].forEach(([x, y], i) => rect(g, x ?? 0, y ?? 0, i % 2 === 0 ? 4 : 5, 2, BLOCKS[i] ?? C.red))
209 return
210 }
211 const count = Math.min(6, Math.floor(k / 6) + 1)
212 for (let i = 0; i < count; i++) {
213 const wobble = k >= 36 && i >= 3 ? (k % 2 === 0 ? -1 : 1) * (i - 2) : 0
214 rect(g, 219 + wobble, 23 - i * 2, 6, 2, BLOCKS[i] ?? C.red)
215 }
216}
217
218/** The games' own things: the table, the net and ball, the rope, the blocks, the mats. */
219export function gameRoom(g: Grid, t: number, play: Play): void {
220 switch (play.game) {
221 case 'pong': {
222 if (!play.slots.some(s => s.role === 'L')) break
223 rect(g, 220, 18, 18, 1, C.table)
224 rect(g, 220, 19, 18, 1, C.dark)
225 rect(g, 228, 15, 1, 3, C.light)
226 rect(g, 221, 20, 1, 5, C.dark)
227 rect(g, 236, 20, 1, 5, C.dark)
228 px(g, 220, 18, C.white)
229 px(g, 237, 18, C.white)
230 if (play.isOn) {
231 const k = t % 16
232 const u = (k % 8) / 8
233 const from = k < 8 ? 219 : 238
234 const to = k < 8 ? 238 : 219
235 rect(g, Math.round(from + (to - from) * u), Math.round(16 - Math.sin(Math.PI * u) * 5), 2, 2, C.white)
236 }
237 break
238 }
239 case 'volley': {
240 if (play.slots.length < 2) break
241 rect(g, 220, 10, 1, 15, C.dark)
242 rect(g, 219, 10, 3, 1, C.white)
243 for (let y = 11; y < 16; y++) {
244 px(g, 219, y, y % 2 === 0 ? C.net : C.light)
245 px(g, 221, y, y % 2 === 1 ? C.net : C.light)
246 }
247 if (play.isOn) {
248 const { ball } = volley(play, t)
249 rect(g, ball.x, ball.y, 3, 3, C.white)
250 px(g, ball.x + 1, ball.y, C.yellow)
251 px(g, ball.x, ball.y + 2, C.blue)
252 }
253 break
254 }
255 case 'rope':
256 if (play.isOn) rope(g, t)
257 break
258 case 'tower':
259 if (play.slots.length >= 2) tower(g, play.isOn ? t : 0)
260 break
261 case 'sleep':
262 play.slots.forEach((slot, i) => {
263 rect(g, slot.x, 24, 16, 1, [C.sky, C.pink, C.green][i] ?? C.sky)
264 rect(g, slot.x - 2, 22, 3, 2, C.white)
265 })
266 break
267 case 'lunch': {
268 // A checked picnic cloth, and a lunch box beside each of them.
269 if (play.slots.length === 0) break
270 const from = (play.slots[0]?.x ?? 186) - 2
271 for (let x = from; x < Math.min(252, from + play.slots.length * 21 + 8); x++) px(g, x, 24, Math.floor(x / 2) % 2 === 0 ? C.red : C.white)
272 for (const slot of play.slots) {
273 const x = slot.x + 16
274 if (x + 4 > 252) continue
275 rect(g, x, 21, 4, 3, C.dark)
276 rect(g, x + 1, 22, 2, 1, C.white)
277 px(g, x + 3, 22, C.yellow)
278 px(g, x, 21, C.red)
279 }
280 break
281 }
282 case 'tea':
283 // A little table with a teapot and a slice of cake beside each of them.
284 for (const slot of play.slots) {
285 const x = slot.x + 16
286 if (x + 5 > 252) continue
287 rect(g, x, 21, 5, 1, C.wood)
288 rect(g, x + 2, 22, 1, 3, C.wood)
289 rect(g, x + 1, 19, 2, 2, C.white)
290 px(g, x, 19, C.white)
291 px(g, x + 3, 20, C.pink)
292 px(g, x + 4, 20, C.yellow)
293 }
294 break
295 }
296}
297
298/** Blankets go over the sleepers, drawn after them. */
299export function blankets(g: Grid, play: Play, s: SceneProps, now: number): void {
300 if (play.game !== 'sleep') return
301 play.slots.forEach((slot, i) => {
302 if (!s.actors.some(a => a.toX === slot.x && a.doing === 'sleep' && !isWalking(a, now))) return
303 const color = [C.blue, C.purple, C.table][i] ?? C.blue
304 rect(g, slot.x + 1, 20, 14, 4, color)
305 for (let x = slot.x + 2; x < slot.x + 15; x += 3) px(g, x, 21, C.white)
306 })
307}
308
309export type BackgroundOptions = {
310 /** Draw the clouds into the window (the SVG moves its own instead). */
311 hasClouds?: boolean
312 /** Leave out the wallpaper stripes, roof tiles and floor seams: fewer runs for the terminal. */
313 isPlain?: boolean
314}
315
316/** Everything but the Clawds at tick `t`: the scene, then the game being played. */
317export function drawBackground(g: Grid, t: number, s: SceneProps, play: Play, options: BackgroundOptions = {}): void {
318 const { hasClouds = true, isPlain = false } = options
319 drawScene(g, t, s, { hasClouds, isPlain })
320 decorate(g, t, s)
321 gameRoom(g, t, play)
322}
323
324// ── The Clawds ─────────────────────────────────────────────────────────────
325
326/** The crew's beanies, then the visitors' from other sessions. */
327const CAP: Record<string, number> = { blue: C.blue, green: C.green, purple: C.purple, red: C.red, yellow: C.yellow, teal: C.teal, pink: C.pink }
328
329/** Where an actor is at `now`: walking in a straight line at SPEED. */
330export function actorX(actor: SceneActor, now: number): number {
331 const distance = Math.abs(actor.toX - actor.fromX)
332 if (distance === 0) return actor.toX
333 const done = ((now - actor.departAt) / 1000) * SPEED
334 if (done >= distance) return actor.toX
335 if (done <= 0) return actor.fromX
336 return Math.round(actor.fromX + Math.sign(actor.toX - actor.fromX) * done)
337}
338
339export const isWalking = (actor: SceneActor, now: number): boolean => actorX(actor, now) !== actor.toX
340
341type Eyes = 'open' | 'blink' | 'happy' | 'closed' | 'up' | 'down' | 'wide'
342
343type Arm = 'mid' | 'up' | 'low'
344
345/** What a game asks of a player on top of his own pose: where to look, his arms, a jump. */
346export type Mod = {
347 look?: number
348 armL?: Arm
349 armR?: Arm
350 lift?: number
351 eyes?: Eyes
352 isSweating?: boolean
353 hasScarf?: boolean
354 hat?: Hat | 'santa' | 'witch'
355 isTired?: boolean
356 /** Trophies that gild the commit stamp and the envelope's seal. */
357 golden?: { stamp: boolean; seal: boolean }
358}
359
360type HatArt = { top: number; rows: readonly string[]; key: Readonly<Record<string, number>> }
361
362/**
363 * Every hat as rows of pixels from `top` (rows above the head are negative),
364 * 16 columns across his body's box; a letter is a colour from `key`.
365 */
366const HATS: Record<Hat | 'santa' | 'witch', HatArt> = {
367 santa: { top: -4, rows: ['.........rrrrw..', '......rrrrrr.w..', '....rrrrrrrr....', '...wwwwwwwwww...'], key: { r: C.red, w: C.white } },
368 witch: { top: -5, rows: ['........p.......', '.......pp.......', '......pppp......', '.....oooooo.....', '..pppppppppppp..'], key: { p: C.witch, o: C.pumpkin } },
369 party: { top: -5, rows: ['.......yy.......', '.......mm.......', '......mymm......', '.....mmmmym.....', '....mymmmmmm....'], key: { m: C.magenta, y: C.yellow } },
370 crown: { top: -3, rows: ['....g..gg..g....', '....gggggggg....', '....ggrggrgg....'], key: { g: C.gold, r: C.red } },
371 halo: { top: -4, rows: ['.....eeeeee.....', '....e......e....'], key: { e: C.ember } },
372 wizard: {
373 top: -6,
374 rows: ['.........u......', '........uu......', '.......uuu......', '......uyuuu.....', '.....uuuuuuu....', '..uuuuuuuuuuuu..'],
375 key: { u: C.purple, y: C.yellow },
376 },
377 captain: { top: -3, rows: ['.....wwwwww.....', '....wwwggwww....', '...dddddddddd...'], key: { w: C.white, g: C.gold, d: C.dark } },
378 flower: { top: -4, rows: ['..........p.....', '.........pyp....', '..........p.....', '..........g.....'], key: { p: C.pink, y: C.yellow, g: C.green } },
379 explorer: { top: -3, rows: ['.....bbbbbb.....', '.....nnnnnn.....', '..bbbbbbbbbbbb..'], key: { b: C.beige, n: C.wood } },
380 graduation: { top: -3, rows: ['...dddddddddd...', '.....dddddd.y...', '.....dddddd.y...'], key: { d: C.dark, y: C.yellow } },
381 headphones: { top: -2, rows: ['...dddddddddd...', '..d..........d..', '................', '.rr..........rr.', '.rr..........rr.'], key: { d: C.dark, r: C.red } },
382}
383
384/** How far a hat rises over the head, for a speech bubble to clear it. */
385export const hatRise = (hat: Mod['hat']): number => (hat === undefined ? 0 : -HATS[hat].top)
386
387function drawHat(g: Grid, x: number, y: number, hat: Hat | 'santa' | 'witch'): void {
388 const { top, rows, key } = HATS[hat]
389 rows.forEach((row, j) => {
390 for (let i = 0; i < row.length; i++) {
391 const c = key[row[i] ?? '.']
392 if (c !== undefined) px(g, x + i, y + top + j, c)
393 }
394 })
395}
396
397/** A Clawd at the CLI banner's own size: 16 × 10, feet on the floor at FEET. */
398export function drawClawd(g: Grid, x: number, t: number, doing: Doing | 'walk', cap: string | null, phase = 0, mod: Mod = {}): void {
399 const k = t + phase
400 let y = FEET - 9
401 let eyes: Eyes = k % 48 === 7 ? 'blink' : 'open'
402 let look = 0
403 let armL: Arm = 'mid'
404 let armR: Arm = 'mid'
405 let legs: 'stand' | 'a' | 'b' = 'stand'
406 let shake = 0
407 switch (doing) {
408 case 'walk':
409 legs = k % 4 < 2 ? 'a' : 'b'
410 if (legs === 'b') y -= 1
411 break
412 case 'read':
413 eyes = 'down'
414 break
415 case 'think':
416 eyes = 'up'
417 look = 1
418 break
419 case 'code':
420 case 'type':
421 eyes = 'up'
422 look = 1
423 armL = k % 2 === 0 ? 'mid' : 'low'
424 armR = k % 2 === 0 ? 'low' : 'mid'
425 break
426 case 'web':
427 eyes = 'up'
428 look = 1
429 break
430 case 'arcade':
431 eyes = 'up'
432 look = -1
433 armL = k % 2 === 0 ? 'mid' : 'low'
434 armR = k % 3 === 0 ? 'low' : 'mid'
435 if (k % 8 < 1) y -= 1
436 break
437 case 'pong':
438 case 'volley':
439 armL = 'low'
440 armR = 'low'
441 break
442 case 'turn':
443 case 'jump':
444 case 'tower':
445 break
446 case 'clap':
447 eyes = 'happy'
448 armL = k % 4 < 2 ? 'up' : 'mid'
449 armR = k % 4 < 2 ? 'up' : 'mid'
450 break
451 case 'wait':
452 armR = k % 4 < 2 ? 'up' : 'mid'
453 if (k % 8 < 2) y -= 1
454 break
455 case 'sleep':
456 eyes = 'closed'
457 armL = 'low'
458 armR = 'low'
459 break
460 case 'pin':
461 armL = 'up'
462 eyes = 'happy'
463 look = -1
464 break
465 case 'panic':
466 eyes = 'wide'
467 shake = k % 2 === 0 ? -1 : 1
468 armL = k % 2 === 0 ? 'up' : 'mid'
469 armR = k % 2 === 0 ? 'mid' : 'up'
470 break
471 case 'love':
472 case 'cheer':
473 eyes = 'happy'
474 armL = 'up'
475 armR = 'up'
476 if (k % 6 < 2) y -= 2
477 break
478 case 'oops':
479 eyes = k % 16 < 10 ? 'wide' : 'blink'
480 armL = 'low'
481 armR = 'low'
482 break
483 case 'ask':
484 // Holds a sign up to the glass and taps it.
485 eyes = 'wide'
486 armL = 'up'
487 armR = 'up'
488 if (k % 8 < 2) y -= 1
489 break
490 case 'stamp':
491 eyes = 'happy'
492 armR = k % 8 < 4 ? 'up' : 'low'
493 break
494 case 'mail':
495 eyes = 'happy'
496 armR = k % 16 < 3 ? 'up' : 'mid'
497 look = 1
498 break
499 case 'tidy':
500 eyes = 'happy'
501 armL = 'up'
502 armR = 'up'
503 if (k % 12 < 2) y -= 1
504 break
505 case 'eat':
506 eyes = k % 16 < 10 ? 'happy' : 'open'
507 armR = k % 8 < 3 ? 'up' : 'mid'
508 break
509 case 'sip':
510 eyes = k % 12 < 4 ? 'closed' : 'open'
511 armR = k % 12 < 4 ? 'up' : 'mid'
512 break
513 case 'stretch':
514 eyes = 'closed'
515 armL = 'up'
516 armR = 'up'
517 if (k % 8 < 4) y -= 1
518 break
519 case 'idle':
520 if (k % 48 >= 28 && k % 48 < 34) look = -1
521 if (k % 48 >= 36 && k % 48 < 42) look = 1
522 break
523 }
524 if (mod.look !== undefined) look = mod.look
525 if (mod.armL !== undefined) armL = mod.armL
526 if (mod.armR !== undefined) armR = mod.armR
527 if (mod.eyes !== undefined) eyes = mod.eyes
528 // Tired: heavy eyelids, the eyes open now and then.
529 if (mod.isTired && (eyes === 'open' || eyes === 'up' || eyes === 'down') && k % 16 < 11) eyes = 'blink'
530 const lift = mod.lift ?? 0
531 y -= lift
532 const bx = x + shake
533 ;[3, 5, 10, 12].forEach((lx, i) => {
534 const isLifted = lift > 0 || (legs === 'a' && i % 2 === 0) || (legs === 'b' && i % 2 === 1)
535 rect(g, bx + lx, y + 8, 1, isLifted ? 1 : FEET - (y + 8) + 1, C.body)
536 })
537 rect(g, bx + 2, y, 12, 8, C.body)
538 rect(g, bx + 3, y, 10, 1, C.highlight)
539 rect(g, bx + 2, y + 7, 12, 1, C.shade)
540 const arm = (ax: number, kind: Arm, out: number): void => {
541 if (kind === 'mid') rect(g, ax, y + 4, 2, 2, C.body)
542 if (kind === 'low') rect(g, ax, y + 5, 2, 2, C.body)
543 if (kind === 'up') {
544 rect(g, ax, y + 2, 2, 2, C.body)
545 rect(g, ax + out, y, 2, 2, C.body)
546 }
547 }
548 arm(bx, armL, -1)
549 arm(bx + 14, armR, 1)
550 for (const ex of [bx + 4 + look, bx + 11 + look]) {
551 switch (eyes) {
552 case 'open':
553 rect(g, ex, y + 2, 1, 2, C.eye)
554 break
555 case 'blink':
556 px(g, ex, y + 3, C.eye)
557 break
558 case 'up':
559 rect(g, ex, y + 1, 1, 2, C.eye)
560 break
561 case 'down':
562 rect(g, ex, y + 3, 1, 2, C.eye)
563 break
564 case 'happy':
565 px(g, ex - 1, y + 3, C.eye)
566 px(g, ex, y + 2, C.eye)
567 px(g, ex + 1, y + 3, C.eye)
568 break
569 case 'closed':
570 rect(g, ex - 1, y + 3, 3, 1, C.eye)
571 break
572 case 'wide':
573 rect(g, ex - 1, y + 1, 2, 3, C.white)
574 px(g, ex, y + 2, C.eye)
575 break
576 }
577 }
578 if (cap !== null) {
579 const color = CAP[cap] ?? C.blue
580 rect(g, bx + 3, y - 1, 10, 1, color)
581 rect(g, bx + 5, y - 2, 6, 1, color)
582 rect(g, bx + 7, y - 3, 2, 1, C.white)
583 }
584 if (mod.hasScarf) {
585 rect(g, bx + 2, y + 6, 12, 1, C.red)
586 px(g, bx + 5, y + 6, C.white)
587 px(g, bx + 9, y + 6, C.white)
588 rect(g, bx + 10, y + 7, 2, 2, C.red)
589 px(g, bx + 10, y + 8, C.white)
590 }
591 if (mod.hat !== undefined) drawHat(g, bx, y, mod.hat)
592 drawProps(g, bx, y, k, doing, mod)
593 if (mod.isTired && doing !== 'sleep' && k % 48 >= 30 && k % 48 < 42) {
594 const rise = Math.floor((k % 48 - 30) / 4)
595 rect(g, bx + 15, y - 1 - rise, 2, 1, C.light)
596 px(g, bx + 16, y - rise, C.light)
597 rect(g, bx + 15, y + 1 - rise, 2, 1, C.light)
598 }
599 if (mod.isSweating && k % 24 < 9) {
600 const drop = Math.floor((k % 24) / 3)
601 px(g, bx + 15, y + 1 + drop, C.sweat)
602 px(g, bx + 15, y + 2 + drop, C.sweat)
603 }
604}
605
606/**
607 * A laptop on the desk in front of him: the lid toward us, its hinge, and
608 * the deck under it, wider than the lid. Clawd sits behind it, his arms
609 * taking turns at the keys either side, and a key clicks now and then. For
610 * the spinner's working Clawd, drawn over `drawClawd`.
611 */
612export function drawLaptop(g: Grid, x: number, t: number): void {
613 const y = FEET - 9
614 rect(g, x + 3, y + 5, 10, 3, C.gray)
615 rect(g, x + 4, y + 5, 8, 2, C.light)
616 rect(g, x + 3, y + 8, 10, 1, C.steel)
617 rect(g, x, y + 9, 16, 1, C.gray)
618 if (t % 4 === 1) px(g, x + 1, y + 8, C.white)
619 if (t % 4 === 3) px(g, x + 14, y + 8, C.white)
620}
621
622/** Ticks in one round of the spinner's compacting Clawd. */
623export const SQUASH_LOOP = 28
624
625type SquashStep = { pile: 0 | 2 | 3 | 5; hop: number; eyes: Eyes; arms: Arm; puff: 0 | 1 | 2; poof?: boolean; sparkle?: 0 | 1 | 2 }
626
627/**
628 * Tick by tick: he eyes a messy pile of five pages, jumps, and stomps it to
629 * three; jumps again and stomps it into a neat bundle tied with a red strap;
630 * cheers while it sparkles; then hops up, the bundle goes in a puff, and he
631 * lands on the next messy pile.
632 */
633const SQUASH: readonly SquashStep[] = [
634 { pile: 5, hop: 0, eyes: 'down', arms: 'mid', puff: 0 },
635 { pile: 5, hop: 0, eyes: 'down', arms: 'mid', puff: 0 },
636 { pile: 5, hop: 0, eyes: 'blink', arms: 'mid', puff: 0 },
637 { pile: 5, hop: 0, eyes: 'down', arms: 'low', puff: 0 },
638 { pile: 5, hop: 2, eyes: 'up', arms: 'up', puff: 0 },
639 { pile: 5, hop: 3, eyes: 'up', arms: 'up', puff: 0 },
640 { pile: 3, hop: 0, eyes: 'closed', arms: 'low', puff: 2 },
641 { pile: 3, hop: 0, eyes: 'closed', arms: 'low', puff: 1 },
642 { pile: 3, hop: 0, eyes: 'down', arms: 'mid', puff: 0 },
643 { pile: 3, hop: 0, eyes: 'down', arms: 'low', puff: 0 },
644 { pile: 3, hop: 2, eyes: 'up', arms: 'up', puff: 0 },
645 { pile: 3, hop: 3, eyes: 'up', arms: 'up', puff: 0 },
646 { pile: 2, hop: 0, eyes: 'closed', arms: 'low', puff: 2 },
647 { pile: 2, hop: 0, eyes: 'closed', arms: 'low', puff: 1 },
648 { pile: 2, hop: 0, eyes: 'happy', arms: 'up', puff: 0, sparkle: 2 },
649 { pile: 2, hop: 1, eyes: 'happy', arms: 'up', puff: 0, sparkle: 2 },
650 { pile: 2, hop: 0, eyes: 'happy', arms: 'up', puff: 0, sparkle: 1 },
651 { pile: 2, hop: 1, eyes: 'happy', arms: 'up', puff: 0, sparkle: 1 },
652 { pile: 2, hop: 0, eyes: 'happy', arms: 'mid', puff: 0, sparkle: 2 },
653 { pile: 2, hop: 0, eyes: 'happy', arms: 'mid', puff: 0, sparkle: 1 },
654 { pile: 2, hop: 0, eyes: 'happy', arms: 'mid', puff: 0 },
655 { pile: 2, hop: 0, eyes: 'open', arms: 'low', puff: 0 },
656 { pile: 2, hop: 3, eyes: 'up', arms: 'up', puff: 0 },
657 { pile: 0, hop: 5, eyes: 'up', arms: 'up', puff: 0, poof: true },
658 { pile: 5, hop: 2, eyes: 'down', arms: 'up', puff: 0, poof: true },
659 { pile: 5, hop: 1, eyes: 'down', arms: 'mid', puff: 0 },
660 { pile: 5, hop: 0, eyes: 'down', arms: 'mid', puff: 0 },
661 { pile: 5, hop: 0, eyes: 'open', arms: 'mid', puff: 0 },
662]
663
664/** The pile under him, `height` rows up from the floor: loose pages, or at two rows the tied bundle. */
665function drawPile(g: Grid, x: number, height: number): void {
666 if (height === 2) {
667 rect(g, x + 2, FEET - 1, 12, 1, C.white)
668 rect(g, x + 2, FEET, 12, 1, C.beige)
669 rect(g, x + 7, FEET - 1, 2, 2, C.red)
670 return
671 }
672 const offsets = height === 5 ? [0, 1, -1, 2, 0] : [0, 1, 0]
673 const colors = [C.white, C.pageNear, C.sky, C.pink, C.white]
674 for (let j = 0; j < height; j++) rect(g, x + 1 + (offsets[j] ?? 0), FEET - j, 14, 1, colors[j] ?? C.white)
675}
676
677/** The spinner's Clawd while the conversation is compacted: one tick of `SQUASH`, at `x`. */
678export function drawSquash(g: Grid, x: number, t: number): void {
679 const step = SQUASH[((t % SQUASH_LOOP) + SQUASH_LOOP) % SQUASH_LOOP] ?? SQUASH[0]!
680 drawPile(g, x, step.pile)
681 // On a pile his legs reach its top row; in the air, they dangle.
682 const stand = step.pile === 0 ? 3 : step.pile - 1
683 drawClawd(g, x, t, 'idle', null, 0, { look: 0, eyes: step.eyes, armL: step.arms, armR: step.arms, lift: stand + step.hop })
684 const top = FEET - step.pile + 1
685 if (step.puff > 0) {
686 px(g, x - 1, top, C.light)
687 px(g, x + 16, top, C.light)
688 if (step.puff === 2) {
689 px(g, x - 2, top - 1, C.white)
690 px(g, x + 17, top - 1, C.white)
691 }
692 }
693 if (step.poof) {
694 rect(g, x + 1, FEET - 1, 14, 2, C.light)
695 for (const dx of [2, 5, 9, 12]) px(g, x + dx, FEET - 2, C.white)
696 for (const dx of [3, 7, 11]) px(g, x + dx, FEET, C.white)
697 }
698 if (step.sparkle !== undefined && step.sparkle > 0) {
699 px(g, x + 18, FEET - 4, C.white)
700 if (step.sparkle === 2) {
701 px(g, x + 17, FEET - 4, C.yellow)
702 px(g, x + 19, FEET - 4, C.yellow)
703 px(g, x + 18, FEET - 5, C.yellow)
704 px(g, x + 18, FEET - 3, C.yellow)
705 }
706 }
707}
708
709/** How far a Clawd floats off the ground: in open space, all but a sleeper, who is tucked in on the rock. */
710export const floatOf = (s: Pick<SceneProps, 'theme'>, doing: Doing | 'walk'): number => (s.theme === 'cosmos' && doing !== 'sleep' ? 4 : 0)
711
712/** A deadline under three days away makes the main Clawd sweat. */
713export const isNervous = (s: SceneProps): boolean => s.urgency === 'near' || s.urgency === 'urgent'
714
715/** What the Clawds wear: scarves in winter; the main Clawd's trophy hat, else a holiday's; tired eyes late in the five-hour window; gold seals once earned. */
716export function outfitOf(a: SceneActor, s: SceneProps): Mod {
717 const holidayHat = s.holiday === 'christmas' ? 'santa' : s.holiday === 'halloween' ? 'witch' : undefined
718 // A birthday puts everyone at home in a party hat.
719 const hat = s.isBirthday && a.neighbor === undefined ? 'party' : a.cap !== null ? undefined : (s.hat ?? holidayHat)
720 return { hasScarf: s.season === 'winter', isTired: s.isTired, ...(hat === undefined ? {} : { hat }), ...(a.cap === null ? { golden: s.golden } : {}) }
721}
722
723function drawProps(g: Grid, x: number, y: number, k: number, doing: Doing | 'walk', mod: Mod = {}): void {
724 switch (doing) {
725 case 'read':
726 rect(g, x + 5, y + 5, 6, 3, C.white)
727 rect(g, x + 7, y + 5, 2, 3, C.wood)
728 if (k % 24 < 3) rect(g, x + 9 - (k % 24), y + 4, 1, 4, C.white)
729 break
730 case 'think': {
731 const dots = Math.floor((k % 12) / 3)
732 for (let i = 0; i < dots; i++) px(g, x + 14 + i * 2, y - 2, C.gray)
733 break
734 }
735 case 'wait':
736 if (k % 4 !== 3) {
737 rect(g, x + 17, y - 6, 1, 3, C.red)
738 px(g, x + 17, y - 2, C.red)
739 }
740 break
741 case 'sleep': {
742 const rise = (phase: number) => 6 - Math.floor(((k + phase) % 24) / 4)
743 const a = rise(0)
744 if (a >= -4) {
745 rect(g, x + 15, y + a - 2, 3, 1, C.light)
746 px(g, x + 16, y + a - 1, C.light)
747 rect(g, x + 15, y + a, 3, 1, C.light)
748 }
749 break
750 }
751 case 'pin':
752 if (k % 4 < 2) px(g, x - 2, y - 1, C.yellow)
753 break
754 case 'panic':
755 rect(g, x + 17, y - 6, 1, 3, C.red)
756 px(g, x + 17, y - 2, C.red)
757 if (k % 3 !== 2) {
758 px(g, x - 1 - (k % 3), y + 1 + (k % 3), C.sweat)
759 px(g, x - 1 - (k % 3), y + 2 + (k % 3), C.sweat)
760 }
761 break
762 case 'love':
763 for (const [dx, phase] of [[15, 0], [-3, 6]] as const) {
764 const h = y + 2 - ((k + phase) % 12)
765 if (h > -4) {
766 px(g, x + dx, h, C.red)
767 px(g, x + dx + 2, h, C.red)
768 rect(g, x + dx, h + 1, 3, 1, C.red)
769 px(g, x + dx + 1, h + 2, C.red)
770 }
771 }
772 break
773 case 'cheer':
774 if (k % 2 === 0) {
775 px(g, x - 2, y - 1, C.yellow)
776 px(g, x + 17, y + 1, C.yellow)
777 } else {
778 px(g, x - 1, y + 2, C.yellow)
779 px(g, x + 18, y - 1, C.yellow)
780 }
781 break
782 case 'oops':
783 if (k % 12 < 9) {
784 px(g, x + 15, y + Math.floor((k % 12) / 3), C.sweat)
785 px(g, x + 15, y + 1 + Math.floor((k % 12) / 3), C.sweat)
786 }
787 break
788 case 'ask': {
789 // A sign with a big "?", held up over his head.
790 const top = y - 8 - (k % 8 < 2 ? 1 : 0)
791 rect(g, x + 2, top, 12, 6, C.white)
792 rect(g, x + 2, top + 6, 12, 1, C.light)
793 rect(g, x + 7, top + 1, 2, 1, C.red)
794 px(g, x + 9, top + 2, C.red)
795 px(g, x + 8, top + 3, C.red)
796 px(g, x + 8, top + 5, C.red)
797 if (k % 8 < 2) {
798 px(g, x - 2, top + 1, C.yellow)
799 px(g, x + 17, top + 1, C.yellow)
800 }
801 break
802 }
803 case 'stamp': {
804 // A form on the desk; the stamp comes down and leaves a red seal.
805 rect(g, x + 16, y + 7, 7, 1, C.white)
806 rect(g, x + 16, y + 6, 7, 1, C.light)
807 const isDown = k % 8 >= 4
808 const sy = isDown ? y + 3 : y - 1
809 rect(g, x + 18, sy, 3, 1, C.wood)
810 rect(g, x + 19, sy + 1, 1, 1, C.wood)
811 const ink = mod.golden?.stamp ? C.gold : C.red
812 rect(g, x + 17, sy + 2, 5, 1, ink)
813 if (isDown || k % 16 >= 8) rect(g, x + 18, y + 6, 3, 1, ink)
814 break
815 }
816 case 'mail': {
817 // A sealed envelope off his hand, up and away.
818 const p = k % 16
819 if (p >= 2 && p < 14) {
820 const ex = x + 15 + Math.floor((p - 2) * 0.55)
821 const ey = y + 1 - Math.floor((p - 2) / 2)
822 rect(g, ex, ey, 5, 3, C.white)
823 px(g, ex + 1, ey, C.gray)
824 px(g, ex + 2, ey + 1, C.gray)
825 px(g, ex + 3, ey, C.gray)
826 px(g, ex + 2, ey + 2, mod.golden?.seal ? C.gold : C.red)
827 }
828 break
829 }
830 case 'eat': {
831 // A rice ball in his hand, up to his face now and then.
832 const isUp = k % 8 < 3
833 const ox = x + (isUp ? 15 : 16)
834 const oy = isUp ? y - 1 : y + 2
835 px(g, ox + 1, oy, C.white)
836 rect(g, ox, oy + 1, 3, 1, C.white)
837 rect(g, ox, oy + 2, 3, 1, C.dark)
838 break
839 }
840 case 'sip': {
841 // A cup of tea, steam rising from it.
842 const isUp = k % 12 < 4
843 const cx = x + (isUp ? 15 : 16)
844 const cy = isUp ? y + 1 : y + 3
845 rect(g, cx, cy, 2, 2, C.white)
846 px(g, cx + 2, cy, C.white)
847 if (k % 6 < 4) px(g, cx + (k % 2), cy - 1 - Math.floor((k % 6) / 2), C.light)
848 break
849 }
850 case 'stretch':
851 if (k % 8 < 4) {
852 px(g, x - 2, y + 1, C.yellow)
853 px(g, x + 17, y + 1, C.yellow)
854 }
855 break
856 case 'tidy': {
857 // An armful of books, held high on the way back to the shelf.
858 const top = y - 4 - (k % 12 < 2 ? 1 : 0)
859 rect(g, x + 3, top, 10, 1, C.red)
860 rect(g, x + 4, top + 1, 9, 1, C.blue)
861 rect(g, x + 3, top + 2, 10, 1, C.green)
862 rect(g, x + 4, top + 3, 8, 1, C.yellow)
863 break
864 }
865 case 'pong':
866 case 'arcade':
867 case 'code':
868 case 'type':
869 case 'web':
870 case 'idle':
871 case 'walk':
872 case 'volley':
873 case 'turn':
874 case 'jump':
875 case 'tower':
876 case 'clap':
877 break
878 }
879}
880
881/** What the game asks of `actor` at tick `t`: the rally, the rope, the tower. */
882export function playPose(actor: SceneActor, t: number, play: Play): Mod {
883 const role = roleOf(play, actor)
884 if (role === undefined || !play.isOn) return {}
885 const center = actor.toX + 8
886 switch (play.game) {
887 case 'pong': {
888 if (role === 'arcade') return {}
889 const k = t % 16
890 const isSwing = role === 'L' ? k === 0 : k === 8
891 return { look: k < 8 ? 1 : -1, ...(isSwing ? (role === 'L' ? { armR: 'up' as const } : { armL: 'up' as const }) : {}) }
892 }
893 case 'volley': {
894 if (role === 'arcade') return {}
895 const { ball, hitter } = volley(play, t)
896 const span = 24 / (play.slots.length === 3 ? 3 : 2)
897 const look = ball.x + 1 < center - 3 ? -1 : ball.x + 1 > center + 3 ? 1 : 0
898 if (hitter === role && t % 24 % span <= 1) return { look, armL: 'up', armR: 'up', lift: 2 }
899 return { look }
900 }
901 case 'rope': {
902 const p = t % 8
903 const arm: Arm = p === 7 || p <= 1 ? 'up' : p === 2 || p === 6 ? 'mid' : 'low'
904 if (role === 'L') return { armR: arm, look: 1 }
905 if (role === 'R') return { armL: arm, look: -1 }
906 return p >= 3 && p <= 5 ? { lift: 3, eyes: 'happy', armL: 'up', armR: 'up' } : {}
907 }
908 case 'tower': {
909 const k = t % 48
910 if (role === 'C') {
911 if (k >= 42) return { eyes: 'wide', armL: 'up', armR: 'up' }
912 if (k >= 36) return { eyes: 'happy', armL: 'up', armR: 'up', lift: k % 2 }
913 return {}
914 }
915 if (k >= 42) return { eyes: 'wide', look: role === 'L' ? 1 : -1 }
916 const isPlacing = role === 'L' ? k % 12 <= 1 : k % 12 >= 6 && k % 12 <= 7
917 if (!isPlacing) return { look: role === 'L' ? 1 : -1 }
918 return role === 'L' ? { armR: 'up', look: 1 } : { armL: 'up', look: -1 }
919 }
920 case 'lunch':
921 case 'tea':
922 case 'sleep':
923 return {}
924 }
925}
926
927export type ComposeOptions = BackgroundOptions & {
928 /** The actor under the pointer: he hams it up. */
929 hover?: string
930}
931
932/** The whole scene at tick `t`, wall-clock `now`. */
933// ── The rare sights ───────────────────────────────────────────────────────
934
935const EGG_WINDOW = 10 * 60_000
936const EGG_SHOW = 90_000
937
938/**
939 * The rare sight at `now`, the same for every session and every redraw: about
940 * one ten-minute window in twenty-five has one, for its first minute and a
941 * half. A shooting star needs a dark sky (dusk, night, or space); otherwise
942 * it is the scene's own critter.
943 */
944export function eggOf(now: number, time: TimeOfDay, theme: Theme): Egg | null {
945 const window = Math.floor(now / EGG_WINDOW)
946 const h = hash(window * 7919 + 29)
947 if (h % 25 !== 0 || now % EGG_WINDOW >= EGG_SHOW) return null
948 const isDark = theme === 'space' || theme === 'cosmos' || time !== 'day'
949 return isDark && (h >> 8) % 2 === 0 ? 'star' : 'critter'
950}
951
952/** Ticks a shooting star's loop takes, and how many of them it is in the sky. */
953export const STAR_LOOP = 32
954export const STAR_TICKS = 10
955
956/** Where a shooting star's head is `k` ticks into its streak across `box`: down and to the left. */
957export function starAt(box: Box, k: number): { x: number; y: number } {
958 const step = box.w > 60 ? 3 : 2
959 return { x: box.x + box.w - 3 - k * step, y: box.y + 1 + Math.floor((k * (box.h - 3)) / STAR_TICKS) }
960}
961
962/** A shooting star's head and tail, inside `box`. */
963export function drawStar(g: Grid, box: Box, k: number): void {
964 const { x, y } = starAt(box, k)
965 const step = box.w > 60 ? 3 : 2
966 const inside = (px_: number, py: number): boolean => px_ >= box.x && px_ < box.x + box.w && py >= box.y && py < box.y + box.h
967 for (let i = 3; i >= 0; i--) {
968 const tx = x + i * step
969 const ty = y - Math.round((i * (box.h - 3)) / STAR_TICKS)
970 if (inside(tx, ty)) px(g, tx, ty, i === 0 ? C.white : i === 1 ? C.ember : C.light)
971 }
972}
973
974/** The rare sight on show at tick `t`, for the terminal: the star while it streaks, or the scene's critter. */
975function drawEgg(g: Grid, t: number, s: SceneProps): void {
976 if (s.egg === null) return
977 const eggs = THEMES[s.theme].eggs
978 if (s.egg === 'critter') eggs.critter.draw(g, t)
979 else if (t % STAR_LOOP < STAR_TICKS) drawStar(g, eggs.star, t % STAR_LOOP)
980}
981
982// ── The pal ───────────────────────────────────────────────────────────────
983
984type PalArt = { frames: readonly [readonly string[], readonly string[]]; key: Readonly<Record<string, number>> }
985
986/** Each pal facing right, two steps of its walk, its feet on the floor. */
987const PALS: Record<Pal, PalArt> = {
988 cat: {
989 frames: [
990 ['.....#.#', '#....#e#', '.#######', '.######.', '.#.#.#.#'],
991 ['.....#.#', '#....#e#', '.#######', '.######.', '..#.#.#.'],
992 ],
993 key: { '#': C.gray, e: C.yellow },
994 },
995 owl: {
996 frames: [
997 ['#....#', '######', '#y##y#', '#bbbb#', '.o..o.'],
998 ['......', '#....#', '######', '#y##y#', '.oooo.'],
999 ],
1000 key: { '#': C.woodLight, b: C.beige, y: C.yellow, o: C.pumpkin },
1001 },
1002 crab: {
1003 frames: [
1004 ['.r...r.', 'rr...rr', '.rerer.', 'rrrrrrr', 'r.r.r.r'],
1005 ['r.....r', '.r...r.', '.rerer.', 'rrrrrrr', '.r.r.r.'],
1006 ],
1007 key: { r: C.red, e: C.eye },
1008 },
1009}
1010
1011/** Ticks for a pal's walk across the floor and back. */
1012export const PAL_LOOP = 320
1013export const PAL_FROM = 4
1014export const PAL_TO = 240
1015
1016/** Where the pal is at tick `t`, and which way it faces. */
1017export function palAt(t: number): { x: number; isLeft: boolean } {
1018 const half = PAL_LOOP / 2
1019 const k = ((t % PAL_LOOP) + PAL_LOOP) % PAL_LOOP
1020 const span = PAL_TO - PAL_FROM
1021 return k < half ? { x: Math.round(PAL_FROM + (span * k) / half), isLeft: false } : { x: Math.round(PAL_TO - (span * (k - half)) / half), isLeft: true }
1022}
1023
1024/** The pal at `x`, step `step` (0 or 1) of its walk, facing left or right. */
1025export function drawPal(g: Grid, pal: Pal, x: number, step: number, isLeft: boolean): void {
1026 const { frames, key } = PALS[pal]
1027 const rows = frames[step % 2] ?? frames[0]
1028 rows.forEach((row, j) => {
1029 const line = isLeft ? [...row].reverse().join('') : row
1030 for (let i = 0; i < line.length; i++) {
1031 const c = key[line[i] ?? '.']
1032 if (c !== undefined) px(g, x + i, FEET - rows.length + 1 + j, c)
1033 }
1034 })
1035}
1036
1037export function composeScene(s: SceneProps, t: number, now: number, options: ComposeOptions = {}): Grid {
1038 const g = blank()
1039 const play = playOf(s, now)
1040 drawBackground(g, t, s, play, options)
1041 drawEgg(g, t, s)
1042 const order = [...s.actors].sort((a, b) => Number(a.id === 'main') - Number(b.id === 'main'))
1043 order.forEach((actor, i) => {
1044 const x = actorX(actor, now)
1045 const doing = x !== actor.toX ? 'walk' : actor.id === options.hover ? 'love' : actor.doing
1046 const mod = doing === actor.doing ? playPose(actor, t, play) : {}
1047 drawClawd(g, x, t, doing, actor.cap, i * 3, { ...mod, ...outfitOf(actor, s), isSweating: actor.cap === null && isNervous(s), lift: (mod.lift ?? 0) + floatOf(s, doing) })
1048 })
1049 blankets(g, play, s, now)
1050 if (s.pal !== null) {
1051 const { x, isLeft } = palAt(t)
1052 drawPal(g, s.pal, x, Math.floor(t / 2) % 2, isLeft)
1053 }
1054 drawFalling(g, t, s)
1055 return g
1056}
1057
1058// ── What the pointer finds ─────────────────────────────────────────────────
1059
1060/** What a Clawd's hover card says about him. */
1061export function actorTip(a: SceneActor, s: Pick<SceneProps, 'lang' | 'names'>): string {
1062 const words = say(s.lang)
1063 if (a.neighbor !== undefined) return `${words.neighborClawd(a.neighbor)} · ${a.label || words.doings[a.doing]}`
1064 const name = s.names[a.id] ?? (a.cap === null ? words.mainClawd : words.crewClawd(words.caps[a.cap] ?? ''))
1065 if (a.agentKey !== undefined) return `${name} · ${words.forSubagent(a.label || words.gettingReady)}`
1066 if (a.cap === null) return `${name} · ${a.label || words.doings[a.doing]}`
1067 return `${name} · ${words.doings[a.doing]}`
1068}
1069
1070hooks/scene-svg.ts 639 lines1// The house as one animated SVG for the surfaces that draw SVG, drawn as an
2// image: the static house once, each moving part looping on its own as a
3// flipbook, and a walking Clawd sliding to his spot with SMIL and then
4// switching to what he does there. Over the pixels, light: each room lit by
5// its own lamps and screens, dim with its switch off or late at night, the
6// day coming in at the windows, soft shadows under the Clawds, and the air
7// moving (dust in the light, fireflies, glints on the sea). `color-scheme` on
8// the root keeps the surface's frame transparent. With reduced motion every
9// loop holds its first frame and nothing drifts. What the pointer finds is
10// the band's own: `roomHoverSvg` and `actorHoverSvg` draw what shows there.
11
12import type { RoomId } from '../types'
13import { decorate, decorParts, fallingSvg } from './decor'
14import { say } from './i18n'
15import { FLOOR } from './pixels'
16import { isSwitched, NIGHT, roomSpans, shadeOf } from './light'
17import { drawScene, THEMES, type Glow, type ThemeArt } from './themes'
18import {
19 actorX,
20 blankets,
21 blank,
22 drawBackground,
23 gameRoom,
24 drawPal,
25 drawStar,
26 hatRise,
27 STAR_LOOP,
28 STAR_TICKS,
29 PAL_FROM,
30 PAL_LOOP,
31 PAL_TO,
32 drawClawd,
33 drawLaptop,
34 drawSquash,
35 SQUASH_LOOP,
36 isNervous,
37 floatOf,
38 outfitOf,
39 playOf,
40 playPose,
41 ROOMS,
42 SCENE_PALETTE,
43 SH,
44 SPEED,
45 STEP,
46 SW,
47 type Box,
48 type Doing,
49 type Grid,
50 type Play,
51 type SceneActor,
52 type SceneProps,
53 type TimeOfDay,
54} from './scene'
55
56/** A loop of 48 ticks covers every period the house uses (2, 3, 4, 6, 8, 12, 16, 24, 48). */
57const LOOP = 48
58/** Where an actor is drawn before his group is moved into place. */
59const REF = 12
60
61const FULL: Box = { x: 0, y: 0, w: SW, h: SH }
62
63/** One path per colour over `box` (the pixels `keep` lets through), each run a `M x y h w v 1 h -w z`. */
64function paths(grid: Grid, box: Box = FULL, keep: (i: number) => boolean = () => true): string {
65 const runs = new Map<number, string>()
66 for (let y = Math.max(0, box.y); y < Math.min(SH, box.y + box.h); y++) {
67 let x = Math.max(0, box.x)
68 const end = Math.min(SW, box.x + box.w)
69 while (x < end) {
70 const i = y * SW + x
71 if (!keep(i)) {
72 x++
73 continue
74 }
75 const c = grid[i]!
76 let run = x + 1
77 while (run < end && grid[y * SW + run] === c && keep(y * SW + run)) run++
78 if (c !== 0) runs.set(c, `${runs.get(c) ?? ''}M${x} ${y}h${run - x}v1h-${run - x}z`)
79 x = run
80 }
81 }
82 return [...runs].map(([c, d]) => `<path fill="${SCENE_PALETTE[c]}" d="${d}"/>`).join('')
83}
84
85/**
86 * Frames over an opaque `box` that the still scene already shows at tick 0:
87 * each frame paints only the pixels where it differs from that, and only
88 * those `keep` lets through.
89 */
90function overStill(frames: readonly Grid[], box: Box, keep: (i: number) => boolean = () => true): string {
91 const first = frames[0]
92 if (first === undefined) return ''
93 const marks = frames.map(g => paths(g, box, i => g[i] !== first[i] && keep(i)))
94 return new Set(marks).size > 1 ? flipbook(marks) : ''
95}
96
97/**
98 * Frames on a transparent ground: the pixels every frame shares drawn once,
99 * then per frame only the rest.
100 */
101function layered(frames: readonly Grid[], box: Box): string {
102 const first = frames[0]
103 if (first === undefined) return ''
104 const isShared = (i: number): boolean => first[i] !== 0 && frames.every(g => g[i] === first[i])
105 return paths(first, box, isShared) + flipbook(frames.map(g => paths(g, box, i => !isShared(i))))
106}
107
108/** Frames as groups that take turns, each shown for one tick; the first is `f0`, the one that stays when motion is reduced. */
109function flipbook(frames: readonly string[]): string {
110 const unique = [...new Set(frames)]
111 if (unique.length === 1) return `<g>${unique[0]}</g>`
112 const dur = ((frames.length * STEP) / 1000).toFixed(3)
113 return unique
114 .map((markup, k) => {
115 const values = frames.map(f => (f === markup ? 'visible' : 'hidden')).join(';')
116 return `<g class="f${k === 0 ? '0' : ''}" visibility="hidden"><animate attributeName="visibility" values="${values}" dur="${dur}s" calcMode="discrete" repeatCount="indefinite"/>${markup}</g>`
117 })
118 .join('')
119}
120
121/**
122 * The scene in three layers: everything still at tick 0; the scene's own
123 * moving parts, each looping on its own period; and over them the game being
124 * played, so the scene's periods and the game's never multiply into one long
125 * loop of frames.
126 */
127function house(s: SceneProps, play: Play): string {
128 const art = THEMES[s.theme]
129 const sceneAt = (t: number): Grid => {
130 const g = blank()
131 drawScene(g, t, s, { hasClouds: false, isPlain: false })
132 decorate(g, t, s)
133 return g
134 }
135 const scene = sceneAt(0)
136 const still = blank()
137 drawBackground(still, 0, s, play, { hasClouds: false })
138 const isBare = (i: number): boolean => still[i] === scene[i]
139 const moving = [...art.parts, ...decorParts(s)].map(part => {
140 const frames: Grid[] = []
141 for (let t = 0; t < part.ticks; t++) frames.push(sceneAt(t))
142 return overStill(frames, part, isBare)
143 })
144 const games: Grid[] = []
145 for (let t = 0; t < LOOP; t++) {
146 const g = scene.slice()
147 gameRoom(g, t, play)
148 games.push(g)
149 }
150 return paths(still) + moving.join('') + overStill(games, { x: 174, y: 0, w: SW - 174, h: SH })
151}
152
153/**
154 * The pal walking the floor and back on a loop of its own: facing right one
155 * way, left the other, its two steps taking turns. Reduced motion keeps it
156 * home, with everything else that drifts.
157 */
158function palSvg(s: SceneProps): string {
159 if (s.pal === null) return ''
160 const box: Box = { x: 0, y: 14, w: 12, h: SH - 14 }
161 const facing = (isLeft: boolean): string => {
162 const steps = [0, 1].map(step => {
163 const g = blank()
164 drawPal(g, s.pal!, 0, step, isLeft)
165 return paths(g, box)
166 })
167 return flipbook([steps[0]!, steps[0]!, steps[1]!, steps[1]!])
168 }
169 const loop = ((PAL_LOOP * STEP) / 1000).toFixed(1)
170 return (
171 `<g class="quiet drift"><animateTransform attributeName="transform" type="translate" values="${PAL_FROM} 0;${PAL_TO} 0;${PAL_FROM} 0" keyTimes="0;0.5;1" dur="${loop}s" repeatCount="indefinite"/>` +
172 `<g><animate attributeName="visibility" values="visible;hidden" keyTimes="0;0.5" calcMode="discrete" dur="${loop}s" repeatCount="indefinite"/>${facing(false)}</g>` +
173 `<g visibility="hidden"><animate attributeName="visibility" values="hidden;visible" keyTimes="0;0.5" calcMode="discrete" dur="${loop}s" repeatCount="indefinite"/>${facing(true)}</g>` +
174 '</g>'
175 )
176}
177
178/**
179 * The rare sight on show: a shooting star whose streak's frames take turns
180 * for a moment each loop, or the scene's critter on a clear ground. Both sit
181 * over the scene and under the Clawds; reduced motion keeps them still.
182 */
183function eggSvg(s: SceneProps): string {
184 if (s.egg === null) return ''
185 const eggs = THEMES[s.theme].eggs
186 if (s.egg === 'critter') {
187 const frames: Grid[] = []
188 for (let t = 0; t < eggs.critter.ticks; t++) {
189 const g = blank()
190 eggs.critter.draw(g, t)
191 frames.push(g)
192 }
193 return `<g class="quiet">${layered(frames, eggs.critter.box)}</g>`
194 }
195 const streak: string[] = []
196 for (let k = 0; k < STAR_LOOP; k++) {
197 const g = blank()
198 if (k < STAR_TICKS) drawStar(g, eggs.star, k)
199 streak.push(paths(g, eggs.star))
200 }
201 return `<g class="quiet drift">${flipbook(streak)}</g>`
202}
203
204/** The sleepers' blankets, over the sleepers. */
205function covers(s: SceneProps, play: Play, now: number): string {
206 const g = blank()
207 blankets(g, play, s, now + 60_000)
208 return paths(g)
209}
210
211/** Clouds drift on their own: across the house's window, or the whole sky outdoors. */
212function clouds(s: SceneProps): string {
213 const sky = THEMES[s.theme].sky
214 if (sky === null || s.time === 'night') return ''
215 const fill = s.time === 'dusk' ? '#FBD3C0' : '#FFFFFF'
216 if (s.theme === 'house') {
217 return (
218 '<clipPath id="glass"><rect x="141" y="6" width="12" height="4"/><rect x="154" y="6" width="13" height="4"/><rect x="141" y="11" width="12" height="4"/><rect x="154" y="11" width="13" height="4"/></clipPath>' +
219 `<g class="drift" clip-path="url(#glass)" fill="${fill}">` +
220 '<g><animateTransform attributeName="transform" type="translate" from="-12 0" to="30 0" dur="17s" repeatCount="indefinite"/><path d="M141 8h6v1h-6zM142 7h3v1h-3z"/></g>' +
221 '<g><animateTransform attributeName="transform" type="translate" from="-20 0" to="30 0" dur="23s" begin="-9s" repeatCount="indefinite"/><path d="M146 12h5v1h-5zM147 11h2v1h-2z"/></g>' +
222 '</g>'
223 )
224 }
225 const rows = sky.h > 13 ? [4, 7, 10] : [3, 6]
226 return (
227 `<clipPath id="sky"><rect x="${sky.x}" y="${sky.y}" width="${sky.w}" height="${sky.h}"/></clipPath><g class="drift" clip-path="url(#sky)" fill="${fill}">` +
228 rows
229 .map(
230 (y, i) =>
231 `<g><animateTransform attributeName="transform" type="translate" from="-20 0" to="${SW + 10} 0" dur="${70 + i * 23}s" begin="-${(i * 37) % 70}s" repeatCount="indefinite"/><path d="M0 ${y}h7v1h-7zM1 ${y - 1}h4v1h-4z"/></g>`,
232 )
233 .join('') +
234 '</g>'
235 )
236}
237
238/** Roughly how wide `text` sets at `size`: CJK a full em, the rest six tenths. */
239function textWidth(text: string, size: number): number {
240 let em = 0
241 for (const ch of text) em += (ch.codePointAt(0) ?? 0) > 0x2e80 ? 1 : 0.6
242 return em * size
243}
244
245const escape = (text: string): string =>
246 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
247
248const FONT = "font-family=\"'PingFang TC','Noto Sans TC','Microsoft JhengHei',system-ui,sans-serif\""
249
250/** How far over his head the bubble must go: above a holiday hat, or an armful of books. */
251function headroom(a: SceneActor, s: SceneProps): number {
252 return Math.max(hatRise(outfitOf(a, s).hat), a.doing === 'tidy' ? 6 : 0) + floatOf(s, a.doing)
253}
254
255function bubble(label: string, isMain: boolean, rise: number): string {
256 if (label === '') return ''
257 const size = isMain ? 2.4 : 1.9
258 const width = textWidth(label, size) + 1.6
259 const height = size + 1.2
260 const cx = REF + 8
261 const x = Math.max(cx - width / 2, REF - 12)
262 // Never off the top: a floater in his tallest hat has the bubble brush its brim.
263 const y = Math.max(0.1, 13.2 - height - (isMain ? 0 : 1.2) - rise)
264 return (
265 `<g class="quiet"><rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${width.toFixed(2)}" height="${height.toFixed(2)}" rx="0.8" fill="#FFFFFF" fill-opacity="0.94" stroke="#2A1A15" stroke-width="0.18"/>` +
266 `<path d="M${cx - 0.8} ${(y + height).toFixed(2)}l0.8 1l0.8 -1z" fill="#FFFFFF"/>` +
267 `<text x="${(x + 0.8).toFixed(2)}" y="${(y + size + 0.25).toFixed(2)}" font-size="${size}" fill="${isMain ? '#B4553A' : '#2A1A15'}" ${FONT}>${escape(label)}</text></g>`
268 )
269}
270
271/** A soft shadow on the floor under a Clawd. */
272const SHADOW = `<ellipse cx="${REF + 8}" cy="${FLOOR + 0.15}" rx="6.8" ry="0.85" fill="#000" fill-opacity="0.22"/>`
273
274/** One Clawd: a walk that ends where he is going, then what he does there. */
275function actor(a: SceneActor, index: number, s: SceneProps, now: number, play: Play): string {
276 const box: Box = { x: REF - 6, y: 0, w: 32, h: SH }
277 const frames = (doing: Parameters<typeof drawClawd>[3], ticks: number, isPlaying: boolean): string => {
278 const out: Grid[] = []
279 for (let t = 0; t < ticks; t++) {
280 const g = blank()
281 const pose = isPlaying ? playPose(a, t, play) : {}
282 drawClawd(g, REF, t, doing, a.cap, index * 3, {
283 ...pose,
284 ...outfitOf(a, s),
285 isSweating: isPlaying && a.cap === null && isNervous(s),
286 lift: (pose.lift ?? 0) + floatOf(s, doing),
287 })
288 out.push(g)
289 }
290 return layered(out, box)
291 }
292 const stay = frames(a.doing, LOOP, true)
293 // In open space nothing stands on the ground: no shadow, and he bobs as he floats.
294 if (floatOf(s, a.doing) > 0) return following(a, now, '', bobbing(stay, index), bobbing(frames('walk', 4, false), index))
295 return following(a, now, `${SHADOW}`, stay, frames('walk', 4, false))
296}
297
298/** A floater's gentle bob, a pixel at a time, each Clawd on a beat of his own. */
299const bobbing = (body: string, index: number): string => `<g class="bob" style="animation-delay:-${(index * 1.3).toFixed(1)}s">${body}</g>`
300
301/** `stay` where a Clawd is going, or a walk there in `walk` first; `always` goes with him the whole way. */
302function following(a: SceneActor, now: number, always: string, stay: string, walk: string): string {
303 const x = actorX(a, now)
304 if (x === a.toX) return `<g transform="translate(${a.toX - REF} 0)">${always}${stay}</g>`
305 const dur = (Math.abs(a.toX - x) / SPEED).toFixed(3)
306 return (
307 `<g><animateTransform attributeName="transform" type="translate" from="${x - REF} 0" to="${a.toX - REF} 0" dur="${dur}s" fill="freeze"/>${always}` +
308 (walk === '' ? '' : `<g><set attributeName="visibility" to="hidden" begin="${dur}s" fill="freeze"/>${walk}</g>`) +
309 `<g visibility="hidden"><set attributeName="visibility" to="visible" begin="${dur}s" fill="freeze"/>${stay}</g></g>`
310 )
311}
312
313/** A Clawd's speech bubble, going with him, drawn over the light so a dark room keeps it readable. */
314function speech(a: SceneActor, s: SceneProps, now: number): string {
315 // Asking, he holds up a sign of his own; the band's line says what for.
316 const label = a.doing === 'ask' ? '' : bubble(a.label, a.cap === null, headroom(a, s))
317 if (label === '') return ''
318 const x = actorX(a, now)
319 if (x === a.toX) return `<g transform="translate(${a.toX - REF} 0)">${label}</g>`
320 const dur = (Math.abs(a.toX - x) / SPEED).toFixed(3)
321 return `<g><animateTransform attributeName="transform" type="translate" from="${x - REF} 0" to="${a.toX - REF} 0" dur="${dur}s" fill="freeze"/>${label}</g>`
322}
323
324function signs(s: SceneProps): string {
325 const names = say(s.lang).rooms[s.theme]
326 const { fill, stroke, y } = THEMES[s.theme].signs
327 const outline = stroke === 'none' ? '' : ` stroke="${stroke}" stroke-width="0.45" paint-order="stroke"`
328 return ROOMS.map(
329 room =>
330 `<text x="${room.x + room.w / 2}" y="${y}" font-size="2.1" text-anchor="middle" fill="${fill}"${outline} ${FONT} font-weight="600">${escape(names[room.id])}</text>`,
331 ).join('')
332}
333
334const rectOf = (b: Box, attrs = ''): string => `<rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" ${attrs}/>`
335
336/**
337 * Reduced motion: the first frame of every loop stays (`!important` outranks
338 * SMIL's own visibility), and the other frames, the clouds, the falling
339 * leaves, the flicker and the drifting motes go.
340 */
341const STILL = '@media (prefers-reduced-motion:reduce){.f,.drift{display:none}.f0{visibility:visible!important}.bob{animation:none}}'
342
343/** A floater's bob: up a pixel, two, one, and back, in steps so the pixels stay crisp. */
344const BOB = '.bob{animation:bob 4.2s steps(1,end) infinite}@keyframes bob{0%,100%{transform:translateY(0)}25%,75%{transform:translateY(-1px)}50%{transform:translateY(-2px)}}'
345
346const STYLE = `<style>:root{color-scheme:light dark;background:transparent}${BOB}${STILL}</style>`
347
348/** How far a light reaches, as an ellipse's two radii. */
349function reachOf(glow: Glow): [number, number] {
350 const { w, h } = glow.box
351 switch (glow.kind) {
352 case 'lamp':
353 return [Math.max(9, w * 1.4), Math.max(7, h * 2)]
354 case 'screen':
355 return [w * 0.95, h * 1.3]
356 case 'leds':
357 return [Math.max(6, w * 0.8), Math.max(5, h * 0.6)]
358 case 'window':
359 return [w * 0.75, h * 1.6]
360 case 'fire':
361 return [w * 2, h * 1.5]
362 }
363}
364
365/** How strongly a window lets light in at this hour; the station's only ever shows the stars. */
366function daylight(art: ThemeArt, time: TimeOfDay): number {
367 if (!art.hasDaylight) return 0.45
368 return time === 'day' ? 1 : time === 'dusk' ? 0.7 : 0.3
369}
370
371
372/**
373 * The scene's light: room by room, a dim veil with holes where its lights
374 * reach; screens that are off go black; what is lit blooms a little. Then
375 * the warm cast of dusk, and the depth every room has: a shadow under the
376 * ceiling, and the floor a touch darker where it meets the wall.
377 */
378function lighting(s: SceneProps): string {
379 const art = THEMES[s.theme]
380 let defs =
381 '<radialGradient id="pool"><stop offset="0" stop-color="#000"/><stop offset="0.6" stop-color="#000" stop-opacity="0.6"/><stop offset="1" stop-color="#000" stop-opacity="0"/></radialGradient>' +
382 '<filter id="bloom" x="-60%" y="-60%" width="220%" height="220%"><feGaussianBlur stdDeviation="1.2"/></filter>'
383 let veil = ''
384 let over = ''
385 for (const span of roomSpans()) {
386 const isDark = s.dark.includes(span.id)
387 const shade = shadeOf(art, s.time, isDark)
388 const glows = art.glows.filter(g => g.room === span.id)
389 const lit = glows.filter(g => !(isDark && isSwitched(g)))
390 for (const g of glows) {
391 if (lit.includes(g)) continue
392 if (g.kind === 'screen') over += rectOf(g.box, 'fill="#0B0F14" fill-opacity="0.9"')
393 }
394 for (const g of lit) {
395 const strength = g.kind === 'window' ? daylight(art, s.time) : 1
396 const bloom = (0.12 + shade * 0.55) * strength
397 const box = { x: g.box.x - 0.5, y: g.box.y - 0.5, w: g.box.w + 1, h: g.box.h + 1 }
398 over += rectOf(box, `fill="${g.color}" opacity="${bloom.toFixed(2)}" filter="url(#bloom)"`)
399 // Screens and racks breathe a little, each at a pace of its own.
400 if (g.kind === 'screen' || g.kind === 'leds') {
401 over += `<g class="drift">${rectOf(box, `fill="${g.color}" filter="url(#bloom)" opacity="0"`).replace('/>', `><animate attributeName="opacity" values="0;${(0.1 + shade * 0.3).toFixed(2)};0" dur="${g.kind === 'leds' ? 2.4 : 3.7}s" begin="${((g.box.x % 7) * 0.4).toFixed(1)}s" repeatCount="indefinite"/></rect>`)}</g>`
402 }
403 // The day falls in at a window as a beam, down to the floor.
404 if (g.kind === 'window' && art.hasDaylight && s.time !== 'night') {
405 const { x, y, w, h } = g.box
406 const beam = s.time === 'dusk' ? '#FFC9A0' : '#FFFBEA'
407 defs += `<linearGradient id="beam-${span.id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${beam}" stop-opacity="0.22"/><stop offset="1" stop-color="${beam}" stop-opacity="0"/></linearGradient>`
408 over += `<path d="M${x} ${y + h}h${w}l${8} ${FLOOR - y - h}h${-w - 16}z" fill="url(#beam-${span.id})"/>`
409 }
410 if (g.kind === 'fire') {
411 over += `<g class="drift">${rectOf(box, `fill="${g.color}" filter="url(#bloom)"`).replace('/>', '><animate attributeName="opacity" values="0.05;0.35;0.12;0.3;0.05" dur="1.3s" repeatCount="indefinite"/></rect>')}</g>`
412 }
413 }
414 if (shade === 0) continue
415 const id = `veil-${span.id}`
416 const width = span.x1 - span.x0
417 let holes = ''
418 for (const g of lit) {
419 const [rx, ry] = reachOf(g)
420 const strength = g.kind === 'window' ? daylight(art, s.time) : 1
421 const cx = g.box.x + g.box.w / 2
422 const cy = g.box.y + g.box.h / 2 + (g.kind === 'window' ? g.box.h * 0.6 : 0)
423 holes += `<ellipse cx="${cx}" cy="${cy}" rx="${rx.toFixed(1)}" ry="${ry.toFixed(1)}" fill="url(#pool)" opacity="${strength.toFixed(2)}"/>`
424 holes += rectOf(g.box, `fill="#000" opacity="${strength.toFixed(2)}"`)
425 }
426 defs += `<mask id="${id}" maskUnits="userSpaceOnUse" x="${span.x0}" y="0" width="${width}" height="${SH}"><rect x="${span.x0}" y="0" width="${width}" height="${SH}" fill="#FFF"/>${holes}</mask>`
427 veil += `<rect x="${span.x0}" y="0" width="${width}" height="${SH}" fill="${NIGHT}" opacity="${shade}" mask="url(#${id})"/>`
428 }
429 const dusk = s.time === 'dusk' && art.hasDaylight ? `<rect width="${SW}" height="${SH}" fill="#FF8A4C" opacity="0.07"/>` : ''
430 defs +=
431 '<linearGradient id="ceiling" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#000" stop-opacity="0.16"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient>' +
432 '<linearGradient id="ground" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity="0.12"/></linearGradient>'
433 const depth = art.isIndoor
434 ? `<rect x="0" y="3" width="${SW}" height="5" fill="url(#ceiling)"/><rect x="0" y="17" width="${SW}" height="8" fill="url(#ground)"/>`
435 : `<rect x="0" y="19" width="${SW}" height="6" fill="url(#ground)"/>`
436 return `<defs>${defs}</defs><g>${depth}${veil}${dusk}${over}</g>`
437}
438
439/**
440 * Behind open space: the deep, darker at the top, two nebulae as soft as
441 * smoke, and far stars drifting so slowly the near ones seem to pass them.
442 */
443function backdrop(s: SceneProps): string {
444 if (s.theme !== 'cosmos') return ''
445 const far = Array.from({ length: 40 }, (_, i) => {
446 const x = (i * 97 + 13) % (SW + 40)
447 const y = (i * 37 + 5) % 24
448 return `<rect x="${x}" y="${y}" width="0.6" height="0.6" fill="#9FB0D8" opacity="${(0.35 + (i % 4) * 0.12).toFixed(2)}"/>`
449 }).join('')
450 return (
451 '<defs><linearGradient id="deep" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#05071A"/><stop offset="1" stop-color="#0E1230"/></linearGradient>' +
452 '<filter id="haze" x="-30%" y="-80%" width="160%" height="260%"><feGaussianBlur stdDeviation="5"/></filter></defs>' +
453 `<rect width="${SW}" height="${SH}" fill="url(#deep)"/>` +
454 '<g filter="url(#haze)" opacity="0.75">' +
455 '<ellipse cx="38" cy="9" rx="34" ry="7" fill="#4B2E8A"/><ellipse cx="62" cy="14" rx="22" ry="5" fill="#2E4FA0" opacity="0.7"/>' +
456 '<ellipse cx="205" cy="8" rx="40" ry="6" fill="#7A2E6E" opacity="0.8"/><ellipse cx="236" cy="15" rx="20" ry="5" fill="#3A2E8A" opacity="0.7"/>' +
457 '</g>' +
458 `<g class="drift"><animateTransform attributeName="transform" type="translate" from="0 0" to="-40 0" dur="240s" repeatCount="indefinite"/>${far}</g>`
459 )
460}
461
462/** Where the sun sits by day and at dusk, and the moon by night, in the scenes under an open sky. */
463const SUN: Partial<Record<SceneProps['theme'], { day: [number, number]; dusk: [number, number]; moon: [number, number]; horizon: number }>> = {
464 beach: { day: [228, 4], dusk: [189, 13.5], moon: [227.5, 3.5], horizon: 15 },
465 forest: { day: [216, 4], dusk: [177, 12.5], moon: [215.5, 3.5], horizon: 14 },
466}
467
468/** An open sky made smooth, the bands blended into one fall of colour, and a halo round the sun or the moon. */
469function skyGlow(s: SceneProps): string {
470 const sun = SUN[s.theme]
471 if (sun === undefined) return ''
472 const [top, low] = s.time === 'night' ? ['#141C33', '#2E3F6E'] : s.time === 'dusk' ? ['#E8836B', '#F6C77A'] : ['#6EC1F0', '#B9E2F7']
473 const [cx, cy] = s.time === 'night' ? sun.moon : s.time === 'dusk' ? sun.dusk : sun.day
474 const halo = s.time === 'night' ? '#F4E8B0' : s.time === 'dusk' ? '#FFB070' : '#FFF4C0'
475 return (
476 `<defs><linearGradient id="sky-fall" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${top}"/><stop offset="1" stop-color="${low}"/></linearGradient>` +
477 `<radialGradient id="halo"><stop offset="0" stop-color="${halo}" stop-opacity="0.7"/><stop offset="1" stop-color="${halo}" stop-opacity="0"/></radialGradient></defs>` +
478 `<rect width="${SW}" height="${sun.horizon}" fill="url(#sky-fall)" opacity="0.4"/>` +
479 `<circle cx="${cx}" cy="${cy}" r="${s.time === 'dusk' ? 14 : 9}" fill="url(#halo)"/>`
480 )
481}
482
483/**
484 * The air moving, a few specks at a time: dust in the light from the house's
485 * window by day, the stars twinkling past the station's, glints on the sea,
486 * and fireflies at the camp after sundown.
487 */
488function atmosphere(s: SceneProps): string {
489 const specks: string[] = []
490 const drift = (x: number, y: number, dx: number, dy: number, r: number, fill: string, dur: number, begin: number, blink = false): string =>
491 `<circle cx="${x}" cy="${y}" r="${r}" fill="${fill}">` +
492 `<animateTransform attributeName="transform" type="translate" values="0 0;${dx} ${dy};0 0" dur="${dur}s" begin="-${begin}s" repeatCount="indefinite"/>` +
493 `<animate attributeName="opacity" values="${blink ? '0;1;0.2;1;0' : '0;0.7;0.5;0.7;0'}" dur="${(dur / 2).toFixed(1)}s" begin="-${begin}s" repeatCount="indefinite"/></circle>`
494 switch (s.theme) {
495 case 'house':
496 if (s.time === 'night' || s.dark.includes('web')) break
497 for (let i = 0; i < 6; i++) specks.push(drift(143 + ((i * 37) % 24), 13 + ((i * 11) % 9), 2 + (i % 3), -3 - (i % 2), 0.28, '#FFF6D8', 9 + i * 2, i * 3))
498 break
499 case 'space':
500 for (const [x, y, k] of [[145, 8, 0], [159, 7, 1], [151, 12, 2], [164, 13, 3]] as const) {
501 specks.push(`<path d="M${x - 0.9} ${y}h1.8M${x} ${y - 0.9}v1.8" stroke="#FFFFFF" stroke-width="0.35" opacity="0"><animate attributeName="opacity" values="0;0.9;0" dur="3.2s" begin="${k * 0.8}s" repeatCount="indefinite"/></path>`)
502 }
503 break
504 case 'cosmos':
505 for (let i = 0; i < 12; i++) {
506 const x = 6 + ((i * 47) % 244)
507 const y = 2 + ((i * 7) % 18)
508 specks.push(`<path d="M${x - 0.9} ${y}h1.8M${x} ${y - 0.9}v1.8" stroke="${i % 3 === 0 ? '#BFE9FF' : '#FFFFFF'}" stroke-width="0.35" opacity="0"><animate attributeName="opacity" values="0;0.9;0" dur="${(2.6 + (i % 4) * 0.6).toFixed(1)}s" begin="${(i * 0.7).toFixed(1)}s" repeatCount="indefinite"/></path>`)
509 }
510 break
511 case 'beach':
512 if (s.time === 'night') break
513 for (let i = 0; i < 8; i++) {
514 const x = 8 + ((i * 53) % 236)
515 const y = 15.5 + (i % 3) * 1.1
516 specks.push(`<rect x="${x}" y="${y}" width="1.6" height="0.3" fill="#FFFFFF" opacity="0"><animate attributeName="opacity" values="0;0.9;0" dur="${2 + (i % 3) * 0.7}s" begin="${(i * 0.45).toFixed(2)}s" repeatCount="indefinite"/></rect>`)
517 }
518 break
519 case 'forest':
520 // Smoke off the campfire, puff after puff, drifting as it climbs.
521 for (let i = 0; i < 5; i++) {
522 specks.push(
523 `<circle cx="184" cy="13" r="${(0.9 + (i % 3) * 0.3).toFixed(1)}" fill="#A9A9B4" opacity="0">` +
524 `<animateTransform attributeName="transform" type="translate" values="0 0;${2 + (i % 2) * 2} -6;${-1 + (i % 3)} -11" dur="5s" begin="${i}s" repeatCount="indefinite"/>` +
525 `<animate attributeName="opacity" values="0;0.45;0" dur="5s" begin="${i}s" repeatCount="indefinite"/></circle>`,
526 )
527 }
528 if (s.time === 'day') break
529 for (let i = 0; i < 8; i++) specks.push(drift(20 + ((i * 61) % 220), 15 + ((i * 7) % 7), 4 - (i % 3) * 3, -2 + (i % 2) * 3, 0.38, '#E8FF8A', 6 + i, i * 2, true))
530 break
531 }
532 if (specks.length === 0) return ''
533 return `<g class="drift" filter="url(#bloom)">${specks.join('')}</g>`
534}
535
536/**
537 * What lights up under the pointer in one room, cropped to the room's own
538 * stretch (`x0` to `x0 + width`, scene pixels) so the band can lay it over
539 * exactly that: the room a little brighter, its lamp lit, its toy saying hi.
540 */
541export function roomHoverSvg(s: SceneProps, x0: number, width: number): string {
542 const art = THEMES[s.theme]
543 const inside = (b: Box): boolean => b.x + b.w > x0 && b.x < x0 + width
544 const body =
545 `<rect x="${x0}" y="3" width="${width}" height="22" fill="#FFFFFF" fill-opacity="0.08"/>` +
546 (inside(art.light.box) ? art.light.glow : '') +
547 (inside(art.toy.box) ? art.toy.hi : '')
548 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${x0.toFixed(3)} 0 ${width.toFixed(3)} ${SH}" width="100%" height="100%" shape-rendering="crispEdges" preserveAspectRatio="none">${STYLE}${body}</svg>`
549}
550
551/** Hearts and a blush for a Clawd under the pointer, cropped like `roomHoverSvg`; `x` is his left edge. */
552export function actorHoverSvg(x: number, x0: number, width: number): string {
553 const dx = x - REF
554 const body =
555 `<g transform="translate(${dx} 0)"><path fill="#E5484D" d="M${REF + 15} 9h1v1h-1zM${REF + 17} 9h1v1h-1zM${REF + 15} 10h3v1h-3zM${REF + 16} 11h1v1h-1z` +
556 `M${REF - 3} 12h1v1h-1zM${REF - 1} 12h1v1h-1zM${REF - 3} 13h3v1h-3zM${REF - 2} 14h1v1h-1z"/>` +
557 `<path fill="#F6A5B8" d="M${REF + 2} 20h2v1h-2zM${REF + 12} 20h2v1h-2z"/></g>`
558 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${x0.toFixed(3)} 0 ${width.toFixed(3)} ${SH}" width="100%" height="100%" shape-rendering="crispEdges" preserveAspectRatio="none">${STYLE}${body}</svg>`
559}
560
561/** The house as an SVG that fills its box; the same props and time give the same text. */
562export function sceneSvg(s: SceneProps, now: number): string {
563 // The game is drawn as it will be once the players get there, so a ball
564 // never waits for a redraw to appear.
565 const play = playOf(s, now + 60_000)
566 const order = s.actors.map((a, i) => ({ a, i })).sort((p, q) => Number(p.a.cap === null) - Number(q.a.cap === null))
567 return (
568 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${SW} ${SH}" width="100%" height="100%" shape-rendering="crispEdges">` +
569 STYLE +
570 backdrop(s) +
571 house(s, play) +
572 skyGlow(s) +
573 clouds(s) +
574 eggSvg(s) +
575 order.map(({ a, i }) => actor(a, i, s, now, play)).join('') +
576 covers(s, play, now) +
577 palSvg(s) +
578 lighting(s) +
579 atmosphere(s) +
580 order.map(({ a }) => speech(a, s, now)).join('') +
581 signs(s) +
582 fallingSvg(s) +
583 '</svg>'
584 )
585}
586
587/** A finished tool row's little Clawd: one still frame, pleased when it went well, worried when it did not. */
588export function doneClawdSvg(isOk: boolean): string {
589 const box: Box = { x: REF - 3, y: 9, w: 24, h: 16 }
590 const g = blank()
591 drawClawd(g, REF, 1, isOk ? 'idle' : 'oops', null, 0, { eyes: isOk ? 'happy' : 'wide' })
592 return (
593 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${box.x} ${box.y} ${box.w} ${box.h}" width="100%" height="100%" shape-rendering="crispEdges">` +
594 `<style>:root{color-scheme:light dark;background:transparent}</style>${paths(g, box)}</svg>`
595 )
596}
597
598/**
599 * One small Clawd on his own, for the spinner and the folded band:
600 * transparent, filling its box. `isOnLaptop` has him typing away on a laptop.
601 */
602export function miniClawdSvg(doing: Doing, isOnLaptop = false): string {
603 const box: Box = { x: REF - 3, y: 9, w: 24, h: 16 }
604 const frames: Grid[] = []
605 for (let t = 0; t < LOOP; t++) {
606 const g = blank()
607 if (isOnLaptop) {
608 drawClawd(g, REF, t, 'type', null, 0, { look: 0, eyes: t % 24 === 11 ? 'blink' : 'open' })
609 drawLaptop(g, REF, t)
610 } else {
611 drawClawd(g, REF, t, doing, null)
612 }
613 frames.push(g)
614 }
615 return (
616 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${box.x} ${box.y} ${box.w} ${box.h}" width="100%" height="100%" shape-rendering="crispEdges">` +
617 `<style>:root{color-scheme:light dark;background:transparent}${STILL}</style>${layered(frames, box)}</svg>`
618 )
619}
620
621/**
622 * The spinner's Clawd while the conversation is compacted, stomping a messy
623 * pile of pages into a neat bundle. Three rows taller than `miniClawdSvg`
624 * for his jumps: drawn 30 × 23.75, his pixels are the same size.
625 */
626export function squashClawdSvg(): string {
627 const box: Box = { x: REF - 3, y: 6, w: 24, h: 19 }
628 const frames: Grid[] = []
629 for (let t = 0; t < SQUASH_LOOP; t++) {
630 const g = blank()
631 drawSquash(g, REF, t)
632 frames.push(g)
633 }
634 return (
635 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${box.x} ${box.y} ${box.w} ${box.h}" width="100%" height="100%" shape-rendering="crispEdges">` +
636 `<style>:root{color-scheme:light dark;background:transparent}${STILL}</style>${layered(frames, box)}</svg>`
637 )
638}
639hooks/light.ts 48 lines1// Light, room by room: which stretch of the scene each room takes, what its
2// switch turns off, and how dim it is by the hour and by that switch. The
3// Desktop scene draws it as light and shadow; the terminal tints its cells.
4
5import type { RoomId } from '../types'
6import { ROOMS, SW, type TimeOfDay } from './scene'
7import type { Glow, ThemeArt } from './themes'
8
9/** Each room's stretch of the scene, the wall between two shared halfway. */
10export function roomSpans(): { id: RoomId; x0: number; x1: number }[] {
11 return ROOMS.map((room, i) => {
12 const before = ROOMS[i - 1]
13 const after = ROOMS[i + 1]
14 return {
15 id: room.id,
16 x0: before === undefined ? 0 : (before.x + before.w + room.x) / 2,
17 x1: after === undefined ? SW : (room.x + room.w + after.x) / 2,
18 }
19 })
20}
21
22/** What a room's switch turns off: its screens, lamps and racks. A window or a fire it leaves be. */
23export const isSwitched = (glow: Glow): boolean => glow.kind === 'screen' || glow.kind === 'lamp' || glow.kind === 'leds'
24
25/** How dim a room is: indoors by its switch and the hour, outdoors by the hour alone. */
26export function shadeOf(art: ThemeArt, time: TimeOfDay, isDark: boolean): number {
27 // Indoors, or out in space where no sun reaches: the switch decides.
28 if (art.isIndoor || !art.hasDaylight) {
29 if (isDark) return !art.hasDaylight || time === 'night' ? 0.66 : time === 'dusk' ? 0.52 : 0.36
30 return time === 'night' ? 0.2 : time === 'dusk' ? 0.08 : 0
31 }
32 return time === 'night' ? 0.32 : time === 'dusk' ? 0.1 : 0
33}
34
35/** The colour a room fades toward as it dims. */
36export const NIGHT = '#060914'
37
38/** `hex` dimmed by `shade` (0 to 1) toward NIGHT. */
39export function tint(hex: string, shade: number): string {
40 if (shade <= 0) return hex
41 const mix = (i: number): number => {
42 const from = parseInt(hex.slice(i, i + 2), 16)
43 const to = parseInt(NIGHT.slice(i, i + 2), 16)
44 return Math.round(from + (to - from) * shade)
45 }
46 return `#${[1, 3, 5].map(i => mix(i).toString(16).padStart(2, '0')).join('')}`
47}
48hooks/sprite.ts 481 lines1// Clawd, drawn procedurally on a 40 × 14 pixel canvas.
2//
3// Every pose is the same little body (18 × 10, two eyes, two arm nubs, four
4// legs, after the CLI banner's Clawd) with different eyes, arms, legs and a
5// prop. `frame(pose, t)` answers palette indexes, one per pixel; the terminal
6// client draws them as half blocks and the desktop as an animated SVG.
7
8import type { Pose } from '../types'
9
10export type { Pose }
11
12export const W = 40
13export const H = 14
14
15/** One tick of animation, in milliseconds (8 frames a second). */
16export const TICK_MS = 125
17
18export const PALETTE: readonly string[] = [
19 '', // 0 transparent
20 '#D97757', // 1 body
21 '#B4553A', // 2 shade
22 '#2A1A15', // 3 eye
23 '#FFFFFF', // 4 white
24 '#F6A5B8', // 5 pink
25 '#E5484D', // 6 red
26 '#7CC6F2', // 7 sweat blue
27 '#F5C542', // 8 yellow
28 '#F1EADB', // 9 paper
29 '#8A8F98', // 10 gray
30 '#4CB363', // 11 green
31 '#A98BF5', // 12 purple
32 '#3B3F46', // 13 dark
33 '#C9CDD3', // 14 light gray
34 '#4D8DF6', // 15 blue
35 '#EE9A78', // 16 highlight
36 '#8B5A2B', // 17 brown
37]
38
39export const POSES: readonly Pose[] = [
40 'idle',
41 'think',
42 'type',
43 'write',
44 'read',
45 'search',
46 'web',
47 'agent',
48 'wait',
49 'cheer',
50 'panic',
51 'oops',
52 'sleep',
53 'itemget',
54 'love',
55 'ask',
56 'stamp',
57 'mail',
58 'tidy',
59 'stretch',
60]
61
62/** Ticks before a pose's animation repeats. */
63export const CYCLE: Record<Pose, number> = {
64 idle: 48,
65 think: 12,
66 type: 4,
67 write: 24,
68 read: 32,
69 search: 16,
70 web: 8,
71 agent: 16,
72 wait: 8,
73 cheer: 8,
74 panic: 4,
75 oops: 16,
76 sleep: 32,
77 itemget: 8,
78 love: 12,
79 ask: 8,
80 stamp: 8,
81 mail: 16,
82 tidy: 12,
83 stretch: 16,
84}
85
86export type FrameOptions = {
87 /** Where the eyes look: -1 left, 0 ahead, 1 right (the pointer, say). */
88 look?: -1 | 0 | 1
89 /** A deadline is near: a sweat drop now and then. */
90 isNervous?: boolean
91}
92
93export type Grid = Uint8Array
94
95type Eyes = 'open' | 'blink' | 'happy' | 'closed' | 'wide' | 'x' | 'down' | 'up'
96type Arm = 'mid' | 'up' | 'wave' | 'down' | 'out' | 'none'
97type Legs = 'stand' | 'walkA' | 'walkB' | 'squat'
98type Mouth = 'none' | 'o' | 'smile'
99
100type Body = {
101 x: number
102 y: number
103 eyes: Eyes
104 look: number
105 armL: Arm
106 armR: Arm
107 legs: Legs
108 isSquashed: boolean
109 hasBlush: boolean
110 mouth: Mouth
111}
112
113const blank = (): Grid => new Uint8Array(W * H)
114
115function px(g: Grid, x: number, y: number, c: number): void {
116 if (x >= 0 && x < W && y >= 0 && y < H) g[y * W + x] = c
117}
118
119function rect(g: Grid, x: number, y: number, w: number, h: number, c: number): void {
120 for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) px(g, x + i, y + j, c)
121}
122
123const KEY: Record<string, number> = {
124 o: 1,
125 s: 2,
126 e: 3,
127 w: 4,
128 p: 5,
129 r: 6,
130 b: 7,
131 y: 8,
132 P: 9,
133 g: 10,
134 G: 11,
135 u: 12,
136 d: 13,
137 l: 14,
138 B: 15,
139 h: 16,
140 n: 17,
141}
142
143/** Draws ASCII art: each letter a palette entry (KEY), `.` left alone. */
144function stamp(g: Grid, x: number, y: number, art: readonly string[]): void {
145 art.forEach((row, j) => {
146 for (let i = 0; i < row.length; i++) {
147 const c = KEY[row[i] ?? '.']
148 if (c !== undefined) px(g, x + i, y + j, c)
149 }
150 })
151}
152
153const HEART = ['.r.r.', 'rprrr', '.rrr.', '..r..']
154const HEART_SMALL = ['p.p', 'ppp', '.p.']
155const BANG = ['rr', 'rr', 'rr', '..', 'rr']
156const SIGN = ['wwwwwww', 'wwrrrww', 'wrwwwrw', 'wwwwrww', 'wwwrwww', 'wwwwwww', 'wwwrwww', 'lllllll']
157const PLANE = ['wwwwwww', 'wgwwwgw', 'wwgwgww', 'wwwrwww']
158const BOOKS = ['rrrrrr.', '.BBBBBB', 'GGGGGG.', '.yyyyy.']
159const Z_SMALL = ['lll', '.l.', 'lll']
160const Z_BIG = ['llll', '..l.', '.l..', 'llll']
161const DROP = ['.b', 'bb', 'bb']
162const SPARKLE_A = ['.y.', 'yyy', '.y.']
163const SPARKLE_B = ['y.y', '.y.', 'y.y']
164const TINY = ['.oooooo.', '.oeooeo.', 'oooooooo', '.ssssss.', '.o.o.o.o']
165const TINY_STEP = ['.oooooo.', '.oeooeo.', 'oooooooo', '.ssssss.', 'o.o.o.o.']
166const CLIPBOARD = ['..ll..', 'nnllnn', 'nPPPPn', 'nPggPn', 'nPPPPn', 'nPggPn', 'nnnnnn']
167const GLOBE_MASK = ['..###..', '.#####.', '#######', '#######', '#######', '.#####.', '..###..']
168const LAND = ['..GG.....G..', '.GGG....GGG.', 'GG.....GGGG.', '.G......GG..', '.....G...G..', '....GG......', '.....G......']
169
170// The body's geometry, after the CLI banner's Clawd scaled to square pixels:
171// 18 × 10, eyes two by three, arms two by two at mid height, four legs.
172const BW = 18
173const BH = 10
174const LEGS = [2, 5, 11, 14]
175const EYES = [3, 13]
176
177function eye(g: Grid, ex: number, ey: number, kind: Eyes, isLeft: boolean): void {
178 switch (kind) {
179 case 'open':
180 rect(g, ex, ey, 2, 3, 3)
181 px(g, isLeft ? ex : ex + 1, ey, 4)
182 return
183 case 'blink':
184 rect(g, ex, ey + 2, 2, 1, 3)
185 return
186 case 'happy':
187 px(g, ex - 1, ey + 2, 3)
188 rect(g, ex, ey + 1, 2, 1, 3)
189 px(g, ex + 2, ey + 2, 3)
190 return
191 case 'closed':
192 px(g, ex - 1, ey + 1, 3)
193 rect(g, ex, ey + 2, 2, 1, 3)
194 px(g, ex + 2, ey + 1, 3)
195 return
196 case 'wide':
197 rect(g, ex - 1, ey, 3, 3, 4)
198 px(g, ex, ey + 1, 3)
199 return
200 case 'x':
201 px(g, ex - 1, ey, 3)
202 px(g, ex + 1, ey, 3)
203 px(g, ex, ey + 1, 3)
204 px(g, ex - 1, ey + 2, 3)
205 px(g, ex + 1, ey + 2, 3)
206 return
207 case 'down':
208 rect(g, ex, ey + 1, 2, 2, 3)
209 return
210 case 'up':
211 rect(g, ex, ey, 2, 2, 3)
212 px(g, isLeft ? ex : ex + 1, ey, 4)
213 return
214 }
215}
216
217function arm(g: Grid, b: Body, isLeft: boolean, kind: Arm): void {
218 const top = b.y + (b.isSquashed ? 1 : 0)
219 const near = isLeft ? b.x - 2 : b.x + BW
220 const out = isLeft ? -1 : 1
221 switch (kind) {
222 case 'mid':
223 rect(g, near, top + 5, 2, 2, 1)
224 return
225 case 'up':
226 rect(g, near, top + 3, 2, 2, 1)
227 rect(g, near + out, top + 1, 2, 2, 1)
228 return
229 case 'wave':
230 rect(g, near, top + 4, 2, 2, 1)
231 rect(g, near + 2 * out, top + 3, 2, 2, 1)
232 return
233 case 'down':
234 rect(g, near, top + 6, 2, 2, 1)
235 return
236 case 'out':
237 rect(g, isLeft ? near - 1 : near, top + 5, 3, 2, 1)
238 return
239 case 'none':
240 return
241 }
242}
243
244function clawd(g: Grid, b: Body): void {
245 const top = b.y + (b.isSquashed ? 1 : 0)
246 const height = BH - (b.isSquashed ? 1 : 0)
247 LEGS.forEach((lx, i) => {
248 const isLifted =
249 (b.legs === 'walkA' && i % 2 === 0) || (b.legs === 'walkB' && i % 2 === 1) || b.legs === 'squat'
250 rect(g, b.x + lx, b.y + BH, 2, isLifted ? 1 : 2, 1)
251 })
252 rect(g, b.x, top, BW, height, 1)
253 rect(g, b.x + 2, top, BW - 4, 1, 16)
254 rect(g, b.x, b.y + BH - 1, BW, 1, 2)
255 arm(g, b, true, b.armL)
256 arm(g, b, false, b.armR)
257 const look = Math.max(-1, Math.min(1, b.look))
258 eye(g, b.x + (EYES[0] ?? 3) + look, top + 2, b.eyes, true)
259 eye(g, b.x + (EYES[1] ?? 13) + look, top + 2, b.eyes, false)
260 if (b.hasBlush) {
261 rect(g, b.x + 1, top + 6, 2, 1, 5)
262 rect(g, b.x + BW - 3, top + 6, 2, 1, 5)
263 }
264 const mid = b.x + BW / 2 - 1 + look
265 if (b.mouth === 'o') rect(g, mid, top + 6, 2, 2, 3)
266 if (b.mouth === 'smile') {
267 px(g, mid - 1, top + 6, 3)
268 rect(g, mid, top + 7, 2, 1, 3)
269 px(g, mid + 2, top + 6, 3)
270 }
271}
272
273const base = (over: Partial<Body> = {}): Body => ({
274 x: 11,
275 y: 2,
276 eyes: 'open',
277 look: 0,
278 armL: 'mid',
279 armR: 'mid',
280 legs: 'stand',
281 isSquashed: false,
282 hasBlush: false,
283 mouth: 'none',
284 ...over,
285})
286
287/** A cheap, repeatable scramble, so frames never depend on Math.random. */
288const hash = (n: number): number => {
289 let x = (n ^ 0x9e3779b9) >>> 0
290 x = Math.imul(x ^ (x >>> 16), 0x85ebca6b) >>> 0
291 x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35) >>> 0
292 return (x ^ (x >>> 16)) >>> 0
293}
294
295function sweat(g: Grid, t: number, x: number): void {
296 const k = t % 12
297 if (k < 9) stamp(g, x, 1 + Math.floor(k / 3), DROP)
298}
299
300/** The pixels of one frame of `pose` at tick `t`. */
301export function frame(pose: Pose, t: number, options: FrameOptions = {}): Grid {
302 const g = blank()
303 const k = ((t % CYCLE[pose]) + CYCLE[pose]) % CYCLE[pose]
304 const look = options.look
305 switch (pose) {
306 case 'idle': {
307 const isBlink = k === 20 || k === 21 || k === 40
308 const glance = k >= 28 && k < 34 ? -1 : k >= 34 && k < 38 ? 1 : 0
309 clawd(g, base({ eyes: isBlink ? 'blink' : 'open', look: look ?? glance, isSquashed: (k >> 3) % 2 === 1 }))
310 if (options.isNervous) sweat(g, k, 31)
311 break
312 }
313 case 'think': {
314 clawd(g, base({ eyes: 'up', look: 1, isSquashed: k >= 6 }))
315 const dots = Math.floor(k / 3)
316 for (let i = 0; i < dots; i++) rect(g, 31 + i * 3, 0, 2, 2, 14)
317 break
318 }
319 case 'type': {
320 clawd(g, base({ eyes: 'down', armL: 'none', armR: 'none' }))
321 rect(g, 11, 9, 18, 1, 14)
322 rect(g, 11, 10, 18, 2, 13)
323 for (let i = 0; i < 8; i++) if ((hash(i * 7 + k) & 3) !== 0) px(g, 12 + i * 2, 10, 10)
324 const isLeftUp = k % 2 === 0
325 rect(g, 13, isLeftUp ? 7 : 8, 2, 2, 1)
326 rect(g, 25, isLeftUp ? 8 : 7, 2, 2, 1)
327 break
328 }
329 case 'write': {
330 clawd(g, base({ eyes: 'down', look: 1, armR: 'none' }))
331 rect(g, 13, 6, 14, 6, 9)
332 rect(g, 13, 11, 14, 1, 14)
333 const first = Math.min(10, k)
334 const second = Math.max(0, Math.min(10, k - 12))
335 rect(g, 15, 7, first, 1, 10)
336 if (second > 0) rect(g, 15, 9, second, 1, 10)
337 const tipX = 15 + (k < 12 ? first : second)
338 const tipY = k < 12 ? 7 : 9
339 px(g, tipX, tipY - 1, 13)
340 px(g, tipX + 1, tipY - 2, 8)
341 px(g, tipX + 2, tipY - 3, 8)
342 px(g, tipX + 3, tipY - 4, 5)
343 rect(g, tipX + 1, tipY - 1, 2, 2, 1)
344 break
345 }
346 case 'read': {
347 clawd(g, base({ eyes: 'down', armL: 'none', armR: 'none' }))
348 rect(g, 11, 7, 18, 5, 17)
349 rect(g, 12, 7, 7, 4, 9)
350 rect(g, 21, 7, 7, 4, 9)
351 rect(g, 19, 7, 2, 5, 2)
352 for (const row of [8, 10]) {
353 rect(g, 13, row, 5, 1, 14)
354 rect(g, 22, row, 5, 1, 14)
355 }
356 if (k < 3) rect(g, 26 - k * 3, 6, 2, 5, 4)
357 rect(g, 9, 8, 2, 2, 1)
358 rect(g, 29, 8, 2, 2, 1)
359 break
360 }
361 case 'search': {
362 const sweep = k < 8 ? k : 16 - k
363 const cx = 10 + sweep * 2
364 clawd(g, base({ look: cx < 15 ? -1 : cx > 21 ? 1 : 0 }))
365 stamp(g, cx, 4, ['.lll.', 'lbbbl', 'lbwbl', 'lbbbl', '.lll.'])
366 px(g, cx + 4, 8, 17)
367 px(g, cx + 5, 9, 17)
368 px(g, cx + 6, 10, 17)
369 break
370 }
371 case 'web': {
372 clawd(g, base({ look: 1, armR: 'out' }))
373 GLOBE_MASK.forEach((row, j) => {
374 for (let i = 0; i < row.length; i++) {
375 if (row[i] !== '#') continue
376 const isLand = LAND[j]?.[(i + k) % 12] === 'G'
377 px(g, 32 + i, 3 + j, isLand ? 11 : 15)
378 }
379 })
380 break
381 }
382 case 'agent': {
383 clawd(g, base({ x: 4, look: 1, legs: k % 4 < 2 ? 'walkA' : 'walkB' }))
384 const hop = k % 4 < 2 ? 0 : 1
385 stamp(g, 29, 7 + hop, k % 4 < 2 ? TINY : TINY_STEP)
386 if (k % 8 < 4) stamp(g, 33, 1, SPARKLE_A)
387 break
388 }
389 case 'wait': {
390 const isHop = k < 2
391 clawd(g, base({ y: isHop ? 1 : 2, eyes: k === 5 ? 'blink' : 'open', look: look ?? 0, armR: k % 4 < 2 ? 'up' : 'wave' }))
392 if (k % 4 !== 3) stamp(g, 35, 0, BANG)
393 break
394 }
395 case 'cheer': {
396 const isUp = k < 3
397 clawd(g, base({ y: isUp ? 0 : 2, eyes: 'happy', armL: 'up', armR: 'up', hasBlush: true, mouth: 'smile' }))
398 stamp(g, 2, 1, k % 2 === 0 ? SPARKLE_A : SPARKLE_B)
399 stamp(g, 35, 2, k % 2 === 1 ? SPARKLE_A : SPARKLE_B)
400 stamp(g, 4, 9, k % 2 === 1 ? SPARKLE_A : SPARKLE_B)
401 stamp(g, 33, 9, k % 2 === 0 ? SPARKLE_A : SPARKLE_B)
402 break
403 }
404 case 'panic': {
405 clawd(g, base({ x: k % 2 === 0 ? 10 : 12, eyes: 'wide', armL: k % 2 === 0 ? 'up' : 'wave', armR: k % 2 === 0 ? 'wave' : 'up', mouth: 'o' }))
406 if (k < 2) stamp(g, 2, 0, BANG)
407 else stamp(g, 36, 0, BANG)
408 stamp(g, k % 2 === 0 ? 4 : 34, 5 + (k % 3), DROP)
409 break
410 }
411 case 'oops': {
412 clawd(g, base({ eyes: k < 10 ? 'x' : 'blink', armL: 'down', armR: 'down', isSquashed: true }))
413 sweat(g, k, 31)
414 break
415 }
416 case 'sleep': {
417 clawd(g, base({ y: 3, eyes: 'closed', armL: 'down', armR: 'down', legs: 'squat', isSquashed: k < 16 }))
418 const rise = (phase: number): number => 8 - Math.floor(((k + phase) % 32) / 4)
419 const small = rise(0)
420 if (small >= 0) stamp(g, 31 + Math.floor((8 - small) / 3), small, Z_SMALL)
421 const big = rise(16)
422 if (big >= 0) stamp(g, 32 + Math.floor((8 - big) / 3), big, Z_BIG)
423 break
424 }
425 case 'itemget': {
426 clawd(g, base({ eyes: 'happy', armR: 'up', hasBlush: true }))
427 stamp(g, 32, k < 4 ? 0 : 1, CLIPBOARD)
428 stamp(g, 3, 1, k % 2 === 0 ? SPARKLE_A : SPARKLE_B)
429 if (k % 4 < 2) stamp(g, 5, 9, SPARKLE_B)
430 break
431 }
432 case 'love': {
433 clawd(g, base({ eyes: 'happy', hasBlush: true, mouth: 'smile', isSquashed: k % 6 < 3 }))
434 const climb = (phase: number): number => 9 - ((k + phase) % 12)
435 const right = climb(0)
436 if (right >= -3) stamp(g, 32, right, HEART)
437 const left = climb(6)
438 if (left >= -2) stamp(g, 5, left + 2, HEART_SMALL)
439 break
440 }
441 case 'ask': {
442 const isTap = k < 2
443 clawd(g, base({ eyes: 'wide', armR: 'up', mouth: 'o' }))
444 stamp(g, 31, isTap ? 0 : 1, SIGN)
445 rect(g, 34, isTap ? 8 : 9, 1, 3, 17)
446 if (isTap) stamp(g, 2, 1, SPARKLE_B)
447 break
448 }
449 case 'stamp': {
450 const isDown = k >= 4
451 clawd(g, base({ eyes: 'happy', armR: isDown ? 'out' : 'up', isSquashed: isDown }))
452 rect(g, 30, 11, 9, 2, 9)
453 rect(g, 33, isDown ? 6 : 1, 3, 2, 17)
454 rect(g, 32, isDown ? 8 : 3, 5, 2, 6)
455 if (isDown) rect(g, 33, 11, 3, 1, 6)
456 break
457 }
458 case 'mail': {
459 clawd(g, base({ eyes: 'happy', look: 1, armR: k < 3 ? 'up' : 'wave', hasBlush: true }))
460 if (k >= 2 && k < 14) stamp(g, 29 + Math.floor((k - 2) * 0.6), 8 - Math.floor((k - 2) * 0.7), PLANE)
461 break
462 }
463 case 'stretch': {
464 const isUp = k % 8 < 4
465 clawd(g, base({ y: isUp ? 1 : 2, eyes: 'closed', armL: 'up', armR: 'up', isSquashed: !isUp }))
466 if (isUp) {
467 stamp(g, 3, 2, SPARKLE_B)
468 stamp(g, 34, 2, SPARKLE_B)
469 }
470 break
471 }
472 case 'tidy': {
473 const isUp = k % 12 < 2
474 clawd(g, base({ y: isUp ? 1 : 2, eyes: 'happy', armL: 'up', armR: 'up' }))
475 stamp(g, 31, isUp ? 2 : 3, BOOKS)
476 break
477 }
478 }
479 return g
480}
481hooks/themes.ts 975 lines1// The scenes Clawd can live in: the house, the beach, the space station, the
2// forest camp and open space, where everything floats. Every one keeps the same five zones at the same places
3// (scene.ts's ROOMS and SPOT_X), the board and the calendar where the
4// pointer looks for them, and the screen and terminal the animations know,
5// so the Clawds behave the same anywhere; only what is around them changes.
6
7import type { RoomId, SceneProps, Theme } from '../types'
8import { C, digit, hash, px, rect, SH, stamp, SW, type Box, type Grid } from './pixels'
9
10export type Part = Box & { ticks: number }
11
12export type ThemeArt = {
13 /** Everything but the game-room games and the Clawds at tick `t`. */
14 draw: (g: Grid, t: number, s: SceneProps, options: { hasClouds: boolean; isPlain: boolean }) => void
15 /** The boxes that move, each with the ticks it loops over (all divide 48). */
16 parts: readonly Part[]
17 /** What lights up under the pointer, as SVG drawn over the scene. */
18 light: { box: Box; glow: string }
19 /** What says hi under the pointer. */
20 toy: { box: Box; hi: string }
21 /** The SVG's drifting clouds: the sky they cross, or none. */
22 sky: Box | null
23 /** How the room names are lettered. */
24 signs: { fill: string; stroke: string; y: number }
25 /**
26 * Claude's memory, in the library zone: how full the context is, from 0 to
27 * 10 tenths, as something the scene keeps there (books, data crystals).
28 * Every scene must show it, and show it changing; `box` is its hover.
29 */
30 memory: { box: Box; draw: (g: Grid, level: number) => void }
31 /**
32 * The rare sights: where a shooting star crosses the sky (the window,
33 * indoors), and the scene's own critter, drawn on a clear ground inside
34 * `box` and looping over `ticks`. Every scene must have both.
35 */
36 eggs: { star: Box; critter: { box: Box; ticks: number; draw: (g: Grid, t: number) => void } }
37 /**
38 * What gives off light, room by room, for the Desktop scene's lighting: a
39 * screen, a lamp or a rack's lights go dark with their room's switch; a
40 * window lets the day in; a fire burns whatever the switch says. Every
41 * scene must list its own.
42 */
43 glows: readonly Glow[]
44 /** Indoors, a room whose lights are off goes dim; outdoors only its lights go out. */
45 isIndoor: boolean
46 /** Whether daylight reaches in: through a window, or all around outdoors. */
47 hasDaylight: boolean
48}
49
50export type Glow = { room: RoomId; box: Box; color: string; kind: 'screen' | 'lamp' | 'leds' | 'window' | 'fire' }
51
52/** The two desk screens every scene has: the code lab's and the terminal room's. */
53const SCREENS: readonly Glow[] = [
54 { room: 'codelab', box: { x: 77, y: 10, w: 10, h: 6 }, color: '#BFE9FF', kind: 'screen' },
55 { room: 'terminal', box: { x: 99, y: 11, w: 10, h: 6 }, color: '#8FF0B4', kind: 'screen' },
56]
57
58const ARCADE_SCREEN: Glow = { room: 'game', box: { x: 178, y: 9, w: 8, h: 5 }, color: '#B8F5C8', kind: 'screen' }
59
60const ROOM_EDGES = [46, 94, 136, 174] as const
61
62// ── Furniture every scene shares ───────────────────────────────────────────
63
64/** The board the deadlines are pinned to: 16 × 9 at (48, 5). */
65function noticeBoard(g: Grid, s: SceneProps, frame: number, surface: number): void {
66 rect(g, 48, 5, 16, 9, frame)
67 rect(g, 49, 6, 14, 7, surface)
68 // A note for each deadline ahead, the colour of how near it is.
69 const colors = { far: C.green, near: C.yellow, urgent: C.pink, over: C.gray } as const
70 s.notes.slice(0, 8).forEach((note, i) => {
71 const x = 50 + (i % 4) * 3
72 const y = 7 + Math.floor(i / 4) * 3
73 rect(g, x, y, 2, 2, colors[note.urgency])
74 px(g, x, y, C.red)
75 })
76}
77
78/** The calendar counting down to the next deadline: 9 × 8 at (66, 4). */
79function calendar(g: Grid, t: number, s: SceneProps, style: { header: number; page: number | null; ink: number; off: number }): void {
80 const page = style.page ?? (s.urgency === 'urgent' ? C.pageUrgent : s.urgency === 'near' ? C.pageNear : C.white)
81 const isBlinkOff = s.urgency === 'urgent' && t % 4 >= 2
82 rect(g, 66, 4, 9, 2, isBlinkOff ? style.off : style.header)
83 rect(g, 66, 6, 9, 6, page)
84 if (s.days === null) {
85 rect(g, 67, 8, 3, 1, C.gray)
86 rect(g, 71, 8, 3, 1, C.gray)
87 return
88 }
89 const days = Math.max(0, Math.min(99, s.days))
90 digit(g, 67, 6, Math.floor(days / 10), style.ink)
91 digit(g, 71, 6, days % 10, style.ink)
92}
93
94/** The screen Claude's code scrolls on: 12 × 8 at (76, 9). */
95function monitor(g: Grid, t: number, frame: number, hasStand: boolean): void {
96 rect(g, 76, 9, 12, 8, frame)
97 rect(g, 77, 10, 10, 6, C.screen)
98 const lengths = [6, 3, 8, 5, 2, 7, 4, 9]
99 const palette = [C.code, C.white, C.purple, C.code]
100 for (let row = 0; row < 3; row++) {
101 const n = (row + Math.floor(t / 2)) % lengths.length
102 rect(g, 78 + (n % 2), 10 + row * 2, lengths[n] ?? 4, 1, palette[n % 4] ?? C.code)
103 }
104 if (hasStand) rect(g, 81, 17, 2, 1, frame)
105 else rect(g, 75, 17, 14, 1, C.light)
106}
107
108/** The desk the screen stands on. */
109function desk(g: Grid, top: number, leg: number): void {
110 rect(g, 70, 18, 21, 2, top)
111 rect(g, 71, 20, 1, 5, leg)
112 rect(g, 89, 20, 1, 5, leg)
113}
114
115/** The terminal commands run on, with its blinking cursor: 12 × 9 at (98, 10). */
116function terminalBox(g: Grid, t: number, casing: number): void {
117 rect(g, 95, 19, 19, 2, C.woodLight)
118 rect(g, 96, 21, 1, 4, C.wood)
119 rect(g, 112, 21, 1, 4, C.wood)
120 rect(g, 98, 10, 12, 9, casing)
121 rect(g, 99, 11, 10, 6, C.screen)
122 rect(g, 100, 12, 6, 1, C.code)
123 rect(g, 100, 14, 4, 1, C.code)
124 px(g, 100, 16, C.code)
125 if (t % 4 < 2) rect(g, 102, 16, 2, 1, C.code)
126}
127
128/** An arcade cabinet, its invader changing colour: 10 × 18 at (176, 7). */
129function arcadeCabinet(g: Grid, t: number, body: number, trim: number): void {
130 rect(g, 176, 7, 10, 18, body)
131 rect(g, 177, 9, 8, 6, C.dark)
132 const invader = ['.#..#.', '######', '#.##.#']
133 const hue = [C.code, C.pink, C.yellow, C.sky][Math.floor(t / 2) % 4] ?? C.code
134 invader.forEach((row, j) => {
135 for (let i = 0; i < row.length; i++) if (row[i] === '#') px(g, 178 + i, 10 + j + (t % 4 < 2 ? 0 : 1), hue)
136 })
137 rect(g, 176, 16, 10, 2, trim)
138 px(g, 178, 15, C.red)
139 px(g, 182, 16, C.yellow)
140 px(g, 184, 16, C.sky)
141}
142
143function bunting(g: Grid, from: number, to: number): void {
144 for (let x = from; x < to; x += 5) {
145 const color = [C.red, C.yellow, C.blue, C.green][((x - from) / 5) % 4] ?? C.red
146 rect(g, x, 4, 3, 1, color)
147 px(g, x + 1, 5, color)
148 }
149}
150
151const GLOBE = ['..###..', '.#####.', '#######', '#######', '.#####.', '..###..']
152
153/** A round thing whose surface turns: a globe, a planet, a beach ball. */
154function spinner(g: Grid, x: number, y: number, t: number, colors: (column: number, row: number) => number): void {
155 GLOBE.forEach((row, j) => {
156 for (let i = 0; i < row.length; i++) if (row[i] === '#') px(g, x + i, y + j, colors((i + Math.floor(t / 2)) % 8, j))
157 })
158}
159
160const STARS: readonly (readonly [number, number])[] = Array.from({ length: 24 }, (_, i) => [hash(i * 7 + 3) % SW, 1 + (hash(i * 13 + 5) % 11)] as const)
161
162/** The sky's three bands, top to bottom, by time of day. */
163function skyBands(s: SceneProps): readonly number[] {
164 if (s.time === 'night') return [C.nightTop, C.night, C.nightLow]
165 return s.time === 'dusk' ? [C.duskTop, C.duskMid, C.duskLow] : [C.skyTop, C.sky, C.skyLow]
166}
167
168/** An open sky by the person's time of day: bands, then the sun, or the moon and stars. */
169function outdoorSky(g: Grid, t: number, s: SceneProps, bottom: number, sunX: number): void {
170 const third = Math.ceil(bottom / 3)
171 skyBands(s).forEach((color, i) => rect(g, 0, i * third, SW, Math.min(third, bottom - i * third), color))
172 if (s.time === 'night') {
173 STARS.forEach(([x, y], i) => {
174 if (y < bottom - 2 && hash(i * 11 + (Math.floor(t / 3) % 8)) % 3 !== 0) px(g, x, y, C.white)
175 })
176 rect(g, sunX, 2, 3, 3, C.moon)
177 px(g, sunX, 2, C.nightTop)
178 px(g, sunX, 3, C.nightTop)
179 } else if (s.time === 'dusk') {
180 rect(g, sunX - 40, bottom - 3, 6, 3, C.sunset)
181 rect(g, sunX - 39, bottom - 4, 4, 1, C.sunset)
182 } else {
183 rect(g, sunX, 2, 4, 4, C.yellow)
184 }
185}
186
187/** Clouds drawn into the sky, for the terminal (the SVG moves its own). */
188function driftingClouds(g: Grid, t: number, s: SceneProps, rows: readonly number[]): void {
189 if (s.time === 'night') return
190 rows.forEach((y, i) => {
191 const x = ((Math.floor(t / 4) + i * 97) % 300) - 20
192 rect(g, x, y, 7, 1, C.white)
193 rect(g, x + 1, y - 1, 4, 1, C.white)
194 })
195}
196
197// ── Claude's memory, in every scene's library zone ────────────────────────
198
199/** The two racks of the house's library and the station's archive. */
200const RACKS = [3, 30] as const
201const SHELVES = [5, 10, 15, 20] as const
202const CRYSTAL_ROWS = [6, 11, 16, 21] as const
203const BOOK_COLORS = [C.red, C.blue, C.green, C.yellow, C.purple, C.beige]
204
205/** Books on the shelves, top left to bottom right, as far as `level` tenths go; near full, a pile on the floor. */
206function houseMemory(g: Grid, level: number): void {
207 let columns = level * 8
208 for (const sx of RACKS) {
209 for (const shelf of SHELVES) {
210 const top = shelf + 1
211 const height = shelf === 20 ? 3 : 4
212 const end = sx + 1 + Math.min(10, Math.max(0, columns))
213 columns -= 10
214 let bx = sx + 1
215 while (bx < end) {
216 const h = hash(bx * 31 + shelf)
217 const width = 1 + (h % 2)
218 const tall = height - ((h >> 3) % 2)
219 rect(g, bx, top + height - tall, Math.min(width, end - bx), tall, BOOK_COLORS[(h >> 5) % 6] ?? C.red)
220 bx += width + ((h >> 8) % 3 === 0 ? 1 : 0)
221 }
222 }
223 }
224 for (let i = 0; i < Math.max(0, level - 7) * 2; i++) rect(g, 42, 23 - i, 2, 1, BOOK_COLORS[i % 6] ?? C.red)
225}
226
227/** The archive's data crystals, lit one by one as `level` tenths go; the rest dark. */
228function spaceMemory(g: Grid, level: number): void {
229 const crystals = [C.cyan, C.magenta, C.yellow, C.teal]
230 const lit = level * 4
231 let n = 0
232 for (const sx of RACKS) {
233 CRYSTAL_ROWS.forEach((shelf, row) => {
234 for (let k = 0; k < 5; k++) {
235 const color = n < lit ? (crystals[(k + row + sx) % 4] ?? C.cyan) : C.panelLine
236 rect(g, sx + 2 + k * 2, shelf + 1 + (hash(sx + k + row) % 2), 1, 2, color)
237 n++
238 }
239 })
240 }
241}
242
243/** Outdoors: a stack of books on the ground at `x`, eight high when the context is full. */
244function bookStack(g: Grid, x: number, level: number): void {
245 const books = Math.round(level * 0.8)
246 for (let i = 0; i < books; i++) {
247 const shift = hash(i * 13 + x) % 2
248 const color = BOOK_COLORS[(i * 5 + 1) % 6] ?? C.red
249 rect(g, x + shift, 24 - i, 5, 1, color)
250 px(g, x + shift + 4, 24 - i, C.white)
251 }
252}
253
254// ── The rare sights ───────────────────────────────────────────────────────
255
256const WINDOW: Box = { x: 141, y: 6, w: 26, h: 9 }
257
258/** Draws ASCII art like `stamp`, flipped left to right when `isLeft`, and only inside `clip`. */
259function sprite(g: Grid, x: number, y: number, rows: readonly string[], key: Readonly<Record<string, number>>, isLeft = false, clip?: Box): void {
260 rows.forEach((row, j) => {
261 const line = isLeft ? [...row].reverse().join('') : row
262 for (let i = 0; i < line.length; i++) {
263 const c = key[line[i] ?? '.']
264 const px_ = x + i
265 const py = y + j
266 if (c === undefined) continue
267 if (clip !== undefined && (px_ < clip.x || px_ >= clip.x + clip.w || py < clip.y || py >= clip.y + clip.h)) continue
268 px(g, px_, py, c)
269 }
270 })
271}
272
273/** The house: a sparrow hopping along the window sill and back. */
274function sparrow(g: Grid, t: number): void {
275 const k = t % 48
276 const isLeft = k >= 24
277 const x = isLeft ? 160 - Math.floor(((k - 24) * 18) / 24) : 142 + Math.floor((k * 18) / 24)
278 const hop = k % 4 === 0 ? 1 : 0
279 sprite(g, x, 12 - hop, ['...b.', 'bbbbo', '.d.d.'], { b: C.woodLight, o: C.pumpkin, d: C.dark }, isLeft, WINDOW)
280}
281
282/** The beach: a whale's back far out at sea, and its spout. */
283function whale(g: Grid, t: number): void {
284 const k = t % 32
285 if (k < 4 || k >= 26) return
286 rect(g, 133, 15, 9, 1, C.steel)
287 rect(g, 135, 14, 5, 1, C.steel)
288 if (k >= 20) rect(g, 143, 13, 1, 2, C.steel)
289 if (k >= 8 && k < 20) {
290 const h = Math.min(4, k - 7)
291 rect(g, 137, 14 - h, 1, h, C.white)
292 px(g, 136, 13 - h, C.foam)
293 px(g, 138, 13 - h, C.foam)
294 }
295}
296
297/** The space station: a saucer gliding past the observatory window. */
298function saucer(g: Grid, t: number): void {
299 const k = t % 48
300 const x = 134 + Math.floor((k * 34) / 48)
301 const y = 8 + (Math.floor(k / 8) % 2)
302 const lights = k % 4 < 2 ? C.yellow : C.magenta
303 sprite(g, x, y, ['..ccc..', 'mmmmmmm', '.y.y.y.'], { c: C.cyan, m: C.metalLight, y: lights }, false, WINDOW)
304}
305
306/** The forest: a deer looking out from behind a bush, then gone again. */
307function deer(g: Grid, t: number): void {
308 const k = t % 48
309 const offset = k < 12 || k >= 36 ? 6 : k < 16 ? 16 - k + 1 : k >= 32 ? k - 31 : 0
310 if (offset < 6) {
311 const ears = k % 8 === 0 ? ['.a..a.', 'a....a'] : ['a....a', '.a..a.']
312 sprite(g, 142, 13 + offset, [...ears, '.hhhh.', 'hehheh', '.hhhh.', '..nn..'], { a: C.trunk, h: C.woodLight, e: C.eye, n: C.dark })
313 }
314 rect(g, 140, 18, 12, 1, C.pineDark)
315 rect(g, 139, 19, 14, 3, C.pine)
316 for (const x of [141, 145, 149]) px(g, x, 20, C.pineDark)
317}
318
319// ── The house ─────────────────────────────────────────────────────────────
320
321function house(g: Grid, t: number, s: SceneProps, o: { hasClouds: boolean; isPlain: boolean }): void {
322 rect(g, 0, 0, SW, 1, C.roofEdge)
323 rect(g, 0, 1, SW, 1, C.roof)
324 if (!o.isPlain) for (let x = 0; x < SW; x += 4) px(g, x + 1, 1, C.roofTile)
325 rect(g, 0, 2, SW, 1, C.beam)
326 const tints = [
327 [C.wall, C.wallLine, 2, 42],
328 [C.wallBlue, C.wallBlueLine, 46, 46],
329 [C.wallSlate, C.wallSlateLine, 94, 40],
330 [C.wallSky, C.wallSkyLine, 136, 36],
331 [C.wallLilac, C.wallLilacLine, 174, 80],
332 ] as const
333 for (const [wall, line, x, w] of tints) {
334 rect(g, x, 3, w, 15, wall)
335 if (!o.isPlain) for (let lx = x + 4; lx < x + w; lx += 12) rect(g, lx, 3, 1, 15, line)
336 }
337 rect(g, 0, 18, SW, 7, C.wains)
338 rect(g, 0, 18, SW, 1, C.wainsDark)
339 rect(g, 0, 25, SW, 1, C.floorHi)
340 rect(g, 0, 26, SW, 1, C.floor)
341 if (!o.isPlain) for (let x = 5; x < SW; x += 11) px(g, x, 26, C.floorDark)
342 rect(g, 0, 27, SW, 1, C.floorDark)
343 rect(g, 0, 3, 2, 22, C.wood)
344 rect(g, SW - 2, 3, 2, 22, C.wood)
345 for (const edge of ROOM_EDGES) {
346 rect(g, edge - 2, 3, 2, 10, C.wood)
347 rect(g, edge - 3, 12, 4, 1, C.beam)
348 }
349 // Library: the shelves (their books are houseMemory's)
350 for (const sx of RACKS) {
351 rect(g, sx, 5, 12, 20, C.wood)
352 for (const shelf of SHELVES) rect(g, sx + 1, shelf + 1, 10, shelf === 20 ? 3 : 4, C.woodLight)
353 }
354 rect(g, 18, 19, 11, 2, C.woodLight)
355 rect(g, 18, 21, 1, 4, C.wood)
356 rect(g, 28, 21, 1, 4, C.wood)
357 if (s.time === 'night') {
358 rect(g, 22, 14, 9, 1, C.pageNear)
359 rect(g, 21, 17, 11, 1, C.pageNear)
360 rect(g, 22, 18, 9, 1, C.pageNear)
361 }
362 rect(g, 25, 15, 3, 1, C.yellow)
363 rect(g, 24, 16, 5, 1, C.yellow)
364 rect(g, 26, 17, 1, 2, C.gray)
365 rect(g, 20, 18, 4, 1, C.white)
366 // Workshop
367 noticeBoard(g, s, C.wood, C.cork)
368 calendar(g, t, s, { header: C.red, page: null, ink: C.eye, off: C.wood })
369 desk(g, C.woodLight, C.wood)
370 monitor(g, t, C.dark, true)
371 rect(g, 47, 21, 4, 4, C.shade)
372 rect(g, 46, 18, 6, 3, C.green)
373 px(g, 48, 17, C.green)
374 // Server room
375 rect(g, 120, 6, 12, 19, C.steel)
376 for (let slot = 0; slot < 5; slot++) {
377 const y = 8 + slot * 3
378 rect(g, 121, y, 10, 2, C.dark)
379 for (let led = 0; led < 3; led++) {
380 const isOn = hash(slot * 7 + led + (Math.floor(t / 2) % 8) * 13) % 3 !== 0
381 px(g, 122 + led * 2, y, isOn ? ([C.code, C.yellow, C.red][led] ?? C.code) : C.gray)
382 }
383 }
384 terminalBox(g, t, C.beige)
385 // Lookout
386 rect(g, 140, 5, 28, 11, C.wood)
387 if (s.time === 'night') {
388 rect(g, 141, 6, 26, 9, C.night)
389 rect(g, 162, 7, 3, 3, C.moon)
390 px(g, 162, 7, C.night)
391 px(g, 162, 8, C.night)
392 STARS.slice(0, 6).forEach(([x, y], i) => {
393 if (hash(i * 11 + (Math.floor(t / 3) % 8)) % 3 !== 0) px(g, 141 + (x % 26), 6 + (y % 9), C.white)
394 })
395 } else if (s.time === 'dusk') {
396 rect(g, 141, 6, 26, 3, C.duskTop)
397 rect(g, 141, 9, 26, 3, C.duskMid)
398 rect(g, 141, 12, 26, 3, C.duskLow)
399 rect(g, 161, 11, 4, 2, C.sunset)
400 rect(g, 162, 10, 2, 1, C.sunset)
401 } else {
402 rect(g, 141, 6, 26, 9, C.sky)
403 rect(g, 163, 7, 3, 3, C.yellow)
404 }
405 if (o.hasClouds && s.time !== 'night') {
406 for (const [phase, y] of [
407 [0, 8],
408 [13, 11],
409 ] as const) {
410 const cx = 141 + ((Math.floor(t / 3) + phase) % 30) - 4
411 for (let i = 0; i < 6; i++) if (cx + i > 140 && cx + i < 167) px(g, cx + i, y, C.white)
412 for (let i = 1; i < 4; i++) if (cx + i > 140 && cx + i < 167) px(g, cx + i, y - 1, C.white)
413 }
414 }
415 rect(g, 153, 6, 1, 9, C.wood)
416 rect(g, 141, 10, 26, 1, C.wood)
417 spinner(g, 160, 16, t, (column, row) => ((hash(column * 5 + row) & 3) === 0 ? C.green : C.blue))
418 rect(g, 163, 22, 1, 2, C.gray)
419 rect(g, 161, 24, 5, 1, C.wood)
420 // Game room
421 arcadeCabinet(g, t, C.arcade, C.steel)
422 bunting(g, 189, 252)
423}
424
425// ── The beach ─────────────────────────────────────────────────────────────
426
427function beach(g: Grid, t: number, s: SceneProps, o: { hasClouds: boolean; isPlain: boolean }): void {
428 outdoorSky(g, t, s, 15, 226)
429 if (o.hasClouds) driftingClouds(g, t, s, [4, 7, 10])
430 // The lighthouse on its rock, out at sea
431 rect(g, 155, 13, 14, 2, C.rock)
432 rect(g, 157, 12, 9, 1, C.rock)
433 for (let y = 5; y < 12; y++) rect(g, 159, y, 4, 1, Math.floor((y - 5) / 2) % 2 === 0 ? C.white : C.red)
434 rect(g, 158, 3, 6, 2, s.time === 'night' && t % 8 >= 4 ? C.ember : C.yellow)
435 rect(g, 158, 2, 6, 1, C.red)
436 rect(g, 160, 1, 2, 1, C.red)
437 // The sea, its waves, the shore
438 const sea = s.time === 'night' ? C.seaNight : s.time === 'dusk' ? C.seaDusk : C.sea
439 rect(g, 0, 15, SW, 5, sea)
440 rect(g, 0, 15, SW, 1, s.time === 'night' ? C.nightLow : C.seaDeep)
441 for (let i = 0; i < 8; i++) rect(g, i * 32 + (Math.floor(t / 2) % 8) * 4, 16 + (i % 2) * 2, 3, 1, C.foam)
442 rect(g, 0, 20, SW, 8, C.sand)
443 rect(g, 0, 20, SW, 1, C.sandWet)
444 for (let x = 0; x < SW; x++) if (hash(Math.floor(x / 5)) % 3 === 0) px(g, x, 20, C.foam)
445 if (!o.isPlain) for (let i = 0; i < 70; i++) px(g, hash(i * 3 + 1) % SW, 22 + (hash(i * 5 + 2) % 6), C.sandDot)
446 // The umbrella, the deck chair, a bucket and spade
447 rect(g, 21, 6, 1, 19, C.palm)
448 for (const [y, from, to] of [
449 [4, 15, 27],
450 [5, 13, 29],
451 [6, 12, 30],
452 ] as const) {
453 for (let x = from; x <= to; x++) px(g, x, y, Math.floor((x - 12) / 3) % 2 === 0 ? C.red : C.white)
454 }
455 for (let x = 12; x <= 30; x++) if ((x - 12) % 3 !== 2) px(g, x, 7, Math.floor((x - 12) / 3) % 2 === 0 ? C.red : C.white)
456 rect(g, 27, 21, 9, 1, C.blue)
457 rect(g, 34, 16, 2, 5, C.blue)
458 rect(g, 34, 17, 2, 1, C.white)
459 rect(g, 34, 19, 2, 1, C.white)
460 rect(g, 28, 22, 1, 3, C.gray)
461 rect(g, 35, 22, 1, 3, C.gray)
462 rect(g, 5, 21, 4, 3, C.red)
463 rect(g, 5, 20, 4, 1, C.yellow)
464 rect(g, 10, 19, 1, 5, C.gray)
465 // The beach desk: the board on posts, the calendar on its own post, a laptop on the cooler
466 rect(g, 49, 14, 1, 11, C.wood)
467 rect(g, 62, 14, 1, 11, C.wood)
468 noticeBoard(g, s, C.wood, C.cork)
469 rect(g, 70, 12, 1, 13, C.wood)
470 calendar(g, t, s, { header: C.red, page: null, ink: C.eye, off: C.wood })
471 rect(g, 70, 19, 21, 6, C.blue)
472 rect(g, 70, 19, 21, 1, C.white)
473 rect(g, 78, 21, 5, 1, C.white)
474 monitor(g, t, C.dark, false)
475 // The lifeguard tower and its radio
476 terminalBox(g, t, C.beige)
477 rect(g, 116, 9, 17, 2, C.woodLight)
478 rect(g, 117, 11, 1, 14, C.wood)
479 rect(g, 131, 11, 1, 14, C.wood)
480 for (let i = 0; i < 13; i++) {
481 px(g, 118 + i, 12 + i, C.wood)
482 px(g, 130 - i, 12 + i, C.wood)
483 }
484 rect(g, 121, 5, 6, 3, C.red)
485 rect(g, 121, 8, 7, 1, C.red)
486 rect(g, 132, 1, 1, 8, C.gray)
487 if (t % 4 < 2) rect(g, 126, 1, 6, 3, C.red)
488 else rect(g, 127, 2, 5, 3, C.red)
489 px(g, 129, 2, C.white)
490 // A telescope on its tripod, and a beach ball
491 for (let i = 0; i < 9; i++) {
492 px(g, 146 - Math.floor(i / 2), 16 + i, C.gray)
493 px(g, 146 + Math.floor(i / 2), 16 + i, C.gray)
494 px(g, 146, 16 + i, C.gray)
495 }
496 rect(g, 142, 15, 3, 1, C.light)
497 rect(g, 144, 14, 3, 1, C.light)
498 rect(g, 146, 13, 3, 1, C.light)
499 rect(g, 148, 12, 2, 1, C.light)
500 spinner(g, 160, 18, t, column => [C.red, C.white, C.blue, C.yellow][column >> 1] ?? C.white)
501 // The sandcastle, its flag, the palm
502 rect(g, 175, 20, 12, 5, C.sandWet)
503 rect(g, 176, 15, 3, 5, C.sandWet)
504 rect(g, 183, 15, 3, 5, C.sandWet)
505 rect(g, 179, 13, 4, 7, C.sandDot)
506 for (const [x, y] of [
507 [176, 14],
508 [178, 14],
509 [183, 14],
510 [185, 14],
511 [179, 12],
512 [182, 12],
513 ] as const)
514 px(g, x, y, C.sandWet)
515 rect(g, 180, 22, 2, 3, C.tentDoor)
516 rect(g, 181, 8, 1, 4, C.wood)
517 rect(g, 182, t % 4 < 2 ? 8 : 9, 3, 2, C.red)
518 for (let i = 0; i < 19; i++) rect(g, 251 - Math.floor(i / 6), 24 - i, 2, 1, C.palm)
519 stamp(g, 240, 1, ['....LLLL..LLL...', '..LLLLDLLLDLLLL.', '.LLDD.LLLL.DDLLL', 'LD.....LL.....DL', '.......D........'], { L: C.leaf, D: C.leafDark })
520 bunting(g, 189, 240)
521}
522
523// ── The space station ─────────────────────────────────────────────────────
524
525function space(g: Grid, t: number, s: SceneProps, o: { hasClouds: boolean; isPlain: boolean }): void {
526 rect(g, 0, 0, SW, 1, C.hullLine)
527 rect(g, 0, 1, SW, 1, C.hull)
528 if (!o.isPlain) for (let x = 3; x < SW; x += 8) px(g, x, 1, C.metalLight)
529 rect(g, 0, 2, SW, 1, C.hullLine)
530 rect(g, 0, 3, SW, 22, C.panel)
531 if (!o.isPlain) {
532 for (let x = 8; x < SW; x += 16) rect(g, x, 3, 1, 22, C.panelLine)
533 rect(g, 0, 13, SW, 1, C.panelLine)
534 }
535 ;[
536 [2, 42, C.cyan],
537 [46, 46, C.magenta],
538 [94, 40, C.yellow],
539 [136, 36, C.teal],
540 [174, 80, C.purple],
541 ].forEach(([x, w, color]) => rect(g, x ?? 0, 3, w ?? 0, 1, color ?? C.cyan))
542 rect(g, 0, 25, SW, 1, C.metalLight)
543 rect(g, 0, 26, SW, 1, C.metal)
544 if (!o.isPlain) for (let x = 2; x < SW; x += 4) px(g, x, 26, C.hull)
545 rect(g, 0, 27, SW, 1, C.hull)
546 rect(g, 0, 3, 2, 22, C.metal)
547 rect(g, SW - 2, 3, 2, 22, C.metal)
548 for (const edge of ROOM_EDGES) {
549 rect(g, edge - 2, 3, 2, 10, C.metal)
550 rect(g, edge - 3, 12, 4, 1, C.metalLight)
551 }
552 // Archive pod: racks of data crystals, a desk, a holo-lamp
553 for (const sx of RACKS) {
554 rect(g, sx, 5, 12, 20, C.metal)
555 CRYSTAL_ROWS.forEach((shelf, row) => rect(g, sx + 1, shelf, 10, row === 3 ? 3 : 4, C.space))
556 }
557 rect(g, 18, 19, 11, 1, C.metalLight)
558 rect(g, 23, 20, 1, 5, C.metal)
559 rect(g, 25, 15, 3, 1, C.cyan)
560 rect(g, 24, 16, 5, 1, C.teal)
561 rect(g, 26, 17, 1, 2, C.metal)
562 // Lab: a whiteboard, a digital calendar, a console
563 noticeBoard(g, s, C.metalLight, C.board)
564 calendar(g, t, s, { header: C.magenta, page: C.space, ink: C.cyan, off: C.metal })
565 desk(g, C.metal, C.metalLight)
566 monitor(g, t, C.metal, true)
567 rect(g, 47, 21, 4, 4, C.metalLight)
568 rect(g, 46, 18, 6, 3, C.green)
569 px(g, 48, 17, C.green)
570 // Reactor: a glass column of moving light
571 rect(g, 119, 4, 14, 21, C.metal)
572 rect(g, 121, 5, 10, 17, C.glass)
573 for (let y = 5; y < 22; y++) {
574 const band = (y + Math.floor(t / 2)) % 4
575 if (band === 0) rect(g, 122, y, 8, 1, C.core)
576 else if (band === 1) rect(g, 123, y, 6, 1, C.cyan)
577 }
578 rect(g, 119, 22, 14, 3, C.metalLight)
579 terminalBox(g, t, C.metalLight)
580 // Observatory: a planet turning beyond the glass
581 rect(g, 140, 5, 28, 11, C.metalLight)
582 rect(g, 141, 6, 26, 9, C.space)
583 STARS.slice(0, 9).forEach(([x, y], i) => {
584 if (hash(i * 11 + (Math.floor(t / 3) % 8)) % 3 !== 0) px(g, 141 + (x % 26), 6 + (y % 9), C.white)
585 })
586 spinner(g, 155, 7, t, (column, row) => ((hash(column * 5 + row) & 3) === 0 ? C.green : C.blue))
587 rect(g, 161, 19, 6, 6, C.metalLight)
588 rect(g, 162, 16, 4, 3, C.green)
589 // Rec deck: a holo-arcade, a strip of lights
590 arcadeCabinet(g, t, C.teal, C.metal)
591 for (let x = 189; x < 252; x += 3) px(g, x, 4, (x / 3 + Math.floor(t / 2)) % 4 === 0 ? C.cyan : C.magenta)
592}
593
594// ── The forest camp ───────────────────────────────────────────────────────
595
596function pine(g: Grid, x: number, top: number, bottom: number, color: number, s: SceneProps): void {
597 for (let y = top; y < bottom; y++) {
598 const half = Math.floor((y - top) / 2)
599 rect(g, x - half, y, half * 2 + 1, 1, color)
600 if (s.season === 'spring' && y > top + 1 && hash(x * 31 + y) % 4 === 0) px(g, x - half + (hash(y + x) % (half * 2 + 1)), y, C.blossom)
601 }
602}
603
604/** The woods' leaves by season: green, autumn's reds and golds, winter's darker green. */
605function leaves(s: SceneProps, i: number): number {
606 if (s.season === 'autumn') return [C.autumnRed, C.autumnOrange, C.autumnYellow][i % 3] ?? C.autumnOrange
607 if (s.season === 'winter') return i % 2 === 0 ? C.pineDark : C.pine
608 return i % 2 === 0 ? C.pine : C.pineDark
609}
610
611function forest(g: Grid, t: number, s: SceneProps, o: { hasClouds: boolean; isPlain: boolean }): void {
612 outdoorSky(g, t, s, 14, 214)
613 if (o.hasClouds) driftingClouds(g, t, s, [3, 6])
614 const hills = s.time === 'night' ? C.mountainNight : s.time === 'dusk' ? C.mountainDusk : C.mountain
615 for (const [cx, top] of [
616 [36, 6],
617 [118, 5],
618 [196, 7],
619 ] as const) {
620 for (let y = top; y < 15; y++) rect(g, cx - (y - top) * 3, y, (y - top) * 6 + 1, 1, hills)
621 }
622 for (let x = 3; x < SW; x += 9) pine(g, x, 8 + (hash(x) % 3), 17, leaves(s, Math.floor(x / 9)), s)
623 const grass = s.time === 'night' ? C.grassNight : C.grass
624 rect(g, 0, 17, SW, 5, grass)
625 rect(g, 0, 22, SW, 6, C.path)
626 rect(g, 0, 22, SW, 1, C.pathDark)
627 if (!o.isPlain) for (let i = 0; i < 50; i++) px(g, hash(i * 7 + 4) % SW, 17 + (hash(i * 3 + 9) % 5), C.grassDark)
628 // The tent and its lantern
629 for (let y = 8; y < 25; y++) {
630 const half = y - 8
631 rect(g, 21 - half, y, half + 1, 1, C.tent)
632 rect(g, 22, y, half, 1, C.tentDark)
633 }
634 for (let y = 14; y < 25; y++) {
635 const half = Math.floor((y - 14) * 0.6)
636 rect(g, 21 - half, y, half * 2 + 1, 1, C.tentDoor)
637 }
638 rect(g, 33, 9, 1, 4, C.trunk)
639 rect(g, 32, 13, 3, 1, C.dark)
640 rect(g, 32, 14, 3, 3, s.time === 'night' && t % 8 < 4 ? C.flame : C.ember)
641 rect(g, 32, 17, 3, 1, C.dark)
642 // The log desk: the board on a post, the calendar nailed to a tree
643 rect(g, 55, 14, 2, 11, C.trunk)
644 noticeBoard(g, s, C.trunk, C.cork)
645 rect(g, 68, 0, 5, 25, C.trunk)
646 rect(g, 60, 0, 21, 3, leaves(s, 0))
647 rect(g, 63, 3, 15, 1, leaves(s, 1))
648 calendar(g, t, s, { header: C.red, page: null, ink: C.eye, off: C.trunk })
649 rect(g, 70, 18, 21, 2, C.woodLight)
650 rect(g, 71, 20, 3, 5, C.trunk)
651 rect(g, 87, 20, 3, 5, C.trunk)
652 monitor(g, t, C.dark, false)
653 // The radio hut, its antenna, the lights in its window
654 terminalBox(g, t, C.radio)
655 rect(g, 116, 8, 17, 17, C.wood)
656 for (let y = 10; y < 25; y += 3) rect(g, 116, y, 17, 1, C.woodLight)
657 for (let i = 0; i < 5; i++) rect(g, 114 + i, 7 - i, 21 - i * 2, 1, C.pineDark)
658 rect(g, 120, 11, 11, 6, C.dark)
659 for (const row of [12, 14]) {
660 for (let led = 0; led < 5; led++) {
661 const isOn = hash(row * 7 + led + (Math.floor(t / 2) % 8) * 13) % 3 !== 0
662 px(g, 122 + led * 2, row, isOn ? ([C.code, C.yellow, C.red][led % 3] ?? C.code) : C.gray)
663 }
664 }
665 rect(g, 130, 0, 1, 3, C.gray)
666 px(g, 130, 0, t % 8 < 4 ? C.red : C.gray)
667 // The treehouse and its ladder, mushrooms at the roots
668 rect(g, 152, 0, 20, 3, leaves(s, 2))
669 rect(g, 160, 3, 4, 22, C.trunk)
670 rect(g, 146, 9, 25, 2, C.woodLight)
671 rect(g, 148, 4, 10, 5, C.wood)
672 rect(g, 147, 3, 12, 1, C.pineDark)
673 px(g, 152, 6, s.time === 'night' ? C.ember : C.yellow)
674 rect(g, 156, 11, 1, 14, C.woodLight)
675 rect(g, 158, 11, 1, 14, C.woodLight)
676 for (let y = 12; y < 25; y += 3) rect(g, 156, y, 3, 1, C.woodLight)
677 rect(g, 165, 21, 3, 1, C.red)
678 rect(g, 166, 22, 1, 2, C.white)
679 rect(g, 168, 22, 2, 1, C.red)
680 px(g, 168, 23, C.white)
681 // The campfire, string lights, fireflies, and the woods' edge
682 for (let x = 175; x < 188; x += 2) px(g, x, 24, C.gray)
683 rect(g, 177, 23, 9, 1, C.trunk)
684 rect(g, 178, 22, 7, 1, C.trunk)
685 const k = t % 8
686 for (let y = 15; y < 22; y++) {
687 const half = Math.max(0, Math.floor((y - 15 - (k % 2)) / 2))
688 const sway = k < 4 ? 0 : 1
689 rect(g, 181 - half + (y < 18 ? sway : 0), y, half * 2 + 1, 1, C.flameHot)
690 if (half > 0) rect(g, 182 - half + (y < 18 ? sway : 0), y, half * 2 - 1, 1, C.flame)
691 if (half > 1 && y > 18) rect(g, 183 - half, y, half * 2 - 3, 1, C.ember)
692 }
693 for (let x = 189; x < 246; x += 4) {
694 rect(g, x, 3, 4, 1, C.trunk)
695 px(g, x + 2, 4, s.time === 'night' && (Math.floor((x - 189) / 4) + Math.floor(t / 2)) % 3 === 0 ? C.flame : C.ember)
696 }
697 if (s.time === 'night') {
698 for (let i = 0; i < 7; i++) if (hash(i * 5 + (Math.floor(t / 3) % 8)) % 3 === 0) px(g, 190 + (hash(i) % 55), 6 + (hash(i * 9) % 9), C.ember)
699 }
700 pine(g, 250, 1, 25, leaves(s, 1), s)
701}
702
703// ── Open space ────────────────────────────────────────────────────────────
704
705/**
706 * A rock adrift: bumps along its lit top, craters in its face, an underside
707 * that tapers into shadow, and a few pebbles floating loose off its ends.
708 */
709function asteroid(g: Grid, x: number, w: number, y: number): void {
710 for (let i = 2; i < w - 3; i++) {
711 const bump = hash(x * 31 + i * 7) % 11
712 if (bump < 2) rect(g, x + i, y - 1, bump + 2, 1, C.stoneLight)
713 }
714 rect(g, x + 1, y, w - 2, 1, C.stoneLight)
715 rect(g, x, y + 1, w, 1, C.stone)
716 rect(g, x + 2, y + 2, w - 4, 1, C.stoneDark)
717 for (let i = 4; i < w - 5; i += 8 + (hash(x + i) % 5)) {
718 rect(g, x + i, y + 1, 2, 1, C.stoneDark)
719 px(g, x + i, y, C.stone)
720 }
721 for (let i = 5; i < w - 6; i += 11) px(g, x + i, y + 2, C.stone)
722 px(g, x - 2, y - 2, C.stone)
723 px(g, x + w + 1, y - 1, C.stoneDark)
724 px(g, x + w + 2, y - 3, C.stone)
725}
726
727/** Where the drifting library's books float, the first `level` of them out: one more a tenth of the context. */
728const FLOATING_BOOKS: readonly (readonly [number, number])[] = [
729 [6, 18], [13, 15], [9, 11], [20, 19], [17, 8], [27, 13], [33, 18], [31, 7], [38, 12], [24, 5],
730]
731
732function cosmosMemory(g: Grid, level: number): void {
733 FLOATING_BOOKS.slice(0, level).forEach(([x, y], i) => {
734 const color = BOOK_COLORS[i % BOOK_COLORS.length] ?? C.red
735 rect(g, x, y, 4, 2, color)
736 px(g, x, y, C.white)
737 rect(g, x + 1, y + 2, 3, 1, C.stoneDark)
738 })
739}
740
741/** An astronaut drifting across the far dark, feet first, the visor catching the light. */
742function astronaut(g: Grid, t: number): void {
743 const x = 98 + Math.floor(t * 0.8)
744 const y = 2 + (Math.floor(t / 8) % 2)
745 sprite(g, x, y, ['.ss.', 'svvs', 'ssss', '.ss.', 's..s'], { s: C.suit, v: C.visor })
746}
747
748function cosmos(g: Grid, t: number, s: SceneProps, o: { hasClouds: boolean; isPlain: boolean }): void {
749 // The deep and its nebulae are the SVG's own, soft as no pixel can be (scene-svg's backdrop);
750 // drawn plain, the dark is filled here.
751 if (o.isPlain) rect(g, 0, 0, SW, SH, C.deep)
752 // Stars, more than any sky on Earth; the SVG makes some twinkle.
753 for (let i = 0; i < 70; i++) {
754 const x = hash(i * 7 + 11) % SW
755 const y = hash(i * 13 + 3) % 24
756 px(g, x, y, i % 9 === 0 ? C.sky : i % 5 === 0 ? C.light : C.white)
757 }
758 // A ringed planet far off, past the telescope.
759 for (let y = -5; y <= 5; y++) {
760 for (let x = -5; x <= 5; x++) {
761 if (x * x + y * y <= 25) px(g, 152 + x, 10 + y, x + y > 2 ? C.planetDark : C.planet)
762 }
763 }
764 rect(g, 143, 10, 19, 1, C.ring)
765 rect(g, 145, 11, 4, 1, C.ring)
766 rect(g, 156, 9, 4, 1, C.ring)
767 // The rocks everything stands on, one under each zone, gaps of stars between.
768 asteroid(g, 1, 43, 25)
769 asteroid(g, 46, 46, 25)
770 asteroid(g, 94, 40, 25)
771 asteroid(g, 137, 34, 25)
772 asteroid(g, 175, 79, 25)
773 // Drifting library: the books float free (the memory); a lantern drifts by them.
774 rect(g, 25, 14, 3, 1, C.stoneLight)
775 rect(g, 24, 15, 5, 2, C.ember)
776 rect(g, 25, 17, 3, 1, C.stoneLight)
777 // Orbital desk: the board, the calendar and the screen, on their rock.
778 noticeBoard(g, s, C.stoneLight, C.board)
779 calendar(g, t, s, { header: C.magenta, page: C.space, ink: C.cyan, off: C.stoneDark })
780 desk(g, C.stoneLight, C.stone)
781 monitor(g, t, C.metal, true)
782 // Satellite: the terminal, and a probe with its solar wings and a blinking light.
783 terminalBox(g, t, C.metalLight)
784 rect(g, 123, 8, 6, 7, C.metalLight)
785 rect(g, 124, 9, 4, 5, C.metal)
786 rect(g, 116, 9, 6, 4, C.solar)
787 rect(g, 130, 9, 6, 4, C.solar)
788 for (const x of [118, 120, 132, 134]) rect(g, x, 9, 1, 4, C.solarLine)
789 rect(g, 122, 10, 1, 2, C.metal)
790 rect(g, 129, 10, 1, 2, C.metal)
791 px(g, 125, 7, C.light)
792 if (t % 4 < 2) px(g, 126, 6, C.red)
793 // Telescope: on its rock, looking at the planet.
794 rect(g, 160, 14, 8, 2, C.light)
795 rect(g, 159, 13, 2, 2, C.metalLight)
796 rect(g, 163, 16, 1, 6, C.metal)
797 rect(g, 161, 22, 5, 1, C.metal)
798 rect(g, 160, 23, 1, 2, C.metal)
799 rect(g, 166, 23, 1, 2, C.metal)
800 if (t % 8 < 4) px(g, 167, 13, C.red)
801 // Zero-g court: the arcade, a string of beacons.
802 arcadeCabinet(g, t, C.purple, C.metal)
803 for (let x = 189; x < 252; x += 3) px(g, x, 4, (x / 3 + Math.floor(t / 2)) % 4 === 0 ? C.cyan : C.yellow)
804}
805
806// ── The roster ────────────────────────────────────────────────────────────
807
808const ARCADE_HI =
809 '<g class="on"><rect x="177" y="9" width="8" height="6" fill="#2E3138"/><path fill="#F5C542" d="M178 10h1v4h-1zM180 10h1v4h-1zM179 12h1v1h-1zM182 10h1v4h-1z"/><path fill="#E5484D" d="M184 10h1v3h-1zM184 14h1v1h-1z"/></g>'
810
811const LAMP_GLOW = (color: string): string =>
812 `<path class="on" fill="${color}" fill-opacity="0.85" d="M22 14h9v1h-9zM21 17h11v1h-11zM22 18h9v1h-9zM20 15h4v1h-4zM29 15h4v1h-4z"/>`
813
814/** The parts every scene moves; the games' own motion is a layer of its own (scene-svg.ts). */
815const COMMON_PARTS: readonly Part[] = [
816 { x: 77, y: 10, w: 10, h: 6, ticks: 16 },
817 { x: 100, y: 16, w: 4, h: 1, ticks: 4 },
818 { x: 66, y: 4, w: 9, h: 2, ticks: 4 },
819]
820
821export const THEMES: Record<Theme, ThemeArt> = {
822 house: {
823 draw: house,
824 parts: [
825 ...COMMON_PARTS,
826 { x: 121, y: 8, w: 10, h: 14, ticks: 16 },
827 { x: 141, y: 6, w: 26, h: 9, ticks: 24 },
828 { x: 160, y: 16, w: 7, h: 6, ticks: 16 },
829 { x: 177, y: 9, w: 8, h: 7, ticks: 8 },
830 ],
831 light: { box: { x: 23, y: 14, w: 7, h: 5 }, glow: LAMP_GLOW('#FCE7A6') },
832 toy: { box: { x: 176, y: 7, w: 10, h: 18 }, hi: ARCADE_HI },
833 sky: { x: 141, y: 6, w: 26, h: 9 },
834 signs: { fill: '#F3E3C3', stroke: 'none', y: 2.35 },
835 memory: { box: { x: 3, y: 5, w: 41, h: 20 }, draw: houseMemory },
836 eggs: { star: WINDOW, critter: { box: { x: 141, y: 10, w: 26, h: 5 }, ticks: 48, draw: sparrow } },
837 glows: [
838 { room: 'library', box: { x: 24, y: 14, w: 6, h: 3 }, color: '#FFE6A0', kind: 'lamp' },
839 ...SCREENS,
840 { room: 'terminal', box: { x: 121, y: 7, w: 7, h: 15 }, color: '#8FF0B4', kind: 'leds' },
841 { room: 'web', box: WINDOW, color: '#E6F4FF', kind: 'window' },
842 ARCADE_SCREEN,
843 ],
844 isIndoor: true,
845 hasDaylight: true,
846 },
847 beach: {
848 draw: beach,
849 parts: [
850 ...COMMON_PARTS,
851 { x: 0, y: 0, w: SW, h: 13, ticks: 24 },
852 { x: 0, y: 16, w: SW, h: 3, ticks: 16 },
853 { x: 125, y: 1, w: 8, h: 4, ticks: 4 },
854 { x: 158, y: 3, w: 6, h: 2, ticks: 8 },
855 { x: 160, y: 18, w: 7, h: 6, ticks: 16 },
856 { x: 182, y: 8, w: 3, h: 3, ticks: 4 },
857 ],
858 light: {
859 box: { x: 155, y: 1, w: 14, h: 15 },
860 glow: '<path class="on" fill="#FFF6C8" fill-opacity="0.5" d="M158 3L128 -2L128 9Z"/><path class="on" fill="#FFF6C8" fill-opacity="0.5" d="M164 3L194 -2L194 9Z"/>',
861 },
862 toy: {
863 box: { x: 175, y: 8, w: 12, h: 17 },
864 hi: '<g class="on"><path fill="#E5484D" d="M187 23h5v1h-5zM187 22h1v1h-1zM191 22h1v1h-1zM188 24h1v1h-1zM190 24h1v1h-1z"/><path fill="#2A1A15" d="M188 21h1v1h-1zM190 21h1v1h-1z"/></g>',
865 },
866 sky: { x: 0, y: 0, w: SW, h: 14 },
867 signs: { fill: '#FFFFFF', stroke: '#2A1A15', y: 2.6 },
868 memory: { box: { x: 37, y: 16, w: 7, h: 9 }, draw: (g, level) => bookStack(g, 37, level) },
869 eggs: { star: { x: 0, y: 0, w: SW, h: 13 }, critter: { box: { x: 131, y: 8, w: 16, h: 9 }, ticks: 32, draw: whale } },
870 glows: [...SCREENS, { room: 'web', box: { x: 158, y: 2, w: 6, h: 3 }, color: '#FFF1B8', kind: 'lamp' }],
871 isIndoor: false,
872 hasDaylight: true,
873 },
874 space: {
875 draw: space,
876 parts: [
877 ...COMMON_PARTS,
878 { x: 121, y: 5, w: 10, h: 17, ticks: 8 },
879 { x: 141, y: 6, w: 26, h: 9, ticks: 24 },
880 { x: 177, y: 9, w: 8, h: 7, ticks: 8 },
881 { x: 186, y: 4, w: 66, h: 1, ticks: 8 },
882 ],
883 light: { box: { x: 23, y: 14, w: 7, h: 5 }, glow: LAMP_GLOW('#5EE6F0') },
884 toy: { box: { x: 176, y: 7, w: 10, h: 18 }, hi: ARCADE_HI },
885 sky: null,
886 signs: { fill: '#BFF8FF', stroke: 'none', y: 2.35 },
887 memory: { box: { x: 3, y: 5, w: 39, h: 20 }, draw: spaceMemory },
888 eggs: { star: WINDOW, critter: { box: WINDOW, ticks: 48, draw: saucer } },
889 glows: [
890 { room: 'library', box: { x: 3, y: 5, w: 11, h: 19 }, color: '#5EE6F0', kind: 'leds' },
891 { room: 'library', box: { x: 24, y: 15, w: 5, h: 2 }, color: '#5EE6F0', kind: 'lamp' },
892 ...SCREENS,
893 { room: 'terminal', box: { x: 121, y: 5, w: 9, h: 17 }, color: '#7FEFFF', kind: 'leds' },
894 { room: 'web', box: WINDOW, color: '#9FB8FF', kind: 'window' },
895 ARCADE_SCREEN,
896 ],
897 isIndoor: true,
898 hasDaylight: false,
899 },
900 forest: {
901 draw: forest,
902 parts: [
903 ...COMMON_PARTS,
904 { x: 0, y: 0, w: SW, h: 12, ticks: 24 },
905 { x: 32, y: 14, w: 3, h: 3, ticks: 8 },
906 { x: 122, y: 12, w: 9, h: 3, ticks: 16 },
907 { x: 130, y: 0, w: 1, h: 1, ticks: 8 },
908 { x: 175, y: 14, w: 13, h: 8, ticks: 8 },
909 { x: 189, y: 4, w: 57, h: 1, ticks: 6 },
910 { x: 189, y: 6, w: 57, h: 9, ticks: 24 },
911 ],
912 light: {
913 box: { x: 30, y: 9, w: 7, h: 10 },
914 glow: '<path class="on" fill="#FFE08A" fill-opacity="0.6" d="M30 13h7v5h-7zM29 14h1v3h-1zM37 14h1v3h-1z"/>',
915 },
916 toy: {
917 box: { x: 175, y: 12, w: 13, h: 13 },
918 hi: '<path class="on" fill="#FFE08A" d="M178 12h1v1h-1zM181 9h1v1h-1zM184 12h1v1h-1zM180 7h1v1h-1zM183 6h1v1h-1z"/>',
919 },
920 sky: { x: 0, y: 0, w: SW, h: 13 },
921 signs: { fill: '#FFFFFF', stroke: '#2A1A15', y: 2.6 },
922 memory: { box: { x: 38, y: 16, w: 7, h: 9 }, draw: (g, level) => bookStack(g, 38, level) },
923 eggs: { star: { x: 0, y: 0, w: SW, h: 12 }, critter: { box: { x: 138, y: 12, w: 16, h: 10 }, ticks: 48, draw: deer } },
924 glows: [
925 { room: 'library', box: { x: 31, y: 13, w: 5, h: 5 }, color: '#FFE08A', kind: 'lamp' },
926 ...SCREENS,
927 { room: 'terminal', box: { x: 122, y: 12, w: 6, h: 2 }, color: '#F5C542', kind: 'leds' },
928 { room: 'web', box: { x: 151, y: 6, w: 3, h: 2 }, color: '#FFE08A', kind: 'lamp' },
929 { room: 'game', box: { x: 179, y: 14, w: 9, h: 8 }, color: '#F2A65A', kind: 'fire' },
930 { room: 'game', box: { x: 190, y: 3, w: 56, h: 5 }, color: '#F5C542', kind: 'leds' },
931 ],
932 isIndoor: false,
933 hasDaylight: true,
934 },
935 cosmos: {
936 draw: cosmos,
937 parts: [
938 ...COMMON_PARTS,
939 { x: 126, y: 6, w: 1, h: 1, ticks: 4 },
940 { x: 167, y: 13, w: 1, h: 1, ticks: 8 },
941 { x: 177, y: 9, w: 8, h: 7, ticks: 8 },
942 { x: 189, y: 4, w: 63, h: 1, ticks: 8 },
943 ],
944 light: { box: { x: 23, y: 14, w: 7, h: 5 }, glow: LAMP_GLOW('#FFE6A0') },
945 toy: { box: { x: 176, y: 7, w: 10, h: 18 }, hi: ARCADE_HI },
946 sky: null,
947 signs: { fill: '#E6E0FF', stroke: 'none', y: 2.35 },
948 memory: { box: { x: 5, y: 4, w: 38, h: 18 }, draw: cosmosMemory },
949 eggs: { star: { x: 0, y: 0, w: SW, h: 22 }, critter: { box: { x: 96, y: 1, w: 44, h: 8 }, ticks: 48, draw: astronaut } },
950 glows: [
951 { room: 'library', box: { x: 24, y: 15, w: 5, h: 2 }, color: '#FFE6A0', kind: 'lamp' },
952 ...SCREENS,
953 { room: 'terminal', box: { x: 124, y: 9, w: 4, h: 5 }, color: '#7FA8E8', kind: 'leds' },
954 { room: 'web', box: { x: 167, y: 13, w: 1, h: 1 }, color: '#E5484D', kind: 'lamp' },
955 ARCADE_SCREEN,
956 { room: 'game', box: { x: 189, y: 4, w: 63, h: 1 }, color: '#5EE6F0', kind: 'leds' },
957 ],
958 // Open space has no daylight and no walls: lights out, it is dark all round.
959 isIndoor: false,
960 hasDaylight: false,
961 },
962}
963
964export const THEME_ORDER: readonly Theme[] = ['house', 'beach', 'space', 'forest', 'cosmos']
965
966/** Memory's level when the context is not known yet: a session just begun has read a little. */
967export const MEMORY_UNKNOWN = 2
968
969/** A scene at tick `t` and, in its library zone, Claude's memory: every surface draws through this. */
970export function drawScene(g: Grid, t: number, s: SceneProps, options: { hasClouds: boolean; isPlain: boolean }): void {
971 const art = THEMES[s.theme]
972 art.draw(g, t, s, options)
973 art.memory.draw(g, Math.max(0, Math.min(10, s.memory ?? MEMORY_UNKNOWN)))
974}
975hooks/svg.ts 52 lines1// Clawd for the surfaces that draw SVG (desktop, VS Code, mobile): every
2// frame of a pose as one group, shown in turn by SMIL, so he moves with no
3// redraw at all. Hovering him swaps in the petting loop, by CSS alone.
4
5import { CYCLE, frame, H, PALETTE, TICK_MS, W, type FrameOptions, type Grid, type Pose } from './sprite'
6
7/** One path per colour, each pixel run a `M x y h w v 1 h -w z`. */
8function paths(grid: Grid): string {
9 const runs = new Map<number, string>()
10 for (let y = 0; y < H; y++) {
11 let x = 0
12 while (x < W) {
13 const c = grid[y * W + x]!
14 let end = x + 1
15 while (end < W && grid[y * W + end] === c) end++
16 if (c !== 0) runs.set(c, `${runs.get(c) ?? ''}M${x} ${y}h${end - x}v1h-${end - x}z`)
17 x = end
18 }
19 }
20 return [...runs].map(([c, d]) => `<path fill="${PALETTE[c]}" d="${d}"/>`).join('')
21}
22
23/** The frames of `pose` as groups that take turns. */
24function flipbook(pose: Pose, options: FrameOptions): string {
25 const ticks = CYCLE[pose]
26 const frames: string[] = []
27 for (let t = 0; t < ticks; t++) frames.push(paths(frame(pose, t, options)))
28 const unique = [...new Set(frames)]
29 if (unique.length === 1) return `<g>${unique[0]}</g>`
30 const dur = ((ticks * TICK_MS) / 1000).toFixed(3)
31 return unique
32 .map(markup => {
33 const values = frames.map(f => (f === markup ? 'visible' : 'hidden')).join(';')
34 return `<g visibility="hidden"><animate attributeName="visibility" values="${values}" dur="${dur}s" calcMode="discrete" repeatCount="indefinite"/>${markup}</g>`
35 })
36 .join('')
37}
38
39/** Clawd in `pose` as groups to set inside another SVG, on a 40 × 14 grid of his own. */
40export const clawdArt = (pose: Pose, options: FrameOptions = {}): string => flipbook(pose, options)
41
42/** An animated SVG of Clawd in `pose`, `scale` CSS pixels a pixel. */
43export function clawdSvg(pose: Pose, options: FrameOptions = {}, scale = 4): string {
44 const main = flipbook(pose, options)
45 const pet = pose === 'love' ? '' : `<g class="pet">${flipbook('love', {})}</g>`
46 const style = pet === '' ? '' : '<style>.pet{display:none}svg:hover .pet{display:inline}svg:hover .main{display:none}</style>'
47 return (
48 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W * scale}" height="${H * scale}" shape-rendering="crispEdges">` +
49 `${style}<g class="main">${main}</g>${pet}</svg>`
50 )
51}
52hooks/changes.ts 107 lines1// What this session changed: every file Claude edited or wrote, with the
2// lines it added and removed, and the commands it ran and how they ended.
3// Counted from the tools' own patches and inputs; nothing is read from disk.
4
5import type { Changes, CommandRun, FileChange } from '../types'
6
7export type { Changes, CommandRun, FileChange }
8
9export const NO_CHANGES: Changes = { files: [], commands: [], turn: 0 }
10
11/** The most commands the list keeps, newest first. */
12const COMMANDS_KEPT = 40
13
14const str = (value: unknown): string => (typeof value === 'string' ? value : '')
15
16/** Lines added and removed in a tool's `structuredPatch`, if it has one. */
17export function patchCounts(patch: unknown): { added: number; removed: number } | undefined {
18 if (!Array.isArray(patch)) return undefined
19 let added = 0
20 let removed = 0
21 for (const hunk of patch as { lines?: unknown }[]) {
22 for (const line of Array.isArray(hunk.lines) ? (hunk.lines as unknown[]) : []) {
23 if (typeof line !== 'string') continue
24 if (line.startsWith('+')) added++
25 else if (line.startsWith('-')) removed++
26 }
27 }
28 return { added, removed }
29}
30
31/** Lines in `after` that `before` lacks, and the other way: a rough diff when no patch came back. */
32function lineDiff(before: string, after: string): { added: number; removed: number } {
33 const count = (text: string): Map<string, number> => {
34 const lines = new Map<string, number>()
35 for (const line of text === '' ? [] : text.split('\n')) lines.set(line, (lines.get(line) ?? 0) + 1)
36 return lines
37 }
38 const was = count(before)
39 const now = count(after)
40 let added = 0
41 let removed = 0
42 for (const [line, n] of now) added += Math.max(0, n - (was.get(line) ?? 0))
43 for (const [line, n] of was) removed += Math.max(0, n - (now.get(line) ?? 0))
44 return { added, removed }
45}
46
47export const EDITING = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
48
49/** The change an editing tool made: from its result's patch, else from its input. */
50export function fileChangeOf(tool: string, input: Record<string, unknown>, result: unknown): { path: string; added: number; removed: number; isNew: boolean } | undefined {
51 if (!EDITING.has(tool)) return undefined
52 const record = (typeof result === 'object' && result !== null ? result : {}) as Record<string, unknown>
53 const path = str(record.filePath) || str(input.file_path) || str(input.notebook_path)
54 if (path === '') return undefined
55 const isNew = record.type === 'create'
56 const patched = patchCounts(record.structuredPatch)
57 if (patched !== undefined && (patched.added > 0 || patched.removed > 0)) return { path, ...patched, isNew }
58 if (tool === 'Write') return { path, added: str(input.content).split('\n').length, removed: 0, isNew }
59 if (tool === 'Edit') return { path, ...lineDiff(str(input.old_string), str(input.new_string)), isNew }
60 if (tool === 'MultiEdit' && Array.isArray(input.edits)) {
61 let added = 0
62 let removed = 0
63 for (const one of input.edits as Record<string, unknown>[]) {
64 const d = lineDiff(str(one.old_string), str(one.new_string))
65 added += d.added
66 removed += d.removed
67 }
68 return { path, added, removed, isNew }
69 }
70 return { path, added: 0, removed: 0, isNew }
71}
72
73/** `changes` with one more edit to a file, the file brought to the top. */
74export function recordFile(changes: Changes, change: { path: string; added: number; removed: number; isNew: boolean }, at: number): Changes {
75 const was = changes.files.find(f => f.path === change.path)
76 const file: FileChange = {
77 path: change.path,
78 edits: (was?.edits ?? 0) + 1,
79 added: (was?.added ?? 0) + change.added,
80 removed: (was?.removed ?? 0) + change.removed,
81 isNew: (was?.isNew ?? false) || change.isNew,
82 turn: changes.turn,
83 at,
84 }
85 return { ...changes, files: [file, ...changes.files.filter(f => f.path !== change.path)] }
86}
87
88export function recordCommand(changes: Changes, run: Omit<CommandRun, 'turn'>): Changes {
89 return { ...changes, commands: [{ ...run, turn: changes.turn }, ...changes.commands].slice(0, COMMANDS_KEPT) }
90}
91
92/** `path` from the project's root when it is inside it. */
93export function relativePath(path: string, root: string): string {
94 const base = root.endsWith('/') ? root : `${root}/`
95 return path.startsWith(base) ? path.slice(base.length) : path
96}
97
98export function totals(changes: Changes): { files: number; added: number; removed: number; commands: number; failed: number } {
99 return {
100 files: changes.files.length,
101 added: changes.files.reduce((sum, f) => sum + f.added, 0),
102 removed: changes.files.reduce((sum, f) => sum + f.removed, 0),
103 commands: changes.commands.length,
104 failed: changes.commands.filter(c => !c.isOk).length,
105 }
106}
107hooks/events.ts 36 lines1// What a finished command means for Clawd: a test run that passed or failed,
2// a commit, a push, a pull request opened or merged. Claude Code classifies
3// git and gh itself (Bash's `gitOperation`); test runs are recognized by the
4// command. Nothing here draws: it names the pose and the line.
5
6import type { Pose } from '../types'
7import { say, type Lang } from './i18n'
8
9/** The part of Bash's result that names git and gh operations. */
10export type GitOperation = {
11 commit?: { sha: string }
12 push?: { branch: string }
13 pr?: { number: number; action: string }
14}
15
16export type Moment = { pose: Pose; label: string; ms: number }
17
18/** The usual ways to run a test suite, in most languages' tooling. */
19const TEST_RUN =
20 /(^|[\s;&|(])((npm|pnpm|yarn|bun)( run)? test\b|npx (vitest|jest|mocha|playwright test)\b|(vitest|jest|pytest|mocha|rspec|phpunit|ctest|tox|nox)\b|go test\b|cargo (test|nextest)\b|(mvn|gradle|\.\/gradlew)\b[^|;&]*\btest\b|dotnet test\b|mix test\b|deno test\b|swift test\b|make (test|check)\b|python3? -m (pytest|unittest)\b|claude plugin test\b)/
21
22export const isTestRun = (command: string): boolean => TEST_RUN.test(command)
23
24/** The moment a Bash command makes, if it makes one: what Claude Code says it did first, then the test run. */
25export function momentOf(command: string, isFailed: boolean, git: GitOperation | undefined, lang: Lang): Moment | undefined {
26 const words = say(lang)
27 if (!isFailed && git !== undefined) {
28 if (git.pr?.action === 'merged') return { pose: 'cheer', label: words.prMerged(git.pr.number), ms: 4000 }
29 if (git.pr?.action === 'created') return { pose: 'mail', label: words.prOpened(git.pr.number), ms: 3500 }
30 if (git.push !== undefined) return { pose: 'mail', label: words.pushed(git.push.branch), ms: 3500 }
31 if (git.commit !== undefined) return { pose: 'stamp', label: words.committed(git.commit.sha.slice(0, 7)), ms: 3000 }
32 }
33 if (isTestRun(command)) return isFailed ? { pose: 'oops', label: words.testsFailed, ms: 3500 } : { pose: 'cheer', label: words.testsPassed, ms: 3500 }
34 return undefined
35}
36hooks/git.ts 44 lines1// The git safety net: the project's branch, how far it is from its
2// upstream, and what hasn't been committed, read from `git status` on this
3// machine. Nothing here runs git; register.tsx does, and hands the text in.
4
5import type { GitState } from '../types'
6
7export type { GitState }
8
9/** `git status --porcelain=v1 -b`, read: the branch line, then a line a file. */
10export function parseStatus(text: string): Omit<GitState, 'lastCommitAt'> | undefined {
11 const lines = text.split('\n').filter(line => line !== '')
12 const head = lines[0]
13 if (head === undefined || !head.startsWith('## ')) return undefined
14 const info = head.slice(3)
15 let branch = info.split('...')[0]?.split(' ')[0] ?? ''
16 const fresh = info.match(/^No commits yet on (\S+)/)
17 if (fresh !== null) branch = fresh[1] ?? branch
18 if (info.startsWith('HEAD (no branch)')) branch = 'HEAD'
19 const ahead = Number(info.match(/ahead (\d+)/)?.[1] ?? 0)
20 const behind = Number(info.match(/behind (\d+)/)?.[1] ?? 0)
21 const files = lines.slice(1)
22 const untracked = files.filter(line => line.startsWith('??')).length
23 return { branch, ahead, behind, changed: files.length - untracked, untracked }
24}
25
26export const isDirty = (git: GitState): boolean => git.changed + git.untracked > 0
27
28/** An hour's work not committed: since the tree got dirty, or since the last commit, whichever is later. */
29export const COMMIT_AFTER = 60 * 60_000
30
31export function needsCommit(git: GitState, dirtySince: number, now: number): boolean {
32 if (!isDirty(git) || dirtySince === 0) return false
33 return now - Math.max(dirtySince, git.lastCommitAt) >= COMMIT_AFTER
34}
35
36/** How worried the band's git figure looks: fine, a while uncommitted, or long or large. */
37export function gitColor(git: GitState, dirtySince: number, now: number): string | undefined {
38 if (!isDirty(git) || dirtySince === 0) return undefined
39 const age = now - Math.max(dirtySince, git.lastCommitAt)
40 if (age >= 2 * COMMIT_AFTER || git.changed + git.untracked >= 20) return '#E5484D'
41 if (age >= COMMIT_AFTER / 2) return '#F5C542'
42 return undefined
43}
44