A cozy pixel house for your Claude Code agents: the lead, subagents and background tasks from every session as a Clawd household in an autumn garden, with…

<img src=".github/media/vibehaus-day.gif" width="100%" alt="Clawds working, napping and chatting in a cozy isometric pixel house in an autumn garden">
A cozy pixel house for your Claude Code agents. Your lead agent, subagents and background tasks live in an isometric house in an autumn garden as Clawds: they work at desks in the room for each kind of job, carry a box out to the mailbox when they finish, and nap, read, watch TV or goof about when idle.
Work in progress. Built on Claude Code's function hooks, which are early access and may change.
<img src=".github/media/vibehaus-tall.gif" alt="The house at night in a tall docked pane, centred in an autumn garden of copses, ponds and flower beds" width="220" align="right">
--.Your background tasks themselves are listed in Claude Code's own background tasks view; in Vibehaus they live in the house as Clawds.
<img src=".github/media/vibehaus-terminal.gif" alt="The house in a terminal: a floor plan of six rooms with their furniture, Clawds working and walking, the yard by the front door" width="300" align="right">
A terminal can't show the picture, so there the house is drawn as a floor plan from above, in the terminal's own character cells, and stays crisp at any size.
/plugin install vibehaus --marketplace rmguney/vibehaus
Needs Claude Code 2.1.295 or later, in the terminal or the desktop Code tab. Tested on 2.1.299 and 2.1.301.
Where an organization manages Claude Code, its classic hooks run only for plugins the organization allows. Without them there is no amber tag, no sync with the background tasks list and no session titles.
/haus opens it anywhere./haus demo moves a made-up household in for five minutes; run it again to send them home./haus snapshot saves the house as vibehaus-snapshot.svg, an animated SVG, in the folder Claude Code runs in.vibehaus-snapshot.svg in the folder Claude Code runs in, over the last one, only when you run /haus snapshot. It edits no build, start-up, settings or instructions file. It shows what this session's agents are doing as the house does, commands included, so look it over before you share it. Besides that only Claude Code's local plugin store, which every session on this machine reads, whatever its project. It holds the Host's token counts, the newest five-hour and weekly reading, when each box shipped, and each session's household: the session's title (its folder's name until it has one), and for each member its id, agent type, role, task name, room and status, when it started, last worked and finished and the times of its last few tool calls, whether its last call failed, the tool in use (with the file's name for file tools), and the Host's context use. Never a command, pattern, address or query./haus command and answers only that one./haus command, pane and band.session.start adds /haus and clears stale entries from the store; session.end takes the session's household out of the store.command.run answers /haus alone. Every other command, and any made by other code, goes by untouched.session.measure reads the five-hour and weekly windows from Claude Code's measurements.tool.call puts each agent in the room for its tool and picks up the background tasks it starts. The call and its result go on as they were.agent.spawn, turn.start, turn.complete and turn.step move agents in and out and count each one's tokens. The agent, turn and request go on as they were.session.append reads background tasks' end notices from the session's rows, and keeps every row as it is.Stop and SubagentStop sync the house with Claude Code's own list of background work.SessionStart and UserPromptSubmit give the session's title; after a /clear or a resume, SessionStart sets the house up again.Notification of a permission prompt raises the amber tag over the agent that asks. The plugin hooks no permission check: the prompt and your decision are untouched.ui.render draws its own pane, and the band above the prompt, which it leaves to Claude Code's surveys whenever one is showing.vibehaus/hooks/.claude --plugin-dir vibehaus
node tests/run.mjs
claude plugin validate --strict .
claude plugin validate --strict vibehaus
npx -p typescript tsc -p vibehaus
npx -p typescript tsc -p tests
The tests sit in tests/, outside the plugin, so they are not installed with it; tests/run.mjs runs them in a copy of the plugin. The type checks use the types Claude Code writes into vibehaus/.claude-plugin/types/ when it loads the plugin, so run claude --plugin-dir vibehaus once first.
MIT, see LICENSE. Clawd, the Claude Code mascot, and the Claude and Claude Code names are Anthropic's and not covered by the license. Vibehaus is an unofficial fan project, not affiliated with or endorsed by Anthropic.
hooks/register.tsx 1804 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderElement, SessionRateLimit } from 'claude-code'
3
4import type { Resident, ResidentStatus, PlanUse, Room, Track } from '../types'
5import { cardSvg } from './cards'
6import { ALL_ROOMS } from './house'
7import { cellAt, gardenRaster, packCells, planCells, planOf, planSize, RASTER_ROWS, reachOf } from './plan'
8import type { Plan, Walker } from './plan'
9import type { HouseGrid, Meter } from './scene'
10import {
11 clip, colorFor, displayName, errandEnds, gardenPicture, houseGrid, housePicture, houseTracks, meterColor, onePicture, own, paintSessions,
12 reanchor, spotOf, svg, SVG_LIMIT, taskTypeOf,
13} from './scene'
14import { visit } from './visitors'
15import type { Household } from './visitors'
16
17const PANE = 'vibehaus'
18const MAIN = 'main'
19const KEEP_DONE = 4
20const MAX_TEXT = 160
21const MAX_CALLS = 8
22
23const residents = atom({ plugin: 'vibehaus', key: 'residents' } as const, [])
24const plan = atom({ plugin: 'vibehaus', key: 'plan' } as const, {})
25const minute = atom({ plugin: 'vibehaus', key: 'minute' } as const, 0)
26// The pending redraw, so pictures and hover zones catch up.
27let nextDraw: { cancel: () => void } | undefined
28
29function redrawSoon($: EngineInterface, ms: number): void {
30 nextDraw?.cancel()
31 nextDraw = undefined
32 try {
33 nextDraw = $.clock.after(ms, () => {
34 try {
35 $.ui.invalidate('ui.render')
36 } catch {
37 // the pane or this plugin load is gone
38 }
39 })
40 } catch {
41 // no timer: the next change redraws instead
42 }
43}
44
45// The terminal house, played into a Raster frame by frame (see plan.ts); repeat frames are skipped.
46const RASTER = 'vh-house'
47const FRAME_MS = 200
48const INLINE_ROWS = 22
49let played: { plan: Plan; walkers: Walker[] } | undefined
50let shownCells: Uint32Array | undefined
51let frames: { cancel: () => void } | undefined
52let isPainting = false
53
54const sameCells = (a: Uint32Array, b: Uint32Array | undefined) => b !== undefined && a.length === b.length && a.every((v, i) => v === b[i])
55
56function playHouse($: EngineInterface): void {
57 if (frames) return
58 try {
59 frames = $.clock.every(FRAME_MS, () => void paintFrame($))
60 } catch {
61 // no timer: the house moves with each redraw instead
62 }
63}
64
65async function paintFrame($: EngineInterface): Promise<void> {
66 const house = played
67 if (!house || isPainting) return
68 isPainting = true
69 try {
70 const now = await $.clock.now()
71 const cells = planCells(house.plan, house.walkers, now)
72 if (played !== house || sameCells(cells, shownCells)) return
73 const done = await $.ui.blit({ requestId: PANE, key: RASTER, cells: packCells(cells) })
74 // a redraw replaced the plan meanwhile
75 if (played !== house) return
76 // not shown or resized: the next redraw restarts it
77 if (done.deny) stopHouse()
78 else shownCells = cells
79 } catch {
80 stopHouse()
81 } finally {
82 isPainting = false
83 }
84}
85
86function stopHouse(): void {
87 frames?.cancel()
88 frames = undefined
89 played = undefined
90 shownCells = undefined
91}
92
93// A permission prompt's asker waits: the agent it names (any agent asking is named), else the lead.
94async function askFor($: EngineInterface, agentId: string | undefined, message: string): Promise<void> {
95 const tool = /permission to use ([^\s.,!]+)/i.exec(message)?.[1]
96 await move($, agentId ?? MAIN, c => (c.status === 'working' ? { status: 'waiting', waitingTool: tool ?? c.tool } : {}))
97}
98
99const LEAD: Resident = {
100 id: MAIN, name: 'Lead', kind: 'main', room: 'study',
101 movedAt: 0, status: 'idle', activity: 'Waiting for you',
102}
103
104// BashOutput, KillShell, MultiEdit and NotebookRead are older builds' tools, kept for them.
105function roomFor(tool: string): Room {
106 if (/^(Read|Grep|Glob|LSP|NotebookRead|ListMcpResourcesTool|ReadMcpResourceTool)$/.test(tool)) return 'library'
107 if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(tool)) return 'workshop'
108 if (/^(Bash|PowerShell|Monitor|BashOutput|KillShell|TaskStop)$/.test(tool)) return 'kitchen'
109 if (/^(WebFetch|WebSearch)$/.test(tool) || tool.startsWith('mcp__')) return 'sunroom'
110 return 'study'
111}
112
113function roomForKind(kind: string): Room {
114 return kind === 'Explore' ? 'library' : 'study'
115}
116
117// One short line, so no heredoc reaches the state or alt text.
118function oneLine(text: string): string {
119 return clip(text.replace(/\s+/g, ' ').trim(), MAX_TEXT)
120}
121
122function detail(call: object): string {
123 const args = call as Record<string, unknown>
124 for (const key of ['command', 'file_path', 'pattern', 'url', 'query', 'description', 'skill']) {
125 const v = args[key]
126 if (typeof v === 'string' && v.length > 0) {
127 return oneLine(key === 'file_path' ? v.split(/[\\/]/).pop() ?? v : v)
128 }
129 }
130 return ''
131}
132
133function activityOf(tool: string, call: object): string {
134 const what = detail(call)
135 return what ? `${tool}: ${what}` : tool
136}
137
138function str(v: unknown): string | undefined {
139 return typeof v === 'string' && v.length > 0 ? v : undefined
140}
141
142// Fields naming a tool's background task; the todo tools' `taskId` is a todo.
143const BACKGROUND_FIELD: Record<string, string> = {
144 Bash: 'backgroundTaskId', PowerShell: 'backgroundTaskId', Monitor: 'taskId', Workflow: 'taskId',
145}
146
147function backgroundId(tool: string, result: unknown): string | undefined {
148 const field = own(BACKGROUND_FIELD, tool)
149 if (!field || typeof result !== 'object' || result === null) return undefined
150 return str((result as Record<string, unknown>)[field])
151}
152
153function backgroundKind(tool: string): string {
154 return tool === 'Bash' || tool === 'PowerShell' ? 'shell' : tool.toLowerCase()
155}
156
157const isLive = (c: Resident) => c.status === 'working' || c.status === 'waiting'
158
159// Dropped finished members, so a brief late listing does not bring one back.
160const forgotten = new Set<string>()
161
162// Keeps a few finished members, none still carrying a box; the demo's few never push out real work.
163function settle(list: Resident[]): Resident[] {
164 const byAge = (a: Resident, b: Resident) => (a.doneAt ?? 0) - (b.doneAt ?? 0)
165 const demoDone = list.filter(c => c.status === 'done' && c.isDemo && !c.carrying && !c.session).sort(byAge)
166 if (demoDone.length > 2) {
167 const drop = new Set(demoDone.slice(0, demoDone.length - 2).map(c => c.id))
168 list = list.filter(c => !drop.has(c.id))
169 }
170 const done = list.filter(c => c.status === 'done' && !c.isDemo && !c.carrying && !c.session).sort(byAge)
171 if (done.length <= KEEP_DONE) return list
172 const drop = new Set(done.slice(0, done.length - KEEP_DONE).map(c => c.id))
173 for (const id of drop) forgotten.add(id)
174 for (const id of [...forgotten].slice(0, Math.max(0, forgotten.size - 500))) forgotten.delete(id)
175 return list.filter(c => !drop.has(c.id))
176}
177
178function without<K extends keyof Resident>(c: Resident, key: K): Resident {
179 const copy = { ...c }
180 delete copy[key]
181 return copy
182}
183
184// Desks stick, so nobody slides over when others leave.
185function assignSeats(prev: Resident[], wanted: Resident[]): Resident[] {
186 const was = new Map(prev.map(c => [c.id, c]))
187 const taken = new Map<Room, Set<number>>()
188 const takenIn = (room: Room) => taken.get(room) ?? taken.set(room, new Set()).get(room)!
189 const kept = wanted.map(c => {
190 if (!isLive(c)) return c.seat === undefined ? c : without(c, 'seat')
191 const before = was.get(c.id)
192 const stays = c.seat !== undefined && before !== undefined && isLive(before) && before.room === c.room && !takenIn(c.room).has(c.seat)
193 if (stays && c.seat !== undefined) {
194 takenIn(c.room).add(c.seat)
195 return c
196 }
197 return without(c, 'seat')
198 })
199 return kept.map(c => {
200 if (!isLive(c) || c.seat !== undefined) return c
201 const used = takenIn(c.room)
202 let seat = 0
203 while (used.has(seat)) seat++
204 used.add(seat)
205 return { ...c, seat }
206 })
207}
208
209// All household changes: changed plans start where shown now; unchanged writes are skipped.
210async function change($: EngineInterface, fn: (list: Resident[]) => Resident[]): Promise<void> {
211 void startSharing($).catch(() => undefined)
212 const now = await $.clock.now()
213 const apply = (list: Resident[]) => {
214 const base = ordered(list.length === 0 ? [LEAD] : list)
215 const seated = ordered(assignSeats(base, fn(base)))
216 return reanchor(base, seated, now)
217 }
218 const before = await read($, residents)
219 const after = apply(before)
220 if (JSON.stringify(after) === JSON.stringify(before)) return
221 // as worked out, unless another change landed first
222 await update($, residents, list => (list === before ? after : apply(list)))
223 void publish($).catch(() => undefined)
224}
225
226type Patch = Partial<Resident> | ((c: Resident) => Partial<Resident>)
227
228async function move($: EngineInterface, id: string, patch: Patch, fallback?: Resident): Promise<void> {
229 // not in the house: nothing to write (the lead always is)
230 if (!fallback && id !== MAIN && !(await read($, residents)).some(c => c.id === id)) return
231 const now = await $.clock.now()
232 await change($, base => {
233 const all = base.some(c => c.id === id) || !fallback ? base : [...base, fallback]
234 return settle(all.map((c): Resident => {
235 if (c.id !== id) return c
236 const p = typeof patch === 'function' ? patch(c) : patch
237 const room = p.room ?? c.room
238 const moved = room !== c.room
239 const status = p.status ?? c.status
240 const wasIdle = c.status === 'idle' || c.status === 'done'
241 const isIdle = status === 'idle' || status === 'done'
242 return {
243 ...c, ...p, room, movedAt: moved ? now : c.movedAt,
244 idleSince: !wasIdle && isIdle ? now : c.idleSince,
245 workSince: wasIdle && !isIdle ? now : c.workSince,
246 }
247 }))
248 })
249}
250
251async function join($: EngineInterface, member: Resident): Promise<void> {
252 forgotten.delete(member.id)
253 await change($, base => settle([...base.filter(c => c.id !== member.id), member]))
254}
255
256// Read-then-write store jobs run one at a time, so none counts from a stale value.
257let storeQueue: Promise<unknown> = Promise.resolve()
258
259function serial<T>(job: () => Promise<T>): Promise<T> {
260 const run = storeQueue.then(job, job)
261 storeQueue = run.catch(() => undefined)
262 return run
263}
264
265// Today's shipping, a key per session; boxes stack by time so each keeps its place and tape.
266type Shipment = { at: number; session: string }
267let shippedToday: { day: string; boxes: Shipment[] } = { day: '', boxes: [] }
268
269function dayOf(now: number): string {
270 const d = new Date(now)
271 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
272}
273
274// This session's id, before sharing starts too.
275const ownId = async ($: EngineInterface) => self?.id ?? await $.session.id().catch(() => 'local')
276
277async function readShipped($: EngineInterface, now: number): Promise<void> {
278 const day = dayOf(now)
279 const prefix = `shipped:${day}:`
280 const own = await ownId($)
281 const all: { at: number; id: string }[] = []
282 for (const key of await $.store.keys()) {
283 if (!key.startsWith(prefix)) continue
284 const id = key.slice(prefix.length)
285 for (const at of numbers(await $.store.get(key))) all.push({ at, id })
286 }
287 // one order in every house; this session's own named ''
288 all.sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
289 shippedToday = { day, boxes: all.map(b => ({ at: b.at, session: b.id === own ? '' : b.id })) }
290}
291
292async function shippedOn($: EngineInterface, now: number): Promise<Shipment[]> {
293 if (shippedToday.day !== dayOf(now)) await readShipped($, now).catch(() => undefined)
294 return shippedToday.day === dayOf(now) ? shippedToday.boxes : []
295}
296
297// One more box on this session's key; an unreadable key is not started over.
298async function countShipped($: EngineInterface, now: number): Promise<void> {
299 const key = `shipped:${dayOf(now)}:${await ownId($)}`
300 const sofar = await $.store.get(key)
301 await $.store.set(key, [...numbers(sofar), now])
302 await readShipped($, now)
303}
304
305// Today's boxes from every session and the demo, in order, each as its shipper.
306async function yardBoxes($: EngineInterface, now: number): Promise<string[]> {
307 const boxes = [...await shippedOn($, now), ...demoBoxes.map(at => ({ at, session: '' }))]
308 return boxes.sort((a, b) => a.at - b.at).map(b => b.session)
309}
310
311// A finished member carries a box to the pallets (see `deliver`), one at a time so none share a place.
312let finishing: Promise<unknown> = Promise.resolve()
313
314function finish($: EngineInterface, id: string, activity: string): Promise<void> {
315 const run = finishing.then(() => finishOne($, id, activity), () => finishOne($, id, activity))
316 finishing = run.catch(() => undefined)
317 return run
318}
319
320async function finishOne($: EngineInterface, id: string, activity: string): Promise<void> {
321 const member = (await read($, residents)).find(c => c.id === id)
322 if (!member || member.status === 'done' || member.session) return
323 const now = await $.clock.now()
324 // the next free pallet place, counting every session's latest
325 await readShipped($, now).catch(() => undefined)
326 const box = (await yardBoxes($, now)).length
327 let isShipped = false
328 let isDemo = false
329 await change($, list => {
330 // the update may run again on a clash: decide afresh each time
331 isShipped = false
332 return settle(list.map((c): Resident => {
333 if (c.id !== id || c.status === 'done' || c.session) return c
334 isShipped = true
335 isDemo = c.isDemo === true
336 // a shell or monitor keeps its command; the end goes to its status
337 const keepsCommand = c.id !== MAIN && taskTypeOf(c) !== 'subagent'
338 return { ...c, status: 'done', ...(keepsCommand ? {} : { activity }), taskStatus: activity.toLowerCase(), doneAt: now, idleSince: now, carrying: true, box }
339 }))
340 })
341 if (!isShipped) return
342 if (isDemo) demoBoxes.push(now)
343 else await serial(() => countShipped($, now)).catch(() => undefined)
344 await sendOut($, id)
345}
346
347// Corrects a task taken as completed once the engine's notice says how it ended.
348async function restate($: EngineInterface, id: string, word: string): Promise<void> {
349 await change($, list => list.map(c => {
350 if (c.id !== id || c.status !== 'done' || c.session || c.taskStatus === word.toLowerCase()) return c
351 const keepsCommand = c.id !== MAIN && taskTypeOf(c) !== 'subagent'
352 return { ...c, ...(keepsCommand ? {} : { activity: word }), taskStatus: word.toLowerCase() }
353 }))
354}
355
356// One timer per courier on its way.
357const errands = new Map<string, { cancel: () => void }>()
358
359// Brings a courier back when its errand ends; after a reload, one overdue lands at once.
360async function sendOut($: EngineInterface, id: string): Promise<void> {
361 const now = await $.clock.now()
362 const end = errandEnds(ordered(await read($, residents)), now).get(id)
363 errands.get(id)?.cancel()
364 errands.delete(id)
365 if (end === undefined) return
366 errands.set(id, $.clock.after(Math.max(0, end - now), () => {
367 errands.delete(id)
368 void deliver($, id).catch(() => undefined)
369 }))
370}
371
372// Back from the pallets, box stacked.
373async function deliver($: EngineInterface, id: string): Promise<void> {
374 const now = await $.clock.now()
375 await change($, list => settle(list.map(c => (c.id === id && c.carrying ? { ...without(without(c, 'carrying'), 'box'), idleSince: now } : c))))
376}
377
378function ordered(list: Resident[]): Resident[] {
379 const rank = (c: Resident) => (c.id === MAIN ? 0 : isLive(c) ? 1 : c.status === 'idle' ? 2 : 3)
380 return list.map((c, i) => ({ c, i })).sort((a, b) => rank(a.c) - rank(b.c) || a.i - b.i).map(x => x.c)
381}
382
383// Where a member stands, for its hover card.
384type State = 'running' | 'waiting' | 'pending' | 'idle' | 'done' | 'failed' | 'stopped'
385
386function endState(c: Resident): State {
387 const w = (c.taskStatus ?? c.activity).toLowerCase()
388 if (/kill|stop|cancel|abort/.test(w)) return 'stopped'
389 if (/fail|error/.test(w)) return 'failed'
390 return 'done'
391}
392
393const STATE_WORD: Record<State, string> = {
394 running: 'running', waiting: 'awaiting approval', pending: 'pending', idle: 'waiting for you',
395 done: 'completed', failed: 'failed', stopped: 'stopped',
396}
397
398// ---------- token usage, kept as time and token pairs ----------
399
400const HOUR = 60 * 60 * 1000
401const WEEK = 7 * 24 * HOUR
402
403function tokensSince(usage: number[] | undefined, since: number): number {
404 let sum = 0
405 for (let i = 0; i + 1 < (usage?.length ?? 0); i += 2) if ((usage?.[i] ?? 0) >= since) sum += usage?.[i + 1] ?? 0
406 return sum
407}
408
409// One pair per hour, at its latest time, so a week is at most 168; pairs before `since` drop.
410function byHour(flat: number[], since = 0): number[] {
411 const hours = new Map<number, [number, number]>()
412 for (let i = 0; i + 1 < flat.length; i += 2) {
413 const [t, n] = [flat[i] ?? 0, flat[i + 1] ?? 0]
414 if (t < since) continue
415 const was = hours.get(Math.floor(t / HOUR))
416 hours.set(Math.floor(t / HOUR), [Math.max(t, was?.[0] ?? 0), n + (was?.[1] ?? 0)])
417 }
418 return [...hours.values()].sort((a, b) => a[0] - b[0]).flat()
419}
420
421function addUsage(usage: number[] | undefined, now: number, tokens: number): number[] {
422 return byHour([...(usage ?? []), now, tokens], now - WEEK)
423}
424
425function compact(n: number): string {
426 if (n < 1000) return String(n)
427 if (n < 10000) return `${(n / 1000).toFixed(1)}k`
428 if (n < 1e6) return `${Math.round(n / 1000)}k`
429 return `${(n / 1e6).toFixed(1)}M`
430}
431
432// The week's tokens: the lead's span sessions, anyone else's is its one run.
433function usageText(c: Resident, now: number): string | undefined {
434 const week = tokensSince(c.usage, now - WEEK)
435 return week === 0 ? undefined : compact(week)
436}
437
438// A request's cost; cache reads are left out, being nearly free.
439function tokensOf(u: { input_tokens: number; output_tokens: number; cache_creation_input_tokens: number } | null | undefined): number {
440 return u ? u.input_tokens + u.cache_creation_input_tokens + u.output_tokens : 0
441}
442
443// The lead's usage spans sessions, a key each, summed so none overwrite another.
444const LEAD_USAGE = 'usage:lead'
445let ownKey: string | undefined
446
447async function leadKey($: EngineInterface): Promise<string> {
448 if (ownKey) return ownKey
449 const started = await $.session.usage({}).then(u => u.startedAt).catch(() => 0)
450 ownKey = `${LEAD_USAGE}:${started || await $.clock.now()}`
451 return ownKey
452}
453
454const numbers = (v: unknown) => (Array.isArray(v) ? v.filter((n): n is number => typeof n === 'number') : [])
455
456// The lead's usage over all sessions, others' read at most once a minute; undefined if the store fails.
457let othersUsage: { at: number; flat: number[] } | undefined
458
459async function leadUsage($: EngineInterface, mine?: number[]): Promise<number[] | undefined> {
460 try {
461 const now = await $.clock.now()
462 const own = await leadKey($)
463 if (!othersUsage || now - othersUsage.at > MINUTE) {
464 const flat: number[] = []
465 for (const key of (await $.store.keys()).filter(k => k.startsWith(LEAD_USAGE) && k !== own)) flat.push(...numbers(await $.store.get(key)))
466 othersUsage = { at: now, flat: byHour(flat) }
467 }
468 return byHour([...othersUsage.flat, ...(mine ?? numbers(await $.store.get(own)))])
469 } catch {
470 return undefined
471 }
472}
473
474// Drops old days' counts, week-old usage and dead sessions (a crashed one never deletes its own).
475async function pruneStore($: EngineInterface): Promise<void> {
476 const now = await $.clock.now()
477 const today = dayOf(now)
478 for (const key of await $.store.keys()) {
479 if (key.startsWith('shipped:') && !key.startsWith(`shipped:${today}`)) await $.store.delete(key)
480 if (key.startsWith(PEER)) {
481 const v = await $.store.get(key) as Partial<Snapshot> | undefined
482 if (now - (typeof v?.at === 'number' ? v.at : 0) > FORGET_AFTER) await $.store.delete(key)
483 }
484 if (key.startsWith(LEAD_USAGE)) {
485 const v = numbers(await $.store.get(key))
486 const newest = v.filter((_t, i) => i % 2 === 0).reduce((a, b) => Math.max(a, b), 0)
487 if (newest < now - WEEK) await $.store.delete(key)
488 }
489 }
490}
491
492// No plan changes here, so this skips `change` and its reanchoring.
493async function recordUsage($: EngineInterface, id: string, tokens: number): Promise<void> {
494 if (tokens <= 0) return
495 const now = await $.clock.now()
496 if (id === MAIN) {
497 await serial(async () => {
498 const key = await leadKey($)
499 const kept = (await read($, residents)).find(c => c.id === MAIN)?.usage
500 const stored = await $.store.get(key).then(numbers, () => undefined)
501 let usage = addUsage(kept, now, tokens)
502 if (stored !== undefined) {
503 const mine = addUsage(stored, now, tokens)
504 if (await $.store.set(key, mine).then(() => true, () => false)) usage = await leadUsage($, mine) ?? mine
505 }
506 await update($, residents, list => (list.length === 0 ? [LEAD] : list).map(c => (c.id === MAIN ? { ...c, usage } : c)))
507 })
508 return
509 }
510 // nobody to add to: an engine fork's step, say
511 if (!(await read($, residents)).some(c => c.id === id)) return
512 await update($, residents, list => list.map(c => (c.id === id ? { ...c, usage: addUsage(c.usage, now, tokens) } : c)))
513}
514
515// ---------- the plan's windows, for the vibe fatigue band ----------
516
517// Read off Claude Code's response headers, so nothing leaves the machine; the account's newest is shared.
518const PLAN_KEY = 'plan'
519const MINUTE = 60 * 1000
520
521// A reading taken `at`; none off a subscription or before the first response.
522function readingOf(limits: readonly SessionRateLimit[], at: number): PlanUse | undefined {
523 const five = limits.find(r => r.kind === 'five_hour')
524 const week = limits.find(r => r.kind === 'seven_day')
525 if (!five && !week) return undefined
526 const resets = (r: SessionRateLimit) => (r.resetsAt ? Date.parse(r.resetsAt) : NaN)
527 const out: PlanUse = { at }
528 if (five) {
529 out.fiveHour = five.percentUsed
530 if (Number.isFinite(resets(five))) out.fiveHourResets = resets(five)
531 }
532 if (week) {
533 out.week = week.percentUsed
534 if (Number.isFinite(resets(week))) out.weekResets = resets(week)
535 }
536 return out
537}
538
539// A stored reading, checked field by field, percentages floored at zero.
540function planFrom(raw: unknown): PlanUse | undefined {
541 if (typeof raw !== 'object' || raw === null) return undefined
542 const v = raw as Record<string, unknown>
543 const out: PlanUse = {}
544 for (const k of ['at', 'fiveHour', 'fiveHourResets', 'week', 'weekResets'] as const) {
545 const n = v[k]
546 if (typeof n === 'number' && Number.isFinite(n)) out[k] = k === 'fiveHour' || k === 'week' ? Math.max(0, n) : n
547 }
548 return out.at !== undefined ? out : undefined
549}
550
551// A newer reading over an older, window by window.
552function newer(was: PlanUse, r: PlanUse): PlanUse {
553 const five = r.fiveHour !== undefined ? r : was
554 const week = r.week !== undefined ? r : was
555 return {
556 ...(r.at !== undefined ? { at: r.at } : {}),
557 ...(five.fiveHour !== undefined ? { fiveHour: five.fiveHour } : {}),
558 ...(five.fiveHourResets !== undefined ? { fiveHourResets: five.fiveHourResets } : {}),
559 ...(week.week !== undefined ? { week: week.week } : {}),
560 ...(week.weekResets !== undefined ? { weekResets: week.weekResets } : {}),
561 }
562}
563
564// The windows at `now`: one past its reset is blank until a response brings the next.
565function current(p: PlanUse, now: number): PlanUse {
566 const fiveOver = p.fiveHourResets !== undefined && p.fiveHourResets <= now
567 const weekOver = p.weekResets !== undefined && p.weekResets <= now
568 if (!fiveOver && !weekOver) return p
569 return {
570 ...(p.at !== undefined ? { at: p.at } : {}),
571 ...(!fiveOver && p.fiveHour !== undefined ? { fiveHour: p.fiveHour } : {}),
572 ...(!fiveOver && p.fiveHourResets !== undefined ? { fiveHourResets: p.fiveHourResets } : {}),
573 ...(!weekOver && p.week !== undefined ? { week: p.week } : {}),
574 ...(!weekOver && p.weekResets !== undefined ? { weekResets: p.weekResets } : {}),
575 }
576}
577
578const sameWindows = (a: PlanUse, b: PlanUse) =>
579 a.fiveHour === b.fiveHour && a.fiveHourResets === b.fiveHourResets && a.week === b.week && a.weekResets === b.weekResets
580
581// Shows a newer, different local reading, and stores it unless the store's is newer.
582async function takeReading($: EngineInterface, reading: PlanUse | undefined, now: number): Promise<void> {
583 const at = reading?.at
584 if (!reading || at === undefined) return
585 // the reading applied to `was`, if it says anything new
586 const over = (was: PlanUse) => {
587 if (at <= (was.at ?? 0)) return undefined
588 const merged = current(newer(was, reading), now)
589 return sameWindows(merged, was) ? undefined : merged
590 }
591 if (!over(await read($, plan))) return
592 let wrote: PlanUse | undefined
593 await update($, plan, was => (wrote = over(was)) ?? was)
594 if (!wrote) return
595 const stored = planFrom(await $.store.get(PLAN_KEY).catch(() => undefined))
596 if ((stored?.at ?? 0) < at) await $.store.set(PLAN_KEY, wrote).catch(() => undefined)
597}
598
599// Adopts the store's newer reading, else restores this one's (two writes may race); false if none stored.
600async function adoptPlan($: EngineInterface, now: number): Promise<boolean> {
601 const stored = planFrom(await $.store.get(PLAN_KEY))
602 if (!stored) return false
603 const at = stored.at ?? 0
604 const was = await read($, plan)
605 if (at > (was.at ?? 0)) await update($, plan, p => (at > (p.at ?? 0) ? current(newer(p, stored), now) : p))
606 else if ((was.at ?? 0) > at) await $.store.set(PLAN_KEY, was).catch(() => undefined)
607 return true
608}
609
610// At start: the store's newest reading, else Claude Code's own.
611async function startPlan($: EngineInterface): Promise<void> {
612 const now = await $.clock.now()
613 await update($, minute, () => now)
614 if (await adoptPlan($, now)) return
615 const { rateLimits } = await $.session.usage({})
616 await takeReading($, readingOf(rateLimits, now), now)
617}
618
619// Session setup, each step on its own so one failing leaves the rest.
620async function begin($: EngineInterface): Promise<void> {
621 // a session resumed here after ending shares again
622 endedHere.clear()
623 await $.command.register({
624 name: 'haus',
625 description: 'Show Vibehaus · "demo" fills it with a demo household · "snapshot" saves it as an SVG',
626 argumentHint: '[demo | snapshot]',
627 immediate: true,
628 }).catch(() => undefined)
629 try {
630 const usage = await leadUsage($)
631 await change($, list => list.filter(c => !c.isDemo).map(c => (c.id === MAIN && usage ? { ...c, usage } : c)))
632 for (const c of await read($, residents)) if (c.carrying && !c.session) await sendOut($, c.id)
633 } catch {
634 // the household as it is
635 }
636 void endQuietly($).catch(() => undefined)
637 $.clock.after(ENDING_GRACE, () => void endQuietly($).catch(() => undefined))
638 void startSharing($).catch(() => undefined)
639 void startPlan($).catch(() => undefined)
640 void pruneStore($).catch(() => undefined)
641}
642
643// Each minute: resets count down and a window past its reset loses its reading.
644async function tickPlan($: EngineInterface): Promise<void> {
645 const now = await $.clock.now()
646 await update($, minute, () => now)
647 const was = await read($, plan)
648 if (current(was, now) !== was) await update($, plan, p => current(p, now))
649}
650let ticker: { cancel: () => void } | undefined
651
652// Cells per plan bar when the band's width is unknown.
653const BAR = 8
654
655function metersOf(p: PlanUse): Meter[] {
656 return [
657 { label: '5H', color: '#52d2f5', ...(p.fiveHour !== undefined ? { percent: p.fiveHour } : {}), ...(p.fiveHourResets !== undefined ? { resets: p.fiveHourResets } : {}) },
658 { label: 'Week', color: '#4cf08c', ...(p.week !== undefined ? { percent: p.week } : {}), ...(p.weekResets !== undefined ? { resets: p.weekResets } : {}) },
659 ]
660}
661
662// A reset time as the band words it: minutes or hours today, else the weekday.
663const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
664function resetText(at: number, now: number): string {
665 const m = Math.max(1, Math.ceil((at - now) / MINUTE))
666 if (m < 60) return `${m}m`
667 if (m < 24 * 60) return m % 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m / 60}h`
668 return DAYS[new Date(at).getDay()] ?? ''
669}
670
671// Bar width and reset wording: full resets while bars keep BAR_ROOMY cells, then short, then none.
672const TOGGLE = '◧ Vibehaus'
673const BAR_MIN = 3
674const BAR_ROOMY = 4
675const BAR_MAX = 40
676const percentText = (m: Meter) => (m.percent === undefined ? '--' : `${Math.round(m.percent)}%`)
677
678function bandOf(meters: Meter[], columns: number | undefined, now: number): { bar: number; words: string[] } {
679 const wordings = [(t: string) => `Resets ${t}`, (t: string) => t, () => '']
680 let last = { bar: BAR_MIN, words: meters.map(() => '') }
681 for (const [i, say] of wordings.entries()) {
682 const words = meters.map(m => (m.percent !== undefined && m.resets !== undefined ? say(resetText(m.resets, now)) : ''))
683 if (columns === undefined) return { bar: BAR, words }
684 // the toggle, gaps, a spare cell, and each meter's words: "5H ", " 42%", " · 2h 10m"
685 const fixed = TOGGLE.length + 2 * meters.length + 1
686 + meters.reduce((n, m, k) => n + m.label.length + 2 + percentText(m).length + (words[k] ? 3 + [...(words[k] as string)].length : 0), 0)
687 const bar = Math.floor((columns - fixed) / meters.length)
688 if (bar >= (i < wordings.length - 1 ? BAR_ROOMY : BAR_MIN)) return { bar: Math.min(BAR_MAX, bar), words }
689 last = { bar: BAR_MIN, words }
690 }
691 return last
692}
693
694function stateOf(c: Resident): State {
695 if (c.kind === 'main') return c.status === 'waiting' ? 'waiting' : c.status === 'working' ? 'running' : 'idle'
696 if (c.status === 'waiting') return 'waiting'
697 if (c.isTeammate && c.status === 'idle') return 'idle'
698 if (isLive(c)) return c.taskStatus === 'pending' ? 'pending' : 'running'
699 return endState(c)
700}
701
702function ended(status: string): string {
703 return status.charAt(0).toUpperCase() + status.slice(1)
704}
705
706// One of the Stop hooks' `background_tasks`; long texts end in "... [+N chars]".
707type TaskSummary = {
708 id: string
709 type: string
710 status: string
711 description: string
712 command?: string
713 agent_type?: string
714 name?: string
715 server?: string
716}
717const CLIPPED = /\.\.\. \[\+\d+ chars\]$/
718
719function fromSummary(t: TaskSummary): Resident {
720 const description = oneLine(t.description.replace(CLIPPED, '…'))
721 const isAgent = t.type === 'subagent'
722 const kind = isAgent ? t.agent_type ?? 'agent' : t.type
723 return {
724 id: t.id,
725 name: isAgent ? description || kind : `Background ${t.type}`,
726 kind,
727 room: isAgent ? roomForKind(kind) : t.type === 'shell' || t.type === 'monitor' ? 'kitchen' : t.server ? 'sunroom' : 'study',
728 // start unknown: none rather than a wrong one
729 movedAt: 0,
730 status: 'working',
731 activity: oneLine(t.command?.replace(CLIPPED, '…') || description || t.name || t.type),
732 isBackground: true,
733 taskType: t.type,
734 taskStatus: t.status,
735 }
736}
737
738// Syncs to the engine's background list as of `asOf`: missing tasks join, vanished ones end, newer stay.
739// Agents go by the agent list instead (foreground ones are not background); workflow agents are in neither.
740async function reconcile($: EngineInterface, tasks: readonly TaskSummary[] | undefined, asOf: number): Promise<void> {
741 if (!tasks) return
742 const live = new Map(tasks.map(t => [t.id, t]))
743 let agents: Map<string, string> | undefined
744 try {
745 agents = new Map((await $.agent.list()).map(a => [a.id, a.status as string]))
746 } catch {
747 agents = undefined
748 }
749 let over: { id: string; word: string }[] = []
750 let isEnding = false
751 const hasEnded = (s: string | undefined) => s === undefined || s === 'completed' || s === 'failed' || s === 'killed'
752 await change($, list => {
753 over = []
754 isEnding = false
755 const synced = list.map((c): Resident => {
756 if (c.session) return c
757 const t = live.get(c.id)
758 if (t) return c.status === 'done' ? c : { ...without(c, 'endingSince'), taskType: t.type, taskStatus: t.status }
759 const seen = c.spawnedAt ?? c.workSince
760 const isOld = seen === undefined || seen < asOf
761 // a teammate, working or idle, ends once the engine stops listing it
762 if (c.isTeammate && agents && c.status !== 'done') {
763 const s = agents.get(c.id)
764 if (isOld && hasEnded(s)) over.push({ id: c.id, word: s ?? 'completed' })
765 return c
766 }
767 if (c.id === MAIN || c.isDemo || !isLive(c) || !isOld) return c
768 if (taskTypeOf(c) === 'subagent') {
769 if (!agents || c.isWorkflow) return c
770 const s = agents.get(c.id)
771 if (hasEnded(s)) over.push({ id: c.id, word: s ?? 'completed' })
772 return c
773 }
774 // gone from the list: its end notice may be coming, so it ends as completed after a moment
775 isEnding = true
776 return c.endingSince !== undefined ? c : { ...c, endingSince: asOf }
777 })
778 for (const t of tasks) {
779 if (list.some(c => c.id === t.id) || forgotten.has(t.id)) continue
780 if (t.status !== 'running' && t.status !== 'pending') continue
781 synced.push(fromSummary(t))
782 }
783 return settle(synced)
784 })
785 for (const o of over) await finish($, o.id, ended(o.word))
786 if (isEnding) $.clock.after(ENDING_GRACE, () => void endQuietly($).catch(() => undefined))
787}
788
789// How long work gone from the engine's list waits for its notice.
790const ENDING_GRACE = 5000
791
792async function endQuietly($: EngineInterface): Promise<void> {
793 const now = await $.clock.now()
794 const due = (await read($, residents)).filter(c => !c.session && c.status !== 'done' && c.endingSince !== undefined && now - c.endingSince >= ENDING_GRACE)
795 for (const c of due) await finish($, c.id, 'Completed')
796}
797
798async function contextNow($: EngineInterface): Promise<number | undefined> {
799 try {
800 const usage = await $.session.usage({})
801 return usage.context.percent
802 } catch {
803 return undefined
804 }
805}
806
807// ---------- other sessions in the same house ----------
808
809// Each session shares its household under its own key; the others' agents walk in from the street.
810// One that stops writing, or idles unmessaged for five hours, moves out until busy again.
811const PEER = 'session:'
812const SYNC_EVERY = 3000
813const BEAT_EVERY = 10000
814const GONE_AFTER = 35000
815const FORGET_AFTER = HOUR
816const ACTIVE_FOR = 5 * HOUR
817
818// What a session writes, with when it last woke from a long sleep.
819type Snapshot = { at: number; since: number; activeAt: number; name: string; residents: Resident[]; wokeAt?: number }
820
821// When a session was last busy, by its members' latest times.
822const activeAtOf = (mine: Resident[]) => Math.max(0, ...mine.flatMap(c => [c.workSince, c.spawnedAt, c.resultAt, c.doneAt, c.idleSince, ...(c.calls ?? [])].filter((t): t is number => typeof t === 'number')))
823type Peer = { id: string; name: string; since: number }
824
825// This session as the others know it, and the others as last read.
826let self: { id: string; name: string; since: number } | undefined
827let peers: Peer[] = []
828// sessions gone, whose households are still walking out
829let going: Peer[] = []
830let lastSync = 0
831let shared = { at: 0, text: '' }
832// sessions seen ending here, so late changes do not share them again; then the sleep state
833const endedHere = new Map<string, number>()
834const END_QUIET = 10000
835let wasAsleep = false
836let wokeAt = 0
837let publishing: { cancel: () => void } | undefined
838let sharing: Promise<void> | undefined
839// counts starts, so one stopped mid-start gives up
840let runs = 0
841let beats: { cancel: () => void }[] = []
842
843const peerName = (id: string) => [...peers, ...going].find(p => p.id === id)?.name ?? 'another session'
844
845async function whoAmI($: EngineInterface): Promise<NonNullable<typeof self>> {
846 const now = await $.clock.now()
847 const id = await $.session.id().catch(() => 'local')
848 const root = await $.session.root().catch(() => '')
849 const since = await $.session.usage({}).then(u => u.startedAt).catch(() => 0)
850 // its name before a reload: its title, once it had one
851 const was = await $.store.get(PEER + id).catch(() => undefined) as Partial<Snapshot> | undefined
852 const name = typeof was?.name === 'string' && was.name ? was.name : root.split(/[\\/]/).filter(Boolean).pop() ?? 'session'
853 return { id, name, since: since || now }
854}
855
856// A session goes by its title once the engine sends one, else its folder.
857async function retitle($: EngineInterface, title: string | undefined): Promise<void> {
858 const name = title ? oneLine(title).slice(0, 40) : ''
859 if (!name) return
860 await startSharing($)
861 const me = self
862 if (!me || me.name === name) return
863 me.name = name
864 await publish($)
865}
866
867// What others see: nothing house-local, of its work only the tool and file name, never a command or query.
868const FILE_TOOLS = /^(Read|Edit|Write|MultiEdit|NotebookEdit|NotebookRead)$/
869function outward(c: Resident): Resident {
870 const kept = without(without(without(without(c, 'anchor'), 'seat'), 'usage'), 'endingSince')
871 const isTask = c.id !== MAIN && taskTypeOf(c) !== 'subagent'
872 const activity = isTask
873 ? c.status === 'done' ? ended(c.taskStatus ?? 'done') : 'Running'
874 : c.tool && c.activity.startsWith(`${c.tool}: `) && !FILE_TOOLS.test(c.tool) ? c.tool : c.activity
875 return { ...kept, activity }
876}
877
878// Shares this session's members at most every two seconds, and each beat as a sign of life.
879async function publish($: EngineInterface, beat = false): Promise<void> {
880 const me = self
881 if (!me) return
882 const now = await $.clock.now()
883 if (!beat && now - shared.at < 2000) {
884 publishing ??= $.clock.after(2000 - (now - shared.at), () => {
885 publishing = undefined
886 void publish($).catch(() => undefined)
887 })
888 return
889 }
890 const mine = (await read($, residents)).filter(c => !c.session && !c.isDemo).map(outward)
891 // a session just started is not asleep, whatever its members did
892 const activeAt = Math.max(me.since, activeAtOf(mine))
893 // marks waking from a long sleep, so every house sees it arrive at once
894 const asleep = !mine.some(isLive) && now - activeAt > ACTIVE_FOR
895 if (wasAsleep && !asleep) wokeAt = now
896 wasAsleep = asleep
897 const body = { since: me.since, activeAt, name: me.name, residents: mine, ...(wokeAt ? { wokeAt } : {}) }
898 const text = JSON.stringify(body)
899 if ((!beat && text === shared.text) || self !== me) return
900 shared = { at: now, text }
901 await $.store.set(PEER + me.id, { ...body, at: now } satisfies Snapshot)
902}
903
904const ROOMS = new Set<string>(ALL_ROOMS)
905const STATUSES = new Set<string>(['working', 'waiting', 'idle', 'done'])
906
907// Another session's member, checked field by field; one lacking essentials is dropped.
908function memberOf(raw: unknown): Resident | undefined {
909 if (typeof raw !== 'object' || raw === null) return undefined
910 const m = raw as Record<string, unknown>
911 const text = (k: string) => (typeof m[k] === 'string' ? oneLine(m[k] as string) : undefined)
912 const num = (k: string) => (typeof m[k] === 'number' && Number.isFinite(m[k]) ? m[k] as number : undefined)
913 const { id, kind, name, room, status } = { id: text('id'), kind: text('kind'), name: text('name'), room: text('room'), status: text('status') }
914 if (!id || !kind || name === undefined || !room || !ROOMS.has(room) || !status || !STATUSES.has(status)) return undefined
915 const out: Resident = { id, kind, name, room: room as Room, status: status as ResidentStatus, movedAt: num('movedAt') ?? 0, activity: text('activity') ?? '' }
916 for (const k of ['tool', 'taskType', 'taskStatus', 'waitingTool'] as const) {
917 const v = text(k)
918 if (v !== undefined) out[k] = v
919 }
920 for (const k of ['resultAt', 'spawnedAt', 'doneAt', 'idleSince', 'workSince', 'context', 'box'] as const) {
921 const v = num(k)
922 if (v !== undefined) out[k] = v
923 }
924 for (const k of ['isBackground', 'isOk', 'isWorkflow', 'isTeammate', 'carrying'] as const) {
925 if (typeof m[k] === 'boolean') out[k] = m[k] as boolean
926 }
927 const from = text('spawnRoom')
928 if (from && ROOMS.has(from)) out.spawnRoom = from as Room
929 if (Array.isArray(m.calls)) out.calls = m.calls.filter((t): t is number => typeof t === 'number').slice(-MAX_CALLS)
930 return out
931}
932
933// Session names as every house shows them, repeats numbered.
934function nameAll(list: { id: string; name: string; since: number }[]): Map<string, string> {
935 const seen = new Map<string, number>()
936 const out = new Map<string, string>()
937 for (const s of [...list].sort((a, b) => a.since - b.since || (a.id < b.id ? -1 : 1))) {
938 const n = (seen.get(s.name) ?? 0) + 1
939 seen.set(s.name, n)
940 out.set(s.id, n > 1 ? `${s.name} ${n}` : s.name)
941 }
942 return out
943}
944
945type Visible = Peer & Household
946
947// Other sessions to show: neither stopped writing nor asleep. Long-stale keys are cleared.
948async function readPeers($: EngineInterface, own: string, now: number): Promise<Visible[]> {
949 const found: Visible[] = []
950 const time = (t: unknown) => (typeof t === 'number' && Number.isFinite(t) ? t : undefined)
951 for (const key of await $.store.keys()) {
952 if (!key.startsWith(PEER) || key === PEER + own) continue
953 const v = await $.store.get(key) as Partial<Snapshot> | undefined
954 const at = time(v?.at) ?? 0
955 if (now - at > FORGET_AFTER) {
956 await $.store.delete(key).catch(() => undefined)
957 continue
958 }
959 if (!v || now - at > GONE_AFTER || !Array.isArray(v.residents)) continue
960 const members = v.residents.flatMap(m => memberOf(m) ?? [])
961 if (!members.some(isLive) && now - (time(v.activeAt) ?? 0) > ACTIVE_FOR) continue
962 const woke = time(v.wokeAt)
963 found.push({
964 id: key.slice(PEER.length), name: oneLine(String(v.name || 'session')).slice(0, 40),
965 since: time(v.since) ?? 0, residents: members, ...(woke !== undefined ? { wokeAt: woke } : {}),
966 })
967 }
968 return found.sort((a, b) => a.since - b.since || (a.id < b.id ? -1 : 1))
969}
970
971// Moves other sessions in and quiet ones out; all go if the store stays out of reach as long.
972let syncing = false
973
974async function syncPeers($: EngineInterface): Promise<void> {
975 if (syncing) return
976 syncing = true
977 try {
978 await syncOnce($)
979 } finally {
980 syncing = false
981 }
982}
983
984async function syncOnce($: EngineInterface): Promise<void> {
985 const me = self
986 if (!me) return
987 const now = await $.clock.now()
988 let found: Visible[]
989 try {
990 found = await readPeers($, me.id, now)
991 } catch {
992 if (now - lastSync > GONE_AFTER) {
993 going = [...peers, ...going]
994 peers = []
995 paintHomes()
996 await change($, list => visit(list, [], now))
997 }
998 return
999 }
1000 lastSync = now
1001 await readShipped($, now).catch(() => undefined)
1002 await adoptPlan($, now).catch(() => false)
1003 const names = nameAll([me, ...found])
1004 const was = [...peers, ...going]
1005 peers = found.map(f => ({ id: f.id, name: names.get(f.id) ?? f.name, since: f.since }))
1006 // a session gone keeps its colour and name while its household walks out
1007 const here = await read($, residents)
1008 going = was.filter(p => !found.some(f => f.id === p.id) && here.some(c => c.session === p.id))
1009 paintHomes()
1010 // alone, with no visitors left to see out: nothing to change
1011 if (found.length === 0 && !here.some(c => c.session)) return
1012 await change($, list => visit(list, found, now))
1013}
1014
1015// Starts sharing once: at session start, or the first change if the world could not answer yet.
1016function startSharing($: EngineInterface): Promise<void> {
1017 if (sharing) return sharing
1018 const run = ++runs
1019 sharing = (async () => {
1020 const me = await whoAmI($)
1021 // stopped while it asked
1022 if (run !== runs) return
1023 // ended just now: after /clear the next change uses the new id; a resumed one shares again
1024 const endedAt = endedHere.get(me.id)
1025 if (endedAt !== undefined && await $.clock.now() - endedAt < END_QUIET) {
1026 sharing = undefined
1027 return
1028 }
1029 self = me
1030 for (const beat of beats) beat.cancel()
1031 beats = [
1032 $.clock.every(SYNC_EVERY, () => void syncPeers($).catch(() => undefined)),
1033 $.clock.every(BEAT_EVERY, () => void publish($, true).catch(() => undefined)),
1034 ]
1035 await publish($, true).catch(() => undefined)
1036 await syncPeers($).catch(() => undefined)
1037 })()
1038 // failed: the next change tries again
1039 sharing.catch(() => {
1040 if (run === runs) sharing = undefined
1041 })
1042 return sharing
1043}
1044
1045async function stopSharing($: EngineInterface): Promise<void> {
1046 for (const beat of beats) beat.cancel()
1047 beats = []
1048 publishing?.cancel()
1049 publishing = undefined
1050 sharing = undefined
1051 runs++
1052 const me = self
1053 self = undefined
1054 if (me) endedHere.set(me.id, await $.clock.now().catch(() => 0))
1055 peers = []
1056 going = []
1057 paintHomes()
1058 shared = { at: 0, text: '' }
1059 wasAsleep = false
1060 wokeAt = 0
1061 if (me) await $.store.delete(PEER + me.id).catch(() => undefined)
1062}
1063
1064// Colours each session by when it moved in; none while this one is alone.
1065function paintHomes(): void {
1066 const others = [...peers, ...going]
1067 if (!self || others.length === 0) {
1068 paintSessions([], '')
1069 return
1070 }
1071 const me = self
1072 const all = [{ id: '', since: me.since, key: me.id }, ...others.map(p => ({ id: p.id, since: p.since, key: p.id }))]
1073 .sort((a, b) => a.since - b.since || (a.key < b.key ? -1 : 1))
1074 paintSessions(all.map(s => s.id), me.id)
1075}
1076
1077// ---------- hover cards ----------
1078
1079// Whole percentages (all a Box takes), rounded at running edges so the pieces add up.
1080function percents(edges: number[], whole: number): string[] {
1081 const at = edges.map(edge => Math.round((Math.min(Math.max(edge, 0), whole) / Math.max(1, whole)) * 100))
1082 return at.slice(1).map((p, i) => `${Math.max(0, p - (at[i] ?? 0))}%`)
1083}
1084
1085function ago(ms: number): string {
1086 const s = Math.max(0, Math.floor(ms / 1000))
1087 if (s < 60) return `${s}s`
1088 const m = Math.floor(s / 60)
1089 return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`
1090}
1091
1092// An agent's hover card: who, the task, what it does, how long, at what cost.
1093function agentCard(c: Resident, now: number): string[] {
1094 const type = taskTypeOf(c)
1095 const state = stateOf(c)
1096 const live = state === 'running' || state === 'waiting' || state === 'pending'
1097 const started = c.spawnedAt ?? c.workSince
1098 const took = started && started > 0 ? live ? ago(now - started) : c.doneAt !== undefined ? ago(c.doneAt - started) : '' : ''
1099 const doing = state === 'waiting' ? `awaiting approval${c.waitingTool ? ` for ${c.waitingTool}` : ''}` : live || (state === 'idle' && c.isTeammate) ? c.activity : STATE_WORD[state]
1100 const used = usageText(c, now)
1101 const from = c.session ? `from "${peerName(c.session)}"` : ''
1102 if (c.kind === 'main') {
1103 return ['Host', from, doing, [c.context !== undefined ? `context ${Math.round(c.context)}%` : '', live ? took : '', used ? `${used} tokens this week` : ''].filter(Boolean).join(' · ')].filter(Boolean)
1104 }
1105 if (type === 'subagent') return [`${displayName(c)} · ${c.kind}`, c.name, from, doing, [took, used ? `${used} tokens` : ''].filter(Boolean).join(' · ')].filter(Boolean)
1106 return [`⧗ ${displayName(c)}`, c.activity || c.name, from, [STATE_WORD[state], took].filter(Boolean).join(' · ')].filter(Boolean)
1107}
1108
1109// The desktop picture is centred, garden clipped evenly. A row's height against a column is unknown;
1110// code fonts run about 2.3 to 2.8, and the low end is taken so the house is never cut.
1111const ROW_RATIO = 2.3
1112// the high end, for the most garden a pane could show
1113const TALLEST_ROW = 2.8
1114
1115// A picture is an image with its own clock, which stops while the window is hidden and restarts on reload.
1116// So each redraw draws every picture afresh, at least this often; no oftener, as each resends them all.
1117const REFRESH_MS = 2000
1118// the redraw's pace while nobody in the house walks
1119const QUIET_MS = 4000
1120
1121// A sprite's reach round its spot: body, hat and name tag.
1122const REACH = { left: 8, right: 8, up: 15, down: 6 }
1123// In ms: a redraw's lag to the screen, which a zone outlasts, and how finely a walk is followed.
1124const LAG_MS = 400
1125const STEP_MS = 50
1126
1127// Hover zones cover where each agent goes until the next redraw shows. Overlapping zones share a band,
1128// split halfway, so each is one keyed Box holding the card.
1129type Zone = { c: Resident; x0: number; x1: number; y0: number; y1: number }
1130type Band = { y0: number; y1: number; cells: { c: Resident; x0: number; x1: number }[] }
1131
1132// zones over the desktop picture, in its units: a sprite's reach round each spot it takes
1133function houseBands(list: Resident[], tracks: Track[], now: number, grid: HouseGrid): Band[] {
1134 const zones: Zone[] = []
1135 const byId = new Map(list.map(c => [c.id, c]))
1136 for (const t of tracks) {
1137 const c = byId.get(t.id)
1138 if (!c) continue
1139 let span: Omit<Zone, 'c'> | undefined
1140 for (let ms = now; ms <= now + REFRESH_MS + LAG_MS; ms += STEP_MS) {
1141 const p = spotOf(t, ms)
1142 if (!p) continue
1143 span = span
1144 ? { x0: Math.min(span.x0, p.x), x1: Math.max(span.x1, p.x), y0: Math.min(span.y0, p.y), y1: Math.max(span.y1, p.y) }
1145 : { x0: p.x, x1: p.x, y0: p.y, y1: p.y }
1146 }
1147 if (!span) continue
1148 zones.push({
1149 c,
1150 x0: Math.max(0, span.x0 - REACH.left), x1: Math.min(grid.w, span.x1 + REACH.right),
1151 y0: Math.max(0, span.y0 - REACH.up), y1: Math.min(grid.h, span.y1 + REACH.down),
1152 })
1153 }
1154 return bandsOf(zones)
1155}
1156
1157// zones over the terminal plan, in cells: each agent's Clawd and name at each cell it takes
1158function planBands(plan: Plan, walkers: Walker[], now: number): Band[] {
1159 const zones = walkers.flatMap(w => {
1160 let zone: Zone | undefined
1161 for (let ms = now; ms <= now + REFRESH_MS + LAG_MS; ms += STEP_MS) {
1162 const cell = cellAt(plan, w, ms)
1163 if (!cell) continue
1164 const [x0, y0, x1, y1] = reachOf(plan, w, cell)
1165 zone = zone
1166 ? { c: w.c, x0: Math.min(zone.x0, x0), y0: Math.min(zone.y0, y0), x1: Math.max(zone.x1, x1), y1: Math.max(zone.y1, y1) }
1167 : { c: w.c, x0, y0, x1, y1 }
1168 }
1169 return zone ? [zone] : []
1170 })
1171 return bandsOf(zones)
1172}
1173
1174function bandsOf(zones: Zone[]): Band[] {
1175 const bands: { y0: number; y1: number; zones: Zone[] }[] = []
1176 for (const z of [...zones].sort((a, b) => a.y0 - b.y0)) {
1177 const last = bands[bands.length - 1]
1178 if (last && z.y0 < last.y1) {
1179 last.y1 = Math.max(last.y1, z.y1)
1180 last.zones.push(z)
1181 } else bands.push({ y0: z.y0, y1: z.y1, zones: [z] })
1182 }
1183 return bands.map(band => {
1184 const cells = [...band.zones].sort((p, q) => p.x0 + p.x1 - (q.x0 + q.x1)).map(z => ({ c: z.c, x0: z.x0, x1: z.x1 }))
1185 for (let i = 1; i < cells.length; i++) {
1186 const a = cells[i - 1] as { x0: number; x1: number }
1187 const c = cells[i] as { x0: number; x1: number }
1188 if (c.x0 < a.x1) {
1189 const mid = (Math.max(a.x0, c.x0) + Math.min(a.x1, c.x1)) / 2
1190 a.x1 = mid
1191 c.x0 = mid
1192 }
1193 }
1194 return { y0: band.y0, y1: band.y1, cells }
1195 })
1196}
1197
1198// A card beside a zone on the roomier side, narrower when neither fits it.
1199function beside(left: number, span: number, columns: number): { isRight: boolean; width: number } {
1200 const toRight = columns - left - span - 1hooks/cards.ts 54 lines1/* Hover cards for the desktop, in the house's pixel font and scale. */
2import { fitText, glyphText, wrapText } from './font'
3
4const PX = 0.5
5const LINE = 6
6
7const BRIGHT = '#e8e8f0'
8const DIM = '#8c96a0'
9
10// Dark theme colours; light mode gives neutral text the light theme's greys and darkens colour for contrast.
11const LIGHT_CARD = '@media (prefers-color-scheme: light){' +
12 '.vh-p1{fill:#1f2328}.vh-p2{fill:#57606a}' +
13 '.vh-ink{filter:brightness(.6) saturate(1.35)}.vh-cbg{fill:#ffffff}' +
14 '}'
15const NEUTRAL: Record<string, string> = { [BRIGHT]: 'vh-p1', [DIM]: 'vh-p2' }
16
17// Pixel text that follows the theme, each glyph defined once in `glyphs`.
18function say(text: string, x: number, y: number, color: string, glyphs: Map<string, string>): string {
19 return glyphText(text, x, y, { color, cls: NEUTRAL[color] ?? 'vh-ink' }, (id, d) => glyphs.set(id, `<path id="${id}" d="${d}"/>`))
20}
21
22// A card `w` units wide, sized by the caller to the house's pixels. Kept, as cards repeat each redraw,
23// the least recently drawn dropped first: a live agent's card, new each second, leaves the rest be.
24const cards = new Map<string, string>()
25
26export function cardSvg(lines: string[], color: string, w: number): string {
27 const key = JSON.stringify([lines, color, w])
28 const known = cards.get(key)
29 if (known !== undefined) {
30 cards.delete(key)
31 cards.set(key, known)
32 return known
33 }
34 const made = drawCard(lines, color, w)
35 if (cards.size >= 300) cards.delete(cards.keys().next().value as string)
36 cards.set(key, made)
37 return made
38}
39
40function drawCard(lines: string[], color: string, w: number): string {
41 const textW = w - 8
42 const shown = lines.flatMap((line, i) => wrapText(line, textW, PX, 2).map(text => ({ text, i })))
43 const h = 4 + shown.length * LINE + 1
44 const tone = (i: number) => (i === 0 ? color : i === 1 ? BRIGHT : DIM)
45 const frame =
46 `<g class="vh-ink"><rect x="1" y="0" width="${w - 2}" height="1" fill="${color}"/><rect x="1" y="${h - 1}" width="${w - 2}" height="1" fill="${color}"/>` +
47 `<rect x="0" y="1" width="1" height="${h - 2}" fill="${color}"/><rect x="${w - 1}" y="1" width="1" height="${h - 2}" fill="${color}"/></g>` +
48 `<rect class="vh-cbg" x="1" y="1" width="${w - 2}" height="${h - 2}" fill="#0d1117"/>`
49 const glyphs = new Map<string, string>()
50 const text = shown.map((line, k) => say(fitText(line.text, textW, PX), 4, 3 + k * LINE, tone(line.i), glyphs)).join('')
51 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w * 8}" height="${h * 8}" shape-rendering="crispEdges" style="display:block"><style>${LIGHT_CARD}</style>` +
52 `<defs>${[...glyphs.values()].join('')}</defs>${frame}${text}</svg>`
53}
54hooks/house.ts 248 lines1/*
2 * The house: six rooms seen from above at an angle, near walls cut down. Plan x runs to the picture's
3 * lower right, y to its lower left. Back row: library, study, sunroom; front: kitchen, workshop, lounge.
4 */
5import type { Room } from '../types'
6import { Canvas, faces, mat, warm, warmFaces } from './iso'
7import type { Faces, Mat, Pt } from './iso'
8
9export type Dir = 'N' | 'S' | 'E' | 'W'
10export type Cell = { x: number; y: number; w: number; h: number; col: number; row: number }
11// `w`, `h`: picture size; `pw`, `ph`: plan size; `ox`, `oy`: where the plan's corner lands.
12// `extra`: in a tall picture, the garden added above and below the house, in picture units each way.
13export type Layout = { w: number; h: number; pw: number; ph: number; ox: number; oy: number; grid: Room[][]; rooms: Record<Room, Cell>; extra?: number }
14
15// Room floors are S units square; walls are T thick.
16const S = 40
17export const T = 2
18export const TALL_WALL = 26
19const LOW_WALL = 5
20const PAD = 3
21// for the back walls' labels
22const HEADROOM = 4
23// margins: the sides, the trees behind, the yard's far end below
24const SIDE = 14
25const TREES = 10
26const BELOW = 14
27
28const HOUSE: Room[][] = [['library', 'study', 'sunroom'], ['kitchen', 'workshop', 'lounge']]
29export const ALL_ROOMS: Room[] = ['study', 'library', 'workshop', 'kitchen', 'sunroom', 'lounge']
30
31export function house(): Layout {
32 const rooms = {} as Record<Room, Cell>
33 HOUSE.forEach((line, row) => line.forEach((room, col) => {
34 rooms[room] = { x: T + col * (S + T), y: T + row * (S + T), w: S, h: S, col, row }
35 }))
36 const pw = T + 3 * (S + T)
37 const ph = T + 2 * (S + T)
38 const ox = ph + PAD + SIDE
39 const oy = TALL_WALL + PAD + HEADROOM + TREES
40 return { w: pw + ph + 2 * (PAD + SIDE), h: Math.ceil((pw + ph) / 2 + oy + PAD + BELOW), pw, ph, ox, oy, grid: HOUSE, rooms }
41}
42
43// The house in a picture `by` units taller each way, more garden above and below.
44export function stretch(L: Layout, by: number): Layout {
45 return { ...L, oy: L.oy + by, h: L.h + 2 * by, extra: by }
46}
47
48// A plan point at height z, on the picture.
49export function at(L: Layout, x: number, y: number, z = 0): Pt {
50 return { x: x - y + L.ox, y: (x + y) / 2 - z + L.oy }
51}
52
53// Doorways near each inner wall's front end, clear of the furniture at the back.
54const DOOR = 32
55export const doorY = (c: Cell) => c.y + DOOR
56export const doorX = (c: Cell) => c.x + DOOR
57export const floorBottom = (c: Cell) => c.y + c.h - 3
58
59// The front door, where the road starts.
60export function frontDoor(L: Layout): { x0: number; x1: number; mid: number } {
61 const mid = L.rooms.workshop.x + 21
62 return { x0: mid - 5, x1: mid + 5, mid }
63}
64
65// Where idle agents may stand in a room, clear of its furniture.
66export function freeFloor(room: Room, c: Cell): { x0: number; x1: number; y0: number; y1: number } {
67 if (room === 'lounge') return { x0: c.x + 17, x1: c.x + c.w - 4, y0: c.y + 23, y1: c.y + c.h - 3 }
68 return { x0: c.x + 11, x1: c.x + c.w - 5, y0: c.y + 15, y1: c.y + c.h - 3 }
69}
70
71// ---------- materials ----------
72
73const MAT = {
74 slab: faces('ks', '#40362e', '#9c8a76'),
75 trim: warmFaces('kt', '#f3ebde'),
76 low: warmFaces('kw', '#e8dfd1'),
77 glass: mat('kg', '#141c33', '#a9daf5'),
78 star: mat('kx', '#f7e7a6', '#a9daf5'),
79 cloud: mat('kc', '#141c33', '#ffffff'),
80 moon: mat('km', '#f1e3a8', '#fff3b8'),
81 frame: warm('kf', '#fbf8f1'),
82}
83
84// Each room's wallpaper, floor, and `ink` for its name.
85export const ROOM_STYLE: Record<Room, { paper: Mat; stripe: Mat; floor: Mat; seam: Mat; kind: 'planks' | 'tiles' | 'carpet'; ink: string }> = {
86 library: { paper: warm('pl', '#7aa587'), stripe: warm('ql', '#8bb697'), floor: warm('fl', '#a26f47'), seam: warm('gl', '#86593a'), kind: 'planks', ink: '#9fe0b4' },
87 study: { paper: warm('pb', '#7b98c8'), stripe: warm('qb', '#8aa6d3'), floor: warm('fb', '#b3814f'), seam: warm('gb', '#946840'), kind: 'planks', ink: '#9cc4ff' },
88 sunroom: { paper: warm('pc', '#86c7bc'), stripe: warm('qc', '#97d2c8'), floor: warm('fc', '#cf9271'), seam: warm('gc', '#bb7c5c'), kind: 'tiles', ink: '#8ef0d8' },
89 kitchen: { paper: warm('pe', '#d4e3e8'), stripe: warm('qe', '#e4eef1'), floor: warm('fe', '#e3e9ec'), seam: warm('ge', '#bcc8cf'), kind: 'tiles', ink: '#ff9aa2' },
90 workshop: { paper: warm('pw', '#d8b38a'), stripe: warm('qw', '#e1c09a'), floor: warm('fw', '#cda46d'), seam: warm('gw', '#b38b56'), kind: 'planks', ink: '#ffc27a' },
91 lounge: { paper: warm('pq', '#c3b2df'), stripe: warm('qq', '#cfc0e7'), floor: warm('fq', '#bcadd9'), seam: warm('gq', '#c8bbe2'), kind: 'carpet', ink: '#d6b8ff' },
92}
93
94// ---------- patterns ----------
95
96// Floor and wallpaper patterns, on the pixel grid.
97export function patterns(cv: Canvas): string {
98 const parts: string[] = []
99 const use = (m: Mat) => {
100 cv.used.set(m.cls, m)
101 return `class="${m.cls}" fill="${m.night}"`
102 }
103 for (const room of ALL_ROOMS) {
104 const s = ROOM_STYLE[room]
105 if (s.kind === 'planks') {
106 // a seam every four units, a joint now and then
107 let seam = ''
108 for (let i = 0; i < 8; i++) seam += `<rect x="${i}" y="${Math.floor(i / 2)}" width="1" height="1"/>`
109 parts.push(`<pattern id="f-${room}" width="8" height="4" patternUnits="userSpaceOnUse"><rect width="8" height="4" ${use(s.floor)}/><g ${use(s.seam)}>${seam}</g></pattern>`)
110 } else if (s.kind === 'tiles') {
111 parts.push(`<pattern id="f-${room}" width="8" height="4" patternUnits="userSpaceOnUse"><rect width="8" height="4" ${use(s.floor)}/>` +
112 `<path ${use(s.seam)} d="M3 0h2v1h2v2h-2v1h-2v-1h-2v-2h2z"/></pattern>`)
113 } else {
114 parts.push(`<pattern id="f-${room}" width="4" height="4" patternUnits="userSpaceOnUse"><rect width="4" height="4" ${use(s.floor)}/>` +
115 `<path ${use(s.seam)} d="M0 0h1v1h-1zM2 2h1v1h-1z"/></pattern>`)
116 }
117 if (HOUSE[0]?.includes(room) || HOUSE.some(line => line[0] === room)) {
118 parts.push(`<pattern id="w-${room}" width="4" height="1" patternUnits="userSpaceOnUse"><rect width="4" height="1" ${use(s.paper)}/><rect x="3" width="1" height="1" ${use(s.stripe)}/></pattern>`)
119 }
120 }
121 return parts.join('')
122}
123
124// A box stamped once per size and material: stretches of wall, mostly.
125function boxStamp(cv: Canvas, L: Layout, tag: string, x: number, y: number, w: number, d: number, h: number, f: Faces, z = 0): void {
126 cv.stamp(`${tag}${w}x${d}x${h}`, at(L, x, y, z), sub => box(sub, L, x, y, w, d, h, f, z))
127}
128
129// ---------- boxes and faces ----------
130
131// A box: back corner `x`, `y`, `h` tall from `z`; only the visible faces.
132export function box(cv: Canvas, L: Layout, x: number, y: number, w: number, d: number, h: number, f: Faces, z = 0): void {
133 const z1 = z + h
134 cv.poly([at(L, x, y, z1), at(L, x + w, y, z1), at(L, x + w, y + d, z1), at(L, x, y + d, z1)], f.top)
135 cv.poly([at(L, x, y + d, z1), at(L, x + w, y + d, z1), at(L, x + w, y + d, z), at(L, x, y + d, z)], f.left)
136 cv.poly([at(L, x + w, y, z1), at(L, x + w, y + d, z1), at(L, x + w, y + d, z), at(L, x + w, y, z)], f.right)
137}
138
139// A patch on a face looking along y, at depth y: x0 to x1, heights z0 to z1.
140export function faceY(cv: Canvas, L: Layout, y: number, x0: number, x1: number, z0: number, z1: number, m: Mat | string): void {
141 cv.poly([at(L, x0, y, z1), at(L, x1, y, z1), at(L, x1, y, z0), at(L, x0, y, z0)], m)
142}
143
144// The same, looking along x.
145export function faceX(cv: Canvas, L: Layout, x: number, y0: number, y1: number, z0: number, z1: number, m: Mat | string): void {
146 cv.poly([at(L, x, y0, z1), at(L, x, y1, z1), at(L, x, y1, z0), at(L, x, y0, z0)], m)
147}
148
149// ---------- the shell: base, floors, walls ----------
150
151export function shell(cv: Canvas, L: Layout): void {
152 // the low base: only its front sides show
153 faceY(cv, L, L.ph, 0, L.pw, -3, 0, MAT.slab.left)
154 faceX(cv, L, L.pw, 0, L.ph, -3, 0, MAT.slab.right)
155 cv.layer()
156 for (const room of ALL_ROOMS) floor(cv, L, L.rooms[room], `url(#f-${room})`)
157 cv.layer()
158 // the tall back walls, papered per room: bare only at tops, ends, a top band and pillars
159 const top = (pts: Pt[]) => cv.poly(pts, MAT.trim.top)
160 top([at(L, 0, 0, TALL_WALL), at(L, T, T, TALL_WALL), at(L, T, L.ph, TALL_WALL), at(L, 0, L.ph, TALL_WALL)])
161 top([at(L, 0, 0, TALL_WALL), at(L, L.pw, 0, TALL_WALL), at(L, L.pw, T, TALL_WALL), at(L, T, T, TALL_WALL)])
162 faceY(cv, L, L.ph, 0, T, 0, TALL_WALL, MAT.trim.left)
163 faceX(cv, L, L.pw, 0, T, 0, TALL_WALL, MAT.trim.right)
164 faceX(cv, L, T, T, L.ph, TALL_WALL - 1, TALL_WALL, MAT.trim.right)
165 faceY(cv, L, T, T, L.pw, TALL_WALL - 1, TALL_WALL, MAT.trim.left)
166 // each wall's inner front end, past the last room's paper
167 faceX(cv, L, T, L.ph - T, L.ph, 0, TALL_WALL - 1, MAT.trim.right)
168 faceY(cv, L, T, L.pw - T, L.pw, 0, TALL_WALL - 1, MAT.trim.left)
169 for (const room of ALL_ROOMS) {
170 const c = L.rooms[room]
171 if (c.col === 0) cv.stamp('wy', at(L, T, c.y), sub => faceX(sub, L, T, c.y, c.y + c.h, 0, TALL_WALL - 1, 'inherit'), `url(#w-${room})`)
172 if (c.row === 0) cv.stamp('wx', at(L, c.x, T), sub => faceY(sub, L, T, c.x, c.x + c.w, 0, TALL_WALL - 1, 'inherit'), `url(#w-${room})`)
173 if (c.col === 0 && c.row > 0) faceX(cv, L, T, c.y - T, c.y, 0, TALL_WALL - 1, MAT.trim.right)
174 if (c.row === 0 && c.col > 0) faceY(cv, L, T, c.x - T, c.x, 0, TALL_WALL - 1, MAT.trim.left)
175 }
176 cv.layer()
177 for (const room of ALL_ROOMS) {
178 const c = L.rooms[room]
179 // skirting boards
180 if (c.col === 0) faceX(cv, L, T, c.y, c.y + c.h, 0, 2, MAT.trim.right)
181 if (c.row === 0) faceY(cv, L, T, c.x, c.x + c.w, 0, 2, MAT.trim.left)
182 }
183 cv.layer()
184 // low walls between rooms, a doorway in each
185 for (const room of ALL_ROOMS) {
186 const c = L.rooms[room]
187 if (c.col > 0) {
188 boxStamp(cv, L, 'lw', c.x - T, c.y - (c.row > 0 ? T : 0), T, DOOR - 5 + (c.row > 0 ? T : 0), LOW_WALL, MAT.low)
189 boxStamp(cv, L, 'lw', c.x - T, c.y + DOOR + 5, T, c.h - DOOR - 5, LOW_WALL, MAT.low)
190 }
191 if (c.row > 0) {
192 boxStamp(cv, L, 'lw', c.x, c.y - T, DOOR - 5, T, LOW_WALL, MAT.low)
193 boxStamp(cv, L, 'lw', c.x + DOOR + 5, c.y - T, c.w - DOOR - 5, T, LOW_WALL, MAT.low)
194 }
195 }
196 cv.layer()
197}
198
199// A room's floor, filled with its pattern or a lamp's light.
200export function floor(cv: Canvas, L: Layout, c: Cell, fill: string, attrs = '', inner = ''): void {
201 cv.stamp('fl', at(L, c.x, c.y), sub => sub.poly([at(L, c.x, c.y), at(L, c.x + c.w, c.y), at(L, c.x + c.w, c.y + c.h), at(L, c.x, c.y + c.h)], 'inherit'), fill, attrs, inner)
202}
203
204// The cut-down front walls, drawn last to sit in front of the floors.
205export function frontWalls(cv: Canvas, L: Layout): void {
206 // stamped room-long stretches; the front door's split around a doorstep
207 const d = frontDoor(L)
208 for (let x = 0; x < L.pw; x += S + T) {
209 const w = Math.min(S + T, L.pw - x)
210 if (d.x0 > x && d.x1 < x + w) {
211 cv.poly([at(L, d.x0, L.ph - T), at(L, d.x1, L.ph - T), at(L, d.x1, L.ph), at(L, d.x0, L.ph)], MAT.trim.top)
212 box(cv, L, x, L.ph - T, d.x0 - x, T, 3, MAT.low)
213 box(cv, L, d.x1, L.ph - T, x + w - d.x1, T, 3, MAT.low)
214 } else boxStamp(cv, L, 'fw', x, L.ph - T, w, T, 3, MAT.low)
215 }
216 for (let y = 0; y < L.ph - T; y += S + T) boxStamp(cv, L, 'fw', L.pw - T, y, T, Math.min(S + T, L.ph - T - y), 3, MAT.low)
217 cv.layer()
218}
219
220// A back-wall window: frame, sky (stars and moon by night, cloud by day), bars. `along`: which wall.
221export function windowOn(cv: Canvas, L: Layout, along: 'x' | 'y', a0: number, a1: number, z0: number, z1: number, seed: number): void {
222 const origin = along === 'x' ? at(L, a0, T, z1) : at(L, T, a0, z1)
223 cv.stamp(`win-${along}${a1 - a0}x${z1 - z0}s${seed}`, origin, sub => windowDrawn(sub, L, along, a0, a1, z0, z1, seed))
224}
225
226function windowDrawn(cv: Canvas, L: Layout, along: 'x' | 'y', a0: number, a1: number, z0: number, z1: number, seed: number): void {
227 const face = (b0: number, b1: number, c0: number, c1: number, m: Mat) => (along === 'x'
228 ? faceY(cv, L, T, b0, b1, c0, c1, m)
229 : faceX(cv, L, T, b0, b1, c0, c1, m))
230 face(a0, a1, z0, z1, MAT.frame)
231 face(a0 + 1, a1 - 1, z0 + 1, z1 - 1, MAT.glass)
232 cv.layer()
233 const mid = Math.round((a0 + a1) / 2)
234 const span = a1 - a0 - 2
235 // a few stars, fixed by the seed
236 for (let i = 0; i < 4; i++) {
237 const u = a0 + 1.5 + ((seed * 7 + i * 5) % Math.max(1, span - 1))
238 const v = z0 + 2 + ((seed + i * 3) % Math.max(1, z1 - z0 - 4))
239 face(u, u + 1, v, v + 1, MAT.star)
240 }
241 if (seed % 2 === 0) face(a1 - 4, a1 - 2, z1 - 4, z1 - 2, MAT.moon)
242 face(a0 + 2, a0 + 6, z1 - 4, z1 - 3, MAT.cloud)
243 cv.layer()
244 face(mid, mid + 1, z0 + 1, z1 - 1, MAT.frame)
245 face(a0 + 1, a1 - 1, Math.round((z0 + z1) / 2), Math.round((z0 + z1) / 2) + 1, MAT.frame)
246 cv.layer()
247}
248hooks/plan.ts 540 lines1/*
2 * The house in the terminal: its floor plan from above in cells, crisp in any pane. Turned a quarter so a
3 * tall docked pane fits it big, the furniture drawn flat. Agents are Clawds as Claude Code draws its own,
4 * named in their colour, walking room to room; fires, screens and lights flicker.
5 */
6import type { Resident, Room, Track } from '../types'
7import { ALL_ROOMS, doorX, doorY, frontDoor, house, ROOM_STYLE } from './house'
8import type { Cell } from './house'
9import { appFor, bodyColor, colorFor, displayName, LAYOUT, spotOf } from './scene'
10import type { App } from './scene'
11import { yardOf } from './yard'
12
13// the terminal's own colour
14const DEFAULT = 0x01000000
15const L = house()
16const YARD = yardOf(L)
17const ROAD = YARD.road
18// The plan turned: depth runs across, width down; `x`, `y` are the plan's own, `X`, `Y` turned.
19const ACROSS = L.ph
20const DOWN = L.pw
21const YARD_DEPTH = ROAD.y1 - L.ph
22
23// the picture's colours by night
24const C = {
25 wall: '#cbb397', glass: '#9cc4ff', grass: '#1d3324', tuft: '#3f6b45', blade: '#5c8a4c', tree: '#2f6b3a', path: '#4d4842',
26 amber: '#f5c47a', woodDark: '#9a6740', wood: '#b8834f', pine: '#d6b07a', white: '#f4f2ec', stone: '#9ea3aa',
27 dark: '#24282e', brick: '#b5644f', brass: '#d8b25a', leaf: '#5fae62', linen: '#fbf9f4', blue: '#6f8fd6', green: '#79b89a',
28 armchair: '#5d8f76', couch: '#c77fae', couchBack: '#a8618f', shade: '#f5dca0', red: '#d9584c', parcel: '#c9965a',
29 mail: '#d0782e', zz: '#cfd8ff', steam: '#c9cdd3',
30}
31const BOOKS = ['#c75a5a', '#5a86c7', '#c7ad5a', '#5ac79a', '#9a5ac7', '#c7865a']
32const RUGS: Partial<Record<Room, [string, string]>> = { library: ['#b85c5c', '#d07a7a'], lounge: ['#d9a9c9', '#e8bfd8'] }
33const FIRE = ['#f5a623', '#ffd166', '#f26b3a']
34const TV = ['#5ad0ff', '#ff6ad5', '#ffd34a', '#7df08a']
35const LAMP = { working: '#4cf08c', waiting: '#f5ac45', failed: '#f2503f' }
36
37// Claude Code's own Clawd, nine cells by three, its legs stepping as it walks.
38const CLAWD = [' ▐▛███▜▌ ', '▝▜█████▛▘', ' ▘▘ ▝▝ ']
39const STEPPING = ' ▝▝ ▘▘ '
40const CLAWD_W = 9
41
42// box drawing by a line's arms: up 1, right 2, down 4, left 8
43const LINES = ' ╵╶└╷│┌├╴┘─┴┐┤┬┼'
44
45// What a terminal draws one cell wide, all a Raster takes; anything else becomes a question mark.
46const NARROW = /^[\x20-\x7e\u00a0-\u00ac\u00ae-\u024f\u2010-\u2027\u2261\u2500-\u25a1\u25ac\u25b2\u25cb\u25cf\u2663\u273d]$/
47const letter = (ch: string) => (NARROW.test(ch) ? ch.codePointAt(0) ?? 63 : 63)
48
49// A cell per letter: joiners, variation selectors and skin tones dropped, so a joined emoji is one
50// letter (a question mark: two cells wide).
51const SHAPER = /^(?:[\ufe0e\ufe0f]|[\u{1f3fb}-\u{1f3ff}])$/u
52function lettersOf(text: string): string[] {
53 const out: string[] = []
54 let joined = false
55 for (const ch of text) {
56 if (ch === '\u200d') joined = true
57 else if (SHAPER.test(ch)) continue
58 else if (joined) joined = false
59 else out.push(ch)
60 }
61 return out
62}
63
64// colours as numbers, each parsed once: the plan is rebuilt every redraw
65const inks = new Map<string, number>()
66const hex = (c: string) => {
67 let n = inks.get(c)
68 if (n === undefined) {
69 n = parseInt(c.replace('#', '').padEnd(6, '0').slice(0, 6), 16)
70 if (inks.size > 512) inks.clear()
71 inks.set(c, n)
72 }
73 return n
74}
75// a colour toward black, so things read on a floor
76const dim = (c: string, f: number) => {
77 const n = hex(c)
78 return (Math.round(((n >> 16) & 255) * f) << 16) | (Math.round(((n >> 8) & 255) * f) << 8) | Math.round((n & 255) * f)
79}
80const dimmed = (c: string, f: number) => `#${dim(c, f).toString(16).padStart(6, '0')}`
81
82// A cell of lawn, now and then a tuft in one of two greens.
83const TUFTS = ['"', ',', "'", '"']
84function grassAt(k: number, row: number): [string, string] {
85 const n = ((Math.imul(k, 73856093) ^ Math.imul(row, 19349663)) >>> 0) % 19
86 return n < TUFTS.length ? [TUFTS[n] as string, n % 2 ? C.blade : C.tuft] : [' ', C.tuft]
87}
88
89// One agent: who, and its walk.
90export type Walker = { c: Resident; t: Track }
91
92// A cell cycling glyphs and inks every `ms`. Turns land on BEATs of whole house frames, so frames
93// between change nothing and are not sent.
94const BEAT = 400
95type Flicker = { at: number; frames: [string, string][]; ms: number }
96type Frames = { at: number; frames: [number, number][]; ms: number }
97
98// A room's box in cells: name row, floor rows below, columns between its walls.
99type Box = { head: number; top: number; bottom: number; left: number; right: number }
100
101// The plan for a pane: fixed cells, flickers, where agents may stand, beds for naps.
102export type Plan = {
103 columns: number; rows: number
104 base: Uint32Array; flickers: Frames[]; walkable: Uint8Array
105 cellOf: (x: number, y: number) => [number, number]
106 boxOf: (col: number, row: number) => Box
107 beds: [number, number][]
108}
109
110// The plan's size in a pane `columns` by `bodyRows`: rooms two across and three down beside the yard, each
111// up to about square; rooms and yard each fit a Clawd, all within what a Raster takes.
112type PlanSize = { columns: number; across: number; down: number; rows: number }
113export const RASTER_COLUMNS = 512
114export const RASTER_ROWS = 256
115
116export function planSize(columns: number, bodyRows: number): PlanSize {
117 const yard = Math.max(CLAWD_W + 1, Math.min(18, Math.round(columns * 0.2)))
118 const across = Math.max(CLAWD_W + 2, Math.min(Math.floor((RASTER_COLUMNS - yard - 1) / 2), Math.floor((columns - yard - 1) / 2)))
119 const down = Math.max(5, Math.min(Math.floor((RASTER_ROWS - 1) / 3), Math.round(across / 1.8), Math.floor((bodyRows - 1) / 3)))
120 return { columns: 2 * across + 1 + yard, across, down, rows: 3 * down + 1 }
121}
122
123// desk screen colours by tool, as in the picture
124const SCREENS: Record<App, string> = { code: '#5ad0ff', diff: '#5ad0ff', term: '#4cf08c', web: '#52d2f5', files: '#e6c35c', notes: '#f4f1e8' }
125const screenOf = (tool: string | undefined) => (tool ? SCREENS[appFor(tool)] : C.dark)
126
127// The plan: rooms `across` cells wide and `down` tall, walls included.
128export function planOf(size: PlanSize, boxes: number, residents: Resident[]): Plan {
129 const { columns, across, down, rows } = size
130 const houseRight = 2 * across
131 const yardCols = columns - houseRight - 1
132 const base = new Uint32Array(columns * rows * 3)
133 const walkable = new Uint8Array(columns * rows)
134 const flickers: Flicker[] = []
135 const at = (k: number, r: number) => r * columns + k
136 const inside = (k: number, r: number) => k >= 0 && k < columns && r >= 0 && r < rows
137 // a cell; its paper only if given
138 const set = (k: number, r: number, ch: string, ink: string | number, paper?: string | number) => {
139 if (!inside(k, r)) return
140 const i = at(k, r) * 3
141 base[i] = letter(ch)
142 base[i + 1] = typeof ink === 'number' ? ink : hex(ink)
143 if (paper !== undefined) base[i + 2] = typeof paper === 'number' ? paper : hex(paper)
144 }
145 const write = (k: number, r: number, text: string, ink: string) => lettersOf(text).forEach((ch, i) => set(k + i, r, ch, ink))
146 for (let i = 0; i < base.length; i += 3) {
147 base[i] = 0x20
148 base[i + 1] = DEFAULT
149 base[i + 2] = DEFAULT
150 }
151
152 // Turned plan units to cells, walls on whole cells, each room's name row above its floor.
153 const colOf = (X: number) => (X <= ACROSS
154 ? Math.max(0, Math.min(houseRight, Math.round(((X - 1) / (ACROSS - 2)) * houseRight)))
155 : houseRight + Math.min(yardCols, Math.ceil(((X - ACROSS) / YARD_DEPTH) * yardCols)))
156 const wallRow = (Y: number) => Math.max(0, Math.min(3 * down, Math.round(((Y - 1) / (DOWN - 2)) * 3 * down)))
157 const rooms = Object.values(L.rooms)
158 const boxOfRoom = (c: Cell): Box => {
159 const head = wallRow(c.x - 1) + 1
160 return { head, top: head + 1, bottom: wallRow(c.x + c.w + 1) - 1, left: colOf(c.y - 1), right: colOf(c.y + c.h + 1) }
161 }
162 const roomBoxes = rooms.map(boxOfRoom)
163 const rowOf = (X: number, Y: number) => {
164 const i = X <= ACROSS ? rooms.findIndex(r => Y >= r.x && Y <= r.x + r.w) : -1
165 const c = rooms[i]
166 const b = roomBoxes[i]
167 if (!c || !b) return wallRow(Y)
168 return Math.min(b.bottom, b.top + Math.floor(((Y - c.x) / c.w) * (b.bottom - b.top + 1)))
169 }
170 const cellOf = (x: number, y: number): [number, number] => [colOf(y), rowOf(y, x)]
171 // a rect of the floor plan as cells, at least one
172 const fill = (x0: number, y0: number, x1: number, y1: number, paint: (k: number, r: number, i: number, j: number) => void) => {
173 const [a, b] = [cellOf(x0 + 0.5, y0 + 0.5), cellOf(x1 - 0.5, y1 - 0.5)]
174 const [k0, k1, r0, r1] = [Math.min(a[0], b[0]), Math.max(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[1], b[1])]
175 for (let r = r0; r <= r1; r++) for (let k = k0; k <= k1; k++) paint(k, r, k - k0, r - r0)
176 }
177
178 // floors (planks in rows, tiles checkered, carpet plain) under a wallpaper strip: name and activity
179 for (const name of ALL_ROOMS) {
180 const c = L.rooms[name]
181 const style = ROOM_STYLE[name]
182 const b = boxOfRoom(c)
183 for (let r = b.head; r <= b.bottom; r++) {
184 for (let k = b.left + 1; k < b.right; k++) {
185 const shade = style.kind === 'tiles' ? ((k + r) % 2 ? 0.3 : 0.25) : style.kind === 'planks' ? (r % 2 ? 0.3 : 0.26) : 0.28
186 set(k, r, ' ', DEFAULT, r === b.head ? dim(style.paper.night, 0.32) : dim(style.floor.night, shade))
187 if (r > b.head) walkable[at(k, r)] = 1
188 }
189 }
190 write(b.left + 2, b.head, name, style.ink)
191 const busy = residents.find(m => m.room === name && (m.status === 'working' || m.status === 'waiting') && m.leaving === undefined)
192 if (busy) {
193 const doing = lettersOf(busy.status === 'waiting' ? `wants ${busy.waitingTool ?? 'you'}` : busy.activity)
194 const from = b.left + name.length + 4
195 const room = b.right - 1 - from
196 if (room > 3) write(from, b.head, doing.length > room ? `${doing.slice(0, room - 1).join('')}…` : doing.join(''), busy.status === 'waiting' ? C.amber : dimmed(style.ink, 0.75))
197 }
198 }
199
200 // the rugs, under everything on them
201 const R = L.rooms
202 for (const [name, [edge, middle]] of Object.entries(RUGS) as [Room, [string, string]][]) {
203 const [x0, y0, x1, y1] = name === 'library' ? [12, 15, 34, 33] : [15, 21, 37, 36]
204 const [a, b] = [cellOf(R[name].x + x0, R[name].y + y0), cellOf(R[name].x + x1, R[name].y + y1)]
205 const [k0, k1, r0, r1] = [Math.min(a[0], b[0]), Math.max(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[1], b[1])]
206 for (let r = r0; r <= r1; r++) {
207 for (let k = k0; k <= k1; k++) set(k, r, ' ', DEFAULT, dim(r === r0 || r === r1 || k === k0 || k === k1 ? edge : middle, 0.55))
208 }
209 }
210
211 // Furniture, a little of each piece, against the same walls as in the picture.
212 const piece = (room: Room, x0: number, y0: number, x1: number, y1: number, paint: (k: number, r: number, i: number, j: number) => void) =>
213 fill(R[room].x + x0, R[room].y + y0, R[room].x + x1, R[room].y + y1, paint)
214 // a small thing: one cell at any pane size
215 const spot = (room: Room, x: number, y: number, ch: string, ink: string, paper?: string, frames?: [string, string][], ms = BEAT) => {
216 const [k, r] = cellOf(R[room].x + x, R[room].y + y)
217 set(k, r, ch, ink, paper)
218 if (frames) flickers.push({ at: at(k, r), frames, ms })
219 }
220 // desks on each work room's back wall, screens lit by whoever works there, a light by each
221 for (const name of ALL_ROOMS) {
222 if (name === 'lounge') continue
223 for (let d = 0; d < 2; d++) {
224 const sitter = residents.find(c => c.room === name && c.seat === d && (c.status === 'working' || c.status === 'waiting') && c.leaving === undefined)
225 const lit = sitter ? screenOf(sitter.tool) : C.dark
226 const x0 = 2 + 14 * d
227 piece(name, x0, 1, x0 + 12, 7, (k, r, i, j) => {
228 const isScreen = i === 0 && j > 0 && j < 3
229 set(k, r, isScreen ? '▌' : ' ', lit, name === 'kitchen' ? C.stone : C.wood)
230 if (isScreen && sitter) flickers.push({ at: at(k, r), frames: [['▌', lit], ['▌', lit], ['▌', dimmed(lit, 0.6)]], ms: BEAT * j })
231 })
232 if (sitter) {
233 const light = sitter.status === 'waiting' ? LAMP.waiting : sitter.isOk === false ? LAMP.failed : LAMP.working
234 spot(name, x0 + 11, 4, '•', light, name === 'kitchen' ? C.stone : C.wood, [['•', light], ['•', C.woodDark]], 2 * BEAT)
235 }
236 }
237 }
238 // library: bookshelves, reading lamp
239 piece('library', 0, 9, 5, 27, (k, r, i, j) => set(k, r, '▄', BOOKS[(i * 2 + j) % BOOKS.length] ?? C.red, BOOKS[(i * 2 + j + 3) % BOOKS.length] ?? C.blue))
240 spot('library', 33.5, 3.5, '●', C.shade)
241 // study: fireplace, armchair, plant, lamp
242 piece('study', 29, 1, 39, 6, (k, r) => set(k, r, '▐', C.dark, C.brick))
243 for (const x of [32.5, 35.5]) spot('study', x, 4, '▲', FIRE[0] ?? C.red, C.dark, FIRE.map((f, n) => ['▲', FIRE[(n + Math.round(x)) % FIRE.length] ?? f]), BEAT)
244 piece('study', 1, 16, 8, 23, (k, r, _, j) => set(k, r, j === 0 ? '▀' : ' ', C.armchair, C.green))
245 spot('study', 3.5, 10.5, '♣', C.leaf)
246 spot('study', 1.5, 26.5, '●', C.shade)
247 // sunroom: telescope, radio, plant
248 spot('sunroom', 6, 14, '╲', C.brass)
249 spot('sunroom', 3.5, 21.5, '■', C.dark, C.wood)
250 spot('sunroom', 3.5, 24, '•', LAMP.working, C.wood, [['•', LAMP.working], ['•', '#1f5f3a']], 2 * BEAT)
251 spot('sunroom', 35.5, 4.5, '♣', C.leaf)
252 // kitchen: fridge, stove and steaming pot, coffee machine
253 piece('kitchen', 0, 8, 6, 15, (k, r, _, j) => set(k, r, j === 0 ? '▀' : ' ', C.stone, C.white))
254 piece('kitchen', 0, 15, 6, 27, (k, r) => set(k, r, ' ', DEFAULT, C.stone))
255 spot('kitchen', 2.5, 17.5, '●', C.red, C.stone, [['●', C.red], ['●', C.red], ['~', C.steam]], BEAT)
256 spot('kitchen', 2.5, 21, '○', C.dark, C.stone)
257 spot('kitchen', 2.5, 24.5, '■', C.dark, C.stone, [['■', C.dark], ['■', C.red]], 3 * BEAT)
258 // workshop: workbench, toolbox, vise, level, plant
259 piece('workshop', 0, 9, 7, 24, (k, r) => set(k, r, ' ', DEFAULT, C.pine))
260 spot('workshop', 2.5, 12, '▬', C.red, C.pine)
261 spot('workshop', 2.5, 18, '≡', C.dark, C.pine)
262 spot('workshop', 4.5, 21.5, '▬', C.brass, C.pine)
263 spot('workshop', 36.5, 36.5, '♣', C.leaf)
264 // lounge: two beds, TV, couch, plant
265 for (let b = 0; b < 2; b++) {
266 piece('lounge', 0, 1 + 11 * b, 15, 10 + 11 * b, (k, r, _, j) => set(k, r, j === 0 ? ' ' : '▄', b === 0 ? C.blue : C.green, j === 0 ? C.linen : b === 0 ? C.blue : C.green))
267 }
268 piece('lounge', 18, 0, 28, 4, (k, r, i) => {
269 set(k, r, '▌', C.dark, C.wood)
270 if (i === 0) flickers.push({ at: at(k, r), frames: TV.map(c => ['▌', c] as [string, string]), ms: 2 * BEAT })
271 })
272 piece('lounge', 17, 13, 30, 18, (k, r, i) => set(k, r, i === 0 ? ' ' : '▐', C.couchBack, C.couch))
273 spot('lounge', 36.5, 36.5, '♣', C.leaf)
274 const beds: [number, number][] = [[8, 5.5], [8, 16.5]].map(([x, y]) => cellOf(R.lounge.x + (x ?? 0), R.lounge.y + (y ?? 0)))
275
276 // The walls, but for doorways, with windows in the outer walls.
277 const arms = new Uint8Array(columns * rows)
278 const line = (k0: number, r0: number, k1: number, r1: number) => {
279 for (let r = r0; r <= r1; r++) {
280 for (let k = k0; k <= k1; k++) {
281 const i = at(k, r)
282 if (k0 === k1 && r0 !== r1) arms[i] = (arms[i] ?? 0) | (r > r0 ? 1 : 0) | (r < r1 ? 4 : 0)
283 else arms[i] = (arms[i] ?? 0) | (k > k0 ? 8 : 0) | (k < k1 ? 2 : 0)
284 }
285 }
286 }
287 const walls = [0, down, 2 * down, 3 * down]
288 for (const k of [0, across, houseRight]) line(k, 0, k, 3 * down)
289 for (const r of walls) line(0, r, houseRight, r)
290 const gap = (k0: number, r0: number, k1: number, r1: number) => {
291 for (let r = r0; r <= r1; r++) {
292 for (let k = k0; k <= k1; k++) {
293 arms[at(k, r)] = 0
294 walkable[at(k, r)] = 1
295 }
296 }
297 }
298 for (const c of rooms) {
299 // doorways to the rooms once beside it (now below) and once below it (now beside)
300 if (c.col < 2) {
301 const r = boxOfRoom(c).bottom + 1
302 gap(colOf(doorY(c) - 3), r, colOf(doorY(c) + 3), r)
303 }
304 if (c.row < 1) gap(across, cellOf(doorX(c), c.y + c.h - 1)[1], across, cellOf(doorX(c), c.y + c.h - 1)[1])
305 }
306 const door = frontDoor(L)
307 gap(houseRight, cellOf(door.x0 + 2, L.ph - 2)[1], houseRight, cellOf(door.x1 - 2, L.ph - 2)[1])
308 const glass = new Set<number>()
309 for (const c of rooms) {
310 const b = boxOfRoom(c)
311 if (c.y < L.ph / 2) for (let r = b.top + 1; r < b.bottom; r++) glass.add(at(0, r))
312 if (c.x < 10) for (let k = b.left + 3; k < b.right - 3; k++) glass.add(at(k, 0))
313 if (c.x > L.pw - 50) for (let k = b.left + 3; k < b.right - 3; k++) glass.add(at(k, 3 * down))
314 }
315 for (let r = 0; r < rows; r++) {
316 for (let k = 0; k < columns; k++) {
317 const i = at(k, r)
318 if (!arms[i]) continue
319 const has = (dk: number, dr: number) => inside(k + dk, r + dr) && (arms[at(k + dk, r + dr)] ?? 0) > 0
320 const a = (arms[i] as number) & ((has(0, -1) ? 1 : 0) | (has(1, 0) ? 2 : 0) | (has(0, 1) ? 4 : 0) | (has(-1, 0) ? 8 : 0))
321 walkable[i] = 0
322 const isGlass = glass.has(i) && (a === 10 || a === 5)
323 set(k, r, isGlass ? (a === 10 ? '═' : '║') : LINES[a] ?? ' ', isGlass ? C.glass : C.wall, DEFAULT)
324 }
325 }
326
327 // The yard: lawn, trees, road, the day's boxes, mailbox.
328 const [roadTop, roadBottom] = [cellOf(ROAD.x0 + 1, L.ph + 1)[1], cellOf(ROAD.x1 - 1, L.ph + 1)[1]]
329 for (let r = 0; r < rows; r++) {
330 for (let k = houseRight + 1; k < columns; k++) {
331 const isRoad = r >= roadTop && r <= roadBottom
332 const [ch, ink] = isRoad ? [' ', C.tuft] : grassAt(k, r)
333 set(k, r, ch, ink, isRoad ? C.path : C.grass)
334 walkable[at(k, r)] = 1
335 }
336 }
337 for (const [x, y] of [[20, 100], [110, 120], [40, 128], [100, 105]] as [number, number][]) {
338 const [k, r] = cellOf(x, y)
339 if (r < roadTop - 1 || r > roadBottom + 1) set(k, r, '♣', C.tree)
340 }
341 const count = `${boxes}`
342 const squares = Math.max(0, Math.min(3, boxes, columns - houseRight - 3 - count.length))
343 write(houseRight + 2, roadTop - 2, `${'■'.repeat(squares)}${squares > 0 ? ' ' : ''}${count}`, C.parcel)
344 set(columns - 2, roadTop - 1, '▐', C.mail)
345
346 // the room a cell is in, for a doorway either one; past the house, the yard
347 const yard: Box = { head: 0, top: 1, bottom: rows - 1, left: houseRight, right: columns }
348 const boxOf = (k: number, r: number): Box => (k >= houseRight ? yard
349 : roomBoxes.find(b => k > b.left && k < b.right && r >= b.head && r <= b.bottom) ??
350 roomBoxes.find(b => k >= b.left && k <= b.right && r >= b.head - 1 && r <= b.bottom + 1) ?? yard)
351 // one flicker per cell, the first; none where a wall covers it in a tiny pane
352 const flickering = new Set<number>()
353 const frames = flickers
354 .filter(f => !arms[f.at] && !flickering.has(f.at) && flickering.add(f.at))
355 .map(f => ({ at: f.at, ms: f.ms, frames: f.frames.map(([ch, ink]): [number, number] => [letter(ch), hex(ink)]) }))
356 return { columns, rows, base, flickers: frames, walkable, cellOf, boxOf, beds }
357}
358
359// A cell the walker may stand on at `now`; none out of sight.
360export function cellAt(plan: Plan, w: Walker, now: number): [number, number] | undefined {
361 const p = spotOf(w.t, now)
362 if (!p) return undefined
363 // the picture's point back on the floor plan
364 const { ox, oy } = LAYOUT
365 const [k, r] = plan.cellOf((p.x - ox) / 2 + (p.y - oy), p.y - oy - (p.x - ox) / 2)
366 const ok = (dk: number, dr: number) => k + dk >= 0 && k + dk < plan.columns && plan.walkable[(r + dr) * plan.columns + k + dk] === 1
367 const near = [[0, 0], [1, 0], [-1, 0], [0, -1], [0, 1], [1, 1], [-1, -1], [1, -1], [-1, 1]].find(([dk, dr]) => ok(dk ?? 0, dr ?? 0))
368 return near ? [k + (near[0] ?? 0), r + (near[1] ?? 0)] : [k, r]
369}
370
371// A Clawd's place, feet at `cell`, in the room; its name's row below, else above, else none.
372function spriteAt(plan: Plan, cell: [number, number]): { left: number; feet: number; name: number } {
373 const [k, r] = cell
374 const b = plan.boxOf(k, r)
375 const feet = Math.max(b.top + 2, Math.min(b.bottom, r))
376 const left = Math.max(b.left + 1, Math.min(b.right - CLAWD_W, k - 2))
377 return { left, feet, name: feet + 1 <= b.bottom ? feet + 1 : feet - 3 > b.head ? feet - 3 : -1 }
378}
379
380// A name's columns, centred under its Clawd in the room, to one past its last; none if it cannot fit.
381function nameSpan(plan: Plan, w: Walker, s: { left: number; feet: number; name: number }): [number, number] | undefined {
382 if (s.name < 0) return undefined
383 const length = lettersOf(displayName(w.c)).length
384 const b = plan.boxOf(s.left + 4, s.feet)
385 const start = Math.max(b.left + 1, Math.min(b.right - length, s.left + 4 - Math.floor(length / 2)))
386 return start >= 0 && start + length <= plan.columns ? [start, start + length] : undefined
387}
388
389// The cells a Clawd and its name cover, feet at `cell`.
390export function reachOf(plan: Plan, w: Walker, cell: [number, number]): [number, number, number, number] {
391 const s = spriteAt(plan, cell)
392 const span = nameSpan(plan, w, s)
393 const row = span ? s.name : s.feet
394 return [
395 Math.max(0, Math.min(s.left, span?.[0] ?? s.left)), Math.max(0, Math.min(s.feet - 2, row)),
396 Math.min(plan.columns, Math.max(s.left + CLAWD_W, span?.[1] ?? 0)), Math.min(plan.rows, Math.max(s.feet, row) + 1),
397 ]
398}
399
400// The garden for rows a taller pane leaves above or below the plan; fixed per size.
401const AUTUMN = ['#e2702a', '#cf3a2a', '#e8b23a', '#f0663e']
402const FLOWERS = ['#e48aa6', '#b89ae6', '#f2cf5c', '#f4f2ec']
403
404const gardens = new Map<string, string>()
405
406// Garden rows packed for a Raster, once per size.
407export function gardenRaster(columns: number, rows: number, side: 'above' | 'below'): string {
408 const key = `${columns}x${rows}:${side}`
409 const known = gardens.get(key)
410 if (known !== undefined) return known
411 if (gardens.size > 16) gardens.clear()
412 const packed = packCells(gardenCells(columns, rows, side))
413 gardens.set(key, packed)
414 return packed
415}
416
417function gardenCells(columns: number, rows: number, side: 'above' | 'below'): Uint32Array {
418 const cells = new Uint32Array(columns * rows * 3)
419 let seed = (columns * 131 + rows * 7 + (side === 'above' ? 0 : 977)) >>> 0
420 const r = () => (seed = (Math.imul(seed, 1103515245) + 12345) >>> 0) / 4294967296
421 const pick = <T,>(list: T[]) => list[Math.floor(r() * list.length)] as T
422 const taken = new Set<number>()
423 const set = (k: number, row: number, ch: string, ink: string, bg = C.grass) => {
424 if (k < 0 || k >= columns || row < 0 || row >= rows || taken.has(row * columns + k)) return false
425 cells.set([letter(ch), hex(ink), hex(bg)], (row * columns + k) * 3)
426 return true
427 }
428 for (let row = 0; row < rows; row++) {
429 for (let k = 0; k < columns; k++) {
430 const [ch, ink] = grassAt(k, side === 'above' ? -1 - row : 1000 + row)
431 set(k, row, ch, ink)
432 }
433 }
434 const area = columns * rows
435 // a pond, its far corners off
436 const pond = (pw: number, ph: number) => {
437 const pk = 1 + Math.floor(r() * Math.max(1, columns - pw - 2))
438 const pr = Math.floor(r() * Math.max(1, rows - ph + 1))
439 for (let dr = 0; dr < ph; dr++) {
440 for (let dk = dr === ph - 1 && ph > 1 ? 1 : 0; dk < pw - (dr === ph - 1 && ph > 1 ? 1 : 0); dk++) {
441 if (set(pk + dk, pr + dr, (pk + dk + pr) % 3 === 0 ? '~' : ' ', C.glass, '#16202e')) taken.add((pr + dr) * columns + pk + dk)
442 }
443 }
444 }
445 if (rows >= 4 && columns >= 16) for (let n = Math.max(1, Math.round(area / 1200)); n > 0; n--) pond(6 + Math.floor(r() * 4), 2)
446 for (let n = Math.round(area / 400); n > 0; n--) pond(3 + Math.floor(r() * 3), 1)
447 // a tree, and its leaves round it
448 const plant = (k: number, row: number, isPine: boolean, ink: string) => {
449 if (!set(k, row, isPine ? '▲' : '♣', ink)) return
450 taken.add(row * columns + k)
451 if (!isPine) for (let m = Math.floor(r() * 2); m > 0; m--) set(k + pick([-2, -1, 1, 2]), row + pick([0, 0, 1]), pick(['·', ',']), ink)
452 }
453 // copses of two to four, pines or one autumn colour
454 for (let n = Math.round(area / 90); n > 0; n--) {
455 const [k, row] = [Math.floor(r() * columns), Math.floor(r() * rows)]
456 const isPine = r() < 0.3
457 const ink = isPine ? C.tree : pick(AUTUMN)
458 for (let m = 2 + Math.floor(r() * 3); m > 0; m--) plant(k + Math.round((r() - 0.5) * 7), row + Math.round((r() - 0.5) * 3), isPine, ink)
459 }
460 for (let n = Math.round(area / 115); n > 0; n--) {
461 const isPine = r() < 0.3
462 plant(Math.floor(r() * columns), Math.floor(r() * rows), isPine, isPine ? C.tree : pick(AUTUMN))
463 }
464 // flowers in twos and threes
465 for (let n = Math.round(area / 40); n > 0; n--) {
466 const k = Math.floor(r() * columns)
467 const row = Math.floor(r() * rows)
468 const ink = pick(FLOWERS)
469 for (let m = 2 + Math.floor(r() * 2), dk = 0; m > 0; m--, dk += 1 + Math.floor(r() * 2)) set(k + dk, row + (m % 2), pick(['*', '✽', '·']), ink)
470 }
471 // leaves blown about the lawn
472 for (let n = Math.round(area / 34); n > 0; n--) set(Math.floor(r() * columns), Math.floor(r() * rows), pick(['·', ',', '`']), pick(AUTUMN))
473 return cells
474}
475
476// The plan at `now`: codepoint, ink and paper per cell, row by row.
477export function planCells(plan: Plan, walkers: Walker[], now: number): Uint32Array {
478 const out = plan.base.slice()
479 const { columns } = plan
480 for (const f of plan.flickers) {
481 const [ch, ink] = f.frames[Math.floor(now / f.ms) % f.frames.length] ?? [0x20, 0]
482 out[f.at * 3] = ch
483 out[f.at * 3 + 1] = ink
484 }
485 const taken = new Uint8Array(columns * plan.rows)
486 const put = (k: number, r: number, ch: string, ink: string, isWord = false) => {
487 const i = r * columns + k
488 if (k < 0 || k >= columns || r < 0 || r >= plan.rows || (isWord && taken[i])) return false
489 taken[i] = 1
490 out[i * 3] = letter(ch)
491 out[i * 3 + 1] = hex(ink)
492 return true
493 }
494 // the Clawds first, nearest the front last, so a word never hides one
495 const placed = walkers
496 .map(w => ({ w, cell: cellAt(plan, w, now), before: cellAt(plan, w, now - 250) }))
497 .filter((p): p is typeof p & { cell: [number, number] } => p.cell !== undefined)
498 .sort((a, b) => a.cell[1] - b.cell[1])
499 .map(p => ({ ...p, s: spriteAt(plan, p.cell) }))
500 for (const { w, cell, before, s } of placed) {
501 const isWalking = before !== undefined && (before[0] !== cell[0] || before[1] !== cell[1])
502 const body = w.c.status === 'waiting' ? C.amber : bodyColor(w.c)
503 const lines = [CLAWD[0] ?? '', CLAWD[1] ?? '', isWalking && Math.floor(now / (BEAT / 2)) % 2 ? STEPPING : CLAWD[2] ?? '']
504 lines.forEach((line, j) => [...line].forEach((ch, i) => ch !== ' ' && put(s.left + i, s.feet - 2 + j, ch, body)))
505 // a nap in a bed: z's rising
506 if (!isWalking && plan.beds.some(([k, r]) => Math.abs(k - cell[0]) <= 3 && Math.abs(r - cell[1]) <= 2)) {
507 put(s.left + CLAWD_W - 1, s.feet - 2 - (Math.floor(now / (2 * BEAT)) % 2), 'z', C.zz, true)
508 }
509 if (w.c.status === 'waiting' && Math.floor(now / BEAT) % 2) put(s.left + CLAWD_W - 1, s.feet - 2, '!', C.amber, true)
510 }
511 // the names, under their Clawds
512 for (const { w, s } of placed) {
513 const span = nameSpan(plan, w, s)
514 if (!span) continue
515 const [start] = span
516 const name = lettersOf(displayName(w.c))
517 if (name.some((_, i) => taken[s.name * columns + start + i])) continue
518 name.forEach((ch, i) => put(start + i, s.name, ch, colorFor(w.c), true))
519 }
520 return out
521}
522
523// Cells as a Raster takes them: little-endian words in base64; on Claude Code's little-endian machines
524// the bytes go as they are.
525const LITTLE = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1
526
527export function packCells(cells: Uint32Array): string {
528 let bytes = new Uint8Array(cells.buffer, cells.byteOffset, cells.byteLength)
529 if (!LITTLE) {
530 bytes = new Uint8Array(cells.length * 4)
531 const view = new DataView(bytes.buffer)
532 cells.forEach((w, i) => view.setUint32(i * 4, w, true))
533 }
534 const native = (bytes as Uint8Array & { toBase64?: () => string }).toBase64
535 if (native) return native.call(bytes)
536 let text = ''
537 for (let i = 0; i < bytes.length; i += 0x2000) text += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x2000) as unknown as number[])
538 return btoa(text)
539}
540hooks/scene.ts 2178 lines1/*
2 * The house: six rooms seen from above at an angle, near walls cut down (see house.ts, furniture.ts).
3 * Working agents type at the desk for their kind of work; idle ones drift through moods, use the
4 * furniture and huddle up to talk in runes.
5 *
6 * Plans are made on the floor plan and projected into layered SVGs (garden, house, agents), animated
7 * with SMIL timelines anchored to the clock so a redraw picks every agent up mid-stride.
8 */
9import type { Resident, Place, Room, Track } from '../types'
10import { fitText, glyphText, pixelText, textWidth } from './font'
11import { desk, deskAt, furniture, machine } from './furniture'
12import { frontTrees, garden, gardenPatterns, lamps, treesBehind, weather } from './garden'
13import { boxSpots, road, signAt, yardOf, yardPieces } from './yard'
14import type { Parcel } from './yard'
15import { ALL_ROOMS, at, doorX, doorY, floor, floorBottom, freeFloor, frontDoor, frontWalls, house, patterns, ROOM_STYLE, shell, stretch, T, TALL_WALL, windowOn } from './house'
16import type { Cell, Dir, Layout } from './house'
17import { Canvas, loopBegin, pathOf, rng, setClock, shade, styleOf } from './iso'
18import type { Pt } from './iso'
19
20// Seconds in every idle loop, shared so meetings and props line up.
21const LOOP = 90
22// Pixel text: each font pixel half an art pixel; an agent's name smaller.
23const PX = 0.5
24const NAME_PX = 0.375
25
26const CLAUDE_ORANGE = '#d97757'
27
28const ACC = { work: '#4cf08c', data: '#52d2f5', alert: '#f2503f' }
29const MACHINE_GLOW: Record<Exclude<Room, 'lounge'>, string> = {
30 library: '#e6c35c', sunroom: ACC.data, kitchen: ACC.work, workshop: '#e6c35c', study: ACC.work,
31}
32const PROP_FAIL = '#f25a5a'
33const LAMP = { idle: '#f59a3a', working: '#4cf08c', waiting: '#f5ac45', failed: ACC.alert }
34const PHOSPHOR = '#66f5a0'
35
36type Span = [number, number]
37type Run = { x: number; y: number; w: number; h: number; c: string }
38
39// ---------- things agents say ----------
40
41const LINES_UNWIND = ['five minutes', 'long day of tokens', 'this couch gets it', 'just resting my eyes']
42const LINES_BORED = ['anyone need a hand?', 'high score time', 'one more game', 'beat that']
43const LINES_STANDBY = ['ping me', 'ready', 'all yours', 'say the word']
44const LINES_BROWSE = ['hm, interesting', 'never read this one', 'where was that file', 'good reference']
45const LINES_LOOK = ['nice work', 'ooh', 'go on', 'that compiles?']
46const LINES_FIRE = ['so toasty', 'crackle crackle', 'cozy', 'warming up']
47const LINES_READ = ['one more chapter', 'plot twist!', 'good book', 'ahh, cozy']
48const LINES_MAIL = ['no mail today', 'a postcard!', 'bills, bills', 'look at that stack']
49const LINES_SHIP = ['shipped!', 'one more box', 'special delivery', 'stack it high']
50const LINES_SNACK = ['just one bite', 'who ate my sandwich', 'midnight snack', 'mmm, leftovers']
51const LINES_STARS = ['so many stars', 'is that a satellite?', 'clear skies', 'a comet!']
52const LINES_RADIO = ['good song', 'turn it up', 'static again', 'this station rocks']
53const LINES_TINKER = ['almost...', 'tap tap', 'needs a screw', 'there we go']
54const LINES_TV = ['one more episode', 'no spoilers', 'plot twist!', 'cozy night in']
55const LINES_COFFEE = ['decaf again?', 'smells like progress', 'refill', 'brew time']
56const LINES_SLEEP = ['zzz', 'back soon', 'nap protocol', 'dreaming in json']
57const LINES_ERROR = ['oops', 'that did not work', 'hmm, error', 'retrying…', 'ugh']
58const LINES_WAVE = ['hi!', 'o/', 'hello!', 'hey you', 'hiya!']
59const LINES_SPIN = ['wheee', 'whee!', 'so dizzy', 'again!']
60const LINES_MOON = ['smooth', 'hee-hee', 'like butter', 'look ma']
61const LINES_JUGGLE = ['look!', 'ta-da', 'watch this', 'no bugs dropped']
62const LINES_SNEEZE = ['achoo!', 'ah-choo', 'bless me', 'dusty logs']
63const LINES_STUMBLE = ['oof!', 'whoops', 'nobody saw that', 'who put that there']
64const LINES_YAWN = ['*yawn*', 'so sleepy', 'mmh', 'is it friday']
65const LINES_HOP = ['♪', 'yay!', 'tests pass!', 'woo']
66const LINES_HIGHFIVE = ['yeah!', 'nice!', 'teamwork', 'up top!']
67const LINES_DANCE = ['♪', '♪♪', 'groovy', 'dance break']
68const LINES_WIN = ['ha!', 'again?', 'too easy', 'undefeated']
69const LINES_LOSE = ['nooo', 'best of 3', 'rigged', 'rematch!']
70const THROWS = ['rock!', 'paper!', 'scissors!']
71const BEATS: Record<string, string> = { 'rock!': 'scissors!', 'paper!': 'rock!', 'scissors!': 'paper!' }
72
73// ---------- helpers ----------
74
75function hash(text: string): number {
76 let n = 2166136261
77 for (const ch of text) n = Math.imul(n ^ ch.charCodeAt(0), 16777619) >>> 0
78 return n
79}
80
81const between = (r: () => number, a: number, b: number) => a + r() * (b - a)
82const pick = <T>(list: readonly T[], r: () => number): T => list[Math.floor(r() * list.length) % list.length] as T
83const r1 = (n: number) => Math.round(n * 10) / 10
84const r2 = (n: number) => Math.round(n * 100) / 100
85const k4 = (n: number) => n.toFixed(4)
86
87// By code points, so an emoji is never split.
88export function clip(text: string, max: number): string {
89 const chars = [...text]
90 return chars.length > max ? chars.slice(0, Math.max(1, max - 1)).join('') + '…' : text
91}
92
93// Own entries only: an agent type named like one of an object's built-in keys is a name like any other.
94export const own = <T>(table: Record<string, T>, key: string): T | undefined =>
95 (Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined)
96
97function rect(r: Run, extra = ''): string {
98 return `<rect x="${r1(r.x)}" y="${r1(r.y)}" width="${r.w}" height="${r.h}" fill="${r.c}"${extra}/>`
99}
100
101function blinkAnim(attr: string, on: string, off: string, periodMs: number): string {
102 const dur = periodMs / 1000
103 return `<animate attributeName="${attr}" values="${on};${off}" keyTimes="0;0.5" calcMode="discrete" dur="${dur}s" ${loopBegin(dur)} repeatCount="indefinite"/>`
104}
105
106// The on (1) and off (0) steps of `spans` in a timeline `total` seconds long.
107type Step = { t: number; v: number }
108function stepsOf(spans: Span[], total: number): Step[] {
109 const merged = spans
110 .map(([a, b]) => [Math.max(0, a), Math.min(total, b)] as Span)
111 .filter(([a, b]) => b > a)
112 .sort((p, q) => p[0] - q[0])
113 if (merged.length === 0) return [{ t: 0, v: 0 }]
114 const points: Step[] = [{ t: 0, v: (merged[0] as Span)[0] <= 0 ? 1 : 0 }]
115 for (const [a, b] of merged) {
116 if (a > 0) points.push({ t: a, v: 1 })
117 if (b < total) points.push({ t: b, v: 0 })
118 }
119 const clean: Step[] = []
120 for (const p of points) {
121 const last = clean[clean.length - 1]
122 if (last && p.t / total - last.t / total < 0.0002) { last.v = p.v; continue }
123 if (last && last.v === p.v) continue
124 clean.push({ ...p })
125 }
126 return clean
127}
128
129function stepAnim(attr: string, values: string[], steps: Step[], total: number, begin: number, isLoop: boolean): string {
130 const repeat = isLoop ? 'repeatCount="indefinite"' : 'fill="freeze"'
131 return `${attr} values="${values.join(';')}" keyTimes="${steps.map(p => k4(p.t / total)).join(';')}" calcMode="discrete" dur="${r2(Math.max(0.1, total))}s" begin="-${r2(begin)}s" ${repeat}/>`
132}
133
134// A discrete 0/1 opacity track, on inside `spans`, looping or held at its end.
135function track(spans: Span[], total: number, begin: number, isLoop: boolean): { attr: string; anim: string } {
136 const steps = stepsOf(spans, total)
137 const attr = ` opacity="${steps[0]?.v ?? 0}"`
138 if (steps.length === 1) return { attr, anim: '' }
139 return { attr, anim: stepAnim('<animate attributeName="opacity"', steps.map(p => String(p.v)), steps, total, begin, isLoop) }
140}
141
142function group(content: string, t: { attr: string; anim: string }): string {
143 if (t.attr === ' opacity="0"' && !t.anim) return ''
144 return `<g${t.attr}>${t.anim}${content}</g>`
145}
146
147// ---------- pixel art ----------
148
149const JUGGLE = ['#f2503f', '#52d2f5', '#ffd166']
150
151const KIND_COLOR: Record<string, string> = {
152 main: '#ffd166',
153 Explore: '#5ad0ff',
154 Plan: '#e0a0ff',
155 'general-purpose': '#7df08a',
156 shell: '#5affd0',
157 monitor: '#ff8a5a',
158 workflow: '#ff6a9a',
159}
160const OTHER_COLORS = ['#5cc8f5', '#f58c5c', '#80e88c', '#d9a3f5', '#f5d05c', '#5cf5cc', '#f5709c']
161
162export function colorFor(c: Resident): string {
163 return teamOf(c.session ?? '') ?? own(KIND_COLOR, c.kind) ?? OTHER_COLORS[hash(c.kind) % OTHER_COLORS.length] ?? '#5cc8f5'
164}
165
166// A session's colour; none while it is alone in the house.
167const teamOf = (session: string): string | undefined => hues.get(session)
168
169// With several sessions, each colours its names, rings and tape; alone, names keep their kind's colour.
170const SESSION_COLORS = ['#ffd166', '#5ad0ff', '#f56fcf', '#7df08a', '#7b8cff', '#c9a0ff', '#5affd0', '#e4e6f0']
171let hues = new Map<string, string>()
172
173// `sessions` oldest first, this one as '' (`self`), so every house picks the same colours.
174export function paintSessions(sessions: string[], self: string): void {
175 hues = new Map()
176 if (sessions.length < 2) return
177 const taken = new Set<number>()
178 for (const id of sessions) {
179 let k = hash('session:' + (id || self)) % SESSION_COLORS.length
180 while (taken.has(k) && taken.size < SESSION_COLORS.length) k = (k + 1) % SESSION_COLORS.length
181 taken.add(k)
182 hues.set(id, SESSION_COLORS[k] ?? '#ffd166')
183 }
184}
185
186// Members saved before `taskType` existed are told apart by their name.
187export function taskTypeOf(c: Resident): string {
188 if (c.taskType) return c.taskType
189 return c.isBackground && c.name.startsWith('Background ') ? c.kind : 'subagent'
190}
191
192// Everyone goes by a one-word role, homely and a little silly; background work goes by its chore.
193const BUILT_IN_ROLES: Record<string, string> = {
194 Explore: 'Snoop',
195 Plan: 'Schemer',
196 'general-purpose': 'Handyman',
197 'claude-code-guide': 'Granny',
198 'statusline-setup': 'Plumber',
199}
200const ROLE_HINTS: [RegExp, string][] = [
201 [/review|critic|audit|lint/i, 'Inspector'],
202 [/test|qa\b|verif/i, 'Taster'],
203 [/secur|vuln|pentest/i, 'Watchdog'],
204 [/fix|debug|bug|repair/i, 'Mouser'],
205 [/(^|[-_ ])ui([-_ ]|$)|front|design|css|style/i, 'Decorator'],
206 [/setup|config|deploy|infra|ops|install|(^|[-_ ])ci([-_ ]|$)/i, 'Plumber'],
207 [/data|sql|(^|[-_ ])db([-_ ]|$)|analy|metric/i, 'Bookkeeper'],
208 [/doc|write|readme|scribe|copy/i, 'Scribbler'],
209 [/guide|help|question|oracle/i, 'Granny'],
210 [/explor|research|search|find|scout/i, 'Snoop'],
211 [/plan|architect|strateg/i, 'Schemer'],
212 [/build|implement|code|dev|engineer/i, 'Tinkerer'],
213]
214const WORK_ROLES: Record<string, string> = { shell: 'Cook', monitor: 'Neighbor', workflow: 'Butler' }
215
216export function displayName(c: Resident): string {
217 if (c.kind === 'main') return 'Host'
218 const type = taskTypeOf(c)
219 if (type !== 'subagent') return own(WORK_ROLES, type) ?? 'Gremlin'
220 return own(BUILT_IN_ROLES, c.kind) ?? ROLE_HINTS.find(([hint]) => hint.test(c.kind))?.[1] ?? 'Handyman'
221}
222
223export function bodyColor(c: Resident): string {
224 return c.status === 'done' ? '#7a7a88' : CLAUDE_ORANGE
225}
226
227// ---------- glyph chatter ----------
228
229// A made-up script: each glyph is a few strokes on a 3x4 grid.
230const GLYPHS: number[][][] = [
231 [[0, 0, 3, 0], [1.5, 0, 1.5, 4]],
232 [[0, 4, 1.5, 0], [1.5, 0, 3, 4]],
233 [[0, 0, 0, 4], [0, 4, 3, 4]],
234 [[0, 2, 3, 2], [3, 0, 3, 4]],
235 [[0, 0, 3, 4], [0, 4, 3, 0]],
236 [[0, 1, 3, 1], [0, 3, 3, 3]],
237 [[1.5, 0, 1.5, 4], [0, 2, 1.5, 0]],
238 [[0, 0, 3, 0], [3, 0, 0, 4], [0, 4, 3, 4]],
239 [[0, 4, 0, 1], [0, 1, 3, 1], [3, 1, 3, 4]],
240 [[0, 0, 3, 2], [3, 2, 0, 4]],
241 [[1.5, 0, 1.5, 1], [1.5, 3, 1.5, 4], [0, 2, 3, 2]],
242 [[0, 0, 0, 4], [3, 0, 3, 4], [0, 0, 3, 0]],
243 [[0, 4, 3, 0], [0, 1, 1, 0]],
244 [[0, 0, 1.5, 2], [3, 0, 1.5, 2], [1.5, 2, 1.5, 4]],
245]
246
247// Each agent favours its own handful of glyphs, so it has an accent.
248function accentOf(seed: number): number[] {
249 const r = rng(seed ^ 0xa5a5a5a5)
250 const mine = GLYPHS.map((_, i) => i).filter(() => r() < 0.5)
251 return mine.length >= 4 ? mine : [0, 3, 6, 9]
252}
253
254function chatterLine(seed: number, accent: number[]): number[][] {
255 const r = rng(seed)
256 return Array.from({ length: r() < 0.55 ? 1 : 2 }, () =>
257 Array.from({ length: r() < 0.5 ? 2 : 3 }, () => accent[Math.floor(r() * accent.length)] ?? 0))
258}
259
260function runeBubble(phrase: number[][]): string {
261 let x = 0
262 let d = ''
263 for (const word of phrase) {
264 for (const glyph of word) {
265 for (const [x1, y1, x2, y2] of GLYPHS[glyph] ?? []) d += `M${r1(x + (x1 ?? 0))} ${y1 ?? 0}L${r1(x + (x2 ?? 0))} ${y2 ?? 0}`
266 x += 4.2
267 }
268 x += 2
269 }
270 const w = Math.max(6, x - 1.6)
271 return `<g transform="translate(${r1(-w / 2)} -24)"><rect x="-2" y="-2" width="${r1(w + 4)}" height="8" fill="#f4f1e8"/><rect x="${r1(w / 2 - 1)}" y="6" width="2" height="2" fill="#f4f1e8"/>` +
272 `<path d="${d}" stroke="#2b261f" stroke-width="0.7" fill="none" stroke-linecap="square"/></g>`
273}
274
275// Speech bubble text width; every line an agent says fits.
276const BUBBLE = 56
277
278function textBubble(text: string, color = '#1a1a1a'): string {
279 const fill = '#f4f1e8'
280 const t = fitText(text, BUBBLE, PX)
281 const w = r1(textWidth(t, PX) + 4)
282 return `<g transform="translate(${r1(-w / 2)} -25)"><rect x="0" y="0" width="${w}" height="6" fill="${fill}"/><rect x="${r1(w / 2 - 1)}" y="6" width="2" height="2" fill="${fill}"/>` +
283 `${agentText(t, 2, 1.2, { color })}</g>`
284}
285
286// ---------- layout and routes ----------
287
288// One layout for every picture: the house amid 440 units of garden above and below; a pane shows what fits.
289export const LAYOUT = stretch(house(), 440)
290
291const heading = (dx: number, dy: number): Dir => (Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 'E' : 'W') : (dy > 0 ? 'S' : 'N'))
292const BACK = { E: 'W', W: 'E', N: 'S', S: 'N' } as const
293
294function cellAt(L: Layout, col: number, row: number): Cell {
295 return L.rooms[L.grid[row]?.[col] ?? 'lounge']
296}
297
298// Through the doorways, row then column, the yard by the front door; `lane` lets two walkers pass abreast.
299function route(L: Layout, fromPlace: Place, from: Pt, toPlace: Place, to: Pt, lane = 0): Pt[] {
300 if (fromPlace === 'yard' || toPlace === 'yard') {
301 if (fromPlace === toPlace) return [from, to]
302 const d = frontDoor(L)
303 const inside = { x: d.mid + lane, y: L.ph - T - 10 }
304 const outside = { x: d.mid + lane, y: L.ph + 6 }
305 return toPlace === 'yard'
306 ? [...route(L, fromPlace, from, 'workshop', inside, lane), outside, to]
307 : [from, outside, ...route(L, 'workshop', inside, toPlace, to, lane)]
308 }
309 const pts: Pt[] = [from]
310 let { col, row } = L.rooms[fromPlace]
311 const target = L.rooms[toPlace]
312 while (col !== target.col) {
313 const step = target.col > col ? 1 : -1
314 const cur = cellAt(L, col, row)
315 const left = step > 0 ? cur : cellAt(L, col - 1, row)
316 const edge = left.x + left.w
317 const y = doorY(cur) + lane
318 pts.push({ x: step > 0 ? edge - 12 : edge + T + 12, y }, { x: step > 0 ? edge + T + 12 : edge - 12, y })
319 col += step
320 }
321 while (row !== target.row) {
322 const step = target.row > row ? 1 : -1
323 const cur = cellAt(L, col, row)
324 const top = step > 0 ? cur : cellAt(L, col, row - 1)
325 const edge = top.y + top.h
326 const x = doorX(cur) + lane
327 pts.push({ x, y: step > 0 ? edge - 12 : edge + T + 12 }, { x, y: step > 0 ? edge + T + 12 : edge - 12 })
328 row += step
329 }
330 pts.push(to)
331 return pts
332}
333
334const laneOf = (id: string) => (hash('lane:' + id) % 5) - 2
335
336// A doorway's gap counts as the place the walker was in.
337function roomAt(L: Layout, p: Pt, fallback: Place): Place {
338 if (p.y > L.ph) return 'yard'
339 for (const room of ALL_ROOMS) {
340 const c = L.rooms[room]
341 if (p.x >= c.x && p.x <= c.x + c.w && p.y >= c.y && p.y <= c.y + c.h) return room
342 }
343 return fallback
344}
345
346// A room half a body clear of its walls, or the road out front.
347function floorOf(L: Layout, place: Place): { x0: number; x1: number; y0: number; y1: number } {
348 if (place === 'yard') return yardOf(L).floor
349 const c = L.rooms[place]
350 return { x0: c.x + 4, x1: c.x + c.w - 4, y0: c.y + 4, y1: floorBottom(c) }
351}
352
353function onFloor(L: Layout, place: Place, p: Pt): Pt {
354 const f = floorOf(L, place)
355 return { x: Math.max(f.x0, Math.min(f.x1, p.x)), y: Math.max(f.y0, Math.min(f.y1, p.y)) }
356}
357
358function floorSpot(L: Layout, place: Place, r: () => number): Pt {
359 const f = place === 'yard' ? yardOf(L).floor : freeFloor(place, L.rooms[place])
360 return { x: Math.round(between(r, f.x0, f.x1)), y: Math.round(between(r, f.y0, f.y1)) }
361}
362
363// Two desks to a work room; extra workers stand about.
364const DESKS = 2
365
366function workSpot(c: Cell, i: number): Pt {
367 if (i < DESKS) return { x: deskAt(c, i).x + 6, y: c.y + 11 }
368 // past the desks, a floor grid; a bigger crowd fills its gaps, then the gaps' gaps
369 const k = i - DESKS
370 const rows = Math.max(1, Math.floor((floorBottom(c) - (c.y + 19)) / 6) + 1)
371 const pass = Math.floor(k / (4 * rows))
372 return { x: c.x + 10 + (k % 4) * 7 + (pass % 2) * 3.5, y: Math.min(c.y + 19 + (Math.floor(k / 4) % rows) * 6 + (pass % 3) * 2, floorBottom(c)) }
373}
374
375// ---------- props agents use ----------
376
377type Pose = 'stand' | 'sit' | 'sleep'
378type Drive = 'rest' | 'stim'
379// `absorbed`: holds attention, so no glancing about
380type Prop = { id: string; room: Place; spot: Pt; face: Dir; dwell: Span; pose: Pose; drive: Drive; lines: string[]; absorbed?: boolean }
381
382// What there is to do, a spot beside each piece of furniture.
383function furnish(L: Layout): Prop[] {
384 const props: Prop[] = []
385 const r = L.rooms
386 const add = (room: Room, id: string, x: number, y: number, face: Dir, pose: Pose, drive: Drive, dwell: Span, lines: string[], absorbed = false) =>
387 props.push({ id, room, spot: { x: r[room].x + x, y: r[room].y + y }, face, dwell, pose, drive, lines, absorbed })
388 add('lounge', 'bed1', 8, 5.5, 'S', 'sleep', 'rest', [24, 40], LINES_SLEEP)
389 add('lounge', 'bed2', 8, 16.5, 'S', 'sleep', 'rest', [24, 40], LINES_SLEEP)
390 add('lounge', 'couch1', 21, 15, 'N', 'sit', 'rest', [15, 32], [...LINES_UNWIND, ...LINES_TV])
391 add('lounge', 'couch2', 26, 15, 'N', 'sit', 'stim', [16, 30], LINES_BORED, true)
392 add('study', 'fire', 34, 10, 'N', 'stand', 'rest', [6, 12], LINES_FIRE)
393 add('study', 'armchair', 6, 19.5, 'E', 'sit', 'rest', [10, 24], LINES_READ, true)
394 add('kitchen', 'fridge', 11, 11.5, 'W', 'stand', 'rest', [4, 8], LINES_SNACK)
395 add('kitchen', 'coffee', 11, 24, 'W', 'stand', 'rest', [5, 10], LINES_COFFEE)
396 add('library', 'shelf', 10, 17, 'W', 'stand', 'stim', [12, 22], LINES_BROWSE, true)
397 add('sunroom', 'telescope', 6, 19, 'N', 'stand', 'stim', [8, 16], LINES_STARS, true)
398 add('sunroom', 'radio', 10, 22.5, 'W', 'stand', 'stim', [5, 10], LINES_RADIO)
399 add('workshop', 'bench', 12, 16, 'W', 'stand', 'stim', [8, 14], LINES_TINKER)
400 const m = yardOf(L).mailbox
401 props.push({ id: 'mailbox', room: 'yard', spot: { x: m.x + 3.5, y: m.y + 1 }, face: 'W', dwell: [4, 8], pose: 'stand', drive: 'stim', lines: LINES_MAIL })
402 return props
403}
404const PROPS = furnish(LAYOUT)
405
406// ---------- temperament and moods ----------
407
408function temperament(id: string) {
409 const r = rng(hash('temper:' + id))
410 return {
411 pace: between(r, 0.85, 1.2),
412 restless: between(r, 0.5, 1.4),
413 curious: between(r, 0.4, 1.2),
414 homebody: between(r, 0.4, 1.2),
415 chatty: between(r, 0.5, 1.3),
416 }
417}
418
419// Moods take turns every few minutes, each agent on its own clock.
420const MOODS = [
421 { tag: 'busy', rest: 0.7, stim: 1.0, restless: 1.3 },
422 { tag: 'wander', rest: 0.75, stim: 1.5, restless: 1.1 },
423 { tag: 'unwind', rest: 1.5, stim: 0.8, restless: 0.75 },
424 { tag: 'doze', rest: 1.3, stim: 0.6, restless: 0.5 },
425]
426const MOOD_MS = 200000
427
428// A mood holds for a whole idle stretch, so a redraw never swaps the plan.
429function moodOf(id: string, idleSince: number) {
430 const index = (hash(id) + Math.floor(idleSince / MOOD_MS)) % MOODS.length
431 return { index, ...(MOODS[index] ?? MOODS[0]!) }
432}
433
434// ---------- planning an idle loop ----------
435
436const mod = (n: number) => ((n % LOOP) + LOOP) % LOOP
437
438type Key = { t: number; p: Pt; room: Place }
439
440function posAt(keys: Key[], t: number): Key {
441 let prev = keys[0] as Key
442 for (const k of keys) {
443 if (k.t >= t) {
444 const span = k.t - prev.t
445 const u = span > 0 ? (t - prev.t) / span : 1
446 return { t, p: { x: prev.p.x + (k.p.x - prev.p.x) * u, y: prev.p.y + (k.p.y - prev.p.y) * u }, room: u >= 1 ? k.room : prev.room }
447 }
448 prev = k
449 }
450 return prev
451}
452type Say = { t0: number; t1: number; text: string }
453// A meeting; `talk` is glyph chatter.
454type Appointment = {
455 t0: number; t1: number; room: Room; spot: Pt; face: Dir
456 talk: Span[]; hop: Span[]; spark: Span[]; says: Say[]; spins: { t: number; dir: Dir }[]
457}
458
459class Plan {
460 keys: Key[]
461 walk: Span[] = []
462 sit: Span[] = []
463 sleep: Span[] = []
464 faces: { t: number; dir: Dir }[] = [{ t: 0, dir: 'S' }]
465 says: Say[] = []
466 talk: Span[] = []
467 // goofing about
468 hop: Span[] = []
469 wave: Span[] = []
470 juggle: Span[] = []
471 spark: Span[] = []
472 t = 0
473 room: Place
474 at: Pt
475 speed: number
476 private L: Layout
477 private r: () => number
478 private lane: number
479 constructor(room: Place, at: Pt, speed: number, L: Layout, r: () => number, lane = 0) {
480 this.room = room
481 this.at = at
482 this.speed = speed
483 this.L = L
484 this.r = r
485 this.lane = lane
486 this.keys = [{ t: 0, p: at, room }]
487 }
488
489 face(dir: Dir, t = this.t): void {
490 this.faces.push({ t, dir })
491 }
492
493 hold(seconds: number): void {
494 if (seconds <= 0) return
495 this.t += seconds
496 this.keys.push({ t: this.t, p: this.at, room: this.room })
497 }
498
499 // `backwards`: a moonwalk
500 walkTo(room: Place, to: Pt, backwards = false): void {
501 this.walkAlong(room, route(this.L, this.room, this.at, room, to, this.lane), this.at, to, backwards, true)
502 }
503
504 // `stops`: maybe one short pause on the way
505 private walkAlong(room: Place, pts: Pt[], last: Pt, to: Pt, backwards: boolean, stops: boolean): void {
506 const start = this.t
507 const end = pts[pts.length - 1]
508 for (const p of pts.slice(1)) {
509 const dx = p.x - last.x
510 const dy = p.y - last.y
511 const d = Math.hypot(dx, dy)
512 if (d < 0.5) continue
513 const dir = heading(dx, dy)
514 this.face(backwards ? BACK[dir] : dir)
515 this.t += d / this.speed
516 this.keys.push({ t: this.t, p, room: p === end ? room : roomAt(this.L, p, this.room) })
517 last = p
518 if (stops && p !== end && this.r() < 0.06) {
519 this.walk.push([start, this.t])
520 this.room = roomAt(this.L, p, this.room)
521 this.at = p
522 this.hold(between(this.r, 0.3, 0.8))
523 const rest = pts.slice(pts.indexOf(p))
524 return this.walkAlong(room, rest, p, to, false, false)
525 }
526 }
527 this.walk.push([start, this.t])
528 this.room = room
529 this.at = to
530 }
531
532 travel(room: Place, to: Pt): number {
533 const pts = route(this.L, this.room, this.at, room, to, this.lane)
534 let d = 0
535 for (let i = 1; i < pts.length; i++) d += Math.hypot((pts[i] as Pt).x - (pts[i - 1] as Pt).x, (pts[i] as Pt).y - (pts[i - 1] as Pt).y)
536 return d / this.speed + 0.8
537 }
538
539 // squeeze a routine that ran long back into the loop
540 fit(total: number): void {
541 if (this.t <= total) return
542 const f = total / this.t
543 const s = (span: Span): Span => [span[0] * f, span[1] * f]
544 this.keys = this.keys.map(k => ({ ...k, t: k.t * f }))
545 this.walk = this.walk.map(s)
546 this.sit = this.sit.map(s)
547 this.sleep = this.sleep.map(s)
548 this.talk = this.talk.map(s)
549 this.hop = this.hop.map(s)
550 this.wave = this.wave.map(s)
551 this.juggle = this.juggle.map(s)
552 this.spark = this.spark.map(s)
553 this.faces = this.faces.map(x => ({ t: x.t * f, dir: x.dir }))
554 this.says = this.says.map(x => ({ t0: x.t0 * f, t1: x.t1 * f, text: x.text }))
555 this.t = total
556 }
557
558 say(text: string, seconds = 4.2): void {
559 this.says.push({ t0: this.t, t1: this.t + seconds, text })
560 }
561
562 // a look to one side and back
563 glance(): void {
564 const back = this.faces[this.faces.length - 1]?.dir ?? 'S'
565 this.face(this.r() < 0.5 ? 'W' : 'E')
566 this.hold(between(this.r, 0.5, 1.2))
567 this.face(back)
568 }
569}
570
571// Props are booked on the house's clock so no two agents share one; `offset` maps own time to it.
572type Bookings = Map<string, Span[]>
573
574function book(bookings: Bookings, id: string, span: Span, offset: number): boolean {
575 const a = mod(span[0] + offset)
576 const len = Math.min(LOOP, span[1] - span[0])
577 const pieces: Span[] = a + len <= LOOP ? [[a, a + len]] : [[a, LOOP], [0, a + len - LOOP]]
578 const list = bookings.get(id) ?? []
579 if (pieces.some(([x, y]) => list.some(([p, q]) => x < q && p < y))) return false
580 list.push(...pieces)
581 bookings.set(id, list)
582 return true
583}
584
585type IdleContext = {
586 bookings: Bookings
587 offset: number
588 // idle agents so far: the more, the less each talks
589 crowd: number
590 // desks the lead may look in on
591 watch: { room: Room; spot: Pt }[]
592 // where workers stand: an idle agent starting on one steps off it
593 busy: Pt[]
594}
595
596const near = (a: Pt, b: Pt, d = 6) => Math.hypot(a.x - b.x, a.y - b.y) < d
597
598// ---------- goofing about ----------
599
600// Bits of silliness an idle agent gets up to, by mood.
601type Antic = 'hop' | 'spin' | 'moonwalk' | 'wave' | 'juggle' | 'sneeze' | 'stumble' | 'doze' | 'yawn'
602const ANTICS: Record<string, Antic[]> = {
603 busy: ['juggle', 'hop', 'spin', 'wave', 'sneeze', 'stumble'],
604 wander: ['moonwalk', 'spin', 'wave', 'hop', 'stumble', 'juggle'],
605 unwind: ['yawn', 'wave', 'hop', 'sneeze', 'doze', 'moonwalk'],
606 doze: ['doze', 'yawn', 'stumble', 'sneeze'],
607}
608const SPIN_DIRS: Dir[] = ['S', 'E', 'N', 'W']
609
610// One antic on the spot, if it fits before `deadline` with time to get `back`.
611function goof(plan: Plan, L: Layout, kind: Antic, r: () => number, chatty: number, deadline: number, back: { room: Place; spot: Pt }): boolean {
612 const left = deadline - plan.travel(back.room, back.spot)
613 const fitsIn = (seconds: number) => plan.t + seconds <= left
614 const maybeSay = (lines: string[], chance = 0.7, seconds = 2.4) => {
615 if (r() < chance * chatty) plan.say(pick(lines, r), seconds)
616 }
617 switch (kind) {
618 case 'hop': {
619 const dur = between(r, 1.6, 2.6)
620 if (!fitsIn(dur)) return false
621 plan.face('S')
622 maybeSay(LINES_HOP)
623 plan.hop.push([plan.t, plan.t + dur])
624 plan.hold(dur)
625 return true
626 }
627 case 'spin': {
628 const dur = 1.8
629 if (!fitsIn(dur + 0.4)) return false
630 maybeSay(LINES_SPIN)
631 for (let k = 0; k < 12; k++) plan.face(SPIN_DIRS[k % 4] as Dir, plan.t + k * 0.15)
632 plan.hold(dur)
633 plan.face('S')
634 plan.hold(0.4)
635 return true
636 }
637 case 'moonwalk': {
638 if (plan.room === 'yard' || roomAt(L, plan.at, 'yard') !== plan.room) return false
639 const room = L.rooms[plan.room]
640 const dx = (r() < 0.5 ? -1 : 1) * between(r, 12, 20)
641 const spot = { x: Math.max(room.x + 8, Math.min(room.x + room.w - 10, plan.at.x + dx)), y: plan.at.y }
642 if (Math.abs(spot.x - plan.at.x) < 6) return false
643 const there = plan.travel(plan.room, spot) * 1.7
644 if (!fitsIn(there + 0.8)) return false
645 maybeSay(LINES_MOON, 0.6, 2.8)
646 const speed = plan.speed
647 plan.speed = speed * 0.6
648 plan.walkTo(plan.room, spot, true)
649 plan.speed = speed
650 plan.hold(0.8)
651 return true
652 }
653 case 'wave': {
654 const dur = 2.2
655 if (!fitsIn(dur)) return false
656 plan.face('S')
657 maybeSay(LINES_WAVE, 0.9)
658 plan.wave.push([plan.t, plan.t + dur])
659 plan.hold(dur)
660 return true
661 }
662 case 'juggle': {
663 const dur = between(r, 3, 5)
664 if (!fitsIn(dur)) return false
665 plan.face('S')
666 maybeSay(LINES_JUGGLE, 0.5)
667 plan.juggle.push([plan.t, plan.t + dur])
668 plan.hold(dur)
669 return true
670 }
671 case 'sneeze': {
672 if (!fitsIn(1.8)) return false
673 plan.say(pick(LINES_SNEEZE, r), 1.6)
674 plan.hop.push([plan.t + 0.3, plan.t + 0.5])
675 plan.hold(1.8)
676 return true
677 }
678 case 'stumble': {
679 if (!fitsIn(2)) return false
680 plan.say(pick(LINES_STUMBLE, r), 1.8)
681 plan.sit.push([plan.t, plan.t + 0.7])
682 plan.hold(2)
683 return true
684 }
685 case 'doze': {
686 const dur = between(r, 4, 7)
687 if (!fitsIn(dur)) return false
688 plan.face('S')
689 plan.sleep.push([plan.t, plan.t + dur])
690 plan.hold(dur)
691 return true
692 }
693 case 'yawn': {
694 if (!fitsIn(2.6)) return false
695 plan.face('S')
696 plan.say(pick(LINES_YAWN, r), 2.2)
697 plan.hold(2.6)
698 return true
699 }
700 }
701}
702
703// Depends only on the agent, the house, its huddle and the agents idle before it.
704function planIdle(
705 L: Layout, props: Prop[], c: Resident, home: { room: Place; at: Pt }, appts: Appointment[], ctx: IdleContext,
706): Plan {
707 const p = temperament(c.id)
708 const ph = moodOf(c.id, c.idleSince ?? 0)
709 const r = rng(hash(`${c.id}:${ph.index}`))
710 const plan = new Plan(home.room, home.at, speedOf(c), L, r, laneOf(c.id))
711 const talkChance = 0.35 / Math.sqrt(Math.max(1, ctx.crowd))
712
713 // starting on a busy desk: step off, and come back only as the loop ends
714 const onBusy = ctx.busy.some(b => near(b, home.at))
715 const base = onBusy ? { room: home.room, at: onFloor(L, home.room, { x: home.at.x, y: home.at.y + 9 }) } : home
716 if (onBusy) plan.walkTo(base.room, base.at)
717 plan.hold(between(r, 0.4, 1.6))
718 const queue = [...appts].sort((a, b) => a.t0 - b.t0)
719 const endBy = LOOP - 0.5 - (onBusy ? plan.travel(home.room, home.at) : 0)
720
721 // whether going, staying and going on to `back` all end by the deadline
722 const fits = (room: Place, spot: Pt, dwell: number, deadline: number, back: { room: Place; spot: Pt }, go = plan.travel(room, spot)) => {
723 const saved = { room: plan.room, at: plan.at }
724 plan.room = room
725 plan.at = spot
726 const ret = plan.travel(back.room, back.spot)
727 plan.room = saved.room
728 plan.at = saved.at
729 return plan.t + go + dwell + ret <= deadline
730 }
731
732 let guard = 0
733 while (guard++ < 40) {
734 const next = queue[0]
735 const deadline = next ? next.t0 : endBy
736 const back = next ? { room: next.room, spot: next.spot } : { room: base.room, spot: base.at }
737
738 // what it feels like: rest, something to do, or nothing
739 const wRest = 38 * (0.6 + 0.7 * p.homebody) * ph.rest
740 const wStim = (36 * (0.6 + 0.7 * p.curious) + 14 * p.restless) * ph.stim
741 const wContent = 30
742 const roll = r() * (wRest + wStim + wContent)
743 const drive: Drive | 'content' = roll < wRest ? 'rest' : roll < wRest + wStim ? 'stim' : 'content'
744
745 let done = false
746 // the lead looks in on whoever is working, a few seconds each
747 if (ctx.watch.length > 0 && r() < 0.45) {
748 const w = pick(ctx.watch, r)
749 const spot = onFloor(L, w.room, { x: w.spot.x + (r() < 0.5 ? -7 : 7), y: w.spot.y + 11 })
750 const dwell = between(r, 3, 6)
751 const go = plan.travel(w.room, spot)
752 if (fits(w.room, spot, dwell, deadline, back, go) && book(ctx.bookings, `watch:${w.room}:${w.spot.x}`, [plan.t + go, plan.t + go + dwell], ctx.offset)) {
753 plan.walkTo(w.room, spot)
754 plan.face('N')
755 if (r() < talkChance) plan.say(pick(LINES_LOOK, r))
756 plan.hold(dwell)
757 done = true
758 }
759 }
760 if (!done && drive !== 'content') {
761 const options = props.filter(o => o.drive === drive && (o.pose !== 'sleep' || ph.tag === 'doze' || ph.tag === 'unwind'))
762 if (options.length > 0) {
763 const o = pick(options, r)
764 const dwell = between(r, o.dwell[0], o.dwell[1])
765 const go = plan.travel(o.room, o.spot)
766 if (fits(o.room, o.spot, dwell, deadline, back, go) && book(ctx.bookings, o.id, [plan.t + go - 0.5, plan.t + go + dwell + 0.5], ctx.offset)) {
767 plan.walkTo(o.room, o.spot)
768 plan.face(o.face)
769 const t0 = plan.t
770 if (r() < talkChance) plan.say(pick(o.lines, r))
771 // glancing about now and then, unless absorbed
772 let left = dwell
773 while (left > 0) {
774 const chunk = Math.min(left, between(r, 3.5, 7.5))
775 plan.hold(chunk)
776 left -= chunk
777 if (left > 1.5 && o.pose === 'stand' && !o.absorbed && r() < 0.4) {
778 const t = plan.t
779 plan.glance()
780 plan.face(o.face)
781 left -= plan.t - t
782 }
783 }
784 if (o.pose === 'sit') plan.sit.push([t0, plan.t])
785 if (o.pose === 'sleep') {
786 plan.sleep.push([t0, plan.t])
787 plan.face('S', t0)
788 }
789 done = true
790 }
791 }
792 if (!done && drive === 'stim' && r() < 0.6) {
793 // explore: a stroll to another room
794 const room = pick(ALL_ROOMS, r)
795 const spot = floorSpot(L, room, r)
796 const dwell = between(r, 1.5, 4)
797 if (fits(room, spot, dwell + 1.2, deadline, back)) {
798 plan.walkTo(room, spot)
799 plan.hold(dwell)
800 plan.glance()
801 done = true
802 }
803 }
804 if (!done && drive === 'rest' && c.kind === 'main' && r() < 0.5) {
805 // a step to whichever side has room
806 const f = floorOf(L, base.room)
807 const spot = onFloor(L, base.room, { x: base.at.x + (base.at.x + 6 > f.x1 ? -6 : 6), y: base.at.y })
808 const dwell = between(r, 3, 7)
809 if (fits(base.room, spot, dwell, deadline, back)) {
810 plan.walkTo(base.room, spot)
811 plan.face('N')
812 if (r() < talkChance) plan.say(pick(LINES_STANDBY, r))
813 plan.hold(dwell)
814 done = true
815 }
816 }
817 }
818 if (!done && r() < 0.3 * p.restless * ph.restless) {
819 done = goof(plan, L, pick(ANTICS[ph.tag] ?? ANTICS.busy ?? ['hop'], r), r, p.chatty, deadline, back)
820 }
821 if (!done) {
822 // content: stand, look round or stroll; lengths drawn first so no deadline is overrun
823 const x = r() * 100
824 const left = deadline - plan.travel(back.room, back.spot)
825 const stand = between(r, 4, 10)
826 const settle = between(r, 2, 4.5)
827 if (x < 55 && plan.t + stand <= left) {
828 plan.hold(stand)
829 done = true
830 } else if (x < 80 && plan.t + 1.2 + settle <= left) {
831 plan.glance()
832 plan.hold(settle)
833 done = true
834 } else {
835 const spot = floorSpot(L, plan.room, r)
836 const pause = between(r, 1.5, 4)
837 if (fits(plan.room, spot, pause, deadline, back)) {
838 plan.walkTo(plan.room, spot)
839 plan.hold(pause)
840 done = true
841 }
842 }
843 }
844 if (done) continue
845
846 // nothing fits: keep the next appointment, unless it cannot be reached in time
847 if (!next) break
848 queue.shift()
849 const go = plan.travel(next.room, next.spot)
850 if (plan.t + go > next.t0 + 1) continue
851 plan.hold(Math.max(0, next.t0 - plan.t - go))
852 plan.walkTo(next.room, next.spot)
853 plan.hold(Math.max(0, next.t0 - plan.t))
854 plan.face(next.face)
855 plan.talk.push(...next.talk)
856 plan.hop.push(...next.hop)
857 plan.spark.push(...next.spark)
858 plan.says.push(...next.says)
859 for (const f of next.spins) plan.face(f.dir, f.t)
860 plan.hold(next.t1 - plan.t)
861 }
862 plan.walkTo(base.room, base.at)
863 plan.face('S')
864 if (onBusy) {
865 // arrive back on the starting spot exactly as the loop comes round
866 plan.hold(LOOP - plan.t - plan.travel(home.room, home.at) + 0.8)
867 plan.walkTo(home.room, home.at)
868 }
869 plan.hold(LOOP - plan.t)
870 plan.fit(LOOP)
871 return plan
872}
873
874// Huddles: two or three idle agents chatter in glyphs, high-five, dance or play rock, paper, scissors.
875// An agent's id picks its meeting time, so its plan changes only with its meeting group (huddleKey).
876const MEETINGS = [14, 40, 64]
877
878const slotOf = (c: Resident) => hash('meet:' + c.id) % MEETINGS.length
879const joins = (c: Resident, slot: number) => rng(hash(`joins:${slot}:${c.id}`))() < 0.45 * temperament(c.id).chatty + 0.3
880
881// The meeting group with each loop start, since who can come depends on it.
882function huddleKey(L: Layout, c: Resident, idle: Resident[]): string {
883 const slot = slotOf(c)
884 return idle.filter(o => slotOf(o) === slot).map(o => `${o.id}@${t0Of(o, L)}`).sort().join('|')
885}
886
887function huddles(L: Layout, idle: Resident[], offsetOf: (c: Resident) => number): Map<string, Appointment[]> {
888 const out = new Map<string, Appointment[]>()
889 MEETINGS.forEach((g0, slot) => {
890 const members = idle.filter(c => slotOf(c) === slot).sort((a, b) => (a.id < b.id ? -1 : 1))
891 const r = rng(hash(`huddle:${slot}:${members.map(m => m.id).join('|')}`))
892 const kind = pick(['chat', 'chat', 'highfive', 'dance', 'rps'] as const, r)
893 const hold = kind === 'chat' ? between(r, 8, 14) : kind === 'highfive' ? between(r, 4, 5) : kind === 'dance' ? between(r, 7, 10) : between(r, 6.5, 7.5)
894 // an agent whose loop would end mid-meeting sits out; under two, the meeting is off
895 const comes = (m: Resident) => {
896 const t0 = mod(g0 - offsetOf(m))
897 return t0 >= 6 && t0 + hold <= LOOP - 18
898 }
899 const party = members.filter(m => joins(m, slot) && comes(m)).slice(0, 3)
900 if (party.length < 2) return
901 const throws = party.map(() => pick(THROWS, r))
902 const winner = BEATS[throws[0] ?? ''] === throws[1] ? 0 : BEATS[throws[1] ?? ''] === throws[0] ? 1 : -1
903 const room = pick(['lounge', 'study', 'lounge', 'sunroom', 'library'] as Room[], r)
904 const f = freeFloor(room, L.rooms[room])
905 const center = { x: Math.round(between(r, f.x0 + 9, f.x1 - 9)), y: Math.round(between(r, f.y0 + 1, f.y1 - 5)) }
906 const offsets = party.length === 2 ? [-7, 7] : [-9, 0, 9]
907 party.forEach((m, idx) => {
908 const t0 = mod(g0 - offsetOf(m))
909 const spot = { x: center.x + (offsets[idx] ?? 0), y: center.y + (party.length === 3 && idx === 1 ? 5 : 0) }
910 const face: Dir = party.length === 3 && idx === 1 ? 'N' : (offsets[idx] ?? 0) < 0 ? 'E' : 'W'
911 const mine = rng(hash(`${kind}:${slot}:${m.id}`))
912 const appt: Appointment = { t0, t1: t0 + hold, room, spot, face, talk: [], hop: [], spark: [], says: [], spins: [] }
913 if (kind === 'chat') {
914 for (let k = 0; k * 2 < hold; k++) {
915 if (k % party.length === idx) appt.talk.push([t0 + k * 2, t0 + k * 2 + 1.5])
916 }
917 } else if (kind === 'highfive') {
918 appt.hop.push([t0 + 1, t0 + 1.36])
919 if (idx === 0) appt.spark.push([t0 + 1.1, t0 + 1.8])
920 appt.says.push({ t0: t0 + 1.4 + idx * 0.3, t1: t0 + 3.4 + idx * 0.3, text: pick(LINES_HIGHFIVE, mine) })
921 } else if (kind === 'dance') {
922 appt.hop.push([t0 + 0.6 + idx * 0.18, t0 + hold - 0.6])
923 if (idx % 2 === 1) {
924 for (let k = 0; t0 + 1.5 + k * 0.3 < t0 + hold - 1; k++) appt.spins.push({ t: t0 + 1.5 + k * 0.3, dir: SPIN_DIRS[k % 4] as Dir })
925 appt.spins.push({ t: t0 + hold - 1, dir: face })
926 }
927 appt.says.push({ t0: t0 + 0.8 + idx * 1.6, t1: t0 + 2.8 + idx * 1.6, text: pick(LINES_DANCE, mine) })
928 } else {
929 if (idx === 0) appt.says.push({ t0: t0 + 0.3, t1: t0 + 2, text: 'rock, paper...' })
930 appt.says.push({ t0: t0 + 2.2, t1: t0 + 4, text: idx < 2 ? throws[idx] ?? 'rock!' : 'go go go' })
931 const result = winner === -1 ? 'again!' : idx === winner ? pick(LINES_WIN, mine) : idx < 2 ? pick(LINES_LOSE, mine) : 'ooh'
932 appt.says.push({ t0: t0 + 4.3, t1: t0 + 6.2, text: result })
933 if (idx === winner) appt.hop.push([t0 + 4.3, t0 + 5])
934 }
935 out.set(m.id, [...(out.get(m.id) ?? []), appt])
936 })
937 })
938 return out
939}
940
941// A spot of its own clear of the furniture, so a crowd that went idle together does not stack up.
942function idleHome(L: Layout, c: Resident): { room: Room; at: Pt } {
943 const f = freeFloor(c.room, L.rooms[c.room])
944 const h = hash('home:' + c.id)
945 return { room: c.room, at: { x: f.x0 + (h % Math.max(1, Math.floor(f.x1 - f.x0))), y: f.y0 + ((h >>> 5) % Math.max(1, Math.floor(f.y1 - f.y0))) } }
946}
947
948// ---------- drawing an agent ----------
949
950type AgentDraw = {
951 at: Pt
952 // a box on its head, out to the pallets
953 carry?: Span[]
954 motion: string
955 total: number
956 begin: number
957 isLoop: boolean
958 walk: Span[]
959 sit: Span[]
960 sleep: Span[]
961 faces: { t: number; dir: Dir }[]
962 typing?: boolean
963 bubbles: string
964 hop?: Span[]
965 wave?: Span[]
966 juggle?: Span[]
967 spark?: Span[]
968 // drops the name a line, so neighbours at desks do not overprint
969 nameDy?: number
970}
971
972// A face at `total` gets no span, so a finished walk keeps its last step's pose for the few seconds until
973// the hooks end the errand. Holding the end longer shifts the next plans' timelines, and they jump.
974function faceSpans(faces: { t: number; dir: Dir }[], total: number): Record<Dir, Span[]> {
975 const out: Record<Dir, Span[]> = { N: [], S: [], E: [], W: [] }
976 const sorted = [...faces].sort((a, b) => a.t - b.t)
977 sorted.forEach((f, i) => {
978 const end = sorted[i + 1]?.t ?? total
979 if (end > f.t) out[f.dir].push([f.t, end])
980 })
981 return out
982}
983
984function invert(spans: Span[], total: number): Span[] {
985 const sorted = [...spans].sort((a, b) => a[0] - b[0])
986 const out: Span[] = []
987 let t = 0
988 for (const [a, b] of sorted) {
989 if (a > t) out.push([t, a])
990 t = Math.max(t, b)
991 }
992 if (t < total) out.push([t, total])
993 return out
994}
995
996// Clawd is a block seen at the house's angle, feet at (0, 0).
997const iso = (x: number, y: number, z: number): Pt => ({ x: x - y, y: (x + y) / 2 - z })
998
999// Pixels of one colour as one path: [x, y, w, h] each.
1000function pix(color: string, boxes: number[][]): string {
1001 return `<path fill="${color}" d="${boxes.map(([x, y, w, h]) => `M${r1(x ?? 0)} ${r1(y ?? 0)}h${w}v${h}h-${w}z`).join('')}"/>`
1002}
1003
1004// Shared parts are defined once per picture and placed by reference.
1005let bodyDefs = new Map<string, string>()
1006
1007function part(id: string, draw: () => string): string {
1008 if (!bodyDefs.has(id)) bodyDefs.set(id, `<g id="${id}">${draw()}</g>`)
1009 return `<use href="#${id}"/>`
1010}
1011
1012// Font glyphs, defined once for all the agents' words.
1013const glyph = (id: string, d: string) => {
1014 if (!bodyDefs.has(id)) bodyDefs.set(id, `<path id="${id}" d="${d}"/>`)
1015}
1016
1017const agentText = (text: string, x: number, y: number, opts: Parameters<typeof pixelText>[3]) => glyphText(text, x, y, opts, glyph)
1018
1019// One pose: legs standing (`s`), stepping (`w`) or tucked (`n`), body `wide` across x or along y.
1020function poseOf(color: string, wide: 'x' | 'y', legs: 's' | 'w' | 'n', typing: boolean): string {
1021 const id = `ps${wide}${legs}${typing ? 't' : ''}${color.slice(1)}`
1022 if (!bodyDefs.has(id)) bodyDefs.set(id, `<g id="${id}">${legs === 'n' ? '' : clawdLegs(color, wide, legs === 'w')}${clawdBody(color, wide, typing)}</g>`)
1023 return id
1024}
1025
1026// Every pose in a strip seen through a one-pose window, so one sliding animation does for all.
1027const POSE_GAP = 48
1028const POSE_WINDOW = '<clipPath id="vh-pose"><rect x="-20" y="-30" width="40" height="38"/></clipPath>'
1029
1030function poseStrip(color: string, typing: boolean, across: Span[], along: Span[], walk: Span[], still: Span[], d: AgentDraw): string {
1031 const total = d.total
1032 const inside = (spans: Span[], t: number) => spans.some(([a, b]) => t >= a && t < b)
1033 const edges = [...new Set([0, ...[across, along, walk, still].flatMap(list => list.flat())])].filter(t => t >= 0 && t < total).sort((a, b) => a - b)
1034 const poses: string[] = []
1035 const steps: Step[] = []
1036 edges.forEach((t0, i) => {
1037 const mid = (t0 + (edges[i + 1] ?? total)) / 2
1038 const wide = inside(across, mid) ? 'x' : inside(along, mid) ? 'y' : undefined
1039 const legs = inside(walk, mid) ? 'w' : inside(still, mid) ? 's' : 'n'
1040 let v = -1
1041 if (wide) {
1042 const id = poseOf(color, wide, legs, wide === 'x' && typing)
1043 v = poses.indexOf(id)
1044 if (v < 0) v = poses.push(id) - 1
1045 }
1046 if (steps[steps.length - 1]?.v !== v) steps.push({ t: t0, v })
1047 })
1048 const only = poses[0]
1049 if (only !== undefined && poses.length === 1 && steps.every(p => p.v === 0)) return `<use href="#${only}"/>`
1050 if (!bodyDefs.has('vh-pose')) bodyDefs.set('vh-pose', POSE_WINDOW)
1051 const strip = poses.map((id, i) => `<use href="#${id}"${i ? ` x="${i * POSE_GAP}"` : ''}/>`).join('')
1052 const values = steps.map(p => (p.v < 0 ? '-9999 0' : `${-p.v * POSE_GAP} 0`))
1053 const slide = steps.length > 1
1054 ? `<g>${stepAnim('<animateTransform attributeName="transform" type="translate"', values, steps, total, d.begin, d.isLoop)}`
1055 : `<g transform="translate(${values[0] ?? '0 0'})">`
1056 return `<g clip-path="url(#vh-pose)">${slide}${strip}</g></g>`
1057}
1058
1059// `wide` across x faces S or N, along y faces E or W; typing, the arms take turns going up.
1060function clawdBody(color: string, wide: 'x' | 'y', typing: boolean): string {
1061 return part(`cb${wide}${typing ? 't' : ''}${color.slice(1)}`, () => {
1062 const w = wide === 'x' ? 8 : 4.5
1063 const d = wide === 'x' ? 4.5 : 8
1064 const [x0, x1, y0, y1, z0, z1] = [-w / 2, w / 2, -d / 2, d / 2, 3, 10]
1065 const top = [iso(x0, y0, z1), iso(x1, y0, z1), iso(x1, y1, z1), iso(x0, y1, z1)]
1066 const left = [iso(x0, y1, z1), iso(x1, y1, z1), iso(x1, y1, z0), iso(x0, y1, z0)]
1067 const right = [iso(x1, y0, z1), iso(x1, y1, z1), iso(x1, y1, z0), iso(x1, y0, z0)]
1068 const a = ARMS[wide]
1069 const nub = (p: Pt, lift = 0) => [p.x, p.y - lift, 2, 2]
1070 const arms = (front: boolean) => {
1071 const p = front ? a.front : a.back
1072 if (!typing) return pix(color, [nub(p)])
1073 const [up, down] = front ? [1, 0] : [0, 1]
1074 return `<g>${pix(color, [nub(p, up)])}${blinkAnim('opacity', '1', '0', 330)}</g><g>${pix(color, [nub(p, down)])}${blinkAnim('opacity', '0', '1', 330)}</g>`
1075 }
1076 return arms(false) + `<path fill="${shade(color, 0.22)}" d="${pathOf(top)}"/><path fill="${color}" d="${pathOf(left)}"/>` +
1077 `<path fill="${shade(color, -0.26)}" d="${pathOf(right)}"/>` + arms(true)
1078 })
1079}
1080
1081// The session's ring at an agent's feet: an outline over a faint wash.
1082function teamRing(color: string): string {
1083 return part(`ring${color.slice(1)}`, () => {
1084 const inside = (x: number, y: number, rx: number, ry: number) => ((x + 0.5) / rx) ** 2 + ((y + 0.5) / ry) ** 2 <= 1
1085 const line: number[][] = []
1086 const wash: number[][] = []
1087 for (let y = -5; y <= 5; y++) {
1088 for (let x = -9; x <= 9; x++) {
1089 const run = inside(x, y, 8.2, 4.1) ? (inside(x, y, 6.4, 2.9) ? wash : line) : undefined
1090 const last = run?.[run.length - 1]
1091 if (!run) continue
1092 if (last && last[1] === y && (last[0] ?? 0) + (last[2] ?? 0) === x) last[2] = (last[2] ?? 0) + 1
1093 else run.push([x, y, 1, 1])
1094 }
1095 }
1096 return `<g opacity="0.18">${pix(color, wash)}</g><g opacity="0.8">${pix(color, line)}</g>`
1097 })
1098}
1099
1100// A taped box icon, 5x4.
1101const TAPE = '#ecd6a6'
1102
1103function boxIcon(tape: string): string {
1104 return pix('#e2b57a', [[0, 0, 5, 1]]) + pix('#c9965a', [[0, 1, 5, 3]]) + pix('#9a6c3c', [[4, 1, 1, 3]]) + pix(tape, [[2, 0, 1, 4]])
1105}
1106
1107// ---------- hats ----------
1108
1109// A hat per role, the same from every side: [x, y, w, h] pixels per colour, the brim's row at y -12.
1110const HATS: Record<string, [string, number[][]][]> = {
1111 // a crown with a jewel
1112 Host: [['#ffd166', [[-2, -12, 5, 1], [-2, -13, 1, 1], [0, -13, 1, 1], [2, -13, 1, 1]]], ['#e84a5f', [[0, -12, 1, 1]]]],
1113 // a checked deerstalker
1114 Snoop: [['#7a532e', [[-3, -12, 7, 1], [0, -15, 1, 1]]], ['#b5844f', [[-2, -13, 5, 1], [-1, -14, 3, 1]]], ['#dcae74', [[-1, -13, 1, 1], [1, -13, 1, 1]]]],
1115 // a wizard's pointed hat with a star
1116 Schemer: [['#5e3c9c', [[-3, -12, 7, 1]]], ['#8e5fd6', [[-2, -13, 5, 1], [-1, -14, 3, 1], [0, -15, 2, 1], [1, -16, 1, 1]]], ['#ffe066', [[0, -14, 1, 1]]]],
1117 // a hard hat with a ridge
1118 Handyman: [['#c9971f', [[-3, -12, 7, 1]]], ['#f5c242', [[-2, -13, 5, 1], [-1, -14, 3, 1]]], ['#ffe9a3', [[0, -14, 1, 2]]]],
1119 // silver hair in a bun, a flower in it
1120 Granny: [['#cfd2dc', [[-2, -12, 5, 1], [-1, -14, 3, 2]]], ['#9da1b0', [[1, -13, 1, 1]]], ['#ff8fb8', [[2, -13, 1, 1]]]],
1121 // a flat red cap
1122 Plumber: [['#a32c29', [[-3, -12, 7, 1]]], ['#d64541', [[-2, -13, 5, 1]]], ['#ffffff', [[0, -13, 1, 1]]]],
1123 // a peaked cap with a badge
1124 Inspector: [['#1b1b26', [[-2, -12, 5, 1]]], ['#3a5a94', [[-3, -13, 7, 1], [-2, -14, 5, 1]]], ['#ffd166', [[0, -12, 1, 1]]]],
1125 // lab goggles pushed up
1126 Taster: [['#3a3a48', [[-3, -12, 7, 1]]], ['#8fd3ff', [[-2, -14, 2, 2], [1, -14, 2, 2]]], ['#ffffff', [[-2, -14, 1, 1], [1, -14, 1, 1]]]],
1127 // floppy dog's ears
1128 Watchdog: [['#8b5a2b', [[-2, -12, 5, 1], [-3, -12, 1, 3], [3, -12, 1, 3]]], ['#b07a45', [[-1, -13, 3, 1]]]],
1129 // cat ears
1130 Mouser: [['#a3a3b2', [[-2, -12, 5, 1], [-2, -13, 1, 2], [2, -13, 1, 2]]], ['#f2a0b5', [[-1, -13, 1, 1], [1, -13, 1, 1]]]],
1131 // a beret worn to one side
1132 Decorator: [['#d1477a', [[-2, -12, 6, 1], [-1, -13, 5, 1]]], ['#9e2f58', [[2, -14, 1, 1]]]],
1133 // a green eyeshade
1134 Bookkeeper: [['#2d6e46', [[-2, -13, 5, 1]]], ['#5fd08a', [[-3, -12, 7, 1]]]],
1135 // a quill tucked in a band
1136 Scribbler: [['#7a5a3a', [[-2, -12, 5, 1]]], ['#f3ead2', [[1, -13, 1, 1], [1, -14, 2, 1], [2, -15, 2, 1], [3, -16, 1, 1]]]],
1137 // a propeller beanie (spun in hatOf)
1138 Tinkerer: [['#4a7fd6', [[-2, -12, 5, 1]]], ['#e2483d', [[-1, -13, 1, 1]]], ['#f5c242', [[0, -13, 1, 1]]], ['#4cbf6a', [[1, -13, 1, 1]]], ['#5a5a68', [[0, -14, 1, 1]]]],
1139 // a chef's toque
1140 Cook: [['#b9bab4', [[-2, -12, 5, 1], [3, -15, 1, 2], [2, -16, 1, 1], [2, -13, 1, 1]]], ['#f7f7f2', [[-2, -13, 4, 1], [-3, -15, 6, 2], [-2, -16, 4, 1]]], ['#dcddd8', [[-1, -15, 1, 1], [0, -16, 1, 1]]]],
1141 // a wide straw sun hat
1142 Neighbor: [['#e2c27a', [[-4, -12, 9, 1], [-2, -14, 5, 1]]], ['#d9534f', [[-2, -13, 5, 1]]]],
1143 // a top hat
1144 Butler: [['#2e2e3a', [[-3, -12, 7, 1], [-2, -16, 5, 3]]], ['#6c6c84', [[-2, -16, 1, 3]]], ['#d4d4de', [[-2, -13, 5, 1]]]],
1145 // little horns
1146 Gremlin: [['#c0392b', [[-2, -12, 1, 1], [-2, -13, 1, 1], [-3, -14, 1, 1], [2, -12, 1, 1], [2, -13, 1, 1], [3, -14, 1, 1]]]],
1147}
1148
1149function hatOf(c: Resident): string {
1150 const role = displayName(c)
1151 return part(`hat-${role}`, () => {
1152 const hat = (own(HATS, role) ?? []).map(([color, boxes]) => pix(color, boxes)).join('')
1153 if (role !== 'Tinkerer') return hat
1154 // the propeller: blades across, then end on, turning
1155 return hat + `<g>${pix('#c9ccd6', [[-2, -15, 5, 1]])}${blinkAnim('opacity', '1', '0', 240)}</g><g>${pix('#c9ccd6', [[0, -15, 1, 1]])}${blinkAnim('opacity', '0', '1', 240)}</g>`
1156 })
1157}
1158// Eyes on the side facing you; none from behind.
1159const EYES: Record<'S' | 'E', [Pt, Pt]> = {
1160 S: [iso(-2.2, 2.25, 9), iso(1.6, 2.25, 9)],
1161 E: [iso(2.25, -1.6, 9), iso(2.25, 2.2, 9)],
1162}
1163// An arm at each end; the back one is drawn first so the body hides its root.
1164const ARMS: Record<'x' | 'y', { back: Pt; front: Pt }> = {
1165 x: { back: { x: -6.6, y: -10.3 }, front: { x: 4.6, y: -5.7 } },
1166 y: { back: { x: 4.6, y: -10.3 }, front: { x: -6.6, y: -5.7 } },
1167}
1168
1169// Four three-pixel posts along the long side, back ones first; walking, diagonal pairs lift in turn.
1170function clawdLegs(color: string, wide: 'x' | 'y', walking: boolean): string {
1171 return part(`lg${wide}${walking ? 'w' : 's'}${color.slice(1)}`, () => {
1172 const [ax, ay] = wide === 'x' ? [2.6, 1.2] : [1.2, 2.6]
1173 const corners = [[-ax, -ay], [ax, -ay], [-ax, ay], [ax, ay]] as const
1174 const post = (cx: number, cy: number, z0: number) => {
1175 const [x0, x1, y0, y1, z1] = [cx - 0.7, cx + 0.7, cy - 0.7, cy + 0.7, 3]
1176 return `<path fill="${shade(color, -0.12)}" d="${pathOf([iso(x0, y1, z1), iso(x1, y1, z1), iso(x1, y1, z0), iso(x0, y1, z0)])}"/>` +
1177 `<path fill="${shade(color, -0.34)}" d="${pathOf([iso(x1, y0, z1), iso(x1, y1, z1), iso(x1, y1, z0), iso(x1, y0, z0)])}"/>`
1178 }
1179 const frame = (lifted: (i: number) => boolean) => corners.map(([cx, cy], i) => post(cx, cy, lifted(i) ? 1 : 0)).join('')
1180 if (!walking) return frame(() => false)
1181 return `<g>${frame(i => i === 0 || i === 3)}${blinkAnim('opacity', '1', '0', 360)}</g><g>${frame(i => i === 1 || i === 2)}${blinkAnim('opacity', '0', '1', 360)}</g>`
1182 })
1183}
1184
1185function agentSvg(c: Resident, d: AgentDraw): string {
1186 const tk = (spans: Span[]) => track(spans, d.total, d.begin, d.isLoop)
1187 const body = bodyColor(c)
1188 const faces = faceSpans(d.faces, d.total)
1189 const sleeping = d.sleep
1190 const across = [...faces.S, ...faces.N]
1191 const along = [...faces.E, ...faces.W]
1192 const team = teamOf(c.session ?? '')
1193 const hat = hatOf(c)
1194 // the ring stays on the floor: not lifted by hops, hidden while sitting or lying
1195 const ring = team ? group(teamRing(team), tk(invert([...d.sit, ...d.sleep], d.total))) : ''
1196 const carry = d.carry ?? []
1197 const tape = team ?? TAPE
1198 const load = carry.length > 0 ? group(part(`load${tape.slice(1)}`, () => `<g transform="translate(-2.5 -16)">${boxIcon(tape)}</g>`), tk(carry)) : ''
1199 const capped = carry.length > 0 ? group(hat, tk(invert(carry, d.total))) : hat
1200 // eyes blink out of step with the othershooks/visitors.ts 61 lines1// Other sessions' visitors walking in from the street and out. Set-off times come from the store so all
2// houses agree; one let go leaves from where it stands now, as an earlier start would jump it back.
3import type { Resident } from '../types'
4import { arrive, errandEnds } from './scene'
5
6// Another session's household from the store; `wokeAt`: when it last woke from a long sleep.
7export type Household = { id: string; since: number; residents: Resident[]; wokeAt?: number }
8
9// seconds between one session's visitors setting off together
10const ARRIVE_GAP = 1.5
11// set off longer ago than this (ms): already in, no walk to show
12const WALK_FOR = 60000
13
14// Each member's set-off: when it joined (the lead: its session's start) or the session woke, if later;
15// those that came together a moment apart.
16function arrivals(f: Household): Map<string, number> {
17 const joined = (m: Resident) => Math.max(m.spawnedAt ?? (m.id === 'main' ? f.since : m.workSince ?? 0), f.wokeAt ?? 0)
18 const out = new Map<string, number>()
19 let next = -Infinity
20 for (const m of [...f.residents].sort((a, b) => joined(a) - joined(b))) {
21 const t = Math.max(joined(m), next)
22 out.set(m.id, t)
23 next = t + ARRIVE_GAP * 1000
24 }
25 return out
26}
27
28// The others' members now, each keeping its spot and desk. Newcomers walk in from their set-off time;
29// those let go walk out and are gone at the road's end.
30export function visit(list: Resident[], found: Household[], now: number): Resident[] {
31 const here = new Map(list.filter(c => c.session).map(c => [c.id, c]))
32 const ends = here.size > 0 ? errandEnds(list, now) : new Map<string, number>()
33 const isThere = (c: Resident) => (ends.get(c.id) ?? 0) <= now
34 const seen = new Set<string>()
35 const theirs = found.flatMap(f => {
36 const starts = arrivals(f)
37 return f.residents.map((m): Resident => {
38 const id = `${f.id}/${m.id}`
39 seen.add(id)
40 const had = here.get(id)
41 const member: Resident = { ...m, id, session: f.id }
42 if (!had) {
43 const start = starts.get(m.id) ?? now
44 if (now - start > WALK_FOR) return member
45 const from = Math.min(now, start)
46 return arrive(member, from, (start - from) / 1000)
47 }
48 return {
49 ...member,
50 ...(had.anchor ? { anchor: had.anchor } : {}),
51 ...(had.seat !== undefined ? { seat: had.seat } : {}),
52 ...(had.arriving !== undefined && !isThere(had) ? { arriving: had.arriving } : {}),
53 }
54 })
55 })
56 const gone = [...here.values()]
57 .filter(c => !seen.has(c.id) && !(c.leaving !== undefined && isThere(c)))
58 .map(c => (c.leaving !== undefined ? c : { ...c, leaving: now }))
59 return [...list.filter(c => !c.session), ...theirs, ...gone]
60}
61hooks/font.ts 266 lines1/*
2 * Vibehaus's pixel font, 5x7 with descenders, as SVG rectangles on the art's pixel grid so text stays
3 * crisp at any size. Widths vary; a blank column follows each letter.
4 */
5
6const GLYPHS: Record<string, string[]> = {
7 ' ': ['...'],
8 A: ['.###.', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'],
9 B: ['####.', '#...#', '#...#', '####.', '#...#', '#...#', '####.'],
10 C: ['.###.', '#...#', '#....', '#....', '#....', '#...#', '.###.'],
11 D: ['####.', '#...#', '#...#', '#...#', '#...#', '#...#', '####.'],
12 E: ['#####', '#....', '#....', '####.', '#....', '#....', '#####'],
13 F: ['#####', '#....', '#....', '####.', '#....', '#....', '#....'],
14 G: ['.###.', '#...#', '#....', '#.###', '#...#', '#...#', '.####'],
15 H: ['#...#', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'],
16 I: ['###', '.#.', '.#.', '.#.', '.#.', '.#.', '###'],
17 J: ['..###', '...#.', '...#.', '...#.', '...#.', '#..#.', '.##..'],
18 K: ['#...#', '#..#.', '#.#..', '##...', '#.#..', '#..#.', '#...#'],
19 L: ['#....', '#....', '#....', '#....', '#....', '#....', '#####'],
20 M: ['#...#', '##.##', '#.#.#', '#.#.#', '#...#', '#...#', '#...#'],
21 N: ['#...#', '#...#', '##..#', '#.#.#', '#..##', '#...#', '#...#'],
22 O: ['.###.', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
23 P: ['####.', '#...#', '#...#', '####.', '#....', '#....', '#....'],
24 Q: ['.###.', '#...#', '#...#', '#...#', '#.#.#', '#..#.', '.##.#'],
25 R: ['####.', '#...#', '#...#', '####.', '#.#..', '#..#.', '#...#'],
26 S: ['.####', '#....', '#....', '.###.', '....#', '....#', '####.'],
27 T: ['#####', '..#..', '..#..', '..#..', '..#..', '..#..', '..#..'],
28 U: ['#...#', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
29 V: ['#...#', '#...#', '#...#', '#...#', '#...#', '.#.#.', '..#..'],
30 W: ['#...#', '#...#', '#...#', '#.#.#', '#.#.#', '#.#.#', '.#.#.'],
31 X: ['#...#', '#...#', '.#.#.', '..#..', '.#.#.', '#...#', '#...#'],
32 Y: ['#...#', '#...#', '.#.#.', '..#..', '..#..', '..#..', '..#..'],
33 Z: ['#####', '....#', '...#.', '..#..', '.#...', '#....', '#####'],
34 a: ['.....', '.....', '.###.', '....#', '.####', '#...#', '.####'],
35 b: ['#....', '#....', '#.##.', '##..#', '#...#', '#...#', '####.'],
36 c: ['.....', '.....', '.###.', '#....', '#....', '#...#', '.###.'],
37 d: ['....#', '....#', '.##.#', '#..##', '#...#', '#...#', '.####'],
38 e: ['.....', '.....', '.###.', '#...#', '#####', '#....', '.###.'],
39 f: ['..##', '.#..', '.#..', '###.', '.#..', '.#..', '.#..'],
40 g: ['.....', '.....', '.####', '#...#', '#...#', '#...#', '.####', '....#', '.###.'],
41 h: ['#....', '#....', '#.##.', '##..#', '#...#', '#...#', '#...#'],
42 i: ['.#.', '...', '##.', '.#.', '.#.', '.#.', '###'],
43 j: ['..#', '...', '.##', '..#', '..#', '..#', '..#', '#.#', '.#.'],
44 k: ['#...', '#...', '#..#', '#.#.', '##..', '#.#.', '#..#'],
45 l: ['##.', '.#.', '.#.', '.#.', '.#.', '.#.', '###'],
46 m: ['.....', '.....', '##.#.', '#.#.#', '#.#.#', '#...#', '#...#'],
47 n: ['.....', '.....', '#.##.', '##..#', '#...#', '#...#', '#...#'],
48 o: ['.....', '.....', '.###.', '#...#', '#...#', '#...#', '.###.'],
49 p: ['.....', '.....', '####.', '#...#', '#...#', '#...#', '####.', '#....', '#....'],
50 q: ['.....', '.....', '.####', '#...#', '#...#', '#...#', '.####', '....#', '....#'],
51 r: ['.....', '.....', '#.##.', '##..#', '#....', '#....', '#....'],
52 s: ['.....', '.....', '.####', '#....', '.###.', '....#', '####.'],
53 t: ['.#..', '.#..', '####', '.#..', '.#..', '.#.#', '..#.'],
54 u: ['.....', '.....', '#...#', '#...#', '#...#', '#..##', '.##.#'],
55 v: ['.....', '.....', '#...#', '#...#', '#...#', '.#.#.', '..#..'],
56 w: ['.....', '.....', '#...#', '#...#', '#.#.#', '#.#.#', '.#.#.'],
57 x: ['.....', '.....', '#...#', '.#.#.', '..#..', '.#.#.', '#...#'],
58 y: ['.....', '.....', '#...#', '#...#', '#...#', '#...#', '.####', '....#', '.###.'],
59 z: ['.....', '.....', '#####', '...#.', '..#..', '.#...', '#####'],
60 0: ['.###.', '#...#', '#..##', '#.#.#', '##..#', '#...#', '.###.'],
61 1: ['..#..', '.##..', '..#..', '..#..', '..#..', '..#..', '.###.'],
62 2: ['.###.', '#...#', '....#', '...#.', '..#..', '.#...', '#####'],
63 3: ['#####', '...#.', '..#..', '...#.', '....#', '#...#', '.###.'],
64 4: ['...#.', '..##.', '.#.#.', '#..#.', '#####', '...#.', '...#.'],
65 5: ['#####', '#....', '####.', '....#', '....#', '#...#', '.###.'],
66 6: ['..##.', '.#...', '#....', '####.', '#...#', '#...#', '.###.'],
67 7: ['#####', '....#', '...#.', '..#..', '.#...', '.#...', '.#...'],
68 8: ['.###.', '#...#', '#...#', '.###.', '#...#', '#...#', '.###.'],
69 9: ['.###.', '#...#', '#...#', '.####', '....#', '...#.', '.##..'],
70 '.': ['..', '..', '..', '..', '..', '##', '##'],
71 ',': ['..', '..', '..', '..', '..', '##', '.#', '#.'],
72 ':': ['..', '##', '##', '..', '##', '##', '..'],
73 ';': ['..', '##', '##', '..', '##', '##', '.#', '#.'],
74 '!': ['#', '#', '#', '#', '#', '.', '#'],
75 '?': ['.###.', '#...#', '....#', '...#.', '..#..', '.....', '..#..'],
76 "'": ['#', '#'],
77 '"': ['#.#', '#.#'],
78 '`': ['#.', '.#'],
79 '-': ['....', '....', '....', '####'],
80 '_': ['.....', '.....', '.....', '.....', '.....', '.....', '.....', '#####'],
81 '+': ['.....', '..#..', '..#..', '#####', '..#..', '..#..'],
82 '=': ['.....', '.....', '#####', '.....', '#####'],
83 '*': ['.....', '..#..', '#.#.#', '.###.', '#.#.#', '..#..'],
84 '/': ['....#', '....#', '...#.', '..#..', '.#...', '#....', '#....'],
85 '|': ['#', '#', '#', '#', '#', '#', '#', '#'],
86 '(': ['..#', '.#.', '#..', '#..', '#..', '.#.', '..#'],
87 ')': ['#..', '.#.', '..#', '..#', '..#', '.#.', '#..'],
88 '[': ['###', '#..', '#..', '#..', '#..', '#..', '###'],
89 ']': ['###', '..#', '..#', '..#', '..#', '..#', '###'],
90 '{': ['..##', '.#..', '.#..', '#...', '.#..', '.#..', '..##'],
91 '}': ['##..', '..#.', '..#.', '...#', '..#.', '..#.', '##..'],
92 '<': ['...#', '..#.', '.#..', '#...', '.#..', '..#.', '...#'],
93 '>': ['#...', '.#..', '..#.', '...#', '..#.', '.#..', '#...'],
94 '#': ['.#.#.', '.#.#.', '#####', '.#.#.', '#####', '.#.#.', '.#.#.'],
95 '%': ['##..#', '##..#', '...#.', '..#..', '.#...', '#..##', '#..##'],
96 '&': ['.##..', '#..#.', '#.#..', '.#...', '#.#.#', '#..#.', '.##.#'],
97 '@': ['.###.', '#...#', '#.###', '#.#.#', '#.###', '#....', '.###.'],
98 $: ['..#..', '.####', '#.#..', '.###.', '..#.#', '####.', '..#..'],
99 '^': ['..#..', '.#.#.', '#...#'],
100 '~': ['.....', '.....', '.#...', '#.#.#', '...#.'],
101 '…': ['.....', '.....', '.....', '.....', '.....', '.....', '#.#.#'],
102 '·': ['..', '..', '..', '##', '##'],
103 '⧗': ['#####', '.###.', '..#..', '..#..', '.#.#.', '#...#', '#####'],
104 '♥': ['.....', '.#.#.', '#####', '#####', '.###.', '..#..'],
105 '♪': ['..###', '..#.#', '..#..', '..#..', '###..', '###..'],
106}
107GLYPHS[String.fromCharCode(92)] = ['#....', '#....', '.#...', '..#..', '...#.', '....#', '....#']
108
109const UNKNOWN = ['#####', '#...#', '#...#', '#...#', '#...#', '#...#', '#####']
110
111// Accents dropped; dotless i, ß, long dashes and curly quotes made plain.
112const FOLD: Record<string, string> = { 'ı': 'i', 'İ': 'I', 'ß': 'ss' }
113for (const [code, plain] of [[0x2014, '-'], [0x2013, '-'], [0x201c, '"'], [0x201d, '"'], [0x2018, "'"], [0x2019, "'"]] as const) {
114 FOLD[String.fromCharCode(code)] = plain
115}
116const MARKS = new RegExp(`[${String.fromCharCode(0x300)}-${String.fromCharCode(0x36f)}]`, 'g')
117
118// joiners, variation selectors and skin tones, which only shape the emoji beside them
119const SHAPERS = /[\u200d\ufe0e\ufe0f]|\ud83c[\udffb-\udfff]/g
120
121function normalize(text: string): string[] {
122 return [...[...text.replace(SHAPERS, '')].map(ch => FOLD[ch] ?? ch).join('').normalize('NFD').replace(MARKS, '').normalize('NFC')]
123}
124
125const glyphOf = (ch: string): string[] => GLYPHS[ch] ?? (/\s/.test(ch) ? (GLYPHS[' '] as string[]) : UNKNOWN)
126
127const widthOf = (g: string[]) => g[0]?.length ?? 5
128
129// Width of `text` at `px` units a pixel.
130export function textWidth(text: string, px: number): number {
131 const chars = normalize(text)
132 if (chars.length === 0) return 0
133 return (chars.reduce((n, ch) => n + widthOf(glyphOf(ch)) + 1, 0) - 1) * px
134}
135
136// Cuts `text` to `maxWidth`, ending in "…" when cut.
137export function fitText(text: string, maxWidth: number, px: number): string {
138 if (textWidth(text, px) <= maxWidth) return text
139 const chars = normalize(text)
140 let out = ''
141 for (const ch of chars) {
142 if (textWidth(`${out}${ch}…`, px) > maxWidth) break
143 out += ch
144 }
145 return `${out.trimEnd()}…`
146}
147
148// Wraps at spaces, cutting overlong words; at most `maxLines`, the last ending in "…" if text is left.
149export function wrapText(text: string, maxWidth: number, px: number, maxLines: number): string[] {
150 const lines: string[] = []
151 // by code points, so an emoji is never split
152 let rest = text.trim()
153 while (rest.length > 0 && lines.length < maxLines) {
154 if (textWidth(rest, px) <= maxWidth) {
155 lines.push(rest)
156 rest = ''
157 break
158 }
159 const chars = [...rest]
160 let cut = 0
161 for (let i = 1; i <= chars.length; i++) {
162 if (textWidth(chars.slice(0, i).join(''), px) > maxWidth) break
163 cut = i
164 }
165 const space = chars.lastIndexOf(' ', cut)
166 const end = space > 0 ? space : Math.max(1, cut)
167 lines.push(chars.slice(0, end).join('').trimEnd())
168 rest = chars.slice(end).join('').trimStart()
169 }
170 if (rest.length > 0 && lines.length > 0) {
171 lines[lines.length - 1] = fitText(`${lines[lines.length - 1]} ${rest}`, maxWidth, px)
172 }
173 return lines
174}
175
176const r2 = (n: number) => Math.round(n * 100) / 100
177
178type Rect = [x: number, y: number, w: number, h: number]
179
180// Lit pixels as few rectangles: each run in a row grows down while rows below match it.
181function rectsOf(rows: string[]): Rect[] {
182 const used = rows.map(row => [...row].map(() => false))
183 const lit = (x: number, y: number) => rows[y]?.[x] === '#' && used[y]?.[x] === false
184 const out: Rect[] = []
185 rows.forEach((row, y) => {
186 for (let x = 0; x < row.length; x++) {
187 if (!lit(x, y)) continue
188 let w = 1
189 while (lit(x + w, y)) w++
190 let h = 1
191 while (Array.from({ length: w }, (_, k) => lit(x + k, y + h)).every(Boolean)) h++
192 for (let yy = y; yy < y + h; yy++) for (let xx = x; xx < x + w; xx++) (used[yy] as boolean[])[xx] = true
193 out.push([x, y, w, h])
194 }
195 })
196 return out
197}
198
199const glyphRects = new Map<string, Rect[]>()
200
201// by letter; letters the font lacks share one box, so the map never outgrows the font
202function rectsOfChar(ch: string): Rect[] {
203 const glyph = glyphOf(ch)
204 const key = glyph === UNKNOWN ? '' : ch
205 const known = glyphRects.get(key)
206 if (known) return known
207 const rects = rectsOf(glyph)
208 glyphRects.set(key, rects)
209 return rects
210}
211
212const pathOf = (rects: Rect[], dx = 0, dy = 0) =>
213 rects.map(([x, y, w, h]) => `M${x + dx} ${y + dy}h${w}v${h}h${-w}z`).join('')
214
215// Outlines in font pixels from the top-left, kept since names repeat.
216const outlines = new Map<string, string>()
217
218function outline(text: string): string {
219 const known = outlines.get(text)
220 if (known !== undefined) return known
221 let d = ''
222 let cx = 0
223 for (const ch of normalize(text)) {
224 d += pathOf(rectsOfChar(ch), cx)
225 cx += widthOf(glyphOf(ch)) + 1
226 }
227 if (outlines.size > 500) outlines.clear()
228 outlines.set(text, d)
229 return d
230}
231
232// One path, its capitals' top at (x, y), `anchor` as text-anchor; `cls` a class the light theme recolours.
233export function pixelText(
234 text: string, x: number, y: number,
235 { px = 0.5, color, anchor = 'start', cls }: { px?: number; color: string; anchor?: 'start' | 'middle' | 'end'; cls?: string },
236): string {
237 const d = outline(text)
238 if (!d) return ''
239 const w = textWidth(text, px)
240 const left = anchor === 'middle' ? x - w / 2 : anchor === 'end' ? x - w : x
241 return `<path${cls ? ` class="${cls}"` : ''} transform="translate(${r2(left)} ${r2(y)}) scale(${Math.round(px * 1000) / 1000})" fill="${color}" d="${d}"/>`
242}
243
244// As pixelText, but each glyph defined once through `define` and reused: far shorter once letters repeat.
245export function glyphText(
246 text: string, x: number, y: number,
247 { px = 0.5, color, anchor = 'start', cls }: { px?: number; color: string; anchor?: 'start' | 'middle' | 'end'; cls?: string },
248 define: (id: string, d: string) => void,
249): string {
250 let uses = ''
251 let cx = 0
252 for (const ch of normalize(text)) {
253 const rects = rectsOfChar(ch)
254 if (rects.length > 0) {
255 const id = `gl${(ch.codePointAt(0) ?? 63).toString(36)}`
256 define(id, pathOf(rects))
257 uses += `<use href="#${id}"${cx ? ` x="${cx}"` : ''}/>`
258 }
259 cx += widthOf(glyphOf(ch)) + 1
260 }
261 if (!uses) return ''
262 const w = textWidth(text, px)
263 const left = anchor === 'middle' ? x - w / 2 : anchor === 'end' ? x - w : x
264 return `<g${cls ? ` class="${cls}"` : ''} transform="translate(${r2(left)} ${r2(y)}) scale(${Math.round(px * 1000) / 1000})" fill="${color}">${uses}</g>`
265}
266hooks/iso.ts 242 lines1/*
2 * Isometric pixel drawing: a 2:1 projection and a canvas that fills shapes on the pixel grid. Each fill
3 * is a material with night (dark theme) and day colours; a style swaps in the day ones in light mode.
4 */
5
6export type Pt = { x: number; y: number }
7
8// A material: night and day colours, and a short class for the style.
9export type Mat = { night: string; day: string; cls: string }
10
11// A box's faces: lit top, left side, darkest right side.
12export type Faces = { top: Mat; left: Mat; right: Mat }
13
14export function shade(hex: string, f: number): string {
15 const v = parseInt(hex.slice(1), 16)
16 const c = (s: number) => {
17 const ch = (v >> s) & 255
18 return Math.max(0, Math.min(255, Math.round(f < 0 ? ch * (1 + f) : ch + (255 - ch) * f)))
19 }
20 return '#' + ((c(16) << 16) | (c(8) << 8) | c(0)).toString(16).padStart(6, '0')
21}
22
23// mulberry32: a small seeded generator, so a picture is the same on every redraw.
24export function rng(seed: number): () => number {
25 let a = seed >>> 0
26 return () => {
27 a = (a + 0x6d2b79f5) >>> 0
28 let t = a
29 t = Math.imul(t ^ (t >>> 15), t | 1)
30 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
31 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
32 }
33}
34
35export function mat(cls: string, night: string, day: string): Mat {
36 return { cls, night, day }
37}
38
39// Indoors by night: the day colour dimmed towards amber, so the house glows in the dark garden.
40function lamplit(day: string): string {
41 const v = parseInt(day.slice(1), 16)
42 const ch = (s: number, f: number) => Math.min(255, Math.round(((v >> s) & 255) * f))
43 return '#' + ((ch(16, 0.8) << 16) | (ch(8, 0.67) << 8) | ch(0, 0.54)).toString(16).padStart(6, '0')
44}
45
46export function warm(cls: string, day: string): Mat {
47 return mat(cls, lamplit(day), day)
48}
49
50export function warmFaces(cls: string, day: string): Faces {
51 return faces(cls, lamplit(day), day)
52}
53
54export function faces(cls: string, night: string, day: string): Faces {
55 return {
56 top: mat(`${cls}t`, shade(night, 0.14), shade(day, 0.18)),
57 left: mat(`${cls}l`, night, day),
58 right: mat(`${cls}r`, shade(night, -0.28), shade(day, -0.2)),
59 }
60}
61
62// A convex polygon's pixel rows (centres inside; left edge in, right out), so shared edges tile exactly.
63function rows(pts: Pt[]): [number, number, number][] {
64 if (pts.length < 3) return []
65 const ys = pts.map(p => p.y)
66 const top = Math.floor(Math.min(...ys))
67 const bottom = Math.ceil(Math.max(...ys))
68 const out: [number, number, number][] = []
69 for (let r = top; r < bottom; r++) {
70 const yc = r + 0.5
71 let lo = Infinity
72 let hi = -Infinity
73 for (let i = 0; i < pts.length; i++) {
74 const a = pts[i] as Pt
75 const b = pts[(i + 1) % pts.length] as Pt
76 if ((a.y <= yc && yc < b.y) || (b.y <= yc && yc < a.y)) {
77 const x = a.x + ((yc - a.y) * (b.x - a.x)) / (b.y - a.y)
78 lo = Math.min(lo, x)
79 hi = Math.max(hi, x)
80 }
81 }
82 if (lo > hi) continue
83 const c0 = Math.ceil(lo - 0.5 - 1e-9)
84 const c1 = Math.ceil(hi - 0.5 - 1e-9)
85 if (c1 > c0) out.push([r, c0, c1 - c0])
86 }
87 return out
88}
89
90// One picture's paths: one per material in each layer; layers stack in the order opened.
91export class Canvas {
92 private parts: string[] = []
93 private open = new Map<string, { m: Mat | string; d: string[] }>()
94 readonly used = new Map<string, Mat>()
95 // stamped pieces: where each was first drawn
96 private stamps = new Map<string, Pt>()
97 private defs: string[] = []
98
99 // Draws a recurring piece once, then copies moved by whole pixels. `origin`: the same point on each;
100 // `fill` fills pieces drawn with `inherit` (floors, wallpaper).
101 stamp(name: string, origin: Pt, draw: (cv: Canvas) => void, fill?: string, attrs = '', inner = ''): void {
102 // pieces at different half-pixel phases rasterise differently: one stamp each
103 const id = `${name}${phaseOf(origin)}`
104 const first = this.stamps.get(id)
105 const paint = (fill ? ` fill="${fill}"` : '') + attrs
106 const end = inner ? `>${inner}</use>` : '/>'
107 if (!first) {
108 const sub = new Canvas()
109 draw(sub)
110 for (const [k, m] of sub.used) this.used.set(k, m)
111 this.defs.push(`<g id="${id}">${sub.svg()}</g>`)
112 this.stamps.set(id, origin)
113 this.raw(`<use href="#${id}"${paint}${end}`)
114 return
115 }
116 const dx = Math.round(origin.x - first.x)
117 const dy = Math.round(origin.y - first.y)
118 this.raw(`<use href="#${id}" x="${dx}" y="${dy}"${paint}${end}`)
119 }
120
121 // Defines a piece without placing it, for hand-placed copies; `draw` may return an animation.
122 define(id: string, draw: (cv: Canvas) => string | void): void {
123 if (this.stamps.has(id)) return
124 const sub = new Canvas()
125 const anim = draw(sub) ?? ''
126 for (const [k, m] of sub.used) this.used.set(k, m)
127 this.defs.push(`<g id="${id}">${anim}${sub.svg()}</g>`)
128 this.stamps.set(id, { x: 0, y: 0 })
129 }
130
131 // The pieces stamped, for the picture's defs.
132 stamped(): string {
133 return this.defs.join('')
134 }
135
136 // Starts a new layer over everything drawn before.
137 layer(): void {
138 for (const { m, d } of this.open.values()) {
139 if (d.length === 0) continue
140 this.parts.push(typeof m === 'string'
141 ? `<path fill="${m}" d="${d.join('')}"/>`
142 : `<path class="${m.cls}" d="${d.join('')}"/>`)
143 }
144 this.open.clear()
145 }
146
147 private runs(m: Mat | string, list: [number, number, number][]): void {
148 if (list.length === 0) return
149 const key = typeof m === 'string' ? m : m.cls
150 if (typeof m !== 'string') this.used.set(m.cls, m)
151 const slot = this.open.get(key) ?? { m, d: [] }
152 this.open.set(key, slot)
153 // each stretch of consecutive rows is one stair-stepped outline
154 let start = 0
155 for (let i = 1; i <= list.length; i++) {
156 const prev = list[i - 1] as [number, number, number]
157 const next = list[i]
158 if (next && next[0] === prev[0] + 1) continue
159 slot.d.push(outline(list.slice(start, i)))
160 start = i
161 }
162 }
163
164 poly(pts: Pt[], m: Mat | string): void {
165 this.runs(m, rows(pts))
166 }
167
168 rect(x: number, y: number, w: number, h: number, m: Mat | string): void {
169 if (w <= 0 || h <= 0) return
170 this.runs(m, Array.from({ length: h }, (_, i) => [y + i, x, w] as [number, number, number]))
171 }
172
173 // Raw markup over everything drawn so far.
174 raw(svg: string): void {
175 this.layer()
176 this.parts.push(svg)
177 }
178
179 svg(): string {
180 this.layer()
181 return this.parts.join('')
182 }
183}
184
185// A point's half-pixel phase, to tell stamps apart.
186function phaseOf(p: Pt): string {
187 const part = (v: number) => Math.round((((v % 1) + 1) % 1) * 2) % 2
188 return part(p.x) || part(p.y) ? `-h${part(p.x)}${part(p.y)}` : ''
189}
190
191// The outline of consecutive pixel rows: down the right side, back up the left.
192function outline(list: [number, number, number][]): string {
193 const first = list[0] as [number, number, number]
194 let d = `M${first[1]} ${first[0]}`
195 let x = first[1]
196 let down = 0
197 const across = (to: number) => {
198 if (to === x) return
199 if (down !== 0) {
200 d += `v${down}`
201 down = 0
202 }
203 d += `h${to - x}`
204 x = to
205 }
206 for (const [, left, w] of list) {
207 across(left + w)
208 down += 1
209 }
210 for (let i = list.length - 1; i >= 0; i--) {
211 across((list[i] as [number, number, number])[1])
212 down -= 1
213 }
214 return `${d}${down !== 0 ? `v${down}` : ''}z`
215}
216
217// CSS for every material a picture used, by night or day.
218export function styleOf(used: Iterable<Mat>, time: 'night' | 'day'): string {
219 let css = ''
220 for (const m of used) css += `.${m.cls}{fill:${m[time]}}`
221 return css
222}
223
224// A convex polygon's pixels as their own path, for shapes that animate alone.
225export function pathOf(pts: Pt[]): string {
226 return rows(pts).map(([r, x, w]) => `M${x} ${r}h${w}v1h-${w}z`).join('')
227}
228
229// ---------- the clock ----------
230
231// Draw time (s): loops (a sway, a blink) start where this clock puts them, so redraws never show.
232let clockS = 0
233
234export function setClock(now: number): void {
235 clockS = now / 1000
236}
237
238// `begin` for a loop of `dur` seconds, running `lead` seconds ahead.
239export function loopBegin(dur: number, lead = 0): string {
240 return `begin="-${((((clockS + lead) % dur) + dur) % dur).toFixed(2)}s"`
241}
242hooks/yard.ts 176 lines1/*
2 * The front yard: a road from the workshop door to the picture's edge, pallets where the Clawds stack
3 * a box per piece of work shipped, and a mailbox.
4 */
5import { at, box, frontDoor } from './house'
6import type { Layout } from './house'
7import { faces, mat, shade } from './iso'
8import type { Canvas, Mat, Pt } from './iso'
9
10const Y = {
11 kerb: mat('ye', '#2b2925', '#8f8574'),
12 stone: mat('yo', '#47433d', '#c9bfae'),
13 seam: mat('yn', '#36332e', '#aa9f8c'),
14 doormat: mat('ya', '#4e2d20', '#9c5a3c'),
15 card: faces('yc', '#8a6440', '#cf9a5c'),
16 tape: mat('yt', '#9c8058', '#ecd6a6'),
17 pallet: faces('yp', '#4f3d2c', '#a8865e'),
18 post: faces('yq', '#2e241b', '#6b4f36'),
19 mailbox: faces('ym', '#6e2a26', '#c94f45'),
20 flag: mat('yf', '#c9a227', '#ffd34a'),
21}
22
23// Boxes stack two by two, three high, on whole-pixel steps so stacks line up.
24const BOX = 3
25const STEP = 4
26const BOX_H = 3
27const PALLET = 2 * STEP
28const LAYERS = 3
29const PALLETS = 5
30
31// Yard places on the plan: pallets by back corner, `drops` where a Clawd stacks from, `floor` to idle on.
32type Yard = {
33 road: { x0: number; x1: number; y0: number; y1: number }
34 mailbox: Pt
35 pallets: Pt[]
36 drops: Pt[]
37 floor: { x0: number; x1: number; y0: number; y1: number }
38}
39
40const yards = new WeakMap<Layout, Yard>()
41
42export function yardOf(L: Layout): Yard {
43 const known = yards.get(L)
44 if (known) return known
45 const d = frontDoor(L)
46 // out until its near kerb meets the picture's left edge
47 const road = { x0: d.mid - 6, x1: d.mid + 6, y0: L.ph, y1: d.mid + 6 + L.ox }
48 // pallets along the road's far side, the mailbox past them
49 const pallets = Array.from({ length: PALLETS }, (_, i) => ({ x: road.x0 - 2 - PALLET, y: road.y0 + 5 + i * (PALLET + 4) }))
50 const mailbox = { x: road.x0 - 2.5, y: road.y0 + 6 + PALLETS * (PALLET + 4) }
51 const yard = {
52 road,
53 mailbox,
54 pallets,
55 drops: pallets.map(p => ({ x: road.x0 + 2, y: p.y + PALLET / 2 })),
56 floor: { x0: road.x0 + 2, x1: road.x1 - 2, y0: road.y0 + 6, y1: mailbox.y },
57 }
58 yards.set(L, yard)
59 return yard
60}
61
62const quad = (cv: Canvas, L: Layout, x0: number, x1: number, y0: number, y1: number, m: typeof Y.stone) =>
63 cv.poly([at(L, x0, y0), at(L, x1, y0), at(L, x1, y1), at(L, x0, y1)], m)
64
65// The road: slabs in a running bond, kerbed both sides.
66export function road(cv: Canvas, L: Layout): void {
67 const r = yardOf(L).road
68 quad(cv, L, r.x0 - 1, r.x1 + 1, r.y0, r.y1 + 1, Y.kerb)
69 cv.layer()
70 quad(cv, L, r.x0, r.x1, r.y0, r.y1, Y.stone)
71 cv.layer()
72 for (let y = r.y0 + 4, k = 0; y < r.y1; y += 4, k++) {
73 quad(cv, L, r.x0, r.x1, y, y + 0.6, Y.seam)
74 const x = (r.x0 + r.x1) / 2 + (k % 2 ? 3 : -3)
75 quad(cv, L, x, x + 0.6, y + 0.6, Math.min(r.y1, y + 4), Y.seam)
76 }
77 cv.layer()
78}
79
80// A yard box: `tape` colour (else plain); `lands`: seconds until it lands, if in transit.
81export type Parcel = { tape?: string; lands?: number }
82
83// The yard's things with their depth (plan x + y), drawn back to front among the trees; `pile` by fill order.
84export function yardPieces(L: Layout, pile: Map<number, Parcel>): { depth: number; draw: (cv: Canvas) => void }[] {
85 const y = yardOf(L)
86 const d = frontDoor(L)
87 return [
88 // the doormat, over the foot of the house's base
89 { depth: d.mid + y.road.y0, draw: cv => (quad(cv, L, d.x0 + 1.5, d.x1 - 1.5, y.road.y0 + 0.5, y.road.y0 + 3.5, Y.doormat), cv.layer()) },
90 { depth: y.mailbox.x + y.mailbox.y, draw: cv => mailbox(cv, L, pile.size > 0) },
91 ...y.pallets.map((p, i) => ({ depth: p.x + p.y + PALLET, draw: (cv: Canvas) => pallet(cv, L, i, pile) })),
92 ]
93}
94
95// The mailbox, its flag up once anything has been sent.
96function mailbox(cv: Canvas, L: Layout, hasMail: boolean): void {
97 const m = yardOf(L).mailbox
98 box(cv, L, m.x - 0.5, m.y - 0.5, 1, 1, 7, Y.post)
99 cv.layer()
100 box(cv, L, m.x - 1.5, m.y - 2.5, 3, 5, 3, Y.mailbox, 7)
101 cv.layer()
102 const up = hasMail ? 3 : 0
103 cv.poly([at(L, m.x + 1.5, m.y + 1, 8 + up), at(L, m.x + 1.5, m.y + 2.2, 8 + up), at(L, m.x + 1.5, m.y + 2.2, 9 + up), at(L, m.x + 1.5, m.y + 1, 9 + up)], Y.flag)
104 cv.poly([at(L, m.x + 1.5, m.y + 1, 7.5), at(L, m.x + 1.5, m.y + 1.4, 7.5), at(L, m.x + 1.5, m.y + 1.4, 9 + up), at(L, m.x + 1.5, m.y + 1, 9 + up)], Y.flag)
105 cv.layer()
106}
107
108// Box places in fill order: pallets and corners nearest the mailbox first, a layer at a time.
109type Spot = { pallet: number; x: number; y: number; z: number }
110
111const spotsOf = new WeakMap<Layout, Spot[]>()
112
113export function boxSpots(L: Layout): Spot[] {
114 const known = spotsOf.get(L)
115 if (known) return known
116 const { pallets: list, mailbox: m } = yardOf(L)
117 const far = (x: number, y: number) => Math.hypot(x + BOX / 2 - m.x, y + BOX / 2 - m.y)
118 const order = list.map((_, i) => i).sort((a, b) => {
119 const pa = list[a] as Pt
120 const pb = list[b] as Pt
121 return far(pa.x + PALLET / 2, pa.y + PALLET / 2) - far(pb.x + PALLET / 2, pb.y + PALLET / 2)
122 })
123 const spots = order.flatMap(i => {
124 const p = list[i] as Pt
125 const slots = [0, 1, 2, 3].map(k => ({ x: p.x + 0.5 + (k % 2) * STEP, y: p.y + 0.5 + Math.floor(k / 2) * STEP }))
126 .sort((a, b) => far(a.x, a.y) - far(b.x, b.y))
127 return Array.from({ length: LAYERS }, (_, layer) => slots.map(s => ({ pallet: i, ...s, z: 1 + layer * BOX_H }))).flat()
128 })
129 spotsOf.set(L, spots)
130 return spots
131}
132
133// Pallet `i` and its boxes, back to front, bottom to top.
134function pallet(cv: Canvas, L: Layout, i: number, pile: Map<number, Parcel>): void {
135 const p = yardOf(L).pallets[i] as Pt
136 box(cv, L, p.x, p.y, PALLET, PALLET, 1, Y.pallet)
137 cv.layer()
138 const here = boxSpots(L)
139 .flatMap((s, n) => {
140 const parcel = pile.get(n)
141 return s.pallet === i && parcel ? [{ ...s, parcel }] : []
142 })
143 .sort((a, b) => a.z - b.z || a.x + a.y - (b.x + b.y))
144 for (const s of here) {
145 const { lands } = s.parcel
146 const tape = s.parcel.tape ? tapeOf(s.parcel.tape) : Y.tape
147 cv.stamp(`ybox${tape.cls}`, at(L, s.x, s.y, s.z), sub => {
148 box(sub, L, s.x, s.y, BOX, BOX, BOX_H, Y.card, s.z)
149 sub.layer()
150 sub.poly([at(L, s.x + 1.2, s.y, s.z + BOX_H), at(L, s.x + 1.8, s.y, s.z + BOX_H), at(L, s.x + 1.8, s.y + BOX, s.z + BOX_H), at(L, s.x + 1.2, s.y + BOX, s.z + BOX_H)], tape)
151 sub.poly([at(L, s.x + 1.2, s.y + BOX, s.z + BOX_H), at(L, s.x + 1.8, s.y + BOX, s.z + BOX_H), at(L, s.x + 1.8, s.y + BOX, s.z + 1.5), at(L, s.x + 1.2, s.y + BOX, s.z + 1.5)], tape)
152 }, undefined, lands === undefined ? '' : ' opacity="0"',
153 lands === undefined ? '' : `<set attributeName="opacity" to="1" begin="${Math.max(0, lands).toFixed(2)}s" fill="freeze"/>`)
154 }
155}
156
157// Tape in a session's colour, dimmer by night.
158const tapeOf = (color: string): Mat => mat(`yt${color.slice(1)}`, shade(color, -0.35), color)
159
160// The road, pallet and mailbox footprints on the picture.
161export function yardShapes(L: Layout): { road: Pt[]; pallets: Pt[][]; mailbox: Pt } {
162 const y = yardOf(L)
163 const corners = (x0: number, x1: number, y0: number, y1: number) => [at(L, x0, y0), at(L, x1, y0), at(L, x1, y1), at(L, x0, y1)]
164 return {
165 road: corners(y.road.x0 - 1, y.road.x1 + 1, y.road.y0, y.road.y1 + 1),
166 pallets: y.pallets.map(p => corners(p.x, p.x + PALLET, p.y, p.y + PALLET)),
167 mailbox: at(L, y.mailbox.x, y.mailbox.y),
168 }
169}
170
171// Top left of the day's box count, across the road from the mailbox.
172export function signAt(L: Layout): Pt {
173 const y = yardOf(L)
174 return at(L, y.road.x1 + 3, y.mailbox.y - 4)
175}
176hooks/furniture.ts 239 lines1/*
2 * Each room's furniture, built of boxes on the plan: desks with monitors along the back of every work
3 * room, then each room's own pieces. Flames, screens and steam move.
4 */
5import type { Room } from '../types'
6import { box, faceX, faceY, at } from './house'
7import type { Cell, Layout } from './house'
8import { faces, loopBegin, mat, pathOf, warm, warmFaces } from './iso'
9import type { Canvas, Mat, Pt } from './iso'
10
11const F = {
12 woodDark: warmFaces('wd', '#9a6740'),
13 wood: warmFaces('wm', '#b8834f'),
14 pine: warmFaces('wp', '#d6b07a'),
15 white: warmFaces('wh', '#f4f2ec'),
16 stone: warmFaces('st', '#c9cbcf'),
17 dark: faces('dk', '#1c2024', '#3a4047'),
18 brick: warmFaces('br', '#b5644f'),
19 brass: warmFaces('bs', '#d8b25a'),
20 leaf: warmFaces('lf', '#5fae62'),
21 pot: warmFaces('pt', '#c4714e'),
22 linen: warmFaces('ln', '#fbf9f4'),
23 blue: warmFaces('bb', '#6f8fd6'),
24 green: warmFaces('bg', '#79b89a'),
25 couch: warmFaces('cu', '#c77fae'),
26 shade: warmFaces('ls', '#f5dca0'),
27 red: warmFaces('rd', '#d9584c'),
28}
29const RUG = {
30 library: [warm('rl', '#b85c5c'), warm('rm', '#d07a7a')],
31 lounge: [warm('rq', '#d9a9c9'), warm('rr', '#e8bfd8')],
32} as const
33const BOOKS = [
34 warm('b1', '#c75a5a'), warm('b2', '#5a86c7'), warm('b3', '#c7ad5a'),
35 warm('b4', '#5ac79a'), warm('b5', '#9a5ac7'), warm('b6', '#c7865a'),
36]
37const HOLE = mat('ho', '#0d0b0a', '#3a2a24')
38const DESK_BODY: Record<Room, typeof F.wood> = {
39 library: F.woodDark, study: F.wood, sunroom: F.pine, kitchen: F.white, workshop: F.pine, lounge: F.wood,
40}
41
42// Desk i's back corner on the plan.
43export const deskAt = (c: Cell, i: number): { x: number; y: number } => ({ x: c.x + 2 + 14 * i, y: c.y + 1 })
44
45// A desk and monitor on the back wall; returns where the screen (drawn separately) and light go.
46export function desk(cv: Canvas, L: Layout, room: Room, c: Cell, i: number): { screen: Pt; light: Pt } {
47 const d = deskAt(c, i)
48 const body = DESK_BODY[room]
49 cv.stamp(`desk-${body.top.cls}${room === 'kitchen' ? 's' : ''}`, at(L, d.x, d.y), sub => {
50 box(sub, L, d.x, d.y, 12, 6, 5, body)
51 box(sub, L, d.x - 0.5, d.y, 13, 6.5, 1, room === 'kitchen' ? F.stone : body, 5)
52 sub.layer()
53 // the monitor on its stand
54 box(sub, L, d.x + 5.5, d.y + 1.5, 1, 1, 2, F.dark, 6)
55 box(sub, L, d.x + 2, d.y + 1, 8, 1, 6, F.dark, 8)
56 })
57 return { screen: at(L, d.x + 2.5, d.y + 2, 13), light: at(L, d.x + 10.5, d.y + 5.5, 6) }
58}
59
60function plant(cv: Canvas, L: Layout, x: number, y: number, tall: boolean): void {
61 cv.stamp(tall ? 'plant-t' : 'plant', at(L, x, y), sub => {
62 box(sub, L, x, y, 3, 3, 3, F.pot)
63 sub.layer()
64 box(sub, L, x - 0.5, y - 0.5, 4, 4, tall ? 5 : 3, F.leaf, 3)
65 sub.layer()
66 box(sub, L, x + 0.5, y + 0.5, 2, 2, tall ? 3 : 2, F.leaf, tall ? 8 : 6)
67 })
68}
69
70function rug(cv: Canvas, L: Layout, x: number, y: number, w: number, d: number, m: readonly [Mat, Mat]): void {
71 cv.poly([at(L, x, y), at(L, x + w, y), at(L, x + w, y + d), at(L, x, y + d)], m[1])
72 cv.poly([at(L, x + 1.5, y + 1.5), at(L, x + w - 1.5, y + 1.5), at(L, x + w - 1.5, y + d - 1.5), at(L, x + 1.5, y + d - 1.5)], m[0])
73 cv.layer()
74}
75
76// A pixel shape with its own animation, as a flame or a screen.
77function lit(pts: Pt[], fill: string, anim: string): string {
78 return `<path fill="${fill}" d="${pathOf(pts)}">${anim}</path>`
79}
80
81// Steam rising from a point, wisp after wisp.
82function steam(p: Pt): string {
83 return [0, 1].map(i => `<rect x="${Math.round(p.x) + i}" y="${Math.round(p.y)}" width="1" height="1" fill="#e8e8f0" opacity="0">` +
84 `<animate attributeName="y" values="${Math.round(p.y)};${Math.round(p.y) - 6}" dur="2.4s" ${loopBegin(2.4, i * 1.2)} repeatCount="indefinite"/>` +
85 `<animate attributeName="opacity" values="0;0.6;0" dur="2.4s" ${loopBegin(2.4, i * 1.2)} repeatCount="indefinite"/></rect>`).join('')
86}
87
88function library(cv: Canvas, L: Layout, c: Cell): void {
89 rug(cv, L, c.x + 12, c.y + 15, 22, 18, RUG.library)
90 for (let k = 0; k < 2; k++) {
91 const y = c.y + 9 + 9 * k
92 cv.stamp('shelf', at(L, c.x, y), sub => {
93 box(sub, L, c.x, y, 5, 8, 18, F.woodDark)
94 sub.layer()
95 for (const z of [2, 8, 14]) {
96 for (let j = 0; j < 4; j++) {
97 const tall = 3 + ((j * 3 + z) % 2)
98 faceX(sub, L, c.x + 5, y + 0.5 + 2 * j, y + 2 + 2 * j, z, z + tall, BOOKS[(j * 2 + z) % BOOKS.length] as Mat)
99 }
100 }
101 })
102 }
103 // reading lamp by the window
104 box(cv, L, c.x + 33, c.y + 3, 1, 1, 12, F.brass)
105 box(cv, L, c.x + 31.5, c.y + 1.5, 4, 4, 3, F.shade, 12)
106 cv.layer()
107}
108
109function study(cv: Canvas, L: Layout, c: Cell): void {
110 // fireplace and chimney breast on the back wall
111 box(cv, L, c.x + 30, c.y, 8, 2, 25, F.brick)
112 box(cv, L, c.x + 29, c.y, 10, 4, 11, F.brick)
113 box(cv, L, c.x + 28.5, c.y, 11, 5, 1, F.woodDark, 11)
114 faceY(cv, L, c.y + 4, c.x + 31, c.x + 37, 1, 8, HOLE)
115 cv.layer()
116 const fire = lit([at(L, c.x + 32, c.y + 4, 1), at(L, c.x + 36, c.y + 4, 1), at(L, c.x + 35, c.y + 4, 5), at(L, c.x + 34, c.y + 4, 7), at(L, c.x + 33, c.y + 4, 4)], '#f5a623',
117 `<animate attributeName="fill" values="#f5a623;#ffd166;#f26b3a;#f5a623" dur="0.9s" ${loopBegin(0.9)} repeatCount="indefinite"/>`)
118 const embers = lit([at(L, c.x + 33, c.y + 4, 1), at(L, c.x + 35, c.y + 4, 1), at(L, c.x + 34.5, c.y + 4, 3), at(L, c.x + 33.5, c.y + 4, 3)], '#fff1b0',
119 `<animate attributeName="opacity" values="1;0.4;0.8;1" dur="0.6s" ${loopBegin(0.6)} repeatCount="indefinite"/>`)
120 cv.raw(fire + embers)
121 // floor lamp and armchair turned to the fire
122 plant(cv, L, c.x + 2, c.y + 9, true)
123 box(cv, L, c.x + 1, c.y + 26, 1, 1, 11, F.brass)
124 box(cv, L, c.x - 0.5, c.y + 24.5, 4, 4, 3, F.shade, 11)
125 cv.layer()
126 box(cv, L, c.x + 1, c.y + 16, 2, 7, 7, F.green)
127 cv.layer()
128 box(cv, L, c.x + 3, c.y + 16, 5, 7, 3, F.green)
129 cv.layer()
130 box(cv, L, c.x + 3, c.y + 16, 5, 1.5, 5, F.green)
131 cv.layer()
132 box(cv, L, c.x + 3, c.y + 21.5, 5, 1.5, 5, F.green)
133 cv.layer()
134}
135
136function sunroom(cv: Canvas, L: Layout, c: Cell): string {
137 // telescope on a tripod, aimed at the window
138 const tx = c.x + 6
139 const ty = c.y + 14
140 box(cv, L, tx - 2, ty, 1, 1, 6, F.woodDark)
141 box(cv, L, tx + 1, ty, 1, 1, 6, F.woodDark)
142 box(cv, L, tx, ty + 2, 1, 1, 6, F.woodDark)
143 cv.layer()
144 cv.poly([at(L, tx - 1, ty + 2, 8), at(L, tx + 4, ty - 3, 13), at(L, tx + 4, ty - 3, 11), at(L, tx - 1, ty + 2, 6)], F.brass.left)
145 cv.layer()
146 // radio on a side table
147 box(cv, L, c.x + 1, c.y + 20, 5, 5, 5, F.wood)
148 box(cv, L, c.x + 1.5, c.y + 21, 4, 3, 3, F.dark, 5)
149 cv.layer()
150 const light = at(L, c.x + 5.5, c.y + 23, 7)
151 plant(cv, L, c.x + 34, c.y + 3, true)
152 return `<rect x="${Math.round(light.x)}" y="${Math.round(light.y)}" width="1" height="1" fill="#4cf08c"><animate attributeName="opacity" values="1;0.2;1" dur="1.4s" ${loopBegin(1.4)} repeatCount="indefinite"/></rect>`
153}
154
155function kitchen(cv: Canvas, L: Layout, c: Cell): string {
156 box(cv, L, c.x, c.y + 8, 6, 7, 21, F.white)
157 faceX(cv, L, c.x + 6, c.y + 13.5, c.y + 14.2, 9, 15, F.stone.right)
158 faceX(cv, L, c.x + 6, c.y + 8, c.y + 15, 17, 17.6, F.stone.right)
159 cv.layer()
160 box(cv, L, c.x, c.y + 15, 6, 12, 7, F.white)
161 box(cv, L, c.x - 0.5, c.y + 15, 7, 12.5, 1, F.stone, 7)
162 cv.layer()
163 // burners, a pot and a coffee machine
164 for (const y of [c.y + 16.5, c.y + 20]) cv.poly([at(L, c.x + 1, y, 8), at(L, c.x + 4, y, 8), at(L, c.x + 4, y + 2.5, 8), at(L, c.x + 1, y + 2.5, 8)], HOLE)
165 cv.layer()
166 box(cv, L, c.x + 1, c.y + 16.5, 3, 2.5, 2, F.red, 8)
167 box(cv, L, c.x + 1, c.y + 23, 3, 3, 4, F.dark, 8)
168 cv.layer()
169 const pot = at(L, c.x + 2.5, c.y + 17.5, 10)
170 const cup = at(L, c.x + 2.5, c.y + 24, 12)
171 return steam(pot) + `<rect x="${Math.round(cup.x)}" y="${Math.round(cup.y)}" width="1" height="1" fill="#f2503f"><animate attributeName="opacity" values="1;0.3;1" dur="2s" ${loopBegin(2)} repeatCount="indefinite"/></rect>`
172}
173
174function workshop(cv: Canvas, L: Layout, c: Cell): void {
175 box(cv, L, c.x, c.y + 9, 7, 15, 6, F.pine)
176 box(cv, L, c.x - 0.5, c.y + 9, 8, 15.5, 1, F.woodDark, 6)
177 cv.layer()
178 box(cv, L, c.x + 1, c.y + 11, 3, 4, 2, F.red, 7)
179 box(cv, L, c.x + 1.5, c.y + 18, 2, 2, 3, F.dark, 7)
180 box(cv, L, c.x + 4, c.y + 20, 2, 3, 1, F.brass, 7)
181 cv.layer()
182 plant(cv, L, c.x + 35, c.y + 35, false)
183}
184
185function lounge(cv: Canvas, L: Layout, c: Cell): void {
186 rug(cv, L, c.x + 15, c.y + 21, 22, 15, RUG.lounge)
187 for (let k = 0; k < 2; k++) {
188 const y = c.y + 1 + 11 * k
189 box(cv, L, c.x, y, 1, 9, 7, F.woodDark)
190 cv.layer()
191 box(cv, L, c.x + 1, y, 14, 9, 3, F.woodDark)
192 box(cv, L, c.x + 1.5, y + 0.5, 13, 8, 2, F.linen, 3)
193 box(cv, L, c.x + 5, y + 0.5, 9.5, 8, 1, k === 0 ? F.blue : F.green, 5)
194 cv.layer()
195 box(cv, L, c.x + 1.8, y + 1.5, 2.6, 6, 1, F.linen, 5)
196 cv.layer()
197 }
198 // TV on its stand, couch facing it
199 box(cv, L, c.x + 18, c.y, 10, 4, 4, F.wood)
200 box(cv, L, c.x + 19, c.y + 1, 8, 1, 7, F.dark, 4)
201 cv.layer()
202 const tv = lit([at(L, c.x + 19.5, c.y + 2, 10.5), at(L, c.x + 26.5, c.y + 2, 10.5), at(L, c.x + 26.5, c.y + 2, 5.5), at(L, c.x + 19.5, c.y + 2, 5.5)], '#5ad0ff',
203 `<animate attributeName="fill" values="#5ad0ff;#ff6ad5;#ffd34a;#7df08a" calcMode="discrete" dur="3.2s" ${loopBegin(3.2)} repeatCount="indefinite"/>`)
204 cv.raw(tv)
205 box(cv, L, c.x + 18, c.y + 13, 11, 4, 3, F.couch)
206 cv.layer()
207 box(cv, L, c.x + 18, c.y + 16, 11, 2, 6, F.couch)
208 cv.layer()
209 box(cv, L, c.x + 17, c.y + 13, 1.5, 5, 5, F.couch)
210 box(cv, L, c.x + 28.5, c.y + 13, 1.5, 5, 5, F.couch)
211 cv.layer()
212 plant(cv, L, c.x + 35, c.y + 35, true)
213}
214
215// A room's own furniture (not desks); returns animated bits to lay over it.
216export function furniture(cv: Canvas, L: Layout, room: Room, c: Cell): string {
217 switch (room) {
218 case 'library': library(cv, L, c); return ''
219 case 'study': study(cv, L, c); return ''
220 case 'sunroom': return sunroom(cv, L, c)
221 case 'kitchen': return kitchen(cv, L, c)
222 case 'workshop': workshop(cv, L, c); return ''
223 case 'lounge': lounge(cv, L, c); return ''
224 }
225}
226
227// Each work room's machine, which flashes as a tool call returns: its place and size.
228export function machine(L: Layout, room: Room, c: Cell): { at: Pt; w: number; h: number } | undefined {
229 switch (room) {
230 case 'library': return { at: at(L, c.x + 2.5, c.y + 17, 9), w: 14, h: 20 }
231 case 'study': return { at: at(L, c.x + 1.5, c.y + 26.5, 13), w: 8, h: 6 }
232 case 'sunroom': return { at: at(L, c.x + 3.5, c.y + 22.5, 6), w: 8, h: 6 }
233 case 'kitchen': return { at: at(L, c.x + 3, c.y + 21, 8), w: 10, h: 8 }
234 case 'workshop': return { at: at(L, c.x + 3.5, c.y + 16.5, 7), w: 12, h: 8 }
235 default: return undefined
236 }
237}
238
239hooks/garden.ts 535 lines1/*
2 * The garden: an autumn lawn round the house with trees, ponds, beds, stones and toadstools. Crowns sway,
3 * leaves fall, fireflies wander by night. Drawn on the picture, not the floor plan, as its own picture
4 * under the house's.
5 */
6import { TALL_WALL } from './house'
7import type { Layout } from './house'
8import { Canvas, loopBegin, mat, rng } from './iso'
9import type { Mat, Pt } from './iso'
10import { signAt, yardShapes } from './yard'
11
12const G = {
13 grass: mat('na', '#2f3b27', '#9aa24c'),
14 lit: mat('nl', '#35432c', '#a7ae56'),
15 shade: mat('nh', '#29341f', '#8c9443'),
16 speck: mat('ns', '#3a4730', '#b3b35a'),
17 dry: mat('nd', '#4a4228', '#c9a85a'),
18 dark: mat('nk', '#232c1d', '#6f7a34'),
19 blade: mat('nb', '#4e5d36', '#c2c76a'),
20 tip: mat('nt', '#6b6a3a', '#e6d27a'),
21 trunk: mat('tk', '#33241a', '#6b4429'),
22 bark: mat('tl', '#24190f', '#4e301c'),
23 birch: mat('tb', '#8d877b', '#e9e3d6'),
24 cap: mat('mr', '#8f2a22', '#d93a2c'),
25 water: mat('wa', '#16202e', '#5d8fb0'),
26 ripple: mat('wr', '#2a3a52', '#9cc6e0'),
27 post: mat('lp', '#1c1f24', '#3a3f47'),
28 lamp: mat('ll', '#ffd27a', '#e8dcc0'),
29 stone: mat('sn', '#3d4046', '#a9aeb5'),
30 stoneLit: mat('sl', '#585c63', '#cfd3d8'),
31 aster: mat('fa', '#6a4a9a', '#a77fe0'),
32 mum: mat('fm', '#a8822a', '#ffd166'),
33 rose: mat('fr', '#8f2a3a', '#e0566e'),
34}
35
36type Kind = 'oak' | 'maple' | 'birch' | 'pine' | 'bush'
37
38// Crown colours: shadow, body, light, and a fleck.
39const CROWNS: Record<Exclude<Kind, 'pine'>, [Mat, Mat, Mat, Mat]> = {
40 oak: [mat('c1', '#5a2416', '#8e3a1e'), mat('c2', '#a24a1c', '#e2702a'), mat('c3', '#c9762c', '#ffa64a'), mat('c4', '#d9a23a', '#ffd166')],
41 maple: [mat('m1', '#4a1716', '#7e2320'), mat('m2', '#8c2620', '#cf3a2a'), mat('m3', '#b4432c', '#f0663e'), mat('m4', '#d0782e', '#ff9b45')],
42 birch: [mat('y1', '#5e4a1a', '#a37a22'), mat('y2', '#a8822a', '#e8b23a'), mat('y3', '#c9a43c', '#ffd35a'), mat('y4', '#e0c060', '#fff0a0')],
43 bush: [mat('h1', '#262d1c', '#4f5a2a'), mat('h2', '#3f4a24', '#7f8f3a'), mat('h3', '#9a4f1c', '#e07a2c'), mat('h4', '#c9a23a', '#ffd166')],
44}
45const PINE = [mat('p1', '#142219', '#1f3a28'), mat('p2', '#1d3324', '#2e5a3a'), mat('p3', '#284a31', '#3f7a4c')]
46const LEAF = [mat('l1', '#b5541f', '#e8762a'), mat('l2', '#8f2a22', '#d23c2c'), mat('l3', '#b08a2a', '#f0b43a'), mat('l4', '#7a3a1a', '#b85a26')]
47
48// Per kind: trunk height, crown half-sizes, lean in the wind and its beat.
49const BUILD: Record<Kind, { trunk: number; rx: number; ry: number; lean: number; beat: number }> = {
50 oak: { trunk: 12, rx: 14, ry: 10, lean: 2.4, beat: 4.6 },
51 maple: { trunk: 11, rx: 11, ry: 12, lean: 2.4, beat: 4.1 },
52 birch: { trunk: 15, rx: 8, ry: 12, lean: 3, beat: 3.6 },
53 pine: { trunk: 5, rx: 11, ry: 16, lean: 1.2, beat: 5.2 },
54 bush: { trunk: 0, rx: 7, ry: 4.5, lean: 2, beat: 3.3 },
55}
56
57// A rounded blob: an octagon about `c`.
58function blob(cv: Canvas, c: Pt, rx: number, ry: number, m: Mat | string): void {
59 const a = rx * 0.45
60 const b = ry * 0.45
61 cv.poly([
62 { x: c.x - a, y: c.y - ry }, { x: c.x + a, y: c.y - ry }, { x: c.x + rx, y: c.y - b }, { x: c.x + rx, y: c.y + b },
63 { x: c.x + a, y: c.y + ry }, { x: c.x - a, y: c.y + ry }, { x: c.x - rx, y: c.y + b }, { x: c.x - rx, y: c.y - b },
64 ], m)
65}
66
67// The house floor's four corners on the picture.
68function corners(L: Layout) {
69 return {
70 top: { x: L.ox, y: L.oy },
71 left: { x: L.ox - L.ph, y: L.oy + L.ph / 2 },
72 right: { x: L.ox + L.pw, y: L.oy + L.pw / 2 },
73 bottom: { x: L.ox + L.pw - L.ph, y: L.oy + (L.pw + L.ph) / 2 },
74 }
75}
76
77const lerp = (a: Pt, b: Pt, u: number, dy = 0): Pt => ({ x: Math.round(a.x + (b.x - a.x) * u), y: Math.round(a.y + (b.y - a.y) * u + dy) })
78
79// ---------- the lawn ----------
80
81const pondAt = (L: Layout) => lerp(corners(L).bottom, corners(L).right, 0.42, 18)
82const lampsAt = (L: Layout) => [lerp(corners(L).left, corners(L).bottom, 0.62, 9), lerp(corners(L).bottom, corners(L).right, 0.45, 9)]
83const toadstoolsAt = (L: Layout) => [lerp(corners(L).left, corners(L).bottom, 0.5, 26), lerp(corners(L).bottom, corners(L).right, 0.2, 24), lerp(corners(L).left, corners(L).top, 0.24, -30)]
84
85// How far a point lies outside a convex outline: 0 inside.
86function outside(poly: Pt[], p: Pt): number {
87 let pos = false
88 let neg = false
89 let best = Infinity
90 poly.forEach((a, i) => {
91 const b = poly[(i + 1) % poly.length] as Pt
92 const ex = b.x - a.x
93 const ey = b.y - a.y
94 const cross = ex * (p.y - a.y) - ey * (p.x - a.x)
95 if (cross > 0) pos = true
96 if (cross < 0) neg = true
97 const t = Math.max(0, Math.min(1, ((p.x - a.x) * ex + (p.y - a.y) * ey) / (ex * ex + ey * ey || 1)))
98 best = Math.min(best, Math.hypot(p.x - a.x - t * ex, p.y - a.y - t * ey))
99 })
100 return pos && neg ? best : 0
101}
102
103// A clump of longer grass, `n` blades about (x, y).
104function blades(cv: Canvas, r: () => number, x: number, y: number, n: number): void {
105 for (let k = 0; k < n; k++) {
106 const tall = 2 + Math.floor(r() * 4)
107 const bx = Math.round(x + (k - n / 2) * 1.2 + r())
108 cv.rect(bx, Math.round(y) - tall, 1, tall, k % 3 === 0 ? G.dark : G.blade)
109 if (r() < 0.4) cv.rect(bx, Math.round(y) - tall, 1, 1, G.tip)
110 }
111}
112
113// A pond about `at`, with a ripple and a leaf or two.
114type Pond = { at: Pt; rx: number; ry: number }
115
116function pond(cv: Canvas, { at: p, rx, ry }: Pond): void {
117 const k = (n: number) => Math.round((n * rx) / 12)
118 blob(cv, { x: p.x + 1, y: p.y + 1 }, rx + 1, ry + 0.5, G.dark)
119 blob(cv, p, rx, ry, G.water)
120 cv.layer()
121 cv.rect(p.x - k(6), p.y - k(2), Math.max(2, k(5)), 1, G.ripple)
122 cv.rect(p.x - k(2), p.y, 2, 1, LEAF[0] as Mat)
123 if (rx < 9) return
124 cv.rect(p.x + 3, p.y + 1, 4, 1, G.ripple)
125 cv.rect(p.x + 6, p.y - 2, 1, 1, LEAF[2] as Mat)
126}
127
128function toadstool(cv: Canvas, p: Pt): void {
129 cv.rect(p.x, p.y - 1, 1, 1, G.birch)
130 cv.rect(p.x - 1, p.y - 2, 3, 1, G.cap)
131}
132
133// Lawn patterns, tiles of different sizes so repeats never line up; patches span the picture's width,
134// wrapped at the edges.
135const PATCHES = { w: 248, h: 173 }
136// drawn once: the same for every picture
137let patchTile: Canvas | undefined
138
139function patchesTile(): Canvas {
140 if (patchTile) return patchTile
141 const patches = new Canvas()
142 const lawn = rng(7)
143 for (let n = Math.round((PATCHES.w * PATCHES.h) / 2600); n > 0; n--) {
144 const p = { x: lawn() * PATCHES.w, y: lawn() * PATCHES.h }
145 const m = lawn() < 0.5 ? G.lit : G.shade
146 for (let k = 1 + Math.floor(lawn() * 3); k > 0; k--) {
147 const q = { x: Math.round(p.x + (lawn() - 0.5) * 20), y: Math.round(p.y + (lawn() - 0.5) * 8) }
148 const [rx, ry] = [7 + lawn() * 10, 3 + lawn() * 4]
149 for (const dx of [0, -PATCHES.w, PATCHES.w]) {
150 for (const dy of [0, -PATCHES.h, PATCHES.h]) {
151 const c = { x: q.x + dx, y: q.y + dy }
152 if (c.x + rx >= 0 && c.x - rx <= PATCHES.w && c.y + ry >= 0 && c.y - ry <= PATCHES.h) blob(patches, c, rx, ry, m)
153 }
154 }
155 }
156 }
157 patchTile = patches
158 return patches
159}
160
161export function gardenPatterns(cv: Canvas): string {
162 const use = (m: Mat) => {
163 cv.used.set(m.cls, m)
164 return `class="${m.cls}"`
165 }
166 const patches = patchesTile()
167 for (const [k, m] of patches.used) cv.used.set(k, m)
168 const tile = (id: string, w: number, h: number, body: string) => `<pattern id="${id}" width="${w}" height="${h}" patternUnits="userSpaceOnUse">${body}</pattern>`
169 const dots = (m: Mat, d: string) => `<path ${use(m)} d="${d}"/>`
170 return tile('grass', 16, 12, `<rect width="16" height="12" ${use(G.grass)}/>` +
171 dots(G.speck, 'M1 1h1v1h-1zM6 3h1v1h-1zM11 0h1v1h-1zM14 5h1v1h-1zM3 7h1v1h-1zM9 8h2v1h-2zM13 10h1v1h-1zM5 11h1v1h-1zM0 4h1v1h-1z') +
172 dots(G.dark, 'M4 2h1v1h-1zM12 3h1v1h-1zM7 6h1v1h-1zM2 9h1v1h-1zM15 8h1v1h-1zM10 11h1v1h-1z') +
173 dots(G.dry, 'M8 1h1v1h-1zM1 6h1v1h-1zM12 7h1v1h-1z') +
174 dots(G.blade, 'M3 4h1v2h-1zM10 5h1v2h-1zM6 9h1v2h-1zM14 1h1v2h-1z')) +
175 tile('tufts', 29, 23,
176 dots(G.blade, 'M2 3h1v3h-1zM4 4h1v2h-1zM17 9h1v3h-1zM19 10h1v2h-1zM9 16h1v3h-1zM11 17h1v2h-1zM24 19h1v3h-1zM26 20h1v2h-1z') +
177 dots(G.tip, 'M2 3h1v1h-1zM17 9h1v1h-1zM9 16h1v1h-1zM24 19h1v1h-1z') +
178 dots(G.dark, 'M3 6h1v1h-1zM18 12h1v1h-1zM10 19h1v1h-1zM25 22h1v1h-1z')) +
179 tile('leaves', 43, 37,
180 dots(LEAF[0] as Mat, 'M5 7h2v1h-2zM30 22h1v1h-1z') + dots(LEAF[1] as Mat, 'M18 3h1v1h-1zM37 30h2v1h-2z') +
181 dots(LEAF[2] as Mat, 'M12 26h2v1h-2zM40 12h1v1h-1z') + dots(LEAF[3] as Mat, 'M24 15h1v1h-1zM2 33h2v1h-2z')) +
182 tile('patches', PATCHES.w, PATCHES.h, patches.svg()) +
183 tile('flowers', 61, 47,
184 dots(G.aster, 'M7 9h1v1h-1zM9 10h1v1h-1zM44 33h1v1h-1z') + dots(G.mum, 'M29 20h1v1h-1zM31 19h1v1h-1zM52 6h1v1h-1z') +
185 dots(G.rose, 'M17 38h1v1h-1zM19 39h1v1h-1zM38 44h1v1h-1z') +
186 dots(G.dark, 'M7 10h1v1h-1zM9 11h1v1h-1zM44 34h1v1h-1zM29 21h1v1h-1zM31 20h1v1h-1zM52 7h1v1h-1zM17 39h1v1h-1zM19 40h1v1h-1zM38 45h1v1h-1z'))
187}
188
189// A kind's crown about (0, 0), the trunk top; trees of a kind sway on one beat, as in one gust.
190function crown(cv: Canvas, kind: Kind): string {
191 const { rx, ry, lean, beat } = BUILD[kind]
192 const c = { x: 0, y: -ry + (kind === 'bush' ? 1 : 3) }
193 if (kind === 'pine') {
194 const [s, b, l] = PINE as [Mat, Mat, Mat]
195 for (let k = 0; k < 4; k++) {
196 const y = c.y + ry - 2 - k * 8
197 const w = rx - k * 2.5
198 cv.poly([{ x: c.x, y: y - 11 }, { x: c.x + w, y: y + 1 }, { x: c.x - w, y: y + 1 }], s)
199 cv.poly([{ x: c.x - 0.5, y: y - 11 }, { x: c.x + w - 3, y: y - 1 }, { x: c.x - w + 1, y: y }], k % 2 ? l : b)
200 cv.layer()
201 }
202 } else {
203 const [s, b, l, f] = CROWNS[kind]
204 blob(cv, { x: c.x + 1, y: c.y + 1 }, rx, ry, s)
205 cv.layer()
206 blob(cv, c, rx - 1, ry - 1, b)
207 blob(cv, { x: c.x - rx * 0.45, y: c.y - ry * 0.25 }, rx * 0.55, ry * 0.55, b)
208 cv.layer()
209 blob(cv, { x: c.x - rx * 0.3, y: c.y - ry * 0.45 }, rx * 0.45, ry * 0.4, l)
210 blob(cv, { x: c.x + rx * 0.35, y: c.y - ry * 0.2 }, rx * 0.35, ry * 0.35, l)
211 cv.layer()
212 const r = rng([...kind].reduce((h, ch) => (Math.imul(h, 31) + ch.charCodeAt(0)) | 0, 7))
213 for (let i = 0; i < (kind === 'bush' ? 4 : 9); i++) {
214 cv.rect(Math.round(c.x - rx * 0.7 + r() * rx * 1.4), Math.round(c.y - ry * 0.7 + r() * ry * 1.3), 1, 1, f)
215 }
216 }
217 return `<animateTransform attributeName="transform" type="skewX" values="0;${lean};${(lean * 0.4).toFixed(1)};${(-lean * 0.5).toFixed(1)};0" dur="${beat}s" ${loopBegin(beat)} repeatCount="indefinite"/>`
218}
219
220// The house's trees: `u` along the edge from floor corner `from` to `to`, `dy` above it (behind) or below.
221type Spot = { kind: Kind; from: 'top' | 'left' | 'right' | 'bottom'; to: 'top' | 'left' | 'right' | 'bottom'; u: number; dy: number }
222const TREES: Spot[] = [
223 // behind the house, over the back walls
224 { kind: 'pine', from: 'left', to: 'top', u: 0.14, dy: -22 },
225 { kind: 'oak', from: 'left', to: 'top', u: 0.36, dy: -26 },
226 { kind: 'maple', from: 'left', to: 'top', u: 0.58, dy: -24 },
227 { kind: 'birch', from: 'top', to: 'right', u: 0.4, dy: -24 },
228 { kind: 'pine', from: 'top', to: 'right', u: 0.6, dy: -26 },
229 { kind: 'oak', from: 'top', to: 'right', u: 0.8, dy: -22 },
230 { kind: 'maple', from: 'top', to: 'right', u: 1.03, dy: -12 },
231 // in front, in the two gaps below the front edges
232 { kind: 'maple', from: 'left', to: 'bottom', u: 0.03, dy: 26 },
233 { kind: 'birch', from: 'left', to: 'bottom', u: 0.7, dy: 30 },
234 { kind: 'bush', from: 'left', to: 'bottom', u: 0.56, dy: 4 },
235 { kind: 'bush', from: 'left', to: 'bottom', u: 0.66, dy: 14 },
236 { kind: 'bush', from: 'bottom', to: 'right', u: 0.14, dy: 12 },
237 { kind: 'bush', from: 'bottom', to: 'right', u: 0.72, dy: 20 },
238 { kind: 'pine', from: 'bottom', to: 'right', u: 0.8, dy: 38 },
239]
240
241// Two lamp posts at the front, lit by night.
242export function lamps(cv: Canvas, L: Layout): string {
243 let glow = ''
244 for (const p of lampsAt(L)) {
245 cv.rect(p.x, p.y - 12, 1, 12, G.post)
246 cv.rect(p.x - 1, p.y - 15, 3, 3, G.post)
247 cv.rect(p.x, p.y - 14, 1, 1, G.lamp)
248 glow += `<ellipse class="vh-lamp" cx="${p.x}" cy="${p.y - 13}" rx="7" ry="5" fill="#ffcf7a" opacity="0.18"/>` +
249 `<ellipse class="vh-lamp" cx="${p.x}" cy="${p.y}" rx="9" ry="3" fill="#ffcf7a" opacity="0.1"/>`
250 }
251 cv.layer()
252 return glow
253}
254
255function place(L: Layout, t: Spot): Pt {
256 const c = corners(L)
257 return lerp(c[t.from], c[t.to], t.u, t.dy)
258}
259
260// One of the house's trees, in the house's picture.
261function tree(cv: Canvas, L: Layout, t: Spot): void {
262 treeAt(cv, t.kind, place(L, t), 'vh-h')
263}
264
265// A tree at `at`, one element: a copy of its kind. `ns` keeps definitions apart where pictures are
266// laid into one.
267function treeAt(cv: Canvas, kind: Kind, at: Pt, ns: string): void {
268 const b = BUILD[kind]
269 cv.define(`${ns}-crown-${kind}`, sub => crown(sub, kind))
270 cv.define(`${ns}-tree-${kind}`, sub => {
271 if (b.trunk > 0) {
272 sub.rect(-1, -b.trunk, 1, b.trunk, kind === 'birch' ? G.birch : G.trunk)
273 sub.rect(0, -b.trunk, 1, b.trunk, kind === 'birch' ? G.trunk : G.bark)
274 }
275 sub.raw(`<use href="#${ns}-crown-${kind}" y="${-b.trunk}"/>`)
276 })
277 cv.raw(`<use href="#${ns}-tree-${kind}" x="${at.x}" y="${at.y}"/>`)
278}
279
280export function treesBehind(cv: Canvas, L: Layout): void {
281 for (const t of TREES) if (t.dy < 0) tree(cv, L, t)
282}
283
284// Front trees with their depth (the plan's x + y at the trunk), to sort among the yard's things.
285export function frontTrees(L: Layout): { depth: number; draw: (cv: Canvas) => void }[] {
286 return TREES.filter(t => t.dy >= 0).map(t => ({ depth: 2 * (place(L, t).y - L.oy), draw: (cv: Canvas) => tree(cv, L, t) }))
287}
288
289// Whether a point lies over the house, back walls included, with a margin.
290const hulls = new WeakMap<Layout, Pt[]>()
291function hullOf(L: Layout): Pt[] {
292 const known = hulls.get(L)
293 if (known) return known
294 const c = corners(L)
295 const up = (q: Pt) => ({ x: q.x, y: q.y - TALL_WALL })
296 const hull = [up(c.left), up(c.top), up(c.right), c.right, c.bottom, c.left]
297 hulls.set(L, hull)
298 return hull
299}
300
301function overHouse(L: Layout, p: Pt): boolean {
302 const hull = hullOf(L)
303 return hull.every((a, i) => {
304 const b = hull[(i + 1) % hull.length] as Pt
305 return (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x) > -3 * Math.hypot(b.x - a.x, b.y - a.y)
306 })
307}
308
309// Falling leaves: under the house where they fall behind it, else over all; a front tree's leaf that
310// would cross the house tries again.
311export function weather(cv: Canvas, L: Layout): { behind: string; front: string } {
312 const r = rng(11)
313 const out = { behind: '', front: '' }
314 const from = TREES.filter(t => t.kind !== 'pine' && t.kind !== 'bush')
315 for (let i = 0; i < 7; i++) {
316 const t = from[i % from.length] as Spot
317 const b = BUILD[t.kind]
318 const top = place(L, t)
319 let path: Pt[] = []
320 let isBehind = false
321 for (let tries = 0; tries < 6 && path.length === 0; tries++) {
322 const tried = leafPath(r, top, b, 24, 16)
323 isBehind = t.dy < 0 && tried.some(p => overHouse(L, p))
324 if (isBehind || tried.every(p => !overHouse(L, p))) path = tried
325 }
326 if (path.length === 0) continue
327 out[isBehind ? 'behind' : 'front'] += fallingLeaf(cv, LEAF[i % LEAF.length] as Mat, path, r)
328 }
329 return out
330}
331
332// A leaf's path from the crown of a tree at `foot`, falling up to twice `fall`, drifting up to 8 + `drift`.
333function leafPath(r: () => number, foot: Pt, b: (typeof BUILD)[Kind], fall: number, drift: number): Pt[] {
334 const sx = Math.round(foot.x - b.rx * 0.6 + r() * b.rx * 1.2)
335 const sy = Math.round(foot.y - b.trunk - b.ry * 1.5 + r() * b.ry)
336 const f = fall + Math.round(r() * fall)
337 const d = 8 + Math.round(r() * drift)
338 return [0, 1, 2, 3, 4].map(k => ({ x: Math.round(sx + (d * k) / 4 + (k % 2 ? 3 : 0)), y: Math.round(sy + (f * k) / 4) }))
339}
340
341// One leaf drifting down `path`, on a beat of its own.
342function fallingLeaf(cv: Canvas, leaf: Mat, path: Pt[], r: () => number): string {
343 const dur = Math.round((6 + r() * 5) * 10) / 10
344 const lead = Math.round(r() * 100) / 10
345 cv.used.set(leaf.cls, leaf)
346 return `<rect class="${leaf.cls}" width="2" height="1" opacity="0">` +
347 `<animateTransform attributeName="transform" type="translate" values="${path.map(p => `${p.x} ${p.y}`).join(';')}" dur="${dur}s" ${loopBegin(dur, lead)} repeatCount="indefinite"/>` +
348 `<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.1;0.8;1" dur="${dur}s" ${loopBegin(dur, lead)} repeatCount="indefinite"/>` +
349 `<animate attributeName="width" values="2;1" calcMode="discrete" dur="0.7s" ${loopBegin(0.7)} repeatCount="indefinite"/></rect>`
350}
351
352// ---------- the garden round the house ----------
353
354// What grows past the house's trees, from a fixed seed, once per layout. Nothing stands on or reaches over
355// the house, its trees, the road or the yard, so the house's picture over it never cuts one wrongly.
356// `own`: the house's trees; `order`: back to front; `leafy`: those that drop leaves.
357type Standing = { kind: Kind; at: Pt }
358type Grove = {
359 trees: Standing[]; ponds: Pond[]; beds: { at: Pt; n: number }[]; stones: Pt[]; clumps: Pt[]; toadstools: Pt[]
360 own: Standing[]; order: Standing[]; leafy: Standing[]
361}
362const groves = new Map<string, Grove>()
363
364function groveOf(L: Layout): Grove {
365 const key = `${L.w}x${L.h}x${L.oy}`
366 const known = groves.get(key)
367 if (known) return known
368 const r = rng(29)
369 const c = corners(L)
370 const up = (q: Pt, dy: number) => ({ x: q.x, y: q.y - dy })
371 const hull = [up(c.left, TALL_WALL + 6), up(c.top, TALL_WALL + 6), up(c.right, TALL_WALL + 6), up(c.right, -6), up(c.bottom, -6), up(c.left, -6)]
372 const yard = yardShapes(L)
373 const sign = signAt(L)
374 const own = TREES.map(t => ({ kind: t.kind, at: place(L, t) }))
375 const lit = lampsAt(L)
376 const wet0 = pondAt(L)
377 // whether the house, its trees or the yard come within `m` of p
378 const blocked = (p: Pt, m: number) =>
379 outside(hull, p) < m || outside(yard.road, p) < m + 4 || yard.pallets.some(q => outside(q, p) < m + 4) ||
380 Math.hypot(p.x - yard.mailbox.x, p.y - yard.mailbox.y) < m + 8 || Math.hypot(p.x - sign.x - 6, p.y - sign.y - 3) < m + 10 ||
381 own.some(q => Math.hypot(p.x - q.at.x, (p.y - q.at.y) * 0.8) < m + 24) || lit.some(q => Math.hypot(p.x - q.x, p.y - q.y) < m + 6) ||
382 Math.hypot((p.x - wet0.x) / 2, p.y - wet0.y) < m + 10
383 // a tree's whole reach, trunk foot to crown top, kept clear
384 const clear = (kind: Kind, at: Pt) => {
385 const b = BUILD[kind]
386 const top = at.y - b.trunk - 2 * b.ry
387 return [at, { x: at.x - b.rx, y: at.y - b.trunk - b.ry }, { x: at.x + b.rx, y: at.y - b.trunk - b.ry }, { x: at.x, y: top }]
388 .every(p => !blocked(p, 3))
389 }
390 const grove: Grove = { trees: [], ponds: [], beds: [], stones: [], clumps: [], toadstools: [], own, order: [], leafy: [] }
391 const near = (at: Pt, list: Pt[], d: number) => list.some(q => Math.hypot(q.x - at.x, (q.y - at.y) * 1.5) < d)
392 const spaced = (at: Pt, d: number) => !near(at, grove.trees.map(t => t.at), d)
393 // candidate spots on a jittered grid, shuffled
394 const spots = (step: number) => {
395 const out: Pt[] = []
396 for (let y = 42; y <= L.h - 4; y += step) for (let x = 6; x <= L.w - 6; x += step) out.push({ x: Math.round(x + (r() - 0.5) * step), y: Math.round(y + (r() - 0.5) * step) })
397 for (let i = out.length - 1; i > 0; i--) {
398 const k = Math.floor(r() * (i + 1))
399 ;[out[i], out[k]] = [out[k] as Pt, out[i] as Pt]
400 }
401 return out
402 }
403 // up to `max` spots `gap` apart that pass `ok`
404 const scatter = (step: number, max: number, gap: number, ok: (p: Pt) => boolean) => {
405 const out: Pt[] = []
406 for (const p of spots(step)) if (out.length < max && ok(p) && !near(p, out, gap)) out.push(p)
407 return out
408 }
409 const pick = <T,>(list: T[]) => list[Math.floor(r() * list.length)] as T
410 // copses of a kind or two, a bush or two at the edge
411 const MIXES: Kind[][] = [['oak', 'maple'], ['birch'], ['pine'], ['maple', 'birch'], ['pine', 'oak'], ['oak', 'maple', 'birch'], ['pine', 'birch']]
412 for (const p of scatter(23, Math.round(L.h / 46), 52, p => !blocked(p, 12))) {
413 const mix = pick(MIXES)
414 for (let n = 2 + Math.floor(r() * 4), tries = 0; n > 0 && tries < 30; tries++) {
415 const at = { x: Math.round(p.x + (r() - 0.5) * 50), y: Math.round(p.y + (r() - 0.5) * 24) }
416 const kind = pick(mix)
417 if (spaced(at, 17) && clear(kind, at)) (grove.trees.push({ kind, at }), n--)
418 }
419 for (let n = Math.floor(r() * 3), tries = 0; n > 0 && tries < 12; tries++) {
420 const at = { x: Math.round(p.x + (r() - 0.5) * 64), y: Math.round(p.y + 8 + r() * 10) }
421 if (spaced(at, 13) && clear('bush', at)) (grove.trees.push({ kind: 'bush', at }), n--)
422 }
423 }
424 // a lone tree or bush here and there
425 for (const at of scatter(29, Math.round(L.h / 32), 28, p => spaced(p, 28))) {
426 const kind = pick<Kind>(['oak', 'maple', 'birch', 'pine', 'bush', 'bush'])
427 if (clear(kind, at)) grove.trees.push({ kind, at })
428 }
429 // ponds where clearest of trees: a big one or two, then smaller ones
430 const wet = (p: Pt) => Math.min(99, ...grove.trees.map(t => Math.hypot((t.at.x - p.x) / 1.7, t.at.y - p.y)))
431 const pondSpots = spots(13).filter(p => p.x > 20 && p.x < L.w - 20 && !blocked(p, 14)).map(p => ({ p, w: wet(p) })).sort((a, b) => b.w - a.w)
432 const apart = (p: Pt, d: number) => grove.ponds.every(q => Math.hypot(q.at.x - p.x, (q.at.y - p.y) * 1.5) > d + q.rx)
433 for (const { p, w } of pondSpots) if (grove.ponds.length < Math.max(1, Math.round(L.h / 300)) && w > 16 && apart(p, 90)) grove.ponds.push({ at: p, rx: 12, ry: 4.5 })
434 for (const { p, w } of pondSpots) {
435 if (grove.ponds.length >= Math.round(L.h / 300) + Math.round(L.h / 120)) break
436 const rx = 6 + Math.floor(r() * 4)
437 if (w > rx + 6 && apart(p, 36)) grove.ponds.push({ at: p, rx, ry: rx * 0.4 })
438 }
439 const dry = (p: Pt, m: number) => !blocked(p, m) && grove.ponds.every(q => Math.hypot((p.x - q.at.x) / (q.rx + 3), (p.y - q.at.y) / (q.ry + 2)) > 1.4)
440 // reeds at the ponds' edges, longer grass about the lawn
441 for (const q of grove.ponds) grove.clumps.push({ x: q.at.x + (r() < 0.5 ? -1 : 1) * Math.round(q.rx * 0.8), y: Math.round(q.at.y + q.ry * 0.5) })
442 grove.clumps.push(...scatter(17, Math.round(L.h / 30), 22, p => dry(p, 2) && spaced(p, 10)))
443 // flower beds, stones, toadstools at a few trees' feet
444 grove.beds.push(...scatter(19, Math.round(L.h / 34), 28, p => dry(p, 6) && spaced(p, 16)).map(at => ({ at, n: Math.floor(r() * 3) })))
445 for (const p of scatter(31, Math.round(L.h / 70), 40, p => dry(p, 3) && spaced(p, 10))) {
446 grove.stones.push(p)
447 if (r() < 0.5) grove.stones.push({ x: p.x + 4, y: p.y + 1 })
448 }
449 for (const t of grove.trees) if (t.kind !== 'bush' && r() < 0.18) grove.toadstools.push({ x: t.at.x + (r() < 0.5 ? -5 : 4), y: t.at.y + 2 })
450 // nothing wholly off the picture
451 grove.trees = grove.trees.filter(t => t.at.x + BUILD[t.kind].rx > 0 && t.at.x - BUILD[t.kind].rx < L.w)
452 grove.toadstools = grove.toadstools.filter(p => p.x > -2 && p.x < L.w + 1)
453 grove.order = [...grove.trees].sort((a, b) => a.at.y - b.at.y || a.at.x - b.at.x)
454 grove.leafy = grove.trees.filter(t => t.kind !== 'pine' && t.kind !== 'bush')
455 groves.set(key, grove)
456 return grove
457}
458
459// The garden under the house; returns what drifts over it (leaves, fireflies). The house's own trees are
460// in its picture. `band`: only what shows in those rows, as drawn in the whole.
461export function garden(cv: Canvas, L: Layout, band = { y0: 0, y1: L.h }): string {
462 const c = corners(L)
463 const grove = groveOf(L)
464 // whether a thing standing at y, `tall` above it, shows in the band
465 const shows = (y: number, tall = 0) => y + 8 >= band.y0 && y - tall - 8 <= band.y1
466 const [y0, y1] = [Math.max(0, band.y0), Math.min(L.h, band.y1)]
467 cv.rect(0, y0, L.w, y1 - y0, 'url(#grass)')
468 cv.layer()
469 for (const id of ['patches', 'tufts', 'leaves', 'flowers']) cv.rect(0, y0, L.w, y1 - y0, `url(#${id})`)
470 cv.layer()
471 // round the house: clumps of long grass, its pond and toadstools
472 const tufts = rng(8)
473 const clumps = [
474 lerp(c.left, c.bottom, 0.18, 22), lerp(c.left, c.bottom, 0.62, 16), lerp(c.bottom, c.right, 0.28, 20),
475 lerp(c.bottom, c.right, 0.86, 16), lerp(c.left, c.top, 0.15, -20), lerp(c.top, c.right, 0.84, -16),
476 { x: c.left.x - 4, y: c.left.y + 6 }, { x: c.right.x + 4, y: c.right.y + 6 },
477 ]
478 for (const p of clumps) blades(cv, tufts, p.x, p.y, 5 + Math.floor(tufts() * 3))
479 grove.clumps.forEach((p, i) => shows(p.y, 6) && blades(cv, rng(200 + i), p.x, p.y, 3 + (i % 4)))
480 pond(cv, { at: pondAt(L), rx: 12, ry: 4.5 })
481 for (const p of grove.ponds) if (shows(p.at.y + p.ry, 2 * p.ry)) pond(cv, p)
482 for (const p of [...toadstoolsAt(L), ...grove.toadstools]) if (shows(p.y)) toadstool(cv, p)
483 for (const s of grove.stones) {
484 if (!shows(s.y)) continue
485 blob(cv, { x: s.x + 0.5, y: s.y + 0.5 }, 2.6, 1.6, G.stone)
486 cv.rect(s.x - 1, s.y - 1, 2, 1, G.stoneLit)
487 }
488 // fallen leaves under every tree
489 const litter = rng(9)
490 for (const t of [...grove.own, ...grove.trees]) {
491 const b = BUILD[t.kind]
492 for (let i = 0; i < (t.kind === 'pine' ? 0 : 3); i++) {
493 const [x, y, w, m] = [Math.round(t.at.x - b.rx + litter() * b.rx * 2), Math.round(t.at.y - 2 + litter() * 5), litter() < 0.5 ? 2 : 1, LEAF[Math.floor(litter() * LEAF.length)] as Mat]
494 if (shows(y)) cv.rect(x, y, w, 1, m)
495 }
496 }
497 cv.layer()
498 // flower beds, each drawn once and copied
499 for (const b of grove.beds) {
500 if (!shows(b.at.y)) continue
501 cv.stamp(`bed${b.n}`, b.at, sub => {
502 const m = [G.aster, G.mum, G.rose][b.n] as Mat
503 const br = rng(41 + b.n)
504 for (let k = 0; k < 14; k++) {
505 const p = { x: Math.round(b.at.x + (br() - 0.5) * 13), y: Math.round(b.at.y + (br() - 0.5) * 5) }
506 sub.rect(p.x, p.y, 1, 1, G.dark)
507 sub.rect(p.x, p.y - 1, 1, 1, k % 4 === 0 ? G.mum : m)
508 }
509 })
510 }
511 const crownOf = (kind: Kind) => BUILD[kind].trunk + 2 * BUILD[kind].ry
512 for (const t of grove.order) if (shows(t.at.y, crownOf(t.kind))) treeAt(cv, t.kind, t.at, 'vh-g')
513 let over = ''
514 const { leafy } = grove
515 const falling = Math.min(10, leafy.length)
516 for (let i = 0; i < falling; i++) {
517 const t = leafy[Math.floor((i * leafy.length) / falling)] as Standing
518 if (!shows(t.at.y, crownOf(t.kind))) continue
519 const r = rng(60 + i)
520 over += fallingLeaf(cv, LEAF[i % LEAF.length] as Mat, leafPath(r, t.at, BUILD[t.kind], 20, 14), r)
521 }
522 const flies = rng(12)
523 for (let i = 0; i < Math.min(12, Math.round(L.h / 45)); i++) {
524 const p = { x: Math.round(L.w * 0.08 + flies() * L.w * 0.84), y: Math.round(10 + flies() * (L.h - 20)) }
525 const dur = Math.round((5 + flies() * 4) * 10) / 10
526 const lead = Math.round(flies() * 50) / 10
527 if (overHouse(L, p) || !shows(p.y)) continue
528 const loop = [[0, 0], [4, -3], [7, 1], [3, 4], [0, 0]].map(([dx, dy]) => `${p.x + (dx as number)} ${p.y + (dy as number)}`).join(';')
529 over += `<rect class="vh-lamp" width="1" height="1" fill="#ffe08a" opacity="0">` +
530 `<animateTransform attributeName="transform" type="translate" values="${loop}" dur="${dur}s" ${loopBegin(dur, lead)} repeatCount="indefinite"/>` +
531 `<animate attributeName="opacity" values="0;0.9;0.2;0.9;0" dur="${dur}s" ${loopBegin(dur, lead)} repeatCount="indefinite"/></rect>`
532 }
533 return over
534}
535types/index.d.ts 76 lines1// What an agent is doing, which picks the room it works in.
2export type Room = 'study' | 'library' | 'workshop' | 'kitchen' | 'sunroom' | 'lounge'
3// Where someone can stand: a room or the front yard.
4export type Place = Room | 'yard'
5
6export type ResidentStatus = 'working' | 'waiting' | 'idle' | 'done'
7
8export type Resident = {
9 id: string
10 name: string
11 kind: string
12 room: Room
13 movedAt: number
14 status: ResidentStatus
15 activity: string
16 isBackground?: boolean
17 // the tool in hand; picks its desk screen
18 tool?: string
19 // when its last tool call returned, and if it went well
20 resultAt?: number
21 isOk?: boolean
22 spawnedAt?: number
23 // the lead's room when this agent got its task
24 spawnRoom?: Room
25 doneAt?: number
26 // when it last went idle (starting its idle routine) and last started work
27 idleSince?: number
28 workSince?: number
29 // where it stood at its last plan change, so the next starts there; `key`: the house build it is from
30 anchor?: { t: number; x: number; y: number; room: Place; key: string }
31 // its desk while working, kept until it leaves the room
32 seat?: number
33 // engine task kind (`subagent`, `shell`, `monitor`, `workflow`...) and status (`running`, `completed`...)
34 taskType?: string
35 taskStatus?: string
36 // a workflow's agent or a teammate: the engine lists neither as a subagent
37 isWorkflow?: boolean
38 isTeammate?: boolean
39 // the tool a permission prompt asks about
40 waitingTool?: string
41 // its last few tool calls, for how hot its desk runs
42 calls?: number[]
43 // the lead's context window use, in percent
44 context?: number
45 // finished, carrying its box to the pallets; `box`: the yard's count then, which picks its pallet
46 carrying?: boolean
47 box?: number
48 // part of `/haus demo`'s made-up household, never real work
49 isDemo?: boolean
50 // another session's member: that session's id (also its own id's prefix)
51 session?: string
52 // when such a visitor steps onto the road coming in, or was let go; never shared
53 arriving?: number
54 leaving?: number
55 // tokens used, as flat time, tokens pairs; the last week only, the lead's across sessions
56 usage?: number[]
57 // when a background task left the engine's list, so a late end notice still applies
58 endingSince?: number
59}
60
61// The plan's five-hour and weekly use (%) and resets (clock ms), for the vibe fatigue band or corner
62// bars: the newest reading any session took, `at` when; windows without one are left out.
63export type PlanUse = { at?: number; fiveHour?: number; fiveHourResets?: number; week?: number; weekResets?: number }
64
65// One agent's walk, replayed to place its hover zone: start (clock ms), length (s), whether it loops,
66// keys as flat [t, x, y...] in picture units. `shown`: a visitor's time in sight (s), road to road's end.
67export type Track = { id: string; t0: number; total: number; isLoop: boolean; keys: number[]; shown?: [number, number] }
68
69
70declare module 'claude-code' {
71 interface PluginState {
72 // `minute`: the clock at the last minute tick, counting the band's resets down
73 'vibehaus': { residents: Resident[]; plan: PlanUse; minute: number }
74 }
75}
76