SLOPSHOPPER

Clawd Sidekick

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…

newpanebandspinnerrowsguard
v0.18.0MITupdated 2026-10-06Tunai-0511/clawd-sidekick
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-sidekick
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ clawd-sidekick │ ⏺ Read(src/auth.ts) │ Clawd Sidekick lives in the Claude desktop │ ⎿ Read 6 lines │ app: open it there to see this. │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ╭────────────────────────────────────────────╮ ⏺ Bash(bun test) │ clawd-sidekick │ ⎿ 3 pass, 1 fail │ No deadlines yet. Usage: /deadline add │ │ 12/24 Report, /deadline add 2027-03-01 │ ● Done. refresh now rejects expired claims and logs an audit event. │ 09:00 Launch, /deadline add tomorrow Pay │ ╰────────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM › /clawd ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Clawd Sidekick

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.

繁體中文說明

Clawd's house: Clawd walks to the workshop to edit a file, a blue-capped Clawd runs to the lookout for a subagent, two others play volleyball

What you get

The house. It's a cross-section of five rooms, inspired by ClawLibrary's pixel archive for OpenClaw:

RoomClawd goes there when Claude…
Libraryreads or searches files
Workshopedits files or thinks; your deadlines hang on the board and the calendar
Server roomruns a command
Lookoutsearches or fetches the web (the window follows your local time: day, dusk, starry night)
Game roomis 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:

  • House: library, workshop, server room, lookout, game room
  • Beach: umbrella, beach desk, lifeguard tower, lighthouse view, beach court with a sandcastle
  • Space station: archive pod, lab, reactor, observatory, rec deck
  • Forest camp: tent, log desk, radio hut, treehouse, campfire
  • Cosmos: open space, where the Clawds float and bob instead of standing: a drifting library whose books float free, an orbital desk, a satellite, a telescope on its rock, a zero-g court, under soft nebulae with far stars sliding by

The five scenes: open space, the house, the beach at dusk, the forest camp, the space station at night

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

  • play volleyball while Claude works
  • take turns at ping-pong + arcade, jump rope and a block tower while it waits (a new game every 90 s)
  • sleep under blankets from 11 pm to 7 am
  • lend a player to every subagent: one puts the game down, walks to the room of the subagent's tool calls with a speech bubble, and comes back when it's done. The others switch to a game that fits who's left.

The game room: ping-pong and arcade, jump rope at dusk, a block tower, everyone asleep at night

The scene shows what's really happening. You can read the session from the scene alone, in every scene:

  • Claude's memory is the library. As the context fills, books fill the house's shelves (and pile up on the floor), the station's data crystals light up one by one, and the book pile grows on the beach and at the camp. When the conversation is compacted, Clawd carries an armful of books off and the shelves empty out.
  • Test runs: when npm test, pytest, go test, cargo test or the like passes, Clawd cheers; when it fails, he sweats.
  • Git: a commit gets a red stamp, and a push or a new pull request goes off as a sealed envelope. A merged pull request gets a cheer. Claude Code tells the mod what git and gh did, so nothing is guessed from the output.
  • Waiting for your permission: Clawd holds a big "?" sign up to the glass.
  • Late in the five-hour window (past 80%), every Clawd gets heavy eyelids and yawns now and then.

The moments in four scenes: Clawd holds up a "?" sign for your OK in the house, stamps a commit at the camp, sends a push off as an envelope on the beach, and carries the station's data off while compacting; the shelves and crystals show how full the context is

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.

The crew's picnic lunch and afternoon tea, the whale, the UFO and the deer, and a birthday with party hats and confetti

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.

The medals under the bunting, a crowned Clawd and his cat, in all four scenes

TrophyForBronzeSilverGoldLegendary
StreakDays in a row with work3 d7 d + party hat30 d + crown100 d + cat
Days at workDays with work, in all10 d50 d200 d365 d + halo
CommitsCommits11001,000 + golden stamp5,000
PushesPushes150500 + golden seal2,000
Tests passedTest runs passed11001,000 + mortarboard10,000
All greenTest runs passed in a row, with no failure between525100500
Tool callsTool calls1,00010,000100,000 + headphones1,000,000
Files editedFiles edited1001,00010,00050,000
Tidy-upsTimes the conversation was compacted11050 + wizard hat200
Subagents sentSubagents sent out101001,000 + captain's cap5,000
Night owlTurns begun between midnight and 5 a.m.125100 + owl500
Early birdTurns begun between 5 and 7 a.m.125100 + flower300
Marathon dayMost work in a single day4 h8 h12 h16 h
Long haulLongest single turn10 min30 min60 min180 min
Busiest dayMost tool calls in one day3001,0003,00010,000
PetsTimes you petted Clawd101001,000 + little crab10,000
PRs mergedPull requests merged125100500
GlobetrotterScenes lived in—4 + explorer's hat——
Holiday shiftHolidays worked through (Lunar New Year, Halloween, Christmas)123—
Rare sightsRare sights seen (a shooting star, each scene’s visitor)135—
ComebackPassing a test run after three or more failures in a row11050—

Seasons and holidays. The scenes follow the calendar wherever you are, with no network:

  • Seasons, from your local date: blossoms and drifting petals in spring, a red-and-gold forest and falling leaves in autumn, scarves on every Clawd in winter. South of the equator the seasons turn over: your system time zone (Australia/Sydney, America/Sao_Paulo) tells Clawd which side you're on.
  • Holidays: lanterns for Lunar New Year, pumpkins and a witch's hat for Halloween, a tree and a Santa hat for Christmas.

Spring blossoms in the forest camp, its autumn reds and golds, Halloween pumpkins on the beach at dusk, Christmas night in the house with scarves and a Santa hat

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.

Clawd's day: 3 h 12 min with Claude, 14 turns, 148 tool calls, 5 of 6 tests passed, 3 commits, the time by room, the week, and a 4-day streak

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.

Install

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.

Get updates automatically

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.

Commands

CommandWhat it does
/clawdOpen the Clawd Sidekick pane: big Clawd, all your deadlines, Today and Trophies
/clawd changesEvery file Claude changed this session (+/− lines) and every command it ran (✓/✗)
/clawd recapToday's card: time worked, the figures, the rooms, the week and your streak
/clawd summaryThis session's card: time worked, replies, files changed, commands, commits, cost
/clawd lights off · onPut every light out, or back on (off library and the like switch one room)
/clawd trophiesEvery trophy family: your tier, your progress, what the next tier brings
/clawd hat <name> · auto · nonePut on a hat you've earned (auto: the finest)
/clawd pal <name> · auto · noneChoose the pal who walks the floor
/clawd name <name> · name 1 <name> · name resetName the main Clawd or a crew member (1–3)
/clawd cap 1 redGive a crew member (1–3) a beanie: blue, green, purple, red, yellow, teal or pink
/clawd birthday 10/31 · offParty hats and confetti on your birthday
/clawd scene house · beach · space · forest · nextMove the Clawds to another scene
/clawd season winter · autoSet the season by hand, or follow the date
/clawd holiday christmas · lunar · halloween · none · autoSet the decorations by hand, or follow the date
/clawd hide · /clawd showFold the band to one line, or unfold it
/clawd lang en · zh · autoSwitch language (auto follows the system)
/deadlineList deadlines; also add 12/24 Name, add 2027-03-01 09:00 Name, add tomorrow Name, rm N

Settings

These are in /config, under the plugin:

SettingDefault
languageautoauto, en or zh-TW

What it does on your machine

A mod runs with your permissions, so here is everything this one reaches (claude plugin validate . lists the same):

  • Model calls and tokens: none. Clawd never calls a model, never adds to the system prompt or to a tool's result, and its commands answer in a toast rather than in the conversation, so nothing it says is read by the model. Running a /clawd or /deadline command leaves only its own one-line record, as any slash command does.
  • Processes: 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.
  • Storage: your deadlines, scene, language, pet count, the names, caps and birthday you set, each project's scene, each day's figures for the recap, the running totals for the trophies, which rooms' lights are off, which plan-limit warnings were given (by window), each project's last session summary (shown once, then removed), and each open session's word to its neighbors (its project and what it's doing, removed when it ends), in the plugin's own store on your machine. Data that features since removed left there is deleted when a session starts.
  • Environment: reads LANG, LC_ALL, LC_MESSAGES and TZ.
  • Network: none.

How it works

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

Develop

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.

Adding a scene

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.

Credits

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.

Source 19 files
hooks/register.tsx 2022 lines
1// 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 lines
1// 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}
109
hooks/i18n.ts 947 lines
1// 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}
947
hooks/scene.ts 1070 lines
1// 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
1070
hooks/scene-svg.ts 639 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
639
hooks/light.ts 48 lines
1// 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}
48
hooks/sprite.ts 481 lines
1// 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}
481
hooks/themes.ts 975 lines
1// 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}
975
hooks/svg.ts 52 lines
1// 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}
52
hooks/changes.ts 107 lines
1// 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}
107
hooks/events.ts 36 lines
1// 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}
36
hooks/git.ts 44 lines
1// 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