SLOPSHOPPER

clawd-buddy

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

newbandspinnerguardprompttimer
v1.0.0MITupdated 2026-10-08IliaBatko39/clawd-buddy
A shopper browsing a rack in a slop shop
README

clawd-buddy

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.

Clawd at work

Clawd idle

Clawd in ultra mode, dark theme

Memory, compact and offline scenes

Scenes

SceneWhen
Thinkingthe model is thinking or waiting for the server to start answering
Workinga turn is running (laptop; now and then a sip of coffee)
EditingEdit / Write / NotebookEdit, MCP tools named create_*, update_*, write_*...
SearchingRead / Grep / Glob, shell grep, rg, find, cat..., MCP tools named search_*, get_*, list_*...
On the webWebSearch / WebFetch, browser and scraping MCP servers, shell curl, wget, ssh...
QuestionAskUserQuestion, ExitPlanMode — Clawd waits for your answer
Recalling / memorizingreading 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
Compactingthe conversation is being compacted (a 36-second story with a press)
Offlinethe server has not started answering for 90 s, the spinner says it is retrying, or a turn ended with an API error
Celebratingfor 4 s after a turn ends with an answer
Walk and coffeewhile idle: a 24 s walk and 16 s of coffee, in a loop
Sleepingafter 20 minutes of idling
Sunglassesin ultra mode, on top of any scene
Mini-helpersone 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.

Requirements and limits

  • Claude Code desktop app. Clawd is an animated SVG. The band above the prompt exists on the terminal and on the desktop surface, but only the desktop draws Svg, so in the terminal the mod draws nothing (and does not get in the way).
  • Function hooks are early access. The API may change between Claude Code releases. Tested with Claude Code 2.1.292.
  • Ultra-mode sunglasses rely on the engine's ultra_effort_enter / ultra_effort_exit transcript insertions; if a release renames them, the sunglasses simply stop appearing.
  • The mod plays well with other mods that draw in the same band: it always calls next(e) and draws Clawd above whatever the rest of the chain drew.

Install

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.

Development

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.

License

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 Code (~/.claude/projects/<проект>/memory), любой MCP-сервер или инструмент со словом memory, memories, mem0, remember или recall в имени, уведомления движка о памяти.
  • Компакт — идёт сжатие переписки (сюжет на 36 секунд с прессом).
  • Нет связи — сервер не начал отвечать за 90 с, строка хода пишет про повтор, или ход оборвался ошибкой API.
  • Праздник — 4 с после хода, закончившегося ответом.
  • Прогулка и кофе — в простое: 24 с прогулки и 16 с кофе по кругу.
  • Сон — после 20 минут простоя.
  • Очки — в режиме ultra, поверх любой сцены.
  • Мини-помощники — по маленькому Клоду на каждого сабагента основной сессии (до 4, дальше бейдж +N); закончивший агент получает зелёную галочку и растворяется.

Сцены сменяются плавно: Клод остаётся на месте и начинает новое дело там, где стоит (у края — зеркально), старая сцена тает, новая проявляется; сцена держится не меньше 2 с, без мельтешения.

Требования и ограничения

  • Десктоп-приложение Claude Code. Клод — анимированный SVG. Полоса над вводом есть в терминале и на десктопе, но Svg рисует только десктоп; в терминале мод ничего не рисует и не мешает.
  • Function hooks — ранний доступ, API может меняться между версиями. Проверено на Claude Code 2.1.292.
  • Очки в режиме ultra завязаны на вставки движка 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.

Source 10 files
hooks/register.tsx 487 lines
1import { 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}
487
hooks/buddy.ts 551 lines
1// 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}
551
hooks/clawd/core.ts 239 lines
1// 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}
239
hooks/clawd/meta.ts 21 lines
1// 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}
21
hooks/clawd/scenes_rest.ts 497 lines
1// 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}
497
hooks/clawd/scenes_find.ts 468 lines
1// 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}
468
hooks/clawd/scenes_memory.ts 454 lines
1// 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}
454
hooks/clawd/scenes_story.ts 448 lines
1// 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}
448
hooks/clawd/scenes_work.ts 362 lines
1// 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)}` })
362
types/index.d.ts 44 lines
1// 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