A pixel Clawd above the prompt that acts out what the session is doing: thinking, coding, searching, compacting, idling.

A pixel Clawd that lives in the band above the prompt in Claude Code and acts out what your session is doing: thinking, working at a laptop, editing a file, searching, browsing, waiting for your answer, compacting the conversation, taking a walk when idle, and falling asleep when you leave.
It is a Claude Code mod built on function hooks (early access): no server, no network, no settings to fill in.
Unofficial fan-made mod, not affiliated with or endorsed by Anthropic. Claude and Clawd are trademarks of Anthropic.




| Scene | When |
|---|---|
| Thinking | the model is thinking or waiting for the server to start answering |
| Working | a turn is running (laptop; now and then a sip of coffee) |
| Editing | Edit / Write / NotebookEdit, MCP tools named create_*, update_*, write_*... |
| Searching | Read / Grep / Glob, shell grep, rg, find, cat..., MCP tools named search_*, get_*, list_*... |
| On the web | WebSearch / WebFetch, browser and scraping MCP servers, shell curl, wget, ssh... |
| Question | AskUserQuestion, ExitPlanMode — Clawd waits for your answer |
| Recalling / memorizing | reading or writing Claude Code's memory folder (~/.claude/projects/<project>/memory), any MCP server or tool whose name has the word memory, memories, mem0, remember or recall, the engine's own memory notices |
| Compacting | the conversation is being compacted (a 36-second story with a press) |
| Offline | the server has not started answering for 90 s, the spinner says it is retrying, or a turn ended with an API error |
| Celebrating | for 4 s after a turn ends with an answer |
| Walk and coffee | while idle: a 24 s walk and 16 s of coffee, in a loop |
| Sleeping | after 20 minutes of idling |
| Sunglasses | in ultra mode, on top of any scene |
| Mini-helpers | one small Clawd per subagent the main session runs (up to 4, then a +N badge); a finished agent gets a green check mark and dissolves |
Scene changes are smooth: Clawd stays where he is and starts the new activity on that spot (mirrored near an edge), the old scene fades out and the new one fades in; a scene holds for at least 2 s, so there is no flicker.
Svg, so in the terminal the mod draws nothing (and does not get in the way).ultra_effort_enter / ultra_effort_exit transcript insertions; if a release renames them, the sunglasses simply stop appearing.next(e) and draws Clawd above whatever the rest of the chain drew.Clone the repository anywhere, then point Claude Code at the folder and turn function hooks on.
One session from the terminal:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir /path/to/clawd-buddy-mod
Every session, the desktop app included — add both variables to the env block of ~/.claude/settings.json (CLAUDE_CODE_PLUGIN_DIRS takes one or more absolute paths, separated by the platform's path-list separator):
{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1",
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/clawd-buddy-mod"
}
}
The environment is read when a session's process starts, so Clawd appears in new or restarted sessions. A long-lived desktop session does not watch the folder for edits unless CLAUDE_CODE_PLUGIN_DIR_WATCH=1 is set the same way.
claude plugin validate --strict . # manifest and hooks module, the way the engine reads them
claude plugin test . # runs hooks/*.test.ts(x) against the engine
The TypeScript declarations of the hooks API are not in this repository: Claude Code writes them for the exact build you run into .claude-plugin/types/ every time it loads the mod from a folder you own (--plugin-dir or CLAUDE_CODE_PLUGIN_DIRS), and tsconfig.json extends the tsconfig.json it writes there. To type-check:
# load the mod once so the engine writes .claude-plugin/types/
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude -p --plugin-dir . "/context" > /dev/null
npx -p typescript tsc -p .
Layout:
hooks/register.tsx — the hooks: what the session is doing → Clawd's state; draws the band.hooks/buddy.ts — picks the scene, smooth transitions, helpers.hooks/clawd/*.ts — the scenes themselves (SVG + SMIL animation, no CSS, no scripts); meta.ts holds where Clawd stands at every moment of every scene, measured from rendered frames.types/index.d.ts — the mod's state contract.MIT, see LICENSE.
Пиксельный Клод (Clawd) живёт в полосе над полем ввода Claude Code и показывает, чем занята сессия: думает, работает за ноутбуком, правит файл, ищет с лупой, сидит в интернете, ждёт вашего ответа, сжимает переписку, гуляет и пьёт кофе в простое, засыпает, когда вас долго нет.
Это мод Claude Code на function hooks (ранний доступ): без сервера, без сети, без настроек.
Неофициальный фанатский мод, не связан с Anthropic и не одобрен ею. Claude и Clawd — товарные знаки Anthropic.
Edit / Write / NotebookEdit, MCP-инструменты create_*, update_*, write_*…Read / Grep / Glob, в терминале grep, rg, find, cat…, MCP-инструменты search_*, get_*, list_*…WebSearch / WebFetch, браузерные и парсящие MCP-серверы, curl, wget, ssh…AskUserQuestion, ExitPlanMode: Клод ждёт вашего ответа.~/.claude/projects/<проект>/memory), любой MCP-сервер или инструмент со словом memory, memories, mem0, remember или recall в имени, уведомления движка о памяти.+N); закончивший агент получает зелёную галочку и растворяется.Сцены сменяются плавно: Клод остаётся на месте и начинает новое дело там, где стоит (у края — зеркально), старая сцена тает, новая проявляется; сцена держится не меньше 2 с, без мельтешения.
Svg рисует только десктоп; в терминале мод ничего не рисует и не мешает.ultra_effort_enter / ultra_effort_exit; если их переименуют, очки просто перестанут появляться.next(e) и рисует Клода над тем, что нарисовала остальная цепочка.Склонируйте репозиторий куда угодно и укажите Claude Code на папку, включив function hooks.
Одна сессия из терминала:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir /path/to/clawd-buddy-mod
Все сессии, включая десктоп, — обе переменные в блок env файла ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1",
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/clawd-buddy-mod"
}
}
Окружение читается при старте процесса сессии, поэтому Клод появится в новых или перезапущенных сессиях. Долгоживущая десктоп-сессия не следит за правками в папке, если так же не задан CLAUDE_CODE_PLUGIN_DIR_WATCH=1.
Проверки — claude plugin validate --strict . и claude plugin test .. Типы API движка в репозиторий не входят: Claude Code сам пишет их под свою версию в .claude-plugin/types/ при каждой загрузке мода из вашей папки. Чтобы проверить типы, загрузите мод один раз (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude -p --plugin-dir . "/context") и запустите tsc -p ..
Лицензия — MIT, см. LICENSE.
hooks/register.tsx 487 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { Buddy, BuddyMode } from '../types'
5import {
6 type CrewEntry, FADE_S, LEAVE_MS, OFFLINE_WAIT_MS, type Placement, STRIP_H, STRIP_W, type SpinnerMode, type ToolKind, type Walk,
7 buddyAlt, crewJoin, crewKey, crewLeave, crewPrune, crewView, entryStart, kindFromWord, msToNextIdleChange, nextStage, phaseOf,
8 stageSvg,
9 pickMode, spinnerOffline, toolKind,
10 ultraFromAttachment, ultraMarker,
11} from './buddy'
12
13// A pixel Clawd in the band above the prompt: thinks, works at a laptop, edits, searches with a magnifier, browses the web,
14// waits for your answer, compacts the conversation, fixes the connection, recalls and memorizes, celebrates the end of a
15// turn, takes a walk, drinks coffee, sleeps; mini-Clawds work beside him while the main session's agents run; in ultra
16// mode he wears sunglasses. Drawn on surfaces that have Svg (desktop); the terminal has none, so there he is absent.
17// The band hook always calls next(e) and draws Clawd above whatever the rest of the chain drew, so other mods that
18// draw in the same band keep working (the engine does not guarantee an order between mods).
19
20const buddy = atom(
21 { plugin: 'clawd-buddy', key: 'buddy' } as const,
22 {
23 activity: 'idle', tool: '', flash: '', flashUntil: 0, spinner: '', wordTool: '', compacting: false, compactSince: 0,
24 offlineSince: 0, helpers: 0, crewKey: '',
25 idleSince: 0, celebrate: false, tick: 0, ultra: false, booted: false,
26 } as Buddy,
27)
28
29let idleTimer: { cancel: () => void } | null = null
30// the main loop's tools running now (parallel ones too), by kind
31const toolsInFlight = new Map<ToolKind, number>()
32// helpers: agents started by the main session — who sits in which slot, who is leaving (logic: crew* in buddy.ts)
33let crew: CrewEntry[] = []
34let crewTimer: { cancel: () => void } | null = null
35const CREW_SYNC_MS = 15_000
36const WF_IDLE_MS = 15 * 60_000 // a workflow agent with no calls for longer is treated as gone (it is not in $.agent.list())
37const LIST_GRACE_MS = 10_000 // a just-spawned agent may not be in the agent list yet
38// a tool's scene holds TOOL_HOLD_MS after the tool ends (edits and searches take milliseconds — otherwise unseen)
39const TOOL_HOLD_MS = 6_000
40let toolHoldTimer: { cancel: () => void } | null = null
41let flashTimer: { cancel: () => void } | null = null
42// waiting for the server: if "requesting" lasts longer than OFFLINE_WAIT_MS — "offline"
43let requestTimer: { cancel: () => void } | null = null
44// what the spinner said last — state is written only when it changes
45let lastSpinner = ''
46// What is drawn: the scene and where it is placed, from which second of its cycle, when shown, the walking transition.
47// The SVG is rebuilt only on a change — otherwise the picture would restart and the animation would not run on its own
48type Stage = { place: Placement; since: number; walk?: Walk; glasses: boolean; crew: string; svg: string }
49let stage: Stage | null = null
50// when a scene last started from its highlight beat (work — a sip of coffee)
51const highlightAt = new Map<string, number>()
52// a scene holds for at least MIN_SCENE_MS (no "think <-> work" flicker), except urgent ones
53const MIN_SCENE_MS = 2_000
54const URGENT = new Set<string>(['ask', 'compact', 'offline'])
55let stageTimer: { cancel: () => void } | null = null
56
57// Write to Clawd's state only what changed: every write redraws the band on every surface
58// (the change is computed again from the current state inside update — a write never clobbers one made in between).
59// true — written or nothing to write; false — failure
60async function patchBuddy($: Engine, change: (b: Buddy) => Partial<Buddy> | null): Promise<boolean> {
61 try {
62 const b = await read($, buddy)
63 const patch = change(b)
64 if (!patch) return true
65 const diff = (Object.keys(patch) as (keyof Buddy)[]).some(k => patch[k] !== b[k])
66 if (diff) await update($, buddy, cur => ({ ...cur, ...(change(cur) ?? {}) }))
67 return true
68 } catch {
69 // Clawd is decoration: a drawing failure must never get in the way of the turn
70 return false
71 }
72}
73
74// A turn started: Clawd gets to work, the connection is considered restored, no celebration
75async function setBusy($: Engine) {
76 idleTimer?.cancel()
77 await patchBuddy($, b => ({
78 activity: 'busy', offlineSince: 0, celebrate: false,
79 ...(b.activity === 'idle' ? { tool: '' as const, wordTool: '' as const, spinner: '' as const } : {}),
80 }))
81}
82
83// A turn ended (or the session process started): resting from now on; celebrate — celebrate first
84async function setIdle($: Engine, celebrate: boolean, offline = false) {
85 try {
86 const now = await $.clock.now()
87 toolsInFlight.clear()
88 lastSpinner = ''
89 toolHoldTimer?.cancel()
90 requestTimer?.cancel()
91 await patchBuddy($, b => ({
92 activity: 'idle',
93 idleSince: b.activity === 'idle' && b.idleSince ? b.idleSince : now,
94 celebrate: b.activity === 'idle' ? b.celebrate : celebrate,
95 tool: '',
96 flash: '',
97 flashUntil: 0,
98 wordTool: '',
99 spinner: '',
100 compacting: false, // the turn ended — so did a compaction inside it; guards against a stuck flag
101 offlineSince: offline ? now : celebrate ? 0 : b.offlineSince,
102 }))
103 await scheduleIdle($)
104 } catch {
105 // see above
106 }
107}
108
109// While idle the activity changes with time (celebrate -> walk -> coffee -> ... -> sleep): redraw exactly at the boundary
110async function scheduleIdle($: Engine) {
111 idleTimer?.cancel()
112 const b = await read($, buddy)
113 if (b.activity !== 'idle') return
114 const wait = msToNextIdleChange(await $.clock.now(), b.idleSince, b.celebrate)
115 idleTimer = $.clock.after(wait + 100, () => void bumpIdle($).catch(() => {}))
116}
117
118async function bumpIdle($: Engine) {
119 const b = await read($, buddy)
120 if (b.activity !== 'idle') return
121 await update($, buddy, cur => ({ ...cur, tick: (cur.tick ?? 0) + 1 }))
122 await scheduleIdle($)
123}
124
125// Which of the main loop's tools is "on show": a question to the user beats everything, then memory, edit, web, search
126const TOOL_PRIORITY: ToolKind[] = ['ask', 'memorize', 'recall', 'edit', 'web', 'search', 'work']
127function currentTool(): '' | ToolKind {
128 for (const k of TOOL_PRIORITY) if ((toolsInFlight.get(k) ?? 0) > 0) return k
129 return ''
130}
131
132// Helpers changed: remember, remove the dissolved ones after LEAVE_MS (and seat the waiting ones), write for a redraw
133async function crewChanged($: Engine, next: CrewEntry[]) {
134 try {
135 crew = next
136 const now = await $.clock.now()
137 const v = crewView(crew, now)
138 const due = crew.filter(c => c.leaving).map(c => c.leaving! + LEAVE_MS)
139 crewTimer?.cancel()
140 crewTimer = due.length ? $.clock.after(Math.max(0, Math.min(...due) - now) + 50, () => void crewTick($)) : null
141 const leaving = crew.filter(c => c.leaving && c.slot !== null).length
142 await patchBuddy($, () => ({ helpers: v.active + leaving, crewKey: crewKey(v) }))
143 } catch {
144 // see above
145 }
146}
147
148async function crewTick($: Engine) {
149 await crewChanged($, crewPrune(crew, await $.clock.now()))
150}
151
152// Reconcile with the engine's agent list (every 15 s while there are helpers, and at start): add the missing ones,
153// see off the finished (or vanished) ones. Workflow agents are not in the list — turn.complete sees them off
154async function crewSync($: Engine) {
155 try {
156 const list = await $.agent.list()
157 const now = await $.clock.now()
158 let next = crew
159 const byId = new Map(list.map(a => [a.id, a]))
160 for (const a of list)
161 if (!a.parentId && (a.status === 'running' || a.status === 'pending' || a.status === 'waiting')) next = crewJoin(next, a.id, now)
162 for (const c of next) {
163 if (c.leaving) continue
164 const a = byId.get(c.id)
165 const done = a ? a.status === 'completed' || a.status === 'failed' || a.status === 'killed' : !c.wf && now - c.since > LIST_GRACE_MS
166 if (done || (c.wf && now - c.seen > WF_IDLE_MS)) next = crewLeave(next, c.id, now)
167 }
168 if (next !== crew) await crewChanged($, next)
169 } catch {
170 // no list — wait for events
171 }
172}
173
174// What the main loop's spinner said: the model's mode, the kind of work from the step word, and "offline / retrying".
175// The server's answer did not start within OFFLINE_WAIT_MS ("requesting" all along) — also "offline". false — write failed
176async function noteSpinner($: Engine, mode: SpinnerMode, wordTool: '' | ToolKind, offline: boolean): Promise<boolean> {
177 try {
178 const now = await $.clock.now()
179 requestTimer?.cancel()
180 requestTimer = null
181 if (mode === 'requesting' && !offline)
182 requestTimer = $.clock.after(OFFLINE_WAIT_MS, () =>
183 void patchBuddy($, b => (b.activity === 'busy' && b.spinner === 'requesting' && !b.offlineSince ? { offlineSince: now + OFFLINE_WAIT_MS } : null)))
184 return await patchBuddy($, b => ({
185 spinner: mode,
186 wordTool,
187 offlineSince: offline ? b.offlineSince || now : mode === 'requesting' ? b.offlineSince : 0,
188 }))
189 } catch {
190 return false
191 }
192}
193
194// End of a main-loop tool: its scene holds TOOL_HOLD_MS more (and while the model waits for the server or thinks),
195// unless another tool started; the end of the turn drops it at once
196async function holdTool($: Engine) {
197 toolHoldTimer?.cancel()
198 const cur = currentTool()
199 if (cur !== '') return void (await patchBuddy($, b => (b.activity === 'busy' ? { tool: cur } : null)))
200 toolHoldTimer = $.clock.after(TOOL_HOLD_MS, () =>
201 void patchBuddy($, b => (b.activity === 'busy' && currentTool() === '' ? { tool: '' } : null)))
202}
203
204// An engine insertion with no tool (memories attached to the prompt, memory saved): the scene for TOOL_HOLD_MS
205async function flash($: Engine, kind: ToolKind) {
206 try {
207 const until = (await $.clock.now()) + TOOL_HOLD_MS
208 flashTimer?.cancel()
209 await patchBuddy($, () => ({ flash: kind, flashUntil: until }))
210 flashTimer = $.clock.after(TOOL_HOLD_MS, () =>
211 void patchBuddy($, b => (b.flash && (b.flashUntil ?? 0) <= until ? { flash: '' as const, flashUntil: 0 } : null)))
212 } catch {
213 // see above
214 }
215}
216
217async function bumpTick($: Engine) {
218 try {
219 await update($, buddy, cur => ({ ...cur, tick: (cur.tick ?? 0) + 1 }))
220 } catch {
221 // see above
222 }
223}
224
225// Which second of the cycle to enter a scene at: its highlight beat if it has not been shown for a while
226// (work — a sip of coffee), otherwise from the start
227function enterAt(mode: BuddyMode, now: number): number {
228 const startAt = entryStart(mode, now, highlightAt.get(mode))
229 if (startAt) highlightAt.set(mode, now)
230 return startAt
231}
232
233// The scene to show: the same while Clawd keeps doing the same thing; a change is a smooth transition from where he
234// stands now. Called while drawing: writes only module variables, state is written later (a redraw on a timer)
235function stageFor($: Engine, want: BuddyMode, now: number, glasses: boolean): Stage {
236 const gear = { glasses }
237 const crewNow = crewView(crew, now)
238 const key = crewKey(crewNow)
239 const cur = stage && now >= stage.since ? stage : null // the clock went back (a test with a new clock) — start over
240 if (!cur) {
241 const { place } = nextStage(null, want, now, enterAt(want, now))
242 return (stage = { place, since: now, glasses, crew: key, svg: stageSvg({ to: place, gear, crew: crewNow }) })
243 }
244 const el = (now - cur.since) / 1000
245 const settled = el >= (cur.walk?.dur ?? 0) + FADE_S
246 if (want !== cur.place.mode) {
247 if (now - cur.since < MIN_SCENE_MS && !URGENT.has(want)) {
248 stageTimer?.cancel()
249 stageTimer = $.clock.after(cur.since + MIN_SCENE_MS - now + 20, () => void bumpTick($))
250 return cur
251 }
252 const { place, walk, from } = nextStage(cur, want, now, enterAt(want, now))
253 return (stage = { place, since: now, walk, glasses, crew: key, svg: stageSvg({ to: place, gear, crew: crewNow, from, walk }) })
254 }
255 if (cur.glasses !== glasses || cur.crew !== key) {
256 if (!settled) {
257 // a transition is running — rebuild once it ends
258 stageTimer?.cancel()
259 stageTimer = $.clock.after(((cur.walk?.dur ?? 0) + FADE_S) * 1000 - (now - cur.since) + 20, () => void bumpTick($))
260 return cur
261 }
262 // same scene, different glasses or helpers — rebuild from the current second of its cycle, no transition
263 const place = { ...cur.place, startAt: phaseOf(cur.place.mode, cur.place.startAt, el - (cur.walk?.dur ?? 0)) }
264 return (stage = { place, since: now, glasses, crew: key, svg: stageSvg({ to: place, gear, crew: crewNow }) })
265 }
266 return cur
267}
268
269async function setUltra($: Engine, ultra: boolean) {
270 await patchBuddy($, () => ({ ultra }))
271}
272
273const blockTexts = (content: readonly { type: string; [k: string]: unknown }[]) =>
274 content.filter(b => b.type === 'text' && typeof b.text === 'string').map(b => b.text as string)
275
276// Ultra mode at the session process's start: the last "Ultracode is on/still on/off" insertion in the conversation (one pass)
277async function scanUltra($: Engine) {
278 try {
279 const msgs = await $.session.messages({ as: 'api' })
280 for (let i = msgs.length - 1; i >= 0; i--) {
281 const m = msgs[i]!
282 if (m.role !== 'user') continue
283 const texts = blockTexts(m.content)
284 for (let j = texts.length - 1; j >= 0; j--) {
285 const ultra = ultraMarker(texts[j]!)
286 if (ultra !== null) return void (await setUltra($, ultra))
287 }
288 }
289 } catch {
290 // no conversation — stay without sunglasses
291 }
292}
293
294// The process's first start (not a hot reload: state survives that) — idle without a celebration, one pass over the conversation
295async function bootBuddy($: Engine) {
296 const b = await read($, buddy)
297 if (b.booted) return void (await scheduleIdle($))
298 await update($, buddy, cur => ({ ...cur, booted: true, compacting: false }))
299 await setIdle($, false)
300 await scanUltra($)
301}
302
303export const register: Register = on => {
304 on('session.start', async ($, e, next) => {
305 bootBuddy($).catch(() => {})
306 void crewSync($)
307 $.clock.every(CREW_SYNC_MS, () => {
308 if (crew.length) void crewSync($)
309 })
310 return next(e)
311 })
312
313 // to work — only if the prompt reached a turn (a dropped one starts no turn and gets no turn.complete)
314 on('prompt.submit', async ($, e, next) => {
315 const res = await next(e)
316 if (res.drop === undefined) setBusy($).catch(() => {})
317 return res
318 })
319
320 // the main loop's tool picks the scene (question, edit, web, search, shell); calls of subagents and workflow
321 // agents do not touch the scene — they only count as helpers
322 on('tool.call', async ($, e, next) => {
323 if (e.agentId) {
324 // the helper is alive (for workflow agents the only sign)
325 const id = e.agentId
326 void $.clock.now().then(now => {
327 crew = crew.map(c => (c.id === id ? { ...c, seen: now } : c))
328 }).catch(() => {})
329 return next(e)
330 }
331 const kind = toolKind(e.tool, e as unknown as Record<string, unknown>)
332 toolsInFlight.set(kind, (toolsInFlight.get(kind) ?? 0) + 1)
333 toolHoldTimer?.cancel()
334 // the kind as of the call: the write may land after the tool already ended (an edit takes milliseconds)
335 const shown = currentTool()
336 const started = patchBuddy($, () => ({ activity: 'busy', tool: shown })).catch(() => {})
337 try {
338 return await next(e)
339 } finally {
340 toolsInFlight.set(kind, Math.max(0, (toolsInFlight.get(kind) ?? 0) - 1))
341 void started.then(() => holdTool($)).catch(() => {})
342 }
343 })
344
345 // the main loop's turn started (a queued turn too: its prompt.submit came during the previous turn)
346 on('turn.start', ($, e, next) => {
347 setBusy($).catch(() => {})
348 return next(e)
349 })
350
351 // an agent started by the main session (not by an agent) — a helper hops onto a free slot right at spawn
352 on('agent.spawn', async ($, e, next) => {
353 const res = await next(e)
354 try {
355 const id = res && !res.deny ? res.agentId : undefined
356 if (id && !e.parentAgentId) {
357 const wf = Boolean((e as unknown as { workflow?: unknown }).workflow)
358 void $.clock.now().then(now => crewChanged($, crewJoin(crew, id, now, wf))).catch(() => {})
359 }
360 } catch {
361 // see above
362 }
363 return res
364 })
365
366 // compaction of the main loop (manual, auto or by a plugin — not the background precompute): a long scene for its duration
367 on('session.compact', async ($, e, next) => {
368 if (e.agentId || e.trigger === 'precompute') return next(e)
369 const since = await $.clock.now()
370 await patchBuddy($, () => ({ compacting: true, compactSince: since }))
371 try {
372 return await next(e)
373 } finally {
374 void patchBuddy($, () => ({ compacting: false }))
375 }
376 })
377
378 // The spinner says what the model is busy with: thinking / waiting for the server / writing / a tool, and retries.
379 // Drawing writes nothing (not allowed) — the write is deferred to a timer; the engine draws the spinner as usual.
380 // A subagent's spinner (requestId is its id; the main loop's is "main") does not touch the scene.
381 on('ui.render', { component: 'Spinner' }, ($, e, next) => {
382 try {
383 if (!e.requestId || e.requestId === 'main') {
384 const mode = (e.props.mode ?? '') as SpinnerMode
385 const offline = spinnerOffline(e.props.message)
386 // while a status message covers the spinner, the step word is not shown — do not parse it
387 const wordTool = e.props.message ? '' : kindFromWord(e.props.word)
388 const key = `${mode}|${wordTool}|${offline}`
389 if (key !== lastSpinner) {
390 lastSpinner = key
391 $.clock.after(0, () =>
392 void noteSpinner($, mode, wordTool, offline).then(ok => {
393 if (!ok && lastSpinner === key) lastSpinner = '' // not written — retry on the next draw
394 }))
395 }
396 }
397 } catch {
398 // failed — Clawd just won't know about "thinking"
399 }
400 return next(e)
401 })
402
403 // ultra mode is switched on and off by an engine insertion of its own type — the sunglasses follow it;
404 // a relevant_memories insertion — the engine attached memories to the prompt: Clawd recalls
405 on('session.append', { door: 'attachment' }, ($, e, next) => {
406 try {
407 const ultra = e.agentId ? null : ultraFromAttachment(e.message.name)
408 if (ultra !== null) setUltra($, ultra).catch(() => {})
409 if (!e.agentId && e.message.name === 'relevant_memories') flash($, 'recall').catch(() => {})
410 } catch {
411 // parsing an insertion must never get in the way of storing it
412 }
413 return next(e)
414 })
415
416 // the engine's memory notices: saved — memorizes, recalled — recalls
417 on('session.append', { door: 'notice' }, ($, e, next) => {
418 try {
419 const name = e.agentId ? '' : e.message.name
420 if (name === 'memory_saved') flash($, 'memorize').catch(() => {})
421 else if (name === 'memory_recall') flash($, 'recall').catch(() => {})
422 } catch {
423 // see above
424 }
425 return next(e)
426 })
427
428 on('turn.complete', async ($, e, next) => {
429 if (e.agentId) {
430 // an agent finished — its helper gets a check mark and dissolves
431 const id = e.agentId
432 if (crew.some(c => c.id === id && !c.leaving))
433 void $.clock.now().then(now => crewChanged($, crewLeave(crew, id, now))).catch(() => {})
434 return next(e)
435 }
436 // an answer — celebrate; an API error broke the turn — "offline" for 3 more minutes or until the next prompt
437 setIdle($, e.reason === 'answer', e.reason === 'error').catch(() => {})
438 return next(e)
439 })
440
441 // The band above the prompt: Clawd on top, then whatever the rest of the chain draws (other mods, the engine).
442 // next(e) is always called, so a mod below this one is never silenced
443 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
444 if (e.props.hasSurvey) return next(e)
445 // the terminal has no Svg — nothing to draw there (the band is raised on the terminal and desktop only)
446 if (e.surface === 'terminal') return next(e)
447 const surface = $.ui.resolve(e)
448 if (!('Svg' in surface)) return next(e)
449 const b = await read($, buddy)
450 const now = await $.clock.now()
451 const helpers = b.helpers ?? 0
452 // background agents while the main session is free — Clawd "supervises": a working scene with helpers
453 const busy = e.props.isWorking || helpers > 0
454 const mode = pickMode({
455 isWorking: busy,
456 mainWorking: e.props.isWorking,
457 tool: e.props.isWorking ? b.tool ?? '' : '',
458 flash: e.props.isWorking ? b.flash ?? '' : '',
459 flashUntil: b.flashUntil ?? 0,
460 spinner: e.props.isWorking ? b.spinner ?? '' : '',
461 wordTool: e.props.isWorking ? b.wordTool ?? '' : '',
462 compacting: b.compacting === true,
463 compactSince: b.compactSince ?? 0,
464 offlineSince: b.offlineSince ?? 0,
465 idleSince: b.idleSince,
466 celebrate: b.celebrate === true,
467 }, now)
468 const gear = { glasses: b.ultra === true }
469 const st = stageFor($, mode, now, gear.glasses)
470 const below = await next(e)
471 const { Box } = surface
472 return (
473 <Box flexDirection="column">
474 {/* explicit size: without it the desktop's interactive-picture frame picks its own height and leaves a gap */}
475 <surface.Svg
476 source={st.svg}
477 alt={buddyAlt(st.place.mode, gear, crewView(crew, now).active)}
478 width={STRIP_W}
479 height={STRIP_H}
480 isInteractive
481 />
482 {below}
483 </Box>
484 )
485 })
486}
487hooks/buddy.ts 551 lines1// Pixel Clawd above the prompt: following what the session does, he thinks, works in the terminal, edits a file,
2// searches with a magnifier, browses the web, asks the user, compacts the conversation, fixes the connection,
3// recalls and memorizes at the memory cabinet, celebrates the end of a turn, walks, drinks coffee, sleeps;
4// mini-Clawds work beside him while subagents run. In ultra mode he wears cool black sunglasses.
5// The scenes live in ./clawd/*; this file picks the scene and assembles the SVG.
6
7import type { BuddyMode } from '../types'
8import {
9 C, CELEBRATE_S, CH_W, COFFEE_S, EASE, EASE_OUT, type Gear, NO_GEAR, P, STRIP_H, STRIP_W, TOP, WALK_S, at, clawd, fade, g, glyph,
10 mirror, move, px, scaleAnim, strip,
11} from './clawd/core'
12import { SCENE_META, type SceneMeta } from './clawd/meta'
13import { sceneCelebrate, sceneCoffee, sceneSleep, sceneWalk } from './clawd/scenes_rest'
14import { sceneAsk, sceneSearch, sceneWeb } from './clawd/scenes_find'
15import { sceneMemorize, sceneRecall } from './clawd/scenes_memory'
16import { sceneCompact, sceneOffline } from './clawd/scenes_story'
17import { helperAt, helpers, sceneEdit, sceneThink, sceneWork } from './clawd/scenes_work'
18
19export { NO_GEAR, STRIP_H, STRIP_W }
20export type { Gear }
21
22// ── инструмент → сцена ────────────────────────────────────────────────────────
23
24export type ToolKind = 'search' | 'web' | 'edit' | 'work' | 'ask' | 'recall' | 'memorize'
25
26const ASK_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode'])
27const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
28const WEB_TOOLS = new Set(['WebSearch', 'WebFetch'])
29const SEARCH_TOOLS = new Set(['Grep', 'Glob', 'Read', 'ToolSearch', 'LS'])
30// MCP servers that go to the web or drive a browser
31const WEB_SERVER_RE = /^mcp__(?:claude-in-chrome|chrome-devtools|playwright|scrapling|firecrawl[^_]*|computer-use|openrouter|perplexity)__/
32// слова в имени MCP-инструмента (после последнего «__», через «_»)
33const SEARCH_WORDS = new Set(['search', 'find', 'fetch', 'get', 'read', 'list', 'query', 'scrape', 'grep', 'lookup', 'snapshot', 'screenshot'])
34const EDIT_WORDS = new Set(['create', 'update', 'edit', 'write', 'set', 'patch', 'add', 'append', 'upload', 'publish', 'send', 'delete', 'move', 'rename'])
35// первая команда Bash, которая ищет или читает
36const BASH_SEARCH_RE = /^\s*(?:sudo\s+)?(?:grep|rg|egrep|find|fd|locate|ls|tree|cat|head|tail|less|wc|jq)\b/
37const BASH_WEB_RE = /^\s*(?:sudo\s+)?(?:curl|wget|ssh|scp|rsync)\b/
38
39// ── память: вспоминает / запоминает ───────────────────────────────────────────
40
41// Claude Code's auto-memory folder — .../.claude/projects/<project>/memory, a path relative to ~/.claude included
42const MEMORY_PATH_RE = /(?:^|[\s/'"=])projects\/-[\w.-]+\/memory(?:\/|\b)/
43// a shell command that writes into that folder: git add/commit, an index blob, an in-place edit, a Python write
44const MEMORY_WRITE_CMD_RE =
45 /\bgit\b[^|;&\n]*\b(?:commit|add)\b|hash-object -w|\bsed -i\b|\bopen\([^)]*,\s*['"][wa]['"]|\.write_text\(/
46// a memory MCP server or tool: a word of its server or tool name (split on "_" and "-") is one of these
47const MEMORY_WORDS = new Set(['memory', 'memories', 'mem0', 'remember', 'recall'])
48// a memory tool whose name starts with one of these writes; any other memory tool reads
49const MEMORY_WRITE_RE = /^(?:write|save|add|update|delete|forget|remember|store|create|insert|upsert|put|set|edit|append)/
50
51export type MemoryKind = '' | 'recall' | 'memorize'
52
53export function memoryKind(tool: string, input?: Record<string, unknown>): MemoryKind {
54 // a memory MCP tool: write_note, add_memory and the like — memorizes; search, get, list, stats and the rest — recalls
55 if (tool.startsWith('mcp__')) {
56 const parts = tool.slice('mcp__'.length).split('__')
57 const name = (parts.length > 1 ? parts[parts.length - 1]! : '').toLowerCase()
58 const words = parts.join('_').toLowerCase().split(/[_-]+/)
59 if (words.some(w => MEMORY_WORDS.has(w))) return MEMORY_WRITE_RE.test(name) ? 'memorize' : 'recall'
60 }
61 const path = [input?.file_path, input?.path, input?.notebook_path].find((v): v is string => typeof v === 'string')
62 if (path && MEMORY_PATH_RE.test(path)) {
63 if (EDIT_TOOLS.has(tool)) return 'memorize'
64 if (SEARCH_TOOLS.has(tool)) return 'recall'
65 }
66 if (tool === 'Bash' && typeof input?.command === 'string') {
67 const cmd = input.command
68 if (MEMORY_PATH_RE.test(cmd)) return MEMORY_WRITE_CMD_RE.test(cmd) ? 'memorize' : 'recall'
69 }
70 return ''
71}
72
73export function toolKind(tool: string, input?: Record<string, unknown>): ToolKind {
74 const mem = memoryKind(tool, input)
75 if (mem) return mem
76 if (ASK_TOOLS.has(tool)) return 'ask'
77 if (EDIT_TOOLS.has(tool)) return 'edit'
78 if (WEB_TOOLS.has(tool) || WEB_SERVER_RE.test(tool)) return 'web'
79 if (SEARCH_TOOLS.has(tool)) return 'search'
80 if (tool === 'Bash') {
81 const cmd = typeof input?.command === 'string' ? input.command : ''
82 return BASH_SEARCH_RE.test(cmd) ? 'search' : BASH_WEB_RE.test(cmd) ? 'web' : 'work'
83 }
84 if (tool.startsWith('mcp__')) {
85 const words = (tool.split('__').pop() ?? '').toLowerCase().split('_')
86 if (SEARCH_WORDS.has(words[0] ?? '') || words.includes('search')) return 'search'
87 if (EDIT_WORDS.has(words[0] ?? '')) return 'edit'
88 }
89 return 'work'
90}
91
92// the older name: search or not
93export function modeForTool(tool: string, input?: Record<string, unknown>): 'work' | 'search' {
94 return toolKind(tool, input) === 'search' ? 'search' : 'work'
95}
96
97// ── простой ───────────────────────────────────────────────────────────────────
98
99// после хода: праздник 4 с, потом по кругу прогулка 24 с (ровно туда и обратно) и кофе 16 с; ≥ 20 мин — сон
100export const CELEBRATE_MS = CELEBRATE_S * 1000
101export const WALK_MS = WALK_S * 1000
102export const IDLE_CYCLE_MS = (WALK_S + COFFEE_S) * 1000
103export const SLEEP_AFTER_MS = 20 * 60_000
104
105export function idleMode(now: number, idleSince: number, celebrate = false): BuddyMode {
106 const since = idleSince || now
107 const rel = now - since
108 if (rel >= SLEEP_AFTER_MS) return 'sleep'
109 if (celebrate && rel < CELEBRATE_MS) return 'celebrate'
110 const pos = (celebrate ? rel - CELEBRATE_MS : rel) % IDLE_CYCLE_MS
111 return pos < WALK_MS ? 'walk' : 'coffee'
112}
113
114// через сколько мс сменится занятие в простое (перерисовка ровно на границе)
115export function msToNextIdleChange(now: number, idleSince: number, celebrate = false): number {
116 const since = idleSince || now
117 const rel = now - since
118 if (rel >= SLEEP_AFTER_MS) return SLEEP_AFTER_MS // спит до следующего хода
119 let toPhase: number
120 if (celebrate && rel < CELEBRATE_MS) toPhase = CELEBRATE_MS - rel
121 else {
122 const pos = (celebrate ? rel - CELEBRATE_MS : rel) % IDLE_CYCLE_MS
123 toPhase = pos < WALK_MS ? WALK_MS - pos : IDLE_CYCLE_MS - pos
124 }
125 return Math.max(250, Math.min(toPhase, SLEEP_AFTER_MS - rel))
126}
127
128// ── выбор сцены ───────────────────────────────────────────────────────────────
129
130export type SpinnerMode = '' | 'requesting' | 'responding' | 'thinking' | 'tool-input' | 'tool-use'
131export const OFFLINE_HOLD_MS = 3 * 60_000 // сколько показывать «нет связи» после хода, оборванного ошибкой
132
133export const COMPACT_MAX_MS = 15 * 60_000 // страховка: компакт дольше не бывает — флаг считаем зависшим
134export const OFFLINE_WAIT_MS = 90_000 // ответ сервера не начался за 1,5 мин (повторы при перегрузке) — «нет связи»
135
136export type BuddyView = {
137 isWorking: boolean // идёт ход главной сессии или работают помощники
138 mainWorking?: boolean // идёт ход именно главной сессии (по умолчанию = isWorking)
139 tool: '' | ToolKind // инструмент главного цикла: идёт сейчас или только что был (держится несколько секунд)
140 flash?: '' | ToolKind // вспышка от вставки движка (подобрал заметки памяти к промпту, сохранил память)
141 flashUntil?: number
142 spinner: SpinnerMode // что говорит строка хода (думает / пишет / инструмент)
143 wordTool?: '' | ToolKind // вид работы из слова строки хода на десктопе («Creating notes.md» → правка)
144 compacting: boolean
145 compactSince?: number
146 offlineSince: number // 0 — связь в порядке
147 idleSince: number
148 celebrate: boolean // ход закончился ответом — сначала праздник
149}
150
151export function pickMode(v: BuddyView, now: number): BuddyMode {
152 if (v.compacting && (!v.compactSince || now - v.compactSince < COMPACT_MAX_MS)) return 'compact'
153 const main = v.mainWorking ?? v.isWorking
154 if (v.offlineSince && (main || now - v.offlineSince < OFFLINE_HOLD_MS)) return 'offline'
155 if (v.isWorking) {
156 if (v.tool) return v.tool
157 if (v.flash && now < (v.flashUntil ?? 0)) return v.flash
158 if (v.spinner === 'thinking' || v.spinner === 'requesting') return 'think'
159 if (v.wordTool) return v.wordTool
160 return 'work'
161 }
162 return idleMode(now, v.idleSince, v.celebrate)
163}
164
165// Слово строки хода на десктопе — что делает шаг (фразы движка 2.1.289): «Reading app.py», «Searching for foo»,
166// «Finding *.ts», «Editing notes.md», «Writing notes.md», «Fetching https://…», «Searching the web», «Running …».
167// По нему правка видна, пока модель ещё пишет содержимое файла. В терминале там случайный глагол одним словом
168// («Sauteing», «Creating») — однословное не разбираем
169export function kindFromWord(word: string | null | undefined): '' | ToolKind {
170 if (typeof word !== 'string') return ''
171 const w = word.trim()
172 if (!/^\S+\s+\S/.test(w)) return ''
173 // встроенная память движка: «Saving memory notes.md», «Reading memory», «Listing memory documents»
174 if (/^(?:Saving|Writing|Editing|Updating) memory\b/.test(w)) return 'memorize'
175 if (/^(?:Reading|Listing|Searching) memory\b/.test(w)) return 'recall'
176 if (/^(?:Editing|Writing|Creating|Updating|Saving)\s/.test(w)) return 'edit'
177 if (/^Searching the web\b/.test(w) || /^Fetching\s/.test(w)) return 'web'
178 if (/^(?:Reading|Searching|Finding|Listing)\s/.test(w)) return 'search'
179 return ''
180}
181
182// текст строки хода, который значит «нет связи / повтор»
183const OFFLINE_RE = /retr|reconnect|connection|offline|overload|network|timed? ?out|повтор|переподкл|соедин|сеть|нет связи/i
184export function spinnerOffline(message: string | null | undefined): boolean {
185 return typeof message === 'string' && OFFLINE_RE.test(message)
186}
187
188// ── режим ultra (очки) ────────────────────────────────────────────────────────
189
190// запись транскрипта: тип вставки (ultra_effort_enter / ultra_effort_exit)
191export function ultraFromAttachment(name: string | undefined): boolean | null {
192 if (name === 'ultra_effort_enter') return true
193 if (name === 'ultra_effort_exit') return false
194 return null
195}
196// текст переписки (проход при старте): вставка, которая НАЧИНАЕТСЯ с «Ultracode is on/still on/off»
197const ULTRA_RE = /<system-reminder>\s*Ultracode is (?:still |now )?(on|off)\b/gi
198export function ultraMarker(text: string): boolean | null {
199 let last: boolean | null = null
200 for (const m of text.matchAll(ULTRA_RE)) last = m[1]!.toLowerCase() === 'on'
201 return last
202}
203
204// ── начало сцены ──────────────────────────────────────────────────────────────
205
206// Scenes change every few seconds (think -> terminal -> think) and each starts from the beginning — the late beats of
207// a cycle are rarely reached. So on entry a scene sometimes jumps straight to such a beat: work — a sip of coffee
208// now and then while working, at most once in 40 s
209export const ENTRY_HIGHLIGHT: Partial<Record<BuddyMode, { at: number; everyMs: number }>> = {
210 work: { at: 5.3, everyMs: 40_000 },
211}
212
213// с какой секунды цикла начать сцену при входе в неё: с яркого бита, если его давно не было, иначе с начала
214export function entryStart(mode: BuddyMode, now: number, lastHighlightAt: number | undefined): number {
215 const h = ENTRY_HIGHLIGHT[mode]
216 if (!h) return 0
217 return lastHighlightAt === undefined || now - lastHighlightAt >= h.everyMs ? h.at : 0
218}
219
220// сдвиг времени всех анимаций: сцена начинается с секунды startAt своего цикла (SMIL — отрицательный begin)
221export function shiftTimeline(svg: string, startAt: number): string {
222 if (!startAt) return svg
223 const fmt = (x: number) => `${Math.round(x * 1000) / 1000}s`
224 return svg.replace(/<(animate|animateTransform|set)\b([^>]*?)(\/?)>/g, (tag, name: string, attrs: string, close: string) => {
225 const b = /\sbegin="(-?\d+(?:\.\d+)?)s"/.exec(attrs)
226 if (b) return `<${name}${attrs.replace(b[0], ` begin="${fmt(Number(b[1]) - startAt)}"`)}${close}>`
227 if (/\sbegin="/.test(attrs)) return tag // начало от события или другой анимации не трогаем
228 return `<${name}${attrs} begin="${fmt(-startAt)}"${close}>`
229 })
230}
231
232// ── сборка ────────────────────────────────────────────────────────────────────
233
234const SCENES: Record<BuddyMode, (gear: Gear) => string> = {
235 think: sceneThink,
236 work: sceneWork,
237 edit: sceneEdit,
238 search: sceneSearch,
239 web: sceneWeb,
240 ask: sceneAsk,
241 recall: sceneRecall,
242 memorize: sceneMemorize,
243 compact: sceneCompact,
244 offline: sceneOffline,
245 celebrate: sceneCelebrate,
246 walk: sceneWalk,
247 coffee: sceneCoffee,
248 sleep: sceneSleep,
249}
250
251export const MODE_ALT: Record<BuddyMode, string> = {
252 think: 'Clawd is thinking',
253 work: 'Clawd is working at the laptop',
254 edit: 'Clawd is editing a file',
255 search: 'Clawd is searching with a magnifier',
256 web: 'Clawd is on the web',
257 ask: 'Clawd is waiting for your answer',
258 recall: 'Clawd is recalling',
259 memorize: 'Clawd is memorizing',
260 compact: 'Clawd is compacting the conversation',
261 offline: 'Clawd is fixing the connection: the server is not answering or failed',
262 celebrate: 'Clawd is celebrating: done',
263 walk: 'Clawd is taking a walk',
264 coffee: 'Clawd is drinking coffee',
265 sleep: 'Clawd is sleeping',
266}
267
268// сцены, во время которых справа могут работать мини-клодики-помощники
269const WITH_HELPERS = new Set<BuddyMode>(['think', 'work', 'edit', 'search', 'web', 'ask', 'recall', 'memorize', 'compact', 'offline'])
270
271export function buddyAlt(mode: BuddyMode, gear: Gear, helperCount = 0): string {
272 const base = gear.glasses ? `${MODE_ALT[mode]}, in sunglasses` : MODE_ALT[mode]
273 return helperCount > 0 && WITH_HELPERS.has(mode) ? `${base}; helpers: ${helperCount}` : base
274}
275
276// startAt — с какой секунды цикла начать сцену (entryStart); помощники справа идут своим временем
277export function buddySvg(mode: BuddyMode, gear: Gear = NO_GEAR, helperCount = 0, startAt = 0): string {
278 const scene = SCENES[mode] ?? sceneWalk
279 const extra = helperCount > 0 && WITH_HELPERS.has(mode) ? helpers(Math.min(helperCount, 4)) : ''
280 return strip(shiftTimeline(scene(gear), startAt) + extra)
281}
282
283// ── плавная смена сцен ────────────────────────────────────────────────────────
284// Smooth switching, and "wherever he stops, he does the new thing there": searching with the magnifier, then starting to
285// code — he stops on that spot, puts the magnifier away and takes out the laptop; searching again — takes the magnifier
286// and walks on. A new scene is placed exactly where Clawd stands: shifted horizontally, mirrored near an edge (props on
287// the other side); the old one fades out, the new one fades in over FADE_S. He walks only if the scene fits neither
288// way. Helpers sit on the free side of the strip. A walk continues from where he is.
289// Where Clawd is at every moment of every scene was measured from rendered frames (clawd/meta.ts).
290
291export const FADE_S = 0.3
292const WALK_SPEED = 140 // px/с
293const WALK_MIN_S = 0.4
294const WALK_MAX_S = 2
295const ROAM = new Set<BuddyMode>(['walk']) // Клод сам ходит по полосе: подбирается момент цикла, а не место сцены
296// сцены с украшениями во всю полосу (конфетти, звёзды): обрезка краёв не страшна — ставим просто под Клода
297const CLIP_OK = new Set<BuddyMode>(['celebrate', 'sleep'])
298// помощники (helpers) нарисованы справа, x 478…702; слева — тот же рисунок, сдвинутый на HELPERS_LEFT
299const HELPERS_LEFT = -470
300
301const sceneMeta = (mode: BuddyMode): SceneMeta | undefined => SCENE_META[mode]
302
303// где центр Клода (px, в координатах сцены) на секунде phase её цикла
304export function clawdX(mode: BuddyMode, phase: number): number {
305 const m = sceneMeta(mode)
306 if (!m || m.x.length === 0) return 40
307 const t = ((phase % m.cycle) + m.cycle) % m.cycle
308 const i = t / m.step
309 const i0 = Math.floor(i)
310 const a = m.x[i0 % m.x.length]!
311 const b = m.x[(i0 + 1) % m.x.length]!
312 return a + (b - a) * (i - i0)
313}
314
315// секунда цикла сцены, начатой с секунды startAt, через elapsedS
316export function phaseOf(mode: BuddyMode, startAt: number, elapsedS: number): number {
317 const cycle = sceneMeta(mode)?.cycle ?? 10
318 const t = startAt + Math.max(0, elapsedS)
319 return Math.round((((t % cycle) + cycle) % cycle) * 1000) / 1000
320}
321
322// Где сцена на полосе: точка x сцены → x0 + x (как нарисована) или x0 − x (зеркально: Клод смотрит в другую сторону)
323export type Placement = { mode: BuddyMode; startAt: number; x0: number; flip: boolean }
324const toStrip = (p: Placement, x: number) => (p.flip ? p.x0 - x : p.x0 + x)
325
326// Куда поставить сцену, чтобы Клод (центр сейчас в xFrom) остался ровно на месте: как нарисована, иначе зеркально,
327// иначе — как можно ближе к нему (тогда он дойдёт пешком)
328export function placeScene(mode: BuddyMode, startAt: number, xFrom?: number): Placement {
329 const m = sceneMeta(mode)
330 if (!m || xFrom === undefined) return { mode, startAt, x0: 0, flip: false }
331 if (ROAM.has(mode)) {
332 let best = 0
333 let bestD = Infinity
334 m.x.forEach((x, i) => {
335 const d = Math.abs(x - xFrom)
336 if (d < bestD - 0.5) {
337 bestD = d
338 best = i * m.step
339 }
340 })
341 return { mode, startAt: best, x0: 0, flip: false }
342 }
343 const c = clawdX(mode, startAt)
344 const plain = Math.round(xFrom - c)
345 const mirr = Math.round(xFrom + c)
346 if (CLIP_OK.has(mode) || (plain + m.minX >= 0 && plain + m.maxX <= STRIP_W)) return { mode, startAt, x0: plain, flip: false }
347 if (mirr - m.maxX >= 0 && mirr - m.minX <= STRIP_W) return { mode, startAt, x0: mirr, flip: true }
348 const plainX = Math.max(-m.minX, Math.min(STRIP_W - m.maxX, plain))
349 const mirrX = Math.max(m.maxX, Math.min(STRIP_W + m.minX, mirr))
350 return Math.abs(plainX - plain) <= Math.abs(mirrX - mirr)
351 ? { mode, startAt, x0: plainX, flip: false }
352 : { mode, startAt, x0: mirrX, flip: true }
353}
354
355export type Walk = { x0: number; x1: number; dur: number }
356
357// переход пешком (центр Клода из x0 в x1), если сцена не встала прямо под ним
358export function walkBetween(x0: number, x1: number): Walk | undefined {
359 const d = Math.abs(x1 - x0)
360 if (d < 6) return undefined
361 const dur = Math.min(WALK_MAX_S, Math.max(WALK_MIN_S, d / WALK_SPEED))
362 return { x0: Math.round(x0), x1: Math.round(x1), dur: Math.round(dur * 100) / 100 }
363}
364
365// где центр Клода через elapsedS после начала показа сцены (с переходом пешком в начале)
366export function stageX(p: Placement, walk: Walk | undefined, elapsedS: number): number {
367 if (walk && elapsedS < walk.dur) {
368 const s = Math.max(0, elapsedS / walk.dur)
369 return walk.x0 + (walk.x1 - walk.x0) * s * s * (3 - 2 * s)
370 }
371 return toStrip(p, clawdX(p.mode, phaseOf(p.mode, p.startAt, elapsedS - (walk?.dur ?? 0))))
372}
373
374export type StageState = { place: Placement; since: number; walk?: Walk }
375export type StageStep = { place: Placement; walk?: Walk; from?: Placement & { phase: number } }
376
377// Следующая сцена (чистая логика смены): куда поставить, нужен ли переход пешком и какую сцену растворить.
378// cur — что показано сейчас (с какого момента now), startAt — с какой секунды цикла войти в новую сцену.
379// В прогулку не идём пешком: она сама начинается с ближайшего к Клоду места, а шагающий переход рядом с
380// идущей сценой даёт двух Клодов. Переход ещё идёт — старую сцену не растворяем (она не проявилась)
381export function nextStage(cur: StageState | null, want: BuddyMode, now: number, startAt: number): StageStep {
382 if (!cur) return { place: placeScene(want, startAt) }
383 const el = (now - cur.since) / 1000
384 const settled = el >= (cur.walk?.dur ?? 0) + FADE_S
385 const xFrom = stageX(cur.place, cur.walk, el)
386 const place = placeScene(want, startAt, xFrom)
387 const walk = ROAM.has(want) ? undefined : walkBetween(xFrom, toStrip(place, clawdX(want, place.startAt)))
388 const from = settled ? { ...cur.place, phase: phaseOf(cur.place.mode, cur.place.startAt, el - (cur.walk?.dur ?? 0)) } : undefined
389 return { place, walk, from }
390}
391
392const placed = (p: Placement, gear: Gear, phase: number) =>
393 `<g transform="translate(${p.x0},0)${p.flip ? ' scale(-1,1)' : ''}">${shiftTimeline((SCENES[p.mode] ?? sceneWalk)(gear), phase)}</g>`
394
395// помощникам — свободная сторона полосы: справа, если сцена туда не заходит, иначе слева, если там свободно
396export function helpersSide(p: Placement): 'right' | 'left' {
397 const m = sceneMeta(p.mode)
398 if (!m) return 'right'
399 const a = toStrip(p, m.minX)
400 const b = toStrip(p, m.maxX)
401 if (Math.max(a, b) <= 466) return 'right'
402 if (Math.min(a, b) >= 244) return 'left'
403 return 'right'
404}
405
406// SVG полосы со сменой сцены: старая (from, на своей секунде цикла) тает, Клод доходит пешком (walk), новая (to)
407// проявляется и идёт со своей секунды startAt. Без from и walk — просто сцена. Метка <!--stage …--> — для тестов
408export function stageSvg(o: {
409 to: Placement
410 gear: Gear
411 helperCount?: number
412 crew?: Crew
413 from?: Placement & { phase: number }
414 walk?: Walk
415}): string {
416 const t0 = o.walk?.dur ?? 0
417 const total = Math.round((t0 + FADE_S) * 100) / 100
418 const k = (t0 / total).toFixed(4)
419 let s = `<!--stage to=${o.to.mode} start=${o.to.startAt} x0=${o.to.x0}${o.to.flip ? ' flip=1' : ''}` +
420 `${o.from ? ` from=${o.from.mode}` : ''}${o.walk ? ` walk=${o.walk.dur}` : ''}-->`
421 if (o.from) s += g(placed(o.from, o.gear, o.from.phase), fade('1;0', `${FADE_S}s`, { once: true }))
422 if (o.walk) {
423 const w = o.walk
424 const half = (CH_W * P) / 2
425 const body = clawd({ legs: 'walk', gear: o.gear })
426 const go = move(`${w.x0 - half} 0;${w.x1 - half} 0`, `${w.dur}s`, { calc: 'spline', keyTimes: '0;1', keySplines: EASE, once: true })
427 s += g(at(0, TOP, w.x1 < w.x0 ? mirror(body) : body, go), fade('1;1;0', `${total}s`, { keyTimes: `0;${k};1`, once: true }))
428 }
429 const scene = placed(o.to, o.gear, o.to.startAt - t0)
430 if (o.from || o.walk) {
431 const show = t0 ? fade('0;0;1', `${total}s`, { keyTimes: `0;${k};1`, once: true }) : fade('0;1', `${FADE_S}s`, { once: true })
432 s += g(scene, show, ' opacity="0"')
433 } else s += scene
434 const crew = o.crew ?? { slots: Array.from({ length: Math.min(o.helperCount ?? 0, CREW_SLOTS) }, (_, i) => ({ slot: i, state: 'work' as const, age: 0 })), extra: 0 }
435 if ((crew.slots.length || crew.extra) && WITH_HELPERS.has(o.to.mode)) {
436 const h = crewSvg(crew)
437 s += helpersSide(o.to) === 'left' ? `<g transform="translate(${HELPERS_LEFT},0)">${h}</g>` : h
438 }
439 return strip(s)
440}
441
442// ── helpers: agents of the main session ───────────────────────────────────────────
443// As many mini-Clawds as the main session has agents running; an agent finished — a green check mark over its
444// mini-Clawd, a hop, and it dissolves. Each keeps its own slot 0..3 (nobody shifts when someone leaves);
445// extra ones wait in a queue (a "+N" badge) and take the slot that frees up.
446
447export const CREW_SLOTS = 4
448export const ARRIVE_MS = 450
449export const LEAVE_MS = 1_600
450
451export type CrewEntry = { id: string; slot: number | null; since: number; leaving?: number; wf?: boolean; seen: number }
452
453function freeSlot(crew: CrewEntry[]): number | null {
454 const used = new Set(crew.filter(c => c.slot !== null).map(c => c.slot))
455 for (let i = 0; i < CREW_SLOTS; i++) if (!used.has(i)) return i
456 return null
457}
458
459// новый агент: первое свободное место, нет мест — в очередь
460export function crewJoin(crew: CrewEntry[], id: string, now: number, wf = false): CrewEntry[] {
461 if (crew.some(c => c.id === id)) return crew
462 return [...crew, { id, slot: freeSlot(crew), since: now, wf, seen: now }]
463}
464
465// агент закончил: на месте — галочка и растворение (место свободно через LEAVE_MS); из очереди — просто уходит
466export function crewLeave(crew: CrewEntry[], id: string, now: number): CrewEntry[] {
467 return crew.flatMap(c => (c.id !== id || c.leaving ? [c] : c.slot === null ? [] : [{ ...c, leaving: now }]))
468}
469
470// убрать растворившихся; свободные места — первым из очереди (они выпрыгивают на место)
471export function crewPrune(crew: CrewEntry[], now: number): CrewEntry[] {
472 const out = crew.filter(c => !(c.leaving && now - c.leaving >= LEAVE_MS)).map(c => ({ ...c }))
473 for (const c of out) {
474 if (c.slot !== null || c.leaving) continue
475 const slot = freeSlot(out)
476 if (slot === null) break
477 c.slot = slot
478 c.since = now
479 }
480 return out
481}
482
483export type CrewView = { slot: number; state: 'arrive' | 'work' | 'leave'; age: number }
484export type Crew = { slots: CrewView[]; extra: number }
485
486// что рисовать: места с состоянием, сколько ждут в очереди, сколько работают
487export function crewView(crew: CrewEntry[], now: number): Crew & { active: number } {
488 const slots: CrewView[] = []
489 let extra = 0
490 let active = 0
491 for (const c of crew) {
492 if (!c.leaving) active++
493 if (c.slot === null) {
494 if (!c.leaving) extra++
495 continue
496 }
497 if (c.leaving) slots.push({ slot: c.slot, state: 'leave', age: now - c.leaving })
498 else slots.push({ slot: c.slot, state: now - c.since < ARRIVE_MS ? 'arrive' : 'work', age: now - c.since })
499 }
500 return { slots: slots.sort((a, b) => a.slot - b.slot), extra, active }
501}
502
503// меняется — картинку пересобрать (выпрыгивание и уход играют от начала своей анимации)
504export const crewKey = (v: Crew) => v.slots.map(s => `${s.slot}${s.state === 'leave' ? 'l' : 'w'}`).join(',') + (v.extra ? `+${v.extra}` : '')
505
506const helperX = (slot: number) => 478 + slot * 56
507const around = (inner: string, cx: number, cy: number, anim: string) =>
508 `<g transform="translate(${cx},${cy})"><g>${anim}<g transform="translate(${-cx},${-cy})">${inner}</g></g></g>`
509
510// выпрыгнул на место: из точки до перелёта и обратно к своему размеру
511function helperArrive(slot: number): string {
512 const x = helperX(slot)
513 const pop = scaleAnim('0.2 0.2;1.15 1.15;1 1', '0.45s', { calc: 'spline', keyTimes: '0;0.6;1', keySplines: `${EASE_OUT};${EASE}`, once: true })
514 return g(around(helperAt(slot), x + 16, 54, pop), fade('0;1', '0.15s', { once: true }))
515}
516
517// закончил: зелёная галочка над ним, подскок, растворение и искорки (LEAVE_MS)
518function helperLeave(slot: number): string {
519 const x = helperX(slot)
520 const d = `${LEAVE_MS / 1000}s`
521 const badge = g(
522 px(x + 6, 8, 20, 18, C.green) + px(x + 8, 10, 16, 14, C.white) + glyph('✓', x + 11, 12, C.green) + px(x + 13, 26, 6, 2, C.green),
523 move('0 8;0 0;0 0;0 -6', d, { keyTimes: '0;0.12;0.75;1', once: true }) + fade('0;1;1;0', d, { keyTimes: '0;0.08;0.8;1', once: true }),
524 ' opacity="0"',
525 )
526 const hop = scaleAnim('1 1;1.15 0.85;0.9 1.12;1 1;1 1', d, { keyTimes: '0;0.12;0.25;0.38;1', once: true })
527 const body = g(around(helperAt(slot), x + 16, 54, hop), fade('1;1;0;0', d, { keyTimes: '0;0.4;0.85;1', once: true }))
528 const spark = (dx: number, dy: number, f: string) =>
529 g(px(x + 15, 42, 2, 2, f), move(`0 0;0 0;${dx} ${dy}`, d, { keyTimes: '0;0.45;1', once: true }) + fade('0;0;1;0', d, { keyTimes: '0;0.45;0.55;1', once: true }), ' opacity="0"')
530 return body + badge + spark(-14, -10, C.yellow) + spark(14, -10, C.yellow) + spark(-10, 6, C.green) + spark(12, 6, C.green)
531}
532
533// бейдж «+N» — сколько агентов ждут места
534function crewExtra(n: number): string {
535 if (n <= 0) return ''
536 const x = 688
537 const digit = String(Math.min(n, 9))
538 return px(x, 4, 28, 16, C.paperEdge) + px(x + 2, 6, 24, 12, C.white) + glyph('+', x + 3, 7, C.blue) + glyph(digit, x + 14, 7, C.blue)
539}
540
541// все помощники: работают на своих местах, выпрыгивают, уходят с галочкой; анимации ухода/прибытия продолжаются
542// с того места, где были, если картинку пересобрали посередине
543export function crewSvg(v: Crew): string {
544 let s = ''
545 for (const h of v.slots) {
546 if (h.state === 'work') s += helperAt(h.slot)
547 else s += shiftTimeline(h.state === 'arrive' ? helperArrive(h.slot) : helperLeave(h.slot), h.age / 1000)
548 }
549 return s + crewExtra(v.extra)
550}
551hooks/clawd/core.ts 239 lines1// The shared pixel-Clawd frame and the SVG primitives the scenes are built from.
2// Everything is drawn in a STRIP_W x STRIP_H CSS px strip; animation is SMIL only (<animate>, <animateTransform>, <set>):
3// the engine guarantees SMIL in the isInteractive Svg sandbox, while <style>/CSS animations and scripts are not allowed.
4// Character coordinates are in P x P px cells; the standing Clawd sprite is 16 x 10 cells, feet on the FLOOR line.
5
6export const STRIP_W = 720
7export const STRIP_H = 58
8export const P = 4 // пиксель персонажа, CSS px
9export const FLOOR = STRIP_H - 4 // y линии пола, px
10export const CH_W = 16 // ширина спрайта, клеток
11export const CH_H = 10 // высота спрайта стоя (тело 7 + ноги 3), клеток
12export const TOP = FLOOR - CH_H * P // верх стоящего Клода, px (= 14)
13// длительности сцен простоя (с): прогулка — ровно туда и обратно, кофе, праздник после хода
14export const WALK_S = 24
15export const COFFEE_S = 16
16export const CELEBRATE_S = 4
17
18// Палитра: читается и на светлой (#f0efea), и на тёмной (#262624) теме десктопа.
19// Тёмное (ink) на тёмной теме пропадает — у тёмных предметов делайте светлую кромку (edge).
20export const C = {
21 orange: '#D97757', // Клод
22 orangeDark: '#b45a3c', // тень Клода
23 ink: '#1f1f1f', // глаза, очки
24 ink2: '#3a3a3a', // корпус ноутбука, техника
25 edge: '#8a8a8a', // светлая кромка тёмных предметов
26 white: '#ffffff',
27 paper: '#f4efe4',
28 paperEdge: '#c9bda6',
29 blue: '#3d6fd9',
30 sky: '#8fb8ff',
31 green: '#5fbf6e',
32 greenLight: '#7fd18b',
33 red: '#e5484d',
34 yellow: '#f2b134',
35 purple: '#9b6bd6',
36 grey: '#9a9a9a',
37 brown: '#6b3f26',
38 wood: '#8a5a3c',
39 steam: '#b8b8b8',
40} as const
41
42export type Gear = { glasses: boolean }
43export const NO_GEAR: Gear = { glasses: false }
44
45// ── анимация ──────────────────────────────────────────────────────────────────
46
47export type Anim = {
48 calc?: 'discrete' | 'linear' | 'spline' | 'paced'
49 keyTimes?: string
50 keySplines?: string
51 begin?: string
52 once?: boolean // без repeatCount (сыграть один раз и замереть — fill="freeze")
53}
54const tail = (o: Anim) =>
55 (o.calc ? ` calcMode="${o.calc}"` : '') +
56 (o.keyTimes ? ` keyTimes="${o.keyTimes}"` : '') +
57 (o.keySplines ? ` keySplines="${o.keySplines}"` : '') +
58 (o.begin ? ` begin="${o.begin}"` : '') +
59 (o.once ? ' fill="freeze"' : ' repeatCount="indefinite"')
60
61// сдвиг группы: values "x y;x y;…" в px
62export const move = (values: string, dur: string, o: Anim = {}) =>
63 `<animateTransform attributeName="transform" type="translate" values="${values}" dur="${dur}"${tail(o)}/>`
64// поворот: values "угол cx cy;…" (cx, cy — в px системы координат группы)
65export const spin = (values: string, dur: string, o: Anim = {}) =>
66 `<animateTransform attributeName="transform" type="rotate" values="${values}" dur="${dur}"${tail(o)}/>`
67// масштаб: values "sx sy;…" (вокруг начала координат группы — см. squash)
68export const scaleAnim = (values: string, dur: string, o: Anim = {}) =>
69 `<animateTransform attributeName="transform" type="scale" values="${values}" dur="${dur}"${tail(o)}/>`
70export const fade = (values: string, dur: string, o: Anim = {}) =>
71 `<animate attributeName="opacity" values="${values}" dur="${dur}"${tail(o)}/>`
72export const attr = (name: string, values: string, dur: string, o: Anim = {}) =>
73 `<animate attributeName="${name}" values="${values}" dur="${dur}"${tail(o)}/>`
74// набор n одинаковых keySplines для calc:'spline' (n = число значений − 1)
75export const EASE = '0.42 0 0.58 1'
76export const EASE_OUT = '0 0 0.58 1'
77export const EASE_IN = '0.42 0 1 1'
78export const splines = (n: number, s = EASE) => Array.from({ length: n }, () => s).join(';')
79
80// ── рисование ─────────────────────────────────────────────────────────────────
81
82// прямоугольник в клетках; inner — анимации внутри rect
83export const r = (x: number, y: number, w: number, h: number, fill: string, inner = '') => {
84 const a = `x="${x * P}" y="${y * P}" width="${w * P}" height="${h * P}" fill="${fill}"`
85 return inner ? `<rect ${a}>${inner}</rect>` : `<rect ${a}/>`
86}
87// прямоугольник в px (для мелочей: пар, искры, конфетти, мини-клодики)
88export const px = (x: number, y: number, w: number, h: number, fill: string, inner = '') => {
89 const a = `x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"`
90 return inner ? `<rect ${a}>${inner}</rect>` : `<rect ${a}/>`
91}
92// группа: inner, анимации группы, атрибуты (' opacity="0"' и т.п.)
93export const g = (inner: string, anim = '', attrs = '') => `<g${attrs}>${anim}${inner}</g>`
94// сдвинуть содержимое в точку (px); anim — анимация вложенной группы (у неё нет своего transform)
95export const at = (xPx: number, yPx: number, inner: string, anim = '') =>
96 `<g transform="translate(${xPx},${yPx})">${g(inner, anim)}</g>`
97// поставить Клода на пол в точке x (px): верх спрайта на TOP
98export const stand = (xPx: number, inner: string, anim = '') => at(xPx, TOP, inner, anim)
99// сжатие/растяжение вокруг ступней спрайта (values "sx sy;…": 1.15 0.85 — сплющился, 0.9 1.12 — вытянулся)
100export function squash(inner: string, values: string, dur: string, o: Anim = {}) {
101 const cx = (CH_W * P) / 2
102 const fy = CH_H * P
103 return `<g transform="translate(${cx},${fy})">${g(`<g transform="translate(${-cx},${-fy})">${inner}</g>`, scaleAnim(values, dur, o))}</g>`
104}
105// зеркало по горизонтали вокруг середины спрайта (Клод смотрит влево)
106export const mirror = (inner: string) => `<g transform="translate(${CH_W * P},0) scale(-1,1)">${inner}</g>`
107
108// пиксельные значки 5×5 (символ → строки, «#» — закрашено); cell — размер точки, px
109const GLYPHS: Record<string, string[]> = {
110 '?': [' ### ', '# #', ' # ', ' # ', ' ', ' # '],
111 '!': [' # ', ' # ', ' # ', ' # ', ' ', ' # '],
112 Z: ['#####', ' # ', ' # ', ' # ', '#####'],
113 z: ['###', ' # ', '###'],
114 '♪': [' ## ', ' # #', ' # ', '### ', '### '],
115 '♥': [' # # ', '#####', '#####', ' ### ', ' # '],
116 '✓': [' #', ' # ', '# # ', ' ## ', ' # '],
117 '✗': ['# #', ' # # ', ' # ', ' # # ', '# #'],
118 '+': [' # ', ' # ', '#####', ' # ', ' # '],
119 '*': ['# #', ' # # ', ' # ', ' # # ', '# #'],
120 '.': [' ', ' ', ' ', ' ', ' # '],
121 '0': [' ### ', '# #', '# #', '# #', ' ### '],
122 '1': [' # ', ' ## ', ' # ', ' # ', ' ### '],
123 '2': [' ### ', '# #', ' ## ', ' # ', '#####'],
124 '3': ['#### ', ' #', ' ### ', ' #', '#### '],
125 '4': ['# # ', '# # ', '#####', ' # ', ' # '],
126 '5': ['#####', '# ', '#### ', ' #', '#### '],
127 '6': [' ### ', '# ', '#### ', '# #', ' ### '],
128 '7': ['#####', ' #', ' # ', ' # ', ' # '],
129 '8': [' ### ', '# #', ' ### ', '# #', ' ### '],
130 '9': [' ### ', '# #', ' ####', ' #', ' ### '],
131}
132export function glyph(ch: string, xPx: number, yPx: number, fill: string, cell = 2): string {
133 const rows = GLYPHS[ch]
134 if (!rows) return ''
135 let s = ''
136 rows.forEach((row, yy) => {
137 for (let xx = 0; xx < row.length; xx++) if (row[xx] === '#') s += px(xPx + xx * cell, yPx + yy * cell, cell, cell, fill)
138 })
139 return s
140}
141
142// ── Клод ──────────────────────────────────────────────────────────────────────
143
144export type Eyes = 'open' | 'closed' | 'happy' | 'left' | 'right' | 'up' | 'wide' | 'dizzy'
145export type Arms = 'down' | 'type' | 'wave' | 'up' | 'none'
146export type Legs = 'stand' | 'walk' | 'sit' | 'none'
147
148function eyesSvg(e: Eyes, blink: boolean): string {
149 const D = C.ink
150 switch (e) {
151 case 'closed':
152 return r(4, 3, 2, 1, D) + r(10, 3, 2, 1, D)
153 case 'happy': // ^ ^
154 return r(4, 3, 1, 1, D) + r(5, 2, 1, 1, D) + r(6, 3, 1, 1, D) + r(9, 3, 1, 1, D) + r(10, 2, 1, 1, D) + r(11, 3, 1, 1, D)
155 case 'dizzy': // × ×
156 return [4, 9].map(x => r(x, 2, 1, 1, D) + r(x + 2, 2, 1, 1, D) + r(x + 1, 3, 1, 1, D) + r(x, 4, 1, 1, D) + r(x + 2, 4, 1, 1, D)).join('')
157 case 'wide':
158 return r(4, 2, 2, 2, D) + r(10, 2, 2, 2, D) + r(4, 2, 1, 1, C.white) + r(10, 2, 1, 1, C.white)
159 default: {
160 const pos = { open: [5, 10, 2], left: [4, 9, 2], right: [6, 11, 2], up: [5, 10, 1] }[e]
161 const [lx, rx, y] = pos!
162 const eyes = r(lx!, y!, 1, 2, D) + r(rx!, y!, 1, 2, D)
163 return blink ? g(eyes, fade('1;0;1', '5s', { calc: 'discrete', keyTimes: '0;0.95;0.98' })) : eyes
164 }
165 }
166}
167
168function armsSvg(a: Arms): string {
169 const L = r(0, 3, 2, 2, C.orange)
170 const R = r(14, 3, 2, 2, C.orange)
171 switch (a) {
172 case 'none':
173 return ''
174 case 'type': // попеременно стучат по клавиатуре
175 return g(L, move('0 0;0 4', '0.3s', { calc: 'discrete' })) + g(R, move('0 4;0 0', '0.3s', { calc: 'discrete' }))
176 case 'wave': // правая рука вверх и машет
177 return L + g(r(14, 0, 2, 2, C.orange) + r(15, -1, 1, 1, C.orange), move('0 0;3 -2;0 0', '0.6s', { calc: 'discrete' }))
178 case 'up': // обе вверх
179 return r(0, 0, 2, 2, C.orange) + r(0, -1, 1, 1, C.orange) + r(14, 0, 2, 2, C.orange) + r(15, -1, 1, 1, C.orange)
180 default:
181 return L + R
182 }
183}
184
185export const legsStand = (h = 3) => [3, 6, 9, 12].map(x => r(x, 7, 1, h, C.orange)).join('')
186// шаг: пары ног по очереди короче (период 0.5 с)
187export const legsWalk = (period = '0.5s') =>
188 g([3, 9].map(x => r(x, 7, 1, 3, C.orange)).join('') + [6, 12].map(x => r(x, 7, 1, 2, C.orange)).join(''),
189 fade('1;0', period, { calc: 'discrete' })) +
190 g([3, 9].map(x => r(x, 7, 1, 2, C.orange)).join('') + [6, 12].map(x => r(x, 7, 1, 3, C.orange)).join(''),
191 fade('0;1', period, { calc: 'discrete' }), ' opacity="0"')
192
193// крутые чёрные очки (режим ultra): перемычка, сплошные стёкла со ступенькой, блик; glint — пробегающий отблеск.
194// Перемычка на клетку уже тела с каждой стороны: во всю ширину она на тёмной теме сливалась с фоном и резала голову
195export function glasses(glint = true): string {
196 const D = C.ink
197 const base =
198 r(3, 1, 10, 1, D) +
199 r(3, 2, 4, 2, D) + r(4, 4, 2, 1, D) +
200 r(9, 2, 4, 2, D) + r(10, 4, 2, 1, D) +
201 r(4, 2, 1, 1, C.white) + r(10, 2, 1, 1, C.white) +
202 r(5, 3, 1, 1, '#4a4a4a') + r(11, 3, 1, 1, '#4a4a4a')
203 if (!glint) return base
204 // косая полоска света пробегает по стёклам раз в 4 с
205 const shine = g(px(14, 8, 2, 2, C.white) + px(16, 6, 2, 2, C.white), move('0 0;0 0;32 0;32 0', '4s', { keyTimes: '0;0.7;0.85;1' }) +
206 fade('0;0;0.9;0;0', '4s', { keyTimes: '0;0.7;0.78;0.85;1' }), ' opacity="0"')
207 return base + shine
208}
209
210// Клод целиком в своих клетках (0..16 × −?..10): тело, глаза, руки, ноги и очки (gear)
211export function clawd(o: { eyes?: Eyes; blink?: boolean; arms?: Arms; legs?: Legs; gear?: Gear; glint?: boolean } = {}): string {
212 const eyes = eyesSvg(o.eyes ?? 'open', o.blink ?? true)
213 const legs = o.legs === 'walk' ? legsWalk() : o.legs === 'sit' ? legsStand(1) : o.legs === 'none' ? '' : legsStand(3)
214 const gl = o.gear?.glasses ? glasses(o.glint ?? true) : ''
215 return r(2, 0, 12, 7, C.orange) + armsSvg(o.arms ?? 'down') + legs + eyes + gl
216}
217
218// мини-клодик (помощник-сабагент) в px: масштаб 0.5, с ноутбучком; x, y — левый верх; delay — сдвиг анимации
219export function miniClawd(xPx: number, yPx: number, delay = 0): string {
220 const s = 2 // пиксель мини-клодика
221 const q = (x: number, y: number, w: number, h: number, f: string) => px(xPx + x * s, yPx + y * s, w * s, h * s, f)
222 const arms = g(q(0, 3, 2, 2, C.orange), move('0 0;0 2', '0.3s', { calc: 'discrete', begin: `${delay}s` })) +
223 g(q(14, 3, 2, 2, C.orange), move('0 2;0 0', '0.3s', { calc: 'discrete', begin: `${delay}s` }))
224 return q(2, 0, 12, 7, C.orange) + arms + q(5, 2, 1, 2, C.ink) + q(10, 2, 1, 2, C.ink) +
225 [3, 6, 9, 12].map(x => q(x, 7, 1, 1, C.orange)).join('') +
226 q(16, 2, 10, 6, C.ink2) + q(17, 3, 8, 4, '#151515') + q(15, 8, 12, 1, C.edge) +
227 g(q(18, 4, 5, 1, C.greenLight), attr('width', `0;${5 * s};${5 * s}`, '1.6s', { keyTimes: '0;0.7;1', begin: `${delay}s` }))
228}
229
230// обёртка SVG полосы: пол + содержимое сцены
231export function strip(inner: string): string {
232 return (
233 `<svg xmlns="http://www.w3.org/2000/svg" width="${STRIP_W}" height="${STRIP_H}" viewBox="0 0 ${STRIP_W} ${STRIP_H}" shape-rendering="crispEdges">` +
234 `<rect x="0" y="${FLOOR}" width="${STRIP_W}" height="1" fill="#d9d4cc" opacity="0.7"/>` +
235 inner +
236 `</svg>`
237 )
238}
239hooks/clawd/meta.ts 21 lines1// Measured from rendered frames of every scene — re-measure after editing a scene, do not hand-tune.
2// cycle — cycle length, s; minX/maxX — the scene's horizontal bounds, px;
3// x — Clawd's horizontal center, px, at moments 0, step, 2*step... of the cycle (in scene coordinates)
4export type SceneMeta = { cycle: number; minX: number; maxX: number; step: number; x: number[] }
5export const SCENE_META: Record<string, SceneMeta> = {
6 think: { cycle: 8, minX: 52, maxX: 222, step: 0.25, x: [88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88, 88] },
7 work: { cycle: 10, minX: 24, maxX: 178, step: 0.25, x: [72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 69, 68, 68, 68, 68, 68, 68, 68, 68, 69, 72, 72, 72, 72, 72, 72, 72, 72] },
8 edit: { cycle: 8, minX: 27, maxX: 233, step: 0.25, x: [62, 62, 62, 62, 83, 125, 126, 118, 110, 102, 100, 94, 86, 86, 90, 96, 101, 107, 112, 118, 123, 129, 107, 63, 62, 62, 62, 62, 62, 62, 62, 62] },
9 search: { cycle: 10, minX: 16, maxX: 321, step: 0.25, x: [72, 72, 72, 76, 89, 106, 128, 151, 173, 195, 214, 229, 239, 245, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 239, 217, 184, 149, 117, 91, 76, 72] },
10 web: { cycle: 8, minX: 37, maxX: 329, step: 0.25, x: [72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72] },
11 ask: { cycle: 6, minX: 35, maxX: 141, step: 0.25, x: [80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 79, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80] },
12 recall: { cycle: 8, minX: 83, maxX: 257, step: 0.25, x: [148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 149, 149, 149, 148, 148, 148, 148, 148, 148, 148, 148, 148, 148] },
13 memorize: { cycle: 8, minX: 93, maxX: 257, step: 0.25, x: [148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 150, 150, 148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 148, 150, 148] },
14 compact: { cycle: 36, minX: 4, maxX: 451, step: 0.25, x: [368, 368, 365, 354, 334, 306, 272, 234, 195, 159, 128, 105, 90, 84, 84, 84, 84, 84, 84, 84, 103, 136, 152, 152, 152, 141, 99, 84, 84, 90, 120, 148, 152, 152, 152, 127, 88.5, 84, 84, 98, 132, 152, 152, 152, 148, 108, 84, 84, 86, 109, 142, 152, 152, 152, 152, 152, 152, 152, 152, 152, 152, 152, 152, 152, 152, 152, 152, 146, 140, 140, 140, 140, 140, 140, 140, 140, 140, 143, 152, 168, 189, 215, 244, 272, 298, 320, 336, 345, 348, 348, 348, 348, 348, 348, 348, 348, 348, 348, 348, 348, 352, 365, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368, 368] },
15 offline: { cycle: 8, minX: 0, maxX: 275, step: 0.25, x: [156, 156, 156, 156, 156, 156, 153, 152, 152, 153, 148, 152.5, 150.5, 152, 154.5, 156, 156, 156, 156, 152.5, 152, 152, 152, 152, 152, 152, 152, 153, 155, 156, 156, 156] },
16 celebrate: { cycle: 4, minX: 3, maxX: 278, step: 0.25, x: [40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40] },
17 walk: { cycle: 24, minX: 6, maxX: 706, step: 0.25, x: [40, 40, 40, 44, 58, 81, 112, 145, 175, 197, 209, 212, 212, 212, 212, 212, 212, 212, 212, 217, 233, 261, 296, 329, 353, 366, 365, 372, 368, 368, 373, 386, 406, 433, 465, 498, 527.5, 550.5, 573.5, 596.5, 627, 656, 671, 672, 672, 672, 671, 654, 624, 604, 603, 596, 600, 600, 589, 564, 532, 509, 502, 502, 502, 502, 502, 502, 502, 502, 502, 502, 502, 493, 469, 437.5, 414, 391, 368, 341, 309, 276, 241, 208, 176, 146, 120, 97, 78, 63, 51, 44, 40, 40, 40, 40, 40, 40, 40, 40] },
18 coffee: { cycle: 16, minX: 7, maxX: 111, step: 0.25, x: [40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 38.5, 38.5, 37.5, 37.5, 37.5, 39.5, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 38.2, 37.5, 38.5, 39.5, 38.5, 39.5, 38, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40] },
19 sleep: { cycle: 12, minX: 37, maxX: 695, step: 0.25, x: [72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72, 72] },
20}
21hooks/clawd/scenes_rest.ts 497 lines1// Clawd scenes, group "rest" (idle): celebrate, walk, coffee, sleep. Each function returns the strip's content
2// (without <svg>), see core.ts. The character is built in layers: body with eyes — clawd({arms:'none', legs:'none'}),
3// arms and legs — their own cells of the same geometry, sunglasses — glasses() on top; poses switch by discrete
4// opacity on the scene's shared timeline, movement uses splines.
5import {
6 type Eyes, type Gear, C, CELEBRATE_S, COFFEE_S, EASE, EASE_IN, EASE_OUT, FLOOR, P, TOP, WALK_S,
7 at, attr, clawd, fade, g, glasses, glyph, legsStand, legsWalk, mirror, move, px, r, scaleAnim,
8} from './core'
9
10// ── шкалы ─────────────────────────────────────────────────────────────────────
11
12// число покороче: 3 знака, без ведущего нуля (.125) — экономит размер SVG
13const f4 = (n: number) => String(+n.toFixed(3)).replace(/^(-?)0\./, '$1.')
14const LIN = '0 0 1 1'
15const DRAG = '0.1 0.6 0.4 1' // резкий старт, долгое торможение (камешек, конфетти)
16const WIN = '0.45 0 0.75 0.75' // разгон до ровного шага
17const WOUT = '0.25 0.25 0.55 1' // с ровного шага до остановки
18
19// ключ: [время, с; значение; сплайн участка, который кончается в этом ключе]
20type K = [number, string, string?]
21function spl(dur: number, ks: K[], def = EASE) {
22 return {
23 values: ks.map(k => k[1]).join(';'),
24 o: {
25 calc: 'spline' as const,
26 keyTimes: ks.map(k => f4(k[0] / dur)).join(';'),
27 keySplines: ks.slice(1).map(k => k[2] ?? def).join(';'),
28 },
29 }
30}
31const mvK = (dur: number, ks: K[], def = EASE, begin?: number) => {
32 const s = spl(dur, ks, def)
33 return move(s.values, `${dur}s`, begin ? { ...s.o, begin: `${-begin}s` } : s.o)
34}
35const scK = (dur: number, ks: K[], def = EASE) => {
36 const s = spl(dur, ks, def)
37 return scaleAnim(s.values, `${dur}s`, s.o)
38}
39const opK = (dur: number, ks: [number, string][]) =>
40 fade(ks.map(k => k[1]).join(';'), `${dur}s`, { keyTimes: ks.map(k => f4(k[0] / dur)).join(';') })
41
42// дискретная шкала: [начало, значение] (первое — с 0), одинаковые соседние склеиваются
43function steps(dur: number, segs: [number, string][]) {
44 const v: string[] = []
45 const t: string[] = []
46 for (const [tt, val] of segs) {
47 if (v[v.length - 1] === val) continue
48 v.push(val)
49 t.push(f4(tt / dur))
50 }
51 return { values: v.join(';'), keyTimes: t.join(';'), first: v[0] ?? '1', n: v.length }
52}
53const dmove = (dur: number, segs: [number, string][]) => {
54 const s = steps(dur, segs)
55 return s.n > 1 ? move(s.values, `${dur}s`, { calc: 'discrete', keyTimes: s.keyTimes }) : ''
56}
57// переключатель вариантов: в каждый момент видна ровно одна часть из parts
58function sw(dur: number, segs: [number, string][], parts: Record<string, string>): string {
59 return Object.entries(parts)
60 .map(([key, svg]) => {
61 const s = steps(dur, segs.map(([t, k]) => [t, k === key ? '1' : '0'] as [number, string]))
62 if (s.n === 1) return s.first === '1' ? svg : ''
63 return g(svg, fade(s.values, `${dur}s`, { calc: 'discrete', keyTimes: s.keyTimes }), ` opacity="${s.first}"`)
64 })
65 .join('')
66}
67// сжатие/растяжение вокруг точки опоры (fx, fy) в px системы группы
68const squashAt = (inner: string, anim: string, fx: number, fy: number) =>
69 `<g transform="translate(${fx},${fy})">${g(`<g transform="translate(${-fx},${-fy})">${inner}</g>`, anim)}</g>`
70// детерминированный «шум» 0..1 (без Math.random: SVG одинаков при каждой отрисовке)
71const rnd = (i: number, k: number) => {
72 const s = Math.sin(i * 127.1 + k * 311.7) * 43758.5453
73 return s - Math.floor(s)
74}
75
76// картинка из строк («#» — точка cell×cell px), точки в строке склеены в полосы
77function bitmap(rows: string[], cell: number, fill: (ch: string) => string | null): string {
78 let s = ''
79 rows.forEach((row, y) => {
80 let x = 0
81 while (x < row.length) {
82 const ch = row[x] ?? ' '
83 let e = x + 1
84 while (e < row.length && row[e] === ch) e++
85 const col = fill(ch)
86 if (col) s += px(x * cell, y * cell, (e - x) * cell, cell, col)
87 x = e
88 }
89 })
90 return s
91}
92
93// ── части Клода (клетки спрайта) ─────────────────────────────────────────────
94
95const O = C.orange
96const armL = r(0, 3, 2, 2, O)
97const armR = r(14, 3, 2, 2, O)
98const armLUp = r(0, 0, 2, 2, O) + r(0, -1, 1, 1, O)
99const armRUp = r(14, 0, 2, 2, O) + r(15, -1, 1, 1, O)
100const ARMS_DOWN = armL + armR
101const ARMS_UP = armLUp + armRUp
102// машет: два положения правой руки на целых клетках, оба касаются тела (вверх / наклон наружу)
103const ARMS_WAVE = armL +
104 g(armRUp, fade('1;0', '0.6s', { calc: 'discrete' })) +
105 g(r(14, 1, 2, 2, O) + r(16, 0, 1, 1, O), fade('0;1', '0.6s', { calc: 'discrete' }), ' opacity="0"')
106// руки в прыжке: «\o/» — вскинуты вверх-наружу, клешни торчат по диагонали (не выше верха тела — не вылезают за полосу)
107const ARMS_JUMP = r(0, 1, 2, 2, O) + r(-1, 0, 1, 1, O) + r(14, 1, 2, 2, O) + r(16, 0, 1, 1, O)
108const face = (eyes: Eyes, blink = false) => clawd({ eyes, blink, arms: 'none', legs: 'none' })
109const specs = (gear: Gear, glint = true) => (gear.glasses ? glasses(glint) : '')
110// пыль из-под ног: n серых точек разлетаются и тают в момент t (с) шкалы dur
111function dust(dur: number, t: number, xPx: number, yPx: number, dirs: number[]): string {
112 return at(xPx, yPx, dirs.map((d, i) =>
113 px(0, 0, 2, 2, C.steam, mvK(dur, [[0, '0 0'], [t, '0 0'], [t + 0.35, `${d} ${-2 - (i % 2) * 2}`, EASE_OUT], [dur, '0 0', LIN]]))).join(''),
114 opK(dur, [[0, '0'], [t, '0'], [t + 0.02, '0.9'], [t + 0.4, '0'], [dur, '0']]))
115}
116
117// ── 1. праздник: ход закончен (CELEBRATE_S = 4 с) ─────────────────────────────
118
119export function sceneCelebrate(gear: Gear): string {
120 const D = CELEBRATE_S
121 const X = 8
122 const cx = X + 32
123 const eyes = sw(D, [[0, 'c'], [0.36, 'h'], [3.1, 'o']], { c: face('closed'), h: face('happy'), o: face('open', true) })
124 const pump =
125 g(armLUp + armR, fade('1;0', '0.36s', { calc: 'discrete' })) +
126 g(armL + armRUp, fade('0;1', '0.36s', { calc: 'discrete' }), ' opacity="0"')
127 const arms = sw(D, [[0, 'd'], [0.36, 'u'], [0.44, 'j'], [1.12, 'u'], [1.6, 'p'], [2.68, 'u'], [2.95, 'd']], { d: ARMS_DOWN, u: ARMS_UP, j: ARMS_JUMP, p: pump })
128 const legs = sw(D, [[0, 's'], [0.54, 't'], [1.1, 's']], { s: legsStand(3), t: legsStand(1) })
129 const body = legs + eyes + arms + specs(gear)
130 const sq = scK(D, [
131 [0, '1 1'], [0.3, '1.18 0.82'], [0.38, '1.18 0.82'], [0.5, '0.88 1.1', EASE_OUT], [0.6, '0.94 1.05'], [0.74, '1 1'], [1.0, '1 1'],
132 [1.18, '0.94 1.06'], [1.3, '1.22 0.78', EASE_IN], [1.45, '0.94 1.06'], [1.6, '1.04 0.97'], [1.75, '1 1'],
133 [1.95, '1.08 0.92'], [2.06, '0.96 1.04'], [2.2, '1.08 0.92'], [2.31, '0.96 1.04'], [2.45, '1.06 0.94'],
134 [2.6, '1 1'], [2.9, '1 1'], [3.0, '1.04 0.96'], [3.15, '1 1'], [D, '1 1'],
135 ])
136 const hop = mvK(D, [
137 [0, '0 0'], [0.38, '0 0'], [0.76, '0 -12', EASE_OUT], [0.96, '0 -12', LIN], [1.3, '0 0', EASE_IN],
138 [1.95, '0 0'], [2.06, '0 -4', EASE_OUT], [2.2, '0 0', EASE_IN], [2.31, '0 -4', EASE_OUT], [2.45, '0 0', EASE_IN], [D, '0 0'],
139 ])
140 const hero = at(X, TOP, squashAt(body, sq, 32, 40), hop)
141 // тень на полу: сжимается, когда Клод в воздухе
142 const shadow = at(cx, FLOOR + 1, px(-18, 0, 36, 2, C.grey),
143 scK(D, [[0, '1 1'], [0.3, '1.2 1'], [0.38, '1.2 1'], [0.76, '0.5 1', EASE_OUT], [0.96, '0.5 1'], [1.3, '1.25 1', EASE_IN], [1.6, '1 1'], [D, '1 1']]))
144
145 // конфетти: 22 кусочка вылетают из-за спины двумя залпами, летят дугами, падают на пол и тают
146 const COLORS = [C.red, C.yellow, C.blue, C.green, C.purple, C.sky]
147 let confetti = ''
148 for (let i = 0; i < 20; i++) {
149 const left = i % 4 === 1
150 const ox = cx - 10 + Math.round(rnd(i, 1) * 10) * 2
151 const oy = 20 + Math.round(rnd(i, 2) * 3) * 2
152 const dx = left ? -Math.round(8 + rnd(i, 3) * 20) : Math.round(14 + rnd(i, 3) * 230)
153 const h = Math.round(oy - 2 - rnd(i, 4) * (oy - 6)) // подъём: вершина дуги не выше y=2
154 const t0 = (i < 13 ? 0.5 : 1.3) + rnd(i, 5) * 0.25
155 const up = 0.3 + rnd(i, 6) * 0.15
156 const land = t0 + up + 0.95 + rnd(i, 7) * 0.7
157 const gone = Math.min(land + 0.35, D - 0.15)
158 const fall = (i % 3 ? 50 : 52) - oy
159 const color = COLORS[i % COLORS.length] ?? C.red
160 const flip = attr('width', '4;2', `${f4(0.2 + rnd(i, 8) * 0.12)}s`, { calc: 'discrete' })
161 const op = opK(D, [[0, '0'], [t0, '0'], [t0 + 0.02, '1'], [land - 0.05, '1'], [gone, '0'], [D, '0']])
162 const piece = px(ox, oy, 4, i % 3 ? 4 : 2, color, flip + op).replace('<rect ', '<rect opacity="0" ')
163 // одна траектория: взлёт с торможением, у вершины — разгон до ровного «порхающего» падения
164 const ax = Math.round(dx * 0.55)
165 const path = mvK(D, [[0, '0 0'], [t0, '0 0'], [t0 + up, `${ax} ${-h}`, EASE_OUT], [land, `${dx} ${fall}`, WIN], [gone, `${dx} ${fall}`, LIN], [D, '0 0', LIN]])
166 confetti += g(piece, path)
167 }
168
169 // искры «*» и большая галочка «✓»: выскакивают с перелётом масштаба и гаснут
170 const pop = (t: number, life = 0.75): K[] => [
171 [0, '0 0'], [t, '0 0'], [t + 0.14, '1.35 1.35', EASE_OUT], [t + 0.28, '1 1'], [t + life - 0.18, '1 1'], [t + life, '0 0', EASE_IN], [D, '0 0'],
172 ]
173 const sparks: [number, number, number, string][] = [
174 [92, 8, 0.62, C.yellow], [100, 26, 0.9, C.yellow], [150, 32, 1.15, C.purple], [86, 34, 1.45, C.yellow],
175 [190, 12, 1.75, C.yellow], [160, 8, 2.1, C.sky], [128, 8, 2.45, C.yellow],
176 ]
177 // через одну — глиф «*», остальные — четырёхлучевой блик (крест 10×10)
178 const twinkle = (col: string) => px(-5, -1, 10, 2, col) + px(-1, -5, 2, 10, col)
179 const sparkles = sparks.map(([sx, sy, t, col], i) => at(sx, sy, i % 2 ? twinkle(col) : glyph('*', -5, -5, col), scK(D, pop(t, 0.7)))).join('')
180 const check = at(120, 22,
181 g(glyph('✓', -10, -10, C.green, 4), scK(D, [[0, '0 0'], [0.7, '0 0'], [0.86, '1.3 1.3', EASE_OUT], [1.02, '1 1'], [D, '1 1']])),
182 mvK(D, [[0, '0 0'], [1.02, '0 0'], [2.9, '6 -4'], [3.5, '8 -6'], [D, '0 0', LIN]]) +
183 opK(D, [[0, '1'], [2.9, '1'], [3.5, '0'], [D, '0']]))
184 return shadow.replace('<g transform', '<g opacity="0.45" transform') + confetti + hero + check + sparkles
185}
186
187// ── 2. прогулка: туда и обратно ровно за WALK_S = 24 с ─────────────────────────
188
189export function sceneWalk(gear: Gear): string {
190 const D = WALK_S
191 // путь (левый край спрайта, px): разгон/торможение у остановок, прыжки над камешком — ровным шагом
192 const xk: K[] = [
193 [0, '8 0'], [0.5, '8 0'], [2.7, '180 0'], [4.5, '180 0'], [6.4, '336 0'],
194 [6.55, '332 0'], [6.72, '340 0', EASE_OUT], [6.95, '336 0'], [7.2, '336 0'],
195 [9.05, '500 0', WIN], [9.75, '564 0', LIN], [10.6, '640 0', WOUT], [11.4, '640 0'],
196 [12.4, '568 0'], [12.55, '572 0'], [12.72, '564 0', EASE_OUT], [12.95, '568 0'], [13.2, '568 0'],
197 [14.5, '470 0'], [17.0, '470 0'], [17.8, '400 0', WIN], [18.45, '340 0', LIN], [22.1, '8 0', WOUT], [D, '8 0'],
198 ]
199 const legs = sw(D, [
200 [0, 's'], [0.5, 'w'], [2.7, 's'], [4.5, 'w'], [6.4, 's'], [6.68, 'k'], [6.95, 's'], [7.2, 'w'],
201 [9.0, 's'], [9.12, 't'], [9.66, 's'], [9.8, 'w'], [10.6, 's'], [11.4, 'w'], [12.4, 's'], [12.68, 'k'], [12.95, 's'],
202 [13.2, 'w'], [14.5, 's'], [17.0, 'w'], [17.75, 's'], [17.87, 't'], [18.38, 's'], [18.5, 'w'], [22.1, 's'],
203 ], {
204 s: legsStand(3),
205 w: legsWalk('0.4s'),
206 t: legsStand(1),
207 // пинок: передняя нога выброшена вперёд-вверх
208 k: [3, 6, 9].map(x => r(x, 7, 1, 3, O)).join('') + r(12, 7, 1, 1, O) + r(13, 8, 2, 1, O),
209 })
210 // глаза в своей системе спрайта (при развороте влево всё зеркалится: 'left' смотрит вправо)
211 const eyes = sw(D, [
212 [0, 'r'], [2.7, 'o'], [2.9, 'h'], [4.3, 'o'], [4.45, 'r'], [6.68, 'w'], [7.0, 'r'],
213 [9.05, 'h'], [9.75, 'r'], [10.6, 'o'], [11.0, 'r'], [12.68, 'w'], [13.0, 'r'],
214 [14.5, 'l'], [14.85, 'u'], [15.25, 'r'], [16.0, 'u'], [16.4, 'l'], [16.75, 'h'], [17.0, 'r'],
215 [17.8, 'h'], [18.45, 'r'], [22.1, 'o'], [23.3, 'r'],
216 ], { r: face('right', true), o: face('open', true), h: face('happy'), w: face('wide'), l: face('left'), u: face('up') })
217 const arms = sw(D, [[0, 'd'], [2.88, 'v'], [4.3, 'd']], { d: ARMS_DOWN, v: ARMS_WAVE })
218 const body = legs + eyes + arms + specs(gear)
219 // разворот: дискретное зеркало вокруг середины спрайта
220 const flip = scaleAnim('1 1;-1 1;1 1', `${D}s`, { calc: 'discrete', keyTimes: `0;${f4(11.0 / D)};${f4(22.6 / D)}` })
221 const turned = squashAt(body, flip, 32, 0)
222 const sq = scK(D, [
223 [0, '1 1'], [0.4, '1 1'], [0.5, '1.05 0.95'], [0.62, '1 1'], [2.7, '1 1'], [2.8, '1.06 0.94'], [2.95, '1 1'],
224 [3.3, '1 1'], [3.42, '0.97 1.03'], [3.55, '1 1'], [3.9, '1 1'], [4.02, '0.97 1.03'], [4.15, '1 1'],
225 [6.4, '1 1'], [6.55, '0.95 1.05'], [6.72, '1.08 0.92', EASE_OUT], [6.95, '1 1'],
226 [8.95, '1 1'], [9.05, '1.12 0.88'], [9.2, '0.94 1.06', EASE_OUT], [9.45, '1 1'], [9.68, '0.96 1.04'], [9.76, '1.14 0.86', EASE_IN], [9.92, '1 1'],
227 [10.6, '1 1'], [10.68, '1.05 0.95'], [10.8, '1 1'], [10.9, '1 1'], [11.0, '1.08 0.92'], [11.12, '0.96 1.04'], [11.25, '1 1'],
228 [12.4, '1 1'], [12.55, '0.95 1.05'], [12.72, '1.08 0.92', EASE_OUT], [12.95, '1 1'],
229 [14.5, '1 1'], [14.6, '1.05 0.95'], [14.72, '1 1'], [16.75, '1 1'], [16.85, '1.04 0.96'], [16.95, '1 1'],
230 [17.7, '1 1'], [17.8, '1.12 0.88'], [17.94, '0.94 1.06', EASE_OUT], [18.15, '1 1'], [18.38, '0.96 1.04'], [18.46, '1.14 0.86', EASE_IN], [18.6, '1 1'],
231 [22.1, '1 1'], [22.2, '1.06 0.94'], [22.32, '1 1'], [22.5, '1 1'], [22.6, '1.08 0.92'], [22.72, '0.96 1.04'], [22.85, '1 1'], [D, '1 1'],
232 ])
233 const hop = mvK(D, [
234 [0, '0 0'], [9.05, '0 0'], [9.4, '0 -12', EASE_OUT], [9.75, '0 0', EASE_IN],
235 [10.95, '0 0'], [11.05, '0 -4', EASE_OUT], [11.18, '0 0', EASE_IN],
236 [17.8, '0 0'], [18.12, '0 -12', EASE_OUT], [18.45, '0 0', EASE_IN],
237 [22.55, '0 0'], [22.65, '0 -4', EASE_OUT], [22.78, '0 0', EASE_IN], [D, '0 0'],
238 ])
239 const hero = at(0, TOP, g(squashAt(turned, sq, 32, 40), hop), mvK(D, xk))
240
241 // камешек: пинок вперёд (катится, подпрыгивая), на обратном пути — пинок назад на место
242 const roll = (a: number, b: number, fwd: boolean): [number, string][] => {
243 const out: [number, string][] = []
244 let t = a
245 let k = 0
246 const ofs = fwd ? ['0 0', '4 0', '6 2', '2 2'] : ['2 2', '6 2', '4 0', '0 0']
247 while (t < b) {
248 out.push([t, ofs[k % 4] ?? '0 0'])
249 k++
250 t += 0.06 + ((t - a) / (b - a)) ** 2 * 0.3
251 }
252 return out
253 }
254 const shine = dmove(D, [[0, '0 0'], ...roll(6.72, 8.1, true), [8.1, '0 0'], ...roll(12.72, 14.1, false), [14.1, '0 0']])
255 const pebbleArt = px(2, 0, 8, 2, C.grey) + px(0, 2, 12, 4, C.grey) + px(0, 6, 12, 2, '#6f6f6f') + px(2, 2, 2, 2, '#d6d6d6', shine)
256 const bounce = (t: number): K[] => [[t, '0 0'], [t + 0.18, '0 -8', EASE_OUT], [t + 0.36, '0 0', EASE_IN], [t + 0.48, '0 -3', EASE_OUT], [t + 0.6, '0 0', EASE_IN]]
257 const pebble = at(400, FLOOR - 8,
258 g(pebbleArt, mvK(D, [[0, '0 0'], ...bounce(6.72), ...bounce(12.72), [D, '0 0']])),
259 mvK(D, [[0, '0 0'], [6.72, '0 0'], [8.1, '160 0', DRAG], [12.72, '160 0'], [14.1, '0 0', DRAG], [D, '0 0']]))
260
261 // бабочка: порхает над Клодом, он следит глазами
262 const wingsOpen = px(0, 2, 6, 6, C.purple) + px(8, 2, 6, 6, C.purple) + px(2, 8, 4, 4, C.yellow) + px(8, 8, 4, 4, C.yellow) +
263 px(2, 4, 2, 2, C.yellow) + px(10, 4, 2, 2, C.yellow)
264 const wingsShut = px(3, 2, 3, 8, C.purple) + px(8, 2, 3, 8, C.purple) + px(4, 8, 2, 2, C.yellow) + px(8, 8, 2, 2, C.yellow)
265 const bfly =
266 g(wingsOpen, fade('1;0', '0.24s', { calc: 'discrete' })) +
267 g(wingsShut, fade('0;1', '0.24s', { calc: 'discrete' }), ' opacity="0"') +
268 px(6, 2, 2, 10, C.brown) + px(4, 0, 2, 2, C.brown) + px(8, 0, 2, 2, C.brown)
269 const butterfly = g(
270 g(g(bfly, mvK(0.6, [[0, '0 0'], [0.3, '0 -2'], [0.6, '0 0']])),
271 mvK(D, [
272 [0, '640 6'], [13.8, '660 8'], [14.5, '590 12'], [14.95, '520 2'], [15.4, '470 6'], [15.75, '436 4'],
273 [16.15, '500 2'], [16.55, '546 10'], [17.0, '486 4'], [17.6, '404 2'], [18.2, '330 6'], [D, '640 6', LIN],
274 ])),
275 opK(D, [[0, '0'], [13.8, '0'], [14.1, '1'], [17.8, '1'], [18.2, '0'], [D, '0']]), ' opacity="0"')
276
277 const puffs =
278 dust(D, 6.72, 398, 50, [-6, 6, 10]) + dust(D, 9.76, 584, 50, [-8, 8]) +
279 dust(D, 12.72, 570, 50, [-10, -6, 6]) + dust(D, 18.46, 360, 50, [-8, 8]) + dust(D, 0.5, 20, 50, [-6, -10])
280 return pebble + puffs + hero + butterfly
281}
282
283// ── 3. кофе на табурете (COFFEE_S = 16 с) ────────────────────────────────────
284
285// кружка: ручка слева (4 px) + корпус 12×12, сверху видно кофе; mirror — ручка справа
286function mug(handleRight = false): string {
287 const b = handleRight ? 0 : 4
288 const h = handleRight ? 12 : 0
289 const handle = handleRight
290 ? px(h, 2, 4, 2, C.blue) + px(h + 2, 4, 2, 4, C.blue) + px(h, 8, 4, 2, C.blue)
291 : px(h, 2, 4, 2, C.blue) + px(h, 4, 2, 4, C.blue) + px(h, 8, 4, 2, C.blue)
292 return handle + px(b, 0, 12, 12, C.blue) + px(b + 2, 0, 8, 2, C.brown) + px(b + 2, 4, 2, 4, C.sky)
293}
294
295// кружка (ручка справа), наклонённая к лицу примерно на 30°: нарисована по клеткам 2 px — край с кофе идёт
296// снизу-слева вверх-вправо, ниже всех — левый нижний угол; ручка на месте ручки прямой кружки, сдвинутой на 4 px влево
297const MUG_TILT = [
298 '......BB..',
299 '....KKBBH.',
300 '..KKBBBB.H',
301 'BBBBBBBB.H',
302 'BBSBBBBBH.',
303 '.BSBBBBB..',
304 '..BBBBB...',
305 '...BB.....',
306]
307const mugTilted = () => at(-4, 0, bitmap(MUG_TILT, 2, ch => (ch === 'B' || ch === 'H' ? C.blue : ch === 'K' ? C.brown : ch === 'S' ? C.sky : null)))
308
309export function sceneCoffee(gear: Gear): string {
310 const D = COFFEE_S
311 const X = 8
312 // табурет и ящик-столик (ящик правее Клода)
313 const stool = r(1, 7, 14, 1, C.wood) + px(4, 28, 56, 2, '#a87452') + r(1, 8, 1, 2, C.wood) + r(14, 8, 1, 2, C.wood) +
314 px(4, 32, 2, 8, C.edge) + px(58, 32, 2, 8, C.edge)
315 const crate = px(84, 38, 28, 16, C.wood) + px(84, 38, 28, 2, '#a87452') + px(84, 46, 28, 2, C.brown) + px(88, 40, 2, 14, C.brown) + px(106, 40, 2, 14, C.brown) +
316 px(84, 40, 2, 14, C.edge) + px(110, 40, 2, 14, C.edge)
317 const pair = (xs: number[], h: number) => xs.map(x => r(x, 7, 1, h, O)).join('')
318 // ноги болтаются: пары по очереди то свисают (2 клетки), то поджаты вперёд (1)
319 const dangle = (xs: number[], ph: string) =>
320 g(pair(xs, 2), fade(ph, '0.8s', { calc: 'discrete' }), ` opacity="${ph.slice(0, 1)}"`) + pair(xs, 1)
321 const legs = dangle([3, 9], '1;0') + dangle([6, 12], '0;1')
322 // шкала: стол → взял → дует → пьёт → ♪ → поставил → потянулся/зевнул → взял → долгий глоток → ♥ → огляделся → поставил
323 const S: [number, string][] = [
324 [0, 'table'], [1.4, 'reach'], [1.65, 'hold'], [2.0, 'blow'], [4.0, 'sip'], [5.3, 'hold'], [7.6, 'reach'], [7.85, 'table'],
325 [10.4, 'reach'], [10.65, 'hold'], [10.9, 'sip'], [12.6, 'hold'], [15.2, 'reach'], [15.45, 'table'],
326 ]
327 const armRight = sw(D, S.map(([t, s]) => [t, s === 'reach' ? 'reach' : s === 'sip' ? 'raise' : 'side'] as [number, string]), {
328 side: armR,
329 reach: armR + r(16, 4, 4, 1, O),
330 raise: r(14, 4, 2, 3, O),
331 })
332 // во время потягивания обе руки вверх (поверх обычных — обычные прячем)
333 const armsAll = sw(D, [[0, 'n'], [8.0, 'u'], [10.0, 'n']], {
334 n: armL + armRight,
335 u: ARMS_UP,
336 })
337 const eyes = sw(D, [
338 [0, 'r'], [1.65, 'r'], [4.0, 'c'], [5.3, 'h'], [6.8, 'o'], [7.6, 'r'], [8.0, 'c'], [10.0, 'o'], [10.4, 'r'],
339 [10.9, 'c'], [12.6, 'h'], [14.0, 'l'], [14.6, 'o'], [14.9, 'r'],
340 ], { r: face('right', true), c: face('closed'), h: face('happy'), o: face('open', true), l: face('left') })
341 // зевок: открытый рот с языком
342 const yawn = g(r(7, 4, 2, 2, C.ink) + px(30, 20, 4, 2, C.red),
343 fade('0;1;0', `${D}s`, { calc: 'discrete', keyTimes: `0;${f4(8.4 / D)};${f4(9.7 / D)}` }), ' opacity="0"')
344 const body = legs + eyes + yawn + armsAll + specs(gear)
345 const sq = scK(D, [
346 [0, '1 1'], [1.4, '1 1'], [1.52, '1.03 0.97'], [1.65, '1 1'],
347 [4.3, '1 1'], [4.45, '1.03 0.97'], [4.6, '1 1'], [4.8, '1 1'], [4.95, '1.03 0.97'], [5.1, '1 1'],
348 [5.3, '1 1'], [5.45, '0.97 1.03'], [5.6, '1 1'],
349 [8.0, '1 1'], [8.6, '0.94 1.08'], [9.6, '0.94 1.08'], [9.9, '1.06 0.94'], [10.05, '0.98 1.02'], [10.2, '1.03 0.97'], [10.35, '1 1'],
350 [11.2, '1 1'], [11.35, '1.03 0.97'], [11.5, '1 1'], [11.7, '1 1'], [11.85, '1.03 0.97'], [12.0, '1 1'], [12.2, '1 1'], [12.35, '1.03 0.97'], [12.5, '1 1'],
351 [12.6, '1 1'], [12.75, '0.97 1.03'], [12.9, '1 1'], [D, '1 1'],
352 ])
353 const hero = at(X, TOP, squashAt(body, sq, 32, 28))
354
355 // кружка: стол / в руке / у губ (дует) / пьёт (у лица, ручка справа)
356 // пока рука тянется (reach), кружка стоит на столе: и когда её берут, и когда ставят
357 const mugStates: [number, string][] = S.map(([t, s]) => [t, s === 'reach' ? 'table' : s])
358 const gulps = [4.3, 4.8, 11.2, 11.7, 12.2]
359 // глоток: кружка наклоняется к лицу и чуть поднимается; кисть (4×4) держит ручку поверх неё
360 const hand = px(12, 4, 4, 4, O)
361 const sipMug = sw(D, [[0, 'u'], ...gulps.flatMap(t => [[t, 't'], [t + 0.3, 'u']] as [number, string][])], {
362 u: mug(true) + hand,
363 t: at(0, -4, mugTilted() + hand),
364 })
365 const mugs = sw(D, mugStates, {
366 table: at(84 + 4, 26, mug()),
367 hold: at(70, 22, mug()),
368 blow: at(68, 24, mug()),
369 // у рта, вплотную к правому глазу, ручка справа в руке; на каждом глотке наклоняется к лицу (дискретно 0 → −30° → 0)
370 sip: at(56, 28, sipMug),
371 })
372 // верх кружки (центр) по состояниям — туда переезжает пар
373 const top: Record<string, string> = { table: '98 26', hold: '80 22', blow: '78 24', sip: '62 28' }
374 const steamPos = dmove(D, mugStates.map(([t, s]) => [t, top[s] ?? '98 26'] as [number, string]))
375 // обычный пар: три струйки с разными фазами
376 const wisp = (dur: number, beginS: number, sway: number, w: number) =>
377 px(-1, -2, w, 2, C.steam,
378 mvK(dur, [[0, '0 0'], [dur * 0.35, `${sway} -4`], [dur * 0.7, `${-sway} -8`], [dur, `${sway} -12`]], EASE, beginS) +
379 fade('0;0.9;0.6;0', `${dur}s`, { keyTimes: '0;0.15;0.6;1', begin: `${-beginS}s` }))
380 const steamCalm = wisp(1.6, 0, 2, 2) + wisp(1.6, 0.55, -2, 2) + wisp(1.6, 1.05, 2, 4)
381 // когда дует: пар срывается вправо и закручивается
382 const gust = (beginS: number, dy: number) =>
383 px(0, -2, 2, 2, C.steam,
384 mvK(0.8, [[0, '0 0'], [0.25, `4 ${-4 + dy}`], [0.5, `10 ${-2 + dy}`], [0.8, `16 ${-8 + dy}`]], EASE, beginS) +
385 fade('0;1;0', '0.8s', { keyTimes: '0;0.2;1', begin: `${-beginS}s` }))
386 const steamGust = gust(0, 0) + gust(0.27, 2) + gust(0.53, -2)
387 const blowOn = (on: boolean) => fade(on ? '0;1;0' : '1;0;1', `${D}s`, { calc: 'discrete', keyTimes: `0;${f4(2.1 / D)};${f4(3.95 / D)}` })
388 const steam = g(g(steamCalm, blowOn(false)) + g(steamGust, blowOn(true), ' opacity="0"'), steamPos)
389 // дуновение: короткие чёрточки от лица к кружке
390 const breath = g([0, 0.13, 0.26].map(b =>
391 px(54, 30 + (b === 0.13 ? 2 : 0), 4, 2, C.sky,
392 mvK(0.4, [[0, '0 0'], [0.4, '10 -4']], EASE_OUT, b) +
393 fade('0;1;0', '0.4s', { keyTimes: '0;0.3;1', begin: `${-b}s` }))).join(''), blowOn(true), ' opacity="0"')
394
395 // ноты и сердечко после глотков
396 const floatUp = (ch: string, col: string, x0: number, t: number) =>
397 g(glyph(ch, 0, 0, col),
398 mvK(D, [[0, `${x0} 12`], [t, `${x0} 12`], [t + 0.5, `${x0 + 6} 7`], [t + 1.0, `${x0 + 2} 3`], [t + 1.4, `${x0 + 8} 0`], [D, `${x0} 12`, LIN]]) +
399 opK(D, [[0, '0'], [t, '0'], [t + 0.15, '1'], [t + 1.0, '1'], [t + 1.4, '0'], [D, '0']]), ' opacity="0"')
400 const notes = floatUp('♪', C.purple, 66, 5.4) + floatUp('♪', C.blue, 84, 5.95) + floatUp('♥', C.red, 66, 12.8) + floatUp('♪', C.purple, 88, 13.4)
401 return at(X, TOP, stool) + crate + hero + mugs + steam + breath + notes
402}
403
404// ── 4. сон: ночь, колпак, «z z Z», пузырь (12 с) ──────────────────────────────
405
406// пиксельный круг-пузырь n×n клеток по 2 px (обводка голубая, внутри светлый, блик)
407function bubble(n: number): string {
408 const c = (n - 1) / 2
409 const R = n / 2
410 const hl = Math.round(c * 0.6)
411 const rows = Array.from({ length: n }, (_, y) => Array.from({ length: n }, (_, x) => {
412 const d = Math.hypot(x - c, y - c)
413 return d > R - 0.25 ? ' ' : d > R - 1.25 ? 'o' : n >= 4 && x === hl && y === hl ? 'w' : 'i'
414 }).join(''))
415 return bitmap(rows, 2, ch => (ch === 'o' ? C.sky : ch === 'i' ? '#eef5ff' : ch === 'w' ? C.white : null))
416}
417
418export function sceneSleep(gear: Gear): string {
419 const D = 12
420 const X = 40
421 const Y = FLOOR - 8 * P // сидит: тело 7 + ножки 1 клетка
422 // ночной колпак: синий конус, белая опушка, кончик свисает с помпоном (рисуется зеркально — влево, справа место для пузыря)
423 const cap =
424 r(2, -1, 12, 1, C.white) + px(8, -4, 48, 1, C.paperEdge) +
425 r(3, -2, 10, 1, C.blue) + r(4, -3, 8, 1, C.blue) + r(6, -4, 7, 1, C.blue) +
426 r(5, -2, 2, 1, C.sky) + r(9, -3, 2, 1, C.sky) + r(8, -4, 1, 1, C.sky) +
427 r(12, -4, 2, 1, C.blue) + r(13, -3, 2, 1, C.blue) + r(14, -2, 1, 1, C.blue)
428 // помпон на свисающем кончике — качается в такт дыханию, на хлопке подпрыгивает
429 const pompom = g(r(14, -1, 2, 2, C.white) + px(56, -4, 8, 1, C.paperEdge) + px(56, 3, 8, 1, C.paperEdge) + px(56, -4, 1, 8, C.paperEdge) + px(63, -4, 1, 8, C.paperEdge),
430 mvK(D, [[0, '0 0'], [1.5, '0 2'], [3, '0 0'], [4.5, '0 2'], [6.0, '0 0'], [6.15, '0 -6', EASE_OUT], [6.4, '0 2', EASE_IN], [6.6, '0 -2'], [6.8, '0 0'], [9.0, '0 2'], [10.5, '0 0'], [D, '0 0']]))
431 const eyes = sw(D, [[0, 'c'], [6.05, 'w'], [6.5, 'l'], [6.85, 'r'], [7.2, 'c']], {
432 c: face('closed'), w: face('wide'), l: face('left'), r: face('right'),
433 })
434 const body = legsStand(1) + eyes + ARMS_DOWN + specs(gear, false) + mirror(cap + pompom)
435 // дыхание: вдох-выдох по 3 с, на хлопке пузыря — вздрогнул
436 const sq = scK(D, [
437 [0, '1 1'], [1.5, '0.98 1.04'], [3.0, '1 1'], [4.5, '0.98 1.04'], [6.0, '1 1'],
438 [6.08, '0.9 1.1', EASE_OUT], [6.25, '1.08 0.92'], [6.4, '1 1'],
439 [7.6, '1 1'], [9.0, '0.98 1.04'], [10.5, '1 1'], [11.25, '0.99 1.02'], [D, '1 1'],
440 ])
441 const startle = mvK(D, [[0, '0 0'], [6.05, '0 0'], [6.18, '0 -4', EASE_OUT], [6.32, '0 0', EASE_IN], [D, '0 0']])
442 const hero = at(X, Y, squashAt(body, sq, 32, 32), startle)
443
444 // пузырь у носа: растёт ступенями на выдохах и лопается в 6.0 с
445 // левый-нижний угол пузыря прижат к правому краю лица на уровне глаз; растёт вверх-вправо, вне силуэта
446 const bx = X + 14 * P
447 const by = Y + 3 * P
448 const sizes = [2, 3, 4, 5, 6, 8]
449 const bub = sw(D, [[0, '0'], [3.2, '1'], [3.8, '2'], [4.4, '3'], [4.9, '4'], [5.35, '5'], [5.7, '6'], [6.0, '0']],
450 Object.fromEntries(sizes.map((n, i) => [String(i + 1), at(bx, by - 2 * n, bubble(n))])))
451 // искры разлетаются наружу — вверх и вправо, в пустое небо
452 const pc = [bx + 6, by - 10]
453 const sparksB = [[-4, -12], [6, -14], [14, -10], [16, -2], [14, 6], [4, -16], [20, -6]].map(([dx, dy], i) =>
454 px(pc[0]!, pc[1]!, 2, 2, i % 2 ? C.sky : C.white, mvK(D, [[0, '0 0'], [6.0, '0 0'], [6.35, `${dx} ${dy}`, EASE_OUT], [D, '0 0', LIN]]))).join('')
455 const pop = g(sparksB, opK(D, [[0, '0'], [6.0, '0'], [6.02, '1'], [6.4, '0'], [D, '0']]), ' opacity="0"')
456
457 // «z z Z»: поднимаются сбоку от головы до верха полосы, раскачиваются и тают (два выпуска за цикл);
458 // каждая следующая чуть правее и больше — лесенкой, не налезают друг на друга
459 const zz = (ch: string, col: string, t1: number, t2: number, dx: number) => {
460 const life = 2.6
461 const x0 = X + 68 + dx
462 const y0 = Y + 8
463 const path = (t: number): K[] => [[t, `${x0} ${y0}`], [t + life * 0.33, `${x0 + 6} ${y0 - 10}`], [t + life * 0.66, `${x0 + 2} ${y0 - 20}`], [t + life, `${x0 + 10} ${y0 - 30}`]]
464 const k: K[] = [[0, `${x0} ${y0}`], ...path(t1), [t2 - 0.01, `${x0} ${y0}`, LIN], ...path(t2).slice(1), [D, `${x0} ${y0}`, LIN]]
465 const o: [number, string][] = [[0, '0'], [t1, '0'], [t1 + 0.3, '1'], [t1 + 1.7, '1'], [t1 + life, '0'], [t2, '0'], [t2 + 0.3, '1'], [t2 + 1.7, '1'], [t2 + life, '0'], [D, '0']]
466 const rows = ch === 'z' ? ['####', ' ##', '## ', '####'] : ['######', ' # ', ' # ', ' # ', ' # ', '######']
467 const art = bitmap(rows, 2, c => (c === '#' ? col : null))
468 return g(art, mvK(D, k) + opK(D, o), ' opacity="0"')
469 }
470 const zs = zz('z', C.blue, 0.2, 7.6, 0) + zz('z', C.purple, 1.1, 8.5, 6) + zz('Z', C.blue, 2.0, 9.3, 12)
471
472 // небо: месяц, мерцающие звёзды, падающая звезда
473 const MOON = ['...####.', '.####...', '.###....', '###.....', '###.....', '###.....', '###.....', '.###....', '.####...', '...####.']
474 const moon = bitmap(MOON, 2, ch => (ch === '#' ? C.yellow : null))
475 const moonG = at(640, 4, moon,
476 fade('1;0.75;1', '6s', { calc: 'spline', keyTimes: '0;0.5;1', keySplines: `${EASE};${EASE}` }))
477 const star = (x: number, y: number, col: string, dur: number, ofs: number) =>
478 at(x, y,
479 px(4, 4, 2, 2, col) +
480 g(px(4, 0, 2, 4, col) + px(4, 6, 2, 4, col) + px(0, 4, 4, 2, col) + px(6, 4, 4, 2, col),
481 fade('1;0;1', `${dur}s`, { calc: 'discrete', keyTimes: '0;0.45;0.7', begin: `${-ofs}s` })),
482 fade('1;0.35;1', `${dur}s`, { calc: 'spline', keyTimes: '0;0.5;1', keySplines: `${EASE};${EASE}`, begin: `${-ofs}s` }))
483 const stars =
484 star(250, 4, C.yellow, 2, 0) + star(330, 22, C.blue, 3, 1.1) + star(410, 2, C.yellow, 2.4, 0.6) +
485 star(480, 26, C.purple, 4, 2.2) + star(548, 8, C.yellow, 1.5, 0.4) + star(686, 30, C.blue, 3, 2.0) + star(180, 24, C.yellow, 6, 3.5)
486 // падающая звезда: голова 4×4 с белой серединкой, хвост из 4 клеток с убывающей яркостью
487 const tail = [0.75, 0.5, 0.3, 0.15].map((o, i) => g(px(4 + i * 4, -2 - i * 2, 4, 2, C.yellow), '', ` opacity="${o}"`)).join('')
488 const shooting = g(
489 px(0, 0, 4, 4, C.yellow) + px(0, 0, 2, 2, C.white) + tail,
490 mvK(D, [[0, '600 4'], [9.0, '600 4'], [9.9, '520 40', LIN], [D, '600 4', LIN]]) + opK(D, [[0, '0'], [9.0, '0'], [9.1, '1'], [9.65, '1'], [9.9, '0'], [D, '0']]),
491 ' opacity="0"')
492 // «!» над головой, когда пузырь лопнул
493 const alarm = g(glyph('!', X + 68, Y - 16, C.red),
494 opK(D, [[0, '0'], [6.04, '0'], [6.06, '1'], [6.6, '1'], [6.8, '0'], [D, '0']]) + mvK(D, [[0, '0 0'], [6.04, '0 4'], [6.2, '0 0', EASE_OUT], [D, '0 0']]), ' opacity="0"')
495 return moonG + stars + shooting + zs + hero + bub + pop + alarm
496}
497hooks/clawd/scenes_find.ts 468 lines1// Clawd scenes, group "find": searching (magnifier), on the web (globe and packets), asking (a "?" sign).
2// Each function returns the strip's content (without <svg>), see core.ts. SMIL only.
3// "While working" scenes stay at x <= 460: the mini-Clawd helpers are drawn on the right.
4import {
5 type Eyes, type Gear, C, EASE, EASE_IN, EASE_OUT, TOP,
6 at, attr, clawd, fade, g, glyph, legsStand, legsWalk, move, px, r, scaleAnim, spin, squash,
7} from './core'
8
9// ── таймлайны ────────────────────────────────────────────────────────────────
10// Кадр: [время, с; значение; easing отрезка, который КОНЧАЕТСЯ этим кадром]
11type KF = [number, string, string?]
12type Iv = [number, number]
13
14const frac = (t: number, dur: number) => String(Math.round((t / dur) * 10000) / 10000)
15const kt = (dur: number, f: KF[]) => f.map(k => frac(k[0], dur)).join(';')
16const vals = (f: KF[]) => f.map(k => k[1]).join(';')
17const ks = (f: KF[], def: string) => f.slice(1).map(k => k[2] ?? def).join(';')
18const sp = (dur: number, f: KF[], def = EASE) => ({ calc: 'spline' as const, keyTimes: kt(dur, f), keySplines: ks(f, def) })
19
20// плавные дорожки (кадры от 0 до dur включительно)
21const mvT = (dur: number, f: KF[], def = EASE) => move(vals(f), `${dur}s`, sp(dur, f, def))
22const spinT = (dur: number, f: KF[], def = EASE) => spin(vals(f), `${dur}s`, sp(dur, f, def))
23const scaleT = (dur: number, f: KF[], def = EASE) => scaleAnim(vals(f), `${dur}s`, sp(dur, f, def))
24const attrT = (name: string, dur: number, f: KF[], def = EASE) => attr(name, vals(f), `${dur}s`, sp(dur, f, def))
25const fadeLin = (dur: number, f: KF[]) => fade(vals(f), `${dur}s`, { calc: 'linear', keyTimes: kt(dur, f) })
26// ступенчатые дорожки (значение держится до следующего кадра)
27const mvD = (dur: number, f: KF[]) => move(vals(f), `${dur}s`, { calc: 'discrete', keyTimes: kt(dur, f) })
28const attrD = (name: string, dur: number, f: KF[]) => attr(name, vals(f), `${dur}s`, { calc: 'discrete', keyTimes: kt(dur, f) })
29
30// видимость по интервалам (дискретно): [[a,b],…] — отсортированы, не пересекаются
31function vis(dur: number, on: Iv[]): string {
32 const f: KF[] = [[0, on.some(([a]) => a <= 0) ? '1' : '0']]
33 for (const [a, b] of on) {
34 if (a > 0) f.push([a, '1'])
35 if (b < dur) f.push([b, '0'])
36 }
37 return fade(vals(f), `${dur}s`, { calc: 'discrete', keyTimes: kt(dur, f) })
38}
39const shown = (inner: string, dur: number, on: Iv[]) =>
40 g(inner, vis(dur, on), on.some(([a]) => a <= 0) ? '' : ' opacity="0"')
41const complement = (dur: number, on: Iv[]): Iv[] => {
42 const out: Iv[] = []
43 let t = 0
44 for (const [a, b] of on) {
45 if (a > t) out.push([t, a])
46 t = b
47 }
48 if (t < dur) out.push([t, dur])
49 return out
50}
51
52// след, который появляется в момент a и тает за life секунд; через конец цикла переходит бесшовно
53function trace(dur: number, a: number, life: number, peak = 0.8): string {
54 const p = String(peak)
55 if (a + life <= dur) {
56 const f: KF[] = a > 0 ? [[0, '0'], [a, '0'], [a + 0.02, p], [a + life, '0']] : [[0, p], [life, '0']]
57 if (a + life < dur) f.push([dur, '0'])
58 return fadeLin(dur, f)
59 }
60 const v0 = String(Math.round(peak * (1 - (dur - a) / life) * 100) / 100)
61 return fadeLin(dur, [[0, v0], [a + life - dur, '0'], [a, '0'], [a + 0.02, p], [dur, v0]])
62}
63
64// пиксельная картинка из строк: символ → цвет, cell — размер точки (px); соседние точки в строке сливаются
65function pix(x: number, y: number, rows: string[], pal: Record<string, string>, cell = 2): string {
66 let s = ''
67 rows.forEach((row, yy) => {
68 let xx = 0
69 while (xx < row.length) {
70 const ch = row[xx]!
71 const col = pal[ch]
72 if (!col) { xx++; continue }
73 let w = 1
74 while (row[xx + w] === ch) w++
75 s += px(x + xx * cell, y + yy * cell, w * cell, cell, col)
76 xx += w
77 }
78 })
79 return s
80}
81
82// глаза поверх базового Клода: тело без рук и ног с другими глазами, видимое в интервалах.
83// В очках глаза скрыты стёклами — накладки не нужны (и закрыли бы очки базового спрайта).
84// Свои направления взгляда (в core нет): вверх-вправо (за пакетами/вдаль) и вниз-вправо (на лупу у пола, по строкам).
85type Look = Eyes | 'upRight' | 'downRight'
86const LOOK: Record<'upRight' | 'downRight', number> = { upRight: 1, downRight: 3 }
87function lookFace(e: Look): string {
88 if (e === 'upRight' || e === 'downRight') {
89 const y = LOOK[e]
90 return r(2, 0, 12, 7, C.orange) + r(6, y, 1, 2, C.ink) + r(11, y, 1, 2, C.ink)
91 }
92 return clawd({ eyes: e, blink: false, arms: 'none', legs: 'none' })
93}
94function eyesOver(gear: Gear, dur: number, list: [Look, Iv[]][]): string {
95 if (gear.glasses) return ''
96 return list.map(([e, on]) => shown(lookFace(e), dur, on)).join('')
97}
98
99// всплывающий значок с пружиной: центр (cx, cy), появление в t0, исчезновение в t1
100function popGlyph(ch: string, cx: number, cy: number, fill: string, cell: number, dur: number, t0: number, t1: number, big = 1.3): string {
101 const w = 5 * cell, h = (ch === '?' || ch === '!' ? 6 : 5) * cell
102 const f: KF[] = [[0, '0 0'], [t0, '0 0'], [t0 + 0.12, `${big} ${big}`, EASE_OUT], [t0 + 0.24, '0.85 0.85'], [t0 + 0.34, '1.08 1.08'],
103 [t0 + 0.44, '1 1'], [t1, '1 1'], [t1 + 0.14, '0 0', EASE_IN], [dur, '0 0']]
104 return at(cx, cy, at(-w / 2, -h / 2, glyph(ch, 0, 0, fill, cell)), scaleT(dur, f))
105}
106
107// пылинки из-под ног: две точки разлетаются от (x, y) в момент t
108const dust = (dur: number, x: number, y: number, t: number) => {
109 const life = Math.min(0.5, dur - t - 0.04)
110 return [-1, 1].map(d => g(px(x, y, 2, 2, C.steam),
111 mvT(dur, [[0, '0 0'], [t, '0 0'], [t + life, `${d * 8} -4`, EASE_OUT], [t + life + 0.02, '0 0'], [dur, '0 0']]) +
112 fadeLin(dur, [[0, '0'], [t, '0'], [t + 0.04, '0.9'], [t + life, '0'], [dur, '0']]), ' opacity="0"')).join('')
113}
114
115// ── 1. Ищет: детектив с лупой (10 с) ─────────────────────────────────────────
116// t=0–0.5 стоит, 0.5–3.4 бредёт вправо (следы тают), 3.4 стоп (пыль, шляпа по инерции),
117// 3.7–4.9 водит лупой: вверх-вдаль, вниз, оглядывается, 4.9–6.45 склоняется к полу, «?»,
118// 5.7 под лупой блестит монетка (глаза широкие), 6.5 сжатие → «!» с пружиной, кулак вверх, глаза ^ ^,
119// 7.35 монетка прыгает в шляпу, 7.8–9.7 разворот и обратно, 9.7 стоп и разворот лицом вправо.
120export function sceneSearch(gear: Gear): string {
121 const D = 10
122 const X0 = 40, X1 = 214
123 const walkIv: Iv[] = [[0.5, 3.4], [7.8, 9.7]]
124
125 // ноги: шаг во время ходьбы, иначе стоят
126 const legs = shown(legsWalk(), D, walkIv) + shown(legsStand(), D, complement(D, walkIv))
127
128 // левая рука: опущена / кулак вверх на «!» — плечо у тела, предплечье и кулак выше полей шляпы
129 const armUp = r(0, 1, 2, 2, C.orange) + r(0, -1, 1, 2, C.orange) + g(r(-1, -3, 2, 2, C.orange), move('0 0;0 2', '0.3s', { calc: 'discrete' }))
130 const leftArm = shown(r(0, 3, 2, 2, C.orange), D, complement(D, [[6.6, 7.6]])) + shown(armUp, D, [[6.6, 7.6]])
131
132 // лупа в правой руке: заранее нарисованные позы (пиксели чёткие) + короткий доворот spin вокруг кисти (60,16) при смене позы.
133 // Ручка двухцветная: светлая верхняя кромка видна на тёмной теме.
134 const handle = (cells: [number, number][]) => cells.map(([x, y]) => r(x, y, 1, 1, C.wood) + px(x * 4, y * 4, 4, 2, '#c08a5e')).join('')
135 const ring = (x: number, y: number) =>
136 r(x + 1, y, 4, 1, C.grey) + r(x + 1, y + 4, 4, 1, C.grey) + r(x, y + 1, 1, 3, C.grey) + r(x + 5, y + 1, 1, 3, C.grey) +
137 g(r(x + 1, y + 1, 4, 3, C.sky), '', ' opacity="0.85"') + r(x + 2, y + 1, 1, 1, C.white) + px((x + 4) * 4, (y + 3) * 4, 2, 2, C.white)
138 const POSE = {
139 level: handle([[16, 3], [17, 2]]) + ring(17, -2), // вперёд
140 up: handle([[16, 2], [17, 1]]) + ring(18, -3), // вдаль-вверх
141 far: handle([[16, 4], [17, 4]]) + ring(18, 4), // к полу, дальше
142 near: handle([[16, 5]]) + ring(17, 5), // к полу, ближе
143 }
144 const poseIv: Record<keyof typeof POSE, Iv[]> = {
145 level: [[0, 3.7], [4.2, 4.9], [7.6, D]],
146 up: [[3.7, 4.2], [6.5, 7.6]],
147 far: [[4.9, 5.2], [5.45, 5.7], [5.95, 6.2]],
148 near: [[5.2, 5.45], [5.7, 5.95], [6.2, 6.5]],
149 }
150 const lens = (Object.keys(POSE) as (keyof typeof POSE)[]).map(k => shown(POSE[k], D, poseIv[k])).join('')
151 // доворот: поза появляется чуть повёрнутой со стороны, откуда пришла, и за 0.12 с встаёт ровно
152 const settle = (t: number, a: number): KF[] => [[t - 0.01, '0 60 16'], [t, `${a} 60 16`], [t + 0.12, '0 60 16', EASE_OUT]]
153 const lensArm = g(lens, spinT(D, [
154 [0, '0 60 16'], [3.4, '0 60 16'], [3.48, '10 60 16', EASE_OUT], [3.6, '0 60 16'],
155 ...settle(3.7, 10), ...settle(4.2, -8), ...settle(4.9, -8), ...settle(6.5, 10), ...settle(7.6, -8), [10, '0 60 16'],
156 ]))
157
158 // шляпа детектива: поля, тулья с лентой; запаздывает на остановках и подпрыгивает на «!»
159 const hat = g(
160 r(1, -1, 14, 1, C.wood) + r(4, -3, 8, 2, C.wood) + r(4, -2, 8, 1, C.brown) + px(20, -12, 8, 2, C.brown),
161 mvT(D, [[0, '0 0'], [3.4, '0 0'], [3.5, '2 0'], [3.7, '0 0'], [6.5, '0 0'], [6.62, '0 -2', EASE_OUT], [6.8, '0 0'],
162 [7.7, '0 0'], [7.78, '0 2'], [7.9, '0 0'], [9.7, '0 0'], [9.8, '2 0'], [9.95, '0 0'], [10, '0 0']]))
163
164 const face = clawd({ eyes: 'right', arms: 'none', legs: 'none', gear }) + eyesOver(gear, D, [
165 ['closed', [[1.7, 1.85], [8.7, 8.85]]],
166 ['upRight', [[3.75, 4.2]]],
167 ['left', [[4.45, 4.9]]],
168 ['downRight', [[4.95, 5.65]]],
169 ['wide', [[5.65, 6.55]]],
170 ['happy', [[6.6, 7.75]]],
171 ])
172
173 // покачивание на ходу: тело на 2 px ниже на каждом втором шаге (шаг 0.25 с, в такт ногам)
174 const bobVals = Array.from({ length: 40 }, (_, k) => {
175 const t = k * 0.25 + 0.01
176 return walkIv.some(([a, b]) => t >= a && t < b) && k % 2 === 1 ? '0 2' : '0 0'
177 }).join(';')
178 const body = g(face + leftArm + lensArm + r(14, 3, 2, 2, C.orange) + hat, move(bobVals, `${D}s`, { calc: 'discrete' }))
179
180 const sprite = squash(legs + body, vals(SQ_SEARCH), `${D}s`, sp(D, SQ_SEARCH))
181 // разворот: лицом вправо, с 7.8 — влево, с 9.7 снова вправо
182 const flip = `<g transform="translate(32,0)">${g(`<g transform="translate(-32,0)">${sprite}</g>`,
183 scaleAnim('1 1;-1 1;1 1', `${D}s`, { calc: 'discrete', keyTimes: `0;${frac(7.8, D)};${frac(9.7, D)}` }))}</g>`
184
185 // значки у головы, сразу за полями шляпы (над самой шляпой места нет: её тулья у верха полосы)
186 const marks = popGlyph('?', 66, 2, C.blue, 2, D, 5.1, 6.35) + popGlyph('!', 64, 0, C.red, 2, D, 6.6, 7.6, 1.35)
187
188 const actor = at(0, TOP, flip + marks, mvT(D, [
189 [0, `${X0} 0`], [0.5, `${X0} 0`], [3.4, `${X1} 0`, '0.3 0 0.6 1'], [7.8, `${X1} 0`], [9.7, `${X0} 0`, '0.3 0 0.6 1'], [10, `${X0} 0`],
190 ]))
191
192 // следы: задняя нога оставляет пару точек, они тают
193 const ease = bezier(0.3, 0, 0.6, 1)
194 const xAt = (t: number, t0: number, t1: number, a: number, b: number) => a + (b - a) * ease((t - t0) / (t1 - t0))
195 const when = (pred: (t: number) => boolean, t0: number, t1: number) => {
196 for (let t = t0; t <= t1; t += 0.01) if (pred(t)) return t
197 return -1
198 }
199 let prints = ''
200 for (let x = X0 + 12; x <= X1 + 4; x += 16) { // вправо: задняя нога — клетка 3 (rel 12)
201 const t = when(tt => xAt(tt, 0.5, 3.4, X0, X1) + 12 >= x + 8, 0.5, 3.4)
202 if (t > 0) prints += g(px(x, 55, 2, 2, C.grey) + px(x + 4, 56, 2, 2, C.grey), trace(D, t, 2.2), ' opacity="0"')
203 }
204 for (let x = X1 + 48; x >= X0 + 56; x -= 16) { // влево: задняя нога — rel 48
205 const t = when(tt => xAt(tt, 7.8, 9.7, X1, X0) + 48 <= x - 8, 7.8, 9.7)
206 if (t > 0) prints += g(px(x, 55, 2, 2, C.grey) + px(x + 4, 56, 2, 2, C.grey), trace(D, t, 2.2), ' opacity="0"')
207 }
208
209 // монетка под лупой: проявляется, блестит, прыгает в шляпу
210 const cx = X1 + 76, cy = 48
211 const coin = g(
212 px(cx + 2, cy, 4, 2, C.yellow) + px(cx, cy + 2, 8, 2, C.yellow) + px(cx + 2, cy + 4, 4, 2, C.orangeDark) +
213 px(cx, cy + 4, 2, 2, C.orangeDark) + px(cx + 6, cy + 4, 2, 2, C.orangeDark) + px(cx + 2, cy + 2, 2, 2, C.white),
214 mvT(D, [[0, '0 0'], [7.35, '0 0'], [7.5, '-12 -30', EASE_OUT], [7.66, '-38 -40'], [7.72, '-44 -36', EASE_IN], [7.74, '0 0'], [10, '0 0']]) +
215 fadeLin(D, [[0, '0'], [5.6, '0'], [5.9, '1'], [7.7, '1'], [7.72, '0'], [10, '0']]), ' opacity="0"')
216 const sparkle = (x: number, y: number, ph: number) => shown(
217 g(px(x, y - 2, 2, 6, C.yellow) + px(x - 2, y, 6, 2, C.yellow) + px(x, y, 2, 2, C.white),
218 fade('1;0;1', '0.5s', { calc: 'discrete', keyTimes: `0;${ph};${ph + 0.3}` })), D, [[5.95, 7.35]])
219
220 return prints + dust(D, X1 + 54, 52, 3.4) + dust(D, X0 + 10, 52, 9.7) + actor + coin +
221 sparkle(cx - 4, 44, 0.1) + sparkle(cx + 10, 48, 0.5)
222}
223
224// сжатие/растяжение (вокруг ступней): стоп, склон к полу, упреждение и пружина на «!», ловит монетку, стоп
225const SQ_SEARCH: KF[] = [
226 [0, '1 1'], [3.4, '1 1'], [3.5, '1.06 0.94', EASE_OUT], [3.65, '0.98 1'], [3.8, '1 1'],
227 [5.2, '1 1'], [5.6, '1.08 0.88'], [6.35, '1.08 0.88'], [6.5, '1.13 0.82'], [6.62, '0.95 1', EASE_OUT], [6.75, '1.03 0.98'], [6.9, '1 1'],
228 [7.7, '1 1'], [7.78, '1.04 0.96'], [7.9, '1 1'],
229 [9.7, '1 1'], [9.8, '1.05 0.95', EASE_OUT], [9.92, '1 1'], [10, '1 1'],
230]
231
232// кубическая кривая Безье для расчёта моментов (как keySplines): x1,y1,x2,y2 → f(u)
233function bezier(x1: number, y1: number, x2: number, y2: number) {
234 const b = (a1: number, a2: number, s: number) => 3 * a1 * s * (1 - s) ** 2 + 3 * a2 * s * s * (1 - s) + s ** 3
235 return (u: number) => {
236 if (u <= 0) return 0
237 if (u >= 1) return 1
238 let lo = 0, hi = 1
239 for (let i = 0; i < 30; i++) {
240 const m = (lo + hi) / 2
241 if (b(x1, x2, m) < u) lo = m
242 else hi = m
243 }
244 return b(y1, y2, (lo + hi) / 2)
245 }
246}
247
248// ── 2. В интернете: глобус, Wi-Fi, пакеты, браузер (8 с) ─────────────────────
249// t=0–0.55 набирает адрес (стучит по окну), 0.6–1.4 запрос улетает к глобусу, 1.4 глобус вспыхивает, Wi-Fi частит,
250// 1.75–5.05 пять пакетов летят по дуге к окну, глаза за ними вверх-вниз, полоса загрузки растёт с каждым,
251// 5.15 загрузилось: прыжок со сжатием, рука вверх, «✓», глаза ^ ^; 5.6–7.85 страница проявляется, Клод читает.
252export function sceneWeb(gear: Gear): string {
253 const D = 8
254 const X = 40
255 const GX = 296, GY = 14 // глобус 32×32 px (16×16 точек по 2 px)
256
257 // ── глобус ──
258 const R = 8
259 const disk: string[] = []
260 for (let j = 0; j < 16; j++) {
261 let row = ''
262 for (let i = 0; i < 16; i++) row += (i + 0.5 - R) ** 2 + (j + 0.5 - R) ** 2 <= (R - 0.2) ** 2 ? '#' : ' '
263 disk.push(row)
264 }
265 const clip = `<clipPath id="cw-web-globe">${pix(GX, GY, disk, { '#': '#000' })}</clipPath>`
266 const land = [
267 ' ',
268 ' ## # ',
269 ' #### #### ',
270 ' ###### ###### ## ',
271 ' ####### ####### #### ',
272 ' ###### ##### ##### ',
273 ' #### ### ### ',
274 ' ### #### # ',
275 ' ## ###### ## ',
276 ' ## ##### ### ',
277 ' ### ### ## ',
278 ' ## ## ',
279 ' # # ',
280 ' ## ',
281 ' #### ',
282 ' ',
283 ]
284 const inDisk = (i: number, j: number) => disk[j]?.[i] === '#'
285 const rim = Array.from({ length: 18 }, (_, b) => Array.from({ length: 18 }, (_, a) => {
286 const i = a - 1, j = b - 1
287 return !inDisk(i, j) && (inDisk(i - 1, j) || inDisk(i + 1, j) || inDisk(i, j - 1) || inDisk(i, j + 1)) ? '#' : ' '
288 }).join(''))
289 const landRows = land.map(s => s + s) // две копии подряд — прокрутка без шва
290 const shade: string[] = disk.map((row, j) => [...row].map((c, i) =>
291 c === '#' && (i + 0.5 - 6.5) ** 2 + (j + 0.5 - 6.5) ** 2 > 8.2 ** 2 ? 's' : ' ').join(''))
292 const scroll = Array.from({ length: 32 }, (_, k) => `${-2 * k} 0`).join(';')
293 const globe = clip + `<g clip-path="url(#cw-web-globe)">` +
294 px(GX, GY, 32, 32, C.blue) +
295 g(pix(GX, GY, landRows, { '#': C.green }), move(scroll, `${D}s`, { calc: 'discrete' })) +
296 g(pix(GX, GY, shade, { s: C.ink }), '', ' opacity="0.28"') +
297 `</g>` + px(GX + 8, GY + 6, 4, 2, C.white) + px(GX + 6, GY + 8, 2, 2, C.white) +
298 // вспышка приёма: белый ободок на точку снаружи круга (сам глобус не бледнеет)
299 g(pix(GX - 2, GY - 2, rim, { '#': C.white }), fadeLin(D, [[0, '0'], [1.38, '0'], [1.45, '1'], [1.9, '0'], [D, '0']]), ' opacity="0"')
300 const stand = px(GX + 14, 46, 4, 4, C.wood) + px(GX + 8, 50, 16, 4, C.wood) + px(GX + 8, 50, 16, 2, C.paperEdge)
301
302 // ── Wi-Fi над глобусом: точка и три дуги, частят во время обмена ──
303 const WX = GX + 4 // 24×12 px, центр над глобусом
304 const arcs = [
305 [' #### ', ' # # '],
306 [' ######## ', ' # # '],
307 ['############', '# #'],
308 ]
309 const pulseAt = [0.2, 1.45, 1.95, 2.45, 2.95, 3.45, 3.95, 4.45, 4.95, 6.2, 7.2]
310 const wifi = px(WX + 10, 10, 4, 2, C.blue) + arcs.map((rows, k) => {
311 const y = 6 - k * 3
312 const f: KF[] = [[0, '0.4']]
313 for (const p0 of pulseAt) {
314 const t = p0 + (2 - k) * 0.1 // от внутренней дуги к внешней
315 f.push([t, '0.4'], [t + 0.08, '1'], [t + 0.4, '0.4'])
316 }
317 f.push([D, '0.4'])
318 return g(pix(WX, y, rows, { '#': C.blue }), fadeLin(D, f), ' opacity="0.4"')
319 }).join('')
320
321 // ── пакеты данных по дуге ──
322 const path: [number, number][] = [[298, 24], [262, 8], [222, 2], [182, 4], [150, 14], [134, 24]]
323 const lens: number[] = [0]
324 for (let i = 1; i < path.length; i++) lens.push(lens[i - 1]! + Math.hypot(path[i]![0] - path[i - 1]![0], path[i]![1] - path[i - 1]![1]))
325 const total = lens[lens.length - 1]!
326 const flight = (t0: number, fl: number, back: boolean): KF[] => {
327 const pts = back ? [...path].reverse() : path
328 const ls = back ? lens.map(l => total - l).reverse() : lens
329 const f: KF[] = [[0, `${pts[0]![0]} ${pts[0]![1]}`]]
330 pts.forEach((p, i) => f.push([t0 + (fl * ls[i]!) / total, `${p[0]} ${p[1]}`]))
331 f.push([D, `${pts[pts.length - 1]![0]} ${pts[pts.length - 1]![1]}`])
332 return f
333 }
334 const packet = (t0: number, fl: number, col: string, back = false) => g(
335 px(0, 0, 6, 6, col) + px(back ? -4 : 8, 2, 2, 2, col) + px(0, 0, 2, 2, C.white),
336 move(vals(flight(t0, fl, back)), `${D}s`, { calc: 'linear', keyTimes: kt(D, flight(t0, fl, back)) }) + vis(D, [[t0, t0 + fl]]),
337 ' opacity="0"')
338 const down: [number, number, string][] = [[1.75, 0.95, C.blue], [2.3, 0.85, C.green], [2.85, 0.9, C.purple], [3.5, 0.95, C.red], [4.15, 0.9, C.blue]]
339 const packets = packet(0.6, 0.8, C.orange, true) + down.map(([t0, fl, c]) => packet(t0, fl, c)).join('')
340
341 // ── окно браузера в правой руке (rel px: 64..112 × 0..32) ──
342 const page = 66
343 const skeleton = g(px(70, 12, 24, 4, C.paperEdge) + px(70, 18, 14, 8, C.paperEdge) +
344 px(88, 18, 18, 2, C.paperEdge) + px(88, 22, 14, 2, C.paperEdge) + px(88, 26, 16, 2, C.paperEdge),
345 fade('0.45;1;0.45', '1s'))
346 const wipe = (x: number, y: number, w: number, h: number, col: string, t: number) =>
347 px(x, y, 0, h, col, attrT('width', D, [[0, '0'], [t, '0'], [t + 0.25, String(w), EASE_OUT], [7.85, String(w)], [7.9, '0'], [D, '0']]))
348 const content =
349 wipe(70, 12, 24, 4, C.orange, 5.6) + wipe(70, 18, 14, 8, C.sky, 5.75) +
350 shown(px(72, 22, 4, 4, C.green) + px(76, 24, 4, 2, C.green) + px(80, 20, 2, 2, C.yellow), D, [[5.95, 7.85]]) +
351 wipe(88, 18, 18, 2, C.ink2, 5.85) + wipe(88, 22, 14, 2, C.ink2, 5.95) + wipe(88, 26, 16, 2, C.ink2, 6.05)
352 const arrive = down.map(([t0, fl]) => t0 + fl)
353 const bar = shown(px(page, 8, 44, 2, C.paperEdge) + px(page, 8, 0, 2, C.blue, attrT('width', D, [
354 [0, '0'], [1.75, '0'], [2.0, '4', EASE_OUT], [arrive[0]!, '4'], [arrive[0]! + 0.15, '8', EASE_OUT],
355 [arrive[1]!, '8'], [arrive[1]! + 0.15, '18', EASE_OUT], [arrive[2]!, '18'], [arrive[2]! + 0.15, '26', EASE_OUT],
356 [arrive[3]!, '26'], [arrive[3]! + 0.15, '36', EASE_OUT], [arrive[4]!, '36'], [arrive[4]! + 0.15, '44', EASE_OUT],
357 [6.0, '44'], [6.05, '0'], [D, '0'],
358 ])), D, [[1.75, 5.6]])
359 const url = px(82, 4, 26, 2, C.edge) + px(82, 4, 0, 2, C.white, attrD('width', D, [
360 [0, '0'], [0.08, '4'], [0.16, '8'], [0.24, '10'], [0.34, '14'], [0.42, '18'], [0.5, '22'], [7.9, '0'],
361 ]))
362 const win = g(
363 r(14, 3, 2, 2, C.orange) +
364 px(64, 0, 48, 32, C.edge) + px(66, 2, 44, 6, C.ink2) +
365 px(68, 4, 2, 2, C.red) + px(72, 4, 2, 2, C.yellow) + px(76, 4, 2, 2, C.green) + url +
366 px(page, 8, 44, 22, C.white) + skeleton + content + bar,
367 mvD(D, [[0, '0 0'], [0.1, '0 2'], [0.2, '0 0'], [0.3, '0 2'], [0.4, '0 0'], [0.5, '0 2'], [0.6, '0 0'],
368 ...arrive.flatMap((a): KF[] => [[a, '0 2'], [a + 0.1, '0 0']])]))
369
370 // ── Клод ──
371 const upIv: Iv[] = [[0.85, 1.2], ...down.map(([t0, fl]): Iv => [t0 + 0.2 * fl, t0 + 0.6 * fl])]
372 // взгляд: вверх-вправо за пакетами; при чтении — бегает по строкам (вправо / вправо-вниз)
373 const face = clawd({ eyes: 'right', arms: 'none', legs: 'none', gear }) + eyesOver(gear, D, [
374 ['upRight', upIv],
375 ['happy', [[5.25, 6.3]]],
376 ['downRight', [[6.45, 6.7], [6.95, 7.25], [7.45, 7.6]]],
377 ['closed', [[7.62, 7.74]]],
378 ])
379 const armUp = r(0, 0, 2, 2, C.orange) + r(0, -1, 1, 1, C.orange)
380 const leftArm = shown(r(0, 3, 2, 2, C.orange), D, complement(D, [[5.3, 6.3]])) +
381 shown(g(armUp, move('0 0;0 2', '0.34s', { calc: 'discrete' })), D, [[5.3, 6.3]])
382 // кивает на каждый принятый пакет, затем прыжок со сжатием
383 const nods = arrive.slice(0, 4).flatMap((a): KF[] => [[a, '1 1'], [a + 0.08, '1.04 0.96', EASE_OUT], [a + 0.22, '1 1']])
384 const sq: KF[] = [[0, '1 1'], ...nods, [5.15, '1 1'], [5.3, '1.1 0.88'], [5.42, '0.94 1.06', EASE_OUT], [5.55, '1 1'], [5.68, '1 1'],
385 [5.76, '1.12 0.86', EASE_IN], [5.92, '0.98 1.02'], [6.05, '1 1'],
386 [7.0, '1 1'], [7.08, '1.04 0.95', EASE_OUT], [7.22, '1 1'], [D, '1 1']] // кивок «понял» на чтении
387 const sprite = squash(legsStand() + face + leftArm + win, vals(sq), `${D}s`, sp(D, sq))
388 const actor = at(X, TOP, sprite, mvT(D, [[0, '0 0'], [5.42, '0 0'], [5.56, '0 -8', EASE_OUT], [5.72, '0 0', EASE_IN], [D, '0 0']]))
389
390 const check = popGlyph('✓', 166, 9, C.green, 2, D, 5.2, 6.3)
391 return wifi + stand + globe + packets + actor + check
392}
393
394// ── 3. Спрашивает: табличка «?» и ждёт ответа (6 с) ──────────────────────────
395// t=0–0.5 смотрит на зрителя, 0.5–1.6 косится влево-вправо, 1.6–2.4 закатывает глаза и стучит ножкой,
396// 2.6–3.5 «эй!»: машет второй рукой, глаза широкие, табличку трясёт, «!»; 3.7–4.4 качает табличкой вверх-вниз (^ ^),
397// 4.6–5.4 снова постукивает ножкой; всё время над головой «…» — ждёт ответа.
398export function sceneAsk(gear: Gear): string {
399 const D = 6
400 const X = 48
401
402 // табличка на палочке в правой руке (rel px 60..88 × −12..20). Наклон без поворота (поворот рвёт пиксели):
403 // доска из двух полос, верхняя уходит вбок на 4 px, нижняя на 2, хвост палки под кулаком — на 2 px в другую сторону,
404 // кулак неподвижен — читается как наклон вокруг кулака.
405 const Q = { top: [' ### ', '# #', ' # '], bot: [' # ', ' ', ' # '] }
406 const qMark = (rows: string[], y: number, col: string) => pix(64, y, rows, { '#': col }, 4)
407 const red: Iv[] = [[2.6, 3.5]]
408 const boardTop = px(60, -12, 28, 16, C.wood) + px(62, -10, 24, 14, C.paper) +
409 qMark(Q.top, -8, C.blue) + shown(qMark(Q.top, -8, C.red), D, red)
410 const boardBot = px(60, 4, 28, 16, C.wood) + px(62, 4, 24, 12, C.paper) + px(62, 16, 24, 2, C.paperEdge) +
411 qMark(Q.bot, 4, C.blue) + shown(qMark(Q.bot, 4, C.red), D, red)
412 const stick = px(72, 20, 4, 16, C.wood)
413 const hand = r(14, 4, 2, 2, C.orange) + r(16, 5, 3, 2, C.orange)
414 // наклон k: −1 влево, 0 прямо, 1 вправо (дискретные кадры)
415 const lean: [number, number][] = [[0, 0], [0.4, 1], [0.8, 0], [1.2, -1], [1.6, 0], [2.0, 1], [2.4, 0],
416 [2.65, -1], [2.75, 1], [2.85, -1], [2.95, 1], [3.05, -1], [3.15, 1], [3.25, 0],
417 [4.6, 1], [5.0, 0], [5.4, -1], [5.8, 0], [D, 0]]
418 const leanBy = (m: number) => mvD(D, lean.map(([t, k]): KF => [t, `${k * m} 0`]))
419 const sign = g(g(stick, leanBy(-2)) + g(boardBot, leanBy(2)) + g(boardTop, leanBy(4)) + hand,
420 mvT(D, [[0, '0 0'], [3.7, '0 0'], [3.85, '0 4', EASE_OUT], [4.0, '0 0'], [4.15, '0 4', EASE_OUT], [4.32, '0 0'], [D, '0 0']]))
421
422 // ноги: три стоят, правая постукивает (вверх на 4 px)
423 const tapUp: Iv[] = [[1.65, 1.8], [1.95, 2.1], [2.25, 2.4], [4.65, 4.8], [4.95, 5.1], [5.25, 5.4]]
424 const tapF: KF[] = [[0, '0 0']]
425 for (const [a, b] of tapUp) tapF.push([a, '0 -4'], [b, '0 0'])
426 const legs = [3, 6, 9].map(x => r(x, 7, 1, 3, C.orange)).join('') + g(r(12, 7, 1, 3, C.orange), mvD(D, tapF))
427
428 // левая рука: опущена / машет «эй!»
429 // машет: плечо поднято, кисть качается влево-вправо
430 const wave = r(0, 1, 2, 2, C.orange) +
431 g(r(-1, -1, 2, 2, C.orange), fade('1;0', '0.3s', { calc: 'discrete' })) +
432 g(r(1, -1, 2, 2, C.orange), fade('0;1', '0.3s', { calc: 'discrete' }), ' opacity="0"')
433 const leftArm = shown(r(0, 3, 2, 2, C.orange), D, complement(D, [[2.6, 3.5]])) + shown(wave, D, [[2.6, 3.5]])
434
435 const face = clawd({ eyes: 'open', arms: 'none', legs: 'none', gear }) + eyesOver(gear, D, [
436 ['left', [[0.5, 1.05]]],
437 ['right', [[1.05, 1.6]]],
438 ['up', [[1.6, 2.45]]],
439 ['wide', [[2.6, 3.5]]],
440 ['happy', [[3.65, 4.45]]],
441 ['closed', [[5.5, 5.62]]],
442 ])
443 const sq: KF[] = [[0, '1 1'], [2.5, '1 1'], [2.6, '1.07 0.93', EASE_OUT], [2.75, '0.97 1.03'], [2.9, '1 1'],
444 [3.7, '1 1'], [3.85, '1.03 0.97'], [4.0, '1 1'], [4.15, '1.03 0.97'], [4.32, '1 1'], [D, '1 1']]
445 // дыхание: лёгкое сжатие-вытягивание с периодом 2 с (в 6 с ровно три вдоха — без шва)
446 const breathe: KF[] = [[0, '1 1'], [1, '1.03 0.97'], [2, '1 1']]
447 const sprite = squash(squash(legs + face + leftArm + sign, vals(sq), `${D}s`, sp(D, sq)), vals(breathe), '2s', sp(2, breathe))
448 const actor = at(X, TOP, sprite)
449
450 // пузырь «…» над головой: точки-клетки по очереди подпрыгивают и загораются
451 const bx = X + 2
452 const bubble = pix(bx, 0, [
453 ' ################ ',
454 '#wwwwwwwwwwwwwwww#',
455 '#wwwwwwwwwwwwwwww#',
456 '#wwwwwwwwwwwwwwww#',
457 '#wwwwwwwwwwwwwwww#',
458 ' ################ ',
459 ' #w# ',
460 ], { '#': C.edge, w: C.white })
461 const dots = [0, 1, 2].map(i => g(px(bx + 6 + i * 10, 4, 4, 4, C.ink2),
462 move('0 0;0 -2;0 0;0 0', '1.2s', { calc: 'discrete', keyTimes: '0;0.15;0.3;1', begin: `${i * 0.2}s` }) +
463 fade('0.55;1;0.55;0.55', '1.2s', { calc: 'discrete', keyTimes: '0;0.15;0.45;1', begin: `${i * 0.2}s` }), ' opacity="0.55"')).join('')
464
465 const hey = popGlyph('!', X - 12, 8, C.red, 2, D, 2.65, 3.4)
466 return actor + bubble + dots + hey
467}
468hooks/clawd/scenes_memory.ts 454 lines1// Memory scenes: Clawd at the memory card cabinet — recalls (pulls out a card) and memorizes (writes a new one and
2// files it in a drawer). Each function returns the strip's content (without <svg>), see core.ts.
3// Both are "while working" scenes: everything stays at x <= 460 (helpers are drawn on the right). SMIL only.
4import {
5 type Anim, type Eyes, type Gear, C, EASE, EASE_IN, EASE_OUT, TOP,
6 at, attr, clawd, fade, g, glyph, move, px, r, scaleAnim, squash,
7} from './core'
8
9// ── таймлайны ────────────────────────────────────────────────────────────────
10
11type Span = readonly [number, number] // отрезок времени цикла, с
12type Key = readonly [number, string, string?] // [время, значение, сплайн участка, ведущего в этот кадр]
13const LIN = '0 0 1 1'
14const kt = (t: number, dur: number) => +(t / dur).toFixed(3)
15const ev = (v: number) => Math.round(v / 2) * 2 // пиксели частиц и полётов — кратно 2
16
17// видимость по отрезкам цикла: дискретная opacity 1 внутри отрезков, 0 вне их
18function show(inner: string, spans: Span[], dur: number): string {
19 const on = (t: number) => spans.some(([a, b]) => t >= a && t < b)
20 const ts = [...new Set([0, ...spans.flat()])].filter(t => t >= 0 && t < dur).sort((a, b) => a - b)
21 const pts: Array<[number, number]> = []
22 for (const t of ts) {
23 const v = on(t) ? 1 : 0
24 if (pts.length === 0 || pts[pts.length - 1]![1] !== v) pts.push([t, v])
25 }
26 const anim = fade(pts.map(p => p[1]).join(';'), `${dur}s`, { calc: 'discrete', keyTimes: pts.map(p => kt(p[0], dur)).join(';') })
27 return g(inner, anim, on(0) ? '' : ' opacity="0"')
28}
29// дополнение отрезков до цикла
30function rest(spans: Span[], dur: number): Span[] {
31 const out: Span[] = []
32 let t = 0
33 for (const [a, b] of [...spans].sort((x, y) => x[0] - y[0])) {
34 if (a > t) out.push([t, a])
35 t = Math.max(t, b)
36 }
37 if (t < dur) out.push([t, dur])
38 return out
39}
40
41// плавная дорожка: первый кадр — в 0, последний — в dur (= первому значению, цикл бесшовный)
42function kf(keys: Key[], dur: number): { values: string; o: Anim } {
43 const all: Key[] = [...keys]
44 if (all[0]![0] !== 0) all.unshift([0, all[0]![1]])
45 if (all[all.length - 1]![0] !== dur) all.push([dur, all[0]![1]])
46 return {
47 values: all.map(k => k[1]).join(';'),
48 o: { calc: 'spline', keyTimes: all.map(k => kt(k[0], dur)).join(';'), keySplines: all.slice(1).map(k => k[2] ?? EASE).join(';') },
49 }
50}
51const moveK = (keys: Key[], dur: number) => { const k = kf(keys, dur); return move(k.values, `${dur}s`, k.o) }
52const scaleK = (keys: Key[], dur: number) => { const k = kf(keys, dur); return scaleAnim(k.values, `${dur}s`, k.o) }
53const fadeK = (keys: Key[], dur: number) => { const k = kf(keys, dur); return fade(k.values, `${dur}s`, k.o) }
54const attrK = (name: string, keys: Key[], dur: number) => { const k = kf(keys, dur); return attr(name, k.values, `${dur}s`, k.o) }
55// ступенчатый сдвиг: [время, "x y"], значение держится до следующего кадра
56const moveD = (keys: Array<[number, string]>, dur: number) =>
57 move(keys.map(k => k[1]).join(';'), `${dur}s`, { calc: 'discrete', keyTimes: keys.map(k => kt(k[0], dur)).join(';') })
58
59// дискретная дрожь: в отрезке [a, b) каждые step с значение чередуется между v1 и v0, вне — v0
60function jitter(spans: Array<[number, number, number, string]>, v0: string, dur: number): string {
61 const pts: Array<[number, string]> = [[0, v0]]
62 for (const [a, b, step, v1] of spans) {
63 let k = 0
64 for (let t = a; t < b - 1e-6; t += step) pts.push([+t.toFixed(3), k++ % 2 === 0 ? v1 : v0])
65 pts.push([b, v0])
66 }
67 return moveD(pts, dur)
68}
69// полёт по кривой f(s), s ∈ (0, 1]: n линейных кадров от t0 до t1
70function path(t0: number, t1: number, n: number, f: (s: number) => [number, number]): Key[] {
71 const out: Key[] = []
72 for (let k = 1; k <= n; k++) {
73 const [x, y] = f(k / n)
74 out.push([+(t0 + ((t1 - t0) * k) / n).toFixed(3), `${ev(x)} ${ev(y)}`, LIN])
75 }
76 return out
77}
78// масштаб вокруг точки (cx, cy) в px
79const scaleAround = (inner: string, cx: number, cy: number, anim: string) =>
80 `<g transform="translate(${cx},${cy})">${g(`<g transform="translate(${-cx},${-cy})">${inner}</g>`, anim)}</g>`
81// искра: появляется в t0, улетает на (dx, dy) к t1 и гаснет
82function burst(inner: string, t0: number, t1: number, dx: number, dy: number, dur: number): string {
83 // после t1 искра невидима — возврат в 0 0 идёт за кадром; гаснет линейно (без keySplines — короче SVG)
84 const m = move(`0 0;0 0;${dx} ${dy};0 0`, `${dur}s`, { calc: 'spline', keyTimes: `0;${kt(t0, dur)};${kt(t1, dur)};1`, keySplines: `${LIN};${EASE_OUT};${LIN}` })
85 const f = fade('0;0;1;0;0', `${dur}s`, { calc: 'linear', keyTimes: `0;${kt(t0, dur)};${kt(t0 + 0.05, dur)};${kt(t1, dur)};1` })
86 return g(inner, m + f, ' opacity="0"')
87}
88// упаковка готовой сцены: подряд идущие простые <rect>/<path> одного цвета → один <path>
89// (порядок слоёв сохраняется; склеиваются только соседи — картинка та же, SVG короче)
90const TOK = /<rect x="(-?[\d.]+)" y="(-?[\d.]+)" width="([\d.]+)" height="([\d.]+)" fill="([^"]+)"\/>|<path d="([^"]+)" fill="([^"]+)"\/>/y
91function pack(s: string): string {
92 let out = '', i = 0, fill = '', d = ''
93 const flush = () => { if (fill) out += `<path d="${d}" fill="${fill}"/>`; fill = ''; d = '' }
94 while (i < s.length) {
95 TOK.lastIndex = i
96 const m = TOK.exec(s)
97 if (m) {
98 const f = m[5] ?? m[7] ?? ''
99 if (f !== fill) flush()
100 fill = f
101 d += m[6] ?? `M${m[1]} ${m[2]}h${m[3]}v${m[4]}h-${m[3]}z`
102 i = TOK.lastIndex
103 } else {
104 flush()
105 const j = s.indexOf('<', i + 1)
106 const e = j < 0 ? s.length : j
107 out += s.slice(i, e)
108 i = e
109 }
110 }
111 flush()
112 return out
113}
114// взгляд по таймлайну [время начала, глаза]: одна копия clawd() на каждый вид глаз, видна в своих отрезках.
115// Моргание — отдельные короткие отрезки 'closed' (блинк core с периодом 5 с не делит цикл)
116function eyesTrack(seq: Array<[number, Eyes]>, gear: Gear, dur: number, glintOn: Eyes): string {
117 // в очках глаз не видно (стёкла закрывают все виды глаз) — одна копия с бликом
118 if (gear.glasses) return clawd({ eyes: 'open', arms: 'none', blink: false, gear })
119 const spans = new Map<Eyes, Span[]>()
120 seq.forEach(([t, e], i) => {
121 const end = i + 1 < seq.length ? seq[i + 1]![0] : dur
122 const l = spans.get(e) ?? []
123 l.push([t, end])
124 spans.set(e, l)
125 })
126 let s = ''
127 for (const [e, sp] of spans) s += show(clawd({ eyes: e, arms: 'none', blink: false, gear, glint: e === glintOn }), sp, dur)
128 return s
129}
130
131// ── картотека памяти (общая декорация обеих сцен) ─────────────────────────────
132// Шкаф 64×40 px на полу (x 184..248, y 14..54): 2 столбца × 3 ряда ящиков с ярлычком и ручкой,
133// сверху — стопка чистых карточек и розовый пиксельный мозг.
134
135const CX = 184
136const CY = 14
137const CW = 64
138const CH = 40
139const FACE = '#a8734e'
140const FACE_S = '#7a4a30'
141const SLOT = '#3b2416'
142const PINK = '#f2a0bf'
143const PINK_D = '#c45f88'
144const GLOW = '#ffe7a6'
145// левый верх лицевой панели ящика i (0..5: слева направо, сверху вниз)
146const dPos = (i: number): [number, number] => [CX + 4 + (i % 2) * 30, CY + 4 + Math.floor(i / 2) * 12]
147const faceL = (x: number, y: number) => [px(x, y, 26, 10, FACE), px(x, y + 8, 26, 2, FACE_S), px(x + 9, y + 2, 8, 4, C.paper), px(x + 10, y + 6, 6, 2, C.ink2)]
148const face = (x: number, y: number) => faceL(x, y).join('')
149// выдвинутый ящик ближе к зрителю: панель крупнее
150const faceNear = (x: number, y: number) =>
151 px(x - 2, y - 2, 30, 12, FACE) + px(x - 2, y + 8, 30, 2, FACE_S) + px(x + 8, y, 10, 4, C.paper) + px(x + 9, y + 4, 8, 2, C.ink2)
152const BRAIN = [
153 '..PPP.PP..',
154 '.PWPPDPPP.',
155 'PPPDPPPDPP',
156 'PDPPPDPPDP',
157 '.PPDPPDPP.',
158 '..PPP.PPP.',
159]
160const BX = CX + 38 // мозг: 20×12 px на крышке
161const BY = CY - 12
162function brain(pal: Record<string, string>): string {
163 // пиксели не пересекаются: по цветам и горизонтальными отрезками
164 let s = ''
165 for (const ch of ['P', 'D', 'W']) {
166 BRAIN.forEach((row, y) => {
167 for (let x = 0; x < row.length; x++) {
168 if (row[x] !== ch) continue
169 let n = 1
170 while (row[x + n] === ch) n++
171 s += px(BX + x * 2, BY + y * 2, n * 2, 2, pal[ch]!)
172 x += n - 1
173 }
174 })
175 }
176 return s
177}
178// ореол по силуэту мозга: клетки (2 px) на расстоянии ≤ k от мозга; строки выше полосы (y < 0) отбрасываются
179function brainRing(k: number, fill: string): string {
180 const on = (x: number, y: number) => (BRAIN[y]?.[x] ?? '.') !== '.'
181 let s = ''
182 for (let y = -k; y < BRAIN.length + k; y++) {
183 if (BY + y * 2 < 0) continue
184 let run = -1
185 for (let x = -k; x <= 10 + k; x++) {
186 let hit = false
187 for (let yy = y - k; yy <= y + k && !hit; yy++) for (let xx = x - k; xx <= x + k && !hit; xx++) hit = on(xx, yy)
188 if (hit && x < 10 + k && run < 0) run = x
189 if ((!hit || x === 10 + k) && run >= 0) { s += px(BX + run * 2, BY + y * 2, (x - run) * 2, 2, fill); run = -1 }
190 }
191 }
192 return s
193}
194const BRAIN_LIT = { P: '#ff8fc0', D: '#d93f7c', W: C.white }
195// вспышка мозга в отрезке [a, b): under — ореол ПОД мозгом (кольца по силуэту), over — мозг мигает яркой палитрой,
196// искры разлетаются снаружи силуэта
197function brainFlash(a: number, b: number, dur: number): { under: string; over: string } {
198 const under = show(g(brainRing(2, C.yellow), fade('0.45;1;0.45', '0.2s')) + brainRing(1, '#fff3c4'), [[a, b]], dur)
199 const lit = show(brain(BRAIN_LIT), [[a, a + 0.1], [a + 0.18, b]], dur)
200 const sparks = burst(px(BX - 8, BY + 4, 2, 2, C.yellow), a, a + 0.5, -8, -2, dur) + burst(px(BX + 26, BY + 4, 2, 2, C.yellow), a + 0.04, a + 0.55, 8, -2, dur) +
201 burst(px(BX - 6, BY + 10, 2, 2, PINK_D), a + 0.08, a + 0.6, -8, 4, dur) + burst(px(BX + 24, BY + 10, 2, 2, PINK_D), a + 0.1, a + 0.6, 8, 4, dur)
202 return { under, over: lit + sparks }
203}
204// четырёхлучевая искорка 6×6 (не крестик ✗): лучи жёлтые, сердцевина белая
205const star = (x: number, y: number) => px(x + 2, y, 2, 6, C.yellow) + px(x, y + 2, 6, 2, C.yellow) + px(x + 2, y + 2, 2, 2, C.white)
206
207type Drawer = {
208 i: number // активный ящик
209 slide: string // сдвиг панели (выдвигается вниз-вперёд на 8 px)
210 open: Span[] // когда показана «ближняя» панель
211 inside: string // содержимое ящика (видно в щели, когда выдвинут)
212 onClosed?: string // поверх закрытой панели (ярлычок горит и т.п.)
213 onOpen?: string // поверх выдвинутой панели
214 brainFx?: { under: string; over: string }
215 jolt?: string // дрожь всего шкафа (одна и та же анимация на всех слоях)
216}
217// слои шкафа: back — корпус и щель ящика, front — активная панель, top — крышка с мозгом.
218// Между слоями сцена кладёт то, что должно быть за панелью ящика (карточка, ныряющая внутрь)
219function cabinet(d: Drawer, dur: number): { back: string; front: string; top: string } {
220 const [x, y] = dPos(d.i)
221 // панели не пересекаются — слои по цветам, чтобы pack склеил их в 4 пути
222 const fl = [0, 1, 2, 3, 4, 5].filter(j => j !== d.i).map(j => faceL(...dPos(j)))
223 const faces = [0, 1, 2, 3].map(k => fl.map(f => f[k]!).join('')).join('')
224 const rails = show(px(x - 2, y, 2, 8, FACE_S) + px(x + 26, y, 2, 8, FACE_S), d.open, dur)
225 const back = px(CX, CY, CW, CH, C.wood) + px(CX - 2, CY, CW + 4, 2, FACE) + px(CX - 2, CY + 2, CW + 4, 2, FACE_S) +
226 px(CX, CY + CH - 2, CW, 2, FACE_S) + faces + px(x, y, 26, 8, SLOT) + rails + d.inside
227 const front = g(show(face(x, y) + (d.onClosed ?? ''), rest(d.open, dur), dur) + show(faceNear(x, y) + (d.onOpen ?? ''), d.open, dur), d.slide)
228 // стопка чистых карточек и мозг; блик на мозге мерцает (период 2 с делит цикл)
229 // стопка: кромки карточек темнее фона, строчки и цветные закладки — читается на обеих темах
230 const stack = px(CX + 2, CY - 8, 14, 8, C.paperEdge) + px(CX + 4, CY - 6, 10, 2, C.paper) + px(CX + 4, CY - 2, 10, 2, C.paper) +
231 px(CX + 6, CY - 6, 6, 2, C.grey) + px(CX + 6, CY - 2, 4, 2, C.grey) + px(CX + 4, CY - 10, 4, 2, C.blue) + px(CX + 10, CY - 10, 4, 2, C.green)
232 const twinkle = g(px(BX + 4, BY + 2, 2, 2, C.white), fade('1;0.3;1', '2s'))
233 const top = stack + (d.brainFx?.under ?? '') + brain({ P: PINK, D: PINK_D, W: '#ffe0ec' }) + twinkle + (d.brainFx?.over ?? '')
234 const j = d.jolt ?? ''
235 return j ? { back: g(back, j), front: g(front, j), top: g(top, j) } : { back, front, top }
236}
237
238// карточка 20×14: цветная полоса сверху, кромка, строки текста
239function card(x: number, y: number, band: string, lines: boolean, tick = false): string {
240 let s = px(x, y, 20, 14, C.paperEdge) + px(x + 2, y + 2, 16, 10, C.paper) + px(x, y, 20, 2, band)
241 if (lines) s += px(x + 4, y + 4, 10, 2, C.ink2) + px(x + 4, y + 8, tick ? 4 : 8, 2, C.blue)
242 if (tick) s += px(x + 10, y + 8, 2, 2, C.green) + px(x + 12, y + 10, 2, 2, C.green) + px(x + 14, y + 8, 2, 2, C.green) + px(x + 16, y + 6, 2, 2, C.green)
243 return s
244}
245// мягкое свечение вокруг карточки (пульс 0.4 с — делит цикл)
246const cardGlow = (x: number, y: number) => g(px(x - 2, y - 2, 24, 18, GLOW), fade('0.55;1;0.55', '0.4s'))
247
248// ── вспоминает ────────────────────────────────────────────────────────────────
249// Цикл 8 с: «?» и взгляд вверх → выдвигает ящик, перебирает карточки (каждая выглядывает над ящиком, глаза
250// следят) → находка: карточка вылетает, светится, перелетает в поднятую руку → подносит к лицу и читает
251// (взгляд на карточку) → огоньки от карточки бегут в голову, мозг на шкафу вспыхивает → «!» и прыжок →
252// карточка улетает обратно в ящик, ящик задвигается.
253export function sceneRecall(gear: Gear): string {
254 const D = 8
255 const X = 116 // Клод: тело x 124..172, правая рука касается шкафа
256 const [dx, dy] = dPos(0) // верхний левый ящик: 188, 18
257
258 // карточки в ящике (видны верхушки-закладки); перебирает по одной: 0, 1, 3, затем находит 2.
259 // Перебираемая карточка поднимается на 6 px: над ящиком выглядывает белый край с закладкой
260 const bands = [C.blue, C.green, C.yellow, C.purple]
261 const tabX = (k: number) => dx + 2 + 6 * k
262 const tab = (k: number) => px(tabX(k), dy + 2, 4, 8, C.paper) + px(tabX(k), dy + 2, 4, 2, bands[k]!)
263 const flick = (k: number, t: number) => g(tab(k), moveD([[0, '0 0'], [t, '0 -6'], [t + 0.25, '0 0']], D))
264 const found = show(
265 g(show(px(tabX(2) - 2, dy - 2, 8, 8, GLOW), [[3.15, 3.5]], D) + tab(2), moveD([[0, '0 0'], [2.85, '0 -6'], [3.15, '0 -10'], [7.0, '0 0']], D)),
266 [[0, 3.5], [7.0, 8]], D)
267 const inside = flick(0, 1.8) + flick(1, 2.15) + flick(3, 2.5) + found
268
269 const cab = cabinet({
270 i: 0,
271 slide: moveK([[0, '0 0'], [1.4, '0 0'], [1.75, '0 8', EASE_OUT], [7.05, '0 8'], [7.35, '0 0', EASE_IN]], D),
272 open: [[1.55, 7.2]],
273 inside,
274 // находка: ярлычок ящика загорается
275 onOpen: show(px(dx + 8, dy, 10, 4, C.yellow), [[3.15, 4.0]], D),
276 brainFx: brainFlash(5.25, 5.85, D),
277 }, D)
278
279 // Клод: глаза следят за каждой поднятой карточкой; читая, смотрит на карточку у лица (влево)
280 const eyes = eyesTrack([
281 [0, 'open'], [0.15, 'closed'], [0.25, 'open'], [0.4, 'up'], [1.3, 'right'],
282 [2.0, 'open'], [2.15, 'right'], [2.35, 'up'], [2.5, 'right'], [2.7, 'open'], [2.85, 'right'],
283 [3.15, 'wide'], [3.55, 'right'], [3.95, 'up'], [4.2, 'left'], [4.95, 'closed'], [5.2, 'up'],
284 [5.35, 'wide'], [5.55, 'happy'], [6.45, 'up'], [6.65, 'right'], [7.5, 'open'], [7.75, 'closed'], [7.85, 'open'],
285 ], gear, D, 'open')
286 // левая рука: опущена → поднята (ловит) → в два шага опускается, поднося карточку к лицу
287 const armL = show(r(0, 3, 2, 2, C.orange), [[0, 4.35], [4.65, 8]], D)
288 const armLUp = show(r(0, 1, 2, 2, C.orange), [[4.35, 4.55]], D)
289 const armLMid = show(r(0, 2, 2, 2, C.orange), [[4.55, 4.65]], D)
290 const armR = show(r(14, 3, 2, 2, C.orange), [[0, 1.35], [3.5, 6.95], [7.45, 8]], D)
291 // рука в ящике: тянется к каждой карточке и подталкивает её вверх, потом задвигает ящик
292 const pokes: Array<[number, string]> = [[0, '0 0']]
293 for (const [t, x] of [[1.8, 0], [2.15, 4], [2.5, 12], [2.85, 8]] as const) pokes.push([t - 0.08, `${x} 0`], [t, `${x} -2`], [t + 0.12, `${x} 0`])
294 pokes.push([3.15, '8 -4'], [3.3, '8 0'], [3.5, '0 0'])
295 const reach = show(g(r(14, 2, 5, 2, C.orange), moveD(pokes, D)), [[1.35, 3.5], [6.95, 7.45]], D)
296 // карточка у лица (x 104..124, y 16..30 — на уровне глаз, рука держит её снизу-справа); пока читает — светится
297 const held = show(g(show(cardGlow(0, 0), [[4.45, 5.3]], D) + card(0, 0, C.yellow, true),
298 moveD([[0, '-20 -2'], [4.55, '-16 0'], [4.65, '-12 2']], D)), [[4.45, 6.45]], D)
299 const sq = kf([
300 [0, '1 1'], [1.2, '1 1'], [1.3, '1.06 0.94'], [1.42, '1 1'],
301 [4.45, '1 1'], [4.52, '1.06 0.94'], [4.65, '1 1'],
302 [5.45, '1 1'], [5.55, '1.12 0.88'], [5.66, '0.92 1.1', EASE_OUT], [5.85, '1 1'], [5.98, '1.1 0.9', EASE_IN], [6.12, '1 1'],
303 ], D)
304 const jump = moveK([[0, '0 0'], [5.6, '0 0'], [5.78, '0 -6', EASE_OUT], [5.96, '0 0', EASE_IN]], D)
305 const hero = at(X, TOP, squash(eyes + held + armL + armLUp + armLMid + armR + reach, sq.values, `${D}s`, sq.o), jump)
306
307 // «?» над головой покачивается; «!» выскакивает справа от головы
308 const q = show(g(glyph('?', 144, 2, C.purple), jitter([[0.4, 1.3, 0.2, '0 -2']], '0 0', D)), [[0.4, 1.3]], D)
309 const bang = show(scaleAround(glyph('!', 170, 2, C.orange), 174, 14,
310 scaleK([[0, '1 1'], [5.35, '1 1'], [5.36, '0.4 0.4'], [5.46, '1.2 1.2', EASE_OUT], [5.56, '1 1']], D)), [[5.35, 6.3]], D)
311
312 // карточка из ящика: выскакивает, зависает со свечением и перелетает над головой в левую руку (поверх всего)
313 const HX = 96, HY = 12 // где карточку ловит поднятая рука
314 const out = show(at(HX, HY, cardGlow(0, 0) + card(0, 0, C.yellow, true), moveK([
315 [0, '98 -2'], [3.5, '98 -2'], [3.75, '98 -12', EASE_OUT], [3.85, '98 -10'], [3.95, '98 -12'],
316 ...path(3.95, 4.45, 10, s => [98 * (1 - s), -12 + 12 * s ** 3]),
317 [4.5, '0 0'],
318 ], D)), [[3.5, 4.45]], D)
319 // обратно: от лица вверх, над головой (позади неё) и вниз в ящик — за панель
320 const back = show(at(HX, HY, card(0, 0, C.yellow, true), moveK([
321 [0, '8 4'], [6.45, '8 4'], [6.58, '4 -12', EASE_OUT], [6.85, '98 -12'], [7.0, '98 10', EASE_IN], [7.05, '98 10'],
322 ], D)), [[6.45, 7.0]], D)
323
324 // огоньки бегут от карточки у лица к макушке; на макушке вспышка
325 const dotsAt: Array<[number, number]> = [[110, 10], [118, 6], [126, 4], [134, 4], [142, 6], [148, 10]]
326 const dots = show(dotsAt.map(([x, y], i) =>
327 g(px(x, y, 4, 4, C.yellow) + px(x + 2, y, 2, 2, C.white), fade('0.6;1;0.6', '0.4s', { keyTimes: '0;0.3;1', begin: `${(i * 0.06).toFixed(2)}s` }))).join(''),
328 [[4.7, 5.3]], D)
329 const flash = show(px(148, 6, 2, 8, C.yellow) + px(144, 8, 10, 2, C.yellow) + px(148, 8, 2, 2, C.white), [[5.2, 5.4]], D)
330
331 const sparks =
332 burst(star(178, 2), 3.7, 4.2, -6, 2, D) +
333 burst(px(216, 2, 2, 2, C.yellow), 3.75, 4.2, 8, -2, D) +
334 burst(px(214, 14, 2, 2, C.orange), 3.8, 4.3, 8, 4, D) +
335 burst(px(184, 6, 2, 2, C.yellow), 4.05, 4.45, 4, 8, D) +
336 burst(px(156, 8, 2, 2, C.yellow), 4.2, 4.6, 2, 6, D) +
337 burst(px(130, 12, 2, 2, C.orange), 4.32, 4.7, 0, 6, D) +
338 burst(star(88, 6), 4.45, 4.85, -6, -4, D) +
339 burst(px(94, 26, 2, 2, C.yellow), 4.47, 4.9, -6, 4, D)
340 const dust = burst(px(184, 34, 4, 4, C.edge), 7.35, 7.75, -6, -2, D) + burst(px(214, 34, 4, 4, C.edge), 7.35, 7.75, 6, -2, D)
341
342 return pack(cab.back + back + cab.front + cab.top + hero + q + bang + dots + flash + out + sparks + dust)
343}
344
345// ── запоминает ────────────────────────────────────────────────────────────────
346// Цикл 8 с: держит карточку низко у левого бока и пишет (карандаш в правой руке наклонён к карточке, дрожит,
347// строчки появляются по одной), задумался — постукивает, ставит галочку → кладёт карандаш на макушку →
348// выдвигает ящик, замах карточкой вверх-влево и бросок по дуге за головой — карточка ныряет в ящик, ящик
349// захлопывается (пыль, дрожь шкафа), ярлычок горит зелёным, «+1», мозг светится → прыжок радости, отряхивает
350// руки (хлопки, облачка пыли) → гордо смотрит на шкаф и кивает (ярлычок и мозг отзываются) → новая чистая
351// карточка со стопки прилетает в руку, карандаш с макушки — снова в руку.
352export function sceneMemorize(gear: Gear): string {
353 const D = 8
354 const X = 116
355 const [dx, dy] = dPos(2) // средний левый ящик: 188, 30
356
357 const inside = [0, 1, 3].map(k => {
358 const x = dx + 2 + 6 * k, band = [C.blue, C.purple, C.yellow, C.green][k]!
359 return px(x, dy + 2, 4, 8, C.paper) + px(x, dy + 2, 4, 2, band)
360 }).join('')
361 const jolt = jitter([[3.98, 4.1, 0.04, '2 0']], '0 0', D)
362 const fx = brainFlash(4.05, 4.6, D)
363 const cab = cabinet({
364 i: 2,
365 slide: moveK([[0, '0 0'], [2.75, '0 0'], [3.05, '0 8', EASE_OUT], [3.88, '0 8'], [3.98, '0 0', EASE_IN]], D),
366 open: [[2.88, 3.93]],
367 inside,
368 // ярлычок «записано»: вспышка белым, горит зелёным; пока Клод любуется — ещё два коротких пульса
369 onClosed: show(px(dx + 9, dy + 2, 8, 4, C.white), [[3.98, 4.08], [6.05, 6.12], [6.2, 6.27]], D) +
370 show(px(dx + 9, dy + 2, 8, 4, C.greenLight) + px(dx + 13, dy + 3, 2, 2, C.green), [[4.08, 6.05], [6.12, 6.2], [6.27, 7.7]], D),
371 brainFx: { under: fx.under, over: fx.over + show(brain(BRAIN_LIT), [[6.1, 6.3]], D) },
372 jolt,
373 }, D)
374
375 // Клод
376 const eyes = eyesTrack([
377 [0, 'left'], [1.35, 'up'], [1.8, 'left'], [2.15, 'happy'], [2.6, 'right'], [3.95, 'wide'], [4.15, 'happy'],
378 [5.7, 'right'], [6.0, 'happy'], [6.4, 'right'], [6.55, 'closed'], [6.65, 'right'], [6.8, 'up'], [7.1, 'left'],
379 ], gear, D, 'left')
380 // карточка: низко у левого бока (x 4..24, y 24..38 в px героя) → замах вверх-влево в два шага → бросок;
381 // новая прилетает в поднятую руку и в два шага опускается к боку
382 const CXr = 4, CYr = 24
383 const w1 = attrK('width', [[0, '0'], [0.1, '0'], [0.75, '10', LIN], [3.35, '10'], [3.4, '0', LIN]], D)
384 const w2 = attrK('width', [[0, '0'], [0.85, '0'], [1.3, '4', LIN], [3.35, '4'], [3.4, '0', LIN]], D)
385 const tickAt: Array<[number, number, number]> = [[10, 8, 1.88], [12, 10, 1.95], [14, 8, 2.04], [16, 6, 2.12]]
386 const writing = card(0, 0, C.sky, false) +
387 px(4, 4, 0, 2, C.ink2, w1) + px(4, 8, 0, 2, C.blue, w2) +
388 tickAt.map(([x, y, t]) => show(px(x, y, 2, 2, C.green), [[t, 3.3]], D)).join('')
389 const heldCard = show(g(writing, moveD([[0, `${CXr} ${CYr}`], [3.05, '-14 10'], [3.15, '-20 -2'], [7.35, '-14 10'], [7.45, `${CXr} ${CYr}`]], D)),
390 [[0, 3.3], [7.2, 8]], D)
391 // левая рука держит карточку (низко / на полпути / вверху), в остальное время у бока; отряхивая — хлопки вверх-вниз
392 const armLow = show(r(0, 5, 2, 2, C.orange), [[0, 3.05], [7.45, 8]], D)
393 const armLMid = show(r(0, 3, 2, 2, C.orange), [[3.05, 3.15], [7.35, 7.45]], D)
394 const armLUp = show(r(0, 1, 2, 2, C.orange), [[3.15, 3.35], [7.2, 7.35]], D)
395 const armL = show(g(r(0, 3, 2, 2, C.orange), jitter([[4.85, 5.65, 0.1, '0 -8']], '0 0', D)), [[3.35, 7.2]], D)
396 const armR = show(g(r(14, 3, 2, 2, C.orange), jitter([[4.95, 5.65, 0.1, '0 -8']], '0 0', D)), [[3.98, 7.3]], D)
397 const armRUp = show(r(14, 0, 2, 2, C.orange) + r(15, -1, 1, 1, C.orange), [[2.4, 2.75], [7.3, 7.5]], D)
398
399 // карандаш в правой руке лесенкой вниз-влево: кончик (0,0) на карточке, кисть — правая рука у бока;
400 // ниже глаз и стёкол очков. Когда кончик уходит вправо, рука вытягивается из бока
401 const pencilDiag = px(0, 0, 2, 2, C.ink2) + px(2, -2, 4, 4, '#e3c08f') +
402 px(6, -4, 14, 2, C.yellow) + px(20, -6, 14, 2, C.yellow) + px(6, -2, 14, 2, '#d9962a') + px(20, -4, 14, 2, '#d9962a') +
403 px(30, -8, 26, 8, C.orange)
404 const shake = jitter([[0.1, 0.75, 0.08, '0 -2'], [0.85, 1.3, 0.08, '0 -2'], [1.45, 1.78, 0.12, '0 2']], '0 0', D)
405 const tip = (x: number, y: number) => `${CXr + x} ${CYr + y}` // точка на карточке → px героя
406 const pen = show(g(g(pencilDiag, shake), moveK([
407 [0, tip(4, 4)], [0.1, tip(4, 4)], [0.75, tip(12, 4), LIN], [0.85, tip(4, 8)], [1.3, tip(8, 8), LIN], [1.42, tip(10, 2)], [1.78, tip(10, 2)],
408 [1.86, tip(10, 8)], [1.94, tip(12, 10), LIN], [2.04, tip(14, 8), LIN], [2.12, tip(16, 6), LIN], [2.4, tip(16, 6)], [2.45, tip(4, 4), LIN],
409 ], D)), [[0, 2.4], [7.5, 8]], D)
410 // карандаш на макушке: из поднятой руки ложится на голову и потом возвращается в руку
411 const pencilFlat = px(0, 0, 2, 2, C.edge) + px(2, -2, 4, 4, '#e3c08f') + px(6, -2, 12, 4, C.yellow) + px(6, 0, 12, 2, '#d9962a') + px(18, -2, 4, 4, '#e88a9a')
412 const penTop = show(g(pencilFlat, moveK([
413 [0, '40 -6'], [2.4, '40 -6'], [2.5, '32 -6'], [2.62, '22 -2', EASE_IN], [7.3, '22 -2'], [7.42, '40 -6', EASE_OUT],
414 ], D)), [[2.4, 7.5]], D)
415
416 const sq = kf([
417 [0, '1 1'], [3.1, '1 1'], [3.22, '1.08 0.92'], [3.35, '0.94 1.06', EASE_OUT], [3.55, '1 1'],
418 [4.2, '1 1'], [4.3, '1.1 0.9'], [4.4, '0.92 1.1', EASE_OUT], [4.6, '1 1'], [4.72, '1.08 0.92', EASE_IN], [4.85, '1 1'],
419 [5.85, '1 1'], [5.92, '1.04 0.94'], [6.0, '1 1'], [6.08, '1.04 0.94'], [6.16, '1 1'],
420 [7.2, '1 1'], [7.26, '1.05 0.95'], [7.38, '1 1'],
421 ], D)
422 const jump = moveK([[0, '0 0'], [4.38, '0 0'], [4.55, '0 -6', EASE_OUT], [4.72, '0 0', EASE_IN]], D)
423 const hero = at(X, TOP, squash(eyes + heldCard + armLow + armLMid + armLUp + armL + armR + armRUp + pen + penTop, sq.values, `${D}s`, sq.o), jump)
424 // рука на ручке ящика (поверх панели): выдвигает, держит, захлопывает
425 const reach = show(at(X, TOP, r(14, 4, 5, 2, C.orange)), [[2.75, 3.98]], D)
426
427 // бросок из поднятой руки: дуга позади головы, карточка ныряет в выдвинутый ящик (за панель)
428 const HX = X - 20, HY = TOP - 2 // 96, 12 — карточка в поднятой руке
429 const toss = show(at(HX, HY, card(0, 0, C.sky, true, true), moveK([
430 [0, '0 0'], [3.35, '0 0'], ...path(3.35, 3.8, 8, s => [96 * s, 22 * s - 64 * s * (1 - s)]), [3.85, '96 22'],
431 ], D)), [[3.35, 3.85]], D)
432 // новая чистая карточка со стопки летит позади головы в поднятую руку
433 const take = show(at(HX, HY, card(0, 0, C.sky, false), moveK([
434 [0, '90 -10'], [6.85, '90 -10'], ...path(6.85, 7.2, 7, s => [90 * (1 - s), -10 * (1 - s * s)]), [7.25, '0 0'],
435 ], D)), [[6.85, 7.2]], D)
436
437 // «+1» всплывает над шкафом
438 const plus = g(glyph('+', 202, 0, C.green) + glyph('1', 212, 0, C.green),
439 moveK([[0, '0 2'], [4.05, '0 2'], [4.5, '0 0', EASE_OUT], [5.2, '0 0'], [5.3, '0 2']], D) +
440 fadeK([[0, '0'], [4.05, '0'], [4.15, '1', LIN], [4.85, '1'], [5.15, '0', LIN]], D), ' opacity="0"')
441
442 // искры галочки, пыль от захлопнутого ящика, облачка пыли с рук при хлопках
443 const puff = (x: number, y: number) => px(x, y, 4, 4, C.edge) + px(x + 4, y + 2, 2, 2, C.edge)
444 const sparks =
445 burst(px(146, 46, 2, 2, C.yellow), 2.14, 2.5, 6, -6, D) + burst(px(146, 50, 2, 2, C.greenLight), 2.16, 2.55, 8, 2, D) +
446 burst(px(184, 40, 4, 4, C.edge), 3.98, 4.4, -8, -4, D) + burst(px(182, 46, 2, 2, C.edge), 4.0, 4.4, -8, 2, D) +
447 burst(px(214, 40, 4, 4, C.edge), 3.98, 4.4, 8, -4, D) + burst(px(216, 46, 2, 2, C.edge), 4.0, 4.4, 8, 2, D) +
448 burst(px(186, 30, 2, 2, C.yellow), 3.98, 4.4, -6, -8, D) + burst(px(214, 30, 2, 2, C.yellow), 3.98, 4.4, 6, -8, D) +
449 burst(puff(108, 22), 4.95, 5.4, -6, 10, D) + burst(puff(108, 18), 5.25, 5.7, -8, 12, D) +
450 burst(puff(180, 22), 5.0, 5.45, 4, 10, D) + burst(puff(180, 18), 5.3, 5.75, 6, 12, D)
451
452 return pack(cab.back + toss + take + hero + cab.front + reach + cab.top + plus + sparks)
453}
454hooks/clawd/scenes_story.ts 448 lines1// Clawd scenes, group "story". Each function returns the strip's content (without <svg>), see core.ts.
2// Story scenes: compact (the conversation press, 36 s) and lost connection (plug into the socket, 8 s).
3// All times are absolute seconds of the cycle T; keys are turned into keyTimes 0..1 by the helpers below.
4import {
5 type Gear, type Eyes, C, TOP,
6 r, px, g, glyph, move, spin, scaleAnim, fade, attr, squash, clawd, legsStand, legsWalk,
7} from './core'
8
9// ── помощники таймлайна ──────────────────────────────────────────────────────
10
11const LIN = '0 0 1 1'
12// сплайны короче, чем в core (экономия размера SVG): плавно, разгон-торможение, торможение, разгон
13const E = '.4 0 .6 1', EO = '0 0 .6 1', EI = '.4 0 1 1'
14// число без ведущего нуля: 0.86 → .86
15const nf = (n: number) => String(n).replace(/^(-?)0\./, '$1.')
16// ближайшее чётное (сетка мелочей 2 px)
17const ev = (n: number) => Math.round(n / 2) * 2
18type Seg = [number, number]
19type Key = [number, number[], string?] // [t, значения, сплайн входа в ключ]
20
21// keyTimes с 3 знаками: соседние ключи через 0,01 с сливаются в мгновенный скачок (Chrome это допускает)
22const ft = (t: number, T: number) => nf(Math.round((t / T) * 1000) / 1000)
23
24// дискретные ступени [t, значение] (первая — t=0); одинаковые соседние склеиваются
25function disc(pts: Array<[number, string]>, T: number) {
26 const out: Array<[number, string]> = []
27 for (const p of [...pts].sort((a, b) => a[0] - b[0])) {
28 const last = out[out.length - 1]
29 if (last && last[1] === p[1]) continue
30 if (last && ft(last[0], T) === ft(p[0], T)) { out[out.length - 1] = p; continue }
31 out.push(p)
32 }
33 return { values: out.map(p => p[1]).join(';'), keyTimes: out.map(p => ft(p[0], T)).join(';') }
34}
35const discAnim = (pts: Array<[number, string]>, T: number, mk: (v: string, d: string, o: { calc: 'discrete'; keyTimes: string }) => string) => {
36 const d = disc(pts, T)
37 return mk(d.values, `${T}s`, { calc: 'discrete', keyTimes: d.keyTimes })
38}
39const inside = (segs: Seg[], t: number) => segs.some(([a, b]) => t >= a && t < b)
40// видимость по окнам времени
41function show(inner: string, segs: Seg[], T: number): string {
42 const ts = [0, ...segs.flat()].filter(t => t >= 0 && t < T)
43 const pts = ts.map(t => [t, inside(segs, t) ? '1' : '0'] as [number, string])
44 return g(inner, discAnim(pts, T, fade), inside(segs, 0) ? '' : ' opacity="0"')
45}
46// дополнение окон до [0,T]
47function gaps(segs: Seg[], T: number): Seg[] {
48 const s = [...segs].sort((a, b) => a[0] - b[0])
49 const out: Seg[] = []
50 let cur = 0
51 for (const [a, b] of s) { if (a > cur) out.push([cur, a]); cur = Math.max(cur, b) }
52 if (cur < T) out.push([cur, T])
53 return out
54}
55// плавная дорожка по ключам (calc spline)
56function trk(keys: Key[], T: number, fmt: (v: number[]) => string = v => v.map(nf).join(' ')) {
57 const vals = keys.map(k => fmt(k[1]))
58 return {
59 values: vals.join(';'),
60 keyTimes: keys.map(k => ft(k[0], T)).join(';'),
61 // на участке без изменения значения сплайн не важен — короткий линейный
62 keySplines: keys.slice(1).map((k, i) => vals[i] === vals[i + 1] ? LIN : k[2] ?? E).join(';'),
63 }
64}
65// опции анимации по дорожке: все участки линейные → calc linear без keySplines (короче)
66const opt = (k: ReturnType<typeof trk>) => k.keySplines.split(';').every(s => s === LIN)
67 ? { calc: 'linear' as const, keyTimes: k.keyTimes }
68 : { calc: 'spline' as const, keyTimes: k.keyTimes, keySplines: k.keySplines }
69const moveK = (keys: Key[], T: number) => { const k = trk(keys, T); return move(k.values, `${T}s`, opt(k)) }
70const attrK = (name: string, keys: Key[], T: number) => { const k = trk(keys, T); return attr(name, k.values, `${T}s`, opt(k)) }
71const fadeK = (keys: Key[], T: number) => attrK('opacity', keys, T)
72// поворот по ключам вокруг (cx, cy)
73const spinK = (keys: Key[], cx: number, cy: number, T: number) => { const k = trk(keys, T, v => `${v[0]} ${cx} ${cy}`); return spin(k.values, `${T}s`, opt(k)) }
74// сжатие/растяжение по ступеням [t, 'sx sy'] (плавно между ними)
75const squashK = (inner: string, pts: Array<[number, string]>, T: number) => {
76 const k = trk([...pts].sort((a, b) => a[0] - b[0]).map(([t, v]) => [t, v.split(' ').map(Number)] as Key), T)
77 return squash(inner, k.values, `${T}s`, opt(k))
78}
79// вспышки-выбросы: из from в to за d с в моменты times (вне вспышек — в from, невидимо)
80function burstMove(times: number[], d: number, from: number[], to: number[], T: number, s = EO): string {
81 const keys: Key[] = [[0, from]]
82 for (const t of times) keys.push([t, from, LIN], [t + d, to, s], [t + d + 0.01, from, LIN])
83 keys.push([T, from, LIN])
84 return moveK(keys, T)
85}
86function burstFade(times: number[], d: number, T: number, peak = 1): string {
87 const keys: Key[] = [[0, [0]]]
88 for (const t of times) keys.push([t, [0], LIN], [t + Math.min(0.12, d / 3), [peak], LIN], [t + d, [0], LIN])
89 keys.push([T, [0], LIN])
90 return fadeK(keys, T)
91}
92// веер искр из точки (cx, cy) тремя кольцами-кадрами (пиксели не масштабируются): близко → середина → далеко и чуть вниз;
93// головы dirs жёлтые, хвосты к центру красные; vis(i) — анимация видимости i-го кольца
94const RINGS: Array<[number, number, number]> = [[0.3, 0.12, 0], [0.62, 0.42, 0], [1, 0.76, 4]] // [голова, хвост, провис вниз px]
95function sparkRings(cx: number, cy: number, dirs: Array<[number, number]>, vis: (i: number) => string): string {
96 return `<g transform="translate(${cx},${cy})">` + RINGS.map(([h, t, dy], i) => g(
97 pth(C.red, dirs.map(([x, y]) => [ev(x * t), ev(y * t + dy / 2), 2, 2] as Rc)) + pth(C.yellow, dirs.map(([x, y]) => [ev(x * h), ev(y * h + dy), 2, 2] as Rc)),
98 vis(i), ' opacity="0"')).join('') + '</g>'
99}
100// кадры колец: доли 0..a, a..b, b..c от момента вспышки
101const RING_T = [0, 0.1, 0.22, 0.4]
102const nf2 = (n: number) => nf(Math.round(n * 1000) / 1000)
103// поворот группы зеркалом по таймлайну (1 — вправо, -1 — влево) вокруг середины спрайта
104function facing(inner: string, pts: Array<[number, string]>, T: number): string {
105 return `<g transform="translate(32,0)"><g>${discAnim(pts, T, (v, d, o) => `<animateTransform attributeName="transform" type="scale" values="${v}" dur="${d}" calcMode="discrete" keyTimes="${o.keyTimes}" repeatCount="indefinite"/>`)}<g transform="translate(-32,0)">${inner}</g></g></g>`
106}
107// Клод с меняющимися глазами: копии clawd() по окнам (в очках глаза закрыты стёклами — одна копия)
108function faces(gear: Gear, eyes: Array<[Eyes, Seg[]]>, T: number): string {
109 if (gear.glasses) return clawd({ gear, arms: 'none', legs: 'none' })
110 // базовая копия видна всегда; копии с другими глазами рисуются поверх (их тело закрывает базовые глаза)
111 return clawd({ eyes: 'open', arms: 'none', legs: 'none' }) +
112 eyes.map(([e, segs]) => show(clawd({ eyes: e, arms: 'none', legs: 'none', blink: false }), segs, T)).join('')
113}
114// ноги: стоит / шагает по окнам
115const legsBy = (walk: Seg[], T: number) => show(legsStand(), gaps(walk, T), T) + show(legsWalk(), walk, T)
116const ARMS_DOWN = r(0, 3, 2, 2, C.orange) + r(14, 3, 2, 2, C.orange)
117const ARMS_UP = r(0, 0, 2, 2, C.orange) + r(0, -1, 1, 1, C.orange) + r(14, 0, 2, 2, C.orange) + r(15, -1, 1, 1, C.orange)
118
119// много прямоугольников одного цвета одним <path> (x, y, w, h в px) — экономит размер SVG
120type Rc = [number, number, number, number]
121const pth = (fill: string, rs: Rc[], inner = '') => {
122 const d = rs.map(([x, y, w, h]) => `M${x} ${y}h${w}v${h}h${-w}z`).join('')
123 return inner ? `<path d="${d}" fill="${fill}">${inner}</path>` : `<path d="${d}" fill="${fill}"/>`
124}
125// упаковка готовой сцены: подряд идущие простые <rect>/<path> одного цвета → один <path>
126// (порядок слоёв сохраняется; склеиваются только соседи — картинка та же, SVG короче)
127const TOK = /<rect x="(-?[\d.]+)" y="(-?[\d.]+)" width="([\d.]+)" height="([\d.]+)" fill="([^"]+)"\/>|<path d="([^"]+)" fill="([^"]+)"\/>/y
128function pack(s: string): string {
129 let out = '', i = 0, fill = '', d = ''
130 const flush = () => { if (fill) out += `<path d="${d}" fill="${fill}"/>`; fill = ''; d = '' }
131 while (i < s.length) {
132 TOK.lastIndex = i
133 const m = TOK.exec(s)
134 if (m) {
135 const f = m[5] ?? m[7] ?? ''
136 if (f !== fill) flush()
137 fill = f
138 d += m[6] ?? `M${m[1]} ${m[2]}h${m[3]}v${m[4]}h-${m[3]}z`
139 i = TOK.lastIndex
140 } else {
141 flush()
142 const j = s.indexOf('<', i + 1)
143 const e = j < 0 ? s.length : j
144 out += s.slice(i, e)
145 i = e
146 }
147 }
148 flush()
149 return out
150}
151// кристалл-«саммари» 8×8 px в точке (x, y)
152const crystal = (x: number, y: number) =>
153 pth(C.sky, [[x, y, 6, 6]]) + pth(C.blue, [[x + 6, y, 2, 8], [x, y + 6, 6, 2]]) + pth(C.white, [[x + 2, y + 2, 2, 2]])
154// блёстка-крестик 6×6 px
155const twinkle = (x: number, y: number, fill: string, dur: string, begin: string) =>
156 pth(fill, [[x + 2, y, 2, 6], [x, y + 2, 6, 2]], fade('0;1;0;0', dur, { keyTimes: '0;0.25;0.5;1', begin }))
157// мягкое свечение w×h вокруг кристалла
158const glow = (x: number, y: number, w: number, h: number, dur: string) =>
159 pth(C.sky, [[x, y, w, h]], fade('.2;.55;.2', dur))
160
161// ── компакт: пресс переписки ─────────────────────────────────────────────────
162
163// компакт: сжатие переписки (долгая сценка)
164export function sceneCompact(gear: Gear): string {
165 const T = 36
166 const XB = 336 // у коробки «архив»
167 const XS = 52 // у стопки
168 const XP = 120 // у пресса (правая рука на рычаге)
169 const XW = 108 // отступил от пресса — смотрит
170 const XC = 316 // у конца конвейера
171 const trips = [4.4, 6.8, 9.2, 11.6] // моменты, когда Клод берёт листы
172 const drops = trips.map(p => p + 1.2) // бросает в окно пресса
173 const PULL = 14.2 // дёрнул рычаг
174 const SL1 = 14.7, SL2 = 16.2 // удары плиты (через 1,5 с — кратно циклу 36 с: искры и пар идут периодом 1,5 с)
175 const LET = 16.5 // отпустил рычаг
176 const CUBE_IN = 17.9, CUBE_PICK = 22.6, CUBE_BOX = 25.8, DONE = 26.4, RESET = 33
177 const falls = [0, 1, 2, 3].map(j => 32.0 + 0.7 * j) // пачки по 2 листа сыплются сверху
178 const N = 10 // листов в полной стопке (2 нижних остаются всегда)
179
180 // ─ стопка переписки слева: 10 листов (40 px — выше Клода с каской), видимая высота — окно клипа
181 const OFF = [0, 2, -2, 2, 0, -2, 2, 0, -2, 2]
182 const sheetsArt = (ks: number[]) =>
183 pth(C.white, ks.map(k => [8 + (OFF[k] ?? 0), 50 - 4 * k, 40, 2] as Rc)) +
184 pth(C.paperEdge, ks.map(k => [8 + (OFF[k] ?? 0), 52 - 4 * k, 40, 2] as Rc)) +
185 pth(C.edge, ks.flatMap(k => [[8 + (OFF[k] ?? 0), 50 - 4 * k, 2, 4], [46 + (OFF[k] ?? 0), 50 - 4 * k, 2, 4], [14 + (OFF[k] ?? 0) + (k % 3) * 4, 50 - 4 * k, 12 + (k % 2) * 6, 2]] as Rc[]))
186 const sway = move('0 0;0 0;2 0;0 0;0 0;-2 0', '2.6s', { calc: 'discrete' })
187 const nPts: Array<[number, string]> = [[0, String(54 - 4 * N)]]
188 trips.forEach((p, j) => nPts.push([p, String(54 - 4 * (N - 2 * (j + 1)))]))
189 falls.forEach((f, j) => nPts.push([f + 0.35, String(54 - 4 * (2 + 2 * (j + 1)))]))
190 const stackClip = `<clipPath id="cw-compact-stack"><rect x="0" y="14" width="70" height="44">${discAnim(nPts, T, (v, d, o) => attr('y', v, d, o))}</rect></clipPath>`
191 const stack = stackClip + `<g clip-path="url(#cw-compact-stack)">` +
192 sheetsArt([0, 1, 2, 3, 4]) + g(sheetsArt([5, 6, 7, 8, 9]), sway) + '</g>' +
193 // одна падающая пачка (2 листа) — 4 раза, каждый раз садится на 8 px выше
194 show(g(sheetsArt([0, 1]), moveK([[0, [0, -50]], ...falls.flatMap((f, j) => [[f, [0, -50], LIN], [f + 0.35, [0, -8 * (j + 1)], EI], [f + 0.36, [0, -50], LIN]] as Key[]), [T, [0, -50], LIN]], T)),
195 falls.map(f => [f, f + 0.36] as Seg), T)
196
197 // ─ пресс (x 184..290): станина с окном загрузки слева, стол, плита на штоке, шестерни, манометр, лампа, прогресс, выхлоп
198 const gearArt = (cx: number, cy: number, s: number, keys: Key[]) =>
199 g(pth(C.grey, [[cx - s, cy - s, 2 * s, 2 * s], [cx - 2, cy - s - 2, 4, 2 * s + 4], [cx - s - 2, cy - 2, 2 * s + 4, 4]]) + pth(C.ink2, [[cx - 2, cy - 2, 4, 4]]),
200 spinK(keys, cx, cy, T))
201 const gears =
202 gearArt(194, 8, 4, [[0, [0]], [14.4, [0]], [18.0, [720], EI], [21.8, [900], EO], [21.81, [0], LIN], [T, [0]]]) +
203 gearArt(208, 6, 2, [[0, [0]], [14.4, [0]], [18.0, [-1080], EI], [21.8, [-1350], EO], [21.81, [0], LIN], [T, [0]]])
204 // плита: вверх-упреждение, удар, второй удар, затем уходит высоко (освобождает стол под кристалл) и опускается в покой
205 const ram: Key[] = [[0, [0, 0]], [PULL, [0, 0]], [14.45, [0, -2], EO], [SL1, [0, 10], EI], [15.3, [0, 10]], [15.85, [0, -2]],
206 [SL2, [0, 10], EI], [16.9, [0, 10]], [17.6, [0, -6]], [19.6, [0, -6]], [20.4, [0, 0]], [T, [0, 0]]]
207 const ramArt = g(pth(C.grey, [[218, 12, 8, 14]]) + pth(C.ink2, [[200, 28, 44, 4]]) + pth(C.edge, [[200, 26, 44, 2]]) + pth(C.red, [[204, 30, 36, 2]]), moveK(ram, T))
208 // стол со стопкой заброшенных листов (по одному на рейс) и сплющенный «пласт» после удара
209 const pileY = disc([[0, '42'], ...drops.map((d, k) => [d + 0.34, String(40 - 2 * k)] as [number, string]), [SL1, '42']], T)
210 const pile = `<clipPath id="cw-compact-pile"><rect x="200" y="42" width="44" height="12">${attr('y', pileY.values, `${T}s`, { calc: 'discrete', keyTimes: pileY.keyTimes })}</rect></clipPath>` +
211 `<g clip-path="url(#cw-compact-pile)">${pth(C.white, [[202, 40, 38, 2], [202, 36, 38, 2]])}${pth(C.paperEdge, [[204, 38, 38, 2], [204, 34, 38, 2]])}</g>`
212 // летящий лист: с рук над головой вперёд-вниз и в окно загрузки на уровне стола (под стойкой, не над крышей)
213 const sheet = (inner = '') => g(pth(C.white, [[0, 0, 40, 2]]) + pth(C.paperEdge, [[0, 2, 40, 2]]) + pth(C.edge, [[0, 0, 2, 4], [38, 0, 2, 4], [6, 0, 14, 2]]), inner)
214 // (рейсы через 2,4 с — кратно циклу: полёт один и тот же, периодом 2,4 с, видим только в окна бросков)
215 const flyer = show(sheet(move('130 0;150 16;176 34;202 38;202 38', '2.4s',
216 { calc: 'spline', keyTimes: '0;.05;.1;.142;1', keySplines: `${EO};${LIN};${EO};${LIN}`, begin: `${nf(Math.round((drops[0]! % 2.4) * 100) / 100)}s` })),
217 drops.map(d => [d, d + 0.34] as Seg), T)
218 const slab = show(pth(C.yellow, [[204, 40, 36, 2]]) + pth(C.white, [[210, 40, 6, 2]]), [[SL1, CUBE_IN]], T)
219 // манометр: стрелка вскидывается на ударах
220 const gauge = pth(C.red, [[256, 20, 2, 6]], spinK([[0, [-40]], [14.5, [-40]], [SL1 + 0.1, [55], EO], [15.7, [35]],
221 [SL2 + 0.1, [75], EO], [18.6, [15]], [23, [-30]], [T, [-40]]], 257, 26, T)) + pth(C.ink, [[256, 24, 2, 2]])
222 const lamp = show(pth(C.green, [[266, 20, 8, 8]]) + pth(C.white, [[268, 22, 2, 2]]), [[DONE, RESET]], T)
223 const progW = disc([[0, '0'], [SL1, '8'], [SL2, '16'], [CUBE_IN, '24'], [20, '28'], [CUBE_PICK, '32'], [24.4, '36'], [DONE, '40'], [RESET, '0']], T)
224 const progress =
225 `<rect x="202" y="12" width="0" height="4" fill="${C.green}">${attr('width', progW.values, `${T}s`, { calc: 'discrete', keyTimes: progW.keyTimes })}</rect>`
226 const check = show(glyph('✓', 260, 0, C.green), [[DONE, 27.0], [27.25, 27.8], [28.0, 32.8]], T)
227 // рычаг у левой стойки: вверху / дёрнут вниз (180° вокруг оси — ровно в пиксели), отпущен — пружинит
228 const lever = g(pth(C.grey, [[180, 18, 2, 12]]) + pth(C.red, [[178, 14, 4, 4]]),
229 discAnim([[0, '0 182 30'], [PULL, '180 182 30'], [LET, '-20 182 30'], [LET + 0.1, '12 182 30'], [LET + 0.2, '-6 182 30'], [LET + 0.3, '0 182 30']], T, spin)) +
230 pth(C.ink2, [[180, 28, 4, 4]])
231 // неподвижные части пресса — по одному <path> на цвет (порядок цветов = порядок слоёв)
232 const frame =
233 pth(C.ink2, [[188, 18, 8, 12], [248, 18, 28, 14], [184, 12, 92, 6], [190, 46, 84, 8], [276, 22, 10, 6], [286, 18, 4, 14]]) +
234 pth(C.edge, [[188, 18, 2, 12], [188, 28, 8, 2], [274, 18, 2, 14], [248, 32, 28, 2], [214, 18, 16, 2], [184, 10, 92, 2], [188, 42, 88, 2], [190, 46, 2, 8],
235 [250, 18, 16, 16], [266, 20, 8, 8], [184, 28, 4, 4], [276, 20, 10, 2], [276, 28, 10, 2], [290, 18, 2, 14]]) +
236 pth(C.white, [[252, 20, 12, 10]]) + pth(C.paperEdge, [[252, 30, 12, 2]]) + pth('#151515', [[268, 22, 4, 4], [200, 12, 44, 4]]) +
237 pth(C.grey, [[188, 44, 88, 2]]) +
238 pth(C.yellow, [[194, 50, 8, 2], [218, 50, 8, 2], [242, 50, 8, 2]]) + pth(C.ink, [[206, 50, 8, 2], [230, 50, 8, 2], [254, 50, 8, 2]])
239 // тряска машины на ударах
240 const shakePts: Array<[number, string]> = [[0, '0 0']]
241 for (const s of [SL1, SL2]) ['2 0', '-2 0', '2 0', '-2 0', '0 0'].forEach((v, i) => shakePts.push([s + i * 0.07, v]))
242 const machine = g(gears + ramArt + pile + slab + frame + gauge + lamp + progress + lever, discAnim(shakePts, T, move))
243 // пар из бокового выхлопа: 3 клуба за удар (период 1,5 с), растут и уходят вправо-вверх; белый верх, серый низ — видно на обеих темах
244 const puff = (b: number) => `<g transform="translate(296,24)">` + g(
245 g(pth(C.steam, [[-4, -6, 8, 2], [-6, -4, 2, 4], [4, -4, 2, 4]]) + pth(C.white, [[-4, -4, 8, 4]]) + pth(C.grey, [[-4, 0, 8, 2]]),
246 scaleAnim('.6 .6;1.8 1.8', '1.5s', { begin: `${b}s` })),
247 move('0 0;36 -14', '1.5s', { calc: 'spline', keyTimes: '0;1', keySplines: EO, begin: `${b}s` }) + fade('1;.9;0', '1.5s', { keyTimes: '0;.5;1', begin: `${b}s` }), ' opacity="0"') + '</g>'
248 const steam = g(puff(1.2) + puff(1.7) + puff(2.2),
249 fadeK([[0, [0]], [SL1 - 0.05, [0], LIN], [SL1 + 0.25, [1], LIN], [18.2, [1], LIN], [18.9, [0], LIN], [T, [0], LIN]], T), ' opacity="0"')
250 // искры из-под плиты на ударах: два веера (жёлтые головы, красные хвосты), период 1,5 с совпадает с ударами
251 const spB = `${nf(Math.round((SL1 % 1.5) * 100) / 100)}s`
252 const sparks = show(
253 sparkRings(222, 40, [[-44, 0], [-36, -12], [-26, -22], [-12, -28], [4, -30], [16, -26], [28, -20], [38, -10], [44, 0], [-22, 2], [-18, -14], [24, -8]],
254 i => fade(i ? '0;1;0' : '1;0', '1.5s', { calc: 'discrete', keyTimes: i ? `0;${nf2(RING_T[i]! / 1.5)};${nf2(RING_T[i + 1]! / 1.5)}` : `0;${nf2(RING_T[1]! / 1.5)}`, begin: spB })),
255 [[SL1 - 0.05, 16.8]], T)
256
257 // ─ конвейер (x 290..388) с бегущими полосками ленты и кристаллом
258 const belt: Key[] = [[0, [0, 0]], [18.35, [0, 0]], [21.8, [160, 0], LIN], [23.0, [160, 0]], [23.01, [0, 0], LIN], [T, [0, 0]]]
259 const stripeRs: Rc[] = []
260 for (let x = 116; x < 388; x += 16) stripeRs.push([x, 42, 6, 2])
261 const conveyor =
262 `<clipPath id="cw-compact-belt"><rect x="276" y="42" width="112" height="2"/></clipPath>` +
263 pth(C.grey, [[276, 42, 112, 2], [278, 46, 4, 4], [382, 46, 4, 4]]) + `<g clip-path="url(#cw-compact-belt)">${pth(C.ink2, stripeRs, moveK(belt, T))}</g>` +
264 pth(C.ink2, [[276, 46, 112, 2]]) + pth(C.edge, [[276, 44, 112, 2], [292, 48, 4, 6], [368, 48, 4, 6]])
265 // кристалл появляется на столе (плита уже высоко), подпрыгивает и едет по ленте
266 const cubeKeys: Key[] = [[0, [218, 34]], [CUBE_IN, [218, 34]], [18.05, [218, 28], EO], [18.25, [218, 34], EI], [18.35, [218, 34]],
267 [21.8, [378, 34], LIN], [23.0, [378, 34]], [23.01, [218, 34], LIN], [T, [218, 34]]]
268 const cube = show(g(glow(-2, 0, 12, 8, '0.9s') + crystal(0, 0) + twinkle(-8, -6, C.yellow, '1.1s', '0s') + twinkle(12, -8, C.white, '1.3s', '0.4s'),
269 moveK(cubeKeys, T)), [[CUBE_IN, CUBE_PICK]], T)
270
271 // ─ коробка «архив» (x 396..452): створки открыты, пока Клод кладёт кристалл
272 const FLAPS: Seg = [25.0, 26.6]
273 const boxCube = show(g(crystal(0, 0), moveK([[0, [400, 22]], [CUBE_BOX, [400, 22]], [26.1, [420, 38], EI], [26.11, [400, 22], LIN], [T, [400, 22]]], T)), [[CUBE_BOX, 26.1]], T)
274 const box =
275 show(pth(C.wood, [[396, 22, 4, 12], [448, 22, 4, 12]]), [FLAPS], T) +
276 show(pth(C.wood, [[398, 30, 52, 4]]) + pth('#a8754f', [[398, 30, 52, 2]]), gaps([FLAPS], T), T) +
277 pth(C.wood, [[400, 34, 48, 20]]) + pth('#a8754f', [[400, 34, 48, 2], [420, 36, 8, 18]]) + pth(C.paper, [[406, 40, 10, 8]]) + pth(C.edge, [[408, 42, 6, 2]])
278
279 // ─ Клод: каска, позы рук, глаза, ноги, движение
280 const xKeys: Key[] = [[0, [XB, TOP]], [0.3, [XB, TOP]], [3.3, [XS, TOP]]]
281 const face: Array<[number, string]> = [[0, '-1 1']]
282 const walk: Seg[] = [[0.3, 3.3]]
283 trips.forEach((p, k) => {
284 xKeys.push([p + 0.3, [XS, TOP]], [p + 1.1, [XP, TOP]])
285 face.push([p + 0.2, '1 1'])
286 walk.push([p + 0.3, p + 1.1])
287 if (k < trips.length - 1) {
288 xKeys.push([p + 1.7, [XP, TOP]], [p + 2.3, [XS, TOP]])
289 face.push([p + 1.6, '-1 1'])
290 walk.push([p + 1.7, p + 2.3])
291 }
292 })
293 // отпустил рычаг и отпрыгнул назад, потом к конвейеру, к коробке, прыжок радости
294 xKeys.push([LET + 0.05, [XP, TOP]], [16.75, [114, TOP - 4], EO], [16.95, [XW, TOP], EI],
295 [19.0, [XW, TOP]], [22.0, [XC, TOP]], [24.8, [XC, TOP]], [25.4, [XB, TOP]], [28.8, [XB, TOP]], [29.0, [XB, TOP - 6], EO], [29.3, [XB, TOP], EI],
296 [29.75, [XB, TOP]], [29.85, [XB, TOP - 3], EO], [29.95, [XB, TOP], EI], [T, [XB, TOP]])
297 face.push([31.6, '-1 1'])
298 walk.push([19.0, 22.0], [24.8, 25.4])
299
300 const sq: Array<[number, string]> = [[0, '1 1']]
301 for (const p of trips) sq.push([p - 0.25, '1 1'], [p - 0.05, '1.12 .86'], [p + 0.25, '1 1'])
302 sq.push([13.9, '1 1'], [14.1, '.96 1.04'], [PULL + 0.05, '1.12 .86'], [14.45, '.96 1.04'], [SL1 - 0.02, '1 1'])
303 for (const s of [SL1, SL2]) sq.push([s + 0.08, '1.1 .9'], [s + 0.3, '1 1'])
304 sq.push([LET + 0.05, '1 1'], [16.62, '1.1 .9'], [16.78, '.94 1.06'], [16.98, '1.1 .9'], [17.15, '1 1'],
305 [17.85, '1 1'], [17.95, '.92 1.1'], [18.2, '1 1'],
306 [22.3, '1 1'], [22.55, '1.12 .86'], [22.75, '.95 1.05'], [22.9, '1 1'],
307 [25.4, '1 1'], [25.7, '1.1 .9'], [26.0, '1 1'],
308 [28.6, '1 1'], [28.8, '1.15 .85'], [28.9, '.94 1.06'], [29.0, '1 1'], [29.3, '1.15 .85'], [29.5, '1 1'],
309 [29.75, '.95 1.05'], [29.95, '1.08 .92'], [30.1, '1 1'],
310 [32.1, '1 1'], [32.25, '.94 1.07'], [32.5, '1 1'], [34.2, '1 1'], [34.45, '1.05 .95'], [34.8, '1 1'], [T, '1 1'])
311
312 const carry: Seg[] = trips.map((p, k) => [p - 0.05, drops[k]!] as Seg)
313 const LEVER_UP: Seg = [13.6, PULL], LEVER_DOWN: Seg = [PULL, LET]
314 const HOLD: Seg = [CUBE_PICK, CUBE_BOX], WIPE: Seg = [26.8, 28.4], CHEER: Seg = [28.6, 30.2]
315 const OR = C.orange
316 const arms =
317 show(ARMS_DOWN, gaps([...carry, LEVER_UP, LEVER_DOWN, HOLD, WIPE, CHEER], T), T) +
318 // лист над головой: 4 px толщиной, на 2 px выше каски, ходит в руках; кисти поверх листа
319 show(pth(OR, [[0, 4, 8, 8], [4, -4, 4, 8], [56, 4, 8, 8], [56, -4, 4, 8]]) +
320 g(pth(C.white, [[10, -14, 44, 2]]) + pth(C.paperEdge, [[10, -12, 44, 2]]) + pth(C.edge, [[10, -14, 2, 4], [52, -14, 2, 4], [16, -14, 14, 2], [34, -14, 8, 2]]),
321 move('0 0;2 0;0 0;-2 0', '0.6s', { calc: 'discrete' })) +
322 pth(OR, [[8, -12, 4, 8], [52, -12, 4, 8]]), carry, T) +
323 show(pth(OR, [[0, 12, 8, 8], [56, 8, 8, 8], [60, 4, 4, 4]]), [LEVER_UP], T) +
324 show(pth(OR, [[0, 12, 8, 8], [56, 16, 8, 8], [60, 24, 4, 4]]), [LEVER_DOWN], T) +
325 show(pth(OR, [[0, 12, 8, 8], [56, 12, 8, 8], [64, 16, 8, 4]]) + glow(62, 6, 12, 12, '0.8s') + crystal(64, 8), [HOLD], T) +
326 // вытирает пот: рука у виска ↔ ладонь по лбу над глазами (не шире тела)
327 show(pth(OR, [[0, 12, 8, 8], [56, 4, 8, 8]]) +
328 pth(OR, [[60, -4, 4, 8]], fade('1;0', '0.4s', { calc: 'discrete' })) +
329 pth(C.orangeDark, [[38, 8, 18, 4]], fade('0;1', '0.4s', { calc: 'discrete' })), [WIPE], T) +
330 show(ARMS_UP, [CHEER], T)
331 const hat = pth(C.yellow, [[20, -8, 24, 4], [12, -4, 40, 2]]) + pth('#c98a1c', [[12, -2, 40, 2], [30, -8, 2, 6]]) + pth('#fff3c4', [[24, -8, 4, 2]])
332 const body = faces(gear, [
333 ['right', [[3.3, 4.2], [13.0, 13.9], [15.6, SL2], [17.15, CUBE_IN], [22.0, 22.6], [30.4, 31.4], [31.6, 32.1]]],
334 ['closed', [[PULL, 14.45], [SL1, 15.0], [SL2, LET], [26.8, 28.4], [34.2, 34.8]]],
335 ['wide', [[14.45, SL1], [15.0, 15.6], [CUBE_IN, 18.6], [CUBE_PICK, 23.4], [32.1, 34.2]]],
336 ['happy', [[LET, 17.15], [23.4, 25.0], [25.8, 26.8], [28.4, 30.4]]],
337 ], T)
338 const rig = facing(legsBy(walk, T) + body + hat + arms, face, T)
339 // значок «!» у головы (не зеркалится): кристалл появился, сверху сыплются листы
340 const bang = show(glyph('!', 66, -14, C.red), [[CUBE_IN, 18.6]], T) + show(glyph('!', -10, -14, C.red), [[32.1, 32.9]], T)
341 const clawdAll = g(squashK(rig, sq, T) + bang, moveK(xKeys, T))
342
343 // эмоции поверх: ♥ над кристаллом, ♪ на прыжке, капля пота при вытирании и при новой стопке
344 const heart = show(g(glyph('♥', 388, 2, C.red), move('0 2;0 0', '.5s', { calc: 'discrete' })), [[23.6, 24.9]], T)
345 const note = show(g(glyph('♪', 316, 2, C.purple), move('0 0;-2 -2', '.4s', { calc: 'discrete' })), [[28.9, 29.9]], T)
346 const sweat = show(pth(C.sky, [[398, 16, 2, 4], [400, 18, 2, 2]], move('0 0;4 4;8 10;12 18', '.6s', { calc: 'discrete' })), [[27.3, 27.9]], T) +
347 show(pth(C.sky, [[340, 12, 2, 4], [338, 14, 2, 2]], move('0 0;0 2;0 4;0 4', '0.9s', { calc: 'discrete' })), [[32.5, 35.4]], T)
348
349 return pack(stack + machine + conveyor + cube + boxCube + box + clawdAll + flyer + heart + note + sweat + steam + sparks + check)
350}
351
352
353// ── разрыв соединения ─────────────────────────────────────────────────────────
354
355// пиксельная иконка Wi-Fi (клетка 2 px), левый верх (x, y)
356function wifi(x: number, y: number, fill: string): string {
357 const rows = [' ##### ', ' # # ', '# ### #', ' # # ', ' ', ' # ']
358 let s = ''
359 rows.forEach((row, yy) => { for (let xx = 0; xx < row.length; xx++) if (row[xx] === '#') s += px(x + xx * 2, y + yy * 2, 2, 2, fill) })
360 return s
361}
362
363// разрыв соединения / повтор запроса
364export function sceneOffline(gear: Gear): string {
365 const T = 8
366 const CX = 124 // где стоит Клод
367 const MISS = 2.2, IN = 3.6, POP = 4.6
368 // Клод: присел за вилкой, прицелился, промах (отскок), вставил, вилка выскочила
369 const xk: Key[] = [[0, [CX]], [1.3, [CX]], [1.6, [120]], [2.0, [120]], [MISS, [124], EI], [2.4, [116], EO], [2.9, [118]],
370 [IN, [124], EI], [POP, [124]], [4.8, [120], EO], [6.4, [120]], [7.4, [CX]], [T, [CX]]]
371 // вилка (левый верх) — пока в руке, x = Клод + 66; поднимается только когда рука её схватила
372 const pk: Key[] = [[0, [176, 44]], [1.15, [176, 44]], [1.6, [186, 38], EO], [2.0, [186, 38]], [MISS, [190, 30], EI], [2.4, [182, 30], EO],
373 [2.9, [184, 38]], [IN, [190, 38], EI], [POP, [190, 38]], [4.85, [188, 10], EO], [5.2, [178, 44], EI], [5.35, [176, 40], EO],
374 [5.5, [176, 44], EI], [T, [176, 44]]]
375 // вилка с кромкой по всем сторонам (видна на тёмной теме), латунные штыри
376 const plug = g(px(0, 0, 8, 10, C.edge) + px(2, 2, 4, 6, C.ink2) + px(8, 2, 4, 2, C.yellow) + px(8, 6, 4, 2, C.yellow), moveK(pk, T))
377 // кабель: по полу до вилки и стояк к ней
378 const ck = (f: (v: number[]) => number) => trk(pk, T, v => String(f(v)))
379 const cW = ck(v => v[0]! - 2), rX = ck(v => v[0]! - 2), rY = ck(v => v[1]! + 4), rH = ck(v => 50 - v[1]!)
380 const cable =
381 `<rect x="0" y="52" width="174" height="2" fill="${C.grey}">${attr('width', cW.values, `${T}s`, opt(cW))}</rect>` +
382 `<rect x="174" y="48" width="2" height="6" fill="${C.grey}">${attr('x', rX.values, `${T}s`, opt(rX))}${attr('y', rY.values, `${T}s`, opt(rY))}${attr('height', rH.values, `${T}s`, opt(rH))}</rect>`
383
384 // роутер с антенной и розеткой (x 196..244)
385 const OK: Seg = [IN + 0.05, POP]
386 const router =
387 px(200, 38, 44, 16, C.ink2) + px(200, 38, 44, 2, C.edge) + px(242, 38, 2, 16, C.edge) +
388 px(214, 46, 4, 2, C.ink) + px(222, 46, 4, 2, C.ink) +
389 g(px(230, 46, 4, 2, C.red), fade('1;0.2;1', '0.6s', { calc: 'discrete' })) +
390 show(px(214, 46, 4, 2, C.greenLight) + px(222, 46, 4, 2, C.greenLight) + px(230, 46, 4, 2, C.greenLight), [OK], T) +
391 // антенна качается, когда вилка выскакивает: три пиксельных кадра (прямо / наклон влево / вправо) ступенькой от основания
392 show(px(236, 20, 2, 18, C.edge) + px(234, 16, 6, 4, C.red), gaps([[POP, POP + 0.48]], T), T) +
393 show(px(236, 32, 2, 6, C.edge) + px(234, 26, 2, 6, C.edge) + px(232, 20, 2, 6, C.edge) + px(228, 16, 6, 4, C.red), [[POP, POP + 0.12], [POP + 0.24, POP + 0.36]], T) +
394 show(px(236, 32, 2, 6, C.edge) + px(238, 26, 2, 6, C.edge) + px(240, 20, 2, 6, C.edge) + px(238, 16, 6, 4, C.red), [[POP + 0.12, POP + 0.24], [POP + 0.36, POP + 0.48]], T) +
395 px(196, 36, 10, 14, C.white) + px(196, 36, 10, 2, C.paperEdge) + px(196, 48, 10, 2, C.paperEdge) + px(196, 36, 2, 14, C.paperEdge) +
396 px(200, 40, 2, 2, C.ink) + px(200, 44, 2, 2, C.ink)
397 const flash = g(px(192, 30, 56, 24, C.green), fadeK([[0, [0]], [IN, [0], LIN], [IN + 0.08, [0.45], LIN], [IN + 0.5, [0], LIN], [T, [0], LIN]], T), ' opacity="0"')
398 // Wi-Fi: серый с мигающим ✗; на миг зелёный с полосками сигнала
399 const bad = gaps([OK], T)
400 const wifiIcon = show(wifi(206, 2, C.grey), bad, T) + show(wifi(206, 2, C.green), [OK], T) +
401 show(g(glyph('✗', 226, 2, C.red), fade('1;1;0', '0.7s', { calc: 'discrete', keyTimes: '0;0.6;0.8' })), bad, T) +
402 [0, 1, 2].map(i => show(px(228 + i * 6, 10 - i * 4, 4, 4 + i * 4, C.green), [[IN + 0.1 + i * 0.15, POP]], T)).join('')
403 // бегущие «…» на полу справа: заметная база, на пике — полная яркость и подскок
404 const dots = [0, 1, 2].map(i => g(px(256 + i * 8, 48, 4, 4, C.grey),
405 fade('.45;1;.45;.45', '1.2s', { keyTimes: '0;.2;.5;1', begin: `${i * 0.2}s` }) + move('0 0;0 -2;0 0;0 0', '1.2s', { calc: 'discrete', keyTimes: '0;.1;.3;1', begin: `${i * 0.2}s` }),
406 ' opacity=".45"')).join('')
407 // искры промаха (веер жёлто-красных с хвостами) и вспышка контакта
408 const sparks =
409 sparkRings(198, 36, [[-20, -12], [-10, -22], [4, -24], [16, -14], [-22, 2], [18, 0], [10, -6]],
410 i => discAnim([[0, '0'], [MISS + RING_T[i]!, '1'], [MISS + RING_T[i + 1]!, '0']], T, fade)) +
411 g(glyph('*', 192, 30, C.yellow), burstFade([MISS], 0.25, T), ' opacity="0"') +
412 ([[196, 38, -8, -8, C.greenLight], [196, 46, -8, 6, C.greenLight]] as const)
413 .map(([x, y, dx, dy, f]) => g(px(x, y, 2, 2, f), burstMove([IN], 0.35, [0, 0], [dx, dy], T) + burstFade([IN], 0.35, T), ' opacity="0"')).join('')
414
415 // Клод
416 const LOW: Seg = [0.8, 1.45], REACH: Seg[] = [[1.45, MISS - 0.15], [2.6, POP]], HIGH: Seg = [MISS - 0.15, 2.6]
417 const arms =
418 show(ARMS_DOWN, gaps([LOW, ...REACH, HIGH], T), T) +
419 show(r(0, 3, 2, 2, C.orange) + r(14, 6, 3, 2, C.orange), [LOW], T) +
420 show(r(0, 3, 2, 2, C.orange) + r(14, 5, 3, 2, C.orange), REACH, T) +
421 show(r(0, 3, 2, 2, C.orange) + r(14, 3, 3, 2, C.orange), [HIGH], T)
422 const body = faces(gear, [
423 ['wide', [[0, 0.6], [POP, 5.4], [7.6, T]]],
424 ['right', [[1.6, MISS], [2.9, IN], [7.2, 7.6]]],
425 ['dizzy', [[MISS, 2.9], [5.4, 6.0]]],
426 ['happy', [[IN + 0.1, POP]]],
427 ['closed', [[6.0, 6.6]]],
428 ['left', [[6.6, 7.2]]],
429 ], T)
430 // присел глубоко за вилкой (рука достаёт до неё), промах — отдача, вставил, выскочила — вздрогнул, вздох — осел
431 const sq: Array<[number, string]> = [[0, '1 1'], [0.6, '1 1'], [0.95, '1.16 .8'], [1.2, '1.16 .8'], [1.45, '.96 1.04'], [1.6, '1 1'],
432 [MISS - 0.05, '.94 1.06'], [2.35, '1.1 .9'], [2.6, '1 1'], [IN - 0.1, '.94 1.07'], [IN + 0.1, '1.06 .94'], [IN + 0.3, '1 1'],
433 [POP, '1 1'], [4.7, '.92 1.08'], [4.9, '1.06 .95'], [5.1, '1 1'], [6.0, '1 1'], [6.2, '1.08 .9'], [6.6, '1.08 .9'], [6.8, '1 1'], [T, '1 1']]
434 const xk2 = trk(xk, T, v => `${v[0]} ${TOP}`)
435 const rig = legsStand() + body + arms
436 // значки над головой: «?» — смотрит на кабель; в очках глаз не видно — промах ✗ и контакт ✓ значками
437 const marks = show(glyph('?', 26, -14, C.purple), [[6.7, 7.4]], T) +
438 (gear.glasses ? show(glyph('✗', 26, -12, C.red), [[MISS, 2.9]], T) + show(glyph('✓', 26, -12, C.green), [[IN + 0.1, POP]], T) : '')
439 const clawdAll = g(squashK(rig, sq, T) + marks, move(xk2.values, `${T}s`, opt(xk2)))
440 // капля пота у виска (не на радостном миге) и «!» при выскакивании
441 const sweat = show(g(px(0, 0, 2, 4, C.sky) + px(-2, 2, 2, 2, C.sky), move('0 0;0 2;0 4;0 6;0 6', '1.1s', { calc: 'discrete' })),
442 gaps([OK], T), T)
443 const sweatAt = g(sweat, moveK(xk.map(k => [k[0], [k[1][0]! + 4, 16], k[2]] as Key), T))
444 const bang = show(glyph('!', 176, 0, C.red), [[POP + 0.05, 5.6]], T)
445
446 return pack(cable + router + flash + clawdAll + plug + sweatAt + wifiIcon + dots + sparks + bang)
447}
448hooks/clawd/scenes_work.ts 362 lines1// Clawd scenes, group "work". Each function returns the strip's content (without <svg>), see core.ts.
2// "While working" scenes stay at x <= 460: helpers(n) are drawn on the right (x 470..710).
3import {
4 type Anim, type Eyes, type Gear, C, EASE, EASE_IN, EASE_OUT, P, TOP,
5 at, attr, clawd, fade, g, glyph, miniClawd, move, px, r, scaleAnim, squash,
6} from './core'
7
8// ── общие помощники сцен ──────────────────────────────────────────────────────
9
10type Span = readonly [number, number] // отрезок времени цикла, с
11const kt = (t: number, dur: number) => +(t / dur).toFixed(4)
12const LIN = '0 0 1 1'
13const FLOOR_SIT = 22 // верх сидящего на полу Клода (тело 7 + ноги 1 клетка), px
14const CH_W_PX = 16 * P
15
16// видимость по отрезкам цикла: дискретная opacity 1 внутри отрезков, 0 вне их
17function show(inner: string, spans: Span[], dur: number): string {
18 const on = (t: number) => spans.some(([a, b]) => t >= a && t < b)
19 const ts = [...new Set([0, ...spans.flat()])].filter(t => t >= 0 && t < dur).sort((a, b) => a - b)
20 const pts: Array<[number, number]> = []
21 for (const t of ts) {
22 const v = on(t) ? 1 : 0
23 if (pts.length === 0 || pts[pts.length - 1]![1] !== v) pts.push([t, v])
24 }
25 const anim = fade(pts.map(p => p[1]).join(';'), `${dur}s`, { calc: 'discrete', keyTimes: pts.map(p => kt(p[0], dur)).join(';') })
26 return g(inner, anim, on(0) ? '' : ' opacity="0"')
27}
28
29// ключевые кадры для spline: [время, значение, сплайн участка, ведущего в этот кадр]
30type Key = readonly [number, string, string?]
31function kf(keys: Key[], dur: number): { values: string; o: Anim } {
32 const all: Key[] = [...keys]
33 if (all[0]![0] !== 0) all.unshift([0, all[0]![1]])
34 if (all[all.length - 1]![0] !== dur) all.push([dur, all[0]![1]])
35 return {
36 values: all.map(k => k[1]).join(';'),
37 o: { calc: 'spline', keyTimes: all.map(k => kt(k[0], dur)).join(';'), keySplines: all.slice(1).map(k => k[2] ?? EASE).join(';') },
38 }
39}
40const moveK = (keys: Key[], dur: number) => { const k = kf(keys, dur); return move(k.values, `${dur}s`, k.o) }
41const scaleK = (keys: Key[], dur: number) => { const k = kf(keys, dur); return scaleAnim(k.values, `${dur}s`, k.o) }
42const fadeK = (keys: Key[], dur: number) => { const k = kf(keys, dur); return fade(k.values, `${dur}s`, k.o) }
43
44// дискретная дрожь: в отрезке [a, b) каждые step с значение чередуется между v1 и v0, вне — v0
45function jitter(spans: Array<[number, number, number, string]>, v0: string, dur: number): string {
46 const pts: Array<[number, string]> = [[0, v0]]
47 for (const [a, b, step, v1] of spans) {
48 let k = 0
49 for (let t = a; t < b - 1e-6; t += step) pts.push([t, k++ % 2 === 0 ? v1 : v0])
50 pts.push([b, v0])
51 }
52 return move(pts.map(p => p[1]).join(';'), `${dur}s`, { calc: 'discrete', keyTimes: pts.map(p => kt(p[0], dur)).join(';') })
53}
54
55// масштаб вокруг точки (cx, cy) в px
56const scaleAround = (inner: string, cx: number, cy: number, anim: string) =>
57 `<g transform="translate(${cx},${cy})">${g(`<g transform="translate(${-cx},${-cy})">${inner}</g>`, anim)}</g>`
58
59// искра-частица: появляется в t0, улетает на (dx, dy) к t1 и гаснет
60function burst(inner: string, t0: number, t1: number, dx: number, dy: number, dur: number): string {
61 const m = moveK([[0, '0 0'], [t0, '0 0'], [t1, `${dx} ${dy}`, EASE_OUT], [Math.min(t1 + 0.05, dur - 0.01), `${dx} ${dy}`], [dur, '0 0']], dur)
62 const f = fadeK([[0, '0'], [t0, '0'], [t0 + 0.06, '1', LIN], [t1, '0', EASE_IN], [dur, '0']], dur)
63 return g(inner, m + f, ' opacity="0"')
64}
65
66// пар: пиксели поднимаются и тают, у каждого свой период
67const steam = (x: number, y: number) =>
68 [[0, 1.9, 0], [2, 2.3, 0.7], [0, 2.7, 1.4]]
69 .map(([dx, d, b]) => g(px(x + dx!, y, 2, 2, C.steam),
70 move('0 0;2 -5;0 -10', `${d}s`, { begin: `${b}s` }) + fade('0;0.9;0', `${d}s`, { keyTimes: '0;0.3;1', begin: `${b}s` }), ' opacity="0"'))
71 .join('')
72
73// слои позы Клода: каждая поза (глаза/руки/ноги) видна в своих отрезках цикла
74type Pose = { eyes: Eyes; arms?: 'down' | 'type' | 'wave' | 'up' | 'none'; legs?: 'stand' | 'walk' | 'sit'; glint?: boolean }
75const poses = (list: Array<[Span[], Pose]>, gear: Gear, dur: number) =>
76 list.map(([spans, o]) => show(clawd({ ...o, gear, glint: o.glint ?? false }), spans, dur)).join('')
77
78// ── думает ────────────────────────────────────────────────────────────────────
79// Цикл 8 с: облачко → шестерёнки → «??» (стучит по подбородку) → эврика: лампочка, прыжок → облачко гаснет.
80export function sceneThink(gear: Gear): string {
81 const D = 8
82 const X = 56, Y = FLOOR_SIT
83 // Клод сидит; глаза по битам
84 const bodies = poses([
85 [[[0, 0.9], [7.75, 8]], { eyes: 'open', arms: 'none', legs: 'sit', glint: true }],
86 [[[7.2, 7.75]], { eyes: 'left', arms: 'none', legs: 'sit' }],
87 [[[0.9, 1.9], [3.4, 4.85]], { eyes: 'up', arms: 'none', legs: 'sit' }],
88 [[[1.9, 2.5]], { eyes: 'left', arms: 'none', legs: 'sit' }],
89 [[[2.5, 3.0]], { eyes: 'right', arms: 'none', legs: 'sit' }],
90 [[[3.0, 3.4]], { eyes: 'closed', arms: 'none', legs: 'sit' }],
91 [[[4.85, 5.9]], { eyes: 'wide', arms: 'none', legs: 'sit' }],
92 [[[5.9, 7.2]], { eyes: 'happy', arms: 'none', legs: 'sit' }],
93 ], gear, D)
94 const armL = r(0, 3, 2, 2, C.orange)
95 // правая рука у подбородка: плечо вбок, предплечье вверх, кулак у щеки (палец — тёмная клетка); в «??» стучит по щеке
96 const tap = jitter([[3.4, 4.85, 0.2, '0 -4']], '0 0', D)
97 const chin = show(r(14, 4, 3, 2, C.orange) + r(14, 5, 3, 1, C.orangeDark) +
98 g(r(16, 1, 1, 4, C.orange) + r(14, 1, 2, 2, C.orange) + r(14, 2, 2, 1, C.orangeDark) + r(13, 2, 1, 1, C.orangeDark), tap),
99 [[0, 4.85], [7.2, 8]], D)
100 // эврика: палец вверх, рука подрагивает
101 const raised = show(g(r(14, 1, 2, 2, C.orange) + r(15, -1, 1, 2, C.orange), move('0 0;0 -2', '0.4s', { calc: 'discrete' })), [[4.85, 7.2]], D)
102 const sq = scaleK([[0, '1 1'], [4.7, '1 1'], [4.9, '1.14 0.86'], [5.05, '0.9 1.12', EASE_OUT], [5.3, '1 1'], [5.5, '1.12 0.88', EASE_IN], [5.7, '1 1'], [7.5, '1 1'], [7.65, '1.08 0.92'], [7.95, '1 1']], D)
103 const jump = moveK([[0, '0 0'], [4.95, '0 0'], [5.22, '0 -8', EASE_OUT], [5.5, '0 0', EASE_IN], [8, '0 0']], D)
104 const hero = at(X, Y, scaleAround(bodies + armL + chin + raised, (CH_W_PX) / 2, 8 * P, sq), jump)
105
106 // пузырьки мыслей от головы к облаку
107 const dot = (x: number, y: number, s: number) => px(x, y, s, s, C.paperEdge) + (s > 4 ? px(x + 2, y + 2, s - 4, s - 4, C.white) : '')
108 const dots = show(dot(118, 16, 2), [[0.15, 7.5]], D) + show(dot(124, 10, 4), [[0.35, 7.5]], D) + show(dot(132, 6, 6), [[0.55, 7.5]], D)
109 // облако: обводка + заливка (силуэт из прямоугольников)
110 const CX = 144
111 const blobs: Array<[number, number, number, number]> = [[4, 6, 52, 18], [12, 2, 16, 8], [28, 0, 18, 10], [0, 10, 8, 10], [52, 10, 8, 10], [10, 20, 20, 6], [34, 20, 14, 6]]
112 const cloud = blobs.map(([x, y, w, h]) => px(CX + x, y, w, h, C.paperEdge)).join('') +
113 blobs.map(([x, y, w, h]) => px(CX + x + 2, y + 2, w - 4, h - 4, C.white)).join('')
114
115 // шестерёнки (большая и малая крутятся навстречу)
116 // поворот пиксельно: два нарисованных кадра (зубцы по осям ↔ по диагоналям) чередуются дискретно, малая — в противофазе
117 const flip = (a: string, b: string, per: string) =>
118 g(a, fade('1;0', per, { calc: 'discrete' })) + g(b, fade('0;1', per, { calc: 'discrete' }), ' opacity="0"')
119 const gearBig = (cx: number, cy: number) => {
120 const t = (x: number, y: number) => px(cx + x, cy + y, 4, 4, C.ink2)
121 return px(cx - 4, cy - 6, 8, 12, C.ink2) + px(cx - 6, cy - 4, 12, 8, C.ink2) +
122 flip(t(-2, -8) + t(-2, 4) + t(-8, -2) + t(4, -2), t(4, -8) + t(-8, -8) + t(-8, 4) + t(4, 4), '0.36s') +
123 px(cx - 2, cy - 2, 4, 4, C.white)
124 }
125 const gearSmall = (cx: number, cy: number) => {
126 const t = (x: number, y: number) => px(cx + x, cy + y, 2, 2, C.grey)
127 return px(cx - 3, cy - 3, 6, 6, C.grey) +
128 flip(t(-4, -5) + t(2, -5) + t(-4, 3) + t(2, 3), t(-1, -5) + t(-1, 3) + t(-5, -1) + t(3, -1), '0.24s') +
129 px(cx - 1, cy - 1, 2, 2, C.white)
130 }
131 const gears = show(gearBig(166, 12) + gearSmall(181, 17), [[1.0, 3.1]], D)
132
133 // «??» мигают по очереди и качаются
134 const q1 = show(g(glyph('?', 162, 5, C.purple), jitter([[3.1, 4.7, 0.25, '2 0']], '0 0', D)), [[3.1, 3.5], [3.65, 4.05], [4.2, 4.7]], D)
135 const q2 = show(glyph('?', 180, 9, C.blue), [[3.4, 3.8], [3.95, 4.35], [4.45, 4.7]], D)
136
137 // лампочка: серая → вспыхивает (мигнула) → горит с ореолом и лучами
138 const bx = 174
139 const glass = (f: string) => px(bx - 4, 3, 8, 2, f) + px(bx - 6, 5, 12, 8, f) + px(bx - 4, 13, 8, 2, f) + px(bx - 2, 15, 4, 2, f)
140 const halo = show(g(px(bx - 10, 5, 20, 8, '#ffe7a6') + px(bx - 8, 1, 16, 16, '#ffe7a6'), fade('0.55;1;0.55', '0.6s')), [[5.16, 7.5]], D)
141 const rays = show(
142 g(px(bx - 14, 8, 4, 2, C.yellow) + px(bx + 10, 8, 4, 2, C.yellow) + px(bx - 12, 18, 2, 2, C.yellow) + px(bx + 10, 18, 2, 2, C.yellow), fade('1;0', '0.4s', { calc: 'discrete' })) +
143 g(px(bx - 12, 2, 2, 2, C.yellow) + px(bx + 10, 2, 2, 2, C.yellow) + px(bx - 14, 13, 4, 2, C.yellow) + px(bx + 10, 13, 4, 2, C.yellow), fade('0;1', '0.4s', { calc: 'discrete' })),
144 [[5.16, 7.0]], D)
145 const bulb = show(
146 halo + rays + glass(C.steam) + px(bx - 1, 8, 2, 5, C.grey) +
147 show(glass(C.yellow) + px(bx - 4, 6, 2, 4, C.white) + px(bx - 1, 8, 2, 5, C.white), [[5.0, 5.08], [5.16, 7.5]], D) +
148 px(bx - 3, 17, 6, 2, C.grey) + px(bx - 2, 19, 4, 2, C.ink2),
149 [[4.7, 7.5]], D)
150 // искры «*» и «+» вылетают из облака
151 const sparks =
152 burst(glyph('*', 196, 0, C.yellow), 5.15, 6.1, 14, 2, D) +
153 burst(glyph('+', 200, 14, C.orange), 5.25, 6.2, 14, 8, D) +
154 burst(glyph('*', 150, 18, C.yellow), 5.3, 6.3, -2, 14, D) +
155 burst(px(186, 22, 2, 2, C.orange), 5.2, 6.0, 8, 12, D)
156
157 const inside = scaleAround(show(cloud + gears + q1 + q2 + bulb, [[0.6, 7.5]], D), 174, 13,
158 scaleK([[0, '0.3 0.3'], [0.6, '0.3 0.3'], [0.8, '1.1 1.1', EASE_OUT], [0.95, '1 1'], [5.0, '1 1'], [5.1, '1.08 1.08', EASE_OUT], [5.3, '1 1'], [7.0, '1 1'], [7.5, '0.8 0.8', EASE_IN], [8, '0.3 0.3']], D))
159 const fadeOut = fadeK([[0, '1'], [7.0, '1'], [7.5, '0', EASE_IN], [7.9, '0'], [8, '1', LIN]], D)
160 return hero + g(dots + inside, fadeOut) + sparks
161}
162
163// ── работает в терминале ──────────────────────────────────────────────────────
164// Цикл 10 с: печатает (строки уезжают вверх) → зелёная ✓, искры, радость → откинулся, глоток кофе → снова печатает.
165export function sceneWork(gear: Gear): string {
166 const D = 10
167 const X = 40, Y = 18 // сидит на подушке (низ 50)
168 const typing: Span[] = [[0, 2.4], [2.9, 4.2], [8.0, 10]]
169 const bodies = poses([
170 [typing, { eyes: 'right', arms: 'type', legs: 'sit', glint: true }],
171 [[[2.4, 2.9]], { eyes: 'up', arms: 'type', legs: 'sit' }],
172 [[[4.2, 5.2]], { eyes: 'happy', arms: 'up', legs: 'sit' }],
173 [[[5.2, 5.9]], { eyes: 'left', arms: 'none', legs: 'sit' }],
174 [[[5.9, 7.1]], { eyes: 'closed', arms: 'none', legs: 'sit' }],
175 [[[7.1, 8.0]], { eyes: 'right', arms: 'none', legs: 'sit' }],
176 ], gear, D)
177 const armR = show(r(14, 3, 2, 2, C.orange), [[5.2, 8.0]], D)
178 const armL = show(r(0, 3, 2, 2, C.orange), [[5.2, 5.6], [7.6, 8.0]], D)
179 // радость на ✓: подпрыгнул на подушке
180 const sq = scaleK([[0, '1 1'], [4.2, '1 1'], [4.32, '1.12 0.88'], [4.45, '0.92 1.1', EASE_OUT], [4.75, '1 1'], [4.95, '1.1 0.9', EASE_IN], [5.15, '1 1'], [10, '1 1']], D)
181 const hop = moveK([[0, '0 0'], [4.4, '0 0'], [4.65, '0 -6', EASE_OUT], [4.95, '0 0', EASE_IN], [10, '0 0']], D)
182 const lean = moveK([[0, '0 0'], [5.2, '0 0'], [5.6, '-4 0'], [7.6, '-4 0'], [8.0, '0 0'], [10, '0 0']], D)
183 const thinking = show([0, 1, 2].map(i => g(r(5 + i * 2, -2, 1, 1, C.grey), fade('0.2;1;0.2', '1.2s', { begin: `${i * 0.2}s` }))).join(''), typing, D)
184 const hero = at(X, Y, g(scaleAround(bodies + armR + armL, CH_W_PX / 2, 8 * P, sq), hop) + thinking, lean)
185 const cushion = px(42, 50, 60, 4, C.purple) + px(44, 48, 56, 2, C.purple)
186
187 // стол и ноутбук
188 const desk = px(104, 42, 74, 4, C.wood) + px(108, 46, 4, 8, C.wood) + px(170, 46, 4, 8, C.wood)
189 const laptop = px(112, 4, 52, 34, C.edge) + px(114, 6, 48, 32, C.ink2) + px(116, 8, 44, 26, '#151515') +
190 px(106, 38, 60, 4, C.ink2) + px(106, 38, 60, 2, C.edge) + px(150, 40, 8, 2, '#151515')
191 // код на экране: 8 строк (шаг 4 px), прокрутка вверх по строке; две копии подряд — бесшовно
192 type Chunk = readonly [number, number, string]
193 const L: Chunk[][] = [
194 [[0, 6, C.purple], [8, 10, C.sky]],
195 [[4, 8, C.orange], [14, 14, C.white]],
196 [[4, 4, C.purple], [10, 16, C.greenLight]],
197 [[8, 12, C.sky], [22, 6, C.yellow]],
198 [[8, 18, C.greenLight]],
199 [[4, 6, C.orange], [12, 8, C.white], [22, 10, C.sky]],
200 [[0, 4, C.purple]],
201 [[0, 10, C.yellow], [12, 14, C.white]],
202 ]
203 const codeSet = (y0: number) => L.map((line, i) => line.map(([dx, w, f]) => px(118 + dx, y0 + i * 4, w, 2, f)).join('')).join('')
204 const stepsAt = [0.6, 1.2, 1.9, 2.5, 3.2, 3.8, 8.7, 9.4]
205 const scroll = move(['0 0', ...stepsAt.map((_, i) => `0 ${-4 * (i + 1)}`)].join(';'), `${D}s`,
206 { calc: 'discrete', keyTimes: [0, ...stepsAt.map(t => kt(t, D))].join(';') })
207 const code = `<clipPath id="cw-work-scr"><rect x="116" y="8" width="44" height="20"/></clipPath>` +
208 `<g clip-path="url(#cw-work-scr)">${g(codeSet(10) + codeSet(42), scroll)}</g>`
209 // строка ввода: «>» и буквы появляются по одной; при прокрутке строка уходит вверх — ввод с нуля
210 // в [9.4, 10] не печатает — строка пустая, мигает курсор: на шве 10→0 ввод начинается с нуля
211 const segs: Array<[number, number]> = [[0, 0.6], [0.6, 1.2], [1.2, 1.9], [1.9, 2.5], [2.5, 3.2], [3.2, 3.8], [3.8, 4.2], [8.0, 8.7], [8.7, 9.4], [9.4, 9.45]]
212 const wPts: Array<[number, number]> = []
213 segs.forEach(([a, b], si) => {
214 const startW = si === 7 ? 4 * Math.round((4.2 - 3.8) / 0.1) : 0 // после кофе дописывает начатую строку
215 let w = startW
216 for (let t = a; t < b - 1e-6 && w <= 32; t += 0.1) { wPts.push([t, w]); w += 4 }
217 })
218 const wVals = wPts.map(p => p[1])
219 const wKeys = wPts.map(p => kt(p[0], D)).join(';')
220 const inputWords = px(122, 30, 6, 2, C.greenLight) + px(130, 30, 10, 2, C.white) + px(142, 30, 4, 2, C.sky) + px(148, 30, 8, 2, C.white)
221 const input = px(118, 30, 2, 2, C.orange) +
222 `<clipPath id="cw-work-in"><rect x="122" y="28" width="0" height="6">${attr('width', wVals.join(';'), `${D}s`, { calc: 'discrete', keyTimes: wKeys })}</rect></clipPath>` +
223 `<g clip-path="url(#cw-work-in)">${inputWords}</g>` +
224 g(px(122, 28, 2, 4, C.greenLight, attr('x', wVals.map(w => 122 + w).join(';'), `${D}s`, { calc: 'discrete', keyTimes: wKeys })), fade('1;0', '0.5s', { calc: 'discrete' }))
225 // тесты прошли: экран вспыхивает зелёным с ✓
226 const pass = show(px(116, 8, 44, 26, '#173d22') + glyph('✓', 128, 10, C.greenLight, 4), [[4.2, 4.45], [4.55, 5.3]], D)
227 // искры из-под клавиш
228 const sparks =
229 burst(px(124, 36, 2, 2, C.yellow), 4.22, 5.0, -8, -14, D) +
230 burst(px(134, 36, 2, 2, C.greenLight), 4.26, 5.1, -2, -20, D) +
231 burst(px(144, 36, 2, 2, C.orange), 4.3, 5.05, 6, -16, D) +
232 burst(px(154, 36, 2, 2, C.yellow), 4.24, 4.95, 12, -10, D) +
233 burst(glyph('*', 160, 30, C.yellow), 4.3, 5.2, 10, -22, D)
234
235 // кружка на полу слева; во время перерыва рука несёт её ко рту и наклоняет
236 // кружка в трёх нарисованных наклонах (0 / ~20° / ~40° к Клоду), переключаются дискретно — без размытого поворота
237 const mugAt = (sh: number[]) => sh.map((dx, row) =>
238 px(dx, row * 2, 8, 2, row === 0 ? C.brown : C.blue) + (row >= 2 && row <= 3 ? px(dx + 2, row * 2, 2, 2, C.sky) : '') +
239 (row >= 1 && row <= 3 ? px(dx + 8, row * 2, 2, 2, C.blue) : '')).join('')
240 const mugs = show(mugAt([0, 0, 0, 0, 0]), [[0, 6.15], [7.2, 10]], D) +
241 show(mugAt([2, 2, 2, 0, 0]), [[6.15, 6.3], [7.05, 7.2]], D) + show(mugAt([4, 4, 2, 2, 0]), [[6.3, 7.05]], D)
242 // рука-кулак держит кружку и всегда касается тела (на полу — тянется вниз к левому боку)
243 const hand = show(px(10, -4, 10, 8, C.orangeDark), [[5.6, 7.6]], D)
244 const carry = moveK([[0, '0 0'], [5.6, '0 0'], [5.75, '0 -2'], [6.15, '12 -16'], [7.1, '12 -16'], [7.55, '0 0'], [10, '0 0']], D)
245 const mug = at(24, 44, g(mugs + hand + steam(2, -4), carry))
246
247 return cushion + desk + laptop + code + input + pass + hero + sparks + mug
248}
249
250// ── правит файл ───────────────────────────────────────────────────────────────
251// Цикл 8 с: подходит к мольберту → стирает красную строку (крошки) → пишет зелёную → штамп ✓ → радость → сброс.
252export function sceneEdit(gear: Gear): string {
253 const D = 8
254 const X = 30
255 // идёт только вперёд (к листу и вдоль строки при записи); стирая и отходя назад — стоит и переступает мелкими шажками
256 const walk: Span[] = [[0.8, 1.3], [3.3, 5.3]]
257 const bodies = poses([
258 [[[0, 0.8], [1.3, 2.9], [5.3, 5.8]], { eyes: 'right', arms: 'none', glint: true }],
259 [walk, { eyes: 'right', arms: 'none', legs: 'walk' }],
260 [[[2.9, 3.3]], { eyes: 'up', arms: 'none' }],
261 [[[5.8, 6.2]], { eyes: 'wide', arms: 'none' }],
262 [[[6.2, 7.6]], { eyes: 'happy', arms: 'none' }],
263 [[[7.6, 8]], { eyes: 'open', arms: 'none' }],
264 ], gear, D)
265 const armL = r(0, 3, 2, 2, C.orange)
266 // длинный карандаш в правой руке — пиксельной лесенкой (ступень 12×4 px, подъём 4 px); кончик: x = X + сдвиг + 116, y = TOP + 16 + dy − 12
267 const pencil = [0, 1, 2, 3].map(i => px(i * 12, -2 - i * 4, 12, 4, C.yellow) + px(i * 12, -i * 4, 12, 2, '#d9962a')).join('') +
268 px(48, -14, 4, 4, '#e3c08f') + px(52, -14, 4, 4, C.ink2)
269 const scrub = jitter([[1.3, 2.9, 0.1, '-2 0'], [3.3, 5.3, 0.12, '0 -2']], '0 0', D)
270 const dy = moveK([[0, '0 8'], [0.8, '0 8'], [1.25, '0 4'], [2.9, '0 4'], [3.05, '0 -2', EASE_OUT], [3.25, '0 4', EASE_IN], [5.3, '0 4'], [5.6, '0 8'], [8, '0 8']], D)
271 const tool = at(60, 16, g(pencil, scrub) + px(-4, -4, 8, 8, C.orange), dy)
272 const sq = kf([[0, '1 1'], [6.2, '1 1'], [6.32, '1.14 0.86'], [6.45, '0.9 1.12', EASE_OUT], [6.75, '1 1'], [6.95, '1.12 0.88', EASE_IN], [7.15, '1 1'], [8, '1 1']], D)
273 // отскок назад от листа (5.3–5.8) и прыжок радости (6.4–6.95)
274 const jump = moveK([[0, '0 0'], [5.3, '0 0'], [5.55, '0 -6', EASE_OUT], [5.8, '0 0', EASE_IN], [6.4, '0 0'], [6.65, '0 -8', EASE_OUT], [6.95, '0 0', EASE_IN], [8, '0 0']], D)
275 // стирая (1.3–2.9), отступает влево пятью шажками по 8 px: шаг 0.12 с, между шагами трёт на месте; ширина красной строки — те же ключи
276 const steps: Key[] = []
277 const redSteps: Key[] = []
278 for (let k = 1; k <= 5; k++) {
279 const t1 = 1.3 + 0.32 * k, t0 = t1 - 0.12
280 steps.push([t0, `${64 - 8 * (k - 1)} 0`], [t1, `${64 - 8 * k} 0`])
281 redSteps.push([t0, `${40 - 8 * (k - 1)}`], [t1, `${40 - 8 * k}`])
282 }
283 const slide = moveK([[0, '0 0'], [0.8, '0 0'], [1.3, '64 0'], ...steps, [3.3, '24 0'], [5.3, '68 0', LIN], [5.8, '0 0'], [8, '0 0']], D)
284 // карандаш внутри squash — при прыжке рука с карандашом сжимается вместе с телом и не отрывается
285 const hero = at(X, TOP, g(squash(bodies + armL + tool, sq.values, `${D}s`, sq.o), jump), slide)
286
287 // мольберт: две ноги, задняя стойка, полочка, зажим
288 const leg = (x0: number, dir: number) => [0, 1, 2, 3, 4, 5].map(i => px(x0 + dir * i * 2, 10 + i * 8, 4, 8, C.wood)).join('')
289 const easel = px(190, 0, 4, 54, C.brown) + leg(170, -2) + leg(210, 2)
290 const tray = px(150, 46, 84, 4, C.wood) + px(150, 46, 84, 2, '#a8734e')
291 const clamp = px(186, 0, 12, 4, C.ink2) + px(186, 0, 12, 2, C.edge)
292 // лист: текст полосками-словами; строка 22 — правка
293 type Chunk = readonly [number, number, string]
294 const rows: Array<[number, Chunk[]]> = [
295 [10, [[164, 16, C.blue], [182, 12, C.ink2]]],
296 [16, [[164, 8, C.purple], [174, 22, C.ink2], [198, 10, C.grey]]],
297 [28, [[168, 14, C.ink2], [184, 20, C.grey]]],
298 [34, [[168, 10, C.purple], [180, 16, C.ink2]]],
299 [40, [[164, 20, C.ink2], [186, 8, C.grey]]],
300 ]
301 const text = rows.map(([y, cs]) => cs.map(([x, w, f]) => px(x, y, w, 2, f)).join('')).join('')
302 const paper = px(156, 4, 72, 42, C.paperEdge) + px(158, 6, 68, 38, C.paper)
303 // красная строка стирается справа налево (вместе с кончиком), в конце цикла возвращается
304 const redW = kf([[0, '40'], [1.3, '40'], ...redSteps, [7.65, '0'], [7.95, '40', LIN], [8, '40']], D)
305 const red = `<clipPath id="cw-edit-red"><rect x="168" y="20" width="40" height="6">${attr('width', redW.values, `${D}s`, redW.o)}</rect></clipPath>` +
306 `<g clip-path="url(#cw-edit-red)">${px(166, 21, 44, 4, '#fbe1e1')}${px(168, 22, 10, 2, C.red)}${px(180, 22, 14, 2, C.red)}${px(196, 22, 12, 2, C.red)}</g>`
307 // зелёная строка пишется слева направо
308 const grW = kf([[0, '0'], [3.3, '0'], [5.3, '44', LIN], [8, '44']], D)
309 const green = `<clipPath id="cw-edit-grn"><rect x="168" y="20" width="0" height="6">${attr('width', grW.values, `${D}s`, grW.o)}</rect></clipPath>` +
310 g(`<g clip-path="url(#cw-edit-grn)">${px(166, 21, 48, 4, '#dff3e2')}${px(168, 22, 12, 2, C.green)}${px(182, 22, 8, 2, C.green)}${px(192, 22, 18, 2, C.green)}</g>`,
311 fadeK([[0, '0'], [3.25, '0'], [3.3, '1', LIN], [7.5, '1'], [7.8, '0', LIN], [8, '0']], D))
312 // крошки ластика сыплются на полочку
313 const crumbs = [[200, 1.45, C.red], [192, 1.75, C.paperEdge], [184, 2.05, C.red], [176, 2.35, C.paperEdge], [170, 2.65, C.red]] as const
314 const crumbSvg = crumbs.map(([x, t, f], i) => burst(px(x, 24, 2, 2, f), t, t + 0.5, i % 2 ? 4 : -2, 20, D)).join('')
315 // штамп ✓: падает с размаху, лист вздрагивает, пыль
316 const stampInner = px(206, 30, 16, 2, C.green) + px(206, 42, 16, 2, C.green) + px(206, 30, 2, 14, C.green) + px(220, 30, 2, 14, C.green) +
317 glyph('✓', 209, 32, C.green)
318 const stamp = g(scaleAround(stampInner, 214, 37, scaleK([[0, '2 2'], [5.8, '2 2'], [5.95, '1 1', EASE_IN], [7.8, '1 1'], [7.85, '2 2'], [8, '2 2']], D)),
319 fadeK([[0, '0'], [5.8, '0'], [5.85, '1', LIN], [7.5, '1'], [7.8, '0', LIN], [8, '0']], D), ' opacity="0"')
320 const dust = burst(px(204, 44, 2, 2, C.grey), 5.95, 6.3, -6, 2, D) + burst(px(222, 44, 2, 2, C.grey), 5.95, 6.3, 6, 2, D) +
321 burst(px(222, 28, 2, 2, C.grey), 5.95, 6.3, 6, -6, D) + burst(px(204, 28, 2, 2, C.grey), 5.95, 6.3, -6, -6, D)
322 const jolt = moveK([[0, '0 0'], [5.95, '0 0'], [6.0, '0 2', EASE_OUT], [6.15, '0 0'], [8, '0 0']], D)
323 // лист не поворачивается (поворот ломал пиксели) — только вздрагивает от штампа
324 const sheet = g(paper + text + red + green + crumbSvg + stamp + dust, jolt)
325
326 return easel + sheet + tray + clamp + hero
327}
328// ── помощники-сабагенты ───────────────────────────────────────────────────────
329// n мини-клодиков (до 4) справа (x 478..702): сидят на подушках, у каждого свой ритм, иногда «!» или «✓» с подскоком.
330// один мини-клодик на месте i (0…3, x = 478 + 56·i): свой период, реплики «!» и «♪», подскок на реплику
331export function helperAt(i: number): string {
332 {
333 const x = 478 + i * 56, y = 36
334 const per = [6.2, 7.4, 8.6, 9.8][i % 4]!
335 const delay = [0, 0.13, 0.27, 0.41][i % 4]!
336 // реплики «!» и «✓» в своих долях цикла (у всех разные), каждая видна 1.3 с
337 const tA = [0.15, 0.35, 0.05, 0.3][i % 4]! * per, tB = [0.55, 0.75, 0.45, 0.7][i % 4]! * per
338 const bubble = (ch: string, f: string, t0: number) => {
339 const t1 = t0 + 1.3
340 const inner = px(x + 8, y - 18, 14, 16, C.paperEdge) + px(x + 10, y - 16, 10, 12, C.white) + px(x + 12, y - 2, 4, 2, C.paperEdge) +
341 glyph(ch, x + 10, y - 16, f)
342 const pop = moveK([[0, '0 4'], [t0, '0 4'], [t0 + 0.2, '0 0', EASE_OUT], [t1, '0 0'], [per, '0 4']], per)
343 return g(g(inner, pop), fadeVis(t0, t1, per), ' opacity="0"')
344 }
345 // реакция на реплику — сжатие и вытягивание вокруг низа (y = 54): ноутбук не отрывается от пола
346 const bounceAt = (t0: number) => [[t0, '1 1'], [t0 + 0.1, '1 0.86'], [t0 + 0.22, '1 1.1', EASE_OUT], [t0 + 0.4, '1 1']] as Key[]
347 const bounce = scaleK([[0, '1 1'], ...[tA, tB].sort((a, b) => a - b).flatMap(bounceAt), [per, '1 1']], per)
348 return px(x + 4, 52, 24, 2, C.purple) + scaleAround(miniClawd(x, y, delay), x + 16, 54, bounce) +
349 bubble('!', C.red, tA) + bubble('♪', C.purple, tB) // ✓ — только «агент закончил» (уход помощника)
350 }
351}
352
353export function helpers(n: number): string {
354 const k = Math.max(0, Math.min(4, Math.floor(n)))
355 let s = ''
356 for (let i = 0; i < k; i++) s += helperAt(i)
357 return s
358}
359// дискретно видно в [t0, t1) цикла per
360const fadeVis = (t0: number, t1: number, per: number) =>
361 fade('0;1;0', `${per}s`, { calc: 'discrete', keyTimes: `0;${kt(t0, per)};${kt(t1, per)}` })
362types/index.d.ts 44 lines1// What the pixel Clawd is doing right now
2export type BuddyMode =
3 | 'think' | 'work' | 'edit' | 'search' | 'web' | 'ask' // a turn is running
4 | 'recall' | 'memorize' // memory: recalling, memorizing
5 | 'compact' | 'offline' // compacting the conversation, connection trouble
6 | 'celebrate' | 'walk' | 'coffee' | 'sleep' // idle
7
8// Clawd's state.
9// activity — a turn is running or Clawd is resting; tool — the main loop's tool running now;
10// spinner — what the turn's spinner says (thinking / waiting for the server / writing / tool);
11// wordTool — the kind of work read from the spinner's step word (desktop: "Editing notes.md");
12// compacting / compactSince — a compaction is running and since when;
13// flash / flashUntil — a short scene raised by an engine insertion (memories attached to the prompt, memory saved) and until when;
14// offlineSince — since when the connection is down (0 — fine);
15// helpers — how many helpers are on the strip (agents of the main session, leaving ones included);
16// crewKey — who sits in which slot and who is leaving (changes — rebuild the picture);
17// idleSince — when idling began (ms); celebrate — the turn ended with an answer (celebrate first);
18// tick — idle redraw counter; ultra — ultra mode (sunglasses);
19// booted — the process's first start has been handled (a hot reload does not repeat it)
20export type Buddy = {
21 activity: 'idle' | 'busy'
22 tool: '' | 'search' | 'web' | 'edit' | 'work' | 'ask' | 'recall' | 'memorize'
23 flash: '' | 'search' | 'web' | 'edit' | 'work' | 'ask' | 'recall' | 'memorize'
24 flashUntil: number
25 spinner: '' | 'requesting' | 'responding' | 'thinking' | 'tool-input' | 'tool-use'
26 wordTool: '' | 'search' | 'web' | 'edit' | 'work' | 'ask' | 'recall' | 'memorize'
27 compacting: boolean
28 compactSince: number
29 offlineSince: number
30 helpers: number
31 crewKey: string
32 idleSince: number
33 celebrate: boolean
34 tick: number
35 ultra: boolean
36 booted: boolean
37}
38
39declare module 'claude-code' {
40 interface PluginState {
41 'clawd-buddy': { buddy: Buddy }
42 }
43}
44